☰
STM32嵌入式C++入门:从零开始点亮LED并用按键控制
2026/10/1 15:27:32 网站建设 项目流程

“看了三篇了,一行都没让我写呢”——这句吐槽我看见了,而且不止一个读者提过类似的话。确实,前面几篇又是配环境、又是讲工程结构、又是聊C++和C的暧昧关系,看着什么都说了,就是没见正经代码。但嵌入式开发跟纯软件不太一样,最前面的几步不铺垫好,后面写代码全是坑。今天这篇不绕弯子,咱们直接上手写代码,目标很明确:用C++在你的STM32上点亮一颗LED,再让按键控制它的亮灭。这篇文章适合已经搭好开发环境、但还没正经写过嵌入式C++代码的朋友,你不需要多深的基础,只要会一点C语法,就能跟下来。

1. 为什么前几篇憋着不让你碰代码

1.1 嵌入式开发的“隐性门槛”不在语法

很多从桌面C++转过来的朋友,拿到一块STM32开发板,第一反应就是打开编辑器写main函数,结果连编译下载都搞不定。这不是你笨,而是嵌入式开发的流程里有一堆纯软件领域看不见的环节:交叉编译工具链、链接脚本、启动文件、芯片型号对应的Flash和RAM地址,还有下载调试器。这些环节里任何一步出错,你的代码写得再漂亮也跑不起来。

我见过太多新手卡在“明明点灯的代码很简单,但怎么弄就是不亮”,最后排查半天发现是芯片包没装全,或者调试器驱动不对。所以前几篇我花了不少篇幅把开发环境、工程模板、启动流程这些东西过了一遍。用个不太恰当但贴切的类比:学开车不能一上来就踩油门,先得知道档位在哪、刹车在哪、仪表盘上哪个灯亮了是什么意思。

1.2 前几篇铺垫的内容,这一篇全都会用到

简单回顾一下前面铺垫了什么,新读者也能接上:

  • 开发环境用的是VSCode加arm-none-eabi-gcc这套工具链,也可以用Keil MDK,核心思路是一致的。如果你还没配好环境,建议回头翻第一篇,把编译器和芯片支持包装好,不然这一篇的代码没法验证。
  • 工程结构上,我们用的是包含启动文件、链接脚本和main函数入口的最小模板。启动文件负责初始化堆栈、拷贝数据段、调用C++全局构造函数,最后跳转到main。链接脚本则告诉编译器代码段放Flash、变量放RAM。
  • C++在嵌入式这块,我们明确过一个基调:不用异常,不碰动态内存分配,RTTI也基本不开。很多桌面C++的“标配”,在单片机上是奢侈甚至危险的。

这些东西平时看着抽象,等你真正写代码、真正遇到问题的时候,才知道它们每一行都是干嘛的。今天这篇,就是把它们全部串起来。

2. 动手前的临门一脚:环境、工程与C++的“脾气”

2.1 环境没问题的话,直接套模板工程

这个系列前面的配置方案,我建议继续用VSCode加arm-none-eabi-gcc,或者Keil MDK5,哪个顺手用哪个,不用纠结。需要确认的是你这块板子的芯片型号。我下面所有的代码都基于STM32F103C8T6这个非常大众的芯片,也就是常见的“蓝丸”板子。如果你用的是其他的STM32型号,外设基址可能会有差异,后面讲到寄存器的地方,你在参考手册上对照一下自己那款的地址就行。

工程模板最好是带这些文件的:startup_stm32f10x_hd.s这类启动文件、STM32F103C8T6_FLASH.ld链接脚本,以及一个空的main.cpp。你要是之前跟着系列文章建过C语言的工程模板,直接用就可以,只需要把文件后缀改成.cpp,编译选项里切到C++。

2.2 C++在STM32上的三条军规

这节算是我个人的经验总结,也是后面写代码大方向背后的原因:

第一条:不开异常,不开RTTI。在只有几十KB RAM的单片机上,异常处理需要额外的栈空间和运行时支持,一次throw的开销可能让你直接HardFault。我们写嵌入式C++,用的是C++的封装、抽象、类型安全这些能力,而不是它那些重量级特性。

第二条:慎用new和delete。不是说绝对不能用,但你得清楚你的堆空间有多大、谁负责回收。在裸机环境下,我很少用动态分配,大部分对象都是静态定义或者栈上定义,生命周期清清楚楚。

第三条:全局对象的构造函数,在main()之前执行。这意味着如果你在某个全局对象构造函数里访问了一个还没开启时钟的外设,程序会直接卡死。后面我们会专门设计代码来规避这个问题。

这三条军规决定了我们写STM32的C++代码时,用的是“轻量、直接、贴硬件”的风格,而不是“重抽象、重框架”的桌面风格。

2.3 准备一个最小的main.cpp

先给一个最小的起点,后面所有代码都基于它:

#include <cstdint> void delay(volatile std::uint32_t count) { while (count--) { // 空转,编译器不会优化掉这个循环 } } int main() { while (1) { delay(1000000); } return 0; }

注意delay参数用了volatile修饰,这是防止编译器优化掉整个空循环,后面我们会写一个更认真的延时,先拿这个凑合用。把这个文件编译下载进去,如果板子上没有现象,那也正常,因为我们还没操作任何外设,程序只是在空转。但这一步能验证整个工具链和下载通道是通的。

3. 第一行代码:用C++点亮一颗LED

3.1 读懂寄存器,点灯就不是玄学

很多教程直接给你库函数或者HAL库的代码,那确实能亮,但你对芯片为什么这么写还是一头雾水。我自己带人做项目的经验是:能点亮LED是其次,搞清楚为什么这些配置就能让灯亮,才是真正入门。所以这一节我们选择操作寄存器的方式。

先搞清楚几个关键地址。STM32F103系列的外设都挂在ARM Cortex-M3的总线上,每个外设有一块独立的地址空间:

外设基址常用寄存器地址
RCC0x40021000APB2外设时钟使能寄存器基址 + 0x18
GPIOA0x40010800配置寄存器低32位CRL基址 + 0x00
GPIOA0x40010800数据输出寄存器ODR基址 + 0x0C
GPIOC0x40011000配置寄存器高32位CRH基址 + 0x04
GPIOC0x40011000数据输入寄存器IDR基址 + 0x08

这些地址在参考手册的存储映射章节都能查到,不需要背,但你要知道怎么查。我们用PA5这颗引脚接LED,用PC13这颗引脚接按键,这是很多开发板的默认接法。

GPIO配置本身分两步:先开时钟,再配置模式。开时钟是为了让GPIO外设通电工作,不使能时钟的话,你对寄存器写啥都没反应,这是新手最容易踩的坑。PA5和PC13分别挂在APB2总线上的IOPA和IOPC位,也就是RCC_APB2ENR寄存器的bit2和bit4。

至于引脚模式,PA5要作为推挽输出,在GPIOA的CRL寄存器里,每个引脚占4位。PA5的这4位在bit[23:20],其中高2位是CNF(配置),低2位是MODE(速度)。推挽输出50MHz对应CNF=00,MODE=11,合起来就是0x3。PC13要作为浮空输入,它在GPIOC的CRH寄存器里,因为是第13脚,对应的4位在bit[23:20],输入模式对应CNF=01,MODE=00,合起来是0x4。

3.2 C的写法,C++的写法:同样一件事,两种境界

先用最传统的C语言方式写一遍:

#include <stdint.h> #define RCC_APB2ENR (*(volatile uint32_t *)0x40021018) #define GPIOA_CRL (*(volatile uint32_t *)0x40010800) #define GPIOA_ODR (*(volatile uint32_t *)0x4001080C) #define GPIOC_CRH (*(volatile uint32_t *)0x40011004) #define GPIOC_IDR (*(volatile uint32_t *)0x40011008) void delay(volatile uint32_t count) { while (count--) {} } int main() { RCC_APB2ENR |= (1u << 2) | (1u << 4); GPIOA_CRL &= ~(0xFu << 20); GPIOA_CRL |= (0x3u << 20); GPIOC_CRH &= ~(0xFu << 20); GPIOC_CRH |= (0x4u << 20); while (1) { GPIOA_ODR &= ~(1u << 5); delay(1000000); GPIOA_ODR |= (1u << 5); delay(1000000); } }

这段代码很直白,也充满了“魔法数字”。0x40021018是什么?20位偏移又是什么?0x3和0x4代表什么意思?第一次看的人一脸懵。而且如果有多个引脚要同时配置,你得复制粘贴一堆宏,时间一长就是一团乱麻。

现在用C++来改写同样的事:

#include <cstdint> namespace reg { constexpr std::uint32_t RCC_APB2ENR = 0x40021018U; constexpr std::uint32_t GPIOA_CRL = 0x40010800U; constexpr std::uint32_t GPIOA_ODR = 0x4001080CU; constexpr std::uint32_t GPIOC_CRH = 0x40011004U; constexpr std::uint32_t GPIOC_IDR = 0x40011008U; } inline void writeReg(std::uint32_t addr, std::uint32_t value) { *reinterpret_cast<volatile std::uint32_t*>(addr) = value; } inline std::uint32_t readReg(std::uint32_t addr) { return *reinterpret_cast<volatile std::uint32_t*>(addr); } inline void modifyReg(std::uint32_t addr, std::uint32_t mask, std::uint32_t value) { writeReg(addr, (readReg(addr) & ~mask) | value); } void initLeds() { modifyReg(reg::RCC_APB2ENR, (1U << 2) | (1U << 4), (1U << 2) | (1U << 4)); modifyReg(reg::GPIOA_CRL, 0xFU << 20, 0x3U << 20); modifyReg(reg::GPIOC_CRH, 0xFU << 20, 0x4U << 20); modifyReg(reg::GPIOA_ODR, 1U << 5, 1U << 5); } int main() { initLeds(); while (1) { modifyReg(reg::GPIOA_ODR, 1U << 5, 0U); delay(1000000); modifyReg(reg::GPIOA_ODR, 1U << 5, 1U << 5); delay(1000000); } }

一眼看过去区别不大是正常的,因为底层本质就是改寄存器。但仔细体会,这里有三个不同的层次:第一,魔法数字被constexpr常量名字化,以后知道0x40010800是GPIOA的基址,不用注释就能看懂;第二,寄存器读写封装出readReg、writeReg、modifyReg三个基础函数,后面写任何外设都是这几个函数的排列组合;第三,用namespace把所有地址约束在一个作用域里,不会污染全局命名空间。

这里特别说一下为什么用constexpr而不是#define宏。constexpr是有作用域、有类型的编译期常量,你可以让编译器帮你检查类型错误。宏只是简单替换,不经过类型检查,而且不知道从哪儿冒出来的宏经常和别人头文件里的宏冲突。在C++里能不用宏,就尽量不用。

3.3 封装一个GPIO类

基础版本跑通以后,就该体现C++的封装能力了。在实际项目里,一个板子上的LED往往不止一个,可能还有几个按键。如果每次都写modifyReg(reg::GPIOA_ODR, 1U << 5, 0U)这种代码,写几次还行,写多了就想吐。

我们抽象出一个最简单的GPIO输出类:

class GpioOutput { public: enum class Mode { OutputPushPull50MHz = 0x3U, InputFloating = 0x4U }; GpioOutput(std::uint32_t crAddr, std::uint32_t odrAddr, std::uint32_t pin, Mode mode) : crAddr_(crAddr), odrAddr_(odrAddr), pinMask_(1U << pin), crShift_(pin * 4U) { std::uint32_t cr = readReg(crAddr_); cr &= ~(0xFU << crShift_); cr |= static_cast<std::uint32_t>(mode) << crShift_; writeReg(crAddr_, cr); } void set() { modifyReg(odrAddr_, pinMask_, pinMask_); } void reset() { modifyReg(odrAddr_, pinMask_, 0U); } void toggle(){ modifyReg(odrAddr_, pinMask_, readReg(odrAddr_) ^ pinMask_); } private: std::uint32_t crAddr_; std::uint32_t odrAddr_; std::uint32_t pinMask_; std::uint32_t crShift_; };

这个类的核心思路是:构造函数完成引脚配置,set/reset/toggle三个方法完成电平操作。以后你只需要写:

GpioOutput led(GPIOA_CRL, GPIOA_ODR, 5, GpioOutput::Mode::OutputPushPull50MHz); led.set(); led.reset(); led.toggle();

代码可读性和复用性好了一大截。还有一个细节值得注意,就是在构造函数里配置完模式以后,并没有操作ODR,所以LED默认状态取决于外部电路,这点我在后面踩坑部分细说。另外,类的成员变量用了pinMask_和crShift_这种名字,避免和局部变量混淆,这是嵌入式代码里的常见习惯,可读性至上。

4. 上板之前,先避开这三个必踩的坑

4.1 第一个坑:链接器找不到main

如果直接把C++文件和原来的C工程混在一起编译,很容易出现类似undefined reference to 'main'的报错。这不是语法问题,而是链接器不知道该把入口函数放在哪里。

原因多半是编译选项里没告诉链接器要链接标准C++库。arm-none-eabi-gcc在编译.cpp文件时默认会带上C++的运行时库,但如果你是用arm-none-eabi-gcc -c编译、arm-none-eabi-gcc链接,链接阶段需要额外加-lstdc++。Keil MDK的话,在Options for Target里的Language/Code Generation勾选ARM Compiler的C++模式,一般会自动带上。

我自己处理这类问题的固定套路是:先编译一个只含main()的空C++文件,确认链接通过,再往里面加外设代码。这样能把“环境问题”和“代码问题”分开排查。

4.2 第二个坑:全局对象构造函数引发的HardFault

这个坑我有发言权。早期我用C++写STM32,图省事把GpioOutput定义成全局对象:

GpioOutput led(GPIOA_CRL, GPIOA_ODR, 5, GpioOutput::Mode::OutputPushPull50MHz);

结果上电直接HardFault,调试器一看,卡在启动文件的某条memcpy上。原因就是前面说的军规第三条:全局对象的构造函数在main()之前执行,而那时候RCC外设时钟还没有打开,构造函数里去操作GPIO寄存器,等于对一个没通电的模块写数据,芯片总线直接报错。

解决方案有两个。第一个是不要在全局对象构造函数里操作硬件,只保存地址和引脚号,延迟到main()里显式调用一个init()方法。第二个更省事,就是把对象定义在main()函数内部,作为局部对象:

int main() { GpioOutput led(GPIOA_CRL, GPIOA_ODR, 5, GpioOutput::Mode::OutputPushPull50MHz); ... }

局部对象在栈上构造,生命周期到函数结束为止,完美避开全局构造时机问题。这是嵌入式C++新手最容易忽略的坑,记住了,能省一个晚上的排查时间。

4.3 第三个坑:C头文件在C++里的“身份”问题

工程里不可能全部用C++,有时候要复用别人写好的C库,比如厂商固件库里的stm32f10x.h。你在C++文件里#include这个头文件后,链接时可能出现各种奇怪问题:函数被声明了,但编译过不了,或者函数名对不上。

因为C和C++对函数符号名的处理不一样。C++有函数重载,编译器会把函数名和参数信息一起编码成新的符号名,而C编译器直接使用原始函数名。所以在C++里包含一个C头文件,必须在头文件外面包一层extern "C":

extern "C" { #include "stm32f10x.h" }

现在大部分厂商头文件里都自带这段兼容代码:

#ifdef __cplusplus extern "C" { #endif ... #ifdef __cplusplus } #endif

但你如果自己写头文件,或者引入一些老旧的第三方库,一定记得手动加上。不然你会在链接阶段看到一堆undefined reference to开头的报错,每个函数名后面还带着乱七八糟的字符,看着头大。

问题现象大概率原因快速验证方法
undefined reference to main链接阶段没带C++运行库单独编译一个空main.cpp测试链接
上电卡死HardFault全局对象构造函数操作了未初始化外设改成局部对象,或延迟到init方法
包含C头文件后链接报错C函数符号与C++不匹配包一层extern "C"
寄存器写进去没反应外设时钟没开启检查RCC使能位是否置1

5. 让代码活起来:按键控制LED状态

5.1 需求分析和按键输入初始化

点灯本身没什么实用价值,它最大的价值是验证你对GPIO输出的控制。下面加一个输入控制。需求很简单:按键按下,LED状态翻转;按键释放,什么都不干。看起来很简单,但其实暗藏两个小坑:按键抖动和输入电平逻辑。

参考这篇开头提到的“stm32超声波测距”“stm32定时器模式”这类扩展场景,你会发现所有外设应用都是从最基本的引脚状态读取开始的。按键按下通常对应低电平,也就是引脚接地;释放是高电平,内部上拉或者外部上拉。我们在前面已经把PC13配置成了浮空输入,如果开发板外部没有上拉电阻,按键释放时引脚电平是悬空的,读到什么值完全不确定。稳妥的做法是配置成上拉输入,也就是CNF=10(带上拉/下拉),默认状态是高电平,按下变低。

修改PC13的配置:

modifyReg(reg::GPIOC_CRH, 0xFU << 20, 0x8U << 20); // CNF=10, MODE=00

顺手把这个按键操作也封装成类:

class GpioInput { public: GpioInput(std::uint32_t crAddr, std::uint32_t idrAddr, std::uint32_t pin) : idrAddr_(idrAddr), pinMask_(1U << pin) { // 假设已经在外部配置好了输入模式 } bool isPressed() const { return (readReg(idrAddr_) & pinMask_) == 0; } private: std::uint32_t idrAddr_; std::uint32_t pinMask_; };

5.2 消抖逻辑与状态切换

按键抖动是机械结构带来的物理现象,按下和释放的瞬间,触点会来回弹跳几毫秒到几十毫秒,对应到程序里就是电平在短时间内跳变多次。如果不做处理,你可能按一次按键,LED连续翻转了好几次。

消抖的思路很简单:检测到电平变化后,等一段时间再确认,确认还是同一个电平,才认为按键真正按下了。这个“等一段时间”如果靠前面那个空转的delay实现,会让程序在消抖期间卡住,体验特别差。但现阶段先用它演示逻辑,后面讲到定时器后再换成不阻塞的消抖方案。

bool detectKeyPress(GpioInput& key) { static bool lastLevel = true; static int stableCnt = 0; bool curLevel = key.isPressed(); if (curLevel == lastLevel) { if (++stableCnt >= 5) { stableCnt = 0; lastLevel = curLevel; if (curLevel == true) { // 确认按下 return true; } } } else { stableCnt = 0; lastLevel = curLevel; } return false; }

这段逻辑用了一个静态变量lastLevel来记录上一次的稳定电平,只有连续5次读到的结果一致,才判定为一次有效的按键动作。每次调用之间间隔大约10ms,5次就是50ms,足够越过机械抖动的时间窗。

主循环就非常简洁了:

int main() { initLeds(); GpioOutput led(GPIOA_CRL, GPIOA_ODR, 5, GpioOutput::Mode::OutputPushPull50MHz); GpioInput key(GPIOC_CRH, GPIOC_IDR, 13); while (1) { if (detectKeyPress(key)) { led.toggle(); } delay(10000); } }

delay(10000)大约对应十几毫秒,既给消抖逻辑提供了采样周期,又没有明显卡顿感。整个程序的运行流程是:上电点灯初始化,然后无限循环读取按键状态,一旦确认按下就翻转LED。这个代码量不大,但它把前面所有概念——寄存器操作、时钟使能、GPIO输入输出、C++封装、消抖——都串了起来。

5.3 后续可以自己扩展的方向

按键控制LED跑通后,你已经算摸到“嵌入式C++编程”的门槛了。后面几个方向是我建议你尝试的延展:

如果你的按键是普通的机械按键,可以试着把消抖逻辑做成一个类,让它适用于任意按键,而不是一个单独的函数。这就是C++封装在实际项目中真正发挥作用的地方——每个按键、每个LED、每个传感器,都对应一个对象,代码的复用性和可维护性比一堆散装函数好太多。

如果你手头有超声波模块或者温湿度传感器,下一步可以试着把数据读取的逻辑封装成类,再通过串口把数据显示出来。到时候你会发现,“基于STM32的嵌入式C++编程”其实就是掌握几个基础外设,然后用C++的语法把它们组合成能用、好看、好维护的代码。

我自己的体会是:从这一篇开始,才算是真正进入了嵌入式C++的世界。前面那些环境配置、工程结构的铺垫,就像学做饭前先认识调料和灶台,你今天终于炒出了第一盘菜。后面每绕一个弯,每踩一个坑,都是进步的过程。写完这段代码,你可以试着把LED换成呼吸灯效果,把按键改成双击判断,玩明白了,下一篇咱们上定时器的时候,你会觉得比第一次点灯从容得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询