1. 上电那一刻,CPU 其实根本不知道“main”是谁
你写过多少次int main()?在 Keil、STM32CubeIDE 或者 PlatformIO 里点下“下载+运行”,LED 亮了,串口打印出 “Hello World”,一切顺理成章——仿佛 CPU 是个听话的学生,你把main()交给他,他就从头开始执行。但真相是:CPU 上电后第一秒,连“函数”“参数”“返回值”这些概念都不存在;它只认一个东西:地址。一个硬编码的、烧在芯片内部的、不可更改的地址。
这个地址,就是复位向量(Reset Vector)。对 WeAct STM32F411CEU6(我们常称的“黑板”或“小蓝板”)来说,这个地址是0x08000004。注意,不是0x08000000,也不是0x00000000,更不是你代码里main()所在的.text段起始地址。它是一个固定偏移,指向 Flash 存储器中第 2 个 32 位字的位置。而这个字,存储的正是复位后 CPU 要跳转去执行的第一条指令的地址。
所以,“CPU 不认识 main()” 这句话,不是修辞,是物理事实。main()是 C 语言标准库(通常是libc的_start或__main)包装出来的入口抽象,是编译器和链接器联手构建的一层“文明社会”。而 CPU 本身,只活在裸机世界:它上电、清零寄存器、读取向量表、跳转、取指、译码、执行……周而复始,不带感情。它不认识printf,不认识malloc,甚至不认识栈指针SP应该初始化成什么——这些全靠你(或者更准确地说,靠启动代码)在它执行第一条 C 代码之前,亲手喂给它。
WeAct STM32F411 的典型 Flash 布局是:前 16KB(0x08000000–0x08003FFF)为用户程序区,其中0x08000000存放的是主堆栈指针初始值(MSP),0x08000004存放的就是复位向量。当你用arm-none-eabi-gcc编译一个空工程,生成的.map文件里会清晰地列出:
. = ALIGN(4); _estack = . + SIZEOF(.stack); /* Top of Stack */ __initial_sp = . + SIZEOF(.stack); . = ALIGN(4); PROVIDE ( _stack = . ); . = ALIGN(4); __stack = .; . = ALIGN(4); __data_start__ = .; . = ALIGN(4); __data_end__ = .; . = ALIGN(4); __bss_start__ = .; . = ALIGN(4); __bss_end__ = .; . = ALIGN(4); __heap_start__ = .; . = ALIGN(4); __heap_end__ = .; . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_end__ = .; . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM); . = ALIGN(4); __ram_size__ = LENGTH(RAM); . = ALIGN(4); __ram_start__ = ORIGIN(RAM); . = ALIGN(4); __ram_end__ = ORIGIN(RAM) + LENGTH(RAM......别被这堆__ram_end__冲晕——关键在开头。.map文件里必然有类似这样的段:
.isr_vector 0x08000000 0x1c0 *(.isr_vector) .isr_vector 0x08000000 0x1c0 startup_stm32f411xe.o(.isr_vector)它告诉你:.isr_vector段(中断向量表)被链接到了0x08000000,而这个段的前两个字,就是 MSP 和复位向量。打开startup_stm32f411xe.s(STM32CubeMX 生成的标准启动文件),你会看到:
.section .isr_vector,"a",%progbits .type GLOBL_ISR_VECTORS, %object .size GLOBL_ISR_VECTORS, .-GLOBL_ISR_VECTORS .word _estack /* Top of Stack */ .word Reset_Handler /* Reset Handler */ .word NMI_Handler /* NMI Handler */ .word HardFault_Handler /* Hard Fault Handler */ ...这里,Reset_Handler就是那个真正被 CPU 执行的第一段代码。它不是 C 函数,是汇编;它不接受参数,不返回值;它的唯一使命,就是为 C 世界铺路。所以,当你发现 WeAct 板子“上电没反应”,或者“用 J-Link 点 Run 可以运行,但断电重上电就不行”,问题几乎一定出在Reset_Handler到main()这条链路上的某个环节——而不是main()本身写错了。因为 CPU 根本还没走到main()那一步。
提示:很多初学者误以为“程序不运行”=“main() 写错了”。这是最大的认知陷阱。CPU 在到达
main()之前,要完成至少 5 个不可跳过的物理/逻辑步骤:供电稳定、时钟起振、复位信号释放、向量表加载、启动代码执行。任何一个环节卡住,main()都是“未定义行为”。
2. 启动代码:从裸机到 C 的七步桥
Reset_Handler是一座桥,连接着 CPU 的原始状态和你熟悉的 C 语言世界。这座桥不是天生就有的,是编译器(GCC)、链接脚本(STM32F411RETx_FLASH.ld)和启动文件(startup_stm32f411xe.s)共同浇筑的。我们来拆解这座桥的每一块砖,看看它是如何把一个只会读地址的硅基机器,变成能调用printf的“文明人”的。
2.1 第一步:栈指针初始化(SP)
CPU 上电后,SP寄存器是随机值。如果不清零或赋值,任何函数调用、局部变量分配都会导致灾难性后果——栈溢出、覆盖关键数据、死机。Reset_Handler的第一行汇编,就是:
ldr sp, =_estack_estack是链接脚本里定义的符号,指向 RAM 的最高地址(例如0x2001FFFF)。这条指令把栈顶地址加载进SP寄存器。注意,这里用的是ldr而不是mov,因为_estack是一个 32 位地址,无法用立即数直接mov。实测中,如果你在链接脚本里错误地把_estack定义在了 Flash 地址(比如0x08000000),CPU 会尝试往 Flash 写栈,结果就是硬故障(HardFault),板子彻底“假死”,连 SWD 调试都连不上。
2.2 第二步:BSS 段清零(Zero-initialization)
C 语言规定,全局未初始化变量(如int global_var;)必须为 0。但 Flash 里存的只是.data段的初始值,BSS 段本身在 Flash 中不占空间,只记录长度。所以启动代码必须手动把 BSS 区域全部填 0。Reset_Handler会调用一个叫SystemInit()的 C 函数(在system_stm32f4xx.c里),但在此之前,它先执行一段汇编循环:
ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sbss ldr r3, =_ebss cmp r2, r3 beq LoopCopyDataInit mov r4, #0 /* Zero loop */ ZeroLoop: str r4, [r2], #4 cmp r2, r3 bne ZeroLoop这段代码的意思是:从_sbss(BSS 起始)开始,每次写入一个 0(r4),地址加 4([r2], #4),直到达到_ebss(BSS 结束)。这里有个极易被忽略的细节:BSS 清零必须在SystemInit()之前完成。因为SystemInit()里会用到全局变量(比如RCC_Clocks结构体),如果这些变量还是随机值,时钟配置就会失败,后续所有外设都无法工作。我曾在一个项目里把SystemInit()放在了 BSS 清零之前,结果 UART 初始化永远超时,查了三天才发现是这个顺序问题。
2.3 第三步:DATA 段复制(Copy from Flash to RAM)
C 语言允许全局变量带初始值(如int global_init = 10;)。这些初始值存在 Flash 的.data段里,但变量本身必须在 RAM 中运行。所以启动代码要把.data段的内容,从 Flash 复制到 RAM 的对应位置。代码如下:
ldr r0, =_sdata ldr r1, =_edata ldr r2, =_sidata movs r3, #0 cmp r0, r1 beq LoopCopyDataInit CopyLoop: ldr r3, [r2], #4 str r3, [r0], #4 cmp r0, r1 bne CopyLoop_sidata是.data在 Flash 中的起始地址,_sdata是它在 RAM 中的目标起始地址。这个复制过程,本质上是一次内存搬运。如果SystemInit()里需要修改某些全局配置(比如SysTick_Config()的计数器值),而这个值又依赖于.data段里的常量,那么复制失败(比如源地址或目标地址错位)会导致SystemInit()行为异常。WeAct 板子常见的“上电后 LED 不闪,但串口有乱码”,往往就是.data复制偏移量算错,导致USART_TypeDef结构体指针被写成了垃圾值。
2.4 第四步:系统时钟初始化(SystemInit)
SystemInit()是 CMSIS 标准函数,位于system_stm32f4xx.c。它负责配置 HSI/HSE、PLL、AHB/APB 总线分频,最终让SYSCLK达到你期望的频率(比如 100MHz)。这个函数内部会操作 RCC(Reset and Clock Control)寄存器。关键点在于:SystemInit()必须在任何外设初始化之前调用,且其成功与否,直接决定后续所有外设能否正常工作。如果你在main()里直接调用HAL_UART_Init(),而SystemInit()因为晶振未起振(HSE 未稳定)就返回了,那么 UART 的波特率计算就会基于错误的SYSCLK,结果就是通信完全失败。WeAct STM32F411 默认使用 HSE(外部晶振),但它的原理图上,HSE 晶振是 8MHz,而很多新手在 CubeMX 里误设为 HSI(内部 RC),导致SystemInit()卡在等待 HSE 就绪的 while 循环里,main()永远不会被执行。
2.5 第五步:调用 C 运行时初始化(__libc_init_array)
在SystemInit()返回后,Reset_Handler会跳转到__libc_init_array。这不是一个标准 C 函数,而是 GCC 工具链提供的一个“钩子”,用于执行所有__attribute__((constructor))标记的函数,以及.init_array段里的函数指针。对于裸机开发,这个阶段通常为空,但在使用newlib或microlib时,它会初始化stdio缓冲区、malloc堆管理器等。如果你在main()之前定义了一个构造函数:
void __attribute__((constructor)) my_init(void) { RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN; }那么它就会在这个阶段被调用。实测发现,如果__libc_init_array执行过程中发生异常(比如访问了未映射的内存),CPU 会进入Default_Handler,而这个 handler 默认是无限循环,板子就“卡死”了,没有任何输出。
2.6 第六步:跳转到 main()
终于,所有基础设施准备就绪。Reset_Handler最后一行是:
bl main这才是你熟悉的main()的起点。此时,SP已设好,BSS 已清零,DATA 已复制,时钟已配置,C 运行时环境已就绪。main()函数可以安全地声明局部变量、调用库函数、使用malloc(如果启用了堆)。但请注意:bl main是一个“分支并链接”指令,它会把返回地址(即main()之后的下一条指令地址)压入栈,并跳转到main的入口。这意味着,main()执行完毕后,CPU 会试图返回到那个地址——而那里通常是无效的。所以,在裸机程序中,main()的结尾必须是一个无限循环:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); while(1) { HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5); HAL_Delay(500); } }如果没有while(1),CPU 会继续执行main()后面的内存(可能是.bss或.data的末尾),结果不可预测。
2.7 第七步:中断向量表重定位(可选但关键)
WeAct STM32F411 的向量表默认在0x08000000(Flash 起始)。但如果你开启了 Bootloader 或者想把向量表放到 RAM 中(为了动态修改中断处理函数),就必须在SystemInit()之后、main()之前,执行向量表重定位:
SCB->VTOR = (uint32_t)0x20000000; // RAM 起始地址VTOR(Vector Table Offset Register)是 Cortex-M4 的一个特殊寄存器。一旦你改了它,所有中断(包括 SysTick、NMI、HardFault)都会从新的地址取向量。如果重定位后没有在新地址放置完整的向量表(256 个 32 位字),那么第一次中断发生时,CPU 就会读到一个非法地址,触发 HardFault。WeAct 板子上电后偶尔“一闪即灭”,有时就是因为 Bootloader 把 VTOR 指向了 RAM,但 RAM 里没有有效向量表。
3. WeAct STM32F411 上电失效的四大真实场景与排查链路
WeAct STM32F411CEU6 是一款高性价比的国产替代板,但它也继承了 STM32 系列经典的“上电玄学”。很多用户反馈:“J-Link 下载后点 Run 能跑,拔掉 USB 断电再上电就不亮”、“串口打印乱码”、“LED 完全不响应”。这些问题,99% 都发生在Reset_Handler执行期间,而非main()内部。下面,我将还原四个最典型的、我在客户现场亲手解决的真实案例,展示完整的排查逻辑链。
3.1 场景一:电源纹波过大,导致复位芯片误触发
现象:板子上电瞬间,LED 闪一下就灭;用万用表测VDD(3.3V)引脚,电压在 3.1V–3.4V 之间剧烈抖动;J-Link 连接后,能识别到芯片,但无法 halt。
排查链路:
- 第一步:确认供电路径。WeAct 板子的电源来自 USB 的
VBUS(5V),经 AMS1117-3.3 LDO 降压。LDO 的输入电容(10uF)和输出电容(22uF)是稳定的关键。用示波器探头(1X 档)直接测量 AMS1117 的OUT引脚,发现纹波峰峰值高达 400mV。 - 第二步:定位噪声源。断开所有外设(USB-TTL、OLED 屏幕),只留核心电路。纹波依然存在。怀疑是 USB 供电质量差,换用实验室稳压电源(5V/2A)直接给
VBUS供电,纹波降至 50mV,板子上电正常。 - 第三步:验证复位芯片。WeAct 使用的是
TPS3823复位芯片,其RESET引脚在VDD低于阈值(2.93V)时会拉低 CPU 的NRST。实测发现,当纹波谷底低于 2.93V 时,NRST会被反复拉低,CPU 不断复位,根本来不及执行Reset_Handler的第一行ldr sp, =_estack。 - 解决方案:在 AMS1117 的
IN和OUT引脚各并联一个 100nF 的陶瓷电容(X7R),并确保 PCB 上电源走线短而宽。整改后,纹波降至 30mV,上电一次成功。
注意:很多新手用万用表测 DC 电压,看不到纹波。必须用示波器,且探头接地线要尽可能短(用弹簧接地夹),否则测出来的是假象。
3.2 场景二:HSE 晶振未起振,卡死在 SystemInit()
现象:上电后,没有任何反应(LED 不闪、串口无输出);J-Link 连接后,能 halt,但 PC(程序计数器)停在system_stm32f4xx.c的第 387 行:while(__HAL_RCC_GET_FLAG(RCC_FLAG_HSERDY) == RESET)。
排查链路:
- 第一步:确认硬件连接。WeAct 板子的 HSE 晶振是 8MHz,焊接在
OSC_IN/OSC_OUT引脚(PA8/PA9)。用万用表二极管档测晶振两端,应有约 10kΩ 电阻(表示晶振未短路)。再用示波器(10X 档)探头轻触OSC_IN,观察是否有正弦波。实测无波形。 - 第二步:检查负载电容。HSE 晶振需要匹配的负载电容(通常 12pF–22pF)。WeAct 原理图设计为 12pF,但实际贴片电容误差较大。更换为两颗 15pF 的 NP0(C0G)电容,波形出现,但幅度很小(<100mVpp)。
- 第三步:调整驱动能力。在
RCC_OscInitTypeDef结构体中,将OscillatorType设为RCC_OSCILLATORTYPE_HSE,HSEState设为RCC_HSE_ON,但漏掉了HSEPredivValue和HSEFilter。查阅 RM0090 手册,发现 F411 的 HSE 需要配置RCC_CR寄存器的HSEBYPASS位。将其设为DISABLE(非旁路模式),并增加HAL_Delay(100)让晶振充分起振。 - 最终方案:在
SystemClock_Config()函数开头,添加:
/* Enable HSE oscillator */ __HAL_RCC_HSE_CONFIG(RCC_HSE_ON); /* Wait for HSE ready */ uint32_t timeout = 0x1000; while((__HAL_RCC_GET_FLAG(RCC_FLAG_HSERDY) == RESET) && (timeout-- > 0)) { HAL_Delay(1); // Give it time } if(timeout == 0) { // HSE failed, fallback to HSI __HAL_RCC_HSI_ENABLE(); while(__HAL_RCC_GET_FLAG(RCC_FLAG_HSIRDY) == RESET); __HAL_RCC_PLL_DISABLE(); __HAL_RCC_PLL_CONFIG(RCC_PLLSOURCE_HSI, RCC_PLLM_DIV2, RCC_PLLN_MUL16, RCC_PLLP_DIV2, RCC_PLLQ_DIV4); __HAL_RCC_PLL_ENABLE(); while(__HAL_RCC_GET_FLAG(RCC_FLAG_PLLRDY) == RESET); __HAL_RCC_SYSCLK_CONFIG(RCC_SYSCLKSOURCE_PLLCLK); }这样,即使 HSE 失败,也能自动切换到 HSI,保证main()至少能跑起来。
3.3 场景三:Flash 地址偏移错误,复位向量指向空地址
现象:上电后,J-Link 无法连接芯片(提示 “No target connected”);用 ST-Link Utility 也无法识别;但用 J-Link 的 “Unlock” 功能,能擦除芯片,擦完后又能识别。
排查链路:
- 第一步:检查 Flash 布局。WeAct 板子的 Flash 起始地址是
0x08000000,大小 512KB。但在 CubeMX 的 “Project Manager” -> “Code Generator” 里,有人误将 “Flash Layout” 的 “Start Address” 改成了0x08001000(跳过前 4KB)。 - 第二步:分析向量表。链接脚本
STM32F411RETx_FLASH.ld中,.isr_vector段的地址是.=ORIGIN(FLASH)。如果ORIGIN(FLASH)被设为0x08001000,那么向量表就被放到了0x08001000,而 CPU 仍然固执地去0x08000004读取复位向量——那里是空白的 Flash,读出来是0xFFFFFFFF,CPU 就跳转到0xFFFFFFFF,这是一个非法地址,触发 HardFault,芯片锁死。 - 第三步:验证 Flash 内容。用 J-Link Commander 连接,执行
mem32 0x08000000 10,查看前 16 个字。正常应为:
0x08000000: 2001FFFF 08000185 00000000 00000000 ...第一个字是 MSP,第二个字是复位向量。如果第二个字是FFFFFFFF,就证实了向量表没烧对位置。 4.解决方案:在 CubeMX 中,将 “Flash Layout” 的 Start Address 改回0x08000000,重新生成代码,重新编译下载。或者,用 J-Link 的 “Flash Download” 功能,手动指定正确的 Flash 起始地址0x08000000进行烧录。
3.4 场景四:Bootloader 占用向量表,APP 未重定位
现象:WeAct 板子刷入 Bootloader 后,再刷 APP,APP 上电不运行;但用 J-Link 直接下载 APP 到0x08000000,就能运行。
排查链路:
- 第一步:理解 Bootloader 架构。WeAct 的 Bootloader 通常放在
0x08000000–0x08003FFF(16KB),APP 放在0x08004000开始。Bootloader 的向量表在0x08000000,APP 的向量表应在0x08004000。 - 第二步:检查 APP 的链接脚本。APP 的
STM32F411RETx_FLASH.ld中,MEMORY区段必须改为:
MEMORY { FLASH (rx) : ORIGIN = 0x08004000, LENGTH = 512K - 16K RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K }并且,.isr_vector段的起始地址要强制对齐到0x08004000。 3.第三步:验证 APP 的向量表。用objdump -d your_app.elf | head -20查看反汇编,确认第一条指令的地址是0x08004000,且.isr_vector段确实在此地址。 4.第四步:APP 启动时重定位 VTOR。在 APP 的main()开头,必须加上:
/* Set vector table offset */ SCB->VTOR = FLASH_BASE + 0x4000; // 0x08004000否则,CPU 仍会从0x08000000取向量,而那里是 Bootloader 的代码,结果就是跳转到 Bootloader 的Reset_Handler,然后又跳回 Bootloader,APP 永远不会执行。
4. 从汇编到 C:手写一个最小可运行的 Reset_Handler
理论听再多,不如亲手写一段能跑通的代码。下面,我将带你从零开始,手写一个 WeAct STM32F411 的最小启动文件,不依赖任何 HAL 库或 CubeMX,只用纯汇编和 C,让你彻底看清main()之前的每一步。
4.1 创建最小工程结构
新建一个文件夹weact_minimal,结构如下:
weact_minimal/ ├── startup.s # 我们手写的启动文件 ├── main.c # 主程序 ├── linker.ld # 自定义链接脚本 ├── Makefile # 编译脚本 └── build/ # 输出目录4.2 编写 linker.ld(链接脚本)
/* linker.ld */ ENTRY(Reset_Handler) SECTIONS { . = 0x08000000; .isr_vector : { . = ALIGN(4); _vector_start = .; *(.isr_vector) . = ALIGN(4); _vector_end = .; } > FLASH .text : { . = ALIGN(4); _text_start = .; *(.text) *(.rodata) . = ALIGN(4); _text_end = .; } > FLASH .data : { . = ALIGN(4); _data_start = .; *(.data) . = ALIGN(4); _data_end = .; } > RAM AT > FLASH .bss : { . = ALIGN(4); _bss_start = .; *(.bss) *(COMMON) . = ALIGN(4); _bss_end = .; } > RAM .stack (NOLOAD) : { . = ALIGN(4); _stack_start = .; . += 0x400; /* 1KB stack */ . = ALIGN(4); _stack_end = .; } > RAM . = ALIGN(4); _estack = .; }这个脚本定义了:
- 入口点为
Reset_Handler .isr_vector段从0x08000000开始.text和.rodata放在 Flash.data放在 RAM,但初始化数据从 Flash 加载(AT > FLASH).bss放在 RAM,且清零- 栈大小为 1KB,从 RAM 末尾向下生长
4.3 编写 startup.s(启动汇编)
/* startup.s */ .syntax unified .cpu cortex-m4 .fpu softvfp .thumb .global Reset_Handler .global Default_Handler .global NMI_Handler .global HardFault_Handler .global MemManage_Handler .global BusFault_Handler .global UsageFault_Handler .global SVC_Handler .global DebugMon_Handler .global PendSV_Handler .global SysTick_Handler .extern main .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 SVC_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler .section .text .thumb_func Reset_Handler: /* 1. Initialize SP */ ldr sp, =_estack /* 2. Copy .data from Flash to RAM */ ldr r0, =_data_start ldr r1, =_data_end ldr r2, =_data_load_start cmp r0, r1 beq copy_done copy_loop: ldr r3, [r2], #4 str r3, [r0], #4 cmp r0, r1 bne copy_loop copy_done: /* 3. Zero .bss */ ldr r0, =_bss_start ldr r1, =_bss_end mov r2, #0 cmp r0, r1 beq bss_done bss_loop: str r2, [r0], #4 cmp r0, r1 bne bss_loop bss_done: /* 4. Call main */ bl main /* 5. Infinite loop */ loop: wfi b loop .thumb_func NMI_Handler: b Default_Handler .thumb_func HardFault_Handler: b Default_Handler .thumb_func Default_Handler: b . .section .data .align 2 _data_load_start: .incbin "build/main.bin"关键点解析:
ldr sp, =_estack:加载栈顶地址copy_loop:将.data从 Flash(_data_load_start)复制到 RAM(_data_start)bss_loop:将.bss区域清零bl main:跳转到 C 函数wfi:Wait For Interrupt,低功耗等待,比nop更省电
4.4 编写 main.c(主程序)
/* main.c */ #include <stdint.h> // WeAct STM32F411CEU6 的 GPIOA 寄存器地址 #define RCC_BASE 0x40023800 #define GPIOA_BASE 0x40020000 #define RCC_AHB1ENR (*(volatile uint32_t*)(RCC_BASE + 0x30)) #define GPIOA_MODER (*(volatile uint32_t*)(GPIOA_BASE + 0x00)) #define GPIOA_ODR (*(volatile uint32_t*)(GPIOA_BASE + 0x14)) void delay_ms(uint32_t ms) { volatile uint32_t i, j; for(i = 0; i < ms; i++) { for(j = 0; j < 3000; j++); // 粗略延时,依赖 SYSCLK } } int main(void) { // 1. 使能 GPIOA 时钟 RCC_AHB1ENR |= (1 << 0); // 2. 配置 PA5 为输出模式(WeAct 板子的 LED 在 PA5) GPIOA_MODER &= ~(3 << 10); // 清除 PA5 的 MODER 位 GPIOA_MODER |= (1 << 10); // 设置为输出模式 // 3. 主循环:翻转 PA5 while(1) { GPIOA_ODR ^= (1 << 5); delay_ms(500); } }这个main.c没有任何库依赖,直接操作寄存器。它只做了三件事:开时钟、设模式、翻电平。这就是裸机编程的精髓——剥离所有抽象,直面硬件。
4.5 编写 Makefile
# Makefile ARMGNU ?= arm-none-eabi- CC = $(ARMGNU)gcc LD = $(ARMGNU)gcc OBJCOPY = $(ARMGNU)objcopy OBJDUMP = $(ARMGNU)objdump SIZE = $(ARMGNU)size TARGET = weact_minimal SOURCES = startup.s main.c OBJECTS = $(SOURCES:.c=.o) $(SOURCES:.s=.o) CFLAGS = -mcpu=cortex-m4 -mthumb -mfpu=vfp -mfloat-abi=hard -O2 -Wall -Wextra -std=gnu11 LDFLAGS = -T linker.ld -nostartfiles -Wl,--gc-sections all: $(TARGET).bin $(TARGET).elf: $(OBJECTS) linker.ld $(LD) $(LDFLAGS) -o $@ $^ $(TARGET).bin: $(TARGET).elf $(OBJCOPY) -O binary $< $@ $(TARGET).list: $(TARGET).elf $(OBJDUMP) -d $< > $@ %.o: %.c $(CC) $(CFLAGS) -c -o $@ $< %.o: %.s $(CC) $(CFLAGS) -c -o $@ $< clean: rm -f $(OBJECTS) $(TARGET).elf $(TARGET).bin $(TARGET).list .PHONY: all clean4.6 编译与烧录
在终端中执行:
make clean make会生成weact_minimal.bin。用st-flash write weact_minimal.bin 0x08000000烧录。上电,WeAct 板子的蓝色 LED(PA5)就会以 1Hz 频率闪烁。整个过程,没有 HAL,没有 CubeMX,只有你和 CPU 的直接对话。
实测心得:手写启动代码的最大价值,不是“炫技”,而是建立一种“确定性”。当你知道每一行汇编的作用,每一个寄存器的含义,你就不会再被“上电不运行”这种问题吓住。它就像学开车时,先搞懂离合、油门、档位的物理原理,而不是只记住教练说的“抬离合、踩油门、挂二档”。底层越清晰,上层越自由。
5. 关于 main() 的三个反常识真相
main()是 C 语言的入口,但它在嵌入式世界里,是一个被精心包装、层层包裹的“幻觉”。揭开它的面纱,你会发现三个颠覆常识的事实。
5.1 真相一:main() 的签名是编译器强加的契约,不是硬件要求
标准 C 规定main()可以有两种形式:int main(void)或int main(int argc, char *argv[])。前者用于嵌入式,后者用于桌面程序。但 CPU 根本不关心这个。Reset_Handler里那句bl main,只是把控制权交给一个叫main的符号。至于这个符号是void main()、int main()还是void main(int x),只要链接器能找到它,CPU 就会跳过去。
然而,编译器(GCC)在生成代码时,会根据main的签名,自动生成不同的函数序言(prologue)和尾声(epilogue)。例如,int main(int argc, char *argv[])会期望R0和R1寄存器里分别存放argc和argv的值。但在裸机环境下,谁来提供这两个值?没人。所以,如果你强行声明int main(int argc, char *argv[]),编译器会生成代码去读R0/R1,结果读到的是随机值,argv[0]指向一个非法地址,printf一调用就 HardFault。
实测对比:
int main(void):编译后,main的入口汇编是干净的,只做栈帧设置。void main():GCC 会警告return type of 'main' is not 'int',但代码能跑。不过,main返回后,Reset_Handler的bl指令会把返回地址压栈,而void main()没有return语句,