C++在单片机的应用(二)这个系列标题,看着普通,但真正能把C++拿到MCU上写出工程价值的人,比会用C的人少一个数量级。绝大多数51、STM32教程和例程都是C写的,你突然想把项目改成C++,脑子里冒出来的第一串问题多半是:类会不会太吃资源?虚函数还能不能用?编译出来的hex是不是大一倍?这篇我不打算重复语法,直接聊怎么落地。
内容上聚焦几个真实会撞到的问题:状态机怎么用C++建模、字符串数组怎么初始化才不会翻车、结构体链表在只有几KB RAM的片子上到底能不能用、LCD1602为什么死活显示不出字符、小车测速和触摸屏坐标映射背后的数学逻辑。适合已经玩过51或者STM32、想用C++重构项目的人,也适合那些C语法熟练但一到“类设计”就不知道从哪下手的开发者。
1. 整体设计思路:C++在单片机上到底该怎么落地
1.1 先想清楚为什么用C++,而不是为了“高级”而“高级”
先说结论:单片机上用C++,最大的绊脚石往往是开发者自己,而不是编译器或者硬件。Cortex-M0、STC8这类常见芯片,Flash通常只有8KB到64KB,RAM在1KB到16KB之间,跟PC完全不是一个量级。但这不代表C++不能碰,只是你不能把PC上写服务端那套“现代C++全家桶”原封不动搬过来。
C++在单片机上真正的价值,我总结下来是四个:类型安全、数据内聚、状态建模、跨项目复用。类型安全最典型的就是enum class,以前的C代码里一个uint8_t可以同时表示模式、状态、错误码,传错参数编译器根本不会管你。C++的强枚举能把这些错误在编译期就拦住,省下的调试时间非常可观。数据内聚就是把一个外设相关的东西收进类里,比如一个ADC采样器的缓冲区、滤波系数、通道号都是它的成员,而不是散落在全局变量里。状态建模后面会专门讲,这是C++在单片机业务逻辑里最能发光的地方。
那要避开哪些用法?异常基本不要开,RTTI不要在MCU上开,虚函数非必要不用,new和free这组动态内存操作能不用就不用,STL容器除非你做了完整的内存池配置,否则也容易把堆搞乱。这些限制听起来很多,但它们占C++特性的比重其实很小。模板、重载、命名空间、constexpr、自动类型推导这些反而是真正的好东西,而且它们在编译期就完成工作,运行时不产生任何额外开销。
做一个小类比。C语言像手动挡,空挡滑行、半联动、跟趾动作都能做,全看个人手艺,但也容易拖挡熄火。C++像带辅助系统的车,它不允许你挂着三挡起步,在编译期就把很多低级错误拦下来。你没必要开越野模式过草地,那是PC端程序员干的事。
1.2 环境与工程组织:从“一个大main.c”到“模块化C++工程”
开发环境这块,我见过太多人卡住。Keil里打开一个.c文件随便写当然最简单,但要在Keil里跑C++,你得把文件后缀改成.cpp,并且在魔术棒选项卡里确认编译器版本和C++标准。STM32CubeIDE、VS Code配PlatformIO、CLion配arm-none-eabi-gcc也是主流选择。重要提醒一句:AC5编译器只支持C++03,很多现代写法不可用;AC6(基于Clang)才支持C++11/14/17。如果你刚开始用C++做单片机,建议直接上AC6或者GCC系工具链,开C++14支持,auto、enum class、编译期常量这些特性都能用,效率高很多。
工程组织建议分三层:硬件抽象层、驱动层、应用层。硬件抽象层里面放寄存器操作和芯片厂商库的封装,这一层可以允许普通函数甚至extern "C",因为它要跟HAL库、标准外设库互相调用。驱动层是设备类,比如Lcd1602、KeyScanner、Dht11,每个类负责一种外设的“行为和状态”。应用层放业务状态机、协议解析、用户逻辑,这一层纯粹用C++写,告别C语言那种函数指针满天飞的状态。
一个很多教程没提的坑:C++里全局对象会在main()之前调用构造函数。如果你在构造函数里直接操作硬件,比如初始化定时器、拉高某个引脚,而此时时钟系统还没准备好,跑起来必翻车。稳妥做法是构造函数只做普通变量初始化,硬件操作放到一个独立的Init()方法里,由你在main()中显式调用。
模块划分上,学会用命名空间。好多嵌入式老手写C++不用命名空间,结果就是几个类全挤在全局,名字冲突一多就爆炸。把HAL层放namespace hal,驱动层放namespace driver,应用层放namespace app,代码可读性提升不是一点半点。
2. 核心语法与技巧:在单片机上写C++,和PC上有什么不同
2.1 C++字符串与数组初始化的几个常见坑
热词里出现“c++字符串数组初始化”,说明这个问题卡了很多人。单片机的RAM本来就小,字符串处理稍不注意就浪费大量内存。最典型的错误是把菜单文本、日志前缀、状态名称写成了可修改的char数组,白白占着RAM。
在裸机上,字符串应该放Flash,用const char*数组管理。
// 状态名称表,字符串保存在Flash,不占RAM const char* const kStateName[] = { "IDLE", "RUN", "ALARM", "ERROR" }; // 使用示例 void ShowState(uint8_t state) { Lcd1602::Show(0, 0, kStateName[state]); }注意kStateName的声明方式。第一个const char*表示字符串内容不可通过这个指针修改,第二个const修饰数组本身,表示指针数组不可重定向。两层const缺一不可,否则要么内容被意外改写,要么数组被意外指向别处。
另一种写法是二维字符数组,比如char names[][8]。它的好处是字符串在Flash里连续存放,坏处是每行都要按最长字符串预留空间。如果一个数组里既有“OK”又有“CONFIG_ERR”,你每行都得按11字节来开,浪费立刻显现。所以菜单、枚举文本这类不要求连续内存的,用指针数组;确实需要一块连续缓冲区做拼接的,才用二维数组。
还有字符串末尾的\0。"123"实际占4字节,不是3字节。写代码时很容易忽略这一点:
char buf[3]; strcpy(buf, "123"); // 越界,'\0'写到buf之外在C++里字符串字面量类型是const char[N],从C++11开始不能隐式转换成char*。所以你写函数时参数尽量用const char*,既能接收字符串字面量,又能防止函数内部意外修改内容。这个细节直接关系到你能否编译通过。
2.2 数据结构:结构体链表如何在有限RAM下使用
热词里有“c++结构体链表基本语法”,看得出有些人想在单片机上用链表但又怕内存爆炸。链表本身没问题,问题出在内存管理方式上。单片机项目里,任务队列、按键事件缓冲、状态历史记录,用链表表达非常自然。但要是和malloc、free绑在一起,频繁申请释放会让堆产生严重碎片,跑几天就挂。
标准做法是“静态内存池”。一次性在编译期开一个结构体数组,空闲时串成一串,使用时从池中取出,归还时挂回空闲链表。这样所有内存都在编译期分配完毕,没有碎片,执行时间也可预测。
struct EventMsg { uint16_t id; uint16_t param; uint16_t next; // 用索引代替指针,节省一半RAM }; static EventMsg s_pool[16]; static uint16_t s_free_head = 0; // 空闲链表第一个节点索引 void InitPool() { for (uint16_t i = 0; i < 15; i++) { s_pool[i].next = i + 1; } s_pool[15].next = 0xFFFF; // 链表结束标记 s_free_head = 0; } EventMsg* AllocMsg() { if (s_free_head == 0xFFFF) return nullptr; EventMsg* msg = &s_pool[s_free_head]; s_free_head = msg->next; return msg; }为什么用uint16_t索引而不是指针?32位单片机上指针占4字节,索引占2字节,同样一个链表节点能省一半内存。索引还有一个隐藏好处:节点数组可以整体复制、存储,甚至烧录到Flash里做历史记录,指针做不到这种序列化。这在做固件逆向或者协议日志回放时非常有用。
C++的struct完全可以当成C风格结构体用,同时还可以加成员函数。不过要克制,结构体加成员函数没问题,别为了炫技搞出深继承链。单片机上的结构体,保持内存布局透明比什么都重要。
2.3 常用算法落地:冒泡排序、前缀和与随机数
这几个热词里“c++冒泡排序算法”“c++前缀和”“c++随机数”比较有代表性。先说冒泡排序。单片机项目里排序的数据量通常很小,比如ADC校准表只有几十个点,按键扫描映射表十来个。这么小的数据量,复杂度O(n^2)完全不是问题。用C++模板写,可以同时兼容多个数组类型:
template<typename T, size_t N> void BubbleSort(T (&arr)[N]) { for (size_t i = 0; i < N - 1; i++) { bool swapped = false; for (size_t j = 0; j < N - 1 - i; j++) { if (arr[j] > arr[j + 1]) { T tmp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = tmp; swapped = true; } } if (!swapped) break; } }加一个swapped标志,可以避免已经有序的数据再做无意义的比较。这个是教科书上经常不强调,但工程上非常有用的优化。
前缀和适合窗口类统计。举个例子,ADC采样电池电压,需要看最近10次采样的平均值。你可以用一个环形缓冲加一个累计变量,每次更新时减旧值加新值;也可以用前缀和数组,每次直接作差。后者的好处是你可以同时查询任意长度窗口的累计值,不局限于固定窗口。在小车测速、触摸坐标均值滤波里都很实用。代价是前缀和数组多占一些RAM,所以用之前算好内存账。
随机数这里要特别提醒:std::mt19937这类算法本身没问题,但它内部状态量大,一个对象就要几百字节RAM,很多MCU吃不消。工业上常用线性同余生成器(LCG),参数选得好,质量足够应付大多数场景:
uint32_t NextRandom(uint32_t* state) { *state = *state * 1664525u + 1013904223u; return *state; } // 用定时器计数器和ADC噪声做种子,避免伪随机序列每次一样 uint32_t seed = GetTickMs() ^ GetAdcNoise(); NextRandom(&seed);很多“随机抽奖”“随机音效”项目跑出来每次都一样,就是种子写死了。种子来源可以用一个悬空ADC引脚的采样值,或者上电时多次读定时器计数器的低几位,混合出一个初始状态。
3. 实战环节:从显示到控制,把C++套进真实项目里
3.1 LCD1602显示不出字符:一次完整的排查实录
热词里“c51单片机接lcd1602显示不出字符”妥妥是高频车祸现场。我在不同板卡上碰到过至少五六种原因,按出现频率排个序:对比度电位器没调好、接线错误、初始化时序不对、IO模式配置不对、电源电压不匹配。
LCD1602的第三脚V0是液晶对比度调节端,很多新手以为它应该是GND,结果屏幕永远白茫茫或者淡到看不见。正确做法是接一个10K电位器,上电后慢慢旋转,直到第一行出现一排黑色小方块。为什么会出现方块?1602上电后内部显示RAM是随机值,有方块说明LCD控制器已经正常启动,只是没有初始化或没有写显示命令。连方块都没有,大概率是供电、背光或者对比度的问题。
接线排查要分清4线模式和8线模式。4线模式比8线省IO,但初始化时序要求更高。RS、RW、E、D4-D7每根线都要查一遍,别只查顺序忽略插紧程度。单片机IO驱动能力也要考虑,51单片机的准双向IO口输出高电平能力弱,接1602这种外设最好设置成推挽模式,否则高电平可能达不到2.0V的门限。
初始化时序这块,1602要求上电后等待超过15ms,然后写0x30(8位模式)或0x28(4位模式),再等待超过4.1ms,再写一次,最后是短延时。很多代码里延时时间不足,初始化过程刚发一半屏幕就罢工了。主频不一样延时也不一样,用STC12、STC8这类芯片注意延时函数要重新算。
用C++封装1602的时候,建议把每个底层时序操作拆成独立的私有方法:
class Lcd1602 { public: void Init(); void ShowString(uint8_t row, uint8_t col, const char* str); void Clear(); private: void WriteCommand(uint8_t cmd); void WriteData(uint8_t data); void SendByte(uint8_t bits); void PulseEnable(); };公开方法只有Init、ShowString、Clear,底层的命令、数据、时序全在私有方法里。这样业务代码里就不会出现满地飞的数据线操作,换芯片时只需要改这个类。为什么值得用类封装?因为液晶驱动是典型的“状态+时序”组合,C语言的做法是在每个函数里传入一大堆引脚宏参数,而类把这些参数变成成员,一处配置处处使用。
3.2 数码管、密码锁与状态机:用C++管理不断复杂的状态
热词里的“3461BS数码管”是典型的4位共阴数码管。多位数码管驱动必然用到动态扫描,也就是轮流点亮每一“位”。扫描速度要足够快,人眼看起来才不闪。C++封装思路和1602类似,但这里更重点的是状态管理。
以密码锁为例,最简单的需求也要经过这几个状态:等待输入、校验、成功、失败、锁定。你要是用C写,一个uint8_t state配上switch已经能跑,但状态一变多就乱。enum class可以把状态名写清楚,同时避免整型常量到处飘:
enum class PwState { WaitInput, Checking, PassOpen, FailWait, Locked }; enum class PwEvent { DigitPressed, ConfirmPressed, ClearPressed, Timeout };有了状态枚举和事件枚举,状态机就变成一个清晰的矩阵。每个状态处理事件后返回下一个状态。C++类可以把状态机数据和行为绑在一起:
class PasswordDoor { public: void HandleEvent(PwEvent event); private: PwState state_ = PwState::WaitInput; uint8_t input_buf_[6] = {0}; uint8_t input_len_ = 0; };这里还要说一句关于按键消抖的经验。状态机写得再漂亮,按键不消抖也没用。常见做法是“延时20ms后再判断一次电平”,但这会让状态机在HandleEvent里阻塞。更好的做法是按键扫描单独跑一个定时器,每2ms读一次IO,连续读到3次相同结果才认为有效,然后作为事件投递给密码锁对象。这样既能消抖,又不阻塞其他任务。
3.3 小车测速与触摸屏坐标映射:数学和外设结合
热词里“单片机小车测速”和“从触摸屏上获取到触摸点坐标后如何对应到屏幕上内容”本质上是同一件事:把物理世界的量,映射到逻辑坐标里。
测速这块,常见方案是霍尔传感器配合码盘,或者编码器。核心原理是:轮子转一圈,码盘上几个齿或者磁铁经过传感器,产生若干个脉冲。定时器固定周期(比如100ms)读取脉冲累加值,速度公式为:
速度 = 脉冲数 / 单圈脉冲数 × 轮子周长 / 定时周期
假如码盘一圈20个脉冲,轮子周长0.2米,100ms读取一次,这段时间读到40个脉冲,那么速度为:
(40 / 20) × 0.2 / 0.1 = 4 m/s
C++类可以这样组织:
class WheelSpeed { public: void OnTick() { uint32_t ticks = ticks_; uint32_t delta = ticks - last_ticks_; last_ticks_ = ticks; speed_ms_ = (float)delta * (wheel_circumference_ / pulses_per_rev_); } void AddTick() { ticks_++; } // 在中断里调用 float GetSpeedMs() const { return speed_ms_; } private: volatile uint32_t ticks_ = 0; uint32_t last_ticks_ = 0; float speed_ms_ = 0.0f; float wheel_circumference_ = 0.2f; uint32_t pulses_per_rev_ = 20; };这里用volatile修饰ticks_,因为在中断里被修改,主循环里被读取,如果编译器把它优化进寄存器,读到的就一直是旧值。AddTick用volatile uint32_t是因为在51这种8位平台上读32位变量不是原子操作,需要在读的时候关中断,否则读到一半被中断打断会出现错乱。
触摸屏坐标映射相比之下更“数学”。触摸屏输出的是一组AD值,范围比如X轴[300, 3700],Y轴[280, 3600],而屏幕像素是128×64或者240×320。线性映射公式是:
uint16_t x_pixel = (x_raw - x_min) * lcd_width / (x_max - x_min); uint16_t y_pixel = (y_raw - y_min) * lcd_height / (y_max - y_min);这个公式必须在拿到四个边界值之后再算。如果方向反了,把x_min和x_max互换即可;如果只上下边反或者左右反,单独处理一个轴就行。移植到GD32这类芯片时,逻辑完全一致,变的是LCD和触摸屏的硬件连接,以及AD值的范围。
触摸屏还有一个比边界校准更实用的点:多次采样取平均。手指按上去的时候,AD值会抖动,简单方法是连续采5次,去掉最大最小,取中间3次平均值,再去做映射。这一段话值得写在所有触摸屏项目的最前面。
3.4 顺带聊聊微型推杆:执行器驱动和控制保护
热词里“32单片机加什么驱动微型推杆”也是个高频问题。微型推杆本质是电机加减速箱加直线运动机构。驱动它不是你直接把单片机的IO接到电机线上就能转的,推杆电机工作电流动辄几十毫安到一两安,单片机IO根本带不动。
常规方案:直流有刷电机推杆,用H桥驱动芯片,比如TB6612、DRV8833、L298N,通过PWM控制速度,通过两个方向控制引脚切换推出收回。丝杆步进推杆,用A4988、DRV8825驱动,配合细分设置,可以做精确定位。
控制推杆比控制普通电机多一件关键的事:限位保护。推杆两端有行程开关,到了尽头必须切断电源,否则轻则电机堵转发热,重则机械卡死打齿。用C++封装时,把限位开关检测做成状态机的一部分:
enum class PushState { Idle, Extending, Retracting, AtLimit };每次更新PWM之前先检查限位开关,一旦触顶立即停止并清除方向请求。单片机程序跑得比机械反应快得多,靠软件判断完全来得及。更保险的是硬件上把限位开关串进电机电源回路,即使程序跑飞,机械行程也不会冲过头。
4. 常见问题与排查技巧实录
4.1 编译与链接阶段的“C++特有坑”
C和C++混编时,C文件里的函数要在C++侧声明为extern "C",否则链接器会因为名字改编找不到符号。这个出现频率极高,解决办法是给头文件加一层导出包装:
#ifdef __cplusplus extern "C" { #endif void Hal_Uart_SendByte(uint8_t data); #ifdef __cplusplus } #endif还有一个“编译过了但运行时挂”的坑:全局对象构造顺序。单片机的启动代码会调用__libc_init_array或类似函数来执行全局构造函数,但这些构造函数执行的顺序取决于链接器,跟你文件包含顺序无关。如果A对象的构造函数依赖B对象已经初始化完成,程序一上电就进入未定义行为。规避方法就是前面说的:构造函数不做跨对象依赖,统一用Init()函数按你确定顺序调用。
代码体积暴增也是常见的劝退问题。很多人第一次把几个.c改成.cpp,发现bin文件大了10%。查一下map文件,很大概率是iostream、异常处理表、RTTI这些被拉进来了。解决方案很简单:单片机工程不用iostream,用sprintf或者自己封装的格式化函数;编译选项里关闭异常(-fno-exceptions)和RTTI(-fno-rtti);不要大量使用模板元编程。下面这个表整理了高频编译错误:
| 问题现象 | 常见原因 | 处理方式 |
|---|---|---|
undefined reference to operator new | C++运行时没有完整实现 | 提供简单全局operator new或改用静态内存池 |
__cplusplus宏值为1 | 编译器语言标准级别过低 | AC5切AC6,或指定-std=c++14 |
| C文件报链接错误 | C++侧未加extern "C" | 在头文件做C/C++保护 |
.text段暴涨 | 拉了iostream或异常/RTII | 关闭异常和RTTI,换成轻量格式化函数 |
| 全局对象构造函数调硬件崩溃 | 时钟或外设尚未初始化 | 构造函数只初始化变量,另写Init() |
4.2 运行与调试阶段的高发故障
这一块我把几个热词里踩坑价值最高的都揉在一起说了。第一个是Access Violation。虽然热词说的是“c#调用c++出现access violation c0000005”,这是PC上位机常见的崩溃,但本质跟单片机的野指针一模一样:访问了不属于你的内存地址。排查思路从最近改动和可疑指针开始,逐段注释定位,不要从头到尾看代码。
第二个是单片机最常见的HardFault。C++类的封装隐藏了许多内存操作细节,一旦数组越界或者栈溢出,定位比C还痛苦,因为你看到的调用栈经常是解构函数和模板代码。预防措施比排查重要:开启编译器栈使用检测、尽量把大缓冲放在静态区而不是栈上、在HardFault处理函数里点亮一块LED作为“灯语”区分故障来源。
第三个是中断回调问题。C++不允许直接把非静态成员函数作为中断回调传给启动文件里的中断向量表,因为成员函数隐含this指针参数。解决方法是回调打桩到静态函数,再接回单例:
class Timer { public: static Timer* instance; void OnTick(); static void IrqHandler() { if (instance) instance->OnTick(); } }; Timer* Timer::instance = nullptr;中断服务函数里,instance->OnTick()访问的成员变量一定是volatile或者被原子访问保护,否则主循环和中断同时操作同一个变量,轻则数据错乱,重则HardFault。
第四个是“单片机下载失败”。热词里也有“单片机下载失败”。STC系列尤其迷信:有些板子需要断电重上电才能进下载模式,有些是串口被程序占用,有些是目标板供电不足导致下载器拉死。排查顺序:检查接线是否松动、确认波特率和芯片型号、手动断电上电、最后再怀疑芯片。
4.3 外设调试的独门经验:显示、触摸、数码管
显示类外设排查有两条铁律。第一条,先确认时序对不对,再怀疑驱动代码逻辑。用逻辑分析仪看RS、E、D4-D7引脚的信号波形,对比数据手册时序图。第二条,先换一块屏测试,再怀疑代码。1602屏幕也有批次差异,有些高仿屏对初始化命令的响应慢,需要稍微延长延时。我在一块“号称兼容”的1602上遇到过0x28之后必须加2ms以上的延时,否则显示乱码的情况,这个在正规屏幕数据手册上根本查不到。
数码管动态扫描容易栽的坑是亮度不均。如果每个位轮流点亮的时间过长,会看到明显闪烁;过短,亮度不够。经验值:刷新频率500Hz到1kHz,也就是每一位点亮时间在1ms到2ms之间。如果你用PWM控制亮度,注意别在段选和位选切换中间插入长时间计算,否则一个位点亮时间突然变长,亮度就会出现一条明一条暗。
触摸屏坐标映射如果怎么校准都偏,先判断是不是线序反了。很多触摸面板的X+和X-物理位置和注释不一致,厂商文档印刷错误也不少见。调试方法是在屏幕上画一个十字光标,点击四个角,打印几个点的原始AD值,一眼就能看出哪个轴反了。
固件逆向分析这块,虽然不是C++入门向内容,但如果你手上有一颗找不到资料的老芯片,C++逆向里你会经常遇到对象布局、虚表这些C里没有的概念。总之遇到问题先冷静,按硬件层到软件层、从静态到动态的顺序排查,比上来就改代码有效得多。
我个人在实际操作中的体会是,用C++重构一个六位数码管+矩阵按键+EEPROM存储的小项目,代码量比C版本多不到10%,但调试心态完全不同。C版本每次加功能,最少要理一遍全局变量的关系;C++版本直接在对应类里加方法就行,根本不怕动到别的模块。模板函数写一次,以后新项目直接搬,这种积累是C语言很难给你的。
下一篇系列文章我准备写C++和RTOS怎么配合,重点讲消息队列怎么安全地传给类对象、任务状态机和阻塞调用怎么拆。到时候再看实际需求,把这篇里提到的触摸校准、测速滤波、推杆堵转检测这几个场景做成一个完整的小项目拆给大家。