本文适合对 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、clean+invalidate、PoU、PoC、VA 操作和 set/way 操作。
本文参考的 Zephyr 4.4.1 源码如下:
text
include/zephyr/cache.h
include/zephyr/arch/arm64/cache.h
include/zephyr/arch/arm64/barrier.h
arch/arm64/core/Kconfig
1. 先统一几个术语
讨论 cache 维护前,需要先把几个词说清楚。不同 OS 或芯片 SDK 对 "flush" 的叫法并不完全一致,所以看代码时一定要回到具体实现。
1.1 Clean
Clean 的意思是:如果某个 cache line 是 dirty 的,就把它写回到下一级 cache 或内存体系中;写回后,这条 cache line 可以继续保留在 cache 里。
在 Zephyr 的 cache API 里,flush 对 D-cache 来说对应的就是 clean:
c
static ALWAYS_INLINE int arch_dcache_flush_range(void *addr, size_t size)
{
return arm64_dcache_range(addr, size, K_CACHE_WB);
}
这里的 K_CACHE_WB 可以理解为 write back。
典型场景是 CPU 写好了一个 DMA TX buffer,然后外设要从内存读取这段数据。CPU 写入的数据可能还停留在 D-cache 中,设备直接读内存会读到旧值。因此在启动 DMA 前,需要 clean 到设备能观察到的位置。
1.2 Invalidate
Invalidate 的意思是:让 cache line 失效。后续 CPU 再访问这段地址时,必须重新从下一级 cache 或内存体系中取数据。
Invalidate 是有破坏性的。如果某条 cache line 里有 dirty 数据,还没有写回内存,就直接 invalidate,那么这些 dirty 数据会丢失。
典型场景是 DMA 已经把 RX buffer 写到了内存,CPU 之前可能缓存过这段地址。CPU 读取前需要 invalidate 对应的 D-cache line,避免读到旧 cache 内容。
1.3 Clean and Invalidate
Clean 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 PoU:Point of Unification
PoU 描述的是 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,最后用屏障保证后续取指按新的指令流执行。
text
clean D-cache by VA to PoU
DSB
invalidate I-cache by VA to PoU
DSB
ISB
常见指令是:
asm
dc cvau, xN // Clean data cache by VA to PoU
ic ivau, xN // Invalidate instruction cache by VA to PoU
PoU 定义中包含 translation table walk,所以页表更新也与它相关。但页表更新的正确性还涉及页表写入顺序、TLBI、DSB/ISB 以及异常级切换等要求,不能把上面这组自修改代码流程直接套到页表维护上。
2.2 PoC:Point of Coherency
PoC 描述的是所有能够访问某个内存位置的 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,设备读 buffer:CPU 需要 clean D-cache 到 PoC,使设备读取时能看到 CPU 写入的新内容。
- 设备写 buffer,CPU 读 buffer:CPU 需要 invalidate 对应 D-cache line,丢弃本地可能存在的旧副本,使后续读取重新从一致的内存体系取数。
Zephyr ARM64 的 D-cache range 操作注释写得很直接:
c
/*
* operation for data cache by virtual address to PoC
*/
也就是说,Zephyr 这里的 cvac、ivac、civac 都是面向 PoC 的 D-cache 维护。
3. ARM64 常见 cache 维护指令
ARM64 的 cache 维护指令很多,这里只列本文相关的核心指令。指令名可以拆开看:
I:invalidate。C:clean。CI:clean and invalidate。VA:by virtual address。SW:by set/way。PoU/PoC:维护目标边界。
| 指令 | 含义 | 常见用途 |
|---|---|---|
dc cvac, Xt |
Clean data cache by VA to PoC | CPU 写完 DMA TX buffer,设备读之前 |
dc ivac, Xt |
Invalidate data cache by VA to PoC | DMA 写完 RX buffer,CPU 读之前 |
dc civac, Xt |
Clean and invalidate data cache by VA to PoC | buffer 生命周期切换、关闭 cache 前的局部维护 |
dc cvau, Xt |
Clean data cache by VA to PoU | 自修改代码、JIT、代码加载 |
ic ivau, Xt |
Invalidate instruction cache by VA to PoU | 自修改代码后让 I-cache 重新取指 |
dc csw, Xt |
Clean data cache by set/way | 全 D-cache clean |
dc isw, Xt |
Invalidate data cache by set/way | 全 D-cache invalidate |
dc cisw, Xt |
Clean and invalidate data cache by set/way | 关闭 cache、启动阶段清理 |
ic iallu |
Invalidate all instruction caches to PoU | 全 I-cache invalidate |
ic ialluis |
Invalidate 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 有:
c
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 会进入架构实现:
c
static ALWAYS_INLINE int z_impl_sys_cache_data_flush_range(void *addr, size_t size)
{
#if defined(CONFIG_CACHE_MANAGEMENT) && defined(CONFIG_DCACHE)
return cache_data_flush_range(addr, size);
#endif
ARG_UNUSED(addr);
ARG_UNUSED(size);
return -ENOTSUP;
}
对 ARM64 来说,最终会落到:
c
static ALWAYS_INLINE int arch_dcache_flush_range(void *addr, size_t size)
{
return arm64_dcache_range(addr, size, K_CACHE_WB);
}
static ALWAYS_INLINE int arch_dcache_invd_range(void *addr, size_t size)
{
return arm64_dcache_range(addr, size, K_CACHE_INVD);
}
static ALWAYS_INLINE int arch_dcache_flush_and_invd_range(void *addr, size_t size)
{
return arm64_dcache_range(addr, size, K_CACHE_WB_INVD);
}
这三个接口共用 arm64_dcache_range(),区别只是传入的 op 不同。
全 cache 操作则由 CONFIG_ARM64_DCACHE_ALL_OPS 控制:
kconfig
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 的核心实现如下:
c
static ALWAYS_INLINE int arm64_dcache_range(void *addr, size_t size, int op)
{
size_t line_size;
uintptr_t start_addr = (uintptr_t)addr;
uintptr_t end_addr = start_addr + size;
line_size = arch_dcache_line_size_get();
/* invalidate 的非对齐边界特殊处理 */
start_addr &= ~(line_size - 1);
while (start_addr < end_addr) {
if (op == K_CACHE_INVD) {
dc_ops("ivac", start_addr);
} else if (op == K_CACHE_WB) {
dc_ops("cvac", start_addr);
} else if (op == K_CACHE_WB_INVD) {
dc_ops("civac", start_addr);
}
start_addr += line_size;
}
barrier_dsync_fence_full();
return 0;
}
5.1 获取 cache line 大小
ARM64 使用 CTR_EL0 获取 D-cache 最小 line size:
c
ctr_el0 = read_sysreg(CTR_EL0);
dminline = (ctr_el0 >> CTR_EL0_DMINLINE_SHIFT) & CTR_EL0_DMINLINE_MASK;
dcache_line_size = 4 << dminline;
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 对齐的,例如:
text
addr = 0x1003
size = 100
line_size = 64
实际涉及的 cache line 是:
text
0x1000..0x103f
0x1040..0x107f
因此 Zephyr 会先做:
c
start_addr &= ~(line_size - 1);
这一步等价于把地址向下取整到 cache line 边界。否则如果从 0x1003 开始每 64 字节递增,虽然指令会作用到包含该 VA 的 cache line,但循环边界计算容易变得不直观。先对齐后逐 line 维护,是最清晰、最不容易漏的写法。
5.3 clean range:dc cvac
当 op == K_CACHE_WB 时,Zephyr 执行:
c
dc_ops("cvac", start_addr);
cvac 是 Clean by VA to PoC。它适用于 CPU 写、设备读的方向:
text
CPU write buffer
sys_cache_data_flush_range(buffer, len)
start DMA_TO_DEVICE
clean 不会丢数据,只是把 dirty line 写出去,因此 Zephyr 通用 API 文档也说 flush 操作对非对齐地址通常没有 invalidate 那么危险。
5.4 invalidate range:dc ivac
当 op == K_CACHE_INVD 时,主循环中执行:
c
dc_ops("ivac", start_addr);
ivac 是 Invalidate by VA to PoC。它适用于设备写、CPU 读的方向:
text
DMA writes buffer
DMA complete interrupt
sys_cache_data_invd_range(buffer, len)
CPU reads buffer
但是 invalidate 有一个大坑:如果范围首尾不是 cache line 对齐,直接 invalidate 会把相邻对象一起失效。相邻对象如果是 dirty 的,就会丢数据。
Zephyr 对此做了保护:
c
if (op == K_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_addr == end_addr) {
goto done;
}
dc_ops("civac", start_addr);
start_addr += line_size;
}
}
逻辑是:
- 对范围头部和尾部的非完整 cache line,不直接
ivac。 - 改用
civac,先写回再失效,避免相邻 dirty 数据被丢掉。 - 中间完整覆盖的 cache line 再用
ivac。
举个例子,假设 cache line 是 64 字节,两个 32 字节 buffer 共享同一条 line:
text
cache line: 0x1000..0x103f
+----------------------+----------------------+
| buffer A, CPU dirty | buffer B, DMA buffer |
+----------------------+----------------------+
0x1000 0x1020 0x1040
如果只想 invalidate buffer 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 range:dc civac
当 op == K_CACHE_WB_INVD 时,Zephyr 执行:
c
dc_ops("civac", start_addr);
它会先 clean 到 PoC,再 invalidate。常见用途是让一段 buffer 从 CPU cache 状态中退出,或者在不确定是否存在 dirty line 时做保守维护。
不过在 DMA 场景中,不能机械地把所有操作都换成 civac。例如 DMA 已经写完 RX buffer 后,如果 CPU cache 中仍有旧 dirty line,civac 的 clean 可能把旧数据写回去,覆盖 DMA 新数据。因此 DMA 同步要按方向和时机来设计,而不是简单地"clean+invalidate 总是最强"。
5.6 为什么最后要 DSB
Zephyr 在 range 操作最后调用:
c
barrier_dsync_fence_full();
在 ARM64 上它对应:
asm
dsb sy
cache maintenance 指令不是普通函数调用意义上的同步点。执行到下一条指令,不代表维护操作对系统其他观察者已经完成。DSB 的作用是等待前面的 cache maintenance 完成,再继续执行后续代码。
对于 DMA 场景,这个屏障非常关键。否则代码可能先启动了 DMA,而 clean 还没有真正完成,设备仍然可能读到旧数据。
6. Set/Way 方式:遍历整个 D-cache
Set/Way 方式不关心某个虚拟地址。它把 cache 当成一个由 level、set、way 组成的硬件结构,逐项遍历并执行维护指令。
Zephyr 的 all 操作入口是:
c
static ALWAYS_INLINE int arm64_dcache_all(int op)
它只在 CONFIG_ARM64_DCACHE_ALL_OPS 打开时可用。
6.1 读取 CLIDR_EL1,知道有多少级 cache
代码先读 CLIDR_EL1:
c
clidr_el1 = read_clidr_el1();
loc = (clidr_el1 >> CLIDR_EL1_LOC_SHIFT) & CLIDR_EL1_LOC_MASK;
if (!loc) {
return 0;
}
LoC 可以理解为处理器需要维护 coherency 的 cache 层级范围。Zephyr 随后从 level 0 遍历到 loc - 1。
每一级 cache 的类型由 CTYPE 表示:
c
ctype = (clidr_el1 >> CLIDR_EL1_CTYPE_SHIFT(cache_level)) & CLIDR_EL1_CTYPE_MASK;
if (ctype < 2) {
continue;
}
ctype < 2 表示这一层没有 data cache 或 unified cache,跳过。因为这里维护的是 D-cache,只有 data 或 unified cache 才需要处理。
6.2 通过 CSSELR_EL1 选择 cache level
接下来选择某一级 cache:
c
csselr_el1 = cache_level << 1;
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_EL1:
c
ccsidr_el1 = read_ccsidr_el1();
line_size = (ccsidr_el1 >> CCSIDR_EL1_LN_SZ_SHIFT & CCSIDR_EL1_LN_SZ_MASK) + 4;
max_ways = (ccsidr_el1 >> CCSIDR_EL1_WAYS_SHIFT) & CCSIDR_EL1_WAYS_MASK;
max_sets = (ccsidr_el1 >> CCSIDR_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 是最大编号,因此后面的循环使用 <=:
c
for (set = 0; set <= max_sets; set++) {
for (way = 0; way <= max_ways; way++) {
...
}
}
6.4 构造 set/way 操作数
ARM64 的 set/way 指令操作数不是普通地址,而是把 level、set、way 编到一个寄存器里:
c
dc_val = way << way_pos;
dc_val |= csselr_el1;
dc_val |= set << line_size;
然后根据操作类型执行不同指令:
c
if (op == K_CACHE_INVD) {
dc_ops("isw", dc_val);
} else if (op == K_CACHE_WB_INVD) {
dc_ops("cisw", dc_val);
} else if (op == K_CACHE_WB) {
dc_ops("csw", dc_val);
}
对应关系是:
text
K_CACHE_INVD -> dc isw
K_CACHE_WB -> dc csw
K_CACHE_WB_INVD -> dc cisw
6.5 结束时恢复 CSSELR_EL1 并加屏障
最后 Zephyr 恢复 CSSELR_EL1:
c
write_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 civac |
dc 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 DMA:
text
CPU 填充 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:
text
准备 rx_buf
必要时先 clean+invalidate,确保没有旧 dirty line
启动 DMA
DMA 完成
sys_cache_data_invd_range(rx_buf, len)
CPU 读取 rx_buf
DMA 完成后的 invalidate 是为了丢弃 CPU 可能缓存的旧内容,让 CPU 重新从内存体系读取设备写入的新数据。
这里最需要注意 cache line 对齐。如果 RX buffer 和普通 CPU 数据共享 cache line,DMA 写一部分、CPU 写另一部分,很容易互相覆盖。
8.3 双向 DMA:clean+invalidate,但要分阶段
双向 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-cache:
text
CPU 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 初始化时就有类似逻辑:
c
if (reg & SCTLR_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。