浅析C和汇编混合编程/ABI规则
本节说明:本节内容与编译和链接相关,该部分内容繁杂,不是一篇博客就能说明的,且本文仅为后续文章加载内核作铺垫,关于这方面详细的内容请阅读《装载,链接与库》。如有错误,请在评论区提出,谢谢。
C和汇编相互调用
编写源文件
给出如下两个文件:
1 | //文件说明:cprint.c |
1 | ;文件说明:asm_print.s |
让我们先聚焦 cprint.c 文件:
- 第 2 行,extern 声明,引入函数 asm_print 。因为在 c_print 函数中调用了 asm_print 函数,而在当前文件中并没有 asm_print 的定义,所以必须进行声明,告诉编译器我要使用这个函数,你现在没有找到它的定义不要紧,请不要报错,稍后链接时会把定义补上 。这里可以省略 extern 关键字,直接声明函数。
- 第 2 行,函数原型给出了参数类型:asm_print 有俩参数,一个是 char* 类型,一个是 int 类型。这里声明了两个参数,和 asm_print.s 中的第14,15 行的两个 push 恰能对应;但看到参数类型时,我们不禁大呼一句卧槽,asm_print 是用汇编写的啊,哪来的类型?哈哈,是的,汇编语言没有类型之分,只有操作数大小之分 。那这里为什么可以指定参数类型 char* 和 int 呢?其实,数据类型,只是在指导编译器如何去解释这个数据以及如何控制它的行为 。比如你声明
char* ptr
,那么编译器就认为 ptr 中装的是地址,且将 ptr 的步长指定为 1(也就是自增自减时以1为单位);如果你声明int* ptr
,那么编译器就认为 ptr 中装的是地址,且将 ptr 的步长指定为 4 。好了,由于这里涉及编译原理,笔者暂不熟悉,就不多做解释,以免误导读者。
另外需要注意的是,C 语言不管函数参数类型是 char 还是 short 或者 int,压参时每个参数都会压入 4 字节 !这点在我们后面编写供 C 语言调用的汇编函数时有重要作用。演示如下:
其汇编代码如下:
看,参数 b 先被放入 eax 中,再压入参数,则压入 4 字节;对于 push 3
,32 位下压立即数时,也是压入 4 字节。我们再来看看编译器如何从栈中去参数:
注意第 1 行,使用了 word 修饰,因为 b 的类型本就是 short,只占两个字节。movsx 是带符号扩展传送指令,不在此阐述。
再来看 asm_print.s :
-
第 2 行,
[bits 32]
声明以下环境为 32 位,之前有提到过,见32位保护模式概览 。 -
第 10 行,引入 c_print ,与前面提到的不同,此处 extern 关键字不能省略。
-
第 11 行,
global
的作用是导出某符号,使其他文件可以发现该符号。_start
是默认的程序入口,这个咋们待会再详细讨论。 -
第 22 行,导出 asm_print ,这样在 cprint.c 中的 cprint() 函数才能调用 asm_print 。
-
第 14,15 行,将两个参数压栈,随后调用 c_print 。
-
第 18 行,由于 c_print() 是由 C 语言编写的函数,所以默认的调用约定是 cdecl,所以必须由调用者手动平栈。对此陌生的朋友可参考函数调用约定。
这里就体现出调用约定的重要性了。如果 c_print() 采用 stdcall(只需要在定义时在函数名前声明 __stdcall),则是被调函数平栈。如果不清楚调用约定,则会导致最终堆栈不平衡,引发程序错误。
-
第 24,25 行,也请参见函数调用约定 。
-
第 30 行,0x80 是 Linux 下系统调用的统一入口,具体的子功能在 eax 中指定。后续会详述该部分内容。
简单总结 :
- 在汇编中导出符号供外部引用,使用关键字
glbal
;引用外部文件的符号使用extern
。 - 在 C 文件中只要将符号定义为全局就能供外部引用,无需额外关键字;引用外部符号时用
extern
声明。
编译
分别编译上述两个文件:
1 | gcc -m32 -c cprint.c -o cprint.o |
-m32
与 -f elf32
是在指定编译器将源文件编译为 32 位的 ELF 文件格式。
链接
1 | ld -m elf_i386 asm_print.o cprint.o -o print |
-m elf_i386
同样是在指定指令架构。最终得到可执行文件 print 。
运行
1 |
|
初识ELF文件
在以上过程中,我们链接 asm_print.o 和 cprint.o 这两个文件后便能直接运行该程序。问题是,计算机是怎么知道程序的入口在哪的呢?由于程序内的地址是在链接时就编排好了(重定位),所以链接阶段就必须确定好程序入口。于是链接器规定,默认只把名为 _start 的函数(或标号)作为程序的入口符号。如果要另行指定入口,则需要使用 -e
参数来指定:
1 | #将入口符号指定为main |
那么问题又来了,入口符号确定了,计算机又从哪获得该符号对应的地址呢?这就不得不提到 ELF 文件格式了。其实,我们早在本系列的前期文章就已经接触到了 ELF 的雏形,即程序加载器 。ELF 文件格式同程序加载器一样,都是调用程序和被调程序的一种协议,而协议的意义在于通用性 。也就是说,只要遵守协议,那么一个调用方就能调用多种用户程序,比如,调用方一般都为操作系统,而操作系统能调用无数种类,不同厂商开发的应用程序。Linux 的可执行程序为 ELF 格式,ELF 格式采用文件头 header+文件体 body 的形式 。文件头用来描述程序的布局,包括入口,代码段,程序段的地址等。有了文件头的好处是调用方式变得通用,坏处是这些文件不再是纯粹的二进制可执行文件了,CPU 不能直接运行。所以,将 ELF 可执行文件读入内存后,必须先解析文件头,找到程序的入口地址,然后直接跳转到入口处,CPU 才能够运行该程序 。好了,ELF 的知识较繁杂,就不在此处展开了,想了解详情的朋友可参阅《装载,链接与库》。
接下来,就进入激动人心的时刻了:载入内核 。
ABI 规则
ABI(Application Binary Interface,应用程序二进制接口),描述了应用程序和操作系统之间,一个应用和它的库之间,或者应用的组成部分之间的接口。ABI涵盖了各种细节,如:
- 数据类型的大小、布局和对齐;
- 调用约定(控制着函数的参数如何传送以及如何接受返回值),例如,是所有的参数都通过栈传递,还是部分参数通过寄存器传递;哪个寄存器用于哪个函数参数等。
- 系统调用的编码和一个应用如何向操作系统进行系统调用;
- 以及在一个完整的操作系统ABI中,目标文件的格式、程序库等等。
这里我们不展开,只强调 ABI 中这样一个规定:位于 Intel386 体系上的所有通用寄存器都具有全局性,因此在函数调用时,所有通用寄存器对被调函数和主调函数都可见。但是,规定要求 epb、ebx、esi、edi、esp 这五个寄存器归主调函数使用,其他寄存器随便供被调函数使用。换句话说,不管被调函数中是否使用了这五个寄存器,当被调函数返回时,这几个寄存器都不应该被改变 。这实际上是属于编译原理的范畴,这些规定会被编译器严格遵守,因此,当我们使用 C 语言编写函数时,无需关心这些东西。但在 C 和汇编混合编程时,就需要留点心了:当 C 函数调用我们自己写的汇编函数时,需要保证调用前后这五个寄存器的值不变。其实,我们之前是直接通过 pushad 和 popad 来保存主调函数现场的,但现在咋们就只需要保证这五个寄存器不变就好啦!另外:
- eax 用来储存返回值 。
- esp 一般无需压栈保存,它是通过内外平栈(见函数调用约定)来保证堆栈平衡(即调用前后 esp 不变)的 。下面举例为证:
1 | int add(int a, int b) |
1 | int add(int a, int b) |
看见第 3、6、7、8 行的压栈没?这就和上文很好地呼应了,不信你自己试试。
本文结束。