嵌入式开发入门实战:从零手写第一个STM32工程

很多初学者第一次接触 STM32,路径惊人地一致:打开 STM32CubeMX,选型号,勾引脚,点生成,点编译,点下载,板子上的灯亮了。十分钟搞定,但关掉软件之后,脑子里剩下的往往只是“我点了几次鼠标”。程序从哪一条指令开始跑?向量表是什么?为什么灯偏偏挂在第 13 号引脚上?换一颗芯片又该怎么办?这些问题,代码生成器不会回答。本文换一条路:从新建一个空文件夹开始,不依赖任何图形化生成器,手写启动文件、链接脚本、构建脚本和寄存器级驱动,在 STM32F103C8T6 上点亮第一颗 LED,再把串口打印跑通。目标不是让你以后都这么折腾,而是让你在某一刻真正看懂那些被自动生成的代码。

一、先明确目标,再列出全部家当

二、目录结构先定好,后面不会乱

blink/ ├── Makefile ├── linker/ │ └── stm32f103c8.ld ├── startup/ │ └── startup_stm32f103c8.s ├── src/ │ └── main.c └── build/ (编译产物,不提交到版本库)

三、上电到 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 */

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

五、链接脚本:给链接器一张地图

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 }

六、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)

七、寄存器级点灯:只需要四个寄存器

先定义基地址和寄存器:

#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); }

八、用 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; }

九、串口打印:让 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; }

十、编译、下载、看结果

十一、新手最容易翻车的地方

十二、跑通之后,下一步该学什么

十三、写在最后

手写一遍 STM32 工程,最大的收获不是省下了多少次鼠标点击,而是你终于知道每一行代码为什么在那儿。向量表不是魔法,它只是 Flash 开头的一张地址清单;启动文件不是黑盒,它只是复制数据、清零内存、跳进 main;链接脚本也不是玄学,它就是一张写着“哪段放哪儿”的地图。

等你再打开 CubeMX,看着它生成的那一堆文件,你会有一种很踏实的感觉:这不就是我刚写过的那些东西吗,只是它替我写得更全、更快而已。工具从来没有问题,问题是你得先看懂它在做什么。现在,去把你的第一颗 LED 点亮吧。