看到后台那条“看了三篇了一行都没让我写呢”的时候我直接笑出声。换位思考确实憋屈明明标题是“STM32嵌入式C编程之旅”结果前几篇一直在聊“为什么要在单片机上用C”“怎么选工具链”“类、模板、命名空间到底会带来什么负担”手上始终没有一块能“动”的板子。这篇我不打算再做任何铺垫目标只有一个——给你一份能从零复制到开发板上、编译下载后能看到灯亮起来的STM32 C工程并且把里面最容易翻车的几个坑全部拆开讲清楚。这篇内容适合两类人。一类是从C语言点灯过来的老手想看看C在裸机上到底能不能写出干净、可维护的代码另一类是刚开始接触嵌入式、但已经学过一点C的同学想直接上手STM32。我会尽量让你不需要去翻几十页参考手册也能把代码跑起来。当然前提是你已经装好了编译器、烧录工具和必要的驱动如果环境还没准备好建议先回过头去看本系列的第三篇工具链那部分。顺便说一句我知道你很想跳过“理论”直接看到main函数。但嵌入式C和PC上的C有本质区别MCU上没有操作系统替你打理C运行时环境所以启动文件、中断向量、全局对象构造这些事都必须由你自己处理。今天这篇正好把这些坑一个个踩给你看。1. 为什么我熬了三篇才肯给你第一行代码1.1 MCU上没有“C运行时”替你干活很多刚开始从C转C的人会下意识地把STM32工程当成一个小型Linux程序来写新建一个main.cpp随手定义一个全局对象然后以为它在main之前就会被构造好。在PC上确实是这样操作系统加载程序时C运行时库会执行一堆初始化工作包括遍历全局构造函数表。但在单片机裸机环境里这些“默认设置”统统不存在。C语言的启动流程很简单复制数据段、清空BSS段、调用main。对C来说你还要额外做一件事调用__libc_init_array来执行所有全局对象和静态对象的构造函数。很多芯片厂商提供的startup文件是C语言时代的产物根本没有这一步。如果你不自己补上代码照样能编译、能下载、能复位但那些该执行构造函数的对象一个都不会执行。更麻烦的是中断向量表。C成员函数没法直接作为中断回调函数填进向量表里因为ARM处理器在跳进中断处理函数时并不会给你传递this指针而成员函数的调用约定又必须拿到this才能访问对象里的普通成员。网上很多C嵌入式教程会直接写一个xxx_IRQHandler成员函数那只是看起来像函数封装背后实际上还是要用C链接的静态包装函数绕一层。这些坑如果开篇不讲直接甩给你一个C点灯工程你编译下载之后大概率是灯不亮、调试乱跑、全局对象不初始化。你以为是自己的代码问题折腾半天发现是运行环境的锅。所以我用了三篇时间把工具链、语言特性和硬件映射关系都打了一遍底。这不算拖延算是给后面真正写代码时少流点眼泪。1.2 前几篇到底铺垫了什么简单盘点一下本系列前三篇覆盖的内容以及它们和今天这篇代码的关系。你不需要现在已经完全掌握但至少要知道当时聊的那些东西不是白聊的。篇目主要内容和今天的关系第1篇为什么要用C写嵌入式类封装寄存器、constexpr代替宏、代码可复用性今天GPIO类的设计思路就从这里来第2篇工具链选择Keil MDK vs ARM GCC、调试器、工程结构对比今天给出的编译命令基于命令行/CMake理解后可以迁移到Keil第3篇C在MCU上的特殊语法点volatile、位运算、命名空间、内存模型今天代码里volatile和命名空间会直接用到第4篇中断、定时器、非阻塞编程的基本概念今天的定时器中断章节就是落地所以说前面的内容更像是一张地图告诉你“哪里有坑、为什么有坑”。今天这篇则正式开始“按地图走一遍”而且我会把地图上最容易拌脚的那块石头单独拎出来讲就是后面第4章要说的全局对象初始化。2. 第一份能编译下载的C工程寄存器点灯2.1 最小工程长什么样先看一个能跑通的最简目录结构。这一步不为别的就想让你明白STM32 C工程不是把main.cpp丢进IDE就完了它至少需要启动文件、链接脚本和源码三部分。很多初学者听到“启动文件”就头大实际上绝大多数情况下芯片固件包里都有现成的你只需要知道它做了什么、在哪里改一行就够了。blinky/ ├── startup/ │ ├── startup_stm32f103xb.s # 启动文件需修改后面会说 │ └── system_stm32f103xb.c # 芯片时钟系统初始化可以不写逻辑留空 ├── src/ │ ├── main.cpp │ ├── gpio.h │ └── rcc.h ├── link.ld # GCC链接脚本 └── Makefile这里我用的是STM32F103C8T6也就是最常见的“蓝色Pill”开发板。选它的原因是寄存器手册资料遍地都是哪怕你手上是F401或者F407后面这套代码改起来也只是基地址和时钟树的问题逻辑完全一样。启动文件可以在STM32CubeF1固件包里找到也可以用我在工程里改过的GCC版本。关键点在于必须在Reset_Handler里、跳转到main之前的某一个位置加上bl __libc_init_array这条调用。2.2 从寄存器映射开始封装我不建议一开始就套HAL库因为HAL库虽然省事但它在C语言层面为你隐藏了太多细节。直接用寄存器操作你才能理解C的封装到底在封装什么。先看RCC时钟使能。STM32F103的RCC模块基地址是0x40021000APB2外设时钟使能寄存器在偏移0x18。GPIOC对应的位是第4位。写成C我们通常用一个命名空间加一个类// rcc.h #pragma once #include cstdint namespace board { static constexpr uint32_t kRccBase 0x40021000UL; static constexpr uint32_t kGpioCBase 0x40011000UL; class Rcc { public: void enableGpioC() { volatile uint32_t* apb2enr reinterpret_castvolatile uint32_t*(kRccBase 0x18); *apb2enr | (1U 4U); } }; } // namespace board然后写GPIO类。F103的每个GPIO端口控制引脚模式的寄存器有CRL和CRH0到7号引脚用CRL8到15号引脚用CRH。每个引脚占用4位其中低两位是模式输入、输出10MHz、输出2MHz、输出50MHz高两位是配置位。点灯不需要推挽复用直接把模式设为通用推挽输出就是0b0011也就是3。// gpio.h #pragma once #include cstdint namespace board { class Gpio { public: enum class Mode : uint32_t { kInput 0x0, kOutput10MHz 0x1, kOutput2MHz 0x2, kOutput50MHz 0x3, }; Gpio(uint32_t port_base, uint32_t pin, Mode mode, bool initial false) : port_base_(port_base), pin_(pin) { if (pin_ 8U) { volatile uint32_t* crh reinterpret_castvolatile uint32_t*(port_base_ 0x04); uint32_t offset (pin_ - 8U) * 4U; *crh ~(0xFUL offset); *crh | (static_castuint32_t(mode) offset); } else { volatile uint32_t* crl reinterpret_castvolatile uint32_t*(port_base_ 0x00); uint32_t offset pin_ * 4U; *crl ~(0xFUL offset); *crl | (static_castuint32_t(mode) offset); } set(initial); } void set(bool high) const { volatile uint32_t* bsrr reinterpret_castvolatile uint32_t*(port_base_ 0x10); uint32_t bit 1U pin_; *bsrr high ? bit : (bit 16U); } void toggle() const { volatile uint32_t* odr reinterpret_castvolatile uint32_t*(port_base_ 0x0C); *odr ^ (1U pin_); } private: const uint32_t port_base_; const uint32_t pin_; }; } // namespace board这段代码里的构造函数能设置引脚模式和初始电平set()用BSRR寄存器置位或复位toggle()用ODR寄存器异或翻转。用const uint32_t保存基地址和引脚号是为了让整个对象的配置在构造之后不可变这种设计在嵌入式驱动里很常用一个GPIO对象一旦绑定物理引脚就不允许中途改绑。主文件很简单// main.cpp #include gpio.h #include rcc.h int main() { board::Rcc rcc; rcc.enableGpioC(); board::Gpio led(board::kGpioCBase, 13, board::Gpio::Mode::kOutput50MHz, false); led.set(true); while (true) { } }这里我故意把Gpio led写成了main函数里的局部对象而不是全局对象。原因后面第4章会解释反正开局先绕开“全局对象构造”这个坑让程序先能跑起来。编译下载后PC13引脚应该输出高电平LED常亮。2.3 编译与烧录你不一定需要IDE很多人习惯Keil但Keil对C的支持虽然能用界面里一些工程配置却容易让人困惑。我更推荐用VS Code加ARM GCC这套组合至少你能看清楚每一个编译选项做了什么。比如在工程根目录执行这样的命令arm-none-eabi-g -mcpucortex-m3 -mthumb -Os \ -fno-exceptions -fno-rtti -fno-threadsafe-statics \ -ffunction-sections -fdata-sections \ -Wall \ -T link.ld -nostartfiles \ startup/startup_stm32f103xb.s src/main.cpp \ -o blinky.elf arm-none-eabi-objcopy -O ihex blinky.elf blinky.hex st-flash write blinky.bin 0x08000000解释几个关键选项。-nostartfiles的意思是“不要链接编译器自带的启动文件”因为我们自己提供了startup_stm32f103xb.s这个启动文件负责CPU复位后的初始化。-fno-exceptions -fno-rtti是为了砍掉C异常和运行时类型识别这两个功能在Cortex-M3这种小内存单片机上代价太高而且裸机代码通常也用不到。-fno-threadsafe-statics是告诉GCC函数里的局部静态变量不需要线程安全保护单核裸机不存在多线程竞争可以省下一堆库函数依赖。烧录时可以用st-flash write配合ST-Link也可以用STM32 ST-LINK Utility直接下载hex。第一次下载完记得复位一下板子让程序从头开始执行。如果灯没亮先别急着怀疑代码优先查一下启动文件头部的栈指针初始化是否正确这个问题我在群里见人踩过很多次。2.4 为什么寄存器指针必须加volatile这里值得单独说一句所有指向寄存器的指针都必须用volatile修饰否则遇到编译器优化就是灾难。你可能已经注意到我代码里到处是volatile uint32_t*不是写得顺手是被坑出来教训。举个例子如果写*reg 1; *reg 1;在高优化等级下编译器会认为这是一次“无效重复写入”直接合并成一次赋值。对普通RAM变量来说没问题但对硬件寄存器来说可能每写入一次就会触发一次外设动作有的外设就是靠写边沿启动的你少写一次功能直接失效。所以嵌入式C的第一守则就是访问外设寄存器永远要用volatile并且最好把它写进自己的寄存器映射类里而不是让业务代码直接操作裸指针。3. 摆脱Delay用定时器中断让灯自己闪3.1 为什么别用软件延时点灯之后接下来自然是让灯闪烁。新手第一反应是写一个delay循环void delay(volatile uint32_t count) { while (count--) {} }这种写法能闪但很粗糙。CPU在延时期间完全空转来了中断也得等延时结束才能响应如果你以后要同时处理按键、串口、传感器delay会让整个系统变成“一卡一卡”的。另外延时时间受主频和编译器优化影响很大今天调好明天换个板子又变了。所以我建议你从第一次写闪烁程序开始就养成用硬件定时器的习惯。硬件定时器相当于一个独立的倒计时器。你设置好“初始值”和“分频系数”它就在后台自己倒数倒到0了就会走到更新事件置位一个标志甚至触发中断。CPU完全不用管它等于多了一个不用睡觉的闹钟。3.2 一个最小的Timer类F103系列最常用的通用定时器之一是TIM2基地址在0x40000000。要让它工作我们要做三件事打开TIM2的时钟、设置预分频器PSC和自动重载值ARR然后打开更新中断。先看寄存器操作版本// timer.h #pragma once #include cstdint namespace board { class Timer { public: void begin(uint32_t psc, uint32_t arr) { volatile uint32_t* rcc_apb1enr reinterpret_castvolatile uint32_t*(0x40021000U 0x1C); *rcc_apb1enr | (1U 0U); // TIM2 clock enable volatile uint32_t* tim2 reinterpret_castvolatile uint32_t*(0x40000000U); tim2[0x00 / 4U] 0; // CR1 tim2[0x04 / 4U] 0; // CR2 tim2[0x0C / 4U] 0; // DIER tim2[0x10 / 4U] 0; // SR tim2[0x14 / 4U] 0; // EGR tim2[0x28 / 4U] psc - 1; // PSC tim2[0x2C / 4U] arr - 1; // ARR tim2[0x14 / 4U] 1; // EGR: set update event tim2[0x00 / 4U] 1; // CR1: counter enable } void enableUpdateInterrupt() { volatile uint32_t* tim2 reinterpret_castvolatile uint32_t*(0x40000000U); tim2[0x0C / 4U] | 1U; // DIER: update interrupt enable } void clearUpdateFlag() { volatile uint32_t* tim2 reinterpret_castvolatile uint32_t*(0x40000000U); tim2[0x10 / 4U] 0; // SR: clear update flag } void bindInstance(Timer* instance) { active_instance_ instance; } static Timer* activeInstance() { return active_instance_; } private: static Timer* active_instance_; }; Timer* Timer::active_instance_ nullptr; } // namespace board你可能会问为什么PSC和ARR要减1因为预分频器和自动重载寄存器的实际分频因子是“寄存器值1”。假设系统时钟是8MHz内部时钟那么begin(8000, 500)的含义是先用8000分频得到一个1kHz的时钟再数500个数产生一次更新事件所以中断周期就是500ms。如果你用的板子已经配置成72MHz就得自己重新算系数公式永远是中断周期 时钟频率 / PSC / ARR。3.3 中断回调绕开“成员函数进不了向量表”的老问题定时器中断产生后CPU会跳转到中断向量表里TIM2_IRQHandler的位置。这个函数必须用C链接方式导出不能是C成员函数。原因很简单C成员函数的符号会被name mangling改写而且调用时需要传入this指针ARM处理器没义务帮你把这个指针放进寄存器。所以我们用一个static成员函数来承接中断再通过一个static对象指针回调用到具体对象extern C void TIM2_IRQHandler() { board::Timer* timer board::Timer::activeInstance(); if (timer) { timer-clearUpdateFlag(); timer-onPeriodElapsed(); } }于是Timer类里需要一个onPeriodElapsed()方法这个方法在派生类里重写或者在C11里可以放一个std::function回调。不过裸机环境下std::function可能会带来动态分配我建议先用最简单的方案让Timer暴露一个虚函数或者干脆在业务层用if做分发。下面是结合点灯程序的一个完整main.cpp#include gpio.h #include rcc.h #include timer.h board::Gpio led(board::kGpioCBase, 13, board::Gpio::Mode::kOutput50MHz, false); board::Timer timer; extern C void TIM2_IRQHandler() { board::Timer* t board::Timer::activeInstance(); if (t) { t-clearUpdateFlag(); led.toggle(); } } int main() { board::Rcc rcc; rcc.enableGpioC(); timer.begin(8000, 500); timer.enableUpdateInterrupt(); timer.bindInstance(timer); // NVIC Enable TIM2 interrupt volatile uint32_t* iser0 reinterpret_castvolatile uint32_t*(0xE000E100U); *iser0 | (1U 28U); // TIM2_IRQn 28 while (true) { } }注意这里我把led和timer写成了全局对象因为中断服务函数要访问它们。这正好引出了下一章最核心的那块大坑C全局对象的构造函数在裸机上到底谁来执行。如果你现在直接编译运行大概率会发现灯不会闪因为这两个全局对象可能压根没有被构造。4. 嵌入式C最容易翻车的全局初始化问题4.1 现象全局对象“没干活”把上一章的main.cpp编译下载之后常见现象是LED不亮或者LED以复位后的默认电平亮着完全没有定时器翻转的迹象。你检查了寄存器操作觉得没问题你单步调试发现定时器中断确实没有触发。实际上更可能是这样board::Timer timer;这个全局对象的构造函数根本没有运行所以active_instance_还是nullptr中断来了也找不到对象自然什么都没发生。C语言的项目里不会遇到这个问题因为C语言的main之前只需要把全局变量清零不需要“调用全局构造函数”。C编译器会把所有全局对象的构造函数指针放到一个叫.init_array的段里期望启动代码在进入main之前用__libc_init_array函数遍历这个段逐个调用。一旦启动文件没调__libc_init_array你再多的全局对象也都是“看起来存在实际没初始化”的空壳。4.2 启动文件里要补什么如果你用的是GCC工具链和自己写的启动文件找到Reset_Handler它的大致逻辑应该是设置栈指针、调用SystemInit如果有、复制数据段、清BSS段、调用main。你需要在调用main之前插入一段对__libc_init_array的调用顺序很重要必须在数据段拷贝和BSS清零之后否则构造函数使用的全局变量可能是脏数据。参考修改片段如下关键就是那两行ldr/ blxReset_Handler: ldr sp, _estack ldr r0, _start_data ldr r1, _end_data ldr r2, _sdata bl copy_data ldr r0, __libc_init_array blx r0 ldr r0, main blx r0 LoopForever: b LoopForever不同厂家的启动文件里符号名可能略有差异但思路是一样的。Keil MDK用户要注意如果你用ARMCC和分散加载标准C库的__main函数会自动处理这些初始化原理类似不需要你手动改启动文件。但如果你用的是带GCC工具链的VS Code方案上面这步几乎就是必改项。4.3 链接脚本里的.init_array段光在启动文件里调用__libc_init_array还不够链接脚本必须把.init_array段保留下来否则编译器生成的构造指针会被链接器丢弃函数调用了半天也不知道要调用谁。用ARM GCC时链接脚本link.ld的FLASH段里应当包含类似这样的片段.init_array : { PROVIDE_HIDDEN (__init_array_start .); KEEP (*(.init_array*)) PROVIDE_HIDDEN (__init_array_end .); } FLASHKEEP尤其关键因为整个工程如果没有任何被引用的全局对象--gc-sections优化会把这段全部当成垃圾回收掉。而你加了这个段描述之后__libc_init_array才能从__init_array_start到__init_array_end之间拿到一串真实的函数指针。4.4 项目初期的一个务实选择少用全局对象说句实话即使你完全解决了全局构造问题我也建议在项目初期少用非平凡的全局对象。原因有两个第一嵌入式C的全局构造顺序在不同编译单元之间是未定义的也就是所谓的“静态初始化顺序问题”一旦你的A模块要用B模块的全局对象很可能B还没构造完程序直接崩掉第二MCU上的内存和启动时间都很金贵不必要的全局对象会增加启动阶段的负担。更务实的方案是在main()里创建局部对象再把这些局部对象的指针保存到中断可访问的静态变量里。这样构造顺序由你的代码顺序决定永远可控。等工程规模大了、你确实需要“系统级组件”时再去处理全局初始化也不迟。第3章的Timer代码里我已经用了active_instance_这个静态指针来避开全局对象依赖那一版是可以正常工作的。另外一个隐形坑是动态内存。如果你在C代码里写了new或者用了std::vector裸机环境下必须自己提供堆实现比如重写_sbrk。否则链接能过运行时一分配内存就复位。建议在STM32这种资源有限的平台上优先使用静态数组、std::array、std::span这类无堆依赖的结构。C标准库不是原罪动态内存才是。5. 从点灯到可扩展的驱动工程我后来的组织方式5.1 不要让驱动变成“一坨寄存器代码”上面的点灯和定时器代码目的只是让你跑通第一行。但如果继续往下加串口、I2C、传感器还把所有寄存器操作堆在main.cpp里很快就会无法维护。我在把一个demo项目从F103迁移到F401时吃了不少苦头后来总结出一套还算顺手的目录结构分享给你参考app/ ├── drivers/ │ ├── gpio.h │ ├── gpio.cpp │ ├── timer.h │ └── timer.cpp ├── board/ │ ├── board.h // 引脚映射、时钟配置、板级常量 │ └── board.cpp ├── app/ │ ├── blink_task.h // 业务层不直接碰寄存器 │ └── blink_task.cpp ├── core/ │ ├── startup_stm32f103xb.s │ ├── system_stm32f103xb.c │ └── link.ld ├── CMakeLists.txt └── Makefile分层思路很简单drivers层只关心某个外设的寄存器和时序不关心这块板子上哪个引脚连了LEDboard层把具体板子的连接关系用常量或对象表达出来app层才是你的业务逻辑比如“每隔500ms翻转一次”。这样换板子的时候大部分应用层代码不用动只需要改board层的引脚映射和时钟参数。5.2 一个能直接抄的CMake配置如果你打算用VS Code加ARM GCCCMake会比Makefile更好维护一点。下面是一个能用的最小工程配置注意linker script、C标准和你用的芯片型号要对齐。cmake_minimum_required(VERSION 3.16) project(blinky CXX ASM) set(CMAKE_SYSTEM_NAME Generic) set(CMAKE_CXX_COMPILER arm-none-eabi-g) set(CMAKE_ASM_COMPILER arm-none-eabi-gcc) add_executable(blinky.elf core/startup_stm32f103xb.s src/main.cpp src/drivers/gpio.cpp src/drivers/timer.cpp ) target_compile_options(blinky.elf PRIVATE -mcpucortex-m3 -mthumb -stdc17 -fno-exceptions -fno-rtti -fno-threadsafe-statics -ffunction-sections -fdata-sections ) target_link_options(blinky.elf PRIVATE -mcpucortex-m3 -mthumb -T ${CMAKE_SOURCE_DIR}/core/link.ld -Wl,--gc-sections )如果你还是更习惯Keil记得在工程选项里把编译器语言切到GNU C或AC5/AC6对应的C模式并且打开MicroLIB。KEIL的ARMCC编译器也支持C很多老项目就是用它在裸机上跑起来的。但如果你从GCC切换过去注意有些GCC特性在ARMCC下不支持比如变量模板、部分constexpr能力两者对C标准的支持程度有差异。表格总结一下工具链上手难度C标准支持全局构造处理适用场景Keil MDK ARMCC低C11/14部分由其__main自动处理老项目、公司统一需求VS Code ARM GCC中C17/20支持好需自行在启动文件加__libc_init_array想深入理解、跨平台、现代CCLI GCC CMake中高完全可控完全可控自动化构建、CI、个人定制工程5.3 用状态机替代越来越多的if工程继续扩展时你会发现main的while里会堆越来越多的标志位。一个很简单的改善是把灯的亮灭看作一个状态机而不是在主循环里写“如果时间到了就翻转”。比如enum class LedState { kOn, kOff, }; LedState nextState(LedState current) { switch (current) { case LedState::kOn: return LedState::kOff; case LedState::kOff: return LedState::kOn; } return LedState::kOn; }这只是一个很小的例子但思路很重要。等以后接按键、接串口、接传感器状态机可以帮助你把事件和动作解耦C的enum class也比你从前用#define LED_ON 0要安全得多至少编译器能在你做比较时帮你检查类型错误。5.4 给我自己用的一个小技巧先跑“硬件无关”的单元测试最后分享一个我自己后来非常受益的习惯。虽然STM32驱动没法直接在PC上点灯但那些和硬件无关的模块比如按键状态机、协议解析、数据滤波都可以在你的电脑上编译成普通C程序跑单元测试。CMake里只要切换编译器把不需要的驱动文件从工程里拿掉就能把这些业务逻辑用GCC跑在Linux或Windows上。这样很多逻辑bug不用反复烧板子就能提前发现开发效率提升非常明显。这套代码我后来从F103换到F407的时候应用层几乎没改只动了board层和几个驱动类的寄存器基地址。C在嵌入式里真正的优势不是让你炫技写lambda或者模板而是它能在保持接近C语言性能的同时把复杂度收进一个个类的边界里。那句话怎么说来着别再做观众了动手写吧。
