华为嵌入式软件三面面经:C 与驱动基础、Flash 存储底层与掉电异常场景设计
- 轮次
- 一面+二面+三面
- base
- 深圳
- 时间
- 2026-10
- 来源
- 牛客网
《面试题目》
一面(技术面,约 55 分钟)
- 自我介绍,然后挑简历里参与度最高的项目讲一下。
- 栈和堆分别放什么?局部变量一定在栈上吗?
malloc出来的内存没有释放,进程退出后会怎么样?memcpy和memmove有什么区别?内存重叠时会发生什么?volatile能保证什么?为什么不能拿它做线程同步?- 编译器乱序和 CPU 乱序有什么区别?
- 内存屏障是干什么的?什么场景下需要?
- 中断上下文里为什么不能睡眠?
- 自旋锁和互斥锁怎么选?自旋锁一直拿不到会怎么样?
- 驱动里为什么不能直接用普通指针读写寄存器?
- 字符设备驱动从
open到read,大概会走哪些流程? - 手撕:写一个固定大小的环形缓冲区,不允许动态申请内存,要支持写入、读取,还要能判断空和满。
- 如果中断里写、任务里读,怎么保证数据不会乱?(追问:中断里这个锁能不能拿?)
二面(技术面,约 70 分钟)
- NOR Flash 和 NAND Flash 有什么区别?
- Flash 为什么写之前要先擦除?
- 页、块分别是什么?为什么不能按字节随便擦?
- NAND 为什么会有坏块?坏块一般怎么管理?
- ECC 是干什么的?能纠正多少位错误由什么决定?
- 什么是磨损均衡?静态和动态磨损均衡有什么区别?
- 写放大是怎么来的?为什么小数据频繁写特别伤?
- MTD、UBI、UBIFS 分别解决什么问题?
- 文件调用
write返回成功,数据就一定进存储介质了吗? - page cache、脏页和后台回写大概是什么关系?
fsync到底保证了什么?突然掉电还能不能保证数据在?- 日志文件系统为什么更抗异常掉电?
- DMA 读写缓冲区时,为什么要考虑 Cache 一致性?
- CPU 已经改了缓存里的数据,但 DMA 读到的还是旧数据,怎么处理?
三面(技术面,约 50 分钟)
- 场景设计:设备每 10ms 产生一条数据,需要持续写入 Flash;存储空间有限,要循环覆盖旧数据;设备可能在任何时刻突然掉电,但重启后要尽量保住最近的数据。请现场设计。
追问:
- 数据先放 RAM 还是直接写 Flash,分别有什么问题?
- 小数据频繁写怎么减少擦除次数?
- 缓冲区攒满再写,突然掉电时缓存里的数据怎么办?
- 怎么判断一条记录是不是完整写入的?
- CRC 能发现数据损坏,但怎么判断哪份元数据是最新的?
- 索引写一半掉电,重启后怎么恢复?
- 双备份元数据如果两份都不一致,信哪一份?
- Flash 写满以后怎么回收?回收时又掉电怎么办?
- 某个块突然变成坏块,原来的数据怎么迁移?
- 重启后不能扫描整块 Flash,怎么把恢复时间压下来?
- 看门狗在写入中途复位,和直接断电有什么区别?
- 这种掉电场景你准备怎么测试,总不能真靠手拔电源吧?
《参考解析》
这场面试的题面看起来是「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 定义,而是它暴露的提问方式——面试官只关心「写到一半怎么办、突然掉电怎么办、数据不一致怎么办」。准备存储与文件系统方向的岗位时,与其把定义背一遍,不如把每个概念都问自己一遍异常路径:这个机制在掉电时会停在哪个中间状态、恢复时怎么判断、判断错了会有什么后果。