TaterLi 个人博客

[内核学习]ARM64指令与内存访问

读内核反汇编,写驱动碰到内存序问题,最后都会归结到这一篇的东西:A64 指令的基本套路,原子指令怎么实现,屏障指令各管什么.这篇偏实测,代码全上板跑过,有几组数据挺反直觉的.

这篇回答几件事:

先把指令集的底摸了.A64 指令定长 32 位,没有变长编码,这是好事,反汇编器不会歧义.寻址方式简单到可怜:load/store 只有基址加立即数,基址加寄存器,还有 pre-index/post-index(先加后访或先访后加,顺便回写),没有 x86 那种 [base + index*scale + disp] 一条龙.想干复杂的事,多条指令自己拼.

几个读内核汇编天天见的:

这套东西不用背,拿着 objdump 对着内核源码看两回就熟了.真正值得花力气的是下面两块:原子和屏障.

先看原子.为什么需要原子指令:counter++ 这种读-改-写在没有任何帮助下不是原子的,两条线程同时加,丢一次更新是家常便饭.ARMv8 给了两代方案.

第一代叫 LL/SC,Load-Exclusive/Store-Exclusive,exclusive load 和 exclusive store 必须成对出现.ldxr 读出值的同时给这个 CPU 的"独占监视器"(exclusive monitor)上个标记,然后正常算,最后 stxr 写回.stxr 不是一定成功的:从 ldxr 到 stxr 之间,只要有别的 CPU 写过这块区域,或者中间发生过上下文切换(内核切换时会显式 clrex 清掉监视器),stxr 就返回失败,你得回到 ldxr 重来.所以原子操作永远长这样:

1:	ldxr	w2, [x0]	// 独占读
	add	w2, w2, #0x1	// 正常改
	stxr	w3, w2, [x0]	// 独占写, w3=1 表示失败
	cbnz	w3, 1b		// 失败重试

监视器有几个不显眼的性质.它监视的是一块区域不是单个地址,粒度是微架构实现相关;它只保证"不误报成功" — stxr 成功就真的没人动过,但可能"误报失败" — 没人动过也可能失败(比如粒度内别的字节被写).误失败只是浪费一次循环,不出 correctness 问题,所以设计上是安全的.还有个隐藏福利:基于 LL/SC 的比较交换天然免疫 ABA 问题 — 值被改走又改回来,中间必然有写操作,监视器被清,stxr 失败.单值比较的 CAS 反而有经典 ABA 陷阱.

第二代叫 LSE,ARMv8.1 加的,一条指令搞定原子加:stadd/ldadd,不存在失败,不用循环.没有重试循环意味着延迟可预期,争用激烈时优势明显.代价是它是个新架构特性,老 CPU 不认,一执行就是 undefined instruction.

板上这对 A72/A53 正是 ARMv8.0,没有 LSE,/proc/cpuinfo 的 Features 行写得明明白白:

Features	: fp asimd evtstrm aes pmull sha1 sha2 crc32 cpuid

没有 atomics 字样.那问题来了:内核一份二进制要同时跑在有无 LSE 的机器上,怎么选?

写个内核模块验证,函数里就三个原子操作,编译出来反汇编,答案直接躺在指令流里:

   0:	b	10			// 默认: 跳去 LL/SC
   4:	mov	w2, #0x1
   8:	stadd	w2, [x0]		// LSE 版本, 默认被跳过
   c:	b	24
  10:	prfm	pstl1strm, [x0]	// LL/SC 版本, 默认执行
  14:	ldxr	w2, [x0]
  18:	add	w2, w2, #0x1
  1c:	stxr	w3, w2, [x0]
  20:	cbnz	w3, 14

一段函数里两套实现并存,开头那条 b 决定走哪边.这是 ALTERNATIVE 机制:编译期两套都生成,启动时 cpufeature 检查硬件特性(cpufeature.c 里注册了 ARM64_HAS_LSE_ATOMICS 能力项),有 LSE 就把那条 b 打补丁改成 nop,流程落进 stadd;没有就原样保留,走 LL/SC.模块加载时也会按当前 CPU 补一次.我这块板加载后 dmesg 正常打印结果,没有 undefined instruction,证明跑的就是 LL/SC 那条岔.

细看 LL/SC 那段还有两处讲究.prfm pstl1strm 是预取提示,提前把缓存行拉成待写状态,减少 stxr 失败率,后来内核加的小优化.而第三个操作 atomic64_add_return(带返回值,需要 acquire-release 语义)的 LL/SC 版本用的是 stlxr(带 release 的独占写)加循环出口一条 dmb ish,对应的 LSE 版本是一条 ldaddal,后缀 al 就是 acquire+release.同一个语义,新指令一条顶旧指令一小段.

用户态是另一套答案.Debian 的 gcc 默认开了 -moutline-atomics,原子操作不内联,改成调用辅助函数.反汇编原子加法的调用点,是 bl __aarch64_ldadd8_relax,辅助函数长这样:

 adrp	x16, ...		// glibc 数据页
 ldrb	w16, [x16, #144]	// 一个标志字节
 cbz	w16, LLSC		// 板上是 0, 走 LL/SC
 ldadd	x0, x0, [x1]		// 有 LSE 才走
 ret
LLSC:
 ldxr	x0, [x1]
 ...

那个标志字节是 glibc 启动时按硬件特性初始化的.同一台机器上,内核用启动期打补丁,一次代价换之后零开销;用户态用运行时查表,每次调用多一次 load 加分支.工程上没有免费的午餐,只有你愿意在哪付钱的问题.

下面是这篇最有意思的部分,内存序.ARM64 是弱序架构,这个说法到底意味着什么,书上是背不下来的,实测.经典.store buffering" litmus 测试:两个线程,线程 0 写 x=1 然后读 y,线程 1 写 y=1 然后读 x,两边都在等对方的写.如果一切顺序执行,至少有一边能读到 1;如果两边都读到 0,说明各自的写还没被对方看见,读就先走了 — 这就是 StoreLoad 重排,乱序实锤.

测试程序钉死在两个 A72 核上,严格每轮同步,跑 30 万轮:

no dmb : both read 0 = 135353 / 300000 (45.1177%)
no dmb : both read 0 = 135707 / 300000 (45.2357%)
no dmb : both read 0 = 135662 / 300000 (45.2207%)
dmb ish: both read 0 = 0 / 300000 (0.0000%)

不加屏障,45% 的轮次两边都读到 0,几乎一半!在 store 和 load 之间塞一条 dmb ish,直接归零.这就是弱序架构的日常:你以为的顺序,硬件根本没承诺过.

再补一个反直觉的对照.同一个程序改两行编成 x86-64,跑在我这台 x86 容器上:

no dmb : both read 0 = 48368 / 300000 (16.1227%)
fence  : both read 0 = 0 / 300000

x86 也重排了 15%!很多人背"x86 强内存模型不乱序",其实 x86 的 TSO 只保证 LoadLoad,LoadStore,StoreStore 三个方向,StoreLoad 恰恰是它唯一放行的 — store buffer 天然就是干这个的.区别在程度:同款测试 A72 上 45%,x86 上 15%,而且 ARM64 四个方向都可能重排,x86 只放一个.写跨平台代码千万别赌"intel 上没事就没事",真不巧.

屏障指令三兄弟,内核 barrier.h 里就三行宏,一个不多一个不少:

#define isb()		asm volatile("isb" : : : "memory")
#define dmb(opt)	asm volatile("dmb " #opt : : : "memory")
#define dsb(opt)	asm volatile("dsb " #opt : : : "memory")

分工记住一句话就够:DMB 管顺序,DSB 管完成,ISB 管取指.

dmb 保证屏障前后的访存顺序,不保证"写已经完成",只是个顺序栅栏,最快.dsb 更重,等前面的访存真正完成才放行后续指令.isb 最特殊,冲刷流水线保证之后的指令重新取指,给"改了处理器状态"的场景用 — 改完系统寄存器,改完向量表,entry.S 里那些 isb 干的全是这个,数据同步跟它没关系.

dmb 和 dsb 还带域参数:osh,nsh,ish,sy,从小到大.SMP 内核数据全在一个 inner shareable 域里,所以内核的 __smp_mb() 就是 dmb(ish),barrier.h 里写得清楚.内核代码几乎不该裸写这些,该用 smp_store_release/smp_load_acquire 这类封装,裸屏障留给底层架构代码.

顺手把原子操作的成本也量了,fetch_add 五百万次,cntvct 计时换算成 ns:

场景每次 add 耗时
单线程 cpu4(A72)13 ns
cpu4+cpu5(同簇两个 A72)48 / 43 ns
cpu4+cpu0(跨簇,过 CCI 互连)59 / 58 ns

单线程 13ns 是缓存行独占在手的样子;两个核同簇,缓存行在两个核之间乒乓,贵三倍多;跨簇再过一棍 CCI 互连,再贵一截.这套数据到自旋锁那篇还会复用,争用成本就是这么一点点堆出来的.

最后留几个容易忽略的细节:

退出移动版