☰
STM32裸机C++最小工程实战:从启动文件到Renode仿真
2026/9/29 1:12:07 网站建设 项目流程

1. 为什么前三篇看完还是没写一行代码

如果你是从这个系列的第一篇一路追过来的,大概率会有一种憋屈感:环境装了一堆,工具链配了又配,CMakeLists.txt 抄了又抄,Renode 的启动脚本也跑通了,但就是没正儿八经写过一行跑在 STM32 上的 C++ 业务代码。这个感受非常真实,我当年从 Keil 转到 CMake + VSCode + Renode 这套组合的时候,前两周基本都在跟工具链较劲,真正开始写代码反而是第三周的事。

但我要说一句可能有点反直觉的话:前三篇的“不写代码”恰恰是这套流程里最值钱的部分。原因很简单,嵌入式 C++ 和桌面 C++ 最大的区别不在于语法,而在于“你写的每一行代码最终都要落到一块资源受限的芯片上”。如果你连编译产物是怎么生成的、链接脚本怎么控制内存布局、仿真器怎么加载 ELF 文件都没搞清楚,那后面写出来的代码一旦跑不起来,你连从哪查都不知道。

这一篇我们正式进入“动手写”的阶段,但节奏依然不是上来就堆业务逻辑。我会带你从零写一个能在 Renode 里跑起来的最小 C++ 工程,包含启动文件、链接脚本、寄存器操作封装、以及一个用 C++ 类封装的 LED 闪烁逻辑。整个过程不依赖任何 HAL 库,纯裸机,目的是让你彻底看清“C++ 代码是怎么变成 STM32 上跑的东西”这条链路。

提示:本篇假设你已经完成了前三篇的环境搭建,手上有可用的 arm-none-eabi-gcc 工具链、CMake 3.20+、以及能正常启动 Renode 的环境。如果还没有,建议先回去补课,否则本篇的命令你敲下去大概率会报错。

适合读这篇的人有三类:一是学过 C 但没写过嵌入式 C++ 的;二是用过 HAL 库但没碰过裸机启动流程的;三是想用 Renode 做 CI 自动化测试但不知道怎么组织工程的。如果你属于“只想赶紧点亮一个 LED”的类型,那这篇可能会让你有点着急,但请耐心看完,因为后面所有的复杂项目都建立在这一篇的基础上。

2. 最小可运行工程的目录结构与文件职责

2.1 为什么目录结构要从第一天就定好

很多人写嵌入式项目习惯把所有文件堆在一个文件夹里,main.c、startup.s、链接脚本、Makefile 全在一起。项目小的时候没问题,一旦文件超过二十个,找起来就痛苦了。我踩过的坑是:有一次做一个 F103 的项目,光中断向量表就有三个版本(Bootloader 一个、App 一个、测试一个),因为文件全堆在一起,烧录的时候拿错了链接脚本,排查了整整一个下午。

所以从最小工程开始,我们就按职责分目录。下面是我用了很多年的结构,你可以直接抄:

stm32-cpp-demo/ ├── CMakeLists.txt ├── cmake/ │ └── arm-none-eabi.cmake ├── src/ │ ├── main.cpp │ ├── startup_stm32f103.s │ └── system_stm32f103.cpp ├── include/ │ ├── regs/ │ │ └── rcc.hpp │ │ └── gpio.hpp │ └── led.hpp ├── ld/ │ └── stm32f103c8.ld └── renode/ └── stm32f103.resc

这个结构里,src放实现,include放头文件,ld放链接脚本,renode放仿真脚本,cmake放工具链文件。看起来比“一个文件夹搞定”麻烦,但等你项目里有三十个源文件的时候,你会感谢自己当初分了目录。

2.2 每个文件到底负责什么

startup_stm32f103.s是启动汇编文件,它的核心工作是三件事:定义中断向量表、初始化栈指针、跳转到Reset_Handler。很多人以为启动文件是“芯片厂商给的,不用管”,但实际上如果你用 C++,启动文件里必须调用__libc_init_array,否则全局对象的构造函数不会被执行。这是 C++ 裸机开发第一个大坑,后面会详细讲。

system_stm32f103.cpp负责系统时钟初始化。F103 默认跑在 8MHz 的内部 RC 振荡器上,要跑到 72MHz 必须配置 PLL。这个文件我建议用 C++ 写,因为时钟配置涉及大量位操作,用 C++ 的constexpr和模板可以把这些位域封装得很干净。

ld/stm32f103c8.ld是链接脚本,它决定了代码放 Flash 的哪个位置、数据放 RAM 的哪个位置、栈和堆各占多少。F103C8T6 有 64KB Flash 和 20KB RAM,链接脚本必须精确反映这些数字,否则链接器会给你分配超出物理内存的地址,烧录后直接跑飞。

renode/stm32f103.resc是 Renode 的启动脚本,告诉仿真器加载哪个 ELF 文件、从哪个地址开始执行、外设怎么映射。这个文件的好处是:你不需要真板子就能验证代码逻辑,CI 里也能跑。

2.3 CMakeLists.txt 的最小可用版本

CMake 在嵌入式里的作用经常被低估。很多人觉得“不就是个构建工具吗,Makefile 也能干”,但 CMake 的真正价值在于工具链抽象和依赖管理。你写一次CMakeLists.txt,换到不同的芯片只需要换工具链文件和链接脚本,源码一行不用改。

下面是我实际在用的最小版本,去掉注释大概四十行:

cmake_minimum_required(VERSION 3.20) project(stm32_cpp_demo CXX C ASM) set(CMAKE_TOOLCHAIN_FILE ${CMAKE_SOURCE_DIR}/cmake/arm-none-eabi.cmake) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(${PROJECT_NAME} src/main.cpp src/system_stm32f103.cpp src/startup_stm32f103.s ) target_include_directories(${PROJECT_NAME} PRIVATE include) target_link_options(${PROJECT_NAME} PRIVATE -T${CMAKE_SOURCE_DIR}/ld/stm32f103c8.ld -nostartfiles -Wl,--gc-sections -Wl,-Map=${PROJECT_NAME}.map ) target_compile_options(${PROJECT_NAME} PRIVATE -mcpu=cortex-m3 -mthumb -ffunction-sections -fdata-sections -Wall -Wextra ) set_target_properties(${PROJECT_NAME} PROPERTIES SUFFIX ".elf" )

这里有几个参数值得单独说。-nostartfiles告诉链接器不要用标准 C 库的启动文件,因为我们自己写了startup_stm32f103.s。-Wl,--gc-sections配合-ffunction-sections -fdata-sections可以把没用的函数和数据从最终固件里删掉,对于 Flash 只有 64KB 的 F103 来说,这个优化能省下不少空间。-mcpu=cortex-m3和-mthumb是必须的,F103 是 Cortex-M3 内核,只支持 Thumb 指令集。

注意:CMAKE_TOOLCHAIN_FILE必须在project()之前设置,否则 CMake 会用宿主机编译器去编译,你会看到一堆“无法识别的指令”错误。这个坑我踩过不止一次。

3. 启动文件里那些没人告诉你的事

3.1 中断向量表不是随便排的

启动文件的第一部分是中断向量表。F103 的向量表从 Flash 的 0x08000000 开始,第一个字是栈顶地址,第二个字是复位处理函数的地址,后面依次是 NMI、HardFault、以及各种外设中断。很多人直接从厂商模板复制,但如果你不知道这个表的排列规则,一旦加了自定义中断就会出问题。

.section .isr_vector, "a", %progbits .type g_pfnVectors, %object g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler .word 0 .word 0 .word 0 .word 0 .word SVC_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler /* 后面是外设中断,按芯片手册的顺序排 */

这里的关键是_estack这个符号,它在链接脚本里定义,指向 RAM 的最高地址。Cortex-M3 的栈是向下生长的,所以栈顶地址就是 RAM 末尾。F103C8T6 的 RAM 是 0x20000000 到 0x20005000,所以_estack应该是 0x20005000。

3.2 Reset_Handler 里必须调用 __libc_init_array

这是 C++ 裸机开发最容易被忽略的一点。C 语言的全局变量初始化是编译器直接生成数据,但 C++ 的全局对象需要在运行时调用构造函数。__libc_init_array这个函数会遍历.init_array段,依次调用里面注册的构造函数。

Reset_Handler: ldr r0, =_estack mov sp, r0 bl SystemInit bl __libc_init_array bl main b .

如果你漏了bl __libc_init_array,那么所有全局对象的构造函数都不会执行。表现出的症状是:全局对象的成员变量全是零(因为 BSS 段被清零了),但你在构造函数里设置的初始值全部丢失。这个 bug 非常隐蔽,因为代码能编译能链接,跑起来也不报错,就是行为不对。

我当年做一个串口协议解析的项目,全局定义了一个ProtocolParser对象,构造函数里初始化了状态机。结果跑起来一直卡在初始状态,查了两天才发现是启动文件里没调__libc_init_array。从那以后,我每次新建工程第一件事就是检查这一行。

3.3 栈溢出是裸机开发的头号杀手

F103C8T6 只有 20KB RAM,栈和堆共享这块空间。链接脚本里通常这样定义:

_estack = 0x20005000; _Min_Heap_Size = 0x200; _Min_Stack_Size = 0x400;

栈默认给了 1KB,堆给了 512 字节。对于简单项目够用,但如果你在栈上定义了大数组,比如uint8_t buffer[2048],栈立刻溢出。栈溢出的表现是:程序跑飞、HardFault、或者更诡异的“变量值莫名其妙被改了”。

我的经验是:在裸机上永远不要在栈上分配超过 256 字节的缓冲区。需要大缓冲区就用全局数组或者静态分配。如果非要用动态内存,自己写一个简单的内存池,不要用malloc,因为标准库的malloc在裸机上行为不可预测。

4. 用 C++ 封装寄存器:从宏定义到类型安全

4.1 为什么不用厂商的 HAL 库

HAL 库不是不好,它适合快速出原型。但如果你想真正理解 STM32,或者你的项目对代码体积和运行效率有要求,裸机寄存器操作是绕不开的。HAL 库的一个HAL_GPIO_WritePin调用,背后可能经过三四层函数调用,最终才写到 BSRR 寄存器。而直接操作寄存器只需要一条str指令。

更重要的是,用 C++ 封装寄存器可以做到编译期检查。比如你写GPIOA.setPin(13),如果 13 不是合法的引脚号,编译器可以直接报错。而 HAL 库的HAL_GPIO_WritePin(GPIOA, GPIO_PIN_13, ...)如果写错了引脚宏,只会在运行时表现异常。

4.2 用模板封装 GPIO

下面是我常用的 GPIO 封装方式,核心思路是用模板参数把端口和引脚号变成类型的一部分:

template <uint32_t Base, uint8_t Pin> class GpioPin { public: static void initAsOutput() { // 使能时钟、配置 CRL/CRH volatile uint32_t* cr = reinterpret_cast<volatile uint32_t*>( Base + (Pin < 8 ? 0x00 : 0x04)); uint32_t shift = (Pin % 8) * 4; *cr &= ~(0xF << shift); *cr |= (0x1 << shift); // 通用推挽输出,2MHz } static void set() { *reinterpret_cast<volatile uint32_t*>(Base + 0x10) = (1 << Pin); } static void reset() { *reinterpret_cast<volatile uint32_t*>(Base + 0x14) = (1 << Pin); } static void toggle() { *reinterpret_cast<volatile uint32_t*>(Base + 0x10) = (*reinterpret_cast<volatile uint32_t*>(Base + 0x0C) & (1 << Pin)) ? (1 << Pin) : 0; *reinterpret_cast<volatile uint32_t*>(Base + 0x14) = (*reinterpret_cast<volatile uint32_t*>(Base + 0x0C) & (1 << Pin)) ? 0 : (1 << Pin); } };

这里用到了BSRR和BRR寄存器。BSRR的低 16 位写 1 置位对应引脚,高 16 位写 1 复位对应引脚。BRR的低 16 位写 1 复位对应引脚。用BSRR的好处是它是原子操作,不会被中断打断。

toggle的实现稍微绕一点,因为 F103 没有专门的翻转寄存器。我的做法是先读ODR判断当前状态,然后写BSRR或BRR。如果你对性能要求极高,可以用位带操作,但位带操作的代码可读性差,我一般不用。

4.3 时钟配置:从 8MHz 到 72MHz

F103 的时钟树是嵌入式入门的经典案例。默认情况下,芯片使用内部 8MHz RC 振荡器,要跑到 72MHz 需要经过以下步骤:

  1. 使能外部高速晶振(HSE),等待稳定
  2. 配置 PLL:HSE 8MHz 先二分频到 4MHz,再九倍频到 36MHz,最后乘二到 72MHz
  3. 配置 Flash 等待周期为 2
  4. 配置 AHB、APB1、APB2 分频系数
  5. 切换系统时钟源到 PLL,等待稳定

用 C++ 写出来大概是这样:

void SystemInit() { // 使能 HSE RCC->CR |= (1 << 16); while (!(RCC->CR & (1 << 17))); // 配置 Flash 等待周期 FLASH->ACR = (1 << 4) | (1 << 0); // 配置 PLL RCC->CFGR |= (1 << 16); // HSE 作为 PLL 输入 RCC->CFGR |= (7 << 18); // PLL 九倍频 RCC->CFGR |= (1 << 1); // HSE 二分频 // 使能 PLL RCC->CR |= (1 << 24); while (!(RCC->CR & (1 << 25))); // 配置分频 RCC->CFGR |= (4 << 8); // APB1 四分频 = 18MHz RCC->CFGR |= (0 << 11); // APB2 不分频 = 72MHz RCC->CFGR |= (0 << 4); // AHB 不分频 = 72MHz // 切换系统时钟到 PLL RCC->CFGR |= (2 << 0); while ((RCC->CFGR & (3 << 2)) != (2 << 2)); }

这段代码里最容易出错的是 PLL 的配置顺序。必须先配置 PLL 参数,再使能 PLL,最后切换时钟源。如果顺序错了,芯片可能跑在一个非预期的频率上,串口波特率会全乱。

提示:APB1 的最大频率是 36MHz,APB2 是 72MHz。如果你把 APB1 设成不分频,定时器和串口的时钟会超频,虽然芯片可能不立刻挂,但长期运行不稳定。

5. 在 Renode 里跑起来:仿真脚本怎么写

5.1 Renode 的加载流程

Renode 启动一个 STM32 仿真的基本流程是:创建机器、加载平台描述、加载 ELF 文件、启动。平台描述可以用 Renode 自带的platforms/cpus/stm32f103.repl,也可以自己写。我建议先用自带的,跑通之后再考虑自定义。

mach create "stm32f103" machine LoadPlatformDescription @platforms/cpus/stm32f103.repl sysbus LoadELF @${CMAKE_BINARY_DIR}/stm32_cpp_demo.elf showAnalyzer sysbus.uart1 start

showAnalyzer会打开一个串口分析窗口,如果你的代码里有串口输出,可以在这里看到。start命令让 CPU 开始执行。

5.2 用 Renode 验证 LED 闪烁

Renode 里 GPIO 的状态可以通过监视器查看。假设你的 LED 接在 PA5 上,可以在 Renode 的 monitor 里输入:

sysbus.gpioPortA.pin5

如果代码正确,你会看到这个值在 0 和 1 之间周期性变化。这比用真板子方便多了,因为真板子上你只能看到 LED 亮灭,看不到引脚电平的精确时序。

我实际用 Renode 做 CI 的时候,会写一个 Python 脚本通过 Renode 的 telnet 接口读取引脚状态,然后断言闪烁频率是否正确。这样每次提交代码,CI 会自动验证 LED 闪烁逻辑有没有被改坏。

5.3 仿真和真板的差异

Renode 不是万能的。它模拟的是芯片的数字逻辑,不模拟模拟外设(比如 ADC 的精度、内部 RC 振荡器的温漂)。定时器的行为也可能和真板有细微差异,因为 Renode 的定时器是基于宿主机的时钟模拟的。

我的经验是:逻辑验证用 Renode,时序验证用真板。比如你要验证一个超声波测距的算法,Renode 可以模拟 GPIO 的输入输出,但超声波的传播时间需要你自己在脚本里注入,不能指望 Renode 帮你算。

6. 那些让我熬夜的编译链接问题

6.1 undefined reference to__libc_init_array

这个错误通常出现在你用-nostartfiles但没链接libc的时候。解决方法是加上-lc或者--specs=nosys.specs。但加了nosys之后,printf之类的函数会变成空实现,因为nosys不提供系统调用。

如果你需要printf输出到串口,需要自己实现_write函数:

extern "C" int _write(int fd, char* ptr, int len) { for (int i = 0; i < len; i++) { while (!(USART1->SR & (1 << 7))); USART1->DR = ptr[i]; } return len; }

6.2 section.isr_vectorwill not fit

这个错误说明链接脚本里的 Flash 区域太小,放不下中断向量表。F103 的向量表有 60 多个条目,每个 4 字节,总共 240 多字节。如果你的链接脚本把 Flash 起始地址设成了 0x08000000,但长度只有 0x100,那肯定放不下。

检查链接脚本里的MEMORY定义:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K }

F103C8T6 的 Flash 是 64KB,RAM 是 20KB。如果你用的是 C6T6,Flash 只有 32KB,RAM 只有 10KB,链接脚本要相应修改。

6.3 C++ 异常和 RTTI 的坑

裸机上默认是禁用 C++ 异常和 RTTI 的,因为这两者会显著增加代码体积。如果你在代码里用了try-catch或者dynamic_cast,链接时会报错。解决方法是加上-fno-exceptions -fno-rtti编译选项,然后避免在代码里使用这些特性。

我个人的习惯是:嵌入式 C++ 里完全不用异常,错误处理用返回值或者std::optional。std::optional在 C++17 里是零开销的,非常适合嵌入式。

7. 从最小工程到实际项目的扩展思路

7.1 加入串口日志系统

最小工程跑通之后,下一步通常是加串口输出,方便调试。我的做法是封装一个简单的Logger类,支持printf风格的格式化输出,但底层用_write重定向到 USART。

class Logger { public: static void init(uint32_t baudrate) { // 配置 USART1 的 GPIO 和寄存器 } template <typename... Args> static void info(const char* fmt, Args... args) { char buffer[128]; int len = snprintf(buffer, sizeof(buffer), fmt, args...); _write(1, buffer, len); } };

这里用到了可变参数模板,比 C 的va_list更类型安全。snprintf会消耗一些 Flash 空间,如果空间紧张,可以用一个精简版的格式化函数。

7.2 用 Renode 做自动化测试

Renode 支持通过 Robot Framework 写测试脚本。你可以写一个测试用例:启动仿真、等待 1 秒、检查 PA5 引脚状态、断言闪烁频率在 1Hz 左右。这个测试可以集成到 CI 里,每次提交代码自动跑。

我实际项目里的测试脚本大概长这样:

*** Test Cases *** LED Should Blink At 1Hz Execute Command mach create Execute Command machine LoadPlatformDescription @platforms/cpus/stm32f103.repl Execute Command sysbus LoadELF @build/demo.elf Execute Command start Sleep 1s ${state1}= Execute Command sysbus.gpioPortA.pin5 Sleep 0.5s ${state2}= Execute Command sysbus.gpioPortA.pin5 Should Not Be Equal ${state1} ${state2}

这个测试虽然简单,但能有效防止“改代码改坏了 LED 逻辑”这种低级错误。

7.3 什么时候该上 RTOS

最小工程是裸机的前后台架构,主循环里轮询任务。当你的项目需要同时处理多个实时性要求不同的任务时,就该考虑 RTOS 了。FreeRTOS 在 F103 上跑很轻松,RAM 占用大概 2-3KB。

但我的建议是:能用裸机解决的就别上 RTOS。RTOS 引入了任务调度、优先级反转、栈溢出检测等一堆新问题,调试复杂度直线上升。我见过太多项目,明明一个状态机就能搞定的事情,非要上 RTOS,结果光调任务优先级就花了一周。

8. 一些让我少走弯路的实操习惯

第一个习惯是每次改链接脚本都先看 map 文件。map 文件里会列出每个段的大小和地址,你能清楚地看到 Flash 和 RAM 的使用情况。如果发现某个函数占了几 KB,那大概率是用了printf或者浮点运算,需要优化。

第二个习惯是用-Wl,--print-memory-usage让链接器直接打印内存占用。这个选项会在链接完成后输出一行类似Memory region Used Size Region Size %age Used的信息,比翻 map 文件快多了。

第三个习惯是在启动文件里加一个栈溢出检测。具体做法是在栈底放一个魔数,主循环里定期检查这个魔数有没有被改写。如果被改了,说明栈溢出,可以触发一个错误处理。

#define STACK_MAGIC 0xDEADBEEF extern uint32_t _estack; static volatile uint32_t* stack_guard = &_estack - 256; void check_stack() { if (*stack_guard != STACK_MAGIC) { // 栈溢出,进入安全状态 while (1); } }

这个技巧帮我抓到过好几次隐蔽的栈溢出 bug,尤其是在用递归函数的时候。

第四个习惯是把 Renode 的启动脚本和 CMake 的构建目录关联起来。我通常会在 CMake 里加一个自定义目标,构建完成后自动生成 Renode 脚本,路径指向最新的 ELF 文件。这样每次改完代码,只需要在 Renode 里source一下脚本就能重新加载,不用手动改路径。

add_custom_target(renode COMMAND ${CMAKE_COMMAND} -E echo "mach create; machine LoadPlatformDescription @platforms/cpus/stm32f103.repl; sysbus LoadELF @${CMAKE_BINARY_DIR}/${PROJECT_NAME}.elf; start" > ${CMAKE_BINARY_DIR}/run.resc DEPENDS ${PROJECT_NAME} )

这些习惯看起来琐碎,但积累下来能省下大量调试时间。嵌入式开发的痛点从来不是“写不出代码”,而是“代码写出来了但跑不对,还不知道为什么”。把工具链和调试流程理顺,比多写几百行业务代码有价值得多。

最后说一个我自己的体会:从 C 转到嵌入式 C++,最大的障碍不是语法,而是思维方式。C 的思维方式是“我直接操作硬件”,C++ 的思维方式是“我用类型系统把硬件操作封装起来,让编译器帮我检查错误”。这个转变需要时间,但一旦转过来,你会发现代码的可维护性和可复用性会有质的提升。下一篇我们会在这个最小工程的基础上,加入定时器中断和状态机,真正开始写有业务逻辑的代码。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询