内存交互

目前我们所叙述的赋值指令、数据处理指令,都是在寄存器层面进行的。那么,如何与内存进行交互呢?

C语言层面的内存

首先我们需要知道,为什么要与内存交互。在硬件基础中我们提到,理论上,如果我们有能力CPU直连几十上百万个寄存器,那么是不需要内存的。从另一个层面来讲,如果我们能做到内存与CPU之间的读取速度和寄存器类似,那么我们也不需要寄存器了。也就是说,内存以量取胜,寄存器以速度取胜。我们在编程中的变量动辄成千上百个,都存储在寄存器中也就因此不现实。

因此,我们在C语言中使用的变量,默认情况下往往都是存储在内存中的。但是,当我们涉及到具体的数据处理等等指令的时候,其必须操作寄存器。所以,我们在操作变量的过程中,底层实际上首先都是需要将变量对应的内存中的值传入寄存器的。因此,这里就涉及到与内存进行交互。

这里再顺便提一句,C语言中并非所有变量都会放在内存中。编译器可以根据不同的情况进行优化,可以将变量优化到寄存器中。对于某些编译器来说,我们也可以通过register关键词提示编译器,我们希望这个变量存储在寄存器中而不是内存中。

内存交互指令

基本的内存交互指令就是ldrstr了。这两条指令的用法为:

ldr{sign}{size}    dest_reg, [mem_addr]
str{size}          dest_reg, [mem_addr]

我们首先先不讲[mem_addr]的细节,来看几个实例:

strb    w0, [mem_addr]    ; Instruction 1
ldrh    x1, [mem_addr]    ; Instruction 2
ldrsb   w2, [mem_addr]    ; Instruction 3

这三条指令的意思分别是:

  • 指令1

    r0寄存器最低位的1个字节的内容,存储到地址为mem_addr的内存中。

  • 指令2

    mem_addr处开始的2个字节的内存内容,无符号扩展地存储到r1寄存器的低2字节位置

  • 指令3

    mem_addr处开始的1个字节的内存内容,有符号扩展地存储到r2寄存器的最低的1个字节中

首先,粗粒度地来看,ldr就是将内存数据读取到寄存器中,str就是将寄存器数据存储到内存中。

操作长度

但是由于寄存器的长度和内存单元长度不一致,导致了问题的复杂化。我们知道,AArch64架构下的通用寄存器长度都是64位,也就是8个字节。我们在汇编语言中能操作的寄存器,也就是x0w0等,也就只有8字节和4字节两种。但是,内存的最小单位是1个字节。因此,在寄存器与内存交互的过程中,需要有一种方法以1字节为粒度来控制。

所以,ldrstr指令后面才需要跟着{size}。这里的{size}b表示1字节,h表示2字节,w表示4字节。例如,strb表示存储1字节的内容,ldrw表示读取4字节的内容。当我们想表示的字节与目的操作数的宽度一致时,可以省略。例如,如果想将w0的全部4字节内容存储到内存中,那么我们既可以写strw w0, [mem_addr],也可以省略w,直接写str w0, [mem_addr]

扩展

通过{size}后缀的这种方法,可以有效地解决寄存器宽度与内存操作单元长度不一致的问题,以1字节的粒度进行寄存器与内存之间的交互。这在存储过程中没有问题,但是在读取内存的过程中,还剩下一个问题。如果我想从内存中读取1个字节的内容,存储到r0寄存器中,那r0寄存器中剩下的7个字节该怎么办?

这个问题的解决方法在赋值指令一章中介绍了,就是无符号扩展与有符号扩展。当我们使用ldrsb时,会将内存中这1个字节的内容,有符号扩展地存储到寄存器中;直接使用ldrb,则是无符号扩展。

端序

此外还有一个小问题,就是端序。例如,我们目前w0的值为0x12345678,如果存储到0x400000地址的内存单元中,那么内存单元的内容该怎样分布呢?

  • 小端序

    寄存器中的低位会存储在内存的低地址中:

    0x400000处为0x78, 0x400001处为0x560x400002处为0x340x400003处为0x12

  • 大端序

    寄存器中的低位会存储在内存的高地址中:

    0x400000处为0x12, 0x400001处为0x340x400002处为0x560x400003处为0x78

硬件基础一章中我们提到,Apple Silicon使用的是小端序。

数据对齐(Alignment)

在绝大多数指令集架构中,都会有数据对齐的要求。意思是说,我们读取/写入内存时,对内存地址本身也是有要求的。一般来说,对齐的字节数与读取/写入的字节数相同。例如,我们使用ldrw从内存中读取4字节的内容,那么根据要求,我们读取的地址本身,需要是4的倍数。

这种对齐要求在目前的Apple Silicon中并不是强制的。但是,读取/写入对齐的地址,可以防止意外的性能损失。

事实上,在某些架构中,不对齐的内存访问会直接产生异常,甚至不产生异常而是出现错误的结果。这也是现代的安全的编程语言,例如Rust,有ptr::readptr::read_unaligned两种函数的原因。

这种对齐对我们的日常编程有什么影响呢?这里简单举一个例子:

codes/10-alignment.c文件中,我们有一个C语言的结构体:

struct AlignedStruct {
    short a;
    char b;
    int c;
};

使用Clang编译后这个文件,运行它,得到输出:

sizeof(short) is 2, sizeof(char) is 1, sizeof(int) is 4, but sizeof(struct AlignedStruct) is 8
Inside struct AlignedStruct, short a is at pos 0, char b is at pos 2, int c is at pos 4

可以发现,这个结构体并不是简单地将一个2字节的a、一个1字节的b和一个4字节的c合并在一起变成7字节的结构体,而是在b字段后补了一个1字节的padding。

从某种意义上来说,这也是因为数据对齐。试想,如果我们想不产生性能损耗,那么,a的地址应该以2字节对齐,b的地址应该以1字节对齐,c的地址应该以4字节对齐。那么,使用一个非常简单的想法,就是b后补1个字节,这样就能同时保证这三点了。

这从某种意义上说,也是各种网络报文,例如IP报文头(如下图,改编自IETF的RFC791)如此规整的原因。

 0               1               2               3
 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7 0 1 2 3 4 5 6 7
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version|  IHL  |Type of Service|          Total Length         |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|         Identification        |Flags|      Fragment Offset    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|  Time to Live |    Protocol   |         Header Checksum       |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                       Source Address                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Destination Address                        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                    Options                    |    Padding    |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

寻址模式

接下来,我们就讲一讲,[mem_addr]部分是怎么构成的,也就是所谓的「寻址模式」(Addressing Mode)。

仅基寄存器

首先最直接的,我们可以直接将地址存储在寄存器中,访问内存时先去寄存器中查找相应的地址。例如:

ldr    w1, [x0]

就是指将x0中存储的值看作一个内存地址,向相应的内存地址中取值,赋值到w1中。

这种模式我们在C语言中非常常见,可以理解成C语言中的指针。b = *a就是将a的值看作地址,向内存地址中取值,赋值给b

基寄存器加偏移

仅基寄存器模式已经可以实现绝大部分的内存交互方式了。但是,在用C语言等高级语言编程的时候,会有一些非常常用的代码模式。针对这些代码模式,在底层汇编指令中也做了相应的优化。

基寄存器加常数偏移

在C语言中,我们常常会有对结构体字段的访问:

struct Foo {
    int a;
    int b;
};

struct Foo *foo = get_foo_ptr();
// accessing foo->b
// ...

例如,像这个程序一样,我们有一个Foo结构体指针foo,我们想访问其b字段(在底层而言,其偏移为4字节),那么,我们需要将foo指针指向的地址加4,然后解引用,就可以得到foo->b了。

这种对结构体字段的访问,在底层往往就是「将寄存器存储的地址加上一个常数,再读/写相应的地址」。为了优化这种模式,我们的寻址模式中就有基寄存器加常数偏移这种模式:

ldr    w1, [x0, #4]

上述指令的意思就是,将x0寄存器的值加4,看作一个地址,对其访问,取值并赋值给w1

这种模式除了方便结构体字段的访问,也方便局部变量的访问。不过这章中我们暂时不介绍局部变量,在之后函数的章节会完整介绍。

此外,值得指出的是,在A64指令集中真正的ldrstr,其只能接受满足特定条件的常数偏移:非负、是4或8的倍数等。还有其他A64指令集中的指令,如ldurstur,可以实现负数偏移等。但是,在我们手写汇编的过程中,考虑这个实在是太麻烦了。因此,现在大部分的汇编器,都支持strldr的偏移不满足那些特定条件。在汇编的过程中根据偏移生成不同指令即可。(GCC的汇编器gas负责这部分功能的函数位于gas/config/tc-aarch64.c文件的try_to_encode_as_unscaled_ldst函数,LLVM负责这部分功能的函数位于llvm/lib/Target/AArch64/AArch64InstrInfo.cpp文件的isAArch64FrameOffsetLegal函数)

基寄存器加寄存器偏移

在C语言中,我们往往会有对数组的遍历:

char a[64];
for (size_t i = 0; i < 64; i++) {
    char b = a[i];
    // ...
}

我们可以发现,ai都是变量,我们在翻译成汇编语言的过程中,可以都使用寄存器存储这两个变量。在这种模式下,我们需要将存储a的值的寄存器和存储i的寄存器的值相加,看作一个地址,对其读/写。针对这种情况,汇编语言层面我们可以用:

ldr    w2, [x0, x1]

表示将x0的值与x1的值相加,看作一个地址,取值并赋值给w2

有一点值得注意:我们上面的例子中,a是一个char类型的数组。这意味着,这个数组的第几个元素,就是与首地址距离几个字节。例如,a[2]与首地址a就确实距离2字节。但是,如果是别的类型的数组呢?

对于整型数组int a[64],一个整型的长度是4个字节,那么a[2]就与首地址距离8个字节。对于这种情况,我们可以用到在基本的数据处理指令一章中提到的「操作数的可选移位」实现:

ldr    w2, [x0, x1, lsl #2]

上述指令是指,将x0的值,与x1左移2位后的值相加,看作地址,取值并赋值给w2。我们之前提到过,左移两位就是乘以4,所以这个指令就可以完美地模拟整型数组的遍历。

索引寻址

索引寻址可以用于一些更特殊的代码模式:

int *a;
int b = *(++a);
int c = *(a++);
  • 对于第二行b的赋值而言,我们需要将a的值加4(int类型宽度为4)后赋值给a,然后取值赋值
  • 对于第三行c的赋值而言,我们需要将a取值赋值,然后将a的值加4(int类型宽度为4)后赋值给a

对于这两种代码模式,我们可以分别用:

ldr    w1, [x0, #4]!
ldr    w1, [x0], #4

这两种写法。

  • 第一种写法被称为前索引寻址,将x0的值加4赋值给x0后,将对应内存取值赋值给w1
  • 第二种写法被称为后索引寻址,将x0对应的内存取值赋值给w1后,将x0自身值加4赋值给自身

这两种索引寻址模式往往在程序优化中会使用,可以在LLVM源码中搜索AArch64LoadStoreOpt::mergeUpdateInsn这个函数,看看会有哪些优化可以使用这两种寻址模式。

字面量寻址

赋值指令一章中我们提到过,如果在使用ldr伪指令的时候,相应的数无法在mov指令中表示,那么汇编器将在二进制镜像中创建一块内存区域存储相应的值,在执行时通过读取内存的方式进行赋值。

在这里,读取内存就是通过字面量寻址的方式。在编码时,计算目标内存地址与当前指令地址的距离,在执行时,通过当前程序计数器PC加上相应的距离就可以得到相应的地址。

获得地址的方式

在上述的寻址模式中,除了字面量寻址这个比较少见的方式之外,其他几个方式的前提都是:存在一个寄存器,它的值是内存地址。这是如何做到的呢?

我们知道,在汇编语言中的地址,可以在C语言中用地址和指针来思考。因此,我们不妨首先从C语言的层面来讨论。

在C语言中,我们知道,变量可以在栈区、堆区以及全局变量区。栈的概念我们将在之后函数的章节详细解释,这里略过。堆就是由libc与操作系统共同实现的一块儿地址区域,我们可以通过malloc等libc提供的API进行堆内存的分配。全局变量区一般来说有自己单独的段,存储在二进制程序本身。当我们载入二进制程序时,也会在内存中映射对应的段。

抛开栈区不谈,最简单的将地址存入寄存器的方式就是通过malloc了。当我们调用malloc之后,x0寄存器的值就自动会存入分配的堆的地址(至于为什么是x0寄存器,我们之后函数的章节会谈到):

bl    _malloc
; Here x0 has heap address

接下来,我们主要讲讲是怎样获得全局变量区的地址的。

全局变量的声明

刚刚我们提到,全局变量有自己的段和节。我们常见的已初始化的全局变量往往处于__DATA段的__data节,未初始化的全局变量往往处于__DATA段的__comm节。同__text节类似,我们既可以使用.section __DATA, __data来标注这个节,也可以用.data来标注这个节。

接着,我们就可以用另外一些汇编器指令来生成数据了。例如:

    .data
    .p2align   2
a:
    .long      0x114514

上述汇编代码生成了一个4字节长的变量,名字为a,值为0x114514

下面我们一行一行得来说

  • .data

    正如之前所说,.data指的是__DATA段的__data节。

  • .p2align 2

    表明这个变量以4字节(2表示次方,即2的2次方)对齐。这是因为,正如我们在上面所讲的,我们使用数据对齐可以提高内存读取的性能。我们想声明一个4字节的变量,那么其也应该按4字节对齐。

  • a:

    这被称为「标签」(Label),在后面跳转中会解释。这个标签的作用就是为了方便指令索引这个地址。

  • .long 0x114514

    声明了一个长度为4字节的变量,值为0x114514

    在这里,.long表示长度为4字节。有多种类型标识:

    • .byte

      长度为1字节

    • .short

      长度为2字节

    • .long

      长度为4字节

    • .quad

      长度为8字节

    • .asciz

      声明字符串(自动会以\0结尾),例如:

      my_str:
          .asciz    "Hello world"
      

      我们访问my_str标签时,会指向一个字符串"Hello world",并且这个字符串自动以\0结尾

全局变量地址的获取

下面,就介绍一下怎样将全局变量的地址获取到寄存器中。首先,之前我们介绍过,现代的操作系统要求我们编写PIC,也就是说我们在编码指令的时候,不能真的把一个绝对地址放到寄存器里,因为这个地址在每次加载模块的时候是变化的。

为了解决这个问题,实现PIC,我们一般采用PC-relative的编码模式。这是因为,在加载程序的时候,尽管基地址是变化的,但是地址与地址之间的距离是不变的。因此,我们可以计算目标地址与当前指令地址的差值,而CPU在执行指令的时候,会去寻找当前PC的值,与其相加。这样既保证了PIC,也能实现寻址的目的。

那么,最简单地,我们可以使用adr指令。例如以下这个程序:

    adr    x1, a
    .p2align   2
a:
    .long    0x114514

上述指令的执行后,x1中就会有a对应的地址了。

但是,有一点与众不同的:这里a直接就在__text节中,我们并没有使用.data来标识a(将初始化的全局变量存在__data节只是一个约定,我们也可以不遵守,所以这样是可行的)。这是为什么呢?

刚刚我们提到,我们会采用PC-relative的编码模式。因此adr x1, a在二进制编码层面,实际上就是存的a与当前指令的差值。这里a紧挨着当前指令,所以没有什么问题。但是我们之前提到,AArch64指令集的指令都是32位定长指令。这32位里还要编码指令本身、目的寄存器等等。所以对于adr指令而言,它仅仅能提供21位来编码a与PC的差值。考虑正负号来说,也就是说只能编码当前PC的+/-1MB范围内的标签。但是,如果把a放到了另一个段的另一个节,这之间究竟距离多少就难以控制了。所以说,还得想别的方法。这里,就用到了adrp指令:

    ; In .text
    adrp    x1, a@PAGE
    add     x1, x1, a@PAGEOFF
    ; ...
    ; In .data
    .p2align
a:
    .long    0x114514

这里出现了我们前所未见的语法:@PAGE, @PAGEOFF。我们先不要惊慌,来讲一讲这段代码究竟做了什么,怎样突破了之前+/-1MB的限制的。

adr编码的时候是将a的地址与当前PC的差值进行编码,而adrp则是将a所在的页与PC所在的页的差值除以页大小后进行编码。这是什么意思呢?在硬件基础一章中我们提到过,操作系统和CPU是按页来管理内存的。所以在设计操作系统的时候,往往需要提供一个非常方便的算法来从一个地址得到它所在的页的地址。正因为此,我们可以认为计算一个地址所在的页的性能损耗非常小。那么,我们可以先计算a所在的页,再计算当前PC所在的页,两者相减,得到一个距离。而页大小是4KB,所以这个距离一定是4KB的倍数,从而我们可以放心地将其除以4KB。

这样的话,我们可以表示的距离范围,就是1MB再乘以4KB,也就是+/-4GB。这样就可以突破指令长度的限制了。

在得到页的距离之后,adrp真正执行的时候,会首先计算当前PC所在的页地址,然后加上在编码时得到的页的距离,就可以得到a所在的页地址了。因此,在执行adrp之后,x1存储的值是a所在的页的地址。

a@PAGEOFF则会将a的地址与其页地址的距离进行编码,从而执行这个add之后,x1的值就是真正的a的地址了。

因此我们可以知道,@PAGE@PAGEOFF并不是执行时的记号。@PAGE意思是求a所在的页地址,@PAGEOFF是指a与页地址的距离。事实上,这是一种重定位操作符,在LLVM中被称为Variant Kind。在之后我们会提到,这里简单介绍一下。像.data.text之间,这种两个段两个节之间的距离,往往在汇编成目标文件的时候不能确定,只有在链接的时候才能确定。因此,在汇编的过程中,会生成重定位的信息,在链接时根据重定位信息,填入正确的值。