Files
my-notes/CMU-CSAPP/L08 Data.md
2026-04-02 09:45:34 +08:00

280 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# L08 Data
多个数据元素放到一起的情况
## 目录
- [Arrays 数组](#arrays-数组)
- [Array Access in C C语言中的数组访问](#array-access-in-c-c语言中的数组访问)
- [Multidimensional Arrays 多维数组](#multidimensional-arrays-多维数组)
- [Structures 结构体](#structures-结构体)
- [Explanation 解释](#explanation-解释)
- [Memory Alignment 内存对齐](#memory-alignment-内存对齐)
- [Floating Point 浮点数](#floating-point-浮点数)
- [Floating Point Processor 浮点处理器](#floating-point-processor-浮点处理器)
- [Usage in functions 函数中的使用](#usage-in-functions-函数中的使用)
## Arrays 数组
数组分配的基本思想相当简单: 在内存中分配一块儿连续的空间存储元素。
```T array[L]```
- 一个数据类型为T的数组包含L个元素
- 在内存中占用**连续**的```L * sizeof(T)```字节的空间
- 数组名```array```可以被转换为指向数组元素0的指针 (```Type T *```)
- - 是C语言特殊的原因之一[^Why-Is-C-Special-Array-Pointer]
[^Why-Is-C-Special-Array-Pointer]: C 语言中的数组解引用运算符 [ ] 是用指针定义的```x[y]```的意思是:从指针```x```开始,向前移动```y```个元素,然后取下那里的所有元素。使用指针运算语法, ```x[y]``` 也可以写成 ```*(x+y)``` 。有一个例外情况: 计算```sizeof(array)```时,返回一个指针的占用字节数没卵用。因此,在这里,数组名```array```不会被转换为指针,而是返回整个数组占用的字节数。
### Array Access in C C语言中的数组访问
前面的章节已经提到了(A, B, x)这样子的寄存器寻址表示方式。它简直就是为数组访问而生的 -- 看看下面的代码:
```c
typedef int int_arr[5];
int get_num(int_arr arr, int index) {
return arr[index];
}
```
```asm
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格式的汇编语句也会有这种常见操作。
```asm
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语言的指针与数组
```C
int A1[3]; //是一个整数数组大小为3
int *A2[3]; //是一个指针数组大小为3每个元素都是一个指向整数的指针
int (*A3)[3]; //是一个指针指向一个大小为3的整数数组的第一个元素
```
概念如图所示:
![指针与数组概念辨析 内存图示](/CSAPP/images/L08%20Array%20&%20Pointer%201.png)
![指针与数组概念辨析 图例](/CSAPP/images/L08%20Array%20&%20Pointer%202.png)
#### 内存中的二维数组
二维数组在内存中是以行优先的方式存储的,即先存储第一行的所有元素,再存储第二行的所有元素,以此类推。
```C
int pgh[4][5] =
{
{1, 5, 2, 0, 6 },
{1, 5, 2, 1, 3 },
{1, 5, 2, 1, 7 },
{1, 5, 2, 2, 1 }
};
```
这个数组在内存中存储的顺序如图:
![二维数组在内存中的存储顺序](/CSAPP/images/L08%20Multi%20Demonsion%20Array%20Memory.png)
也可以以这样的方式理解那个二维数组:
```C
/* 定义一个类型别名 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```机器指令可以被用到。
```C
#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];
}
```
```asm
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
```
#### 假装是二维数组的指针数组
还有一种情况,一维数组存储指向一维数组的指针。
```C
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语言函数来获取对应位置的数据但**在内存中的情况完全不同**。它的内存布局长这样:
![存有指向一维数组的指针的一维数组的内存布局](/CSAPP/images/L08%20Array%20Pointer%20Array%20Memory.png)
## Structures 结构体
### Explanation 解释
结构体的内存就是简单的把东西接在一起,考虑内存对齐即可。
```C
#include <stdint.h>
struct structExample
{
uint32_t a[3];
uint16_t shortInt;
uint64_t next;
};
```
![结构体的内存布局](/CSAPP/images/L08%20Struct%20Example%20Memory%20Layout.png)
因此,对结构体的访问也很简单。编译器直接“按图索骥”,根据定义找到对应的偏移量就行了。
```C
uint16_t get_short_int(struct structExample *e) {
return e->shortInt;
}
```
```asm
get_short_int:
movzwl 12(%rdi), %eax # 从结构体e的地址(%rdi) + 12字节处加载shortInt到%eax
ret
```
### Memory Alignment 内存对齐
#### 解释
出于硬件原因[^Memory-Alignment-Hardware-Reason],结构体中的成员在内存中的位置需要按照特定的边界进行对齐。这意味着编译器可能会在成员之间插入填充字节,以确保每个成员都位于正确的内存地址上。
[^Memory-Alignment-Hardware-Reason]: 内存对齐是为了提高访问效率。现在的机器一次从内存中读取大约64字节。如果数据没有对齐可能需操作系统或者硬件的额外处理导致效率降低。
- 编译器会找到最大的成员的大小,并将结构体的总大小调整为该大小的倍数。内存对齐也会按照该大小进行。
例如在上图的例子中你可以看到uint64_t next成员是最大的占用8字节因此结构体的总大小被调整为24字节8的倍数。编译器在shortInt成员之后插入了2字节的填充以确保next成员从一个8字节边界开始。
#### 优化布局
由于编译器有时不会自动优化结构体内存布局,因此在定义结构体时,**合理安排成员的顺序**可以减少填充字节的数量,从而节省内存空间。例如如下结构体:
```C
struct badStruct
{
uint8_t a;
uint64_t b;
uint16_t c;
uint32_t d;
};
```
![badStruct的内存布局](/CSAPP/images/L08%20Bad%20Struct%20Memory%20Layout.png)
它足足空出来了8个字节的空间没有使用这使得它的总大小达到了24字节。
如果我们稍稍改动一下排布顺序......
```C
struct goodStruct
{
uint64_t b;
uint32_t d;
uint16_t c;
uint8_t a;
};
```
![goodStruct的内存布局](/CSAPP/images/L08%20Good%20Struct%20Memory%20Layout.png)
这样子我们只需要1个字节的填充。结构体的总大小也因此减少到了16字节。
通常来说,将结构体成员按照**从大到小**的顺序排列,可以最大程度地减少填充字节的数量。
## Floating Point 浮点数
### Floating Point Processor 浮点处理器
浮点数的运算使用一组完全不同的寄存器与指令集。在现代的CPU上大多数使用AVX指令集处理浮点数。它使用16个256位的寄存器分别为```YMM0```到```YMM15```,可以同时处理多个浮点数。
- 如果只是简单的浮点数运算CPU可能仅使用SSE指令集和128位的```XMM```寄存器。要真正利用 AVX 的高带宽,需要编写一次性将四个双精度浮点数相加的代码。
- ```XMM``` 寄存器实际上只是 ```YMM``` 寄存器的低半部分 。如果你写入 ```XMM0``` ,从技术上讲,你实际上是在操作 ```YMM0``` 的前 128 位。
```C
float add_floats(float a, float b) {
return a + b;
}
```
```asm
add_doubles:
addsd %xmm1, %xmm0
ret
```
可以在[英特尔内部组件指南](https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html)找到所有的AVX2指令和它们的功能描述。对于浮点数运算常见的指令包括:
- ```addss```:对两个寄存器内的第一个单精度浮点数进行加法运算
- ```addps```:对两个寄存器内的所有单精度浮点数进行加法运算
它们的图解如下图所示:
![addss和addps指令的图解](/CSAPP/images/L08%20ADDSS%20&%20ADDPS%20Diagram%20.png)
*这里使用了SSE的128位寄存器来演示```addss```和```addps```指令的功能。对于AVX的256位寄存器```addss```和```addps```指令的功能是一样的,只不过它们可以同时处理更多的浮点数。*
- ```addsd```:对两个寄存器内的第一个双精度浮点数进行加法运算
- ```addpd```:对两个寄存器内的所有双精度浮点数进行加法运算
![addsd指令的图解](/CSAPP/images/L08%20ADDSD%20Diagram.png)
有了前面```addss```和```addps```的图解,```addsd```就不难理解了。它们的区别在于处理的数据类型不同,```addss```和```addps```处理单精度浮点数,而```addsd```和```addpd```处理双精度浮点数。
与此类似,```addpd```指令可以同时处理两个寄存器内的所有双精度浮点数进行加法运算。对于AVX的256位寄存器```addpd```可以同时处理四个双精度浮点数进行加法运算。
理解了这里之后AVX指令集的拓展也不难理解。它的进步之一是引入了```vaddss```这种指令。它允许第三个寄存器作为目标,将运算结果存储在那个寄存器,而不是覆盖其中一个源寄存器,例如```VADDPS YMM2, YMM1, YMM0```。
### Usage in functions 函数中的使用
在函数中使用浮点数时根据x86-64 **Linux** System V ABI约定
- 通过```YMM0```到```YMM7```寄存器传递前8个浮点数参数[^Windows-x64-ABI]
- 返回值通过```YMM0```寄存器返回
- 所有```YMM```寄存器的高128位都是易失性的必须由**调用者**负责保存和恢复。[^Windows-x64-ABI]
[^Windows-x64-ABI]: Windows x64 ABI与Linux System V ABI在寄存器使用约定上有一些不同。Windows x64 ABI仅使用```XMM0```到```XMM3```寄存器传递浮点数参数;且要求被调用者保存```XMM6```到```XMM15```寄存器,而```XMM0```到```XMM5```与Linux相同。所有```YMM```寄存器也与Linux相同由调用者负责保存和恢复。