刚开始玩STM32的时候,我也是一个外设一个外设点来点去:LED亮一下,串口吐几个字节,定时器翻个中断,然后就没有然后了。直到我决定用嵌入式C++重新整理手头这块板子,才意识到“能亮”和“能干活”之间差着一大截工程债。这个系列写到第6篇,前5篇已经把GPIO、串口、定时器、超声波测距、ILI9341 LCD这些硬骨头啃了一轮,数字能上屏了,超声波也有回响了,看起来还挺像回事。但你自己心里清楚:这些东西全是一堆全局函数和宏定义拼起来的,加一个功能就要动一片代码,换一块屏就要重新接线改底层。正好有朋友在评论区追问CAN总线“突然连不上”怎么排查,也有人问STM32怎么做USB设备,这一篇咱们就集中还债——把定时器输入捕获做成正经C++类、把LCD驱动从C函数堆改成面向对象封装、把CAN掉线问题按寄存器一层层挖穿,再给你指一条USB设备曲线救国的最短路径:CDC虚拟串口。
1. 回顾与拆解:第6篇到底还差哪些活
1.1 前5篇完成的地基
先把前面攒下的家底盘一下。整个项目是基于STM32F407,搭配HAL库,开发环境从Keil搬到了VS Code + CMake + arm-none-eabi-gcc,调试用ST-Link。前几篇依次完成了:
| 模块 | 状态 | 遗留问题 |
|---|---|---|
| GPIO按键 + 点灯 | 能用 | 中断回调里没有去抖逻辑,误触明显 |
| UART串口调试 | 能用 | 只做了查询发送,接收还是轮询 |
| 定时器延时/计数 | 能用 | 一直没有做输入捕获,测不了频率 |
| 超声波HC-SR04 | 能用 | 测距代码分散在main.c,复用困难 |
| ILI9341 LCD | 能显示 | 一堆Draw_Line/Draw_Rect全局函数,换屏就得改代码 |
第6篇的标题是“咱们还差活滴”,意思很直接:这么多模块虽然各自能跑,但它们之间是割裂的,没有形成可复用的组件层。这一篇我给自己列了四个任务,也当作你的作业清单:
- 用C++封装一个定时器输入捕获类,把测频率这种高频场景做成通用模块;
- 重写LCD驱动,把显示相关的操作收敛到ILI9341类里,同时把触摸屏接入;
- CAN通信跑着跑着就失去响应,需要从寄存器到中断优先级彻底排查;
- USB设备从CDC虚拟串口起步,打通STM32与电脑的双向通道。
这四个任务看起来彼此独立,实际上有一个共同主题:从“功能裸奔”走向“结构可控”。嵌入式C++的价值在这里才真正体现出来——类封装让模块边界清晰,调试时不用靠一个全局变量猜状态。
1.2 为什么用C++而不是继续C
先回答一个几乎每篇都会被问的问题:嵌入式C++到底有什么必要?我的理由是工程规模到了某个临界点后,全局函数和松散的数据结构会让维护成本爆炸。
举个例子:超声波测距和定时器捕获测频率,本质都是“等一个边沿信号,然后记录时间”。如果用C写,很容易在两处各自维护几个全局变量:capture_ok_flag、capture_value、capture_overflow,然后中断回调里再分发给不同变量。一旦同时接两个传感器,变量名就会变成capture_ok_flag_1、capture_ok_flag_2,改起来想骂人。
用C++做类封装后,一个FreqMeter实例管一个通道,两个传感器就是两个独立对象,中断回调只负责把事件转发给对应实例。这就是为什么我坚持在嵌入式里用C++——不是为了面向对象而面向对象,而是为了让“一个外设一个实例”的直觉映射到代码里。
2. 定时器输入捕获:把频率测准的C++封装
2.1 输入捕获原理与硬件配置
输入捕获是STM32定时器的拿手好戏。它做的事情是:当外部信号出现指定的边沿时,硬件会自动把当前计数器的值存入捕获寄存器,并触发中断或DMA请求。我们只需要记录两次相邻上升沿的计数值,算出差值,就可以得到信号周期,频率就是周期的倒数。
我在F407上用的是TIM3_CH1,对应的引脚是PB4,信号发生器输出方波直接接过来。CubeMX里这样配置:
- Timer3,通道1选择Input Capture direct mode;
- 预分频器PSC设为84,这样计数频率等于APB1定时器时钟84MHz / 84 = 1MHz,计数器每1微秒加1;
- 计数器周期ARR设为0xFFFF,低于1kHz的信号会溢出,需要额外处理;
- 上升沿触发,中断使能。
1MHz计数频率的精度对常见测频场景足够用了。如果你要测几十MHz的高频信号,需要用定时器外部时钟模式或者硬件无源分频,那是另一个话题。
2.2 FreqMeter类的设计与实现
类设计的目标是让用户只关心三件事:绑定定时器、启动测量、读取频率。底层捕获细节全部隐藏。
#pragma once #include "stm32f4xx_hal.h" class FreqMeter { public: void Attach(TIM_HandleTypeDef* htim, uint32_t channel); void Start(); void Stop(); uint32_t GetFrequencyHz() const; void onCaptureEvent(); // 在HAL捕获回调中调用 private: TIM_HandleTypeDef* htim_{nullptr}; uint32_t channel_{0}; uint32_t last_cnt_{0}; uint32_t period_us_{0}; uint32_t overflow_cnt_{0}; uint32_t last_freq_{0}; bool first_edge_{true}; };onCaptureEvent是关键,它处理两次边沿之间的差值,并计入溢出次数:
#include "FreqMeter.hpp" void FreqMeter::Attach(TIM_HandleTypeDef* htim, uint32_t channel) { htim_ = htim; channel_ = channel; } void FreqMeter::Start() { __HAL_TIM_SET_COUNTER(htim_, 0); last_cnt_ = 0; first_edge_ = true; HAL_TIM_IC_Start_IT(htim_, channel_); } uint32_t FreqMeter::GetFrequencyHz() const { return last_freq_; } void FreqMeter::onCaptureEvent() { uint32_t cnt = HAL_TIM_ReadCapturedValue(htim_, channel_); if (first_edge_) { last_cnt_ = cnt; overflow_cnt_ = 0; first_edge_ = false; return; } // 需要考虑计数器溢出,溢出一次需要加上 ARR + 1 uint32_t period = cnt - last_cnt_; if (cnt < last_cnt_) { period += 0x10000; } period += overflow_cnt_ * 0x10000; if (period > 0) { last_freq_ = 1000000UL / period; // 计数频率1MHz,单位Hz } last_cnt_ = cnt; overflow_cnt_ = 0; }在中断回调里只需要转发事件,不要做打印或者耗时操作:
extern FreqMeter g_freqMeter; void HAL_TIM_IC_CaptureCallback(TIM_HandleTypeDef* htim) { if (htim->Instance == TIM3) { g_freqMeter.onCaptureEvent(); } }注意一点:如果被测频率很低,两次上升沿之间定时器会溢出多次。上面的代码需要配合定时器更新中断统计溢出次数,我为了篇幅简化了,实际项目里要在HAL_TIM_PeriodElapsedCallback里对overflow_cnt_做累加。低频测量是输入捕获最容易翻车的地方,建议统一处理。
2.3 实测校准与经验
我用信号发生器分别输出了1kHz、10kHz、100kHz方波,测出来的结果如下:
| 设定频率 | 实测频率 | 误差 |
|---|---|---|
| 1 kHz | 999 Hz | -0.1% |
| 10 kHz | 9999 Hz | -0.01% |
| 100 kHz | 100005 Hz | +0.005% |
高频段误差主要来自系统主频的偏移和捕获瞬间的延迟,这个精度对大多数电机转速检测足够了。如果你需要更高精度,建议用定时器的主从定时器机制产生时基,或者用DMA搬移捕获数据,避免中断响应抖动。
实操中我踩过的坑有三个:
- 捕获通道的中断优先级一定要比其他长耗时外设高。我之前把串口中断设为比捕获更高优先级,结果一帧串口数据发过来,捕获中断被长时间抢占,测频结果周期性跳变。
- 不要在
onCaptureEvent里直接调用回调函数处理业务。中断里只更新数据,让主循环轮询GetFrequencyHz()做后续动作。 - 开始测频前,如果信号一直保持低电平或高电平,中断永远不来,
last_freq_会停留在旧值。业务代码需要做超时判断,比如500毫秒没有新捕获就上报0。
3. ILI9341显示驱动:从裸机函数到面向对象的封装
3.1 为什么值得重写一套显示驱动
前几篇的LCD驱动是典型C语言写法:LCD_Init()、LCD_DrawLine()、LCD_ShowNum(),参数里到处传lcd_dev这种全局结构体,函数之间互相依赖,注释只能靠记忆力。有一次我想把横屏改成竖屏,结果要翻遍五个源文件去改坐标宏。
用C++封装的思路完全不同。初始化、画点、画线、填色、显示字符串,这些操作都收进ILI9341类里。换引脚、换SPI接口,只需要改底层sendCommand和sendData两个私有函数,上层业务代码一行不用动。这其实就是简单版的“驱动接口隔离”——在单片机上也成立。
3.2 ILI9341类与图形接口
核心接口设计得尽量小,因为功能越多越容易变成大杂烩:
#pragma once #include <cstdint> class ILI9341 { public: void Init(); void DrawPixel(uint16_t x, uint16_t y, uint16_t color); void FillRect(uint16_t x, uint16_t y, uint16_t w, uint16_t h, uint16_t color); void DrawString(uint16_t x, uint16_t y, const char* str, uint16_t fg, uint16_t bg); void SetRotation(uint8_t rotation); // 0,1,2,3对应横竖屏 private: void SendCommand(uint8_t cmd); void SendData(uint8_t* data, uint16_t len); void Reset(); };初始化序列是所有LCD驱动里最繁琐的部分。ILI9341需要配置电源控制、帧率、伽马曲线、像素格式等寄存器。我通常直接把官方初始化序列整理成一个常量数组:
static const uint16_t init_cmds[] = { // 每两条一组:第一条是命令, 第二条是带参数长度, 后面是参数 0xCF, 3, 0x00, 0xC1, 0x30, // ... 省略部分 0x29, 0, // Display On };Reset()函数里要注意一个细节:拉低RST引脚的时间必须大于10毫秒,有些屏对复位时序不敏感,而有些屏如果复位时间不够,初始化后会花屏。我习惯在复位后额外加一个50毫秒延时。
3.3 触摸接入与菜单交互
LCD模块上通常还有一块电阻触摸屏,典型控制器是XPT2046,通过SPI读取坐标。触摸扫描不需要常开,可以在主循环里每20毫秒读一次。
先把触摸做成一个独立的TouchDriver类,它和ILI9341松耦合:
class TouchDriver { public: void Init(); bool ReadPoint(uint16_t& x, uint16_t& y); // 返回是否有触摸 void Calibrate(); };然后实现一个极简的按钮检测逻辑。画几个虚拟按钮,存好矩形区域,每次读到触摸点后判断点落在哪个区域,就触发对应动作:
struct Rect { uint16_t x1, y1, x2, y2; }; struct Button { Rect area; void (*onClick)(void); }; Button menuButtons[3] = { {{20, 50, 100, 90}, &ShowDistance}, {{20, 110, 100, 150}, &ShowFrequency}, {{20, 170, 100, 210}, &ShowCANStatus}, };主循环里做一次坐标映射:
uint16_t tx = 0, ty = 0; if (touch.ReadPoint(tx, ty)) { for (int i = 0; i < 3; i++) { if (tx >= menuButtons[i].area.x1 && tx <= menuButtons[i].area.x2 && ty >= menuButtons[i].area.y1 && ty <= menuButtons[i].area.y2) { menuButtons[i].onClick(); } } }这种事件轮询方式在MCU上非常实用,简单可靠,也不引入复杂框架。
3.4 读ID读到0xA1A1的坑
很多人在第一次驱动ILI9341时会遇到一个经典现象:执行读ID指令0xD3返回0xA1A1,而不是期望的0x93或0x94。我看到热词里有人也在搜这个问题,原因一般有三种:
- MISO引脚没接对或复用模式错误。读ID需要SPI全双工,如果板子上的MISO线松了,读回来的就是默认电平,容易凑出0xA1A1这种值。
- 读时序里忘了发送第三个字节。ILI9341读ID并不是发一条
0xD3命令就完事,后续还要继续发3个空字节才能把ID完整移出来,只读一次8位数据会拿到偏移不对的字节。 - 复位时序问题。初始化之前在SPI模式稳定前就执行读操作,控制器内部状态还没就绪。
如果读ID出错,不要急着怀疑屏幕坏了,先拿示波器看SPI的MISO波形,看每个字节的位是否完整。读ID只是一个自检手段,实在读不对但显示正常,也可以跳过这步直接初始化。
4. CAN通信掉线排查:别急着改代码,先看总线状态
4.1 现象与第一反应
很多人在调试CAN总线时都遇到过这种诡异情况:程序刚烧进去能通信,收发都正常,跑了一会儿甚至几小时后,上位机突然收不到数据了,复位板子又恢复正常。热词里“stm32 can通信突然连不上”搜索量不低,说明这不是个别现象。
遇到这种问题,第一反应千万不要是“我改一下波特率看看”或者“把重试次数调大”。总线通信是一个协作系统,掉线大概率有两类原因:一是板上配置有隐藏错误,二是总线上有其他节点干扰。盲目改代码只会让问题更难复现。
正确做法是先把当前的状态读出来,再做决定。
4.2 寄存器诊断步骤
CAN外设自带丰富的错误状态寄存器,HAL库也把一部分暴露在hcan->ErrorCode里,但更底层的寄存器才是有价值的第一手信息。
我在排查时习惯按这个顺序来:
- 读取
CAN_ESR(错误状态寄存器),看EWGF、EPVF、BOFF这些标志位。如果BOFF为1,说明控制器已经进入Bus Off状态,收发全部停止,表现就是“突然连不上”。 - 读取
CAN_TSR(发送状态寄存器)和CAN_RFR(接收FIFO寄存器),确认数据是压根没发出去,还是发出去了但接收方没收到。 - 检查波特率计算是否和总线上其他节点完全一致。很多掉线问题源于主频改了或APB1分频改了,导致CAN外设时钟变化,波特率偏了。
波特率计算的公式不太复杂,但特别容易错:
位时间 = (1 / SCLK) × (同步段 + 传播段 + 相位段1 + 相位段2)
HAL库配置的是Prescaler、TimeSeg1、TimeSeg2和TimeQuanta。假设APB1外设时钟是42MHz,如果要得到500kbps,每个位时间应该是84个时钟周期。可以这样配:Prescaler=2,也就是CAN时钟为21MHz,然后分到1个同步段 + 13个TimeSeg1 + 6个TimeSeg2,共20个时间量子,20 × 2 / 42000000 ≈ 0.95微秒,算出来差不多500kbps。手算一遍确认无误,再上总线测试。
4.3 Bus Off恢复策略
如果确认控制器进了Bus Off,软件上要做的是主动恢复,而不是等硬件自动复位。HAL库里的处理流程是重新初始化CAN并启动:
extern CAN_HandleTypeDef hcan; void CAN_PerformRecovery(CAN_HandleTypeDef* hcan) { if (hcan->Instance->ESR & CAN_ESR_BOFF) { // 请求退出初始化模式,回到正常模式 CLEAR_BIT(hcan->Instance->MCR, CAN_MCR_INRQ); while (hcan->Instance->MSR & CAN_MSR_INAK) { // 等待进入正常模式 } // 重新启动CAN HAL_CAN_Start(hcan); // 重新使能接收中断 HAL_CAN_ActivateNotification(hcan, CAN_IT_RX_FIFO0_MSG_PENDING); } }这里的要点是:CAN控制器一旦进入Bus Off,必须由软件恢复,而且恢复后要重新使能中断和通知,否则即使总线恢复了,也不会再进接收中断。
恢复策略可以放在一个定时中断里周期检查,比如每100毫秒读一次ESR,有Bus Off就执行恢复。但是这种“看门狗式”的处理只是兜底,根本原因还得继续查。
4.4 中断优先级和屏蔽中断的坑
另一个很容易被忽略的坑是中断优先级嵌套。CAN接收中断如果设的优先级太低,而其他外设中断(比如串口、定时器)处理时间又长,CAN FIFO里的数据就可能溢出。数据一溢,控制器会进入错误状态,错误计数逐渐累加,最后变成Bus Off。
我调试过一块板子,现象是每次上位机连续下发几十条CAN帧时板子就会掉线,单独发一两条永远没事。最后定位到是一段LCD刷新函数在定时器中断里执行,耗时1.2毫秒,而CAN接收中断优先级比它低。在那1.2毫秒里来一个CAN帧队列,FIFO塞不下就丢帧,错误计数一路飙升。
解决办法很简单:
- CAN接收中断优先级提到最高或次高;
- 中断服务函数里只做数据搬运,把CAN帧复制到环形缓冲区,解析放主循环;
- LCD刷新逻辑移出定时器中断。
另外要检查总线上有没有接终端电阻。CAN总线两端各需要120欧姆终端电阻,如果只有一块板子没接,通信可能短距离正常,稍微远一点或者节点多了就出问题。这个用万用表量一下CAN_L到CAN_H之间的电阻,没有上电应该是60欧姆左右。
5. USB设备先从CDC虚拟串口开始
5.1 为什么选CDC而不是HID
“STM32如何做USB设备”是很多新手进阶必问的问题,我建议从CDC虚拟串口入手。原因很简单:
- CDC的Windows驱动是系统自带的,插上就能识别出COM口,不需要写驱动;
- 调试工具多,串口助手上位机可以直接收发;
- 不用纠结报告描述符和HID协议那一堆复杂的枚举过程;
- 和串口用法几乎一致,迁移成本低。
HID当然也值得学,适合做鼠标、键盘、手柄这类交互设备。但作为第一个USB项目,CDC的成就感来得最直接——你能立刻在电脑上看到设备管理器里多出一个COM口。
5.2 CubeMX配置与关键时钟
我这里以STM32F407的OTG_FS为例。在CubeMX里选择USB_OTG_FS,模式选Device,然后Middleware部分选中USB_DEVICE,Class选择Communication Device Class (Virtual Port Com)。下一步是检查时钟。
USB外设需要48MHz时钟,在F407上通常由PLLQ输出提供。很多人的USB电脑不识别,就是因为CubeMX里把System Clock忘了调成48MHz到USB时钟源,或者配置页里两个48MHz来源打勾不一致。常见的报错是枚举失败,设备管理器里出现Unknown Device。
我习惯在CubeMX的Clock Configuration页面里先确认USB OTG_FS旁边显示的是48.000 MHz,再确认PLLQOUT也指向48MHz。最好把USB Clock Source固定为PLLQ。
设备描述符里还要注意字符串描述符,比如制造商和产品名,建议改成自己看得懂的名字,方便在Windows设备管理器里辨认。如果枚举失败,第一件事就是打开串口打印(用UART1)把HAL_PCD_SetupCallback里的请求过程打印出来,看卡在哪个Setup阶段。
5.3 用户的收发接口实现
CubeMX生成的CDC代码已经提供了回调骨架,我们只需要把自己的逻辑填进去。接收方向,当电脑下发数据时,会触发CDC_Receive_FS:
static int8_t CDC_Receive_FS(uint8_t* Buf, uint32_t *Len) { // 把收到的数据放进自己的环形缓冲区 RingBufferPush(rxBuf, Buf, *Len); // 重新开启接收,否则端点只会收到一次数据 USBD_CDC_SetRxBuffer(&hUsbDeviceFS, Buf); USBD_CDC_ReceivePacket(&hUsbDeviceFS); return (USBD_OK); }发送方向,用CDC_Transmit_FS把数据发到电脑,但它有长度上限,通常最大包长度是64字节。如果数据长于64字节,要么拆分循环发,要么等上一个发送完成再发下一个。很多人第一次用CDC时遇到的坑是:发送太快,上一包还没发完就调下一次CDC_Transmit_FS,结果一部分数据被丢弃。解决办法是检查CDC_Transmit_FS返回值,如果不是USBD_OK就等一会儿再重试。
另一个隐藏坑是堆栈空间。CubeMX生成的USB回调里如果用变长数组或者开较大缓冲区,而工程里默认的堆栈又不够,会造成HardFault。我在MDK里把栈从0x400调到0x1000,在GCC链接脚本里也相应加大,才把问题压下去。USB库本身要消耗不少RAM,移植前先看map文件确认内存余量。
6. 开发环境与调试小细节
6.1 VS Code + CMake 环境搭建要点
想要舒服地写STM32的C++代码,VS Code是个称职的选择。我最常用的是三件套:C/C++扩展、CMake Tools、Cortex-Debug。工程直接用STM32CubeMX生成CMake工具链,CubeMX里选择Toolchain为CMake,然后生成。
CMakeLists.txt里需要加上C++标准设置,否则GCC默认标准太低,结构体初始化、模板这些特性用起来很别扭:
set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)如果工程里有.c和.cpp混编,记得在CMake里把源文件都列全,CubeMX生成的add_executable默认只包含了Core和Drivers下的源文件,新增的cpp文件要手动加进去。还有一个常见报错是“cstdint not found”,多半是工具链的include路径没配对,检查CMAKE_CXX_FLAGS里是否显式指定了C++头文件目录。
6.2 Cortex-Debug配置小抄
用VS Code调试STM32非常直观。给一个最小的launch.json配置:
{ "version": "0.2.0", "configurations": [ { "name": "ST-Link Debug", "type": "cortex-debug", "request": "launch", "servertype": "stlink", "device": "STM32F407VG", "executable": "build/firmware.elf", "svdFile": "STM32F407.svd", "runToEntryPoint": "main" } ] }带上svdFile是重点,它会加载外设寄存器的定义,调试时可以直接看CAN_ESR里每一位到底置没有置位,比靠猜强太多。
调试时我养成了一个习惯:改动代码之前先在Git里提交一次可运行版本。嵌入式调试里“这改了那又没反应”是常态,只有基准版本是干净可回溯的,才有底气去乱试。如果你还没有这个习惯,从第6篇开始建立一个吧,后面写USB、CAN多节点交互时你会感谢它的。
6.3 一次串口与CAN联调的杂项记录
最后分享一个这周实际遇到的杂项问题:系统里面有GPS的串口、调试串口、CAN、USB CDC,四个外设都在跑,发现CAN掉线的频率比单独测试时高。我一度以为是CAN总线问题,后来发现是调试串口的DMA中断和CAN接收中断抢优先级,导致CAN FIFO溢出。
解决思路很朴素:把每个外设的中断服务函数和主循环的任务耗时间写清楚,做一个简单的调度表。中断里只搬运数据,数据处理统一放到主循环的时间片里。很多嵌入式稳定性问题,最后都收敛到这一条经验:中断越短越好,数据越早落地越好。
我个人在实际操作中的体会是,“还差活滴”不只是一句调侃,它描述的是嵌入式项目的常态:每一篇写完,总有几个功能还没精深下去,总有几条总线还在闹脾气。但正是这种永远差一点的循环,逼着你把每个模块都打磨到能独立复用的程度。如果这篇里的定时器捕获、LCD封装、CAN排查和CDC虚拟串口能帮你少踩几个坑,那这一篇就值了。下一步你可以试试把超声波测距模块和CAN总线组合起来,做一个远程距离监控节点,等总线稳定了再接USB CDC做参数下发,你会发现项目突然就开始像样了。