STM32嵌入式C++工程实战:从CMake构建到Renode仿真运行

发布时间:2026/9/27 10:48:25
STM32嵌入式C++工程实战:从CMake构建到Renode仿真运行
1. 为什么“看了三篇还没写一行代码”反而是对的如果你是从这个系列的第一篇一路追过来的大概率心里已经憋了一句话“看了三篇了一行都没让我写呢。”我完全理解这种感受。前三篇我们聊了工具链的选型逻辑、工程目录该怎么摆、构建系统为什么选 CMake 而不是手搓 Makefile甚至把 Renode 这个仿真器搬出来讲了一通。全是“准备工作”一行main函数都没见着。这要搁在短视频时代估计早就被划走了。但我得说句实在话嵌入式 C 项目里前期的工程骨架搭得好不好直接决定了你后面三个月是写业务逻辑还是天天修构建脚本。我见过太多人Keil 里新建个工程main.c一写编译烧录跑通觉得“这不挺简单”。等到项目要加第二个模块、要引入第三方库、要换芯片型号、要上 CI 自动构建的时候整个工程烂成一锅粥改一个头文件路径能引出三十个报错。那时候再回头重构成本是现在的十倍。所以这一篇我们终于要动手了。但动手之前我得先把“为什么前三篇那么啰嗦”这件事讲透否则你后面遇到构建报错、链接失败、仿真跑不起来的时候还是会觉得“这破工具怎么这么麻烦”。理解了设计意图踩坑的时候你才知道往哪个方向排查。这一篇的目标很明确从零把一个能在 PC 上编译、能在 Renode 里仿真运行、结构清晰的 STM32 C 工程跑起来。不是点个灯就完事而是让你理解每一行配置背后的原因。适合已经看过前三篇、手痒想写代码的读者也适合中途跳进来、想直接看实操的朋友——我会把关键前置条件再点一遍保证你能跟上。关键词先摆在这儿STM32、嵌入式、C、CMake、Renode。这五个词就是这一篇的全部骨架。我们围绕它们把“从工程创建到仿真运行”这条链路完整走一遍。2. 动手前必须想清楚的三个工程决策在敲第一条命令之前有三个决策必须先定下来。这三个决策如果拍脑袋定后面返工的概率极高。我把它们单独拎出来讲是因为它们直接决定了你工程目录长什么样、CMake 怎么写、代码怎么组织。2.1 裸机还是上 RTOS这决定了代码的组织方式很多人一上来就问“STM32 能不能跑 C”这问题本身问偏了。真正该问的是你的项目是裸机前后台架构还是要上 RTOS。这个选择直接决定了你的 C 代码怎么写。裸机架构下你的代码基本是“初始化 大循环”的结构。C 在这里的价值主要是封装外设驱动、用类管理状态、用模板做编译期计算。中断服务函数还是得用 C 风格写因为向量表是 C 的。这种场景下C 的运行时特性异常、RTTI通常要关掉因为裸机没有标准库支撑开了也是给自己找麻烦。上 RTOS 的话比如 FreeRTOS 或 RT-ThreadC 的价值就体现在任务封装、消息队列的 RAII 管理、用类封装同步原语。这时候你更需要注意栈空间分配和对象生命周期因为任务切换时栈是独立的全局对象的构造函数在调度器启动前就跑完了这个时序得心里有数。我个人的建议是新手先用裸机把 C 的封装能力练熟别急着上 RTOS。裸机下你能清楚看到每个字节的去向等你能把中断、DMA、定时器用 C 类封装得干干净净再上 RTOS 就是水到渠成的事。这一篇我们走裸机路线把工程骨架和仿真链路打通RTOS 留到后面单独讲。2.2 标准库选 newlib-nano 还是自己写链接脚本会告诉你答案嵌入式 C 绕不开一个问题new和delete从哪来。桌面环境下这俩是标准库提供的堆空间由操作系统管。但裸机 STM32 上堆就是你链接脚本里划出来的那一小段 RAM标准库的malloc实现又大又慢还可能带锁。所以工程决策的第二条是要么用 newlib-nano 的精简实现要么自己实现operator new/operator delete直接管一块静态内存池。前者省事后者可控。我倾向于后者原因很简单嵌入式项目里动态内存分配本身就是个需要谨慎对待的事自己实现一个基于静态数组的分配器既避免了堆碎片又能在编译期就知道内存上限出问题也好排查。这个决策会影响你的链接脚本和启动文件。如果你用自己实现的分配器链接脚本里就不需要给标准库堆留空间_sbrk那个系统调用也可以直接返回错误防止有人不小心调了标准库的malloc。这些细节后面写 CMake 和链接脚本的时候会具体展开。2.3 仿真优先还是硬件优先决定了你的调试节奏第三条决策最容易被忽略你是先在 Renode 里把逻辑跑通还是直接上硬件调试。直接上硬件的好处是真实坏处是调试成本高。一个空指针解引用在硬件上可能就是 HardFault 然后卡死你得接调试器、看寄存器、翻栈回溯。而在 Renode 里你可以直接看内存、下断点、单步甚至把外设寄存器的值打印出来。对于验证 C 的对象构造、虚函数表、模板实例化这些“软件层面”的东西仿真器效率高得多。我的工作流是纯逻辑和算法在 Renode 里跑通涉及精确时序和模拟外设特性的部分再上硬件。这一篇我们全程用 Renode把工程跑起来让你看到 C 代码在仿真环境里的完整执行过程。等你把这套流程走顺了换到真实硬件上只是把构建目标从仿真改成实际芯片代码一行不用动。这三个决策定下来我们的工程轮廓就清晰了裸机架构、自实现内存分配、仿真优先。下面开始动手。3. 从空目录到可编译工程CMake 配置逐行拆解这一节是重头戏。我会带着你从一个空目录开始一步步把 CMake 工程搭起来每一行配置都解释清楚它在干什么。你跟着敲一遍比看十篇教程都管用。3.1 目录结构为什么这样分层先看最终要达成的目录结构stm32-cpp-demo/ ├── CMakeLists.txt ├── cmake/ │ ├── arm-none-eabi.cmake │ └── stm32f103.cmake ├── src/ │ ├── main.cpp │ ├── startup_stm32f103.s │ └── syscalls.c ├── include/ │ └── board/ │ └── led.hpp ├── linker/ │ └── stm32f103.ld └── build/这个结构不是随便定的。cmake/放工具链和芯片相关的配置文件是为了把“平台相关”和“业务相关”彻底分开。你以后换芯片只改cmake/里的东西src/一行不动。include/单独放头文件是为了让target_include_directories干净不会把源文件目录也暴露出去。linker/放链接脚本因为链接脚本是芯片强相关的跟工具链配置放一起容易混。提示不要把所有文件堆在一个目录里。嵌入式工程文件少的时候看不出差别文件一多头文件互相包含的路径能把你绕晕。分层是为了让每个文件的职责单一。3.2 工具链文件交叉编译的入口cmake/arm-none-eabi.cmake是工具链文件CMake 靠它知道用哪个编译器、哪个链接器。内容如下set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(CMAKE_C_COMPILER arm-none-eabi-gcc) set(CMAKE_CXX_COMPILER arm-none-eabi-g) set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) set(CMAKE_OBJCOPY arm-none-eabi-objcopy) set(CMAKE_SIZE arm-none-eabi-size) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)逐行看。CMAKE_SYSTEM_NAME设成Generic是关键它告诉 CMake 这是个裸机目标没有操作系统所以 CMake 不会去尝试链接标准启动文件也不会加-lc之类的默认库。CMAKE_TRY_COMPILE_TARGET_TYPE设成STATIC_LIBRARY是为了让 CMake 的编译器检测阶段不去链接可执行文件——裸机环境下链接可执行文件会因为缺少启动代码而失败设成静态库就绕过了这个问题。CMAKE_CXX_STANDARD 17是我推荐的版本。C17 有if constexpr、结构化绑定、std::optional这些在嵌入式里都很有用而且 GCC 对 C17 的支持已经很成熟。别贪新上 C20嵌入式工具链对 C20 的支持参差不齐concepts和ranges编译出来的代码体积也不小。3.3 芯片配置文件编译选项和链接脚本的绑定cmake/stm32f103.cmake定义芯片相关的编译选项set(CPU_FLAGS -mcpucortex-m3 -mthumb) set(FPU_FLAGS ) set(CMAKE_C_FLAGS_INIT ${CPU_FLAGS} ${FPU_FLAGS}) set(CMAKE_CXX_FLAGS_INIT ${CPU_FLAGS} ${FPU_FLAGS} -fno-exceptions -fno-rtti -fno-threadsafe-statics) set(CMAKE_ASM_FLAGS_INIT ${CPU_FLAGS} ${FPU_FLAGS} -x assembler-with-cpp) set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/linker/stm32f103.ld) set(CMAKE_EXE_LINKER_FLAGS_INIT -T ${LINKER_SCRIPT} -Wl,--gc-sections -Wl,-Mapoutput.map)-mcpucortex-m3 -mthumb是 STM32F103 的标配Cortex-M3 内核只支持 Thumb 指令集。-fno-exceptions和-fno-rtti是嵌入式 C 的常规操作关掉异常和运行时类型信息能省下可观的 Flash 空间。-fno-threadsafe-statics是防止编译器为局部静态变量加锁——裸机没有线程这个锁纯属浪费。-Wl,--gc-sections配合编译时的-ffunction-sections -fdata-sections使用能把没用的函数和数据从最终固件里剔除。这个组合在嵌入式里几乎是必开的不然标准库和模板实例化会带进来一堆你用不到的东西。3.4 顶层 CMakeLists把碎片拼起来顶层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) include(${CMAKE_SOURCE_DIR}/cmake/stm32f103.cmake) add_executable(${PROJECT_NAME} src/main.cpp src/startup_stm32f103.s src/syscalls.c ) target_include_directories(${PROJECT_NAME} PRIVATE ${CMAKE_SOURCE_DIR}/include ) target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic -ffunction-sections -fdata-sections ) set_target_properties(${PROJECT_NAME} PROPERTIES SUFFIX .elf ) add_custom_command(TARGET ${PROJECT_NAME} POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $TARGET_FILE:${PROJECT_NAME} ${PROJECT_NAME}.bin COMMAND ${CMAKE_SIZE} $TARGET_FILE:${PROJECT_NAME} )add_executable里把启动文件和syscalls.c一起编进去。syscalls.c是给 newlib 提供系统调用的桩函数比如_write、_sbrk、_close不提供的话链接会报一堆未定义符号。POST_BUILD里生成.bin文件并打印大小这是嵌入式构建的常规收尾动作方便你一眼看到 Flash 和 RAM 的占用。到这里CMake 部分就完整了。你可以在build/目录下执行cmake -B build -DCMAKE_BUILD_TYPEDebug cmake --build build如果一切正常你会看到stm32-cpp-demo.elf和.bin生成size命令输出类似text data bss dec hex filename 1234 12 2048 3294 cde stm32-cpp-demo.elftext是 Flash 占用data是初始化过的全局变量同时占 Flash 和 RAMbss是未初始化全局变量只占 RAM。这三个数字你要养成习惯看后面加功能的时候心里有数。4. 让 C 在裸机上真正跑起来启动、内存与入口工程能编译只是第一步能跑起来才算数。这一节讲三个关键点启动文件怎么把控制权交给 C、内存分配器怎么自己实现、main函数之前发生了什么。4.1 启动文件里那段汇编到底在干什么startup_stm32f103.s是上电后执行的第一段代码。它的核心任务就三件事初始化栈指针、把数据段从 Flash 搬到 RAM、跳转到main。很多人直接抄一份启动文件就不管了但如果你不理解它在干什么遇到“全局变量初值不对”“栈溢出”这类问题就无从下手。关键片段Reset_Handler: ldr r0, _sdata ldr r1, _edata ldr r2, _sidata movs r3, #0 b LoopCopyDataInit CopyDataInit: ldr r4, [r2, r3] str r4, [r0, r3] adds r3, r3, #4 LoopCopyDataInit: adds r4, r0, r3 cmp r4, r1 bcc CopyDataInit ldr r0, _sbss ldr r1, _ebss movs r3, #0 b LoopFillZerobss FillZerobss: str r3, [r0] adds r0, r0, #4 LoopFillZerobss: cmp r0, r1 bcc FillZerobss bl SystemInit bl __libc_init_array bl main_sidata是数据段在 Flash 里的起始地址_sdata和_edata是数据段在 RAM 里的起止地址。这段循环把初值从 Flash 复制到 RAM。_sbss到_ebss是未初始化数据段直接清零。这两个符号都定义在链接脚本里所以链接脚本和启动文件是配套的换一个就得改另一个。__libc_init_array这个调用很关键它负责调用所有全局对象的构造函数。C 的全局对象在main之前构造靠的就是这个函数。如果你发现全局对象的构造函数没执行先检查这里有没有调用。4.2 自己实现 operator new把动态内存攥在手里前面说了我倾向于自己实现内存分配。代码不长但每一行都有讲究#include cstddef #include cstdint namespace { constexpr std::size_t HEAP_SIZE 4096; alignas(std::max_align_t) std::uint8_t heap[HEAP_SIZE]; std::size_t heap_offset 0; } void* operator new(std::size_t size) { const std::size_t aligned (size alignof(std::max_align_t) - 1) ~(alignof(std::max_align_t) - 1); if (heap_offset aligned HEAP_SIZE) { return nullptr; } void* ptr heap[heap_offset]; heap_offset aligned; return ptr; } void operator delete(void* ptr) noexcept { (void)ptr; } void operator delete(void* ptr, std::size_t) noexcept { (void)ptr; }这是一个“只分配不释放”的分配器。听起来很粗暴但在嵌入式里非常实用大部分对象要么是全局的要么在初始化阶段分配一次就长期存在根本不需要释放。delete做成空操作避免了堆碎片也避免了释放后重用导致的内存踩踏。alignas(std::max_align_t)保证堆数组本身是对齐的aligned的计算保证每次分配返回的地址都满足最大对齐要求。这个对齐逻辑不能省否则在某些平台上访问未对齐的double或指针会触发硬件异常。注意这个分配器不是线程安全的裸机单线程没问题上了 RTOS 就得加临界区保护。另外HEAP_SIZE要根据你的实际需求调4096 字节对大多数小项目够用但如果你要动态创建大量对象得往上加同时盯着bss段的增长。4.3 main 函数之前C 运行时做了什么从Reset_Handler到你的main函数中间隔着SystemInit和__libc_init_array。SystemInit配置时钟树把芯片从默认的内部时钟切到外部晶振并倍频这一步不做的话所有外设时序都是错的。__libc_init_array遍历.init_array段挨个调用全局构造函数。这里有个坑全局对象的构造顺序是不确定的跨编译单元更是如此。所以全局对象之间不要有依赖关系。如果对象 A 的构造函数里用了对象 B而 B 还没构造就是未定义行为。我的做法是全局对象只做最简单的初始化真正的依赖注入放到main里显式做。还有一个坑全局对象的构造函数里不要调用 HAL 库的初始化函数。因为__libc_init_array执行的时候SystemInit虽然跑完了但 HAL 的HAL_Init还没调用中断优先级分组、SysTick 都没配好。全局对象构造时只做纯软件的事硬件初始化老老实实放main里。5. Renode 仿真不接硬件也能看到代码在跑工程编译出来了但没硬件怎么验证Renode 就是干这个的。它是一个开源仿真器能模拟 STM32F103 的外设让你在 PC 上跑固件、看寄存器、下断点。5.1 Renode 平台文件把芯片外设描述出来Renode 需要一个.repl文件来描述平台。针对 STM32F103核心内容如下uart1: UART.STM32F7_USART sysbus 0x40013800 frequency: 8000000 - nvic37 gpioPortC: GPIOPort.STM32_GPIOPort sysbus 0x40011000 [0-15] - nvic0 memory: Memory.MappedMemory sysbus 0x08000000 size: 0x10000 sram: Memory.MappedMemory sysbus 0x20000000 size: 0x5000memory映射到 Flash 地址0x08000000大小 64KB。sram映射到0x20000000大小 20KB。这两个地址和大小必须和链接脚本里定义的一致否则固件加载进去后地址对不上跑起来就是乱码。uart1映射到0x40013800这是 STM32F103 的 USART1 基地址。- nvic37表示它的中断线连到 NVIC 的第 37 号这个编号在参考手册的中断向量表里能查到。gpioPortC类似中断线是 0 号。5.2 启动脚本加载固件并运行Renode 的启动脚本run.rescmach create stm32f103 machine LoadPlatformDescription platforms/stm32f103.repl sysbus LoadELF build/stm32-cpp-demo.elf showAnalyzer uart1 startLoadELF把编译出来的 ELF 文件加载到仿真内存里Renode 会自动解析 ELF 的段信息把代码放到 Flash 地址、数据放到 RAM 地址。showAnalyzer uart1打开串口分析窗口你往串口打印的内容会显示在那里。start启动仿真CPU 从复位向量开始执行。在 Renode 命令行里执行include run.resc如果一切正常你会看到串口窗口输出你main里打印的内容。这时候你可以在 Renode 里下断点、单步、查看变量体验和硬件调试器几乎一样。5.3 在仿真里验证 C 特性虚函数和模板仿真环境最大的价值是让你能“看见” C 的运行时行为。比如你写一个带虚函数的类class Led { public: virtual void toggle() 0; virtual ~Led() default; }; class GpioLed : public Led { public: void toggle() override { // 翻转 GPIO } };在 Renode 里你可以查看对象的虚函数表指针确认它指向了正确的 vtable。模板实例化后的代码也能在反汇编窗口里看到。这些在硬件上很难直接观察但在仿真器里就是几个命令的事。我实测下来Renode 对 STM32F103 的 GPIO 和 UART 模拟得相当准确定时器稍弱一些涉及精确延时的场景需要调整。但对于验证 C 的对象模型、内存布局、算法逻辑完全够用。6. 那些没人告诉你但一定会踩的坑前面讲的是“应该怎么做”这一节讲“实际做的时候会出什么幺蛾子”。这些都是我在真实项目里踩过的有些坑排查了大半天才找到原因。6.1 链接报错 undefined reference to__libc_init_array这个报错通常出现在你用了-nostdlib但没手动链接libc的时候。__libc_init_array是 newlib 提供的如果你完全不用标准库就得自己实现一个空的版本或者干脆在启动文件里不调用它。我的建议是不要用-nostdlib而是用-nostartfiles。前者会把整个标准库都排除掉后者只排除启动文件标准库还是可用的。嵌入式项目里memcpy、memset、strlen这些函数编译器会自动生成调用完全排除标准库会带来一堆麻烦。6.2 全局对象构造函数没执行如果你确认启动文件里调用了__libc_init_array但全局对象的构造函数还是没跑检查两件事一是链接脚本里.init_array段有没有被正确收集二是--gc-sections有没有把这个段误删。链接脚本里要有类似这样的段定义.init_array : { . ALIGN(4); __init_array_start .; KEEP(*(.init_array)) KEEP(*(.init)) __init_array_end .; } FLASHKEEP是必须的否则--gc-sections会认为这个段没人引用直接删掉。这个坑我踩过一次现象就是全局对象的构造函数静默不执行没有任何报错排查了很久。6.3 Renode 里程序跑飞PC 指针乱跳仿真里程序跑飞最常见的原因是链接脚本的地址和 Renode 平台文件里的内存映射对不上。比如链接脚本把代码放在0x08000000但 Renode 里memory映射到了别的地址CPU 取指就取到了空内存。排查方法在 Renode 里执行sysbus.cpu PC看当前程序计数器对比objdump -h输出的段地址。两个地址对不上就是映射问题。另外检查 ELF 文件的入口地址readelf -h里的Entry point address应该是Reset_Handler的地址。6.4 C 异常和 RTTI 关掉之后dynamic_cast 用不了-fno-rtti关掉之后dynamic_cast和typeid都不能用了。如果你的代码里用了这些编译会报错。替代方案是用static_cast加自己的类型标记或者用访问者模式绕开运行时类型识别。嵌入式里本来就不推荐用dynamic_cast它的开销和代码体积都不可控。6.5 栈溢出最隐蔽的 bug裸机环境下栈大小是在链接脚本里定的默认可能只有 1KB 到 2KB。C 的函数调用层次比 C 深局部对象多很容易栈溢出。栈溢出不会报错只会表现为“程序莫名其妙跑飞”或者“某个变量值被改了”。排查方法在链接脚本里把栈顶附近填一个魔数程序跑一段时间后检查这个魔数有没有被覆盖。或者用 Renode 的内存监视功能观察栈指针有没有超出范围。我的经验是C 项目的栈至少给 4KB用得多的话给 8KB。7. 工程跑通之后下一步往哪走到这里你应该已经有一个能在 PC 上编译、能在 Renode 里仿真运行的 STM32 C 工程了。回头看看前三篇的铺垫没有白费工具链配置决定了编译能不能过目录结构决定了工程能不能扩展CMake 配置决定了构建能不能自动化Renode 配置决定了调试能不能高效。接下来可以往几个方向深入。一是把 GPIO、UART、定时器的驱动用 C 类封装起来体会 RAII 在嵌入式里的用法。二是引入一个轻量级的测试框架在 PC 上跑单元测试把纯逻辑代码和硬件相关代码彻底分离。三是把 Renode 集成到 CI 里每次提交自动跑仿真测试这个在团队协作里价值很大。我个人在实际操作中的体会是嵌入式 C 的难点从来不是语法而是对内存和时序的掌控。C 给了你封装的能力但封装的同时你得清楚每个抽象背后的开销。一个虚函数调用多一次间接寻址一个模板实例化多一份代码体积这些在资源受限的芯片上都是要算账的。把工程骨架搭好把仿真链路跑通你才有余力去算这些账。最后分享一个小技巧在main.cpp里加一个编译期断言把芯片型号和工程配置绑死static_assert(sizeof(void*) 4, This project targets 32-bit ARM only);这样万一有人用 64 位工具链编译编译期就报错了不会等到链接或者运行时才发现问题。类似的断言可以加在关键的数据结构上把“假设”变成“编译期检查”这是 C 相比 C 的一个实实在在的优势。