"看了三篇了,一行都没让我写呢"——这条留言几乎出现在我每一期的评论区,我憋了三篇,就是为了这一篇能把这句话接住。嵌入式C++和PC端写C++最大的不同,就是你没办法打开一个IDE、新建个文件、敲个cout然后点运行,打印结果直接怼到屏幕上。STM32要跑起来,你得先和硬件约定好"代码放哪、变量放哪、寄存器在哪、时钟开没开",这些东西没有理清楚,你写出来的代码就是开盲盒。前三篇我一直在铺垫存储映射、寄存器访问、编译链接模型,这期开始,我们真的动手,从手动搭建最小工程到用C++点亮一颗LED,全程不用HAL库,不带魔术配置,把每一个步骤都摊开讲清楚。
适合谁看?已经看过这个系列前几篇但还没下手的朋友,或者被CubeMX生成代码弄得一知半解、想在芯片层面建立确定感的开发者。看完这一篇,你会获得一个完全由自己掌控的STM32工程模板,以及一套可以继续扩展的C++封装写法。
1. 为什么前三篇我硬是压着你不让写代码
很多人觉得"写代码"才是学嵌入式的一切,代码确实重要,但嵌入式里代码只是最上面一层。你真正面对的是一个拥有独立地址空间、需要你手动管理时钟、外设和中断的单片机,动手前脑子里必须有一幅地图。
1.1 嵌入式开发不是"新建工程写个Hello World"
PC开发里,操作系统帮你把程序加载进内存,库函数帮你做格式化输出,你只需要关心业务逻辑。STM32上没有操作系统,没有标准的输入输出,连"内存从哪里开始、多大"都要你自己通过链接脚本告诉编译器。
这一点和"上网需要先配IP地址"一个道理。你手上这台机器出厂时什么都没有,你得先给它一个IP、一条路由,它才知道往哪儿发送数据。STM32也一样,写第一行业务代码之前,要先回答三个问题:
- 程序烧进去之后放在哪一段Flash上?答案通常是
0x08000000附近。 - 栈指针从哪个值启动?不设置的话,系统一复位就会跑飞。
- 端口时钟开了没有?没开时钟,你配置寄存器等于对着空气说话。
这些问题的答案,就是前三篇反复强调的存储映射、寄存器、启动流程。我故意没有在第一天让你写代码,因为只给代码不给流程,你调不通的时候根本无从下手。一旦这些基础在自己脑子里扎了根,今天这第一行代码,你一晚上就能跑通。
1.2 前三篇埋下的伏笔,这一篇全部用上
回顾一下前三篇的核心内容:第一篇讲芯片存储映射,Flash和RAM的物理地址范围;第二篇讲寄存器操作,为什么寄存器必须用volatile修饰;第三篇讲编译链接的整体概念,目标文件、段、链接脚本的作用。这期全都会落到实处。
举一个最直接的例子:第三篇提到过"段(Section)"这个概念。当时听起来很抽象,今天你会亲手在链接脚本里写.data段、.bss段,亲眼看到代码和数据如何被安排进内存。你没有白看,之前那些"与我无关"的概念,今天全部变得具体了。
2. 第一件产品,从手拉最小STM32工程开始
我坚持不用IDE的一键工程模板,比如STM32CubeMX或Keil的默认工程。不是说它们不好,而是它们隐藏了太多关键信息。等你从一个型号换到另一个型号、从开发板换到自制板,那些被IDE藏起来的东西会突然跳出来咬你一口。手动搭一次最小工程,值得。
2.1 四个文件组成的最小工程
一个能支持C++的最小STM32工程,只需要四个部分:
| 文件/内容 | 作用 |
|---|---|
链接脚本(.ld) | 告诉编译器芯片Flash和RAM的地址、容量,以及各个段如何排布 |
启动文件(.s) | 初始化栈指针、处理复位入口、中断向量表 |
main.cpp | 你的业务代码入口 |
Makefile | 把上面几个文件用正确的参数编译、链接、生成烧录文件 |
我当前用STM32F103C8T6这块最常见的芯片做示范,板子是蓝丸那种,板载LED接在PC13上,低电平点亮。别急着抄代码,先理解每个文件为什么存在。
2.2 链接脚本:用文字向编译器描述这块芯片
先看链接脚本的核心部分,这是我对F103C8T6做的最小可用的版本:
/* stm32f103c8t6.ld */ MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K } _estack = ORIGIN(RAM) + LENGTH(RAM); SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); *(.text) *(.text*) . = ALIGN(4); _etext = .; } >FLASH .rodata : { . = ALIGN(4); *(.rodata) *(.rodata*) } >FLASH .data : { . = ALIGN(4); _sdata = .; *(.data) *(.data*) . = ALIGN(4); _edata = .; } >RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; __bss_start__ = _sbss; *(.bss) *(COMMON) . = ALIGN(4); _ebss = .; __bss_end__ = _ebss; } >RAM .init_array : { __init_array_start = .; KEEP(*(.init_array*)) __init_array_end = .; } >FLASH }有些细节我提一下,不然你连抄都不知道为什么。
.isr_vector是中断向量表,统一放在Flash开头,因为芯片复位后第一条指令要到这里取。.data段比较特殊:代码里初始化过的全局变量,其初值在Flash里,但运行时这些变量要被复制到RAM中才能被读写,所以写法是>RAM AT> FLASH,前面的RAM是运行时地址,后面的FLASH是存储地址。.bss段则是未初始化或零初始化的变量,只需要在RAM里占位置,不需要从Flash复制。
.init_array是C++特有的段。这个段里存的是一堆函数指针,指向全局对象构造器,启动时会依次调用它们。没有这个段,你定义在全局的C++类对象构造函数永远不会被执行。这也是为什么纯C玩不转,或者某些用C写的"扩展名为cpp但本质是C风格"程序没遇到问题——因为压根没有需要构造的全局对象。
2.3 启动文件:最简版本我给你了
很多教程让你直接在CubeMX里生成启动文件,然后一辈子不见它。我建议至少手写一次,哪怕只保留必需的部分。下面是浓缩到极致的启动文件:
/* startup.s */ .syntax unified .cpu cortex-m3 .thumb .section .isr_vector .word _estack .word Reset_Handler .word Default_Handler /* NMI */ .word Default_Handler /* HardFault */ .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word Default_Handler /* SVCall */ .word 0 .word 0 .word Default_Handler /* PendSV */ .word Default_Handler /* SysTick */ .text .thumb_func .global Reset_Handler Reset_Handler: LDR R0, =_estack MOV SP, R0 LDR R0, =_sdata LDR R1, =_edata LDR R2, =_sidata copy_data: CMP R0, R1 BEQ zero_bss LDR R3, [R2], #4 STR R3, [R0], #4 B copy_data zero_bss: LDR R0, =_sbss LDR R1, =_ebss MOVS R2, #0 clear_bss: CMP R0, R1 BEQ run_ctors STR R2, [R0], #4 B clear_bss run_ctors: BL __libc_init_array BL main loop_forever: B loop_forever .thumb_func .global Default_Handler Default_Handler: B loop_forever注意几个关键处。_estack定义在链接脚本里,它是RAM起点 + 容量,也就是这块芯片地址最高的位置。ARM Cortex-M的栈是向下增长的,所以把栈顶指针初始化为RAM最高地址是正确的。
接下来启动文件要干三件事:把.data段从Flash复制到RAM、把.bss段清零、调用__libc_init_array执行C++全局构造函数,最后才进入main。__libc_init_array来自编译器的C运行库,它会扫描.init_array段逐个调用构造器。这一步就是C++程序和纯C程序在启动路径上最大的区别。
3. 终于写代码了:点亮板载LED的第一行C++
好了,地基铺完,现在真真正正开始写代码。我不会一上来就给你封装好的漂亮类,先把最原始的寄存器版本写出来跑通,让你看清楚"一行代码到底在操作什么",再去封装。
3.1 寄存器地址是怎么查出来的
点灯需要操作三个寄存器:
RCC_APB2ENR:地址0x40021018,控制APB2总线上的外设时钟,我们用它给GPIOC开时钟。GPIOC_CRH:地址0x40011004,配置PC8到PC15的引脚模式,我们要把PC13配置成推挽输出。GPIOC_ODR:地址0x4001100C,输出数据寄存器,写1引脚输出高电平,写0输出低电平。
这些地址不是我背下来的,需要查阅STM32F103数据手册里的存储映射表和外设寄存器描述。因为我用的是F103C8T6,GPIOC挂在APB2总线上,它的基地址是0x40011000,CRH是该基地址偏移0x04,ODR偏移0x0C。每个系列、每个型号可能都不一样,不要套用别的芯片的值。
PC13属于高8位引脚,所以配置寄存器用CRH。它的第13引脚对应CRH寄存器中的位20到位23这4个bit:前2位是MODE[1:0],后2位是CNF[1:0]。推挽输出50MHz在F103中的编码是0b0011,也就是0x3。
提示:不同的LED接法极性不一样,板载LED通常是低电平点亮,因为LED一端接3.3V,另一端通过GPIO输出拉低来形成电流回路。如果你自己的板子LED接法相反,把点灯的代码逻辑反过来即可。
3.2 第一版点灯代码:先能看见亮,再谈优雅
// main.cpp - 第一版:直接操作寄存器点灯 #include <cstdint> void delay(volatile std::uint32_t count) { while (count != 0) { __asm volatile("nop"); --count; } } int main() { constexpr std::uint32_t RCC_APB2ENR = 0x40021018UL; constexpr std::uint32_t GPIOС_CRH = 0x40011004UL; constexpr std::uint32_t GPIOС_ODR = 0x4001100CUL; auto& rcc = *reinterpret_cast<volatile std::uint32_t*>(RCC_APB2ENR); auto& crh = *reinterpret_cast<volatile std::uint32_t*>(GPIOС_CRH); auto& odr = *reinterpret_cast<volatile std::uint32_t*>(GPIOС_ODR); // 1. 开启GPIOC端口时钟,对应RCC_APB2ENR的IOPCEN位(bit4) rcc |= (1UL << 4); // 2. 配置PC13为推挽输出,50MHz,先清零再置位 crh = (crh & ~(0xFUL << 20)) | (0x3UL << 20); while (true) { odr &= ~(1UL << 13); // PC13输出低电平 -> LED亮 delay(500000); odr |= (1UL << 13); // PC13输出高电平 -> LED灭 delay(500000); } }注意上面代码里的GPIOС,我在中文语境里写了好几处西里尔字母C,实际你的代码里请用普通的ASCII字母GPIOC,否则编译会报未定义标识符。这是我故意提醒你"复制粘贴时一定看清楚字符"的坑,别问我怎么知道的。
3.3 这段代码为什么能工作:volatile和指针解析
很多新手第一次写嵌入式C++时常被reinterpret_cast<volatile std::uint32_t*>绕晕,其实拆开看就两层意思。
reinterpret_cast的作用是把一个整数地址转换成一个指针,这在硬件寄存器访问里无法避免。寄存器在芯片内部就是一个特定地址上的内存单元,你不转换编译器不认账。volatile则是对编译器的承诺:这个内存位置的值可能在当前程序之外被改变,也可能当前程序对它的写入需要被立刻"看见",所以每次读写都必须真的访问一次内存,不能把它优化到寄存器缓存里。
举个例子,delay函数里我写了一个while(count != 0)的循环。如果没有volatile修饰,-O2开启后编译器可能认为这个循环没有外部副作用,直接把整个循环优化掉。加了volatile后,编译器老老实实每次判断都读取这个变量。这也是为什么嵌入式里写空延时循环必须加上volatile,否则你的程序会变成"要么永远亮、要么永远灭",根本看不到闪烁效果。
这个版本能跑,但代码里没有任何C++的"腔调"。别急,下一节先把跑起来的底层原理讲完,最后我会带你把它重构回一个像样的C++工程。
4. main之前发生了什么:C++程序启动环节拆解
你大概率遇到过一种情况:明明程序逻辑没问题,下载到板子上就是不工作,于是开始怀疑硬件、怀疑连线、怀疑人生。其实很多时候问题出在main之前的启动流程上。
4.1 Reset_Handler不是简单的"跳到main"
从启动文件可以看到,芯片复位后首先执行Reset_Handler,它先给栈顶指针赋值,然后复制.data段、清零.bss段。这两个步骤如果没有做好,会发生什么呢?
.data段复制出了问题,全局变量的初值就是错的。你明明写了int flag = 1;,实际运行时flag可能是0,甚至是一个随机值。.bss段没清零,那些默认应该为0的全局变量同样不可信。
这些步骤和C++直接相关。因为在C++里,全局对象的构造顺序依赖两个基础条件:一是.data段里的内部状态是对的,二是.init_array里的构造函数列表被正确执行。任何一个环节被省略,你都可能看到"全局对象构造函数没跑"的诡异现象。
4.2 C++和C启动流程的差异:全局对象构造
纯C程序在启动时只需要设置栈、拷贝数据、清零BSS、调用main就结束了。C++程序多了一条潜在路径:编译链接时,每个全局对象会把自己的构造函数地址放到.init_array段,启动时__libc_init_array会按顺序调用这些函数指针。
但要记得,.init_array段必须在链接脚本里显式保留。如果你用的链接脚本是别人写来给纯C工程用的,里面没有.init_array,结果就是你的类对象悄无声息地"跳过"构造过程。
我用一个最小化的类来说明这个问题:
class Led { public: Led() { // 这个构造函数在 main 之前被调用 // 这里单纯用来演示 } }; Led led; // 全局对象如果.init_array段和启动流程不完整,led的构造函数永远不会执行,后面所有对led的操作都建立在错误状态上,你会得到一个"看起来完全没有规律"的故障。排查半天往往发现不是业务逻辑的问题,而是启动基础设施缺了东西。
注意:这也是为什么我不推荐完全从零手写复杂启动文件时直接抄"别人精简过的版本"。一定要确保启动文件里有
BL __libc_init_array,且链接脚本里有.init_array段。
4.3 链接期常见的三个编译错误
我第一次从纯C转向C++工程时,链接报了一堆错误,常见的有这些:
undefined reference to '__dso_handle' undefined reference to '__gxx_personality_v0' undefined reference to '__cxa_pure_virtual'__dso_handle是C++运行时用来处理全局对象析构的符号。嵌入式里几乎不会用到动态卸载共享库,析构函数基本没有机会被调用,但链接器依然希望找到这个符号。解决方法有两种:一是链接时不要用-nostdlib把C++运行时全关掉,而是使用--specs=nano.specs保持精简的C库;二是在代码里手动补一个占位定义:
extern "C" void* __dso_handle = nullptr;__gxx_personality_v0和异常处理有关,如果你在嵌入式里不用C++异常,编译参数里要明确加上:
CXXFLAGS += -fno-exceptions -fno-rtti这两个参数既减小代码体积,又避免链接器去找异常和运行时类型识别相关的符号。嵌入式里绝大多数场景用不到异常,关闭它们是合理选择。
__cxa_pure_virtual则是纯虚函数被调用时的兜底函数,如果你的类有抽象接口,理论上应该实现一个空的兜底定义。虽然我没见过正常程序里哪个纯虚函数真的被调到了,但链接器看到-fno-rtti后可能依然需要它,稳妥起见可以补上空实现:
extern "C" void __cxa_pure_virtual() { while (true); }5. 升级为可复用的工程写法:模板、类和命名空间
跑通第一版点灯,你已经有了完整的最小工程。接下来如果不做任何整理,代码量一旦上去,寄存器地址散落各处,就是一场灾难。是时候用C++的手段把代码整理成可复用的样子了。
5.1 用模板类替代魔法数
C++最擅长的事情之一就是编译期抽象。嵌入式开发里最常见的抽象对象就是"寄存器+位操作"。我写了一个简单的模板类Reg:
// reg.hpp #pragma once #include <cstdint> template<std::uint32_t Addr> class Reg { public: static volatile std::uint32_t& ref() { return *reinterpret_cast<volatile std::uint32_t*>(Addr); } static std::uint32_t read() { return ref(); } static void write(std::uint32_t value) { ref() = value; } static void set(std::uint32_t mask) { ref() |= mask; } static void clear(std::uint32_t mask) { ref() &= ~mask; } }; using RccApb2Enr = Reg<0x40021018UL>; using GpioC_Crh = Reg<0x40011004UL>; using GpioC_Odr = Reg<0x4001100CUL>;这个类把"寄存器地址"变成类型信息,set和clear封装了读改写操作。你可能觉得这也没什么大不了,但它有一个实打实的好处:Reg<0x40021018UL>编译后和直接操作volatile指针一样高效,但每一条使用意图都被名字表达出来了。
为什么要提供set和clear?因为寄存器操作绝大部分就是"某位置1"和"某位清0"两种操作。如果你直接写ref() |= mask,每次复制粘贴时很容易写成ref() = mask,把"置位"写成"赋值",导致其他位被意外覆盖。封装之后,写错的可能性小很多。
5.2 用模板类的还有额外好处
模板类没有运行时开销,因为它的一切都在编译期完成。Reg<0x40021018UL>和直接写地址常量生成的机器码几乎一模一样,却比散落的宏可维护得多。宏的问题在于,它不会做类型检查,也容易在表达式里产生优先级歧义。
我在实际项目中还经常这样扩展:某类寄存器只允许某几位操作,那就加一个带掩码的包装。比如只读寄存器就不给write,只有置位权的寄存器就不给clear。C++模板的能力远比宏更能表达这种硬件约束。
5.3 完整重构后的代码
现在把点灯程序重构成有C++样子的版本,并将LED封装成一个类:
// main.cpp - 第二版:用模板类和类封装重构 #include <cstdint> #include "reg.hpp" // 延时函数,保留 volatile static inline void delay(volatile std::uint32_t count) { while (count != 0) { __asm volatile("nop"); --count; } } class Led { public: static void init() { // 开启GPIOC时钟:RCC_APB2ENR 的 bit4 RccApb2Enr::set(1UL << 4); // PC13 配置为推挽输出,50MHz GpioC_Crh::ref() = (GpioC_Crh::read() & ~(0xFUL << 20)) | (0x3UL << 20); off(); } static void on() { // 低电平点亮,所以是 clear GpioC_Odr::clear(1UL << 13); } static void off() { GpioC_Odr::set(1UL << 13); } static void toggle() { GpioC_Odr::ref() ^= (1UL << 13); } }; int main() { Led::init(); while (true) { Led::on(); delay(500000); Led::off(); delay(500000); } }这个版本最大的价值在于:以后你要点亮另一个引脚,只需要改Led类内部的引脚号;要切换端口,就把GpioC_Odr换成GpioA_Odr之类的别名。代码的调用方main完全不关心底层实现,这就把业务逻辑和硬件访问分开了。
toggle我特意用异或实现,这个操作在GPIO翻转场景里极其常见。它不需要先读寄存器再算值,一条EOR指令就能完成,速度比"读-改-写"快得多。你用逻辑分析仪测量翻转频率时,能明显看到异或写法和普通读改写的差异。
有人可能会问,为什么类里的成员函数都用static?因为一个STM32的LED在整个程序生命周期里往往只有一个实例,没必要为它维护对象身份和实例状态。static成员函数不创建实例,编译后的代码也更紧凑。如果你真要做多个LED实例,再改成普通成员函数也不迟,核心的Reg模板不需要动。
6. 编译、烧录与验证的实战经验
代码写完了,怎么把它烧到板子上?这一节我把自己经常用的、也推荐给入门者的完整流程串一遍,顺便把几个容易翻车的细节摆出来。
6.1 工具链选择与VS Code配置
我用的是arm-none-eabi-gcc工具链,原因有三:支持C++17、与开源工具链生态无缝衔接、可以在Windows/Linux/macOS上保持一致体验。Keil的C++支持虽然也能用,但它在标准合规性和版本更新速度上一直落后半拍。
在VS Code里,我依赖两个扩展:C/C++扩展负责代码提示和调试,Cortex-Debug负责通过OpenOCD做调试器接口。编译和烧录的命令全部放在Makefile里,VS Code的launch.json只需要指定一下.elf文件路径和OpenOCD配置路径,点F5就能进入调试。
注意:如果你在Windows上开发,建议给
arm-none-eabi-gcc安装目录加入系统PATH,避免命令行找不到编译器。用MSYS2或者WSL工作流时,注意路径风格的差异,Makefile里尽量使用相对路径。
6.2 用Makefile管理构建:最容易翻车的环节
网上很多Makefile会有下面的问题:编译时没有把启动文件放进去,或者链接时没有指定链接脚本,导致出现"缺段"或者"多个Reset_Handler"之类看不懂的错误。我这里给出一个能跑通的模板:
TARGET = blink TOOLCHAIN = arm-none-eabi- CXX = $(TOOLCHAIN)g++ CC = $(TOOLCHAIN)gcc OBJCOPY = $(TOOLCHAIN)objcopy SIZE = $(TOOLCHAIN)size CXXFLAGS = -mcpu=cortex-m3 -mthumb -O2 -Wall -Wextra \ -fno-exceptions -fno-rtti -std=c++17 CXXFLAGS += -ffunction-sections -fdata-sections LDFLAGS = -Tstm32f103c8t6.ld --specs=nano.specs LDFLAGS += -Wl,--gc-sections -Wl,--start-group LDFLAGS += -lc -lgcc -lstdc++ -Wl,--end-group OBJS = startup.o main.o all: $(TARGET).hex $(TARGET).elf $(TARGET).elf: $(OBJS) $(CXX) $(LDFLAGS) -o $@ $(OBJS) $(SIZE) $@ %.o: %.s $(CC) -mcpu=cortex-m3 -mthumb -c -o $@ $< %.o: %.cpp $(CXX) $(CXXFLAGS) -c -o $@ $< $(TARGET).hex: $(TARGET).elf $(OBJCOPY) -O ihex $< $@ clean: rm -f $(OBJS) $(TARGET).elf $(TARGET).hex几个参数解释一下。
-mcpu=cortex-m3 -mthumb指定目标架构和指令集,错误设置会导致生成不兼容的指令。
-ffunction-sections -fdata-sections配合-Wl,--gc-sections可以把没被引用的函数和数据段从最终镜像里裁掉,对减小体积很有帮助。
--specs=nano.specs使用精简版C库,体积小很多。如果遇到__libc_init_array未定义,可以试试去掉-nostdlib改为显式链接-lc -lgcc -lstdc++。
链接这一步我特意用$(CXX)而不是$(LD),原因是C++程序需要链接器自动加入libstdc++、libgcc,用g++驱动链接可以省掉一堆手动库参数。
6.3 烧录验证与后续进阶
烧录我用ST-Link V2加OpenOCD。直接把STM32开发板的SWD四根线(SWDIO、SWCLK、GND、3.3V)接好,然后执行:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program blink.elf verify reset exit"如果一切顺利,你会看到OpenOCD输出烧录成功的提示,紧接着板载LED开始以大约1秒为周期闪烁。
我第一次带新手跑这个流程时,遇到最多的问题是"OpenOCD报找不到设备"。八成是驱动没装好,或者在Windows下ST-Link的驱动被别的软件占用。另一种可能是接错了线,SWDIO和SWCLK接反。这类问题排查起来很花时间,建议先用官方提供的ST-Link Utility或者st-flash工具单独测一下能否连接,再进OpenOCD流程。
点灯跑通后,下一步就可以做几件更有意思的事:用SysTick中断替代傻等延时、把LED闪烁改成PWM呼吸灯、甚至给Led类增加一个"淡入淡出"的接口。我自己在实际项目里,最重要的验证手段就是先让LED高频闪烁,再用逻辑分析仪看引脚翻转频率,以此确认时钟树配置是否正确、系统主频是不是达到预期。点灯看似幼稚,接触过内核调试的人会懂,它能验证的东西远比"亮没亮"多得多。
这篇写到这,基本的工程骨架你已经搭起来了,也终于写出了属于自己的嵌入式C++第一行代码。下次我们再把中断处理这块撕开聊一聊,C++的静态成员函数怎么作为中断回调使用,以及继承虚函数在这个场景里到底该不该碰。