1. 为什么第四篇了还在讲环境
如果你是从这个系列的第一篇一路追过来的,看到这个标题大概会心一笑——"看了三篇了,一行都没让我写呢"。这个吐槽太真实了,我自己当年学 STM32 的时候也是这个心态:买板子、装软件、看教程,折腾了两三天,LED 还没亮起来,心里那个急啊。
但我要说的是,前三篇不让你写代码,恰恰是对你负责。嵌入式开发和纯软件开发最大的区别在于:你的代码跑在一颗没有操作系统兜底的芯片上,任何环境配置的疏漏都会以"编译通过但板子没反应"这种最让人抓狂的形式表现出来。PC 上写 Python,环境错了顶多报个 ImportError;STM32 上环境错了,你面对的是沉默的板子和闪烁不定的调试器,连个错误信息都不给你。
所以这一篇,我们终于要动手了。但在动手之前,我得先把工具链的最后一环补齐——CMake 构建系统和Renode 仿真验证。这两个东西是让你从"跟着教程点鼠标"进化到"自己搭工程"的关键。我见过太多人 Keil 用得很溜,但一旦让他脱离 IDE 从命令行构建一个 STM32 工程,就完全懵了。这不是能力问题,是从来没接触过底层构建流程。
这篇文章的目标很明确:让你在不依赖任何商业 IDE的前提下,用 CMake + arm-none-eabi-gcc 把代码编译出来,用 Renode 在 PC 上先把逻辑跑通,最后再烧到真实板子上。整套流程走完,你对"一个 STM32 工程是怎么从源码变成可执行文件"这件事的理解,会超过 80% 只会点 Keil 编译按钮的人。
适合谁看?如果你已经跟着前三篇把工具链装好了,这篇是必经之路。如果你是从别的地方跳过来的,只要你会基本的 C/C++ 语法,能看懂#include和函数调用,也能跟上。我会把每个配置项为什么这么写讲清楚,而不是甩给你一个 CMakeLists.txt 让你复制粘贴。
2. 从 Keil 到 CMake:构建系统的思维切换
2.1 Keil 帮你隐藏了什么
大部分人的 STM32 入门路径是这样的:装 Keil MDK,装芯片包(STM32F1xx_DFP 之类的),新建工程,勾选需要的库,写代码,点编译,点下载。整个过程行云流水,你甚至不需要知道编译器叫什么名字。
但 Keil 在背后做了大量工作,只是没告诉你:
- 它自动帮你选了编译器(ARMCC 或 ARMCLANG),并配置了一大堆编译选项
- 它自动帮你写了链接脚本(分散加载文件 .sct),告诉链接器代码放哪、数据放哪
- 它自动帮你生成了启动文件,配置了中断向量表
- 它自动帮你调用了 fromelf 把 elf 转成 hex 或 bin
这些"自动"在初学阶段是好事,降低了门槛。但当你需要定制构建流程、做持续集成、在团队里统一构建环境、或者用 VSCode 写代码但用命令行编译的时候,Keil 的黑盒就成了障碍。
CMake 的价值就在于:它把这些"自动"变成了显式的、可读的、可版本控制的配置。你的构建逻辑写在 CMakeLists.txt 里,谁都能看懂,谁都能改,放进 Git 里一目了然。换一台电脑,装好工具链,cmake .. && make就能出结果,不需要重新在 IDE 里点一遍配置向导。
2.2 交叉编译到底在交叉什么
在讲 CMake 配置之前,必须先把这个概念讲透,否则后面看到CMAKE_C_COMPILER那一堆设置你会一头雾水。
你平时在 PC 上写 C 代码,用的编译器是 gcc 或 clang,它生成的是x86-64 指令集的机器码,能在你的 Intel/AMD CPU 上直接跑。但 STM32 用的是ARM Cortex-M 内核,指令集完全不同。x86 的机器码丢给 STM32,它一个字节都看不懂。
所谓交叉编译,就是"在 A 平台上编译出能在 B 平台上运行的代码"。这里 A 是你的 PC(x86-64),B 是 STM32(ARM Cortex-M)。实现这件事的工具叫交叉编译工具链,我们用的是arm-none-eabi-gcc。
这个名字拆开看很有意思:
arm:目标架构是 ARMnone:没有操作系统(裸机)eabi:嵌入式应用二进制接口(Embedded Application Binary Interface)
所以arm-none-eabi-gcc就是"给裸机 ARM 用的 GCC"。它和你在 Linux 上用的 gcc 是同一套代码库编译出来的,只是目标架构不同。这意味着 GCC 的绝大多数选项、语法支持都是一致的,你学到的知识可以迁移。
2.3 工具链里到底有哪几个关键工具
装好gcc-arm-none-eabi之后,你的 PATH 里会多出一堆以arm-none-eabi-开头的命令。常用的就这几个,我列个表让你心里有数:
| 工具 | 作用 | 类比 PC 上的 |
|---|---|---|
| arm-none-eabi-gcc | C 编译器 | gcc |
| arm-none-eabi-g++ | C++ 编译器 | g++ |
| arm-none-eabi-as | 汇编器 | as |
| arm-none-eabi-ld | 链接器 | ld |
| arm-none-eabi-objcopy | 格式转换(elf→bin/hex) | objcopy |
| arm-none-eabi-objdump | 反汇编、查看段信息 | objdump |
| arm-none-eabi-size | 查看代码/数据占用 | size |
| arm-none-eabi-gdb | 调试器 | gdb |
实际构建时,你主要跟 gcc/g++ 打交道,它们会自动调用 as 和 ld。objcopy 和 size 是构建后处理阶段用的,后面会讲。
提示:如果你在 Windows 上,建议用 MSYS2 或 WSL 来获得类 Unix 的构建环境,CMake 和 Make 的体验会顺畅很多。纯 Windows 命令行下用 MinGW 也能做,但路径分隔符和 shell 语法的坑会多一些。
3. 手写一个能编译的 CMakeLists.txt
3.1 最小可编译工程需要哪些文件
在写 CMakeLists.txt 之前,先明确一个 STM32 裸机工程最少需要哪些源文件。很多人以为只要有 main.c 就行,其实不然:
- 启动文件(startup_stm32f103xb.s):这是芯片上电后执行的第一段代码,负责初始化栈指针、设置中断向量表、调用 SystemInit,最后跳到 main。没有它,芯片上电后不知道从哪开始执行。
- 链接脚本(STM32F103C8Tx_FLASH.ld):告诉链接器 Flash 从 0x08000000 开始、大小 64K,RAM 从 0x20000000 开始、大小 20K,以及各个段(.text/.data/.bss)怎么摆放。
- 系统初始化文件(system_stm32f1xx.c):配置系统时钟,比如把 8MHz 外部晶振倍频到 72MHz。
- HAL 库或标准库:提供 GPIO、USART 等外设的驱动函数。
- main.c:你的业务代码。
这五样缺一不可。启动文件和链接脚本通常从 ST 官方的 CubeMX 或者芯片包例程里拿,不用自己写。HAL 库可以从 GitHub 上的 STM32CubeF1 仓库获取。
3.2 CMakeLists.txt 逐段拆解
下面这份 CMakeLists.txt 是我在实际项目中反复打磨过的版本,去掉了不必要的复杂度,但保留了工程化需要的结构。我按段落讲,每段都告诉你为什么这么写。
cmake_minimum_required(VERSION 3.20) # 关键:必须在 project() 之前设置,否则 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) # 告诉 CMake 不要尝试链接可执行文件来测试编译器 set(CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY) project(stm32_cpp_journey LANGUAGES C CXX ASM)第一段的核心是CMAKE_SYSTEM_NAME Generic。如果不设这个,CMake 会认为你在做本机编译,然后尝试用 arm-none-eabi-gcc 去链接一个能在 x86 上跑的程序,结果必然失败。设成 Generic 就是告诉 CMake:"这是裸机目标,别做那些本机编译的假设。"
CMAKE_TRY_COMPILE_TARGET_TYPE STATIC_LIBRARY这一行是很多人的坑。CMake 默认会编译一个测试程序并尝试链接成可执行文件来验证编译器可用性。但裸机环境下没有标准库的_start,链接会失败,CMake 就误判编译器不可用。设成 STATIC_LIBRARY 后,它只编译成静态库,不链接,就能通过检测。
3.3 编译选项:每一个都有存在的理由
set(CPU_FLAGS "-mcpu=cortex-m3 -mthumb") set(COMMON_FLAGS ${CPU_FLAGS} -Wall -Wextra -fdata-sections -ffunction-sections -ffreestanding -fno-builtin ) set(CMAKE_C_FLAGS "${COMMON_FLAGS} -std=gnu11") set(CMAKE_CXX_FLAGS "${COMMON_FLAGS} -std=gnu++17 -fno-exceptions -fno-rtti -fno-threadsafe-statics")-mcpu=cortex-m3 -mthumb:STM32F103 是 Cortex-M3 内核,只支持 Thumb 指令集(不支持 ARM 指令集)。这两个选项必须成对出现,缺了 -mthumb 编译器会生成 ARM 指令,芯片跑不了。
-fdata-sections -ffunction-sections:把每个函数和每个数据都放到独立的段里。配合链接选项--gc-sections,可以把没被引用的函数和数据从最终固件里剔除,显著减小体积。对于 Flash 只有 64K 的 F103C8 来说,这个优化很关键。
-ffreestanding -fno-builtin:告诉编译器"我运行在裸机环境,没有完整的标准库",这样它不会把printf优化成puts,也不会假设memcpy的行为符合标准库规范。嵌入式里这两个选项能避免很多诡异问题。
C++ 的三个-fno-选项值得单独说:
-fno-exceptions:异常机制需要运行时支持(栈展开、类型信息),裸机上没有,关掉能省不少空间。-fno-rtti:运行时类型识别同样需要额外数据,裸机上基本用不到,关掉。-fno-threadsafe-statics:C++ 局部静态变量的线程安全初始化需要__cxa_guard_acquire之类的运行时函数,裸机上没有多线程,关掉避免链接错误。
注意:关掉异常和 RTTI 意味着你不能用 try/catch 和 dynamic_cast。在嵌入式 C++ 里这是标准做法,用错误码和模板替代这些特性。
3.4 链接脚本与 gc-sections
set(LINKER_SCRIPT ${CMAKE_SOURCE_DIR}/STM32F103C8Tx_FLASH.ld) set(CMAKE_EXE_LINKER_FLAGS "${CPU_FLAGS} -T${LINKER_SCRIPT} \ -Wl,--gc-sections \ -Wl,-Map=${PROJECT_NAME}.map \ --specs=nano.specs --specs=nosys.specs" )-T${LINKER_SCRIPT}:指定链接脚本。这个文件决定了你的代码在 Flash 和 RAM 里的布局,是裸机工程的核心配置。
-Wl,--gc-sections:开启段回收。配合前面的-ffunction-sections -fdata-sections,把没用到的代码从固件里删掉。我实测过一个 HAL 工程,不加这个选项固件 28K,加了之后降到 12K,效果非常明显。
-Wl,-Map=xxx.map:生成 map 文件。这个文件记录了每个符号被链接到了哪个地址、占多大空间。当你的固件超出 Flash 容量,或者想搞清楚某个函数到底占了多少空间时,map 文件是唯一的排查依据。
--specs=nano.specs:使用 newlib-nano,这是为嵌入式精简过的 C 标准库,比完整版小很多。
--specs=nosys.specs:提供一堆系统调用的空实现(_write、_sbrk等)。裸机上没有文件系统、没有进程管理,这些系统调用本来就不存在,但 newlib 里有些函数会引用它们,不提供就会链接报错。
3.5 把源文件组织起来
file(GLOB_RECURSE HAL_SOURCES "${CMAKE_SOURCE_DIR}/Drivers/STM32F1xx_HAL_Driver/Src/*.c" ) set(APP_SOURCES ${CMAKE_SOURCE_DIR}/Core/Src/main.c ${CMAKE_SOURCE_DIR}/Core/Src/system_stm32f1xx.c ${CMAKE_SOURCE_DIR}/Core/Src/stm32f1xx_it.c ${CMAKE_SOURCE_DIR}/Core/Startup/startup_stm32f103xb.s ) add_executable(${PROJECT_NAME}.elf ${APP_SOURCES} ${HAL_SOURCES}) target_include_directories(${PROJECT_NAME}.elf PRIVATE ${CMAKE_SOURCE_DIR}/Core/Inc ${CMAKE_SOURCE_DIR}/Drivers/STM32F1xx_HAL_Driver/Inc ${CMAKE_SOURCE_DIR}/Drivers/CMSIS/Device/ST/STM32F1xx/Include ${CMAKE_SOURCE_DIR}/Drivers/CMSIS/Include )这里有个细节:HAL 库的源文件我用GLOB_RECURSE一次性收集,但应用代码我一个个列出来。为什么区别对待?因为 HAL 库是第三方代码,文件多且稳定,用 GLOB 省事;而应用代码是你自己要频繁增删的,显式列出能让你清楚知道工程里到底有哪些文件,也避免 GLOB 在新增文件后不重新配置 CMake 导致文件没被编译的坑。
提示:
file(GLOB)有个众所周知的缺点——新增文件后 CMake 不会自动感知,需要重新运行 cmake 配置。CMake 官方甚至不推荐用 GLOB。但在 HAL 这种"一次引入、长期不动"的场景下,它的便利性大于缺点。应用代码还是老实列出来。
3.6 生成 bin 和 hex 的后处理
add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.bin COMMAND ${CMAKE_OBJCOPY} -O ihex $<TARGET_FILE:${PROJECT_NAME}.elf> ${PROJECT_NAME}.hex COMMAND ${CMAKE_SIZE} $<TARGET_FILE:${PROJECT_NAME}.elf> )编译产物是 elf 格式,包含调试信息和段信息,但烧录工具通常需要 bin 或 hex。这段 POST_BUILD 命令在每次链接完成后自动生成这两种格式,并调用 size 打印固件占用情况。
$<TARGET_FILE:...>是 CMake 的生成器表达式,会自动展开成目标文件的完整路径,比手写路径可靠。
size 的输出长这样:
text data bss dec hex filename 12340 108 1564 14012 36bc stm32_cpp_journey.elftext 是代码+只读数据,data 是已初始化的全局变量(存在 Flash,运行时拷贝到 RAM),bss 是未初始化的全局变量(只占 RAM)。Flash 占用 = text + data,RAM 占用 = data + bss。这个账要会算,不然固件超了都不知道超在哪。
4. Renode:不插板子也能验证逻辑
4.1 为什么要在 PC 上仿真
你可能会问:板子就在手边,为什么不直接烧进去看效果,还要搞什么仿真?
原因有几个,都是实际开发中会遇到的:
第一,硬件不一定随时可用。我经常在通勤路上或者出差途中想改点代码,板子不在身边。这时候 Renode 能让我在笔记本上把逻辑跑通,回去再烧板子验证。
第二,调试信息更丰富。真板子上跑飞了,你只能看到 LED 不亮或者串口没输出。Renode 里你可以直接看寄存器、看内存、看每条指令的执行,定位问题快得多。
第三,CI 集成。团队协作时,你不可能给每个 CI runner 都插一块 STM32。Renode 可以在纯软件环境里跑自动化测试,每次提交代码自动验证逻辑没跑偏。
第四,学习成本低。初学阶段,你还没搞懂时钟配置、引脚复用这些硬件细节,直接上板子容易一头雾水。Renode 里先把纯逻辑(比如算法、状态机)跑通,再上硬件,心理负担小很多。
4.2 Renode 模拟 STM32F103 的基本配置
Renode 用.resc脚本描述要模拟的平台。下面这份是针对 STM32F103 的最小配置:
# stm32f103.resc mach create "stm32f103" machine LoadPlatformDescription @platforms/cpus/stm32f103.repl sysbus LoadELF @${CURDIR}/../build/stm32_cpp_journey.elf showAnalyzer sysbus.uart1 start逐行解释:
mach create创建一个新的机器实例,名字随便取。
LoadPlatformDescription加载平台描述文件。Renode 自带了很多常见芯片的.repl文件,STM32F103 的在platforms/cpus/stm32f103.repl。这个文件描述了芯片有哪些外设、寄存器地址在哪、中断怎么连。
LoadELF把你的固件加载进去。Renode 会解析 elf 里的段信息,把代码放到对应的 Flash 地址,数据放到 RAM 地址。
showAnalyzer sysbus.uart1打开 UART1 的分析窗口。这是 Renode 最实用的功能之一——你的代码往串口发数据,分析窗口里能直接看到,不需要真实的 USB 转串口模块。
start开始执行。
4.3 用 Renode 验证一个 GPIO 闪烁逻辑
光说不练假把式。我们写一段最简单的代码,让 PC13 上的 LED 闪烁,然后在 Renode 里验证。
// main.c #include "stm32f1xx_hal.h" void SystemClock_Config(void); static void MX_GPIO_Init(void); int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { HAL_GPIO_TogglePin(GPIOC, GPIO_PIN_13); HAL_Delay(500); } } static void MX_GPIO_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; __HAL_RCC_GPIOC_CLK_ENABLE(); GPIO_InitStruct.Pin = GPIO_PIN_13; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Pull = GPIO_NOPULL; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOC, &GPIO_InitStruct); }在 Renode 里跑起来后,你可以在 Renode 的监视器里输入命令查看 GPIOC 的 ODR 寄存器:
(machine) sysbus.gpioc.ODR你会看到 bit 13 在 0 和 1 之间周期性变化,说明闪烁逻辑正常。这个过程不需要真实板子,也不需要示波器。
4.4 Renode 的局限与真机验证的不可替代性
Renode 虽好,但必须清楚它的边界:
时序不精确。Renode 模拟的是功能,不是精确时序。你的代码在 Renode 里跑得好好的,真机上可能因为时钟配置错误、中断优先级冲突、外设初始化顺序问题而跑飞。特别是涉及精确延时的场景(比如软件模拟的 WS2812 驱动),Renode 完全无法验证。
外设覆盖不全。STM32F103 的.repl文件覆盖了 GPIO、USART、SPI、I2C、定时器等常用外设,但一些冷门外设或者特定型号的差异可能没模拟到。用到这些外设时,Renode 里可能直接报"未实现的寄存器访问"。
电气特性无法验证。上拉电阻够不够、驱动电流足不足、信号完整性好不好,这些只有真机能告诉你。
所以我的建议是:Renode 用来验证逻辑和算法,真机用来验证硬件交互。两者互补,不是替代关系。
5. 从零到点灯:完整流程走一遍
5.1 目录结构规划
在动手之前,先把目录结构定下来。一个好的结构能让你后面少折腾:
stm32_cpp_journey/ ├── CMakeLists.txt ├── STM32F103C8Tx_FLASH.ld ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ └── stm32f1xx_hal_conf.h │ ├── Src/ │ │ ├── main.c │ │ ├── system_stm32f1xx.c │ │ └── stm32f1xx_it.c │ └── Startup/ │ └── startup_stm32f103xb.s ├── Drivers/ │ ├── STM32F1xx_HAL_Driver/ │ └── CMSIS/ ├── build/ └── renode/ └── stm32f103.rescCore放你自己的代码,Drivers放 ST 官方库,build是 CMake 的输出目录(不纳入版本控制),renode放仿真脚本。这个结构基本是 CubeMX 生成工程的翻版,但去掉了 IDE 相关的文件,更干净。
5.2 构建命令与常见报错
mkdir -p build && cd build cmake -DCMAKE_BUILD_TYPE=Debug .. make -j4如果一切顺利,你会在 build 目录下看到stm32_cpp_journey.elf、.bin、.hex和.map四个文件。
但第一次构建大概率不会顺利。我列几个最常见的报错和排查思路:
报错一:arm-none-eabi-gcc: command not found
工具链没装或者没加到 PATH。Linux 下用sudo apt install gcc-arm-none-eabi,Windows 下装完记得把bin目录加到环境变量。验证方法:arm-none-eabi-gcc --version能打印版本号就对了。
报错二:undefined reference to _exit或_sbrk
这是 newlib 的系统调用没提供。检查链接选项里有没有--specs=nosys.specs。如果加了还报错,可能是某个库函数引用了更冷门的系统调用,需要自己实现一个空函数。
报错三:region FLASH overflowed by XXXX bytes
固件超出 Flash 容量。先看 size 输出,确认是 text 还是 data 太大。如果是 HAL 库引入太多,检查stm32f1xx_hal_conf.h里是不是把所有模块都打开了,关掉用不到的模块能省不少空间。
报错四:cannot find -lnosys
工具链安装不完整,缺少 newlib 的 nosys 库。重新安装libnewlib-arm-none-eabi包。
5.3 烧录与验证
构建出 bin 文件后,烧录方式取决于你手上的工具:
- ST-Link:用
st-flash write stm32_cpp_journey.bin 0x08000000 - OpenOCD:配置好 interface 和 target 后,
program stm32_cpp_journey.elf verify reset exit - 串口 ISP:用
stm32flash工具,需要把 BOOT0 拉高进入 bootloader 模式
烧录后如果 LED 没反应,按这个顺序排查:
- 用万用表量 PC13 对地电压,看有没有在 0V 和 3.3V 之间跳变。如果有跳变但 LED 不亮,是 LED 电路问题(限流电阻太大、LED 极性接反)。
- 如果没有跳变,用调试器连上去看 PC 卡在哪。常见的是卡在
HAL_Delay里,说明 SysTick 中断没配好。 - 如果连调试器都连不上,检查 BOOT0/BOOT1 引脚状态,以及复位电路。
5.4 把 C++ 特性用起来
既然是"C++ 编程之旅",总得用点 C++ 的东西。在裸机上,最实用的 C++ 特性是模板和编译期计算,因为它们零运行时开销。
举个例子,用模板封装 GPIO 操作:
template<uint32_t PortBase, uint16_t Pin> class Led { public: static void init() { // 使能对应端口的时钟 if constexpr (PortBase == GPIOC_BASE) { __HAL_RCC_GPIOC_CLK_ENABLE(); } GPIO_InitTypeDef cfg = {0}; cfg.Pin = Pin; cfg.Mode = GPIO_MODE_OUTPUT_PP; cfg.Pull = GPIO_NOPULL; cfg.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(reinterpret_cast<GPIO_TypeDef*>(PortBase), &cfg); } static void toggle() { HAL_GPIO_TogglePin(reinterpret_cast<GPIO_TypeDef*>(PortBase), Pin); } }; using StatusLed = Led<GPIOC_BASE, GPIO_PIN_13>;用的时候:
StatusLed::init(); while (1) { StatusLed::toggle(); HAL_Delay(500); }if constexpr是 C++17 的特性,编译期求值,不满足条件的分支根本不会生成代码。整个Led类没有任何虚函数、没有运行时开销,编译出来的汇编和直接调 HAL 函数一模一样。这就是嵌入式 C++ 的正确打开方式——用抽象换可读性,但不换性能。
6. 那些教程不会告诉你的坑
6.1 启动文件选错型号
STM32F103 有好几个型号,启动文件也分startup_stm32f103xb.s(中容量,64K/128K Flash)、startup_stm32f103xe.s(大容量,256K以上)等。选错了会怎样?编译能过,但中断向量表可能对不上,表现为中断进不去或者进错中断。
判断方法:看你的芯片型号后缀。C8T6 是 64K Flash,对应xb;CBT6 是 128K,也是xb;ZET6 是 512K,对应xe。拿不准就查 ST 的参考手册,别猜。
6.2 链接脚本的 RAM 大小写错
F103C8T6 的 RAM 是 20K,但很多人从别的工程复制链接脚本时忘了改,写成 64K。结果就是:编译链接都通过,但运行时栈溢出,程序随机跑飞。这种问题最难查,因为现象不固定。
链接脚本里这两行必须和芯片实际匹配:
RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K6.3 HAL_Delay 在中断里调用会死锁
HAL_Delay依赖 SysTick 中断来更新计时变量。如果你在某个中断服务函数里调用HAL_Delay,而该中断优先级高于或等于 SysTick 的优先级,SysTick 中断进不来,计时变量不更新,HAL_Delay就永远等下去,整个系统卡死。
正确做法是在中断里用简单的忙等待循环,或者用硬件定时器做精确延时,绝对不要在中断里调HAL_Delay。
6.4 CMake 的 build 目录不要提交到 Git
build目录里全是生成的文件,路径还跟你的机器绑定,提交上去只会污染仓库。在.gitignore里加上:
build/ *.elf *.bin *.hex *.map6.5 Renode 加载 ELF 时找不到符号
Renode 加载 ELF 需要符号信息来定位入口点。如果你编译时加了-s或者 strip 掉了符号,Renode 可能加载失败。Debug 构建不要 strip,Release 构建如果要给 Renode 用,保留一份带符号的 elf。
7. 下一步:从点灯到真正的项目
走到这里,你已经有了一个完全自主可控的 STM32 C++ 工程:不依赖任何商业 IDE,构建流程透明,能在 PC 上仿真验证,也能烧到真机运行。这个基础打好了,后面学什么外设都是在这个框架里加代码的事。
接下来可以尝试的方向:
把串口用起来。在 Renode 里showAnalyzer看输出,在真机上用 USB 转串口模块看输出。串口是嵌入式开发最重要的调试手段,比点灯有用得多。实现一个printf重定向到 UART,后面调试会轻松很多。
引入状态机框架。裸机程序写复杂了就是一堆if-else和标志位,用 C++ 写个简单的状态机模板,代码会清晰很多。这是 C++ 在嵌入式里真正发挥价值的地方。
试试单元测试。把纯逻辑代码(不依赖硬件的部分)抽出来,在 PC 上用 Google Test 跑单元测试,硬件相关的部分用 mock 替代。这样大部分 bug 在 PC 上就能发现,不用反复烧板子。
深入链接脚本。把变量放到特定段、把函数放到 RAM 里执行、配置栈和堆的大小,这些都需要改链接脚本。搞懂了链接脚本,你对程序在芯片里怎么布局就有了完整的掌控。
我在实际项目里最大的体会是:构建系统搭一次,受益整个项目周期。前期花两天把 CMake 和工具链理顺,后面每次改代码、加文件、做 CI 都是几分钟的事。反过来,如果一直依赖 IDE 的图形界面,换个环境就要重新折腾一遍,团队协作时更是灾难。这笔时间投资,绝对值。