1. 为什么这门课的第二次作业,成了“劝退现场”
事情是这样的:我选的《嵌入式系统设计》这门课,第一次作业是点灯。用库函数让LED按一定节奏闪烁,写的代码不超过二十行,下载到开发板上灯一亮一灭,大家都觉得“也就这样了”。作业交上去,老师没多说什么,只在评语里写了一句“注意看数据手册”。
第二次作业的主题一出来,教室里安静了半分钟。题目是:利用STM32的实时时钟(RTC)模块,在LCD1602液晶屏上显示当前时间,并支持通过按键调整时、分、秒。具体要求包括:上电自动显示时间;按键进入调时状态后对应位闪烁提示;系统掉电后时间不丢失(依赖后备电池);整个系统功耗尽可能低。
说实话,第一次看到这个题目,我的第一反应是“没难度”。点灯都写了,RTC不就是一个读寄存器的操作?LCD1602网上教程多如牛毛,按键就是读IO口。但真正开始动手之后,我才发现这个作业像一面筛子,把“会调库”和“真的懂单片机”的人第一次区分开来了。
这篇博文我就完整复盘一下做“第二次作业”的全过程——需求拆解、方案选型、代码实现,以及三个让我当场怀疑人生的Bug排查链路。文章主要面向三类读者:正在做类似课设的在校生、刚转嵌入式没多久的新工程师、以及所有想从“点灯阶段”往“真正做一个小系统”过渡的朋友。看完你能带走的不只是一个能跑通的作业,更是一套拿到需求后怎么下手、遇到问题后怎么定位的思路。
2. 拿到题目以后,我先把需求拆成了五块硬骨头
很多人做课设的习惯是:先搜“STM32 RTC LCD1602”的现成代码,看到能编译的就直接下载,然后改两行IO口定义,烧进去发现不对,再开始各种补丁式修改。我这次学乖了,动手前先花了一个晚上把题目需求拆开,一条一条对硬件能力。
拆完之后,整道题其实就变成了五个独立的小子问题:
RTC模块的初始化和读取。这是核心中的核心。STM32的RTC在F1系列里是一个独立于主程序的时钟域设备,靠后备电源区域供电。它的问题是寄存器访问方式比较绕——不是你想读就读,需要先把后备区域访问使能打开,然后等内部同步完成。如果这一步没处理好,你读到的永远是初始化之前的值。
LCD1602的驱动,尤其是时序和忙标志。网上流传的LCD1602驱动代码大都是“写完命令就delay一会儿”,这在教学场景下能跑,但如果你较真去看数据手册,会发现1602有一个Busy Flag(忙标志),读取它才能知道液晶内部的控制器是否准备好了。正确的做法是写命令前先轮询BF位,而不是盲等固定时长。这个差异在时钟显示这种高频刷新任务里尤其明显。
按键去抖与按键状态机。题目要求“按键调整时间”,看起来就是一个GPIO读电平的事情。但机械按键按下和释放的瞬间,会因为簧片抖动产生几十毫秒的不稳定电平。如果我只做一个简单的延时消抖,那么“按一次”经常会被识别成“按两次”,调整分钟的时候数字会多跳一格。这个问题的正解是用状态机加定时器,而不是用delay。
显示屏的刷新策略。“每秒更新一次时间”,这事看着简单,但如果直接在主循环里每秒全屏刷新一次,液晶会明显闪烁,而且和按键调整的显示逻辑混在一起时,容易出现“上一秒还在调分钟,下一秒被时间刷新覆盖”的撕裂问题。更合理的方案是分区域刷新——秒变了只更新秒的两位,分变了才更新分的两位,整个小时变了才更新小时。这样既减少闪烁,也把显示任务从1秒钟一次的粗粒度刷新里解放出来。
低功耗约束。题目特别加了“功耗尽可能低”这一条,很多同学直接忽略了。第二轮提交的时候老师特别强调:能进入低功耗模式的,进入低功耗模式;能用睡眠中断唤醒的,不要用轮询去读。这其实是一个非常实际的产品级约束——现实中的电子钟不可能靠一个主控全速空转来维持走时。
把这些需求拆完,我对工作量的估计大约是:RTC部分一天,LCD驱动一天半,按键状态机加逻辑半天,显示刷新和联调两天,功耗优化预留一天。这个估时在后面的开发中被验证是“比较乐观的”,因为实际把五块拼在一起的时候,还会出现模块之间的相互干扰。
3. 逐个模块的实现过程,以及每一块里藏着什么样的坑
3.1 RTC初始化的前置操作:备份域访问使能和同步等待
RTC在STM32F103里的地位比较特殊,它挂在备份域(Backup Domain)下,由VBAT引脚供电。也就是说,即使主电源断开,只要电池座上有纽扣电池或者超级电容,RTC就可以继续走时。
但访问备份域是有“锁”的。想读写RTC相关寄存器,必须先打开PWR和BKP外设的时钟,然后把PWR_CR寄存器里的DBP位置1。我刚开始不知道这个前提,直接在RTC初始化函数里读时间,读回来的数据永远是上次上电时的垃圾值,一度以为是晶振没起振。
这套操作的代码逻辑大致是:
RCC_APB1PeriphClockCmd(RCC_APB1Periph_PWR | RCC_APB1Periph_BKP, ENABLE); PWR_BackupAccessCmd(ENABLE); // 解除备份域访问保护 RCC_LSEConfig(RCC_LSE_ON); // 启动外部低速晶振,频率一般为32.768kHz while (RCC_GetFlagStatus(RCC_FLAG_LSERDY) == RESET) { // 等待LSE稳定就绪,超时保护记得加 } RCC_RTCCLKConfig(RCC_RTCCLKSource_LSE); RCC_RTCCLKCmd(ENABLE); RTC_WaitForSynchro(); // 等待RTC寄存器与APB1总线同步 RTC_SetPrescaler(32767); // 1秒中断,LSE频率除以(32767+1)这里有一个细节:RTC_SetPrescaler(32767)表示预分频值是32767,不是32768。RTC的预分频寄存器存的是“分频系数减一”,因为RTC的初始值是0。关于这个预分频值的设置,评论区里几乎每年都会有人问为什么我的RTC走时不准、快或者慢,绝大多数时候就是这里差了“1”,以及晶振本身的负载电容匹配问题。
LSE晶振还有一个“玄学”问题——很多开发板虽然预留了电池座和晶振位置,但晶振本身没焊或者焊接不良,导致LSERDY永远置不了位。如果你在等待就绪的循环里卡死了,优先检查硬件:万用表量晶振两端对地电压,或者看示波器能不能抓到32.768kHz的波形。软件层面也可以加一个超时计数器,不然后续的主流程会卡死在初始化里。
3.2 LCD1602驱动:为什么我没有直接抄网上的“标准库”
网上的LCD1602驱动确实很多,绝大多数能正常工作,但他们用了一个取巧的办法——“盲等”。就是每次写完命令或者数据之后,固定延时1到2毫秒甚至更长,用延时覆盖液晶内部命令处理时间。
这种办法在单独测试液晶模块的时候毫无问题,但在时钟显示这种需要“每秒刷新一次、高频读写”的场景里有两个缺陷:
第一,盲等不够高效。LCD1602在大多数操作上其实需要40到50微秒的命令执行时间,而清屏需要1.64毫秒左右。如果你统一delay 2毫秒,单次操作的速度被强行拖慢,但显示本身又没有慢到肉眼可察觉的地步——问题在于按键操作和主循环的实时性会被拉低。第二,盲等和忙检查混用时容易出逻辑时序问题。
我后来对照HD44780的数据手册重新写了驱动。关键是在两次操作之间读取BF位:
uint8_t lcd_busy_wait(void) { GPIO_InitTypeDef gpio; uint8_t busy = 0; // 数据引脚切换为输入模式,读取DB7位 // DB7为1表示忙,为0表示空闲 // ... 具体IO配置代码略 while (busy) { // 读取DB7 LCD_RS_LOW(); LCD_RW_HIGH(); LCD_E_HIGH(); delay_us(1); busy = GPIO_ReadInputDataBit(LCD_DATA_PORT, LCD_DB7_PIN); LCD_E_LOW(); } return busy; }值得提醒的是,1602的E使能引脚有个脉冲宽度要求(至少450纳秒),RW和RS的设置需要比E上升沿提前至少40纳秒。用主控50MHz以下的APB2总线速度,GPIO翻转一次大概需要几十纳秒,勉强够用,但如果你的主控跑在72MHz以上,最好在E拉高前后各加一个__nop()微延时,避免时序裕量不足。
LCD初始化时序也是必须严格按照手册走的:上电后等待40ms,功能设定命令0x38(8位总线、两行、5x7点阵)要等待4.1ms,再发一次要等待100微秒,第三次之后每次操作都要等待忙标志空闲。这套流程的时序参数可以在HD44780手册的初始化流程图里找到。
3.3 按键调时逻辑:一个不需要RTOS的多任务调度方案
按键这一块,我一开始想把所有逻辑都塞进主循环里轮询,后来很快发现这样会乱:时间在走、按键在扫、液晶在刷,三个任务在单线程里互相抢占。如果调时间的时候,主循环恰好执行到“每秒刷新一次时间”的代码段,调进去的数字会被旧时间覆盖,表现为“改了没反应”。
后来我采用的方案是典型的“前后台系统”雏形:
- RTC的秒更新产生一个中断(后台任务),在中断里只设置一个全局标志位
time_tick = 1; - 主循环(前台任务)优先检查
time_tick标志,置位则读取RTC时间并更新显示缓冲区; - 按键通过状态机检测,检测到一次完整按下之后,进入“调整模式”,此时显示刷新暂停,仅当用户保存退出后才恢复。
状态机的核心代码框架如下:
typedef enum { KEY_IDLE, KEY_PRESSED, KEY_WAIT_RELEASE, } KeyState; void key_scan(void) { static KeyState state = KEY_IDLE; uint8_t key_value = GPIO_ReadInputDataBit(KEY_PORT, KEY_PIN); switch (state) { case KEY_IDLE: if (key_value == PRESSED_LEVEL) { key_timer_start(); // 启动去抖定时,例如10ms state = KEY_PRESSED; } break; case KEY_PRESSED: if (key_timer_timeout() && key_value == PRESSED_LEVEL) { key_event = true; // 确认是有效按下,产生事件 state = KEY_WAIT_RELEASE; } else if (key_value == RELEASED_LEVEL) { state = KEY_IDLE; // 去抖期间松开,判定为抖动 } break; case KEY_WAIT_RELEASE: if (key_value == RELEASED_LEVEL) { key_timer_stop(); state = KEY_IDLE; // 等待释放,避免一次按下触发多次 } break; } }这个状态机的精妙之处在于:去抖和“等待释放”是分开的。第一个状态段消除了硬件抖动导致的不稳定电平问题;第三段则保证了“按住不放”只触发一次事件,而不是每秒触发几次。很多同学的作业翻车就翻在第二段缺失——按下一次,系统识别出两三次的“按键事件”,分钟数从10变到13甚至更多,根本没法调准时间。
3.4 显示刷新策略:一秒刷新一次和局部刷新到底差在哪
最开始我的显示屏刷新逻辑非常简单粗暴,主循环每秒钟把整个1602全部重新写一遍。效果是能看,但有两个问题:一是偏屏闪烁,尤其是当主循环里还有按键扫描和其他处理时,刷新时刻不固定,肉眼能看到“刷新抖动”;二是白写了很多字节——每秒都在把“12:30:45”这个字符串从头到尾刷一遍,而实际上每秒变化的只有秒位,最多带上分钟进位。
优化思路就是“局部刷新”。RTC读取的时间更新到显示缓冲区后,对比新值和旧值,只有变化的部分才重写LCD对应的字符位置:
if (new_hour != old_hour) lcd_set_cursor(0, 0); lcd_write_string(hour_str); if (new_min != old_min) lcd_set_cursor(0, 3); lcd_write_string(min_str); if (new_sec != old_sec) lcd_set_cursor(0, 6); lcd_write_string(sec_str);LCD1602的写入接口是整个模块的瓶颈,每次写数据都需要先等待忙标志,再拉E脚脉冲,一次操作至少几十微秒。全屏刷新一次大概是40个字符写操作,局部刷新最多写4个字符(例如从“12:59:59”跳到“13:00:00”)。前者每次刷新耗时按毫秒算,后者按几百微秒算——对于单片机可是一个量级以上的性能差距。
同时这个策略还提升了代码的可维护性:按键调整功能改动显示数值时,也只需要操作缓冲区,对应的局部刷新逻辑会自动工作,不会出现“这边改那边被覆盖”的冲突。
3.5 低功耗权衡:我到底该睡多深
题目要求低功耗,实际操作上有两个层面。第一层是主循环的空闲时间尽量进睡眠模式,第二层是合理选择睡眠深度。
我最终采用的是SLEEPONEXIT加WFI的方案:RTC的秒中断到来时唤醒主控,完成读时间和刷新显示后立即再次睡眠,相当于只有每秒醒来不超过10毫秒。这种模式与全速空转相比,电流可以从几十毫安降到几毫安量级。如果继续深挖,还可以考虑RTC闹钟中断定时几分钟唤醒一次,不过这会导致时间显示不能每秒更新,与题目“实时显示”的要求冲突,所以我折中保留了每秒唤醒的方案。
关于低功耗还有一个容易忽略的点——GPIO的配置。进入睡眠前,所有未使用的IO应该配置为模拟输入或者下拉输入,避免浮空导致的漏电;LCD等外设的背光如果不需要常亮也要考虑关断或降亮度。这块不是核心考核点,但做了的话老师答辩时印象分会高不少。
4. 实测现场:三个让我怀疑人生的Bug与排查链路
4.1 Bug之一:时间乱码回00:00:00,RTC的“假初始化”问题
现象:第一次烧录程序,开发板按复位键之后,LCD正常显示了时间,但是显示的不是当前时间,而是00:00:00。过了几秒一看,还是00:00:00,只有秒在走——这至少说明RTC在计时,但初始值不对。
排查链路:我第一步想到的是RTC初始化功能的时序问题。我的初始化函数里,先设置RTC预分频,再设置时间和日期。看似没问题,但翻查参考手册后发现一个非常隐蔽的陷阱:RTC_PRL、RTC_CNT这些寄存器属于备份域,而备份域在系统复位后并不会被复位,除非你执行了BKP_DeInit()。如果上一次运行程序时,某段代码已经把RTC初始化了一半(比如只设置了预分频没设置计数值),这一半的状态会被保留下来,导致本次初始化中部分写入操作被跳过。
根因:我的初始化函数为了支持“首次设置”和“掉电恢复后保留时间”,做了判断:如果后备寄存器里的标志位不是预设魔数(比如0xA5A5),才执行设置时间;否则直接跳过。问题出现在这里——电池没装的时候,后备寄存器读出来的值可能恰好等于魔数,或者我用了同一个变量既做标志又做判断,逻辑上产生了歧义。
修复方案:把初始化分成两段。第一段无条件执行“使能备份域访问、设置RTC时钟源、等待同步”,第二段才判断是否需要写入初始时间。给后备寄存器写入的魔数改成两个,一个表示“首次初始化”,一个表示“正常时间有效”,避免偶然重合的概率。修复后再烧录,时间显示就正常了。
这个Bug教会我一件事:RTC这类有电池后备功能的设备,在仿真器调试和反复烧录的过程中,“残留状态”会成为最隐蔽的敌人。每次改代码前最好带上电池,否则所有状态都可能被上一次乱七八糟的实验弄脏。
4.2 Bug之二:按键调整时间,按一次跳两格,而且是规律性跳两格
现象:进入调时模式后,短按一下“加分钟”键,分钟数字直接从12跳到14或者15,稳定复现,不是偶发。
排查链路:这个Bug的定位比第一个快,因为现象太有规律了。第一次怀疑是硬件问题——用万用表量按键两端,发现按下时电阻稳定,无异常抖动;示波器看波形,按下瞬间确实有一串长约15毫秒的电平毛刺,属于机械按键正常现象。我的去抖状态机理论上应该能滤掉这十几毫秒的抖动。
接着开始在逻辑里找问题。我把key_event变量在每次按键事件触发后打印出来,发现按一次,事件标志被置位了两次。回溯状态机,问题出在第三个状态KEY_WAIT_RELEASE的判定条件上——我判断“松开”用的电平阈值是RELEASED_LEVEL,但如果按键模块还有一行代码在持续轮询时把事件标志清掉了,就会导致“第一次释放时已经产生事件,但释放后的下一毫秒状态机又跑了一遍,生成了第二个事件”。
根因:再往深挖一层,是“状态机与事件处理”的归属问题。我的状态机在主循环里每200微秒调用一次,key_timer_timeout()用的是一个全局计数值。问题是这个计数值在KEY_IDLE状态下没有清零,导致从KEY_IDLE到KEY_PRESSED再到确认按下,本来应该等待10ms才能确认,实际上因为计数值已经残留了足够大,第一次进入KEY_PRESSED就立即超时。等于消抖定时器形同虚设。按一次键变成了“确认按下成功”和“定时器残留后马上又确认第二次”两次触发。
修复方案:在进入KEY_IDLE状态初始化时强制清一次定时器计数值;事件处理完以后,用一个独立的“事件已处理”标志位防止同一条事件被主循环的多个任务重复消费。修复后按键行为恢复正常:按一下只跳一格,按住不放只会发生一次事件。
这个Bug的普适性很强——消抖逻辑看起来简单,但“定时器初始化和复位时机”这种细节没处理好,任何基于超时的机械按键识别都会翻车。
4.3 Bug之三:时间更新一瞬间屏幕闪白,隐约还带重影
现象:整体功能调试完毕,系统稳定运行了一段时间之后,我注意到一个不仔细看根本不会发现的细节——每次秒数变化的那一瞬间,整块LCD屏会闪一下白,像是把所有字符擦掉重画了一遍。虽然每次只有十几毫秒,看久了眼睛非常疲劳。
排查链路:一开始我以为是1602的背光或者是电源纹波问题,给LCD的VDD脚并联了一个100uF电容,没效果。再怀疑是局部刷新逻辑没生效,在lcd_write_string前后加了GPIO翻转示波器观察,发现刷新周期内确实只有变化的字符位置有波形,说明局部刷新逻辑在跑。
真正定位到根因是偶然的:我把刷新的波形和RTC秒中断用示波器双通道同时抓,发现闪烁点的波形和RTC的中断处理函数有重叠——闪烁总是在RTC中断进入之后、主循环还没执行完上一轮显示刷新之前发生。
根因:RTC中断服务函数里除了设置time_tick = 1之外,我还顺手做了一件事:直接更新了全局显示缓冲区里的秒字符串。而主循环这个时刻可能正在执行LCD的写入操作,两者共同操作同一个内存数组,没有做互斥。于是显示缓冲区的数据在“正在被LCD写”和“正在被中断改”之间发生了竞争。具体表现就是LCD这个瞬间读到的数据是不完整的——高位还是旧秒,低位已经变成了新秒,LCD认为是两条命令或数据,因此清屏重画。
修复方案:严格遵循“后台只设标志、前台统一处理”的约定。RTC中断里禁止任何显示缓冲区和字符串操作,只置标志位。主循环检测到标志后再统一读取RTC时间、拼接字符串、执行LCD刷新。这样虽然多了一次缓冲区拷贝的开销,但对于这个应用场景完全可忽略,换来的是显示的一致性和稳定性。
修复之后,闪白和重影彻底消失。如果你在调试类似系统时也遇到屏幕偶尔闪白、数据残缺的问题,优先检查自己是不是在中断里碰了“不该碰”的数据——这是最经典的“数据竞争”教材。
5. 这次作业沉淀下来的一些“作业之外”的心得
做完第二次作业,我自己最大的收获其实不是“我会用RTC”“我会驱动LCD1602”这些具体技能,而是对开发板上的现象和代码之间的对应关系有了更准确的直觉。以前遇到bug,我习惯先改代码,再看现象;现在我会先观察现象里有没有“重复性”“是否有规律”“是否和某个中断或外设相关”,再反过来读代码,定位速度大幅提升。
如果你的第二次作业也在做类似的东西,我特别想留下三个可带走的建议:
第一,拿到需求先拆解,不要急着写一行代码。把需求拆成“数据获取、数据展示、人机交互、功耗策略”几个层次,每个层次里再列子任务,子任务表面上是独立的,但你要提前想好它们之间的数据接口。比如“RTC时间字符串”就是贯穿所有模块的公共数据,一开始就明确“谁修改、谁读取、谁负责拼接”,后面联调能省一半时间。
第二,中断服务函数里只做最必要的事。能用标志位通知的,就不要在中断里处理数据;能在主循环里解决的问题,就不要去碰中断里的全局变量。这条原则不只在单片机上适用,在所有嵌入式系统里都是铁律。测试时可以用示波器测量中断服务函数的执行时长,如果超过几十微秒,就该考虑把处理逻辑搬出去。
第三,看懂时序图比你背下任何现成的驱动代码都重要。LCD1602的初始化时序、RTC的同步流程、按键的抖动特性——这些东西在数据手册里都写得清清楚楚。你写驱动时遇到的绝大多数“莫名其妙的问题”,都能在时序图或寄存器描述里找到根源。与其网上求代码,不如花两个小时把数据手册的相关章节读一遍,这种投入的回报率远超预期。
最后说点个人感受。这门课的第二次作业表面上是“做一个电子时钟”,但它真正考核的是:你有没有能力把一个实际需求拆成可实现的硬件与软件功能,并且在软硬件交互的细节里保持清晰的逻辑。点灯,最多能证明你会配置GPIO;而做一个会走时、能调整、抗干扰的系统,才让你第一次感受到“设计一个产品”和“写一段代码”之间的落差。如果你也被这样一份作业折磨过,或者正在被折磨,坚持把每一个Bug定位到底——它们大概率不是简单的问题,而是你嵌入式思维从“会跑就行”升级到“为什么这样做更可靠”的契机。