说实话,写到“第二十三天”这个标题的时候,我自己也有点意外。一个多月前我还觉得寄存器是离我特别远的东西,现在居然能一手拿着数据手册,一手在IDE里敲寄存器位定义了。第二十三天这个时间点其实很微妙:GPIO已经练熟了,中断的基本概念也清楚了,但对定时器的理解还停留在“开个灯灭个灯”的水平。所以这一天,我给自己定的目标很明确——把定时器、外部中断、串口这三件套真正打通,做一个带实际功能的小东西,而不是继续在单一外设上反复做无意义的点灯实验。
这篇文章不是那种按部就班的教学文档,我尽量按照自己当天的实际踩坑过程来写。你会看到我怎么选方案、怎么算参数、代码写崩了怎么定位,以及哪些坑是教科书上不会告诉你的。如果你也处在自学的中间阶段,看这篇应该能找到不少共鸣。
1. 第二十三天的学习定位与目标
1.1 二十多天的学习进度大致在哪
先说说我这二十多天都干了什么。前两周基本上是在折腾C语言和位运算,把指针、结构体、回调函数这些概念重新过了一遍,然后在开发板上逐个点亮外设:GPIO输出、按键输入、外部中断、延时函数。到第二十天左右,我已经能把一个按键控制LED亮灭这种入门实验做得很熟练了。
但问题也随之出现。单个外设的实验做多了之后,人会进入一种“舒适区”:每个实验都是固定的套路,初始化、配置、循环里写逻辑,跑起来了就算完成。这种状态其实很危险,因为嵌入式真正的难点从来不在单个外设,而在多个外设怎么协同工作。定时器你要配置,中断你也要配置,两个同时工作的时候,优先级怎么分?共享变量怎么保护?中断服务函数里到底能不能写延时?
第二十三天,我就是带着这一堆问题开始的。说白了,这一天不打算学任何“新外设”,而是要把已经学过的三样东西组合起来。这个思路也是我自己反复摸索后总结出来的:与其贪多嚼不烂地一天开一个新外设,不如用一整天时间把一个“小组合拳”打得熟练。
1.2 这一天的目标和验收标准
我给这一天定的目标是做一个这样的功能组合:一个按键短按切换LED的显示模式(常亮/呼吸灯),另一个按键调节亮度挡位(10%、30%、60%、100%),同时每次状态变化通过串口打印一行日志,方便我在电脑上确认程序确实按照预期在跑。
这个目标看起来不复杂,但它要求三件事同时成立:
- 定时器工作在PWM模式下,输出可调的占空比;
- 两个按键通过外部中断触发,且要处理抖动;
- 串口初始化正确,能在状态切换时打印信息。
我还给自己写了一个验收标准,比“能跑就行”要严格得多:无论我怎么快速按按键,LED的状态变化都符合预期;串口日志能准确反映每一次状态切换;长时间跑下来不会出现按键失灵或者LED无响应。这三个标准对应的其实是中断的可靠性、共享数据的一致性和主循环的鲁棒性。
之所以特意写验收标准,是因为我前二十天的经验告诉我:没有标准的学习任务,最后都会变成“好像会了”。写下来,就等于给今天设了终点线。
2. 核心知识点拆解:定时器、外部中断、串口是怎么配合的
2.1 定时器的PWM频率和占空比是怎么算出来的
PWM的核心就是让某个引脚按照固定频率输出高低电平,并且高电平所占的比例(也就是占空比)可以调节。LED的亮度之所以看起来不同,不是因为电流大小变了,而是因为亮的时间比例变了。这里有个生活化的类比:你把一个风扇开关快速拨来拨去,风扇转速介于停和满速之间,转速高低取决于“开的时间占比”。PWM控制LED亮度就是同一个逻辑。
在具体配置上,我用的MCU是Cortex-M0内核,内部时钟经过倍频之后到72MHz。PWM的频率由三个关键参数决定:预分频系数、自动重装载值、通道比较值。计算公式很直接:
PWM频率 = 时钟频率 /(预分频值+1)/(自动重装载值+1)
我当时给LED选的PWM频率是100Hz,也就是每秒亮灭100次,这个频率人眼看起来已经足够平滑,不会明显闪烁。算起来就是:
- 72MHz ÷ 720 = 100kHz,这里预分频寄存器写入719,实际分频倍数是719+1=720;
- 100kHz ÷ 1000 = 100Hz,这里自动重装载寄存器写入999,实际计数周期是999+1=1000个时钟。
这里最容易犯的错就是把“寄存器值”和“实际分频数”搞混。寄存器里写719,实际分频是720倍;写999,实际计数周期是1000个时钟。第一次上手的人十个有九个在这里翻车,我就是其中之一。后来我养成了一个习惯:写寄存器之前,先列一个简单的等式,把目标频率、系统时钟、分频值三者关系写清楚再动手。
至于占空比,就是比较值除以自动重装载值。比较值写500,占空比就是50%;写100,占空比就是10%。调节亮度挡位,本质就是改这个比较值。
2.2 外部中断为什么必须处理消抖
按键的物理结构决定了它不是一个理想开关。按下和松开的过程中,金属触点会经过多次微小弹跳,时间可能持续几毫秒到二十毫秒。如果你在上升沿或者下降沿触发中断,一次按键可能触发七八次中断。这就导致了经典的“按一次键,LED跳了三挡”的问题。
处理抖动有几种常见办法。硬件上可以加RC滤波电路,很多成品开发板上其实已经加了;软件上的经典做法是延时消抖——检测到中断之后,等待十几毫秒再读一次电平,确认电平稳定才算有效。另一种更积极的做法是:在定时器驱动的扫描过程中多次采样,连续几次采到相同电平才认定有效。
我这一天的做法比较朴素:外部中断触发后,先读一次引脚电平做确认,再等待十几毫秒读第二次,两次都稳定才置事件标志。但这里埋了个大坑——如果直接在中断服务函数里写延时函数,会导致中断处理时间过长,后续的中断会被堵住。这个问题的细节,我会在后面专门讲。
2.3 串口调试是学习阶段的“第二双眼睛”
串口在自学阶段的价值,很多人是低估的。LED能告诉你的信息极其有限:要么亮要么灭。但你的程序内部到底有没有进入某个函数、某个变量的值到底变成了多少、分支到底走了哪边——这些信息,LED一个字都说不出来。
串口调试的原理不复杂:MCU通过TX引脚把数据一位一位地按约定速率发出去,电脑这边用串口工具按同样的速率接收并显示成字符。这个速率就是波特率。我用的波特率是115200,双方约定一致,数据才能对得上。
学习和调试的过程中,我在关键位置都加了打印语句。比如进入外部中断时打印状态提示,PWM挡位改变时打印当前亮度百分比。程序跑起来之后,我是真真切切“看见”程序的行为,而不是靠猜。这一步让调试效率和之前相比完全不是一个量级。
3. 实操全过程:如何把三件套拼成一个完整Demo
3.1 先列功能清单再写代码
动手敲代码之前,我先把整个程序要做什么列成了一个清单,这也是我踩了几次坑之后养成的习惯:
- 系统时钟初始化;
- 配置LED引脚为定时器PWM输出模式;
- 配置两个按键引脚为外部中断输入,下降沿触发;
- 配置串口,波特率115200;
- 主循环中根据中断里设置的事件标志,执行对应操作;
- LED默认状态为常亮50%占空比;
- 按键1:切换常亮/呼吸灯模式;
- 按键2:依次循环10%、30%、60%、100%四挡亮度;
- 每次模式或亮度变化时,串口打印一行状态。
这份清单的作用是让我明确知道每个函数该干什么,也让“哪里没写”一目了然。写代码最怕的是“想到哪写到哪”,最后整个文件乱成一团,连自己都要找半天。清单就是定海神针。
3.2 代码结构的组织和关键变量的设计
这一天我采用的代码结构很简单但有效。头文件部分放宏定义和全局声明,然后是一个初始化总入口,三个子模块各自的初始化函数,一个主循环,中断服务函数单独放。下面这段是初始化部分的简化代码,我做了一些注释,方便刚接触的人理解:
#define BUTTON1_IRQ_NUM 10 // 示意编号,实际以芯片为准 #define BUTTON2_IRQ_NUM 11 // 示意编号,实际以芯片为准 uint8_t g_led_mode = 0; // 0=常亮,1=呼吸灯 uint8_t g_brightness_level = 1; // 亮度挡位索引 uint16_t g_pwm_compare = 500; // 默认50%占空比 volatile uint8_t g_event_flags = 0; static void TimerPWM_Init(void); static void ButtonExti_Init(void); static void Uart_Init(void); static void TimerPWM_Init(void) { // 打开定时器时钟 // 配置预分频719,自动重装载999,通道比较值500 // 配置LED引脚复用功能 // 启动定时器 } static void ButtonExti_Init(void) { // 配置按键引脚为输入模式并启用内部上拉 // 配置下降沿触发外部中断 // 使能对应NVIC中断通道 } static void Uart_Init(void) { // 配置TX/RX引脚复用 // 设置波特率115200 // 使能发送和接收 } void System_Init(void) { TimerPWM_Init(); ButtonExti_Init(); Uart_Init(); } int main(void) { System_Init(); Uart_SendString("System started\r\n"); while (1) { if (g_event_flags & 0x01) { g_event_flags &= ~0x01; SwitchLedMode(); } if (g_event_flags & 0x02) { g_event_flags &= ~0x02; ChangeBrightness(); } if (g_led_mode == 1) { Breathing_Update(); } } }这里有几个设计上的细节,我觉得值得展开说。
第一,g_event_flags被声明成了volatile,这个很关键。因为变量在中断里被修改、在主循环里被读取,编译器如果不知道外部会改变它,可能会把变量优化掉,导致你明明按了按键,程序却感觉不到变化。任何跨越中断和主循环边界共享的变量,都要加volatile,这是嵌入式开发的基本功。
第二,主循环里不是直接调用处理函数,而是先读标志、再清除标志、再执行动作。为什么顺序不能反过来?因为如果在执行处理函数的过程中,另一个中断又来了,标志位会再次置起来。先清标志再处理,就能保证这一轮的事件不会丢失;处理完如果标志又置上了,下一轮循环自然会再处理一次。这个“标志位”模式是中断和主循环通信的最基础方式,后面写更复杂的项目还会反复用到。
第三,处理函数本身放在了主循环里而不是中断里,这意味着按键的消抖延时、串口打印这种相对耗时的工作,不会阻塞其他中断。前面提到的“中断里不放耗时操作”原则,在这里通过工程结构直接落地了。
3.3 呼吸灯的渐变逻辑与节奏计算
呼吸灯的效果是LED亮度从最暗渐变到最亮,再渐变回来,往复循环。用PWM描述,就是比较值从0慢慢增到999,再从999慢慢减到0,每一步之间加一点延时。
这里有一个关键的节奏问题:每一步增量是10、延时是8毫秒,总周期就是多少?我们来算一下:比较值从0到990,需要99步;从990回到10,又是99步;一来一回大约198步。198乘以8毫秒,约等于1.58秒。也就是说,LED完成一个完整的“呼吸”周期大约1.6秒,眼睛看着很舒服。如果你把延时改成50毫秒,周期就变成接近10秒,那种缓慢的呼吸感反而显得拖沓。所以参数的数值不是随便拍的,每个数背后都可以用计算说清楚。
下面是呼吸灯部分的代码逻辑:
void Breathing_Update(void) { static uint8_t direction = 1; if (direction) { g_pwm_compare += 10; if (g_pwm_compare >= 990) { g_pwm_compare = 990; direction = 0; } } else { g_pwm_compare -= 10; if (g_pwm_compare <= 10) { g_pwm_compare = 10; direction = 1; } } TimerPWM_SetCompare(g_pwm_compare); SoftwareDelayMs(8); }这个函数放在主循环里调用,每次循环把占空比微调一次。但要注意,它不能阻塞太久,否则串口打印和按键响应都会被拖慢。8毫秒延时对主循环来说完全可接受,但如果延时变成800毫秒,按键响应就会迟钝到令人抓狂。所以我一般把呼吸步进延时控制在10毫秒以内,宁可让渐变步长小一点、刷新勤一点,也别让主循环卡顿。
3.4 中断服务函数里到底该放什么
这是我学嵌入式以来被反复教育、也是这次实践最纠结的一点:中断服务函数要“短小精悍”,不能在里面做耗时操作。
什么叫耗时操作?延时、串口打印整串字符串、跑一个上千次的循环,这些都算。因为中断服务函数正在执行的时候,同优先级或者更低优先级的中断是进不来的。你在里面磨蹭100毫秒,外边再有十个按键事件,全都要排队等着,表现就是“按键反应特别慢”甚至“感觉失灵了”。
所以我的中断处理策略是:中断服务函数里只是置一个标志位,真正的逻辑全部放到主循环里处理。
void EXTI_IRQHandler1(void) { if (CheckButton1Level()) { // 简单电平确认 g_event_flags |= 0x01; } } void EXTI_IRQHandler2(void) { if (CheckButton2Level()) { g_event_flags |= 0x02; } }置标志位这个动作非常快,几个CPU周期就完成了。主循环看到标志之后,再去执行模式切换、亮度调整、串口打印这些事情。这样即使多个中断连续发生,也不会互相阻塞太久。这个设计模式值得反复体会,因为它几乎是所有嵌入式程序的基础架构。
3.5 NVIC优先级:一次错误配置的教训
组合三件套的当天,我遇到一个很诡异的现象:呼吸灯模式下按键响应偶尔失灵,LED亮灭节奏偶尔会卡顿一下。排查到最后,问题出在中断优先级的配置上。
NVIC的全称是嵌套向量中断控制器,负责给每个中断安排优先级。如果定时器中断的优先级比按键外部中断高,而且定时器中断发生得特别频繁,那么按键中断可能在它到来时被挤压,每次都要等定时器中断处理完才能响应。当定时器中断处理时间较长时,按键响应就会表现出不稳定的延迟。
解决办法是把外部中断的优先级设置得比定时器中断更高。可以这样理解:按键是用户主动操作,优先级应该最高;定时器是周期性触发,稍微晚一点也没关系。这里的原则是“越是需要即时响应的中断,优先级越高”。优先级配错之后,现象的隐蔽性很强,因为程序大部分时间看起来是正常的,只有在特定时序下才出错。排查这种问题没有捷径,只能把各个中断的触发频率和优先级列一张表,逐个检查。
4. 实际遇到的坑与排查记录
4.1 按键抖动带来的“跳挡”问题
这是我第一个撞上的问题。刚开始测试的时候,按一次按键,亮度挡位直接从10%跳到了60%,中间隔着30%。我当时的第一反应是代码逻辑写错了,仔细看了一遍发现处理函数本身没有问题。
后来用逻辑分析仪抓了按键引脚的电平波形,真相才浮出水面:按下一次,引脚电平在一两毫秒内弹跳了十几次,每次跳变都触发了一次外部中断,所以程序被连续执行了多次变色操作。软件消抖加上之后,情况立刻就好了。我用的消抖办法是在中断里先读电平确认状态,然后延时十几毫秒再读一次,两次都稳定才算有效。但前面也说了,消抖延时别在中断里待太久。如果你发现中断里做消抖太挤,更稳妥的方案是把按键扫描挪到定时器中断里,每5毫秒扫一次,连续三次采样一致才判定为有效按键。
4.2 串口输出乱码的排查思路
调试过程中另一个高频问题是串口打印出来全是乱码。排查思路其实不复杂,先把波特率这个变量排除掉。我一开始用的是9600,后来又改成115200,乱码依旧。后来发现根本不是波特率的问题,而是初始化串口的时候,引脚的复用功能配置漏写了一行,导致TX引脚没有正确连接到串口的输出信号上。
这种问题用肉眼看代码很难发现,但用逻辑分析仪抓一下引脚波形,几秒钟就能定位:如果波形完全不对或者根本没有波形,那基本就是引脚配置的问题,而不是波特率的问题。排查的顺序也很重要,我的习惯是:先看引脚波形对不对,再看波特率设置对不对,最后才怀疑代码逻辑。
4.3 中断里写延时的后果有多严重
有一次我图省事,直接把消抖延时写在了外部中断服务函数里。结果现象是:快速连按按键的时候,偶尔会丢事件;呼吸灯模式下,LED的渐变节奏还会出现肉眼可见的卡顿。
原因我在前面已经讲过了——中断服务函数长时间占用CPU,其他中断被堵住了。虽然当时“看起来”项目还能跑,但这种潜在问题一旦遇到更复杂的项目,就会变成灾难。从那天起,我给自己定了一条铁律:中断服务函数里只做三类事——置标志位、清标志位、拷贝极少量数据,其余一律放到主循环。这条铁律后来帮我避免了无数次“莫名其妙”的故障。
4.4 用逻辑分析仪而不是“看现象”去猜
自学阶段最忌讳的就是“看现象猜原因”。LED没反应,可能是GPIO没配置对,可能是定时器没启动,可能是中断没触发,也可能是代码压根没编译进去。而逻辑分析仪可以直接看到引脚上的实际电平变化,让一切猜测都变得多余。
我用的是一个几十块钱的逻辑分析仪,配合开源上位机软件,能同时抓多路信号。这一天我抓了三路:一路是按键引脚,看弹跳波形;一路是PWM输出引脚,看频率和占空比;一路是串口TX引脚,看数据波形是否正确。三路信号一对比,问题定位的速度快了好几倍。
我把这一天遇到的典型问题整理成了一张表,方便对照:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 按键一次触发多次切换 | 按键抖动未处理 | 用逻辑分析仪抓引脚波形,加软件消抖 |
| LED亮度没有变化 | PWM比较值未更新 | 检查通道比较值写入函数是否生效 |
| 串口输出乱码 | 波特率不一致或引脚复用未配置 | 先看TX波形,再核对波特率设置 |
| 快速按按键丢事件 | 中断里耗时过长或标志位被覆盖 | 把业务逻辑移出中断,只用标志位通信 |
| 呼吸灯卡顿 | 主循环被长时间阻塞 | 检查循环里是否有大延时或死循环 |
5. 第二十三天的复盘与下一步计划
5.1 这天的关键经验清单
这一天的收获不在具体外设的配置,而在“系统思维”。单独学定时器的PWM,单独学外部中断,单独学串口,每个都只需要半小时。但把它们组合起来,要操心的事情成倍增加:中断优先级、共享变量的保护、主循环的阻塞时间、消抖策略的选择……这些才是嵌入式开发和点灯实验之间的分水岭。
我把第二十三天的几条关键经验列在这里,提醒以后的自己,也希望能帮到同路的你:
- 写代码前先列功能清单和验收标准,不要上来就敲;
- 中断服务函数里只置标志位,业务逻辑放主循环;
- 所有跨中断和主循环共享的变量,一律加 volatile;
- 出现诡异现象时先用逻辑分析仪看引脚,再回去看代码;
- 每个寄存器参数都要能算出来,不拍脑袋填数;
- 每完成一个阶段性Demo,用Git建个版本记录,方便复盘。
5.2 关于连续记录第二十三天的体会
最后说点个人感触。二十三天并不算一个特别长的时间,但连续记录本身就是一种督促。把每天踩的坑写下来,逼自己把“大概是这么回事”变成“我确切知道是怎么回事”,这个过程比代码本身更有价值。
我见过不少人学嵌入式,前一周热情高涨,第二周就开始迷茫,第三周就换方向了。能坚持到第二十三天,靠的不是天赋,而是把目标切成小块,每天只解决一个具体问题。第二十三天解决了“三件套协同”,这比自己想象中更让人踏实。如果你也处在类似的阶段,我特别建议你也试一试“目标清单+验收标准”的方式,你会发现每天的学习不再是一盘散沙。
下一步我打算尝试传感器数据采集和简单的状态机设计,把按键、显示、传感器数据整合成一个小系统。到时候如果又踩了什么新坑,再回来接着写。