CSAPP(三)下——过程控制&数组分配&浮点 程序的机器级表示
本篇主要介绍机器层面对实现过程调用的支持。
过程
过程即编程语言中的函数、方法、子例程、处理器等。
当P调用Q时:
- 传递控制:在进入过程Q时,程序计数器必须设置成Q的起始地址,当从Q退出时,程序计数器必须设置成P中执行Q的下一条指令的地址
- 传递数据:P必须能够向Q提供一个或多个参数,Q必须能够向P提供一个返回值
- 分配和释放内存:开始时Q可能需要为局部变量分配空间,返回前必须释放这些空间
运行时栈
过程调用中,后被调用的代码总会先执行完毕,比如P调用Q,Q肯定在P执行完毕之前完毕,所以,这种后进先出的模式特别适合使用栈这种数据结构来进行管理。
大多数编程语言都是使用栈来管理过程调用的,如下图(注意x86-64的栈向低地址方向增长):
每一个方法调用都具有一个栈帧(Stack Frame)。我们也知道了,寄存器可以用来保存方法调用中的参数和局部变量,但是寄存器的数量有限,当没有足够的寄存器来保存方法中的数据时,它们就会被存在栈帧中。所以栈帧中的内容可能包含参数、局部变量。
当过程P调用过程Q时,P首先会将自己的返回地址(也就是调用Q后的下一条指令)压入栈的顶部(当前还在P的栈帧中),Q要返回时就会从这个返回地址开始恢复P的执行。之后,Q会扩展栈的边界,将自己的栈帧压入栈中。大多数栈帧是定长的,不过也有变长的。
目前为止,我们知道栈帧中可能具有参数,局部变量和返回地址,这是一个粗略的概括,现在对栈帧的细节进行介绍还为时尚早。
控制转移
call
指令用于完成一次过程调用,它会将call
指令的下一条指令的地址A压入栈中(也就是压返回地址),它接收目标过程的直接或间接起始地址,并且将PC计数器的值调节到这个地址上。ret
指令用于在栈中弹出返回地址A并将PC计数器的值调节到这个地址上。
看下面两个函数的objdump
输出,main
函数调用了multstore
函数,下面我们分析一下该方法调用:
0000000000001169 <main>:
116d: 53 push %rbx
116e: 48 83 ec 10 sub $0x10,%rsp
... omit some line ...
118f: e8 41 00 00 00 callq 11d5 <multstore>
1194: 48 8b 14 24 mov (%rsp),%rdx
... omit some line...
00000000000011d5 <multstore>:
11d5: f3 0f 1e fa endbr64
11d9: 53 push %rbx
11da: 48 89 d3 mov %rdx,%rbx
... omit some line ...
11e6: c3 retq
callq 11d5
,将下面一行代码的地址1194
压入main
栈帧的返回地址中,并将PC计数器的值设置成11d5
- 控制权交给
multstore
,创建它的栈帧 - 从
multstore
的第一行指令11d5
开始执行,执行到retq
时,该方法执行完毕 - 从栈中弹出
main
方法的返回地址,将程序计数器的值设置为返回地址1194
- 从
1194
开始执行
%rip
是pc寄存器,%rsp
是栈顶寄存器
下面是一个更为复杂的例子,call
和ret
指令和之前没什么区别,我们这次主要分析过程间的参数和返回值传递:
首先整个调用过程是main -> top -> leaf
,从用惯了高级语言的程序员的直观感受上来说,传递参数这一工作应该是callq
指令来完成的,但并不是,callq
指令只负责上面所述的极其简单的压入返回地址,调节PC寄存器这两个工作。
实际上,在整个调用链过程中,参数都被存在了寄存器%rdi
中,返回值被保存在了%rax
中。top
过程的第一行代码就是对寄存器%rdi
进行减5操作,这是因为main
过程在调用callq
前就先把它要传递给top
的参数保存在了%rdi
中。而在整个调用链的尾端——leaf
中,top
依然没做任何传参操作,甚至直接沿用了之前的寄存器%rdi
作为leaf
的参数,leaf
对它进行+2,并保存到了%rax
中,作为返回值。也可以看到,从那些callq
调用之后的指令都已经在操作%rax
了。
练习题3.32
标号 | PC | 指令 | %rdi | %rsi | %rax | %rsp | *%rsp | 描述 |
---|---|---|---|---|---|---|---|---|
M1 | 0x400560 | callq | 10 | —— | —— | 0x7ffffffe820 | —— | 调用first(10) |
F1 | 0x400548 | lea | 10 | —— | —— | 0x7fffffffe818 | 0x400565 | x+1 |
F2 | 0x40054c | sub | 10 | 11 | —— | 0x7fffffffe818 | 0x400565 | x-1 |
F3 | 0x400550 | callq | 9 | 11 | —— | 0x7ffffffe818 | 0x400565 | 调用last(x-1, x+1) |
L1 | 0x400540 | mov | 9 | 11 | —— | 0x7fffffffe810 | 0x400555 | 接收参数u |
L2 | 0x400543 | imul | 9 | 11 | 9 | 0x7ffffffe810 | 0x400555 | u*v |
L3 | 0x400547 | retq | 9 | 11 | 99 | 0x7ffffffe810 | 0x400555 | 返回到first |
F4 | 0x400555 | repz retq | 9 | 11 | 99 | 0x7ffffffe810 | 0x400565 | 返回到main |
M2 | 0x400565 | mov | 9 | 11 | 99 | 0x7ffffffe820 | —— | 恢复执行 |
数据传送
x86-64中有如下6个寄存器可以进行参数传递
如果P调用Q时向Q传递的参数多于6个,那么第06个参数可以在寄存器中分配,其余的参数7n必须由P从自己的栈上进行分配,其中参数7位于栈顶。过程Q可以通过寄存器和栈来访问P传递过来的参数。通过栈传递参数时,所有数据大小都必须向8的整数倍对齐。
练习题3.33
这里只介绍一种正确答案,即假设第三行的addq
是做*u += a
操作的,另一种答案是反过来。
u -> long *, a -> int, v -> char *, b -> short
,
- 首先确定
%rdx
一定是u
,%rcx
一定是v
,因为在后面的运算中,用来去值的括号明显说明了它们是指针类型。 - 然后,首先
movslq
首先将一个四字节转换成了八字节,然后再使用addq
与%rdx
中的值进行计算,所以(%rdx)
中的值一定是八字节的,u
的类型就是long *
,而a
的类型就是int
。 - 第二个运算中,
addb
说明了(%rcx)
中的值一定是单字节的,所以v
的类型是char *
- 第二个运算中的
%sil
并不能确定b
的类型,因为这有可能只是b
的最低字节,通过观察最后一个movl
,它将立即值6压入返回值寄存器%eax
中,也就是说sizeof(a) + sizeof(b) = 6
,sizeof(a) = 4
,那么b
的大小就是2,也就是说,b
的类型是short
栈上的局部存储
很多时候的方法调用可以只使用寄存器来完成方法局部变量的存储,不过有些时候必须将它们存储在内存中。
- 当寄存器不足以存放本地数据
- 对一个局部变量使用
&
取地址运算符,为了产生一个地址,必须将该变量保存到内存中 - 对于数组或者结构,必须通过引用被访问
在这些情况下,过程可以通过减小栈指针%rsp
(因为栈向下增长!!!!)来在栈上分配空间,分配的结果作为栈帧的一部分。
这一部分中,书上有两个示例,我觉得和之前的也差不多,我就没贴,因为这部分内容之前就介绍了
寄存器中的局部存储空间
嗨嗨嗨,之前就有这个疑问,因为寄存器是有限且被所有过程共享的,那么一个过程Q如果需要用到某个寄存器时,这个寄存器已经被调用链中的上一个过程P占用了咋办。
下面的描述中,P代表调用者,Q代表被调用者。
%rbx, %rbp, %r12~%r15
称为被调用者保存寄存器。调用者保存寄存器的隐含意思就是,调用者P需要这些寄存器中的值不被被调用者Q改变,所以,Q不可以改变这些寄存器中的值,一旦P要调用Q,并且希望保存当前寄存器中的一些值时,P就需要手动将这些值复制到调用者保存寄存器中。
而Q如何保证调用者保存寄存器中的值不被它改变呢?Q也有可能需要用到调用者保存寄存器啊,因为它也有可能希望保存一些值。
两种手段:
- Q不占用这些寄存器(它不需要保存)
- Q将P之前在寄存器中保存的值压入栈中,然后Q就可以使用这些寄存器了,当返回时Q需要将这些值从栈中弹出并恢复P的寄存器状态
所以被调用者保存寄存器并非不会被被调用者Q修改,而是对于P来说,Q已经做了足够的努力(比如将它要修改的寄存器值压入栈中并在退出时恢复)来保证过程调用返回时,P看到的就是它所保存时的样子。而对于P,它在使用被调用者保存寄存器时,也要做与Q同样的努力。
比如下面的代码:
long Q(long arg);
long P(long x, long y) {
long u = Q(y);
long v = Q(x);
return u + v;
}
会编译成这样:
# x in %rdi, y in %rsi
P:
pushq %rbp # P稍后要用到调用者保存寄存器%rbp,所以先将它压入栈中保存起来
pushq %rbx # 同上
subq $8, %rsp # 对齐栈顶指针
movq %rdi, %rbp # 移动x到%rbp中,以免过程Q更改x所在寄存器
movq %rsi, %rdi # 移动y到第一个参数
call Q@PLT # 调用Q
movq %rax, %rbx # 移动返回值%rax到%rbx,以免Q更改
movq %rbp, %rdi # 移动x到第一个参数
call Q@PLT # 调用Q
addq %rbx, %rax # 将结果和上一次调用Q的结果相加
addq $8, %rsp # 释放栈顶指针
popq %rbx # 善后工作,将这俩值弹出并恢复
popq %rbp
ret
练习题3.34
这题向我们解释了如果被调用者保存寄存器用完了但还有要保存的数据时咋办。
- a0~a5被存在被调用者保存寄存器中
- a6,a7被存储在栈上
- 因为被调用者保存寄存器个数有限,所以当无法使用时,只能退而求其次将它们保存到栈中。
练习题3.35
- 保存参数x
- 如下:
if (x == 0) return x; unsigned long nx = x >> 2; long rv = rfun(nx); return x + rv;
未完...