
1. 从“光看不练”到“动手写第一行代码”的认知转变“看了三篇了一行都没让我写呢”——这句话我太熟了。几乎每个跟着系列教程学STM32嵌入式C的朋友到第三、四篇的时候都会冒出这个念头。前面几篇把开发环境、工具链、CMake构建系统、Renode仿真平台讲了个遍读者手里攒了一堆工具却始终没机会把它们串起来跑通一个完整的流程。这种感觉就像把厨房装修好了、刀具磨快了、食材买齐了但菜还没下锅。这篇的核心目标很明确让你真正动手写出第一段能在STM32上跑的C代码并且用CMake把它构建出来最后在Renode里看到运行结果。不是点灯那种“Hello World”级别的敷衍而是一个包含GPIO控制、定时器中断、串口输出的完整小项目。为什么选这个组合因为GPIO是嵌入式的“手”定时器是“心跳”串口是“嘴”——这三样凑齐了你就能让芯片做大部分基础交互了。适合谁看如果你已经跟着前几篇把arm-none-eabi-gcc、CMake、Renode装好了但不知道下一步该干嘛这篇就是为你写的。如果你还没装好建议先回去补课因为这篇默认你的工具链是可用的。另外这篇虽然用的是STM32F103但整个工程结构和构建逻辑对F4、G0、H7系列都是通用的换芯片主要改链接脚本和启动文件。我自己的习惯是每学一个新平台第一件事不是点灯而是搭一个“最小可运行系统”——能编译、能烧录、能打印、能中断。这个系统跑通了后面加什么外设都是在这个骨架上挂载。这篇就是带你搭这个骨架。2. 工程目录结构为什么这样组织比“一键生成”更值得学2.1 手工搭建目录的底层逻辑很多人用STM32CubeMX生成工程点几下鼠标代码框架就出来了。方便是方便但代价是你不知道里面发生了什么。一旦编译报错或者链接失败你连从哪查起都不知道。我建议至少在入门阶段手工搭一次目录结构把每个文件的角色搞清楚。我的工程目录是这样的stm32-cpp-demo/ ├── CMakeLists.txt ├── cmake/ │ └── arm-none-eabi.cmake ├── src/ │ ├── main.cpp │ ├── gpio.cpp │ ├── timer.cpp │ └── uart.cpp ├── include/ │ ├── gpio.hpp │ ├── timer.hpp │ └── uart.hpp ├── startup/ │ └── startup_stm32f103.s ├── linker/ │ └── stm32f103.ld └── build/为什么把cmake/arm-none-eabi.cmake单独放因为交叉编译的工具链配置和项目本身的构建逻辑是两回事。工具链文件里定义编译器路径、目标架构、编译选项顶层CMakeLists.txt只管源文件、头文件路径、链接脚本。这样你换芯片或者换工具链版本时只改工具链文件项目逻辑不动。startup/放启动汇编文件linker/放链接脚本这两个是芯片相关的和C代码分开。src/和include/按模块拆分每个外设一对文件。这种结构在项目变大之后优势非常明显——你找任何功能都直奔对应文件不用在几千行的main.c里翻。2.2 工具链文件里必须写清楚的几个变量arm-none-eabi.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(CPU_FLAGS -mcpucortex-m3 -mthumb) set(CMAKE_C_FLAGS_INIT ${CPU_FLAGS} -ffunction-sections -fdata-sections) set(CMAKE_CXX_FLAGS_INIT ${CPU_FLAGS} -ffunction-sections -fdata-sections -fno-exceptions -fno-rtti) set(CMAKE_EXE_LINKER_FLAGS_INIT ${CPU_FLAGS} -T${CMAKE_SOURCE_DIR}/linker/stm32f103.ld -Wl,--gc-sections -nostartfiles)这里有几个关键点值得展开。-fno-exceptions -fno-rtti是嵌入式C的标配因为异常处理和运行时类型信息会带来不小的代码体积和运行时开销在资源受限的MCU上通常关掉。-ffunction-sections -fdata-sections配合链接器的--gc-sections能把没用的函数和数据从最终固件里剔除这对Flash只有64KB或128KB的F103来说非常关键。-nostartfiles告诉链接器不要用标准启动文件因为我们自己提供了startup_stm32f103.s。如果你不加这个链接时可能会报重复定义_start或者Reset_Handler的错误。注意工具链文件里的路径最好用绝对路径或者基于CMAKE_CURRENT_LIST_DIR的相对路径不要用~CMake不认。2.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) file(GLOB_RECURSE SOURCES src/*.cpp src/*.c startup/*.s) add_executable(${PROJECT_NAME}.elf ${SOURCES}) target_include_directories(${PROJECT_NAME}.elf PRIVATE include) set_target_properties(${PROJECT_NAME}.elf PROPERTIES LINK_FLAGS -Wl,-Map${PROJECT_NAME}.map ) add_custom_command(TARGET ${PROJECT_NAME}.elf POST_BUILD COMMAND ${CMAKE_OBJCOPY} -O binary $TARGET_FILE:${PROJECT_NAME}.elf ${PROJECT_NAME}.bin COMMAND ${CMAKE_SIZE} $TARGET_FILE:${PROJECT_NAME}.elf )file(GLOB_RECURSE ...)能自动收集源文件省得你每加一个文件就改一次CMakeLists。但要注意GLOB的结果在CMake配置阶段就固定了新增文件后需要重新运行cmake才能生效。如果你用VSCode的CMake Tools插件它通常会自动重新配置问题不大。add_custom_command在构建完成后自动生成bin文件并打印固件大小。这个size输出非常有用——每次编译完看一眼Flash和RAM占用心里有数。我见过太多人写到一半发现Flash不够了回头优化代码结构那才叫痛苦。3. 用C写GPIO和定时器从寄存器到类的封装3.1 为什么不用HAL库而直接操作寄存器STM32的HAL库确实能加快开发速度但它有几个问题代码体积大、执行效率低、抽象层次过高导致调试困难。对于学习目的来说直接操作寄存器能让你真正理解芯片是怎么工作的。而且C的封装能力可以让你在保持底层控制的同时获得比C语言更好的代码组织方式。我的做法是用C类封装寄存器访问但不引入虚函数和多态——那些在MCU上代价太高。用constexpr和模板在编译期完成地址计算运行时就是纯粹的寄存器读写零开销。3.2 GPIO类的设计编译期地址计算先看gpio.hpp#pragma once #include cstdint class Gpio { public: enum class Mode : uint32_t { Input 0x00, Output10MHz 0x01, Output2MHz 0x02, Output50MHz 0x03 }; enum class Cnf : uint32_t { PushPull 0x00, OpenDrain 0x01, AltPushPull 0x02, AltOpenDrain 0x03 }; constexpr Gpio(uint32_t portBase, uint8_t pin) : base_(portBase), pin_(pin) {} void configure(Mode mode, Cnf cnf) const; void set() const; void reset() const; void toggle() const; bool read() const; private: uint32_t base_; uint8_t pin_; };constexpr构造函数意味着你可以在编译期创建Gpio对象比如constexpr Gpio led(GPIOA_BASE, 5);。编译器会直接算出地址运行时没有任何构造开销。实现文件gpio.cpp#include gpio.hpp namespace { constexpr uint32_t CRL_OFFSET 0x00; constexpr uint32_t CRH_OFFSET 0x04; constexpr uint32_t IDR_OFFSET 0x08; constexpr uint32_t ODR_OFFSET 0x0C; constexpr uint32_t BSRR_OFFSET 0x10; } void Gpio::configure(Mode mode, Cnf cnf) const { volatile uint32_t* cr; uint32_t shift; if (pin_ 8) { cr reinterpret_castvolatile uint32_t*(base_ CRL_OFFSET); shift pin_ * 4; } else { cr reinterpret_castvolatile uint32_t*(base_ CRH_OFFSET); shift (pin_ - 8) * 4; } uint32_t val *cr; val ~(0x0F shift); val | (static_castuint32_t(cnf) 2 | static_castuint32_t(mode)) shift; *cr val; } void Gpio::set() const { *reinterpret_castvolatile uint32_t*(base_ BSRR_OFFSET) (1 pin_); } void Gpio::reset() const { *reinterpret_castvolatile uint32_t*(base_ BSRR_OFFSET) (1 (pin_ 16)); } void Gpio::toggle() const { *reinterpret_castvolatile uint32_t*(base_ ODR_OFFSET) ^ (1 pin_); } bool Gpio::read() const { return (*reinterpret_castvolatile uint32_t*(base_ IDR_OFFSET)) (1 pin_); }这里用BSRR寄存器来置位和复位而不是读改写ODR。BSRR是原子操作不会被打断在中断和主循环同时操作GPIO时更安全。toggle用ODR异或因为BSRR没有翻转功能只能置位或复位。注意volatile关键字不能省。没有它编译器可能把连续的寄存器读写优化掉导致外设行为异常。这是嵌入式C和桌面C最大的区别之一。3.3 定时器中断用C封装但保留C链接定时器部分稍微复杂一点因为中断向量表需要C链接的函数名。我的做法是用一个C函数作为中断入口在里面调用C对象的方法。timer.hpp#pragma once #include cstdint class Timer { public: constexpr Timer(uint32_t base) : base_(base) {} void init(uint16_t prescaler, uint16_t period) const; void enableInterrupt() const; void start() const; static void handleInterrupt(); private: uint32_t base_; };timer.cpp#include timer.hpp namespace { constexpr uint32_t CR1_OFFSET 0x00; constexpr uint32_t DIER_OFFSET 0x0C; constexpr uint32_t SR_OFFSET 0x10; constexpr uint32_t PSC_OFFSET 0x28; constexpr uint32_t ARR_OFFSET 0x2C; volatile uint32_t* const NVIC_ISER0 reinterpret_castvolatile uint32_t*(0xE000E100); } void Timer::init(uint16_t prescaler, uint16_t period) const { *reinterpret_castvolatile uint32_t*(base_ PSC_OFFSET) prescaler; *reinterpret_castvolatile uint32_t*(base_ ARR_OFFSET) period; *reinterpret_castvolatile uint32_t*(base_ CR1_OFFSET) | 0x01; } void Timer::enableInterrupt() const { *reinterpret_castvolatile uint32_t*(base_ DIER_OFFSET) | 0x01; *NVIC_ISER0 | (1 28); } void Timer::start() const { *reinterpret_castvolatile uint32_t*(base_ CR1_OFFSET) | 0x01; } extern C void TIM2_IRQHandler() { *reinterpret_castvolatile uint32_t*(0x40000000 0x10) ~0x01; Timer::handleInterrupt(); }extern C是关键它告诉编译器这个函数用C链接规则这样链接器才能在向量表里找到它。TIM2_IRQHandler里先清除中断标志位再调用C处理函数。清除标志位必须放在处理逻辑之前或之中否则中断会反复触发。4. Renode仿真不接硬件也能看到代码跑起来4.1 Renode平台描述文件怎么写Renode的好处是你不需要真实的开发板就能验证代码逻辑。对于STM32F103Renode有内置支持但你需要写一个平台描述文件来指定固件加载地址和外设映射。stm32f103.replusing platforms/cpus/stm32f103.repl uart1: UART.STM32_UART sysbus 0x40013800 - nvic37 timer2: Timers.STM32_Timer sysbus 0x40000000 - nvic28这个文件告诉Renode在0x40013800地址放一个UART外设中断号37在0x40000000放一个定时器中断号28。这些地址和中断号都是从STM32F103参考手册里查到的不是随便写的。启动Renode并加载固件的脚本mach create stm32f103 machine LoadPlatformDescription stm32f103.repl sysbus LoadELF build/stm32_cpp_demo.elf showAnalyzer uart1 startshowAnalyzer uart1会弹出一个窗口显示串口输出。如果你在代码里往UART1的数据寄存器写字符就能在这个窗口里看到。4.2 仿真中验证GPIO和定时器行为Renode的GPIO仿真不像真实硬件那样有物理引脚但你可以通过监视器查看引脚状态。在Renode命令行里gpioPortA.5 State如果代码里翻转了PA5这个命令会显示当前电平。定时器中断也能正常触发你可以在中断处理函数里往串口打印信息通过串口输出确认中断频率是否符合预期。我实测下来Renode对STM32F103的定时器仿真精度足够验证逻辑正确性但不要用它来测精确的时序参数——仿真的时间流逝和真实硬件有差异。它的价值在于你可以在没有硬件的情况下确认代码逻辑没有死循环、没有数组越界、中断能正常进出。4.3 从仿真到真实硬件的过渡注意事项仿真跑通之后烧到真实硬件上可能会遇到几个问题。第一是时钟配置——Renode可能默认使用某个时钟频率而真实硬件的晶振和PLL配置需要你手动设置。第二是启动时间——真实硬件的电源建立和晶振起振需要时间仿真里没有这个延迟。第三是外设的未使用引脚状态——真实硬件上浮空的引脚可能引入干扰仿真里不存在这个问题。我的建议是在仿真里验证逻辑在硬件上验证时序和电气特性。两者互补不能互相替代。5. 那些教程不会告诉你的实操细节5.1 链接脚本里最容易写错的两个地方链接脚本stm32f103.ld里MEMORY块定义Flash和RAM的起始地址与大小MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 128K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K }F103C8T6的Flash是64KF103RB是128K别搞混了。RAM一般是20K。如果你用的芯片型号不同这两个值必须改否则链接器可能把代码放到不存在的地址上。.data段的加载地址和处理是另一个容易出错的地方。.data段里的变量有初始值运行时需要在RAM里但初始值存在Flash里。启动文件负责把初始值从Flash拷贝到RAM。如果链接脚本里.data的AT地址写错了变量初始值就是乱的。5.2 C全局对象的构造时机在嵌入式C里全局对象的构造函数在main之前执行。但此时时钟可能还没配置好外设还没初始化。如果你的全局对象构造函数里访问了外设寄存器行为是不可预期的。我的做法是全局对象只做最简单的初始化不碰硬件。硬件初始化放在main里显式调用。比如constexpr Gpio led(GPIOA_BASE, 5);只是存了地址和引脚号不写寄存器。真正配置GPIO方向是在main里调用led.configure(...)。5.3 中断处理函数里不要做的事中断处理函数应该尽可能短。不要在里面做浮点运算、不要调用printf、不要动态分配内存。我见过有人在定时器中断里调用printf结果串口输出乱码甚至死机——因为printf内部有缓冲区和锁中断上下文里用会出问题。正确的做法是在中断里设置一个volatile标志位主循环检测到这个标志位后再做耗时操作。比如volatile bool timerTick false; extern C void TIM2_IRQHandler() { *reinterpret_castvolatile uint32_t*(0x40000000 0x10) ~0x01; timerTick true; } int main() { // ... while (true) { if (timerTick) { timerTick false; uart.send(tick\n); } } }这样中断里只做一件事清标志、置标志。剩下的逻辑在主循环里从容处理。5.4 CMake配置阶段报错的排查顺序CMake报错信息有时候很模糊我一般按这个顺序排查先看工具链文件路径对不对再看编译器能不能找到然后看链接脚本路径是否正确最后看源文件有没有语法错误。大部分配置阶段的错误都是路径问题。如果VSCode的CMake Tools插件底部状态栏没有出现Configure按钮通常是因为没有识别到CMakeLists.txt或者工具链文件路径写错了。手动在终端里跑cmake -B build -DCMAKE_TOOLCHAIN_FILEcmake/arm-none-eabi.cmake能看到更详细的错误信息。6. 从这个小工程能延伸出什么这个骨架搭好之后加超声波测距就是加一个GPIO输入捕获或者定时器输入捕获的类加USB虚拟串口就是配置USB外设和描述符加FreeRTOS就是在CMake里引入RTOS源码把任务创建放在main里。核心结构不变只是往src/里加文件、往include/里加头文件。我自己的经验是嵌入式C项目最怕的不是功能复杂而是结构混乱。一旦main.cpp超过500行各种外设初始化混在一起改一个功能就可能影响另一个。按外设分文件、按职责分层次这个习惯越早养成越好。另外Renode虽然方便但不要完全依赖它。真实硬件上的问题——比如电源纹波导致的复位、晶振不起振、引脚焊接不良——仿真永远发现不了。仿真跑通只是第一步最终还是要上硬件验证。我一般是在仿真里把逻辑调通然后烧到硬件上跑长时间稳定性测试两者结合效率最高。最后说一个我踩过的坑CMake的file(GLOB_RECURSE)在Windows上路径分隔符和Linux不同如果你在Windows上开发但用Linux工具链最好统一用正斜杠CMake会自动处理。另外build/目录不要提交到版本控制每次构建重新生成就行避免缓存导致的奇怪问题。