计算机基础

程序的机器级表示-过程

3.7 过程

一个过程调用包括将数据(以过程参数和返回追的形式)和控制从代码的一部分转移到另一部分。

3.7.1 栈帧结构

IA32程序用程序栈来支持过程调用。机器用栈来传递过程参数、返回信息、保存寄存器用于以后恢复,以及本地存储。为单个过程分配的那部分栈称为栈帧。

下图描绘了栈帧的通用结构。栈帧的最顶端以两个指针界定,寄存器%ebp为帧指针,而寄存器%esp为栈指针。当程序执行时,栈指针可以移动,因此大多数信息的访问都是相对于帧指针的。

image

假设过程P(调用者)调用过程Q(被调用者),则Q的参数放在P的栈帧中。另外,当P调用Q时,P中的返回地址被压入栈中,形成P的栈帧的末尾。返回地址就是当程序从Q返回时应该继续执行的地方。Q的栈帧从保存的帧指针的值(%ebp)开始,后面是保存的其他寄存器的值。

过程Q也用栈来保存其他不能放在寄存器中的局部变量。这样做的原因:

  • 没有足够多的寄存器存放所有的局部变量。
  • 有些局部变量是数组或结构,因此必须通过数组或结构引用来访问。
  • 要对局部变量使用地址符号‘&’,必须能为它生成一个地址。

另外,Q会用栈帧来存放它调用其他过程的参数。如上图所示,在背调的过程中,第一个参数放在相对于%ebp偏移量为8的位置处,剩下的参数(假设数据类型需要的字节数不超过4)存储在后续的4个字节块中,所以参数i就相对于%ebp偏移量为4+4i地方。较大的参数(比如结构和较大的数字格式)需要栈上更大的区域。

栈向地地址方向增长,而栈指针%esp指向栈顶元素。入栈指令pushl,出栈指令popl。将栈指针值减少可以分配数据空间,将栈指针增加可以释放空间。

3.7.2 转移控制

call 指令有一个目标,即指明被调用过程起始的指令地址。 clall指令的效果是将返回地址入栈,并跳转到被调用过程的起始处。返回地址是在程序中紧跟在call后面的那条指令的地址,这样当被调用过程返回时,执行会从此处继续。 ret指令从栈中弹出地址,并跳转这个位置。

3.7.3 寄存器使用惯例

寄存器组是唯一能被所有过程共享的资源。虽然在给定时刻只能有一个过程是活动的,但是必须保存当一个过程调用另一个过程时,被调用者不会覆盖调用者稍后会使用的寄存器的值。为此,IA32采用了一组统一的寄存器使用惯例,所有的过程都必须遵守,包括程序库中的过程。

根据惯例,寄存器%eax,%edx,%ecx被划分为调用者保存寄存器,当过程P调用过程Q时,Q可以覆盖这些寄存器,而不会破坏P所需要的数据。另一方面,%ebx,%esi,%edi被划分为被调用者保存寄存器。这意味着,Q必须在使用这些寄存器之前,先把它们保存到栈中,并在返回前恢复它们。 此外,根据这里描述的惯例,必须保存寄存器%ebp和%esp。

3.7.4 过程示例

// 被调函数
int swap_add(int *xp, int *yp){
    int x = *xp;
    int y = *yp;
    
    *xp = y;
    *yp = x;
    
    return x + y;
}
// 调用函数
int callere(){
    int arg1 = 534;
    int arg2 = 1057;
    int sum = swap_add(&arg1, &arg2);
    int diff = arg1 - arg2;
    
    return sum * diff;
}

栈帧结构图示: image

汇编代码:

caller:
    pushl %ebp          // 保存旧的%ebp到栈顶
    movl %esp, %ebp      // 将%ebp作为帧指针(此时%ebp,%esp都指向整个栈的栈顶)
    // 以上两步为过程起始通用操作
    
    subl %24, %esp       // 分配24字节的栈空间(栈向低地址扩展)
    movl $534, -4(%ebp)  // 存储数据 数1
    movl $1057, -8(%ebp) // 存储数据 数2
    leal -8(%ebp), %eax  // 将数2的地址放到%eax
    movl %eax, 4(%esp)   // 将%eax中的值放到栈顶的 4-8 字节空间中 (参数2)
    leal -4(%ebp), %eax  // 将数1的地址放到%eax
    movl %eax, (%esp)    // 将%eax的值放到栈顶的 0-4 字节空间中(参数1)
    call swap_add        // 调用过程

这段代码先保存了%ebp的一个副本,然后将%ebp设置为栈帧的开始位置。然后将栈指针减去24,从而在栈上分配了24字节空间。 将arg1,arg2分别初始化为534和1057,计算&arg2和&arg1的值并存储到栈上,形成swap_add的参数。将这些参数存储到相对于栈指针偏移量为0和+4的地方,留待稍后swap_add访问。然后调用swap_add。分配给你栈帧的24字节中,8个用于局部变量,8个用于向swap_add传递参数,还有8个未使用。

说明:GCC为什么分配从不使用的空间?

gcc坚持一个x86编程指导方针,也就是一个函数使用的所有栈空间必须是16字节的整数倍。包括保存了%ebp的4个字节和返回地址的4个字节,caller一共使用了32个字节。采用这个规则是为了保证访问数据的严格对齐。

实验:如下代码,GCC 64位环境下,调用者先会将两个参数先放到%edi和%esi中,被调过程然后再从这两个寄存器中取出来存入栈中。
// C代码
int sum(int x, int y){
	
	return x + y;
}

int main(){
	int x = 10;
	int y = 20;
	int s = sum(x, y);

	return 0;
}
// 64位汇编
_sum:                                   ## @sum

	pushq	%rbp
	movq	%rsp, %rbp
	
	movl	%edi, -4(%rbp)
	movl	%esi, -8(%rbp)
	movl	-4(%rbp), %esi
	addl	-8(%rbp), %esi ## 求和
	movl	%esi, %eax  ## 返回值放到%eax中
	
	popq	%rbp  ## 函数结束通用代码
	retq        ## 相当于popl %eip, 将返回地址放入%eip中,执行
_main:                                  ## @main
	pushq	%rbp
	movq	%rsp, %rbp
	
	subq	$16, %rsp ##分配16字节栈空间
	
	movl	$0, -4(%rbp)
	movl	$10, -8(%rbp)
	movl	$20, -12(%rbp)
	movl	-8(%rbp), %edi
	movl	-12(%rbp), %esi
	callq	_sum ## 会先将返回地址放入栈中
	xorl	%esi, %esi
	movl	%eax, -16(%rbp)  ## 取出返回值
	movl	%esi, %eax
	
	addq	$16, %rsp ## 释放16字节空间
	
	popq	%rbp
	retq
                                        ## -- End function

.subsections_via_symbols

过程分析: image

3.7.5 递归过程

上一节描述的栈和链接惯例使得过程可以递归地调用它们自身。因为每个调用在栈中都有它们的私有空间,多个未完成调用的局部变量不会相互影响。此外,栈的原则很自然地提供了适当的策略,当过程被调用时分配局部存储,当返回时释放存储。

关于作者

程序员,软件工程师,java, golang, rust, c, python,vue, Springboot, mybatis, mysql,elasticsearch, docker, maven, gcc, linux, ubuntu, centos, axum,llm, paddlepaddle, onlyoffice,minio,银河麒麟,中科方德,rpm