“看了三篇了一行都没让我写呢”——这句吐槽我看见了而且不止一个读者提过类似的话。确实前面几篇又是配环境、又是讲工程结构、又是聊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点亮一颗LED3.1 读懂寄存器点灯就不是玄学很多教程直接给你库函数或者HAL库的代码那确实能亮但你对芯片为什么这么写还是一头雾水。我自己带人做项目的经验是能点亮LED是其次搞清楚为什么这些配置就能让灯亮才是真正入门。所以这一节我们选择操作寄存器的方式。先搞清楚几个关键地址。STM32F103系列的外设都挂在ARM Cortex-M3的总线上每个外设有一块独立的地址空间外设基址常用寄存器地址RCC0x40021000APB2外设时钟使能寄存器基址 0x18GPIOA0x40010800配置寄存器低32位CRL基址 0x00GPIOA0x40010800数据输出寄存器ODR基址 0x0CGPIOC0x40011000配置寄存器高32位CRH基址 0x04GPIOC0x40011000数据输入寄存器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对应CNF00MODE11合起来就是0x3。PC13要作为浮空输入它在GPIOC的CRH寄存器里因为是第13脚对应的4位在bit[23:20]输入模式对应CNF01MODE00合起来是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_castvolatile std::uint32_t*(addr) value; } inline std::uint32_t readReg(std::uint32_t addr) { return *reinterpret_castvolatile 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_caststd::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 Cextern 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使能位是否置15. 让代码活起来按键控制LED状态5.1 需求分析和按键输入初始化点灯本身没什么实用价值它最大的价值是验证你对GPIO输出的控制。下面加一个输入控制。需求很简单按键按下LED状态翻转按键释放什么都不干。看起来很简单但其实暗藏两个小坑按键抖动和输入电平逻辑。参考这篇开头提到的“stm32超声波测距”“stm32定时器模式”这类扩展场景你会发现所有外设应用都是从最基本的引脚状态读取开始的。按键按下通常对应低电平也就是引脚接地释放是高电平内部上拉或者外部上拉。我们在前面已经把PC13配置成了浮空输入如果开发板外部没有上拉电阻按键释放时引脚电平是悬空的读到什么值完全不确定。稳妥的做法是配置成上拉输入也就是CNF10带上拉/下拉默认状态是高电平按下变低。修改PC13的配置modifyReg(reg::GPIOC_CRH, 0xFU 20, 0x8U 20); // CNF10, MODE00顺手把这个按键操作也封装成类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次读到的结果一致才判定为一次有效的按键动作。每次调用之间间隔大约10ms5次就是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换成呼吸灯效果把按键改成双击判断玩明白了下一篇咱们上定时器的时候你会觉得比第一次点灯从容得多。