1. 从“卡顿”到“丝滑”:为什么你的传感器总是不听话?
大家好,我是老李,一个在嵌入式坑里摸爬滚打了十多年的老码农。今天咱们不聊那些虚头巴脑的理论,直接从一个让我抓狂了好几天的问题说起。
前段时间,我接了个小项目,要用STM32驱动一个DHT11温湿度传感器。这玩意儿大家应该都熟,一个经典的单总线数字传感器。我心想,这还不简单?照着数据手册的时序图,用HAL库的HAL_Delay()函数写几个延时,分分钟搞定。结果呢?读取的数据十次里有八次是错的,要么全是0xFF,要么温度湿度值跳得跟心电图似的。我一度怀疑是传感器坏了,换了三四个新的,问题依旧。那几天,我对着逻辑分析仪抓出来的波形图,头发都薅掉了一大把。
后来我静下心来仔细对比波形才发现,问题就出在HAL_Delay()这个函数上。数据手册要求,主机(也就是我们的STM32)发送开始信号后,需要等待20-40微秒(μs)的拉低,然后等待传感器80微秒的响应低电平,再等待80微秒的响应高电平……每一个关键节点,都是几十到上百微秒的量级。而HAL_Delay()的最小单位是1毫秒(ms),也就是1000微秒。你想用这个“米尺”去量“毫米”甚至“微米”的尺寸,那不是开玩笑吗?你让它延时1毫秒,它可能给你延时了1.0001毫秒,误差不大;但你让它“模拟”一个40微秒的延时,你只能写HAL_Delay(1),然后祈祷它快点执行完其他指令,这精度完全不可控,时序自然就乱套了。
这不仅仅是DHT11的问题。像DS18B20温度传感器、WS2812B彩灯(虽然不算传感器,但时序要求同样苛刻)、某些红外接收头,它们都依赖于精确的微秒级甚至纳秒级时序。总线协议在“说话”的时候,每一个比特位的宽度、每一个应答信号的间隔,都是严格规定好的。你的MCU如果“口齿不清”,或者“反应迟钝”,对方就完全听不懂你在说什么,通信必然失败。
所以,要驯服这些“娇气”的传感器,第一步就是扔掉那把刻度太粗的“米尺”——HAL_Delay(),换上一把高精度的“游标卡尺”。而SysTick,这个ARM内核自带的系统定时器,就是我们手边最好用、最现成的那把卡尺。它不像外部通用定时器(TIMx)那样需要额外的硬件资源和复杂的配置,它是芯片“与生俱来”的能力,我们只需要学会如何精准地读取它的“刻度”就行了。
2. 庖丁解牛:SysTick如何成为我们的“精密秒表”?
在动手写代码之前,我们得先搞清楚SysTick这把“秒表”是怎么工作的。很多教程一上来就贴代码,我觉得那是耍流氓。你不明白原理,代码稍微变个花样你就懵了,出了问题更不知道怎么调试。
SysTick本质上是一个24位的向下计数器。你可以把它想象成一个倒计时的沙漏。这个沙漏的沙子总数(最大值)是 2^24 - 1,大概1600多万。沙漏的流速由系统时钟决定,比如你的STM32F1主频是72MHz,那么一秒钟就会“流下”7200万颗“沙子”(时钟脉冲)。SysTick的工作流程是这样的:
- 我们给
LOAD寄存器设置一个初始值,比如71999。 - 启动后,
VAL寄存器(当前值)从这个初始值开始,每来一个时钟脉冲就减1。 - 当
VAL减到0时,会触发一个SysTick中断(如果开启了的话),同时硬件会自动把LOAD的值重新装载到VAL里,然后开始下一轮倒计时。
HAL库默认就是把SysTick配置成每1ms中断一次,用来给HAL_GetTick()和HAL_Delay()提供基准的。这个“1ms”是怎么算出来的呢?很简单:系统时钟72MHz,那么一个时钟周期就是 1/72 us。要产生1ms(1000us)的间隔,就需要 72MHz * 0.001s = 72000 个时钟周期。所以LOAD寄存器就设置为 72000 - 1 = 71999。从71999开始减到0,正好72000次计数。
好了,关键点来了:HAL_Delay()是靠“数中断次数”来延时的。它不管两次中断之间VAL具体走到了多少,它只关心“中断发生了多少次”。这就好比你看沙漏,只关心它漏完了几次,而不关心这次漏到一半还剩多少沙子。所以它的精度就被限制在了“一次漏完”的时间,也就是1ms。
我们的思路恰恰相反:我们不依赖中断,而是直接去“看”沙漏里当前还剩多少沙子(实时读取VAL寄存器的值)。通过计算两次“看”的时候,沙子减少了多少,我们就能精确地知道过去了多少个时钟周期,从而换算出精确的微秒甚至纳秒时间。这就把精度从“一次沙漏的时间”提升到了“一颗沙子的时间”。
这里有个小坑需要注意,就是“沙漏漏完”的情况,也就是VAL从0重新装载到LOAD的瞬间,我们称之为“溢出”。假设我第一次看的时候VAL是100(旧值),然后沙漏继续漏。等我第二次看的时候,如果沙漏还没漏完一轮,VAL可能变成了50(新值)。那么流逝的沙子数就是 100 - 50 = 50。但如果在我两次“看”的中间,沙漏漏完了一整轮(VAL从0跳回了71999),那么我第二次看到的VAL(比如71999)就会比第一次看到的(100)大。这时候流逝的沙子数应该是:从100漏到0的100颗,加上从71999漏到当前71999的0颗(因为刚装载完),总共100颗。通用公式就是:流逝时钟数 = 旧值 + (LOAD + 1) - 新值。这个处理逻辑是我们高精度延时的核心。
3. 手把手实战:编写微秒级延时函数
理论说了一箩筐,是时候亮出真家伙了。我们不搞花架子,就写一个最朴实、最可靠的udelay(uint32_t us)函数。我建议你在工程里单独新建一个文件,比如bsp_systick.c和bsp_systick.h,把相关函数都放进去,这样模块清晰,以后移植也方便。
首先,我们得知道“1微秒”对应多少颗“沙子”(时钟周期)。前面算过,系统72MHz下,1ms对应LOAD+1=72000个周期。那么1us就对应 72000 / 1000 = 72个周期。所以,要延时us微秒,我们需要等待的时钟周期总数就是 target_ticks = us * 72。
接下来,就是函数的实现逻辑了:
- 记录开始时的“沙子数”(
told = SysTick->VAL)。 - 进入一个循环,不断读取当前的“沙子数”(
tnow = SysTick->VAL)。 - 根据
told和tnow的大小关系,判断中间是否发生过溢出,并计算出这段时间流走的沙子数(cnt),累加起来。 - 判断累加的沙子数
cnt是否超过了目标值target_ticks,如果超过了,就跳出循环,延时结束。
下面是我在实际项目中打磨过的代码,加了详细的注释:
/**
* @brief 微秒级延时函数 (基于SysTick)
* @param us: 需要延时的微秒数,范围1 ~ (2^32-1)/72 (在72MHz下约596小时,绝对够用)
* @retval 无
* @note 此函数在SysTick正常工作(HAL_Init()后)即可使用,占用CPU进行忙等待。
* 在延时期间会阻塞CPU,故不适合在实时性要求极高的中断服务程序中长时间使用。
*/
void udelay(uint32_t us)
{
// 1. 获取系统当前的LOAD值,并计算1微秒对应的时钟周期数
uint32_t load = SysTick->LOAD;
// 注意:LOAD寄存器存储的是重装载值,从N减到0,实际计数周期是 N+1
// 默认1ms中断下,(load + 1) = 72000, 1us = 72000/1000 = 72个周期
uint32_t ticks_per_us = (load + 1) / 1000;
// 2. 计算本次延时需要等待的总时钟周期数
uint32_t target_ticks = us * ticks_per_us;
// 3. 记录延时开始时刻的计数器值
uint32_t told = SysTick->VAL;
uint32_t tnow;
uint32_t elapsed_ticks = 0; // 累计已经流逝的时钟周期
// 4. 核心等待循环
while(elapsed_ticks < target_ticks)
{
tnow = SysTick->VAL; // 实时获取当前计数值
// 判断是否发生溢出(VAL从0重载到了LOAD)
if (tnow <= told)
{
// 情况A:未发生溢出,正常递减
// 流逝的周期数 = 上次值 - 当前值
elapsed_ticks += (told - tnow);
}
else
{
// 情况B:发生溢出
// 流逝的周期数 = (上次值减到0) + (从LOAD减到当前值)
// = told + ((load + 1) - tnow)
elapsed_ticks += (told + (load + 1) - tnow);
}
// 更新“上次值”,为下一次循环计算做准备
told = tnow;
}
// 当累计流逝周期数 >= 目标周期数,循环结束,延时完成
}
几个踩坑点提醒:
LOAD与LOAD+1:这是最容易出错的地方。LOAD寄存器设置的是重装载值,计数器是从这个值开始向下减到0。所以,一个完整的计数周期是LOAD + 1个时钟。所有计算周期数的地方,都要想清楚用的是LOAD还是LOAD+1。- 变量类型:
elapsed_ticks和target_ticks可能会很大,确保使用足够宽的类型(uint32_t)。虽然VAL是24位,但累加值可能超过24位。 - 中断的影响:这个函数是“忙等待”,期间如果发生SysTick中断,会有一点点额外的中断处理时间。但对于微秒级的延时,这个影响通常可以忽略。更关键的是,你要确保没有更高优先级的中断长时间关闭全局中断,否则SysTick计数器会暂停,导致延时严重拉长。
- 系统时钟频率:我的计算基于72MHz。如果你的主频是别的值,比如48MHz或168MHz,一定要重新计算
ticks_per_us。公式是(SystemCoreClock / 1000000),其中SystemCoreClock是你的系统核心时钟频率。
有了udelay(),实现毫秒延时mdelay()就太简单了,就是一个循环调用。但这里有个小优化:对于超过1毫秒的延时,我们可以结合HAL_Delay()和udelay(),减少CPU占用。比如延时10ms,可以用HAL_Delay(9)加上udelay(1000),这样既能保证总体精度,又能在9ms里让CPU有机会处理其他任务(如果开了调度器的话)。当然,最简单的就是:
void mdelay(uint32_t ms)
{
while(ms--)
{
udelay(1000);
}
}
4. 驱动传感器实战:以DHT11和DS18B20为例
现在我们有了精准的“武器”,是时候回到最初的战场,去征服那些不听话的传感器了。我们挑两个最典型的“刺头”:DHT11(温湿度)和DS18B20(温度),看看如何用udelay()函数重构它们的驱动。
4.1 DHT11驱动改造
DHT11的时序有几个关键点,数据手册要求非常严格:
- 主机起始信号:拉低总线至少18ms,然后拉高20-40us,等待传感器响应。
- 传感器响应:传感器会拉低总线80us,再拉高80us,然后开始传输数据。
- 数据位:每一位都以50us的低电平开始,随后的高电平长度决定是0(26-28us)还是1(70us)。
用HAL_Delay()时,你根本无法产生20-40us这种信号。现在用udelay(),我们可以写出非常精准的代码:
// 假设数据线连接在GPIO_PIN_2上
#define DHT11_PIN GPIO_PIN_2
#define DHT11_PORT GPIOA
// 主机发送开始信号
void DHT11_Start(void)
{
// 1. 主机拉低至少18ms
HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_RESET);
mdelay(20); // 这里用mdelay,多给一点余量
// 2. 主机拉高20-40us
HAL_GPIO_WritePin(DHT11_PORT, DHT11_PIN, GPIO_PIN_SET);
udelay(30); // 取中间值30us,非常精准
// 3. 切换为输入模式,准备读取传感器响应(需根据实际硬件设置上下拉)
// ... 设置GPIO为输入 ...
}
// 等待传感器拉低/拉高信号
uint8_t DHT11_Wait_For(uint8_t level, uint16_t timeout_us)
{
uint32_t start_time;
// 这里需要用一个更精准的获取当前计数值的函数,我们稍后实现
start_time = systick_get_current_tick();
while(读取引脚电平 != level)
{
if(获取当前tick - start_time > timeout_us)
{
return 0; // 超时
}
}
return 1; // 成功等到
}
// 读取一个比特位
uint8_t DHT11_Read_Bit(void)
{
uint32_t high_time = 0;
// 等待50us低电平开始位
while(读取引脚为低); // 等待变高,即开始位的结束
// 开始计时高电平持续时间
high_time = measure_high_pulse_width(); // 用一个函数测量高脉冲宽度
// 判断
if(high_time > 60) // 大于60us认为是比特1
return 1;
else
return 0;
}
你看,当我们能控制udelay(30)这样精确的延时时,整个时序就变得非常清晰和可靠。measure_high_pulse_width()函数同样可以基于SysTick实时读取VAL寄存器来实现,原理和udelay()类似,记录高电平开始和结束时的“沙子数”差值,再换算成微秒。
4.2 DS18B20驱动要点
DS18B20是单总线协议的另一个代表,它对时序的要求同样变态,尤其是“写1”和“写0”的时隙(Time Slot)控制。
- 复位脉冲:主机拉低480us以上。
- 存在脉冲:传感器在拉低60-240us后回应一个存在脉冲。
- 写时隙:主机拉低总线启动一个写时隙。写“1”时,主机需在15us内释放总线并保持高电平至时隙结束(约60us)。写“0”时,主机需保持拉低60us。
- 读时隙:主机拉低总线至少1us后释放,然后在15us内采样总线状态。
这里的15us、60us,对于HAL_Delay()来说就是天方夜谭。用上udelay()后,代码变得直观:
void DS18B20_Write_Bit(uint8_t bit)
{
// 启动写时隙:拉低总线
HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_RESET);
udelay(5); // 拉低5us,远大于要求的1us最小值
if(bit)
{
// 写“1”:在15us内释放总线
HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_SET);
udelay(60); // 保持高电平直到写时隙结束(总时长约60us)
}
else
{
// 写“0”:保持拉低
udelay(60); // 持续拉低60us
HAL_GPIO_WritePin(ONEWIRE_PORT, ONEWIRE_PIN, GPIO_PIN_SET); // 最后释放
}
// 两个写时隙之间需要至少1us的恢复时间,由函数调用和udelay本身保证
}
通过这样精准的控制,DS18B20的通信成功率可以做到接近100%。我实测过,在72MHz主频下,用这套方法驱动DS18B20,在5米长的普通导线上都能稳定通信。
5. 进阶与避坑:让高精度延时更稳健
掌握了基本方法,我们再来聊聊一些进阶话题和容易踩的坑,让你的驱动更加稳健。
第一坑:系统时钟变了怎么办?
我们的udelay()函数里,ticks_per_us = (load + 1) / 1000这个计算,隐含了一个假设:SysTick的时钟源是系统核心时钟(SystemCoreClock),并且LOAD值是基于1ms中断配置的。这在HAL库默认初始化后是成立的。但是,如果你的项目后期修改了系统时钟频率,或者通过HAL_SYSTICK_Config()函数重新配置了SysTick的重装载值,这个假设就被打破了。所以,一个健壮的实现,应该提供一个初始化函数或者宏,让用户明确指定系统时钟频率,或者直接从SystemCoreClock全局变量中获取。
第二坑:阻塞式延时的局限
udelay()是“忙等待”,CPU会卡在while循环里啥也不干。这在主循环里驱动一两个传感器没问题。但如果你在中断服务函数里调用它,特别是时间较长的延时(比如几十微秒),就可能导致中断响应不及时,甚至错过其他重要中断。对于中断环境,通常的策略是使用状态机,设置一个标志位,然后退出中断,在主循环里根据标志位和精确的时间戳来判断是否该进行下一步操作。这就需要我们实现一个非阻塞的、基于时间戳检查的延时方式。
第三招:实现一个“时间戳”获取函数 这比单纯的延时更有用。我们可以实现一个函数,返回系统启动以来经过的微秒数(或时钟周期数)。这样,我们可以在某个时刻打一个“时间戳A”,在另一个时刻打一个“时间戳B”,然后一减,就知道中间过去了多久,而不需要阻塞等待。
uint64_t systick_get_microseconds(void)
{
uint64_t us;
uint32_t load = SysTick->LOAD;
uint32_t val = SysTick->VAL;
uint32_t tick = HAL_GetTick(); // 获取毫秒部分
// 将毫秒转换为微秒
us = (uint64_t)tick * 1000ULL;
// 加上当前毫秒内已过去的微秒数
// 注意:VAL是当前值,从LOAD减下来的。已过去的周期数 = (load + 1) - val
// 每个周期的时间(us) = 1000.0 / (load + 1)
// 所以已过去的微秒数 = ((load + 1) - val) * 1000 / (load + 1)
// 为了避免浮点数,我们做整数运算:
us += ((uint64_t)(load + 1 - val) * 1000ULL) / (load + 1);
return us;
}
有了这个函数,你就可以在驱动中这样用:
uint64_t start_time = systick_get_microseconds();
// ... 执行一些操作 ...
while( (systick_get_microseconds() - start_time) < 1000 )
{
// 等待1000us,但期间可以插入一些条件检查或其他非阻塞任务
if(某个条件满足) break;
}
第四注意:优化与编译器屏障
在udelay的循环里,我们频繁地读取SysTick->VAL这个内存映射的寄存器。编译器在优化时,可能会认为这个值在短时间内不会变化,从而只读取一次并存入寄存器,导致死循环。为了避免这种情况,我们需要将tnow = SysTick->VAL这个变量声明为volatile,或者使用编译器屏障。在HAL库的底层,访问外设寄存器通常已经用volatile指针处理好了,所以我们直接使用SysTick->VAL是安全的。但如果你自己封装了一个读函数,就要注意这个问题。
最后,分享一个我调试时的习惯:在最初验证udelay函数是否准确时,我会用一个IO口来配合示波器或逻辑分析仪。在延时开始前拉高IO,延时结束后拉低IO,然后测量这个脉冲的宽度。如果测量出来是50.2us,而你写的是udelay(50),那说明你的计算是基本正确的,那零点几微秒的误差可能是函数调用开销和指令执行时间,这在很多应用里是可以接受的。通过这种“眼见为实”的验证,心里会踏实很多。
:SysTick 微秒级延时在传感器驱动中的实战应用&spm=1001.2101.3001.5002&articleId=151241328&d=1&t=3&u=deaf741ac83d4260924afa10fa7a1141)
639

被折叠的 条评论
为什么被折叠?



