面灵AI→

华为嵌入式软件三面面经:C 与驱动基础、Flash 存储底层与掉电异常场景设计

轮次
一面+二面+三面
base
深圳
时间
2026-10
来源
牛客网

《面试题目》

一面(技术面,约 55 分钟)

  1. 自我介绍,然后挑简历里参与度最高的项目讲一下。
  2. 栈和堆分别放什么?局部变量一定在栈上吗?
  3. malloc 出来的内存没有释放,进程退出后会怎么样?
  4. memcpy 和 memmove 有什么区别?内存重叠时会发生什么?
  5. volatile 能保证什么?为什么不能拿它做线程同步?
  6. 编译器乱序和 CPU 乱序有什么区别?
  7. 内存屏障是干什么的?什么场景下需要?
  8. 中断上下文里为什么不能睡眠?
  9. 自旋锁和互斥锁怎么选?自旋锁一直拿不到会怎么样?
  10. 驱动里为什么不能直接用普通指针读写寄存器?
  11. 字符设备驱动从 open 到 read,大概会走哪些流程?
  12. 手撕:写一个固定大小的环形缓冲区,不允许动态申请内存,要支持写入、读取,还要能判断空和满。
  13. 如果中断里写、任务里读,怎么保证数据不会乱?(追问:中断里这个锁能不能拿?)

二面(技术面,约 70 分钟)

  1. NOR Flash 和 NAND Flash 有什么区别?
  2. Flash 为什么写之前要先擦除?
  3. 页、块分别是什么?为什么不能按字节随便擦?
  4. NAND 为什么会有坏块?坏块一般怎么管理?
  5. ECC 是干什么的?能纠正多少位错误由什么决定?
  6. 什么是磨损均衡?静态和动态磨损均衡有什么区别?
  7. 写放大是怎么来的?为什么小数据频繁写特别伤?
  8. MTD、UBI、UBIFS 分别解决什么问题?
  9. 文件调用 write 返回成功,数据就一定进存储介质了吗?
  10. page cache、脏页和后台回写大概是什么关系?
  11. fsync 到底保证了什么?突然掉电还能不能保证数据在?
  12. 日志文件系统为什么更抗异常掉电?
  13. DMA 读写缓冲区时,为什么要考虑 Cache 一致性?
  14. CPU 已经改了缓存里的数据,但 DMA 读到的还是旧数据,怎么处理?

三面(技术面,约 50 分钟)

  1. 场景设计:设备每 10ms 产生一条数据,需要持续写入 Flash;存储空间有限,要循环覆盖旧数据;设备可能在任何时刻突然掉电,但重启后要尽量保住最近的数据。请现场设计。

追问:

  1. 数据先放 RAM 还是直接写 Flash,分别有什么问题?
  2. 小数据频繁写怎么减少擦除次数?
  3. 缓冲区攒满再写,突然掉电时缓存里的数据怎么办?
  4. 怎么判断一条记录是不是完整写入的?
  5. CRC 能发现数据损坏,但怎么判断哪份元数据是最新的?
  6. 索引写一半掉电,重启后怎么恢复?
  7. 双备份元数据如果两份都不一致,信哪一份?
  8. Flash 写满以后怎么回收?回收时又掉电怎么办?
  9. 某个块突然变成坏块,原来的数据怎么迁移?
  10. 重启后不能扫描整块 Flash,怎么把恢复时间压下来?
  11. 看门狗在写入中途复位,和直接断电有什么区别?
  12. 这种掉电场景你准备怎么测试,总不能真靠手拔电源吧?

《参考解析》

这场面试的题面看起来是「Flash 八股」,实际考的是异常路径的设计能力。 一面的问题还能靠定义回答,二面开始所有问题都指向同一个东西:数据在什么情况下会丢、会坏、会不一致。三面的场景题把这条线拉直了——面试官的追问顺序几乎是固定的:单条记录(怎么判断写完整)→ 元数据(两份不一致信谁)→ 块管理(回收时掉电、块变坏)→ 系统(重启不能扫全盘)。答题时如果主动把这条链讲出来,再逐层给方案,比被动接每一问要高一档;反过来,只答「加 CRC」「加双重备份」而说不出代价和边界,会被一直往下钻。

C 与并发基础:volatile、乱序和屏障是同一个问题的三种问法。 volatile 的语义只是「这次访存不许被编译器优化掉、每次都重新读」,它不提供原子性(i++ 依然是读-改-写三步)、不提供内存序(周围的访存照样可以被重排)、也不能在多核之间建立顺序保证,所以不能当线程同步用——它在驱动里的正确用途是映射后的寄存器地址,让编译器别把看似无副作用的读写优化掉。乱序分两层:编译器重排是在单线程语义不可见的前提下做的优化,CPU 乱序来自 Store Buffer、乱序执行和缓存一致性协议下的可见性顺序。屏障也分两层:编译屏障(barrier() 或 asm volatile("" ::: "memory"))只挡住编译器,CPU 屏障(smp_mb / smp_rmb / smp_wmb,ARM 上落到 dmb / dsb / isb)才管核间可见性顺序。单核场景可以省掉 CPU 屏障,但编译屏障永远不能省。

中断、锁与寄存器访问。 中断上下文不能睡眠,是因为它没有可被调度器挂起和唤醒的任务上下文、中断栈很小,而且它往往是在关中断或持有中断描述符锁的状态下运行的,睡下去唤醒路径自己都跑不了;要延后就下半部(softirq、tasklet、workqueue)或线程化中断。自旋锁与互斥锁的选择判据是「临界区长度」和「能不能睡眠」:临界区只有几条指令、或者身处中断上下文,用自旋锁;可能阻塞、持锁期间要访问可能睡眠的资源,用互斥锁。自旋锁一直拿不到就是空转烧 CPU,更糟的是同核上如果没关中断可能自死锁(持锁时被中断打断,中断处理里又要同一把锁),所以内核里常用的是 spin_lock_irqsave。驱动不能拿普通指针读寄存器:寄存器不在被 cache 覆盖的内存语义里,直接用指针既可能被编译器优化掉,也绕不开 MMU 和缓存,正确做法是 ioremap 映射后用 readl / writel 访问。

环形缓冲区那道手撕题,真正的分数在后面半句。 固定大小、不动态申请,通常取 2 的幂大小用掩码取模;空满判据有三种写法——牺牲一个空槽(head == tail 为空、(tail+1)%size == head 为满)、额外维护 size 计数、或者用单调递增的序号差,各有取舍(前者浪费一格,后两者的读写索引不再完全无锁)。基础版写完之后,追问「中断里写、任务里读」其实是在问单生产者单消费者(SPSC)模型:生产者只改写索引、消费者只改读索引,可以完全无锁——各自读对方索引时要用 READ_ONCE / WRITE_ONCE 防止编译器把索引缓存进寄存器,生产者侧在「写完数据之后、更新写索引之前」插写屏障(smp_wmb()),保证消费者一旦看到新索引,数据一定已经可见;消费者侧读到索引后插读屏障(smp_rmb())。要主动说清代价:这个方案只在单生产者单消费者下成立,一旦变成多生产者或多消费者,就得回到锁,而中断里能用的只有 spin_lock_irqsave(注意加锁顺序和关中断,别在持锁时做耗时操作)。

字符设备驱动从 open 到 read 的链路。 open 一侧,VFS 按设备号从 cdev 找到 file_operations、调用驱动的 open,驱动在这里初始化硬件并把私有结构挂到 file->private_data;read 一侧走 vfs_read → file->f_op->read 或 read_iter,驱动把内核缓冲区里的数据 copy_to_user 拷回用户态,返回实际拷贝的字节数。容易被追问的点:用户指针必须先校验、copy_to_user 返回未拷贝的字节数要转成 -EFAULT;阻塞与非阻塞语义(数据没来是睡在等待队列还是返回 -EAGAIN);有没有实现 poll 支持 select / epoll;copy_to_user 可能睡眠,不能在中断等原子上下文里调用。

Flash 这一层要把「物理限制」讲成因果链。 擦除粒度大于写粒度是 Flash 的物理特性:编程只能把 bit 从 1 写成 0,所以改写之前必须先擦成全 1。NOR 支持按字节随机读、写按字或页,容量小、可靠性高,常用来放启动代码;NAND 读写按页、擦除按块,容量大、单位成本低,但有坏块、会随擦写次数和读次数退化。NAND 出厂即有坏块、使用中还会新增,靠坏块表加备用块替换来管理;ECC 的纠错能力由控制器的算法和 OOB 里留给校验的空间决定(常见 BCH、LDPC),能纠几位不是写死的常数,而错误率随擦写次数上升,所以还要配合 read retry、擦写次数监控和读干扰处理。磨损均衡分静态和动态:动态只在需要写入时挑擦写次数最少的可用块,静态会主动搬运长期不动的冷数据,把擦写摊到全部块上,效果更彻底但搬运开销和写放大更大。写放大 = 实际写入介质的数据量 / 上层请求写入量,来源是「为了改一小段要擦写一整块」、元数据与日志开销、以及地址对齐不良;小数据频繁写的坏处正在于此,既吃寿命又拖性能,所以工程上要缓冲合并、按块顺序写。层次上,MTD 是裸 Flash 的抽象层(提供擦除、读、写原语),UBI 在 MTD 之上做磨损均衡与坏块管理、把 Flash 切成逻辑卷,UBIFS 是跑在 UBI 卷上的文件系统;另一条路是 FTL 把 Flash 伪装成块设备再跑普通文件系统,eMMC 和 SD 卡内置的就是这条路。

write 返回成功绝不等于数据已经落盘。 write() 只是把数据拷进 page cache、把页标脏就返回了,真正的落盘由回写线程按脏页比例、超时或显式 sync 触发,这中间掉电数据就没了;fsync() 才会把该文件的脏页和必要元数据刷到设备并等 IO 完成,而且严格意义上还要考虑设备自身的写缓存有没有被 flush(所以需要预刷 / FUA 语义或设备支持 cache flush),fdatasync 只保证数据、不管无关的元数据。文件系统的日志(如 data=ordered 模式)保证的是元数据一致性——不会出现元数据指向一个还没写好的数据块——代价是同一份数据要写两次(日志区加最终位置),而且能否保住数据取决于日志模式。整条因果链是「write → page cache 脏页 → 回写 → 设备缓存 → 介质」,掉电可能丢在任意一环,这正好接上三面的场景题。

DMA 与 Cache 一致性,要分清两个方向。 CPU 经 cache 访问内存,DMA 走总线直接访问内存,两边不打招呼就出两种错:设备写内存后,CPU 从 cache 里读到的还是旧值;或者 CPU 写的数据还在 cache 里没回写,DMA 从内存读到旧数据。内核的做法是正确配置 DMA 方向(DMA_TO_DEVICE / DEVICE_TO_DMA / 双向),用 dma_map_single 和 dma_sync_single_for_device / for_cpu 在合适的时机做 cache clean(把 CPU 的写刷回内存)和 invalidate(丢弃 CPU cache 里的旧值);驱动自己缓存了映射地址时要注意一致性域。工程细节里两个常被追问的点:buffer 要按 cache line 对齐,invalidate 是按 cache line 操作的,如果相邻变量或另一个设备的 buffer 挤在同一条 cache line 上,一次 invalidate 会把别人的数据抹掉;另外 mapping 期间 CPU 不该再去碰那块 buffer。

三面的场景题,标准答法是分层给方案并把取舍写明。 第一层是落盘策略:10ms 一条直接写 Flash 会让同一个块反复擦除,必须先在 RAM 里攒批、按块批量写;代价是掉电会丢缓冲区里还没写下去的数据,所以要么把「最近的数据」定义成「已提交缓冲区大小的数据」并按 缓冲区条数 × 10ms 给出最坏丢失窗口,要么缩短攒批周期,这个取舍要主动讲。第二层是记录完整性:定长记录 + 头部魔数 + 单调递增序号 + 长度 + 数据 + CRC,写入采用「先写数据、校验通过后再写 commit 标记」的顺序,重启时只认 commit 位有效且 CRC 通过的记录。CRC 只能发现「这条坏了」,判断新旧要靠单调递增的序号或时间戳,这是原帖里那个追问的考点。第三层是元数据一致性:A/B 两份元数据交替写、各自带 CRC 和序号,重启时选「CRC 通过且序号最大」的那份,两份都不过就回退到再上一份并进入恢复流程。第四层是恢复时间:元数据里保存当前写指针,重启只从写指针往回扫到第一条校验不通过的记录为止,代价与损坏范围成正比,而不是与 Flash 容量成正比——扫描整块 Flash 是 O(容量) 的,根本满足不了启动时间要求,这也是「不能扫全盘」那一问的标准答案。第五层是回收与坏块:环形日志按块回收,回收前先写一条「正在搬块 X」的日志再擦,掉电重启后看到这条日志就把这次回收幂等地重做一遍;块变坏时把该块上的有效记录迁到备用块,更新坏块表和逻辑到物理的映射,映射表本身也要双备份。看门狗在写入中途复位和直接断电不同的地方在于供电没断:外设状态被复位(DMA 半途中断、Flash 控制器写缓冲里的内容去向取决于控制器),但「写到一半的页」依然是同一条错误路径,只是可以通过复位原因寄存器区分这两类异常。最后是测试:不靠手拔电源,用可编程电源做毫秒级随机断电、用故障注入(写失败注入、模拟软硬错误)、在写路径的关键点插桩主动触发看门狗复位,然后跑长时间随机断电并逐次校验记录连续性与 CRC;判据是任何一次断电后重启,最后 N 条记录要么完整、要么明确缺失,绝不出现半条数据被当成有效记录。

复盘的价值点: 这版面经最有用的不是那些 Flash 定义,而是它暴露的提问方式——面试官只关心「写到一半怎么办、突然掉电怎么办、数据不一致怎么办」。准备存储与文件系统方向的岗位时,与其把定义背一遍,不如把每个概念都问自己一遍异常路径:这个机制在掉电时会停在哪个中间状态、恢复时怎么判断、判断错了会有什么后果。