第3.1讲:程序的结构-简单的程序
可执行文件 & 程序的装入
-
.rwdata(读写数据段): 存放程序中的含初值常量。这些常量在程序运行可以修改。
-
零初始化数据段(.zidata/.bss - Block Started by Symbol):存放程序中的不含初值(初始化为0的)可修改常量。
-
堆段: 存放程序的堆,也即动态分配内存时内存的来源。
-
栈段: 存放程序的运行栈,以供过程调用时保存和恢复上下文。
可执行文件头
最后,在将控制权交给程序时,将PC指向.text段,将SP指向.stack段。
编译器、汇编器、链接器、调试器与解释器程序
工具链
外部符号引用
在一个目标文件中可能引用了其它目标文件的内容。由于编译 器一次生成一个目标文件,因此并不知道那些引用的内容的地 址在哪。况且,自身在内存中的位置也还没确定,因此所有对 符号的引用(全局变量访问、函数调用等等)的具体地址都只 能推迟。
这个时候需要链接器进行操作:链接器会先收集全部目标文件的符号,然后给每个符号分配地址。在地址确定后,反过来补全程序中对这些符号地址的引用。
- 直接回填法
- 间接地址法
调用者向被调用者传递一个或多个参数
参数的传递有三种方法:
(1)通过全局变量传递(需要在数据段声明)
(2)通过栈传递(记得设置SP和SS)
(3)通过寄存器传递(寄存器数量有限)
下面是利用栈传递的情况:
GPT解释:
这张图展示了两个 C 语言函数和它们对应的汇编代码,并且结合了栈帧的概念。我们分别解释一下左侧的
main
函数和右侧的subp
函数及它们的汇编指令。左侧代码:
main
函数及其汇编指令C 代码:
int main(void) { ... val = subp(); ... }
汇编指令解释:
MOV BP, SP:保存栈指针到基址指针
BP
。栈帧基址设置为当前栈顶指针(SP
)。在函数调用中,BP
通常用于引用局部变量和参数。SUB SP, 2:在栈上分配 2 个字节的空间,可能是给局部变量
val
分配的空间。CALL FAR PTR SUBP:调用远程子程序
subp()
。调用指令会将IP
和CS
(指令指针和代码段寄存器)压入栈中,然后跳转到subp
函数的地址。MOV AX, [BP-2]:从栈中取出
subp
函数返回的值,BP-2
指向栈中的返回值,将其加载到寄存器AX
。MOV VAL, AX:将
AX
中的值(subp
返回的值)存储到变量VAL
中(对应 C 代码中的val
)。MOV SP, BP:将栈指针恢复到基址指针
BP
的值,清理栈帧。右侧代码:
subp
函数及其汇编指令C 代码:
short subp(void) { ... return value; }
汇编指令解释:
MOV DX, VALUE:将
VALUE
的值(subp
函数要返回的值)移动到寄存器DX
。MOV [BP-2], DX:将寄存器
DX
中的值存储到栈中BP-2
的位置,这是给main
函数的返回值留出的栈空间。RETF:返回远程调用,将
IP
和CS
出栈并恢复程序的执行,返回到main
函数中。
栈帧分析:
- 在
main
函数调用subp
时,栈中会保存当前的指令指针 (IP
) 和代码段 (CS
),从而实现函数调用的跳转。subp
函数会将返回值放到栈中,main
函数再从栈中读取返回值并赋给val
。- 栈指针 (
SP
) 和基址指针 (BP
) 的配合用于管理函数调用时的参数传递和局部变量。总体上,这段代码展示了如何通过栈来处理函数调用和返回值。
补充知识:
MOV指令,能实现以下操作:
CPU内部寄存器之间数据的任意传送(除了码段寄存器CS和指令指针IP以外)。
立即数传送至CPU内部的通用寄存器组(即AX、BX、CX、DX、BP、SP、SI、DI),给这些寄存器赋初值。
CPU内部寄存器(除了CS和IP以外)与存储器(所有寻址方式)之间的数据传送,可以实现一个字节或一个字的传送。
能实现用立即数给存储单元赋初值。
在汇编语言中,bp和sp分别指代以下内容:
- sp(栈顶指针):指向栈顶地址,与SS相配合使用,用于访问栈中的数据。
- bp(基址指针):指向栈帧的底部,一般称之为栈底指针,用于定位物理地址
- BP叫做栈框指针,因为通过它就能找到与该过程相关的整个调用栈。调用栈包括参数,临时变量,还有返回时的IP。
栈的四种类型
递增栈的优势:便于判断栈是否溢出
Q:参数和返回值的传递都有多种方法。那么,调用一个函数时, 具体采取哪种方法,以及那种方法具体是怎样实现的?
A:被调用者和调用者都遵守的变量和返回值的传递规则。具体怎 样约定,是随心所欲的。但是,一旦这个标准确定下来,就必 须在整个项目中遵循,否则就会乱套。常见的8086(包括后续 的i686扩展)使用的传值约定大体如下。
Q:为什么绝大多数调用约定都把参数放在栈上?为什么不使用寄 存器传递所有的参数,或者使用变量传递所有的参数呢?
A:栈的大小比较大,因此传递的参数可以比较多,准确地讲数量 是不受限制的。此外,栈传递参数非常好理解,一般是把当前SP 赋给BP,然后将参数列表从右往左依次入栈就可以了。被调用 者通过BP指针就可以寻址各个参数,调用完成后要将SP指针恢 复到原状也只需要将BP再赋给SP,非常方便。之所以8086的BP 寄存器被指定隐含SS段寄存器,就是为了方便栈传参。
Q:使用栈传递参数太麻烦了,要保存栈框,还要恢复栈指针。那 么,在小型程序中,能否在数据段定义几个变量传递参数呢?
A:(概括一下)这样的话不能使用任何形式的递归调用
Q:直接传参法和间接地址法的优劣比较(课前小测)
A:两者都是对于程序中符号地址的引用进行补全(链接器的操作)
- 直接传参法:运行时速度快,但是不便于在运行时修改地址
- 间接地址法:运行时速度满(相当于多进行一次指针转换),但是便于修改地址
寄存器保护约定
-
调用者负责
-
被调用者负责:更容易做到权责一致,但是会产生不必要的PUSH和POP
-
混合制负责:部分寄存器由调用者负责保存,另一部分寄存器则由被调用者 负责保存。下例中,AX、BX由调用者负责保存,CX、DX则由被 调用者负责保存,且AX~DX中均含有调用者的有效数据。
- 优势:一方面有利于生成尽量少的PUSH和POP,减少代码量,并方便被 调用者内部决定保护哪些寄存器(若被调用者也不使用那个寄存器,则不管便可),另一方面也允许在每次调用时定制保护列表,触碰尽量少的寄存器。
- 一方面允许编译器进行高度复杂的优化,另一方面也方便人手写汇编。
- 优势:一方面有利于生成尽量少的PUSH和POP,减少代码量,并方便被 调用者内部决定保护哪些寄存器(若被调用者也不使用那个寄存器,则不管便可),另一方面也允许在每次调用时定制保护列表,触碰尽量少的寄存器。
栈框恢复约定:
- 调用者负责
- 优势:可以最少化栈框调整。如果一个过程被连续调用两次,或者在 循环中被反复调用,或者尾递归,那么也许调整一次SP就足够了, 已分配的栈框本身可以重新填值并复用。另外,栈框怎么调整 是调用者说了算,因此调用者可以更灵活地处理每一次调用。 这在那些**可变参数函数(如C语言printf)**中非常有用。
- 被调用者负责
- 混合制负责
调用者负责:
这段栈框恢复代码的作用是保存和恢复调用子程序时的栈帧。下面是对每行代码的逐步解释:
PUSH BP
:将基指针(BP)的值压入栈中。这样做的目的是保护当前栈框,以便在函数返回时能够恢复。
MOV BP, SP
:将当前栈指针(SP)的值赋给基指针(BP)。这一步建立了新的栈帧,使得接下来的局部变量和参数可以通过BP来访问。
PUSH VAR
:将局部变量(这里用VAR表示)压入栈中,通常用于为子程序传递参数或保存局部变量。
CALL SUBP
:调用名为SUBP的子程序。在调用时,返回地址会自动压入栈中,以便子程序执行完毕后能够返回到正确的位置。
MOV SP, BP
:在子程序执行完毕后,这一行将栈指针(SP)恢复到基指针(BP)的位置。这意味着局部变量和参数已经不再需要,可以将栈指针恢复到上一个栈框的状态。
POP BP
:从栈中弹出之前保存的BP值,恢复到原来的基指针。这一步是栈框恢复的最后一步,确保在函数返回后能够正确地访问调用者的栈框。总结来说,这段代码通过保存和恢复BP值以及SP值来管理栈帧,使得在调用子程序时能够正确处理局部变量和参数,并在返回时恢复到原来的栈状态。
被调用者负责:
这段代码展示了被调用者负责的栈框恢复约定,主要分为主函数和子程序两部分。以下是对代码的详细解释:
主函数部分
PUSH BP
:将当前基指针(BP)压入栈,以保护调用者的栈框。
MOV BP, SP
:将当前栈指针(SP)的值赋给基指针(BP),建立新的栈框。
PUSH VAR1
:将参数(VAR1)压入栈中,以便传递给子程序SUBP。
SUB SP, 2
:在栈中为局部变量分配空间,这里分配了2个字节。
CALL SUBP
:调用子程序SUBP,返回地址会被压入栈。
POP BP
:从栈中弹出之前保存的BP值,恢复调用者的栈框。子程序部分 (SUBP)
注释:指出被调用者需要弹出所有局部变量和参数。此处有两个参数和一个局部变量需要弹出。
MOV AX, [BP-2]
:通过基指针(BP)访问压入栈中的第一个参数(VAR1)。因为参数相对于BP的位置是负偏移。
MOV [BP-4], CX
:将寄存器CX的值存储到第二个局部变量的位置(BP-4)。这说明此子程序有一个局部变量。
RET 4
:从子程序返回,同时清理4个字节的栈空间,实际上是弹出之前传递的参数。此指令确保在返回时,栈指针恢复到调用子程序之前的状态。总结
这段代码体现了被调用者负责的栈框恢复约定:在子程序中,必须处理自己的局部变量和参数,并在返回时清理栈空间,以保证栈的完整性。主函数负责设置栈框和调用子程序,而子程序则负责自己的栈管理。