
单片机C语言延时函数怎么写?用定时器实现更稳定的延时
单片机程序经常需要“等一会儿”:等待传感器转换、让 LED 闪烁、满足外设的时序要求,或者给按键消抖。最直接的写法是循环执行一段空操作,但这种延时容易受主频、编译器优化和中断影响。需要更可预测的时间间隔时,通常应让硬件定时器负责计时。
本文先说明忙等延时适合什么场景,再以经典 12 MHz、12T 8051 和 Keil C51 为例,给出定时器毫秒计数及延时函数。需要注意,示例中的定时器参数只适用于明确列出的硬件条件;不同单片机即使都叫“8051”,定时器时钟和工作方式也可能不同。
先分清两种“延时”
忙等延时是 CPU 不断执行循环,直到循环次数达到指定值。它实现简单,不依赖定时器,但延时期间 CPU 基本不能做其他工作。循环本身的耗时还会随时钟、编译选项和生成的指令改变。
定时器延时是先配置硬件定时器,让它按时钟计数;计数到期后产生中断,或由程序读取计时标志。CPU 可以在这段时间处理其他任务。若使用定时器中断维护系统节拍,主程序还可以通过检查经过的时间来安排非阻塞任务。
| 方法 | 适用情形 | 主要限制 |
|---|---|---|
| 忙等 | 极短等待、上电后的简单等待、临时调试 | 占用 CPU;精度受时钟和编译结果影响 |
| 定时器中断 | 周期任务、软件计时、需要等待时继续处理其他工作 | 需要正确配置时钟和中断;中断响应与重装方式会带来误差 |
| 硬件输出比较、PWM 或捕获 | 对边沿和周期要求较严格的信号 | 需要相应外设,且须遵循芯片的数据手册 |
这里说的“稳定”,通常是指不再依赖 C 语言空循环的执行时间,并能以硬件时钟为基准。它不代表绝对精确:晶振误差、定时器时钟配置、中断响应延迟和软件重装时间都会影响实际间隔。
忙等延时为什么不可靠
以下代码看起来像延时函数,但并不能直接表达一个确定的时间:
void delay(unsigned int n)
{
unsigned int i;
for (i = 0; i < n; i++) {
/* 等待 */
}
}
编译器可能发现循环体没有可观察的副作用,并对循环进行优化;即使循环确实保留,其每次迭代的指令数也取决于编译器和优化等级。更重要的是,n 是循环次数,不是毫秒数。同一份代码换到不同主频、不同型号的芯片或不同编译选项下,实际延时都可能变化。
有些工程用 volatile 变量让循环不容易被删除,但这并不会自动把循环次数变成时间单位,也不能消除中断造成的额外耗时。用示波器测量后再调整循环次数,能够得到某个特定构建条件下的近似延时,不应把它当作跨平台的精确时基。
如果项目确实需要用忙等,至少要把它标明为特定时钟和编译配置下的近似实现,并通过测量验证。对一般应用来说,定时器是更清楚、更容易维护的选择。
定时器计数的基本计算
定时器每个计数周期所需的时间,由它的输入时钟和分频系数决定。若计数频率为 f_timer,所需计数值为 N,那么理想计时长度为:
t = N / f_timer
经典 12T 8051 在传统模式下,定时器计数频率通常是机器周期频率,即主频除以 12。以主频 12 MHz 为例:
- 机器周期频率:12 MHz ÷ 12 = 1 MHz。
- 每次定时器计数:1 微秒。
- 计 1000 次:理想情况下为 1 毫秒。
- 16 位定时器从初值计到溢出,计数次数为
65536 - 初值。 - 计 1000 次的初值为 65536 – 1000 = 64536,即十六进制
0xFC18。
这些数值依赖“12 MHz、传统 12T 定时器”这一前提。许多现代 8051 兼容芯片可以运行在 1T 模式,部分芯片还能单独设置定时器时钟分频。不能只看主频就照抄重装值;应查目标芯片数据手册中的定时器时钟选择、计数模式和中断行为。
示例一:Keil C51 下的 8051 毫秒计时
下面的程序面向 Keil C51 的经典 8051 语法,假定系统时钟为 12 MHz,Timer 0 工作在 16 位模式,定时器每个计数为 1 微秒。定时器溢出中断中重装初值,并维护一个毫秒计数器。示例将 P1.0 上的 LED 约每 500 毫秒翻转一次。
#include <REG52.H>
sbit LED = P1^0;
/* 由定时器中断更新,单位为毫秒 */
volatile unsigned long g_ms = 0;
/*
* 在短临界区内读取 32 位计数器。
* 8051 通常无法用一条指令原子读取 32 位数据,
* 若读取过程中发生中断,可能读到高低字节不一致的值。
*/
unsigned long millis(void)
{
unsigned long value;
bit old_ea;
old_ea = EA;
EA = 0;
value = g_ms;
EA = old_ea;
return value;
}
/*
* 适用前提:12 MHz、传统 12T 定时器时钟,
* Timer 0 模式 1,计 1000 次约为 1 ms。
*/
void timer0_init(void)
{
TMOD = (TMOD & 0xF0) | 0x01; /* Timer 0:16 位模式 1 */
TH0 = 0xFC;
TL0 = 0x18;
TF0 = 0;
ET0 = 1; /* 允许 Timer 0 中断 */
EA = 1; /* 开总中断 */
TR0 = 1; /* 启动 Timer 0 */
}
void timer0_isr(void) interrupt 1
{
/* 模式 1 不自动重装,软件写回 1 ms 初值 */
TH0 = 0xFC;
TL0 = 0x18;
g_ms++;
}
/*
* 等待至少经过 ms 个软件毫秒刻度。
* 这是阻塞函数:等待期间主循环不会继续执行其他任务。
*/
void delay_ms(unsigned long ms)
{
unsigned long start;
start = millis();
while ((unsigned long)(millis() - start) < ms) {
/* 让出 CPU 并不适用这里;此循环本身仍是忙等 */
}
}
void main(void)
{
unsigned long previous;
LED = 0;
timer0_init();
previous = millis();
while (1) {
if ((unsigned long)(millis() - previous) >= 500UL) {
previous += 500UL;
LED = !LED;
}
/*
* 此处可以检查按键、处理通信等。
* 应避免在其他地方长时间关闭总中断。
*/
}
}
在 Keil C51 中,interrupt 1 对应 8051 的 Timer 0 中断向量。使用其他编译器时,中断函数声明方式可能不同,不能直接假设同一段代码无需修改即可编译。REG52.H 和端口定义也应与所用芯片及开发环境匹配。
为什么保留无符号减法的写法
计数器 g_ms 是 32 位无符号整数。它达到最大值后会回绕到零。判断经过时间时,写成 (unsigned long)(now - start) >= ms 可以自然处理一次回绕,前提是观察间隔和设定的等待长度没有达到或超过无符号计数范围的一半(对 32 位计数器,即小于 2^31 毫秒)。
直接写 now >= start + ms 在加法回绕时可能得出错误结果。对于普通短延时,两种写法往往都能工作,但使用无符号差值是更稳妥的计时习惯。
millis() 暂时关闭总中断,是为了避免 8051 在读取 32 位计数器的过程中被中断打断,导致读到撕裂值。临界区必须尽可能短。这里先保存原来的 EA 状态再恢复,避免函数无论调用前中断是否开启,都在返回时强行开启中断。不同芯片或编译器对中断控制位的声明可能有差异,应按对应头文件确认。
这段定时器代码的精度边界
示例每次溢出后由软件写入 TH0 和 TL0。从溢出到执行这两次写操作之间,CPU 要经历中断响应、编译器生成的入口代码和函数执行指令。因此,定时器可能已经从零继续计数一段时间,实际中断周期会比理想的 1 毫秒稍长;连续运行时,这种误差还可能累积。
所以示例适合演示软件毫秒节拍及一般人机交互,并不保证严格的 1.000 毫秒。如果实际产品需要更好的周期精度,可以考虑:
- 使用芯片提供的自动重装定时器模式,减少软件重装造成的周期漂移。
- 使用输出比较、PWM 等外设由硬件产生边沿或周期信号,避免在中断中翻转引脚。
- 若芯片只支持软件重装,按数据手册和编译后的指令分析中断延迟,并通过示波器或逻辑分析仪验证。
- 确认晶振精度、定时器时钟源和分频配置。硬件配置无误,也不意味着实际时钟绝对无误差。
手动补偿中断延迟不能靠随意减小重装值。延迟会受到中断优先级、其他中断、关中断区间、编译器生成代码以及定时器读写方式影响。补偿值必须在明确的芯片、时钟和软件构建条件下验证。
阻塞延时与非阻塞计时不要混淆
delay_ms(500) 会一直等待计时器走过 500 个刻度,主循环在这段时间无法继续做其他工作。定时器中断仍可运行,但如果主循环要同时处理通信、按键或多个周期任务,阻塞延时可能导致响应变慢。
示例中的 LED 闪烁使用另一种写法:保存上次处理时刻,每次循环检查是否已经经过 500 毫秒。等待期间主循环可以执行其他工作,这属于基于时钟的非阻塞调度。对于简单项目,可以把每个周期任务写成“检查时间—执行一次—更新上次时刻”的形式;任务本身也应尽量短,避免长时间占用主循环。
若需要精确维持平均周期,更新基准时刻时使用 previous += 500UL,通常比每次直接赋成当前时间更能避免任务执行时间造成的累积漂移。不过,如果程序长时间没有机会处理,具体是补做一次、连续补做多次,还是跳过过期任务,应由应用需求决定,不能一概而论。
常见错误及排查方法
1. 把主频直接当作定时器计数频率
传统 12T 8051 的定时器计数时钟常为主频的十二分之一,而一些 1T 芯片的行为不同。先查数据手册,并核对定时器时钟配置,再计算重装值。若 LED 的闪烁速度与预期相差固定倍数,优先检查时钟分频和 12T/1T 模式。
2. 计数值或重装初值算错
16 位定时器计数 N 次的初值是 65536 - N,不是 65535 - N。还要确认计数范围、定时器模式和计数方向。计数值超出定时器位宽时,单次溢出无法覆盖目标时间,需要使用较短的硬件周期累计多个刻度,或选用更合适的时钟分频。
3. 只重装高字节或误以为模式 1 自动重装
经典 8051 的 Timer 0 模式 1 是 16 位计数模式,不会像自动重装模式那样在溢出后自动恢复初值。中断中要正确重装高、低字节。不同定时器模式的自动重装特性不同,不能只凭名称猜测。
4. 在主循环和中断中同时改计数变量
由中断与主程序共享的数据应明确标记为 volatile,但 volatile 只要求编译器按要求读写变量,不保证多字节读写原子,也不提供完整的并发保护。对于 8 位 MCU 上的 16 位或 32 位计数器,要根据芯片能力使用短临界区、原子访问机制或其他可靠同步方式。
5. 中断服务函数里做太多工作
中断中适合做简单、耗时可控的事情,例如清理中断标志、更新计数或记录事件。不要在中断里等待、执行复杂计算或调用耗时很长的函数。中断处理过慢可能延迟其他中断,甚至让定时事件无法按预期处理。
6. 在延时期间关闭中断
如果忙等函数或其他代码长时间关闭总中断,定时器中断可能得不到及时响应,毫秒计数会变慢甚至丢失事件。需要原子读取时,只短暂关闭相关中断,并在所有路径上恢复原有状态。
7. 把定时器延时当成精密测量
软件计时适合控制一般等待和调度任务,但对通信位宽、窄脉冲、超声测距或电机换相等有明确时间要求的场景,应优先使用专用外设,并根据器件规格和系统时钟评估误差。对时钟精度敏感时,还要考虑晶振容差、温漂和校准需求。
参数限制与安全用途
示例的 delay_ms 参数类型为 unsigned long,在典型 8051 C51 环境中通常是 32 位,但 C 语言类型宽度并非所有编译器都相同,移植时应确认 unsigned long 的位宽。延时参数表示毫秒刻度数,不是任意精度的时间。参数过大时,函数会长时间阻塞;计时差值写法还要求等待长度小于计数器回绕范围的一半。
如果参数来自外部输入,应在调用前做范围检查,并考虑函数是否会导致系统失去响应。不要把一个很大的用户输入值直接传给阻塞延时。需要长期等待时,应改为非阻塞状态机或任务调度,而不是让主程序一直停在延时循环里。
软件延时和普通定时器计数不能单独作为安全保护机制。它们不保证在主频异常、时钟故障、中断被屏蔽、程序跑飞或器件故障时仍按期工作。涉及过温、过压、运动限位、人员防护或其他安全风险时,应依据产品风险与适用标准设计,并采用合适的硬件保护、看门狗、故障检测及失效安全措施;还要对实际硬件和软件进行验证。示例代码只是通用计时方法演示,不构成安全认证或安全功能实现。
实际使用前的检查清单
- 确认芯片型号、主频、定时器输入时钟及分频方式。
- 按数据手册核对工作模式、计数范围、溢出标志和重装行为。
- 检查编译器的中断声明、头文件和变量类型宽度。
- 验证共享计数器的原子访问方式,避免读取到不一致的数据。
- 用示波器或逻辑分析仪测量周期,尤其是在精度要求较高时。
- 确认长延时不会阻塞关键任务,也不会被外部输入放大成不可接受的等待。
简单结论是:短暂、非关键的等待可以使用忙等,但应接受它对编译和时钟条件的依赖;需要可重复的时间基准时,使用硬件定时器;多个任务需要同时运行时,优先使用非阻塞计时;对边沿或周期精度有严格要求时,尽量让硬件外设直接产生信号。把时钟前提、计算过程和误差边界写清楚,比只提供一个看似通用的 delay_ms() 更重要。








