尧图精选

嵌入式C++实战:手写STM32最小工程与寄存器点灯全流程

🕒 发布时间:2026/10/2 17:47:07 📁 来源:尧图网络
看了三篇了一行都没让我写呢——这条留言几乎出现在我每一期的评论区我憋了三篇就是为了这一篇能把这句话接住。嵌入式C和PC端写C最大的不同就是你没办法打开一个IDE、新建个文件、敲个cout然后点运行打印结果直接怼到屏幕上。STM32要跑起来你得先和硬件约定好代码放哪、变量放哪、寄存器在哪、时钟开没开这些东西没有理清楚你写出来的代码就是开盲盒。前三篇我一直在铺垫存储映射、寄存器访问、编译链接模型这期开始我们真的动手从手动搭建最小工程到用C点亮一颗LED全程不用HAL库不带魔术配置把每一个步骤都摊开讲清楚。适合谁看已经看过这个系列前几篇但还没下手的朋友或者被CubeMX生成代码弄得一知半解、想在芯片层面建立确定感的开发者。看完这一篇你会获得一个完全由自己掌控的STM32工程模板以及一套可以继续扩展的C封装写法。1. 为什么前三篇我硬是压着你不让写代码很多人觉得写代码才是学嵌入式的一切代码确实重要但嵌入式里代码只是最上面一层。你真正面对的是一个拥有独立地址空间、需要你手动管理时钟、外设和中断的单片机动手前脑子里必须有一幅地图。1.1 嵌入式开发不是新建工程写个Hello WorldPC开发里操作系统帮你把程序加载进内存库函数帮你做格式化输出你只需要关心业务逻辑。STM32上没有操作系统没有标准的输入输出连内存从哪里开始、多大都要你自己通过链接脚本告诉编译器。这一点和上网需要先配IP地址一个道理。你手上这台机器出厂时什么都没有你得先给它一个IP、一条路由它才知道往哪儿发送数据。STM32也一样写第一行业务代码之前要先回答三个问题程序烧进去之后放在哪一段Flash上答案通常是0x08000000附近。栈指针从哪个值启动不设置的话系统一复位就会跑飞。端口时钟开了没有没开时钟你配置寄存器等于对着空气说话。这些问题的答案就是前三篇反复强调的存储映射、寄存器、启动流程。我故意没有在第一天让你写代码因为只给代码不给流程你调不通的时候根本无从下手。一旦这些基础在自己脑子里扎了根今天这第一行代码你一晚上就能跑通。1.2 前三篇埋下的伏笔这一篇全部用上回顾一下前三篇的核心内容第一篇讲芯片存储映射Flash和RAM的物理地址范围第二篇讲寄存器操作为什么寄存器必须用volatile修饰第三篇讲编译链接的整体概念目标文件、段、链接脚本的作用。这期全都会落到实处。举一个最直接的例子第三篇提到过段(Section)这个概念。当时听起来很抽象今天你会亲手在链接脚本里写.data段、.bss段亲眼看到代码和数据如何被安排进内存。你没有白看之前那些与我无关的概念今天全部变得具体了。2. 第一件产品从手拉最小STM32工程开始我坚持不用IDE的一键工程模板比如STM32CubeMX或Keil的默认工程。不是说它们不好而是它们隐藏了太多关键信息。等你从一个型号换到另一个型号、从开发板换到自制板那些被IDE藏起来的东西会突然跳出来咬你一口。手动搭一次最小工程值得。2.1 四个文件组成的最小工程一个能支持C的最小STM32工程只需要四个部分文件/内容作用链接脚本.ld告诉编译器芯片Flash和RAM的地址、容量以及各个段如何排布启动文件.s初始化栈指针、处理复位入口、中断向量表main.cpp你的业务代码入口Makefile把上面几个文件用正确的参数编译、链接、生成烧录文件我当前用STM32F103C8T6这块最常见的芯片做示范板子是蓝丸那种板载LED接在PC13上低电平点亮。别急着抄代码先理解每个文件为什么存在。2.2 链接脚本用文字向编译器描述这块芯片先看链接脚本的核心部分这是我对F103C8T6做的最小可用的版本/* stm32f103c8t6.ld */ MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (rwx) : ORIGIN 0x20000000, LENGTH 20K } _estack ORIGIN(RAM) LENGTH(RAM); SECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) . ALIGN(4); } FLASH .text : { . ALIGN(4); *(.text) *(.text*) . ALIGN(4); _etext .; } FLASH .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) } FLASH .data : { . ALIGN(4); _sdata .; *(.data) *(.data*) . ALIGN(4); _edata .; } RAM AT FLASH .bss : { . ALIGN(4); _sbss .; __bss_start__ _sbss; *(.bss) *(COMMON) . ALIGN(4); _ebss .; __bss_end__ _ebss; } RAM .init_array : { __init_array_start .; KEEP(*(.init_array*)) __init_array_end .; } FLASH }有些细节我提一下不然你连抄都不知道为什么。.isr_vector是中断向量表统一放在Flash开头因为芯片复位后第一条指令要到这里取。.data段比较特殊代码里初始化过的全局变量其初值在Flash里但运行时这些变量要被复制到RAM中才能被读写所以写法是RAM AT FLASH前面的RAM是运行时地址后面的FLASH是存储地址。.bss段则是未初始化或零初始化的变量只需要在RAM里占位置不需要从Flash复制。.init_array是C特有的段。这个段里存的是一堆函数指针指向全局对象构造器启动时会依次调用它们。没有这个段你定义在全局的C类对象构造函数永远不会被执行。这也是为什么纯C玩不转或者某些用C写的扩展名为cpp但本质是C风格程序没遇到问题——因为压根没有需要构造的全局对象。2.3 启动文件最简版本我给你了很多教程让你直接在CubeMX里生成启动文件然后一辈子不见它。我建议至少手写一次哪怕只保留必需的部分。下面是浓缩到极致的启动文件/* startup.s */ .syntax unified .cpu cortex-m3 .thumb .section .isr_vector .word _estack .word Reset_Handler .word Default_Handler /* NMI */ .word Default_Handler /* HardFault */ .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word 0 .word Default_Handler /* SVCall */ .word 0 .word 0 .word Default_Handler /* PendSV */ .word Default_Handler /* SysTick */ .text .thumb_func .global Reset_Handler Reset_Handler: LDR R0, _estack MOV SP, R0 LDR R0, _sdata LDR R1, _edata LDR R2, _sidata copy_data: CMP R0, R1 BEQ zero_bss LDR R3, [R2], #4 STR R3, [R0], #4 B copy_data zero_bss: LDR R0, _sbss LDR R1, _ebss MOVS R2, #0 clear_bss: CMP R0, R1 BEQ run_ctors STR R2, [R0], #4 B clear_bss run_ctors: BL __libc_init_array BL main loop_forever: B loop_forever .thumb_func .global Default_Handler Default_Handler: B loop_forever注意几个关键处。_estack定义在链接脚本里它是RAM起点 容量也就是这块芯片地址最高的位置。ARM Cortex-M的栈是向下增长的所以把栈顶指针初始化为RAM最高地址是正确的。接下来启动文件要干三件事把.data段从Flash复制到RAM、把.bss段清零、调用__libc_init_array执行C全局构造函数最后才进入main。__libc_init_array来自编译器的C运行库它会扫描.init_array段逐个调用构造器。这一步就是C程序和纯C程序在启动路径上最大的区别。3. 终于写代码了点亮板载LED的第一行C好了地基铺完现在真真正正开始写代码。我不会一上来就给你封装好的漂亮类先把最原始的寄存器版本写出来跑通让你看清楚一行代码到底在操作什么再去封装。3.1 寄存器地址是怎么查出来的点灯需要操作三个寄存器RCC_APB2ENR地址0x40021018控制APB2总线上的外设时钟我们用它给GPIOC开时钟。GPIOC_CRH地址0x40011004配置PC8到PC15的引脚模式我们要把PC13配置成推挽输出。GPIOC_ODR地址0x4001100C输出数据寄存器写1引脚输出高电平写0输出低电平。这些地址不是我背下来的需要查阅STM32F103数据手册里的存储映射表和外设寄存器描述。因为我用的是F103C8T6GPIOC挂在APB2总线上它的基地址是0x40011000CRH是该基地址偏移0x04ODR偏移0x0C。每个系列、每个型号可能都不一样不要套用别的芯片的值。PC13属于高8位引脚所以配置寄存器用CRH。它的第13引脚对应CRH寄存器中的位20到位23这4个bit前2位是MODE[1:0]后2位是CNF[1:0]。推挽输出50MHz在F103中的编码是0b0011也就是0x3。提示不同的LED接法极性不一样板载LED通常是低电平点亮因为LED一端接3.3V另一端通过GPIO输出拉低来形成电流回路。如果你自己的板子LED接法相反把点灯的代码逻辑反过来即可。3.2 第一版点灯代码先能看见亮再谈优雅// main.cpp - 第一版直接操作寄存器点灯 #include cstdint void delay(volatile std::uint32_t count) { while (count ! 0) { __asm volatile(nop); --count; } } int main() { constexpr std::uint32_t RCC_APB2ENR 0x40021018UL; constexpr std::uint32_t GPIOС_CRH 0x40011004UL; constexpr std::uint32_t GPIOС_ODR 0x4001100CUL; auto rcc *reinterpret_castvolatile std::uint32_t*(RCC_APB2ENR); auto crh *reinterpret_castvolatile std::uint32_t*(GPIOС_CRH); auto odr *reinterpret_castvolatile std::uint32_t*(GPIOС_ODR); // 1. 开启GPIOC端口时钟对应RCC_APB2ENR的IOPCEN位bit4 rcc | (1UL 4); // 2. 配置PC13为推挽输出50MHz先清零再置位 crh (crh ~(0xFUL 20)) | (0x3UL 20); while (true) { odr ~(1UL 13); // PC13输出低电平 - LED亮 delay(500000); odr | (1UL 13); // PC13输出高电平 - LED灭 delay(500000); } }注意上面代码里的GPIOС我在中文语境里写了好几处西里尔字母C实际你的代码里请用普通的ASCII字母GPIOC否则编译会报未定义标识符。这是我故意提醒你复制粘贴时一定看清楚字符的坑别问我怎么知道的。3.3 这段代码为什么能工作volatile和指针解析很多新手第一次写嵌入式C时常被reinterpret_castvolatile std::uint32_t*绕晕其实拆开看就两层意思。reinterpret_cast的作用是把一个整数地址转换成一个指针这在硬件寄存器访问里无法避免。寄存器在芯片内部就是一个特定地址上的内存单元你不转换编译器不认账。volatile则是对编译器的承诺这个内存位置的值可能在当前程序之外被改变也可能当前程序对它的写入需要被立刻看见所以每次读写都必须真的访问一次内存不能把它优化到寄存器缓存里。举个例子delay函数里我写了一个while(count ! 0)的循环。如果没有volatile修饰-O2开启后编译器可能认为这个循环没有外部副作用直接把整个循环优化掉。加了volatile后编译器老老实实每次判断都读取这个变量。这也是为什么嵌入式里写空延时循环必须加上volatile否则你的程序会变成要么永远亮、要么永远灭根本看不到闪烁效果。这个版本能跑但代码里没有任何C的腔调。别急下一节先把跑起来的底层原理讲完最后我会带你把它重构回一个像样的C工程。4. main之前发生了什么C程序启动环节拆解你大概率遇到过一种情况明明程序逻辑没问题下载到板子上就是不工作于是开始怀疑硬件、怀疑连线、怀疑人生。其实很多时候问题出在main之前的启动流程上。4.1 Reset_Handler不是简单的跳到main从启动文件可以看到芯片复位后首先执行Reset_Handler它先给栈顶指针赋值然后复制.data段、清零.bss段。这两个步骤如果没有做好会发生什么呢.data段复制出了问题全局变量的初值就是错的。你明明写了int flag 1;实际运行时flag可能是0甚至是一个随机值。.bss段没清零那些默认应该为0的全局变量同样不可信。这些步骤和C直接相关。因为在C里全局对象的构造顺序依赖两个基础条件一是.data段里的内部状态是对的二是.init_array里的构造函数列表被正确执行。任何一个环节被省略你都可能看到全局对象构造函数没跑的诡异现象。4.2 C和C启动流程的差异全局对象构造纯C程序在启动时只需要设置栈、拷贝数据、清零BSS、调用main就结束了。C程序多了一条潜在路径编译链接时每个全局对象会把自己的构造函数地址放到.init_array段启动时__libc_init_array会按顺序调用这些函数指针。但要记得.init_array段必须在链接脚本里显式保留。如果你用的链接脚本是别人写来给纯C工程用的里面没有.init_array结果就是你的类对象悄无声息地跳过构造过程。我用一个最小化的类来说明这个问题class Led { public: Led() { // 这个构造函数在 main 之前被调用 // 这里单纯用来演示 } }; Led led; // 全局对象如果.init_array段和启动流程不完整led的构造函数永远不会执行后面所有对led的操作都建立在错误状态上你会得到一个看起来完全没有规律的故障。排查半天往往发现不是业务逻辑的问题而是启动基础设施缺了东西。注意这也是为什么我不推荐完全从零手写复杂启动文件时直接抄别人精简过的版本。一定要确保启动文件里有BL __libc_init_array且链接脚本里有.init_array段。4.3 链接期常见的三个编译错误我第一次从纯C转向C工程时链接报了一堆错误常见的有这些undefined reference to __dso_handle undefined reference to __gxx_personality_v0 undefined reference to __cxa_pure_virtual__dso_handle是C运行时用来处理全局对象析构的符号。嵌入式里几乎不会用到动态卸载共享库析构函数基本没有机会被调用但链接器依然希望找到这个符号。解决方法有两种一是链接时不要用-nostdlib把C运行时全关掉而是使用--specsnano.specs保持精简的C库二是在代码里手动补一个占位定义extern C void* __dso_handle nullptr;__gxx_personality_v0和异常处理有关如果你在嵌入式里不用C异常编译参数里要明确加上CXXFLAGS -fno-exceptions -fno-rtti这两个参数既减小代码体积又避免链接器去找异常和运行时类型识别相关的符号。嵌入式里绝大多数场景用不到异常关闭它们是合理选择。__cxa_pure_virtual则是纯虚函数被调用时的兜底函数如果你的类有抽象接口理论上应该实现一个空的兜底定义。虽然我没见过正常程序里哪个纯虚函数真的被调到了但链接器看到-fno-rtti后可能依然需要它稳妥起见可以补上空实现extern C void __cxa_pure_virtual() { while (true); }5. 升级为可复用的工程写法模板、类和命名空间跑通第一版点灯你已经有了完整的最小工程。接下来如果不做任何整理代码量一旦上去寄存器地址散落各处就是一场灾难。是时候用C的手段把代码整理成可复用的样子了。5.1 用模板类替代魔法数C最擅长的事情之一就是编译期抽象。嵌入式开发里最常见的抽象对象就是寄存器位操作。我写了一个简单的模板类Reg// reg.hpp #pragma once #include cstdint templatestd::uint32_t Addr class Reg { public: static volatile std::uint32_t ref() { return *reinterpret_castvolatile std::uint32_t*(Addr); } static std::uint32_t read() { return ref(); } static void write(std::uint32_t value) { ref() value; } static void set(std::uint32_t mask) { ref() | mask; } static void clear(std::uint32_t mask) { ref() ~mask; } }; using RccApb2Enr Reg0x40021018UL; using GpioC_Crh Reg0x40011004UL; using GpioC_Odr Reg0x4001100CUL;这个类把寄存器地址变成类型信息set和clear封装了读改写操作。你可能觉得这也没什么大不了但它有一个实打实的好处Reg0x40021018UL编译后和直接操作volatile指针一样高效但每一条使用意图都被名字表达出来了。为什么要提供set和clear因为寄存器操作绝大部分就是某位置1和某位清0两种操作。如果你直接写ref() | mask每次复制粘贴时很容易写成ref() mask把置位写成赋值导致其他位被意外覆盖。封装之后写错的可能性小很多。5.2 用模板类的还有额外好处模板类没有运行时开销因为它的一切都在编译期完成。Reg0x40021018UL和直接写地址常量生成的机器码几乎一模一样却比散落的宏可维护得多。宏的问题在于它不会做类型检查也容易在表达式里产生优先级歧义。我在实际项目中还经常这样扩展某类寄存器只允许某几位操作那就加一个带掩码的包装。比如只读寄存器就不给write只有置位权的寄存器就不给clear。C模板的能力远比宏更能表达这种硬件约束。5.3 完整重构后的代码现在把点灯程序重构成有C样子的版本并将LED封装成一个类// main.cpp - 第二版用模板类和类封装重构 #include cstdint #include reg.hpp // 延时函数保留 volatile static inline void delay(volatile std::uint32_t count) { while (count ! 0) { __asm volatile(nop); --count; } } class Led { public: static void init() { // 开启GPIOC时钟RCC_APB2ENR 的 bit4 RccApb2Enr::set(1UL 4); // PC13 配置为推挽输出50MHz GpioC_Crh::ref() (GpioC_Crh::read() ~(0xFUL 20)) | (0x3UL 20); off(); } static void on() { // 低电平点亮所以是 clear GpioC_Odr::clear(1UL 13); } static void off() { GpioC_Odr::set(1UL 13); } static void toggle() { GpioC_Odr::ref() ^ (1UL 13); } }; int main() { Led::init(); while (true) { Led::on(); delay(500000); Led::off(); delay(500000); } }这个版本最大的价值在于以后你要点亮另一个引脚只需要改Led类内部的引脚号要切换端口就把GpioC_Odr换成GpioA_Odr之类的别名。代码的调用方main完全不关心底层实现这就把业务逻辑和硬件访问分开了。toggle我特意用异或实现这个操作在GPIO翻转场景里极其常见。它不需要先读寄存器再算值一条EOR指令就能完成速度比读-改-写快得多。你用逻辑分析仪测量翻转频率时能明显看到异或写法和普通读改写的差异。有人可能会问为什么类里的成员函数都用static因为一个STM32的LED在整个程序生命周期里往往只有一个实例没必要为它维护对象身份和实例状态。static成员函数不创建实例编译后的代码也更紧凑。如果你真要做多个LED实例再改成普通成员函数也不迟核心的Reg模板不需要动。6. 编译、烧录与验证的实战经验代码写完了怎么把它烧到板子上这一节我把自己经常用的、也推荐给入门者的完整流程串一遍顺便把几个容易翻车的细节摆出来。6.1 工具链选择与VS Code配置我用的是arm-none-eabi-gcc工具链原因有三支持C17、与开源工具链生态无缝衔接、可以在Windows/Linux/macOS上保持一致体验。Keil的C支持虽然也能用但它在标准合规性和版本更新速度上一直落后半拍。在VS Code里我依赖两个扩展C/C扩展负责代码提示和调试Cortex-Debug负责通过OpenOCD做调试器接口。编译和烧录的命令全部放在Makefile里VS Code的launch.json只需要指定一下.elf文件路径和OpenOCD配置路径点F5就能进入调试。注意如果你在Windows上开发建议给arm-none-eabi-gcc安装目录加入系统PATH避免命令行找不到编译器。用MSYS2或者WSL工作流时注意路径风格的差异Makefile里尽量使用相对路径。6.2 用Makefile管理构建最容易翻车的环节网上很多Makefile会有下面的问题编译时没有把启动文件放进去或者链接时没有指定链接脚本导致出现缺段或者多个Reset_Handler之类看不懂的错误。我这里给出一个能跑通的模板TARGET blink TOOLCHAIN arm-none-eabi- CXX $(TOOLCHAIN)g CC $(TOOLCHAIN)gcc OBJCOPY $(TOOLCHAIN)objcopy SIZE $(TOOLCHAIN)size CXXFLAGS -mcpucortex-m3 -mthumb -O2 -Wall -Wextra \ -fno-exceptions -fno-rtti -stdc17 CXXFLAGS -ffunction-sections -fdata-sections LDFLAGS -Tstm32f103c8t6.ld --specsnano.specs LDFLAGS -Wl,--gc-sections -Wl,--start-group LDFLAGS -lc -lgcc -lstdc -Wl,--end-group OBJS startup.o main.o all: $(TARGET).hex $(TARGET).elf $(TARGET).elf: $(OBJS) $(CXX) $(LDFLAGS) -o $ $(OBJS) $(SIZE) $ %.o: %.s $(CC) -mcpucortex-m3 -mthumb -c -o $ $ %.o: %.cpp $(CXX) $(CXXFLAGS) -c -o $ $ $(TARGET).hex: $(TARGET).elf $(OBJCOPY) -O ihex $ $ clean: rm -f $(OBJS) $(TARGET).elf $(TARGET).hex几个参数解释一下。-mcpucortex-m3 -mthumb指定目标架构和指令集错误设置会导致生成不兼容的指令。-ffunction-sections -fdata-sections配合-Wl,--gc-sections可以把没被引用的函数和数据段从最终镜像里裁掉对减小体积很有帮助。--specsnano.specs使用精简版C库体积小很多。如果遇到__libc_init_array未定义可以试试去掉-nostdlib改为显式链接-lc -lgcc -lstdc。链接这一步我特意用$(CXX)而不是$(LD)原因是C程序需要链接器自动加入libstdc、libgcc用g驱动链接可以省掉一堆手动库参数。6.3 烧录验证与后续进阶烧录我用ST-Link V2加OpenOCD。直接把STM32开发板的SWD四根线SWDIO、SWCLK、GND、3.3V接好然后执行openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c program blink.elf verify reset exit如果一切顺利你会看到OpenOCD输出烧录成功的提示紧接着板载LED开始以大约1秒为周期闪烁。我第一次带新手跑这个流程时遇到最多的问题是OpenOCD报找不到设备。八成是驱动没装好或者在Windows下ST-Link的驱动被别的软件占用。另一种可能是接错了线SWDIO和SWCLK接反。这类问题排查起来很花时间建议先用官方提供的ST-Link Utility或者st-flash工具单独测一下能否连接再进OpenOCD流程。点灯跑通后下一步就可以做几件更有意思的事用SysTick中断替代傻等延时、把LED闪烁改成PWM呼吸灯、甚至给Led类增加一个淡入淡出的接口。我自己在实际项目里最重要的验证手段就是先让LED高频闪烁再用逻辑分析仪看引脚翻转频率以此确认时钟树配置是否正确、系统主频是不是达到预期。点灯看似幼稚接触过内核调试的人会懂它能验证的东西远比亮没亮多得多。这篇写到这基本的工程骨架你已经搭起来了也终于写出了属于自己的嵌入式C第一行代码。下次我们再把中断处理这块撕开聊一聊C的静态成员函数怎么作为中断回调使用以及继承虚函数在这个场景里到底该不该碰。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →