计算机系统基础_程序的机器级表示

机器级代码

在程序编译过程中,编译器会把抽象的高级代码转化为处理器执行的非常基本的指令,而汇编代码非常接近于机器代码,与机器代码的二进制格式相比,,汇编代码的主要特点是它用可读性更好的文本格式表示。

并且通过汇编代码,一些对于程序员隐藏的处理器状态都是可见的:

  • 程序计数器-(通常称为 “PC”, 在 x86-64 中用%rip 表示)给出将要执行的下一条指令在内存中的地址。
  • 整数寄存器文件包含 16个命名的位置,分别存储64位的值。这些寄存器可以存储地址或整数数据。有的寄存器被用来记录某些重要的程序状态,而其他的寄存器用来保存临时数据,例如过程的参数和局部变量,以及函数的返回值。
  • 条件码寄存器- 保存着最近执行的算术或逻辑指令的状态信息。它们用来实现控制或数据流中的条件变化,比如说用来实现 if和 while语句。
  • 一组向量寄存器可以存放一个或多个整数或浮点数值。

代码示例

假设我们写了一个c语言代码文件mstroe.c:

1
2
3
4
5
long mult2(long, long); 
void multstore(long x, long y, long *dest) {
long t = mult2(x, y);
*dest = t;
}

该函数的汇编代码如下:(这段代码中已经除去了所有关于局部变星名或数据类型的信息)
1730080451312

如果我们将这个函数的可执行程序反汇编,就可得到如下代码:

1730080743726

Bytes这一行为对应汇编代码的机器代码的16进制表示,每一组有1~5个字节。每一组为一条指令,右边为等价的汇编代码。

这里我们需要注意4点:

  • x86-64 的指令长度从 1 到 15 个字节不等。常用的指令以及操作数较少的指令所需的字节数少,而那些不太常用或操作数较多的指令所需字节数较多。
  • 设计指令格式的方式是,从某个给定位置开始,可以将字节唯一地解码成机器指令。例如,只有指令 pushq %rbx是以字节值 53 开头的。
  • 反汇编器只是基于机器代码文件中的字节序列来确定汇编代码。它不需要访问该程序的源代码或汇编代码。
  • 反汇编器使用的指令命名规则与 GCC 生成的汇编代码使用的有些细微的差别。 在我们的示例中,它省略了很多指令结尾的’q’。这些后缀是大小指示符,在大多数情况中可以省略。相反,反汇编器给 call 和 ret指令添加了'矿后缀,同样,省略这些后缀也没有问题。

格式注解

之前我们只截取了mstroe.s中的部分信息,现在我们看mstroe.s中的完整代码:

1730081153101

所有的以 “.” 开头的行的行都是指导汇编器和链接器工作的伪指令。我们可以忽略这些内容。

通常我们以一下方式展示汇编代码:

1730081369093

数据格式

不同数据类型在汇编语言的表示字母如下:

1730081473194

这里的字母即对应gcc生成的汇编语言中,一些语句最后的字母。如 pushq,即表示将一个大小为8字节的数据存入栈。

访问信息/寄存器

在x86-64的cpu中包含16个储存64位值的通用目的寄存器,这些寄存器用来存储整数数据和指针。每个寄存器可能有多个命名,这是在历史演化造成的。其中,虽然不同的命名指向的是同一个寄存器,但是指定的大小却不同。分别对应b,w,l,q的大小

1730081749502

其中最特别的是栈指针%rsp, 用来指明运行时栈的结束位置。

每个寄存器存储的内容都有规定,并非随意存储。

  • %rax用来存储函数结束时的返回值。
  • %rdi %rsi %rdx %rcx %r8 %r9 寄存器分别用来存储调用函数时传递的参数,从前往后依次存储。
  • %rsp 用来保存栈顶指针

被调用者寄存器

在函数调用过程中,寄存器的使用分为两类:被调用者保存的寄存器(callee-saved registers)和调用者保存的寄存器(caller-saved registers)。

被调用者寄存器是指在函数调用过程中,由被调用的函数负责保存和恢复的寄存器。这意味着在函数执行之前,这些寄存器的值会被保存,在函数执行完毕后,这些寄存器的值会被恢复,以确保调用函数的寄存器状态不受影响。

在 x86-64 架构中,以下寄存器通常被视为被调用者保存的寄存器:

  • %rbx
  • %rsp
  • %rbp
  • %r12
  • %r13
  • %r14
  • %r15

这些寄存器在函数调用过程结束后必须保持不变,因此被调用的函数需要在使用这些寄存器之前将它们的值保存到栈中,并在函数返回之前将它们的值恢复。

操作数指示符

  1. 立即数(immediate) 用来表示常数

    表示方法:$数字,例如:$520$0x1f,$后跟一个标准c表示法表示的整数。

  2. 寄存器(register) 它表示某个寄存器的内容, 16 个寄存器的低位 1 字节、 2 字节、 4 字节或 8 字节中的一个作为操作数,这些字节数分别对应于 8位、 16 位、 32 位或 64 位。
    表示方法:用 ra来表示任意寄存器a,用引用 R[ra],来表示它的值。

  3. 内存引用 它会根据计算出来的地址(通常称为有效地址)访问某个内存位置。
    表示方法:Mb[Addr],表示访问对存储在内存中从地址Addr 开始的b 个字节值的引用。

  4. Imm(rb,ri,s),有效地址为:Imm +R[rb] + R[ri] * s ,引用数组元素时,会用到这种通用形式。

1730082851974

数据传送指令

最简单形式的数据传送指令-MOV类

MOV类把数据从原位置复制到目的位置,不做任何变化。MOV 类由四条指令组成: movb、 movw、 movl 和movq

1732114682152

这些指令都执行同样的操作;主要区别在千它们操作的数据大小不同:分别是 1、2 、 4 和 8 字节。

1
movl	%eax, %ecx

其中前面的是原位置,后面的是目的位置,通常MOV 指令只会更新目的操作数指定的那些寄存器字节或内存位置。唯一的例外是movl 指令以寄存器作为目的时,它会把该寄存器的高位 4 字节设置为 0。

同时需要注意的是:

  • x86-64 加了一条限制,mov指令的两个操作数不能都指向地址。

1730083791256

MOVZ类

MOVZ类中的指令把目的中剩余的字节填充为 0。

每条指令的最后两个字母为大小指定符,第一个指定源的大小,第二个指定目的地的大小。

1730083485418

MOVS类

MOVS类中的指令通过符号扩展来填充,把源操作的最高位进行复制。

1730083645173

movs与movz的效果对比如下:

1730083859706

还需要注意的一点是:在x86-64的机器中,无论是movz还是movs,当目的地的寄存器大小为双字 l (4个字节),也会将寄存器的高4个字节清空为0。但是若目的寄存器的大小不为l ,则不会对于高位有影响,只改变扩张的位。

压入和弹出栈数据

在计算机中,栈的结构是倒过来的,栈底在地址最高的位置上,在栈顶删除和增加元素,这样,栈顶一直是整个栈中地址最小的元素。

1730084622188

pushq 指令的功能是把数据压入到栈上,而 popq指令是弹出数据。这些指令都只有一个操作数–压入的数据源和弹出的数据目的。

1730084401466

我们将S压入栈,就是将栈顶的地址减去8,然后将S放在栈顶新的地址中。

同理出栈则先将栈顶的元素放到D中,再将栈顶的地址加8。

所以pushq %rbp 的行为等价千下面两条指令:

%rsp存储栈顶的地址-8,%rbp存放入栈的数据。

1
2
subq $8,%rsp   //给%rsp减去8
mov %rbp,(%rsp) // 将%rbp中的值移到 M(%rsp)中

指令popq %rax 等价千下面两条指令:

1
2
movq(%rsp),%rax // 将M(%rsp)的值移到%rax
addq $8,%rsp // 将%rsp8

算数和逻辑操作

通用:

在上述我们对MOV类中讲解了movb,movw,movl,movq,针对四种大小的指令,操作数据大小分别为:字节,字,双字,四字,事实上,以下给出所有指令类都有这四种针对不同数据大小的指令。

这些操作被我们分为四组:

  1. 加载有效地址
  2. 一元操作(一个操作数)
  3. 二元操作(两个操作数)
  4. 移位

1730206042135

加载有效地址

加栽有效地址(load effective address), 指令leaq

leaq指令的作用与mov类似,将有效地址写入到目的操作数内,这里写入的是一个地址,而非地址中的数据。

但是leaq有时也表示加法:leaq 7(%rdx,%rdx,4) ,%rax 表示%rax = 5*%rdx+7。但是目的操作数必须是一个寄存器

一元二元操作

第二组中的操作都是一元操作,只有一个操作数,它即使源操作数,也是目的操作数。它的操作数可以是一个寄存器,也可以是一个内存位置,

第三组的中的操作都是二元操作,不过第二个操作数,同时是源操作数,也是目的操作数,操作完的数据会储存在第二个操作数中。每个操作可理解成c语言中,D+=S。

第一个操作数可以是立即数,寄存器,内存地址;第二个操作数可以是寄存器和内存地址。但当操作数为内存地址时,cpu必须将内存中的值取出来,再写回地址,即操作的内容时地址中的内容,而非地址本身。

移位操作

最后一组是移位操作,先给出移位量,然后第二项给出的是要移位的数。

位移量可以是一个立即数,或者放在单字节寄存器%cl中。这个指令只允许把%cl寄存器当作操作数。

同时需要注意的是,在进行位移时,位移量是由%cl的低位决定的,如对于salb指令,b表示一个字节,有8个二进制位,最多位移7位,所以只会看%cl的低三位的值。从中我们可以得到公式:取的%cl的低m位数据,则位移变量的二进制位为2^m。即2^m = w。

特殊的算数操作

1730207609188

全位乘法

这些指令可以对运算进行扩位,把两个寄存器拼起来好像一个寄存器使用,实现对于运算的扩位。

在两个64位数相乘时,若只截取低64位的数据,则他们的位级表现是一样的,但是x86-64指令集还提供了两种单操作数指令,用来计算完整的128位的乘积。

mulq用来进行无符号数乘法,imulq用来进行补码乘法。这两个操作都要求另一个数据存储在寄存器 %rax中,另一个作为操作数给出。最后将高位和低位分别存储在 %rdx%rax中,

除法

idivq 指令将寄存器 %rdx(高64位)%rax(低64位) 中的数据作为被除数,而除数作为操作数给出。

最后,操作会将商存储在 %rax中,将余数存储在 %rdx

控制

本节讲述实现条件操作的两种方式,然后描述表达训话和switch语句的方法

条件码

除了整数寄存器,CPU中还有单个位的条件吗寄存器,他们用来描述最近的算数或逻辑操作的属性。

  • CF (Carry Flag): 进位标志。最近的操作使最高位产生了进位或借位(即被减数是否小于减数如:5-10,会产生借位)。可用来检查无符号操作的溢出。(用于检测二进制位是否产生了溢出)
  • ZF (Zero Flag): 零标志。最近的操作得出的结果为 0。
  • SF (Sign Flag): 符号标志。最近的操作得到的结果为负数。
  • OF (Overflow Flag): 溢出标志。最近的操作导致一个补码溢出正溢出或负溢出。(检测运算时的有符号数是否产生了溢出)

除了leaq指令不会设置条件码外,其他操作均会设置条件码。来看几个例子

  • XOR(异或)会设置OF和 CF为0
  • 移位操作会设置CF为最后一个被移出的位,而OF设置为0
  • INC(加一)和DEC(减一)会设置OF和ZF,但不会改变CF。

接下来我们再来认识两种新的操作,这些操作只会改变条件码,而不会改变任何地址和寄存器中的值。

1730254193330

CMP指令根据两个操作数的差(S2-S1)来设置条件码,然后根据各个条件码的状态,就可以确定S1和S2的大小关系。

TEST指令根据(S1&S2)的值来设立条件码的值。

testq %rax ,%rax 用来检查 %rax的值是正数、零还是负数。

  • 若是负数: SF会被设置为1,ZF会被设置为0,其他条件码不变。
  • 若是正数:SF会被设置为0,ZF条件码被设置为0,其他条件码不变。
  • 若是0:SF = 0,ZF = 1,其他条件码不变。

访问条件码

条件码的使用方式:

  1. 可以根据条件码的某种组合,将一个字节设置为0或者 1
  2. 可以条件跳转到程序的某个其他的部分
  3. 可以有条件地传送数据

对于用法1,我们需要通过指令SET来完成。

set指令

SET 指令是一组条件设置指令,用于根据条件码(标志位)的状态将一个字节寄存器或内存位置设置为0或1。

需要注意的是,对于set指令的后缀,表达的不是操作数大小而是条件。同时,执行set指令会对目的的高位清零

1730255715730

为了方便记忆,这里我们结合英语单词解释。

  • e 为 equal 表示相等
  • n 为not 表示否定
  • s 为sign 表示负号
  • g 为greater 表示有符号数大于
  • l 为lower 表示有符号数小于
  • a 为after 表示无符号数更后面,即大于
  • b 为before 表示无符号数更前面,即小于

set指令常用在其他运算的后面,根据上一操作设置的条件码来进行操作。

1730255910836

跳转指令

汇编语言中的 JUMP 指令(跳转指令)用于改变程序的执行流。根据条件码(标志位)的状态,跳转指令可以分为无条件跳转和条件跳转两大类。

直接跳转

直接跳转指令的目标地址是明确指定的,通常是一个标签或一个固定的地址。直接跳转可以是无条件的,也可以是条件的:

1
jmp target_label

间接跳转

间接跳转指令的目标地址是通过寄存器或内存位置间接指定的。我们通常在操作指示符前加一个 * 来表示是间接跳转

1
jmp *%rax  ; 跳转到 %rax 寄存器中存储的地址
1
jmp *(%rax)  ; 跳转到 %rax 寄存器指向的内存地址中存储的地址
1
jmp *.L4(,%rsi,8)  ; 跳转到 .L4 基地址加上 %rsi 寄存器值乘以 8 的偏移量处存储的地址

在汇编语言中,寄存器加括号(如 (%rax))表示间接寻址,即使用寄存器中的值作为内存地址来访问数据。

无条件跳转

1
jmp target_label

会让程序直接跳转到target_label 地址的命令

1730256187734

条件跳转

类似set指令,条件跳转指令会根据上一条语句的结果设置的条件码来执行跳转。

1730256278637

除了jmp 指令是无条件跳转外,其他指令都是有条件的,他们根据条件码的值或者条件码的组合来判断是否进行跳转。

表中的符号大于小于是对于前一句的S2-S1的计算结果。

举例:

1
2
cmp    $0x5,%eax // %eax 和 5 比较
jg 401718 <read_six_numbers+0x3a> // 如果 %eax 大于 5 则跳转到 401718

跳转指令的编码

跳转指令有多种不同的编码方式,但是常用的编码方式有

  • (PC-relative)它们会将目标指令的地址与紧跟在跳转指令后面那条指令的地址之间的差作为编码。
  • “绝对地址”编码:用四个字节直接给出指定目标。

PC-relative

对于如下汇编语言的跳转,看出来跳转到哪里是显而易见的。

1730256756418

但是对于相对地址差的编码方式,很难一眼看出跳转到哪里:

1730256837184

对于第一处跳转地址机器编码,03 表示要跳转地址的相对地址,在第七章链接中,会详细讲解如何计算相对位置,这里我们只先简单了解以下会跳转到哪里:
当执行jmp时,程序计数器PC指向下一条将要指向的指令的地址,此处应为5,为了执行jmp语句,会进行下面两个操作:

  • 将PC压入栈
  • $PC = PC + 偏移量$
    这里PC = 5 + 3 = 8,所以地址8就是下一条计算机将要执行的语句,也就是我们跳转的地址。

所以我们得到后面跳转的地址:03+05 = 08,即第四行第一地址。

loop+0x 后面的16进制数表示要跳转的地址编号,而地址从第0行第1个数为下标0的地址编号。

绝对地址

1730257544083

绝对地址跳转只需要直接找到对应行的地址即可。

条件控制来实现条件分支

假设我们要实现如下的条件语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
long lt_cnt = O; 
long ge_cnt = O;
long absdiff_se(long x, long y)
{
long result;
if (x < y) {
lt_cnt++;
result= y - x;
}
else {
ge_cnt++;
result= x - y;
}
return result;
}

在c语言代码中,我们可以先用goto语句替换其中的else 部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
long lt_cnt = O; 
long ge_cnt = O;
long absdiff_se(long x, long y)
{
long result;
if (x >= y) {
goto x_ge_y;
}
lt_cnt++;
result= y - x;
return result;
x_ge_y:
ge_cnt++;
result= x - y;
return result;
}

goto 语言类似于汇编语言的跳转,这样我们可以更好的翻译成汇编语言:

1730258095396

可见汇编代码对于分支实现的方式是,对于if和else分别生成对应的代码块,用jump条件跳转,确定要执行的代码块。

用条件传送来实现分支

对于下面c语言代码:

1
2
3
4
5
6
7
8
9
long absdiff(long x, long y) 
{
long result;
if (x < y)
result= y - x;
else
result= x - y;
return result;
}

我们也可以通过同时计算好 x-y和y-x的值,然后根据条件来选择最后的返回值:

1
2
3
4
5
6
7
8
9
10
11
long absdiff(long x, long y) 
{
long rval = y-x;
long eval = x-y;
long ntest = x>=y;
if (ntest)
{
rval = eval;
}
return rval;
}

对应汇编语言如下:

1730258662461

条件传送进行了更多的运算,但其实,对于计算机来说条件控制其实更加高效。

条件传送

1730259343515

条件传送的功能和mov一样,只不过需要根据条件码的值来确定是否需要将源操作数的值写到目的操作数中。

循环

循环一般通过条件和跳转来实现重复执行代码主体中的内容一直到条件不满足。

do_while循环

1
2
3
4
5
6
7
8
9
long fact_do(long n) 
{
long result = 1;
do {
result*= n;
n = n-1;
} while (n > 1) ;
return result;
}

我们同样用if和goto语句改写:

1
2
3
4
5
6
7
8
9
long fact_do(long n) 
{
long result = 1;
loop:
result = result * n;
n = n - 1;
if (n > 1) goto loop;
return result;
}

等效汇编代码如下:

1730262667019

while循环

对于while循环,我们需要先检测条件,再执行循环主体。

1
2
3
4
5
6
7
8
9
long fact_while(long n) 
{
long result= 1;
while (n > 1) {
result*= n;
n = n-1;
}
return result;
}

则我们先直接跳转到检测条件的部分:

1
2
3
4
5
6
7
8
9
10
11
long fact_while(long n) 
{
long result= 1;
goto test;
loop:
result*= n;
n = n-1;
test:
if (n > 1) goto loop;
return result;
}

对应汇编代码:

1730262951203

或者我们可以在进入循环前先提前检测一次条件,若不满足则直接跳过循环。

1
2
3
4
5
6
7
8
9
10
11
long fact_while(long n) 
{
long result= 1;
if (n<=1) goto done;
loop:
result*= n;
n = n-1;
if (n > 1) goto loop;
done:
return result;
}

汇编代码:

1730263192072

for循环

对于for循环:

1
2
3
4
for(int i = 0;i<n;i++)
{
//code
}

可以改写成对应while循环:

1
2
3
4
5
6
int i = 0;
while(i<n)
{
//code
i++;
}

则对于以下代码:

1
2
3
4
5
6
7
8
long fact_for_-while(long n) 
{
long result = 1;
for(int i = 2; i <= n; i++){
result*= i;
}
return result;
}

我们可以改写成:

1
2
3
4
5
6
7
8
9
10
long fact_for_-while(long n) 
{
long i = 2;
long result = 1;
while (i <= n) {
result*= i;
i++;
}
return result;
}

进一步改写成:

1
2
3
4
5
6
7
8
9
10
11
12
13
long fact_for_-while(long n) 
{
long i = 2;
long result = 1;
goto test;
loop:
result*= i;
i++;
test:
if (i <= n)
goto loop;
return result;
}

则得到汇编代码:

1730263844856

switch语句

对于switch语句

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void switch_eg(long x, long n, long *dest) 
{
long val= x;
switch (n)
{
case 100:
val*= 13;
break;
case 102:
val+= 10;
case 103 :
val += 11;
break;
case 104:
case 106 :
val*= val;
break;
default:
val= 0;
}
*dest = val;
}

c语言在预处理时,会创建一个跳转表:将所有跳转地址存放在一个数组中。然后,创建一个新变量 index = n - 100,这样,我们就可以通过 jt[index]

来找到需要跳转到的代码块地址,具体如下:

1730264853427

对于case中没有的情况,如101,我们都存储default的地址。当index超过了数组的范围,则让他直接跳转到default。

汇编代码如下:

1730265010439

这里我们对其中一个跳转指令 jmp *.L4(,%rsi,8) 做详细解释,以加强理解。

jmp *.L4(,%rsi,8) 是一种间接跳转指令,通常用于实现跳转表(jump table)或函数指针数组等间接跳转机制。让我们逐步解析这条指令的含义:

  1. jmp :这是无条件跳转指令,表示程序将跳转到指定的地址。
  2. * :星号表示间接跳转,即跳转到一个存储在内存中的地址,而不是直接跳转到一个固定的地址。
  3. .L4(,%rsi,8)
  • .L4 :这是一个标签,通常表示一个跳转表的基地址。
  • (,%rsi,8) :这是一个内存地址计算方式,表示从基地址 .L4 开始,偏移量为 %rsi 寄存器的值乘以 8。

具体含义

假设 .L4 是一个跳转表的基地址,%rsi 寄存器中存储的是一个索引值。jmp *.L4(,%rsi,8) 的含义是:

  1. 计算内存地址:.L4 + (%rsi * 8)
  2. 读取该内存地址处存储的值,这个值是一个目标地址。
  3. 跳转到这个目标地址。

过程

过程是软件中一种很重要的抽象。它提供了一种封装代码的方式,用一组指定的参数和一个可选的返回值实现了某种功能。

过程也被称为函数(Function)、子程序(Subroutine)或方法(Method),具体名称取决于编程语言和上下文。过程的主要特点是它们可以被重复调用,并且可以接收输入参数和返回结果。

运行时栈

在程序运行时,如果在函数P调用一个函数Q,那么函数P以及之前的数据都是被储存在栈中的,栈顶的空间新创建用于存储新调用的函数Q。当 Q运行时,它只需要为局部变量分配新的存储空间,或者设置到另一个过程的调用。同时,当函数Q返回时,任何它所分配的局部存储空间都可以被释放。程序的运行状态将会到P调用Q那一时刻的状态。

1731497768646

当新调用一个函数并且在栈中分配空间时,我们将其称之为栈帧,所以当前正在执行的过程的帧总是在栈顶。

在计算机系统中,栈帧(Stack Frame)是函数调用过程中在栈上分配的一块内存区域,用于存储函数的局部变量、参数、返回地址以及其他与函数调用相关的信息。

对于函数P调用函数Q时,会先将当函数Q运行结束时需要返回到的地址压入栈中,对于这个返回地址,我们将其视为P栈帧的一部分,同时如果需要传递的参数超过了6个,那么从第七个参数开始,也都将存在P的栈帧中。需要注意的一点是,如果一个函数的内容很少,甚至无需在内存中开辟空间,那么不能算作一个栈帧,只有在内存中的才算栈帧

对于图片中被保存的寄存器是什么,请看后续内容。

转移控制

在了解这一条之前我们需要先了解什么是程序计数器(PC)。

程序计数器(PC)。

程序计数器(Program Counter,简称 PC),也称为指令指针(Instruction Pointer,IP),是计算机系统中的一个寄存器,用于存储下一条将要执行指令的地址。程序计数器在指令执行过程中自动更新,以确保处理器能够顺序执行程序中的指令。

当函数P调用函数Q时,通过汇编指令 call 来实现,这一指令会实现两个功能

  1. 将call指令的下一条指令的地址压入栈中(返回地址)
  2. 将PC的值设置为Q函数的起始位置

并且由对应的指令ret来执行函数结束时的操作:

  1. 从栈中弹出返回地址
  2. 将PC设置为弹出的返回地址

1731499061121

数据传送

函数传参时,将按照顺序存储在以下6个寄存器中:

1731499253879

但是对于超过6个的参数,第7到n个参数将存在调用函数的栈帧中(P为调用函数,Q为被调用函数)。并且第七个参数应该处于栈顶。

栈上的局部存储

以下三种情况,局部数据必须存放在内存中,而非寄存器:

  1. 寄存器不足够存放所有的本地数据。
    当函数传参超过6个时,需要将多余的参数在调用函数前入栈(注意:参数入栈顺序与参数顺序相反)
    如a1,a2,a3,a4,a5,a6,a7,a8,一共八个参数,a1~ a6 将通关寄存器传递,a7, a8通过栈传递,且a8 比a7先入栈,这样a7就可以比a8先出栈。
  2. 对一个局部变量使用地址运算符'&',因此必须能够为它产生一个地址。
  3. 某些局部变量是数组或结构,因此必须能够通过数组或结构引用被访问到。

需要注意的是,在x86的计算机中,栈指针%rsp在进行加减时,都是以8的倍数来进行加减的。所以在画栈的局部结构图时,每一行的大小为8个字节。

1731499945564

寄存器中的局部储存空间

寄存器的类型被分为两种,一种是被调用者保存寄存器,一种是调用者保存寄存器。

  • 被调用者保存寄存器:%rbx ,%rbp,%r12~%r15
  • 调用者保存寄存器:除了上述寄存器和 %rsp以外的其他寄存器的

被调用者寄存器

被调用者寄存器要求被调用的函数负责保护和恢复这些寄存的值在被调用函数开始和结束时一样,如果被调用的函数需要使用这些寄存器,它必须在使用之前保存它们的值,并在返回之前恢复它们的值。

  • 被调用函数完全不使用该寄存器
  • 若要使用该寄存器,则先将该寄存器的值入栈,函数结束前再将寄存器的值出栈恢复到该寄存器
1
2
3
4
5
6
7
8
9
callee_function:
pushq %rbx ; 保存 %rbx 的值
pushq %r12 ; 保存 %r12 的值
movq $42, %rbx ; 使用 %rbx
movq $84, %r12 ; 使用 %r12
; 函数体
popq %r12 ; 恢复 %r12 的值
popq %rbx ; 恢复 %rbx 的值
ret ; 返回调用者

注意他们的入栈顺序与出栈顺序相反。

调用者保存寄存器

这些寄存器在被调用函数中可以被随便修改,并且不会被恢复,所以如果调用者函数希望这些寄存器的值能够在函数调用结束后保持不变,则需要自己保存这些寄存器的值。以便在函数调用结束后恢复他们。

1
2
3
4
5
6
7
caller_function:
movq $10, %r10 ; 将 10 存入 %r10
pushq %r10 ; 保存 %r10 的值
call callee_function ; 调用 callee_function
popq %r10 ; 恢复 %r10 的值
; 继续使用 %r10
ret

递归过程

栈规则提供了一种机制,每次函数调用都有它自己私有的状态信息存储空间。所以递归调用一个函数本身与调用其他函数是一样的。

数组的分配和访问

在机器代码中通常用指令mov来访问一个数组中的数据。movl (%rdx, %rcx, 4) , %eax

在这个指令中,%rdx存放数组的首地址,%rcx存放需要访问的下标,4表示数据类型的大小,最后将 arr[i]的值放到 %eax中。

通常用 lea指令来计算数组中某个数据的地址,leaq (%rdx,%rcx,4),%eax 找到的即为 &arr[i]。

具体可以看下表:

1731501536746

嵌套的数组

当我们声明一个二维数组

1731501595522

等价于下面的声明:

1731501618457

我们将一个长度为3的数组当成一个需要12个空间的类型,然后我们用这个类型创建一个长度为5的数组。

1731501743334

对于上述数组,汇编代码一般以如下方式获取arr[i][j]的值:

1731501974745

定长数组

对于定长数组的遍历,编译器一般如下优化:

1731502097013

变长数组

变长数组编译器一般如下优化:

1731502203338

异质的数据结构

结构

c语言的struct结构体的存储方式也类似于数组,所有的组成部分都存放在内存中一段连续的区域内。指向结构体的指针就是结构体第一个字节的地址。在访问结构体中不同的元素时,通过与头指针的偏移量来访问不同的元素。

我们来看一个例子:

1731922260693

对于如上定义的结构体,它在内存中的存储结构如下:

1731922305098

结构体的名字就是结构体的首地址,与首元素的地址是相等的,这点也与数组相似。

联合(union)

在C语言中,union(联合体)是一种数据结构,它允许你在同一个内存位置存储不同类型的数据。union中的所有成员共享同一块内存,因此一个 union变量的大小等于其最大成员的大小。你可以在不同时间存储不同类型的数据,但只能同时存储一个成员的数据。

我们将其存储与结构体进行对比:

1731923404830

1731923428870

对于union来说,只是在一块空间上可以存储的多种类型,有点类似你可以在一块空间上存不同的内容,然后通过不同的强制类型转化来获得它正确的解读。

在教材p187 还介绍了一些union的内容,简单阅读觉得很有帮助,但是由于考试临近右不是考点,先不再整理在博客中,若有机会以后再补。

数据对齐

数据对齐要求对象存储的地址需要为某个K值的倍数,如果没有存满的话,下一个数据也会空出一定的空间以满足自己的地址为K值的倍数。

1731924064354

例如对于下面的结构体:

1731924155572

在地址中存储的方式应该为:

1731924172738

在机器级程序中将控制与数据结合起来

内存越界引用和缓冲区溢出

c语言对于数组的引用不进行任何的边界检查,而且我们知道,内存中的空间分配都是在内存栈中进行的,当内存中分配的空间不够,一直通过数组向栈的高地址方向进行修改,就会破环栈中原本存储的数据,这会让程序无法恢复到之前的状态。

这种常见的错误被称为——缓冲区溢出。

缓冲区溢出导致的网络攻击

首先,输入给程序一个字符串,这个字符串中包含一些可执行代码的字节编码,被称为攻击代码,同时还有一些字节会用一个指向攻击代码的指针覆盖返回地址。那么,执行ret后,程序就会跳转到攻击代码。

对抗缓冲区溢出攻击

栈随机化

效果:

让程序每次开始运行时,栈的位置都有所变化,因此许多机器运行相同的程序,栈地址都是不同的,使得网络攻击者无法容易的确定攻击代码地址的位置,无法轻易让返回地址指向攻击代码。

实现方法:

程序开始时,在栈上分配一段0~n字节之间的随机大小的空间,程序不使用这段空间,但是它会导致程序每次执行时后续的栈位置发生了变化。这里的n不宜过大也不宜过小。

破解方法:(空操作雪橇)

在攻击代码前插入很长的一段的nop(读作"no op",no opreation),执行这条指令会让程序计数器加一。这样,只要攻击者能够猜到这段序列中的某个地址,程序就会经过这个序列到达攻击代码。

如果我们建立一个2^ = 256字节的空操作雪橇(nop sled)那么只需要枚举2^15 = 32768个起始地址,就能破解2^23的随机化。

栈破坏检测

效果:

在缓冲区越界导致的操作造成有害影响前,尝试检测是否发生缓冲区溢出。

方法:

在栈帧中,任何局部缓冲区与栈状态之间储存一个特殊的**金丝雀**值,也称为:“哨兵值”,在每次程序运行时随机产生。在恢复寄存器状态和从函数之前,程序检查这个金丝雀值是否被改变,如果是,那么异常终止。

限制可执行代码区域

效果:

将程序的读和执行访问模式分开。让栈只可进行读写操作,却无法做为可执行代码的存放区域。

实现方法:

AMD引入'NX'(No-Execute,不执行)位。

支持变长栈帧

在函数调用malloc,alloca 等函数时,编译器无法预先确定为函数栈帧分配多少空间。因此我们需要使用变长栈帧。

为了管理变长栈帧,x86-64代码使用寄存器%rbp做为帧指针(frame pointer)/基指针(base pointer) 。

注意:在程序使用%rbp时,必须先将%rbp的值入栈。因为他是被调用者保存寄存器。并且函数在整个执行过程中,都一直指向那个时刻栈的位置。然后通过固定长度的局部变量(如 i )相对于%rbp的偏移量来引用他们。如图:

1731932231797

在变长栈帧函数的结尾:

1731932357402

用leave指令让栈指针恢复到它之前的值,即%rbp当前的值。同时让%rbp也恢复之前的值。

等价于下面两句:

1
2
mov %rbp,%rsp
popq %rbp

leave 指令拥有释放整个栈帧的效果。


计算机系统基础_程序的机器级表示
http://blog.ulna520.com/2024/10/27/计算机系统基础_程序的机器级表示_20241027_212753/
Veröffentlicht am
October 27, 2024
Urheberrechtshinweis