尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

STM32实战入门:用C++写下第一行点灯代码

发布时间:2026/9/29 1:51:38

资讯中心
01
ARTICLE

STM32实战入门:用C++写下第一行点灯代码

STM32实战入门:用C++写下第一行点灯代码
这句吐槽我收到了而且得正面回应你说得对。前四篇我一直在讲开发板怎么选、工具链怎么搭、时钟树长什么样、GPIO的推挽和开漏到底有什么区别确实没有让你亲手写过哪怕一行代码。但这篇的目的就一个——让你真正写出第一行能在STM32上跑起来的C代码然后看着板子上的LED按照你的节奏亮起来。这篇不整虚的我们就做三件事把工程环境准备好、写一个点灯的C类、再把它烧进板子看现象。中间我会顺带解释那些必须解释的坑比如为什么CubeMX生成的main.c要改成main.cpp、为什么嵌入式里C的构造函数不是你想象的那个执行顺序、为什么异常和RTTI默认要关掉。看完这篇你不需要再看别的教程照着做就能把这“第一行”落地。适合谁适合已经装好Keil、有一块STM32开发板、前几篇看了但又有点囫囵吞枣的朋友。零基础也能跟下来我不会跳过任何一个关键操作。1. 为什么拖到第五篇才让你写代码1.1 嵌入式写代码的“第一行”门槛很多人觉得编程的第一课就应该是printf(Hello World)但在嵌入式里这条路走不通。你在电脑上写个Python脚本双击能跑是因为操作系统替你包办了内存管理、进程调度、外设驱动。单片机上没有这些东西你写的那行代码要直接面对CPU、寄存器、中断向量表和链接脚本。用一个做菜的类比在电脑上编程相当于你进了后厨什么都给你备好了锅是热的调料是齐的你只管炒。在单片机上编程相当于给你一堆原材料和一个空灶台你得先确认煤气罐接没接、火打不打得着、锅放在哪个位置然后才能开始炒菜。前四篇我做的就是在给你搭灶台、接煤气、备锅。第一篇如果就直接让你写点灯代码你连GPIO引脚在哪、时钟树为什么有SYSCLK和PCLK2这些概念都搞不清楚遇到问题只会瞎猜最终结果大概率是“程序烧进去了灯就是没反应”然后放弃。1.2 前四篇铺垫的是什么前四篇的铺垫其实分成了两条线一条是硬件线一条是工具线。硬件线讲的是STM32的基本结构电源、时钟、复位、GPIO、定时器、串口这些外设分别是什么它们挂在哪些总线上寄存器操作和HAL库封装之间的关系是什么。这些知识不是“理论推演”而是你写代码时每个函数调用背后的实质。比如HAL_GPIO_WritePin这个函数你得知道它的本质是往GPIO的ODR寄存器里写一个位这样你才不会因为“引脚配置成复用还是推挽”这种问题卡两个小时。工具线讲的是开发环境的搭建和使用Keil MDK怎么安装、芯片包怎么装、ST-Link调试器怎么连接、CubeMX怎么生成工程、烧录工具怎么用。这些是纯操作性的内容但你绕不过去因为嵌入式开发就是“工具链 代码 硬件”三者互动的过程。很多人被劝退不是因为代码难而是因为工具链没搞定。前几篇把工具链的雷都给你排了一遍这篇就可以专心写代码了。所以前四篇不是在拖你进度而是在帮你建立“代码是跑在具体硬件上”的思维习惯。这篇终于到了动真格的时候而且我会尽量把每一行的来龙去脉都讲清楚不仅让你写出来还让你知道它是怎么跑起来的。2. 开发环境准备从CubeMX到C工程2.1 CubeMX的四个关键配置要得到第一个可用的C工程还是得先用STM32CubeMX生成一个基础工程。这里我用最常见的STM32F103C8T6和一块带板载LED的普中/正点原子类开发板举例型号不同配置思路完全一样。打开CubeMX选好芯片型号后需要配置下面几项。第一项是RCC在System Core RCC里把High Speed Clock (HSE)和Low Speed Clock (LSE)设置为Crystal/Ceramic Resonator。这样板子上的8MHz外部晶振才会被启用否则芯片只会用内部RC振荡器跑精度和稳定性都有问题。第二项是调试接口在System Core SYS里把Debug选成Serial Wire。这个设置非常关键如果你不选CubeMX默认会把SWD引脚当成普通GPIO初始化导致你烧录一次程序之后ST-Link再也连不上芯片只能通过ISP擦除或按复位键才能恢复。踩过这个坑的人绝对不止我一个。第三项是时钟树在Clock Configuration页面里配置PLL倍频。以F103为例外部8MHz经过PLL Source后通过PLL Multiply设置成9倍得到72MHz的SYSCLK然后再分频给APB1和APB2总线。CubeMX会在你拖动数值后自动检测合法性你只要确认HCLK那一栏显示72MHz就行。第四项是GPIO找到板载LED对应的引脚把它配置为Output Push Pull。我这里假设LED接在PC13上根据板子的原理图可能是PA1、PB0自己对着改。注意的是很多板子LED是低电平点亮所以GPIO的初始电平可以设成High避免上电后LED先亮一下。生成工程前Project Manager里把Toolchain选成MDK-ARM最小堆栈大小建议设0x200以上其他默认就行。点击GENERATE CODE基础工程就出来了。2.2 把main.c改成main.cpp这是本篇文章最关键的一步C和C的编译器处理逻辑不同链接时的符号命名规则也不同。CubeMX生成的是C工程main.c、stm32f1xx_it.c这些文件都是C语言写的。要让C程序跑起来最简洁的做法是把main.c重命名成main.cpp。具体操作是在Keil里进入工程管理界面Manage Project Items把Application/User/Core组里的main.c移除再把这个文件在Windows资源管理器里改名成main.cpp然后回到Keil里把main.cpp添加回同一个组。这一步做完工程里的主文件就已经按C规则编译了。但不建议把其它.c文件也改成.cpp。HAL库源码本身是C语言写的C编译器虽然能编译C代码却可能遇到C关键字冲突等问题。比如class、template这些C关键字如果在C代码里作为变量名出现编译会直接报错。而且HAL库里很多函数是用C语言风格设计的没必要强行改成C编译。改完main.cpp后打开文件看一眼你会发现CubeMX生成的代码里有大段的/* USER CODE BEGIN */和/* USER CODE END */注释块这些是给用户代码预留的保护区。以后你在CubeMX里修改配置重新生成代码时这些区块里的内容不会被覆盖。我们的代码就要写在这些区域里。还有一点要特别留意stm32f1xx_it.c文件里的SysTick_Handler、ADC_IRQHandler这些中断服务函数如果将来要放到.cpp文件里实现必须在函数外面加上extern C保护否则C编译器会对函数名做修饰中断向量表就找不到对应符号了。2.3 Keil编译器选项AC6、MicroLib与C标准打开Keil的Options for Target在Target选项卡里你会看到ARM Compiler的选项默认可能是Use default toolchain对应的是AC5armcc。如果你用的是Keil MDK 5.36以上版本建议直接选用Use latest installed version也就是AC6armclang。AC6跟AC5相比对C11、C14的支持彻底得多模板、lambda表达式、constexpr这些现代C特性都能用这对我们写嵌入式C很有意义。AC5对C的支持停留在老古董水平很多写法会报错。在C/C选项卡的Language Standard里选择GNU11或GNU14别选C03。GNU系列比标准C系列多支持一些GNU扩展比如__attribute__关键字这在链接脚本和启动文件里会用到。还有一个选项是MicroLib在Target选项卡里勾选Use MicroLib。MicroLib是ARM提供的精简版C运行库体积小、占用RAM少非常适合单片机。代价是某些C标准库函数实现得比较粗糙比如printf的浮点输出默认不支持需要额外勾选Use MicroLIB的Floating Point选项或用printf重定向。对咱们点灯程序来说直接用MicroLib是完全没有问题的。同时在C/C选项卡的Misc Controls里建议加上下面这两行它们的作用分别是关闭C异常支持和关闭RTTI-fno-exceptions -fno-rtti为什么默认要关掉这两个东西我后面单独讲。现在先加上省得工程里不小心用到异常特性时编译出一堆看不懂的错误。3. 第一行C代码写一个点灯类3.1 类设计Led需要封装什么嵌入式C和桌面C最大的区别在于我们想要的不是抽象程度极高的类体系而是能直接映射到硬件行为的小而美的封装。点灯这个操作从硬件的角度看就三件事控制引脚的输出电平、读取引脚当前的电平、翻转引脚电平。对应到C语言里就是反复调用HAL_GPIO_WritePin和HAL_GPIO_ReadPin。但这有两个问题。第一个问题是可读性差。当你写了几百行C代码之后看到HAL_GPIO_WritePin(GPIOC, GPIO_PIN_13, GPIO_PIN_SET)你得去查GPIOC和GPIO_PIN_13是谁才知道这是点灯。但如果你写的是led.TurnOn()任何人一看就懂。第二个问题是复用性差。一个板子上可能有电源指示灯、状态灯、报警灯每个灯的引脚和有效电平不同。C语言的做法是复制粘贴一大堆宏定义C的做法是定义一个类每次创建不同对象就行。所以我设计了下面这样一个Led类它要封装的信息有三个引脚所属的GPIO端口、引脚编号、有效电平。有效电平是这个类设计里容易被忽略的地方。有的板子LED接地引脚输出高就亮这是高电平有效有的板子LED接在电源和引脚之间引脚输出低才亮这是低电平有效。同一个类如果不处理这个差异将来换一块板子就要改一堆代码违背了封装的本意。3.2 完整实现与主程序调用我把头文件led.h这样写#pragma once #include stm32f1xx_hal.h class Led { public: Led(GPIO_TypeDef* port, uint16_t pin, bool activeHigh true); void TurnOn(); void TurnOff(); void Toggle(); bool GetState() const; private: GPIO_TypeDef* m_port; uint16_t m_pin; bool m_activeHigh; bool m_state; };实现文件led.cpp这样写#include led.h Led::Led(GPIO_TypeDef* port, uint16_t pin, bool activeHigh) : m_port(port) , m_pin(pin) , m_activeHigh(activeHigh) , m_state(false) { } void Led::TurnOn() { if (m_activeHigh) { HAL_GPIO_WritePin(m_port, m_pin, GPIO_PIN_SET); } else { HAL_GPIO_WritePin(m_port, m_pin, GPIO_PIN_RESET); } m_state true; } void Led::TurnOff() { if (m_activeHigh) { HAL_GPIO_WritePin(m_port, m_pin, GPIO_PIN_RESET); } else { HAL_GPIO_WritePin(m_port, m_pin, GPIO_PIN_SET); } m_state false; } void Led::Toggle() { uint32_t current HAL_GPIO_ReadPin(m_port, m_pin); if (current GPIO_PIN_SET) { HAL_GPIO_WritePin(m_port, m_pin, GPIO_PIN_RESET); m_state false; } else { HAL_GPIO_WritePin(m_port, m_pin, GPIO_PIN_SET); m_state true; } } bool Led::GetState() const { return m_state; }仔细看构造函数你会发现它没有做任何引脚初始化只是把端口、引脚、极性这些参数存到成员变量里。这是刻意为之。初始化的事情交给CubeMX生成的MX_GPIO_Init()去做Led类只负责“操作”不负责“初始化”。这样可以避免构造函数里调用HAL函数带来的一个隐含风险——如果全局Led对象的构造函数在main之前执行而MX_GPIO_Init()还没跑引脚配置就是错误的此时在构造函数里操作GPIO会出大问题。在main.cpp里我这样实例化并使用#include main.h #include led.h // USER CODE BEGIN PV Led statusLed(GPIOC, GPIO_PIN_13, false); // 低电平点亮 // USER CODE END PV int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while (1) { statusLed.TurnOn(); HAL_Delay(200); statusLed.TurnOff(); HAL_Delay(200); } }如果你的板子LED是高电平有效第三个参数用默认值或显式写true就行。这个例子里的GPIOC, GPIO_PIN_13要看你的板子原理图如果是PA1或PB0要自己改。编译烧录之后你应该看到LED以200毫秒的周期闪烁。3.3 “写这一行”背后发生了什么在电脑上写一个int a 1;编译器生成一条指令操作系统帮你分配内存。在单片机上写statusLed.TurnOn()实际发生的事要多得多。TurnOn()函数里调用HAL_GPIO_WritePinHAL库函数的实现是往GPIO端口的BSRR寄存器写入一个值。BSRR是一个32位寄存器高16位是引脚复位控制低16位是引脚置位控制。你写GPIO_PIN_SET到对应位硬件电路就会在下一个时钟沿把引脚电平拉高或拉低。整个过程没有操作系统介入没有虚拟内存CPU直接操作内存映射的寄存器地址。所以你写的这行代码本质上是一个“精确控制硬件电平”的快捷方式。这也解释了为什么嵌入式编程要求你对硬件足够熟悉——你写的一行代码直接决定了某个物理引脚的电平状态而不是产生一个“抽象的变量变化”。这就是嵌入式C和普通C学习路径最大的分岔点。4. 嵌入式C的开关学问异常、RTTI与模板4.1 为什么我把异常关掉在桌面C里try/catch是处理错误的标准手段但在单片机上异常机制默认应该关掉原因有两条。第一异常机制的实现依赖RTTI和栈展开编译器需要为每个可能抛出异常的函数生成额外的处理代码这会显著增加代码体积。举个例子同样一个简单的点灯程序开启异常和RTTI之后编译出来的二进制可能从2KB膨胀到4KB以上这对动辄只有几十KB Flash的单片机来说很奢侈。第二栈展开需要足够的栈空间。STM32F103C8T6的RAM只有20KB默认栈大小在启动文件里通常只有1KB。如果异常在函数调用链深处抛出栈展开过程中需要调用析构函数、释放局部对象栈空间不够的话直接硬件错误。而且异常一旦抛出程序跳到了哪里你很难在调试器里追踪对嵌入式系统来说这种“不可预期”的行为很要命。所以我在编译选项里加了-fno-exceptions。这样做的另一个好处是new运算符失败时不会抛异常而是返回nullptr我们可以在代码里直接判断返回值。这对嵌入式编程反而更友好。4.2 RTTI、模板和体积的取舍RTTI运行时类型信息是dynamic_cast和typeid的支撑。在桌面C里多态类型的识别靠的就是RTTI。但在嵌入式里RTTI意味着每个有虚函数的类都要额外存储类型信息类越多体积越大。我个人的建议是不用dynamic_cast和typeid就把RTTI关掉。如果确实需要判断对象类型更推荐的做法是自己加一个类型枚举字段或者在设计阶段通过接口抽象来避免类型判断。这正是嵌入式C和桌面C思维上的差别用编译期多态和设计模式替代运行期类型识别。模板在嵌入式C里是完全可用的而且我用得还挺多。模板的开销发生在编译期不占用运行时资源这非常适合单片机。比如下面这个寄存器映射的模板类就是嵌入式C里的经典用法template uint32_t regAddr struct Reg { static void Write(uint32_t value) { *(volatile uint32_t*)regAddr value; } static uint32_t Read() { return *(volatile uint32_t*)regAddr; } };用的时候就是RegGPIOB_BASE 0x14::Write(0x00000001)它跟直接操作寄存器生成的汇编指令一模一样没有任何运行时开销。但模板也不能滥用。模板的每一次实例化都会生成一份代码如果你用模板封装了十个不同引脚的LED操作最终生成的代码量可能比一个普通Led类大不少。嵌入式开发里“过度模板化”导致的Flash溢出是个很现实的坑。我的经验是用模板处理“编译期就知道的东西”比如寄存器地址、波特率、引脚号用普通类处理“运行期才决定的东西”比如状态机的状态、通信协议的包长。4.3 AC5和AC6的编译选项对照如果你在用AC5armcc关闭异常和RTTI的选项不是-fno-exceptions而是--no_exceptions和--no_rtti。这两个编译器的选项风格不一样容易踩坑。功能AC5armccAC6armclang关闭异常--no_exceptions-fno-exceptions关闭RTTI--no_rtti-fno-rttiC标准--cpp11-stdgnu14优化选项-O1 -O2 -O3-O1 -O2 -O3我建议能用AC6就用AC6不只是因为C标准支持得更好还因为armclang是LLVM架构错误提示比armcc友好得多。你在网上搜到的一些AC5的老经验不一定能直接套到AC6上这点要小心。5. 烧录、调试、看现象5.1 编译烧录的标准流程先把代码写完然后点Keil的编译按钮。正常情况下0 Error0 Warning。然后接上ST-Link点击Download按钮程序就烧进去了。如果之前烧录时忘记配置Debug选项导致连不上可以按住开发板复位键不放点烧录在下载进度刚开始的时候松开复位通常能救回来。烧录完成后按一下复位键或者断电重新上电程序开始运行。你应该看到LED以200毫秒周期闪烁而不是一上电就常亮或者完全不亮。如果LED不亮先从硬件查起板载LED有没有被跳线帽禁用LED本身是不是坏的这个排查顺序很重要很多新手一上来就怀疑代码其实嵌入式里硬件问题的概率比你想的高得多。5.2 调试器里观察C对象Keil的调试器进去之后按F10单步执行你会看到程序跳进了各种系统初始化函数。这是因为单片机的启动流程是复位向量 - 启动文件 -SystemInit-__main- C库初始化 -main函数。所以在main函数第一行断点之前CPU已经执行了上百行汇编了。在调试器里你可以打开View Watch窗口把statusLed对象添加进去观察。你会看到对象内部有m_port、m_pin、m_activeHigh、m_state这几个成员变量。单步执行TurnOn()观察m_state从false变成true的过程这会让你对“对象在被真实硬件使用”这件事有非常直观的感觉。还可以把断点打在Led的构造函数里。你会惊讶地发现如果statusLed是全局对象构造函数在main函数之前就已经执行了而不是在main里执行到那一行才调用。这正是我前面强调的“全局对象构造时机”问题现场看一次比我说一百次都管用。5.3 一个重要执行顺序问题构造函数到底什么时候跑如果你把Led对象创建成全局变量那么它的构造函数在main函数之前就会被C运行库调用。Keil的启动文件里__main会调用__scatterload完成代码和数据的加载接着调用__rt_entry这个过程中会执行__libc_init_array而__libc_init_array正是负责遍历.init_array段、调用所有全局对象的构造函数。这意味着一个隐患如果你的构造函数里有操作外设的代码比如初始化GPIO、配置定时器但这些外设的初始化通常在main函数里HAL_Init()之后才执行构造顺序就反了。为了解决这个顺序问题嵌入式C里有一个老派但实用的约定不在全局对象的构造函数里做任何硬件初始化。构造函数只保存参数和状态所有对外设的实际操作留到main函数里由对象的方法显式调用。这就是我为Led类构造函数只保存参数、不调用HAL函数的原因看懂这个设计你就真正理解了嵌入式C“初始化顺序”这个核心问题。6. 常见问题速查与踩坑记录下面这些问题是我实际测试和帮朋友排查时遇到过的整理成表格方便你快速定位。6.1 编译阶段问题错误提示可能原因解决办法Undefined symbol SysTick_Handler把中断函数放进了.cpp却没用extern C用extern C包裹中断函数定义expected primary-expression before class某些C库文件被强行改成.cpp只把main.c改成main.cpp其它C文件保持.cCompiler error C290AC5不支持某些C11语法换成AC6编译器cannot open source input file stm32f1xx_hal.h头文件路径没包含在Options for Target C/C Include Paths里添加HAL库路径L6218E: Undefined symbol HAL_GPIO_WritePin链接时没包含HAL库源文件确认stm32f1xx_hal_gpio.c已加入工程组6.2 运行阶段问题现象可能原因解决办法烧录后LED常亮极性设置反了把Led构造函数第三个参数改成相反的布尔值烧录后LED完全无反应GPIO没初始化确认CubeMX里LED引脚已配置为输出程序跑飞卡死在HardFault栈溢出或指针越界调大启动文件里的Stack大小检查数组越界只在复位时LED闪一下然后停住时钟配置有问题确认CubeMX时钟树里HCLK为72MHz而不是默认的8MHzST-Link连不上芯片SWD引脚被占用按住复位键烧录或用ISP工具擦除Flash6.3 我踩过的一个真实教训有一段时间我帮朋友调一个基于STM32F407的板子他把整个工程的所有.c文件都改成了.cpp编译报错一大堆我帮他改回只保留main.c为main.cpp才恢复正常。这个过程中我发现板子烧录后稳压芯片发烫排查后才知道是GPIOC的某个引脚被配置成了高频翻转模式大电流灌进去导致的。从那以后我就养成了一个习惯任何LED或IO操作先量引脚电平再查代码逻辑而不是一上来就埋头改代码。还有一个细节是启用MicroLib之后printf默认不支持浮点。我第一次用C串口打印浮点数时出来的全是乱码单步跟踪发现是库的问题不是串口配置问题。如果你要打印浮点数要么在Options for Target Target里勾选Use MicroLIB的Floating Point要么干脆不用printf改用snprintf配合整数运算拼接字符串。这个坑我帮你踩过了别再踩一次。最后再分享一个很小但很实用的习惯写嵌入式C时尽量把类的头文件做得“自包含”即它自己包含它依赖的所有头文件而不是依赖调用者提前包含。这样你新建一个对象时不需要思考“是否已经包含过GPIO头文件”直接#include led.h就能用。我在真实项目里维护了几十个类似的设备类这个习惯让协作成本降了很多。接下来这个系列我会用同样的节奏把定时器、串口、状态机都写成C类慢慢搭出一套属于你自己的嵌入式C小型框架。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。