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值)和目标货架的相对位置,计算出目标货架的编号。具体计算过程分三步:

  1. 清除PC值的低12位,得到当前页基址(就像舍去门牌号只保留街道号)
  2. 将21位立即数左移12位形成33位偏移量(相当于计算相隔多少个街区)
  3. 将当前页基址与偏移量相加得到目标页基址

用数学表达式表示就是:

目标地址 = (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位地址:

  1. movz:加载高16位并清零其他位
  2. 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 关键特性对比表

特性adrpadradr_l
寻址范围±4GB±1MB±4GB
指令数量112或4
输出粒度页基址(4KB对齐)精确地址精确地址
位置无关性是是内核/模块自适应
典型用途获取数据区基址访问邻近符号通用地址加载

5.2 使用场景建议

根据实际需求选择合适的指令:

  1. 大范围数据访问:如内核全局变量,使用adrp+add组合
    adrp    x0, global_var
    ldr     x1, [x0, :lo12:global_var]
    
  2. 局部跳转/小数据:1MB范围内的操作使用adr
    adr     x0, .Llocal_label
    
  3. 通用代码编写:不确定使用场景时优先用adr_l
    adr_l   x0, some_symbol
    

5.3 常见问题排查

在实际使用中容易遇到的坑:

  1. 地址不对齐:adrp获取的是页对齐地址,直接使用前需加上偏移
    // 错误用法:
    adrp x0, symbol
    blr  x0       // 可能跳转到错误地址
    
    // 正确用法:
    adrp x0, symbol
    add  x0, x0, :lo12:symbol
    blr  x0
    
  2. 范围溢出:adr的±1MB限制在大型函数中可能不够用
  3. 模块未处理:动态加载的模块必须使用adr_l的模块分支

6. 内核启动流程中的关键应用

6.1 早期地址计算场景

在内核启动的primary_entry阶段,地址计算面临三大挑战:

  1. MMU关闭,需直接操作物理地址
  2. 可能启用KASLR(地址随机化)
  3. 需要位置无关代码(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 位置无关代码设计

位置无关代码就像乐高积木,可以在内存任意位置拼装运行。实现要点:

  1. 所有地址引用使用PC相对寻址(adrp/adr)
  2. 全局数据通过GOT(Global Offset Table)访问
  3. 函数调用使用bl等PC相对跳转指令

示例:

// 位置相关代码(不推荐)
ldr    x0, =global_var

// 位置无关代码(推荐)
adrp   x0, global_var
add    x0, x0, :lo12:global_var

6.3 性能优化实践

在内核启动这个"热路径"上,地址加载优化尤为重要:

  1. 合并指令:将adrp与后续add/ldr合并执行
    adrp    x0, symbol
    ldr     x1, [x0, :lo12:symbol]  // 单周期完成
    
  2. 预取优化:提前计算即将使用的地址
  3. 缓存对齐:关键数据按cache line对齐放置

7. 扩展知识与进阶技巧

7.1 链接器重定位机制

理解adrp/adr背后的链接器魔法:

  1. 编译阶段:生成重定位条目(relocation entries)
    readelf -r vmlinux
    
  2. 链接阶段:根据最终布局填充偏移量
  3. 加载阶段:动态链接器处理剩余重定位

7.2 调试技巧与工具

实战中排查地址问题的利器:

  1. objdump:反汇编查看指令编码
    objdump -d vmlinux | grep -A10 "primary_entry"
    
  2. gdb:动态观察地址计算过程
    b primary_entry
    x/i $pc
    info registers x23
    
  3. 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]

这种改进减少了指令数量,提升了代码密度。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐