STM32F103嵌入式C++实战:CMake+Ninja+Renode从零构建第一个程序

发布时间:2026/9/30 1:30:07
STM32F103嵌入式C++实战:CMake+Ninja+Renode从零构建第一个程序
1. 从“光看不练”到真正上手这篇到底要解决什么问题如果你跟着这个系列一路看过来前三篇大概率都在讲环境、讲工具链、讲CMake和Ninja怎么配合、讲Renode怎么把STM32F103“假装”成一块真芯片。看完之后心里痒痒的想动手写点东西结果发现——一行代码都没让自己敲。这种感受我太懂了就像看别人钓鱼看了三小时鱼竿都没摸到。这篇的核心目标很明确把前面铺垫的所有工具链真正串起来让你亲手写出第一个能在STM32上跑的C程序并且能在Renode里看到它“活”起来。不是点个灯就完事而是让你理解为什么嵌入式C的项目要这么组织、CMake到底在背后干了什么、Renode是怎么在没有硬件的情况下模拟出STM32F103的行为的。适合谁看如果你已经装好了CMake、Ninja、ARM工具链和Renode但不知道下一步该干嘛这篇就是为你写的。如果你还没装好也没关系我会在关键节点提醒你缺什么、怎么补。整个流程我会按照“一个合格嵌入式开发者最可能采用的方案”来走所有代码和配置都可以直接抄作业。先说一下整体思路。嵌入式C项目和普通PC上的C项目最大的区别在于你的代码最终要跑在一块资源极其有限的芯片上。STM32F103C8T6只有64KB Flash和20KB RAM没有操作系统没有标准C运行时库的完整支持甚至连new和delete都要你自己决定要不要用。所以项目结构必须干净、可控编译产物必须精确到字节。这也是为什么我选择CMakeNinja而不是Keil或者IAR。Keil当然能用但它的工程文件是二进制格式的没法用Git做diff团队协作时一个人改了配置另一个人根本看不出来。CMake是纯文本的每一行配置都清清楚楚配合Ninja的构建速度改一行代码重新编译只要几百毫秒。更重要的是CMake能让你把“编译给STM32的代码”和“编译给PC上跑的测试代码”放在同一个工程里管理这对嵌入式开发来说太重要了——你总不想每次改个算法都要烧到板子上才能验证吧Renode的角色则是另一个关键。STM32F103这块芯片现在价格波动大而且有时候手头就是没有硬件。Renode可以模拟它的Cortex-M3内核、外设寄存器、中断控制器甚至能模拟USART输出。你编译出来的elf文件直接丢给Renode就能跑串口输出会打印到终端里。这意味着你可以在没有实物的情况下完成80%的代码逻辑验证等硬件到了再烧进去做最终测试。注意Renode模拟的是芯片的行为不是电气特性。比如你配置了一个GPIO输出Renode会记录这个引脚的状态变化但你没法用它验证LED的亮度或者驱动电路的电流够不够。逻辑验证用Renode电气验证必须上真板子。2. 项目骨架搭建从零开始组织一个嵌入式C工程2.1 目录结构设计背后的逻辑先看整个项目的目录长什么样。我试过很多种组织方式最后固定下来的是这一套stm32-cpp-demo/ ├── CMakeLists.txt # 顶层构建脚本 ├── cmake/ │ ├── arm-gcc-toolchain.cmake # 交叉编译工具链配置 │ └── stm32f103.cmake # 芯片相关的编译选项 ├── src/ │ ├── main.cpp # 主程序入口 │ ├── startup_stm32f103.s # 启动文件 │ └── system_stm32f103.c # 系统初始化 ├── include/ │ └── board.h # 板级定义 ├── linker/ │ └── stm32f103c8t6.ld # 链接脚本 ├── lib/ │ └── cmsis/ # CMSIS头文件 └── test/ └── test_main.cpp # PC上跑的单元测试为什么这么分src放的是最终要烧进芯片的代码test放的是在PC上跑的测试代码两者共享include里的头文件。cmake目录单独放工具链配置是因为交叉编译的配置和项目本身的构建逻辑应该解耦——你换一块芯片只需要改cmake目录里的文件CMakeLists.txt主体不用动。linker目录放链接脚本这个文件决定了你的代码在Flash和RAM里怎么分布。STM32F103C8T6的Flash从0x08000000开始RAM从0x20000000开始这些地址必须和芯片手册完全一致错一个字节都跑不起来。2.2 CMakeLists.txt逐行拆解顶层CMakeLists.txt是整个项目的入口我把它拆成几块来看cmake_minimum_required(VERSION 3.20) project(stm32_cpp_demo LANGUAGES C CXX ASM) set(CMAKE_C_STANDARD 11) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON)第一行要求CMake最低版本3.20因为后面用到了target_link_options的一些新特性。LANGUAGES C CXX ASM三个都要开C用于CMSIS和启动文件C用于业务逻辑ASM用于启动文件里的汇编部分。C标准选17而不是20是因为ARM GCC对C20的支持在嵌入式场景下还不够稳定17已经够用了——constexpr、if constexpr、结构化绑定这些都能用编译出来的代码体积也可控。if(CMAKE_CROSSCOMPILING) include(${CMAKE_SOURCE_DIR}/cmake/stm32f103.cmake) else() add_executable(test_runner test/test_main.cpp) target_include_directories(test_runner PRIVATE include) endif()这段是关键通过CMAKE_CROSSCOMPILING变量判断当前是交叉编译还是本机编译。交叉编译时加载芯片相关的配置本机编译时只构建测试程序。这样你在PC上敲cmake -B build cmake --build build就能跑测试敲cmake -B build-arm -DCMAKE_TOOLCHAIN_FILEcmake/arm-gcc-toolchain.cmake cmake --build build-arm就能编译出给STM32的固件。2.3 交叉编译工具链配置的坑cmake/arm-gcc-toolchain.cmake这个文件我踩过不少坑最终版本是这样的set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_SYSTEM_PROCESSOR arm) set(TOOLCHAIN_PREFIX arm-none-eabi-) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g) set(CMAKE_ASM_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_OBJCOPY ${TOOLCHAIN_PREFIX}objcopy) set(CMAKE_SIZE ${TOOLCHAIN_PREFIX}size) set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY)CMAKE_SYSTEM_NAME设为Generic而不是Linux因为STM32上根本没有操作系统。CMAKE_TRY_COMPILE_TARGET_TYPE设为STATIC_LIBRARY是为了让CMake在检测编译器时不去尝试链接可执行文件——交叉编译环境下链接可执行文件会失败因为缺少目标平台的C库。CMAKE_C_FLAGS和CMAKE_CXX_FLAGS里必须加-mcpucortex-m3 -mthumb这是Cortex-M3的指令集配置。-mthumb表示使用Thumb指令集STM32F103只支持Thumb不支持ARM模式。忘了加这个编译出来的代码直接跑飞。还有一个容易忽略的点-fno-exceptions和-fno-rtti。嵌入式C默认不开异常和运行时类型识别因为这两个特性会显著增加代码体积和运行时开销。STM32F103的64KB Flash经不起这么折腾。如果你确实需要异常处理可以开但要做好代码体积翻倍的准备。3. 第一个C程序从启动文件到main函数3.1 启动文件里到底发生了什么startup_stm32f103.s这个汇编文件是芯片上电后执行的第一段代码。它主要干三件事初始化栈指针、初始化中断向量表、调用SystemInit和main。栈指针的初始值写在链接脚本里通常是RAM的末尾地址。STM32F103C8T6的RAM是20KB从0x20000000到0x20005000所以栈顶初始值是0x20005000。栈是向下生长的第一个压栈的数据会放在0x20004FFC。中断向量表的第一个条目是栈顶地址第二个条目是复位处理函数的地址。复位处理函数里先调用SystemInit配置时钟然后调用__libc_init_array初始化C的全局对象最后跳转到main。注意如果你在C里定义了全局对象它的构造函数会在main之前被调用。这意味着构造函数里不能依赖任何需要main初始化之后才能用的资源。我见过有人在全局对象的构造函数里初始化串口结果串口时钟还没使能直接卡死。3.2 用C写一个GPIO翻转程序现在到了真正写代码的环节。目标很简单让STM32F103的PC13引脚以1Hz的频率翻转。PC13是很多最小系统板上LED连接的引脚。#include cstdint namespace { constexpr std::uint32_t RCC_APB2ENR 0x40021018; constexpr std::uint32_t GPIOC_CRH 0x40011004; constexpr std::uint32_t GPIOC_ODR 0x4001100C; constexpr std::uint32_t GPIOC_BASE 0x40011000; inline void set_bit(volatile std::uint32_t reg, std::uint32_t bit) { reg | (1u bit); } inline void clear_bit(volatile std::uint32_t reg, std::uint32_t bit) { reg ~(1u bit); } } int main() { // 使能GPIOC时钟 auto rcc_apb2enr *reinterpret_castvolatile std::uint32_t*(RCC_APB2ENR); set_bit(rcc_apb2enr, 4); // 配置PC13为推挽输出最大速度2MHz auto gpioc_crh *reinterpret_castvolatile std::uint32_t*(GPIOC_CRH); gpioc_crh ~(0xFu 20); gpioc_crh | (0x2u 20); auto gpioc_odr *reinterpret_castvolatile std::uint32_t*(GPIOC_ODR); while (true) { set_bit(gpioc_odr, 13); for (volatile std::uint32_t i 0; i 500000; i) {} clear_bit(gpioc_odr, 13); for (volatile std::uint32_t i 0; i 500000; i) {} } }这段代码有几个值得说的点。第一所有寄存器地址都用constexpr定义编译期就能确定不占RAM。第二用reinterpret_cast把地址转成volatile uint32_t*volatile告诉编译器这个值可能被硬件修改不要做优化。第三set_bit和clear_bit用inline修饰避免函数调用开销。延时用空循环实现volatile修饰循环变量防止编译器把整个循环优化掉。500000次循环在72MHz主频下大约是几十毫秒具体值需要用示波器或者逻辑分析仪测一下才能精确。3.3 链接脚本的关键配置stm32f103c8t6.ld决定了代码和数据在内存里的布局MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .text : { KEEP(*(.isr_vector)) *(.text*) *(.rodata*) } FLASH .data : { *(.data*) } RAM AT FLASH .bss : { *(.bss*) *(COMMON) } RAM }.isr_vector段必须放在Flash的最前面因为Cortex-M3上电后从0x08000000读取栈顶地址和复位向量。KEEP防止链接器把这个段优化掉。.data段的AT FLASH表示初始化数据存在Flash里运行时拷贝到RAM。.bss段不占Flash空间启动文件里会把它清零。注意链接脚本里的LENGTH必须和芯片实际容量一致。STM32F103C8T6是64KB Flash和20KB RAM如果你写成了128KB编译能过但烧进去之后超出部分的数据会丢失表现为程序随机崩溃。4. Renode模拟运行与调试实战4.1 Renode脚本编写要点Renode需要一个.resc脚本来描述硬件平台。针对STM32F103C8T6核心配置如下mach create stm32f103 machine LoadPlatformDescription platforms/cpus/stm32f103.repl sysbus LoadELF build-arm/stm32_cpp_demo.elf showAnalyzer sysbus.uart1 startLoadPlatformDescription加载的是Renode自带的STM32F103平台描述文件里面定义了内存映射、外设寄存器、中断控制器。LoadELF把编译出来的elf文件加载到模拟的内存里。showAnalyzer打开UART1的分析窗口串口输出会显示在这里。如果你想让Renode在特定地址暂停可以加sysbus.cpu PC 0x08000100这样的断点命令。Renode支持单步执行、查看寄存器、查看内存基本调试功能都有。4.2 用UART输出验证程序运行光看GPIO翻转在Renode里不太直观加个串口输出更实在void uart_init() { // 使能USART1和GPIOA时钟 auto rcc_apb2enr *reinterpret_castvolatile std::uint32_t*(0x40021018); set_bit(rcc_apb2enr, 2); // GPIOA set_bit(rcc_apb2enr, 14); // USART1 // PA9配置为复用推挽输出 auto gpioa_crh *reinterpret_castvolatile std::uint32_t*(0x40010804); gpioa_crh ~(0xFu 4); gpioa_crh | (0xBu 4); // 波特率11520072MHz主频 auto usart1_brr *reinterpret_castvolatile std::uint32_t*(0x40013808); usart1_brr 0x271; // 使能USART1发送和接收 auto usart1_cr1 *reinterpret_castvolatile std::uint32_t*(0x4001380C); usart1_cr1 (1u 13) | (1u 3) | (1u 2); } void uart_send_char(char c) { auto usart1_sr *reinterpret_castvolatile std::uint32_t*(0x40013800); auto usart1_dr *reinterpret_castvolatile std::uint32_t*(0x40013804); while (!(usart1_sr (1u 7))) {} usart1_dr c; }波特率计算72MHz / (16 * 115200) 39.0625取整39小数部分0.0625 * 16 1所以BRR (39 4) | 1 0x271。这个计算过程在参考手册里有公式但实际调试时建议用示波器测一下实际波特率误差超过3%就可能通信失败。在Renode里运行后UART分析窗口会显示你发送的字符。如果什么都没显示先检查时钟使能位有没有写对再检查GPIO配置是不是复用模式。4.3 常见问题排查表现象可能原因排查方法Renode加载elf后立即停止栈顶地址配置错误检查链接脚本RAM起始地址UART无输出时钟未使能或波特率错误用Renode的sysbus.usart1命令查看寄存器程序跑飞中断向量表未对齐确认.isr_vector段在Flash起始位置编译报错undefined reference to _exit缺少newlib的syscall桩添加-specsnosys.specs链接选项代码体积超过64KB开启了异常或RTTI检查-fno-exceptions -fno-rtti-specsnosys.specs这个链接选项特别重要。ARM GCC默认链接newlibnewlib里有些函数依赖操作系统提供的系统调用比如_write、_sbrk。裸机环境下没有这些系统调用链接就会报错。nosys.specs提供了一套空实现让链接能通过。5. 从能跑到好用工程化改进与经验总结5.1 把寄存器操作封装成类直接操作寄存器地址虽然直观但代码一多就乱。用C的模板和constexpr可以封装出零开销的抽象template std::uint32_t Addr struct Register { static volatile std::uint32_t value() { return *reinterpret_castvolatile std::uint32_t*(Addr); } static void set(std::uint32_t bits) { value() | bits; } static void clear(std::uint32_t bits) { value() ~bits; } static bool read(std::uint32_t bits) { return value() bits; } }; using RCC_APB2ENR Register0x40021018; using GPIOC_CRH Register0x40011004; using GPIOC_ODR Register0x4001100C;这样写出来的代码是RCC_APB2ENR::set(1u 4)比裸地址清晰得多。而且因为全是static函数和constexpr地址编译出来的汇编和直接写地址完全一样没有任何额外开销。5.2 用constexpr做编译期检查C17的if constexpr可以在编译期做条件判断这对嵌入式开发很有用template typename T constexpr bool is_valid_gpio_pin(T pin) { return pin 0 pin 15; } template int Pin void toggle_led() { static_assert(is_valid_gpio_pin(Pin), GPIO pin must be 0-15); GPIOC_ODR::value() ^ (1u Pin); }static_assert在编译期检查引脚号是否合法不合法直接编译报错不会等到运行时才发现问题。这种“把错误提前到编译期”的思路在嵌入式开发里特别有价值因为运行时调试的成本远高于编译期。5.3 我踩过的几个坑第一个坑启动文件里的栈大小。默认的启动文件栈大小是1KB如果你的程序里有递归或者大数组栈会溢出。溢出的表现是程序随机崩溃而且很难定位。我的做法是把栈大小改成4KB在链接脚本里显式定义_estack符号。第二个坑Renode的时钟模拟。Renode默认的时钟频率和真实芯片可能不一致导致延时函数的实际时间和预期不符。如果你在Renode里测出来1秒的延时烧到板子上可能只有0.8秒。解决办法是在Renode脚本里显式设置时钟频率sysbus.cpu Frequency 72000000。第三个坑CMake的target_include_directories顺序。CMake会按照你添加的顺序搜索头文件如果两个目录里有同名头文件先添加的会被优先使用。我遇到过CMSIS头文件和自己的board.h冲突的情况排查了半天才发现是包含顺序问题。建议把自己的头文件目录放在CMSIS之前。第四个坑Ninja的并行编译。Ninja默认用所有CPU核心并行编译这在大多数时候是好事但如果你的CMake脚本里有生成代码的步骤比如从.svd文件生成寄存器定义并行编译可能导致生成步骤和编译步骤竞争。解决办法是用add_dependencies显式声明依赖关系。5.4 后续可以怎么扩展这套框架跑通之后扩展方向很多。你可以加FreeRTOS把任务创建和调度用C的类封装起来可以加单元测试框架在PC上验证算法逻辑可以用constexpr和模板元编程在编译期计算查表数据减少运行时开销。我个人比较推荐的一个扩展是用C的强类型枚举替代宏定义。比如GPIO模式enum class GPIOMode : std::uint32_t { InputAnalog 0x0, InputFloat 0x4, OutputPP 0x2, OutputOD 0x6, AfPushPull 0xA, AfOpenDrain 0xE, };这样编译器会帮你检查类型不会出现把输入模式传给输出配置这种低级错误。而且enum class不会隐式转换成整数必须显式static_cast进一步减少误用。最后再分享一个小技巧在CMake里加一个size目标编译完自动显示固件占用的Flash和RAM大小add_custom_target(size COMMAND ${CMAKE_SIZE} $TARGET_FILE:stm32_cpp_demo DEPENDS stm32_cpp_demo )每次编译完敲ninja size就能看到当前固件占了多少空间离64KB上限还有多少余量。这个习惯能帮你避免“功能写完了发现放不下”的尴尬。