本文适合对 cache 相关知识有一定基础的读者。没有基础的建议先阅读CPU Cache 的映射与寻址ARM 架构下 cache 一致性问题整理在嵌入式系统里cache 问题常常不是“cache 是否生效”这么简单而是CPU、DMA、外设、另一个 CPU、甚至同一个 CPU 的 I-cache 和 D-cache看到的内存内容是否一致。ARM64 提供了多类 cache maintenance operation常见的两条主线是按虚拟地址也就是 by VA维护某一段地址范围对应的 cache line。按 set/way遍历整个 cache 层级维护整个 D-cache。这两类操作看起来都能“刷 cache”但它们的语义、适用场景和副作用完全不同。本文以 Zephyr 的 ARM64 cache 实现为例串起 ARM64 下常见的 clean、invalidate、cleaninvalidate、PoU、PoC、VA 操作和 set/way 操作。本文参考的 Zephyr 4.4.1 源码如下include/zephyr/cache.h include/zephyr/arch/arm64/cache.h include/zephyr/arch/arm64/barrier.h arch/arm64/core/Kconfig1. 先统一几个术语讨论 cache 维护前需要先把几个词说清楚。不同 OS 或芯片 SDK 对 “flush” 的叫法并不完全一致所以看代码时一定要回到具体实现。1.1 CleanClean 的意思是如果某个 cache line 是 dirty 的就把它写回到下一级 cache 或内存体系中写回后这条 cache line 可以继续保留在 cache 里。在 Zephyr 的 cache API 里flush对 D-cache 来说对应的就是 cleanstaticALWAYS_INLINEintarch_dcache_flush_range(void*addr,size_tsize){returnarm64_dcache_range(addr,size,K_CACHE_WB);}这里的K_CACHE_WB可以理解为 write back。典型场景是 CPU 写好了一个 DMA TX buffer然后外设要从内存读取这段数据。CPU 写入的数据可能还停留在 D-cache 中设备直接读内存会读到旧值。因此在启动 DMA 前需要 clean 到设备能观察到的位置。1.2 InvalidateInvalidate 的意思是让 cache line 失效。后续 CPU 再访问这段地址时必须重新从下一级 cache 或内存体系中取数据。Invalidate 是有破坏性的。如果某条 cache line 里有 dirty 数据还没有写回内存就直接 invalidate那么这些 dirty 数据会丢失。典型场景是 DMA 已经把 RX buffer 写到了内存CPU 之前可能缓存过这段地址。CPU 读取前需要 invalidate 对应的 D-cache line避免读到旧 cache 内容。1.3 Clean and InvalidateClean and invalidate 是先写回 dirty 数据再让 cache line 失效。它常用于关闭 D-cache 前确保 dirty 数据不会丢。DMA buffer 生命周期边界上确保 cache 中没有未来可能回写的旧内容。某些不确定方向或双向 DMA 场景。在 ARM64 指令名里civac、cisw中的ci就是 clean and invalidate。1.4 Cache line 粒度无论传入的地址是否对齐大多数 cache 维护指令最终作用在 cache line 上而不是单个字节上。假设 cache line 大小为 64 字节维护地址0x1003对应的其实是0x1000..0x103f这一整条 cache line这一点很重要。这也是 cache 维护里最容易出数据损坏的地方两个 buffer 如果共享同一条 cache line一个用于 DMA另一个由 CPU 修改那么 invalidate DMA buffer 时可能把相邻对象的 dirty 数据一起丢掉。Linux DMA 文档也反复强调 DMA buffer 的 cache line 对齐和隔离问题。非 coherent 平台上buffer 和普通 CPU 数据共享 cache line 很容易造成数据被覆盖。2. PoU 与 PoC两个可见性边界ARM 文档里的 PoU 和 PoC 描述的是内存系统中的可见性边界到达这个边界后特定范围内的观察者必须对同一内存位置看到同一个副本。它们不是固定的 cache 层级不是寄存器也不能简单等同于“主存”。实际位置取决于具体实现、shareability domain、内存属性、系统 cache、互联和一致性拓扑。本文只保留理解 cache maintenance 指令所需的语义完整定义应以 Arm Architecture Reference Manual 为准。2.1 PoUPoint of UnificationPoU 描述的是 instruction side、data side 和 translation table walk 在哪里统一。对单个 PE 来说PoU 是该 PE 的 I-cache、D-cache 和页表遍历都保证看到同一内存副本的边界对 Inner Shareable shareability domain 来说PoU 扩展为该共享域内所有 PE 的这些路径都保证看到同一内存副本的边界。这个概念最常见的工程入口是自修改代码、JIT、动态加载代码和热补丁。CPU 通过 data side 写入新的指令字节后instruction side 不一定立刻放弃旧的 I-cache 内容。标准处理思路是先把 D-cache 中的新内容 clean 到 PoU再把对应 I-cache line invalidate 到 PoU最后用屏障保证后续取指按新的指令流执行。clean D-cache by VA to PoU DSB invalidate I-cache by VA to PoU DSB ISB常见指令是dc cvau, xN // Clean data cache by VA to PoU ic ivau, xN // Invalidate instruction cache by VA to PoUPoU 定义中包含 translation table walk所以页表更新也与它相关。但页表更新的正确性还涉及页表写入顺序、TLBI、DSB/ISB 以及异常级切换等要求不能把上面这组自修改代码流程直接套到页表维护上。2.2 PoCPoint of CoherencyPoC 描述的是所有能够访问某个内存位置的 agents 在哪里达到一致。到达 PoC 后这些 agents 对该内存位置的访问必须看到同一个副本而且这个保证不依赖某一种特定的 memory type 或 cacheability attribute。这里的 agents 可以包括 PE、DMA master、外设总线 master以及系统中其他能访问该内存位置的部件。很多系统里PoC 可以近似理解为主存附近的边界但这只是常见实现形态不是体系结构定义。ARM 并不禁止 PoC 之后还存在 cache只要这些 cache 不破坏 memory system agents 之间的一致性。系统级 cache 也会影响 PoC 的实际位置。对驱动来说PoC 最常见的意义是 CPU 与 DMA/外设交接数据CPU 写 buffer设备读 bufferCPU 需要 clean D-cache 到 PoC使设备读取时能看到 CPU 写入的新内容。设备写 bufferCPU 读 bufferCPU 需要 invalidate 对应 D-cache line丢弃本地可能存在的旧副本使后续读取重新从一致的内存体系取数。Zephyr ARM64 的 D-cache range 操作注释写得很直接/* * operation for data cache by virtual address to PoC */也就是说Zephyr 这里的cvac、ivac、civac都是面向 PoC 的 D-cache 维护。3. ARM64 常见 cache 维护指令ARM64 的 cache 维护指令很多这里只列本文相关的核心指令。指令名可以拆开看Iinvalidate。Cclean。CIclean and invalidate。VAby virtual address。SWby set/way。PoU/PoC维护目标边界。指令含义常见用途dc cvac, XtClean data cache by VA to PoCCPU 写完 DMA TX buffer设备读之前dc ivac, XtInvalidate data cache by VA to PoCDMA 写完 RX bufferCPU 读之前dc civac, XtClean and invalidate data cache by VA to PoCbuffer 生命周期切换、关闭 cache 前的局部维护dc cvau, XtClean data cache by VA to PoU自修改代码、JIT、代码加载ic ivau, XtInvalidate instruction cache by VA to PoU自修改代码后让 I-cache 重新取指dc csw, XtClean data cache by set/way全 D-cache cleandc isw, XtInvalidate data cache by set/way全 D-cache invalidatedc cisw, XtClean and invalidate data cache by set/way关闭 cache、启动阶段清理ic ialluInvalidate all instruction caches to PoU全 I-cache invalidateic ialluisInvalidate all instruction caches to PoU, Inner Shareable多核共享域内的 I-cache invalidate可以看到ARM64 的 D-cache 操作本身就分成两大类一类带VA一类带set/way。Zephyr ARM64 正好也按这两类实现。4. Zephyr 的 cache API 层Zephyr 对外提供的是通用 cache API架构层再把这些 API 映射到具体指令。常见 D-cache API 有sys_cache_data_flush_range(addr,size);sys_cache_data_invd_range(addr,size);sys_cache_data_flush_and_invd_range(addr,size);sys_cache_data_flush_all();sys_cache_data_invd_all();sys_cache_data_flush_and_invd_all();在include/zephyr/cache.h中range API 会进入架构实现staticALWAYS_INLINEintz_impl_sys_cache_data_flush_range(void*addr,size_tsize){#ifdefined(CONFIG_CACHE_MANAGEMENT)defined(CONFIG_DCACHE)returncache_data_flush_range(addr,size);#endifARG_UNUSED(addr);ARG_UNUSED(size);return-ENOTSUP;}对 ARM64 来说最终会落到staticALWAYS_INLINEintarch_dcache_flush_range(void*addr,size_tsize){returnarm64_dcache_range(addr,size,K_CACHE_WB);}staticALWAYS_INLINEintarch_dcache_invd_range(void*addr,size_tsize){returnarm64_dcache_range(addr,size,K_CACHE_INVD);}staticALWAYS_INLINEintarch_dcache_flush_and_invd_range(void*addr,size_tsize){returnarm64_dcache_range(addr,size,K_CACHE_WB_INVD);}这三个接口共用arm64_dcache_range()区别只是传入的op不同。全 cache 操作则由CONFIG_ARM64_DCACHE_ALL_OPS控制config ARM64_DCACHE_ALL_OPS bool Provide data cache APIs to operate all data caches depends on CACHE_MANAGEMENT DCACHE也就是说Zephyr 把 all 操作当作一个可选能力而不是默认必备能力。这一点很重要全 cache 操作通常用于启动、关 cache、异常恢复等受控阶段不应该轻易隐藏在普通 range API 下面。另外本版本 Zephyr ARM64 的 I-cache 维护接口目前返回-ENOTSUP。这不代表 ARM64 架构没有 I-cache 维护指令而是 Zephyr 这份实现尚未提供对应的 I-cache range/all 逻辑。5. VA 方式按虚拟地址维护一段范围VA 方式的核心思想是调用者给出一个虚拟地址范围内核逐条 cache line 执行维护指令。Zephyr 的核心实现如下staticALWAYS_INLINEintarm64_dcache_range(void*addr,size_tsize,intop){size_tline_size;uintptr_tstart_addr(uintptr_t)addr;uintptr_tend_addrstart_addrsize;line_sizearch_dcache_line_size_get();/* invalidate 的非对齐边界特殊处理 */start_addr~(line_size-1);while(start_addrend_addr){if(opK_CACHE_INVD){dc_ops(ivac,start_addr);}elseif(opK_CACHE_WB){dc_ops(cvac,start_addr);}elseif(opK_CACHE_WB_INVD){dc_ops(civac,start_addr);}start_addrline_size;}barrier_dsync_fence_full();return0;}5.1 获取 cache line 大小ARM64 使用CTR_EL0获取 D-cache 最小 line sizectr_el0read_sysreg(CTR_EL0);dminline(ctr_el0CTR_EL0_DMINLINE_SHIFT)CTR_EL0_DMINLINE_MASK;dcache_line_size4dminline;DminLine表示以 4 字节 word 为单位的 log2 值所以代码用4 dminline算出字节数。例如dminline 4时line size 是4 4 64字节。为什么要拿最小 line size因为按 VA 做 range 维护时循环步长必须覆盖所有可能受影响的 cache line。步长过大可能漏刷步长过小最多只是重复维护。5.2 为什么要把 start_addr 向下对齐用户传入的地址可能不是 cache line 对齐的例如addr 0x1003 size 100 line_size 64实际涉及的 cache line 是0x1000..0x103f 0x1040..0x107f因此 Zephyr 会先做start_addr~(line_size-1);这一步等价于把地址向下取整到 cache line 边界。否则如果从0x1003开始每 64 字节递增虽然指令会作用到包含该 VA 的 cache line但循环边界计算容易变得不直观。先对齐后逐 line 维护是最清晰、最不容易漏的写法。5.3 clean rangedc cvac当op K_CACHE_WB时Zephyr 执行dc_ops(cvac,start_addr);cvac是 Clean by VA to PoC。它适用于 CPU 写、设备读的方向CPU write buffer sys_cache_data_flush_range(buffer, len) start DMA_TO_DEVICEclean 不会丢数据只是把 dirty line 写出去因此 Zephyr 通用 API 文档也说 flush 操作对非对齐地址通常没有 invalidate 那么危险。5.4 invalidate rangedc ivac当op K_CACHE_INVD时主循环中执行dc_ops(ivac,start_addr);ivac是 Invalidate by VA to PoC。它适用于设备写、CPU 读的方向DMA writes buffer DMA complete interrupt sys_cache_data_invd_range(buffer, len) CPU reads buffer但是 invalidate 有一个大坑如果范围首尾不是 cache line 对齐直接 invalidate 会把相邻对象一起失效。相邻对象如果是 dirty 的就会丢数据。Zephyr 对此做了保护if(opK_CACHE_INVD){if(end_addr(line_size-1)){end_addr~(line_size-1);dc_ops(civac,end_addr);}if(start_addr(line_size-1)){start_addr~(line_size-1);if(start_addrend_addr){gotodone;}dc_ops(civac,start_addr);start_addrline_size;}}逻辑是对范围头部和尾部的非完整 cache line不直接ivac。改用civac先写回再失效避免相邻 dirty 数据被丢掉。中间完整覆盖的 cache line 再用ivac。举个例子假设 cache line 是 64 字节两个 32 字节 buffer 共享同一条 linecache line: 0x1000..0x103f -------------------------------------------- | buffer A, CPU dirty | buffer B, DMA buffer | -------------------------------------------- 0x1000 0x1020 0x1040如果只想 invalidatebuffer B直接ivac 0x1020会让整条0x1000..0x103f失效buffer A的 dirty 数据也会丢失。Zephyr 在这种边界 line 上使用civac就是为了避免这个损坏。需要注意这种保护并不等于“非对齐 DMA buffer 永远安全”。对于 DMA_FROM_DEVICE最理想的做法仍然是让 DMA buffer cache line 对齐并避免和 CPU 会修改的数据共享 cache line。5.5 clean and invalidate rangedc civac当op K_CACHE_WB_INVD时Zephyr 执行dc_ops(civac,start_addr);它会先 clean 到 PoC再 invalidate。常见用途是让一段 buffer 从 CPU cache 状态中退出或者在不确定是否存在 dirty line 时做保守维护。不过在 DMA 场景中不能机械地把所有操作都换成civac。例如 DMA 已经写完 RX buffer 后如果 CPU cache 中仍有旧 dirty linecivac的 clean 可能把旧数据写回去覆盖 DMA 新数据。因此 DMA 同步要按方向和时机来设计而不是简单地“cleaninvalidate 总是最强”。5.6 为什么最后要 DSBZephyr 在 range 操作最后调用barrier_dsync_fence_full();在 ARM64 上它对应dsb sycache maintenance 指令不是普通函数调用意义上的同步点。执行到下一条指令不代表维护操作对系统其他观察者已经完成。DSB的作用是等待前面的 cache maintenance 完成再继续执行后续代码。对于 DMA 场景这个屏障非常关键。否则代码可能先启动了 DMA而 clean 还没有真正完成设备仍然可能读到旧数据。6. Set/Way 方式遍历整个 D-cacheSet/Way 方式不关心某个虚拟地址。它把 cache 当成一个由 level、set、way 组成的硬件结构逐项遍历并执行维护指令。Zephyr 的 all 操作入口是staticALWAYS_INLINEintarm64_dcache_all(intop)它只在CONFIG_ARM64_DCACHE_ALL_OPS打开时可用。6.1 读取 CLIDR_EL1知道有多少级 cache代码先读CLIDR_EL1clidr_el1read_clidr_el1();loc(clidr_el1CLIDR_EL1_LOC_SHIFT)CLIDR_EL1_LOC_MASK;if(!loc){return0;}LoC可以理解为处理器需要维护 coherency 的 cache 层级范围。Zephyr 随后从 level 0 遍历到loc - 1。每一级 cache 的类型由CTYPE表示ctype(clidr_el1CLIDR_EL1_CTYPE_SHIFT(cache_level))CLIDR_EL1_CTYPE_MASK;if(ctype2){continue;}ctype 2表示这一层没有 data cache 或 unified cache跳过。因为这里维护的是 D-cache只有 data 或 unified cache 才需要处理。6.2 通过 CSSELR_EL1 选择 cache level接下来选择某一级 cachecsselr_el1cache_level1;write_csselr_el1(csselr_el1);barrier_isync_fence_full();写CSSELR_EL1后要执行ISB。原因是随后读取CCSIDR_EL1时需要确保选择 cache level 的系统寄存器写入已经生效。6.3 读取 CCSIDR_EL1得到 line/set/way 信息选中 cache level 后Zephyr 读取CCSIDR_EL1ccsidr_el1read_ccsidr_el1();line_size(ccsidr_el1CCSIDR_EL1_LN_SZ_SHIFTCCSIDR_EL1_LN_SZ_MASK)4;max_ways(ccsidr_el1CCSIDR_EL1_WAYS_SHIFT)CCSIDR_EL1_WAYS_MASK;max_sets(ccsidr_el1CCSIDR_EL1_SETS_SHIFT)CCSIDR_EL1_SETS_MASK;way_pos__builtin_clz(max_ways);这里的line_size不是字节数而是用于构造 set/way 操作数的 bit shift。CCSIDR_EL1.LineSize的编码含义是log2(number of bytes in cache line) - 4所以代码加 4 后得到 set 字段左移的位置。max_ways和max_sets是最大编号因此后面的循环使用for(set0;setmax_sets;set){for(way0;waymax_ways;way){...}}6.4 构造 set/way 操作数ARM64 的 set/way 指令操作数不是普通地址而是把 level、set、way 编到一个寄存器里dc_valwayway_pos;dc_val|csselr_el1;dc_val|setline_size;然后根据操作类型执行不同指令if(opK_CACHE_INVD){dc_ops(isw,dc_val);}elseif(opK_CACHE_WB_INVD){dc_ops(cisw,dc_val);}elseif(opK_CACHE_WB){dc_ops(csw,dc_val);}对应关系是K_CACHE_INVD - dc isw K_CACHE_WB - dc csw K_CACHE_WB_INVD - dc cisw6.5 结束时恢复 CSSELR_EL1 并加屏障最后 Zephyr 恢复CSSELR_EL1write_csselr_el1(0);barrier_dsync_fence_full();barrier_isync_fence_full();DSB确保 cache maintenance 完成ISB确保后续指令看到恢复后的系统寄存器状态。7. VA 与 Set/Way 的差异两种方式的差异可以概括为一句话VA 方式是精确维护某个地址范围set/way 方式是按硬件结构维护整个 cache。对比项VA 方式Set/Way 方式操作对象某段虚拟地址覆盖的 cache line某级 cache 的所有 set/way典型指令dc cvac、dc ivac、dc civacdc csw、dc isw、dc cisw常见用途DMA buffer、局部数据同步启动、关 cache、全局清理副作用限定在指定 range 附近影响整个 cache 工作集延迟特征大致和 range 大小相关和 cache 层级、sets、ways 相关是否依赖 VA 映射是否是否适合作为通用 range 替代是原本就是 range 语义不适合副作用太大8. 典型使用场景8.1 CPU 写DMA 读clean range例如网卡发送、SD/MMC 写盘、SPI TX DMACPU 填充 tx_buf sys_cache_data_flush_range(tx_buf, len) 配置 DMA 源地址和长度 启动 DMA对应 ARM64 指令是dc cvac。目标是把 CPU 的 dirty 数据写到 PoC让设备从内存体系中读到最新内容。8.2 DMA 写CPU 读invalidate range例如网卡接收、SD/MMC 读盘、SPI RX DMA准备 rx_buf 必要时先 cleaninvalidate确保没有旧 dirty line 启动 DMA DMA 完成 sys_cache_data_invd_range(rx_buf, len) CPU 读取 rx_bufDMA 完成后的 invalidate 是为了丢弃 CPU 可能缓存的旧内容让 CPU 重新从内存体系读取设备写入的新数据。这里最需要注意 cache line 对齐。如果 RX buffer 和普通 CPU 数据共享 cache lineDMA 写一部分、CPU 写另一部分很容易互相覆盖。8.3 双向 DMAcleaninvalidate但要分阶段双向 DMA 不是一句civac就结束。更合理的思路是设备读之前CPU 修改过的内容要 clean。设备写之后CPU 读之前要 invalidate。如果 buffer 可能残留 dirty line交给设备写之前需要清掉这些旧状态。Linux DMA API 要求驱动明确提供 DMA 方向正是为了让底层平台选择正确的 cache 维护方式。裸用 Zephyr cache API 时驱动作者需要自己把这个方向关系想清楚。8.4 自修改代码或动态加载代码PoU I-cache架构上自修改代码需要维护 D-cache 和 I-cacheCPU store new instructions dc cvau, each line dsb ish ic ivau, each line dsb ish isb branch to new code这里使用的是 PoU 语义而不是 PoC 语义。目标是让 data side 的写入与 instruction side 的后续取指完成统一不是把数据发布给 DMA 或外设这类系统 agent。需要再次强调本文分析的 Zephyr ARM64 文件当前没有实现 I-cache range 维护相关接口返回-ENOTSUP。如果某个平台需要动态代码执行需要补齐架构实现或使用平台提供的专用接口。8.5 关闭 D-cache 或启动阶段set/way all关闭 D-cache 前必须处理 dirty line否则内存中可能缺少 CPU 最近写入的数据。Zephyr 在 EL2 初始化时就有类似逻辑if(regSCTLR_C_BIT){arch_dcache_flush_and_invd_all();barrier_isync_fence_full();reg~(SCTLR_C_BIT|SCTLR_M_BIT);write_sctlr_el2(reg);}这种场景下使用 set/way all 是合理的因为目标就是全局清理 cache 状态而不是维护某个 buffer。