Skip to content

第9讲:调用约定与递归

本周核心:理解 C 和 Pascal 两种调用约定的区别,学会实现可变参数函数,掌握递归的汇编写法。


一、C 语言方式(cdecl)的参数传递特点

  1. 参数从右往左的顺序压入堆栈
  2. 参数由调用者负责清除

老师 1.asm 中的 C 约定演示(下面的代码是 Pascal 方式的,先看区别):


二、Pascal 语言方式的参数传递特点

  1. 参数从左往右的顺序压入堆栈
  2. 参数由被调用者负责清除(用 ret n
asm
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) ←sp

Pascal 方式 vs C 方式的核心区别

C 方式Pascal 方式
参数压栈顺序右 → 左左 → 右
谁清理参数调用者 (add sp, n)被调用者 (ret n)
被调用者返回指令retret n

三、stdcall 方式的参数传递特点

  1. 参数从右往左的顺序压入堆栈(同 C)
  2. 参数由被调用者负责清除(同 Pascal,用 ret n

即:C 的压栈顺序 + Pascal 的清理方式 = stdcall。Windows API 大量使用 stdcall。


四、C 语言的参数为什么要从右往左压栈?

目的是让参数个数不确定的函数(如 printf)能方便地访问参数 1

c
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 语言参考实现:

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),返回两者之和。

asm
.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 语言版本

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,含完整栈迹)

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 main

6.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

八、本周易错点

  1. C 和 Pascal 的参数顺序搞反,导致栈中参数位置错乱
  2. 递归中忘记 push bp / pop bp,导致返回地址链断裂
  3. 可变参数函数中指针推进的字节数写错(int=2, long=4, double=8)
  4. 忘记在调用后 add sp, n 清理栈
  5. lea si, [bp+6](取地址)和 mov si, [bp+6](读内存)混淆