STM32嵌入式C++实战:从寄存器点亮LED到模板封装
看了三篇了一行都没让我写呢——这句话我料到了。第四篇发出去那天晚上我后台就收到好几条类似的留言有说“博主你是不是忘了这是个编程教程”有说“我键盘都擦干净了你就给我看这个”。我认但我得先把这笔账算清楚。不是我不想让你写而是嵌入式C这个玩意儿跟前端、后端不一样——你在PC上写C装个编译器、双击运行就完事了在STM32上写C你连“代码从哪一行开始执行”都要自己操心。这不把这些前置问题解决掉我让你写十行代码你大概率是烧进去没反应然后回来骂我坑你。所以这篇我话不多说直接上代码。从最小可运行的寄存器操作开始一直写到C风格的封装最后把编译、烧录、调试里那些我实实在在踩过的坑也一并丢出来。这篇看完你应该能亲手让一颗LED在STM32上闪起来并且知道自己写的每一行代码为什么长这样。1. 先算清楚账前三篇没让你写代码到底在准备什么在动笔之前我得先把前三篇的铺垫解释明白。不是凑字数是真绕不开。你想想看STM32上跑C和你在电脑上跑C差的不是语法是整个运行环境。1.1 解决代码写在哪、怎么跑起来才是真正的门槛我见过太多人C语法学得滚瓜烂熟智能指针、模板、lambda张口就来结果在STM32上写了个全局对象烧进去发现构造函数根本没执行然后整个人就懵了。为什么会这样因为在PC上操作系统帮你把程序的加载、初始化、堆栈分配全干完了你的main函数是“被准备好”之后才调用的。而在单片机上芯片上电之后运行的第一行代码不是main而是启动文件里的复位中断向量。这块芯片的时钟要初始化、内存要清零、堆栈指针要设定这些全都做完才会有人去Call你的main函数。更进一步如果你用的是C在进入main之前还得执行所有全局对象和静态对象的构造函数否则你定义的对象就是一堆没初始化的内存。这件事在PC上是编译器生成的初始化代码帮你做了在单片机上你得自己确认工具链有没有把这段代码链接进来。所以前三篇我花大量篇幅讲工具链、讲启动流程、讲链接脚本就是在帮你把这些“水面下的事情”铺平。不然你上来就写代码遇到问题根本无从下手——因为你连问题出在哪个环节都不知道。1.2 C在单片机上不是语法能用而是取舍第二个原因是嵌入式C和你平时写的C有一个很大的区别它不是让你把桌面端的编程习惯搬过来而是让你搞清楚哪些特性能用、哪些特性要主动关掉。比如异常处理在PC上try-catch用得飞起但在STM32上我几乎从不开启异常支持因为异常处理需要额外的运行时开销和代码空间对裸机程序来说性价比极低。再比如STL容器vector虽然好用但如果堆空间没配置好new出来的内存会直接砸了你的栈这种问题排查起来极其痛苦。这些“取舍”不讲清楚上来就写代码你写的时候是爽了跑起来就会遇到一堆玄学问题。所以前几篇我宁可啰嗦一点也要把这些规则告诉你。现在规则讲完了该写的代码一个都少不了。2. 第一行能跑的代码寄存器操作点亮LED我先不急着上C特性咱们用最朴素的方式先让一颗LED亮起来。这一步跑通了后面所有的封装和抽象才有着落。这个例程我以STM32F103C8T6为例也就是大家最常玩的“蓝丸”核心板。板载LED接在PC13引脚上高电平熄灭、低电平点亮注意这是个反逻辑后面你会体会到它对调试的影响。2.1 先看启动文件你的main是谁调起来的ST官方和很多开发板厂商都会提供一个启动文件比如.s结尾的汇编文件名字一般叫startup_stm32f10x_hd.s。它里面定义了一个叫Reset_Handler的东西芯片复位后就是从它开始执行的。这个文件里做的事情大致是这样的从.data段已初始化全局变量加载初始值把.bss段未初始化全局变量清零然后调用SystemInit做时钟初始化最后调用__main或者直接调用main取决于你的工具链和启动文件版本。如果你用C__main背后还会调用__libc_init_array这一步就是为了执行全局对象的构造函数。很多人做C嵌入式开发代码写得很正常结果LED就是不亮怀疑是硬件问题最后发现是启动文件里压根没包含构造函数的调用入口。所以我建议刚开始做C STM32项目先用官方或者GCC ARM工具链配套的启动文件别自己写等摸清了机制再考虑定制。2.2 点亮一颗LED需要碰到的三个寄存器点亮PC13这颗LED我们需要操作几个寄存器都在芯片参考手册里写得很清楚。第一个是RCC-APB2ENR这是复位与时钟控制寄存器。任何外设要工作第一步是把它的时钟打开。在STM32F1系列上GPIOC挂在APB2总线上所以我们要把APB2ENR的第4位置1也就是置上IOPCEN位。第二个是GPIOC-CRH这是端口配置寄存器高段负责引脚8~15的模式配置。每个引脚占用4位其中CNF位决定是输入还是输出、推挽还是开漏MODE位决定输出速度。PC13是第13脚在CRH的位20~23。我把它配成通用推挽输出速度2MHz对应的二进制是0010。第三个是GPIOC-ODR这是输出数据寄存器。往第13位写1引脚输出高电平写0输出低电平。前面说过板载LED是低电平点亮所以往ODR的第13位写0灯就亮了。直接开工先来个最原始的操作版你不用理解每一行先把这个流程记下来#include stm32f10x.h void delay(volatile uint32_t count) { while (count--) { } } int main(void) { // 1. 开启GPIOC的时钟 RCC-APB2ENR | RCC_APB2ENR_IOPCEN; // 2. 配置PC13为推挽输出 GPIOC-CRH ~(0xF 20); // 先清掉这4位 GPIOC-CRH | (0x2 20); // 设置CNF00, MODE10即推挽输出2MHz while (1) { GPIOC-ODR ~(1 13); // 第13位置0LED点亮 delay(720000); GPIOC-ODR | (1 13); // 第13位置1LED熄灭 delay(720000); } }这段代码烧进去板子上的LED应该就开始一闪一闪了。如果你用的是别的型号比如F407或者G0系列寄存器的名字和时钟的位会有些差异但思路完全一样——开时钟、配模式、写输出。2.3 为什么第一版故意不用HAL库我知道你可能已经在网上见过无数个用HAL库点亮LED的教程。那我为什么放着现成的HAL不用非要让你先跟寄存器肉搏一回因为HAL库把这些操作封成了一个黑盒子。你调一下HAL_GPIO_WritePin灯亮了但你不知道它背后到底改动了哪个寄存器的哪个位。这在你只做应用开发的时候问题不大可一旦你需要调试、排查问题甚至移植代码到别的芯片上你对底层的理解就会成为瓶颈。更关键的是这篇文章讲的是“嵌入式C”不是“嵌入式调用库函数”。C封装的一个核心价值就是帮你把硬件操作抽象成有语义的对象。而你要做出一个像样的抽象前提是你先搞清楚被抽象的东西底层是什么。所以第一步用寄存器裸操作不是走弯路是在为后面的抽象打地基。3. 用C重新组织寄存器从能用到有着落聊完了基础现在终于轮到C上场了。这一节的目标很清晰把刚才那段寄存器操作组织成一个有语义、可复用、出了Bug还能快速定位的C接口。你可能会问我图什么呢图的就是在工程变大之后不至于满屏都是GPIOC-ODR ~(1 13)这种魔法数字。有了封装之后代码读起来就是“这个LED开灯、那个继电器吸合”整个程序的逻辑一目了然。3.1 最简单的class封装把引脚当对象先从最朴素的做法说起。定义一个Led类构造函数接收一个端口基地址和引脚号提供on()、off()、toggle()三个方法。#include stm32f10x.h enum class LedState { Off 0, On 1 }; class Led { public: Led(GPIO_TypeDef* port, uint16_t pin) : port_(port), pin_(pin) { // 构造时把引脚初始化为熄灭状态 port_-BSRR static_castuint32_t(pin_) 16; } void on() { port_-BSRR pin_; } void off() { port_-BSRR static_castuint32_t(pin_) 16; } void toggle() { port_-ODR ^ pin_; } private: GPIO_TypeDef* port_; uint16_t pin_; };这里用到了STM32F1系列特有的BSRR寄存器。它的好处是写1的位对应的引脚会使输出置位往高16位写1会使对应引脚复位而且它是“写1生效、写0不变”的所以不需要像操作ODR那样小心翼翼地做读-改-写操作。这在多任务环境下能减少被打断出Bug的概率。然后main函数就清爽很多了int main(void) { RCC-APB2ENR | RCC_APB2ENR_IOPCEN; GPIOC-CRH ~(0xF 20); GPIOC-CRH | (0x2 20); Led onboardLed(GPIOC, 13); while (1) { onboardLed.on(); delay(720000); onboardLed.off(); delay(720000); } }3.2 模板化让编译器替我们干活class版本已经可用了但有个问题pin_和port_作为成员变量每次调用方法都要从内存读取并且因为是运行时值编译器没法帮你做优化。在嵌入式场景性能不是唯一的考量但代码尺寸和可预测性往往是重要指标。这时候C的模板就派上用场了。把端口和引脚号从构造函数参数变成模板参数让它们在编译期就确定下来template GPIO_TypeDef* PORT, uint16_t PIN class GpioOutput { static constexpr uint32_t PinMask (1UL PIN); public: static void init() { // 端口配置寄存器引脚8~15用CRH0~7用CRL volatile uint32_t* cr (PIN 8) ? PORT-CRL : PORT-CRH; uint32_t shift (PIN 0x07) 2; *cr ~(0xF shift); *cr | (0x2 shift); } static void high() { PORT-BSRR PinMask; } static void low() { PORT-BSRR PinMask 16; } static void toggle() { PORT-ODR ^ PinMask; } };注意这里我把所有方法都定义成了static。因为此时每个引脚的信息已经完整编码在模板参数里了不同的GpioOutputGPIOC, 13和GpioOutputGPIOC, 14在编译期就是两种完全不同的类型不再需要对象实例来区分状态。这样每次调用都不会有成员变量的额外读取开销整个操作常常能被内联成几条指令甚至一条指令。main里的用法变成这样using BoardLed GpioOutputGPIOC, 13; int main(void) { BoardLed::init(); BoardLed::low(); // 低电平点亮 while (1) { BoardLed::toggle(); delay(720000); } }有没有发现现在代码距离硬件已经相当远了。你完全可以把BoardLed想成一个抽象的“输出引脚”它怎么配置寄存器、怎么写时序都被隐藏在了编译期。这带来的另一个好处是如果哪天你换了引脚只需要改using这一行如果换了芯片平台只需要把这个模板类换成新平台的实现而上层业务代码一行都不用动。3.3 用constexpr和命名空间让工程更严谨模板化之外还有两个C的关键字在嵌入式里特别值得用constexpr和namespace。constexpr能确保某些值在编译期就算好比如上面的PinMask编译器必须能在编译阶段把它的值确定下来否则就报错。这在嵌入式里是一种很好的“自我检查”——如果某个配置依赖运行时的输入编译器就会告诉你“这里不能用constexpr”从而逼迫你重新思考设计。namespace则是用来整理代码归属的。比如把所有硬件抽象都放进sensor::、driver::、app::这样的命名空间里再结合using别名代码的层次感和可读性会明显提升。别小看这种组织性工程规模到了几十个文件的时候命名空间简直能救命。4. 上板前的暗礁构造函数、堆空间与优化选项封装看完了你可能已经跃跃欲试想把项目用C重写了。且慢嵌入式C有几个坑是人人都得趟一遍的。我不希望你踩完再来翻这篇文章所以干脆提前把这些暗礁标出来。4.1 全局对象构造函数的调用时机这是C在单片机上最容易翻车的地方。你在PC上定义一个全局对象它会在进入main之前就被构造好。在单片机上这件事是否发生完全取决于启动文件是否包含了对应的初始化调用。GCC ARM工具链下构造函数的调用藏在__libc_init_array里而这个函数是放在启动文件对应的C库初始化流程中的。如果你用的是ST官方改装过的启动文件直接跳转main跳过__libc_init_array那么所有全局对象的构造函数都不会执行。你看到的现象就是明明我在对象构造函数里把灯初始化了结果上电灯不亮。解决办法有两个方向。第一个是确认启动文件有没有调用这个初始化流程。在最新版GCC配合标准启动文件时复位向量里通常会有bl __libc_init_array然后再bl main。第二个如果你用的是IDE比如Keil MDK它会在main之前通过__main调用__scatterload和__rt_entry等初始化流程这里面也会处理C全局构造。总之第一次用C写STM32先花十分钟确认启动文件比烧进去之后瞎猜强一百倍。4.2 new/delete、堆空间与异常处理再来是动态内存。你写桌面程序用new用习惯了但在STM32这种资源受限的环境里new/delete不是不能用而是要谨慎用并且要明确堆的大小。链接脚本里会有一段像是这样的定义_heap_size 0x2000; _stack_size 0x400;这只是预留了内存区间真正的堆分配器能不能工作要看你的C运行库怎么实现。如果使用-specsnano.specs配合-specsnosys.specsnew和delete通常是可用的但malloc的实现可能非常朴素频繁申请小块内存容易产生碎片。我的做法是在裸机程序里几乎不用动态内存。STM32F103C8T6只有20KB的RAM做个复杂应用随便就占了一半再搞动态分配一个内存碎片问题就够你排查几天。所有对象尽量静态分配实在要变长的数据结构优先用固定大小的数组做环形缓冲。C的std::array和std::array_view如果用得上比vector可靠得多。异常处理我这里再强调一遍能关就关。在编译命令里加上-fno-exceptions这样万一程序运行中出现非法操作也只会触发硬件异常处理而不会进入异常展开流程。这类展开在单片机上不仅没有意义还会占用大量代码空间。4.3 优化选项和volatile的那点事在PC上用编译器优化开不开影响没那么大——反正你肉眼感觉不到几毫秒的差距。但在STM32上优化级别会直接决定你的delay函数还能不能正常延时。我想起第一次写完LED闪灯代码编译器用的-O2优化结果灯直接常亮不闪了。我当时第一反应是代码逻辑错了查了半天最后发现就是优化把空循环给优化掉了。你把delay改成一个volatile uint32_t内联函数编译器就该老实了。这就是为什么我用寄存器操作版本里写的是delay(volatile uint32_t count)。凡是和硬件寄存器相关的变量在C里也一定要保持其volatile语义。比如GpioOutput里直接操作PORT-BSRR这个指针指向的地址在芯片手册里就标注了“volatile属性”所以通常不会出问题。但如果你自己声明了一个全局变量用来在中断里做标志位却没有加volatile编译器可能把循环加载优化成一次性加载——你按开关没反应程序却还在傻傻地跑这种Bug极其隐蔽。5. 编译烧录调试我在这个工程里的完整命令和踩坑代码写得再漂亮跑不起来等于零。最后这部分我把我实际用过的编译命令、烧录流程和调试习惯整理出来给你。这不是唯一的标准答案但这是我验证过能跑通的一套你照着做至少不会卡在半路。5.1 arm-none-eabi-g的编译参数我用的工具链是arm-none-eabi-g在Windows和Linux下都有发行版。假设你的工程里有三个文件main.cpp、startup_stm32f10x_hd.s、stm32f10x_flash.ld可以用下面这样的命令来编译arm-none-eabi-g -c -mcpucortex-m3 -mthumb -stdc17 -Os -Wall \ -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections \ -nostdlib -fno-use-cxa-atexit \ -I. -Istm32_headers \ main.cpp -o main.o arm-none-eabi-as -mcpucortex-m3 -mthumb \ startup_stm32f10x_hd.s -o startup.o arm-none-eabi-g -mcpucortex-m3 -mthumb \ -T stm32f10x_flash.ld \ --specsnano.specs --specsnosys.specs \ -Wl,--gc-sections -Wl,-Mapbuild.map \ startup.o main.o -o firmware.elf arm-none-eabi-objcopy -O ihex firmware.elf firmware.hex说几个参数的含义。-mcpucortex-m3 -mthumb指定目标架构——Cortex-M3只支持Thumb指令集这一点不能错。-fno-exceptions -fno-rtti关掉异常和运行时类型信息省代码空间。-ffunction-sections -fdata-sections加上链接时的--gc-sections可以把没用的函数和数据都删掉对精简镜像很有帮助。-fno-use-cxa-atexit这个参数比较冷门但很重要。编译C时默认会为全局对象注册析构清理函数__cxa_atexit这需要额外的运行时支持。在裸机环境里程序要么不退出要么退出了直接宕机对象的静态析构意义不大所以关掉这个选项能省下一笔空间。5.2 用OpenOCD从命令行烧录烧录方面我推荐用OpenOCD加ST-Link调试器。它的好处是跨平台命令行可控而且和调试器共用一套配置。假设你有一条ST-Link接在板子的SWD接口上那么烧录命令大致是openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c program firmware.hex verify reset exit这条命令的意思是初始化ST-Link初始化STM32F1目标把firmware.hex烧进去校验复位退出。如果连接正常你会看到OpenOCD输出几行进度信息然后板子就自己动起来了。我第一次烧录的时候遇到过一个问题OpenOCD提示target not halted后来发现是板子在调试模式下还在跑程序把芯片的电断了重插一下SWD接口就好了。这种问题跟代码关系不大纯粹是烧录流程的经验碰到了别慌。5.3 调试环节最容易被忽视的一件事最后分享一个调试习惯。很多人在STM32上调试第一反应是打断点看变量。这当然有效但在嵌入式里很多问题发生在启动早期、中断上下文或者高优化级别下断点根本没有用武之地。我的经验是先用串口打印作为第一道侦查手段。在C工程里你可以在关键路径上放一个超级简单的uart_puts(init done\r\n)。这话听着土但确实能快速定位到“到哪一步没跑了”。比如你怀疑构造函数没执行那就在构造函数的函数体里加一句串口输出如果输出没出现说明构造链确实没调起来问题方向一下子就明确了。等你把串口打印的框架搭起来之后再考虑JTAG断点、SWO跟踪这些高级手段。尤其是SWO在Cortex-M3上可以通过SWO引脚输出格式化日志几乎不占用额外IO和CPU时间调试体验比串口强一个档次。只不过STM32F103不是所有型号都支持SWO选型的时候可以留意一下。调试环节还有一件事我建议你养成习惯每次改动后记一下改了什么、为什么改、烧录后的现象是什么。这个习惯听起来很老派但嵌入式调试最怕的就是“上次还能跑改了就不行了”。有记录你能迅速回滚没记录你可能把一整个下午耗在反推上。写到这儿你终于亲手写了STM32上的第一行C代码还把它从单纯的寄存器操作一路升级成了带模板封装的硬件抽象。说实话这个“一行”只是一个开始后面真正有意思的是把这些积木搭起来——比如动动GpioOutput的模板参数去驱动一个超声波测距模块或者把定时器中断封装成事件源再做点USB Device之类的应用。到那个阶段你会感谢自己现在没有跳过寄存器裸操作直接抱HAL大腿。我的体会是嵌入式C最迷人的地方不是“能写出来”而是“明白它为什么这么写”。你现在的起点恰好就在那儿。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →