STM32数字时钟实战:从定时器到数码管动态扫描,打好单片机底子
发布时间:2026/10/3 9:30:04 锦皓数字建站

做嵌入式这一行经常有人问我新手学STM32第一个项目做什么好我的回答一直很固定——做个数字时钟。这玩意儿看着不起眼甚至有点复古但它把一个单片机工程师日常要碰的东西全串起来了GPIO读写、定时器中断、按键消抖、动态扫描显示哪一样都是后面做复杂项目绕不开的底子。我自己就是用一块STM32F103C8T6最小系统板加几个共阴数码管把这块地基打实的。这个项目适合谁我觉得是两类人。一类是刚接触单片机、想找个正经项目练手的学生另一类是有点编程底子、但对STM32内部资源不熟、想系统补课的工程师。做完它你对时钟树、预分频、自动重载、中断优先级这些概念就不再是听过而是亲手配过、亲手调过。这篇文章把我自己完整做过的方案、代码思路、调试踩坑都摊开讲各位照着做就能复现一台自己的数字时钟。1. 项目定位与硬件方案选型先想清楚再做别急着买器件1.1 为什么用STM32而不是51单片机用51单片机做时钟是很多教材里的经典实验代码短、逻辑直白但有个问题51的资源太简单很多东西不用配置写起来像填空练不到系统级的能力。STM32内部有时钟树、定时器、中断系统、复用功能这些复杂资源光是让系统跑起来就需要理解时钟是怎么从外部晶振经过PLL倍频到72MHz的中断优先级是怎么抢占的。这些恰恰是实际项目里天天用的东西。如果你只是想要一个能走字的钟直接花几块钱买DS1302模块加LCD1602代码网上抄一份就能用。但如果想弄明白单片机的每一秒从哪里来显示为什么不会闪烁按键按下去为什么会有毛刺就必须从底层把这些机制自己摸一遍。这个项目的本质不是那个成品钟而是完整走一遍从硬件到软件的设计流程把各个外设挨个激活一遍。1.2 物料清单与选型理由我用的这套方案总成本控制在30块钱以内。具体清单如下器件型号/规格作用参考价格主控板STM32F103C8T6最小系统板核心控制10元左右数码管4位共阴数码管 0.36英寸显示时分2-3元按键轻触开关 6x6mm3个调时、切换几毛一个晶振8MHz最小系统板自带主时钟源已包含电阻330Ω 1/4W8个数码管段选限流1元蜂鸣器无源蜂鸣器可选整点报时1元杜邦线母对母、公对母若干连接器件3元主控选STM32F103C8T6也就是俗称的蓝丸板子原因很简单便宜、资料多、引脚够用而且它和市面上绝大多数STM32入门教程用的是同一颗芯片遇到问题搜一下就有答案。四位数码管、三个按键、一颗可选蜂鸣器加上杜邦线就能搭起来不需要焊接适合第一版快速验证。数码管我建议选共阴的。原因有两个一是F103的GPIO推挽输出对灌电流的承受能力比拉电流稍强共阴接法段选输出高电平点亮逻辑直观二是共阴数码管的段码表网上到处都是不容易踩坑。如果你手头只有共阳代码里段码要全部取反位选逻辑也要反着来这个后面我会单独提醒。限流电阻的计算也顺便说一下。F103的GPIO输出高电平约3.3V红色LED数码管每段正向压降大约1.8V到2.0V如果单段目标电流取8mA限流电阻就是R(3.3-2.0)/0.008≈162Ω取标称150Ω或180Ω都行。实际用330Ω也完全没问题单段电流大概4mA8段全亮约32mA在芯片GPIO总电流能力范围内而且亮度足够寿命也好。电阻大一点换来的是安全余量这个取舍很值。1.3 引脚分配与连接思路引脚分配是很多新手容易忽略的环节。我第一版就是随手乱接结果后面调试串口、下载程序时全冲突了拆了重接好几回。这一版我的分配方案如下PA0-PA7接数码管段选a-g、dp推挽输出PB0-PB3接四位数码管位选公共端COM1-COM4推挽输出PB4、PB5接两个调时按键输入上拉PA8接功能切换按键输入上拉PB6接无源蜂鸣器推挽输出分配时有几个原则。第一把PA13、PA14留出来那两脚是SWD下载口接了别的负载会影响调试器通信。第二PA9、PA10留出来接USB转TTL串口调试时打印时间变量非常有用如果这两个脚被按键占了后面想用串口就尴尬了。第三PB4和PB5默认是JTAG引脚要作为普通IO用必须在代码里禁用JTAG或者做引脚重映射这个很容易漏漏了以后按键怎么按都没反应。接线其实不复杂段选那8根线从PA0到PA7一一对应数码管的a到dp位选4根线从PB0到PB3分别接四个位的公共端按键一端接GPIO另一端接GND靠内部上拉把空闲状态拉高按下时读到低电平。蜂鸣器接PB6到电源之间用NPN三极管驱动或者直接买带驱动的有源模块别把蜂鸣器直接怼到GPIO上电流可能不够。2. 开发环境搭建CubeMX配置时钟树与GPIO的实操步骤2.1 工具链怎么选HAL库还是标准库做STM32开发第一步就面临工具链选择。我的建议很明确新项目用HAL库加CubeMX理由有三个。第一代码生成快外设初始化图形化配置鼠标点几下就出来了省去大量手写寄存器的时间。第二HAL库是目前ST官方主推的方向生态资料最全网上搜问题基本都是HAL风格的代码。第三CubeMX生成的代码框架统一工程结构清晰对新手友好。标准库不是不能用很多老教程、老项目还在用而且标准库代码确实更贴近寄存器底层适合想深挖细节的人。但官方已经停止更新新学的同学没必要再往旧技术栈上投入。我自己的做法是用HAL库做工程但遇到问题时会去翻参考手册和寄存器描述搞清楚HAL封装背后到底操作了什么。这个习惯很重要因为HAL封装的函数看起来很简单一旦出问题不理解底层就会束手无策。工具版本上我用的是Keil MDK 5.x加STM32CubeMX 6.x再配上STM32CubeF1固件包。如果你不想用Keil也可以用STM32CubeIDEST官方出品免费、全集成编译和调试都挺好用唯一的问题是有些老电脑跑起来有点卡。两者流程差不多下面以Keil为例讲。2.2 CubeMX配置关键步骤用CubeMX建工程核心就三步选芯片、配时钟、配引脚。第一步新建工程在芯片搜索框输入STM32F103C8选择LQFP48封装的C8T6。第二步配置RCC在System Core里把HSE设为Crystal/Ceramic Resonator因为蓝丸板上有8MHz外部晶振LSE也就是32.768kHz那个晶振最小系统板上通常没焊直接不使能省得配置出问题。第三步配置时钟树。把HCLK输入72MHzCubeMX会自动帮你算好PLL配置一般是HSE 8MHz经过PLL×9得到72MHz。这里要注意一个点APB1总线时钟是36MHz但APB1上的定时器时钟会自动×2变成72MHz所以后面配TIM3时定时器输入时钟是72MHz不是36MHz这个算错会导致定时时间翻倍。然后是GPIO配置。PA0-PA7设为GPIO_Output速度选High因为动态扫描数码管需要比较快的翻转速度选Low可能产生拖影。PB0-PB3同样设为GPIO_Output。PB4、PB5、PA8设为GPIO_Input下拉或上拉选择Pull-up按键接GND所以空闲时要拉高。这里有个坑PB4、PB5默认复用为JTAG的NJTRST和JTDOCubeMX里虽然能配成普通IO但生成代码后往往还需要在System Core的SYS里把Debug设为Serial Wire否则这两脚可能不听话。最后配置TIM3。在Timers里选中TIM3Clock Source选Internal Clock预分频PSC设为7199计数模式选Up自动重载ARR设为9999然后使能TIM3中断。此时生成的定时器中断频率刚好是1Hz配合中断回调就能作为秒脉冲。生成代码前在Project Manager里把Toolchain选为MDK-ARM其他默认就行。2.3 工程结构规划小项目也要养成模块化习惯很多新手做小项目喜欢把所有代码都塞进main.c一个文件几百行当时觉得爽过两天自己都不想看。这个习惯必须从一开始就改掉。数字时钟虽小但我建议按功能拆成模块main.c主循环逻辑负责调度bsp_timer.c / bsp_timer.h定时器初始化和中断回调bsp_display.c / bsp_display.h数码管驱动段码表、扫描显示bsp_key.c / bsp_key.h按键扫描、消抖、状态机bsp_buzzer.c / bsp_buzzer.h蜂鸣器控制可选为什么要模块化第一可读性好每个文件的职责单一找问题直接定位到对应文件。第二可复用性强以后做温湿度计、做闹钟这些模块拷过去改改就能用。第三方便调试比如显示有问题单独测bsp_display不需要在主逻辑里翻半天。我后来做更复杂的项目时深刻体会到前期这点模块化工作省下的时间远超当时多敲的那几行代码。3. 核心代码实现定时器、数码管、按键逐个击破3.1 时间基准的产生1秒到底从哪来数字时钟的核心是准确的1秒时基。STM32里产生周期中断最常用的方式就是定时器更新中断。以TIM3为例前面CubeMX已经配置了PSC7199、ARR9999这个数字不是随便拍的而是算出来的定时器输入时钟是72MHz预分频后计数频率为72MHz/(PSC1)72MHz/720010kHz也就是每计一个数用0.1ms。自动重载值ARR9999意味着计数器从0数到9999共10000个数产生一次更新事件。所以更新事件频率就是10kHz/100001Hz。综合公式是f_update 72MHz / ((71991)*(99991)) 1Hz。以后你想改成1kHz或者其他频率套这个公式就行。中断回调函数这样写void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM3) { TimeUpdate(); } }TimeUpdate负责时间进位逻辑void TimeUpdate(void) { g_time.second; if (g_time.second 60) { g_time.second 0; g_time.minute; if (g_time.minute 60) { g_time.minute 0; g_time.hour; if (g_time.hour 24) { g_time.hour 0; } } } }进位顺序很重要必须从低位到高位逐级处理。这里有个新手常犯的错误先把second清零再检查second是否达到60结果永远进不了位。正确逻辑是先加1再判断是否到达上限到达了才清零并往上一级进位。有一点我必须强调中断服务函数里只做最轻量的事情。这个例子里只有几个变量加法和比较没问题。但如果你把数码管扫描、按键处理都塞进中断里中断就会拖得特别长轻则影响其他中断响应重则整个系统卡死。时间更新放中断显示和按键放主循环这是典型的前后台系统结构也是很多嵌入式系统的基础模型。3.2 数码管动态扫描显示从段码表到消影四位数码管如果每个位都独立控制需要很多引脚所以工程上基本都用动态扫描所有位的段选引脚并联位选引脚分别控制同一时刻只点亮一位快速轮流点亮四个位利用人眼的视觉暂留效应看起来就像四位数同时亮着。段码表是数码管驱动的第一个关键点。共阴数码管中a段接bit0、b段接bit1一直到g段接bit6、dp接bit7。显示数字0时a、b、c、d、e、f亮g不亮对应二进制0011 1111也就是0x3F。把0到9全部算出来const uint8_t seg_code[10] { 0x3F, // 0 0x06, // 1 0x5B, // 2 0x4F, // 3 0x66, // 4 0x6D, // 5 0x7D, // 6 0x07, // 7 0x7F, // 8 0x6F, // 9 };这个表不用死记自己推一遍比背十遍都管用。如果是共阳数码管把每个值按位取反就行即0xC0、0xF9、0xA4、0xB0、0x99、0x92、0x82、0xF8、0x80、0x90。扫描函数我的写法是这样的uint8_t disp_buf[4]; // 待显示的四位数字 void Display_Scan(void) { static uint8_t pos 0; uint8_t com_table[4] {0x01, 0x02, 0x04, 0x08}; // 第一步关闭所有位选消影 GPIOB-ODR ~0x0F; // 第二步送当前位的段码 GPIOA-ODR (GPIOA-ODR 0xFF00) | seg_code[disp_buf[pos]]; // 第三步打开当前位 GPIOB-ODR | com_table[pos]; pos; if (pos 4) { pos 0; } }这个函数在主循环里反复调用每次刷新一位四位一轮。为了稳定不闪烁每个位停留大约2ms一轮8ms左右刷新率125Hz远高于人眼闪烁感知阈值。如果你用delay函数每扫描一位延时2ms注意延时函数本身也会占用CPU别在中断里调用。关于消影我要多说几句。顺序必须是先关所有位选再送段码再打开当前位这个顺序错了就会出现鬼影。鬼影的成因是切换位的瞬间旧的段码数据还没撤掉新位就已经导通于是旧数字残影投射到了新位上。先全部熄灭等于把显示内容断开再换数据再导通新位就彻底避免了这个现象。有时候你看到下一轮扫描有个淡影多半就是消影没做干净。3.3 按键扫描与软件消抖按下一次不能当三次按键的物理特性决定了它必须消抖。轻触开关内部是金属簧片按下瞬间会来回弹跳电平在高低之间震荡持续大约10到20ms。如果不处理一次按键会被识别成好几次。消抖方案很多最基础的就是延时重读检测到按键为低电平后延时20ms再读一次如果还是低电平才认为真的按下了。#define KEY_MODE_PIN GPIO_PIN_8 #define KEY_ADD_PIN GPIO_PIN_4 #define KEY_SUB_PIN GPIO_PIN_5 uint8_t Key_Scan(Time_t *t) { uint8_t ret 0; if (HAL_GPIO_ReadPin(GPIOA, KEY_MODE_PIN) GPIO_PIN_RESET) { HAL_Delay(20); if (HAL_GPIO_ReadPin(GPIOA, KEY_MODE_PIN) GPIO_PIN_RESET) { ret | KEY_MODE_PRESSED; } } // 其他按键同理 return ret; }比延时消抖更高级一点的是定时器扫描消抖每隔10ms扫描一次按键连续两次读到的状态相同才认为状态稳定这样不阻塞CPU还能顺便做按键释放检测。数字时钟这个项目用延时消抖就够了但你要知道有更好的方案做复杂系统时按键多了阻塞式消抖会拖垮整个流程。按键动作只有两类按下沿和释放沿。处理功能建议检测按下沿也就是从空闲变成按下的那一刻而不是检测电平。原因很简单如果检测到低电平就执行加一你按住不放它会一直加如果只检测按下沿每按一次只加一按住也不会重复触发。调时逻辑我建议用一个简单的状态机用枚举定义typedef enum { MODE_RUN, // 正常运行 MODE_SET_MIN, // 调分钟 MODE_SET_HOUR // 调小时 } ClockMode_t;按键一负责在三个模式间循环切换按键二在调时模式下负责加一。这个状态机虽然简单但它是以后做菜单系统的雏形值得体会一下用状态而不是用标志位的思路。3.4 主循环整体逻辑前后台系统的骨架主循环是整个程序的调度中心我的写法大概是int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM3_Init(); g_time.hour 12; g_time.minute 0; g_time.second 0; HAL_TIM_Base_Start_IT(htim3); while (1) { // 刷新显示 Display_Update(g_time); Display_Scan(); // 扫描按键 uint8_t key Key_Scan(g_time); HandleKey(key, g_time); // 可选的整点报时逻辑 if (g_time.second 0 g_time.minute 0) { Buzzer_Beep(1); } } }这个结构就是典型的前后台系统定时器中断是前台负责维护最严格的时间基准while循环是后台负责显示、按键这些对时序要求不高的任务。很多实时操作系统RTOS的本质也是在这套模型上加了一层调度器。你把数字时钟这个项目做透其实已经把嵌入式系统最底层的架构理念掌握了。Display_Update负责把当前时间拆成四位数字比如12点34分hour/101、hour%102、minute/103、minute%104分别存入disp_buf。调时模式下可以让当前正在调的那一位闪烁实现方式是在扫描时每隔一定周期把该位置为不显示视觉上看起来就是闪动。这个功能虽然小但很能体现你对显示逻辑的掌控力。4. 调试实录显示闪烁、走时不准、按键乱跳的排查过程4.1 数码管鬼影与闪烁问题第一版上电测试时数码管显示出来的数字带着明显的残影数字切换时还能看到拖尾。排查思路是从显示顺序入手。我先确认了段码表没错、引脚对应没错最后发现就是扫描顺序的问题旧位还没有熄灭新位的段码已经送过去了结果旧位的数字残留到了新位上。解决办法就是前面说的三步走先全部熄灭再送段码再打开当前位。闪烁问题的排查方向和鬼影不同。闪烁通常是因为刷新率太低也就是每个位停留时间太长。我见过有人把单位置1延时写了50ms四位一轮就是200ms刷新率只有5Hz肉眼看到的就是明显的一格一格跳。把每位置延时改到2ms左右刷新率125Hz问题立刻消失。另外还要检查一件事是否所有延时都放在中断或主循环里被其他长任务阻塞了。如果主循环里有个延时特别久的函数扫描函数长时间得不到调用闪烁就必然发生。4.2 走时不准的根源与软件校准定时器方案做到后面一定会遇到走时偏差问题。我第一次测试连续跑了两小时比手机时间快了约2秒折算下来一天要快24秒左右这作为桌面时钟绝对不合格。原因主要在于主时钟源的精度。STM32F103C8T6蓝丸板上的8MHz晶振大多是普通贴片晶振精度通常在几十ppm量级而且部分板子没有配负载电容实际谐振频率会偏离标称值。另外一个隐藏问题掉电后时间归零重新上电需要手动调时。这两个问题叠加让我决定做两步处理。第一是软件校准。校准思路是先实测走时偏差比如实测1小时快了1.5秒也就是快0.025秒/分钟。然后修改时间更新逻辑每N次中断跳过或增加一个tick让时间平均下来变准。具体的实现可以是维护一个补偿计数器每40分钟额外少算1秒或者每天在某个固定时刻回拨固定秒数。这种方法能把误差压到每天1到2秒以内对于非精密场景足够了。第二是更彻底的方案换用外部RTC芯片比如DS3231。DS3231内置温补晶振年误差能做到一两分钟级别还带电池备份断电后时间不丢。如果你的目标是做一个真正能长期使用的时钟DS3231是必须的投资。这个升级我放在下一节详细讲。4.3 按键误触发与连加问题按键的问题最常见的是按一次加了好几次。排查后发现原因有两个一是没用消抖金属弹片的毛刺被当成了多次按下二是按下处理写成了电平触发按住不放就持续加一。第一次做的朋友大概率两个坑都会踩。消抖的具体做法前面已经给了代码这里补充一个经验消抖延时不是越大越好。20ms是常见值但如果用的按键品质差可能需要30ms反过来延时太长手速快时会感觉按键反应不过来。另外处理完一次按键后要等按键释放才能处理下一次按下。实现方式是在状态机里加一个等待释放的状态否则快速连按会被消抖算法吞掉。调试按键还有一个方法用串口打印。按下按键时打印当前时间和按键状态看起来是不是符合预期。我当时就是用这个方法发现原来是PB4引脚没有正确禁用JTAG导致按键状态一直读不到。这种问题不看串口输出光盯着数码管看很难定位。4.4 常见问题速查表现象可能原因排查与解决程序烧录失败BOOT0跳线不对、SWD引脚被占用BOOT0接GND检查PA13/PA14没有外接负载数码管全亮且乱码段码表错误、引脚接反依次显示单个数字0-9逐个位验证显示闪烁扫描刷新率太低、主循环被阻塞每位置2ms左右检查是否有长延时阻塞扫描时间不走定时器未启动、中断未使能检查HAL_TIM_Base_Start_IT确认NVIC已使能走时偏差大晶振精度差、使用HSI内部时钟确认使用HSE软件校准换DS3231按键没反应上拉没配、JTAG引脚冲突确认输入上拉PB4/PB5需配置Serial Wire调试模式按键连加没消抖、电平触发写成持续触发加20ms消抖用按下沿触发处理完等释放断电时间丢失定时器方案无掉电保持换DS3231或加后备电池4.5 串口和示波器是调试的法宝做这类项目强烈建议从一开始就预留串口。用USB转TTL模块接PA9、PA10通过printf打印时间变量、按键事件调试效率翻倍。CubeMX里重定向printf的细节不多核心是把fputc重定向到串口网上教程很多。我习惯在每次按键操作后打印一行日志格式大概是mode1, hour12, min05, sec00这样每次改动都有迹可循。如果有示波器或者逻辑分析仪可以抓一下扫描输出波形你会直观看到动态扫描的时序。没有也不要紧我教你一个笨办法把数码管扫描速度人为调慢比如每个位延时500ms你会看到一个位一个位地点亮顺序对不对、段码对不对一目了然。这个慢动作调试法在嵌入式调试里非常实用比对着代码猜快多了。5. 从简易单机时钟到智能桌面设备三个靠谱的升级方向5.1 升级外部RTC彻底解决走时和掉电问题定时器方案最大的两个痛点走时精度和掉电保存都是外部RTC芯片的主场。DS3231是性价比最高的选择内置温补晶振和电池座走时精度年误差在1到2分钟断电能靠纽扣电池维持好几年。硬件连接就是I2C总线SCL接PB6、SDA接PB7注意DS3231是5V逻辑的模块时可能要加电平转换或者选3.3V版本否则I2C通信会不稳定。软件上改用HAL库的I2C接口初始化I2C外设后按DS3231的寄存器映射读写秒、分、时、日、月、年。这个升级会带你接触I2C协议、寄存器读写、BCD编码转换又是一套新知识。调时逻辑也要跟着改以前是直接修改g_time变量现在需要写回DS3231寄存器但整体的应用层逻辑不用大动这正是模块化带来的好处。5.2 升级显示方案从数码管到OLED数码管的优点是直观、便宜缺点也很明显信息容量小只能显示数字要做菜单、图标非常吃力。0.96寸OLED屏幕I2C或SPI接口128x64分辨率能显示中文、曲线、甚至简单的动画做出来的效果立刻从实验室变成产品级。换OLED后时间信息以文本形式显示用户界面可以做菜单比如第一屏显示时间第二屏显示温度第三屏显示设置。这个升级会带出几个新问题字库怎么取模、菜单状态机怎么设计、I2C多设备怎么共存。每一个问题都是嵌入式开发的高频技能。我做第三版时把OLED、DS3231、DHT22温湿度传感器整合到一起做成了一个桌面信息屏功能比数字时钟丰富得多但核心骨架还是当初那个数字时钟打下的底子。5.3 加传感器与低功耗把时钟变成环境监测终端加了DS3231和OLED之后这个项目已经具备小型产品的雏形了。继续往里加东西的方向很多我列几个我觉得最顺手的温度采集DS18B20单总线或者AHT20 I2C在OLED上显示实时温湿度光线传感器光敏电阻加ADC采集环境亮度自动调节OLED亮度或者数码管亮度整点报时无源蜂鸣器播放不同频率的组合音区分整点和半点低功耗改造STM32进入STOP模式由RTC闹钟每秒钟唤醒一次刷新显示电池供电能用很久这些扩展的共性是从能用走向好用每一步都涉及新的外设、新的协议、新的设计权衡。我一直觉得嵌入式学习最好的路径不是刷教程而是把一个项目往深了做每加一个功能就会遇到新的问题每解决一个问题你的能力就实实在在涨一截。数字时钟只是个起点但它是个非常好的起点。最后再分享一点个人体会。这个项目我自己前后做过三版第一版是定时器加共阴数码管按键没有消抖按一下跟抽风似的串口也没预留调试全靠猜第二版加了模块化、串口打印和软件消抖稳定了很多但每天走时还是偏快断电还得重新调第三版换成DS3231配OLED才算真正敢把它放在桌面上当一个能用的钟。每一步都是在前一个版本的坑上长出来的这大概就是嵌入式学习的常态。做数字时钟不是目的借它把STM32的底子打牢才是。如果你也想练手就从这篇文章的方案开始按自己的需求改做完之后你会发现再看那些复杂项目心里有底多了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。