ARTICLE DETAIL

资讯详情

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

STM32嵌入式C++实战:从零封装一个LED类并跑起来

STM32嵌入式C++实战:从零封装一个LED类并跑起来 开门见山STM32、嵌入式、C这三个词放在一起很多人的第一反应不是兴奋而是头大。尤其是我这个系列的前三篇一直讲环境、讲编译工具链、讲芯片启动流程讲得头头是道结果读者留言区炸了“看了三篇了一行都没让我写呢”这语气我太熟悉了因为我当年跟教程的时候也是这个心情。你在PC上写C第一步就是cout hello五分钟就能看到黑框里的输出到了STM32这边光是一个工程怎么建、头文件怎么配、烧录器怎么连就能劝退一大半人结果教程还在那儿讲原理确实反人性。但我要先说清楚一件事前三篇不让你急着写代码不是因为想吊着你是因为嵌入式C这行的“地上”和PC编程不一样。工具链没揉顺、工程是怎么编译链接的没搞明白你写的每一行代码都可能被淹没在“编译不过”“烧不进去”“跑了但没反应”这类破事里最后反而更打击信心。这一篇我直接让你动手而且不搞那些“照着抄一遍”的假把式。我们从需求拆解开始设计一个麻雀虽小、五脏俱全的小模块把它写进工程、编译通过、烧进芯片、看到效果。等你走完这一趟再回头看前三篇很多东西会突然串起来。1. 恕我直言前三篇不让你写代码是因为写了也白写1.1 工具链没通代码写得越欢痛苦越深很多刚接触STM32的人都有一个误解觉得写代码是最难的工具链是“点点鼠标就完事”的东西。实际恰好反过来。在PC上编译器、链接器、调试器、运行库都是厂商帮你配好的你装个VS或者VSCode插件就能跑起来但在嵌入式这边你手里的芯片是裸的编译器要重新指定CPU架构链接脚本要安排Flash和RAM的地址布局启动文件要负责把C语言的运行环境“铺”好然后才轮到main函数出场。这三件事任何一件没配对都会出现让你怀疑人生的报错。比如你辛辛苦苦写了个类编译的时候报undefined reference to __gxx_personality_v0这跟你的业务代码一毛钱关系都没有就是C异常处理库没被链接进来。再比如你写了全局对象结果烧进去以后发现构造函数根本没执行LED死活不亮因为启动文件里少了调用全局构造器的那一步。前三篇铺垫这些东西不是为了灌水是为了让你在动手之前先把“地板”擦干净。这一篇我们真正开始写代码的时候你会发现自己能集中精力在逻辑上而不是被奇怪的工具链问题卡住半天。1.2 嵌入式C的上手门槛从来不在“语法”本身如果你已经会C的类、封装、继承、模板这些基础那语法上其实没什么新东西。嵌入式C和PC C最大的区别是它活在“资源有限”的世界里Flash是几十KB到几百KBRAM是几KB到几十KBCPU主频从几十MHz到几百MHz还经常没有操作系统帮你管内存。这导致你在PC上习以为常的很多写法到了嵌入式里要么跑不动要么会被同行嘲笑。比如在PC上你写std::vector用得顺手到了嵌入式里你要是敢随便push_back很快就会发现堆空间被撑爆、系统卡死。再比如你习惯了std::string的方便但它在底层会频繁做动态内存分配在MCU上就是灾难。所以我不建议嵌入式新手一上来就照着《Effective C》那种PC视角去学而应该先把“在这块芯片上该用C的哪些能力”这个问题想清楚。这一篇我们做的LED类就是在“不过度设计”的前提下让你体会类封装在裸机开发里的价值。1.3 前三篇到底攒下了什么底子简单梳理一下前三篇其实把下面这几块地基给打好了工具链arm-none-eabi-gcc的定位、代码编译和链接的基本流程。芯片视角STM32的存储器布局、启动文件做了什么事、中断向量表是什么。工程结构一个STM32工程通常由启动文件、链接脚本、HAL库源码、应用代码四部分组成。这三块知识单拎出来都挺枯燥但它们是你判断“代码为什么跑不起来”的底层能力。比如后面你遇到“LED不闪”你能按照“时钟有没有开→引脚配置对不对→GPIO输出寄存器有没有写对→代码逻辑有没有问题”去排查而不是只会对着屏幕发呆。2. 第一行代码你其实应该写一个类2.1 为什么从“外设封装类”起步而不是直接写业务很多人学完C以后第一反应是写一个大的业务逻辑比如一个温度采集系统、一个电机控制算法之类的。但这有个问题你对STM32外设的操作还不熟一上来就写业务代码里全是HAL的函数调用C的特性反而被稀释了写着写着就写成了“用C语法包装的C程序”。我的建议是从封装一个外设开始。外设的封装是嵌入式C里最典型、最划算的动作GPIO、UART、定时器、I2C、SPI这些外设每个都有固定的初始化和操作流程天然适合抽象成类。你把LED封装好了后面再做按键、蜂鸣器、传感器模块都是同一套思路代码量不增反减。什么场景适合这种写法往大了说几乎所有STM32项目都适用。你翻开招聘网站的嵌入式岗位要求十个里有八个会写“熟悉STM32”“了解嵌入式C”但真到项目里大部分人是C风格写到底。你要是能在简历里写“有基于C的外设封装实践经验”已经是加分项了。往小了说蓝桥杯的嵌入式赛题、毕业设计做个小车或仪器用C封装以后调试效率会高不少。2.2 动手前先把接口设计画在纸上写类之前先别急着打开编辑器。你花两分钟在白纸上把“这个LED类需要给外部提供什么能力”列一下后面写代码会顺很多。对一个最普通的LED它需要的能力其实就四种初始化配置引脚为推挽输出设置初始电平。打开引脚输出高电平或低电平取决于电路是低电平点亮还是高电平点亮。关闭输出相反电平。切换翻转当前状态。这四种能力对应四个函数init()、on()、off()、toggle()。构造函数可以先不做重活因为STM32的初始化往往依赖具体的时钟和外设配置硬塞进构造函数里反而让构造时机变得暧昧。这算是我踩过坑之后的一个心得嵌入式外设类的构造函数尽量轻真正的硬件初始化放到init()里由应用层在合适的时候调用。2.3 一行C都不写之前先搞懂C与C怎么在一颗芯片里同居这是很多人没意识到的坑。STM32的HAL库是C写的而我们要写C两者必须在同一个工程里共存。C编译器可以调用C函数但前提是它知道这些函数是“C语言符号”否则会按C的名字修饰规则去查找结果是链接找不到。名字修饰是什么简单说C为了支持重载会把函数名和参数类型信息编进符号里比如foo(int)在链接层面可能变成_Z3fooi而C不会干这种事它就叫foo。如果编译器不清楚这个函数是C写的链接的时候就会拿着修饰过的名字去找自然扑空。解决办法就是extern C。在C代码里把需要调用的C头文件或C函数声明包进extern C { ... }告诉编译器这些符号请按C的规则处理。在main.c里反过来不需要做任何特殊处理C语言本来就是C符号。3. 实操让第一个C程序在STM32上跑起来3.1 工程结构自己该把代码放哪里我用的是STM32CubeIDE假设你之前已经用CubeMX配置好了一个最小工程芯片比如是STM32F103C8T6蓝板那种。CubeMX默认生成的目录结构一般是Core/ Inc/ Src/ Start-up/ Drivers/ CMSIS/ STM32F1xx_HAL_Driver/我的习惯是在Core/Src下直接放main.cpp和led.cpp头文件放Core/Inc。新建文件的时候注意.cpp后缀很重要编译器是靠后缀决定用g还是gcc来编译的你写成.c后缀编译器就按C语言处理你的类、public、private这些全都会被当成普通符号直接报错。3.2 写一个不花哨但可以无限复用的LED类先写头文件led.h#pragma once #include stm32f1xx_hal.h class Led { public: Led(GPIO_TypeDef* port, uint16_t pin, bool active_high true); void init(); void on(); void off(); void toggle(); private: GPIO_TypeDef* m_port; uint16_t m_pin; bool m_active_high; };这里我刻意没有把功能写复杂但有几个点值得解释。GPIO_TypeDef*是STM32标准外设库里的端口指针类型它代表的是GPIOA、GPIOB这些外设的寄存器基地址。你把端口指针传进来这个类就能操作任意一个GPIO端口这就是“复用性”的开始。active_high是高低电平有效标志。你的板子上LED可能是高电平点亮也可能是低电平点亮用这个参数区分以后on()/off()的内部实现就不用为每种电路改一遍了。实现led.cpp#include led.h Led::Led(GPIO_TypeDef* port, uint16_t pin, bool active_high) : m_port(port), m_pin(pin), m_active_high(active_high) { } void Led::init() { GPIO_InitTypeDef gpio_init {0}; gpio_init.Pin m_pin; gpio_init.Mode GPIO_MODE_OUTPUT_PP; gpio_init.Speed GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(m_port, gpio_init); off(); } void Led::on() { HAL_GPIO_WritePin(m_port, m_pin, m_active_high ? GPIO_PIN_SET : GPIO_PIN_RESET); } void Led::off() { HAL_GPIO_WritePin(m_port, m_pin, m_active_high ? GPIO_PIN_RESET : GPIO_PIN_SET); } void Led::toggle() { HAL_GPIO_TogglePin(m_port, m_pin); }你可以看到on()和off()内部只是根据active_high判断了一下写哪个电平这对使用方来说是完全透明的。写业务代码的人只需要知道led.on()LED就亮不需要关心里面电平逻辑这就是封装的意义。3.3 main.c与main.cpp的连接extern C这一步别偷懒CubeMX生成的入口文件是main.c它是C语言文件。你在main.c里调用C写的函数直接用函数声明去调会出问题因为C编译器不知道C的名字修饰规则。我们需要一个C和C都能认的桥接函数。在main.cpp里写一个入口函数#include led.h Led led(GPIOB, GPIO_PIN_0, true); // 假设LED接在PB0高电平点亮 extern C void cpp_entry(void) { led.init(); while (1) { led.on(); HAL_Delay(200); led.off(); HAL_Delay(200); led.toggle(); } }注意extern C的用法这个函数是给C语言调的所以必须用extern C修饰让C编译器按C符号管理它。在main.c里你只需要在文件头部声明一下extern void cpp_entry(void);然后在HAL_Init()和SystemClock_Config()之后调用cpp_entry()。为什么放在这两个后面因为你的GPIO时钟和系统时钟还没配置好之前led.init()里去操作GPIO是无效的。这里也顺便回答了一个常见问题为什么不能一上电就干这干那——因为你得先把“供电”和“时钟”这两件事办妥。调用代码写好了还有一个经典细节while (1)循环里我先on()、等、off()、等、再toggle()。这样LED的闪烁效果是“亮200ms灭200ms再翻转一次”。最后那次toggle()让LED又亮起来所以实际效果是亮200、灭200、亮、灭200、亮200……顺序不同但整体效果是稳定的闪烁。这个代码写得不怎么优雅但作为第一个点亮程序逻辑简单容易看出有没有问题。3.4 编译和烧录STM32CubeIDE与命令行Makefile两条路如果你用STM32CubeIDE直接在工程上右键选择Build编译会同时处理.c和.cpp文件编译器会根据文件后缀自动选择g。链接的时候CubeIDE也会自动使用g来链接这是它省心的地方。如果你习惯命令行或者公司里要求用Makefile那要留意两个点第一不要直接用gcc去链接整个工程C程序要用g链接否则C标准库不会被自动带进来。第二编译C源文件的时候建议加上下面这组选项CXXFLAGS -O2 -fno-exceptions -fno-rtti -fno-threadsafe-statics这几个选项在4.1节会专门解释。编译通过以后烧录方式就看你手里的调试器了。ST-Link可以用st-flash write firmware.bin 0x08000000也可以用STM32CubeProgrammer图形化烧录也可以用OpenOCD。第一次烧的时候如果提示连接失败先检查ST-Link的四根线SWDIO、SWCLK、GND、3V3有没有接对尤其GND要共地这是最大概率的翻车点。烧进去之后如果一切正常你会看到LED在闪。这个瞬间虽然平淡但它证明了一整条链路是通的你的代码从C源码变成了机器指令指令被烧进了FlashCPU按你写的逻辑一点点执行。4. 第一次编译最容易碰到的几个坑4.1 链接错误undefined reference to __gxx_personality_v0你第一次用C编译STM32工程十有八九会撞见这个报错。__gxx_personality_v0是C异常机制在GCC里的一个运行时符号只要你在代码里用了try/catch或者某个编译单元默认开启了异常支持链接时就会去找这个符号。问题来了很多ARM GCC工具链的嵌入式标准库对异常的支持需要额外配置当你没有把异常相关库链接进来时就报这个错。解决办法有两个第一个是直接在编译选项里关掉异常支持。我上面提到的-fno-exceptions就是干这个的。STM32这种MCU上你已经知道了自己管着一块很小的RAM异常机制恰恰会引入隐式的栈展开、类型判断成本不低而且裸机环境下catch了异常又能怎样打印日志然后重启还不如在代码里把防御逻辑写清楚。同理-fno-rtti是关掉运行时类型识别。没用dynamic_cast和typeid的话关掉它能省不少Flash空间。-fno-threadsafe-statics是关掉C11对局部静态变量初始化的线程安全保护在单线程MCU里没必要打开反而会在每次访问局部静态变量时引入额外的判断代码。这三刀切下去你的嵌入式C工程会舒适很多。4.2 全局变量在main之前没构造这是嵌入式C里另外一个经典的无声bug。C标准规定全局对象的构造函数会在main函数执行之前全部执行。在PC上这是运行时库帮你安排的在STM32上需要启动文件里调用__libc_init_array()这个函数它才会去执行所有全局对象的构造。很多教程里给的启动文件模板要么压根没有调用它要么在中断向量表的初始化序列里漏了这步。结果就是你在顶层写了一个全局Led对象觉得它已经构造好了进main就直接用但实际那个对象的内存是空的端口指针全是0一调用就出错。排查方式很简单在全局对象的构造函数里临时加一句闪灯或者设置断点如果构造没执行就说明启动文件有问题。解决方式也直接把启动文件里Reset_Handler中的__libc_init_array调用补上或者在main函数的最开头调用它。不过我不会一上来就让你改启动文件你可以先用“局部对象在main里手动实例化”的方式跑通第一个程序等后面工程结构成熟了再回头处理全局构造。我个人的习惯是外设对象多数不作为全局对象而是在一个App类的成员里创建App对象再放在栈上。这样构造时机非常明确不会被启动文件的细节坑到。4.3 “用C写STM32是不是也会变大变慢”——关于体积与性能的实测认知这是每次提嵌入式C都躲不开的问题我在这里给个实际数据参考。同一个LED闪烁工程纯C的HAL库版本编译出来大约10KB左右用C写一个类来做同样的事如果不加优化编译可能会涨到15KB以上其中一部分是模板、异常、运行时类型信息带来的体积但如果按我们上面的方式把异常和RTTI关掉、开启-O2体积基本能和C版本持平差距微乎其微。性能上一个LED类的方法调用经过几层内联之后最终编译出的指令和直接调HAL没有任何本质区别。C在MCU上不会因为“面向对象”就变慢变慢的往往是你无意识引入的堆分配、虚函数动态分发、异常栈展开这类机制。只要不去碰这些C在STM32上的表现完全可以做到和C一样快。4.4 排查速查表现象可能原因排查思路编译报undefined reference to __gxx_personality_v0C异常支持未正确链接加-fno-exceptions -fno-rtti或链接时使用gLED完全不亮程序没卡死GPIO时钟没使能、引脚号错误、初始化函数未调用检查HAL_GPIO_Init之前是否调用了GPIO的时钟使能函数用逻辑分析仪或示波器量引脚电平编译通过烧进去没反应构造器未执行、中断向量表配置错误、烧录地址不对在构造器里加测试位确认全局构造器是否执行检查烧录地址是否为0x08000000烧录时连接不上SWD接线错误、目标板供电不足、芯片被读保护检查四个引脚连接确认共地用STM32CubeProgrammer尝试解除读保护用std::cout以后固件暴涨把PC的C流式IO库拉进来了MCU上不要用iostream串口输出用HAL封装或直接操作寄存器5. 从LED到更多外设这一类还可以这样长出来一个Led类写完你会发现这套“端口指针引脚号有效电平”的模式稍微改改就能套到别的外设上。比如按键输入你只需要把GPIO模式改成GPIO_MODE_INPUT加一个read()方法比如超声波测距你可以把触发脚和回声脚分别封装成两个引脚在read()里实现完整的时序再比如编码器你可以在定时器配置的基础上封装一个getCount()方法把脉冲计数变成一组语义清晰的接口。这些模块类的骨架都和LED类一样构造函数接收硬件资源init()负责配置业务方法负责具体操作。这也是为什么我一直强调第一行嵌入式C不要从大的业务逻辑开始先封装一个引脚级外设是性价比最高的练习方式。6. 写在最后的一点个人体会我之前带过一个新人C语法基础很差但STM32的HAL库调用很熟。他第一次看我写的类封装代码时一直问直接调HAL函数不是一样吗为什么要加一层类我没有解释太多就让他用C风格写了一版LED闪烁再让他用同样思路写一版“按键控制LED”。写到最后他自己发现问题了每加一个LED他就得复制三行初始化加两行控制逻辑代码开始肉眼可见地变长、变乱。而类封装把他的注意力强行“抬”到了业务层让我在代码评审的时候不用去关心某个引脚电平是高有效还是低有效这种细节直接聊逻辑。这篇写到这里你已经亲手写出了第一个基于STM32的嵌入式C类并且让它跑起来了。说实话这个LED闪烁程序在PC程序员眼里一文不值但对我们做裸机的人来讲它是一个真正的分界线从那以后你不再是一个“看教程的人”而是一个“能用C操作硬件的开发者”。我在后面几篇里打算再拿这个同样朴素的类模型去讲定时器回调、状态机、事件队列这些真正能体现C优势的设计。你先把这个LED类反复改几遍加几个不同引脚的LED跑通了再继续。
返回列表