
1. 为什么在嵌入式项目里51单片机做计时器依然值得动手很多人一听到51单片机就觉得是老古董现在都什么年代了随便一颗几块钱的ARM芯片性能都能把它按在地上摩擦。但如果你真正在工厂产线、教学实验室或者小家电控制板里待过就会发现一个事实51内核的芯片每年出货量依然是个天文数字。原因很简单——便宜、稳定、开发门槛低、资料多到烂大街。一个计时器这种需求用51来做成本能压到几块钱以内而且电路简单到用洞洞板就能焊出来。我这次要聊的就是怎么用51单片机从零搭一个真正能用的计时器。不是那种按一下走一秒的玩具而是具备启停控制、复位清零、时间设定、超时提醒这些完整功能的实用计时器。关键词就三个51单片机、计时器、数码管显示。适合谁看如果你刚学完单片机的基础课程会点亮LED、会写延时函数但还没独立做过一个完整的小项目那这篇内容就是给你准备的。如果你已经做过类似的东西也可以看看我在中断配置、按键消抖、显示刷新这几个环节上的处理方式说不定能帮你省掉几个小时的调试时间。先说清楚这个计时器要做什么。核心功能清单如下用4位共阳数码管显示时间格式为分:秒最大显示99分59秒两个独立按键一个控制启动/暂停一个控制复位清零计时精度要求不高秒级误差控制在每天几秒以内即可超时提醒功能当计时到达设定值时蜂鸣器发出提示音供电用5V直流整体功耗控制在1W以内这些需求看起来简单但每一个都藏着坑。比如数码管的动态扫描和计时中断怎么协调按键按下时怎么保证不误触发蜂鸣器响的时候会不会影响计时精度这些问题在后面会一个一个拆开讲。2. 硬件选型与电路搭建哪些地方省不得哪些地方可以省2.1 主控芯片的选择逻辑51系列的单片机型号多如牛毛从最早的AT89C51到后来的STC89C52、STC15系列选哪个直接关系到你后面写代码的难易程度。我的建议很明确用STC89C52RC。理由有三条。第一条它内置了EEPROM虽然做计时器不一定用得上但万一你想加个断电保存计时值的功能不用外挂存储芯片就能实现。第二条它的下载方式极其简单通过串口就能烧录程序不需要专门的编程器一根USB转串口线就搞定。第三条它的工作电压范围宽4.5V到5.5V都能稳定运行对电源的要求没那么苛刻。如果你手头只有AT89C51也能用但要注意它没有内置EEPROM而且烧录需要专用编程器对新手不太友好。至于STC15系列性能更强、外设更多但价格也稍微贵一点做计时器有点杀鸡用牛刀的意思。晶振的选择上11.0592MHz和12MHz是两个最常见的选项。这里有个细节需要说清楚11.0592MHz这个奇怪的频率是为了串口通信时波特率能整除而设计的如果你不做串口通信用12MHz完全没问题而且12MHz计算定时器初值的时候更直观——一个机器周期正好是1微秒。我这次用的是12MHz晶振后面计算定时初值会方便很多。2.2 数码管驱动方案直接驱动还是加锁存器4位共阳数码管如果直接用单片机的IO口驱动需要8根段选线加4根位选线一共12根IO口。STC89C52RC一共有32根IO口看起来够用但你要留出按键、蜂鸣器、可能的串口通信等接口12根线占掉之后余量就不大了。这时候有两种方案可选。方案一是直接用IO口驱动电路最简单但占用IO多而且数码管亮度受限于IO口的驱动能力。方案二是加一片74HC595移位寄存器用3根IO口就能控制8位输出再配合4根位选线总共7根IO口就够了。74HC595便宜到几乎不要钱一片几毛钱但能省下5根IO口而且亮度更稳定。我最终选了方案二。原因很实际后面如果要加按键、蜂鸣器、甚至一个简单的串口输出调试信息IO口资源会很紧张。与其到时候捉襟见肘不如一开始就把显示部分用595隔离出来。而且74HC595的用法非常成熟网上随便一搜就有现成的驱动代码学习成本几乎为零。段选限流电阻的选择也有讲究。共阳数码管每个段的压降大约是1.8V到2.2V工作电流一般取5mA到10mA。如果单片机IO口输出5V那么限流电阻大约是(5 - 2.0) / 0.008 375欧姆取标准值330欧姆或470欧姆都可以。我用的330欧姆实测亮度足够电流也在安全范围内。2.3 按键与蜂鸣器电路的处理细节按键部分我用的是独立按键而不是矩阵键盘因为只需要两个按键没必要搞复杂。每个按键一端接IO口另一端接地IO口内部开启上拉电阻。这样按键未按下时IO口读到高电平按下时读到低电平。这里有个容易被忽略的细节按键必须并联一个0.1微法的小电容。这个电容的作用是硬件消抖能滤掉按键按下瞬间产生的毛刺信号。虽然软件里也会做消抖处理但硬件消抖能减轻软件的负担两者配合效果最好。我试过只做软件消抖在快速连按的时候偶尔会出现误判加了电容之后就非常稳定了。蜂鸣器方面我用的是有源蜂鸣器也就是内部自带振荡电路的那种给电就响不需要单片机输出PWM波。这样控制逻辑最简单一个IO口输出高低电平就能控制。如果是无源蜂鸣器就需要用定时器输出特定频率的方波才能发声会占用一个定时器资源对计时器这种需要精确定时的项目来说不太划算。有源蜂鸣器的工作电流一般在20mA到30mA之间超过了单片机单个IO口的驱动能力通常15mA左右所以需要加一个三极管来驱动。我用的是S8050 NPN三极管基极通过1K电阻接IO口集电极接蜂鸣器负极发射极接地蜂鸣器正极接5V。这样IO口输出高电平时三极管导通蜂鸣器响输出低电平时三极管截止蜂鸣器不响。3. 定时器中断的配置与计时精度的保证3.1 为什么必须用定时器中断而不是延时函数很多初学者做计时器的时候第一反应是用delay()函数来计时。比如要计1秒就写一个延时1秒的函数循环调用。这种做法在简单实验里能跑通但放到实际项目里问题很大。第一个问题是精度差。delay()函数的实际延时时间受编译器优化、晶振误差、中断干扰等因素影响误差可能达到百分之几甚至更多。你让它延时1秒实际可能是0.95秒或者1.05秒累积下来一天能差好几分钟。第二个问题是CPU被占死。delay()执行期间CPU什么其他事情都做不了按键按下去没反应数码管也不刷新整个系统就像卡死了一样。计时器需要同时处理按键输入、数码管显示、蜂鸣器控制等多个任务用delay()根本没法协调。所以正确的做法是用定时器中断产生固定的时间基准在主循环里处理其他任务。具体来说让定时器每50毫秒产生一次中断中断服务程序里对一个计数器加一加到20次就说明过了1秒。这样计时精度完全由晶振决定只要晶振是准的计时就是准的。3.2 定时器初值的计算过程STC89C52RC的定时器0工作在模式116位定时器时定时时间的计算公式是定时时间 (65536 - 初值) × 12 / 晶振频率我用的晶振是12MHz要定时50毫秒即50000微秒代入公式50000 (65536 - 初值) × 12 / 12化简得50000 65536 - 初值所以初值 65536 - 50000 15536转换成十六进制就是0x3CB0高8位是0x3C低8位是0xB0。在代码里这样写TMOD 0xF0; // 清除定时器0的控制位 TMOD | 0x01; // 定时器0工作在模式1 TH0 0x3C; // 装载初值高8位 TL0 0xB0; // 装载初值低8位 ET0 1; // 使能定时器0中断 EA 1; // 使能总中断 TR0 1; // 启动定时器0这里有个细节需要注意每次中断服务程序执行完后必须重新装载初值。因为模式1不是自动重装载的如果不重新赋值下一次中断的时间就不对了。很多人第一次写的时候会忘记这一步结果计时越来越慢找半天找不到原因。3.3 中断服务程序里该做什么、不该做什么中断服务程序的原则是快进快出执行时间越短越好。因为中断执行期间主循环是暂停的如果中断里做了太多事情主循环的响应就会变慢按键可能不灵敏显示可能闪烁。我的中断服务程序只做三件事重新装载定时器初值对一个50毫秒计数器加一判断是否满20次满了就把秒计数器加一并把50毫秒计数器清零void Timer0_ISR() interrupt 1 { TH0 0x3C; TL0 0xB0; count_50ms; if (count_50ms 20) { count_50ms 0; if (timer_running) { seconds; if (seconds 60) { seconds 0; minutes; } } } }注意这里用了一个timer_running标志位来控制计时是否进行。当用户按下暂停键时把这个标志位置0中断里就不再累加秒数但定时器本身还在跑显示刷新也不受影响。这样设计的好处是暂停和启动的响应非常快不需要反复开关定时器。数码管的刷新、按键的扫描、蜂鸣器的控制全部放在主循环里做。主循环的节奏由定时器中断驱动但具体执行不受中断阻塞整体运行非常流畅。4. 数码管动态扫描与按键消抖的实战处理4.1 动态扫描的频率怎么定4位数码管如果同时点亮需要4组段选信号分别控制IO口根本不够用。所以实际项目中都是用动态扫描的方式同一时刻只点亮一位数码管快速轮流切换利用人眼的视觉暂留效应看起来就像4位同时亮着。扫描频率的选择很关键。太快了数码管来不及完全点亮亮度会下降太慢了人眼能察觉到闪烁。一般来说刷新率在50Hz以上人眼就感觉不到闪烁了。4位数码管每位刷新一次算一个周期所以整个扫描周期应该在20毫秒以内每位点亮时间不超过5毫秒。我实际用的参数是每位点亮2毫秒4位轮流一遍是8毫秒刷新率125Hz完全看不到闪烁亮度也很充足。这个2毫秒的延时不能用delay()来实现否则会阻塞主循环。我的做法是在主循环里用一个状态机每次循环只点亮一位然后通过定时器中断来计时2毫秒到了就切换到下一位。具体实现上我定义了一个display_index变量取值0到3分别对应四位。每次进入显示函数时先消隐关闭所有位选然后输出当前位的段选数据再打开对应的位选最后把display_index加一如果超过3就回到0。void display_scan() { // 消隐 P2 0xFF; // 关闭所有位选共阳数码管位选低电平有效 // 输出段选数据 send_byte(seg_code[display_buffer[display_index]]); // 打开当前位 P2 ~(1 display_index); // 切换到下一位 display_index; if (display_index 4) { display_index 0; } }这里seg_code数组存放的是0到9对应的段选码display_buffer存放的是当前要显示的四位数字。比如显示12:34display_buffer就是{1, 2, 3, 4}。4.2 按键消抖的软件实现按键消抖是每个嵌入式项目都要面对的问题。机械按键在按下和释放的瞬间触点会产生10毫秒左右的抖动如果不处理单片机会认为按了好几次。软件消抖的经典做法是检测到按键状态变化后延时一段时间再检测一次如果状态一致才确认按键有效。但这个延时不能用delay()否则会阻塞其他任务。我的做法是在主循环里每隔一段时间比如10毫秒检测一次按键状态连续检测到3次相同状态才确认。#define KEY_DEBOUNCE_CNT 3 void key_scan() { static uint8_t key1_last 1; static uint8_t key1_count 0; uint8_t key1_now KEY1_PIN; if (key1_now ! key1_last) { key1_count 0; key1_last key1_now; } else { if (key1_count KEY_DEBOUNCE_CNT) { key1_count; if (key1_count KEY_DEBOUNCE_CNT) { if (key1_now 0) { // 按键确认按下 key1_pressed 1; } } } } }这段代码的逻辑是每次检测时如果当前状态和上次不同就重置计数器如果相同计数器加一加到3次就确认状态稳定。这样既能消抖又不会阻塞主循环。实际调试的时候我发现一个问题如果主循环执行速度很快按键扫描的间隔太短可能会在抖动期间就完成3次检测导致消抖失效。解决办法是控制按键扫描的频率比如每5毫秒扫描一次这样3次检测就是15毫秒足够覆盖抖动时间。4.3 显示与按键的相互干扰问题数码管动态扫描和按键扫描如果放在同一个循环里可能会出现干扰。具体表现是按下按键的时候数码管某一位突然变暗或者闪烁。原因在于按键按下时IO口的电平会发生变化如果按键和数码管的位选线共用同一个IO口就会互相影响。我的解决方案是把按键和数码管分配到不同的IO口组。数码管的段选通过74HC595控制位选用P2口的高4位按键用P3口的两个引脚。这样两者完全隔离互不干扰。另外在按键扫描函数里读取IO口状态之前先关闭中断读完再打开可以避免中断服务程序修改IO口状态导致的误读。虽然这个概率很低但在要求高的场合值得加上。5. 功能整合与实测中暴露的问题5.1 启动、暂停、复位三个状态的切换逻辑计时器的核心状态机有三个停止态、运行态、暂停态。停止态下计时值为零按启动键进入运行态运行态下按暂停键进入暂停态计时值保持不变暂停态下按启动键回到运行态按复位键回到停止态并清零。这个状态机看起来简单但实际写代码的时候容易出问题。我第一版代码里暂停和启动用了同一个按键结果按一下暂停再按一下启动逻辑是对的但按键按得快的时候偶尔会跳过暂停直接复位。排查后发现是按键消抖没做好快速连按的时候两次按键被识别成了一次长按。后来我改进了消抖逻辑按键按下时触发一次动作必须等到按键释放后才能触发下一次。这样无论按多快每次按下只响应一次不会出现连击。if (key1_pressed) { key1_pressed 0; // 清除标志 if (state STOPPED) { state RUNNING; } else if (state RUNNING) { state PAUSED; } else if (state PAUSED) { state RUNNING; } }复位键的处理类似但只在停止态和暂停态有效运行态下按复位键不响应防止误操作把正在进行的计时清零。5.2 蜂鸣器提示音的触发时机超时提醒功能的需求是当计时到达设定值时蜂鸣器响一声。这个设定值我设计成可以通过按键调整默认是5分钟。实现上在定时器中断里判断当前计时值是否等于设定值如果等于就置一个标志位。主循环检测到这个标志位后控制蜂鸣器响200毫秒然后清除标志。这里有个细节蜂鸣器响的时候会产生电磁干扰可能影响定时器的计数。我实测发现如果蜂鸣器和单片机共用同一个电源蜂鸣器启动的瞬间定时器偶尔会多计一次数。解决办法是在蜂鸣器的电源引脚旁边并联一个100微法的电解电容吸收启动电流的冲击。加了电容之后计时就非常稳定了。另外蜂鸣器的响铃时间也要控制好。太短了听不见太长了吵人。我试过100毫秒、200毫秒、500毫秒几个值最后选了200毫秒既清晰又不刺耳。5.3 长时间运行的稳定性验证计时器这种东西短时间跑没问题不代表长时间跑没问题。我做过一个72小时的连续运行测试每隔24小时记录一次计时值和标准时间对比。测试结果第一个24小时误差1秒第二个24小时累计误差2秒第三个24小时累计误差3秒。平均每天误差1秒左右这个精度对于12MHz晶振来说已经相当不错了。误差的主要来源是晶振本身的频率偏差如果要进一步提高精度可以换用精度更高的晶振或者在软件里做温度补偿。测试中还发现一个问题连续运行超过48小时后数码管的某一位偶尔会出现亮度不均。排查后发现是74HC595的发热导致的长时间工作后芯片温度升高输出电流略有下降。解决办法是在595的电源引脚旁边加一个0.1微法的去耦电容问题就解决了。6. 代码组织与可移植性的一些经验6.1 模块化拆分的实际好处整个项目的代码我分成了四个文件main.c负责主循环和状态机timer.c负责定时器中断和计时逻辑display.c负责数码管驱动key.c负责按键扫描。每个文件对应一个头文件对外只暴露必要的接口。这样拆分的好处是调试方便。比如数码管显示有问题我只需要关注display.c不用在一大堆代码里翻找。而且以后如果要换一个不同尺寸的数码管只需要修改display.c里的段选码和扫描逻辑其他文件完全不用动。对于初学者来说可能觉得分文件很麻烦不如全部写在main.c里来得快。但我的经验是代码超过200行就应该拆分。否则改一个地方牵一发而动全身很容易引入新的bug。6.2 定时器资源的分配建议STC89C52RC有两个定时器定时器0和定时器1。我的项目里定时器0用来做50毫秒的时间基准定时器1空闲。如果你以后想加串口通信功能定时器1就要用来产生波特率不能再做其他用途。所以我的建议是时间基准用定时器0串口通信用定时器1这样分工明确互不干扰。如果还需要更多的定时功能可以考虑用定时器0的溢出中断来软件扩展比如每10次中断触发一次其他任务。6.3 从51迁移到其他平台的注意事项如果你以后想把这段代码迁移到STM32或者其他ARM芯片上有几个地方需要修改。首先是定时器的配置方式完全不同STM32的定时器有预分频器和自动重装载寄存器不需要手动重装初值。其次是IO口的操作方式STM32需要用库函数或者寄存器操作不能像51那样直接赋值。最后是中断服务程序的写法STM32的中断向量表更复杂需要正确配置中断优先级。但核心的逻辑——状态机、消抖算法、动态扫描——这些是通用的不管在哪个平台上都能用。所以把51上的基础打牢以后换平台只是换个写法思路是一样的。7. 我在这个项目里踩过的几个坑第一个坑是定时器初值算错。我一开始把12MHz晶振的机器周期算成了1/12微秒结果定时时间差了12倍。后来才想起来51单片机的机器周期是12个时钟周期12MHz晶振的机器周期是1微秒不是1/12微秒。这个基础概念一定要搞清楚否则后面全错。第二个坑是数码管段选码搞反。共阳和共阴的段选码是完全相反的我一开始拿了一个共阴的码表用在共阳数码管上结果显示的数字全是乱码。后来用万用表一个一个测数码管的引脚确认了每个段对应的引脚重新写了码表才正常。第三个坑是按键消抖的计数器没有复位。我的消抖逻辑里如果按键状态一直不变计数器会一直累加加到溢出后回绕到0导致按键被误触发。后来加了判断计数器加到设定值后就不再增加问题解决。第四个坑是蜂鸣器干扰计时。前面提到过蜂鸣器启动时会导致定时器多计数。我一开始以为是软件bug查了半天代码没发现问题后来用示波器看电源波形才发现是电源波动导致的。加了滤波电容之后就稳定了。这些坑看起来都是小问题但每一个都花了我不少时间。写出来是希望后来的人能少走弯路。嵌入式开发就是这样理论懂了不代表能跑通实际调试中会遇到各种意想不到的情况耐心和细心比什么都重要。