3.4 PC相对寻址

程序计数器(Program Counter,PC)用来指示下一条指令的地址。为了保证CPU正确地执行程序的指令代码,CPU必须知道下一条指令的地址,这就是程序计数器的作用,程序计数器通常是一个寄存器。例如,在程序执行之前,把程序的入口地址(即第一条指令的地址)设置到PC寄存器中,CPU从PC寄存器指向的地址取值,然后依次执行。CPU执行完一条指令后会自动修改PC寄存器的内容,使其指向下一条指令的地址。

RISC-V指令集提供了一条PC相对寻址的指令AUIPC,格式如下。

auipc rd, imm

这条指令把imm(立即数)左移12位并带符号扩展到64位后,得到一个新的立即数。这个新的立即数是一个有符号的立即数,再加上当前PC值,然后存储到rd寄存器中。由于新的立即数表示的是地址的高20位部分,并且是一个有符号的立即数,因此这条指令的寻址范围为基于当前的PC偏移量±2 GB,如图3.6所示。另外,由于这个新的立即数的低12位都是0,因此它只能寻址到与4 KB对齐的地址。涉及4 KB以内的寻址,则需要结合其他指令(如ADDI指令)来完成。

图3.6 AUIPC指令寻址范围

另外,还有一条指令(LUI指令)与AUIPC指令类似。不同点在于LUI指令不使用PC相对寻址,它仅仅把立即数左移12位,得到一个新的32立即数,再带符号扩展到64位,将其存储到rd寄存器中。AUIPC和LUI指令的编码如图3.7所示。

图3.7 AUIPC和LUI指令的编码

【例3-5】 假设当前PC值为0x8020 0000,分别执行如下指令,a5和a6寄存器的值分别是多少?

auipc   a5,0x2
lui     a6,0x2

a5寄存器的值为PC + sign_extend(0x2 << 12) = 0x8020 0000 + 0x2000 = 0x8020 2000。

a6寄存器的值为0x2 << 12 = 0x2000。

AUIPC指令通常和ADDI指令联合使用来实现32位地址空间的PC相对寻址。AUIPC指令可以寻址与被访问地址按4 KB对齐的地方,即被访问地址的高20位。ADDI指令可以在[−2048, 2047]范围内寻址,即被访问地址的低12位。

如果知道了当前的PC值和目标地址,如何计算AUIPC和ADDI指令的参数呢?在图3.8中,offset为地址B与当前PC值的偏移量,地址B与4 KB对齐的地方为地址A,地址A与地址B的偏移量为lo12。lo12是有符号数的12位数值,取值范围为[−2048, 2047]。

图3.8 使用AUIPC和ADDI指令寻址

根据上述信息,可以得出计算hi20和lo12的公式。

hi20 = (offset >> 12) + offset[11]
lo12 = offset & 0xfff

这里特别需要注意如下几点。

hi20表示地址的高20位,用于AUIPC指令的imm操作数中。

lo12表示地址的低12位,用于ADDI指令的imm操作数中。

计算hi20时需要加上offset[11],用于抵消低12位有符号数的影响(见例3-6)。

lo12是一个12位有符号数,取值范围为[−2048, 2047]。

使用AUIPC和ADDI指令对地址B进行寻址。

auipc a0,hi20
addi a1,a0,lo12

【例3-6】 假设PC值为0x8020 0000,地址B为0x8020 1800,地址B正好在4 KB的正中间,地址B与地址A的偏移量为2048字节,与地址C的偏移量为−2048字节,如图3.9所示。

图3.9 地址之间的关系

那我们应该使用地址A还是地址C来计算lo12呢?

应该使用地址C来计算lo12。因为lo12是一个12位的有符号数,取值范围为[−2048, 2047]。若使用地址A来计算,就会超过lo12的取值范围。

地址B与PC值的偏移量均为0x1800。根据前面列出的计算公式,计算hi20和lo12。

hi20 = (0x1800 >> 12) + offset[11] = 2
lo12 = 0x800

因为lo12为12位的有符号数,所以0x800表示的十进制数为−2048。下面是访问地址B的汇编指令。

auipc a0, 2
addi a1, a0, -2048

如果把ADDI指令写成如下形式,汇编器将报错。(因为汇编器把字符“0x800”当成了64位数值(即2048)解析,它已经超过了ADDI指令中立即数的取值范围。)

addi a1, a0, 0x800

报错日志如下。

AS   build_src/boot_s.o
src/boot.S: Assembler messages:
src/boot.S:6: Error: illegal operands 'addi a1,a0,0x800'
make: *** [Makefile:28: build_src/boot_s.o] Error 1

通常很少直接使用AUIPC指令,因为编写汇编代码时不知道当前PC值是多少。计算上述hi20和lo12的过程通常由链接器在重定位时完成。不过RISC-V定义了几条常用的伪指令,这些伪指令是基于AUIPC指令的。伪指令是对汇编器发出的命令,它在源程序汇编期间由汇编器处理。伪指令可以完成选择处理器、定义程序模式、定义数据、分配存储区、指示程序结束等功能。总之,伪指令可以分解为几条指令的集合。与PC相关的加载与存储伪指令如表3.3所示。

表3.3 与PC相关的加载和存储伪指令

表3.3中的PIC表示生成与位置无关的代码(Position Independent Code),GOT表示全局偏移量表(Global Offset Table)。GCC有一个“-fpic”编译选项,它在生成的代码中使用相对地址,而不是绝对地址。所有对绝对地址的访问都需要通过GOT实现,这种方式通常运用在共享库中。无论共享库被加载器加载到内存的什么位置,代码都能正确执行,而不需要重定位(relocate)。若没有使用“-fpic”选项编译共享库,则当有多个程序加载此共享库时,加载器需要为每个程序重定位共享库,即根据加载到的位置重定位,这中间可能会触发写时复制机制。

【例3-7】 观察LA和LLA指令在PIC与非PIC模式下的区别。下面是main.c文件和asm.S文件。

<main.c>
 
extern void asm_test(void);
 
int main(void)
{
    asm_test();
 
    return 0;
}
 
<asm.S>
.globl my_test_data
my_test_data:
    .dword 0x12345678abcdabcd
 
.global asm_test
asm_test:
    la t0, my_test_data
    lla t1, my_test_data
 
    ret

首先,观察非PIC模式。在QEMU+RISC-V+Linux平台[2]上编译,使用“-fno-pic”关闭PIC。


[2]QEMU+RISC-V+Linux平台的搭建方法见第2.4节。

# gcc main.c asm.S -fno-pic -O2 -g -o test

使用OBJDUMP命令反汇编。

root:example_pic# objdump -d test
 
00000000000005f4 <my_test_data>:
 5f4:abcd               j  be6 <__FRAME_END__+0x53e>
 5f6:abcd               j  be8 <__FRAME_END__+0x540>
 5f8:5678               lw a4,108(a2)
 5fa:1234               addi   a3,sp,296
 
00000000000005fc <asm_test>:
 5fc:00000297           auipc  t0,0x0
 600:ff828293           addi   t0,t0,-8 # 5f4 <my_test_data>
 604:00000317           auipc  t1,0x0
 608:ff030313           addi   t1,t1,-16 # 5f4 <my_test_data>
 60c:8082               ret

通过反汇编可知,在非PIC模式下,LA和LLA伪指令都是AUIPC与ADDI指令,并且都直接获取了my_test_data符号的绝对地址。

接下来,使用“-fpic”重新编译test程序。

# gcc main.c asm.S -fpic -O2 -g -o test

使用OBJDUMP命令反汇编。

root:example_pic# objdump -d test
 
0000000000000634 <my_test_data>:
 634:abcd               j   c26 <__FRAME_END__+0x53e>
 636:abcd               j   c28 <__FRAME_END__+0x540>
 638:5678               lw  a4,108(a2)
 63a:1234               addi    a3,sp,296
 
000000000000063c <asm_test>:
 63c:00002297           auipc  t0,0x2
 640:9f42b283           ld  t0,-1548(t0) # 2030 <_GLOBAL_OFFSET_TABLE_+0x10>
 644:00000317           auipc  t1,0x0
 648:ff030313           addi   t1,t1,-16 # 634 <my_test_data>
 64c:8082               ret

通过反汇编可知,在PIC模式下,LA伪指令是AUIPC和LD指令的集合,它会访问GOT,然后从GOT中获取my_test_data符号的地址;而LLA伪指令是AUIPC和ADDI指令的集合,可直接获取my_test_data符号的绝对地址。

总之,在非PIC模式下,LLA和LA伪指令的行为相同,都是直接获取符号的绝对地址;而在PIC模式下,LA指令是从GOT中获取符号的地址,而LLA伪指令则是直接获取符号的绝对地址。

【例3-8】 在例3-7的基础上修改asm.S汇编文件,目的是观察LI伪指令。

<asm.S>
 
.global asm_test
asm_test:
 
    li t0, 0xfffffff080200000
    ret

在QEMU+RISC-V+Linux平台上编译。

# gcc main.c asm.S -O2 -g -o test

使用OBJDUMP命令反汇编。

root:example_pic# objdump -d test
 
00000000000005fc <asm_test>:
5fc:72e1                lui  t0,0xffff8
5fe:4012829b            addiw  t0,t0,1025
602:02d6                slli  t0,t0,0x15
604:8082                ret

从上面的反汇编结果可知,上述的LI伪指令由LUI、ADDIW和SLLI这3条指令组成。