调一块 eMMC 5.1 的板子,最让人上头的不是它不识别,而是它在某个看似正常的写入点上突然多出三四百毫秒。你把文件系统从一种换到另一种,把 cache 关掉再打开,把命令队列开了又关,问题照旧——十次里有八次,最后都落到同一个词上:eMMC 后台操作(Background Operations,下文统一叫 BKOPS)。这是 eMMC 5.1 规范里一个看起来不起眼、但直接决定设备"卡不卡"的机制。它不给主机提供任何新的读写能力,只解决一件事:让设备把内部维护工作挪到总线空闲时去做,而不是卡在你正等结果的时刻。凡是做嵌入式存储、车机、工业网关、手持终端、机顶盒的朋友,只要碰到过"偶发写超时""开机后第一次写特别慢""跑久了延迟越来越抖",这篇都值得从第一个字看到最后。我会把 BKOPS 在设备内部到底动了什么、EXT_CSD 那几个字节怎么读怎么写、主机侧调度怎么排、以及我踩过的几个典型坑,一次讲透。
1. 先把概念摆正:BKOPS 到底在设备内部干什么
1.1 从写放大和垃圾回收说起
NAND 闪存有个物理约束:擦除的最小单位是块,写入的最小单位是页,一个块里页的写入必须是顺序的、且只能写一次(写过的页要再写,得先擦掉整个块)。eMMC 内部有一层 FTL(闪存转换层)把主机看到的逻辑地址映射到物理页上,主机每次覆盖写一个逻辑块,FTL 实际上是在别处找一页空闲的写下去,把旧位置的映射作废。写得越久,作废的页越多,块里就留下大量"半空"的块,有效页被分散在各处。等到空闲块快见底,FTL 必须做一次垃圾回收:挑一个有效页比例最低的块,把里面还有效的页读出来、搬到别的新块里,然后擦掉这个块,回收成空闲块。
这一搬,就是写放大。主机只写了 1 个单位的数据,设备内部可能实际写了 3 个、5 个甚至 10 个单位。而 GC 是耗时动作:读一批、写一批、擦一块,整个链条走下来,几十毫秒是常态,遇到块状态复杂的时候上百毫秒也不稀奇。如果 GC 恰好发生在一个主机写请求的处理过程中,这个写请求的完成时间就会被拉长,上层看到的是一次莫名其妙的超时。BKOPS 存在的意义,就是给这件"必须做但很费时"的事安排一个不被用户感知的窗口。
另一类维护动作也归在同一个筐里:磨损均衡要把写热的物理块和写冷的物理块做迁移,读干扰管理要定期把读了很多次的块刷新重写一遍,数据保持刷新要把长期没动的块搬一次以恢复电荷,掉电恢复之后还需要重建映射表。这些动作如果全堆在写路径上,延迟表现会非常难看;放到后台,设备的响应曲线就平滑得多。这也是为什么 BKOPS 不是一个"可选优化",而是 eMMC 5.1 设备在长期使用后还能维持性能的基础设施。
1.2 四个 EXT_CSD 字节,主机能看到的全部窗口
主机想跟 BKOPS 打交道,能用的全部接口就是 EXT_CSD 里四个字节,全部通过 CMD8(SEND_EXT_CSD)读回来,或者通过 CMD6(SWITCH)写下去。这四个字节之外,主机对设备内部在做什么一无所知——这是理解 BKOPS 的前提,也是很多误判的根源。
| EXT_CSD 字节 | 名称 | 方向 | 含义 |
|---|---|---|---|
| 502 | BKOPS_SUPPORT | 只读 | 设备是否支持 BKOPS,非 0 表示支持 |
| 163 | BKOPS_EN | 可写 | 主机侧的使能位,决定由谁发起后台操作 |
| 164 | BKOPS_START | 可写 | 主机写 1 触发一次 BKOPS,完成后设备自动清 0 |
| 246 | BKOPS_STATUS | 只读 | 设备当前后台工作的紧迫程度,0 到 3 |
BKOPS_STATUS 这四个取值不是随便定的,它对应的是设备内部"还剩多少活没干"的紧迫度分级。0 代表设备当前不需要后台操作,1 代表有活但不急,2 代表再不做就要影响性能了,3 代表已经到了临界状态,设备很可能会在接下来的写操作里强行插入维护动作。我在实际项目里见过太多案例,都是因为没人去看第 246 字节,直到写超时成片出现才回头翻规范。
提示:BKOPS_SUPPORT 是 0 的设备,后面所有流程都无从谈起。选型阶段先读这一位,别等软件都调完了才发现物料不支持。
1.3 它和 Cache、Discard、Sanitize 的边界在哪
新手最容易把 BKOPS 和另外几个 eMMC 特性混成一锅。它们的职责完全不同,混着用会出问题。Cache 是设备内部的一小块写缓存,主机写完数据先进 cache 就返回,靠 CMD6 的 FLUSH_CACHE 落盘,作用是降低写延迟——它加速的是"主机看到写完成"的时间,不改变设备内部的维护节奏。Discard(CMD38,参数 0)是主机告诉设备"这一段逻辑地址我不要了",属于 hint,设备可以不立刻执行,但它能显著减少 GC 时被搬运的有效页数量,从而降低 BKOPS 的工作量。Sanitize(CMD38,参数最高位置 1)是安全擦除,把指定范围的数据彻底物理清除,耗时极长,绝不能在正常业务路径上随手调。
三者之间的关系可以这样理解:Discard 是"减轻后续负担",Cache 是"掩盖当下延迟",BKOPS 是"在空档期把负担消化掉"。一个成熟的存储策略通常是三条线同时布:业务层按生命周期及时 discard,驱动层开 cache 并控制 flush 时机,空闲窗口用 BKOPS 把剩下的活干完。只做其中一条,长期表现都会打折。我见过只开 cache 不开 BKOPS 的方案,前三个月很流畅,第四个月开始写入延迟翻倍,因为设备内部的账一直没还。
2. 方案选型:自动触发还是主机手动调度
2.1 BKOPS_EN 两位组合的真实含义
BKOPS_EN 这个字节里,真正有用的是低两位,但这两位不是"开关"这么简单,它们划分的是控制权归属。bit0 置 1 表示主机接管,设备只在收到 BKOPS_START 之后才做后台操作;bit1 置 1 表示自动模式,设备自己判断空闲时机启动 BKOPS,不需要主机干预。这两个位组合出来的行为和很多人的直觉不一样,值得单独列出来。
| BKOPS_EN | 行为 | 适用场景 |
|---|---|---|
| 0x00 | 设备不做后台维护,直到内部紧迫度逼到临界 | 极短生命周期的临时设备,不推荐 |
| 0x01 | 主机手动控制,写 BKOPS_START 才启动 | 需要精确控制维护窗口的产品 |
| 0x02 | 设备自动启动,主机不干预 | 对延迟不敏感、无精确时序要求的场景 |
| 0x03 | 两位都置,实际以设备实现为准 | 不建议,行为不确定 |
0x00 这个组合在实际产品里几乎不该出现。设备的维护需求不会因为主机不理它就消失,只是被积压到临界点,然后在你最不希望的时刻以写超时的形式爆发。我调过一个手持设备,量产前测试一切正常,用户拿到手用了两个月开始反馈"扫码后偶尔卡住不动",最后查出来就是 BKOPS_EN 一直是 0,设备憋到 critical 才强行做维护,一次卡 600 毫秒。
0x02 的自动模式看起来最省心,但它有一个硬伤:主机不知道设备什么时候在后台干活。如果你在这期间安排了低功耗流程、定时任务、或者对时序有要求的传感器采样,就可能撞车。更麻烦的是自动模式的启动条件由厂商固件决定,不同批次、不同容量的颗粒行为可能不一致,量产一致性上不好保证。
2.2 手动调度到底换来了什么
选 0x01 手动模式,核心收益是可预期。你能明确知道 BKOPS 是什么时候开始的、跑了多久、在什么上下文里跑的,出了问题有据可查。代价是要自己设计调度策略,包括什么时候触发、触发后怎么轮询、超时怎么办、被业务打断怎么续。
我在几个项目里都用的是手动模式,具体做法是:系统进入空闲状态(没有待处理的读写请求、没有前台交互、屏幕熄灭)后延迟一小段时间,确认不是短暂的间隙,再启动 BKOPS。启动后不在主线程轮询,而是挂一个定时器,每隔一段较长的时间读一次状态,读到 0 就收工。这个"较长的时间"很关键,理由在下一章会展开——轮询动作本身会打断后台工作。
自动模式和手动模式不是非此即彼。有些方案会把 bit1 也置上,让设备在主机长时间不管它的时候能自救,同时主机在空闲窗口主动启动。但如前所述,0x03 的行为在不同固件上不一致,要做就得逐批次验证,我不太推荐在量产项目上冒这个险。稳妥的做法是只置 bit0,把控制权牢牢抓在手里。
2.3 和 HPI、命令队列的联动要考虑
eMMC 5.1 引入了命令队列(Command Queue,CQ),允许多个请求在设备内部排队执行,这对吞吐提升明显,但它和 BKOPS 的交互需要在设计时想清楚。CQ 打开后,设备内部有多个请求在飞,主机侧的"空闲"判断变得更微妙:队列深度不为 0 不代表总线忙,但设备可能正在处理队列里的任务。这时候启动 BKOPS,设备会在处理完当前批次后插入维护,实际观感可能是"队列里的请求突然集体慢下来"。
高优先级中断(HPI)则是另一个方向的能力:当某个请求优先级很高、需要立刻插队时,主机可以用它把正在执行的长任务打断。这个机制和 BKOPS 表面上看是矛盾的——一个要打断,一个要在后台慢慢做。实际处理原则是:当业务侧真的有紧急请求时,让 BKOPS 被打断是完全可以接受的,设备会自己保存进度。不必为了保护 BKOPS 而牺牲紧急请求的响应,那属于本末倒置。
真正需要留神的是打断的代价。每次打断之后,如果 BKOPS_EN 是手动模式,设备不会自己续上,需要主机再写一次 BKOPS_START。如果业务侧频繁产生零星请求,BKOPS 就会被反复切碎,永远做不完。这种情况下的正确做法是把维护窗口往前挪——要么在业务真正静默的时段做,要么把零散请求合并后再放行,别让 BKOPS 和业务抢同一个时间段。
3. 实操:把 BKOPS 跑起来的完整流程
3.1 第一步永远是读 EXT_CSD 确认能力位
不管后面写什么代码,先把设备的 EXT_CSD 完整读一遍,确认四个字节的初始值。这一步看起来多余,实际上能挡掉一大半低级错误:有的颗粒 BKOPS_SUPPORT 是 0,有的容量版本 BKOPS_STATUS 出厂就是 1,有的模组厂在固件里把 BKOPS_EN 预设成了 0x02 而你以为是 0x00。不读就写,等于闭着眼睛改配置。
在 Linux 环境下最省事的办法是 mmc-utils 工具包,直接用 extcsd 子命令把 512 字节全 dump 出来。不同版本的子命令写法可能略有出入,用之前先看一眼帮助。
# 查看设备节点 ls /dev/mmcblk* # dump 完整 EXT_CSD mmc extcsd read /dev/mmcblk0 # 只关心 BKOPS 相关的几行 mmc extcsd read /dev/mmcblk0 | grep -i -E "bkops|cache|partition"读回来的输出里,重点确认三项:BKOPS Support 是否为 1,BKOPS_EN 当前是什么值,BKOPS_STATUS 当前是什么级别。如果读出来 STATUS 已经是 2 或 3,说明这颗料在出厂测试或者上一次使用中积压了维护任务,你的第一条命令不该是写数据,而是先让它把活干完。这一点在返修板、二手模组、以及做过大量写入测试的样机上特别常见。
注意:读 EXT_CSD 本身是一次 512 字节的数据传输,会中断设备正在进行的后台操作。所以这个动作不要做成高频轮询,也不要在启动 BKOPS 之后立刻连着读。
3.2 使能、启动、轮询、退出:四段式流程
手动模式的完整动作可以固化成四步,我把它写成 C 伪代码,逻辑比具体 API 更重要。真实工程里你会用内核提供的封装,但理解这四步的先后关系,才不会在出问题时无从下手。
/* 步骤 1:使能主机控制模式,只置 bit0 */ mmc_switch(card, EXT_CSD_CMD_SET_NORMAL, EXT_CSD_BKOPS_EN, 0x01, card->ext_csd.generic_cmd6_time); /* 步骤 2:在空闲窗口启动 BKOPS * 底层是一条 CMD6,SWITCH 参数按 [31:26]=0b000011, * [25:24]=value, [23:16]=index 拼出来: * (0x03 << 24) | (0x01 << 16) | (164 << 8) = 0x0301A400 */ mmc_switch(card, EXT_CSD_CMD_SET_NORMAL, EXT_CSD_BKOPS_START, 0x01, timeout_ms); /* 步骤 3:间隔一段时间后查询状态,不要密集轮询 */ do { sleep_ms(800); mmc_send_ext_csd(card, ext_csd_buf); status = ext_csd_buf[246]; } while (status != 0 && !timeout); /* 步骤 4:收尾。如果被打断过,视需要再启一次 */步骤 2 的 CMD6 参数值得单独说一句,因为很多人第一次写这块会算错。SWITCH 命令的 argument 是按位段拼的:最高 6 位固定为 0b000011 表示这是 SWITCH 操作,接下来 2 位是要写入的值,再 8 位是 EXT_CSD 字节索引。所以写 BKOPS_START(索引 164)为 1,拼出来就是 0x0301A400。写成 164 或者 0x000000A4 都是错的,设备不会报错,只是静默地什么都不做,这类"没反应"的问题最难查。
步骤 3 的轮询间隔是我踩过坑之后定下来的经验值。早期我用 50 毫秒轮询一次,结果发现 BKOPS_STATUS 长期停在 1 不动,一度以为是颗粒不支持。后来想通了:每次读 EXT_CSD 都是一次数据传输,都会打断后台操作,50 毫秒一次的频率意味着设备刚开工就被打断,永远做不完。改成 800 毫秒到 1 秒一次之后,状态很快就归零了。这个数字不是规范规定的,是实测出来的平衡点,你可以根据设备容量和维护量调整。
3.3 内核里那条路径长什么样
如果你在通用内核上做产品,BKOPS 的调度逻辑其实已经被实现过一轮了,位置在块设备驱动层的 mmc 核心部分。理解这条路径有两个好处:一是知道什么时候它会自动帮你做,二是知道什么时候它会跟你手动调的动作打架。
典型实现里会有几个函数:一个负责读状态,一个负责启动,一个负责停止。启动函数内部会先判断几个前提条件——设备是否支持 BKOPS、当前是否已经有 BKOPS 在跑、是不是从挂起流程里调用的。如果是从挂起流程调用,行为会不太一样:它会先把状态读一遍,如果紧迫度已经到了临界,就抓紧在挂起之前做完;如果只是非临界,可能就直接放行了,因为挂起本身也有时间预算。
块设备请求队列空闲之后,会有一个钩子尝试启动 BKOPS。这个钩子的触发时机是"最后一个请求完成之后",也就是天然的静默窗口。如果你在用户态观察到设备在空闲几秒后有一小段总线活动,多半就是它在做这件事。想确认的话可以在调试文件系统里打开 mmc 相关的 trace 事件,观察命令序列里是否出现索引 164 的 SWITCH。这里有个细节需要注意:如果你在用户态也手动写 BKOPS_START,而内核侧的自动钩子同时在工作,两边会互相干扰,表现为状态反复跳变。做产品时把控制权收到一边,别两边都管。
挂起流程里的处理也值得单独提。休眠之前如果有没做完的后台维护,有的实现会选择在休眠前把它做完,有的会选择直接断电让它下次继续。两种策略各有道理:前者保证休眠期间的功耗可预期,后者保证休眠响应够快。如果你的产品对唤醒延迟敏感,就选后者;如果对休眠功耗敏感,就选前者。这个取舍没有标准答案,但要知道它存在,别等到休眠电流测试超标了才回头翻代码。
3.4 用户态能用的观察手段
除了 extcsd 读取,实际排查时还有几个手段值得备着。第一个是内核 trace,把 mmc 相关的请求事件打开,观察命令序列和耗时分布,能直观看到"哪条命令吃掉了时间"。第二个是逻辑分析仪,直接抓 CMD 和 DAT 线,这是最硬的证据,能看到 BKOPS_START 写下去之后设备到底有没有动作、总线空窗有多长。
# 打开 mmc 相关 trace 事件 cd /sys/kernel/debug/tracing echo 1 > events/mmc/enable echo 1 > tracing_on # 复现问题后关闭并查看 cat trace | grep -E "mmc_request|mmc_command"第三个手段比较土但很有效:在启动 BKOPS 前后各打一个时间戳,把耗时记到日志里。跑一整天,统计耗时的分布。如果发现启动时间本身就很长(超过几十毫秒),说明设备内部正在做重活,命令响应的延迟被拉长了;如果启动很快但状态归零很慢,说明维护量本身很大,需要考虑是不是 discard 没做好,让 GC 背了太多无效页。
提示:trace 和逻辑分析仪的观测本身会引入额外负载,测出来的绝对延迟会比真实场景略高。看趋势和相对值,别死抠绝对值。
4. 时序与电源:BKOPS 真正容易翻车的三个地方
4.1 任何一次数据传输都会打断它
这条规则是 BKOPS 所有坑里最容易忽视的一条:主机发起的读写数据传输会中止设备正在进行的后台操作。也就是说,你启动了 BKOPS,然后业务侧突然来一个读请求,后台工作立刻停下,回到空闲后如果 BKOPS_EN 是手动模式,设备不会自己续,得靠主机再写一次 BKOPS_START。
这就解释了一类非常典型的现象:设备明明有很多空闲时间,BKOPS_STATUS 却长期不降。原因是空闲都是碎片化的,每次刚启动就被新请求打断。解决思路不是"让 BKOPS 更强",而是"把空闲时间凑整"——批量处理业务请求、避免高频小包、把维护窗口安排在真正静默的时段。
另一个隐蔽的打断源是状态查询本身。前面说的 50 毫秒轮询就是典型反例。还有一类更隐蔽的:某些框架会在后台定期读设备信息(容量、健康度、温度),这些读取同样会打断 BKOPS。做产品时把这类轮询的周期调长,或者干脆挪到维护窗口之外。
4.2 休眠、断电和恢复的处理
BKOPS 是设备内部动作,主机断电它必然中断。问题在于中断之后留下的状态:如果维护动作做到一半被切断,设备内部的映射表可能处于中间状态,下次上电需要走一次恢复流程。正规颗粒都有掉电保护设计,不会丢已提交的数据,但恢复过程本身要花时间,表现为"冷启动后第一次写特别慢"。
我遇到过一个典型案例:某设备关机时不做任何处理,直接切电。用户反馈"每次开机后头几秒特别卡,之后就正常了"。查下来是关机时设备正在做 BKOPS,被强行切断,下次上电要重建状态。改成关机流程里先等 BKOPS 状态归零(或者超时放弃)再断电,现象明显缓解。
休眠的处理要更细致一些。进入低功耗状态之前,如果 BKOPS 正在跑,有两种选择:等它跑完,或者直接让它断。等它跑完会把休眠延迟拉长,用户体验上就是"按了电源键之后屏幕黑了但设备还要喘几秒"。直接断则要接受下次唤醒后的一小段恢复期。我的做法是分场景:正常休眠走快速路径,直接断;计划内的长时间关机走慢速路径,等它做完。这种分级处理比一刀切要稳。
4.3 温度和长期运行的影响
NAND 的电荷保持特性和温度强相关。高温环境下电荷流失快,设备会更频繁地触发数据刷新;低温环境下写入速度会下降,同样的维护量需要更长时间。这意味着同一套 BKOPS 参数,在实验室常温下跑得很好,到了实际使用环境可能就不够用。
我建议的做法是把维护窗口设计得比实测需求宽一些,并且在状态查询里加入"连续多次读到非零"的计数,超过阈值就上报告警或调整策略。工业类产品尤其要注意,设备可能连续运行几个月不关机,中间没有任何"重启"这样的天然维护窗口,全靠 BKOPS 在后台慢慢消化。这类产品的策略应该是"少量多次",而不是"攒一批干一次"。
还有一点和寿命相关:BKOPS 本身会产生额外的写放大(搬运有效页),所以它不是越频繁越好。频繁启动只会让设备多做无谓的搬运。判断标准应该是 BKOPS_STATUS 的级别,而不是固定周期。状态是 0 的时候启动,纯属浪费寿命。
5. 问题排查实录与速查表
5.1 三个真实场景的定位路径
场景一:开机后首次大量写入出现 600 到 900 毫秒的尖峰。定位路径是先读 BKOPS_STATUS,发现是 3。这说明设备积压已经到临界,写入时被强行插入维护。解决方式是开机流程里加一步:在系统服务起来之前,先检查状态,如果是 2 或 3,就在这个窗口把 BKOPS 跑完。这个窗口本来就是启动等待,把维护塞进去用户完全无感。改完之后尖峰降到 50 毫秒以内。
场景二:设备运行一天后写入延迟整体抬升,但单次尖峰不明显。读状态发现长期停在 1,降不下去。进一步查发现业务侧每秒有十几个小写入,BKOPS 每次启动几十毫秒就被打断。解决办法是在应用层做写入合并,把十几个小写攒成一个批量写,中间留出真正的空档。合并之后状态能在空闲时段正常归零。
场景三:某批次样机 BKOPS 完全没反应。写完 BKOPS_START 之后状态一直是 0,看起来像"不需要维护",但实际写入延迟很差。最后查出来是 CMD6 参数拼错了,写成了 164 而不是 0x0301A400。设备收到非法参数不报错,静默忽略。这类问题的排查方法是对比逻辑分析仪上抓到的实际命令字节,别只信代码。
5.2 常见问题速查表
下面这张表是我这些年攒下来的,遇到现象先对号入座,能省不少时间。
| 现象 | 大概率原因 | 处理动作 |
|---|---|---|
| 写完 BKOPS_START 状态一直为 0 | CMD6 参数拼错,或设备不支持 | 用逻辑分析仪核对命令字节;确认 BKOPS_SUPPORT |
| 状态长期停在 1 不降 | 轮询过密或业务请求频繁打断 | 拉长轮询间隔到 800ms 以上;合并小请求 |
| 状态反复在 0 和 1 之间跳 | 内核自动钩子和用户态手动控制打架 | 只保留一侧控制权 |
| 写入偶发数百毫秒延迟 | 状态已到 2 或 3,设备强行插入维护 | 在启动窗口或空闲窗口提前跑 BKOPS |
| 冷启动后头几秒特别慢 | 上次断电时 BKOPS 中断,上电恢复 | 关机流程加等待,或接受恢复期并延后业务 |
| 休眠唤醒延迟变长 | 休眠前等待 BKOPS 完成 | 分级策略:正常休眠直接断,计划关机等完成 |
| 长时间运行后整体变慢 | 无效页太多,GC 负担重 | 业务层补齐 discard,减少有效页搬运量 |
| 某批次行为不一致 | 固件对 BKOPS_EN 的默认值不同 | 每次上电都显式写一遍,不依赖出厂值 |
5.3 几个只有踩过才知道的细节
第一个:BKOPS_START 写完不是立刻生效的。设备要等当前正在执行的命令收尾,才切到后台模式。所以启动命令的响应时间本身可能就包含了一段等待,别把这个时间算进"BKOPS 耗时"里,否则统计会偏大。
第二个:状态归零不等于所有维护都做完了。设备的策略可能是在紧迫度降到 1 以下就停手,等下次再说。所以看到状态从 3 掉到 1 再掉到 0 是正常过程,不要因为中间停顿就以为卡住了。
第三个:erase group 的定义会影响 discard 的粒度。如果把 ERASE_GROUP_DEF 置 1,擦除单位会变成整个擦除组,discard 的效率会高很多;但这也会让小块 discard 变得不划算。这个取舍要根据业务的数据生命周期来定,不是置 1 就一定好。
第四个:不要拿实验室的测试结果去推量产。同一型号不同批次的颗粒,内部的 GC 策略、预留空间大小、BKOPS 的触发阈值都可能有差异。我一般会在小批量试产时抓一批状态日志,看看分布,再定最终参数。
这套东西我前后在三个产品线上用过,从手持终端到车载设备都有。最深的体会是:BKOPS 不是"开了就完事"的开关,它是一个需要跟业务节奏对齐的调度问题。设备永远比主机更清楚自己内部欠了多少账,主机能做的是给它安排合适的还账时间,以及在它还账的时候别去打扰。把这两件事做好,写入延迟的抖动基本就能压到可接受的范围。