小米汽车嵌入式三面:Bootloader、OTA 与整车升级的状态机
- 轮次
- 一二三面
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
一面(技术面,约 55 分钟)
- static 修饰局部变量和全局变量分别有什么作用?
- const 修饰指针时几种写法有什么区别?
- 结构体为什么会有内存对齐?怎么计算结构体大小?
- 大端和小端是什么?怎么用代码判断?
- memcpy 能不能处理内存重叠?为什么?
- volatile 能解决哪些问题?能不能保证操作原子性?
- 一个变量在主循环和中断中都会访问,要注意什么?
- 中断里为什么不建议做耗时操作?
- 中断嵌套时现场是怎么保存的?
- 临界区、互斥锁和信号量分别适合什么情况?
- 优先级反转是什么?优先级继承怎么缓解?
- 任务栈溢出一般会出现什么现象?怎么排查?
- 看门狗应该在哪里喂?只在一个高优先级任务里喂有什么问题?
- MCU 从复位到进入 main,大概经过哪些步骤?
- 启动文件里通常有什么?
- .data 和 .bss 是怎么初始化的?
- 链接脚本是干什么的?Flash 和 RAM 的地址怎么放进去?
- 手撕:给一个 32 位寄存器,修改其中几位但不能影响其他位。
二面(技术面,约 70 分钟)
- Bootloader 和应用程序一般怎么划分 Flash?
- 上电以后怎么决定跳转到哪个程序?
- 从 Bootloader 跳到 APP 前需要做哪些处理?
- 为什么要重新设置 MSP 和中断向量表?
- 外设、中断和 SysTick 没关干净会有什么现象?
- APP 的入口地址怎么确定?
- 固件升级包里除了程序本体,还应该放哪些信息?
- CRC 和哈希分别能解决什么问题?
- 只校验 CRC 能不能防止固件被替换?
- 固件签名和固件加密有什么区别?
- 公钥放在设备里,怎么防止被人直接替换?
- 什么是安全启动?
- A/B 分区升级有什么好处?
- 单分区设备怎么做升级?失败以后怎么恢复?
- 固件下载完整以后再升级,和边下载边写 Flash,各有什么问题?
- Flash 擦除期间突然复位会怎么样?
- 一个扇区只写了一半,重启后怎么识别?
- 版本号放在哪里?怎么防止降级到有漏洞的旧版本?
- Bootloader 自己需不需要升级?
- 如果 Bootloader 升级失败,设备还有没有办法救回来?
- CAN、以太网和 4G 做车载升级通道时,要分别考虑什么?
- 场景题:设备收到升级包以后校验正常,也成功写进了备用分区,但重启后新程序起不来,怎么排查?
三面(技术面,约 60 分钟,全场景)
- 升级包已经下载完,但车辆此时电量很低,要不要开始升级?
- 写备用分区的时候突然断电,重启后怎么判断写到了哪里?
- 是接着写,还是整包重新写?
- 擦除一个扇区时掉电,这个扇区还能不能直接使用?
- 新固件第一次启动成功,是否可以立刻覆盖旧版本?
- 程序启动了,但运行十秒后看门狗复位,算升级成功吗?
- 谁来确认新版本可以提交?
- 如果一直等不到确认,Bootloader 怎么处理?
- 回滚次数有没有上限?反复重启会不会一直在两个版本之间切换?
- 升级状态写到一半断电,怎么保证状态机不会进入错误分支?
- A/B 两个分区都校验通过,但设备启动结果不一样,怎么选?
- 网关升级成功了,某个 ECU 升级失败,整车版本不一致怎么办?
- 多个 ECU 之间有依赖关系,升级顺序怎么安排?
- 依赖的 ECU 已经升级,新版本却回滚了,其他 ECU 要不要一起回滚?
- CAN 总线中途异常,怎么区分是传输失败还是目标 ECU 已经死机?
- 升级时其他业务报文很多,怎么保证升级流量不会影响控制报文?
- 如果设备存储空间不够放两份完整固件怎么办?
- 差分升级失败以后,能不能恢复到升级前的版本?
- 怎么防止刷入旧版本?
- 签名校验通过,能不能说明这个固件一定可以运行?
- 这些异常情况怎么测试?实验室里怎么稳定复现升级中途断电?
《参考解析》
C 语言这几题的关键在于说清「为什么」。static 修饰局部变量是把生命周期延长到程序结束、只初始化一次(C++11 起首次进入的初始化是线程安全的,裸机 C 里不保证);修饰全局变量或函数是把符号变成内部链接,其他编译单元不可见,用来避免多文件重名——面试官常追问的是链接属性而非「存在静态存储区」。const 的三种写法看 * 与 const 的相对位置:const int *p 是指向常量的指针(值不可改、指针可改),int * const p 是指针常量(指针不可改、值可改),两者都 const 则都不可改。内存对齐的本质是硬件访问效率与原子性要求,计算规则是每个成员按自身对齐值对齐、结构体总大小按最大对齐值补齐。
memcpy 不能处理重叠,因为它是按字节(或字)顺序拷贝,当源和目的区间重叠时,前一次写入会污染后面还没读的源数据。重叠场景要用 memmove,它的实现会先判断方向,从尾部往前拷。大端小端的代码判断用联合体或指针强转:把 int 1 的地址当 char* 读第一个字节,得 1 是小端、得 0 是大端。
volatile 与原子性的区分是高频点:volatile 保证每次访问都真的去内存读/写、不被编译器优化进寄存器,适合中断与主循环共享的标志位、硬件寄存器;但它不保证原子性——i++ 在 volatile 下仍然是读-改-写三步,多核或中断抢占下依然会丢更新。原子性要靠关中断、原子指令(LDREX/STREX、CAS)或锁。
中断现场保存与嵌套:Cortex-M 进入中断时硬件自动压栈 R0-R3、R12、LR、PC、xPSR(所谓的 stacking),软件只需保存剩余的 callee-saved 寄存器;支持嵌套靠的是优先级与硬件自动切换栈指针(MSP/PSP)。中断里不做耗时操作的原因是它会阻塞同优先级及更低优先级的中断、拉长中断延迟,正确做法是中断里只置标志或入队,处理放到主循环或任务里。
优先级反转与继承:低优先级任务持有互斥量时被高优先级任务抢占,中优先级任务又抢走了 CPU,导致高优先级任务实际上被中优先级拖住。优先级继承是让持锁的低优先级任务临时提升到等待者的优先级,让它尽快跑完释放锁;优先级天花板则是把锁的上限预先设好。FreeRTOS 的 mutex 内置优先级继承,二值信号量没有——这是选型时常被问的差别。
Bootloader 跳转的完整动作:先关中断、关外设(尤其是 SysTick 和已经配置的 DMA,否则跳到 APP 后它们还在跑)、清理挂起的中断标志;然后设置 MSP 为 APP 向量表的第一个字(初始栈顶)、把 APP 向量表地址写进 SCB->VTOR;最后取向量表第二个字(Reset_Handler)作为入口,跳转前通常用一个函数指针调用。不重设 MSP 的后果是栈还在 Bootloader 的区域里,APP 一开中断或深递归就可能踩坏数据;不重设 VTOR 则中断仍走 Bootloader 的向量表,APP 的中断全都不响应。
固件包的完整性校验要分三层讲:CRC 或哈希解决「传输/存储有没有出错」(完整性),数字签名解决「这份固件是不是我们签发的」(真实性),加密解决「固件内容会不会被逆向」(机密性)。只校验 CRC 防不住替换——攻击者改完固件重算 CRC 即可。公钥放在设备里要防替换,靠的是把公钥烧进不可改的区域(OTP、受保护的 Flash 扇区)或用设备唯一的密钥加密存储,并让信任根从 ROM 开始逐级校验,这就是安全启动的链条。
A/B 分区的价值是回滚能力:新固件写到备用分区,校验通过后改启动标志,试运行成功再确认;失败就切回原分区,设备永远有一个已知可用的版本。单分区设备只能靠「先把新固件完整写到一个暂存区、校验后再覆盖」,或者依赖外部恢复通道(USB、串口、诊断口),恢复能力弱得多。
边下载边写 Flash 的风险是「写坏了没法回头」:一旦写到一半断网或断电,应用分区已经被破坏,如果没有 Bootloader 保底就变砖。下载完整再写虽然需要双份存储空间,但能保证写之前的固件是完整的。折中做法是分块下载 + 校验每块 + 记录已写偏移,重启后从断点续写,但必须保证断点记录本身是原子更新的。
Flash 擦除期间掉电,这个扇区处于未定义状态——可能部分位已经是 1(擦除态)、部分还是 0,读出来的数据既不是旧内容也不是新内容。所以不能「擦完继续用」,必须重新擦除整个扇区再写。这也是为什么要用「擦除中」的状态标记,且这个标记本身要写在不会被这次擦除影响的地方(另一个扇区或带备份的标志区)。
升级状态机要防的两个坑:状态写到一半断电导致状态不一致、以及反复重启在两个版本间振荡。前者的解法是状态记录用「双份 + 序号 + CRC」或者写日志式追加,读取时取最后一条完整的;后者的解法是设置回滚次数上限,超过上限就锁定在当前版本并上报故障,等外部指令再动。
试运行成功的判据不能只是「起来了」:十秒后看门狗复位说明应用根本没跑通。业界的做法是让新固件在启动后主动上报健康状态(心跳、自检结果、关键任务是否起来),Bootloader 或升级管理模块收到确认才提交;等不到确认就按超时处理,切回旧版本。确认者可以是车端的升级管理模块,也可以是云端。
多 ECU 的依赖与一致性:升级顺序按依赖图做拓扑排序,被依赖的先升。回滚的传播是最难的部分——如果 A 升级后 B 依赖 A 的新版本才升,结果 A 回滚了,B 会因为找不到依赖而起不来。所以要区分「兼容性依赖」(B 的新版本能否在 A 的旧版本上工作)和「强绑定依赖」,前者允许不同步,后者必须作为一个事务整体回滚。
CAN 传输失败与目标 ECU 死机的区分:看的是响应而不只是发送成功——CAN 帧发送成功只说明总线上有人 ACK,不说明目标 ECU 处理了。要靠应用层的诊断响应(UDS 的 ResponsePending、超时)、以及周期性的心跳报文来判断对方是否还活着。
升级流量与控制报文共存:靠 CAN ID 的优先级(数值越小优先级越高,控制报文用更小的 ID)+ 发送节流 + 在总线上留带宽余量。车载以太网则用 VLAN 优先级或流量整形。
存储空间不够放两份固件的折中:差分升级(只下载与旧版本的差异,减少下载量但不减少存储需求)、压缩备份(把旧固件压缩后存放,恢复时解压)、或者外挂存储。差分升级失败能否恢复,取决于旧版本是否还在——如果差分是把旧版本改写成新版本,那中途失败就必须能重新下载完整包。
防降级靠版本号 + 单调计数器:不能只比版本字符串(可被伪造),要用不可回退的计数器存在受保护区域(OTP 或 eFuse),每次升级递增,Bootloader 要求新固件的计数器不小于当前值。签名校验通过只说明固件来源合法,不说明它能在当前硬件上跑——所以还需要硬件版本、外设配置、依赖版本的兼容性校验。
异常测试的做法:断电用可控电源或继电器切断、或用调试器在指定地址打断;写 Flash 失败用注入器或者改写驱动在指定次数后返回错误;总线异常用 CAN 干扰仪或软件模拟丢帧。关键是让这些异常可复现、可脚本化,才能进回归测试。