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

嵌入式C++实战:STM32点灯工程从Makefile到寄存器全流程

发布时间:2026/9/28 1:07:47

资讯中心
01
ARTICLE

嵌入式C++实战:STM32点灯工程从Makefile到寄存器全流程

嵌入式C++实战:STM32点灯工程从Makefile到寄存器全流程
1. 先认账前四篇确实欠了你一次动手看了三篇了一行都没让我写呢。这条留言我收到了而且确实得认。嵌入式C编程之旅写到第五篇前四篇聊了怎么选开发环境、GCC工具链和Make的本质、C对象模型在单片机上的表现、RAM和ROM为什么这么金贵唯独没有让你们亲手敲下一行能跑在STM32上的代码。今天这篇专程补账不做理论铺陈只做一次完整的从零建工程到板子闪灯。目标明确且唯一——你盯着板子那颗LED会按你的代码节奏跳代码是你刚从编辑器里敲出来的Makefile是你自己写的缺一个环节都算我失败。有人问点灯这么简单的事至于拖四篇吗至于。我见过太多照着教程抄能亮、换个引脚就不会配的同学本质是没搞懂工程里那几个文件在干嘛。但另一面我也承认光说不练就是耍流氓所以这篇把节奏反过来先让代码跑起来跑完再回头复盘背后的原理。你会发现STM32的C开发并没有多神秘它只是比Keil里常见的C工程多了一句让编译器知道你在写C。这篇内容适合谁跟读过前三篇、手头有一块STM32F103C8T6开发板和ST-Link调试器的读者直接往下走。如果你完全零基础只是被标题吸引进来也没关系全篇唯一的硬前提是装好arm-none-eabi-gcc并搞定板子的ST-Link连接这两步我在下面都给了具体命令和救急路线。2. 工具链锁定为什么这篇选VS Code加GCC2.1 该选哪一套最终看的不是名气而是看得见在前几篇里我没有把开发环境一锤定音因为读者底子不一样强行指定一个只会制造迁移成本。但现在要写代码了必须锁死一条路我选VS Code配套arm-none-eabi-gcc理由有三个工具链透明。编译、链接过程中每一行命令都摆在终端里新手能直观看到代码是怎么变成固件的这对学习来说是巨大的优势。跨平台。Windows、Linux、macOS行为一致换机器不换思路。C标准支持激进且干净。后面系列里我们要用类模板、静态断言、constexpr这些东西时不用整天跟旧编译器版本搏斗。STM32CubeIDE对新手其实也不错官方维护、开箱即用缺点是整个构建过程藏得太深你点一下锤子按钮中间发生了什么很难看见出了问题也不知道从哪查。Keil则是老牌IDE的尴尬——C51和MDK的兼容问题、License问题社区里已经不止一个人吐槽过Keil5装完只认C51不认STM32了。直接说结论从这篇开始整个系列的工程都以VS Code加GCC为准其他环境请自行适配。这里要额外说清楚一件事VS Code本身只是一个编辑器真正的编译和烧录全部由命令行完成不依赖任何插件。插件只是让你看代码更舒服别一上来就被环境没配好卡住。2.2 四件套安装与验证按顺序装装完每一条验证一次安装VS Code并安装微软官方C/C扩展用来做语法高亮、跳转和悬停提示。安装arm-none-eabi-gcc。Windows推荐到ARM官方GNU Toolchain页面下载最新版zip解压后把bin目录加进系统PATHLinux直接用包管理器apt install gcc-arm-none-eabi或者macOS的brew install arm-none-eabi-gcc都行。安装ST-Link驱动。Windows下是ST-Link USB Driver不装的话板子插上毫无反应设备管理器里只会出现一个带感叹号的未知设备。烧录工具任选其一stlink-tools提供st-flash命令行ST官方STM32CubeProgrammer提供STM32_Programmer_CLI。我习惯用st-flash命令短演示方便。验证命令只有两条arm-none-eabi-gcc --version st-flash --version两条都能看到版本号说明工具链已经就绪。Windows上如果第一句就报不是内部或外部命令不要急着重装——先检查PATH里bin目录加对没有再开一个新的终端窗口重新读取环境变量。这个不起眼的小问题拦住了很多人。关于STM32芯片包安装这里多说一句在CubeIDE或Keil里你会看到一个导入芯片包的操作很多人的直觉是VS Code路径下也得装一个类似的东西。不需要。VS Code加GCC的路线没有芯片包这个按钮你要做的是把STM32CubeF1固件压缩包解压后工程里按需复制CMSIS目录、启动文件和链接脚本。这种方式比IDE维护一个全局包更可控也更容易看清楚工程到底依赖了什么。3. 工程的地基不是main是这三个文件3.1 一个最小工程的完整文件清单先看布局这次真的一行代码都不多给blink/ ├── Makefile ├── stm32f103c8tx.ld ├── startup_stm32f103xb.s ├── main.cpp ├── led.hpp └── led.cpp其中startup_stm32f103xb.s和stm32f103c8tx.ld这两个文件我的建议是直接复制STM32CubeF1包里的现成版本不要自己从零写。但你要清楚它们各自干了什么否则后面遇到问题根本无从下手。3.2 startup文件上电后的第一段代码芯片复位后CPU从FLASH起始地址取出栈顶指针再从复位向量跳转真正执行的第一段代码就是startup文件里的Reset_Handler。CubeF1包里GCC版本的核心逻辑是这样的Reset_Handler: ldr sp, _estack bl SystemInit bl __libc_init_array bl main b .四行话先把栈顶_estack赋给SP调用SystemInit做系统初始化调用__libc_init_array最后跳进main。如果你用的是C第三行__libc_init_array就是全篇最关键的隐藏角色。它会遍历.init_array段执行所有全局对象的构造函数。很多网上流传的startup文件没有这一行因为它们是为纯C工程准备的纯C没有全局构造需求。你用C写了全局对象就得确保这个函数被调用否则对象虽然存在构造函数不执行成员变量全是不明值程序编译成功但行为诡异。3.3 链接脚本告诉固件往哪里放链接脚本定义的是内存地图。F103C8的典型配置很简单MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 64K RAM (xrw) : ORIGIN 0x20000000, LENGTH 20K } SECTIONS { .isr_vector : { KEEP(*(.isr_vector)) } FLASH .text : { *(.text*) *(.rodata*) } FLASH .data : { *(.data*) } RAM AT FLASH .bss : { *(.bss*) } RAM .init_array : { __init_array_start .; *(.init_array*) __init_array_end .; } FLASH }注意.data那行的AT FLASH它表示变量运行时放在RAM但初始值存储在FLASH里启动代码要负责把这段数据从FLASH搬运到RAM。这个搬运动作通常由startup文件里的__do_global_ctors或C库启动代码完成。这也是为什么链接脚本和startup文件必须配套混用不同来源的脚本经常莫名其妙搬错了地址。F103C8T6是64K FLASH和20K RAM你现在运行的点灯程序占用的资源少得可笑但我们要建立的概念是你写的每一段代码、每一个未初始化全局变量最终都会被链接器塞进这块地图里的某个区域arm-none-eabi-size命令后面会告诉你具体数字。3.4 C工程对C工程提出的两个额外要求第一个是名字改编name mangling。C的符号名经过编译器改编后会带上参数类型和命名空间信息而startup文件里的中断向量表、SystemInit、main这些符号名是按C的规则写的。你定义中断服务函数时如果不用extern C包起来链接阶段就找不到符号或者更隐蔽——向量表指向的地址是改编后的名字调试时一进中断就跳飞。extern C void HardFault_Handler(void) { while (1) {} }第二个是全局构造必须由启动代码执行也就是前面说的__libc_init_array。这两个问题在C工程里根本不存在所以网上绝大多数STM32资料都不会提而它们恰恰是嵌入式C第一批翻车点。现在地基打好了可以写代码了。4. 第一行属于你的C代码让LED听话4.1 先写对象led.hpp与led.cpp我们把LED抽象成一个类端口和引脚在构造时传入之后只对着对象喊亮、灭、翻转不需要知道底层寄存器地址长什么样。// led.hpp #pragma once #include stm32f1xx.h namespace board { class Led { public: Led(GPIO_TypeDef* port, std::uint16_t pin); void on() const; void off() const; void toggle() const; private: GPIO_TypeDef* port_; std::uint16_t pin_; }; }// led.cpp #include led.hpp namespace board { Led::Led(GPIO_TypeDef* port, std::uint16_t pin) : port_(port), pin_(pin) { off(); // 默认熄灭行为明确 } void Led::on() const { // 板载LED通常低电平点亮往BSRR高16位写1会把引脚拉低 port_-BSRR static_caststd::uint32_t(pin_) 16; } void Led::off() const { // 往BSRR低16位写1会把引脚拉高 port_-BSRR pin_; } void Led::toggle() const { // ODR支持读改写直接异或翻转 port_-ODR ^ pin_; } }这里有个嵌入式特有的小细节要讲清楚BSRR是一个写寄存器低16位写1对应置位高16位写1对应复位。所以on()不是往BSRR的低16位写而是往高16位写因为很多开发板上的LED是低电平导通的。这就是为什么你不能把LED亮和输出寄存器置1直接画等号先搞清楚板子的硬件极性再决定BSRR怎么写。4.2 然后是main初始化顺序是硬规矩// main.cpp #include stm32f1xx.h #include led.hpp #include cstdint extern C void SystemInit(void) { // 暂不配时钟芯片复位后默认跑内部8MHz HSI } int main() { // 1. 打开 G PIOC 的时钟 RCC-APB2ENR | RCC_APB2ENR_IOPCEN; // 2. 把 PC13 配成 50MHz 推挽输出 // CRH 寄存器管理 PC8 ~ PC15 GPIOC-CRH ~(GPIO_CRH_MODE13 | GPIO_CRH_CNF13); GPIOC-CRH | GPIO_CRH_MODE13; // MODE11, CNF00 // 3. 创建 Led 对象并开始闪烁 board::Led led(GPIOC, GPIO_PIN_13); while (true) { led.toggle(); for (volatile std::uint32_t i 0; i 1000000; i) { // 空转延时8MHz下大约半秒翻转一次 } } }初始化顺序是硬规矩先开外设时钟再配引脚模式最后才操作引脚。很多人习惯上来就操作GPIO忘了第一步LED死活不亮因为GPIO外设的时钟根本没使能——这是STM32系列里最常见的我刚入门时也踩过的坑。引脚配置这里也有讲究。PC13属于高8位对应的是CRH寄存器不是CRL。每个引脚的4位配置字段里低两位是速度MODE高两位是模式CNF。我们清零后只设置MODE13让这两位置成11也就是50MHz最大输出速度CNF位保持00对应推挽输出。如果你配的是PC15以下的引脚记得去翻CRL寄存器很多新手把CRH和CRL搞混。4.3 同样功能C语言怎么写为了让你看清C带来的东西我把同样的功能写成Cvoid led_on(void) { GPIOC-BSRR (uint32_t)0x2000u 16; } void led_off(void) { GPIOC-BSRR 0x2000u; } void led_toggle(void) { GPIOC-ODR ^ 0x2000u; }光看这段你可能觉得C更简单。但换个场景就明白了如果你的板子上有8颗LED分别接在PC0到PC7C的写法要么是8组函数要么是每组调用都要传端口和引脚led_on(GPIOC, 0); led_on(GPIOC, 1); // ...C的写法是定义对象时把LED0接在PC0这个事实绑定进去后面所有调用都脱离硬件细节board::Led led0(GPIOC, GPIO_PIN_0); board::Led led1(GPIOC, GPIO_PIN_1); led0.on(); led1.off();这个抽象带来的直接收益是当某颗LED换到了别的端口你只改定义处的一行而不是改所有调用它的地方。在我们的项目里这是第一个值得你认真体会的C价值点——不是性能提升而是让代码跟硬件之间的关系变得清晰可控。顺便说一句工程里我特意没有提供system_stm32f1xx.c文件而是在main.cpp里自己实现了空壳SystemInit()。这样做是想让你明白不是所有Cube包文件都必须原样搬进工程你不需要的机制可以直接去掉只要符号被满足就行。等后面我们认真配时钟树、跑72MHz的时候再把这个函数换成完整版。5. 从make到st-flash把代码送进芯片5.1 Makefile写完之后编译是靠Makefile串起来的。下面是这个工程最小可用的版本TARGET : blink BUILD : build CC : arm-none-eabi-gcc CXX : arm-none-eabi-g OBJCOPY : arm-none-eabi-objcopy CFLAGS : -mcpucortex-m3 -mthumb -O2 -g -Wall CXXFLAGS : $(CFLAGS) -fno-exceptions -fno-rtti LDFLAGS : -mcpucortex-m3 -mthumb -T stm32f103c8tx.ld \ -specsnosys.specs -Wl,--gc-sections -Wl,-Mapbuild/blink.map OBJS : build/startup.o build/main.o build/led.o all: build/blink.bin build/startup.o: startup_stm32f103xb.s $(CC) $(CFLAGS) -c $ -o $ build/main.o: main.cpp $(CXX) $(CXXFLAGS) -c $ -o $ build/led.o: led.cpp $(CXX) $(CXXFLAGS) -c $ -o $ build/blink.elf: $(OBJS) $(CXX) $(LDFLAGS) $(OBJS) -o $ build/blink.bin: build/blink.elf $(OBJCOPY) -O binary $ $ flash: build/blink.bin st-flash write build/blink.bin 0x08000000 clean: rm -rf build几个关键点解释一下C源文件一定要用arm-none-eabi-g编译链接阶段也要用g而不是gcc。链接器才能自动带上C运行库和必要的启动符号。-fno-exceptions -fno-rtti是嵌入式C的常见配置MCU上跑try/catch和RTTI的代价太高关掉之后固件体积和运行时开销都更可控。-specsnosys.specs提供最小系统的桩函数避免链接时出现_sbrk、_write这类底层符号缺失。LDFLAGS里的-T stm32f103c8tx.ld指向链接脚本这个是整个工程的地址地图。5.2 编译在blink目录下执行make正常你会看到类似这样的输出arm-none-eabi-gcc -mcpucortex-m3 -mthumb -O2 -g -Wall -c startup_stm32f103xb.s -o build/startup.o arm-none-eabi-g -mcpucortex-m3 -mthumb -O2 -g -Wall -fno-exceptions -fno-rtti -c main.cpp -o build/main.o arm-none-eabi-g -mcpucortex-m3 -mthumb -O2 -g -Wall -fno-exceptions -fno-rtti -c led.cpp -o build/led.o arm-none-eabi-g -mcpucortex-m3 -mthumb -T stm32f103c8tx.ld -specsnosys.specs -Wl,--gc-sections -Wl,-Mapbuild/blink.map build/startup.o build/main.o build/led.o -o build/blink.elf arm-none-eabi-objcopy -O binary build/blink.elf build/blink.bin此时用arm-none-eabi-size build/blink.elf看一下你会非常直观地看到Flash和RAM占用通常点灯程序加起来也就几千字节。这套写完代码、跑Make、看size的循环会伴随你整个嵌入式生涯务必养成习惯。5.3 烧录把ST-Link连好SWD接口三个信号加电源地然后make flashst-flash write build/blink.bin 0x08000000这行命令的含义是把二进制文件烧写到0x08000000地址这是F103系列内部FLASH的起点芯片上电后从这里开始执行。如果你用的是STM32CubeProgrammer等价命令是STM32_Programmer_CLI -c portSWD modeUR -w build/blink.bin -v -Rst烧写完成的那一刻如果一切顺利板载LED就开始按你的节奏闪烁。这是整个系列里最普通也最让人上头的瞬间——代码是你写的编译是你跑的烧录是你按下去的从此你不是看教程的人而是会写的人。5.4 如果板子没反应板子没反应先别怀疑人生按顺序排查ST-Link的驱动装了吗板子供电了吗SWD线序接对了吗命令行里st-flash有没有报错如果烧录时提示No ST-Link detected大概率是驱动或USB线问题如果烧录成功但LED不闪问题回到代码和工程文件下一章专门讲排查清单。6. 翻车排查清单这些坑我今天替你踩6.1 编译链接阶段的三大经典报错报错信息根因处理方式undefined reference to SystemInit启动文件调用了SystemInit但工程里没有实现在main.cpp顶部补一个extern C void SystemInit(void) {}undefined reference to __libc_init_array链接阶段没找到C运行库初始化符号链接使用g并确认链接脚本包含.init_array段undefined reference to HardFault_Handler中断向量表引用的是C符号名而C源文件里定义时被名字改编了中断服务函数统一用extern C包裹如果链接阶段报缺少_sbrk、_exit这类系统底层符号第一反应别急着写实现先检查LDFLAGS里有没有-specsnosys.specs。这是我一直建议用这套组合的原因——东西都是公开可查的不像在IDE里点几下按钮错误信息都给你藏起来。6.2 烧录成功但LED不亮的四个隐蔽原因GPIO时钟没有使能。RCC-APB2ENR那行漏了或者写错GPIO外设根本没工作。极性反了。板载LED如果是低电平点亮而你往BSRR低16位置1LED当然不亮。延时循环被优化掉了。没有volatile修饰的空循环在-O2下可能被编译器直接删除代码跑得飞快但你看不到闪烁因为翻转间隔变成几纳秒。全局对象没有构造。startup文件缺少__libc_init_array调用Led对象虽然分配了内存但构造函数里的初始化代码从没执行过。6.3 我的真实翻车经历我踩过最恨的一次就是第四类。当时图省事从某个开源模板里复制了一个startup文件它只处理了SystemInit没有__libc_init_array这一步。我用纯C写了几天一点事没有换成C后把Led对象定义在文件作用域结果程序烧进去LED死活不亮。编译没有报错烧录也正常进调试器一看——对象成员变量全是0构造函数压根没跑。后来把startup文件里加上bl __libc_init_array问题立刻消失。这个经历让我养成了一个习惯任何来自C工程的startup文件和链接脚本拿到C工程里使用前先检查__libc_init_array到底存不存在。这个细节网上资料很少讲但它就是纯C和C工程之间最大的隐形分界线。6.4 接下来可以自己动手的三个改造这一章的代码看完不算完我留三个小改造每个都不难但能确认你确实吃透了把LED从PC13换到PB1自己改RCC-APB2ENR里对应的时钟位、CRL寄存器配置和构造参数。给Led类增加一个构造参数表示低电平点亮还是高电平点亮让同样的类同时兼容两种板子。用SysTick定时器做延时替换掉for空转把CPU从死等里解放出来。三个都做完你对GPIO、寄存器操作和C对象封装的基本功就比大多数只会CtrlC的人扎实了。下一期我们进中断和定时器到时候你会发现今天这个不起眼的点灯工程其实是后面所有外设代码的共同骨架。最后说一句题外话写代码这件事看十篇教程不如自己敲一次。就现在把终端打开按着这篇的Makefile和源码跑一遍。荧光屏上跳出来的不是一块LED而是你正式进入嵌入式C这片领域的入场券。
02
RELATED NEWS

相关资讯

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

03
WHY YAOTU

想打造同款高转化官网?

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

◈

场景化定制

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

◐

营销型架构

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

▲

全周期服务

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

免费获取你的建站方案

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