这两年带嵌入式固件团队,我面试过不少人,也在社区答疑帖里看到过大量相似的问题。很多人单片机裸机跑得飞起,RTOS 也用得很熟,但是一旦被问到启动流程就开始含糊:上电后的第一条指令到底在哪?中断向量表为什么非要放在最前面?SRAM 都还没初始化,这时候能不能碰全局变量?再往深处问,设备量产之后偶发死机怎么定位?OTA 升级做到什么程度才敢说“工程化”?能完整讲清楚的人真的不多。
这个专栏的定位一直是嵌入式固件进阶。前面几篇讲了工程化构建、调试器用法和代码可维护性,这篇把三块硬骨头一次拆透:启动流程深度拆解、故障定位方法论、OTA 升级工程化实战,顺便把上一期课后留的思考题完整解析一遍。内容偏底层,但全部来自我带项目时踩过的坑和沉淀的方法,适合已经能独立做单片机开发、想往更扎实的固件方向进阶的工程师。
1. 上电第一条指令到底在干什么:从向量表到 main 函数的完整路径
1.1 复位后处理器没在执行代码,而是在“查表”
很多从 8051 或者简单 MCU 转过来的工程师,第一次看 Cortex-M 的启动文件都会懵:Reset_Handler前面为什么还摆着一大张表?这张表就是中断向量表,它不只是给中断用的,处理器复位后的第一个动作,本质上是“查表”,而不是“执行”。
Cortex-M 内核在复位释放后,硬件会自动做两件事:从地址0x00000000读取初始栈指针(MSP)的值,写入主栈指针寄存器;再从地址0x00000004读取复位向量,也就是Reset_Handler的入口地址,写入 PC。这两步是芯片流片时就定死的硬件行为,不需要任何软件参与。
0x00000000 初始 MSP 值(栈顶地址) 0x00000004 Reset_Handler 入口地址 0x00000008 NMI_Handler 入口地址 0x0000000C HardFault_Handler 入口地址 ...这里有一个值得细想的点:为什么 ARM 要把向量表设计成“首两项就是 MSP 和复位向量”?本质上是为了让芯片在上电瞬间,连一段引导代码都不需要,就能直接进入 C 语言的世界。栈指针有了,PC 有了,第一条指令就可以是普通的指令流,而不像很多老架构那样要先跑一段汇编才能把栈设好。Cortex-M 用一张表解决了从硬件复位到软件执行的最小初始化问题。
1.2 向量表重定位:一旦跑 Bootloader,这件事绕不开
紧接着就引出一个被问烂但依然有很多人搞错的点:变量和函数地址在链接时就被固定到 Flash 里了,那我在 RAM 里跑一个中断服务程序,中断来了还能不能正确跳转?
Cortex-M 的中断响应同样要查向量表。芯片复位时,向量表的基地址默认是0x00000000,这是硬件默认值。如果你的程序直接烧在 0x08000000(典型 STM32 的内部 Flash),并且没有改变向量表基址,中断响应依然会去0x00000000查表。很多 Cortex-M0 芯片内部有映射机制,把 Flash 映射到 0 地址,所以看起来相安无事。
一旦你做了 Bootloader,情况就变了。App 放在0x08008000,但芯片复位后向量表基址还是 0,Bootloader 把控制权交给 App 之前,必须把向量表基址改到 App 所在的地址。Cortex-M3/M4 有专门的寄存器 VTOR(向量表偏移寄存器),在 App 启动早期就要设置:
/* 假设 App 起始地址为 0x08008000 */ SCB->VTOR = 0x08008000;这里最容易犯的错是:改了 VTOR 但中断向量表本身还存放在 Flash 的 0x08008000 处,这没问题。但如果你用的是 RAM 中的向量表,比如做 USB DFU 或者 IAP 时想动态修改中断响应,就必须先把整个向量表从 Flash 拷贝到 RAM,再把 VTOR 指向 RAM 的地址,并且注意向量表对齐。Cortex-M 要求向量表基址按 64 字节或更大粒度对齐,具体看芯片参考手册,乱对齐会导致查表错位,表现就是中断乱跳、莫名进 HardFault。
1.3 从 Reset_Handler 到 main:谁把全局变量搬进 RAM 的
复位向量指向Reset_Handler,但Reset_Handler并不是直接调main。打开任何一份标准启动文件,你会看到类似这样的逻辑:
Reset_Handler: ldr sp, =_estack /* 保险起见,再设一次栈指针 */ bl SystemInit /* 初始化时钟、Flash 等待周期等 */ bl __main /* C 运行时初始化,不是 main() */__main是 C 库提供的入口,它做了几件非常重要的事:
- 拷贝 RW 段:把启动文件里定义的、初始值非零的全局变量,从 Flash 拷贝到 RAM。
- 清零 ZI 段:把初始值为零的全局变量所在的 RAM 区域全部清零。
- 设置堆栈边界、调用
__rt_entry,最终才调用你写的main()。
所以,“全局变量为什么一上电就有初始值”这个问题的答案,不是编译器魔法,而是启动代码在main之前完成了搬运和清零。这也就解释了一个经典坑:如果你在SystemInit里访问了一个带有非零初值的全局变量,此时 RW 段还没拷贝完成,读到的可能是 Flash 里的值或者随机值,不同芯片表现还不一样,调试起来非常隐蔽。
要看得透彻,最好的办法是在调试器里打断点:先断在Reset_Handler,单步看__main的汇编,你会亲眼看到搬数据的过程。带过不少人做这一步,几乎每个人都跟我说“原来如此”。
1.4 MCU 和 MPU 的两级启动:imx6 这类芯片为什么要分 IVT、DCD、uboot
专栏里有个读者提问特别有代表性:MCU 和 SOC 的启动流程有什么区别?这个问题确实值得掰开讲。网上搜“imx6 ivt 启动流程”能搜到一堆术语,但能把整条线讲清楚的少。
以 i.MX6 这类应用处理器为例,它内部有一个固化在芯片里的 Boot ROM。芯片上电后,运行的第一段代码是 Boot ROM 而不是用户代码。Boot ROM 会根据 eFUSE 或 GPIO 电平选择启动介质,比如 SD 卡、eMMC、NAND,然后从介质固定偏移位置读取 IVT(Image Vector Table)和 Boot Data。IVT 里记录了镜像入口地址、DCD 地址等关键信息。
这里有个 MCU 工程师很难想象的点:DCD(Device Configuration Data)是用来初始化外部 DDR 的。应用处理器的代码跑在 DDR 里,但 DDR 控制器本身也是要初始化才能用的,这就形成了一个“先有鸡还是先有蛋”的问题。Boot ROM 的做法是:把 DCD 数据读进来,用这些数据去配置 DDR 控制器和时钟,让外部内存可用,然后再把真正的 uboot 镜像加载到 DDR 并跳转。uboot 再做后续的外设初始化,最终引导内核。
和 Cortex-M 的差异核心在哪?Cortex-M 是“片上 Flash 直接映射执行”,也就是 XIP,代码就地运行;而应用处理器经常要从外部存储介质加载到内存里执行。所以 MCU 的启动代码关注向量表、分散加载,SOC 的启动流程关注介质读取、DCD 配置、二级引导。很多人把“uboot 启动流程”和“MCU 启动流程”放在一起看,越看越乱,本质原因是没有先区分清楚执行模型。
2. 故障定位方法论:现场保留比原因分析更重要
2.1 一个生产事故复盘:设备偶发死机,最先做对的事情救了整个项目
讲方法论之前,先分享一个真实案例。有一款设备,量产之后客户反馈偶发死机,大概三四天一次,没有任何固定规律。第一轮排查,同事的做法是“死机了就重新上电,抓日志”。结果折腾了两周,什么也没抓到,因为复位之后所有现场全部丢失。
后来我介入,要求全团队改变策略:死机之后绝对不能立刻复位,必须先保留现场,然后再复位。我们在 HardFault_Handler 里加入了现场快照功能,把关键寄存器、栈指针、LR、PC、几个关键内存段全部保存到一片独立的备份 RAM。这样设备即使复位了,下次启动时 bootloader 也能读到上次的故障快照,通过串口或者日志上报出来。
正是靠着这份现场快照,我们定位到了问题根源:一个 I2C 从设备在极端时序下无响应,I2C 驱动里的等待循环设计成死等,导致某个高优先级任务卡死,然后一堆任务堆栈越界,相互踩踏,最终系统崩溃。如果没有现场快照,这个偶发问题可能永远复现不了。
这件事给我最大的教训是:故障定位的第一步不是找原因,而是证明现场。你连事故现场都没有,谈什么推理。
2.2 Cortex-M 的故障寄存器:HardFault 也不一定就是 HardFault 的错
Cortex-M3/M4 内核里有一组故障状态寄存器,它们是定位硬件异常的“黑匣子”。很多人一进 HardFault_Handler 就知道死循环,完全没想过先看这几个寄存器,实在太可惜。
| 寄存器 | 名称 | 关键信息 |
|---|---|---|
| CFSR | 可配置故障状态寄存器 | 由三个子寄存器组合而成 |
| MMFSR | 存储器管理故障状态寄存器 | 是否访问了非法地址、MPU 违规 |
| BFSR | 总线故障状态寄存器 | 取指/数据访问时总线错误,地址在 BFAR |
| UFSR | 使用故障状态寄存器 | 未对齐访问、除零、无效指令等 |
| HFSR | 硬故障状态寄存器 | 是否有 fault 升级到 HardFault |
| BFAR | 总线故障地址寄存器 | 记录访问出错的具体地址 |
| MMFAR | 存储器管理故障地址寄存器 | 记录存储器管理出错的具体地址 |
实际排查时,我习惯在 HardFault_Handler 入口把 CFSR、HFSR、BFAR、MMFAR 连同 PC/LR/PSR 一起打包,存到固定的全局结构体里。用调试器挂上的时候,直接查看这些值,比在 Call Stack 窗口瞎猜要快得多。
举一个典型场景:BFSR 里 IBUSERR 位置位,说明 CPU 取指时发生了总线错误。最常见的原因是函数指针被破坏、调用了一个非法地址的函数、或者从空指针 + 偏移的位置取指令。UFSR 里 DIVBYZERO 位置位,基本就是某个地方整数除以零。这里注意,Cortex-M 默认不开启除零陷阱,只有当 CCR.DIV_0_TRP 置位时才会进 fault,很多工程师不知道这个开关。
2.3 栈回溯:怎么看懂 LR 里的 EXC_RETURN 和保存的 PC
拿到现场后,怎么还原调用链?答案是栈回溯。Cortex-M 处理器在进入异常时,硬件会自动把 xPSR、PC、LR、R12、R3-R0 压入当前栈。问题来了:压到 MSP 还是 PSP,取决于当前使用的是主栈还是进程栈,而这个信息就编码在异常返回时 LR 里的 EXC_RETURN 值中。
| EXC_RETURN | 含义 |
|---|---|
| 0xFFFFFFF1 | 从 Handler 模式返回主栈(MSP) |
| 0xFFFFFFF9 | 返回线程模式,使用 MSP |
| 0xFFFFFFFD | 返回线程模式,使用 PSP |
举个例子。你的任务运行在 RTOS 里,使用的自然是 PSP。任务执行中发生 HardFault,硬件把现场压到了 PSP 指向的任务栈,同时在异常入口,LR 的值看起来是 0xFFFFFFFD。这时候你要做的不是盯着寄存器窗口发呆,而是找到 PSP 的值,然后从 PSP 指向的内存开始,按“R0、R1、R2、R3、R12、LR、PC、xPSR”的顺序把寄存器解出来。
解出来的那个 LR,才是任务被打断之前真正要返回的地址,PC 才是断点位置。用这个 PC 在反汇编窗口里定位,通常能准确看到是哪个函数、哪一行出了问题。再往上,函数调用者的地址保存在栈的更深处,需要结合编译器的栈帧布局继续回溯。
这个技能在平时看起来没什么用,但在偶发问题面前,比任何工具都好使。我在专栏里反复强调过一个观点:用调试器复现问题,是普通工程师的做法;从现场反推问题,才是进阶工程师的做法。因为很多现场问题复现率极低,抓不到第二次。
2.4 没有调试器的产线/现场怎么办:水印、日志分级和异常上报三板斧
带项目多了之后,你会发现很多故障发生在没有调试器的环境,比如客户现场、产线老化房、户外设备。这时候方法论的比工具更重要。
第一招是堆栈水印。上电启动时,把所有任务栈和主栈全部填充成固定值,比如 0xCC。运行一段时间后,扫描栈空间,看还有多少 0xCC 没被覆盖,就能估算出每个栈的实际最大深度。这个方法不打断运行,非常适合做长时间稳定性测试。我的习惯是每 10 分钟扫描一次,低于设定的安全余量就报警。
第二招是日志分级和触发式打印。很多人打日志的习惯是“想打就打、满屏都是”,结果故障发生时真正的关键日志反而被淹没了。我的做法是分三级:运行轨迹级别(只记录任务切换和关键状态机跳转)、数据级(记录协议帧和关键变量)、调试级(只有串口调试时才打开)。平时跑现场用第一级,偶发故障时靠第一级日志大概率能圈出问题范围。
第三招是异常上报。把故障时保存的寄存器快照、栈回溯结果、PC 地址,打包成一条固定格式的日志,通过产测串口、4G 模块或者网关上报。这样哪怕设备已经复位重启,故障记录已经留下来了,后面只需要导出来分析即可。
这三大板斧实现成本都不高,但对故障定位速度的提升是数量级的。很多团队把精力全部花在“优化代码避免故障”上,从来不部署故障取证能力,这是本末倒置。我一般建议先取证、再定位、后修复。
3. OTA 升级工程化实战:从“能升级”到“敢升级”
3.1 为什么很多 OTA 一推就“变砖”:不是升级动作的问题,是系统设计的问题
OTA 升级这件事,看起来很简单:下载固件、擦写 Flash、跳转。真做过量产产品的人都知道,OTA 难度不在功能实现,而在边界条件。升级到一半断电了怎么办?新固件有 bug 启动不了怎么办?升级过程中看门狗超时复位了怎么办?多设备升级时,如何保证最低成功率?
很多团队 OTA 翻车,不是下载或擦写代码写得不好,而是把 OTA 当成一次性动作而不是一个带状态机的流程。OTA 必须设计成一个可以被中断、恢复、回退的“状态系统”,否则只能在理想环境里自娱自乐。
3.2 A/B 双分区架构:用一半 Flash 空间换“永不砖机”
现在比较稳妥的方案是 A/B 双分区。简单说,Flash 里划分两个完全独立的 App 区,一个当前运行,一个预留给升级。Bootloader 根据标记位决定启动哪个区。这个方案相当于用一半的 App 空间换取了极高的可靠性,在存储空间充裕的产品里,是首选。
一个典型的 A/B 分区表长这样:
| 分区 | 起始地址 | 大小 | 作用 |
|---|---|---|---|
| Bootloader | 0x08000000 | 64KB | 固件引导、升级逻辑、回退判断 |
| App_A | 0x08010000 | 512KB | 当前运行区 / 升级目标区 |
| App_B | 0x08090000 | 512KB | 当前运行区 / 升级目标区 |
| Factory | 0x08110000 | 64KB | 出厂固件,最后兜底 |
| 状态区 | 0x08120000 | 32KB | 升级标记、回退计数、CRC 记录 |
为什么状态区要单独占一个扇区?大部分 Flash 的扇区擦除粒度是 4KB 或更大,如果升级标记和 App 代码放在同一个扇区,擦写 App 时会连带把标记擦掉。状态区独立出来,Bootloader 和 App 都可以单独访问它,互不影响。
3.3 升级状态机:写对一半断电也不会砖机的核心
我设计 OTA 升级流程时,强制要求状态机覆盖以下几个状态:
- 下载完成并校验通过后,把新固件写入非活动分区。
- 写完后对非活动分区做整包哈希校验,确认数据完整。
- 在校验通过后,在状态区写入“待升级”标记。
- 软件复位,Bootloader 启动。
- Bootloader 检查到“待升级”标记,校验新分区,然后跳转到新 App。
- 新 App 启动后,不立即清除标记,而是先上报版本信息或发心跳。
这里的关键,在于第 6 步之后还有一个“提交”动作。新 App 只有正常运行了一段时间,比如 30 秒,确认没有启动崩溃,才会把状态区改成“当前区”,并清除回退标记。这个机制叫启动确认与自动回退。
如果新 App 有严重 bug,一启动就 HardFault 或反复复位,看门狗会不断复位设备。Bootloader 每次启动时,发现“待升级”标记还没被清除,并且启动计数超过阈值,就自动回退到另一个分区。这个回退策略不需要任何人工参与,就能避免整批设备变砖。
3.4 回退计数器的设计细节:为什么不能只用单个变量
回退计数器的实现有几个隐蔽的坑。最典型的是:直接用 Flash 里的单个变量做计数,每次启动加一。但 Flash 的擦写寿命有限,如果升级反复失败,这个扇区会被快速磨损。
我的做法是用一整块独立扇区模拟 EEPROM,每次只擦写整个扇区,并在扇区头记录一个累加的序列号。Bootloader 每次读取时扫描所有槽位,取序列号最大的那个作为有效值。扇区写满或者序列号溢出时再做一次整体搬移和擦除。这个方案虽然代码量多几十行,但能把擦写寿命提升几个数量级。
如果嫌麻烦,至少也要用两个备份标记做交叉校验。比如用四个 word 存储状态,0x5A5AFFFF表示 A 有效,0xA5A50000表示 B 有效,只有当两个备份一致时才采信,否则按无效状态走回退流程。这里的原则是:任何关键标记都不能只存一份。
3.5 防降级、签名校验和升级过程的中断服务处理
除了流程,OTA 还有三个容易被忽略的工程化细节。
依赖鸿蒙内核实现,第一个是防降级。用版本号字段,Bootloader 和 App 都要校验“新版本号 >= 当前版本号”,否则拒绝升级。这个逻辑写起来很简单,但不写的人并不少。产线经常需要回退某个版本,如果没有版本号强制判断,生产工具和 OTA 服务容易互相打架。
第二个是固件签名。OTA 下载的固件必须做完整性校验和来源认证。完整性用 SHA-256 哈希就够了,来源认证则需要非对称签名。芯片侧内置公钥,升级工具用私钥签名,Bootloader 验签通过才允许写入。这套机制可以防止伪造固件、中间人篡改,是“工程化”与“demo”的分水岭。
第三个是升级过程中的中断服务处理。写入 Flash 是一个相对较慢的操作,尤其是擦除扇区时,功耗和耗时都比较高。Flash 编程期间,如果来了高优先级中断,CPU 正在执行 Flash 擦写命令,此时访问 Flash 会总线挂死。这就要求在擦写 Flash 时,要么关闭中断、要么把擦写操作放到临界区,并且有独立看门狗喂狗机制,防止擦写时间过长导致看门狗复位。关闭中断的时间太长会影响实时性,所以很多芯片支持 Flash 编程时 CPU 从 RAM 执行代码,把擦写函数放到 RAM 中运行,这是最稳妥的解法。
4. 上篇课后思考题完整解析:把概念变成判断力
4.1 思考题一:中断向量表首两项为什么必须是 MSP 和复位向量?如果复位向量填错会怎样
题目回顾:MCU 启动时,为什么向量表的首两项必须是初始栈指针和复位向量?如果把复位向量故意填成一个奇数地址,会发生什么?
第一问的答案在于 Cortex-M 的硬件设计哲学。处理器复位后要在没有任何软件执行的情况下,准备好两个最核心的硬件状态:栈指针(SP)和程序计数器(PC)。SP 是 C 语言函数调用的基础,PC 是取指的基础。把这两个值固定在向量表的最前面,硬件就能在复位瞬间完成“从表项加载值到寄存器”的操作,一步到位进入代码执行,不需要任何前置引导程序。
第二问更考验对 Thumb 指令集的理解。Cortex-M 只支持 Thumb/Thumb-2 指令,不支持 ARM 状态。ARM 架构规定,CPU 跳转到某个地址执行时,地址的最低位必须为 1,表示目标状态是 Thumb 模式。复位向量表的 PC 值也是一样,硬件在复位时会读取这个值并写入 PC,同时要求 bit0 为 1。如果写成了奇数地址,处理器可能直接进入 fault 状态;如果写成一个偶数但未对齐的地址,行为不可预知。这就是为什么 Keil 和 GCC 生成的启动文件里,Reset_Handler的地址一定是奇数,因为它被汇编器自动加上了 Thumb 标志。
4.2 思考题二:从复位到 main 调用之前,全局变量为什么已经“看起来”可用了
题目回顾:在 SystemInit 里访问一个带非零初值的全局变量,为什么是不靠谱的?
上一篇文章留这道题时,不少人答“全局变量编译后在 Flash 里,可以直接读”。这么说也对,也不对。关键在于“非零初值全局变量”的存放位置和访问方式。
编译链接之后,带非零初值的全局变量会被放入 RW 段,它的初始值在 Flash 里,但它真正的“家”在 RAM 里。程序运行中访问这个变量,访问的是 RAM 地址,不是 Flash 地址。启动代码__main的作用,就是把 Flash 里的初始值搬运到 RAM 地址中,然后程序才去使用它。
如果你的代码在搬运完成之前就去读这个变量,可能出现两种结果:如果链接器把变量放在了 Flash 映射的地址区间,你读到的可能是初始值本身,看起来一切正常;如果链接器把它放在了 RAM 地址区间,你读到的就是 RAM 上电后的随机值。在不同的芯片、不同的链接脚本下行为还不一致,这就是典型的隐蔽 bug。
顺带补充一个进阶点:为什么.bss段(零初值全局变量)只需要清零,不需要从 Flash 拷贝?因为所有零初值变量的初始值都一样,都是 0,直接清零 RAM 区域即可,省 Flash 空间。而 RW 段的每个变量初始值不同,只能用拷贝的方式搬过去。理解了这一点,以后看链接脚本就不慌了。
4.3 思考题三:设计一个“升级到一半掉电也不怕”的 OTA 方案,你会在分区和状态标记上怎么做
题目回顾:如果让你设计一个保证“升级过程中任意时刻掉电,设备仍然能再次升级”的 OTA 方案,分区和状态标记应该如何设计?
这道题没有唯一标准答案,但有几个必答点。
分区上,至少要有两个完全独立的 App 区和一个独立的 Bootloader 区。为什么强调“独立”?因为升级过程中写入非活动分区时,一旦掉电,最多是非活动分区的数据不完整,活动分区还是完好的,Bootloader 依然能找到可启动的系统。如果只有一个 App 区,升级写一半掉电,等于把系统写没了。
状态标记上,不能只靠一个变量表示“我要升级”。我的做法是:状态区里保存两个独立标记,一个是“目标分区序号”,一个是“升级完成标志”。只有两个标记交叉校验都通过时,Bootloader 才认为升级是完整的。任何一方不一致,Bootloader 都判定“上次升级未完成”,自动回退到旧分区。
还有一个加分项:Bootloader 自身不能被升级。Bootloader 一旦写坏,整个系统就没了进入恢复模式的入口。如果一定要支持 Bootloader 升级,必须提供独立于主 Bootloader 的 ROM 引导程序,比如芯片厂家的 System Bootloader 作为最后兜底。
4.4 思考题四:HardFault 异常入口处 LR 为什么是 0xFFFFFFF9,而不是普通函数返回地址
题目回顾:在 HardFault_Handler 里看到 LR 的值是 0xFFFFFFF9,这是不是说明程序是从这个地址调进来的?
这个问题能过滤掉相当一部分人。0xFFFFFFF9 这个值一看就不是普通的 Flash 或 RAM 地址,它属于 Cortex-M 定义的 EXC_RETURN 编码范围。它的含义是:当前正在使用 MSP,异常返回后要回到线程模式继续执行。
那这个值是怎么来的?处理器响应异常时,硬件会把 LR 设置成 EXC_RETURN 值,而不是保存被打断现场的返回地址。真正要返回的地址保存在栈里,也就是异常压栈时的 PC 值。所以如果在 HardFault_Handler 里直接看 LR,你看到的永远是 0xFFFFFFFx 这种值,而不是程序现场的地址。
正确做法是:先看 PSP 或 MSP(取决于 EXC_RETURN 值),再解析压栈的寄存器,找到保存的 PC、LR、xPSR。那个 PC 才是触发异常时刻真正执行的地址。这也是我在第 2.3 节反复强调的栈回溯方法。
结尾:一个老工程师的额外补充
最后再分享一个绕了不少弯路的经验。早期我总觉得“理解了原理就够了,代码能跑就行”,直到接手几个在量产现场被偶发故障反复折磨的项目,才被逼着把启动文件、链接脚本、异常现场、栈回溯这些底层机制逐个翻了个底朝天。现在我带人做固件,要求每个人至少在开发板上做两件事:一件是用调试器观察从复位到 main 之间的每一条指令,一件是人为制造一次 HardFault 把栈回溯完整跑通。这两件事做完,专栏前面的内容基本就内化成自己的判断力了。
下一篇准备写连接脚本与内存布局的进阶用法,重点讲 Scatter 文件、符号表、attribute段属性在实际项目里的定位,提前把链接器这块“灰色地带”补一补。如果你在阅读过程中有哪个启动流程细节卡住了,多半不是理解问题,而是链接脚本没看透,到时候我们一期一期把它彻底解开。