ARMv8汇编指令解析:adrp、adr与adr_l在Linux内核启动代码中的实战应用
1. 从内核启动代码说起:为什么需要adrp、adr和adr_l?
如果你和我一样,喜欢刨根问底地阅读Linux内核源码,尤其是ARM64架构的启动部分,那你一定在arch/arm64/kernel/head.S这个文件里见过不少汇编指令。其中,adrp、adr以及内核自定义的宏adr_l,绝对是出场率极高的明星指令。我第一次看到它们的时候,也是一头雾水,网上搜到的资料要么语焉不详,要么就是直接贴官方手册,看得人云里雾里。
后来我明白了,要真正搞懂这些指令,不能光看理论,必须动手实践。这就好比学游泳,你在岸上把姿势背得再熟,不下水扑腾几下,永远学不会。这些指令的核心使命,其实都是为了解决内核启动初期一个非常关键的问题:在MMU(内存管理单元)还没开启、虚拟地址和物理地址还没建立映射关系的时候,代码如何准确地找到并访问内存中的关键数据或代码位置? 内核启动代码(比如primary_entry)是直接运行在物理地址上的,但我们的代码在编译链接时,是按照虚拟地址布局来生成的。这就产生了一个矛盾:代码不知道自己被加载到物理内存的哪个位置,但它又需要知道自己和某些关键符号(比如内核的起始地址__PHYS_OFFSET)的相对关系。
adrp、adr和adr_l就是为解决这个矛盾而生的“位置无关码”利器。它们不依赖于代码被加载的绝对地址,而是通过计算与当前程序计数器(PC)的相对偏移来定位目标。这样,无论内核镜像被加载到物理内存的哪个角落,这些指令都能正确地计算出目标地址。今天,我就带你一起,通过写测试代码、反汇编、对照内核源码,把这几个指令里里外外扒个明白,让你下次再看到它们时,心里跟明镜似的。
2. 深入剖析adrp:获取4KB页对齐的基地址
2.1 指令原理与内核实战
我们先来看adrp,这是三个指令中最常用,也相对复杂的一个。它的全称是“Address of Page”,顾名思义,是用来计算一个目标地址所在4KB页的基地址的。为什么是4KB?这主要是为了扩大寻址范围,和虚拟内存的页大小没有必然联系。
它的工作原理可以拆解成几步,我们结合内核中的经典用法来看。在primary_entry中,有这样一行:
adrp x23, __PHYS_OFFSET
这行代码的目的是获取内核物理起始地址__PHYS_OFFSET所在页的基地址,存入寄存器x23。__PHYS_OFFSET在链接脚本中定义,通常就是内核代码段_text的起始地址。
那么adrp具体是怎么算的呢?官方手册的描述比较抽象,我把它翻译成“人话”:
- 编译时:汇编器看到
adrp x23, __PHYS_OFFSET,它会计算符号__PHYS_OFFSET的地址与当前adrp指令所在地址的页偏移。具体是:(目标地址 & ~0xFFF) - (当前指令地址 & ~0xFFF)。这个差值是一个以页(4096字节)为单位的33位有符号数。 - 编码时:将这个33位的页偏移数右移12位,得到一个21位的有符号立即数,编码到指令中。
- 运行时:CPU执行这条指令时,会做反向操作:先将21位立即数左移12位,恢复成33位的字节偏移。然后,将当前PC寄存器的值低12位清零,得到当前指令所在页的基地址。最后,将这个基地址加上恢复出来的33位字节偏移,结果写入目标寄存器
x23。
整个过程完全依赖于PC的相对值,因此是位置无关的。无论内核被加载到物理内存的0x80000还是0x2000000,这条指令都能正确算出__PHYS_OFFSET的页基址。计算出的地址是物理地址,因为MMU还没开。
2.2 动手测试:编写用户态程序验证
内核代码环境搭建比较麻烦,我们可以写一个简单的用户态程序来模拟。因为adrp的计算逻辑在用户态和内核态是一样的,只是最终算出来的地址是虚拟地址。
我写了一个测试函数adrp_test:
#include <stdio.h>
#include <stdint.h>
#define PAGE_4KB (4096)
#define __stringify_1(x...) #x
#define __stringify(x...) __stringify_1(x)
// 定义两个全局数组,确保它们各自占据不同的4KB页
uint64_t g_val1[PAGE_4KB / sizeof(uint64_t)];
uint64_t g_val2[PAGE_4KB / sizeof(uint64_t)];
// 内联汇编宏,用于执行adrp指令并获取结果
#define ADRP(label) ({ \
uint64_t __adrp_val__ = 0; \
asm volatile("adrp %0, " __stringify(label) : "=r"(__adrp_val__)); \
__adrp_val__; \
})
void adrp_test() {
printf("函数 adrp_test 自己的地址: 0x%lx\n", (uint64_t)adrp_test);
printf("全局变量 g_val1 的地址: 0x%lx, 通过adrp计算的页基址: 0x%lx\n",
(uint64_t)g_val1, ADRP(g_val1));
printf("全局变量 g_val2 的地址: 0x%lx, 通过adrp计算的页基址: 0x%lx\n",
(uint64_t)g_val2, ADRP(g_val2));
}
编译运行后,输出可能类似这样:
函数 adrp_test 自己的地址: 0x5583e1479c
全局变量 g_val1 的地址: 0x5583e25028, 通过adrp计算的页基址: 0x5583e25000
全局变量 g_val2 的地址: 0x5583e26028, 通过adrp计算的页基址: 0x5583e26000
看,adrp成功计算出了g_val1和g_val2所在页的基地址(即地址的低12位清零)。0x5583e25028 & ~0xFFF = 0x5583e25000,完全正确。
2.3 反汇编观察:编译时与运行时的魔术
光看结果还不够,我们再用objdump -d反汇编一下生成的可执行文件,看看编译器到底生成了什么:
000000000000079c <adrp_test>:
7b0: b0000080 adrp x0, 0x11000 # 对应 ADRP(g_val1)
...
7e0: d0000080 adrp x0, 0x12000 # 对应 ADRP(g_val2)
注意,这里反汇编显示的0x11000和0x12000是链接时的地址。在运行时,由于地址空间布局随机化(ASLR),代码段和数据段的加载地址会变。但adrp指令编码中的21位立即数(0x11和0x12)是不变的。运行时,CPU会用PC & ~0xFFF得到当前页基址(比如0x5583e14000),再加上0x11<<12(即0x11000),就得到了运行时的页基址0x5583e25000。这个“以不变应万变”的机制,正是位置无关码的精髓。
3. 探究adr指令:精确获取±1MB内的目标地址
3.1 与adrp的对比:更精确,但范围更小
如果说adrp是“粗调”,用来定位目标所在的“大楼”(4KB页),那么adr指令就是“微调”,用来获取目标的“精确门牌号”。adr直接计算目标地址相对于当前PC的偏移,并将完整的地址(而非页基址)加载到寄存器中。
它的寻址范围比adrp小很多,只有±1MB。这是因为它的21位立即数直接表示字节偏移,没有左移12位的放大操作。因此,adr通常用于获取附近的一个标签或变量的地址。它的编码格式更简单,就是一个21位有符号立即数加上PC值。
3.2 测试与反汇编分析
我们同样写个测试来感受一下:
uint64_t g_val3 = 0xdeadbeef;
uint64_t g_val4 = 0xcafebabe;
#define ADR(label) ({ \
uint64_t __adr_val__ = 0; \
asm volatile("adr %0, " __stringify(label) : "=r"(__adr_val__)); \
__adr_val__; \
})
void adr_test() {
printf("通过 & 取址 g_val3: 0x%lx, 通过 adr 指令获取: 0x%lx\n",
(uint64_t)&g_val3, ADR(g_val3));
printf("通过 & 取址 g_val4: 0x%lx, 通过 adr 指令获取: 0x%lx\n",
(uint64_t)&g_val4, ADR(g_val4));
}
运行结果会显示,通过C语言&操作符得到的地址,和通过adr汇编指令得到的地址是完全一致的。
再看反汇编代码:
0000000000000810 <adr_test>:
824: 10083fa0 adr x0, 0x11018 <g_val3> # 偏移量 = 0x11018 - 0x824 = 0x107f4
854: 10083e60 adr x0, 0x11020 <g_val4> # 偏移量 = 0x11020 - 0x854 = 0x107cc
这里objdump很贴心地直接算出了目标符号的地址0x11018和0x11020。但我们要清楚,指令实际编码的是21位的偏移量0x107f4和0x107cc。运行时,CPU执行PC + offset,就能得到正确的虚拟地址。由于偏移量是固定的,所以这条指令也是位置无关的。
3.3 内核中的典型应用场景
在内核启动代码中,adr经常用于获取一些近在咫尺的标签地址。例如,在设置异常向量表、或者进行一些短距离跳转时,由于这些目标通常就在同一段代码附近,使用adr比adrp更直接、更高效。它省去了后续再加低12位偏移的步骤,一条指令搞定。
4. 解密内核宏adr_l:一个宏,两种策略
4.1 宏的定义与双重实现
adr_l并不是ARM架构的原生指令,而是Linux内核为了方便开发者而定义的一个汇编宏。它的全称可能是“Address Load Long”,目标是加载一个符号的完整地址,寻址范围可达±4GB。这个宏的巧妙之处在于,它根据代码是编译进内核镜像还是编译成内核模块,采用了两种完全不同的实现策略。
我们直接看它在arch/arm64/include/asm/assembler.h中的定义:
.macro adr_l, dst, sym
#ifndef MODULE
/* 内核镜像内部的代码走这里 */
adrp \dst, \sym
add \dst, \dst, :lo12:\sym
#else
/* 内核模块的代码走这里 */
movz \dst, #:abs_g3:\sym
movk \dst, #:abs_g2_nc:\sym
movk \dst, #:abs_g1_nc:\sym
movk \dst, #:abs_g0_nc:\sym
#endif
.endm
4.2 内核上下文:adrp + add 黄金组合
当代码是内核本体的一部分时(MODULE未定义),adr_l采用了经典的adrp加add组合。这很好理解:
adrp \dst, \sym:先获取符号sym所在页的基地址。add \dst, \dst, :lo12:\sym:再加上符号地址的低12位(:lo12:是一个重定位修饰符,告诉链接器只取低12位),这样就拼凑出了完整的64位地址。
这种方式高效且位置无关,是内核中获取全局符号地址的标准做法。你在head.S里看到的很多地址加载,本质上都是这个模式。
4.3 模块上下文:movz/movk 分步加载
当代码被编译成可加载的内核模块时,情况就不同了。内核模块在运行时可以被加载到内核地址空间的任何位置(在模块地址范围内),它与内核本体符号的偏移量在编译时是未知的。因此,无法使用基于PC相对寻址的adrp。
这时,adr_l宏使用了另一套方案:用movz和movk指令,分四次将64位地址的四个16位部分(从高到低)加载到目标寄存器。
movz \dst, #:abs_g3:\sym:将地址的[63:48]位加载到dst,同时将其他位清零。:abs_g3是相应的重定位类型。movk \dst, #:abs_g2_nc:\sym:将地址的[47:32]位加载到dst的相应位置,保持其他位不变。_nc后缀表示“no check”,通常用于非最高位部分。movk \dst, #:abs_g1_nc:\sym:加载[31:16]位。movk \dst, #:abs_g0_nc:\sym:加载[15:0]位。
这个过程就像拼拼图,分四步把一个完整的64位地址构造出来。由于模块加载时,加载器会解析这些重定位项,并将正确的绝对地址填充进去,所以最终也能得到正确的符号地址。这种方式是位置相关的,依赖于加载器的重定位修正。
4.4 理解重定位:链接器与加载器的魔法
无论是:lo12:还是:abs_g3:,这些冒号前缀的符号都是重定位修饰符。它们是给链接器看的“注释”。链接器在生成目标文件时,看到adrp x0, sym,它知道这里需要计算一个页偏移,并在指令编码中预留一个位置。最终链接时,链接器根据符号sym的最终地址,计算出正确的偏移值填进去。
对于内核模块,这些重定位信息会被保留在模块文件中。当模块被insmod加载时,内核的模块加载器会遍历这些重定位项,根据内核符号表的实际地址,动态地修改模块代码中的相应位置(即那些movz/movk指令的立即数),完成地址的“缝合”。这是动态链接的核心概念。
5. 综合实战:在内核启动代码中追踪指令流
5.1 分析primary_entry中的地址计算
让我们回到最初的场景,仔细看看primary_entry中那段代码:
SYM_CODE_START(primary_entry)
...
adrp x23, __PHYS_OFFSET
and x23, x23, MIN_KIMG_ALIGN - 1 // 清除对齐掩码外的位,对于KASLR
...
SYM_CODE_END(primary_entry)
这里adrp获取的是__PHYS_OFFSET的页对齐物理地址。后续的and操作是为了处理内核地址空间布局随机化(KASLR)。当KASLR启用时,内核的物理起始地址会在一个对齐的范围内随机偏移。adrp计算出的地址已经包含了这个随机偏移,and指令则是为了获取这个偏移量本身(如果KASLR未启用,这个偏移量就是0)。
5.2 模拟MMU关闭环境下的思考
在MMU关闭的启动初期,所有地址都是物理地址。adrp在这里表现得像一个物理地址计算器。但有趣的是,它的计算逻辑完全基于运行时的PC值,而这个PC值也是物理地址。这就确保了无论引导加载器把内核放在哪,它都能自我定位。这种自举能力是操作系统启动的基石。
我们可以设想,如果没有adrp这类位置无关指令,内核启动代码就必须写死一个加载地址,或者依赖引导程序传递一个复杂的地址信息表,整个启动过程会变得非常脆弱和复杂。
5.3 从启动到运行:地址的变迁
当内核初始化完毕,MMU开启,虚拟地址空间建立后,CPU看到的地址世界就完全变了。之前通过adrp计算出的物理地址,可能需要通过页表转换才能访问。内核的启动代码需要非常小心地处理这个过渡阶段。例如,在开启MMU的那一瞬间,会有一条指令专门负责将PC从物理地址“跳转”到对应的虚拟地址上,从此内核就运行在虚拟地址空间中了。
但adrp、adr这些指令的生命周期并没有结束。在虚拟地址空间中,它们依然被广泛使用,因为内核的代码段和数据段之间的相对位置在链接时就已经固定,基于PC的相对寻址方式在虚拟地址世界同样有效且高效。adr_l宏的存在,更是将这种寻址方式封装成了一个统一、便捷的接口,让内核开发者无需关心底层是内核还是模块,是物理地址还是虚拟地址过渡阶段。
通过这次对adrp、adr和adr_l的深入剖析,我希望你不仅记住了它们的语法,更重要的是理解了它们背后“位置无关”的设计哲学,以及在内核启动这个特殊场景下所扮演的关键角色。下次再在源码中遇到它们,你大可以会心一笑,因为你知道,这几条简单的指令,正是操作系统这座大厦能够从混沌的物理内存中稳健屹立起来的第一块基石。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)