Files
my-notes/CMU-CSAPP/L08 Data.md

11 KiB
Raw Blame History

L08 Data

多个数据元素放到一起的情况

目录

Arrays 数组

数组分配的基本思想相当简单: 在内存中分配一块儿连续的空间存储元素。

T array[L]

  • 一个数据类型为T的数组包含L个元素
  • 在内存中占用连续L * sizeof(T)字节的空间
  • 数组名array可以被转换为指向数组元素0的指针 (Type T *)
    • 是C语言特殊的原因之一1

Array Access in C C语言中的数组访问

前面的章节已经提到了(A, B, x)这样子的寄存器寻址表示方式。它简直就是为数组访问而生的 -- 看看下面的代码:

typedef int int_arr[5];

int get_num(int_arr arr, int index) {
    return arr[index];
}
get_num:    # 函数参数: arr -> %rdi, index -> %esi
    movslq  %esi, %rsi      # 将index转换为64位
    movl    (%rdi,%rsi,4), %eax     # 从 (%rdi) + 4 * %rsi 加载元素到eax
    ret

(%rdi, %rsi, 4) 是一个寄存器寻址模式。参考注释,可以发现它就是指向了arr[index]的地址。

很显然Intel格式的汇编语句也会有这种常见操作。

get_num:   # 这是Debug配置下的编译结果因此略显冗长
    movsxd      rax,dword ptr [index]       # 将index转换为64位
    mov         rcx,qword ptr [arr]         # 将arr的地址加载到rcx
    mov         eax,dword ptr [rcx+rax*4]   # 从 (rcx) + 4 * rax 加载元素到eax
    ret

Multidimensional Arrays 多维数组

C语言的指针与数组

    int A1[3];  //是一个整数数组大小为3
    int *A2[3]; //是一个指针数组大小为3每个元素都是一个指向整数的指针
    int (*A3)[3]; //是一个指针指向一个大小为3的整数数组的第一个元素

概念如图所示:

指针与数组概念辨析 内存图示 指针与数组概念辨析 图例

内存中的二维数组

二维数组在内存中是以行优先的方式存储的,即先存储第一行的所有元素,再存储第二行的所有元素,以此类推。

    int pgh[4][5] =
    { 
        {1, 5, 2, 0, 6 },
        {1, 5, 2, 1, 3 },
        {1, 5, 2, 1, 7 },
        {1, 5, 2, 2, 1 }
    };

这个数组在内存中存储的顺序如图: 二维数组在内存中的存储顺序

也可以以这样的方式理解那个二维数组:

/* 定义一个类型别名 five_int_arr表示一个包含5个整数的数组 */
typedef int five_int_arr[5];

five_int_arr pgh[4] = 
{ 
    {1, 5, 2, 0, 6 },
    {1, 5, 2, 1, 3 },
    {1, 5, 2, 1, 7 },
    {1, 5, 2, 2, 1 }
};

从图里我们可以看到,对二维数组来说,假设一个二维数组是T array[R][C]其中T是元素类型R是行数C是列数那么

  • array[i]是一个类型为T[C]的一维数组包含C个元素
    • 起始地址为 array + i * C * sizeof(T)
  • array[i][j]是一个类型为T的元素
    • 地址为 array + (i * C + j) * sizeof(T)

访问二维数组的方式

你可能想到了: 没错,lea机器指令可以被用到。

#define PCOUNT 4
typedef int int_arr[5];

    int get_num_from_2d_arr(int_arr arr[PCOUNT], int row, int col) {
        return arr[row][col];
    }
get_num_from_2d_arr:        # assume: arr -> %rdi, row -> %esi, col -> %edx
    movslq  %esi, %rsi      # 将row转换为64位
    leaq    (%rsi,%rsi,4), %rax     # %rax = %rsi + %rsi * 4
    leaq    (%rdi,%rax,4), %rax     # %rax = %rdi + %rax * 4得到arr[row]的地址
    movslq  %edx, %rdx      # 将col转换为64位
    movl    (%rax,%rdx,4), %eax     # 从%rax + %rdx * 4加载元素到eax
    ret

假装是二维数组的指针数组

还有一种情况,一维数组存储指向一维数组的指针。

    int_arr mit = {0, 2, 1, 3, 9};
    int_arr cmu = {1, 5, 2, 1, 3};
    int_arr ucb = {9, 4, 7, 2, 0};
    
    int_arr *arr[3] = {&mit, &cmu, &ucb};

它也可以用get_num_from_2d_arr C语言函数来获取对应位置的数据在内存中的情况完全不同。它的内存布局长这样: 存有指向一维数组的指针的一维数组的内存布局

Structures 结构体

Explanation 解释

结构体的内存就是简单的把东西接在一起,考虑内存对齐即可。

#include <stdint.h>
struct structExample
{
    uint32_t a[3];
    uint16_t shortInt;
    uint64_t next;
};

结构体的内存布局

因此,对结构体的访问也很简单。编译器直接“按图索骥”,根据定义找到对应的偏移量就行了。

uint16_t get_short_int(struct structExample *e) {
    return e->shortInt;
}
get_short_int:
    movzwl  12(%rdi), %eax  # 从结构体e的地址(%rdi) + 12字节处加载shortInt到%eax
    ret

Memory Alignment 内存对齐

解释

出于硬件原因2 ,结构体中的成员在内存中的位置需要按照特定的边界进行对齐。这意味着编译器可能会在成员之间插入填充字节,以确保每个成员都位于正确的内存地址上。

  • 编译器会找到最大的成员的大小,并将结构体的总大小调整为该大小的倍数。内存对齐也会按照该大小进行。

例如在上图的例子中你可以看到uint64_t next成员是最大的占用8字节因此结构体的总大小被调整为24字节8的倍数。编译器在shortInt成员之后插入了2字节的填充以确保next成员从一个8字节边界开始。

优化布局

由于编译器有时不会自动优化结构体内存布局,因此在定义结构体时,合理安排成员的顺序可以减少填充字节的数量,从而节省内存空间。例如如下结构体:

struct badStruct
{
    uint8_t a;
    uint64_t b;
    uint16_t c;
    uint32_t d;
};

badStruct的内存布局 它足足空出来了8个字节的空间没有使用这使得它的总大小达到了24字节。

如果我们稍稍改动一下排布顺序......

struct goodStruct
{
    uint64_t b;
    uint32_t d;
    uint16_t c;
    uint8_t a;
};

goodStruct的内存布局 这样子我们只需要1个字节的填充。结构体的总大小也因此减少到了16字节。

通常来说,将结构体成员按照从大到小的顺序排列,可以最大程度地减少填充字节的数量。

Floating Point 浮点数

Floating Point Processor 浮点处理器

浮点数的运算使用一组完全不同的寄存器与指令集。在现代的CPU上大多数使用AVX指令集处理浮点数。它使用16个256位的寄存器分别为YMM0YMM15,可以同时处理多个浮点数。

  • 如果只是简单的浮点数运算CPU可能仅使用SSE指令集和128位的XMM寄存器。要真正利用 AVX 的高带宽,需要编写一次性将四个双精度浮点数相加的代码。
  • XMM 寄存器实际上只是 YMM 寄存器的低半部分 。如果你写入 XMM0 ,从技术上讲,你实际上是在操作 YMM0 的前 128 位。
float add_floats(float a, float b) {
    return a + b;
}
add_doubles:
    addsd   %xmm1, %xmm0
    ret

可以在英特尔内部组件指南找到所有的AVX2指令和它们的功能描述。对于浮点数运算常见的指令包括:

  • addss:对两个寄存器内的第一个单精度浮点数进行加法运算
  • addps:对两个寄存器内的所有单精度浮点数进行加法运算

它们的图解如下图所示: addss和addps指令的图解 这里使用了SSE的128位寄存器来演示addssaddps指令的功能。对于AVX的256位寄存器addssaddps指令的功能是一样的,只不过它们可以同时处理更多的浮点数。

  • addsd:对两个寄存器内的第一个双精度浮点数进行加法运算
  • addpd:对两个寄存器内的所有双精度浮点数进行加法运算

addsd指令的图解 有了前面addssaddps的图解,addsd就不难理解了。它们的区别在于处理的数据类型不同,addssaddps处理单精度浮点数,而addsdaddpd处理双精度浮点数。

与此类似,addpd指令可以同时处理两个寄存器内的所有双精度浮点数进行加法运算。对于AVX的256位寄存器addpd可以同时处理四个双精度浮点数进行加法运算。

理解了这里之后AVX指令集的拓展也不难理解。它的进步之一是引入了vaddss这种指令。它允许第三个寄存器作为目标,将运算结果存储在那个寄存器,而不是覆盖其中一个源寄存器,例如VADDPS YMM2, YMM1, YMM0

Usage in functions 函数中的使用

在函数中使用浮点数时根据x86-64 Linux System V ABI约定

  • 通过YMM0YMM7寄存器传递前8个浮点数参数3
  • 返回值通过YMM0寄存器返回
  • 所有YMM寄存器的高128位都是易失性的必须由调用者负责保存和恢复。3

  1. C 语言中的数组解引用运算符 [ ] 是用指针定义的x[y]的意思是:从指针x开始,向前移动y个元素,然后取下那里的所有元素。使用指针运算语法, x[y] 也可以写成 *(x+y) 。有一个例外情况: 计算sizeof(array)时,返回一个指针的占用字节数没卵用。因此,在这里,数组名array不会被转换为指针,而是返回整个数组占用的字节数。 ↩︎

  2. 无论数据是否对齐x86-64硬件都基本可以正常工作。但Intel依旧建议对其数据以获得更好的性能。如果数据未对齐CPU可能需要两次内存访问来获取数据导致性能下降。 ↩︎

  3. Windows x64 ABI与Linux System V ABI在寄存器使用约定上有一些不同。Windows x64 ABI仅使用XMM0XMM3寄存器传递浮点数参数;且要求被调用者保存XMM6XMM15寄存器,而XMM0XMM5与Linux相同。所有YMM寄存器也与Linux相同由调用者负责保存和恢复。 ↩︎