汇编揭秘C中的参数传递
文章作者:怕冷的北极熊
很多学习汇编的朋友想必对C也比较了解,因为在当前大学的课程体系里,它很有可能就是你接触到的第一门编程语言。由于对计算机的理解不够,学习时必定会遇到各种问题。有些问题是你通过思考就可以解决的,而更多的问题则是你无从思考,就好像它天生就是这样,你只要记住就OK了。然而这样的学习方式是机械的,更是没有创造力的。只有真正理解了C语言,你才有能力去驾驭它,否则它和你之间永远会隔着一层窗户纸,虽然很薄,但是你永远也捅不透。这是为什么呢?其实道理很简单,就好比在一个公司有现成的代码库可以调用,有的程序员遇到问题时,他唯一可作的就是调用代码库中的功能模块,完事后就万事大吉。而有的程序员则是只要有时间宁可自己实现。即使没有时间,调用完代码库中的功能模块,他还会想,如果是自己,这个功能应该如何实现,代码库中的模块是否有不妥之处,进而对其功能不断进行改进和完善。这可能就是专业和非专业的重要区别。而那些不善于思考的程序员,将来很有可能就会成为我们眼中的“代码工人”。
那么如何才能真正理解C语言呢?答案就是汇编。汇编指令是机器指令的助记符表示,任何高级语言要想被计算机执行,都必须转化为一条条的机器指令,而它又与汇编指令一一对应,通过分析汇编指令,就能真正理解C语言在计算机中的运行机理,只有这样才算真正掌握了C语言,然而如何通过汇编指令分析C语言,很多朋友还不是很熟悉,或者根本就不知道。在有些人的脑子里,C语言是直接被CPU执行的,根本就不会想到还有汇编这一层。而对汇编只是懂点皮毛的,此时也只能是心有余而力不足。在此,我就用汇编语言来揭开C语言参数传递的真正面纱。首先我们来写一个最简单的C语言源程序t.c如下:
main(){}
然后我们在Turboc集成开发环境下生成可执行文件t.exe,接着我们用debug命令加载此文件,查看里面的汇编代码后发现
C:\c>debug t.exe
-u
0C1C:0000 BA720C MOV DX,0C72
0C1C:0003 2E CS:
0C1C:0004 8916F801 MOV [01F8],DX
0C1C:0008 B430 MOV AH,30
0C1C:000A CD21 INT 21
...
看后一头雾水,虽然我们知道每条指令所代表的具体含义,然而却不明白把t.c编译成此汇编代码的真正缘由。其实这是Turboc集成开发环境在作怪。C源程序要想生成exe文件,同样也要经历编译和链接两个阶段。而在Turboc下对应的编译器和链接器分别为Turboc根目录下的Tcc.exe和Tlink.exe。如果像我们所学汇编那样经过 tcc -c t.c(这里加入-c参数是要求只编译,否则它会自动调用链接程序),然后tlink t.obj,同样会生成t.exe,只是运行时不能正确返回,而Turboc集成开发环境为我们解决了这个问题。既然问题解决了,必然要加入相应的功能代码,因此程序也就不容易读懂了。不过没关系,其实C语言中的函数调用就相当于汇编中call指令,而函数名则代表了功能函数在内存中的偏移地址。我们只要把函数名的值以十六进制形式打印一下就可以了。在C语言中,以十六进形式显示标记为%x。代码t.c如下:
main(){ printf("%x",main); }
执行后显示的结果就是main()函数在内存中的偏移地址。我的电脑打印的结果为1FA,因此我们用debug加载此程序后,通过u命令u 1fa得到的结果如下:
-u 1fa
0C1C:01FA B8FA01 MOV AX,01FA
0C1C:01FD 50 PUSH AX
0C1C:01FE B89401 MOV AX,0194
0C1C:0201 50 PUSH AX
0C1C:0202 E8B708 CALL 0ABC
0C1C:0205 59 POP CX
0C1C:0206 59 POP CX
0C1C:0207 C3 RET
这里有人可能会问到两个问题:
1:第一次打印的是1fa,就能保证第二次加载也是在1fa位置吗?
2:以上汇编指令怎么还是很难看懂呢?
其实对于问题1,多试验几次后你会发现,每次打印的结果都会一样。这和操作系统的内存管理有关,我们只要记住具体方法就可以了,因为我们当前的问题是要通过汇编语言来分析C语言的参数传递,与此问题无关的问题不便过多讨论。
对于问题2,真正的原因在于我们调用了库函数printf()所致。由于我们不知道此函数的具体实现,因此也无法理解
0C1C:01FA B8FA01 MOV AX,01FA
0C1C:01FD 50 PUSH AX
0C1C:01FE B89401 MOV AX,0194
0C1C:0201 50 PUSH AX
四条指令的的具体原因。分析问题要从最简单的开始入手,因此我们需要写一个能够说明问题的最简函数,尽量不去调用库函数。我的t.c如下:
int add(int,int);
main()
{
int a;
int b;
int c;
a=4;
b=5;
c=add(a,b);
}
int add(int a,int b)
{
return a+b;
}
它包括两个函数,主函数和相加函数。我们在Turboc集成开发环境下通过熟悉的快捷键F9就会生成t.exe。我们上面提到,main函数的在内存中的偏移地址为1fa(我的机子上的结果是1fa,不同的机子上可能是其它值),然后我们通过debug t.exe把程序加载到内存,通过u 1fa直接跳转到main()函数的起始位置,查看其对应的汇编代码如下:
C:\c>debug t2.exe
-u 1fa ------------------------------------------>以下对应main()
0C1C:01FA 55 PUSH BP
0C1C:01FB 8BEC MOV BP,SP
0C1C:01FD 83EC02 SUB SP,+02
0C1C:0200 56 PUSH SI
0C1C:0201 57 PUSH DI
0C1C:0202 BE0400 MOV SI,0004
0C1C:0205 BF0500 MOV DI,0005
0C1C:0208 57 PUSH DI
0C1C:0209 56 PUSH SI
0C1C:020A E80B00 CALL 0218
0C1C:020D 59 POP CX
0C1C:020E 59 POP CX
0C1C:020F 8946FE MOV [BP-02],AX
0C1C:0212 5F POP DI
0C1C:0213 5E POP SI
0C1C:0214 8BE5 MOV SP,BP
0C1C:0216 5D POP BP
0C1C:0217 C3 RET
-u --------------------------------------------->以下对应int add(int a,int b)
0C1C:0218 55 PUSH BP
0C1C:0219 8BEC MOV BP,SP
0C1C:021B 8B4604 MOV AX,[BP+04]
0C1C:021E 034606 ADD AX,[BP+06]
0C1C:0221 EB00 JMP 0223
0C1C:0223 5D POP BP
0C1C:0224 C3 RET
查看代码后我们发现,MOV SI,0004 和MOV DI,0005中的4、5正好对应我们t.c中的4和5。我们从头开始一步步执行,并观察栈中元素的变化如下:
PUSH BP 栈中元素为:BP
MOV BP,SP BP中保存当前栈顶位置,即指向栈中元素BP
SUB SP,+02 sp减2,相当于入栈操作,只是入栈元素为当前栈空间中残留字型数据,相当于开辟了一个字空间,此时栈中元素为:BP-残留数据
PUSH SI 此时栈中元素为:BP-残留数据-SI
PUSH DI 此时栈中元素为:BP-残留数据-SI-DI
MOV SI,0004 把4放入寄存器SI
MOV DI,0005 把5放入寄存器DI
PUSH DI 此时栈中元素为:BP-残留数据-SI-DI-5
PUSH SI 此时栈中元素为:BP-残留数据-SI-DI-5-4
CALL 0218 调用子程序,对应c=add(a,b),段内近转移,CALL命令做的第一件事是把IP入栈,以便能正确返回。执行后栈中元素为:
BP-残留数据-SI-DI-5-4-020D
PUSH BP 函数add()的第一句汇编代码,为了还原现场,所以要重新使BP入栈。此时栈中元素为:BP-残留数据-SI-DI-5-4-020D-BP
MOV BP,SP 把当前栈顶位置赋给BP
MOV AX,[BP+04] 注意[BP+idata]默认的段寄存器应该是SS,因为SS:[SP]对应栈顶的BP,而BP==SP,所以 SS:[BP+4]应该对应栈中的4。
ADD AX,[BP+06] 由上部同样可推导出SS:[BP+6]应该对应栈中的5,对应return a+b;把相加后的结果放在AX中。
JMP 0223 跳转到偏移地址0223处,对应指令POP BP,为何跳转,不予考虑。
POP BP 还原BP,执行后栈中元素为:BP-残留数据-SI-DI-5-4-020D
RET 回到调用函数(对应main函数) 执行后栈中元素为:BP-残留数据-SI-DI-5-4,而把IP赋值为020D。
POP CX 执行后栈中元素为:BP-残留数据-SI-DI-5, CX=4
POP CX 执行后栈中元素为:BP-残留数据-SI-DI, CX=5
MOV [BP-02],AX 由于main()对应的汇编指令初始时把栈顶偏移地址给了BP,所以此时的SS:[BP]应该对应栈中元素BP,而SS:[BP-2]则对应栈中的残留数据,由于add()对应的汇编代码ADD AX,[BP+06]把相加后的结果存放在了AX中,所以这里是用战中残留数据的空间存储了两个值相加后的结果。而此位置则对应了t.c中的变量c的存储位置。
POP DI 还原DI,执行后栈中元素为:BP-残留数据-SI
POP SI 还原SI,执行后栈中元素为:BP-残留数据
MOV SP,BP 还原SP,执行后栈中元素为:BP,因为main()对应的汇编指令的前两句PUSH BP,MOV BP,SP,结果就是SS:[BP]执向栈中元素元素BP。所以还原后SS:[BP]仍然指向栈中元素元素BP。
POP BP 还原BP,执行后栈中元素为:空
RET 函数调用完成,返回掉用main()的函数。
经过以上分析我们会发现,C语言经编译链接后生成的汇编程序并不复杂,每一条指令都是我们学过的。从中我们知道了,在函数间的参数传递以及在函数内部局部变量的声明都是通过栈来完成的。明白了这些,你是不是会恍然大悟,原来C语言最终是要以这种方式来执行。我们可以用同样的方法,去分析全局变量、指针、结构体、数组等C语言的各个知识点,到时候C语言会赤裸裸的暴露在我们面前,在我们的眼里,它将不再神秘。而此时,我们也许就已经具备了驾驭它的能力。