哟哟哟,还差活滴——这句话太真实了。嵌入式C++编程之旅写到第6篇,前几篇已经把STM32上的GPIO、中断、定时器、串口和C++类封装这些零件挨个讲了一遍,但我自己心里清楚:光拆零件不算完,读者也肯定在等一个能真正跑起来的“活”。这篇就是来补这个洞的。我选了最常用的STM32F103C8T6小蓝板,做一个能实际拿在手里的超声波测距小工具,带OLED实时显示、按键切换单位、LED距离提示和串口日志。硬件成本几十块,代码量不大,但驱动封装、状态机、非阻塞调度、C++工程改造这些前几篇欠下的“综合债”,这一篇可以一次还清。适合刚把C语言玩熟、准备转向嵌入式C++的读者,也适合写裸机C已经一段时间、想看看C++模块化到底怎么落地的人。
1. “还差活滴”到底是什么意思:前几篇欠下的一笔综合账
1.1 零散知识点看着都懂,一上手就乱
前几篇的内容单拎出来都不难:点个灯,GPIO输出;读个按键,GPIO输入加消抖;串口打印,重定向一下 fputc;定时器中断,在回调里翻转电平。这些实验单独跑,哪怕新手也能在一个晚上搞定。但真实项目不是“一个外设一个main”,而是要把这些外设同时塞进一个程序里。很多人的痛点就在这里:LED会闪了,按键会读了,可一旦组合起来,要么按键响应变得迟钝,要么超声波测量和OLED刷新相互干扰,要么逻辑一多,代码就变成一大坨 if 嵌套。
我自己带过不少刚开始转嵌入式C++的朋友,最常见的状态就是“每个demo都能跑,合在一起就崩”。原因不复杂:之前的代码是把所有逻辑都堆在 main 里,用 delay 硬等,硬等期间其他外设全部停工;或者依赖一堆全局变量,外设之间互相抢数据。这个问题用C也能解决,但C缺乏“边界工具”,写着写着又回到全局变量满天飞的老路。C++的类、命名空间、枚举、状态机这些能力,恰好能把硬件访问、事件处理、界面刷新拆成不同模块,各干各的,不互相污染。
这说的“还差活滴”,不是差一个特别复杂的项目,而是差一个能把前几篇的零件全部拧在一起、能演示、能扩展、能复现的综合小工程。
1.2 这次“活”的任务清单
我选的项目叫“桌面超声波测距小工具”。功能不花哨,但每一项都对应一个真实的嵌入式知识点:
- 超声波模块 HC-SR04 实时测量距离,覆盖定时器计时和GPIO操作;
- OLED SSD1306 显示当前距离和单位,覆盖I2C通信和显存缓冲区管理;
- 按键短按切换厘米/英寸显示,覆盖输入消抖和状态切换;
- 板载LED根据距离区间给出不同提示,覆盖输出控制和闪烁逻辑;
- 串口每500ms输出一条运行日志,用于调试和观察系统状态。
这套功能看上去简单,实际已经把前几篇的内容全串起来了:GPIO输入输出、外部中断或轮询、定时器计数、I2C外设、串口重定向、非阻塞任务调度、C++类封装、状态机设计。做完之后,你手里会有一个可以放在桌上实际测量的小工具,而不是一个“跑完就扔”的实验板程序。
1.3 为什么用C++而不是继续C
很多人在嵌入式圈子里听过“C++跑在单片机上是小题大做”的说法。我不完全反对,如果只是点灯、读按键,C确实够了。但项目一旦到了“超声波+OLED+按键+串口”这个复杂度,C++带来的模块边界就非常有价值。比如超声波模块,我可以把触发逻辑、测量状态、距离换算全部封装在 Ultrasonic 类里,main 只需要调sonar.trigger()和sonar.update(),根本不用关心 Echo 引脚电平怎么读、定时器计数值怎么算。
STM32F103C8T6 的 Flash 是64KB,RAM 是20KB,跑轻量级C++完全没问题,前提是“轻量”:不用异常、不用RTTI、不用动态内存、不随便用继承。很多人一听到C++就想到面向对象三件套,这在单片机上是误区。嵌入式C++的正确打开方式是把类当成“模块边界”和“数据封装工具”,而不是搞一个几十层的继承体系。这篇文章里的所有代码都是按这个原则写的。
2. 硬件准备与工程改造:先把车搭起来
2.1 板卡和外设怎么选
选板卡我没什么犹豫,STM32F103C8T6 的小蓝板,也就是大家常说的 Blue Pill。它便宜、资料多、引脚够用,一个最小系统加上USB转串口就能烧录调试,非常适合做这种综合小项目。Flash 64KB、RAM 20KB,对今天的工程绰绰有余。外设我一共用了五个:HC-SR04 超声波模块、SSD1306 I2C接口的OLED显示屏、一个轻触按键、板载LED、通过USB转串口模块接到电脑的USART1。
之所以选 HC-SR04 和 SSD1306,是因为这两个模块是国产开发板的“国民外设”,网上资料铺天盖地,就算你手里的板子不是 Blue Pill,只要外设引脚对应改一下,代码逻辑也一样能跑。OLED 我建议选 I2C 接口的,只占两根线;如果用SPI接口的SSD1306,接线多一些,但驱动思路类似。
2.2 引脚分配与接线避坑
我的引脚分配如下:
| 外设 | 信号 | STM32引脚 | 说明 |
|---|---|---|---|
| HC-SR04 | Trig | PA0 | 推挽输出,默认低电平 |
| HC-SR04 | Echo | PA1 | 输入,测距离时读高电平脉宽 |
| OLED SSD1306 | SCL | PB6 | I2C1 时钟 |
| OLED SSD1306 | SDA | PB7 | I2C1 数据 |
| 按键 | 信号 | PB0 | 内部上拉输入,按下接地 |
| 板载LED | 负极 | PC13 | 低电平点亮,注意是反逻辑 |
接线这里有一个必须提的坑:HC-SR04 的 Echo 脚输出的是5V电平,而 STM32F103 的 GPIO 耐压是3.3V,直接把 Echo 接到 PA1 有烧引脚的风险。稳妥做法是串联一个1k电阻再接PA1,同时PA1对地接一个2k电阻,组成分压电路,把5V分到约3.3V。实际公式是 5V × 2k / (1k + 2k) ≈ 3.33V,刚刚好在安全范围内。OLED 和按键都按3.3V逻辑接,不需要分压。
按键接PB0、另一端接GND,开启内部上拉,按下时读到低电平。这里我故意没把按键接到板载复位键上,因为复位键是直接连到NRST引脚的,没法当普通按键用。板载LED在PC13,这个引脚在 Blue Pill 上是低电平点亮,所以写代码时“亮”和“灭”的逻辑要反过来。
2.3 CubeMX 配置要点
工程我习惯先用 STM32CubeMX 生成基础代码,减少寄存器配置出错的可能。配置项按顺序来:
- RCC:HSE 选择 Crystal/Ceramic Resonator;
- SYS:Debug 选择 Serial Wire,否则 ST-Link 可能连不上;
- Clock:把 HCLK 配到 72MHz,也就是 F103 的最高主频;
- USART1:异步模式,115200-8-N-1;
- I2C1:标准模式即可,速率100kHz,OLED足够用;
- TIM2:内部时钟,预分频72-1,也就是 72MHz / 72 = 1MHz,计数器每1微秒加1;
- 按键PB0:GPIO_Input,开启 Pull-up;
- Trig PA0:GPIO_Output,初始低电平;
- Echo PA1:GPIO_Input,不上拉也不下拉。
把时钟配到72MHz不只为了性能,更是让外设分频关系清晰:TIM2 预分频72-1后,计数器直接变成微秒计数器,超声波测距的脉宽计算就简单了。TIM2 在 F103 上是32位计数器,不用担心测距30毫秒以内会溢出,这一点比16位定时器省心很多。
2.4 C文件改造成C++工程
CubeMX生成的是标准C工程,默认入口是 main.c。要跑C++,第一步就是把 main.c 改名成 main.cpp,然后让编译器按C++语法处理。用 Keil MDK 时,在工程里把源文件后缀改成 .cpp 即可,ARMCC 会自动按C++编译。注意你的 .cpp 文件里包含 STM32 HAL 头文件时,需要保证头文件按C++方式处理链接,STM32 HAL 头文件在开头结尾基本都有extern "C"保护,所以一般不会出问题,但如果遇到链接符号找不到,就在包含 HAL 头文件之前手动包一层extern "C"。
Keil 的 C++ 配置有两处容易踩坑。第一,在 Options for Target 的 C/C++ 页面里,如果编译器是 ARMCC5,要加--cpp11来开启C++11,ARMCLANG 则直接选 C++11 即可。第二,强烈建议加上--no_exceptions --no_rtti,也就是关闭异常和运行时类型识别。单片机上的C++异常需要额外的展开表,Flash和RAM都吃紧,我们写裸机程序也用不上 try/catch。关闭之后,链接体积会明显小很多。
还有一个很多新手会忽略的东西:Keil 里要勾选 Use MicroLIB。这不是为了跑C++,而是为了让串口重定向的fputc不进入半主机模式。半主机是 ARM 调试环境提供的一套交互机制,如果重定向没做好,程序执行到 printf 时会卡死。勾上 MicroLIB 后,printf 不会再依赖调试器,程序才能独立运行。
启动文件里还有一个和C++相关的细节:全局对象的构造函数是由__cpp_initialize这类运行时初始化函数调用的。正常情况下,标准启动文件会完成这些调用,不需要手工处理。但要注意,全局对象不能写在 main 之前做硬件初始化,因为这个时候时钟、GPIO、外设都还没配好。我习惯的做法是:所有类的构造函数只保存参数,真正的硬件配置放到 init() 方法里,在 main 中完成时钟和外设初始化之后再调用。
3. 驱动的 C++ 封装:把寄存器变成对象
3.1 一个重要原则:构造函数不碰硬件
在写各个驱动类之前,先定一个贯穿全文的规矩:构造函数里不操作寄存器,只保存端口、引脚和其他参数;硬件初始化放到单独的 init() 方法,由 main 在时钟和外设配置完成后调用。这么做有两个原因。
第一是 C++ 全局对象构造顺序不可控。如果在全局对象构造函数里直接写 GPIO 寄存器,程序启动时时钟还没配好,寄存器写入可能无效,甚至触发 HardFault。第二是方便调试和复位:init() 可以重复调用,程序运行中想重新初始化某个外设,直接再调一次 init() 就行。
3.2 Led 类:GPIO 输出的极简封装
LED 控制的本质就两件事:让引脚输出高、让引脚输出低。STM32 的 GPIO 有一个 BSRR 寄存器,写1到对应位可以原子置位或复位引脚,比操作 ODR 更安全,不会因为中断打断造成误操作。封装成一个类,代码反而更清晰:
class Led { public: Led(GPIO_TypeDef* port, uint16_t pin, bool activeHigh) : port_(port), pin_(pin), activeHigh_(activeHigh) {} void init() { // GPIO 时钟和模式在 main 里已用 CubeMX 配置好 // 这里只做初始状态设置 off(); } void on() { port_->BSRR = activeHigh_ ? pin_ : static_cast<uint32_t>(pin_) << 16; } void off() { port_->BSRR = activeHigh_ ? static_cast<uint32_t>(pin_) << 16 : pin_; } void toggle() { if (port_->ODR & pin_) off(); else on(); } private: GPIO_TypeDef* port_; uint16_t pin_; bool activeHigh_; };这里的activeHigh_参数就是为 PC13 这种低电平点亮的引脚准备的。构造函数里传false,调用led.on()时实际输出低电平,外部代码完全不用关心硬件反逻辑。类里面的方法都很短,编译器会默认内联,实际生成的汇编和直接操作寄存器差不多,不会有性能损失。
3.3 Button 类:按键消抖本质是状态机
按键最怕的是机械抖动。按下瞬间,引脚电平会在一两毫秒内反复跳变,如果直接在中断或主循环里读一次就判断按下,会出现一次按击触发多次响应。传统的做法是延时20ms再读一次,但 delay 会阻塞整个系统,在综合项目里不可取。
更好的做法是把消抖写成一个小状态机,每次 update() 只做一个判断,不阻塞:
enum class ButtonEvent { None, ShortPress, LongPress }; class Button { public: Button(GPIO_TypeDef* port, uint16_t pin, bool pressedLevel) : port_(port), pin_(pin), pressedLevel_(pressedLevel), state_(State::Idle), pressStartTick_(0) {} void init() { /* GPIO 模式已在 main 配置为输入上拉 */ } ButtonEvent update(uint32_t now, bool level) { bool pressed = (level == pressedLevel_); ButtonEvent evt = ButtonEvent::None; switch (state_) { case State::Idle: if (pressed) { pressStartTick_ = now; state_ = State::DebouncePress; } break; case State::DebouncePress: if (!pressed) { // 抖动导致复位 state_ = State::Idle; } else if (now - pressStartTick_ >= 20) { state_ = State::Pressed; evt = ButtonEvent::ShortPress; // 先给短按,长按在Pressed状态里再升级 } break; case State::Pressed: if (!pressed) { state_ = State::Idle; } else if (now - pressStartTick_ >= 1000) { evt = ButtonEvent::LongPress; state_ = State::LongPressed; } break; case State::LongPressed: if (!pressed) { state_ = State::Idle; } break; } return evt; } private: enum class State { Idle, DebouncePress, Pressed, LongPressed }; GPIO_TypeDef* port_; uint16_t pin_; bool pressedLevel_; State state_; uint32_t pressStartTick_; };这段代码里,now是系统 tick,每次主循环调用时传入。当引脚首次变为按下电平,记录时间并进入 DebouncePress;如果20ms内又变回释放电平,说明是抖动,状态复位;如果稳定保持20ms,确认是一次有效按下,产生短按事件;再持续到1000ms,产生长按事件。这种方式不会阻塞,而且一次按键可以同时支持短按和长按,后面做模式切换非常方便。
3.4 Logger 类:串口打印不一定要 iostream
很多从桌面C++转到单片机的朋友,第一反应是想用std::cout,这是雷区。iostream 会引入大量静态对象和异常支持代码,Flash 瞬间涨几KB甚至十几KB,裸机项目没必要。C标准库的 printf 配合重定向,在单片机上完全够用,但我想把日志功能也封装成类,顺便用 C++11 的可变参数模板简化调用。
重定向的核心是重写fputc,让格式化输出落到串口。在 main.cpp 里可以这样写:
extern "C" int fputc(int ch, FILE* f) { (void)f; HAL_UART_Transmit(&huart1, reinterpret_cast<uint8_t*>(&ch), 1, 0xFFFF); return ch; }注意必须是extern "C",否则链接时符号对不上。函数体里用 HAL_UART_Transmit 直接把一个字节丢进串口,超时时间设长一点,避免阻塞。之后就可以用printf("distance=%d cm\\r\\n", dist);打印调试信息。
如果嫌printf的可变参数不像C++风格,可以封装一个 Logger:
class Logger { public: void init() { /* 串口初始化由CubeMX完成,这里可以做日志帧头标记 */ } template<typename... Args> void log(const char* fmt, Args... args) { char buf[128]; vsnprintf(buf, sizeof(buf), fmt, args...); HAL_UART_Transmit(&huart1, reinterpret_cast<uint8_t*>(buf), strlen(buf), 0xFFFF); } };log("dist=%d cm", cm);的调用方式比裸printf更清晰。需要注意vsnprintf会占用一点Flash,但只在调试版本保留,正式发布可以把日志关掉。
3.5 Ultrasonic 类:用定时器测出时间
HC-SR04 的测距原理很简单:Trig 脚拉高10微秒以上,模块内部发出40kHz超声波,同时 Echo 脚输出高电平,高电平持续时间就是超声波从发射到碰到物体返回的时间。距离计算公式是:
- 声速约 340m/s = 0.034cm/us;
- 声波走了来回,所以单程距离 = 时间 × 0.034 / 2;
- 换算后,距离厘米数 ≈ 时间微秒 / 58。
如果不想要浮点,可以用整数运算:distanceMm = echoTimeUs * 10 / 58;,得到毫米精度。比如 Echo 高电平持续 2000us,代入公式:2000×10/58 ≈ 344mm,也就是34.4cm,和实际经验值吻合。
驱动类我采用非阻塞状态机,核心是利用 TIM2 做微秒计数器。触发时拉高 Trig,等待10us后拉低,然后不断检查 Echo 引脚。Echo 为高时记录计数器的起始值,Echo 变低后再读一次计数器,差值就是高电平脉宽。
class Ultrasonic { public: enum class State { Idle, Trigger, WaitTrigDone, WaitEchoHigh, WaitEchoLow }; Ultrasonic(GPIO_TypeDef* trigPort, uint16_t trigPin, GPIO_TypeDef* echoPort, uint16_t echoPin, TIM_HandleTypeDef* htim) : trigPort_(trigPort), trigPin_(trigPin), echoPort_(echoPort), echoPin_(echoPin), htim_(htim), state_(State::Idle) {} void start() { trigPort_->BSRR = trigPin_; state_ = State::Trigger; trigStartUs_ = __HAL_TIM_GET_COUNTER(htim_); } bool update() { uint32_t nowUs = __HAL_TIM_GET_COUNTER(htim_); bool echoHigh = (echoPort_->IDR & echoPin_) != 0; switch (state_) { case State::Idle: break; case State::Trigger: if (nowUs - trigStartUs_ >= 10) { trigPort_->BSRR = static_cast<uint32_t>(trigPin_) << 16; state_ = State::WaitEchoHigh; timeoutUs_ = nowUs; } break; case State::WaitEchoHigh: if (echoHigh) { echoStartUs_ = nowUs; state_ = State::WaitEchoLow; } else if (nowUs - timeoutUs_ > 30000) { state_ = State::Idle; lastError_ = true; } break; case State::WaitEchoLow: if (!echoHigh) { uint32_t duration = nowUs - echoStartUs_; distanceMm_ = duration * 10 / 58; lastError_ = false; state_ = State::Idle; return true; } else if (nowUs - timeoutUs_ > 30000) { state_ = State::Idle; lastError_ = true; } break; } return false; } uint32_t distanceMm() const { return distanceMm_; } bool lastError() const { return lastError_; } private: GPIO_TypeDef* trigPort_; uint16_t trigPin_; GPIO_TypeDef* echoPort_; uint16_t echoPin_; TIM_HandleTypeDef* htim_; State state_; uint32_t trigStartUs_; uint32_t echoStartUs_; uint32_t timeoutUs_; uint32_t distanceMm_; bool lastError_; };这里有一个嵌入式C++的经典问题:中断回调、HAL库回调函数都是C语言链接的,而类方法默认是C++链接。如果超声波测距改成Echo上升沿和下降沿中断触发,中断回调里要访问 Ultrasonic 对象,需要桥接。比如在 main.cpp 里维护一个全局指针Ultrasonic* g_sonar;,然后在extern "C" void HAL_GPIO_EXTI_Callback(uint16_t GPIO_Pin)里调用g_sonar->onEchoEdge(...)。因为 HAL 头文件对C++有保护,函数定义用extern "C"包住就不会链接错。为了降低初学者的理解成本,本篇用的是状态机轮询方案,不依赖中断,代码也更安全。
3.6 Oled 类:SSD1306 的显存思想
SSD1306 是一个128×64的OLED驱动芯片,I2C接口只需要两根线。它的核心工作方式不是“直接写像素”,而是往芯片内部的显存GRAM里写数据,芯片自己刷新屏幕。在STM32这边,你也需要维护一块同样大小的缓存,大小是 128×64/8 = 1024字节。每次要显示新内容时,先把内容画进这片1024字节的缓存,再把整片缓存通过I2C发送给OLED。这个思想很重要,它让上层逻辑完全不关心OLED的页地址和列地址,只关心“往哪个坐标画点”。
类的大致结构:
class Oled { public: void init(); void clear(); void drawChar(int x, int y, char c); void drawText(int x, int y, const char* text); void show(); private: uint8_t framebuffer_[1024]; };初始化序列是 SSD1306 驱动里最容易出错的地方。核心命令包括:关闭显示、设置时钟分频、设置多路复用、设置显示偏移、开启内部电荷泵、设置列地址方向、设置行地址方向、设置对比度、开启显示。网上能搜到完整序列,直接复制即可。我踩过的坑是把0xA1和0xC8漏了,这两个命令决定显示方向,漏掉会出现屏幕内容镜像或者上下颠倒,不是硬件坏了。
I2C通信这里多说一句:STM32F1 的硬件 I2C 被不少人诟病,容易卡在忙检测上。如果调试时发现 OLED 时好时坏,一种稳妥方案是改用 GPIO 模拟 I2C,代码虽然多几十行,但时序完全可控。本篇代码默认用 CubeMX 生成的 I2C1 发送函数,对于100kHz标准模式驱动OLED已经足够稳定。
4. 把“活”跑起来:主循环与状态机
4.1 先定任务周期:为什么不能一直 delay
现在每个外设都有了自己的驱动类,接下来要回答一个核心问题:main 函数里怎么安排这些对象的运行顺序?最容易想到的写法是:
while (1) { HAL_Delay(100); 获取距离; 刷新OLED; 打印日志; }这个写法能跑,但有几个问题。第一,按键扫描被 delay 阻塞,按下去可能要等几百毫秒才响应;第二,超声波测距一次最多要30ms,如果刚好在刷新OLED时触发,OLED 显示会卡顿;第三,所有任务挤在一起,时序完全不可控。解决这个问题的最简单方案是时间片轮询:让一个1ms的 SysTick 作为时间基准,主循环不做阻塞等待,而是检查当前时间和上次执行时间的差,到达周期就执行对应任务。
我的任务周期分配如下:
| 任务 | 周期 | 说明 |
|---|---|---|
| 按键扫描 | 5ms | 输入消抖,及时响应操作 |
| 超声波测量 | 50ms | 20Hz,一般测距场景足够 |
| OLED刷新 | 100ms | 屏幕更新,避免闪烁 |
| 串口日志 | 500ms | 观察运行状态,不刷屏 |
4.2 非阻塞主循环的具体写法
主循环的骨架如下:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_USART1_UART_Init(); MX_I2C1_Init(); MX_TIM2_Init(); HAL_TIM_Base_Start(&htim2); // 启动微秒计数器 Led led(GPIOC, GPIO_PIN_13, false); Button btn(GPIOB, GPIO_PIN_0, false); // 按下电平为低 Logger logger; Oled oled; Ultrasonic sonar(GPIOA, GPIO_PIN_0, GPIOA, GPIO_PIN_1, &htim2); led.init(); oled.init(); logger.init(); uint32_t now = HAL_GetTick(); uint32_t lastButton = now; uint32_t lastSonar = now; uint32_t lastOled = now; uint32_t lastLog = now; logger.log("[APP] boot ok\\r\\n"); while (1) { now = HAL_GetTick(); if (now - lastButton >= 5) { bool level = (GPIOB->IDR & GPIO_PIN_0) != 0; ButtonEvent evt = btn.update(now, level); if (evt != ButtonEvent::None) { // 交给应用层处理 Oled 切换单位或提示; } lastButton = now; } if (now - lastSonar >= 50) { sonar.start(); lastSonar = now; } // 超声波 update 可以高频调用,甚至可以每次主循环都调 if (sonar.update()) { uint32_t mm = sonar.distanceMm(); // 刷新距离缓存 } if (now - lastOled >= 100) { oled.clear(); oled.drawText(0, 0, "Dist:"); // 画数字和单位 oled.show(); lastOled = now; } if (now - lastLog >= 500) { logger.log("[APP] dist=%d mm\\r\\n", distanceMm); lastLog = now; } } }这里面最关键的是HAL_GetTick(),它由 SysTick 中断每1ms维护一次,返回一个不断增加的tick。注意不同任务之间是“判断差值”而不是“等于周期”,这样即使某个任务偶尔晚了几毫秒,下一个周期也能自动校正,不会积累漂移。超声波测距的 update 需要高频执行,没有周期限制,所以放在主循环里每次都调用,它会自己跑完整个测量状态机。
4.3 应用层状态机:厘米和英寸怎么切
刚才主循环里“交给应用层处理”这句话不能只是一句注释,得有一个实际的应用层控制器。我设计了一个 AppController 类,专门处理“用户按了哪个键、系统处于什么模式、显示什么内容、LED 怎么闪”这些业务逻辑,让 main 只负责调度。核心状态是单位切换:厘米和英寸。
厘米和英寸互相换算,1英寸等于2.54厘米。如果一直用浮点,F103 没有硬件浮点单元,每次显示都调用浮点库会比较慢。这里我用整数近似:inch ≈ cm / 2.54,放大100倍再计算,显示时手动点小数点。比如距离是127mm,换算成英寸就是 127/10=12.7cm,12.7/2.54=5.0英寸。更简单的方式是在 OLED 上直接用“cm”和“inch”两个模式分别显示,保持毫米精度即可。
按键事件我定义了三个:短按切换单位,长按清零或进入演示模式。实际按键处理代码里只需要一个 switch:
switch (evt) { case ButtonEvent::ShortPress: if (unit_ == Unit::CM) unit_ = Unit::INCH; else unit_ = Unit::CM; break; case ButtonEvent::LongPress: // 可以在这里做LED自检或进入低功耗 break; default: break; }状态机的好处是,以后想加模式,只需在枚举里加一个分支,不需要改动 main 里的调度逻辑。这就是C++封装带给裸机程序的“维护性红利”。
4.4 实测效果与串口日志
把程序烧进板子后的实际表现是:上电后 OLED 第一行显示 “Dist:”,第二行显示当前距离,单位默认 cm;短按按键,单位变成 inch,再按一次切回;LED 在距离小于30cm时常亮,30cm到100cm之间以8Hz闪烁,大于100cm熄灭。串口工具里可以看到类似输出:
[APP] boot ok [SONAR] trigger [APP] dist=342 mm [APP] dist=345 mm [APP] unit change: inch [APP] dist=13.5 inch实测中有一个比较明显的现象:OLED 100ms刷新一次,显示距离时不会闪烁,但如果把刷新周期改成10ms,肉眼能看到轻微抖动。原因是超声波测量本身的跳动和I2C传输时间叠加了。20Hz的超声波测量配合10Hz的OLED显示,正好能稳定跟手。
5. 常见问题与排查技巧:实操中踩过的坑
5.1 C++编译链接问题速查
| 现象 | 可能原因 | 处理办法 |
|---|---|---|
Undefined symbol __gxx_personality_v0 | 编译器开启了C++异常,但运行时支持不完整 | 加--no_exceptions --no_rtti,或勾选MicroLIB |
Undefined symbol __use_no_semihosting | printf 重定向后没处理半主机模式 | 勾选Use MicroLIB,或定义_sys_exit、_ttywrch等空函数 |
| 包含 HAL 头文件时报 C 语法错误 | 没有用extern "C"保护 | 确认 HAL 头文件是否带__cplusplus保护,必要时手动包一层 |
| 编译正常但下载后跑不起来 | C++全局对象构造函数访问了未初始化外设 | 构造函数只保存参数,硬件初始化移到 main 里的 init() |
| Flash 空间不足 | 可能开了异常或 RTTI | 关闭异常、关闭RTTI,检查是否误用了 iostream |
这里特别提醒一点:__gxx_personality_v0这个符号几乎是嵌入式C++新手必见。它跟异常处理有关,只要你不使用 try/catch,就完全没有必要为它浪费Flash。在Keil里给编译器加--no_exceptions --no_rtti,这个符号就不会出现。
5.2 串口打印乱码或卡死
串口乱码九成是波特率不匹配,但这回要反过来排查:不是代码里定义的波特率错了,而是系统时钟没有真正跑到72MHz。CubeMX生成的SystemClock_Config如果HSE起振失败,会退回HSI,主频直接降到8MHz,这时115200波特率就会变成十几k,串口工具自然乱码。检查办法是看程序里有没有在时钟配置后等待 HSE 就绪,或者直接在调试器里读RCC->CFGR,确认 PLL 是否锁定。
卡死则多半是半主机问题。前面说过,没有重定向fputc或者没勾选 MicroLIB,程序一旦执行到 printf 就会进入调试器代管状态。典型表现是串口没有任何输出,程序停住不走。用 Keil 调试时能看到 PC 停在某个断点或者循环里,退出调试后板子又恢复。解决办法就是勾选 MicroLIB,或者自己实现_sys_exit等半主机相关函数。
5.3 超声波测距数据乱跳、奇怪数值
HC-SR04 虽然便宜,但坑不少。最容易忽略的是供电。很多新手直接拿 STM32 板上的3.3V给模块供电,结果模块工作不稳定,读数乱跳。HC-SR04 建议用5V供电,Echo 输出经过分压后再接 STM32,供电稳了测距数值才稳。测量间隔也不要太短,超声波发出后有余波,如果每10ms就触发一次,会接收到上一次的回波残留,导致读数忽远忽近。我实测下来,50ms周期是很稳定的下限。
还要注意测量超时。如果前方没有障碍物或者距离超过4米,Echo 高电平可能持续很长时间甚至完全不拉低。程序里必须有超时保护,我上面代码里设了30ms,超过这个时间就放弃本次测量,标记为错误。这个30ms不是随便拍的,4米距离对应的来回时间是 400×2/34.4 ≈ 23.3毫秒,取30ms足够覆盖常规量程。
HC-SR04还有个盲区,大概2cm以内测不准。要是把模块贴到墙上,OLED 显示的数字会固定在2cm左右,这是模块特性,不是代码问题。另外,不同温度下声速会变,公式里用340m/s是20℃左右的近似值。如果项目要求高精度,可以加一个温度传感器,用331.4 + 0.6×T实时计算声速,不过对桌面小工具来说完全没必要。
5.4 OLED 不亮、花屏、镜像
OLED 上电后没有任何反应,先别急着怀疑屏幕坏了。第一步量电压,确认 VCC 是3.3V,SCL、SDA 有没有接反。第二步查 I2C 地址,SSD1306 常见地址是0x3C或0x3D,取决于模块上地址电阻的焊接位置。HAL库的HAL_I2C_IsDeviceReady可以直接探测地址,返回 HAL_OK 说明通信正常。我见过很多“屏幕不亮”最后是地址写错造成的。
花屏或镜像,多半是初始化序列里缺了方向命令。0xA1设置段重映射,0xC8设置扫描方向,这两个命令写反或漏写,屏幕会左右翻转或上下颠倒。如果只是镜像,不需要怀疑硬件,把初始化序列里的这两条命令改一下就行。还有一个容易被忽略的点:OLED 上电后需要一点启动时间,我习惯在 init 函数里加50~100ms的延时,再发初始化命令,否则首次通信可能失败。
5.5 下载调试失败和复位问题
用ST-Link下载失败,先看 BOOT0 引脚。BOOT0=1时芯片进入系统存储器模式,普通下载工具可能连不上;正常情况下 BOOT0 要保持在0。其次是 Debug 配置,CubeMX里如果没选 Serial Wire,ST-Link 可能只能下载一次,之后无法连接,因为引脚被复用掉了。遇到这种情况,按住复位键同时点击下载,等开始下载瞬间再松开复位,能救回来一部分。
还有一个和本项目有关的复位经验:C++ 工程里如果定义了比较大的局部对象,比如 OLED 的 framebuffer 数组有1024字节,Stack 空间默认0x400可能不够,轻微越界时程序表现很诡异:有时能跑、有时跑到一半 HardFault。稳妥做法是把 Keil 里的 Stack size 调到0x800,Heap 保持默认即可,因为我们不用动态内存。真遇到启动后立刻 HardFault,优先检查这两项。
最后分享一个我自己的习惯
这个项目做完之后,我最大的体会是:嵌入式C++别拿它当桌面开发写。不开异常、不用new、慎用继承,把类当成模块边界和状态机容器,你会舒服很多。我见过不少人为了展示C++能力,硬造一个三层继承的传感器驱动,最后调试时反而不知道回调进了哪个类,这种炫技在单片机上没有意义。
另外一个我想单独拎出来说的小技巧,是给每个硬件模块写一个selfTest()方法。比如 LED 自检就是依次亮灭三次,OLED 自检就是清屏并在固定坐标画一条对角线,串口自检就是上电打印一行版本号。main 里先跑一遍自检再进主循环,哪个模块坏了立刻就能看出来。这个习惯帮我省掉了一半的排查时间,尤其是在接线松动或者模块损坏时,不用满板子查信号,跑一次自检就知道问题出在哪一块。
这个系列聊到这里,我觉得你手头应该已经不是一个只会点灯的板子了。把今天的超声波测距小工具真正跑起来,按键、显示、串口、定时器全都能协同工作,这就算正式“接活”了。后面如果想继续深入,可以试试把自由任务换成 FreeRTOS,或者给项目加一个USB设备功能,让板子插入电脑后被识别成自定义HID设备,那又是一个全新的故事。