rename folders

This commit is contained in:
2026-04-02 09:45:34 +08:00
parent 312dc04755
commit 75720adacd
12 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,214 @@
# Lecture 07 Procedures 函数的调用
## Mechanisms 机制 / 目录
- [Passing Control 移交线程控制权](#passing-control-移交线程控制权)
- [Stack 栈](#stack-栈)
- [Call & Ret 函数调用](#call--ret-函数调用)
- [Passing Data 传递数据](#passing-data-传递数据)
- [传入参数](#传入参数)
- [返回值](#返回值)
- [Managing Local Data 管理其他变量](#managing-local-data-管理其他变量)
- [Stack Frame 栈帧](#stack-frame-栈帧)
- [Recursion 递归](#recursion-递归)
- [`ret` 指令](#ret-指令)
- [Register Saving Conversions 寄存器保存约定](#register-saving-conversions-寄存器保存约定)
- [x86-64 Linux System V ABI](#x86-64-linux-system-v-abi)
- [Illustration of Recursion 递归示例](#illustration-of-recursion-递归示例)
## Passing Control 移交线程控制权
### Stack 栈
在画图时,栈底通常画在上方。
在内存地址视角中,栈底对应较高地址,因此按地址从低到高展示时,栈底会出现在下方。
![栈结构示意图](/CSAPP/images/L07%20Stack%20Picture.png)
#### 表 1栈底在上高地址在上
| 数据编号 | 数据 | 地址 |
|----------|---------|--------------------|
| #5 | 0x55 | 0x7fffffffe0a0 |
| #4 | 0x44 | 0x7fffffffe098 |
| #3 | 0x33 | 0x7fffffffe090 |
| #2 | 0x22 | 0x7fffffffe088 |
| #1 | 0x11 | 0x7fffffffe080 |
#### 表 2低地址在上地址升序
| 地址 | 数据 | 数据编号 |
|--------------------|---------|----------|
| 0x7fffffffe080 | 0x11 | #1 |
| 0x7fffffffe088 | 0x22 | #2 |
| 0x7fffffffe090 | 0x33 | #3 |
| 0x7fffffffe098 | 0x44 | #4 |
| 0x7fffffffe0a0 | 0x55 | #5 |
寄存器```%rsp```用于存储栈顶的当前位置。
- ```push <source:data>``` : 将指定内容压入栈中。系统会自动更改```rsp```寄存器的值(-8
- ```pop <dest:reg>``` : 将栈顶的内容出栈并放入```dest```(必须是寄存器)。系统会自动更改```rsp```寄存器的值(+8
- - 使用```pop```时,实际上不会删除内存中的某个值。它只会修改```rsp```寄存器的值。
### Call & Ret 函数调用
函数使用**栈**来进行调用、返回等操作。
- ```call <label>``` : 将**返回地址**压入栈然后跳转到Label处的函数
- 返回地址 : 调用函数处下一条的汇编指令地址。
- ```ret``` : 将栈中的返回地址出栈,然后跳转至该地址
- *```rep;ret```* : *与ret作用相同已弃用[^rep-ret]*
- ```rip```寄存器: 保存目前指令的地址
[^rep-ret]: 早期 AMD CPU 中,部分场景不写 ```rep``` 会导致分支预测相关优化失效;现代 CPU 上不再需要这样做。
```call```指令实际上做了三件事情: 把```rip```地址处的下一条指令的地址入栈、修改```rsp```然后jump至指定地址。你不可以手动操作```rip```。
## Passing Data 传递数据
### 传入参数
一般来说,我们会将参数使用这六个寄存器传递:
- ```%rdi```
- ```%rsi```
- ```%rdx```
- ```%rcx```
- ```%r8```
- ```%r9```
特殊地,如果参数超过六个,额外参数会通过栈传递。参数在栈中的顺序(从栈顶到栈底)如下:
| 内容 | 位置 |
|-----------|--------|
| ... | <-栈底 |
| 其他内容 | |
| 第 n 参数 | |
| ... | |
| 第 8 参数 | |
| 第 7 参数 | <-栈顶 |
注: 仅适用于64位CPU。[^32-bit-cpu]
[^32-bit-cpu]: 在32位x86架构包括x86-64 CPU运行32位代码的“兼容模式”不存在像64位System V ABI那样统一、跨平台的寄存器参数传递标准。在绝大多数32位程序的默认场景下Linux/Windows C/C++),所有参数通过栈传递,而非寄存器。
### 返回值
返回值一般使用```%rax```进行传递。
## Managing Local Data 管理其他变量
### Stack Frame 栈帧
一般情况下,由于诸多与函数相关的特点,我们会在栈上分配一些内存给局部变量。加上其他要分配的内存[^extra-mmr-allocate],这部分被称作“栈帧”。
| 函数特点 | 栈特点 |
| ---------- | ---------- |
| 返回后,函数内分配的局部变量被丢弃 | 更改```rsp```指针即可丢弃数据 |
| 函数内可能会调用其他函数 | 直接将返回地址压栈 |
| 单线程调用其他函数时,原函数停止继续运行 | 保存原函数 的帧指针,在其之上可直接建立新栈帧 |
[^extra-mmr-allocate]: 上一个函数的寄存器内容、Windows下的影子空间、返回地址等等内容。
### Recursion 递归
由于“栈帧”的特性,它很适合用来做递归。系统会为每一层递归单独分配内存空间,彼此之间不会因为相同的代码导致变量的地址也相同(使用```%rbp + 数```管理)。
- 寄存器```%rbp``` : **几乎不用的**[^rbp-barely-use-explanation]栈帧指针,指向当前栈帧的底部(较大地址)。通过```%rbp```加上一个偏移量,可以访问当前函数的局部变量。
[^rbp-barely-use-explanation]: 现代编译器(如 GCC 或 Clang默认采用帧指针省略Frame Pointer Omission。这是一种优化技术允许编译器不使用```%rbp```寄存器来管理栈帧,而是直接使用```%rsp```寄存器来访问局部变量和参数。这使得 %rbp 可以被用作第 7 个通用寄存器,从而略微提升代码的执行速度。仅仅在调试或必须使用帧指针[^rbp-used-situations]的特殊场景下,编译器才会将帧指针放入```%rbp```。
[^rbp-used-situations]: 例如如果一个函数使用了变长数组或是使用alloca()在栈上分配了内存,编译器将无法确定运行时栈帧到底有多大。这时候,%rbp就必须被保留 ---- 即使 %rsp 来回变动以腾出空间容纳巨大的数组,编译器也总能根据相对于 %rbp 的固定偏移量找到你的其他局部变量。
Q: 既然```%rbp```不是必须的,程序怎么知道如何释放空间呢?程序怎么知道栈帧的底部在哪里?
A: 在编译时,编译器会根据函数预计内存的使用情况来生成相应的代码。无论是否使用```%rbp```,编译器都会确保在函数返回时正确地调整```%rsp```寄存器,以释放栈帧占用的空间。仅有少数例外。[^rbp-optional-exception]
[^rbp-optional-exception]: 有时我们甚至不需要栈帧。在某些情况下例如叶子函数优化Leaf Function Optimization或帧指针省略Frame Pointer Omission, FPO优化编译器可能会选择不使用栈帧但这通常是基于对函数调用和内存使用的分析以确保不会导致错误的内存访问或栈溢出。“叶子”函数是指不调用任何其他函数的函数。由于它从不将控制权让渡给其他例程编译器通常可以将其所有局部变量和参数完全保留在寄存器中。如果编译器确定所有局部数据都能放入可用寄存器且无需将“被调用者保存”的寄存器压入栈中保存。这将移除用于“压入”和“弹出”栈帧的指令从而节省内存和执行时间。
### ret 指令
```ret```指令会直接使用```%rsp```寄存器的值作为返回地址,因此在使用```ret```之前,必须确保```%rsp```指向正确的返回地址。通常情况下,编译器会在函数结束前生成代码来调整```%rsp```,以确保它指向正确的位置。
## Register Saving Conversions 寄存器保存约定
当函数A调用函数B时
- A 被称作 **调用者**caller
- B 被称作 **被调用者**callee
显然通用寄存器只有16个我们不能保证函数B不会修改函数A正在使用的寄存器。因此我们有了两种约定
- **调用者保存约定**Caller-Save Convention: 调用者负责保存它需要保留的寄存器值。A假设B会修改这些寄存器A在调用函数B之前将它们压入栈中待函数B返回后A再将它们弹出。
- 遵循这个约定的寄存器被称作**调用者保存寄存器**Caller-Save Registers
- **被调用者保存约定**Callee-Save Convention: 被调用者负责保存它需要使用的寄存器值。A假设B在返回后寄存器值保持不变在函数B开始时B将它们压入栈中结束前再将它们弹出。
- 遵循这个约定的寄存器被称作**被调用者保存寄存器**Callee-Save Registers
### x86-64 Linux System V ABI
| 寄存器 | 约定类型 | 说明 |
| -------- | ---------- | ------ |
| ```%rax``` | Caller-Save | 返回值 |
| ```%rdi``` | Caller-Save | 传递参数 |
| ```%rsi``` | Caller-Save | 传递参数 |
| ```%rdx``` | Caller-Save | 传递参数 |
| ```%rcx``` | Caller-Save | 传递参数 |
| ```%r8``` | Caller-Save | 传递参数 |
| ```%r9``` | Caller-Save | 传递参数 |
| ```%r10``` | Caller-Save | 临时寄存器 |
| ```%r11``` | Caller-Save | 临时寄存器 |
| | | |
| **```%rbx```** | Callee-Save | 被调用者保存寄存器 |
| ```%r12``` | Callee-Save | 被调用者保存寄存器 |
| ```%r13``` | Callee-Save | 被调用者保存寄存器 |
| ```%r14``` | Callee-Save | 被调用者保存寄存器 |
| ```%rbp``` | Callee-Save[^rbp-linux-reg-saving] | 特殊[^rbp-linux-reg-saving] |
| ```%rsp``` | Callee-Save[^rsp-linux-reg-saving] | 特殊[^rsp-linux-reg-saving] |
[^rbp-linux-reg-saving]: 如果不使用栈帧,则```%rbp```可视作Callee-Save寄存器如果使用栈帧则```%rbp```被用作帧指针,编译器会自动处理它的保存和恢复。
[^rsp-linux-reg-saving]: ```%rsp```是栈指针它在函数调用过程中需要保持不变因此可以当作某种特殊的Callee-Save寄存器。编译器会确保在函数调用前后正确调整```%rsp```,以维护栈的完整性。(一般来说我们不会手动调整```%rsp``` -- 除非你知道你在做什么!)
## Illustration of Recursion 递归示例
递归并不是什么神奇的黑科技。C编译器不会对递归函数进行特殊处理(除非进行优化)。它只是按照正常的函数调用机制来处理递归函数。
```c
long pcount_recursive(unsigned long x){
if (x == 0)
{
return 0;
}
else
{
return (x & 1)
+ pcount_recursive(x >> 1);
}
}
```
这个函数被用作递归示例。它使用递归计算一个无符号长整数中二进制表示中1的个数。
```asm
pcount_recursive:
.LFB24:
testq %rdi, %rdi # if (x == 0)
jne .L9 # jump to .L9 if x != 0
movl $0, %eax # set return value to 0
ret # return
.L9: # else...
# (x & 1) ...
pushq %rbx # save %rbx on the stack (callee-save)
movq %rdi, %rbx # copy x to %rbx
andl $1, %ebx # compute x & 1, result in %rbx and discard high 32 bits
# + pcount_recursive(x >> 1) ...
shrq %rdi # compute x >> 1, result in %rdi
call pcount_recursive # recursive call
addq %rbx, %rax # add (x & 1) to the result of the recursive call
popq %rbx # restore %rbx from the stack
# return ...
ret # return
```
可以看到,在递归调用之前,函数将```%rbx```寄存器的值压入栈中,以保存它的值(因为```%rbx```是Callee-Save寄存器。在递归调用之后函数将```%rbx```的值弹出,以恢复它的值。这样,每次递归调用都会正确地保存和恢复```%rbx```寄存器的值,确保递归函数能够正确地执行。

279
CMU-CSAPP/L08 Data.md Normal file
View File

@@ -0,0 +1,279 @@
# 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相同由调用者负责保存和恢复。

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB