写这篇记录的时候,我刚从串口调试助手的界面里抬起头来。Day 4 和 Day 5 是连在一起过的,主题很明确:先让板子听得懂按键,再让板子和电脑通过串口“聊天”。嵌入式入门走到这个节点,我从“照着例程点灯”变成了“自己查手册配寄存器”,这种转变比想象中更花时间,也更值得写下来。
如果你跟我一样,刚开始接触嵌入式,已经会点灯但还没碰过按键输入和串口通信,那这两天的记录应该对你有用。哪怕你用的是不同型号的板子,底层思路都是通的——GPIO怎么从输出变成输入,中断该不该用、怎么用,UART的波特率是怎么算出来的,数据帧为什么要设计成那个样子。这篇文章我会尽量把每个“为什么”都讲清楚,顺便把这两天踩过的坑也一并倒出来。
1. 两天的内容为什么可以放在一起讲
1.1 一条完整的学习主线
很多人学嵌入式是照着网上的例程一个个点灯、点灯、再点灯,点完就不知道下一步该干嘛。我自己前三天也是这么过来的。直到 Day 4 开始玩按键,我才意识到前面点灯其实只是在“输出”,而按键是“输入”。这两个方向拼在一起,才是一个完整的人机交互闭环:用户按下按键,系统收到信号,再通过 LED 或屏幕反馈结果。
Day 5 的串口通信则是另一个维度的跃迁。它让开发板和电脑之间能传数据,相当于给硬件开了一扇“对外交流”的窗口。调试程序的时候,你用串口打印日志;以后玩传感器,你要把数据读出来看;做复杂一点的项目,板子和板子之间要互相发指令。这些全都要靠串口或者类似串口的通信方式打底。
所以这两天表面上是两个独立实验,实际上是同一条主线上的两个节点:先解决“板子怎么感知外部世界”,再解决“板子怎么把话说出去”。这条主线一旦打通,后面的 I2C、SPI、Wi-Fi 模块、传感器驱动学起来都会顺很多。
1.2 学习路线背后的取舍逻辑
为什么不先学 ADC 模拟采样?为什么不直接上 I2C 驱动 OLED 屏?这是我当时纠结过的问题,后来跟一个做嵌入式的朋友聊完才想明白。
按键输入是 GPIO 输入里最简单、最直观的场景。它只有两个状态,按下和松开,对应的电平变化非常明确,牵涉的知识点却一个都不少:上下拉电阻、电平读取、抖动处理、事件检测。把这些搞懂,GPIO 输入的基本功就扎实了。而 ADC 是从连续变化的模拟信号里读数值,牵扯到参考电压、采样时间、转换精度,新手容易懵。
串口选 UART 而不是 I2C 或 SPI 也有原因。UART 是异步串行通信里最基础的协议,只需要两根线,不需要时钟线,配置起来也最直接。I2C 和 SPI 都要处理时钟时序、设备地址、片选信号,理解成本高不少。而且串口是嵌入式调试的“生命线”,不管是看日志还是以后调 WiFi 模块,都离不开它。先把它吃透,性价比最高。
1.3 开始之前你需要具备什么基础
- 能独立完成“编译—烧录—运行”这个基本循环,会看编译日志里的报错信息。
- 理解 GPIO 输出模式,知道怎么把引脚拉高拉低,知道什么是推挽输出。
- 有最基础的 C 语言能力,至少会看结构体、指针和函数指针,能读懂中断服务函数的写法。
如果你点灯还没点明白,建议先回头把 GPIO 输出的几个模式再摸一摸,尤其是推挽和开漏的区别。我后面踩的不少坑,根子都在对 GPIO 模式理解不够。
2. 动手之前的基础准备
2.1 硬件清单与接线方式
我手头用的是一块非常常见的 Cortex-M 内核开发板,板载一个 USB 转串口芯片,外加两块独立的按键模块,还有几根杜邦线。没有额外买逻辑分析仪之前,全靠板载 LED 和串口助手来观察现象。
接线其实很有讲究。按键模块有三个引脚:VCC、GND、OUT。我第一反应是把 OUT 直接接 GPIO 引脚,VCC 接 3.3V,GND 接 GND,然后读引脚电平。但后来发现,这种接法要求外部按键模块自带上拉或下拉电阻,否则悬空状态下的电平是不确定的。更稳的做法是:按键一端接 GND,另一端直接接 GPIO 引脚,然后靠芯片内部的“上拉电阻”把引脚默认拉高。按下时引脚被拉低,松开时恢复高电平。这样只占一根信号线加一根地线,线路也简单。
串口这边要注意交叉连接:开发板的 TX 接串口模块的 RX,开发板的 RX 接串口模块的 TX,GND 必须要共地。共地这条我一开始忽略了,结果数据时有时无,折腾了快一个小时才找到原因。两个设备各自有独立的电源参考点,不把地线连在一起,电平高低就没了比较基准。
2.2 开发环境里的几个细节
编译工具链在 Day 1 就装好了,这里不再重复。但这两天重新验证了三个容易翻车的细节:
- 烧录器驱动要装对。插上开发板后如果电脑没识别出对应串口,优先检查驱动,而不是怀疑硬件坏了。
- 串口端口号会变。每次重新插拔,Windows 分配的 COM 号可能不一样。我吃过这个亏,写脚本自动打开 COM3,结果设备已经变成 COM5。
- 串口参数必须统一。无论是调试助手还是板子端的串口初始化,波特率、数据位、停止位、校验位四个参数要完全一致,缺一个都对不上话。
2.3 怎么确认环境已经就绪
我在 Day 4 正式开始前做了一个验证动作:写一个最简单的程序,开机后串口每隔一秒输出一行 “Hello UART”,同时板上 LED 以相同频率闪烁。如果串口助手能收到稳定的字符串,且 LED 没有异常闪烁,说明编译、烧录、串口链路、GPIO 输出都没问题。这个验证过程看似简单,但我强烈建议别跳过,否则后边出了问题你可能分不清是代码的问题还是环境的问题。
3. Day 4:按键输入、消抖与状态机
3.1 按键输入要面对的三层问题
按键表面上是一个“按下/松开”的开关,但实际用起来远没有那么简单。
第一层问题是电平读取。你得先把引脚配置成输入模式,并且保证没有按键操作时,引脚的电位是确定的,不能悬空。第二层问题是抖动。机械按键的簧片在闭合和断开瞬间会反复弹跳,造成电平在极短时间内在高低之间来回变化。如果你直接拿这个电平信号去触发逻辑,结果就是按一次按键,程序可能认为你按了十几次。第三层问题是行为识别。同样是一个按键,短按、长按、双击要触发的动作可能完全不同,这就要靠状态机来区分。
有一个类比我一直觉得特别贴切:按键就像一扇门,你以为门只有“开”和“关”两种状态,但推门的过程中门板会晃动。控制门禁的系统不能只看“晃没晃”,要看“门最终停在哪”,还要能区分“轻推一下”和“按住不放”。对应到程序里,就是消抖和状态机要干的事。
3.2 配置输入引脚并读取按键状态
我用的芯片引脚分布和常见开发板略有差异,但思路一致。先把 GPIO 引脚的模式从输出改成输入,并开启内部上拉电阻。下面这段是简化版的初始化代码:
// 以某常见Cortex-M内核芯片的HAL风格伪代码示意 GPIO_InitTypeDef gpio_init = {0}; gpio_init.Pin = GPIO_PIN_A0; // 按键接在A组第0号引脚 gpio_init.Mode = GPIO_MODE_INPUT; // 输入模式 gpio_init.Pull = GPIO_PULLUP; // 内部上拉,默认高电平 HAL_GPIO_Init(GPIOA, &gpio_init);读按键状态的代码更简单:
int key_state = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_A0); // 返回 0 表示按下,返回 1 表示松开为什么按下时读到的是 0?因为按键外部接的是 GND,按下后引脚被拉到地,而内部上拉电阻只能提供微弱的默认高电平,强拉不过地电平。这个知识点当年把我绕晕了很久,记住一句话就行:高电平是“默认值”,低电平才是“动作信号”。很多例程里写if (!key_state)判断按下,原因就在这里。
3.3 消抖:延时法真的不够用
网上最常见的消抖写法是检测到电平变化后,延时 10 到 20 毫秒,再读一次确认。这个写法在练习 Demo 里没毛病,但我要认真提醒一下:它会给你的程序埋下“卡顿”的隐患。
延时法的问题是,在延时的过程里 CPU 什么都干不了。如果主循环里还有 LED 刷新、屏幕显示、数据采集这些任务,按键一按,所有事情都得停 20 毫秒。对于一个大一点的项目,这种写法会直接影响实时性。
更推荐的做法是用定时器做轮询扫描。比如设定一个 5 毫秒的定时器中断,在中断里读取按键电平,把连续几次读到相同电平才判定为有效状态。我实际用的逻辑是:每 5 毫秒采样一次,连续采样 4 次结果一致才更新按键状态。这相当于把 20 毫秒的延时拆成了多次采样,CPU 不用干等,还能天然滤掉短暂毛刺。
#define SAMPLE_COUNT 4 void Timer_5ms_Callback(void) { static uint8_t filter_cnt = 0; static uint8_t last_level = 1; uint8_t cur_level = HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_A0); if (cur_level == last_level) { filter_cnt++; if (filter_cnt >= SAMPLE_COUNT) { key_ready = 1; // 告诉主循环:按键状态有效了 key_level = cur_level; filter_cnt = 0; } } else { filter_cnt = 0; // 电平在跳变,重新计数 last_level = cur_level; } }这中间还有个细节:消抖时间到底取多长,取决于按键本身的机械特性和你按按键的习惯。10 毫秒太短可能滤不掉弹跳,50 毫秒太长会让你觉得按键反应迟钝。我实验下来,15 到 25 毫秒比较平衡。你也可以用逻辑分析仪实际看一下波形,按自己板子的真实抖动宽度来定。
3.4 用状态机区分短按和长按
如果只是检测“按下”这一个动作,上面那段代码就够了。但我 Day 4 的练习目标加了一档:区分短按和长按。这就需要引入状态机。
状态机的核心思想是,把按键过程拆成几个稳定状态,每个状态只关心“什么条件下跳到下一个状态”。我定义的状态很简单:
| 当前状态 | 检测到条件 | 下一状态 | 触发动作 |
|---|---|---|---|
| 空闲 | 按键按下 | 按下确认 | 记录按下时间 |
| 按下确认 | 消抖后仍然按下 | 持续按压 | 启动长按计时 |
| 持续按压 | 松开 | 空闲 | 如果长按时间足够,判定为长按 |
| 按下确认 | 松开 | 空闲 | 判定为短按 |
写成代码,本质就是一句话的事,重点在于不要把整个逻辑堆在 if else 里,而是用state变量记录当前处于哪个阶段,每个阶段单独处理。状态机最大的好处是可扩展。以后要加双击,只需要在“松开后的超时窗口内再检测到按下”这个分支上做文章,不用去改主循环的骨架。
3.5 中断不是万能的
按键能不能用中断?能。但我 Day 4 最开始直接用外部中断,踩了几个跟头之后,反而更倾向于“定时器轮询+状态机”的方式。
先说中断的两个典型坑。第一,中断触发的是边沿,也就是电平变化的那一瞬间。可机械抖动会让这一瞬间反复触发,所以即使你用中断,照样要在中断服务函数里做消抖,中断反而比轮询更“急”。第二,中断服务函数里不能做耗时的事。如果你在中断里写了延时消抖,占用的时间会拖住整个系统,严重的时候连 LED 闪烁都会变卡。
我的建议是:中断服务函数里只做两件事,置标志位、记录事件,剩下的判断和业务逻辑全部丢给主循环。这样做既能让系统及时响应按键,又不会拖累其他实时任务。Day 4 最后我甚至把中断代码删了,改成纯定时器扫描,因为在这个练习场景里,5 毫秒的扫描周期已经足够快,更简单反而更稳。
4. Day 5:UART串口通信,从配置到协议
4.1 串口到底在传什么
如果把串口通信比作寄快递,那过程大概是这样的:你(发送端)先把数据打包成一个信封,在信封上写清楚起始标记、内容长度和结束标记,然后交给邮局(硬件发送电路)。邮局按固定的节奏把信封一个字一个字地投递出去,收件人(接收端)则按照同样的节奏一个字一个字地接收,再按信封上的说明把内容拆出来。这里说的“节奏”就是波特率,“信封格式”就是串口的帧格式。
一帧完整的数据帧通常包含:
- 起始位:一个低电平,告诉接收端“我要开始发数据了”。
- 数据位:通常是 8 位,也就是一个字节。
- 校验位:可选,用于简单的错误检测。
- 停止位:一个或两个高电平,表示这一帧结束。
我用的配置是经典的“115200-8-N-1”,翻译成人话就是:波特率 115200,8 个数据位,无校验,1 个停止位。这个配置是当前最通用的组合,调试助手里默认也是这套。
4.2 波特率是怎么算出来的
波特率这个概念初看很吓人,其实就是一个周期问题。它表示每秒传输多少个位。比如 115200 波特,意思是每秒传 115200 个“位”,一个字节是 10 位左右(起始位+8数据位+停止位),那每秒差不多传 11520 个字节。
但芯片内部怎么产生这个频率?这就要说到波特率寄存器的计算了。串口外设内部有一个专用的分频器,公式大致是:
波特率 = 外设时钟频率 / (分频系数 × 波特率寄存器值)不同的芯片分频系数不一样,有的芯片是 16。假设我的芯片串口外设时钟是 2457600 Hz,想要 115200 波特,那波特率寄存器值 = 2457600 / (16 × 115200) ≈ 13.33。寄存器只能存整数,所以取 13,实际波特率稍微偏一点。这个偏差只要在允许范围内,通信就没问题。
我头一回看到手册里一堆小数计算,第一反应是“这也太麻烦了”。后来发现,大多数厂商的初始化库函数都能直接传波特率参数,库帮你算好寄存器值。但你还是得明白原理,因为如果你外接了一个非常规波特率的设备,比如某些 GPS 模块喜欢 9600 或 57600,你就得能根据公式判断自己的芯片能不能准确生成这个速率。
实际配置串口初始化的简化代码:
UART_HandleTypeDef huart; huart.BaudRate = 115200; huart.WordLength = UART_WORDLENGTH_8B; huart.Parity = UART_PARITY_NONE; huart.StopBits = UART_STOPBITS_1; HAL_UART_Init(&huart);注意初始化之前,必须先保证引脚已经配置成串口复用功能。我第一遍没配引脚复用,结果串口初始化成功但数据发不出去,查了很久才想起来去看引脚模式。像这种“低级错误”,往往是最耗时间的。
4.3 阻塞发送其实很适合新手
串口发送数据有两种基本方式:阻塞发送和中断发送。阻塞发送的意思就是,调用发送函数之后,CPU 就一直等,直到这一帧数据全部发完才返回。
const char *msg = "Hello UART\r\n"; HAL_UART_Transmit(&huart, (uint8_t *)msg, strlen(msg), HAL_MAX_DELAY);这种写法简单直接,适合调试阶段用。但在真实项目里,如果数据量大,或者系统里同时有好几件事要做,阻塞发送会把 CPU 锁住。比如你在更新屏幕显示的同时往串口发一长串日志,屏幕就会明显卡顿。
所以实际项目里我更推荐中断发送:调用发送函数后,CPU 立刻返回去干别的事,发送完成后由中断来通知你。不过中断发送的实现细节更多,新手可以先不用着急,先把阻塞发送用明白,再逐步升级。
4.4 中断接收和环形缓冲区
接收比发送麻烦,因为你不知道数据什么时候会来。如果一直让 CPU 轮询“有没有新数据”,那别的活也不用干了。所以接收的正确姿势是中断:每收到一个字节,硬件触发一次中断,在中断服务函数里把字节取走。
但这里又有个问题:主循环和中断服务函数是异步执行的。如果主循环正在处理上一批数据,新的字节又来了,怎么避免还没处理的数据被覆盖?答案是用环形缓冲区。
环形缓冲区可以理解成一个固定大小的队列,写指针往里面放数据,读指针从中取数据。当写指针走到尾巴时,自动绕回开头继续写。只要读取速度整体跟得上写入速度,缓冲区就不会溢出,数据就是安全的。
#define RX_BUF_SIZE 256 static volatile uint8_t rx_buf[RX_BUF_SIZE]; static volatile uint16_t rx_write = 0; static volatile uint16_t rx_read = 0; void UART_ISR(void) { uint8_t byte = UART->DATA; // 读取硬件寄存器,拿到新字节 rx_buf[rx_write] = byte; rx_write = (rx_write + 1) % RX_BUF_SIZE; }主循环里读取时,只要判断rx_read != rx_write,就说明缓冲区里有新数据。有一点要特别提醒:volatile关键字别漏,否则编译器可能把中断里修改的变量优化出问题,导致主循环永远看不到新数据。这是我当年踩过的一个特别隐蔽的坑。
4.5 自己定义一帧通信协议
串口只是搬运工,它可以帮你把字节从 A 传到 B,但如果你直接发原始字节,接收方要看懂这串字节代表什么,就得靠双方约定的“协议”。
我 Day 5 的业务场景是:电脑通过串口控制开发板上的两个 LED。协议我定义成了这样:
帧头 命令字 数据长度 数据 校验 0xAA 0x55 0x01 1 0x01/0x00 异或校验值命令字 0x01 表示“设置 LED 状态”,接下来一个字节表示哪个 LED,一个字节表示亮灭状态,最后跟一个异或校验值保证传输过程中数据没有出错。
发送和接收都按这个格式来。接收端写了一个简单的状态机解析:没收到帧头时,一个字节一个字节地等;收到帧头后,按长度收数据;数据收齐后,做一次校验。如果校验通过,才执行命令。校验失败就丢弃这一帧,等你下一帧。
这块内容看起来简单,但它其实是嵌入式通信里最核心的模式。以后你玩 RS485、CAN、无线模块,协议设计思路全都是这个套路:帧头做同步,长度做边界,校验做可靠性。你在一个小练习里把思路跑通了,后面遇到什么协议都不会慌。
5. 实测中的踩坑记录
5.1 编译与烧录阶段的坑
Day 4 一开始我就碰了个软钉子。程序编译通过,烧录提示成功,但板上一点反应都没有。按下按键,LED 纹丝不动。
排查思路是这样的:先检查时钟配置,确认系统时钟起来了;再检查 GPIO 时钟有没有开启。这个太基础,容易被忽略。最后发现是初始化代码里把引脚配置成了输出高电平,而按键接的那个引脚在板子上还连了另一路外设,两边打架,导致电平始终被拉高。把引脚从复用状态恢复成纯输入模式,问题立刻解决。
所以烧录成功不等于程序就按你设想的方式跑起来了。嵌入式调试的第一原则:怀疑一切,包括你自己写的初始化顺序。
5.2 按键测试阶段的坑
按键实验里我遇到了三个比较典型的症状:
| 现象 | 可能原因 | 解决方法 |
|---|---|---|
| 按键按下去没反应 | 引脚没配置成输入模式,或读错引脚 | 检查初始化结构体和实际接线是否一致 |
| 按下一次触发多次 | 没有消抖或消抖时间过短 | 加长采样窗口,确认抖动时间 |
| 程序在按键瞬间卡死 | 中断服务函数里写了耗时操作 | 中断只置标志位,主循环处理业务 |
这几个坑相互之间还挺容易搞混的。比如卡死问题,一开始我还以为是死锁或者堆栈溢出,后来用 LED 闪烁做“心跳指示”才发现,原来是中断服务函数里放了延时,导致主循环被拖住。在那之后,我养成了一个习惯:任何中断服务函数里,除了给变量赋值的语句,什么都不放。
5.3 串口调试阶段的坑
Day 5 的串口调试,踩坑记录比 Day 4 还丰富。
乱码是我遇到的第一个问题。明明波特率设的是 115200,收到的却是乱码。仔细一看,是调试助手那边选成了 9600。这种事看着蠢,但真的很容易发生,尤其是你同时开了好几个串口工具的时候。
第二个问题是收不到数据。我把 TX 接 TX、RX 接 RX,连续好几次一发就死等。后来才意识到,串口必须交叉连接:开发板的发送接模块的接收,开发板的接收接模块的发送。这几乎是每个串口新手都会犯的错。
第三个问题是“只能收一次数据”。程序烧进去第一次能收到数据,之后串口就再也没反应。查了半天,发现是中断服务函数里少清了接收中断标志。接收中断标志不清除,硬件就会以为中断已经处理完,后续来的数据全都进不来。
第四个问题比较隐蔽:电脑收到的数据偶发错误。排查到最后发现,开发板供电用的是另一路 USB,和串口模块不共地,电平参考点不一致,导致数据偶尔跳变。接上共地线之后,问题彻底消失。
5.4 两个非常实用的调试技巧
第一个是日志打印法。在关键代码路径上加入串口打印,看程序实际跑到了哪一步。这个技巧门槛低、见效快,是嵌入式调试的基础中的基础。
第二个是观察电平波形。我之前一直靠猜,后来借了一个逻辑分析仪,把按键引脚、串口 TX/RX 引脚都挂上去看波形,按键按下时的抖动毛刺、串口数据帧的高低电平变化全都一目了然。看完之后我就彻底理解了为什么需要消抖、为什么波特率必须一致。如果你有条件,强烈建议备一个逻辑分析仪,几十块钱的入门款就够用了。
6. 常见问题速查与下一步进阶建议
6.1 两天实战的问题速查表
| 问题 | 现象 | 原因 | 解决办法 |
|---|---|---|---|
| 按键无反应 | 按按键 LED 不变 | 引脚配置错误或引脚被复用 | 对照原理图检查引脚初始化 |
| 按键误触发 | 按一次触发多次动作 | 消抖不充分 | 采用定时器采样或加长消抖时间 |
| 中断卡死 | 按键瞬间整个程序卡住 | 中断服务函数里有耗时操作 | 中断只做标志位设置 |
| 串口乱码 | 收到一堆不可读字符 | 波特率不一致 | 统一收发双方串口参数 |
| 串口收不到 | 发送正常但无接收 | TX/RX 没交叉或共地缺失 | 交叉接线,GND 相连 |
| 只能收一次 | 第一次正常后续无数据 | 接收中断标志未清除 | 在中断里及时清除标志 |
| 数据偶发错误 | 大部分正常偶尔出错 | 供电不共地或干扰 | 统一电源地线,缩短杜邦线 |
这张表是我两天的实战浓缩,基本覆盖了新手最容易遇到的 90% 的问题。保存下来,以后遇到类似问题可以先对着查一遍。
6.2 下一步可以做的方向
Day 5 结束的时候,串口已经能稳定收发我自己定义的协议帧了。我接下来计划做的事有三件:
第一,把按键、LED、串口串成一个综合小Demo:串口收到指令后控制 LED,按键按下时通过串口上报事件。这个 Demo 不大,但能把这两天学的所有内容都跑一遍,查漏补缺。第二,开始接触 I2C 和 SPI,目标是驱动一块小屏幕或者读取一个传感器。有了串口的基础,理解它们在时钟、地址、数据位这些概念上会轻松很多。第三,回过来精读芯片参考手册里 GPIO 和 UART 的章节,把寄存器级的配置彻底过一遍,做到不依赖初始化库也能自己写出初始化代码。
以我个人的体会,嵌入式入门最容易踩的坑就是只看不动手,动手了又不看手册。这两天的经历让我想明白一个道理:串口协议里的每一帧、按键状态机里的每一个状态,都是在逼你把“想法”变成“逻辑”,再把“逻辑”变成“硬件能执行的动作”。这个过程很痛苦,但熬过去之后,再看那些密密麻麻的手册和例程,就会觉得它们不再是一堆看不懂的英文,而是一个个能拆开的零件。动手拆一次,比看十遍教程都管用。