第9讲:调用约定与递归
本周核心:理解 C 和 Pascal 两种调用约定的区别,学会实现可变参数函数,掌握递归的汇编写法。
一、C 语言方式(cdecl)的参数传递特点
- 参数从右往左的顺序压入堆栈
- 参数由调用者负责清除
老师 1.asm 中的 C 约定演示(下面的代码是 Pascal 方式的,先看区别):
二、Pascal 语言方式的参数传递特点
- 参数从左往右的顺序压入堆栈
- 参数由被调用者负责清除(用
ret n)
code segment
assume cs:code
f:
push bp ; (4)
mov bp, sp ; (*) 这两条指令用于建立堆栈框架(stack frame)
mov ax, [bp+6] ; 参数1
sub ax, [bp+4] ; ax = ax - 参数2
pop bp ; (5)
ret 4 ; (6) CPU在执行本指令时做以下操作:
; ① pop ip
; ② sp = sp + 4
main:
mov ax, 5
push ax ; (1) 参数1
mov ax, 3
push ax ; (2) 参数2
call f ; (3)
back:
mov ah, 4Ch
mov al, 0
int 21h
code ends
end main以上程序在运行过程中,堆栈变化如下。设 ss=2000h, sp=1000h:
ss:0FF8 old bp (4) (*)bp
ss:0FFA back (3) (5)
ss:0FFC 3 (2)
ss:0FFE 5 (1)
ss:1000 (6) ←spPascal 方式 vs C 方式的核心区别:
| C 方式 | Pascal 方式 | |
|---|---|---|
| 参数压栈顺序 | 右 → 左 | 左 → 右 |
| 谁清理参数 | 调用者 (add sp, n) | 被调用者 (ret n) |
| 被调用者返回指令 | ret | ret n |
三、stdcall 方式的参数传递特点
- 参数从右往左的顺序压入堆栈(同 C)
- 参数由被调用者负责清除(同 Pascal,用
ret n)
即:C 的压栈顺序 + Pascal 的清理方式 = stdcall。Windows API 大量使用 stdcall。
四、C 语言的参数为什么要从右往左压栈?
目的是让参数个数不确定的函数(如 printf)能方便地访问参数 1。
int printf(char *format, ...);在 32 位编译器中,返回地址占 4 字节的指针。参数 1 是宽度为 4 字节的指针,参数 2 是 double(宽度为 8 字节),参数 3 是 int(宽度为 4 字节)。
printf("%f %d", 3.14, 1234);
参数1 参数2 参数3
4字节 8字节 4字节栈中的布局:
bp+8 → 参数1 (format 指针, 4字节)
bp+12 → 参数2 (3.14 double, 8字节)
bp+20 → 参数3 (1234 int, 4字节)由于参数 1 在固定偏移 bp+8,函数总是能先找到它。然后根据 format 字符串里的 %f、%d,推算出后续参数的位置和类型。
参数3 的地址 = 参数1 的地址 + 参数1 的长度 + 参数2 的长度指针的偏移计算公式:
设 p 是一个指针,则 p+i 的值的计算公式为:
(unsigned int)p + i * sizeof(*p)例如 p 的定义为 short int *p = (short int *)1000,那么 p+3 的值 = 1000 + 3*2 = 1006。
五、可变参数函数的完整实现
5.1 C 语言版本
老师 1.asm 中给的 C 语言参考实现:
#include <stdio.h>
double f(char *s, ...)
{
double y=0;
char *p;
p = (char *)&s + sizeof(s); /* p=参数2的地址 */
while(*s)
{
if(*s == 'f')
{
y += *(double *)p;
p += sizeof(double);
}
else if(*s == 'd')
{
y += *(int *)p;
p += sizeof(int);
}
s++;
}
return y;
}
int main()
{
double y;
y = f("fd", 3.14, 1234);
printf("%f\n", y);
}关键技巧:p = (char *)&s + sizeof(s) 把指针 s 的地址加上 4 字节(指针自身大小),恰好指向参数 2。
5.2 汇编版本
这是一个简化的实现:格式字符串中用 'd' 表示 short int(2 字节),'l' 表示 long int(4 字节)。调用 f("dl", 8765h, 12345678h),返回两者之和。
.386
data segment use16
s db "dl",0 ; d:short int, l:long int
result dd 0
data ends
code segment use16
assume cs:code, ds:data
f:
push bp
mov bp, sp
mov edx, 0 ; y = 0
lea si, [bp+6] ; si = bp + 6 → 参数2 的地址
;等效写法: mov si, bp \ add si, 6
mov bx, [bp+4] ; bx = 参数1 = offset s
again:
cmp byte ptr ds:[bx], 0
je done
cmp byte ptr ds:[bx], 'l'
je is_long
cmp byte ptr ds:[bx], 'd'
je is_int
jmp next
is_long:
add edx, dword ptr ss:[si] ; y += *(long int *)参数2
add si, 4 ; 指针后移 4 字节
jmp next
is_int:
movzx eax, word ptr ss:[si] ; 零扩展: 16位→32位
add edx, eax
add si, 2 ; 指针后移 2 字节
next:
add bx, 1
jmp again
done:
mov eax, edx ; eax 是返回值
pop bp
ret
main:
mov ax, data
mov ds, ax
;
mov eax, 12345678h
push eax ; 参数3: long int
mov ax, 8765h
push ax ; 参数2: short int
mov ax, offset s
push ax ; 参数1: 格式字符串指针
call f
back:
add sp, 8 ; 清理参数 (2+2+4=8)
mov [result], eax
mov ah, 4Ch
mov al, 0
int 21h
code ends
end main
movzx eax, word ptr ss:[si]:movzx = Move with Zero eXtend。把 16 位值零扩展到 32 位。因为edx是 32 位累加器,读入的 16 位 short int 需要先扩展到 32 位才能正确相加。
六、递归
6.1 C 语言版本
int f(int n)
{
if(n == 1)
return 1;
else
return n+f(n-1);
}
main()
{
int y;
y = f(3);
}f(3) = 3 + f(2) = 3 + 2 + f(1) = 3 + 2 + 1 = 6。
6.2 汇编版本(老师 1.asm,含完整栈迹)
code segment
assume cs:code
f:
push bp ; (3)(6)(9) 保存调用者的 bp
mov bp, sp
mov ax, [bp+4]
cmp ax, 1
je done ; n == 1 → 递归出口
sub ax, 1
push ax ; (4)(7)
call f ; (5)(8) 递归调用 f(n-1)
here:
add sp, 2 ; (12)(15) 清理参数
add ax, [bp+4] ; ax = f(n-1) + n
done:
pop bp ; (10)(13)(16)
ret ; (11)(14)(17)
main:
mov ax, 3
push ax ; (1)
call f ; (2)
back:
add sp, 2 ; (18)
mov ah, 4Ch
mov al, 0
int 21h
code ends
end main6.3 递归调用时的堆栈变化全貌
以上程序在运行过程中,设 ss=2000h, sp=1000h。每一行的数字对应上面代码中标注的步骤号:
ss:0FEE 0FF4 ← bp=0FEE (9)
ss:0FF0 here ← (8)(10)
ss:0FF2 1 (7)(11)
ss:0FF4 0FFA ← bp=0FF4 (6)(12)
ss:0FF6 here (5)(13)
ss:0FF8 2 (4)(14)
ss:0FFA old bp ← bp=0FFA (3)(15)
ss:0FFC back (2)(16)
ss:0FFE 3 (1)(17)
ss:1000 (18)读懂这张栈迹图:
- 每次
call f前,push ax压入参数(n-1, n-2, ...) - 每次
push bp把上一层的 bp 值保存在栈中,形成链式结构 - 每一层的 bp 指向各自的 old bp 位置,通过
[bp+4]访问各自的参数 n - 递归出口 n==1 时,逐层
pop bp; ret返回,每层执行add ax, [bp+4]累加
以 f(3) 为例:
- f(1) 返回 ax=1,回到 f(2) 的 here
- f(2) 执行
add ax, [bp+4]= 1 + 2 = 3,返回 - f(3) 执行
add ax, [bp+4]= 3 + 3 = 6,返回 main
七、三种调用约定总结
| 约定 | 压栈顺序 | 谁清理参数 | ret 写法 |
|---|---|---|---|
| C (cdecl) | 右 → 左 | 调用者 | ret + 调用者 add sp, n |
| Pascal | 左 → 右 | 被调用者 | ret n |
| stdcall | 右 → 左 | 被调用者 | ret n |
八、本周易错点
- C 和 Pascal 的参数顺序搞反,导致栈中参数位置错乱
- 递归中忘记
push bp/pop bp,导致返回地址链断裂 - 可变参数函数中指针推进的字节数写错(int=2, long=4, double=8)
- 忘记在调用后
add sp, n清理栈 lea si, [bp+6](取地址)和mov si, [bp+6](读内存)混淆