ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

STM32嵌入式C++实战:输入捕获、LCD封装、CAN掉线排查与USB CDC

2026/10/4 19:06:47 拓冰建站 浏览量
STM32嵌入式C++实战:输入捕获、LCD封装、CAN掉线排查与USB CDC 刚开始玩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 kHz999 Hz-0.1%10 kHz9999 Hz-0.01%100 kHz100005 Hz0.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个时钟周期。可以这样配Prescaler2也就是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_DEVICEClass选择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_FSstatic 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做参数下发你会发现项目突然就开始像样了。