程序要想运行,首先需要由操作系统负责为其创建进程,并且在进程的虚拟地址空间中为其代码段和数据段建立映射。光有代码段和数据段还不够,进程在运行过程中,还有其动态环境,其中最重要的就是堆和栈。下图是 Linux 中进程的地址空间布局:
代码段存储程序文本,所以也叫文本段,指令指针中的指令就从这里取得,对应图中的 text
。该段是可被共享,比如在 Linux 中打开两个 Vim 编辑文本,那么一般来说这两个 Vim 共享代码段,但是数据段不同(这有点类似 C++ 中类的不同对象共享相同的成员函数)。代码段的特点是可读可执行不可写。
数据段用于存储数据,包括初始化的数据和未初始化的数据(BSS 段)两部分,对应图中的 data
和 bss
。data
一般存放静态非零数据和全局非零数据;bss
(Block Started by Symbol)段主要存放未初始化的静态数据和全局数据。数据段的特点是可读可写不可执行。
堆用于动态分配内存,malloc 等函数分配的内存就在这个区域里。堆的特点是可读可写可执行。
execve(2)
负责为进程的代码段和数据段建立映射,而真正地将代码段和数据段的内容读进内存由系统的缺页异常处理程序按需完成。另外,execve(2)
还会将 bss
段清零,这就是未赋初值的全局变量和静态变量的初值为零的原因。
进程的用户空间的最高位置用于存放程序运行时的命令行参数和环境变量,在这段地址的下方和 bss
段的上方还有一个很大的“空洞”,作为进程动态运行环境的栈(stack)和堆(heap)就栖身其中,其中栈向下生长,堆向上生长。
栈中存放的是与每次函数调用对应的桢(Frame,也叫活动记录)。当函数调用发生时,新的桢被压入栈;当函数返回时,相应的桢从栈中弹出。典型的桢结构如下图所示:
桢的顶部为函数的实参,下面是函数的返回地址以及前一个桢的基址指针,最下面是分配给函数的局部变量使用的空间。桢通常有两个指针,其中一个为基址指针,另外一个为栈顶指针,前者所指向的位置是固定的,而后者所指向的位置在函数运行过程中可变。因此,在函数中访问实参和局部变量时,都以基址指针为基址,再加上偏移。从上图可以看出,实参的偏移为正,局部变量的偏移为负。
下面的示例是简单的 C 程序,以及编译它生成的汇编程序:
example.c:
int function(int a, int b, int c)
{
char buffer[14];
int sum;
sum = a + b + c;
return sum;
}
void main()
{
int i;
i = function(1,2,3);
}
使用如下命令生成汇编代码:
gcc -S example.c -o example.s
example.s:
.file "example.c"
.text
.globl function
.type function, @function
function:
.LFB0:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
movl %edi, -36(%rbp)
movl %esi, -40(%rbp)
movl %edx, -44(%rbp)
movl -40(%rbp), %eax
movl -36(%rbp), %edx
addl %eax, %edx
movl -44(%rbp), %eax
addl %edx, %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE0:
.size function, .-function
.globl main
.type main, @function
main:
.LFB1:
.cfi_startproc
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
movq %rsp, %rbp
.cfi_def_cfa_register 6
subq $16, %rsp
movl $3, %edx
movl $2, %esi
movl $1, %edi
call function
movl %eax, -4(%rbp)
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
.LFE1:
.size main, .-main
.ident "GCC: (GNU) 4.8.5 20150623 (Red Hat 4.8.5-44)"
.section .note.GNU-stack,"",@progbits
在解释汇编代码之前,先简单介绍这段代码用到的 x86-64 寄存器:
%rbp
(基址指针寄存器):用于保存旧的基址指针,以及建立函数栈帧。它指向当前函数栈帧的基址
%rsp
(栈指针寄存器):指向当前栈顶的地址。在函数调用过程中,用于管理栈上的数据
%eax
(累加器寄存器):通用寄存器,在算术和逻辑操作中作为临时寄存器或存储结果
%edi
、%esi
、%edx
:通用寄存器,通常用于函数参数的传递和临时存储
下面开始对这段汇编代码进行解释。
.file "example.c"
表示源代码文件为 "example.c"。
.text
表示接下来的指令属于代码段。
.globl function
.type function, @function
定义函数 function
,将其标记为全局可见,并且指定其类型为函数。
function:
.LFB0:
函数 function
的起始标签。
.cfi_startproc
开始一个过程的调试信息。.cfi
开头的指令是调试指令,用于生成调试信息,下面不再介绍。
xxxxxxxxxx
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset 6, -16
将 %rbp
寄存器的值推入栈中,保存旧的基址指针。
使用 pushq
指令时,栈指针将自动减小,以便为新数据腾出空间。可以通过 popq
指令弹出被压入栈中的数据。
xxxxxxxxxx
movq %rsp, %rbp
.cfi_def_cfa_register 6
将当前的栈指针 %rsp
的值复制给 %rbp
,建立新的帧。
xxxxxxxxxx
movl %edi, -36(%rbp)
movl %esi, -40(%rbp)
movl %edx, -44(%rbp)
将 %edi
、%esi
和 %edx
寄存器中的值(也就是函数的参数)分别保存在相对于 %rbp
的位置上,即在帧中分配内存。
至此,形成的帧如下所示:
xxxxxxxxxx
movl -40(%rbp), %eax
movl -36(%rbp), %edx
addl %eax, %edx
movl -44(%rbp), %eax
addl %edx, %eax
movl %eax, -4(%rbp)
movl -4(%rbp), %eax
将保存在帧中的参数相加,最终将结果保存在 %eax
寄存器中。
xxxxxxxxxx
popq %rbp
.cfi_def_cfa 7, 8
ret
.cfi_endproc
popq %rbp
从栈中弹出 8 个字节(64 位),将这些字节的值加载到 %rbp
寄存器中,并且将栈指针 %rsp
的位置相应地增加 8。
此时 %rbp
保存调用方的帧的基址指针。
ret
指令从栈中弹出 call
指令压入栈中的返回地址。存储到指令指针寄存器(Instruction Pointer)中,通常是 %rip
(x86-64 架构)或 %eip
(x86 架构)寄存器。在 ret
指令执行后,栈指针 %rsp
被移动到返回地址下面的位置,以恢复调用函数时的栈状态。
至此,调用 function 的帧被完全销毁,函数调用的结果被保存在 %eax
寄存器中。
xxxxxxxxxx
.LFE0:
.size function, .-function
.size
指令的语法是:
xxxxxxxxxx
.size <symbol>, <expression>
其中 <symbol>
是函数或符号的名称,<expression>
是计算大小的表达式。.-function
表示当前位置与函数 function 起始位置之间的距离。
函数 main 与函数 function 类似,下面只介绍差异的部分。
xxxxxxxxxx
subq $16, %rsp
在栈上为局部变量和临时数据分配空间,这里为它们分配 16 字节的空间。subq
指令的执行过程如下:
获取立即数值,即将要分配的字节数
将 %rsp
的当前值减去立即数值,得到新的栈顶地址
将新的栈顶地址存储回 %rsp
,以更新栈指针的位置
xxxxxxxxxx
movl $3, %edx
movl $2, %esi
movl $1, %edi
将立即数分别赋值给寄存器 %edx
、%esi
和 %edi
,用于参数传递。
xxxxxxxxxx
call function
call
指令的执行过程如下:
将 call
指令的下一条指令的地址(即返回地址)压入栈中
将栈指针寄存器(如 %rsp
)的当前值减去返回地址的大小,得到新的栈顶地址
将返回地址存储到新的栈顶地址
计算目标地址,目标地址可以是函数的入口地址,也可以是标签的地址
跳转到目标地址:
将目标地址加载到指令指针寄存器(比如 %rip
)中
至此,形成的帧如下图所示:
xxxxxxxxxx
movl %eax, -4(%rbp)
将函数的返回值从寄存器 %eax
移动到栈上的位置 -4(%rbp)
,用于存储返回值。
xxxxxxxxxx
leave
.cfi_def_cfa 7, 8
ret
.cfi_endproc
leave
指令的等效指令序列为:
xxxxxxxxxx
movq %rbp, %rsp
popq %rbp
movq %rbp, %rsp
完成局部变量的清理。popq %rbp
和 ret
完成函数 main 的退出和帧的恢复。