哟哟哟,咱们还差活滴——看到这个标题别笑,这是我写完上一期之后最真实的内心活动。前面几期我们拿着 STM32 和 C++ 把点灯、按键扫描、串口回显这些基础模块都过了一遍,但心里一直不踏实:独立 demo 能跑,不等于能把它们捏成一个完整的小系统。C++ 的封装、回调、状态机这些好东西,还停留在“教程代码”阶段,没有真正在外设之间形成数据流。更别说调试过程中那些耗掉半宿的奇葩问题,不写下来过几天连自己都忘。
所以这一期不干别的,就是把欠下的“活儿”补上。我会拿一个超声波测距 + ILI9341 液晶显示 + USB 虚拟串口上报的小项目,把散件拼成系统,顺带把定时器输入捕获、USB 设备枚举、读屏 ID 这类实战硬坑逐个拆开讲。适合已经玩过 STM32 基础外设、懂一点 C++、想从“点灯侠”往工程化固件方向走一步的人。
1. 还差活?差的是把模块捏成系统的活儿
1.1 为什么独立 Demo 不能叫项目
很多刚入门的同学都会经历这个阶段:点灯 demo 跑通了,按键中断也能进,串口能打印 “Hello”,就觉得嵌入式差不多了。但一旦你尝试把三个模块放在同一个工程里,问题马上冒出来——全局变量满天飞,中断回调里到处改标志位,轮询逻辑和中断处理互相抢资源,改一个模块还要担心把另一个模块的初始化顺序搞坏。
这不是代码写错了,而是缺少“系统边界”。真正的嵌入式项目,第一步不是写功能,而是先划分谁产生数据、谁消费数据、谁负责传输。比如我们这次要做的小系统,数据流是单向的:超声波传感器测出距离,LCD 负责显示,USB 虚拟串口把数据发到 PC。三个模块之间不允许互相调用内部变量,更不能 A 模块直接去操作 B 模块的引脚。这样设计的好处是:任何一个模块出问题,可以单独替换成假数据来测试,不用动其他代码。这种解耦思维,才是“项目”和“demo”之间的分水岭。
1.2 C++ 在单片机上到底该“用到什么程度”
一说嵌入式用 C++,很多人第一反应是:虚函数?STL?异常?模板?那不是分分钟把 Flash 撑爆?我的态度比较务实:在 MCU 上用 C++,重点不是用多高深的语法,而是用对几个能直接提升代码质量的小工具——类封装、命名空间、constexpr。
类封装解决的是“句柄散落”的问题。裸机 C 语言里,你要操作一个定时器,得把 TIM_HandleTypeDef、GPIO_TypeDef、捕获标志位分别传来传去;用类把这些句柄和变量绑在一起,对外只暴露 Trigger()、GetDistanceCm() 这种接口,调用方根本不用关心内部怎么实现。命名空间防止不同驱动库的符号冲突。constexpr 则在编译期把延时系数、距离换算因子算好,运行时不占 CPU。至于异常、RTTI、重载算子这些,我在 Cortex-M 上基本不用——不是不会用,而是没必要:异常在资源紧张的 MCU 上会引入大量额外代码,RTTI 也会让 Flash 体积变大。
打个比方,C++ 在单片机上是螺丝刀,不是多功能瑞士军刀。你只拿最顺手的几件工具就够了,把整把军刀塞进口袋只会增加负担。
1.3 模块划分与通信边界
这次小项目的模块划分特别简单,我先把表拉出来,后面所有代码都围绕这张表来写。
| 模块 | 数据方向 | 核心资源 | 关键接口 |
|---|---|---|---|
| CDistanceSensor | 产生距离数据 | TIM 输入捕获 + HC-SR04 | Trigger(), GetDistanceCm() |
| CTftDisplay | 消费距离数据 | SPI + ILI9341 | ShowDistance(float cm) |
| CCdcUsb | 消费并上传数据 | USB FS + CDC 类 | SendDistance(float cm) |
模块之间只通过返回值传递数据,不共享全局缓冲区。测距模块只管把距离算出来,至于这个距离是显示在屏幕上还是发给上位机,它一概不知。显示模块和 USB 模块也都只负责自己那一摊事。后面凡是遇到数据不对的问题,我只需要检查数据在哪一个边界上“变脏”了,不用把整个工程翻一遍。
2. 核心模块拆解:测距、显示、USB 上报各自怎么设计
2.1 超声波测距:定时器输入捕获的使用才算真正入门
先聊 HC-SR04。这个传感器大家应该不陌生:给 Trig 脚一个至少 10us 的高电平,模块内部会发出 8 个 40kHz 的超声波脉冲,然后把 Echo 脚拉高,高电平持续时间就是超声波从发出到碰到障碍物再返回的时间。距离换算公式很简单:
离(cm)= 时间(us)÷ 58
这个数字怎么来的?声速在空气中大约 340m/s,也就是 0.0343cm/us。Echo 高电平时间对应的是往返两倍距离,所以距离 = 时间 × 0.0343 ÷ 2。把 0.0343 ÷ 2 换成分数,大约就是 1/58.3。比如 Echo 高电平持续 1000us,距离就是 1000 ÷ 58.3 ≈ 17.2cm。
关键是怎么准确测这个高电平时间。很多人用 GPIO 轮询,等 Echo 脚拉高再拉低,循环读引脚电平——不是不行,但在高负载系统里误差很大,CPU 也被占死了。正确做法是用定时器输入捕获:配置一个通道,让它既能捕获上升沿,又能捕获下降沿。第一次捕获上升沿记下计数器值,然后通过代码把捕获极性切换成下降沿;第二次捕获下降沿再记一个计数器值,两者之差除以定时器时钟频率就是脉宽。
我习惯把定时器时钟配成 1MHz,这样计数器每个 tick 正好是 1us,连换算都省了。下面是使用 HAL 库时输入捕获中断回调的封装骨架:
// 输入捕获中断回调,放在 extern "C" 的回调函数里 void ECHO_CaptureCallback(TIM_HandleTypeDef* htim) { if (htim->Instance == TIM2) { uint32_t cnt = __HAL_TIM_GET_COUNTER(htim); if (capture_edge_ == RISING) { start_us_ = cnt; __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_FALLING); capture_edge_ = FALLING; } else { duration_us_ = (cnt - start_us_) & 0xFFFF; // 注意处理16位计数器溢出 __HAL_TIM_SET_CAPTUREPOLARITY(htim, TIM_CHANNEL_1, TIM_INPUTCHANNELPOLARITY_RISING); capture_edge_ = RISING; echo_done_ = true; } } }这里有两个特别容易踩的坑。第一,16位计数器在 1MHz 计数频率下最多数到 65.535ms,HC-SR04 最长 Echo 脉宽大约 38ms,看着够用,但如果你系统里还有其他中断频繁抢优先级,计数器可能在你还没来得及读的时候就溢出了,所以实际项目里要加上溢出中断做扩展计数。第二,Trig 脉冲和 Echo 信号之间有时间差,发完 Trig 后要等一小会儿再打开捕获,否则可能把 Trig 本身的毛刺当成了 Echo。
2.2 定时器捕获测频率的技巧
输入捕获不仅能测脉宽,还能测频率,而且原理完全一样。测频率只需要在同一个通道上连续捕获两次上升沿,两次计数器值之差就是信号的一个周期,频率 = 定时器时钟 ÷ 两次计数差。比如定时器时钟 1MHz,两次上升沿计数差是 1000,那信号频率就是 1000Hz。
这里有个容易被忽视的问题:如果信号频率很低,两次上升沿间隔超过 65535 个计数,16位计数器会溢出,你算出来的频率会大得离谱。解决办法有两种:一是把定时器分频调大,牺牲测量精度来换取量程;二是开溢出中断,把溢出次数也记进去,做 32 位扩展计数。我实战中更喜欢前者,因为对大多数低速信号来说,测量精度到 1us 已经够用了,没必要追求极端。测低速信号还想保持精度,那就直接换 32 位定时器,比如 STM32F4 的 TIM2 和 TIM5,能数到 4.29e9,彻底告别溢出焦虑。
2.3 ILI9341 的 ID 为什么读出 0xA1A1
这个坑我在调试液晶屏时卡了整整一个晚上。现象很典型:用 SPI 去读 ILI9341 的芯片 ID,返回结果不是 0x9341,而是 0xA1A1 这种看起来毫无意义的数字。屏幕能正常显示,但读 ID 就是不对。
先说结论:0xA1A1 绝大多数情况下是“假数据”,而不是真正的芯片 ID。造成假数据的原因,按概率从高到低排:
- 模块的 SDO/MISO 引脚没有接。很多 4 线 SPI 屏幕模块,出场时把 MISO 脚省掉了,只留了 SCL、MOSI、CS、DC、RST。没有数据回传通道,读出来的自然是总线悬空电平,往往就是 0xFF 的某个变体,0xA1A1 这种看起来有规律的假数据,多半就是位线上电平不受控的产物。
- 读时序里没有给足 Dummy 字节。SPI 是全双工协议,主机发时钟的同时,从机在 MOSI 上采样指令、在 MISO 上输出数据。如果你读命令之后没有继续发时钟,MISO 上根本不会有完整的返回数据,后面读到的字节就是乱的。
- 模块实际不是 ILI9341。市面上很多屏幕标称 ILI9341,实际用的是 ST7789、ILI9488 之类的兼容驱动,它们读 ID 的命令和返回值完全不同。这时候你读不到 0x9341 是正常的,按实际型号配驱动就行。
排查方法也很简单:先万用表量一下模块 SDO 引脚有没有焊盘、有没有连到主控。再写一段回环测试,让 MCU 在 SPI 上发一个 0x55,同时看 MISO 能不能读回 0x55。回环都不通,就别纠结读 ID 了,说明你的屏幕根本就是“只写屏”,直接按 ILI9341 的驱动初始化,不要死磕读寄存器。如果回环通了但读 ID 还是怪,才需要去查时序和 Dummy 字节的问题。
2.4 USB 虚拟串口:从“能点灯”到“能被 PC 识别”
串口调试用了这么久,为什么还要做 USB 虚拟串口?原因很现实:不是每块开发板都板载 USB 转串口芯片,而且 USB CDC 的传输速度、稳定性都比普通 UART 强不少。用 CubeMX 勾选 USB_DEVICE 的 Communication Device Class,生成的就是一个虚拟串口,PC 上装好驱动后能直接识别成 COM 口。
USB 这部分最坑的是枚举。STM32 的 USB FS 外设对时钟要求非常高,必须是精确的 48MHz。如果你用的是内部 HSI,PLL 配置不对,USB 挂上 PC 后大概率就是“无法识别的 USB 设备”。所以第一步永远是检查时钟树,确保 PLL 输出 48MHz 给 USB 外设。其次是 DP 上拉电阻问题,大部分核心板上 STM32 的 PA12 已经集成了 1.5k 上拉,但如果你是自己画的板子,漏了这颗电阻,USB 设备永远不能被识别。
代码层面要注意的是,CubeMX 生成的 CDC_Transmit_FS 是 C 函数,你在 C++ 文件里调用之前要包一层 extern “C”,否则链接报找不到符号:
extern "C" { #include "usbd_cdc_if.h" } void SendFloatViaUsb(float value) { char buf[32]; int n = snprintf(buf, sizeof(buf), "%d.%02d\r\n", (int)value, (int)((value - (int)value) * 100)); CDC_Transmit_FS((uint8_t*)buf, n); }顺便提醒一句:嵌入式 MCU 上能不用 snprintf 就不要用,它会把几 KB 的格式化代码拖进 Flash。真要发 float,我还见过有人直接用 union 把 float 拆成 4 字节发,或者干脆发整数和小数两个字段,让上位机自己拼。务实点,省下来的 Flash 干啥不好。
3. 实操过程:把 C++ 类拼成一个能跑的小系统
3.1 工程搭建与代码结构
我先交代环境:我用的是 STM32CubeIDE,CubeMX 生成外设初始化代码,业务代码用手写的 C++ 类。你也可以用 VS Code + CMake + arm-none-eabi-gcc,核心逻辑不变,只是编译配置不同。
CubeMX 里要配置的东西有:TIM2 输入捕获(用来测 Echo 脉宽)、SPI1(接 ILI9341)、USB_DEVICE(CDC)、PA 引脚接 Trig。生成代码之后,把生成的 main.c 改名为 main.cpp,或者在工程里新建一个 app.cpp,把业务逻辑全部放进去,初始化代码保留在 main.c 里。这样 CubeMX 下次重新生成代码时,不会把你写的业务代码覆盖掉。
我习惯把驱动类放在 drivers 目录下,目录结构大致是这样:
core/ inc/main.h src/main.cpp drivers/ distance_sensor/ inc/distance_sensor.h src/distance_sensor.cpp tft_display/ inc/tft_display.h src/tft_display.cpp cdc_log/ inc/cdc_log.h src/cdc_log.cpp每个类都遵守一个约定:构造函数只保存句柄,不做复杂初始化;复杂初始化放到 init() 方法里,返回值是错误码。这么做有个好处,全局对象被构造时不会因为外设时序没准备好而出问题,init() 的调用顺序完全由你控制。
3.2 主循环与状态机
主循环我不用裸的 while + HAL_Delay 顺序执行,而是搞一个非常轻量的状态机。状态机的好处是,在等待 Echo 返回的这段时间里,CPU 不会被阻塞,其他模块照常可以工作。
状态流转很简单:IDLE 空闲 → 触发测距 → 等待 Echo → 计算距离 → 刷新 LCD → USB 上报 → 回到 IDLE。我用一个枚举变量记录当前状态,主循环里只有一个 switch:
while (1) { switch (app_state_) { case ST_IDLE: distance_sensor_.Trigger(); state_tick_ = HAL_GetTick(); app_state_ = ST_WAIT_ECHO; break; case ST_WAIT_ECHO: if (distance_sensor_.IsEchoDone()) { app_state_ = ST_COMPUTE; } else if (HAL_GetTick() - state_tick_ > 100) { app_state_ = ST_TIMEOUT; // 超时保护,防止卡死 } break; case ST_COMPUTE: last_distance_cm_ = distance_sensor_.GetDistanceCm(); app_state_ = ST_DISPLAY_UPDATE; break; case ST_DISPLAY_UPDATE: tft_display_.ShowDistance(last_distance_cm_); app_state_ = ST_USB_SEND; break; case ST_USB_SEND: cdc_log_.SendDistance(last_distance_cm_); HAL_Delay(60); // 两次测量间隔至少60ms,避免余波干扰 app_state_ = ST_IDLE; break; default: app_state_ = ST_IDLE; break; } }状态机看着比顺序执行啰嗦,但维护性好了不止一个量级。以后想加一个“按键切换显示单位”的功能,只需要在 IDLE 和 TRIGGER 之间插一个状态,不用动其他代码。这就是状态机在嵌入式系统里最朴素的价值。
3.3 数据帧设计
USB 上报数据不能直接发“17.20cm”这种字符串了事。PC 端要稳定解析,就得有帧协议。我用的帧格式很简单:
| 字段 | 字节数 | 说明 |
|---|---|---|
| 帧头 | 2 | 0xAA 0x55,用来同步 |
| 长度 | 1 | 有效载荷长度,不含帧头和长度本身 |
| 类型 | 1 | 0x01 表示距离数据 |
| 距离整数 | 2 | 比如 17.20cm,整数部分是 17 |
| 距离小数 | 2 | 百分位部分,这里是 20 |
| 校验和 | 1 | 从长度到小数全部字节累加取低 8 位 |
加上帧头和校验和,一帧是 9 个字节。校验和特别重要,USB CDC 虽然是可靠的,但你的上位机可能带蓝牙转发、可能走网关,中间任何一环都可能丢字节。有了校验和,上位机丢帧、粘帧都能发现,不至于把错误数据显示出来。实测中我用一个简单的累加和就够用了,没必要上 CRC32;那只会浪费 MCU 的算力和代码量。
PC 端解析也很无脑:收字节 → 查 0xAA 0x55 → 读长度 → 收完一帧 → 校验 → 解析。只要协议定了,上位机用 Python、C#、LabVIEW 都能轻松处理。
3.4 编译选项与链接器的坑
C++ 工程在 MCU 上编译,最烦的是 Flash 体积爆炸。我总结下来有三类问题必须处理。
第一,启动文件里有没有调用__libc_init_array。有些老版本的启动文件只调了 SystemInit 就跳 main,C++ 全局对象的构造函数根本不会执行,结果就是所有全局对象的数据都是初始值之前的样子。怎么确认?在构造函数里加一行 volatile 变量赋值,单步调试看有没有走进来。没进来,就在 main 开头手动调用,或者升级启动文件。
第二,C++ 特性开关。如果你的工程不需要异常和 RTTI,编译选项里加上-fno-exceptions -fno-rtti,能省下不少代码。如果用了纯虚函数,链接时会报__cxa_pure_virtual未定义,需要自己补一个空实现,或者干脆别用纯虚类。
第三,裁剪未使用段。GCC 加上-ffunction-sections -fdata-sections,链接时配合-Wl,--gc-sections,可以把没用到的函数和数据全部丢掉。实测这一组参数在 STM32F103C8 这种 64KB Flash 的单片机上特别有用,能省出好几 KB 空间。那些一上来就说 C++ 不适合单片机的人,很多是因为没开这组参数。
4. 常见问题与排查技巧实录
4.1 关键参数速查表
把这次调试涉及到的关键参数统一列出来,方便你以后直接查表,不用再翻数据手册。
| 项目 | 推荐参数 | 原理/说明 | 典型坑 |
|---|---|---|---|
| HC-SR04 Trig 脉冲 | ≥10us | 低于 10us 模块不触发测距 | 用 HAL_Delay(1) 误差大,改用定时器微秒延时 |
| HC-SR04 Echo 最大脉宽 | 约 38ms(对应 6.5m) | 超出后模块不会再拉低电平 | 主循环必须加超时保护 |
| 定时器计数频率 | 1MHz | 每个 tick 正好 1us,省换算 | 16 位计数器可能溢出,需加溢出中断 |
| ILI9341 SPI 时钟 | 2~20MHz | 过高会读回乱码 | 读 ID 时可以用低速时钟先稳定 |
| USB CDC 单次传输 | ≤64 字节 | FS 端点最大包长是 64 字节 | 发长数据要 Split,否则 CDC_Transmit_FS 返回忙 |
这里面最容易被忽视的是 USB CDC 的 64 字节限制。CDC_Transmit_FS 一次最多发 64 字节,你发 100 字节,函数可能返回 USBD_BUSY,数据就丢了。我自己的经验是,自定义一个小缓冲区,把要发的内容拼好,超过 60 字节就分批发送,避免一次性长数据把端点堵死。
4.2 CAN 通信突然连不上:一次典型的嵌入式排查
虽然这次小系统里没有 CAN,但这个问题是嵌入式社区问得最多的热词之一,我之前实际排查过,值得单独写一段。
现象很简单:昨天还通信正常的两个 STM32 节点,今天上电后 CAN 总线突然连不上,发送函数一直报错。我当时的排查步骤,按优先级排序:
第一步,查硬件终端电阻。CAN 总线两端各需要一只 120Ω 终端电阻。如果总线上一端没焊电阻,或者电阻虚焊,信号反射会让收发器误码率飙升。用万用表量总线上两个节点之间的电阻,正常应该在 60Ω 左右(两端 120Ω 并联),如果量出来是 120Ω,说明有一端断开。
第二步,确认波特率。如果某个节点的系统时钟因为改了外部晶振或者 PLL 配置而变了,CAN 外设的波特率也会跟着变,两边不一致就直接表现为发不出去。用示波器看 CAN_H 和 CAN_L 的位时间,量一下每位宽度,反推波特率,对比是不是 500kbps。
第三步,看错误状态寄存器。HAL 库里可以读 CAN 的错误状态,如果节点进入了 Bus-Off 状态,必须先恢复,否则发多少都是白费。恢复方法不是简单地把外设重新初始化,而是要清错误计数器、重新进入正常模式。我当时写了一个简单的恢复逻辑:
// 粗糙但有效的 CAN 总线恢复示例 if (HAL_CAN_GetState(&hcan) == HAL_CAN_STATE_ERROR) { __HAL_CAN_CLEAR_RESET_ERRORDETAILS(&hcan); HAL_CAN_Start(&hcan); } if (HAL_CAN_IsBitErrorPassive(&hcan)) { HAL_CAN_Stop(&hcan); HAL_CAN_Start(&hcan); }但这里要提醒一句:如果总线上只有你一个节点在发,没有其他节点应答,CAN 协议里 ACK 字段永远收不到隐性位,发送节点会一直重试直到 Bus-Off。所以 CAN 调试第一句话永远是:确认总线上至少有两个节点,波特率一致。总线空着就想测试发送,那是和自己过不去。
4.3 打印、断言、被动调试三板斧
嵌入式调试最忌讳的就是“到处加打印,然后肉眼瞪串口”。我个人的三板斧是打印、断言、被动调试。
打印要带时间戳。每次输出前加一个HAL_GetTick(),这样一旦发现系统卡住,你能立刻知道卡在哪个时间点,再跟代码逻辑一对照,问题范围瞬间缩小一半。断言则是在关键路径上做检查。C 片子上没有 PC 端那种异常机制,你只需要一个非常朴素的断言宏:
#define ASSERT(cond) do { \ if (!(cond)) { \ Error_Handler(); \ } \ } while (0)在传感器数据解析、缓冲区写指针移动这些地方加上断言,比打印一百行日志都有效。被动调试更高级一点,指的是不打断程序运行,直接通过外设的状态寄存器、逻辑分析仪的波形来判断问题。比如 SPI 读 ID 读到 0xA1A1,这时候你加一百个打印也不如用逻辑分析仪抓一次 MISO 波形来得直接。波形上有没有数据回传,一眼就看清楚了。
5. 一些我个人的体会
玩 MCU 上 C++ 这几年,我最大的体会就两个字:克制。别把 PC 端那套大规模面向对象设计搬过来,一个项目恨不得抽出十几个虚基类,最后 Flash 爆了、调试晕了,还反过来怪 C++ 不好用。在单片机上做嵌入式 C++,核心是把每个外设做成小而独立的类,用清晰的返回值替代层层嵌套的标志位,用简单的状态机替代混乱的延时和等待,这已经能解决大部分工程可维护性的问题了。
最后再分享一个小技巧:每个类的构造函数都写成“空操作”,不申请内存、不碰外设、不抛异常,把真正的初始化放到 init() 方法里,并且让 init() 返回错误码。这样既避免了全局对象构造顺序问题,又能在初始化失败时明确知道是哪一步出了问题。嵌入式开发的活儿永远干不完,今天把这块补上,明天还会有新的需求冒出来,但没关系,重要的是每一步都踩得实。这期的“活”补到这里,下一期咱们接着造。