
如果你第一次拿 WeAct 那块黑色 F411 小板照着教程写好点灯程序烧录之后灯一亮心里多半会冒出一个问题我明明只写了 main()它怎么就自己跑起来了更准确点说CPU 到底是怎么知道 main() 在哪里的很多人能背出“复位向量”“启动文件”这些词但真让他解释“CPU 不认 main()”这件事往往会卡壳。答案其实特别朴素CPU 不认识 main()。它不知道什么是函数不关心你叫入口还是出口它只认地址。所谓“程序上电自动运行”本质是硬件在复位后从一个固定的地址取指令然后一路取下去。我们之所以能写一个 main() 并让它自动执行是因为编译器、链接器、启动文件、链接脚本合谋给 CPU 造了一条通往 main 的路。这篇文章我用 WeAct STM32F411 的实际启动流程把这个“看不见的前传”完整讲一遍顺带把上电进不了 main、HardFault、栈溢出这类问题一并拆开看。1. 一个新手必问的问题main() 是谁调进来的1.1 从“Hello World”背后那个看不见的人说起几乎所有嵌入式教程的第一课都是 LED 闪烁或者串口打印而代码里必然有一个 main()。我们在 PC 上写 C 语言时操作系统负责加载程序、构造进程、调用 main在单片机上没有操作系统那 main 是谁调进来的答案早就写在调试工程的启动文件里了但很多教程默认你不需要知道。STM32 的启动文件在工程里通常叫 startup_stm32f411xe.s。文件看着不起眼里面却是一整套 main 前传它定义向量表、设置栈指针、复制 .data、清零 .bss、调用 SystemInit最后调用 main。换句话说main 并不是被硬件直接点名的而是被启动文件调用的。硬件只负责上电后从某个地址开始执行至于那个地址背后的函数叫什么名字CPU 完全不感兴趣。如果你第一次使用 CubeIDE 生成一个 F411 工程可以在项目里找到 startup_stm32f411xe.s翻到最后几行一定会看到类似这样的汇编Reset_Handler: ldr sp, _estack bl SystemInit bl __libc_init_array bl main b .这一小段就是整个“自动运行”的核心逻辑。看到bl main那一行你就会明白main 不是被 CPU 直接认识的它只是被启动代码通过一次普通函数调用拉进去的。1.2 CPU 只认地址不认函数名CPU 本身没有“符号表”的概念。它按照程序计数器 PC 指向的地址去取指令一条一条执行。函数名 main、uart_init、delay_ms 之类本质上都是编译器在编译期间为代码块分配的地址标签链接后再被程序中的调用指令替换成具体地址。到了 CPU 眼里只有地址没有名字。“存储器与 CPU 的连接”这句话听起来很底层但在这里就变得非常具体CPU 通过地址总线访问程序存储器通过数据总线拿到指令编码。它不需要知道这段代码叫 main 还是叫 my_entry它只需要知道“下一步该跑哪个地址”。这个地址存在哪里存在程序存储器的特定位置也就是向量表。Cortex-M 系列对“启动位置”规定得非常固化复位后硬件自动读取两个关键数据一个是初始栈指针一个是复位向量。这两个数据分别存放在向量表最开始的两个 word 里。拿到复位向量后CPU 先把 PC 指向那个地址然后开始取指。整个过程中没有任何一个环节需要“main”这个名字。所以“上电自动运行”真正做的事是硬件先定好一个起点然后从起点开始执行软件。而软件里的启动文件负责把该准备的全局环境准备好最后才把控制权交给 main。CPU 不认识 main它认识的是 Reset_HandlerReset_Handler 才认识 main。1.3 为什么“上电能跑 main”是一种错觉很多刚从单片机入门的同学会下意识认为main 是程序天然的起点。这个理念在接触 RTOS 之后就会被打破在 RTOS 里main 只是创建一个调度器然后启动它在一个带 bootloader 的系统里main 可能只是 App 里的一个普通函数真正起点是 Reset_Handler。如果你把这个“太想当然”带到实际调试里很容易在一些玄学问题上绕圈。比如你有一天改了启动文件或者替换了链接脚本程序烧录后就是不进 main也没有报错。这时候如果你还抱着“程序就应该从 main 开始”的念头会很难受。真正该做的是确认复位后是不是跑进了 Reset_Handler确认向量表有没有被放对位置确认bl main这条跳转到底跳去了哪里。理解 CPU 不认 main 之后这一类问题的排查路径会清晰很多。2. 从 STM32F411 的复位向量到 Reset_Handler2.1 WeAct STM32F411 的硬件格局WeAct 的 STM32F411 核心板在入门圈子里口碑不错常用芯片是 STM32F411CEU6属于 Cortex-M4F 内核带硬件浮点单元主频最高 100MHz。Flash 512KBSRAM 128KB整体资源对一般点灯、传感器采集、小型 GUI、电机控制来说都非常够用。黑色小板Type-C 供电SWD 调试口有的版本集成 ST-Link有的版本需要外接。这块板子的“上电”过程很典型。外部 3.3V 供电稳定后芯片内部上电复位电路需要一点时间然后 BOOT0 引脚的电平决定从哪块存储区域启动。默认 BOOT0 拉低从主 Flash 启动地址 0x08000000。对 CPU 来说它并不关心你的 Flash 是哪个厂生产的它只知道复位后应该到固定地址拿向量表。如果你手头正好有这块板可以先用一个最简单的裸机工程把编译生成的 .elf 文件反汇编观察文件最开头的几十个字节。你会发现 0x08000000 处保存的是一个 RAM 地址初始 SP0x08000004 处保存的是 Reset_Handler 的地址。这两个值就是 CPU 上电后的第一口粮。2.2 上电那一刻Cortex-M4 做了什么Cortex-M4 的复位流程可以拆成三步。第一步硬件从地址 0x00000000 读取初始主栈指针 MSP第二步从地址 0x00000004 读取复位向量第三步把读到的栈指针写入 SP把读到的复位向量写入 PC然后开始执行。由于 BOOT00 时 0x00000000 被映射到 Flash 的 0x08000000所以实际读取地址就是 0x08000000 和 0x08000004。这里有个容易混淆的点0x08000000 是芯片内部 Flash 的起始地址0x00000000 是 Cortex-M 内核默认的向量表别名地址。系统启动时硬件访问 0x00000000实际物理介质却是 Flash。这就是“存储器与 CPU 的连接”在最底层的一种体现映射关系决定了一切。看到这你会发现CPU 上电后的行为完全是机械式的读两个 word设置两个寄存器开跑。它没有检查“main 是否声明为 int”没有检查“编译器是否包含 main 类型”更不会因为你的代码里没有 main 而报错。如果你把向量表里 Reset_Handler 的位置写成一个普通函数地址程序也能跑只是跑起来之后最终会怎样就看启动文件怎么组织了。2.3 Reset_Handler 里的“三大件”拷贝、清零、初始化既然 CPU 已经停在 Reset_Handler接下来的任务就落到软件身上。要理解这段启动代码得先知道 C 语言里那些全局变量在 Flash 和 RAM 中的分布。全局变量分两类有初值的和没初值的。有初值的变量比如int a 100;编译后初值会被放在 Flash 的只读区域程序上电后 RAM 内容是不确定的所以需要把 Flash 里的初值拷贝到 RAM 中对应的位置。没初值的全局变量则按 C 标准默认为 0但 RAM 上电内容随机所以还需要把一整块 .bss 段清零。启动文件里对应的汇编就是一段拷贝循环和一段清零循环。如果只有裸机点灯这两个循环可能很快执行完但道理是通用的。你可以手动在 main 里写个全局大数组再去观察编译生成的 map 文件会发现它的确出现在 RAM 地址段但对应的初始值已经在 Flash 里存了一份这就是 .data 和 .bss 存在的意义。Reset_Handler 里还有一步bl SystemInit。SystemInit 在 system_stm32f4xx.c 里实现主要做一件事把时钟从默认的 HSI 切换到用户配置的 PLL让 CPU 跑到 100MHz同时设置 Flash 等待周期等关键参数。有些同学会把时钟初始化写在 main 里但标准启动流程里这一步更早原因很简单后续所有代码都希望能跑在稳定且足够快的时钟上。如果时钟没配好串口波特率、定时器时间基值全都会出偏差。3. 链接脚本与向量表决定“CPU 该去哪里”的两张地图3.1 链接脚本是怎么给内存“画格子”的启动文件靠的是汇编指令而“把哪个段放到哪个地址”这件事需要链接脚本配合。GCC 工具链下STM32F411 的链接脚本通常叫 stm32f411xe.ld开头就是一段 MEMORY 描述MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (xrw) : ORIGIN 0x20000000, LENGTH 128K } _estack ORIGIN(RAM) LENGTH(RAM);这段定义告诉链接器Flash 从 0x08000000 开始共 512KBRAM 从 0x20000000 开始共 128KB。_estack则被设置为 RAM 的最顶端地址也就是 0x20020000。这个值会被链接器写入向量表的第一个 word作为复位后的初始栈指针。随后链接脚本会把.isr_vector段放在 Flash 最开始紧接着.text代码段、.rodata只读数据段再处理.data和.bssSECTIONS { .isr_vector : { . ALIGN(4); KEEP(*(.isr_vector)) } FLASH .text : { *(.text .text*) } FLASH .data : { _sdata .; *(.data .data*); _edata .; } RAM AT FLASH .bss : { _sbss .; *(.bss .bss*); _ebss .; } RAM }.data的RAM AT FLASH是最关键的一行运行时它被放在 RAM 里但导致初值放在 Flash 里。链接器生成_sdata、_edata和 Flash 侧的加载地址符号启动文件就靠这些符号完成拷贝。如果你手头链接脚本里这些符号缺失或者启动文件版本的符号名对不上程序极可能在 main 之前就跑飞。3.2 向量表每个异常和中断都有固定座位向量表不只是给 CPU 上电用的它还是整个中断系统的“接线表”。Cortex-M4 的所有异常和中断入口都必须登记在向量表里。表项顺序是硬规定不能乱。第 0 项是初始 MSP第 1 项是 Reset_Handler第 2 项是 NMI第 3 项是 HardFault后面依次是 MemManage、BusFault、UsageFault、SVCall、PendSV、SysTick再往后就是芯片厂商定义的外部中断 IRQ。每个表项保存的是一个函数地址而且这个地址的最低有效位 bit0 必须是 1。这不是随便定的Cortex-M 内核只支持 Thumb 指令bit01 表示进入 Thumb 模式。如果你从向量表拿到的地址最低位是 0处理器可能进入错误状态表现就是各种奇怪的 HardFault。当你用汇编定义中断函数时通常会看到THUMB指令那就是在保证这条硬性规定。向量表的整体布局由启动文件负责但你可以在 map 文件里看到它。搜索.isr_vector能看到从 0x08000000 开始的几十个地址。这些地址就是“CPU 被中断后该去哪问路”的名单。CPU 不关心你给函数起的名字只要地址对它就能跑。3.3 启动文件里的 IMPORT main 到底在干嘛汇编里的bl main不是关键字而是一个普通的外部符号引用。启动文件在最前面可以写IMPORT main意思是从别的文件里找一个叫 main 的符号链接阶段链接器在 .text 段里找到 main 函数的实际地址然后把这条bl指令的跳转目标填上。也就是说main只是启动文件“想要调用”的一个标签。如果你在工程里改了一个函数叫app_main而不叫main编译链接时大概率会报undefined symbol main。解决方式有两种一是把app_main改回main二是在链接脚本或编译器命令行里做符号重定义例如-Wl,--defsym,mainapp_main。前者最粗暴后者则能说明一个道理链接器在意的是“启动文件里写了要调 main”CPU 根本不在乎这个符号存在于何处。同理你在反汇编里看bl main显示的会是类似bl 0x08000360 main的指令。对 CPU 而言0x08000360 与 0x08000364、0x08000368 没有任何区别它只是去执行那个地址上的指令。真正让 main 看起来“特殊”的是 C 标准、启动代码和你的编程习惯而不是 CPU 的硬件逻辑。4. 一次完整的“上电到 main”调试实录4.1 准备好 WeAct F411 的调试环境纸上谈兵没用我们把刚才的流程放到板子上走一遍。WeAct F411 的调试环境很简单一块 F411 板子、一个 ST-Link板载或外接、一根 Type-C 线。如果用 STM32CubeIDE直接生成一个裸机工程编译下载即可。如果用命令行工具链可以这样起 OpenOCDopenocd -f interface/stlink.cfg -f target/stm32f4x.cfg启动后保持终端不动另开一个终端运行 arm-none-eabi-gdb加载编译出的 .elf(gdb) target remote :3333 (gdb) monitor reset halt (gdb) load (gdb) monitor reset halt (gdb) info registers sp pc lr第一次复位暂停时你会看到 SP 的值接近 0x20020000PC 的值是一个 0x0800xxxx 开头的地址而 LR 可能是 0xffffffff。这就是上电后第一时刻的现场CPU 已经拿着硬件喂给它的初始 SP停在 Reset_Handler 入口。如果你这里看不到 RAM 顶端的 SP或者 PC 跑到奇怪的地方先别急着在 main 里找 bug八成是启动文件、链接脚本或向量表出了问题。硬件复位后的第一现场往往最能说明问题。4.2 打断点到 Reset_Handler 和 main接下来我们回到 GDB在 Reset_Handler 和 main 各打一个断点。Reset_Handler 的断点可以用函数名也可以用地址main 的断点直接用b main(gdb) b Reset_Handler (gdb) b main (gdb) c先停在 Reset_Handler继续运行后应该停在 main。整个过程 CPU 经过 SystemInit、.data 拷贝、.bss 清零、__libc_init_array 这些代码但这些步骤对你来说是黑盒。如果你想知道中间发生了什么可以单步执行启动文件汇编或者打开 map 文件看这些符号前后的地址范围。很多人会在这里发现一个现象从 Reset_Handler 到 main 之间编译器插入了一大段__libc_init_array相关代码。这是 GCC ARM 工具链自带的 C 运行时初始化负责调用全局构造函数和.init_array段里的函数。在纯 C 工程里它通常什么也不做但在 C 工程里这里会对全局对象执行构造函数。如果有人发现某个全局对象的值在 main 里突然不是构造后的值问题多半出在这里。4.3 从 PC、SP 和 LR 的变化看启动过程当你停在 main 断点时可以再查一次寄存器SP 依然应该接近 RAM 顶端LR 是 Reset_Handler 里bl main的下一条指令地址也就是那个b .死循环的地址。这组寄存器变化非常有意思CPU 的 PC 从复位向量一路走到 Reset_Handler再从 Reset_Handler 走到 main所有跳转都是普通函数调用或分支LR 里记录的返回地址最终指向死循环。如果在一个 RTOS 工程里main 创建了任务然后调用调度器LR 可能变成调度器内部地址但你仍然能感受到一条完整链条复位向量 - Reset_Handler - main - 用户代码。链条的每一环都靠地址连接和函数名没半点关系。这个调试过程虽然简单却很有仪式感。我每次排查上电类问题都会把 PC 拉到复位现场看一眼。与其在 main 里加日志盲猜不如直接看第一步跑得对不对。5. 那些让 CPU 进不了 main 的坑与排查5.1 复位后直接 HardFault先查向量表和栈顶程序一上电就进 HardFault是最让人头痛的问题但排查顺序其实很固定。先复位暂停看 PC 停在哪里。如果 PC 停在 0x00000000 附近或者某个非 Flash 地址说明启动地址已经错了如果 PC 停在启动文件里那再查向量表地址与栈指针。一个常见原因是 Flash 里烧写的位置不对。比如你想做 IAP把 App 的链接地址改成了 0x08010000但忘了把 App 的向量表偏移设置好复位后硬件仍然从 0x08000000 取向量表读到的还是 Bootloader 的数据最后一路跑歪。另一个常见原因是向量表首地址没有对齐或者某个表项最低位不是 1。检查一下 map 文件里.isr_vector的起始地址以及 Reset_Handler 的地址是不是奇数bit01。如果最后一位是 0跳过去必翻车。最后还要看初始 SP。假如你手动控制过链接脚本把_estack设成了一个不存在的内存地址或者 RAM 容量写错了复位后 SP 是错的第一条压栈指令就会写坏内存随后进 HardFault。这一类问题在没有调试器时会表现得像“上电没反应”或者“跑飞”其实都是启动时的地基塌了。5.2 栈大小不够启动可能没事压栈就翻车要区分“启动失败”和“栈不够”其实很容易前者往往一上电就完蛋后者可能还能跑一阵但一旦压栈深度增大就 HardFault。启动文件里默认栈大小通常是 0x400也就是 1KB。对简单裸机程序够用但如果你的 main 里声明了较大的局部数组比如uint8_t buf[1024];一旦使用它会立即把栈打穿直接在函数返回或压栈时触发 HardFault。Cortex-M 的中断处理也使用主栈。中断嵌套越深栈消耗越大。如果你在主循环里使用多个中断且每个中断都有不少局部变量1KB 栈很容易不够。这时可以把Stack_Size改大比如 0x10004KB但如果板子 RAM 本来就紧张更要自己掂量。一个实际操作经验是在 main 入口记录一下 SP 值程序运行一段时间后再看 SP 变化范围就能估算栈实际用量。如果你把一个大数组放进 main其实更合理的方式是把它改成全局静态变量或放到堆上。不是说栈不能放数组而是很多人低估了中断压栈的成本留下一个不稳定因素。尤其在上电后第一次触发串口中断或定时器中断时出问题说明栈深度恰好卡在临界点。5.3 main 返回之后到底发生了什么因为在 PC 上习惯了 main 里return 0;很多新手在单片机上也这么写。区别在于PC 上 main 返回后操作系统会回收进程单片机上没有这个机制。启动文件里bl main之后往往跟着一条b .也就是死循环意思是“无论 main 返不返回都别回来打扰我”。但如果你把 main 写成了int main(void)并用了某些 C 库编译器会把启动流程变成exit(main())也就是说 main 返回值会交给 exit 处理。嵌入式环境里的 exit 最终也会走进一个死循环或者触发abort。这些东西在不同工具链实现里略有差异但一个大方向不会变裸机程序里main 不该正常返回。要让程序持续运行就得在 main 里放一个 while(1)。这里也回应了“编译器未包含 main 类型”这个说法。C 标准要求 main 的返回类型是 int但很多嵌入式教学的例子里有void main()或者int main()。编译器在缺省情况下可能不会拦截但一旦开了-Wmain或-Werrormain就会发现编译器对 main 的类型有自己的要求。CPU 不查类型编译器查所以这类报错其实是编译器在替你把关。5.4 main 函数的参数在裸机里这么处理再来说 main 函数参数。在 PC 上int main(int argc, char *argv[])接收命令行参数是由操作系统启动例程填好了参数再调用的。但在 STM32 裸机工程里启动文件执行bl main时根本没有传参R0、R1 里的值取决于上一条指令执行完的残留状态完全不可控。因此裸机工程里一般不推荐写带参数的 main强行写出来大概率也是未定义行为。老老实实写int main(void)或者在某些 BSP/RTOS 的模板里写一个app_main再让宏替换成 main都是更稳的玩法。有些 RTOS 的官方例程会把用户入口定义为void app_main(void)然后在 main 里创建任务并调度本质上是把 main 退化成“系统的引导入口”而不是用户业务入口。理解这一点后你再看到别人代码里 main 不是实际入口就不会觉得奇怪了。6. 深入一步从启动到 IAP“第二个 main”6.1 跳转到 App 时CPU 为什么能“自己”跑起来理解了“CPU 不认识 main”之后IAP 在线升级就不难剖析。Bootloader 里最常见的动作是校验完 App 固件后跳转到 App 的起始地址。通常情况下Bootloader 会把 App 放在 Flash 的某个偏移位置比如 0x08010000。App 编译时链接地址也改成 0x08010000并且 App 自己也有一个向量表和 Reset_Handler。跳转代码看起来是这样的void jump_to_app(uint32_t app_addr) { uint32_t app_msp *(volatile uint32_t *)app_addr; uint32_t app_reset *(volatile uint32_t *)(app_addr 4); __set_MSP(app_msp); SCB-VTOR app_addr; ((void (*)(void))app_reset)(); }第一行从 App 固件偏移 0 处读出 App 的初始栈指针第二行从偏移 4 处读出 App 的 Reset_Handler 地址然后把 MSP 设置成 App 期望的值把向量表偏移到 App 自己的表最后跳过去。自此CPU 又会像上电一样从这个“第二个复位向量”开始执行。注意这里的跳转并不是“调用”而是真正把 PC 改过去。App 的 Reset_Handler 会再次执行 .data 拷贝、.bss 清零、SystemInit、main。所以 App 里那个 main 依旧不是被 CPU 直接认识的它只是被 App 自己的启动文件调用了而已。很多人在写 YModem 升级功能时忽略了向量表偏移App 一跑就 HardFault原因就在这。6.2 启动阶段的看门狗和延时配置启动阶段还有一个隐蔽的坑看门狗。有些产品要求系统运行期间必须开启 IWDG防止程序跑飞。但如果你在主循环初始化完成之后才开启看门狗问题不大如果在上电后很早就开启而 SystemInit、Flash 擦写、文件系统挂载这些操作耗时过长看门狗可能先超时于是又复位形成“永远启动不起来”的循环。解决办法通常是把耗时的初始化放在喂狗循环里或者先初始化必要的硬件和时钟保证喂狗路径可用后再开狗。注意 IWDG 一旦开启就无法用软件关闭只有复位才能重来所以调试这种问题时特别容易让人误以为是“上电时序”问题。你可以先用调试器暂停看 PC 是不是反复回到 Reset_Handler如果是再查看门狗超时时间与初始化耗时。延时配置也值得看一眼。有些外设在上电后需要几毫秒稳定时间但启动代码在 main 之前没有 delay如果你在 SystemInit 里就操作外设可能踩到“还没准备好”的窗口。这种问题表现为用调试器单步时一切正常全速运行却偶尔失败。处理方式就是确定外设上电复位的典型时间在初始化序列里加上足够裕量。6.3 用小实验验证“CPU 不认识 main”最后分享一个很有意思的小实验。你不用改业务代码只要在编译器链接时做一次符号重定义把 main 改名为 my_test_entry然后启动文件依然调用 main 符号。用链接脚本参数arm-none-eabi-gcc ... -Wl,--defsym,mainmytest_entry或者直接在代码里#define main my_test_entry int my_test_entry(void) { while(1); }编译烧录后你会发现程序照样运行。这说明在硬件眼里main 只是众多符号里的一个普通符号。真正决定上电后去哪里的只有初始 SP、复位向量和启动代码。你也可以在调试器里手动修改 PC把 PC 直接指向 main 的地址并继续执行程序也会跑起来只不过全局变量没初始化、时钟没配置大概率很快翻车。这个操作本身就是对“CPU 不认 main”的最好验证。再配合arm-none-eabi-nm查看 ELF 符号表你会看到 main 的地址。那个地址和任何普通函数一样是一串无区别的数字。CPU 认识它不是因为符号左边写着 main而是因为启动文件的调用指令引用了它。换句话说只要链接器把地址填对了叫什么名字根本不重要。说实话我最初也以为 CPU 认识 main。我第一次用调试器单步跑 STM32 启动流程时看到 PC 从复位向量一路跳到 Reset_Handler最后停在 main才真正理解了“入口地址”和“入口函数”之间的差别。后来每次遇到上电不进 main、上电 HardFault、IAP 跳转失败这些问题我都会习惯性地做两件事先查向量表和栈顶再看启动流程里的 PC 停在哪里。这个习惯帮我省下过很多瞎猜的时间。如果你也正在玩 WeAct STM32F411 或者任何一块 STM32强烈建议花一个下午把启动文件、链接脚本、map 文件对照着看一遍。特别是把bl main前面的每一行都搞清楚之后你做 IAP、低功耗唤醒、RTOS 移植都会觉得顺很多。CPU 不认识 main 这件事看起来是个小知识点但它其实是理解整个单片机“上电自启动”体系的钥匙。