ARM裸机启动代码解析:从向量表到main的完整流程
第一次拿到一块 ARM 开发板我盯着启动文件看了很久。跳来跳去的汇编指令、栈顶地址、复位向量、中断向量表……明明只是想点个灯开头就这么硬核当场把人劝退。后来在裸机环境里泡了几周才慢慢明白这段“启动代码”到底在做什么它负责在 main 函数有机会运行之前把芯片从“上电后的混沌状态”扶到“C 语言环境可用状态”。这篇文章不聊操作系统只聊 ARM 裸机环境下的启动代码以及所有从启动到 main 这条路上容易踩碎的坑。如果你正准备入门嵌入式底层或者已经会点亮外设但看不懂启动流程那这篇内容应该对你有用。1. 裸机启动和操作系统启动同样上电两条完全不同的路1.1 main 并不是上电后执行的第一段代码如果你以前写 PC 程序认为程序入口一定是 main那在裸机上必须把这个习惯打破。CPU 上电之后硬件不会自动找到 C 代码里的 main而是按照架构规定先到固定地址去取启动信息。Cortex-M 系列的做法是处理器从地址 0x00000000 读取初始栈指针从地址 0x00000004 读取复位异常入口地址然后跳到那个入口去执行。这个入口一般就叫 Reset_Handler也就是启动代码真正开始的地方。传统 ARM例如 Cortex-A 或者更早的 ARM9启动模型又不太一样。它们通常在 0x00000000 或 0xFFFF0000 放异常向量表每条向量表项是一条跳转指令CPU 遇到复位、中断、未定义指令时会跳转到对应位置。Cortex-M 则把向量表项直接写成“地址”硬件自动按异常号读取入口地址并切换状态。所以你看启动代码时首先要搞清楚目标内核是哪一类不要拿着 M 内核的启动思路去套 A 内核反过来也一样。很多裸机教程会直接把启动文件丢给你让你编译烧录从来不解释里面为什么是这几行汇编。结果就是程序跑通了你以为是奇迹程序跑飞了你连第一手现场都看不懂。理解启动代码的第一前提就是接受“main 之前还有一个隐藏世界”。1.2 操作系统帮你做的事裸机上全要自己做操作系统的启动过程会把栈、堆、设备驱动、中断体系、内存管理全部准备好然后才把控制权交给应用。你写应用程序时只需要关心业务逻辑因为底下已经有人把毛坯房装修成了精装房。裸机环境完全不同它更像刚交房的毛坯房水电没通、地面没铺、墙没刷你必须自己先做一遍基础工程然后才能住人。启动代码就是那个“水电改造队”。它需要至少完成四件事设置栈指针保证 C 函数调用时能压栈和出栈把带有初值的全局变量从 Flash 拷贝到 RAM把未初始化的全局变量BSS 段清零配置最小硬件环境例如时钟、看门狗、存储器控制器然后才调用 main。在 PC 上这些工作由运行时库和系统 loader 自动完成你甚至不需要知道 C 语言里的 .data、.bss 段是什么。但在裸机上没有人替你做编译器也不会自动生成一段能直接跑在任意芯片上的“万能启动代码”。你用的是这块芯片就得为这块芯片写好对应的启动流程。1.3 裸机启动代码的典型组成以一个典型的 Cortex-M 裸机工程为例启动阶段通常会包含三个部分向量表。放在 Flash 起始位置第一项是栈顶地址第二项是复位入口后面是各类异常和外设中断入口。启动汇编或启动 C 文件。从 Reset_Handler 开始完成栈指针设置、数据段拷贝、BSS 清零最后调用 main。链接脚本。规定各段放在哪个地址、加载地址和执行地址分别是什么启动代码里的“从哪拷到哪”全靠它提供符号。这三个部分不是孤立的。启动代码里写的_estack、_sdata、_edata通常都来自链接脚本。如果你把启动代码单独拿出来看会看到一堆看似 undefined 的符号这不是错误而是故意留给链接器去填充的坐标。2. 手写最小启动代码在一个 Cortex-M3 小板上跑起来2.1 向量表必须放在开始处我拿一块 Cortex-M3 核心板做实验。芯片的 Flash 起始地址是 0x08000000RAM 起始地址是 0x20000000。启动代码第一步就是在链接脚本里把 Flash 起点设为 0x08000000并且把向量表放在所有段的最前面。向量表的语义是固定的Cortex-M 复位后首先从 0x00000000 取得初始 SP再从 0x00000004 取得 Reset_Handler 地址。向量表可以用汇编写也可以用 C 结构体写但实际项目里最常见的是汇编启动文件。下面是一个最小向量表.syntax unified .cpu cortex-m3 .thumb .section .isr_vector, a .align 4 .global g_pfnVectors 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 SVCall_Handler .word DebugMon_Handler .word 0 .word PendSV_Handler .word SysTick_Handler .rept 22 .word Default_Handler .endr第一行_estack被放在向量表第 0 项它必须指向 RAM 的合法栈顶地址。第 1 项放 Reset_Handler第 2 项之后按 Cortex-M 规定的异常编号排列。中间那些保留项不能乱删硬件会在固定位置查找对应入口一旦某个异常发生时表项错位程序会跳到错误地址调试难度直接翻倍。2.2 用汇编把 C 环境搭起来向量表只定义了“入口在哪里”真正的初始化工作要从 Reset_Handler 开始。下面是一段最精简的启动代码作用是设置栈指针、搬运 .data、清零 .bss然后调用 SystemInit 和 main.section .text.Reset_Handler .thumb_func .global Reset_Handler Reset_Handler: ldr r0, _estack mov sp, r0 ldr r0, _sdata ldr r1, _edata ldr r2, _la_data copy_loop: cmp r0, r1 beq zero_bss ldr r3, [r2], #4 str r3, [r0], #4 b copy_loop zero_bss: ldr r0, __bss_start__ ldr r1, __bss_end__ movs r2, #0 bss_loop: cmp r0, r1 beq call_main str r2, [r0], #4 b bss_loop call_main: bl SystemInit bl main b .第一次看到这段代码时你可能觉得它只是简单的循环搬运。但它解决的问题非常关键C 语言里写了int g_count 100这个 100 在编译后通常被放在 Flash 里而不是 RAM 里。程序运行时g_count 这个变量必须出现在 RAM 中否则每次访问都是一次 Flash 读操作而且无法被正常修改。可问题是RAM 断电后内容会丢失所以每次上电都必须把 Flash 里的初始值“复制”到 RAM 里。上面的 copy_loop 做的就是这件事。BSS 清零同理。C 语言标准规定未初始化的全局变量初值应当是 0但 RAM 上电后是随机状态必须主动清零。如果不做这一步你的全局变量第一次读到什么值完全取决于运气可能昨天还能跑今天上电就莫名其妙出错。2.3 中断向量里的“占位符”是干什么的观察向量表你会发现大量 Handler 指向了同一个 Default_Handler。这是项目初期最常用的占位策略把所有暂未实现的中断入口统一指向一个死循环或者指向一个带调试断点的空函数。这样做有两个好处第一链接器不会因为缺少符号而报错第二当某个未处理的中断意外触发时你不会直接掉进 HardFault 的死胡同而是能停在 Default_Handler 里观察是哪个中断出了问题。很多启动文件还会用“宏”来批量生成中断入口。例如#define IRQ_HANDLER(name) void name(void) { for(;;); }然后把每个外设中断名都展开成一个死循环函数。等到真正实现某个外设中断时再把这个函数替换成实际代码。这个做法在裸机开发里很常见算不上多高级但非常好用。3. 链接脚本启动代码在跑之前地址已经被排好3.1 链接脚本的本质三张地址清单启动代码里引用的_estack、_sdata、_edata、_la_data不是凭空存在的它们由链接脚本定义。链接脚本负责告诉链接器三件事系统里有哪些内存区域、每个区域起始地址和大小是多少、每个输入段最终放在哪个区域。你可以把它看作一份“地址布局规划图”。一个最简单的 Cortex-M3 链接脚本长这样ENTRY(Reset_Handler) MEMORY { FLASH (rx) : ORIGIN 0x08000000, LENGTH 512K RAM (rwx) : ORIGIN 0x20000000, LENGTH 128K } SECTIONS { .isr_vector : { *(.isr_vector) } FLASH .text : { *(.text*) *(.rodata*) } FLASH .data : { _sdata .; *(.data*) _edata .; } RAM AT FLASH _la_data LOADADDR(.data); .bss : { __bss_start__ .; *(.bss*) __bss_end__ .; } RAM _estack ORIGIN(RAM) LENGTH(RAM); }注意ENTRY(Reset_Handler)只是告诉链接器“程序入口是 Reset_Handler”对 Cortex-M 来说真正决定起点的是向量表在 Flash 中的位置。如果你的.isr_vector没有放在 SECTIONS 的最前面复位向量可能不在芯片期望的地址程序就会从错误的地方开始执行。3.2 为什么 .data 要写成 RAM AT FLASH先看 RAM它表示段.data的执行地址在 RAM也就是 g_count 这个变量实际存在于 RAM。再看AT FLASH它表示这份段数据在文件中的“加载地址”在 Flash也就是初始值100被烧录到 Flash 里。启动代码的任务就是从 Flash 的加载地址把这段内容复制到 RAM 的执行地址这正是_la_data LOADADDR(.data)存在的意义。如果省略AT FLASH链接器可能会认为数据段只需要放在 RAM。但烧录器烧的是 FlashRAM 里的数据无法在掉电后保留结果就是程序里明明写了int g_count 100上电后 g_count 并没有 100全看 RAM 残留内容是什么。这类问题最坑的地方在于第一次烧录后 RAM 里可能有旧数据碰巧便“看起来正常”第二次上电就完全变了。看到这种偶发性现象第一个应该怀疑的就是 .data 搬运。3.3 从 ELF 到烧录文件地址必须对得上编译链接最终会产生一个 ELF 文件里面已经包含段地址信息。调试器可以直接加载 ELF 运行但生产环境通常需要把它转成 bin 或 hex 再烧录。转换时特别容易忽略的一点是objcopy -O binary输出的内容会按照加载地址LMA排列而不是执行地址。所以 Flash 里第一个字节就应该是向量表而不是某个 RAM 段。如果发现烧录后程序无法启动你可以先用反汇编确认arm-none-eabi-nm xxx.elf | grep -E g_pfnVectors|Reset_Handler arm-none-eabi-objdump -h xxx.elf arm-none-eabi-objdump -d xxx.elf第一行看符号地址第二行看各段 VMA 和 LMA第三行看 Reset_Handler 是否真的是从开头执行。很多时候问题不是 C 代码写错而是地址安排错了。拿这些命令一查往往几分钟就能定位。3.4 链接脚本中的地址参数不能照抄有一次我把某开发板链接脚本里的 RAM 长度多写了一截芯片实际只有 64K RAM我却写成了 128K。结果栈指针_estack指向了一块不存在的地址程序上电后第一次调用函数就崩溃。检查启动代码觉得没问题反汇编也觉得没问题最后看芯片手册才发现是链接脚本里的 LENGTH 错了。所以链接脚本里的ORIGIN和LENGTH必须以目标芯片手册为准千万不要从别的项目里随手复制。Flash 起点、Flash 大小、RAM 起点、RAM 大小哪怕只差一个字都有可能让程序跑在一个“半假”的内存环境里。4. 裸机启动“薛定谔现象”的完整排查链路4.1 启动即 HardFault先别改业务代码有朋友遇到过这样的问题在 main 里写了一个点灯程序灯就是不会按预期闪反而一复位就进入 HardFault。他反复检查 main 里的 GPIO 配置怀疑是寄存器写错折腾很久都没解决。其实这种“启动即挂”的现象十有八九出在启动阶段而不是业务代码。我的排查链路通常是这样用调试器复位看 PC 第一条指令是不是 Reset_Handler。如果 PC 直接跑到 HardFault_Handler 或某个奇怪的地址先怀疑向量表。看 0x00000000 前两个值。如果芯片上电从 0x00000000 取向量表那么第一个值必须是合法的 RAM 地址第二个值必须是 Reset_Handler 入口。如果这两个值不对说明向量表不在基址处或启动文件没被链接进去。检查 SCB-VTOR。Cortex-M 的向量表基址可以由 VTOR 寄存器重定向。代码烧在 0x08000000 时很多芯片默认把 Flash 映射到 0启动没问题但如果你从 bootloader 跳转到 APP却没有把 VTOR 改成 APP 的向量表地址中断就会去旧地方找入口。用arm-none-eabi-objdump -h查看.isr_vector是否位于 Flash 第一个输出段。有时链接脚本里把某个调试段放在了向量表前面导致第一个字节不再是向量表。有一次我实际遇到的情况是启动文件没有被链接器纳入g_pfnVectors 符号虽然在但向量表段没有真正放到 Flash 起始位置。用nm查到符号地址后才意识到那个启动 object 文件没有被打进链接列表。这类问题不看符号表光盯着 C 代码永远找不到。4.2 全局变量初值“时而对时而错”的排查思路另一个高频现象是定义了一个全局变量int counter 100在 main 里读取时counter 却可能是乱码。最诡异的是第一次上电可能是对的按一下复位键就变了再按一次又可能对了。碰到这种现象不要先去怀疑编译器优化。直接做三件事在启动代码的 copy_loop 入口打断点查看_sdata、_edata、_la_data三个符号的值在 copy_loop 执行完之后再次查看 RAM 区域内容确认 100 确实被写入_sdata指向的地址用反汇编确认拷贝循环的源地址是 Flash而不是某个 RAM 地址。如果源地址_la_data是 0或者与 Flash 地址完全无关多半是启动代码引用的符号名和链接脚本不匹配。GCC 工具链中有人习惯用__data_start有人用_sdata链接脚本如果只定义了其中一个另一个符号就可能被链接器解析成 0。一旦拷贝源变成 0程序不会立即报错但数据全部错乱。解决办法很朴素在链接脚本里同时导出简洁明确的符号启动文件引用同一套符号不要混用。4.3 栈异常导致的“第一个函数就挂”还有一个同学遇到的场景更隐蔽启动不 HardFault编译正常但 main 里第一个函数调用就崩。原来是他把栈顶_estack设置到了一个超出 RAM 范围的地址。调用函数时参数和返回地址都需要压栈SP 一旦指向不存在的地方第一个push就会写入无效内存后续行为完全不可控。排查时用调试器看 R13SP的值和 RAM 边界。上电后 SP 应该等于_estack如果它的值大于 RAM 末端那就是链接脚本或启动代码设错了。还要注意Cortex-M 上默认使用 MSP主栈指针裸机环境下通常不切 PSP。若你为了实验强行把 CONTROL 寄存器改掉却没有准备好 PSP 的栈空间中断一来自动压栈就会用到非法栈指针。建议新手在启动阶段就把栈方向、栈大小、MSP 和 PSP 的关系彻底搞清楚不要等出问题再看。栈大小方面裸机程序的栈不需要很大但也不能精确到“刚好够用”。中断嵌套、调试器调用、临时变量压栈都会消耗额外栈空间。保守做法是多给 20% 余量至少不要优化到几乎没有冗余。4.4 时钟树没有初始化串口乱码和定时器飘在裸机环境中启动代码不仅管内存还要管时间。芯片上电后通常运行在内部低速时钟串口波特率、定时器定时时间都建立在某个系统时钟之上。如果时钟树没配好串口打印出来就是乱码定时器中断频率也会全部偏移。正规的做法是在调用 main 之前先通过 SystemInit 或等效的函数完成时钟初始化选择时钟源、等待外部晶振稳定、配置 PLL、设置总线分频。外部晶振起振需要时间不能一上电就立刻把系统主时钟切换到 PLL必须先等待稳定标志位置位。不同芯片的寄存器名字千差万别但流程是同一个套路。如果你发现串口乱码而硬件连接没问题可以先用调试器读一下当前系统时钟的实际值。再对比串口配置里使用的波特率计算基本就能确认是不是时钟初始化没做或者 PLL 参数配错了。5. 把启动代码当“地基”从裸机点亮到任务调度的进阶5.1 落地一个裸机项目时我会按这个顺序搭环境在裸机上写任何外设驱动之前最好先把“地基”搭稳。我习惯的顺序是先写链接脚本规划好 Flash 和 RAM再写启动代码确保能跳到 main然后在 main 里点一个 LED验证最基础的输出路径接着配串口打印启动日志最后才去写外设驱动和业务逻辑。很多教程一上来就让用户点灯其实点灯并不难难的是点灯背后那套“启动流程是可复现的”。如果跳过链接脚本直接套用现成工程模板你一旦需要改内存布局、加一个自定义段、或者从 bootloader 跳转 APP就会立刻踩坑。先搭地基后面填功能才会舒服。5.2 中断向量表重定向APP 从 bootloader 跳转时的关键一步如果你想让一个 bootloader 引导 APPAPP 的中断向量表可能不在 0x00000000。这种情况下APP 启动代码或系统初始化函数里必须设置 VTOR 寄存器把它指向 APP 向量表的实际基址。例如 APP 代码链接到 0x08010000那么早期初始化时应把 VTOR 设置为 0x08010000。否则按键触发一个中断CPU 会去旧向量表取入口大概率取到一个错误的地址然后跳进 HardFault。很多“从 bootloader 跳转后中断失效”的问题本质就是这个寄存器没设。设置 VTOR 时还要注意对齐。Cortex-M 要求向量表基址按表大小对齐通常是 0x200 或 0x400 对齐。你可以把向量表放在单独的链接段然后保证这个段的首地址符合对齐要求。如果地址没对齐芯片行为可能变得诡异且不稳定。5.3 从裸机轮询到简单协作调度器的小思路启动代码铺好路以后main 里通常是一个大循环。裸机环境不需要一上来就跑 RTOS很多场景用一个简单的协作调度器就够了。你可以用 SysTick 产生 1ms 节拍在 main 里维护几个“到期标志”不同任务通过状态机轮转执行。这样既能保证时间基线稳定又不用引入复杂的内存开销。如果你准备从协作调度往抢占调度走那就必须理解上下文切换也就是在 PendSV 或 SysTick 中断里保存当前任务寄存器、恢复下一个任务的寄存器。Cortex-M 的硬件会在进入中断时自动保存一部分寄存器但通用寄存器、返回地址等仍需手工处理。这套机制放在启动代码的语境里不过是向量表里 PendSV 入口从“死循环占位”变成了真正的调度函数入口。启动代码和裸机环境搭得越干净后面做系统级功能就越省事。向量表提供了异常入口链接脚本决定了任务栈和内核栈的位置启动代码则保证了 main 开始时所有内存区域都处于正确状态。到这一步你已经不再“只是点灯”而是在写一个真正可掌控的最小系统。5.4 调试启动代码的三个“笨办法”说三个不花哨但非常实用的调试方法都是我实际用过的在 Reset_Handler、SystemInit、main 入口分别打三个断点复位后看 PC 是否依次经过。如果第二个断点停不下来说明启动代码在搬运段数据时出了问题如果直接停在 HardFault_Handler优先查向量表。用反汇编看复位后的前几条指令确认第一条是设置 SP而不是跳转到了奇怪的地址。Cortex-M 复位后会先取 SP 再取 PC所以你看到的第一条用户指令通常是从 RAM 读栈顶后设置 sp。在启动早期点亮一个 LED每完成一步就移动一次点灯的位置。通过灯的亮灭状态可以快速二分定位是哪一步崩的。这个方法虽然原始但在调试器连接不上的早期板子上非常管用。这几年调 ARM 裸机我的最大感受是启动代码并不是高深魔法它只是一段在 C 语言环境建立之前必须先运行的“引路人”。你越把它当成一份确定性的流程越容易控制整个项目。下次遇到上电跑飞先不要怀疑业务代码去检查复位入口、向量表和链接脚本里的地址大多数问题都在那儿等着。还有一个很小的习惯每次新建裸机工程我会在链接脚本末尾加一行打印 RAM 占用提示然后保留一份启动日志。真正重要的往往只有三样东西一个对齐的向量表、一份正确标注 AT 地址的链接脚本、一个舍得在复位阶段打日志的启动流程。