"看了三篇了,一行都没让我写呢。"——这条留言我刷到的时候,第一反应是有点想笑,第二反应是有点感动。想笑是因为这语气太真实了,就是读者对作者最直接的吐槽;感动是因为,能把前三篇看完并且问出这句话,说明你已经在认真等代码了,不是那种随手划过就关掉的看。那这篇就专门回应这个需求:我们正式开始写代码,而且写的不只是"代码",是带C++味道的嵌入式代码。
先把话说清楚:这是一篇"嵌入式C++"系列的正文,不是嵌入式C教程。我们用的是C++的语法、C++的封装方式,跑在STM32这种资源受限的MCU上。你能用类把外设包装起来,写起来像写桌面程序一样有结构,编译出来的固件还能塞进F103这种只有几十KB RAM的芯片里。这篇适合两类人:一类是跟着系列走到现在、心里有点小急的初学者;另一类是会用STM32但一直写C、想看看C++在嵌入式里到底怎么落地的工程师。看完你至少能干三件事:搭一个最小的嵌入式C++工程、用类封装GPIO点亮一颗LED、再用定时器中断让这颗灯自己闪。整个过程我不跳步,每个环节你都能直接照做。
好,先聊一件最要紧的事:为什么前面三篇我没让你写代码?我不是吊胃口,我在等你躲过几个大坑。
1. 为什么前几篇不急着让你写代码
1.1 前几篇到底讲了什么
往回翻一翻,这个系列前面三篇的内容其实分成两条线:一条是工具线,一条是认知线。
工具线讲的是怎么把"写C++"这件事在MCU上变成现实。交叉编译器arm-none-eabi-g++怎么装、Makefile怎么组织、链接脚本是干嘛的、启动文件里那段汇编点灯之前做了什么。这些内容虽然不直接产生代码,但没有它们,你后面每一行代码都会卡在"编译不过"或者"下载不进去"这种问题上。
认知线讲的是STM32的底层逻辑:寄存器为什么是一段"内存地址"、时钟树是怎么把外部晶振变出72MHz主频、外设模块(GPIO、定时器、串口)在这个芯片内部挂在哪些总线上。这些知识单看很枯燥,却是后面所有代码的骨架。你可以把前三篇想成驾校的交规课,看着乏味,但没它你不敢上路。
1.2 盲目写代码的三个典型翻车现场
我见过很多新手拿到板子就开始写,结果翻车方式高度一致,这里给你提前剧透一下。
第一个坑:不查时钟树,上来就配GPIO寄存器。写完灯不亮,就开始怀疑芯片坏了。其实多半是因为GPIO外设的时钟门压根没打开。STM32是一个大商场,每个外设像一个小商铺,进门刷卡(开时钟)才能用电。不刷RCC那一项,寄存器写得再对,外设也纹丝不动。
第二个坑:不知道寄存器底层是地址映射,直接拿数字去算地址。算错一位,写进了一个不存在的位段,结果程序跑飞,触发HardFault。STM32的寄存器地址是有规律的基地址加偏移,但不是让你背地址,而是要理解CMSIS头文件里的结构体指针是怎么把地址映射成GPIOA->ODR这种可读写形式的。
第三个坑:拿到一块板子,不先验证"能编译、能下载、调试器能连上"就跑去找业务代码问题。最后分不清到底是代码逻辑错了、工具链坏了,还是下载线接触不良。这就是为什么我坚持让你先搭一个能空转的工程,确认链路通了再写真正的功能代码。
1.3 到了第5篇,你的储备够不够
到这里我替你把关一下:你能在终端里敲make构建工程,知道交叉编译器和本地编译器不是一回事,知道芯片上电先跑启动文件再做C/C++运行时初始化,知道外设操作本质是读写寄存器。够了,可以开始写代码了。
如果还不太确定,也没关系,后面的示例每一条都带注释。但我要提醒你:代码能跑和懂了原理能改,是两种完全不同的境界。这一篇的示例,请你务必亲手敲一遍甚至两遍,不要复制粘贴。敲的过程就是在强化你对寄存器、对类、对编译流程的肌肉记忆。
2. 动手前最后一道准备:工程骨架
2.1 一个最小嵌入式C++工程的目录结构
我用的板子是经典的STM32F103C8T6,也就是大家常说的"蓝板子"或者"最小系统板"。下面的工程结构不依赖STM32CubeMX,只有一个Makefile、一个链接脚本、一个启动文件、一个CMSIS头文件目录和几个源文件,足够说明问题。
project/ ├── build/ ├── Makefile ├── linker.ld ├── startup_stm32f103xb.s ├── src/ │ ├── main.cpp │ └── system_stm32f1xx.c └── include/ ├── stm32f1xx.h └── core_cm3.h不要被这个结构吓到,里面绝大多数东西是可以从STM32CubeF1固件包里直接拷贝的,比如startup_stm32f103xb.s、stm32f1xx.h、system_stm32f1xx.c。你自己真正要写的只有main.cpp和Makefile,外加一个linker.ld链接脚本。
2.2 嵌入式C++编译的几个关键开关
同样是arm-none-eabi-g++,编译选项和写桌面C++有很大区别,这份建议你可以直接抄。
-fno-exceptions -fno-rtti --specs=nano.specs -Wl,--gc-sections -mcpu=cortex-m3 -mthumb-fno-exceptions:关闭C++异常处理。MCU上的异常展开需要大量栈空间和运行时支持,在嵌入式场景里基本不用,关掉能省不少代码体积。-fno-rtti:关闭运行时类型识别(dynamic_cast、typeid)。RTTI会为每个有虚函数的类生成类型信息表,在MCU上属于浪费。--specs=nano.specs:使用newlib-nano精简C标准库,printf和各种标准函数的体积会大幅收缩。-Wl,--gc-sections:配合-ffunction-sections -fdata-sections,链接器会把没有被引用的函数和数据段丢弃,这是嵌入式工程压缩固件的常规组合。
有一点需要特别留意:如果你在代码里用了printf,并且要打印浮点数,还需要在链接时加上-u _printf_float,否则浮点格式化代码会被newlib精简掉,输出结果是空的或者固定为垃圾值。调试阶段我建议先只打印整数,等后面串口篇再有需要再开浮点。
2.3 先验证链路,再写业务代码
写LED操作之前,先做一件看起来有点傻的事:烧录一个空转程序。就这么一段:
#include "stm32f1xx.h" int main() { while (1) { __asm volatile("nop"); } return 0; }这个程序烧进芯片完全没有任何现象,它的目的只有一个:验证交叉编译、链接、烧录、调试这整条链路是通的。这一步通过了,后面写的代码产生的问题才可能定位到代码本身。
我把Makefile的核心部分给你,你可以直接套用:
TARGET = blink.elf CC = arm-none-eabi-g++ CFLAGS = -mcpu=cortex-m3 -mthumb -Os \ -ffunction-sections -fdata-sections \ -fno-exceptions -fno-rtti \ -MMD -Iinclude LDFLAGS = -T linker.ld --specs=nano.specs -Wl,--gc-sections SRCS = src/main.cpp src/system_stm32f1xx.c OBJS = $(SRCS:%.cpp=build/%.o) $(TARGET): $(OBJS) $(CC) $(OBJS) $(LDFLAGS) -o $@ build/%.o: %.cpp @mkdir -p build $(CC) $(CFLAGS) -c $< -o $@ flash: st-flash write $(TARGET:.elf=.bin) 0x08000000烧录用st-flash,前提是板子上的ST-Link被系统识别。如果你用的是STM32CubeProgrammer,命令行换成STM32_Programmer_CLI -c port=SWD -w blink.bin 0x08000000,效果一样。
2.4 全局对象构造的隐藏依赖
这一步多说几句。C++全局对象的构造函数是在main之前由__libc_init_array调用完成的,而__libc_init_array这个符号是在启动文件startup_stm32f103xb.s里被执行的。如果你用的是精简版或自己改过的启动文件,没有调用__libc_init_array,那么所有全局对象都不会初始化,成员变量全是复位初值,程序一进main就会表现得"莫名其妙"。
这个问题非常阴险,因为编译不报错、链接不报错、下载也正常,但行为就是不对。所以从这一篇开始,请记住一个检查点:系统启动链路里必须包含__libc_init_array这一步。STM32CubeF1固件包自带的标准启动文件是包含的,放心用,但别自己乱动它。
3. 第一行嵌入式C++代码:点亮一颗LED
3.1 寄存器操作:GPIO点亮LED的底层原理
点灯在嵌入式里相当于编程界的"Hello World",但它并不是简单的一句输出,而是对GPIO外设的完整配置过程。以F103C8T6为例,板载LED通常接在PC13引脚,高电平熄灭、低电平点亮,也就是"低有效"。
要让这颗LED亮起来,需要做三件事:第一件事给GPIOC这个外设打开时钟门;第二件事把PC13配置为推挽输出模式;第三件事让引脚输出低电平。看代码:
#include "stm32f1xx.h" int main() { // 1. 打开GPIOC外设时钟。GPIO在STM32F1系列挂在APB2总线上 RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; // 2. 配置PC13为通用推挽输出,输出速度2MHz // F1系列40脚以上的芯片,引脚8~15的模式由CRH寄存器控制 // PC13对应CRH中第(13-8)=5个引脚位,每引脚占4位 GPIOC->CRH &= ~(0xFUL << (5 * 4)); GPIOC->CRH |= (0x2UL << (5 * 4)); // MODE=10(2MHz),CNF=00(推挽输出) // 3. 输出低电平点亮LED GPIOC->BRR = (1UL << 13); while (1) { // 保持亮 } return 0; }这里有一个很多新手容易混淆的点:STM32F1系列的GPIO模式寄存器是CRL和CRH,每4位描述一个引脚,前两位CNF配置功能类型,后两位MODE配置输入输出和速度;而MODER这种按2位描述的寄存器结构是STM32F4系列才有的。如果你在网上看到一段F4代码,直接拿去F1上跑,寄存器位段完全对不上,灯不亮还是小事,写坏配置导致异常才是大麻烦。做嵌入式,不但要会看代码,还要会看是哪一款芯片、哪一个系列的参考手册。
3.2 用C++类封装GPIO:从裸寄存器到工程化
前面那一段代码能跑,但只能算C语言风格的GPIO操作。既然题目是嵌入式C++,就要把这层寄存器操作包进类里,让外部调用变得干净整洁。下面这个Gpio类是我在教学项目中常用的简化版:
#ifndef GPIO_HPP #define GPIO_HPP #include "stm32f1xx.h" namespace board { class Gpio { public: enum class Mode { Output, // 仅演示输出模式 Input }; Gpio(GPIO_TypeDef* port, uint8_t pin, Mode mode) : port_(port), pin_(pin) { // 打开对应端口的时钟 if (port_ == GPIOA) RCC->APB2ENR |= RCC_APB2ENR_IOPAEN; if (port_ == GPIOB) RCC->APB2ENR |= RCC_APB2ENR_IOPBEN; if (port_ == GPIOC) RCC->APB2ENR |= RCC_APB2ENR_IOPCEN; if (mode == Mode::Output) { // F1系列:CRL控制Pin0~7,CRH控制Pin8~15 uint32_t shift = (pin_ < 8) ? (pin_ * 4) : ((pin_ - 8) * 4); volatile uint32_t* cr = (pin_ < 8) ? &port_->CRL : &port_->CRH; *cr &= ~(0xFUL << shift); *cr |= (0x2UL << shift); // 推挽输出 2MHz } } void set() { port_->BSRR = (1UL << pin_); } void reset() { port_->BRR = (1UL << pin_); } void toggle() { if (port_->ODR & (1UL << pin_)) { reset(); } else { set(); } } private: GPIO_TypeDef* port_; uint8_t pin_; }; } // namespace board #endif // GPIO_HPP有了这个类,主函数里点灯就变成这样:
#include "stm32f1xx.h" #include "gpio.hpp" using board::Gpio; int main() { Gpio led(GPIOC, 13, Gpio::Mode::Output); while (1) { led.set(); for (volatile uint32_t i = 0; i < 1000000; i++); led.reset(); for (volatile uint32_t i = 0; i < 1000000; i++); } return 0; }我来解释一下这个类设计的几个关键点。
构造函数里自动完成了时钟使能和模式配置,调用方不需要再关心寄存器细节。set、reset、toggle分别对应BSRR置位、BRR清零、ODR翻转三种硬件操作。BSRR是32位寄存器,低16位写1就是置位对应引脚为高电平,BRR低16位写1是清零,这样操作不需要读-改-写,避免了多线程或中断环境下的竞态。
volatile关键字我觉得值得单独说一句。GPIO寄存器地址指向的是硬件外设,CPU读之前和读之后,硬件寄存器随时可能被外设自己改变。如果不加volatile,编译器可能会认为某个寄存器值在循环里没变,于是优化成只读一次。这是最典型的"代码看起来对但是行为不对"的坑之一。
你可能会问:这个类是不是太简单了,生产环境够用吗?说实话,这只是教学版本。工程上的GPIO类通常会结合模板在编译期计算寄存器地址,会有更细的读引脚功能,会区分开漏、推挽、上拉、下拉等模式。但核心思想和这个一模一样:把寄存器操作藏在类接口后面,使用方只关心"这个引脚干嘛的"而不是"这个寄存器的第几位"。先把这个思想学会,后面再往里面加东西很自然。
3.3 编译烧录,看到第一束光
把代码抄进工程,在项目根目录执行:
make如果一切顺利,build/目录下会出现blink.elf和blink.bin。烧录到芯片里:
make flash或者用OpenOCD:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program build/blink.elf verify reset exit"板上LED开始闪的时候,你算是真正跨进了嵌入式C++编程的门。我这里说的"闪"是大约1MHz的循环延时,实际上这个软件延时极其不准确,不同优化级别、不同编译器甚至不同温度下延时都能差不少。所以下一节我要换一种更可靠的方式让LED动起来。
4. 从"亮灯"到"动起来":定时器与中断
4.1 定时器在STM32里是什么角色
软件延时让MCU空转数百万条指令,这不是工程上该有的做法。要精确控制时间,得用定时器外设。你可以把定时器想象成一个室内的秒表计数器:每来一个时钟脉冲,计数器的值加一;当计数值等于你设定的自动重载值时,清零并触发一次更新事件。如果使能了更新中断,CPU就会停下来去执行一次中断服务函数,然后再回来继续干活。
还是以F103为例,TIM2挂在APB1总线,初始化它需要四步:开时钟、配置预分频PSC、配置自动重载ARR、使能中断和启动定时器。我给一个精确到1ms中断一次的配置:
void timer2Init() { // 1. 打开TIM2外设时钟 RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; // 2. 预分频:72MHz / (7200) = 10kHz // 也就是说计数器每递增1需要0.1ms TIM2->PSC = 7200 - 1; // 3. 自动重载:10kHz / 10 = 1kHz,即1ms中断一次 // 到这里,溢出周期= PSC+1 再乘 ARR+1,再除以定时器时钟 TIM2->ARR = 10 - 1; // 4. 清中断标志、使能更新中断、启动定时器 TIM2->SR = 0; TIM2->DIER |= TIM_DIER_UIE; NVIC_EnableIRQ(TIM2_IRQn); TIM2->CR1 |= TIM_CR1_CEN; }这里有个小学数学但非常重要的地方:PSC = 7200 - 1,有些新手看到减一就困惑,为什么不能直接写7200?因为预分频器的实际分频系数等于寄存器值加一。PSC写7199,实际是7200分频;ARR写9,实际是10个周期。所以定时器溢出频率的计算公式是:
溢出频率 = 定时器时钟 / ((PSC+1) * (ARR+1))72MHz除以7200得到10kHz,再除以10得到1kHz,也就是1ms。如果你的板子外部晶振不是8MHz或者锁相环倍频系数不同,定时器时钟可能不是72MHz,那就不能直接抄这个数字。这是嵌入式开发里最常见的"照抄翻车"点,一定要自己去查对应芯片的时钟树。
4.2 中断服务函数如何与C++和睦相处
C++编译器会对函数名做名字修饰(name mangling),用于支持函数重载。但中断向量表里的函数符号是芯片厂商在链接层面写死的,名字必须是TIM2_IRQHandler这样不带修饰的形式。这时候就需要extern "C"来告诉链接器:这个函数按C语言规则链接。
另外还有一个C++特性需要绕开:类成员函数有一个隐藏的this指针,不能直接被中断向量表调用。所以通用套路是,中断向量表对应的函数用extern "C"包装,然后转调类的静态成员函数。看这个升级版定时器类:
#include "stm32f1xx.h" #include "gpio.hpp" using board::Gpio; // 全局LED对象:PC13低有效 Gpio led(GPIOC, 13, Gpio::Mode::Output); // 中断标志与软时钟计数 volatile uint32_t tick_ms = 0; extern "C" { void TIM2_IRQHandler(void) { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR = 0; // 清更新标志,否则会一直进中断 tick_ms++; // 软时钟:每次中断+1,代表过了1ms led.toggle(); // 1ms翻转一次,输出500Hz方波 } } } int main() { RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; TIM2->PSC = 7200 - 1; TIM2->ARR = 10 - 1; TIM2->SR = 0; TIM2->DIER |= TIM_DIER_UIE; NVIC_EnableIRQ(TIM2_IRQn); TIM2->CR1 |= TIM_CR1_CEN; while (1) { // 主循环在这里,可以稍后基于tick_ms做调度 } }注意tick_ms和Gpio led都要用volatile吗?tick_ms是中断和主循环共享的变量,必须加volatile,防止编译器把它的值缓存到寄存器里而不重新读内存。led对象本身在中断里调用toggle,其内部的寄存器和成员变量访问都涉及硬件寄存器的volatile访问,这一点类内部已经保证了。
4.3 中断服务函数里能不能乱来
很多初学者一进中断就放飞自我,又是打印又是malloc。在8位单片机上也许还能忍,在ARM Cortex-M3上,中断里写一堆重代码会带来两个问题:一是中断响应时间被拉长,二是如果中断里调用了不可重入的函数,会发生数据错乱。
我的建议是先把这条规则焊死在脑子里:中断服务函数只做最小工作,比如清标志、累加计数、翻转GPIO、置个标志位。真正耗时的处理放到主循环里做。样,定时器只负责"到点了通知你",主循环负责"接到通知后慢慢处理"。
C++里还有一个潜在坑:如果你在中断里调用了new/delete,而堆分配器不是线程安全的,多个中断嵌套或者中断与主循环竞争同一把锁,轻则内存碎片,重则死锁。所以嵌入式C++明确禁用动态内存分配的写法,全局对象在启动阶段一次性构造好,运行期不new不delete。
下面给出一个更工程化的扩展:用函数指针把定时器回调解耦出来,这样同一个定时器类可以驱动不同模块。
class Timer { public: using Callback = void (*)(); static void init() { RCC->APB1ENR |= RCC_APB1ENR_TIM2EN; TIM2->PSC = 7200 - 1; TIM2->ARR = 10 - 1; TIM2->SR = 0; TIM2->DIER |= TIM_DIER_UIE; NVIC_EnableIRQ(TIM2_IRQn); TIM2->CR1 |= TIM_CR1_CEN; } static void setCallback(Callback cb) { callback_ = cb; } static void isr() { if (TIM2->SR & TIM_SR_UIF) { TIM2->SR = 0; if (callback_) { callback_(); } } } private: static Callback callback_; }; Timer::Callback Timer::callback_ = nullptr; extern "C" { void TIM2_IRQHandler(void) { Timer::isr(); } }使用方只需要:
void onTick() { led.toggle(); } int main() { Timer::init(); Timer::setCallback(onTick); while (1) {} }这种"中断入口固定、业务回调可替换"的模式,在单片机裸机开发中非常实用,代码复用性和可读性都会好很多。后面你写串口接收、ADC采样、PWM波形的时,都可以沿用同样的思路。
5. 常见问题与排查实录
5.1 编译期报错:undefined reference to__cxa_pure_virtual
这个错误通常出现在你定义了抽象类并声明了纯虚函数,但某个纯虚函数没有提供实现,或者编译器需要为纯虚函数生成桩代码时,运行库中缺少对应的符号。最常见的原因是在嵌入式C++工程里使用了虚函数,而你的链接参数没有把相应的运行时支持包含进来。
排查方法:先确认是否真的需要虚函数。如果你的类体系里只有一个实现、不存在运行时多态需求,尽量避免虚函数,这既是省空间也是省这类麻烦。如果确实需要,给所有纯虚函数提供一个默认实现,或者检查链接参数中是否混用了--specs=nano.specs和标准libstdc++,导致符号解析不一致。
5.2 编译下载都成功,但LED怎么都不亮
按优先级排查:先量引脚电压。万用表测PC13对GND,如果点亮逻辑是对的但电压一直是高,很可能你板子的LED实际上是高电平点亮,跟前文假设的"低有效"相反。把BRR换成BSRR再试。接着查时钟有没有开,RCC->APB2ENR是否真的置位了IOPCEN。再查CRH的位段算得对不对,PC13在CRH里的偏移是第5组,也就是位20到23,容易算错成第13组。
还有一个隐蔽原因:你用的是F103,头文件和启动文件却拷的是F411的。芯片型号和头文件家族不对,编译虽然能过,但寄存器地址表完全不同,写进物理寄存器的数就歪了。所以拿到任何示例,第一件事核对头文件版本。
5.3 全局对象不初始化,函数跑得乱七八遭
上电之后程序没有按预期工作,点灯类对象里的port_明明是GPIOA,配置却写到了错误的寄存器上。这种问题十有八九是启动文件缺失了对__libc_init_array的调用,全局对象构造函数直接没执行。
解法:检查启动文件startup_*.s的Reset_Handler,确认在跳转到main之前有这段代码。标准启动文件长这样:
bl SystemInit bl __libc_init_array bl main如果只有bl main,需要补上__libc_init_array。如果你的工程是自己拼接的,这也解释了为什么我前面强调:不要轻易简化启动文件,C++运行时初始化是嵌入式C++里最容易踩的地方。
5.4 程序跑着跑着进HardFault
HardFault是所有STM32开发者绕不开的噩梦。引发原因太多了,但在刚开始写代码这个阶段,最常见的就是访问了未使能的外设时钟域、数组或指针越界、栈空间溢出。先看调试器里的Fault状态寄存器(SCB->CFSR),能区分是总线错误、存储管理错误还是用法错误。
栈溢出这一项我要多说一句:C++临时对象、函数调用和中断嵌套都需要栈空间。F103默认启动文件给的栈大小可能是1KB,如果你的C++代码里创建了较大的局部对象,很容易就爆了。建议第一件事把启动文件里的栈大小改成4KB或8KB,反正这芯片有20KB RAM,调试阶段富裕点没坏处。
5.5 ST-Link连接失败
这个不算代码问题,但也让人头疼。现象是烧录工具报告Could not connect to target。原因无外乎三种:ST-Link驱动没装好、SWDIO/SWCLK接线接触不良、目标板供电异常。另外如果你的程序一上电就把SWD引脚复用成了GPIO,也会导致调试器连不上,这时按住复位键再点连接,或者用st-flash的--connect-under-reset参数,一般能救回来。
5.6 定时器中断频率不对
中断频率不对,首要检查的是系统时钟到底是不是72MHz。很多最小系统板的晶振是8MHz,但也有贴了不同的晶振或者外部高速时钟没起振导致系统跑在HSI内部8MHz的情况。直接在调试器里看RCC->CFGR寄存器的PLL相关位,确认系统时钟实际频率。这才是PSC和ARR计算的基础,基础错了后面全错。
再检查APB1分频器的设置。F103的定时器时钟不总是等于APB1总线时钟,当APB1分频系数不等于1时,定时器时钟是APB1的两倍。你要对着参考手册的时钟树一节确认一遍,别拿着公式死算。
用一口气写出来的代码,才是真正学到的
这篇终于让你动笔了,而且是往一块真实的STM32芯片里写C++代码。我个人带新人的习惯是,第一节课就算环境再麻烦,也要让他们亲手从寄存器点灯开始,而不是直接丢一个CubeMX生成的工程给他们。看起来"土",但能把"外设就是一段内存地址、编程就是操作寄存器"这个底层肌肉记忆建立起来。有了这个底子,后面上HAL库、上RTOS、上C++的模板元编程,都不会心虚。
接下来的系列里还可以往这些方向扩展:把串口封装成输出流,用<<打印调试信息;用SysTick做任务调度的时间片;或者把LED改成PWM呼吸灯。你手头有了GPIO和定时器这两个地基,后面每加一个外设,无非就是重复两件事:开时钟、操作寄存器,然后用类把它包起来。祝你点灯顺利。