STM32嵌入式C++实战:USB识别、超声波测距与ILI9341显示

发布时间:2026/10/31 1:07:29
STM32嵌入式C++实战:USB识别、超声波测距与ILI9341显示

STM32和嵌入式C++写到第6期,说实话,前面几期把灯点起来了、串口能吐数据了、中断也能跑了,但真要拿这套代码去做个像样的东西,心里还是发虚——总感觉还差不少活没干完。这篇就是把“还差”的部分补掉:让板子能被电脑认成USB设备、用定时器捕获去测超声波的距离、再把结果画到ILI9341屏幕上。前几篇打下的C++封装基础,这次全部用上,串成一个能独立跑的综合小仪表。适合一路跟下来的朋友,也适合已经会用C写外设驱动、想换成C++思路重新整理一遍工程的嵌入式开发者。

1. 还差几块硬骨头?先把已有的家底盘清楚

1.1 从第1篇到第5篇,手里攒下了一套什么基底

学嵌入式C++最容易犯的错,是上来就画一张很大的架构图,然后写了一堆没人能维护的抽象层。前面几篇我刻意没有这么做,而是一步一步把最基础的东西磨扎实。

第1篇搞定了工具链和最小工程,把main.c改成了main.cpp,让C++编译器真正接管整个项目;第2篇用类把GPIO从HAL函数包成了引脚对象,点灯变成了led.write(true)而不是到处传GPIO端口和引脚号;第3篇给UART加了一个环形队列,收发都不再阻塞,消息到达后由主循环统一处理;第4篇整理了一套中断回调的C++入口方式,解决了一个很现实的问题——中断服务函数是C函数,怎么跳进C++类成员方法里执行;第5篇又把SPI接口封装了一遍,让屏幕这类外设可以像普通对象一样被打开、读写、关闭。

这一套组合下来,工程的基本形状已经出来了:底层寄存器访问藏进类内部,外设操作不再散落在一堆HAL_UART_Transmit调用里,主循环的逻辑变成了对几个对象方法的调用,一眼能看懂程序在干嘛。更重要的是,编译出来的固件没有长大多少,运行效率和纯C版本几乎一样,这对怀疑“C++在单片机上跑不动”的人来说,算是有力的回应。

1.2 为什么说“还差活滴”:USB、测距、显示三缺一

手里有个能点灯、能输出日志的C++骨架,听起来挺好,但离一个“能干活”的产品还差着很远的距离。随便列三个最常见的需求,立刻就把缺口暴露出来。

第一个缺口是USB设备。大部分PC和笔记本到现在还保留着串口,但很多用户拿到设备的第一反应就是插USB线,如果你的板子只跑了一个USART转USB芯片,那就得额外挂一颗芯片,既费钱又费事。如果STM32自带USB外设能直接当HID设备用,就能省掉这颗芯片,插上电脑免驱识别。

第二个缺口是测距。超声波测距是很多小车、水位计、避障模块的核心功能,原理不复杂,但真正实现的时候,回波脉冲的宽度测量直接依赖定时器的输入捕获能力,尤其是要测到微秒级的时间差,不能靠轮询GPIO硬等。

第三个缺口是显示。数据采回来了,总得让人看得见,ILI9341这一类SPI屏幕是最常见的选择。

这三个缺口单独拿出来都有对应的C写法,但用C++重新组织之后,代码的复用性和可读性都能上一个台阶,这也是这一篇真正的价值所在。

1.3 已经在STM32上C++了,就别再退回到C

很多人问过一个问题:嵌入式项目里C++到底图什么?我自己的体会有三点,这三点在前五篇的实践里已经体现得很明显。

第一是类型安全。同样一个引脚编号,在C里是uint16_t一个裸数字,传给函数全靠人眼判断是不是合法;在C++里可以是一个Pin类对象,编译期就挡住了非法的赋值和操作。

第二是资源管理的确定性。C++的构造函数和析构函数在嵌入式里不是花架子。外设初始化放在构造函数里,对象创建出来就自动完成配置,不用再单独写一个Init函数然后在main里忘记调用,这类低级错误能少很多。

第三是模板带来的零成本抽象。定时器、GPIO这类外设,在同一个芯片上有好几个实例,功能逻辑完全一样,只有寄存器基址不同。用模板参数传基址,一份代码编译出多个实例,运行时不产生任何额外开销。

编译器对C++的支持也相当成熟,-Os优化下,类成员函数、模板实例化和手写C函数生成的指令几乎没什么差别。我在实际项目中测过,一个中等规模的HAL驱动从C重写为C++,代码体积增加通常控制在3%以内,换来的是模块边界清晰、测试容易写、多人协作时不至于互相踩脚。这套方案在STM32F103这种72MHz的Cortex-M3上完全跑得动,放到F4、H7上更是绰绰有余。

2. 三个硬核细节先讲透:捕获、USB、屏幕读ID

2.1 定时器输入捕获测频率:边沿、计数器和溢出那点事

超声波测距的底层原理其实就是一个时间测量问题:发射脉冲,等回波,回波到达时把GPIO拉高,这段时间乘以声速再除以二就是距离。

听起来简单,但最关键的测量“时间差”必须用硬件定时器来做。我在STM32上用的是输入捕获模式,具体做法是把定时器配成自由计数状态,某个引脚出现上升沿时,硬件会自动把当前计数值锁存到捕获比较寄存器(CCR)里,完全不消耗CPU时间。

这里有几个细节容易忽略,折腾过定时器的朋友应该都有体会。

第一个是时钟源和分频的选择。STM32F103的TIM2挂在APB1上,APB1时钟默认是36MHz,定时器内部倍频后实际是72MHz计数。为了把分辨率控制在微秒级而不是1/72微秒,我会设置预分频器PSC,让计数频率变成1MHz,这样计数器每跳一次正好是1微秒,后面对时间做换算特别方便。

第二个是边沿捕获的选择。做超声波测距时,我最关心的是从拉高引脚到“触发”引脚收到回波信号拉高那一瞬间的时间差。可以把TIM2的CH1配成上升沿捕获,捕获值就是回波到达的微秒数。

第三个是16位计数器的溢出问题。STM32F103的TIM3、TIM4都是16位计数器,1MHz计数频率下,从0计到65535只需要65.5毫秒。如果测量的时间超过这个值,计数器会溢出回零,直接导致测距结果跳变。解决思路是启动更新中断,在溢出时维护一个扩展计数值,最后把两段数值拼起来。

我实际封装的C++类大概长这样,模板参数直接传定时器基址和分频系数:

template<uint32_t TimBase, uint16_t Psc> class InputCapture { public: void start() { TIM_TypeDef* tim = reinterpret_cast<TIM_TypeDef*>(TimBase); tim->PSC = Psc - 1; tim->CCER = TIM_CCER_CC1E; tim->CCMR1 = TIM_CCMR1_CC1S_0; tim->CR1 = TIM_CR1_CEN; tim->SR = 0; tim->DIER = TIM_DIER_CC1IE; } uint32_t lastRisingTick() { TIM_TypeDef* tim = reinterpret_cast<TIM_TypeDef*>(TimBase); return tim->CCR1; } };

实际使用时,用InputCapture<TIM2_BASE, 72>声明一个对象,初始化完成,测距模块的回波引脚接在对应的CH1脚上即可。这套抽象在不同定时器之间切换非常干净,改一个模板参数就能换一个外设。

2.2 USB描述符到底长什么样:让电脑认识你的板子

把STM32的USB外设做成自定义HID设备,是很多项目从“能用串口”升级到“插上就能用”的关键一步。USB协议本身不复杂,枚举阶段就是主机反复向设备要各种描述符的过程,真正麻烦的是描述符数组里每个字节的含义都要理解清楚。

最简单的情况是一套设备描述符加一套配置描述符。设备描述符固定18字节,前两个字节声明长度和类型,接着是USB版本号、设备类代码、端点0的最大包长度、厂商ID和产品ID,最后是索引号。这个数组在C++里可以直接用constexpr定义成不可修改的常量:

namespace usbd { constexpr uint8_t DEVICE_DESC[18] = { 0x12, 0x01, 0x10, 0x01, 0x00, 0x00, 0x00, 0x40, 0x34, 0x12, 0x78, 0x56, 0x00, 0x01, 0x01, 0x02, 0x00, 0x01 }; }

这里0x12是长度,0x01是描述符类型,0x10 0x01代表USB 1.1版本,0x40表示端点0最大包64字节,0x1234和0x5678分别是厂商ID和产品ID。配置描述符更复杂一点,要根据端点情况组织接口描述符、HID描述符和端点描述符。

一个非常常见的坑是:电脑弹窗提示无法识别的USB设备,或者设备管理器里黄色感叹号。这种情况90%以上不是硬件问题,而是描述符字节没对齐。比如配置描述符里声明的接口数量与实际不符、端点地址写错、端点属性写成了批量而不是中断,都会让主机在枚举阶段直接放弃。

我在调试USB时候养成一个习惯:每次改了描述符,就用Bus Hound或者USBlyzer抓一次枚举包,看主机请求的是哪个描述符、设备回的是什么内容,比对一下就能定位到错位的地方。USB协议不怕复杂,怕的是闷头改代码猜错误,抓包一次顶过瞎猜十次。

2.3 ILI9341读ID读到0xA1A1:一次典型的SPI时序踩坑

ILI9341屏幕初始化时,读ID的动作是很多人的噩梦。正常流程是发命令0xD3,然后连续读三个字节,返回的数据类似0x00、0x93、0x41这种,表示这是ILI9341。

但不少朋友读出来的第一个反应是“这什么东西,0xA1A1?”我第一次遇到也是这样,排查了大半天才发现问题不在读ID命令本身,而在SPI通信的方式上。

ILI9341从命令切换到读数据时序,需要D/CX引脚的电平配合。发送命令时D/CX给低,发送数据时D/CX给高。如果你在读取ID时D/CX电平没有及时切换,或者切换时机不对,最后几个字节就是错位的。

另外SPI的时钟极性和相位也要匹配。ILI9341一般按模式0工作,也就是CPOL=0、CPHA=0,采集数据在第一个时钟沿。如果配成了模式3,时钟空闲电平和采样沿全反了,单片机读回来的字节就会整个移位,出现0xA1A1这类诡异值。

我排查这类问题时按顺序做三件事:先拿示波器看D/CX在命令阶段和读数据阶段是不是真的切换了,再看SPI模式配置和_wiring的时序对应,最后用逻辑分析仪抓连续的SCK和MISO信号,对着ILI9341的数据手册逐位比对。大部分“读ID不对”都能在第三步找到答案,问题常常出在发送命令之后多等了一个时钟周期、少了一个时钟周期,数据整体错位一位。

3. 实操整合:用C++类把超声波、USB和屏幕串成一台小仪表

3.1 用VsCode和CMake把C++工程重新搭起来

第1篇搭工程时用的是IDE自带模板,代码能跑,但工程管理上总觉得别扭,尤其是要把自己写的C++类和官方HAL库混在一起编译,IDE勾选不灵活,文件一多就乱。这次我用VsCode加CMake重新搭了一遍,体验好很多,配置方式也可以直接抄。

首先要准备的是arm-none-eabi工具链,然后装VsCode的几个关键插件:C/C++插件提供代码提示和调试,CMake Tools插件集成构建流程,Cortex-Debug插件负责和ST-Link对接调试。

c_cpp_properties.json里要重点配好头文件路径和编译器路径,不配好的话,代码里全是“无法打开源文件”的错误,提示键都废了。我的配置大概是这样的:

{ "name": "STM32Cpp", "includePath": [ "${workspaceFolder}/Core/Inc", "${workspaceFolder}/Drivers/STM32F1xx_HAL_Driver/Inc", "${workspaceFolder}/Drivers/CMSIS/Device/ST/STM32F1xx/Include", "${workspaceFolder}/Drivers/CMSIS/Include" ], "defines": ["STM32F103xE", "USE_HAL_DRIVER"], "compilerPath": "/usr/local/arm-gnu-toolchain/bin/arm-none-eabi-g++", "cppStandard": "c++17", "compileCommands": "${workspaceFolder}/build/compile_commands.json" }

CMakeLists.txt的核心是同时使能C、C++和汇编语言,并指定链接脚本,最关键的一步是设置编译选项把各种优化和裁剪打开:

cmake_minimum_required(VERSION 3.20) project(stm32cpp_demo LANGUAGES C CXX ASM) add_executable(firmware.elf Core/Src/main.cpp Core/Src/app.cpp Drivers/STM32F1xx_HAL_Driver/Src/stm32f1xx_hal.c Core/Src/stm32f1xx_it.c startup_stm32f103xb.s ) target_include_directories(firmware.elf PRIVATE Core/Inc Drivers/STM32F1xx_HAL_Driver/Inc Drivers/CMSIS/Device/ST/STM32F1xx/Include ) target_compile_options(firmware.elf PRIVATE -mcpu=cortex-m3 -mthumb -Os -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti ) target_link_options(firmware.elf PRIVATE -mcpu=cortex-m3 -mthumb -T STM32F103C8Tx_FLASH.ld -Wl,--gc-sections )

注意最后的链接选项里-fno-exceptions -fno-rtti很有必要。异常和运行时类型识别在裸机环境里基本用不到,关掉之后生成的代码会小很多,也能避免一些意外的运行时开销。

3.2 超声波测距类:把模板参数玩明白

超声波测距模块本身是三个引脚:VCC、Trig和Echo。Trig脚给至少10微秒的高电平触发信号,模块内部发出8个40kHz的超声波脉冲,Echo脚输出高电平,高电平的持续时间就是声音往返的时间。

测量Echo高电平宽度,正好可以用前面做的定时器输入捕获类。思路是把Echo接到TIM2_CH1,配置成上升沿捕获,但这里有个问题:输入捕获只能记录边沿时刻,没法直接得到高电平宽度,所以需要同时记录上升沿和下降沿,然后再做差。

我的做法是配两个通道,CH1捕获上升沿,CH2捕获下降沿,两个捕获值相减,就是回波脉冲宽度。C++类的代码写起来非常自然:

template<uint32_t EchoTimer, uint16_t Psc> class Ultrasonic { public: static constexpr float SOUND_SPEED_MM_US = 0.340f; void init() { EchoTimerDriver::start(); TrigPort = GPIOB; TrigPin = GPIO_PIN_0; } void sendTrigger() { HAL_GPIO_WritePin(TrigPort, TrigPin, GPIO_PIN_SET); delay_us(10); HAL_GPIO_WritePin(TrigPort, TrigPin, GPIO_PIN_RESET); } float measureDistanceMm() { uint32_t rising = EchoTimerDriver::lastCaptured(1); uint32_t falling = EchoTimerDriver::lastCaptured(2); uint32_t widthUs = falling - rising; return widthUs * SOUND_SPEED_MM_US / 2.0f; } private: GPIO_TypeDef* TrigPort; uint16_t TrigPin; };

这里有个经验值得说一下:直接拿单次测量结果用,跳动会非常大,尤其是近距离测量时,噪声引入的误差在毫米级别很常见。我在实际使用中做了滑动平均,取最近5次结果的平均值,测得的数据就稳定很多,用来判断障碍物距离已经足够。

3.3 屏幕显示层:数据在ILI9341上动起来

超声波测距的数据有了,下一步是显示到ILI9341上。屏幕这一层的C++封装,我在第5篇里已经把SPI读写函数包进了类里,这次要做的是在这个基础上叠一个简单的显示逻辑。

我用的办法是把显示分成两层:底层是驱动层,负责填充矩形区域、画点、读写显存;上层是UI层,维护当前要显示的数据内容。每次测量出新距离,UI层只刷新对应区域的文字和背景,而不是整屏重画,频率高了也不会闪。

class DistanceScreen { public: void showDistance(float mm) { char buf[32]; snprintf(buf, sizeof(buf), "Distance: %5.1f mm", mm); lcd.fillRect(20, 20, 200, 32, LCD_COLOR_BLACK); lcd.drawString(20, 20, buf, LCD_COLOR_WHITE, FONT_16x24); } };

这里fillRect只清了一块小区域,画字符串也只画一次,整套刷新控制在几毫秒内。显示刷得太频繁还有一个副作用,SPI总线会被占满,导致其他CS复用的外设响应变慢,所以我把刷新频率限定在每秒10次左右,人眼看起来够流畅,系统也有余量处理别的任务。

4. 烧录运行里的常客坑:编译、链接和中断排查实录

4.1 全局对象不构造:都是__libc_init_array的锅

从C工程改成C++工程后,很多人会遇到一个诡异问题:全局对象明明写了初始值,运行起来却是零,调用成员函数直接进HardFault。我一开始也踩过,排查了很久才发现问题出在启动文件上。

嵌入式工程的启动代码一般是汇编写的,它只做四件事:初始化栈指针、调用Reset_Handler、调用SystemInit、跳转到main。注意这里完全没有“全局对象构造”这一步!C++编译器默认不会帮你在启动文件里插入构造函数调用,这个动作是__libc_init_array函数负责的。

如果不调用它,全局对象的构造函数就永远不会执行,对象内部的成员变量全是内存里的随机值或零。解决办法是在启动文件里、跳转到main之前显式调用一下:

bl SystemInit bl __libc_init_array bl main

调用顺序一定要放在main之前,而且要放在SystemInit之后,因为有些静态对象的构造函数可能依赖系统时钟已经就绪。

4.2 extern "C"到底要包住哪些头文件

用C++编译整个工程之后,链接器经常会报一堆关于HAL_GPIO_WritePin这类函数未定义的错误。第一次遇到这个问题的人会怀疑是不是头文件没包含,其实真正原因是C和C++的名字修饰规则不同。

C编译器生成符号时就是函数名本身,C++编译器却会把参数类型、命名空间编码进符号里,两边对不上就链接失败。解决办法是给C编译的库的头文件加上extern "C"声明:

#ifdef __cplusplus extern "C" { #endif #include "stm32f1xx_hal.h" #ifdef __cplusplus } #endif

需要注意的是,HAL库自带的头文件里已经写了这套宏保护,包含它们的时候通常不需要自己再包一遍。但如果你引入了一个纯C写的库,比如某些开源协议栈,头文件里没有这个宏保护,就必须在include的时候自己包。这是C和C++混编项目里几乎一定遇到的问题,提前了解能省下半天排查时间。

4.3 中断回调如何进C++成员函数,还不出乱子

中断向量表里注册的是C函数指针,不能直接指向某个类的成员函数,这是语法层面就限制死的。解决思路是用静态成员函数做桥梁。

具体做法是:在类里定义一个static成员函数作为中断入口,它内部通过一个全局或静态的单例指针去调用非静态成员方法:

class EchoTimer { public: static void onCaptureISR() { instance().handleCapture(); } private: void handleCapture() { // scale the real logic here } static EchoTimer& instance() { static EchoTimer self; return self; } }; extern "C" void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_CC1IF) { TIM2->SR &= ~TIM_SR_CC1IF; EchoTimer::onCaptureISR(); } }

这里还有一个实际遇到的坑:中断处理函数里访问的成员变量必须考虑编译器优化。因为中断和主循环都在访问同一个对象,主循环的编译器可能把变量优化到寄存器缓存,中断里改了值主循环却看不到。解决办法是把共享变量声明为volatile,需要跨线程同步的场合再加临界区保护。

4.4 外设突然连不上:从CAN和USB两种故障看排查思路

项目越做越复杂之后,我开始频繁遇到一些“昨天还能连上,今天彻底没反应”的怪问题。最典型的是CAN通信突然连不上,USB插到电脑上没任何反应。这两种情况排查思路其实很像,都绕不开硬件状态检查和软件状态确认两步。

CAN通信突然断开的常见原因是总线进入Bus-off状态。总线持续出现错误帧,超过一定次数后控制器会自动退出总线,不再参与通信。排查时我先看CAN_ESR寄存器里的BOFF位是否为1,如果是,先恢复总线再查错误原因。总线错误的主因集中在波特率配置不一致、终端电阻缺失、总线线序反接和电气噪声几个方向。

USB突然不枚举的排查,我在2.2节已经提过,优先抓包看枚举过程。没有抓包工具时,可以先看设备端有没有把USB上拉电阻打开。STM32的USB D+线上通常有个1.5k上拉电阻,主机靠检测这个上拉来识别有设备插入。很多例程把上拉控制放在USB驱动初始化时,如果初始化没执行到或者上拉时序太晚,主机就发现不了设备。

把这两类问题放在一起对比后会发现,这类“突然”故障十有八九不是代码改了,而是硬件连接、电源或外部环境发生了变化。排查时不要一上来就翻代码,先把硬件状态确认清楚,再进软件逻辑,效率会高很多。

写到这儿,这套基于STM32的嵌入式C++综合小项目算是有模有样了。我最后再分享一点个人体会:这一趟折腾下来,如果非要说哪个收获最值得,不是学会了某个具体外设的配置,而是明白了“封装边界”的重要性——寄存器操作全部收进类里、中断入口和业务逻辑隔离、显示层和传感器层各自独立,后续再加新的传感器或者换屏幕,都不需要重写已经稳定的部分。这套思路在项目大了以后,比那几行代码本身值钱多了。如果你也在STM32上用C++写过东西,欢迎把踩过的坑丢到评论区,大家一起填坑。