ARTICLE DETAIL

资讯详情

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

STM32嵌入式C++开发:CMake构建与Renode仿真全流程

STM32嵌入式C++开发:CMake构建与Renode仿真全流程 1. 先聊聊这个标题背后的“怨气”从哪来“看了三篇了一行都没让我写呢”——这句话我第一次看到的时候差点笑出声。因为这几乎是每一个从Arduino或者51单片机转过来的朋友在接触“正规军”嵌入式开发流程时都会发出的灵魂拷问。前三篇文章大概率在讲什么讲环境搭建、讲工具链、讲CMake是干嘛的、讲VSCode插件怎么装、讲STM32CubeMX怎么点灯。全是“准备工作”全是“基础设施”一行用户代码都没写。读者急了这太正常了。但我想说的是这个“急”恰恰是嵌入式C开发路上第一个需要跨过去的心理门槛。如果你是从Keil MDK那种“新建工程→选芯片→写main→点编译”的流程过来的你会觉得前面那些东西全是脱裤子放屁。但如果你真的想用C写STM32想用现代工具链想摆脱Keil那种上古IDE的束缚那前三篇的内容恰恰是地基。地基不打后面写再多代码也是空中楼阁。这篇内容我打算换个角度来写。不按部就班地讲“第一步装什么、第二步配什么”而是把整个基于STM32的嵌入式C开发流程从“为什么这么麻烦”到“怎么一步步落地”再到“踩过的坑和绕过的弯”完整地梳理一遍。核心关键词就几个STM32、嵌入式C、CMake、Renode。这四个词串起来就是一条从裸机到仿真、从C到C、从手工配置到自动化构建的完整链路。适合谁看如果你手头有一块STM32的板子F1、F4、H7都行会一点C语言用过Arduino或者Keil但对CMake、对C在嵌入式里的用法、对Renode仿真还比较陌生那这篇内容就是给你准备的。如果你已经是个老鸟那不妨看看我在工具选型和流程设计上的取舍逻辑也许能给你一些参考。2. 为什么非要折腾CMake和C2.1 从Keil到CMake不是找罪受是找自由很多人不理解Keil用得好好的为什么要换我一开始也这么想。直到我遇到几个场景第一团队协作的时候每个人的Keil版本不一样芯片包版本不一样打开工程各种报错第二我想在CI流水线里自动编译固件Keil的命令行支持一言难尽第三我想用VSCode写代码Keil的编辑器体验实在跟不上时代。CMake解决的就是这些问题。它本质上是一个构建系统生成器你写一份CMakeLists.txt它可以根据你的平台生成Makefile、Ninja构建文件、或者IDE的工程文件。这意味着什么意味着你的工程描述是平台无关的。你在Windows上用VSCode写在Linux上用命令行编译在CI上用Docker跑都是同一份CMakeLists.txt。这种一致性在团队协作和自动化流程里价值巨大。而且CMake对C的支持是原生的。你不需要像在Keil里那样手动去配置C的编译选项、手动指定标准库、手动处理异常和RTTI的开关。CMake的target_compile_features和CMAKE_CXX_STANDARD这些变量一行就能搞定。2.2 嵌入式C不是炫技是工程需求“嵌入式用C就够了用什么C”这句话我听了不下百遍。但实际情况是当你的项目规模超过几千行当你的外设驱动超过十几个当你的团队超过三个人C语言的抽象能力就开始捉襟见肘了。C在嵌入式里的核心价值不是那些花哨的模板元编程而是封装、继承、多态这三个最基本的特性。举个例子你有一个UART驱动一个SPI驱动一个I2C驱动它们都有init、write、read这些操作。用C语言你要么写三个独立的函数集要么用函数指针搞一套丑陋的接口。用C你定义一个抽象基类三个驱动各自继承上层代码只需要持有基类的指针或引用。这就是工程上的可维护性提升。当然嵌入式C有一些限制。异常处理exception和运行时类型识别RTTI通常会关掉因为它们的代码体积和运行时开销在资源受限的MCU上不太划算。标准库也不是全都能用iostream这种重量级的东西基本告别但array、algorithm、type_traits这些编译期的东西完全可以放心用。我个人的经验是把C当成“带类的C”来用先享受封装带来的好处等熟悉了再逐步引入更高级的特性。2.3 Renode没有板子也能开发Renode是一个开源的仿真框架支持多种架构和平台包括STM32系列。它的价值在于你不需要真实的硬件就能跑固件、调试、做单元测试。对于教学、CI流水线、以及手头暂时没有板子的情况非常实用。但我要提前说清楚Renode不是万能的。它的外设仿真覆盖度有限比如某些高级定时器的特定模式、USB的某些描述符处理可能和真实硬件有差异。所以我的建议是用Renode做逻辑验证和自动化测试用真实硬件做最终验证。两者结合效率最高。3. 工具链搭建从零到能编译的完整路径3.1 编译器选型arm-none-eabi-gcc还是clangSTM32的官方工具链是arm-none-eabi-gcc这也是最成熟、资料最多的选择。Clang虽然也支持ARM Cortex-M但在嵌入式领域的生态还不如GCC完善特别是链接脚本和启动文件的兼容性方面。所以我建议直接用arm-none-eabi-gcc不要在这上面纠结。安装方式有几种Windows上可以用MSYS2或者直接下载ARM官方的GNU Toolchain压缩包Linux上用包管理器或者官方压缩包macOS上用Homebrew。我个人的习惯是下载官方压缩包解压到固定目录然后把bin目录加到PATH里。这样版本可控不会因为系统更新导致工具链被意外升级。安装完之后验证一下arm-none-eabi-gcc --version arm-none-eabi-gdb --version如果都能正常输出版本号说明工具链就绪。3.2 CMake安装与配置版本选择有讲究CMake的版本很重要。太老的版本不支持一些现代特性比如target_link_options、CMAKE_CXX_STANDARD的某些取值。我建议至少用3.20以上的版本目前稳定版在3.27左右。Windows上安装CMake官网下载安装包安装时勾选“Add CMake to the system PATH”。Linux上apt install cmake或者下载官方二进制。macOS上brew install cmake。安装完之后我习惯做一件事在项目根目录放一个CMakePresets.json。这个文件可以预设不同的构建配置比如Debug、Release、RelWithDebInfo还可以指定工具链文件、生成器类型等。这样团队成员拉下代码后不需要手动传一堆参数直接cmake --preset debug就行。3.3 VSCode插件组合少而精VSCode的插件市场里嵌入式相关的插件多如牛毛。但我的经验是装得越多冲突越多。核心就这几个C/CMicrosoft官方提供智能提示、跳转、调试支持。CMake ToolsMicrosoft官方提供CMake的集成底部状态栏会出现Configure、Build、Debug等按钮。Cortex-Debug提供ARM Cortex-M的调试支持配合OpenOCD或者J-Link使用。Renode可选如果你用Renode做仿真可以装这个插件直接在VSCode里启动Renode。这里回答一个热词里提到的问题“vscode安装cmake tools 底部状态栏应该有configure按钮吗”答案是应该有。如果你装了CMake Tools打开一个包含CMakeLists.txt的文件夹底部状态栏会出现一排按钮包括No Kit Selected、Configure、Build等。如果没有出现检查一下是不是没有打开正确的文件夹或者CMake Tools插件没有激活。3.4 STM32CubeMX用还是不用STM32CubeMX是一个图形化的配置工具可以生成初始化代码、时钟配置、外设配置。对于新手来说它降低了入门门槛。但对于CMake流程来说CubeMX生成的Makefile工程需要做一些改造才能融入CMake体系。我的做法是用CubeMX生成初始化代码主要是时钟和外设的HAL初始化但不用它生成的构建系统。具体来说在CubeMX里配置好芯片和外设后选择“Generate peripheral initialization as a pair of .c/.h files”然后只把生成的Core/Src和Core/Inc目录拿过来自己写CMakeLists.txt来组织编译。这样既享受了CubeMX的便利又保持了构建系统的干净和可控。4. 工程结构设计让代码有地方放4.1 目录划分不是强迫症是效率一个清晰的目录结构能让你在三个月后回来改代码时不至于一脸懵。我的习惯是这样的project/ ├── CMakeLists.txt # 顶层CMake ├── CMakePresets.json # 预设配置 ├── cmake/ │ ├── arm-gcc-toolchain.cmake # 工具链文件 │ └── stm32f4xx.cmake # 芯片相关的编译选项 ├── src/ │ ├── main.cpp │ ├── app/ # 应用层代码 │ ├── drivers/ # 外设驱动 │ └── bsp/ # 板级支持包 ├── inc/ │ ├── app/ │ ├── drivers/ │ └── bsp/ ├── lib/ # 第三方库 ├── startup/ │ └── startup_stm32f4xx.s # 启动文件 ├── linker/ │ └── STM32F4xx_FLASH.ld # 链接脚本 └── test/ └── renode/ # Renode测试脚本这个结构的好处是源码、头文件、第三方库、启动文件、链接脚本各归其位。CMakeLists.txt里通过target_include_directories把inc下的子目录都加进去代码里直接#include uart.hpp就行不需要写一长串相对路径。4.2 工具链文件一次编写到处使用工具链文件是CMake里用来描述交叉编译环境的。对于STM32核心就是指定编译器前缀、目标架构、浮点ABI等。我写一个典型的arm-gcc-toolchain.cmakeset(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) set(CPU_FLAGS -mcpucortex-m4 -mthumb -mfpufpv4-sp-d16 -mfloat-abihard) set(CMAKE_C_FLAGS_INIT ${CPU_FLAGS}) set(CMAKE_CXX_FLAGS_INIT ${CPU_FLAGS}) set(CMAKE_ASM_FLAGS_INIT ${CPU_FLAGS})这里有几个关键点CMAKE_TRY_COMPILE_TARGET_TYPE设为STATIC_LIBRARY是因为交叉编译时CMake默认会尝试链接一个可执行文件但嵌入式环境没有操作系统链接会失败。设为静态库可以绕过这个问题。CPU_FLAGS里的浮点选项要根据你的芯片来定F4系列一般是fpv4-sp-d16F7和H7系列可能是fpv5-d16具体查芯片手册。4.3 链接脚本与启动文件从CubeMX里“偷”链接脚本和启动文件是嵌入式工程里最“玄学”的部分。链接脚本决定了代码放在Flash的哪个地址、数据放在RAM的哪个地址、堆栈怎么分配。启动文件决定了复位后第一条指令执行什么、中断向量表怎么排布。我的建议是直接从CubeMX生成的工程里拿。CubeMX会根据你选的芯片型号生成对应的启动文件和链接脚本。你只需要把它们复制到自己的工程目录里然后在CMakeLists.txt里引用即可。不要试图自己从头写除非你对ARM Cortex-M的启动流程和链接器脚本语法非常熟悉。在CMakeLists.txt里引用链接脚本target_link_options(${PROJECT_NAME} PRIVATE -T${CMAKE_SOURCE_DIR}/linker/STM32F4xx_FLASH.ld -Wl,-Map${PROJECT_NAME}.map -Wl,--gc-sections )--gc-sections是移除未使用的代码段对减小固件体积很有帮助。-Map生成映射文件方便分析内存占用。5. 从C到C代码层面的关键改动5.1 main函数从.c到.cpp把main.c改成main.cpp这是第一步。但改完之后你会发现一些C语言里理所当然的东西在C里需要调整。首先是头文件的包含。C语言的头文件在C里包含时建议用extern C包裹避免C的名称修饰name mangling导致链接错误。比如extern C { #include stm32f4xx_hal.h }但HAL库的头文件通常已经自带了extern C的判断所以直接#include stm32f4xx_hal.h也可以。不过对于自己写的C语言驱动最好加上。其次是中断处理函数。C语言里中断向量表里填的是函数名C里如果函数被名称修饰了链接器就找不到。所以中断处理函数必须用extern C声明extern C void SysTick_Handler(void) { HAL_IncTick(); }5.2 用类封装外设以UART为例假设我们要封装一个UART驱动。C语言的做法是写一堆uart_init、uart_send、uart_recv函数用一个全局变量或者句柄来区分不同的UART。C的做法是定义一个类class Uart { public: Uart(USART_TypeDef* instance, uint32_t baudrate); void send(const uint8_t* data, size_t len); size_t receive(uint8_t* buffer, size_t maxLen); void setBaudrate(uint32_t baudrate); private: UART_HandleTypeDef handle_; };构造函数里调用HAL_UART_Initsend里调用HAL_UART_Transmitreceive里调用HAL_UART_Receive。上层代码只需要Uart uart1(USART1, 115200);然后uart1.send(data, len);。这就是封装带来的直观好处。但要注意构造函数里不要做太多事情。嵌入式里构造函数的执行时机可能是在全局对象初始化阶段这时候时钟可能还没配置好HAL库可能还没初始化。所以更安全的做法是构造函数只保存参数真正的初始化放在一个init()方法里在main函数里显式调用。5.3 中断与C小心栈和虚函数表中断服务函数ISR在C里要特别小心。首先ISR里不要用异常因为异常处理需要栈展开而ISR的栈空间通常很小。其次ISR里不要调用虚函数因为虚函数表指针可能还没初始化。第三ISR里不要用动态内存分配new和delete在中断上下文里是禁忌。我的经验是ISR里只做最紧急的事情比如置一个标志位、发一个信号量然后把耗时的处理放到主循环里。这其实是嵌入式开发的通用原则和用不用C没关系。5.4 标准库的取舍哪些能用哪些别碰C标准库在嵌入式里的可用性取决于你的工具链和编译选项。array、algorithm、type_traits、cstdint这些编译期的东西完全可以放心用。vector、string、map这些涉及动态内存的要谨慎因为嵌入式里堆空间有限而且动态内存分配可能导致碎片化。iostream基本告别它的代码体积太大了。thread、mutex这些依赖操作系统的在裸机环境里用不了。chrono可以用但要注意它的实现可能依赖系统时钟。我个人的原则是能用编译期的不用运行期的能用栈的不用堆的能用静态的不用动态的。6. Renode仿真没有板子也能跑起来6.1 Renode安装与基本使用Renode的安装很简单官网下载对应平台的安装包解压即可。Linux上也可以直接下载便携版。启动后是一个交互式的命令行界面你可以用命令加载平台描述文件、加载固件、启动仿真。对于STM32F4Renode自带了一些平台描述文件。你可以用startup machine LoadPlatformDescription platforms/boards/stm32f4_discovery-kit.repl sysbus LoadELF path/to/your/firmware.elf showAnalyzer sysbus.uart2 start这几条命令的意思是加载STM32F4 Discovery的平台描述加载你的ELF固件打开UART2的分析器可以看到串口输出然后启动仿真。6.2 用Renode做自动化测试Renode的真正威力在于自动化测试。你可以写一个.resc脚本让Renode自动执行一系列操作然后检查输出是否符合预期。比如startup machine LoadPlatformDescription platforms/boards/stm32f4_discovery-kit.repl sysbus LoadELF build/firmware.elf showAnalyzer sysbus.uart2 start sleep 1 assertLogContains Hello from STM32这个脚本启动仿真等待1秒然后检查日志里是否包含“Hello from STM32”。如果包含测试通过否则失败。这种测试可以集成到CI流水线里每次提交代码后自动运行。6.3 Renode的局限与应对Renode不是万能的。它的外设仿真覆盖度有限比如某些高级定时器的特定模式、USB的某些描述符处理、DMA的某些复杂场景可能和真实硬件有差异。所以我的建议是用Renode做逻辑验证和回归测试用真实硬件做最终验证。另外Renode的时序和真实硬件也有差异。比如某些依赖精确延时的代码在Renode里可能跑得太快或太慢。对于这种情况可以在代码里加一些条件编译仿真时用不同的延时参数。7. 常见问题与排查技巧实录7.1 编译链接类问题问题一undefined reference to _sbrk这是嵌入式C开发里最常见的问题之一。原因是链接器找不到_sbrk、_write、_read这些系统调用的实现。这些是newlibGCC的C标准库需要的用于堆管理和IO。解决方法有两种一是自己实现这些系统调用通常叫syscalls.c二是链接时加上--specsnosys.specs让链接器使用默认的空实现。我通常用第二种简单省事target_link_options(${PROJECT_NAME} PRIVATE --specsnosys.specs --specsnano.specs )nano.specs是使用newlib-nano一个精简版的C标准库适合嵌入式。问题二region RAM overflowed这是内存不够了。可能是你的全局变量太多或者堆栈设置太大。检查链接脚本里的_Min_Heap_Size和_Min_Stack_Size适当调小。也可以用arm-none-eabi-size命令查看各个段的大小arm-none-eabi-size build/firmware.elf输出会显示text、data、bss三个段的大小。text是代码data是已初始化的全局变量bss是未初始化的全局变量。如果bss特别大说明全局变量太多考虑把一些大的数组改成动态分配或者放到外部RAM。问题三C异常导致的体积膨胀如果你发现固件体积比预期大很多检查一下是不是开了异常处理。在CMake里加上target_compile_options(${PROJECT_NAME} PRIVATE -fno-exceptions -fno-rtti )这会关闭异常和RTTI显著减小体积。但注意关闭异常后new失败时不会抛std::bad_alloc而是返回nullptr需要自己检查。7.2 调试类问题问题四程序跑飞进HardFaultHardFault是ARM Cortex-M里最常见的异常。原因可能是空指针解引用、数组越界、栈溢出、对齐错误等。排查方法在HardFault_Handler里加一个死循环然后用调试器查看LR寄存器的值判断是从哪里跳过来的。或者用__attribute__((naked))写一个HardFault处理函数把关键寄存器压栈后打印出来。问题五Renode里跑得好好的真实硬件上不行这通常是时序问题或者外设初始化顺序问题。Renode的仿真速度比真实硬件快很多某些依赖延时的代码在Renode里可能“恰好”能跑但在真实硬件上因为时序不对而失败。解决方法在关键延时处加足够的余量或者用硬件定时器做精确延时。7.3 工具类问题问题六VSCode的CMake Tools找不到工具链检查CMakePresets.json里的toolchainFile路径是否正确。如果用的是相对路径确保它是相对于CMakePresets.json所在目录的。另外CMAKE_TRY_COMPILE_TARGET_TYPE必须设为STATIC_LIBRARY否则CMake在检测编译器时会因为链接失败而报错。问题七Renode加载ELF失败检查ELF文件是否真的是ARM Cortex-M的格式。可以用file命令查看file build/firmware.elf输出应该是ELF 32-bit LSB executable, ARM, EABI5之类的。如果是x86的ELF说明编译时用错了工具链。8. 从“能跑”到“好用”的进阶思路8.1 单元测试在PC上跑逻辑代码嵌入式代码的单元测试一直是个难题因为代码依赖硬件寄存器。但如果你把逻辑代码和硬件访问代码分离逻辑代码就可以在PC上编译和测试。比如你有一个计算PID输出的函数它只依赖输入值和参数不依赖任何硬件。你可以把这个函数抽到一个独立的文件里用PC上的GCC编译用Google Test或者Catch2写测试用例。CMake支持这种“双目标”构建一个目标编译成STM32固件另一个目标编译成PC上的可执行文件用于测试。通过if(CMAKE_CROSSCOMPILING)来判断当前是交叉编译还是本地编译然后决定编译哪些文件。8.2 日志系统嵌入式里的printf嵌入式里没有控制台但你可以用UART或者SWOSerial Wire Output输出日志。SWO是ARM Cortex-M的一个调试特性可以通过调试器输出不占用UART资源。ITMInstrumentation Trace Macrocell是SWO的软件接口用法如下#define ITM_Port8(n) (*((volatile uint8_t*)(0xE0000000 4*n))) #define ITM_Port32(n) (*((volatile uint32_t*)(0xE0000000 4*n))) void itm_send_char(char c) { while (ITM_Port32(0) 0); ITM_Port8(0) c; }然后在调试器里打开SWO Viewer就能看到输出了。这种方式比UART快而且不占用外设。8.3 固件升级Bootloader与App的分离如果你的项目需要固件升级OTA或者通过串口升级通常需要把Flash分成Bootloader区和App区。Bootloader负责接收新固件并写入App区App负责实际功能。CMake里可以通过设置不同的链接脚本和起始地址生成两个独立的固件。Bootloader的链接脚本里Flash起始地址是0x08000000大小比如16KB。App的链接脚本里Flash起始地址是0x08004000大小是剩余的Flash。App的向量表需要偏移在system_stm32f4xx.c里设置VECT_TAB_OFFSET。9. 我踩过的那些坑第一个坑CMake的target_link_options和target_link_libraries顺序。CMake里链接选项的顺序很重要-T链接脚本必须放在目标文件之前否则链接器找不到。我一开始把-T放在target_link_libraries里结果一直报“cannot find linker script”。后来改成target_link_options并且确保它在目标文件之前才解决。第二个坑Renode的UART分析器不显示输出。原因是Renode默认的UART波特率和固件里设置的不一致。解决方法是在Renode脚本里显式设置波特率或者用showAnalyzer的时候指定正确的参数。第三个坑C全局对象的构造函数在HAL初始化之前执行。C的全局对象构造函数在main函数之前执行这时候HAL库还没初始化时钟还没配置。如果构造函数里调用了HAL_Delay或者HAL_GPIO_Init就会卡死或者出错。解决方法是全局对象只做最简单的初始化真正的硬件初始化放在main函数里显式调用。第四个坑-fno-exceptions和new的兼容性。关闭异常后new失败返回nullptr但标准库的某些容器比如std::vector在分配失败时会调用std::terminate而不是返回空。所以如果关了异常最好也避免使用这些容器或者自己实现内存分配器。第五个坑VSCode的IntelliSense和CMake Tools的配置不一致。CMake Tools用CMake的编译数据库compile_commands.json来配置IntelliSense但如果CMake配置失败IntelliSense就会用默认配置导致头文件找不到、宏定义不对。解决方法是确保CMake配置成功然后在VSCode的设置里指定C_Cpp.default.compileCommands指向build/compile_commands.json。10. 关于“什么时候才能写代码”这件事回到标题里的那句话“看了三篇了一行都没让我写呢”。我理解这种心情但我想说的是嵌入式开发的“写代码”和“让代码跑起来”是两件事。在Keil里你新建工程、选芯片、写main、点编译代码就跑起来了。但在CMakeGCCVSCode的流程里你需要先让工具链就绪、让构建系统就绪、让调试环境就绪然后才能写代码。这个过程确实繁琐但一旦搭好后续的开发效率是Keil无法比拟的。而且搭建工具链的过程本身就是在学习。你理解了编译器和链接器的工作原理理解了启动文件和链接脚本的作用理解了构建系统的组织方式。这些知识在你遇到链接错误、内存溢出、程序跑飞的时候会派上大用场。所以如果你还在“看了三篇还没写代码”的阶段别急。把环境搭好把工程结构理清把编译链接跑通。然后从点亮一个LED开始从串口输出一个字符开始从封装第一个C类开始。你会发现前面的铺垫都是值得的。最后分享一个小技巧在CMakeLists.txt里加一个自定义目标一键完成编译、生成bin、生成hex、查看大小。这样你就不需要记一堆命令了add_custom_target(firmware ALL DEPENDS ${PROJECT_NAME} COMMAND ${CMAKE_OBJCOPY} -O binary $TARGET_FILE:${PROJECT_NAME} ${PROJECT_NAME}.bin COMMAND ${CMAKE_OBJCOPY} -O ihex $TARGET_FILE:${PROJECT_NAME} ${PROJECT_NAME}.hex COMMAND ${CMAKE_SIZE} $TARGET_FILE:${PROJECT_NAME} )然后cmake --build build --target firmware一步到位。这个目标可以加到CMakePresets的build预设里以后直接按快捷键就能编译并生成固件。
返回列表