☰
STM32嵌入式C++实战:从零编写程序并用CMake与Renode仿真
2026/10/1 1:32:08 网站建设 项目流程

1. 先聊聊这个标题背后的“怨念”

“看了三篇了,一行都没让我写呢”——这句话我第一次看到的时候,差点把嘴里的茶喷出来。因为这几乎是每一个跟着系列教程学嵌入式的兄弟都会发出的灵魂拷问。前三篇大概率在讲什么?环境搭建、工具链安装、CMake 配置、VSCode 插件、Renode 仿真环境……全是“准备工作”。你跟着敲了一堆命令,装了一堆软件,结果连个main函数都没写出来,那种感觉就像去饭馆吃了三碟花生米,主菜迟迟不上。

但我要说句公道话:在 STM32 上用 C++ 开发,前期这些“看不见的活”恰恰是最容易翻车的地方。我见过太多人,Keil 里点几下就能跑个点灯,一换到 CMake + VSCode + Renode 这套组合,直接卡在配置阶段三天起步。所以这篇我不打算再让你“只看不写”,咱们直接动手,把前面欠的账一次性补上——从零写一个能跑在 STM32 上的 C++ 程序,用 CMake 构建,用 Renode 仿真验证,全程不碰 Keil。

这篇文章适合谁?如果你已经跟着前几篇装好了工具链,但不知道下一步该干嘛;或者你是个有 C 语言基础、想转 C++ 做嵌入式的开发者;再或者你是那种“不自己敲一遍就不算学会”的动手派,那这篇就是给你写的。我会把每一步为什么这么做讲清楚,参数怎么算、坑在哪里、怎么排查,全部摊开说。

核心关键词先摆出来:STM32、嵌入式 C++、CMake、Renode。这四个词贯穿全文,你记住它们的关系就行——STM32 是目标芯片,C++ 是开发语言,CMake 是构建系统,Renode 是仿真验证环境。四者串起来,就是一套完整的、不依赖商业 IDE 的嵌入式开发工作流。

2. 为什么嵌入式 C++ 要用 CMake 而不是 Keil

2.1 Keil 的舒适区和它的天花板

先说清楚,我不是来黑 Keil 的。Keil MDK 在 STM32 开发里的地位,就像螺丝刀在修车行的地位——好用、直接、上手快。新建工程、选芯片包、点编译、点下载,五分钟点灯。对于小项目、单人开发、快速验证,Keil 确实省事。

但问题出在“长大”之后。当你的项目从点灯变成几十个源文件、多个模块、第三方库依赖的时候,Keil 的工程管理就开始让人难受了。.uvprojx文件是 XML 格式,多人协作时合并冲突几乎没法看;想接入一个开源库,得手动把文件一个个加进工程;想换个芯片型号,配置界面点半天。更关键的是,Keil 的构建系统是封闭的,你没法在命令行里一条命令完成编译,也就没法接 CI/CD。

我个人的经验是:学习阶段用 Keil 没问题,但一旦你想认真做点东西,或者想往 Linux 嵌入式方向走,CMake 这套技能是绕不过去的。而且 CMake 在 Linux 驱动开发、Qt 应用开发、跨平台项目里到处都是,学一次,到处能用。

2.2 CMake 到底解决了什么问题

CMake 的本质是一个“构建系统生成器”。它不直接编译代码,而是根据你写的CMakeLists.txt,生成对应平台的构建文件——在 Linux 上生成 Makefile,在 Windows 上生成 Visual Studio 工程,在嵌入式场景下生成 Ninja 或 Makefile 给交叉编译工具链用。

这个“中间层”的设计,好处在于:你的构建描述是平台无关的。同一份CMakeLists.txt,换个工具链文件就能从 STM32 编译到 Linux 上跑,源码一行不用改。这在做跨平台库的时候简直是救命稻草。

对于 STM32 来说,CMake 配合arm-none-eabi-gcc工具链,可以完全替代 Keil 的编译功能。你需要自己写链接脚本(.ld文件)、启动文件(.s文件)、指定编译选项,这些在 Keil 里是自动生成的,在 CMake 里要手动配。听起来麻烦,但配一次之后就是一劳永逸,而且你对整个编译过程的理解会深一个层次。

2.3 Renode 在其中的角色

Renode 是一个开源的仿真框架,由 Antmicro 开发。它能仿真多种架构的处理器和外设,包括 STM32 系列。你可以把它理解成“不需要真实硬件的 STM32 开发板”。

为什么要在学习阶段用 Renode?两个原因。第一,不是每个人都有硬件在手边,尤其是学生党或者刚入门还在观望的人。第二,仿真环境可以精确控制、可重复、可调试,你想看某个寄存器的值、想单步跟踪中断响应,仿真器比真实硬件方便得多。

Renode 和 CMake 的配合方式是:CMake 编译出.elf文件,Renode 加载这个.elf文件并仿真运行。你可以在 Renode 里看到串口输出、GPIO 状态变化,甚至可以用 GDB 连上去调试。这套流程跑通之后,你等于拥有了一个“零成本的 STM32 实验室”。

3. 动手前的最后准备:工具链清单核对

3.1 必须装齐的四样东西

在开始写代码之前,确认你手上有这些工具。我按重要性排序:

工具作用验证命令常见问题
arm-none-eabi-gccARM 交叉编译器arm-none-eabi-gcc --version没加 PATH,命令找不到
CMake构建系统cmake --version版本低于 3.20 会有兼容问题
Ninja构建后端(推荐)ninja --version可选,用 Make 也行
Renode仿真运行renode --versionWindows 下需要 .NET 运行时

验证方法很简单,打开终端,挨个敲上面的命令。如果哪个报“command not found”,就回去补装。Windows 用户注意,装完工具后要把安装目录的bin文件夹加到系统 PATH 里,否则终端里找不到。

提示:arm-none-eabi-gcc 的版本建议用 10.3 以上。太老的版本对 C++17 支持不完整,编译时会报一堆莫名其妙的错。我踩过这个坑,用 9.x 版本编译带constexpr的代码,报错信息完全看不懂,换了 10.3 之后一次过。

3.2 目录结构先规划好

别小看目录结构,前期乱放文件,后期找起来要命。我推荐的结构是这样的:

stm32-cpp-demo/ ├── CMakeLists.txt # 顶层构建脚本 ├── cmake/ │ └── arm-none-eabi.cmake # 工具链文件 ├── src/ │ ├── main.cpp # 主程序 │ └── startup.s # 启动文件 ├── linker/ │ └── stm32f103.ld # 链接脚本 └── build/ # 构建输出目录(不提交到版本控制)

这个结构的好处是职责分明:cmake/放工具链配置,src/放源码,linker/放链接脚本,build/是临时产物。你以后加库、加测试、加文档,都有地方放。

3.3 工具链文件怎么写

工具链文件是 CMake 交叉编译的核心。它的作用是告诉 CMake:“别用你系统默认的编译器,用我指定的这个。”内容如下:

# cmake/arm-none-eabi.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_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) set(CMAKE_C_FLAGS_INIT "-mcpu=cortex-m3 -mthumb") set(CMAKE_CXX_FLAGS_INIT "-mcpu=cortex-m3 -mthumb -fno-exceptions -fno-rtti")

逐行解释一下。CMAKE_SYSTEM_NAME Generic表示这是裸机环境,没有操作系统。CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY这行很关键——CMake 默认会尝试编译一个可执行文件来测试编译器是否工作,但裸机环境下没有链接脚本,链接会失败,导致 CMake 误判编译器不可用。设成STATIC_LIBRARY就只编译不链接,绕过这个问题。

-mcpu=cortex-m3对应 STM32F103 的内核,如果你用的是 F4 系列,改成cortex-m4,F7 也是cortex-m4但可以加-mfpu=fpv5-sp-d16 -mfloat-abi=hard启用硬件浮点。-fno-exceptions -fno-rtti是嵌入式 C++ 的常规操作,关掉异常和运行时类型信息,能省不少 Flash 和 RAM。

4. 从零写第一个 STM32 C++ 程序

4.1 启动文件和链接脚本:不写不行,但不用怕

这两个文件是裸机开发的“地基”。启动文件负责在芯片上电后初始化堆栈指针、跳转到main函数;链接脚本负责告诉链接器代码放哪里、数据放哪里、堆栈多大。

启动文件用汇编写,但你不必从零写。ST 官方提供的 HAL 库里有现成的startup_stm32f103xb.s,直接拿来用就行。核心内容就几段:定义中断向量表、复位处理函数、调用SystemInit、跳转main。

链接脚本稍微需要理解一下。以 STM32F103C8T6 为例,它有 64KB Flash 和 20KB RAM。链接脚本要定义两个内存区域:

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

0x08000000是 STM32 Flash 的起始地址,这是芯片设计决定的,改不了。0x20000000是 RAM 起始地址。后面的rx表示可读可执行,rwx表示可读可写可执行。

然后是段布局,.text放代码,.data放已初始化全局变量,.bss放未初始化全局变量。.data段有个特殊处理:它的初始值存在 Flash 里,运行时需要拷贝到 RAM,这段拷贝代码在启动文件里。

注意:链接脚本里的堆栈大小要留够。我见过有人把栈设成 512 字节,结果一用printf就硬件错误。STM32F103 建议栈至少 1KB,堆根据是否用malloc决定,不用就设 0。

4.2 主程序:终于可以写 C++ 了

好,铺垫够了,上代码。这是一个最小可运行的 STM32 C++ 程序,功能是让 PC13 上的 LED 闪烁:

// src/main.cpp #include <cstdint> // 寄存器定义(简化版,实际项目建议用 CMSIS 头文件) constexpr uint32_t RCC_APB2ENR = 0x40021018; constexpr uint32_t GPIOC_CRH = 0x40011004; constexpr uint32_t GPIOC_ODR = 0x4001100C; // 用 C++ 的 constexpr 和模板做编译期计算 template<uint32_t Addr> struct Register { static inline volatile uint32_t& value() { return *reinterpret_cast<volatile uint32_t*>(Addr); } }; // 简单的延时函数 void delay(volatile uint32_t count) { while (count--) { __asm__ volatile("nop"); } } int main() { // 使能 GPIOC 时钟 Register<RCC_APB2ENR>::value() |= (1 << 4); // 配置 PC13 为推挽输出 Register<GPIOC_CRH>::value() &= ~(0xF << 20); Register<GPIOC_CRH>::value() |= (0x2 << 20); while (true) { // 翻转 PC13 Register<GPIOC_ODR>::value() ^= (1 << 13); delay(500000); } }

这段代码虽然短,但包含了几个 C++ 在嵌入式里的典型用法。constexpr让寄存器地址在编译期就确定,不占运行时开销。模板Register<Addr>把地址作为模板参数,编译器会为每个地址生成独立的代码,最终效果和直接写宏一样,但类型安全更好。

volatile关键字必须加,否则编译器优化时可能把对寄存器的读写“优化掉”,因为它觉得你读了一个值又没用到。在嵌入式里,寄存器的值随时可能被硬件改变,必须告诉编译器“每次都老老实实去读”。

4.3 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) add_executable(${PROJECT_NAME} src/main.cpp src/startup.s ) target_compile_options(${PROJECT_NAME} PRIVATE -Wall -Wextra -Wpedantic -Og -g3 -ffunction-sections -fdata-sections ) target_link_options(${PROJECT_NAME} PRIVATE -T${CMAKE_SOURCE_DIR}/linker/stm32f103.ld -Wl,--gc-sections -Wl,-Map=${PROJECT_NAME}.map --specs=nano.specs --specs=nosys.specs ) set_target_properties(${PROJECT_NAME} PROPERTIES SUFFIX ".elf" )

几个关键点解释。-ffunction-sections -fdata-sections配合-Wl,--gc-sections,作用是让链接器把没用的函数和数据段删掉,能显著减小固件体积。我实测过一个项目,加上这两个选项后 Flash 占用从 28KB 降到 19KB。

--specs=nano.specs使用精简版 C 库,--specs=nosys.specs表示没有系统调用,这两个是裸机开发的标配。不加的话链接会报一堆_exit、_sbrk未定义的错误。

-Og -g3是调试优化级别,-Og在保持可调试性的前提下做适度优化,-g3生成最详细的调试信息,方便 GDB 单步。

4.4 编译验证:看到 .elf 文件才算数

配置和编译命令:

cmake -B build -G Ninja cmake --build build

第一条命令在build/目录下生成 Ninja 构建文件,第二条执行编译。如果一切顺利,你会在build/下看到stm32-cpp-demo.elf。

编译过程中可能遇到的错误,我列个速查表:

错误信息原因解决
arm-none-eabi-gcc: not foundPATH 没配把工具链 bin 目录加入 PATH
undefined reference to _exit没加 nosys.specs链接选项加--specs=nosys.specs
region FLASH overflowed代码超过 Flash 容量检查是否误链接了大库
cannot find linker script路径写错用绝对路径或${CMAKE_SOURCE_DIR}

编译成功后,用arm-none-eabi-size build/stm32-cpp-demo.elf看一下体积:

text data bss dec hex filename 1248 12 1568 2828 b0c stm32-cpp-demo.elf

text是代码和常量,data是已初始化变量,bss是未初始化变量。这个例子很小,但结构是完整的。

5. 用 Renode 跑起来:不插硬件也能看效果

5.1 Renode 脚本怎么写

Renode 用.resc脚本描述仿真平台。针对 STM32F103,脚本大概是这样:

# stm32f103.resc mach create "stm32f103" machine LoadPlatformDescription @platforms/cpus/stm32f103.repl sysbus LoadELF @build/stm32-cpp-demo.elf showAnalyzer sysbus.uart1 start

第一行创建机器,第二行加载平台描述文件(Renode 自带 STM32F103 的描述),第三行加载我们编译的 ELF 文件,第四行打开串口分析器窗口,第五行启动仿真。

运行命令:

renode --console stm32f103.resc

启动后你会看到 Renode 的控制台,输入start后程序开始运行。因为我们这个例子只翻转 GPIO,没有串口输出,所以看不到什么。但你可以用 Renode 的命令行查看 GPIO 状态:

sysbus.gpioc.odr

多敲几次,你会看到 bit 13 在 0 和 1 之间变化,说明程序在正常跑。

5.2 加个串口输出,让效果看得见

光看寄存器不够直观,加个串口输出。STM32F103 的 USART1 在 PA9(TX)和 PA10(RX)。初始化代码:

constexpr uint32_t RCC_APB2ENR = 0x40021018; constexpr uint32_t GPIOA_CRH = 0x40010804; constexpr uint32_t USART1_SR = 0x40013800; constexpr uint32_t USART1_DR = 0x40013804; constexpr uint32_t USART1_BRR = 0x40013808; constexpr uint32_t USART1_CR1 = 0x4001380C; void uart_init() { // 使能 GPIOA 和 USART1 时钟 Register<RCC_APB2ENR>::value() |= (1 << 2) | (1 << 14); // PA9 复用推挽输出 Register<GPIOA_CRH>::value() &= ~(0xF << 4); Register<GPIOA_CRH>::value() |= (0xB << 4); // 波特率 115200,系统时钟 72MHz // BRR = 72000000 / 115200 = 625 = 0x271 Register<USART1_BRR>::value() = 0x271; // 使能 USART,使能发送 Register<USART1_CR1>::value() = (1 << 13) | (1 << 3); } void uart_send(char c) { while (!(Register<USART1_SR>::value() & (1 << 7))); Register<USART1_DR>::value() = c; } void uart_print(const char* s) { while (*s) uart_send(*s++); }

波特率计算这里展开说一下。STM32F103 默认系统时钟 72MHz,USART1 挂在 APB2 总线上,时钟也是 72MHz。波特率寄存器 BRR 的值 = 时钟频率 / 波特率。72,000,000 / 115,200 = 625,十六进制就是 0x271。这个值直接写进 BRR 寄存器就行,STM32 的 USART 会自动处理小数部分(BRR 的高 12 位是整数部分,低 4 位是小数部分,625 刚好是整数)。

在main里调用uart_init(),然后循环里uart_print("Hello STM32 C++\r\n")。重新编译,Renode 里就能在 UART 分析器窗口看到输出了。

5.3 用 GDB 连上 Renode 调试

Renode 支持 GDB 远程调试。在.resc脚本里加一行:

machine StartGdbServer 3333

然后另开一个终端:

arm-none-eabi-gdb build/stm32-cpp-demo.elf (gdb) target remote :3333 (gdb) break main (gdb) continue

这样就能单步调试了。你可以看变量、看寄存器、看调用栈,和调试本地程序体验差不多。对于理解 STM32 的启动流程、中断响应,这种方式比在真实硬件上插调试器还方便。

实操心得:Renode 的 GDB 服务器有时候响应慢,continue之后要等一两秒才停下来。别以为是卡死了,耐心等一下。另外,Renode 对某些外设的仿真不完整,比如 ADC、DMA 的某些模式,遇到仿真结果和真实硬件不一致的情况,优先怀疑仿真器的限制,别死磕代码。

6. 踩坑记录与排查思路

6.1 编译期最常见的三个坑

第一个坑:C++ 标准库用不了。裸机环境下,<iostream>、<string>、<vector>这些标准库组件默认不可用,因为它们依赖操作系统提供的内存管理和系统调用。你只能用<cstdint>、<cstddef>、<type_traits>这类“无宿主”头文件。想用容器的话,得自己实现或者用 ETL(Embedded Template Library)这类专为嵌入式设计的库。

第二个坑:全局对象的构造函数不执行。C++ 的全局对象会在main之前调用构造函数,但在裸机环境里,启动文件默认不调用__libc_init_array,导致构造函数被跳过。解决办法是在启动文件的main调用之前加上:

bl __libc_init_array bl main

这个坑很隐蔽,因为编译链接都不报错,但运行时全局对象的状态就是不对。我第一次遇到的时候排查了一下午。

第三个坑:new和delete不可用。标准new需要堆管理,裸机环境没有。要么自己实现operator new,要么干脆禁用动态内存分配。嵌入式里我强烈建议禁用动态分配,所有内存静态分配,避免碎片和不确定性。

6.2 运行期问题排查表

现象可能原因排查方法
程序不运行复位向量地址错检查链接脚本 FLASH 起始地址
硬件错误中断栈溢出或空指针加大栈,检查指针使用
串口乱码波特率不对重新计算 BRR 值
LED 不亮GPIO 时钟没使能检查 RCC 寄存器
Renode 无输出ELF 没加载成功检查 LoadELF 路径

6.3 关于 Renode 仿真的局限性

Renode 很好用,但要知道它的边界。它仿真的是芯片的“行为模型”,不是真实的时序。比如你写一个精确到微秒的延时循环,在 Renode 里跑的时间和真实硬件可能差很多。所以 Renode 适合验证逻辑正确性,不适合验证时序精度。

另外,Renode 对中断的仿真有时候会有延迟,你配置了定时器中断,可能要多等一会儿才触发。这不是你代码的问题,是仿真器的调度机制决定的。遇到这种情况,可以在脚本里调整机器的时钟频率,或者用emulation SetGlobalQuantum命令调整仿真步长。

7. 从能跑到好用:下一步怎么走

7.1 把寄存器操作封装成类

上面代码里直接操作寄存器地址,可读性差、容易出错。实际项目里应该封装成类:

class Gpio { public: enum class Mode { Input, OutputPushPull, OutputOpenDrain, Analog }; constexpr Gpio(uint32_t base, uint8_t pin) : base_(base), pin_(pin) {} void setMode(Mode m) { // 根据 pin 号操作 CRL 或 CRH } void toggle() { *reinterpret_cast<volatile uint32_t*>(base_ + 0x0C) ^= (1 << pin_); } private: uint32_t base_; uint8_t pin_; };

这样用起来就是Gpio led(GPIOC_BASE, 13); led.setMode(Gpio::Mode::OutputPushPull); led.toggle();,比裸地址清楚多了。而且constexpr构造函数让这些对象可以在编译期创建,不占运行时开销。

7.2 引入 CMSIS 头文件

ST 官方提供的 CMSIS 头文件已经把所有寄存器定义好了,直接用就行,不用自己算地址。把 CMSIS 的stm32f103xb.h加入项目,然后就可以写RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;这种可读性好的代码。CMSIS 是纯头文件,不增加运行时开销,是 STM32 开发的标准做法。

7.3 用上 C++ 的编译期能力

嵌入式 C++ 最大的优势是编译期计算。比如用constexpr函数计算波特率:

constexpr uint32_t calcBrr(uint32_t clk, uint32_t baud) { return (clk + baud / 2) / baud; } constexpr uint32_t BRR_VALUE = calcBrr(72000000, 115200);

编译器会在编译期算出BRR_VALUE,运行时直接用常量,零开销。类似的还有用模板做寄存器位域操作、用static_assert做编译期检查,这些都是 C 语言做不到的。

7.4 构建系统的进阶配置

项目变大之后,CMakeLists.txt 需要拆分。把公共配置放到cmake/下的.cmake文件里,用include()引入。第三方库用add_subdirectory()或FetchContent管理。编译选项按 Debug/Release 分开配置:

target_compile_options(${PROJECT_NAME} PRIVATE $<$<CONFIG:Debug>:-Og -g3> $<$<CONFIG:Release>:-O2 -g0> )

$<CONFIG:Debug>是 CMake 的生成器表达式,根据构建类型选择不同选项。Debug 用-Og方便调试,Release 用-O2优化体积和速度。

8. 关于学习路线的几句实在话

回到标题那句“看了三篇了,一行都没让我写呢”。我理解这种心情,但我想说的是:嵌入式开发里,“配置环境”本身就是一项核心技能。你在配工具链、写 CMake、调链接脚本的过程中学到的东西,比写几行点灯代码值钱得多。因为点灯代码网上到处都是,但能把一套工具链从零搭起来、能排查构建错误、能理解编译链接过程的人,才是真正能独立做项目的人。

我自己的学习路径是这样的:先用 Keil 快速上手,建立信心;然后花时间啃 CMake 和交叉编译,把工具链换成开源的;再学 Renode 和 GDB,建立不依赖硬件的调试能力;最后把这些串起来,形成一套自己的工作流。这个过程花了大概两个月,但之后做任何 STM32 项目,我都能在半小时内搭好框架开始写业务代码。

如果你现在卡在环境配置阶段,我的建议是:别急着往下学新东西,先把这篇文章里的流程完整走一遍。编译出.elf,在 Renode 里跑起来,用 GDB 连上去单步一次。走通之后,你对整个开发流程的理解会完全不一样。后面再学中断、定时器、通信协议,都是在在这个框架上填内容,心里有底。

最后分享一个小技巧:把常用的 CMake 配置和 Renode 脚本做成模板,新建项目时直接复制。我自己的模板库里有一套 STM32F103 的配置,一套 F407 的,一套 F411 的,用的时候改改芯片型号和链接脚本就行。省下来的时间,够你多写好几个模块了。

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

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

立即咨询