深入解析ARMv8汇编指令:adrp、adr与adr_l在Linux内核启动中的应用
1. ARMv8寻址指令基础认知
第一次接触ARMv8汇编的朋友,看到adrp、adr这些指令时可能会一头雾水。这就像刚学开车时看到仪表盘上各种指示灯——知道它们很重要,但具体功能还得慢慢摸索。在ARM架构中,这些指令就是处理地址计算的"方向盘"和"油门",掌握它们才能驾驭底层代码。
与x86架构不同,ARM采用精简指令集(RISC),一个显著特点就是指令功能单一但组合灵活。adrp、adr这类地址计算指令,本质上都是在解决"如何找到目标地址"这个问题。想象你在一个巨大的停车场找车:
- adrp相当于先定位到正确的停车区(页地址)
- adr则是直接给出具体车位号(精确地址)
- adr_l更像是内置了导航系统的智能寻车方案
在Linux内核启动这个特殊场景下,这些指令的威力更加凸显。当内核刚从引导程序接手控制权时,内存管理单元(MMU)还未启用,所有地址操作都是在物理地址层面进行的。这就好比在没有GPS信号的隧道里开车,必须依靠里程表和方向感来导航。
2. adrp指令深度解析
2.1 指令原理剖析
adrp(Address of Page)是ARMv8中的页地址加载指令,它的工作原理可以用快递分拣来类比。假设你经营一个仓储中心:
- 整个仓库被划分为4KB大小的货架区(页)
- 每个货架有唯一的编号(页地址)
- 具体货物放在货架的某个格子里(页内偏移)
adrp的工作就是根据当前位置(PC值)和目标货架的相对位置,计算出目标货架的编号。具体计算过程分三步:
- 清除PC值的低12位,得到当前页基址(就像舍去门牌号只保留街道号)
- 将21位立即数左移12位形成33位偏移量(相当于计算相隔多少个街区)
- 将当前页基址与偏移量相加得到目标页基址
用数学表达式表示就是:
目标地址 = (PC & ~0xFFF) + (imm21 << 12)
2.2 内核启动中的应用实例
在内核启动代码head.S中,adrp的典型用法如下:
// arch/arm64/kernel/head.S
SYM_CODE_START(primary_entry)
adrp x23, __PHYS_OFFSET
and x23, x23, MIN_KIMG_ALIGN - 1 // KASLR偏移处理
...
SYM_CODE_END(primary_entry)
这里__PHYS_OFFSET表示内核映像的物理起始地址。通过adrp获取这个地址后,后续代码才能正确访问内核的各个数据结构和函数。值得注意的是,此时MMU尚未开启,所有地址操作都是物理地址层面的。
2.3 实战测试与反汇编分析
为了验证adrp的行为,我们可以编写用户态测试程序:
#define PAGE_4KB (4096)
uint64_t g_val1[PAGE_4KB / sizeof(uint64_t)];
uint64_t g_val2[PAGE_4KB / sizeof(uint64_t)];
#define ADRP(label) ({ \
uint64_t __val; \
asm("adrp %0, " #label : "=r"(__val)); \
__val; \
})
void test_adrp() {
printf("g_val1: actual=0x%lx, adrp=0x%lx\n",
(uint64_t)g_val1, ADRP(g_val1));
printf("g_val2: actual=0x%lx, adrp=0x%lx\n",
(uint64_t)g_val2, ADRP(g_val2));
}
运行后可能得到如下输出:
g_val1: actual=0x5583e25028, adrp=0x5583e25000
g_val2: actual=0x5583e26028, adrp=0x5583e26000
可以看到adrp确实返回了变量所在页的基地址。通过objdump反汇编观察:
00000000000007b0 <test_adrp>:
7b0: b0000080 adrp x0, 11000 <__data_start>
7e0: d0000080 adrp x0, 12000 <g_val1+0xfd8>
编译器将adrp指令中的偏移量计算为0x11000和0x12000,运行时这些值会与PC基址相加得到正确地址。
3. adr指令详解与应用
3.1 指令工作机制
如果说adrp是粗粒度的"街区导航",那么adr就是精确的"门牌号定位"。它直接计算当前PC到目标标签的21位有符号偏移,范围可达±1MB。计算公式很简单:
目标地址 = PC + imm21
adr特别适合访问邻近的代码标签或小范围数据。在启动代码中常见这样的模式:
adr x0, local_symbol // 获取局部符号地址
bl some_function // 调用附近函数
3.2 内核中的典型用例
在内核初始化过程中,adr常用于获取与位置相关的临时变量地址。例如设置初始页表时:
// arch/arm64/kernel/head.S
__create_page_tables:
adr x0, init_pg_dir // 获取页表基址
adr x1, init_pg_end
mov x2, #PTE_DIRTY | PTE_WRITE
1: str x2, [x0], #8
cmp x0, x1
b.lo 1b
这种用法避免了硬编码地址,保证代码在内存中的任意位置都能正确运行。
3.3 对比测试与分析
通过用户态程序对比adr与常规取址操作:
uint64_t g_val3 = 0x1234;
uint64_t g_val4 = 0x5678;
#define ADR(label) ({ \
uint64_t __val; \
asm("adr %0, " #label : "=r"(__val)); \
__val; \
})
void test_adr() {
printf("g_val3: &=%lx, adr=%lx\n",
(uint64_t)&g_val3, ADR(g_val3));
printf("g_val4: &=%lx, adr=%lx\n",
(uint64_t)&g_val4, ADR(g_val4));
}
输出结果验证两者一致性:
g_val3: &=0x5583e25018, adr=0x5583e25018
g_val4: &=0x5583e25020, adr=0x5583e25020
反汇编显示编译器生成的偏移量:
0000000000000824 <test_adr>:
824: 10083fa0 adr x0, 11018 <g_val3>
854: 10083e60 adr x0, 11020 <g_val4>
实际运行时,CPU会动态计算PC + offset得到正确地址。
4. adr_l宏的进阶应用
4.1 设计原理剖析
adr_l是Linux内核定义的宏,它智能地结合了adrp和add指令,实现了±4GB范围内的地址加载。其精妙之处在于根据运行环境自动选择策略:
- 内核镜像内:使用adrp+add组合
- 内核模块中:使用mov指令序列
这种设计就像瑞士军刀,针对不同场景自动切换工具。源码实现如下:
// 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 内核模块的特殊处理
当处理内核模块时,由于模块可能被加载到任意位置,传统的PC相对寻址不再适用。此时adr_l采用mov指令序列拼接完整64位地址:
- movz:加载高16位并清零其他位
- movk:依次加载后续16位而不影响其他位
这个过程就像拼乐高积木,逐层构建完整地址:
movz x0, #0x8abc // 设置bit[63:48]
movk x0, #0xdef0, lsl #16 // 设置bit[47:32]
movk x0, #0x1234, lsl #32 // 设置bit[31:16]
movk x0, #0x5678, lsl #48 // 设置bit[15:0]
4.3 性能优化考量
在性能敏感路径上,adr_l的选择很有讲究:
- 内核镜像内:adrp+add只需2条指令,且可流水执行
- 内核模块:mov序列需要4条指令,但保证了位置无关性
开发者在编写核心代码时,应当注意将频繁访问的符号放在内核镜像内,避免模块加载带来的性能损耗。
5. 指令对比与选型指南
5.1 关键特性对比表
| 特性 | adrp | adr | adr_l |
|---|---|---|---|
| 寻址范围 | ±4GB | ±1MB | ±4GB |
| 指令数量 | 1 | 1 | 2或4 |
| 输出粒度 | 页基址(4KB对齐) | 精确地址 | 精确地址 |
| 位置无关性 | 是 | 是 | 内核/模块自适应 |
| 典型用途 | 获取数据区基址 | 访问邻近符号 | 通用地址加载 |
5.2 使用场景建议
根据实际需求选择合适的指令:
- 大范围数据访问:如内核全局变量,使用adrp+add组合
adrp x0, global_var ldr x1, [x0, :lo12:global_var] - 局部跳转/小数据:1MB范围内的操作使用adr
adr x0, .Llocal_label - 通用代码编写:不确定使用场景时优先用adr_l
adr_l x0, some_symbol
5.3 常见问题排查
在实际使用中容易遇到的坑:
- 地址不对齐:adrp获取的是页对齐地址,直接使用前需加上偏移
// 错误用法: adrp x0, symbol blr x0 // 可能跳转到错误地址 // 正确用法: adrp x0, symbol add x0, x0, :lo12:symbol blr x0 - 范围溢出:adr的±1MB限制在大型函数中可能不够用
- 模块未处理:动态加载的模块必须使用adr_l的模块分支
6. 内核启动流程中的关键应用
6.1 早期地址计算场景
在内核启动的primary_entry阶段,地址计算面临三大挑战:
- MMU关闭,需直接操作物理地址
- 可能启用KASLR(地址随机化)
- 需要位置无关代码(PIC)
典型的解决方案如下:
// arch/arm64/kernel/head.S
primary_entry:
adrp x23, __PHYS_OFFSET // 获取物理基址
and x23, x23, MIN_KIMG_ALIGN // 处理KASLR偏移
bl __create_page_tables // 初始化页表
6.2 位置无关代码设计
位置无关代码就像乐高积木,可以在内存任意位置拼装运行。实现要点:
- 所有地址引用使用PC相对寻址(adrp/adr)
- 全局数据通过GOT(Global Offset Table)访问
- 函数调用使用bl等PC相对跳转指令
示例:
// 位置相关代码(不推荐)
ldr x0, =global_var
// 位置无关代码(推荐)
adrp x0, global_var
add x0, x0, :lo12:global_var
6.3 性能优化实践
在内核启动这个"热路径"上,地址加载优化尤为重要:
- 合并指令:将adrp与后续add/ldr合并执行
adrp x0, symbol ldr x1, [x0, :lo12:symbol] // 单周期完成 - 预取优化:提前计算即将使用的地址
- 缓存对齐:关键数据按cache line对齐放置
7. 扩展知识与进阶技巧
7.1 链接器重定位机制
理解adrp/adr背后的链接器魔法:
- 编译阶段:生成重定位条目(relocation entries)
readelf -r vmlinux - 链接阶段:根据最终布局填充偏移量
- 加载阶段:动态链接器处理剩余重定位
7.2 调试技巧与工具
实战中排查地址问题的利器:
- objdump:反汇编查看指令编码
objdump -d vmlinux | grep -A10 "primary_entry" - gdb:动态观察地址计算过程
b primary_entry x/i $pc info registers x23 - readelf:分析符号布局
readelf -s vmlinux | grep __PHYS_OFFSET
7.3 未来架构演进
ARMv8.3引入的PC相对加载指令(ldr)进一步简化了地址访问:
// 传统方式
adrp x0, symbol
ldr x1, [x0, :lo12:symbol]
// ARMv8.3新方式
ldr x1, [x0, #:pc_lo12:symbol]
这种改进减少了指令数量,提升了代码密度。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)