STM32F103裸机初始化实战:从启动文件到寄存器配置
接手一个裸机 STM32 项目第一件事通常不是写业务逻辑而是把初始化彻底搞明白。很多朋友用惯 HAL 库之后再回去碰 bare-metal initialization第一反应往往是上电之后芯片到底先执行了什么为什么我点个灯都要配置那么多寄存器这篇文章就以最普及的 STM32F103 为例把复位向量、启动文件、链接脚本、时钟树、GPIO、串口和中断的初始化逻辑从头到尾拆一遍。适合刚脱离库函数、想彻底掌控寄存器行为的开发者也适合已经从其他 MCU 转到 STM32 平台想搞清楚为什么这么初始化的人参考。1. 为什么还要自己写 bare-metal 初始化1.1 上电之后芯片到底在干什么裸机初始化不是指从零画出芯片内部电路而是指从复位向量开始由你自己接管芯片的运行环境。STM32 上电之后硬件会做几件固定的事从 flash 首地址读取初始堆栈指针再读取复位向量然后跳转执行。这一步由芯片内部的固定逻辑完成不需要你写代码但如果你没有把正确的向量表放到 flash 起始位置芯片第 1 条指令就会跑飞。跳进 Reset_Handler 之后真正需要你操心的事情才开始。C 语言约定全局变量在 main 之前完成初始化但芯片并不会自动知道 .data 段要拷贝到哪里、.bss 段要清零。HAL 库里SystemInit、__main 和启动文件帮你做了这些裸机环境里要么你把启动文件写对要么就得自己处理。很多看起来诡异的 HardFault其实都发生在这个无人关注的阶段。所以裸机初始化的本质就是回答三个问题向量表放哪、栈在哪、系统时钟从哪里来。这三个问题没解决后面所有外设代码都只是在操作一堆不会生效的寄存器。1.2 裸机初始化和 HAL 初始化有什么本质区别HAL 库初始化外设时通常一个函数就能完成HAL_UART_Init()帮你算波特率帮你配置 GPIO 复用帮你把时钟使能打开。看起来省事但遇到串口乱码、定时器周期不对、芯片唤醒后异常复位时你很难判断是哪一步没有配对。裸机初始化要求你逐个寄存器确认反而逼你把芯片手册吃透。我并不是让大家彻底抛弃 HAL而是建议至少把底层跑通一遍。比如你手写过 PLL 配置就会理解为什么 HSE 晶振没起振时代码会卡在等待标志位的循环里你手写过 GPIO 复用模式就会明白为什么明明开了串口时钟PA9 没有配置成复用推挽输出数据就发不出去。这些认知是只用库函数很难获得的。另外很多实际项目对代码体积、启动时间、外设响应延迟有要求。HAL 库默认开启的时钟、中断、超时检测不一定符合你的场景。裸机初始化可以把不用的部分全部砍掉让每次复位后的启动时间压缩到几十个时钟周期以内。再者国产 MCU 和 STM32 寄存器兼容性参差不齐裸机代码迁移时反而比庞大 HAL 库更容易控制。2. 复位之后启动文件与链接脚本2.1 一段最小可用的启动文件启动文件是裸机初始化的地基。它必须包含两样东西向量表和复位处理函数。向量表的第一项是初始栈指针第二项是复位函数地址。后面的中断项即使暂时不写处理函数也要保留占位否则中断一旦触发芯片会从错误地址取值结果通常是 HardFault。下面是一段适合 STM32F103 最小可用的 Cortex-M3 启动文件不依赖 CMSIS可以直接放到工程里.syntax unified .cpu cortex-m3 .thumb .global _estack .global Reset_Handler .section .isr_vector, a, %progbits .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 .section .text .thumb_func Reset_Handler: ldr r0, _estack mov sp, r0 bl SystemInit ldr r0, __bss_start__ ldr r1, __bss_end__ movs r2, #0 bss_loop: cmp r0, r1 bge bss_done str r2, [r0], #4 b bss_loop bss_done: bl main b . .thumb_func NMI_Handler: .thumb_func HardFault_Handler: .thumb_func MemManage_Handler: .thumb_func BusFault_Handler: .thumb_func UsageFault_Handler: .thumb_func SVC_Handler: .thumb_func DebugMon_Handler: .thumb_func PendSV_Handler: .thumb_func SysTick_Handler: b .这里有几个关键点。第一_estack是链接脚本里定义的栈顶地址必须在向量表第一项正确引用。第二.thumb_func必须加在 Reset_Handler 前否则链接器可能把函数地址生成为奇数状态位导致首条指令执行时出错。第三BSS 清零循环必须放在调用 main 之前否则未初始化的全局变量会带着随机值进入主逻辑。很多人在工程里直接用官方 startup 文件觉得没必要手写。但当你把启动文件拆开看过一遍才会理解链接脚本里的__bss_start__、__bss_end__、_estack到底从哪里来。如果这些符号链接失败问题通常出在链接脚本和启动文件没有对齐。2.2 链接脚本里的堆栈和内存布局链接脚本是跟启动文件配合使用的它告诉链接器 flash 和 RAM 的地址范围以及各段放到哪里。STM32F103C8T6 的典型配置是 64KB flash、20KB RAM。最小链接脚本长这样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) *(.text*) . ALIGN(4); } FLASH .rodata : { . ALIGN(4); *(.rodata) *(.rodata*) . ALIGN(4); } FLASH _sidata LOADADDR(.data); .data : { . ALIGN(4); __data_start__ .; *(.data) *(.data*) __data_end__ .; . ALIGN(4); } RAM AT FLASH .bss : { . ALIGN(4); __bss_start__ .; *(.bss) *(.bss*) *(COMMON) __bss_end__ .; . ALIGN(4); } RAM }这里最容易被忽略的是.data段的AT FLASH。C 语言中的已初始化全局变量在编译后初始值存放在 flash 里但运行时必须拷贝到 RAM。_sidata就是 flash 中初始值的起始地址__data_start__和__data_end__是 RAM 中的拷贝区间。启动文件里我只写了 BSS 清零没有写 data 段拷贝这是故意的目的是让你先明白省略这一步会导致所有带初值的全局变量都是 0或者更乱。实际工程里你需要在 Reset_Handler 中补上 data 拷贝循环。也不难思路跟 BSS 清零一样只是把_sidata的值逐个搬运到__data_start__。另外栈和堆如果没在链接脚本里定义_Min_Stack_Size之类符号默认会跟 BSS 挤在一起一旦递归调用稍微深一点栈就把变量区冲掉了。2.3 SystemInit 与 SystemCoreClock 的分工很多 STM32 官方工程里Reset_Handler 会调用 SystemInit然后才是__main或main。SystemInit 的职责是先把芯片时钟切换到比较稳定的状态再让 C 环境启动。F103 上电默认使用内部 HSI精度不高。如果应用需要串口通信、USB 或者精确计时就必须在早期把 HSE 和 PLL 配好。SystemCoreClock则是一个全局变量理论上保存当前系统时钟频率。你会发现很多 SDK 里的延时函数都读取它来计算循环次数。裸机工程里如果你不维护这个变量SysTick、定时器溢出时间、串口波特率计算都会出错。我的习惯是在SystemInit末尾同步更新SystemCoreClock 72000000;并让所有依赖时钟频率的模块都去读这个变量而不是在多个地方写死数值。如果你不希望引入 CMSIS可以自己定义SystemCoreClock并手工维护。关键是所有模块必须共用一个时钟频率来源否则改一个地方漏一个地方排查起来非常痛苦。3. 时钟树寄存器级初始化的分水岭3.1 时钟树怎么读刚开始看 STM32F103 时钟树图的人很容易被一堆 AHB、APB1、APB2 分频器绕晕。其实核心逻辑就一句话晶振或者内部 RC 产生原始时钟经过 PLL 倍频得到系统时钟 SYSCLK再经过 AHB 分频给 AHB 总线最后 APB1 和 APB2 分别给低速和高速外设供电。F103 的 HSE 外部晶振常见是 8MHz内部 HSI 是 8MHz。最大系统时钟是 72MHz这就要求 PLL 倍频不能超过 9 倍。APB1 总线的最高频率是 36MHz所以系统时钟 72MHz 时APB1 必须至少 2 分频。串口 USART1 挂在 APB2 上波特率计算时要用 72MHz定时器 TIM2 挂在 APB1 上但 TIM2 的时钟可能因为分频器再翻倍而变成 72MHz。这就是为什么很多人用 HAL 初始化时测定时器溢出时间总觉得差一倍。我建议在动手写代码之前先拿一张纸画出自己目标时钟链路HSE 8MHz → PLL x9 → SYSCLK 72MHz → AHB 1分频 → APB1 2分频、APB2 1分频。画完之后再打开寄存器手册找对应位心里会踏实很多。3.2 手写一个 72M 的 SetSysClock裸机初始化时钟树不需要高深算法就是按顺序操作几个寄存器先配置 flash 等待周期再开 HSE 并等待就绪然后配置 PLL 倍频最后把系统时钟切换到 PLL。F103 上对应的寄存器是 RCC_CR、RCC_CFGR 和 FLASH_ACR。#define RCC_BASE 0x40021000UL #define RCC_CR (*(volatile unsigned long *)(RCC_BASE 0x00)) #define RCC_CFGR (*(volatile unsigned long *)(RCC_BASE 0x04)) #define FLASH_ACR (*(volatile unsigned long *)(0x40022000UL)) void SystemInit(void) { unsigned int timeout; FLASH_ACR 0x12; RCC_CR | (1UL 16); timeout 0; while ((RCC_CR (1UL 17)) 0) { if (timeout 1000000) break; } RCC_CFGR (0x7UL 18) | (1UL 16); RCC_CR | (1UL 24); timeout 0; while ((RCC_CR (1UL 25)) 0) { if (timeout 1000000) break; } RCC_CFGR | 0x2UL; timeout 0; while ((RCC_CFGR 0x0CUL) ! 0x08UL) { if (timeout 1000000) break; } }FLASH_ACR 0x12的意义是LATENCY 设为 2表示 flash 读取需要 2 个等待周期bit4 置 1 打开预取缓冲。为什么必须配置等待周期因为 flash 本身比 CPU 慢系统时钟到 72MHz 时不加等待周期取指就会偶发错误。很多人裸机初始化做完代码不稳定、莫名 HardFault多半就是漏了这一步。PLL 倍频值0x7 18对应 x9。PLL 源选择1 16表示使用 HSE。整个配置顺序不能乱必须先让 HSE 稳定再打开 PLL最后切换时钟源。如果你反过来可能切到 PLL 后 PLL 还没稳定指令流就会出问题。3.3 PLL 参数计算与验证PLL 参数不是一个一个试出来的可以算。F103 的 PLL 输入通常是 HSE比如 8MHz目标 SYSCLK 是 72MHz所以倍频系数是 9。如果外部晶振是 12MHz那倍频系数应改为 6最大只能到 72MHz。寄存器位值和倍频系数一一对应查参考手册 RCC_CFGR 中的 PLLMUL 表格就能得到。验证时钟是否真的切到 72MHz最简单的方法是用定时器测引脚翻转频率或者用串口发一个固定字符再用逻辑分析仪看波特率是否准确。我习惯写一个 LED 翻转函数把它放在 SysTick 中断里配一个已知的 1ms 周期然后对比逻辑分析仪实测周期。如果实际周期和预期差 2 倍很可能就是 AHB 或 APB1 分频没配对。还有一个小坑调试器连接时可能因为芯片已经跑在错误时钟下导致 SWD 时序不对。如果出现连接不上按住复位键再点连接通常能捡回一条命。这个技巧在真机上我很常用裸机时钟写错的时候比什么高级工具都管用。4. GPIO 与串口把外设真正跑起来4.1 点灯之前先看 GPIO 模式的二进制位F103 的 GPIO 配置分 CRL 和 CRH分别管低 8 位和高 8 位。每个引脚占用 4 个 bit其中 MODE 场选输入输出模式和速度CNF 场选具体模式。刚开始用寄存器点灯时最容易犯的错误是直接对整个寄存器赋值把其他引脚配置冲掉了。正确姿势是“先清位再置位”。比如常用的 PC13 推挽输出初始化#define RCC_APB2ENR (*(volatile unsigned long *)(0x40021018UL)) #define GPIOC_CRH (*(volatile unsigned long *)(0x40011004UL)) #define GPIOC_ODR (*(volatile unsigned long *)(0x4001100CUL)) void LedInit(void) { RCC_APB2ENR | (1UL 4); GPIOC_CRH ~(0xFUL 20); GPIOC_CRH | (0x2UL 20); } void LedToggle(void) { GPIOC_ODR ^ (1UL 13); }0x2 20的二进制是0010对应的 MODE13 是10代表输出模式 2MHzCNF13 是00代表通用推挽输出。这里我选 2MHz 是为了省电和减少电磁干扰。如果 LED 灯闪烁频率很高或者要驱动外部负载再改成 50MHz。跑通之后可以试试 50MHz 和 2MHz 的区别用示波器看边沿陡峭程度会非常直观。重点说下 RCC_APB2ENR。这个寄存器是外设时钟开关。很多人配置完 CRH 却没有开 GPIO 时钟读 ODR、写 ODR 都不会报错但引脚完全没有输出。寄存器写入会落到总线可外设根本没被时钟驱动。这种问题不打印、不报错只能靠检查时钟使能位来排查。4.2 串口发送的寄存器级实现串口初始化比 GPIO 多两个步骤配置 TX/RX 引脚为复用功能设置波特率寄存器 USART_BRR。F103 的 PA9 是 USART1_TXPA10 是 USART1_RX。关键是 PA9 必须配置成复用推挽输出而不是普通推挽输出PA10 浮空输入即可。我用宏定义的方式写寄存器操作直接看代码#define GPIOA_CRH (*(volatile unsigned long *)(0x40010804UL)) #define USART1_SR (*(volatile unsigned long *)(0x40013800UL)) #define USART1_DR (*(volatile unsigned long *)(0x40013804UL)) #define USART1_BRR (*(volatile unsigned long *)(0x40013808UL)) #define USART1_CR1 (*(volatile unsigned long *)(0x4001380CUL)) void Uart1Init(void) { RCC_APB2ENR | (1UL 2) | (1UL 14); GPIOA_CRH ~(0xFFUL 4); GPIOA_CRH | (0xBUL 4); // PA9 复用推挽 50MHz GPIOA_CRH | (0x4UL 8); // PA10 浮空输入 USART1_CR1 0; USART1_BRR 625; // 72MHz / 115200 625 USART1_CR1 (1UL 13) | (1UL 3) | (1UL 2); } void Uart1Putc(char c) { while ((USART1_SR (1UL 7)) 0); USART1_DR c; }BRR 625是这么来的USART1 挂在 APB2 总线上F103 倍频后 APB2 是 72MHz。目标波特率 115200所以72000000 / 115200 625。这个值放在 BRR 时会自动拆成整数部分和小数部分。如果你的系统时钟不是 72MHz比如用了 8MHz HSI 而没配置 PLL那么 BRR 应该改成8000000 / 115200 ≈ 69。否则串口发出的数据就是乱码。串口发送函数里的while ((USART1_SR (1UL 7)) 0);是等待 TXE 标志位。这个标志表示发送数据寄存器已空可以写入下个字节。很多人用 HAL 库发送没问题一到寄存器发送就把数据拼命往 DR 塞结果因为上一次还没发完数据直接丢失。裸机下没有超时机制死等倒是简单可靠但如果你在中断里调用发送函数就要小心 TXE 永远置位的场景。4.3 外部中断初始化顺序中断初始化的坑比 GPIO 更多因为顺序不对中断就是触发不了或者触发一次后死循环。F103 的外部中断 EXTI 配置链路是GPIO 引脚连接到 EXTIxEXTI 触发条件配置然后映射到 NVIC 使能 IRQ。我这里用一个 PA0 的上升沿中断做例子#define AFIO_EXTICR1 (*(volatile unsigned long *)(0x40010008UL)) #define EXTI_IMR (*(volatile unsigned long *)(0x40010400UL)) #define EXTI_RTSR (*(volatile unsigned long *)(0x40010408UL)) #define EXTI_PR (*(volatile unsigned long *)(0x40010414UL)) #define NVIC_ISER0 (*(volatile unsigned long *)(0xE000E100UL)) #define GPIOA_CRL (*(volatile unsigned long *)(0x40010800UL)) #define GPIOA_ODR (*(volatile unsigned long *)(0x4001080CUL)) void EXTI0_Init(void) { RCC_APB2ENR | (1UL 0) | (1UL 2); GPIOA_CRL ~(0xFUL 0); GPIOA_CRL | (0x8UL 0); // PA0 上拉/下拉输入 GPIOA_ODR ~(1UL 0); // 默认下拉按键低电平接法 AFIO_EXTICR1 ~(0xFUL 0); // EXTI0 选 PA0 EXTI_IMR | (1UL 0); EXTI_RTSR | (1UL 0); NVIC_ISER0 | (1UL 6); // EXTI0_IRQn }注意 AFIO 时钟也要打开。F103 上如果没开 AFIO 时钟EXTI 引脚映射寄存器写入无效中断自然不会触发。这个坑在系统刚开始调试时特别隐蔽。中断处理函数必须保留标志位读取和清除。裸机工程里没有HAL_GPIO_EXTI_IRQHandler这类帮你清标志位的封装你得在中断服务函数里手动写EXTI_PR | (1UL 0);。否则中断会反复进入看起来像死循环。5. 裸机初始化翻车现场与排错手段5.1 五个高频问题我整理过一张速查表里面都是裸机初始化阶段最容易踩中的问题现象常见原因排查方向下载程序后没有任何反应复位向量或栈指针错误查看向量表第一项是否为_estack程序运行到一半 HardFaultflash 等待周期不足或栈溢出检查 FLASH_ACR 等待周期检查栈大小串口输出乱码波特率计算基础时钟错误确认 SYSCLK/PCLK2 实际频率重算 BRR引脚电平不对没使能 GPIO 时钟或 CNF/MODE 配错查 RCC_APB2ENR对照 CRL/CRH 位定义外部中断不触发AFIO 时钟没开或 EXTI 映射错检查 AFIO_EXTICR确认 pending 标志这些问题有一个共同特点都不会在编译时报错。编译器只保证语法和类型正确不保证你的时钟配置和外设配置在硬件上成立。5.2 从 HardFault 反推问题HardFault 是裸机开发最怕遇到的异常但只要你掌握了定位方法它反而能帮你快速缩小问题范围。最简单的办法是看异常发生时的 PC 值和 LR 值。用调试器停在 HardFault_Handler 中查看当前栈帧里面的返回地址就能大致知道是从哪个函数炸出来的。我常见的做法是在 HardFault_Handler 里写一个小循环保留现场然后用调试器读取 R14 和栈内存。如果你用的 J-Link 或 ST-Link 都看不了现场就在 HardFault_Handler 里把 PC 压入某个全局变量程序死后通过内存查看器读出来。这个技巧比盲猜靠谱得多。如果 PC 落在 0xFFFFFFFF 附近往往是函数指针跳错了或者中断向量表里某个非 4 字节对齐的地址被处理器当成 Thumb 指令地址。如果 PC 落在 flash 范围内但反复异常大概率是 flash 等待周期不够你可以在设置 PLL 之前先把 FLASH_ACR 配好这个问题能直接消除。5.3 一种很推荐的验证习惯我调试裸机初始化时的习惯是“一点一点点亮”。意思是不急着把全部外设初始化写完而是先用一个 LED 验证启动文件和时钟。具体流程是写好最小启动文件和链接脚本main 里只做三件事——初始化 LED、翻转若干次、死循环。确认 LED 正常闪烁再往上加串口串口打印一小段自检信息再加中断。每加一个模块就重新下载一次并验证一次。不要试图一次把所有外设全部初始化完再调试。因为一旦出问题你根本不知道是时钟的锅还是 GPIO 的锅。工具方面STM32 ST-Link Utility 可以用来快速读 flash、改 option bytes也可以看内核寄存器的当前值但它的寄存器查看窗口不如 IDE 里的外设寄存器视图方便。VSCode 配合 OpenOCD 做断点和内存查看体验已经很接近商业 IDE 了。逻辑分析仪的优先级比示波器高因为裸机初始化阶段主要在意时序和频率不需要看波形细节。6. 把初始化沉淀成自己的工程模板6.1 模块划分与代码组织裸机初始化代码如果全塞在 main.c 里后期维护会非常痛苦。我习惯这样分目录project/ ├── startup/ │ ├── startup_stm32f103.s │ └── link.ld ├── core/ │ ├── system_clock.c │ ├── system_clock.h │ ├── delay.c │ └── delay.h ├── drivers/ │ ├── led.c │ ├── led.h │ ├── uart.c │ └── uart.h └── main.ccore目录放跟芯片本身强相关的代码比如时钟、延时、SysTick。drivers目录放板级外设驱动比如 LED、按键、串口、I2C。main.c只负责调初始化函数和主循环业务。重点说一下延时模块。裸机工程没有 HAL_Delay最简单可靠的方式是用 SysTick 或者一个定时器产生 1ms 中断在中断里累加一个 volatile 全局变量。所有需要延时的模块都读这个变量而不是自己写空循环。空循环的等待时间会随着编译器优化等级变化而变化在 O0 下能跑通升到 O2 可能闪烁速度突然变快几倍。用定时器做时基是更稳的工程做法。6.2 从 F103 到其他系列的迁移思路F103 这套初始化流程搞明白之后换到 F4、F7、G0 系列其实很快。寄存器名会变但主线不变先确认复位向量和启动文件再配 flash 等待周期和时钟树再开外设时钟最后配置外设模式。F4 系列比较大的变化是 GPIO 从 CRL/CRH 换成了 MODER、OTYPER、OSPEEDR、PUPDR 四个寄存器每个引脚控制位分布更整齐了。时钟树也重新分组主要多了一个和 HSI/HSE 相关的 RC 校准、PLL 的 N、M、P 分频参数。换芯片时先找参考手册里“Reset and clock control”章节再找目标芯片官方启动文件对照基本不会迷路。有些朋友会觉得裸机初始化太繁琐总想找一键生成的工具。我并不反对用 CubeMX 生成工程但生成之后建议把它当参考说明书而不是直接拿去量产。特别是时钟树配置CubeMX 会根据晶振和电压算出一组它认为正确的参数但实际板子可能因为晶振负载电容、PCB 走线等原因需要微调。如果你连寄存器里哪个 bit 是 PLL 倍频都不知道微调时就会一头雾水。最后分享一个我自己的习惯第一次拿到新板子我不急着写业务代码先做一个最小系统——LED 翻转加串口打印加一个按键中断。然后把 LED 引脚、串口波特率、外部晶振频率全部用宏定义放在头文件里。这套最小系统跑稳之后后面调总线、调协议栈、调传感器全靠它兜底。裸机初始化不是背寄存器的过程而是理解芯片怎么对外设供电、怎么分时钟、怎么连接中断线。把这条主线理清楚换任何一款 MCU 都只是查手册的事。