嵌入式面试真题第 01 题:低速 Flash 与 1MB SRAM 下的通用缓存架构设计
问题
在一款资源受限的嵌入式设备中,系统只有 1MB SRAM 和一块读延迟不稳定的低速 SPI Flash。业务需要从 Flash 中持续或按需读取资源包、配置表、字库、语音片段、模型分块、日志回放数据或其他离线资源,并供应用线程实时或准实时消费。
如果应用线程直接等待 Flash 读取,系统可能因为低速 I/O、随机访问、文件碎片、擦写干扰或后台任务抢占而出现卡顿、超时或数据供应不连续。你会如何设计一套通用 Buffer/Cache 机制,使应用线程尽量读取 SRAM 中已经准备好的数据,而不是直接阻塞等待 Flash?
回答
结论:不要让应用线程直接等待低速 Flash。更合理的设计是在应用和 Flash 之间增加一层 SRAM Cache/Buffer Manager:应用优先读 SRAM 中已经准备好的数据;后台 I/O Worker 根据访问模式、缓存水位和请求压力提前把 Flash 数据搬入 SRAM;缓存层再用 Ring Buffer、Block Cache 或 Segment Cache 组织数据。
这类问题的关键不是“缓存越大越好”,而是把低速存储的长尾延迟、随机访问开销、请求碎片化和后台 I/O 抖动从应用实时路径中隔离出来。Buffer 容量和水位线必须按应用消费速率、Flash 最坏读取延迟、后台 I/O 调度抖动和安全余量量化,而不是按平均读速率拍脑袋。
总体架构
1 | flowchart LR |
这个架构里,应用看到的是缓存 API,而不是 Flash API。应用线程不直接调用底层 flash_read(),也不把低速 I/O 放进关键路径。Cache/Buffer Manager 负责命中判断、预读窗口、随机块缓存、请求合并、淘汰策略和水位线控制。
机制与 Linux/开源实现的对应关系
| 本题机制 | Linux 或开源机制 | 能否直接使用 | 主要参考价值 |
|---|---|---|---|
| Ring Buffer | Linux kfifo, io_uring SQ/CQ ring | Linux 内核/用户态可直接使用对应接口;小系统多为参考 | 单生产者/单消费者 FIFO、共享 ring、提交队列/完成队列。 |
| Block Cache | Linux page cache, XArray, dm-cache, bcache | Linux 下 page cache 是默认机制;dm-cache/bcache 可作为块层缓存使用 | offset 到缓存页/块的映射、缓存块状态、淘汰、脏块、块级 promotion/demotion。 |
| Segment Cache | readahead(2), posix_fadvise(2), page cache | Linux 用户态可通过 readahead()/posix_fadvise()影响内核预读;自定义缓存可参考 |
顺序窗口、访问模式提示、顺序读扩大窗口、随机读关闭预读、已用数据释放。 |
| 水位线机制 | Linux zone watermarks、vm.min_free_kbytes、dirty writeback thresholds |
不能直接作为业务缓存组件使用,但机制高度类似 | 高/低水位触发后台回收或写回,危险水位触发同步回收或限流。 |
| 缓存容量量化 | page cache/readahead 的动态策略、块设备吞吐与尾延迟建模 | Linux 已动态处理大部分场景;受限 SRAM 仍需显式计算 | 用消费速率、最坏延迟、调度抖动、吞吐余量反推缓存大小。 |
应用线程应该如何读数据
应用线程不建议直接调用底层 Flash 读接口,而应通过缓存层访问数据,例如:
1 | cache_read(handle, offset, buf, len, timeout) |
cache_read() 适合普通拷贝式读取;cache_acquire() / cache_release() 适合零拷贝或少拷贝读取;cache_prefetch() 用于应用提前告诉缓存层“接下来大概率会读这段数据”。
应用线程遇到缓存不命中时,不应无限期阻塞。应按业务类型选择策略。
| 场景 | 推荐策略 | 说明 |
|---|---|---|
| 实时数据流 | 优先从缓存读;缓存不足则短超时等待;超时后降级 | 持续渲染、连续资源读取、协议流式处理等。 |
| 普通资源读取 | 允许等待,但必须有超时 | 字库、图标、配置表、索引表等。 |
| 后台任务 | 异步提交请求并等待事件或回调 | 日志扫描、资源遍历、后台校验等。 |
Linux 用户态对应的思想是:普通 read() 本身通常会经过 page cache;如果应用知道访问模式,可以通过 posix_fadvise() 提示 POSIX_FADV_SEQUENTIAL、POSIX_FADV_RANDOM、POSIX_FADV_WILLNEED 或 POSIX_FADV_DONTNEED;如果要显式预热某段文件,可以调用 readahead()。这些接口不等价于自己写 Cache Manager,但它们体现了同一个原则:应用声明访问模式,让缓存层提前或减少 I/O。
Buffer 形态如何选
通用 Buffer 不应只用一种结构。应根据访问模式分成三类:顺序数据流使用 Ring Buffer,随机访问资源使用 Block Cache,混合访问使用 Segment Cache。
顺序数据流使用 Ring Buffer
如果访问模式是顺序消费,例如连续资源读取、日志回放、语音资源流、模型分块读取,最适合使用 Ring Buffer。
Ring Buffer 的特点是读写指针单调推进,内存固定,不产生碎片。后台 I/O Worker 负责把后续数据写入 Ring Buffer;应用线程负责从 Ring Buffer 读取并消费。只要底层存储的长期有效吞吐高于应用消费速率,并且 Ring Buffer 能覆盖底层 I/O 最坏延迟,应用就不会频繁卡在 Flash 上。
1 | flowchart LR |
高吞吐场景下,Ring Buffer 最好支持 claim/finish 语义:
1 | ring_put_claim() -> 取得可写 SRAM 地址 |
可参考的 Linux/开源实现
-
kfifo是 Linux 内核通用 FIFO/ring buffer 工具。它提供kfifo_in()、kfifo_out()、kfifo_put()、kfifo_get()等接口。官方文档明确说明,在只有一个并发读者和一个并发写者时,相关宏不需要额外锁;这正好对应“一个预读生产者 + 一个应用消费者”的典型 Ring Buffer 模式。可参考点:
- 固定内存 FIFO,不做运行期频繁分配。
- 单生产者/单消费者可以简化锁设计。
- 多生产者或多消费者需要外部锁或带锁变体。
in/out指针推进与 wrap-around 处理可以作为 Ring Buffer 实现参考。
-
io_uring不是普通数据缓存,但它的提交队列 SQ 和完成队列 CQ 是共享 ring buffer 模型。用户态把 I/O 请求写入 SQ,内核把完成结果写入 CQ。这个机制非常适合作为“请求队列 + 完成队列”的参考。可参考点:
- 数据路径与控制路径分离。
- SQ 表示应用提交的 I/O 请求,CQ 表示后台 I/O 完成事件。
- 多个请求可以异步在途,完成顺序不一定等于提交顺序,因此每个请求必须有
user_data或 request id。 - 批量提交和批量完成可以减少上下文切换和系统调用成本。
随机访问资源使用 Block Cache
如果访问模式是随机读取,例如字库、图标、配置表、索引、模型权重片段,应使用定长块缓存。
Block Cache 把底层数据按固定粒度切块,例如 512B、1KB、2KB、4KB 或更大的资源块。每个缓存块记录逻辑地址和状态。应用读取某个 offset 时,缓存层先把 offset 映射到 block id,再查块表;命中则直接返回 SRAM 数据,未命中则提交块读取请求。
1 | flowchart TD |
每个缓存块建议至少记录:
1 | resource id |
随机访问缓存通常需要 LRU、clock、2Q 或更简单的近似淘汰策略。对关键块要支持 pin count,避免应用正在使用的块被回收。只读资源可以不考虑 DIRTY;如果缓存层也承接写入,则必须增加 DIRTY 状态、写回策略和掉电一致性设计。
可参考的 Linux/开源实现
-
Linux 文档说明,page cache 是用户和内核与文件系统交互的主要方式,普通 reads、writes 和 mmaps 都会经过 page cache,除非使用类似
O_DIRECT的方式绕过。它本质上就是文件 offset 到内存页/folio 的缓存映射。可参考点:
- 文件数据先进入内存缓存,再供读写路径使用。
- 缓存粒度以页/folio 为单位,而不是任意小字节。
- 普通读路径不一定每次都打到底层块设备。
- 在内存压力下,干净页可以回收,脏页要先写回。
-
XArray 是 Linux 内核里的稀疏数组/索引结构,文档说明它像一个很大的指针数组,也能满足 hash 或动态数组的部分需求,并且 page cache 是 XArray 最重要的使用者之一。
可参考点:
- 用逻辑页号或块号作为索引,查找缓存对象。
- 适合密集或局部密集的整数索引。
- 支持无锁读侧查找思路,即读多写少路径要尽量轻。
- 比链表遍历更适合 Block Cache 的按 offset 查找。
-
dm-cache 是 Linux device-mapper 的块缓存 target,用较小较快设备缓存较大较慢设备的数据。文档中它把 origin device 划成固定大小块,并由策略模块决定哪些块 promotion 到 cache device、哪些块 demotion 回 origin device。
可参考点:
- 固定块大小。
- 缓存数据和元数据分离。
- 缓存策略插件化。
- promotion/demotion 与 Block Cache 的装入/淘汰类似。
-
bcache 是 Linux 块层缓存。文档说明它使用混合 btree/log 跟踪 cached extents,并且会识别大顺序 I/O,默认倾向跳过大顺序流,把缓存资源留给随机 I/O。
可参考点:
- 缓存不应该机械缓存所有数据。
- 大顺序流可能绕过随机块缓存,避免污染缓存。
- block/extents 的元数据索引和错误处理机制值得参考。
- 顺序 I/O 与随机 I/O 要采用不同策略。
混合访问使用 Segment Cache
如果资源大体顺序读取,但中间偶尔跳转,例如媒体文件、资源包、数据库式索引、模型权重分段加载,可以使用 Segment Cache。它不是单一固定数据结构,而是 Ring/Window + Block Cache 的组合:一部分 SRAM 做顺序预读窗口,另一部分 SRAM 做小型随机块缓存。
1 | flowchart TD |
典型拆法如下:
| SRAM 区域 | 作用 | 典型比例 |
|---|---|---|
| Sequential Window | 顺序预读窗口,保障连续吞吐 | 50% ~ 75% |
| Random Block Cache | 缓存索引、字库、图标、跳转块 | 15% ~ 35% |
| I/O Staging Buffer | 处理对齐、回绕和非连续目标 | 5% ~ 15% |
| Cache Metadata | 块表、请求队列、统计信息 | 1% ~ 5% |
Segment Cache 的核心是“不要让顺序流污染随机热点块,也不要让随机块缓存承担连续吞吐”。顺序窗口负责吞吐,Block Cache 负责跳转和热点。
可参考的 Linux/开源实现
-
readahead()会把指定文件区域提前读入 page cache,使后续读取尽量从缓存满足,而不是阻塞在磁盘 I/O 上。它对应 Segment Cache 里的 Sequential Window:提前把将要顺序消费的数据放进内存。 -
posix_fadvise()允许应用声明访问模式。Linux 下POSIX_FADV_SEQUENTIAL会扩大 readahead window,POSIX_FADV_RANDOM会关闭文件 readahead,POSIX_FADV_WILLNEED会发起非阻塞预读,POSIX_FADV_DONTNEED可请求释放不再需要的缓存页。这几个 advice 与 Segment Cache 的策略直接对应:
advice 对应策略 POSIX_FADV_SEQUENTIAL顺序窗口扩大,积极预读。 POSIX_FADV_RANDOM不做盲目顺序预读,转向 Block Cache。 POSIX_FADV_WILLNEED提前装入即将访问的 segment。 POSIX_FADV_DONTNEED已消费 segment 降权或释放。 -
bcache 文档明确提到,它会检测顺序 I/O 并跳过缓存,因为大顺序 I/O 通常不值得进入随机缓存。这是 Segment Cache 里“顺序流和随机热点分开处理”的典型参考。
预读线程与应用线程之间的机制
推荐使用“请求队列 + 后台 Worker + 完成事件 + 水位线控制”的模型。
1 | sequenceDiagram |
应用线程负责提出需求和消费数据,不直接访问 Flash,不直接操作底层 I/O,不在实时路径里无限等待。后台 Worker 平时可以休眠;被低水位、缓存 miss 或显式预取请求唤醒后,合并相邻请求,转换为对齐读,把数据搬到 SRAM 缓存区或 staging buffer。完成后,Worker 更新缓存块状态并唤醒等待的应用线程。
对应 Linux 机制可以参考 io_uring:应用通过提交队列投递请求,内核异步处理后通过完成队列返回结果。即使不用 io_uring,这种“提交队列/完成队列/请求 id/异步完成”的结构也适合抽象后台预读。
优先级与资源仲裁
预读 Worker 不应简单固定为最低优先级。更合理的策略是动态服务能力或资源仲裁。
| 状态 | 预读策略 |
|---|---|
| 缓存高水位 | Worker 休眠或降低预读强度。 |
| 缓存目标水位 | 维持稳定预读窗口。 |
| 缓存低水位 | 唤醒 Worker,合并读取,增大单次 read size。 |
| 缓存危险水位 | 暂停非关键读写,优先补充缓存。 |
| 应用实时读取即将超时 | urgent request 插队,但要避免长期饿死普通请求。 |
| 底层存储正在写入或擦除 | 实时读优先;非关键写入延迟。 |
Linux 里类似的思想不是“把某个线程提优先级”,而是全局资源管理。例如:
- page allocator 通过 min/low/high watermark 决定是否触发直接回收、唤醒 kswapd 或停止回收。
- dirty writeback 通过
dirty_background_bytes、dirty_bytes等阈值决定后台写回和同步写回压力。 - bcache 会根据缓存设备延迟做拥塞控制,延迟过高时降低相关流量。
因此,在本题里,“优先级”不应只理解成线程优先级,而应理解成资源仲裁:谁可以占用 Flash/I/O 通道,谁可以触发预读,谁必须被限流,谁可以插队。
水位线机制
水位线应同时管理可用数据量、空闲空间和请求压力。
1 | stateDiagram-v2 |
| 水位 | 触发条件 | 动作 |
|---|---|---|
| 高水位 | 缓存数据已足够多,空闲空间不足 | 停止或降低预读,释放底层 I/O 给其他任务。 |
| 目标水位 | 正常稳定水位 | 维持预读窗口,保持吞吐平衡。 |
| 低水位 | 应用消费快于补充 | 唤醒 Worker,合并读取,增大单次 read size。 |
| 危险水位 | 即将不够应用消费 | 暂停非关键 I/O,允许紧急请求插队,优先补缓存。 |
| 空水位 | 发生 underrun 或 cache miss timeout | 返回错误、阻塞到超时、填默认数据或进入业务降级。 |
水位线不能只按百分比配置,还应按时间配置。比如缓存中还有 64KB 数据并不一定安全;如果应用消费 2MB/s,它只能支撑约 32ms。如果底层存储最坏延迟可能达到 80ms,64KB 就不够。
可参考的 Linux/开源实现
Linux physical memory watermarks
Linux 的
struct zone里有_watermark。官方文档说明:空闲页低于 min watermark 时可能触发 direct reclaim/compaction;低于 low watermark 时唤醒 kswapd;高于 high watermark 时 kswapd 停止回收。这个机制和缓存水位线非常接近:低水位触发后台补救,高水位停止后台动作,危险水位触发同步或强制动作。-
vm.min_free_kbytes用于让 Linux VM 保留最低空闲内存,并据此计算各 lowmem zone 的WMARK_MIN。这对应本题里的“危险水位/最低保底容量”:不是看到缓存快空了才动作,而是必须保留能覆盖最坏延迟的安全垫。 -
dirty_background_bytes表示达到多少脏内存后后台 flusher 开始写回;dirty_bytes表示进程自己开始参与写回的阈值。它对应“高水位/低水位触发后台动作”的另一类例子:阈值不是为了显示百分比,而是为了改变系统行为。
缓存容量如何量化
最小缓存容量应基于最坏情况,而不是平均情况。
1 | R_app = 应用最大连续消费速率,单位 B/s |
至少满足:
1 | B_min >= R_app * (T_io_tail + T_sched + T_jitter + T_margin) |
同时还要满足长期吞吐条件:
1 | R_io_effective > R_app_peak_average |
如果底层 I/O 的长期有效吞吐小于应用平均消费速率,单纯加 Buffer 只能推迟卡顿,不能从根本解决问题。此时应降低应用消费速率、压缩数据、改变资源布局、减少随机访问或换更快的存储。
顺序 Ring Buffer 容量
Ring Buffer 主要覆盖连续消费过程中的 I/O 空窗期。
1 | B_ring_min = R_stream * T_cover |
若应用消费速率为 2MB/s,底层最坏读延迟为 80ms,调度抖动 20ms,安全余量 50ms:
1 | B_ring_min = 2MB/s * (80ms + 20ms + 50ms) |
这说明 64KB 缓存即使看起来不小,也只能支撑约 32ms 的 2MB/s 连续消费,无法覆盖 80ms 的长尾延迟。
Block Cache 容量
Block Cache 的容量不能只按时间算,还要看工作集大小和命中率。
1 | B_block = N_blocks * block_size |
其中:
hot_working_set_blocks是短时间内会重复访问的热点块数量。pinned_blocks是应用正在持有、不能淘汰的块数量。miss_inflight_blocks是同时在途读取的未命中块数量。
如果随机访问没有局部性,Block Cache 命中率会很差。此时应该优化资源布局或建立索引,而不是盲目扩大缓存。
Segment Cache 容量
Segment Cache 需要在顺序窗口和随机块缓存之间分配预算。
1 | B_total_cache = B_seq_window + B_random_cache + B_staging + B_meta |
经验上,应先保证顺序窗口能覆盖最坏延迟,再把剩余空间分给随机块缓存。否则连续流会先卡住;而随机缓存不足通常只是命中率下降,不一定立即造成连续性失败。
Linux 机制里的容量参考
Linux page cache 会根据系统内存压力动态增减,不需要应用显式指定固定大小;但它仍然遵循相同的本质约束:page cache 命中时读路径快,未命中时要等待底层 I/O。readahead() 的作用是提前把文件区域读入 page cache;posix_fadvise() 的作用是告诉内核访问模式,让内核调整 readahead window 或缓存回收倾向。
对于受限 SRAM 的自定义缓存,不能假设系统会自动帮你保留足够 page cache,因此容量必须显式计算。
Flash 读取策略
底层读取应尽量顺序、大块、对齐、可合并。
推荐策略如下:
- 顺序预读,不要让应用每次读几十字节都打到底层 Flash。
- 读请求按 Flash 页、逻辑块、资源包块和 I/O 对齐要求对齐。
- 合并相邻小请求,减少命令开销和片选切换。
- 资源索引、目录表、偏移表在打开资源时提前加载。
- 避免在实时读取期间做长时间写入或擦除。
- 若必须读写共存,建立 I/O Arbiter:实时读优先,非关键写入延迟或降级。
- 对于只读资源,优先使用连续布局,减少碎片和跨块跳转。
Linux 里的对应机制包括:
readahead():提前把后续数据放进 page cache。POSIX_FADV_SEQUENTIAL:告诉内核顺序访问,扩大 readahead window。POSIX_FADV_RANDOM:告诉内核随机访问,避免错误的顺序预读。- bcache 的 sequential bypass:大顺序 I/O 不一定值得进入随机缓存。
缓存解决的是底层存储长尾延迟,不是长期带宽不足。因此读块大小和水位线要按最坏读延迟配置,而不是按实验室平均读速率配置。
I/O 搬运与完成路径
I/O 搬运不应被设计成“应用线程同步读到底”。更好的方式是异步提交、后台完成、完成后提交缓存状态。
1 | flowchart LR |
对应 Linux 实现里,io_uring 的 SQ/CQ ring 是很好的参考:请求与完成解耦,完成事件带结果和 request id;多个请求可以在途,完成顺序不保证等于提交顺序。即使系统没有 io_uring,这个模型仍然适合作为缓存层的内部结构。
最终回答组织方式
回答这类题时,可以按以下顺序展开:
- 先讲核心原则:应用线程不要直接等 Flash,要读 SRAM 缓存。
- 再讲分层架构:应用接口、Cache Manager、后台 Worker、底层 I/O。
- 接着讲 Buffer 选型:顺序流用 Ring Buffer,随机读用 Block Cache,混合读用 Segment Cache。
- 然后讲线程协作:应用提交请求,Worker 合并请求并异步读取,完成后提交缓存状态。
- 再讲水位线:高水位停预读,低水位启动预读,危险水位优先补缓存并限制非关键 I/O。
- 最后讲容量计算:按应用消费速率、底层最坏延迟、调度抖动和安全余量反推缓存容量。
一句话概括:Flash 上的数据只是存储形态,应用真正依赖的是 SRAM 缓存层;缓存层通过 Ring/Block/Segment 三类结构、预读、水位线、异步 I/O 和资源仲裁,把低速 Flash 的不确定性挡在应用实时路径之外。






