嵌入式开发入门实战:从零手写第一个STM32工程
很多初学者第一次接触 STM32,路径惊人地一致:打开 STM32CubeMX,选型号,勾引脚,点生成,点编译,点下载,板子上的灯亮了。十分钟搞定,但关掉软件之后,脑子里剩下的往往只是“我点了几次鼠标”。程序从哪一条指令开始跑?向量表是什么?为什么灯偏偏挂在第 13 号引脚上?换一颗芯片又该怎么办?这些问题,代码生成器不会回答。本文换一条路:从新建一个空文件夹开始,不依赖任何图形化生成器,手写启动文件、链接脚本、构建脚本和寄存器级驱动,在 STM32F103C8T6 上点亮第一颗 LED,再把串口打印跑通。目标不是让你以后都这么折腾,而是让你在某一刻真正看懂那些被自动生成的代码。
一、先明确目标,再列出全部家当
- 最终目标:板载 LED 以 1Hz 闪烁,同时通过 USART1 每 500ms 打印一次计数。
- 主控:STM32F103C8T6 最小系统板(俗称 Blue Pill),Cortex-M3 内核,最高 72MHz,64KB Flash,20KB SRAM。
- 关键地址:Flash 从 0x08000000 开始,SRAM 从 0x20000000 开始。这两个数字会在链接脚本里反复出现。
- 下载器:ST-Link V2,走 SWD 接口,只需要 SWDIO、SWCLK、GND、3.3V 四根线。
- 其他:USB 转 TTL 模块(CH340)、LED、220Ω 限流电阻、面包板、杜邦线若干。
- 软件:arm-none-eabi-gcc、GNU Make、OpenOCD(或 STM32CubeProgrammer)、VS Code 加 Cortex-Debug 插件。
- 一个容易被忽略的细节:Blue Pill 上的 LED 阳极接 3.3V,阴极接 PC13,因此输出低电平才点亮。PC13 的驱动能力只有 3mA 左右,别拿它直接推继电器或大功率 LED。
二、目录结构先定好,后面不会乱
blink/ ├── Makefile ├── linker/ │ └── stm32f103c8.ld ├── startup/ │ └── startup_stm32f103c8.s ├── src/ │ └── main.c └── build/ (编译产物,不提交到版本库)
- Makefile:把编译、链接、转换、下载串成一条命令。
- 链接脚本:告诉链接器 Flash 和 RAM 有多大、各段分别放在哪。
- 启动文件:芯片上电后执行的第一段代码,负责建立 C 运行环境。
- main.c:业务逻辑,这里就是闪灯加串口。
- build/:目标文件、elf、hex、bin、map 文件。
三、上电到 main 之间,到底发生了什么
这是整篇文章最重要的一节。理解它,后面所有代码都变得顺理成章。
- 复位释放后,Cortex-M3 硬件自动做两件事:从地址 0x08000000 读出第一个 32 位字,装入主栈指针 MSP;从 0x08000004 读出第二个 32 位字,作为复位向量,直接跳过去执行。
- 这意味着 Flash 的最开头必须是向量表,而且第一个条目必须是一个合法的 RAM 地址(栈顶),第二个条目必须是 Reset_Handler 的地址。
- Reset_Handler 通常做四件事:初始化时钟(可选)、把已初始化的全局变量从 Flash 复制到 RAM、把未初始化的全局变量区清零、调用 main。
- main 返回之后程序不应该跑飞,所以标准做法是在 main 返回后进入死循环。
四、手写启动文件
启动文件用汇编写,命名为 startup_stm32f103c8.s。核心结构如下:
.syntax unified .cpu cortex-m3 .thumb .global g_pfnVectors .global Reset_Handler
.section .isr_vector,"a",%progbits .type g_pfnVectors, %object g_pfnVectors: .word _estack .word Reset_Handler .word NMI_Handler .word HardFault_Handler .word MemManage_Handler .word BusFault_Handler .word UsageFault_Handler .word 0 .word 0 .word 0 .word 0 .word SVC_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler /* 后面按芯片手册顺序补全 IRQ0 到 IRQ67 */
-
_estack 不是我们定义的,它由链接脚本提供,一般指向 RAM 末尾。
-
向量表里的每一项都是 4 字节,顺序必须和参考手册里的中断向量表完全一致,错一位就会跑飞到莫名其妙的地址。
-
默认处理函数用弱符号绑定,谁都可以在 C 文件里重写:
.weak NMI_Handler .thumb_set NMI_Handler, Default_Handler
.section .text.Default_Handler,"ax",%progbits Default_Handler: b .
Reset_Handler 的内容:
.section .text.Reset_Handler .type Reset_Handler, %function Reset_Handler: /* 复制 .data 段:从 Flash 到 RAM */ ldr r0, =_sidata ldr r1, =_sdata ldr r2, =_edata b .Lcopy_cmp .Lcopy_loop: ldr r3, [r0], #4 str r3, [r1], #4 .Lcopy_cmp: cmp r1, r2 bne .Lcopy_loop
/* 清零 .bss 段 */ ldr r1, =_sbss ldr r2, =_ebss movs r3, #0 b .Lzero_cmp .Lzero_loop: str r3, [r1], #4 .Lzero_cmp: cmp r1, r2 bne .Lzero_loop
bl main .Lhang: b .Lhang
- 复制 .data 和清零 .bss 是 C 语言能正常工作的前提:带初值的全局变量存在 Flash 里,运行时必须在 RAM 中;未初始化的全局变量必须为 0,这是 C 标准的要求,不是可选项。
- 注意 ldr r0, =_sidata 这种写法,汇编器会把它变成从字面量池加载,链接器最后填上真实地址。
五、链接脚本:给链接器一张地图
stm32f103c8.ld 的骨架:
ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K RAM (xrw) : ORIGIN = 0x20000000, LENGTH = 20K } _estack = ORIGIN(RAM) + LENGTH(RAM); SECTIONS { .isr_vector : { . = ALIGN(4); KEEP(*(.isr_vector)) . = ALIGN(4); } >FLASH .text : { . = ALIGN(4); (.text) (.rodata) . = ALIGN(4); } >FLASH _sidata = LOADADDR(.data); .data : { . = ALIGN(4); _sdata = .; (.data) . = ALIGN(4); _edata = .; } >RAM AT> FLASH .bss : { . = ALIGN(4); _sbss = .; (.bss) *(COMMON) . = ALIGN(4); _ebss = .; } >RAM }
- KEEP 很重要:如果开着 --gc-sections,链接器会因为“没人引用向量表”而把它删掉,结果就是芯片一上电就跑飞。
- AT> FLASH 表示 .data 的加载地址在 Flash、运行地址在 RAM,这正是启动代码里那段复制动作的来源。
- _sidata = LOADADDR(.data) 给出源地址,_sdata 和 _edata 给出目标地址,三个符号与启动文件一一对应。
- 如果以后要用 C 库的 malloc,还需要定义 _end 或 _heap_end 之类的符号。
六、Makefile:三十行搞定构建
核心变量:
TARGET = blink CC = arm-none-eabi-gcc OBJCOPY = arm-none-eabi-objcopy SIZE = arm-none-eabi-size
CFLAGS = -mcpu=cortex-m3 -mthumb -Og -g3 -Wall -Wextra CFLAGS += -ffunction-sections -fdata-sections LDFLAGS = -T linker/stm32f103c8.ld -nostartfiles LDFLAGS += -Wl,--gc-sections -Wl,-Map=build/$(TARGET).map
SRCS = startup/startup_stm32f103c8.s src/main.c OBJS = $(SRCS:%=build/%.o)
- -mcpu=cortex-m3 -mthumb:生成 Thumb 指令,Cortex-M 只支持 Thumb。
- -ffunction-sections -fdata-sections 配合 -Wl,--gc-sections,可以把没用的函数裁掉,对只有 64KB Flash 的芯片很实用。
- -nostartfiles:不要用工具链自带的启动代码,我们自己的 Reset_Handler 才是入口。
- 链接完顺手用 objcopy 生成 hex 和 bin,方便烧录。
七、寄存器级点灯:只需要四个寄存器
先定义基地址和寄存器:
#define RCC_BASE 0x40021000UL #define GPIOC_BASE 0x40011000UL #define RCC_APB2ENR (*(volatile uint32_t )(RCC_BASE + 0x18)) #define GPIOC_CRH ((volatile uint32_t )(GPIOC_BASE + 0x04)) #define GPIOC_BSRR ((volatile uint32_t )(GPIOC_BASE + 0x10)) #define GPIOC_BRR ((volatile uint32_t *)(GPIOC_BASE + 0x14))
static void led_init(void) { RCC_APB2ENR |= (1U << 4); /* IOPCEN:打开 GPIOC 时钟 / (void)RCC_APB2ENR; / 读回一次,确保时钟生效 / GPIOC_CRH &= ~(0xFU << 20); / 清掉 PC13 的配置位 / GPIOC_CRH |= (0x2U << 20); / 通用推挽输出,2MHz */ }
static void led_on(void) { GPIOC_BRR = (1U << 13); } static void led_off(void) { GPIOC_BSRR = (1U << 13); }
- 第一步永远是打开时钟。STM32 的外设默认不接时钟,寄存器写进去也没反应,这是新手最常踩的坑,而且它不报错,只是“没效果”。
- PC13 属于 GPIOC 的高 8 位,配置位在 CRH 的第 20 到 23 位。0x2 表示 2MHz 推挽输出。
- BSRR 置位、BRR 复位,都是写 1 生效、写 0 无影响,而且是单次写操作,天然原子,比读改写 ODR 安全。
- 想让灯闪起来,要在 on 和 off 之间加延时。可以用空循环,但不精确;更好的是 SysTick。
八、用 SysTick 做毫秒延时
SysTick 是内核自带的 24 位递减计数器,寄存器只有四个:
#define SYSTICK_BASE 0xE000E010UL #define STK_CTRL (*(volatile uint32_t )(SYSTICK_BASE + 0x00)) #define STK_LOAD ((volatile uint32_t )(SYSTICK_BASE + 0x04)) #define STK_VAL ((volatile uint32_t *)(SYSTICK_BASE + 0x08))
void delay_ms(uint32_t ms) { STK_LOAD = 8000 - 1; /* 默认 HSI 8MHz,1ms 需要 8000 个周期 / STK_VAL = 0; STK_CTRL = 5; / 使能计数,选择内核时钟,关闭中断 */ while (ms--) { while ((STK_CTRL & (1U << 16)) == 0) { } STK_VAL = 0; } STK_CTRL = 0; }
- 这里用的是芯片复位后的默认时钟 HSI,8MHz。等你把 PLL 配到 72MHz,这个 8000 就要改成 72000。
- 轮询 COUNTFLAG 位虽然浪费 CPU,但足够简单,适合第一次跑通。后面再换成中断方式,或者干脆上 FreeRTOS。
九、串口打印:让 printf 听话
把 USART1 配好:
#define USART1_BASE 0x40013800UL #define USART1_SR (*(volatile uint32_t )(USART1_BASE + 0x00)) #define USART1_DR ((volatile uint32_t )(USART1_BASE + 0x04)) #define USART1_BRR ((volatile uint32_t )(USART1_BASE + 0x08)) #define USART1_CR1 ((volatile uint32_t *)(USART1_BASE + 0x0C))
void uart_init(void) { RCC_APB2ENR |= (1U << 14) | (1U << 2); /* USART1EN | IOPAEN / / PA9 复用推挽输出 50MHz / GPIOA_CRH &= ~(0xFU << 4); GPIOA_CRH |= (0xBU << 4); / PA10 浮空输入 / GPIOA_CRH &= ~(0xFU << 8); GPIOA_CRH |= (0x4U << 8); USART1_BRR = 0x457; / 8MHz / 115200 约等于 69.44 / USART1_CR1 = (1U << 13) | (1U << 3) | (1U << 2); / UE | TE | RE */ }
int _write(int fd, char *buf, int len) { (void)fd; for (int i = 0; i < len; i++) { while ((USART1_SR & (1U << 7)) == 0) { } USART1_DR = (uint32_t)buf[i]; } return len; }
- BRR 里 0x457 的含义是:整数部分 0x45 即 69,小数部分 0x7 即 7/16,合计约 69.44,正好对应 8MHz 下的 115200。
- newlib 的 printf 最终会调用 _write,把它实现掉,printf 就能直接往串口输出。
- 记得在链接参数里加上 --specs=nosys.specs,或者自己补齐 _sbrk、_close、_fstat 等一堆桩函数,否则链接会报错。
十、编译、下载、看结果
-
make 之后得到 build/blink.elf、blink.hex、blink.bin 和一个 map 文件。
-
arm-none-eabi-size build/blink.elf 看一眼占用:Flash 大概几 KB,RAM 更少,心里有数。
-
烧录用 OpenOCD:
openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c "program build/blink.elf verify reset exit"
-
也可以直接 st-flash write build/blink.bin 0x08000000。
-
调试:先启动 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg,再开一个终端跑 arm-none-eabi-gdb build/blink.elf,输入 target extended-remote :3333 就能下断点、看寄存器、看内存。VS Code 装 Cortex-Debug 更省事。
十一、新手最容易翻车的地方
- 忘记开外设时钟。症状是寄存器写得好好的,引脚就是没反应。请把“开时钟”当成肌肉记忆。
- 向量表被 --gc-sections 删掉。加 KEEP,或者干脆先不开 gc-sections 验证一次。
- 启动文件里 .data 没复制、.bss 没清零。症状是全局变量初值莫名其妙,或者一进 main 就 HardFault。
- 栈指针设置错。_estack 指向 RAM 之外,第一次压栈就进 HardFault。
- ST-Link 和外部电源同时供电,导致板子电压打架。要么只用 ST-Link 的 3.3V,要么断开它的供电脚。
- BOOT0 被拉高,芯片进了系统存储器启动模式,你的程序根本没跑。
- 中断服务函数名拼错。弱符号会安静地把你引到 Default_Handler 的死循环里,什么提示都没有。
- 拿 PC13 直接驱动大电流负载。它只有 3mA 左右的能力,请加三极管或 MOSFET。
- 用 5V 给 IO 供电前先查手册,标了 FT 的引脚才耐 5V。
十二、跑通之后,下一步该学什么
- 中断与 NVIC:用 EXTI 做按键,理解优先级分组。
- 定时器:PWM 调光、输入捕获测频率、编码器接口。
- DMA:让 ADC 连续采样不占 CPU,这是做信号处理的门槛。
- 通信外设:SPI 驱动 OLED、I2C 读传感器、CAN 上车。
- 时钟树:把 HSI 换成 HSE 加 PLL 跑到 72MHz,理解 AHB、APB1、APB2 的分频关系。
- 读原始资料:RM0008 参考手册管外设,数据手册管引脚和电气特性。中文教程只能帮你入门,最终答案永远在原文手册里。
- 上 RTOS:当你有三个以上任务和一堆延时,FreeRTOS 会比裸机轮询舒服得多。
十三、写在最后
手写一遍 STM32 工程,最大的收获不是省下了多少次鼠标点击,而是你终于知道每一行代码为什么在那儿。向量表不是魔法,它只是 Flash 开头的一张地址清单;启动文件不是黑盒,它只是复制数据、清零内存、跳进 main;链接脚本也不是玄学,它就是一张写着“哪段放哪儿”的地图。
等你再打开 CubeMX,看着它生成的那一堆文件,你会有一种很踏实的感觉:这不就是我刚写过的那些东西吗,只是它替我写得更全、更快而已。工具从来没有问题,问题是你得先看懂它在做什么。现在,去把你的第一颗 LED 点亮吧。