ARM Cache维护实战:DC/IC指令与DMA、JIT一致性解析
2026/9/11 23:35:47 网站建设 项目流程

先从一个真实场景说起。前阵子一个做音视频采集的朋友调试DMA(直接内存访问)驱动,现象很典型:外设明明确认已经把数据写进内存了,CPU这边读出来却总是上一次的旧数据,偶尔还能读到一半新一半旧的"缝合怪"。另一个做动态二进制翻译的同事更崩溃,修改了一段指令并跳到新地址执行,跑出来的还是修改前的旧机器码。两个人问题的根子其实都在同一个地方——CPU cache维护没做对。而ARM体系里解决这类问题最核心的两族指令,就是DC(Data Cache)和IC(Instruction Cache)维护指令。

这篇东西不是给你念架构手册,而是从实际调驱动、写内核、做JIT(即时编译)和裸机开发的视角,把DC/IC指令到底是什么、为什么非用不可、什么场景怎么用、最容易在什么地方翻车讲透。目标是你看完之后,遇到cache一致性相关的bug,能自己推演排查路径,而不是对着ARM手册干瞪眼。

1. 为什么要手动维护cache:弱一致性模型下的必修课

1.1 先厘清问题本质:cache不是"透明"的

很多从x86转过来搞ARM开发的人,第一个不适应就是:x86上写个变量、DMA传个数据、改个代码段,好像什么都不用管,为什么到了ARM上动不动就要"clean cache""invalidate cache"?这背后的根源不是一个简单的"有没有cache",而是内存模型和cache一致性协议的差异。

CPU cache是硬件管理的,这句话没错,但硬件管理的是cache的分配、替换、写回时机,它并不保证软件在所有场景下看到的数据都是最新的。比如CPU写了一份数据到cache line里,此时数据还只在高速缓存中,没有落到DRAM;如果这时候DMA设备直接去读DRAM,读到的就是老数据。反过来,DMA把新数据写进了DRAM,但CPU的cache line里还留着旧副本,CPU一读命中的是cache,拿到的还是旧值。

x86体系之所以"看起来不用管",是因为Intel/AMD在内存模型上做了很强的硬件一致性保证,MESI(修改、独占、共享、无效)协议族加上嗅探(snoop)机制,让多核、DMA等多数情况下的数据一致性问题被硬件扛下来了。ARM则是典型的弱内存模型,CPU、GPU、DMA、其他处理器核心各自看到的数据视图是可以不一致的,软件必须通过barrier和cache维护指令显式地划定"什么时候必须让数据对谁可见"。

这就好比x86开的是一辆带自动巡航和车道保持的车,你闭着眼踩油门大方向不会太偏;ARM是一台手动挡,挡位挂不对、离合不配合,轻则顿挫,重则熄火。DC/IC指令就是你的挡把和离合。

1.2 PoU、PoC、PoP三个边界到底分界在哪

ARM文档里讲cache维护指令时,一定会出现三个缩写:PoU、PoC、PoP。很多入门者挂在第一步,因为这三个概念抽象且容易混。我用大白话拆一遍:

  • PoU(Point of Unification,统一点):指的是某单个处理器核内部,指令缓存、数据缓存、页表遍历和翻译缓存(TLB)在逻辑上"统一"的那个点。最常见的理解是L1指令缓存和L1数据缓存的汇聚点。维护到这个点,主要目的是保证"CPU自己能看到一致的指令和数据视图",典型场景就是自修改代码。

  • PoC(Point of Coherency,一致点):系统中所有观察者(包括所有CPU核、DMA控制器、GPU等)看到的内存视图都一致的那个点。通常就是DRAM主存或系统级互连上的一级。DMA、多核共享数据这类场景,必须维护到PoC。

  • PoP(Point of Persistence,持久点):ARMv8.2之后引入的概念,指的是数据真正落到掉电不丢失的存储介质上的那个点,比如NVDIMM持久内存。普通DRAM没有这个点,用到持久内存的应用才需要关心DC CVAP这类指令。

一句话记忆:PoU管"自己核内的统一",PoC管"所有观察者的一致",PoP管"断电后还得活着"。后面看到DC指令后缀里的VAU对应PoU、VAC对应PoC、VAP对应PoP,就不会乱了。

1.3 一个关键认知:Clean和Invalidate根本是两件事

初学cache维护指令,最常见的一个误区是把"清cache"当成一个笼统的动作。实际上DC指令族里,动作分得非常清楚:

  • Clean(干净化):把cache line里的脏数据(Modified状态)写回下一级内存,但cache line里的数据仍然保留,之后读它仍然命中。
  • Invalidate(失效):把cache line标记为无效,之后读它必须从下一级内存重新加载。注意,失效操作本身不保证把脏数据写回内存,如果cache line是脏的,直接invalidate会导致数据丢失。
  • Clean and Invalidate(干净化并失效):先写回、再作废,一步到位,这也是日常中使用价值最高、最安全的组合操作。

如果你只是想让DMA读到CPU刚写好的数据,需要的是Clean;如果你只是想让CPU重新从内存读DMA写进来的新数据,需要的是Invalidate;如果你分不清当前cache line到底是脏的还是干净的,又必须保证接下来是干净的空cache,那就用Clean and Invalidate,宁可在性能上多花一点,也别赌状态。

2. DC/IC指令族全景拆解:从助记符到硬件行为

2.1 DC(Data Cache)指令:一个动作家族

ARMv8-A AArch64架构下,DC指令按"操作动作 + 操作目标"组合命名。以EL1下最常用的几个为例,我整理成一张表:

指令全称含义维护到哪个点典型用途
DC CIVAC, XtClean and Invalidate by Virtual Address to Point of CoherencyPoCDMA buffer数据更新/共享,最常用的组合操作
DC CVAC, XtClean by Virtual Address to Point of CoherencyPoCCPU写完数据,确保DMA/其他核能读到
DC CVAP, XtClean by Virtual Address to Point of PersistencePoP持久内存写入
DC CVAU, XtClean by Virtual Address to Point of UnificationPoU指令/数据流统一,自修改代码第一步
DC IVAC, XtInvalidate by Virtual Address to Point of CoherencyPoCDMA写入后,CPU丢弃旧cache行
DC ZVA, XtData Cache Zero by Virtual Address当前cache层级清零一整条cache line,优化大块memset
DC CISW, XtClean and Invalidate by Set/Way整个缓存电源管理、CPU hotplug、启动阶段

看到规律了吗?助记符拆开就是:

  • 前缀C表示Clean,前缀I表示Invalidate,CI表示Clean加Invalidate,Z表示Zero。
  • 中间V表示用虚拟地址操作,S表示用Set/Way索引操作。
  • 末尾AC表示维护到PoC,AU表示维护到PoU,AP表示维护到PoP。
  • Xt是保存虚拟地址或Set/Way值的通用寄存器。

以虚拟地址为操作对象的指令,软件只需要给出一个有效地址,硬件会自己算出对应的cache set和way。这意味着你可以精准地只维护一个cache line、一个缓冲区间,而不会影响整个缓存里的其他数据。

2.2 IC(Instruction Cache)指令:不是只有Invalidate

IC指令族比DC简单得多,因为对指令缓存,你绝大多数情况下只需要一个动作——作废。为什么要作废?因为指令缓存只缓存指令,你改了内存里的指令字节,icache里的旧指令副本不会自动失效,CPU取指还是会取到旧指令。

常用的IC指令有:

  • IC IVAU, Xt:Invalidate by Virtual Address to PoU。失效指定虚拟地址对应的指令缓存行,维护到本核统一即可。这是自修改代码、JIT场景最常用的指令。
  • IC IALLU:Invalidate All to PoU。把整个指令缓存全部失效。简单粗暴,适合启动阶段、代码规模变化很大的场景。
  • IC IALLUIS:Invalidate All to PoU, Inner Shareable。不光是当前核,所有Inner Shareable域内的核都把icache清一遍。多核场景下想让全局都重新拉取指令时有用。

很多人第一次看IC IALLUIS会问:为什么是"到PoU"而不是"到PoC"?因为指令缓存的一致性本来就不需要维护到DRAM那个点,指令流的统一发生在核内或核间共享的最后一层cache即可。变量数据才需要关心所有观察者(包括DMA)是否一致,指令流主要是"执行它们的PE(处理单元)"在看。

2.3 操作的是虚拟地址,但不是所有权限都能碰

DC/IC指令按虚拟地址操作,有个隐含前提:这个虚拟地址必须已经在MMU页表里有有效映射,否则地址翻译过不去,指令会触发异常。因此,在使用这些指令之前,要确保操作地址对应的页是映射好的,而且最好避免对UNCACHED(设备内存)区域做cache维护,因为那既没有意义,还可能产生不可预期的副作用。

权限上,ARMv8-A规定,绝大多数DC指令需要EL1或更高特权级才能执行。但有两个例外:DC CVAUIC IVAU,在SCTLR_EL1.UCI位置1的情况下,EL0用户态也能执行。这个设计是有意的:用户态JIT编译器(比如V8、ART、Go运行时)在生成和修改代码后,需要做指令缓存同步,不可能每次修改代码都陷入内核。所以ARM干脆开了个口子,让用户态自己执行这两条轻量维护指令。Linux内核也确实在用户态提供了对应的辅助路径,后面专门讲。

cache line大小也直接影响你写维护循环的效率。AArch64里可以通过CTR_EL0寄存器的DminLineIminLine字段读出数据缓存和指令缓存的最小行大小(以字为单位,换算公式为line_size = 4 << field_value)。写通用cache维护函数时,应该动态读取这个值,而不是硬编码64字节——虽然绝大多数SoC上cache line都是64字节,但这不是架构强制值。

3. 真实场景中的维护序列:照着抄就能用

3.1 DMA与外设:为什么说"Clean后Invalidate"不能反

DMA和外设是DC指令最密集的应用场景。做驱动的人应该听过这样一句话:发给设备的缓冲区要Clean,接收设备数据的缓冲区要Invalidate。这句话对,但不够精细,因为实际中还有"CPU先读DMA写过的buffer、然后改一下再交给DMA"这种复合场景。

典型的DMA接收流程,正确顺序是这样的:

  1. 分配buffer,CPU初始化或者复用上次数据,此时buffer对应的cache行可能是干净的,也可能是脏的。
  2. 在启动DMA之前,执行DC CIVAC(Clean and Invalidate到PoC)。为什么要先 Clean?因为如果cache行是脏的,缓冲区里还残留CPU之前写的数据,DMA直接把外设数据搬进内存,两者会打架。先Clean能确保外设写到DRAM时不会覆盖CPU还没写回的数据;再加Invalidate是为了保证dma收发过程中CPU读这份buffer不会读到自己被Clean前的旧值。
  3. 启动DMA,等中断或轮询完成。
  4. CPU读buffer之前,再执行一次Invalidate(DC IVAC),把CPU cache里可能残留的旧副本作废。

这中间最容易踩的坑是两个:

  • 只Invalidate不Clean。如果这个buffer之前被CPU写过,直接失效会导致脏数据丢失,等你读完DMA数据发现buffer开头还带着CPU之前的残留,根本说不清是哪来的。
  • Clean和Invalidate分开做但顺序反了。先Invalidate再Clean,等于先扔掉可能存在的脏数据,又把可能不该保留的cache内容写回内存,两头不讨好。

我在调I2S音频驱动时,一次经典的bug就是接收buffer在DMA启动前只做了Invalidate,结果每次录音的前几十毫秒音频总是夹杂着上一次录音的尾音——因为上一轮CPU读走数据后,cache line被标记为脏,下一轮直接Invalidate把脏数据丢了,DMA从内存搬的新数据又和残留在cache里的旧数据处理逻辑互相覆盖。改成DC CIVAC之后问题瞬间消失。

3.2 自修改代码/JIT:DC CVAU、IC IVAU、DSB、ISB四步缺一不可

自修改代码(Self-Modifying Code)和JIT是IC指令的核心用户。你在内存里写好一段新指令,接着跳过去执行,如果中间不做任何维护,大概率执行到的还是icache里的旧指令,或者取指流水线里早已预取的过时内容。

正确的指令同步序列长这样:

static void sync_icache(void *addr, size_t size) { uintptr_t start = (uintptr_t)addr; uintptr_t end = start + size; uintptr_t line, dline; /* 读取cache line大小,按行对齐处理 */ asm volatile("mrs %0, ctr_el0" : "=r"(line)); dline = 4 << ((line >> 16) & 0xf); /* 1. 确保新指令从dcache写回,至少到PoU */ for (uintptr_t p = start & ~(dline - 1); p < end; p += dline) { asm volatile("dc cvau, %0" :: "r"(p)); } asm volatile("dsb ish" ::: "memory"); /* 2. 作废指令缓存中相关行 */ for (uintptr_t p = start & ~(dline - 1); p < end; p += dline) { asm volatile("ic ivau, %0" :: "r"(p)); } asm volatile("dsb ish" ::: "memory"); /* 3. 冲刷流水线,确保后续取指看到新指令 */ asm volatile("isb"); }

每一步都不能省,理由如下:

  • DC CVAU:新指令是当数据写进dcache的,必须先把它Clean到PoU,否则后续作废icache时,内存里的新指令可能还没落定。
  • DSB:确保上一步的clean操作真正完成。Cache维护指令是异步的,不插屏障就继续操作race条件。这里用ish(Inner Shareable)就够,因为只关心本核指令流。
  • IC IVAU:作废旧的指令缓存行,让后续取指必须重新从统一内存点加载。
  • 第二次DSB:确保icache失效操作全局可见,不让乱序执行把失效动作拖到后面。
  • ISB:冲刷处理器流水线和预取队列。就算cache已经更新,流水线里可能已经预取了旧指令,ISB强制后续指令重新取指。

有的资料会把DC CVAU写成DC CIVAC,这在功能上更"保险",但性能略差,而且对于仅需统一指令/数据缓存视图的场景是过度的。按ARM官方建议,自修改代码用DC CVAU(到PoU)就够了,维护范围更小、速度更快。

3.3 核间通信与动态加载:inner shareable的讲究

多核场景下,cache维护的"可见范围"成了一个新问题。ARM体系用Shareability domain(共享域)来划分哪些观察者共享同一内存视图。常见的是Non-shareable、Inner Shareable和Outer Shareable。两个核在同一个cluster里通常属于同一个Inner Shareable域;跨cluster或与DMA、GPU的一致性点则在Outer Shareable域。

当你在CPU0上写了一个共享变量,想让CPU1立刻看到时,光有cache维护指令还不够,因为CPU1可能还持有旧值。这在硬件上通常靠缓存一致性协议帮你广播,但软件上仍然需要通过DSB和合适的shareable属性来保证序。具体到指令选择:

  • 只影响当前核的维护:DC CVAUIC IVAU
  • 影响整个Inner Shareable域的维护:IC IALLUIS、带Inner Shareable属性的屏障DSB ISH
  • 影响所有观察者(包括DMA、GPU):DC CVACDC CIVACDSB SY

一个常见错误是:在CPU0上用IC IVAU作废指令缓存,然后在CPU2上跳转到新代码执行,结果CPU2照样执行旧指令。原因就是IC IVAU只作用于当前核。如果多个核都可能运行这段代码,要么让每个核都执行一次IC IVAU(通过IPI中断),要么直接上IC IALLUIS把整个Inner Shareable域都清掉。

我见过的一个内核模块加载器就栽在这里:模块加载完代码,只在自己所在核上做了icache同步,另一个核上已经预取过旧模块映射的指令,执行时各种非法指令fault。后来改成smp_call_function让所有核都跑一遍invalidate,问题才消失。

4. 配套屏障与多核同步:能查到坑才叫懂

4.1 DSB和ISB管的是两回事

从事后来看,几乎每个cache维护的操作序列都会带上一两条DSB或ISB,但很多人只知其然,不知其然。这里把两者区别讲透:

  • DSB(Data Synchronization Barrier):数据同步屏障。它保证在DSB指令之前的所有显式内存访问和Cache维护操作完成之后,DSB后面的指令才开始执行。它管的是"数据通路",解决的是cache维护指令的异步完成问题。DSB有多个级别后缀:SY(全系统)、ISH(Inner Shareable)、NSH(Non-shareable)等,用来限定等待的范围。
  • ISB(Instruction Synchronization Barrier):指令同步屏障。它冲刷处理器流水线中已预取的指令,保证ISB之后的指令都从cache/内存重新取指。它管的是"指令通路",解决的是取指乱序和预取问题。

用个不严谨但好懂的类比:DSB是"等收发室把信都送完",ISB是"把办公桌上旧的会议纪要全扔掉,重新拿最新版"。自修改代码场景为什么两个都要?因为dcache clean、icache invalidate这些操作需要DSB来"确认做完",而流水线里预取的旧指令需要ISB来"作废重来"。

4.2 死锁高发区:set/way操作在多核下的红线

前面表格里有DC CISW这类按Set/Way操作的指令,它们在Linux内核、RTOS里主要出现在CPU启动/关闭、休眠唤醒的早期阶段。为什么日常代码里几乎见不到?因为set/way操作有一个非常危险的特性:它是对整个缓存进行全局性操作,不像VA操作那样能精准限定在某一行。

如果多核同时都在跑、内存访问活跃,某个核执行set/way操作时,其他核的cache会不断产生新的分配和替换,导致set/way操作可能永远等不到一个稳定的缓存状态,极端情况下形成死锁或资源竞争。ARM官方文档也明确警告:Set/Way操作应当在只有单个PE活跃、或整个系统内存流量被有效控制的场景下使用(比如bootloader阶段、CPU hotplug down路径中先将其他核停下来)。

所以判断一个cache维护代码是否专业,很多时候就看它有没有随意使用set/way。能按虚拟地址操作,就绝对不要用set/way;只有当你面对的是一片物理地址连续、但VA映射不明确的内存(比如启用MMU之前)才考虑。

4.3 cache line大小、shareable domain和性能代价

最后提一嘴cache维护的性能代价。一次DC CIVAC,如果cache line是脏的,会触发一次完整的写回,然后还要产生一次作废广播;一次IC IVAU,会让后续取指直接miss到下一级内存。在热路径上频繁做cache维护,性能损失是实打实的。

我见过一个网络驱动,每收一个包都对整个4KB buffer做CIVAC,结果在大包场景下吞吐掉了一半。后来把维护粒度从整包改成只维护实际拷贝的cache line,性能恢复。另一个优化方向是能合并就合并:DMA描述符里多个segment连续时,一次维护整个范围,而不是一个segment维护一次。

另外,不要忘了cache line对齐。维护循环必须从start & ~(line_size - 1)开始,到(end + line_size - 1) & ~(line_size - 1)结束,否则会漏掉头尾两个不完整行。很多"偶尔出一次问题"的cache bug,根因就是起始地址没有对齐,调试了几天才发现是第一个unaligned的cache line没被维护到。

5. 从内核到用户态:在Linux和裸机里如何调用这些指令

5.1 Linux内核中的封装路径:从flush_icache_range到dma_map

对Linux内核开发者来说,你几乎不需要直接写内联汇编去执行DC/IC指令,因为内核已经封装好了对应API,你需要做的是知道每个API背后在干什么、什么时候调用。

  • flush_icache_range(start, end):这是自修改代码、模块加载、BPF JIT等场景的标准入口。在ARM64上,它的实现就是先对范围内地址执行DC CVAU,再执行IC IVAU,中间穿插DSBISB。如果你在内核里改了一段可执行内存,调用这个函数就对了。
  • flush_dcache_range(start, end):只Clean dcache到PoC,用于CPU写的数据需要给DMA或外设看到的场景。
  • invalidate_dcache_range(start, end):只Invalidate,用于DMA写完、CPU准备读的场景。
  • dma_map_single/dma_unmap_single:这两兄弟是DMA场景的高级封装。在non-coherent设备路径上,dma_map_single会根据DMA方向对buffer做cache维护(To Device方向Clean / From Device方向Invalidate),dma_unmap_single再做一次反向维护。这也就是为什么很多驱动工程师"工作正常"的原因——你根本不知道内核在背后帮你做了多少。

但别因此掉以轻心。内核DMA API只保证"标准buffer"的一致性,如果你用的是dma_alloc_coherent(一致性内存)还好,硬件和Cache天然协调;如果你在性能敏感路径上手写了DMA描述符,又绕过了API直接操作物理地址,那cache维护责任就全回你自己头上了。

5.2 用户态JIT的隐藏入口:__clear_cache和__aarch64_sync_cache_range

用户态做JIT或动态插桩的人,可能没直接见过IC/DC指令,但一定接触过GCC/Clang的__builtin___clear_cache内置函数。这个函数在AArch64平台的libgcc/compiler-rt里,最终会调到__aarch64_sync_cache_range这个运行时辅助函数。它的实现很朴素:对代码段做DC CVAU循环,接着做IC IVAU循环,最后用DSBISB收尾——和我们前面写的手写序列几乎一模一样。

也就是说,如果你在Linux用户态用C或C++写JIT,最标准的做法是:

__builtin___clear_cache((char *)code, (char *)code + code_size);

编译器会根据目标架构自动生成完整的cache同步序列,包括必要的barrier。但注意,这个内置函数只在少数架构上有实际效果,x86上基本是空操作,ARM、AArch64、RISC-V等弱内存模型架构上才是真正的cache维护。如果你的JIT代码通过mmap分配了可执行内存,写入新指令后调用__builtin___clear_cache,就能安全地在用户态同步指令流,不需要陷入内核。

当然,如果不想依赖编译器内置函数,也可以直接用内联汇编调用IC IVAUDC CVAU,前提是系统的SCTLR_EL1.UCI位已使能。Linux在ARM64上默认是打开这个位的,所以用户态可以碰这两条指令。

5.3 裸机/RTOS手写维护序列的一个完整样例

最后给一个裸机场景的完整例子。假设你在一个ARM Cortex-A系列SoC上做裸机开发,MMU开启、有指令和数据cache,你想在运行时从SD卡加载一段新程序到内存并跳转执行。在跳转之前,必须做一次完整的cache同步。

下面是直接用汇编写的同步代码:

// x0 = 代码起始地址, x1 = 代码结束地址 .global sync_icache_range sync_icache_range: // 读取CTR_EL0获取数据cache line大小 mrs x2, ctr_el0 ubfx x2, x2, #16, #4 // DminLine字段 mov x3, #4 lsl x3, x3, x2 // line_size = 4 << DminLine // 对齐起始地址 sub x4, x3, #1 and x4, x4, x0 add x0, x0, x4 // start把低x4位补齐 bic x0, x0, x3 // 对齐到line_size // 第一步:Clean dcache到PoU 1: cmp x0, x1 b.hs 2f dc cvau, x0 add x0, x0, x3 b 1b 2: dsb ish // 第二步:Invalidate icache到PoU bic x0, x0, x3 // 重新对齐 3: cmp x0, x1 b.hs 4f ic ivau, x0 add x0, x0, x3 b 3b 4: dsb ish isb ret

写在最后一句真心话:cache维护这件事,技术栈深的人可能花十分钟写出正确的序列,但真正难的是建立起"出了问题优先怀疑cache一致性"的排查直觉。我在实际调试中总结的经验是,凡是你看到"数据有时对有时错、多核表现不一致、DMA数据带残留、改代码不生效"这些经典症状,先别急着怀疑编译器和指针,按本文的顺序把cache维护捋一遍,往往比抓破脑袋查逻辑快得多。另外也提醒一句,每次在ARM裸机或RTOS上启用cache之前,认真检查一遍所有DMA描述符、所有可执行内存的刷新路径——这个习惯能帮你省下一个又一个通宵。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询