资讯动态

STM32嵌入式C++实战:从零搭建最小工程点亮LED

发布时间:2026/9/27 11:34:26 来源:尧图企业网站定制
说到压力我是有体会的。系列写到第四篇评论区里已经开始催更了有位读者留言很直接看了三篇了一行都没让我写呢。说实话我看到这种留言反而很放心——因为他把前面那些枯燥的内容都看进去了现在的状态是手痒是想动手而不是被劝退。这个系列我们聊的是基于STM32的嵌入式C编程前面的篇幅里我们花了很多时间在讲C怎么写、类怎么设计、内存模型、编译过程这些底层逻辑。很多人会问这些和让LED亮起来有什么关系关系大了。这一篇我们就从关系讲起然后真正写第一行能跑在STM32上的C代码不是语法Demo是能下载进去、能在板上看到效果的工程。先声明一下这篇文章不是写给三天速成的人看的但也不是写给老鸟看的。它适合已经有C语言基础、会点亮一个工程版本的STM32、但一直没想明白C在嵌入式里到底能干什么、要怎么干的开发者。今天我们把能干什么和要怎么干合并成一个问题亲手搭一个最简C工程把一颗LED点亮。1. 为什么前面几篇一直在铺垫我的理由很直接很多嵌入式C转C的朋友最大的痛点不是学不会语法而是学会了语法写出来还是C。一个class包着几个函数换个马甲而已这种人我见的太多了。原因很简单没有先建立C的思维方式直接就动手写类写出来的东西当然还是C的灵魂。前几篇干货其实一直在解决一个偏见C在MCU上跑不动C是给PC端用的。实际完全相反。C的设计哲学之一就是零开销抽象——你付钱的特性才会产生代价不用的特性绝不拖累你。嵌入式里我们主动关闭异常exceptions和运行期类型识别RTTI其余绝大部分C语法在Cortex-M上都是直接映射成机器指令的没有额外负担。但这样的结论不能光靠讲你得自己看到才能信。所以我刻意把动手环节安排在了现在你有了C的思维底子再来写第一段代码看到的就不会只是这不就是结构体加函数嘛而是我写的这个类是编译期解决绑定、运行期零开销的。这就是为什么我不着急让你写代码。一个工程师最珍贵的不是代码量是判断力而判断力必须建立在原理之上。当然了铺垫归铺垫读者的手痒是真实的。所以这篇我们一口气把工程建起来、代码写起来、板子点起来。接下来不再讲虚的全部动作化。2. 动手前先解决环境一个最小的STM32C工程需要什么选择工具链之前先说一句掏心窝的话不要一上来就研究哪个IDE最强。STM32做C开发市面上成熟方案就三套看你的环境选就行。方案编译器C支持烧录调试适合人群STM32CubeIDEarm-none-eabi-gcc内置完整直接建C工程ST-LINK图形化新手、官方路线Keil MDKARM Compiler 6 (armclang)需勾选C选项ULINK/ST-LINK老团队、现有工程VSCode CMakearm-none-eabi-gcc需要自己配CMake和链接脚本OpenOCD喜欢折腾、定制自由我个人推荐STM32CubeIDE原因很朴素免费、官方、全套工具链都是自带的而且是基于GCC的arm-none-eabi工具链对C的支持最完整社区遇到的问题都能在网上找到答案。Keil的armclang其实也能编译C但它默认为了兼容老工程C支持需要手动开启新手容易漏。工具链定了接着是最小工程的文件清单。很多人不知道一个关键事实一个C的STM32工程和一个C的STM32工程在源文件层面的差异远没有在启动文件和链接脚本层面的差异大。要让C代码真正在STM32上跑起来最少需要下面这些文件main.cpp我们真正的C入口startup_stm32f103c8tx.s汇编启动文件负责跳转到main之前的所有初始化stm32f103xb_flash.ld链接脚本决定代码和数据在Flash/RAM里怎么排布stm32f10x.h寄存器地址定义system_stm32f10x.c或者极简的SystemInit实现多数人不理解C语言工程为啥没有额外要求因为C语言标准没规定全局变量要在main之前自动初始化完成但C标准规定了全局对象全局变量的类实例必须在main之前完成构造。这个动作靠的是链接器生成的初始化数组而启动文件必须实际调用它。很多人在CubeIDE里点了一个New C Project编译通过了就觉得没问题其实如果启动文件或链接脚本不对你的C全局对象根本不会被构造程序表现就会非常诡异而你还以为是自己类写得有问题。如果用的是CubeIDE的新工程模板这些细节官方都处理好了。但既然这个系列的原则是知其所以然我还是建议你把链接脚本和启动文件打开亲眼确认一下后面这几个字段。3. 第一篇真正的嵌入式C代码一个不过度设计的LED类讲完环境现在终于可以写代码了。我们要做的内容是嵌入式领域经典的Hello World——点灯。但我特意不直接用寄存器把灯点亮而是先设计一个最简单的LED类把GPIO操作封装进去。这样做的目的不是让代码变复杂而是让你第一次感受C封装外设带来的边界感。3.1 最小工厂不是注定写一大堆模板才叫C很多人在嵌入式里写C容易走向两个极端要么完全不用。尝试写一个极简封装。这个封装把某个引脚抽象成一个对象操作这个对象的时候不需要考虑它底层挂在哪个端口、控制寄存器是什么地址。// led.hpp #pragma once #include cstdint namespace board { class Led { public: // portBase 是 GPIO 控制器基地址例如 0x40011000 代表 GPIOC explicit Led(uint32_t portBase, uint16_t pin); void on(); // 点亮 void off(); // 熄灭 void toggle(); // 翻转 private: uint32_t portBase_; uint16_t pin_; uint16_t mask_; // 1U pin避免每次计算 }; } // namespace board头文件负责接口声明实现放在cpp文件里。这里先问一个问题为什么我不用模板不用继承不用虚函数而是只用一个最简单的类因为第一版代码最重要的是能预测行为。当你还在学习一个平台的底层行为时过度设计会让调试变得很痛苦。模板和虚函数不是不好而是它们解决的是重复性和多态性问题而我们现在要解决的是可读性问题。一个正常工程师在接到一个新硬件平台时第一版封装的正确姿势就是这种直白到无可再减的形式。3.2 构造函数的现场既是封装也是初始化类编好了接下来是真东西。构造函数据要做两件事计算出常用寄存器地址偏移以及配置这个引脚为通用推挽输出。理论上这两个动作应该在函数内部完成。// led.cpp #include led.hpp namespace board { namespace reg { // STM32F103 的 GPIO 外设基地址与关键寄存器偏移 constexpr uint32_t GPIO_CRL_OFFSET 0x00; constexpr uint32_t GPIO_ODR_OFFSET 0x0C; constexpr uint32_t GPIO_BSRR_OFFSET 0x10; constexpr uint32_t GPIO_BRR_OFFSET 0x14; } // namespace reg Led::Led(uint32_t portBase, uint16_t pin) : portBase_(portBase), pin_(pin), mask_(1U pin) { // 配置为推挽输出最大速度50MHz bool isHighPin (pin_ 8); uint32_t regOffset isHighPin ? 0x04 : 0x00; // CRH 或 CRL uint32_t shift (pin_ % 8) * 4; volatile uint32_t* crReg reinterpret_castvolatile uint32_t*(portBase_ regOffset); // 先清掉该引脚对应的4位配置CNF[1:0] MODE[1:0] *crReg ~(0x0FU shift); // 写入 0b00 10即通用推挽输出、最大速度50MHz *crReg | (0x03U shift); // 初始输出高电平即默认熄灭针对常见的低电平点亮LED off(); } void Led::on() { volatile uint32_t* brr reinterpret_castvolatile uint32_t*(portBase_ reg::GPIO_BRR_OFFSET); *brr mask_; } void Led::off() { volatile uint32_t* bsrr reinterpret_castvolatile uint32_t*(portBase_ reg::GPIO_BSRR_OFFSET); *bsrr mask_; } void Led::toggle() { volatile uint32_t* odr reinterpret_castvolatile uint32_t*(portBase_ reg::GPIO_ODR_OFFSET); *odr ^ mask_; } } // namespace board这个构造函数值得细看它把初始化引脚的动作放在了实例化对象的那一刻这是C对嵌入式最自然的一个恩惠。以后你写一个Led led(GPIOC_BASE, 13)这一行的行为就是把GPIO配置好。如果你用的是C语言你得记得在某处调用Led_Init()忘了也不会报错然后灯不亮你还得排查半天。构造函数在语法层面帮你消灭了这类忘记初始化的bug这是语言特性对编程行为的约束力。toggle()不用BSRR/BRR而直接用ODR是因为翻转操作天然依赖读出当前状态再异或ODR正是干这个的。读改写的过程如果这个寄存器不在临界区裸机单线程场景一点问题没有如果以后你在RTOS里操作这个LED就必须考虑并发了至少加个临界区保护。3.3 写main.cpp从这一行开始C真正接管有了LED类入口文件变得非常简单。// main.cpp #include led.hpp // 极简系统时钟初始化。完整实现请替换为 STM32 标准库提供的 system_stm32f10x.c extern C void SystemInit(void) { // 保持默认系统时钟使用内部8MHz HSI不切换PLL } void spinDelay(volatile uint32_t count) { while (count--) { __asm volatile (nop); } } int main() { // 使能 GPIOC 时钟F103的GPIOC挂在APB2上 constexpr uint32_t RCC_APB2ENR 0x40021018U; constexpr uint32_t IOPC_ENABLE_BIT (1U 4); *reinterpret_castvolatile uint32_t*(RCC_APB2ENR) | IOPC_ENABLE_BIT; board::Led led(0x40011000U, 13U); // 板载LED很多F103板子接在PC13 while (true) { led.toggle(); spinDelay(900000); } }注意我刻意没写复杂时钟树直接用内部HSI 8MHz。这个场景下LED闪烁和GPIO翻转根本不需要PLL跑到72MHz省去十几行时钟配置代码反而让读者聚焦于C代码本身。如果你希望照抄就能跑请确认你板子上LED是不是接在PC13。不是的话改一下第二个参数就行。spinDelay是占空式延时它并不精确但能看清闪烁效果。对于功能验证足够但如果要做真正的时间调度我后面会专门讲SysTick。这里先不过度设计。3.4 编译前的基础配置CubeIDE里怎么建一个真正的C工程如果你用STM32CubeIDE操作路径其实很简单。新建工程时在Target和Toolchain页面选择Empty Project不要选带HAL模板那种。然后让你项目里的main.c改名成main.cpp或者干脆新建一个main.cpp把汇编启动文件、链接脚本放进去。这里要特别提醒CubeIDE虽然支持C但工程里编译选项默认可能没开启-fno-rtti和-fno-exceptions。我们在MCU上不需要这两个特性-fno-exceptions更是能让编译器省去很多异常处理表减小Flash占用。在项目属性里找到C编译器设置加上-fno-exceptions -fno-rtti -fno-threadsafe-statics -fno-use-cxa-atexit这四个选项在单线程MCU上非常实用。-fno-threadsafe-statics的意思是函数内的static局部变量不需要线程安全初始化单核裸机没有多线程省的是每次执行到有static局部变量函数时编译器偷偷插入的guard检查逻辑。简单说就是帮你省几十字节Flash对学习项目无副作用。4. 启动文件和链接脚本C工程真正的门槛在这如果你的板子已经跑起来了、灯在闪了欢迎你回到这一节——因为接下来讲的是很多模板教程不会展开的部分。但如果你的工程编译报了一堆undefined reference那这一节就是救命的部分。4.1 Reset_Handler的隐藏任务调用__libc_init_arrayC工程和C工程在启动流程上最大的差异在于Reset_Handler里面多了一个调用__libc_init_array。这个函数是GCC工具链提供的它的职责是遍历编译器生成的一个构造函数指针数组依次调用每个全局对象的构造函数。标准启动文件汇编里会有类似这样的片段bl SystemInit bl __libc_init_array bl main很多第三方模板或手工精简启动文件为了简化直接写成bl SystemInit bl main这在C语言工程里完全没问题但一旦你的main.cpp里出现了全局对象那么这个对象的构造函数就不会被执行。灯不亮、串口不输出而你检查代码怎么都找不出原因最后发现是启动文件少了一行。所以动手移植C工程的第一件事确认启动文件里确实调用了__libc_init_array。如果你用的是CubeIDE生成的官方启动文件这件事已经被处理好了。但被处理好了不等于你不用知道。因为当你换芯片、换启动文件的时候这个坑迟早会来找你。4.2 链接脚本里的.init_array段__libc_init_array遍历的构造函数数组在链接脚本里对应.init_array段。一个典型的链接脚本片段长这样.init_array : { . ALIGN(4); __init_array_start .; KEEP (*(SORT(.init_array.*))) KEEP (*(.init_array)) __init_array_end .; } FLASHKEEP在这里很关键。如果你的链接脚本没有把.init_array段放进一个可执行段里链接器就会认为这些数据是没用的而直接丢弃后果同样是全局对象不构造。嵌入式C移植过程中很多莫名其妙的现象仔细查到最后都是KEEP丢了或者段定位不对。回头看这个系列的第三篇我们聊过链接器在做什么当时可能觉得离自己很远现在你看到了它直接决定你的C程序是不是一个真正的C程序。这就是为什么我不让你一上来就写代码的原因。很多东西你写代码时不会遇到出了问题才遇到然后你根本不知道问题在哪。现在你至少知道要去检查哪里了。4.3 编译器是如何处理构造函数和析构函数的GCC在处理C全局对象时会把对象地址和构造函数指针放进.init_array。等程序启动时__libc_init_array逐个调用构造函数。析构函数的调用则被交给了atexit机制由程序的退出逻辑触发。但对嵌入式裸机程序来说main函数永不返回析构函数实际上没有机会执行。这就是为什么我可以放心使用-fno-use-cxa-atexit。关闭这个选项后编译器不再为每个全局对象登记atexit条目可以节省RAM代价是析构函数不会被执行。在一个永远不退出main的MCU程序里这个代价几乎可以忽略。普通桌面程序不能这么干但嵌入式程序可以这叫场景驱动技术选型。5. 实测阶段三个我能保证你会踩的坑代码写得再顺下载进板子的时候也一定会有意想不到的事情发生。这里分享三个我在实际调试中反复见到的、和C风格有直接关系的坑。5.1 第一个坑volatile丢了灯就是不亮很多从寄存器操作转过来的同学在封装寄存器时喜欢这么写uint32_t* brr reinterpret_castuint32_t*(portBase_ 0x14);不写volatile。结果是什么编译器优化时发现这个地址从来没有被真正使用过或者连续两次写入完全相同它可能直接把写操作优化掉或者把循环里无关的读写都挪动位置。你的代码看着没问题甚至下载进去偶尔还能动一下但程序行为完全不确定。外设寄存器的本质是内存映射IO硬件会因软件写入而改变引脚电平所以必须通过volatile告诉编译器每次访问都必须真实发生不能缓存、不能合并、不能重排。我写的每一处寄存器指针都带上了volatile uint32_t*这不是代码洁癖是嵌入式访问外设寄存器的基本纪律。这一条不遵守后面写任何外设驱动都会产生幽灵bug。5.2 第二个坑链接报错undefined reference别慌是对症的药如果把工程从C换成C或者把某个.c文件改名为.cpp链接器多半会蹦出几个你从没见过的报错。来对号入座undefined reference to __gxx_personality_v0这是因为编译器看到了C的异常处理相关代码但你链接时没有异常支持库。解决方式编译参数加-fno-exceptions。加了之后这个符号直接从源头消失。undefined reference to _sbrk这个更常见尤其当你一旦使用了printf浮点格式化或者new。GCC默认把malloc的内存分配路径交给了_sbrk而裸机没有操作系统提供这个系统调用。解决方案有两个要么加--specsnosys.specs让系统调用变成空壳要么实现一个精简的_sbrk。学习阶段用前者产品阶段你要认真设计内存池。undefined reference to __cxa_guard_acquire undefined reference to __cxa_guard_release这是函数内静态局部变量初始化需要的线程安全守卫。单核裸机不需要加-fno-threadsafe-statics就能解决。如果将来你在RTOS里写多任务再把这个选项拿掉否则两个任务同时第一次执行这个函数时静态变量初始化就存在竞态条件。这三个链接报错本质上都是C为了运行在桌面操作系统上而设计的机制要去适配裸机环境产生的矛盾。它们在嵌入式上不是特性是负担所以关掉它们是正确的优化。5.3 第三个坑C头文件和extern C的混用时机如果你在C文件里直接包含stm32f10x.h或者包含任何C语言头文件一定要留意编译器对名字修饰的处理。C编译器会对函数名做name mangling而C编译器不会。如果头文件里的函数声明没有用extern C包起来链接时就会报错找不到SystemInit这种目标文件里的原始符号。很多官方头文件已经帮你在最外层处理了extern C像CMSIS头文件里就有大量的条件编译。但如果你自己写的C模块头文件没有这个处理在C里包含时就要手动包一层。否则你看到的典型现象是编译通过链接一堆找不到符号因为C那边要找的是SystemInitiii这种经过修饰的名字而汇编启动文件里导出的是原始名字SystemInit。老话一句C的文件里include C头文件时多问一句这个头文件有没有extern C保护5.4 烧录验证证明你的第一行C确实生效了把代码下载到板子最常见的现象有两种灯完全没反映或者灯在闪。灯在闪不用多说直接恭喜你。但如果灯完全不反映建议按这个顺序排查一看时钟使能有没有写对GPIOC在APB2上使能位是RCC_APB2ENR的第4位二看引脚号对不对PC13是13PB0是0焊错就认命三看极性如果你的板载LED是高电平点亮而我的代码默认低电平点亮你看到的将是LED常灭或者常亮。上面代码用的是低电平点亮这个最常见极性因为绝大多数F103开发板的板载LED都是这个接法。如果在接线没问题的情况下toggle()总是不翻转那一定是第5.1节说的volatile丢了的症状。别怀疑是代码逻辑问题就是优化把读写吞了。6. 灯闪起来了下一步喂饱你的动手欲第一行C代码在板子上跑起来之后你会有一个非常宝贵的感受原来C的封装在嵌入式里是可以如此自然的。LED类本质上是把一堆寄存器读写约束成一个有清晰接口的对象这让上层代码的表达能力提升了一个台阶。但LED只是起点真正发挥C效力的是后面的路。一个我最推荐走的路线是把这个LED类改成模板版本。你可能会问这不是过度设计吗不是模板在这里解决的是一个新的现实问题如果板子改了LED接到了另外的端口和引脚你的类需要重新实例化吗理论上不需要。模板化之后端口基地址和引脚号成了编译期常量编译器甚至能把Led::on()直接内联成一条str指令零成本抽象的魅力就在这里体现template uint32_t PORT_BASE, uint16_t PIN class LedT { public: static void on() { volatile uint32_t* brr reinterpret_castvolatile uint32_t*(PORT_BASE 0x14); *brr (1U PIN); } static void off() { volatile uint32_t* bsrr reinterpret_castvolatile uint32_t*(PORT_BASE 0x10); *bsrr (1U PIN); } };这个版本把引脚配置编译期固定死运行时连成员变量都不需要也因此没有任何RAM开销。如果你对比一下裸寄存器调用和这个模板类的调用机器码几乎一样但可读性完全是两个世界。这就是C给嵌入式带来的最实在的红利。再往前一步可以用同样的思路封装SPI、I2C、UART。把一个外设控制器抽象成类构造函数完成时钟和引脚配置成员函数完成传输事务析构函数负责释放资源。等你把当时学C语言时用结构体和函数硬凑的驱动改成C类之后你会明显感受到内聚这两个字在代码里的实际重量。当然我不建议你盲目把一个原本稳定的标准库HAL工程全部推倒重写。嵌入式工程里稳定性大于一切。最务实的路线是新的模块用C写旧的C模块保留对外接口不变中间用extern C衔接。让C的优势在增量代码里慢慢体现而不是来一场伤筋动骨的大重构。最后如果你想验证自己真的理解了这一篇的内容做一个小练习在不修改main.cpp的前提下把LED从PC13换到PA5代码能改动的最小范围是几行如果答案不是只有Led构造那一行和时钟使能那一位说明封装的边界还没想清楚。想清楚了你这一篇的收获就算真正到手了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑