ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32裸机C++最小工程实战:从启动文件到Renode仿真

STM32裸机C++最小工程实战:从启动文件到Renode仿真 1. 为什么前三篇看完还是没写一行代码如果你是从这个系列的第一篇一路追过来的大概率会有一种憋屈感环境装了一堆工具链配了又配CMakeLists.txt 抄了又抄Renode 的启动脚本也跑通了但就是没正儿八经写过一行跑在 STM32 上的 C 业务代码。这个感受非常真实我当年从 Keil 转到 CMake VSCode Renode 这套组合的时候前两周基本都在跟工具链较劲真正开始写代码反而是第三周的事。但我要说一句可能有点反直觉的话前三篇的“不写代码”恰恰是这套流程里最值钱的部分。原因很简单嵌入式 C 和桌面 C 最大的区别不在于语法而在于“你写的每一行代码最终都要落到一块资源受限的芯片上”。如果你连编译产物是怎么生成的、链接脚本怎么控制内存布局、仿真器怎么加载 ELF 文件都没搞清楚那后面写出来的代码一旦跑不起来你连从哪查都不知道。这一篇我们正式进入“动手写”的阶段但节奏依然不是上来就堆业务逻辑。我会带你从零写一个能在 Renode 里跑起来的最小 C 工程包含启动文件、链接脚本、寄存器操作封装、以及一个用 C 类封装的 LED 闪烁逻辑。整个过程不依赖任何 HAL 库纯裸机目的是让你彻底看清“C 代码是怎么变成 STM32 上跑的东西”这条链路。提示本篇假设你已经完成了前三篇的环境搭建手上有可用的 arm-none-eabi-gcc 工具链、CMake 3.20、以及能正常启动 Renode 的环境。如果还没有建议先回去补课否则本篇的命令你敲下去大概率会报错。适合读这篇的人有三类一是学过 C 但没写过嵌入式 C 的二是用过 HAL 库但没碰过裸机启动流程的三是想用 Renode 做 CI 自动化测试但不知道怎么组织工程的。如果你属于“只想赶紧点亮一个 LED”的类型那这篇可能会让你有点着急但请耐心看完因为后面所有的复杂项目都建立在这一篇的基础上。2. 最小可运行工程的目录结构与文件职责2.1 为什么目录结构要从第一天就定好很多人写嵌入式项目习惯把所有文件堆在一个文件夹里main.c、startup.s、链接脚本、Makefile 全在一起。项目小的时候没问题一旦文件超过二十个找起来就痛苦了。我踩过的坑是有一次做一个 F103 的项目光中断向量表就有三个版本Bootloader 一个、App 一个、测试一个因为文件全堆在一起烧录的时候拿错了链接脚本排查了整整一个下午。所以从最小工程开始我们就按职责分目录。下面是我用了很多年的结构你可以直接抄stm32-cpp-demo/ ├── CMakeLists.txt ├── cmake/ │ └── arm-none-eabi.cmake ├── src/ │ ├── main.cpp │ ├── startup_stm32f103.s │ └── system_stm32f103.cpp ├── include/ │ ├── regs/ │ │ └── rcc.hpp │ │ └── gpio.hpp │ └── led.hpp ├── ld/ │ └── stm32f103c8.ld └── renode/ └── stm32f103.resc这个结构里src放实现include放头文件ld放链接脚本renode放仿真脚本cmake放工具链文件。看起来比“一个文件夹搞定”麻烦但等你项目里有三十个源文件的时候你会感谢自己当初分了目录。2.2 每个文件到底负责什么startup_stm32f103.s是启动汇编文件它的核心工作是三件事定义中断向量表、初始化栈指针、跳转到Reset_Handler。很多人以为启动文件是“芯片厂商给的不用管”但实际上如果你用 C启动文件里必须调用__libc_init_array否则全局对象的构造函数不会被执行。这是 C 裸机开发第一个大坑后面会详细讲。system_stm32f103.cpp负责系统时钟初始化。F103 默认跑在 8MHz 的内部 RC 振荡器上要跑到 72MHz 必须配置 PLL。这个文件我建议用 C 写因为时钟配置涉及大量位操作用 C 的constexpr和模板可以把这些位域封装得很干净。ld/stm32f103c8.ld是链接脚本它决定了代码放 Flash 的哪个位置、数据放 RAM 的哪个位置、栈和堆各占多少。F103C8T6 有 64KB Flash 和 20KB RAM链接脚本必须精确反映这些数字否则链接器会给你分配超出物理内存的地址烧录后直接跑飞。renode/stm32f103.resc是 Renode 的启动脚本告诉仿真器加载哪个 ELF 文件、从哪个地址开始执行、外设怎么映射。这个文件的好处是你不需要真板子就能验证代码逻辑CI 里也能跑。2.3 CMakeLists.txt 的最小可用版本CMake 在嵌入式里的作用经常被低估。很多人觉得“不就是个构建工具吗Makefile 也能干”但 CMake 的真正价值在于工具链抽象和依赖管理。你写一次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) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_executable(${PROJECT_NAME} src/main.cpp src/system_stm32f103.cpp src/startup_stm32f103.s ) target_include_directories(${PROJECT_NAME} PRIVATE include) target_link_options(${PROJECT_NAME} PRIVATE -T${CMAKE_SOURCE_DIR}/ld/stm32f103c8.ld -nostartfiles -Wl,--gc-sections -Wl,-Map${PROJECT_NAME}.map ) target_compile_options(${PROJECT_NAME} PRIVATE -mcpucortex-m3 -mthumb -ffunction-sections -fdata-sections -Wall -Wextra ) set_target_properties(${PROJECT_NAME} PROPERTIES SUFFIX .elf )这里有几个参数值得单独说。-nostartfiles告诉链接器不要用标准 C 库的启动文件因为我们自己写了startup_stm32f103.s。-Wl,--gc-sections配合-ffunction-sections -fdata-sections可以把没用的函数和数据从最终固件里删掉对于 Flash 只有 64KB 的 F103 来说这个优化能省下不少空间。-mcpucortex-m3和-mthumb是必须的F103 是 Cortex-M3 内核只支持 Thumb 指令集。注意CMAKE_TOOLCHAIN_FILE必须在project()之前设置否则 CMake 会用宿主机编译器去编译你会看到一堆“无法识别的指令”错误。这个坑我踩过不止一次。3. 启动文件里那些没人告诉你的事3.1 中断向量表不是随便排的启动文件的第一部分是中断向量表。F103 的向量表从 Flash 的 0x08000000 开始第一个字是栈顶地址第二个字是复位处理函数的地址后面依次是 NMI、HardFault、以及各种外设中断。很多人直接从厂商模板复制但如果你不知道这个表的排列规则一旦加了自定义中断就会出问题。.section .isr_vector, a, %progbits .type g_pfnVectors, %object g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler .word 0 .word 0 .word 0 .word 0 .word SVC_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler /* 后面是外设中断按芯片手册的顺序排 */这里的关键是_estack这个符号它在链接脚本里定义指向 RAM 的最高地址。Cortex-M3 的栈是向下生长的所以栈顶地址就是 RAM 末尾。F103C8T6 的 RAM 是 0x20000000 到 0x20005000所以_estack应该是 0x20005000。3.2 Reset_Handler 里必须调用 __libc_init_array这是 C 裸机开发最容易被忽略的一点。C 语言的全局变量初始化是编译器直接生成数据但 C 的全局对象需要在运行时调用构造函数。__libc_init_array这个函数会遍历.init_array段依次调用里面注册的构造函数。Reset_Handler: ldr r0, _estack mov sp, r0 bl SystemInit bl __libc_init_array bl main b .如果你漏了bl __libc_init_array那么所有全局对象的构造函数都不会执行。表现出的症状是全局对象的成员变量全是零因为 BSS 段被清零了但你在构造函数里设置的初始值全部丢失。这个 bug 非常隐蔽因为代码能编译能链接跑起来也不报错就是行为不对。我当年做一个串口协议解析的项目全局定义了一个ProtocolParser对象构造函数里初始化了状态机。结果跑起来一直卡在初始状态查了两天才发现是启动文件里没调__libc_init_array。从那以后我每次新建工程第一件事就是检查这一行。3.3 栈溢出是裸机开发的头号杀手F103C8T6 只有 20KB RAM栈和堆共享这块空间。链接脚本里通常这样定义_estack 0x20005000; _Min_Heap_Size 0x200; _Min_Stack_Size 0x400;栈默认给了 1KB堆给了 512 字节。对于简单项目够用但如果你在栈上定义了大数组比如uint8_t buffer[2048]栈立刻溢出。栈溢出的表现是程序跑飞、HardFault、或者更诡异的“变量值莫名其妙被改了”。我的经验是在裸机上永远不要在栈上分配超过 256 字节的缓冲区。需要大缓冲区就用全局数组或者静态分配。如果非要用动态内存自己写一个简单的内存池不要用malloc因为标准库的malloc在裸机上行为不可预测。4. 用 C 封装寄存器从宏定义到类型安全4.1 为什么不用厂商的 HAL 库HAL 库不是不好它适合快速出原型。但如果你想真正理解 STM32或者你的项目对代码体积和运行效率有要求裸机寄存器操作是绕不开的。HAL 库的一个HAL_GPIO_WritePin调用背后可能经过三四层函数调用最终才写到 BSRR 寄存器。而直接操作寄存器只需要一条str指令。更重要的是用 C 封装寄存器可以做到编译期检查。比如你写GPIOA.setPin(13)如果 13 不是合法的引脚号编译器可以直接报错。而 HAL 库的HAL_GPIO_WritePin(GPIOA, GPIO_PIN_13, ...)如果写错了引脚宏只会在运行时表现异常。4.2 用模板封装 GPIO下面是我常用的 GPIO 封装方式核心思路是用模板参数把端口和引脚号变成类型的一部分template uint32_t Base, uint8_t Pin class GpioPin { public: static void initAsOutput() { // 使能时钟、配置 CRL/CRH volatile uint32_t* cr reinterpret_castvolatile uint32_t*( Base (Pin 8 ? 0x00 : 0x04)); uint32_t shift (Pin % 8) * 4; *cr ~(0xF shift); *cr | (0x1 shift); // 通用推挽输出2MHz } static void set() { *reinterpret_castvolatile uint32_t*(Base 0x10) (1 Pin); } static void reset() { *reinterpret_castvolatile uint32_t*(Base 0x14) (1 Pin); } static void toggle() { *reinterpret_castvolatile uint32_t*(Base 0x10) (*reinterpret_castvolatile uint32_t*(Base 0x0C) (1 Pin)) ? (1 Pin) : 0; *reinterpret_castvolatile uint32_t*(Base 0x14) (*reinterpret_castvolatile uint32_t*(Base 0x0C) (1 Pin)) ? 0 : (1 Pin); } };这里用到了BSRR和BRR寄存器。BSRR的低 16 位写 1 置位对应引脚高 16 位写 1 复位对应引脚。BRR的低 16 位写 1 复位对应引脚。用BSRR的好处是它是原子操作不会被中断打断。toggle的实现稍微绕一点因为 F103 没有专门的翻转寄存器。我的做法是先读ODR判断当前状态然后写BSRR或BRR。如果你对性能要求极高可以用位带操作但位带操作的代码可读性差我一般不用。4.3 时钟配置从 8MHz 到 72MHzF103 的时钟树是嵌入式入门的经典案例。默认情况下芯片使用内部 8MHz RC 振荡器要跑到 72MHz 需要经过以下步骤使能外部高速晶振HSE等待稳定配置 PLLHSE 8MHz 先二分频到 4MHz再九倍频到 36MHz最后乘二到 72MHz配置 Flash 等待周期为 2配置 AHB、APB1、APB2 分频系数切换系统时钟源到 PLL等待稳定用 C 写出来大概是这样void SystemInit() { // 使能 HSE RCC-CR | (1 16); while (!(RCC-CR (1 17))); // 配置 Flash 等待周期 FLASH-ACR (1 4) | (1 0); // 配置 PLL RCC-CFGR | (1 16); // HSE 作为 PLL 输入 RCC-CFGR | (7 18); // PLL 九倍频 RCC-CFGR | (1 1); // HSE 二分频 // 使能 PLL RCC-CR | (1 24); while (!(RCC-CR (1 25))); // 配置分频 RCC-CFGR | (4 8); // APB1 四分频 18MHz RCC-CFGR | (0 11); // APB2 不分频 72MHz RCC-CFGR | (0 4); // AHB 不分频 72MHz // 切换系统时钟到 PLL RCC-CFGR | (2 0); while ((RCC-CFGR (3 2)) ! (2 2)); }这段代码里最容易出错的是 PLL 的配置顺序。必须先配置 PLL 参数再使能 PLL最后切换时钟源。如果顺序错了芯片可能跑在一个非预期的频率上串口波特率会全乱。提示APB1 的最大频率是 36MHzAPB2 是 72MHz。如果你把 APB1 设成不分频定时器和串口的时钟会超频虽然芯片可能不立刻挂但长期运行不稳定。5. 在 Renode 里跑起来仿真脚本怎么写5.1 Renode 的加载流程Renode 启动一个 STM32 仿真的基本流程是创建机器、加载平台描述、加载 ELF 文件、启动。平台描述可以用 Renode 自带的platforms/cpus/stm32f103.repl也可以自己写。我建议先用自带的跑通之后再考虑自定义。mach create stm32f103 machine LoadPlatformDescription platforms/cpus/stm32f103.repl sysbus LoadELF ${CMAKE_BINARY_DIR}/stm32_cpp_demo.elf showAnalyzer sysbus.uart1 startshowAnalyzer会打开一个串口分析窗口如果你的代码里有串口输出可以在这里看到。start命令让 CPU 开始执行。5.2 用 Renode 验证 LED 闪烁Renode 里 GPIO 的状态可以通过监视器查看。假设你的 LED 接在 PA5 上可以在 Renode 的 monitor 里输入sysbus.gpioPortA.pin5如果代码正确你会看到这个值在 0 和 1 之间周期性变化。这比用真板子方便多了因为真板子上你只能看到 LED 亮灭看不到引脚电平的精确时序。我实际用 Renode 做 CI 的时候会写一个 Python 脚本通过 Renode 的 telnet 接口读取引脚状态然后断言闪烁频率是否正确。这样每次提交代码CI 会自动验证 LED 闪烁逻辑有没有被改坏。5.3 仿真和真板的差异Renode 不是万能的。它模拟的是芯片的数字逻辑不模拟模拟外设比如 ADC 的精度、内部 RC 振荡器的温漂。定时器的行为也可能和真板有细微差异因为 Renode 的定时器是基于宿主机的时钟模拟的。我的经验是逻辑验证用 Renode时序验证用真板。比如你要验证一个超声波测距的算法Renode 可以模拟 GPIO 的输入输出但超声波的传播时间需要你自己在脚本里注入不能指望 Renode 帮你算。6. 那些让我熬夜的编译链接问题6.1 undefined reference to__libc_init_array这个错误通常出现在你用-nostartfiles但没链接libc的时候。解决方法是加上-lc或者--specsnosys.specs。但加了nosys之后printf之类的函数会变成空实现因为nosys不提供系统调用。如果你需要printf输出到串口需要自己实现_write函数extern C int _write(int fd, char* ptr, int len) { for (int i 0; i len; i) { while (!(USART1-SR (1 7))); USART1-DR ptr[i]; } return len; }6.2 section.isr_vectorwill not fit这个错误说明链接脚本里的 Flash 区域太小放不下中断向量表。F103 的向量表有 60 多个条目每个 4 字节总共 240 多字节。如果你的链接脚本把 Flash 起始地址设成了 0x08000000但长度只有 0x100那肯定放不下。检查链接脚本里的MEMORY定义MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K }F103C8T6 的 Flash 是 64KBRAM 是 20KB。如果你用的是 C6T6Flash 只有 32KBRAM 只有 10KB链接脚本要相应修改。6.3 C 异常和 RTTI 的坑裸机上默认是禁用 C 异常和 RTTI 的因为这两者会显著增加代码体积。如果你在代码里用了try-catch或者dynamic_cast链接时会报错。解决方法是加上-fno-exceptions -fno-rtti编译选项然后避免在代码里使用这些特性。我个人的习惯是嵌入式 C 里完全不用异常错误处理用返回值或者std::optional。std::optional在 C17 里是零开销的非常适合嵌入式。7. 从最小工程到实际项目的扩展思路7.1 加入串口日志系统最小工程跑通之后下一步通常是加串口输出方便调试。我的做法是封装一个简单的Logger类支持printf风格的格式化输出但底层用_write重定向到 USART。class Logger { public: static void init(uint32_t baudrate) { // 配置 USART1 的 GPIO 和寄存器 } template typename... Args static void info(const char* fmt, Args... args) { char buffer[128]; int len snprintf(buffer, sizeof(buffer), fmt, args...); _write(1, buffer, len); } };这里用到了可变参数模板比 C 的va_list更类型安全。snprintf会消耗一些 Flash 空间如果空间紧张可以用一个精简版的格式化函数。7.2 用 Renode 做自动化测试Renode 支持通过 Robot Framework 写测试脚本。你可以写一个测试用例启动仿真、等待 1 秒、检查 PA5 引脚状态、断言闪烁频率在 1Hz 左右。这个测试可以集成到 CI 里每次提交代码自动跑。我实际项目里的测试脚本大概长这样*** Test Cases *** LED Should Blink At 1Hz Execute Command mach create Execute Command machine LoadPlatformDescription platforms/cpus/stm32f103.repl Execute Command sysbus LoadELF build/demo.elf Execute Command start Sleep 1s ${state1} Execute Command sysbus.gpioPortA.pin5 Sleep 0.5s ${state2} Execute Command sysbus.gpioPortA.pin5 Should Not Be Equal ${state1} ${state2}这个测试虽然简单但能有效防止“改代码改坏了 LED 逻辑”这种低级错误。7.3 什么时候该上 RTOS最小工程是裸机的前后台架构主循环里轮询任务。当你的项目需要同时处理多个实时性要求不同的任务时就该考虑 RTOS 了。FreeRTOS 在 F103 上跑很轻松RAM 占用大概 2-3KB。但我的建议是能用裸机解决的就别上 RTOS。RTOS 引入了任务调度、优先级反转、栈溢出检测等一堆新问题调试复杂度直线上升。我见过太多项目明明一个状态机就能搞定的事情非要上 RTOS结果光调任务优先级就花了一周。8. 一些让我少走弯路的实操习惯第一个习惯是每次改链接脚本都先看 map 文件。map 文件里会列出每个段的大小和地址你能清楚地看到 Flash 和 RAM 的使用情况。如果发现某个函数占了几 KB那大概率是用了printf或者浮点运算需要优化。第二个习惯是用-Wl,--print-memory-usage让链接器直接打印内存占用。这个选项会在链接完成后输出一行类似Memory region Used Size Region Size %age Used的信息比翻 map 文件快多了。第三个习惯是在启动文件里加一个栈溢出检测。具体做法是在栈底放一个魔数主循环里定期检查这个魔数有没有被改写。如果被改了说明栈溢出可以触发一个错误处理。#define STACK_MAGIC 0xDEADBEEF extern uint32_t _estack; static volatile uint32_t* stack_guard _estack - 256; void check_stack() { if (*stack_guard ! STACK_MAGIC) { // 栈溢出进入安全状态 while (1); } }这个技巧帮我抓到过好几次隐蔽的栈溢出 bug尤其是在用递归函数的时候。第四个习惯是把 Renode 的启动脚本和 CMake 的构建目录关联起来。我通常会在 CMake 里加一个自定义目标构建完成后自动生成 Renode 脚本路径指向最新的 ELF 文件。这样每次改完代码只需要在 Renode 里source一下脚本就能重新加载不用手动改路径。add_custom_target(renode COMMAND ${CMAKE_COMMAND} -E echo mach create; machine LoadPlatformDescription platforms/cpus/stm32f103.repl; sysbus LoadELF ${CMAKE_BINARY_DIR}/${PROJECT_NAME}.elf; start ${CMAKE_BINARY_DIR}/run.resc DEPENDS ${PROJECT_NAME} )这些习惯看起来琐碎但积累下来能省下大量调试时间。嵌入式开发的痛点从来不是“写不出代码”而是“代码写出来了但跑不对还不知道为什么”。把工具链和调试流程理顺比多写几百行业务代码有价值得多。最后说一个我自己的体会从 C 转到嵌入式 C最大的障碍不是语法而是思维方式。C 的思维方式是“我直接操作硬件”C 的思维方式是“我用类型系统把硬件操作封装起来让编译器帮我检查错误”。这个转变需要时间但一旦转过来你会发现代码的可维护性和可复用性会有质的提升。下一篇我们会在这个最小工程的基础上加入定时器中断和状态机真正开始写有业务逻辑的代码。
返回列表