1. 启动流程:从复位向量到 main(),底层到底发生了什么
做嵌入式固件这些年,我见过不少朋友遇到“板子起不来”的问题,第一反应就是拿示波器量晶振、量复位脚,量完一片茫然。其实启动这块是有章可循的——不管你是跑裸机、RT-Thread,还是基于 U-Boot 引导 Linux,底层都遵循一条相似的链路:复位后 CPU 从固定地址取指,然后完成硬件初始化、运行时环境准备,最后才把控制权交给应用代码。区别只是每一步做得粗还是细、谁来做。
1.1 MCU 侧:复位向量、启动文件与 C 运行时初始化
以常见的 Cortex-M 系列 MCU 为例,上电复位后,CPU 硬件会自动从向量表偏移 0x00000000 处取出初始栈指针 MSP,从偏移 0x00000004 处取出复位向量,然后跳转执行。这个过程不需要任何软件干预,是芯片设计时就定好的规则。也就是说,向量表的最前面两个 32 位字,一个决定了 C 语言函数调用时栈在哪,一个决定了第一条指令从哪开始,缺一不可。
实际工程里,这两个值由启动文件(startup_xxx.s)定义。启动文件做的事远不止“跳转 main”,它一般会依次完成:
- 定义栈空间和堆空间,并初始化对应的符号。
- 定义中断向量表,填好默认的中断服务函数。
- 调用
SystemInit(),这一步通常用来配置 Flash 等待周期、设置时钟源等基础硬件。 - 将 RW 数据段从 Flash 复制到 RAM,完成 data 段初始化。
- 清零 ZI 段(也就是 BSS 段),保证未初始化的全局变量为 0。
- 调用
__main(在 ARM Compiler 环境下)或直接调用main()。
很多人以为“main 之前的看不到就不用管”,但恰恰是这几步最容易出问题。比如栈指针给得不够大,函数嵌套一深就溢出;比如 data 段复制没做,全局变量初值是随机数;再比如 BSS 段没清零,malloc 的堆指针初始化就可能异常。对于 MCU 开发,启动文件的这些细节建议当成必读代码,而不是一味用 IDE 默认模板。
1.2 SoC 侧:BootROM、引导加载程序与内核交接
到了 SoC 级别,比如 i.MX 系列、全志 V3s、瑞芯微 RV1126 这些带 MMU、能跑 Linux 的芯片,启动流程就要长得多。芯片内部固化了一段 BootROM,上电后 BootROM 根据启动引脚或 eFuse 的状态,从 SD 卡、eMMC、SPI Nor Flash 或 USB 下载模式中选择一个介质,把下一级引导程序加载到 SRAM 或片内 RAM,然后跳转过去。
这里有一个新手容易忽视的点:BootROM 一般只能认特定格式的镜像头。比如 NXP 平台的 IVT(Image Vector Table),在烧录时需要把镜像按照头部约定的偏移地址写入存储介质,不然 BootROM 找不到可执行镜像,板子就卡死在串口没输出的状态。
从 BootROM 交接出来的通常先是最小化的引导程序,也就是 SPL、U-Boot 这类。U-Boot 自身的启动流程也很清晰:
- 第一阶段(SPL):初始化 DDR、时钟、串口,加载完整版 U-Boot 到 DDR。
- 第二阶段(完整 U-Boot):继续初始化外设,读取环境变量,执行 bootcmd。
- 最后解析 FIT image 或原始内核镜像,把设备树、内核镜像放到约定内存地址,设置好参数后跳到内核入口。
这个阶段最大的体会是:SoC 启动链每一层的介质、地址、格式都不能错。很多“起不来”的问题,最后的根因往往不是内核配置错了,而是 U-Boot 之前的那一步就挂了,只是你没看到输出而已。
1.3 MCU 与 SoC 启动流程的一张对照表
我自己做调试时喜欢做对照表,方便快速定位问题到底出在硬件复位、引导加载还是应用初始化。下面这张表基本覆盖了日常工作的场景:
| 阶段 | 典型 MCU(Cortex-M) | 典型 SoC(Cortex-A + U-Boot) |
|---|---|---|
| 上电复位 | 硬件从向量表取 MSP 和 Reset_Handler | BootROM 根据启动介质加载 SPL |
| 早期初始化 | SystemInit + 时钟/Flash 配置 | SPL 初始化 DDR/时钟/串口 |
| 环境准备 | data 段复制、BSS 段清零 | 加载完整 U-Boot,设置环境变量 |
| 引导交接 | 跳转 main() | bootcmd 加载内核与设备树,跳转 |
| 失败特征 | 进不了 main、HardFault、看门狗复位 | 无串口输出、DDR 初始化失败、内核 panic |
这张表的用途不是背诵,而是告诉你排查起点:MCU 挂了先查向量表、栈、启动文件;SoC 挂了先查介质选型、镜像格式、BootROM 阶段的串口打印。两者不是一个套路,但核心思想一样——从复位后的第一条有效指令开始,一步一步跟踪。
2. RT-Thread 和 U-Boot:两个高频启动链路的逐层拆解
如果说上一章讲的是芯片级别的启动,这一章我们把视角抬高到软件层面。实际项目里,跑 RT-Thread 的 MCU 和跑 U-Boot 的 SoC 是两个最常见场景。两者虽然不同,但启动逻辑里有很多可以互相对照的东西。
2.1 RT-Thread 的启动流程:从复位到调度器跑起来
RT-Thread 的启动分为两部分:板级初始化前的汇编阶段,和进入 C 语言之后的初始化阶段。汇编阶段和裸机 MCU 一致——启动文件把硬件环境准备好,调用entry(或main,取决于宏配置)。如果开启了RT_USING_USER_MAIN,RT-Thread 在完成内核初始化后,会专门创建一个“main”线程来运行用户 main 函数,这是它和裸机很大的一个区别:你的 main 不再是真正的主线,只是众多线程里的一个。
C 语言阶段的关键路径大概是这样:
rt_hw_board_init:初始化系统时钟、串口、堆内存,必要时完成内存堆的注册。rt_system_heap_init:把一段内存交给内核堆管理器。rt_application_init:创建 main 线程,线程入口运行main()。rt_thread_idle_init:创建空闲线程。rt_system_scheduler_start:启动调度器,不再返回。
在调试 RT-Thread 启动问题时,我建议把注意力放在这几个地方:板级初始化函数里是否把堆初始化了、串口驱动是否在调度器启动前就已可用、系统的 tick 是否来自硬件定时器。如果串口驱动初始化放在了调度器启动之后,那么你很可能在启动早期根本看不到任何日志,误以为板子没跑。
另外,RT-Thread 的启动还有一个容易踩的坑:rt_hw_board_init里如果对内存堆RT_HEAP_SIZE配置得过大,而实际 RAM 不够,rt_system_heap_init传的起始地址和结束地址就会不对,轻则创建线程失败,重则直接 HardFault。解决办法是先算清楚工程里最大 RAM 占用,再定堆大小,别拍脑袋。
2.2 U-Boot 的启动流程:SPL、FIT image 与 bootcmd
U-Boot 是嵌入式 Linux 引导阶段的事实标准,它的启动流程比 RT-Thread 更强调“分级加载”。现代 U-Boot 一般会编译出 SPL 和完整 U-Boot 两个部分。SPL 是精简版,占用小,跑在 SRAM 里;它负责最基础的 DDR 初始化和外部存储介质驱动,然后把完整版 U-Boot 从介质中读取并跳转过去。
完整版 U-Boot 启动后,先做板级初始化,接着进入交互界面(如果你没打断启动,它会自动执行环境变量里的bootcmd)。bootcmd经常是一连串命令,比如:
load mmc 1:1 0x82000000 image.itb; bootm 0x82000000意思是先从 MMC 设备的分区 1 加载 FIT 镜像到内存,再通过bootm启动。FIT image 是个灵活的东西,可以把内核、设备树、ramdisk 打包在一起,也可以带多个配置,比如不同的设备树文件对应不同硬件版本。
U-Boot 启动调试的常见卡点在几个地方:DDR 时序参数不对导致没输出、环境变量里的分区编号和实际烧录位置不一致、FIT image 里的 entry-point 和 load-address 写错。如果你在串口上只看到 U-Boot 的 logo 就停住,优先查 SPL 到完整 U-Boot 的加载地址;如果你看到Starting kernel ...之后没有后续,优先查内核和设备树在内存中的加载地址是否正确,以及内核解压所需的内存区域是否和 DTB 里的 reserved-memory 冲突。
2.3 启动卡死的快速分层定位法
我实际操作中总结了一个经验:把整个系统启动按“能看见什么”分成四层,逐层判断:
- 第一层:完全无输出。往往是 BootROM 阶段就没跑起来,或者串口初始化之前的硬件问题,也可能烧录地址错了。
- 第二层:只有 BootROM 或 SPL 输出,之后死掉。问题大概率在 DDR 初始化、外部存储驱动或镜像读取阶段。
- 第三层:U-Boot 完整版有输出,但执行 bootcmd 时死掉。查环境变量、mmc 分区、FIT image 的加载地址。
- 第四层:内核开始启动,但起不来。这时候看内核串口日志是哪个模块报错,多数是设备树配置问题。
这个分层法很简单,但真的管用。每次收到同事说“板子起不来”,我都会先要一段完整的串口 log,看到底是断在哪一层,再用对应层级的工具去查,而不是一开始就怀疑内核配置或者应用程序 bug。
3. 故障定位方法论:把“玄学”问题变成可推导的排查链路
固件开发里最让人头疼的不是难写的功能,而是那种偶发、难复现、看起来毫无逻辑的故障。我早些年遇到一个项目,板子运行几小时后随机死机,硬件同事说是软件问题,软件同事怀疑硬件焊接,最后花了两周才定位到是中断优先级配置错导致中断嵌套风暴。从那时候起,我就意识到:必须有一套系统性的故障定位方法,而不是靠灵感去试。
3.1 启动类故障的经典分类与特征
我习惯把启动类故障分成四类:硬复位类、跑飞类、阻塞类和资源不足类。
- 硬复位类:表现为系统反复重启、复位引脚有毛刺、看门狗超时。常见原因是电源瞬态跌落、晶振起振慢、代码里写到了非法地址触发总线错误。
- 跑飞类:表现为 PC 指针跑到非代码区、HardFault、甚至各外设状态混乱。常见原因是栈溢出、野指针、数组越界、中断里调用了非中断安全函数。
- 阻塞类:表现为程序停在某个死循环、等待某个标志位一直不置位、串口无后续输出。常见原因是外设初始化失败但没有状态检查、信号量没人释放。
- 资源不足类:表现为创建任务失败、malloc 返回 NULL、堆栈溢出检测报警。常见原因是堆配置太小、任务栈分配不合理。
分类的意义在于,不同类的故障对应不同的定位工具。硬复位类优先用示波器配合复位源寄存器;跑飞类优先用故障记录寄存器和调用栈回溯;阻塞类直接看线程状态和信号量列表;资源不足类则看内存统计和栈高水位。把这些手段提前准备好,现场应变才有底气。
3.2 三板斧:日志、寄存器、反汇编
工程实践中,我定位复杂故障主要靠三样东西,我管它叫“三板斧”。
第一板斧是日志,而且是带时间戳、带模块名、分级别的日志。嵌入式环境里printf 往往不能随便用,但至少要保证 RAM 日志缓冲区或 DMA 串口输出存在,让故障前的最后轨迹可以被捞出来。
第二板斧是异常寄存器。Cortex-M 的 SCB->CFSR、SCB->HFSR、SCB->MMFAR、SCB->BFAR,以及进入 HardFault 时的压栈 PC、LR、xPSR,能直接告诉你故障类型、访存地址以及是在哪条指令挂掉的。现在的调试器(比如 J-Link、DAP-Link)都可以在 HardFault 时查看这几个寄存器的值,大部分问题到这一步已经能锁定了。
第三板斧是反汇编。有些问题看 C 源码是看不出来的,因为编译器做了优化,源程序的行号和实际指令可能对不上。把 map 文件、反汇编文件和目标文件对上,看着 LR 寄存器回溯调用链,往往能找到真正的凶手。比如栈被踩了,函数返回时 LR 是一个非法值,通过查找最近的合法函数入口,就能知道是哪个函数污染了栈。
3.3 一个 HardFault 案例:从现象到根因的完整排查
前段时间调试一个 Cortex-M4 项目,现象是设备运行一段时间后随机死机,概率不高,但一死就卡死在 HardFault。拿到串口日志,只有最后几行输出是传感器数据,没有明显异常。我当时的排查步骤是这样:
第一步,挂上调试器,开 HardFault 处理函数,把 CFSR、HFSR、MMFAR、BFAR 以及压栈的 PC/LR 全部记录下来。发现 CFSR 里的 INVSTATE 位被置位,说明 CPU 试图执行一个错误的指令状态,典型原因是函数指针跳错。
第二步,反汇编,找到 LR 对应的调用者地址,在 map 里反查这个地址属于哪个函数。结果指向一个回调函数注册表。再往下看,发现这个注册表是一个全局结构体数组,里面保存了外设中断事件对应的回调函数。
第三步,检查这个数组的写操作。发现某个业务的实现里,用了memcpy往数组里拷贝了一个比目标数组更大的事件结构,数组越界把旁边的函数指针覆盖了。由于触发条件依赖特定的数据长度和时序,所以表现为偶发性死机。
这个案例里,日志没有直接暴露问题,异常寄存器和反汇编提供了关键线索,最后用内存越界检查工具(比如开启 MPU 保护或者用编译器自带的保护选项)复现并验证。整个过程看起来多了一步,但思路其实就是:先抓住故障现场,再顺着现场找代码入口,最后做根因分析,而不是盲目改配置或加看门狗把问题盖过去。
4. OTA 升级工程化实战:方案选型、差分算法与容错设计
OTA(Over-The-Air)升级是现在很多产品的基本能力,但它也是典型的“不做不知道,做了才发现水很深”的模块。很多开发者的第一版 OTA 就是“下载完直接写 Flash,写完整机重启”——这在开发板上跑没问题,量产之后就会遇到各种升级失败变砖的情况。这一章把 OTA 里最关键的几个工程化问题讲清楚。
4.1 为什么 OTA 的核心难点不是“下载固件”
很多人的第一反应是 OTA 难在联网通信、断点续传、服务器压力,这些当然要考虑,但对于嵌入式设备来说,真正的核心难点在于:怎么保证升级过程中任何一次异常断电、传输错误、镜像损坏,都不会把设备变成一个无法启动的砖头。
举个例子,你的 App 区只有一个,Bootloader 从 App 区启动。升级时你直接把新的固件往正在运行的 App 区写,写到一半断电——恭喜你,写了一半的镜像既不能启动,又不会自动恢复,已经变砖了。这种情况下,再强的下载速度、再稳的传输协议都没用。
所以业界的主流做法是“至少双分区,永远有一个可以启动的镜像”。要么做 A/B 双系统,要么做 Bootloader + Recovery 分区,要么利用外部存储保存备份镜像。方案选型的核心是回答一个问题:如果升级失败,设备能不能自己回到上一个可用状态?想清楚这一点,很多技术选型就有了决策依据。
4.2 全量、差分与压缩:三种升级包方案怎么选
升级包的制作有很多种策略,主要看你的带宽、Flash 容量和设备算力。
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 全量升级包 | 生成简单、兼容性强 | 包体大、下载时间长 | Flash 容量充足,网络带宽好 |
| 差分升级包 | 包体小,节省流量 | 需要在设备端做合并,可能因为原镜像变动导致失败 | 存量设备多、流量敏感型产品 |
| 压缩升级包 | 减少传输量 | 占用一定 RAM 解压,增加功耗 | 网络带宽受限,设备有足够 RAM |
差分升级看起来很美,但工程上的坑非常多。差分算法的本质是“利用旧镜像的内容,结合差异描述,生成新镜像”,如果旧镜像已经被破坏,或者升级过程中有别的改动导致基准不一致,差分包就会合并失败。做差分包时要考虑版本匹配校验,并且一定预留全量升级的后路。我的建议是:小存储、大流量的设备用差分;存储宽松的设备直接用全量,省心得多。
4.3 A/B 分区、备份回滚与升级失败自恢复
A/B 分区方案:固件区划分为 Slot A 和 Slot B,Bootloader 根据元数据里的 flag 决定从哪个 Slot 启动。当前运行在 A,下载并完整写入 B,校验通过后把“下次启动从 B”的 flag 写进元数据,重启后从 B 启动,应用运行正常后再把 flag 置为 confirmed。如果 B 启动失败,Bootloader 检测到尝试次数超限,就自动回退到 A。这套机制在 Android 系统上叫“无缝更新”,在很多 RTOS 产品上同样适用。
没有条件做完整 A/B 的,也可以用“备份区 + 标记”的思路:升级前把当前固件备份到另外一个 Flash 分区,写入升级标记,再更新 App 区,启动时 Bootloader 检查 App 区完整性,不完整就尝试从备份区恢复。这个方案不需要双倍 App 区容量,但恢复时间会长一些,适合对成本敏感的硬件。
不管用哪种方案,都一定要在 Bootloader 里加版本和回滚策略,而且回滚必须是可测试的——你至少要手动模拟一次“升级后立即断电再上电”的场景,验证恢复流程真的能跑通。
4.4 OTA 实战中的踩坑记录:Flash 擦写、校验与断电
几个我踩过且非常典型的坑:
第一,Flash 擦除粒度不考虑。很多 Nor Flash 的最小擦除单位是 4KB,如果你只改了一个字节也要擦掉整个扇区。升级代码如果拿“按字节写”的思路处理,写入性能会很差,中断时还可能出现跨扇区不一致。正规做法是按扇区分块写入,每块校验。
第二,校验顺序错误。比如有的方案先擦除再边写边校验,写完才统一算 CRC,结果在写的过程里 Flash 异常导致数据错误,只能等最后才报错。更好的做法是边写边读回校验,或每块写完后立即 CRC,尽早发现错误。
第三,忽略 Flash 磨损均衡。OTA 升级经常更新的是固定扇区,比如存放元数据的分区,如果每次都写同一个地址,几百上千次后这个扇区就废了。工程上要给元数据分区做磨损管理,至少要加“写入次数 + 序号”字段,启动时选择最新的有效记录。
第四,升级过程中的看门狗不喂。升级写入 Flash 时会阻塞执行,如果看门狗超时时间比写入时间短,设备在升级中不断复位,越写越乱。解决方法是升级过程中灵活喂狗,或者在关键步骤前临时调长看门狗超时。
这些坑看着不起眼,但每一个都可能在量产阶段变成批量的售后问题。OTA 的工程化,本质就是把这些边界情况一件件处理掉。
5. 上篇课后思考题完整解析:五道题帮你把启动流程吃透
正文之前留了几道思考题,这里把完整解析放出来,题目本身不涉及具体工程代码,但每道题背后都对应一个真实会遇到的问题。
5.1 思考题一:为什么复位后第一件事是设置栈指针?
因为 C 语言编译后的指令,几乎离不开栈:函数调用的返回地址、局部变量、参数传递都会用到栈。如果栈指针是随机值或指向非法内存,第一条 C 代码可能就会崩溃。Cortex-M 上用“取向量表第一个字作为 MSP”的方式,把栈指针的设置硬编码进了硬件,所以汇编启动文件里第一步总是从向量表取 MSP。这也是为什么向量表地址必须指向合法的 RAM,不然上电即 HardFault。
实际工程中,栈空间的放置也很有讲究:很多链接脚本把栈放在 RAM 末尾,这样配合调试器的栈高水位检测,比较容易发现栈溢出。但要注意,如果 MCU 的 RAM 里同时放了 DMA 描述符和其他关键数据,栈的增长方向不能和它们冲突。
5.2 思考题二:BSS 段清零的意义是什么?不清零会怎样?
BSS 段保存的是未初始化和初始化为 0 的全局变量。C 语言标准要求这些变量在 main 之前值为 0。如果不清零,它们会继承 RAM 里上一次的残留内容——比如上一次运行时的数据,这会让“看似没初始化”的变量带着随机值工作,表现出的问题往往是时好时坏,非常难查。启动文件里那句“LDR r0, =__bss_start; LDR r1, =__bss_end; 循环写 0”就是干这个的。确认它存在,并且链接脚本里 BSS 段的起止地址正确,是移植启动代码时的基本功。
5.3 思考题三:系统时钟初始化放在启动文件里还是 main 函数里?
没有绝对标准,但推荐放在启动文件的SystemInit阶段,原因有二:一是早期串口可能需要基于正确时钟才能练出精确波特率,二是部分外设(比如看门狗)的喂狗周期依赖时钟频率,如果时钟晚配置会造成时间基准混乱,甚至误触发看门狗。
另一个原因是工程复用性。把时钟、Flash 时序这些“板级底料”放在底层,那么上层应用更换硬件平台时,只需要替换启动文件和板级驱动,main 里的业务代码可以保持通用。否则每个应用的 main 都要拷一份重复的时钟配置,后期维护就是灾难。
5.4 思考题四:RTOS 中,main 函数还能不能当成“主流程”来写?
在 RTOS 工程里,main 通常只被用来创建业务线程,然后进入一个启动调度器的过程,之后 main 函数体所在上下文就不会再继续往下执行了。RT-Thread 的实现里,如果的用户代码放在了 main 里,RT-Thread 本身会创建一个入口线程来执行这个 main。所以你在 main 里写的代码,本质上已经变成了线程函数,而不是传统裸机里的“大循环”。
我的建议是,不要在 main 里写任何阻塞性质的内容,也不要让 main 函数 return——在 RTOS 下 return 之后,线程对象可能被系统回收,情况会变得很难查。规规矩矩把所有业务拆成不同优先级、不同栈空间的线程,用信号量或消息队列通信,才是正确的展开方式。
5.5 思考题五:OTA 升级过程中突然断电,如何做到“不变成砖”?
一句话:一定要有“至少一个可启动镜像”的保证。最稳妥是 A/B 分区 + bootloader 侧完整性检查;低成本方案是备份当前固件(或备份关键启动段)到独立分区,同时记录“正在升级中”的状态标记。上电后 Bootloader 先检查状态标记:如果发现上一次升级未完成,就放弃新固件,优先启动旧固件或备份固件。
这里有一个容易被忽略的点:状态标记自身的写入顺序。如果断电恰好发生在“写入升级完成标记”和“擦除旧固件备份”之间,就可能出现“新固件没写完,旧备份也被删了”的死局。因此建议先把新固件完整写入并校验通过,再把启动标记改到新分区,最后再清理备份区。顺序千万别串。
整个启动、定位加 OTA 的内容讲下来,其实核心就三句话:从复位后的第一条指令开始理解系统,用分层和寄存器工具去逼近故障本质,升级方案永远先考虑失败后的退路。这些能力不是看一篇文章就能练成的,但思路理顺了,后面再遇到具体项目,至少知道往哪个方向查。如果有条件,建议你把现在的工程调出来,把启动文件每一行注释一遍,再手动把 A/B 分区方案在你的开发板上做一次,收获会比只看文字大得多。