本节说明:本节内容与编译和链接相关,该部分内容繁杂,不是一篇博客就能说明的,且本文仅为后续文章加载内核作铺垫,关于这方面详细的内容请阅读《装载,链接与库》。如有错误,请在评论区提出,谢谢。

本文前置内容:函数调用约定
本节对应代码:加载内核-代码详解

C和汇编相互调用

编写源文件
给出如下两个文件:

//文件说明:cprint.c
extern void asm_print(char *, int);
void c_print(char* str)
{
	int len = 0;
	while (str[len++]);
	asm_print(str, len);
}
;文件说明:asm_print.s
[bits 32]
section .data
str: db "asm_print say hi youyifeng!",0xa,0x00 
;0x0a是换行符,0x00是字符串结束符,不加的话会把后面字符陆续输出,直到遇到空白字符
;while循环遍历字符串统计字符串长度,遇空白字符结束。汇编不支持NULL
str_len equ $-str

section .text
extern c_print       ;外部申明引用c_print
global _start

_start:
	push str_len
	push str
	
	call c_print
	add esp,8        ;cdecl,调用者清理栈空间(外平栈)
	mov eax,1        ;调用1号中断,告诉Linux咋们要正常退出
	int 0x80

global asm_print ;定义函数asm_print
asm_print:
	push ebp         ;保存原函数栈底
	mov ebp,esp      ;ebp指向当前栈帧的栈底
	mov eax,4        ;调用4号子功能,需要传入三个参数:ebx,ecx,edx
	mov ebx,1        ;此参数为文件描述符,固定3个,0表示标准输入,1表示标准输出,2表示标准错误输出
	mov ecx,[ebp+8]  ;长度参数len 
	mov edx,[ebp+12] ;字符串首地址参数  char *str
	int 0x80         ;功能号填写完毕,发起0x80中断
	pop ebp          ;恢复ebp
	ret

让我们先聚焦 cprint.c 文件:

  1. 第 2 行,extern 声明,引入函数 asm_print 。因为在 c_print 函数中调用了 asm_print 函数,而在当前文件中并没有 asm_print 的定义,所以必须进行声明,告诉编译器我要使用这个函数,你现在没有找到它的定义不要紧,请不要报错,稍后链接时会把定义补上 。这里可以省略 extern 关键字,直接声明函数。
  2. 第 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 :

  1. 第 2 行,[bits 32] 声明以下环境为 32 位,之前有提到过,见32位保护模式概览

  2. 第 10 行,引入 c_print ,与前面提到的不同,此处 extern 关键字不能省略。

  3. 第 11 行,global 的作用是导出某符号,使其他文件可以发现该符号。_start 是默认的程序入口,这个咋们待会再详细讨论。

  4. 第 22 行,导出 asm_print ,这样在 cprint.c 中的 cprint() 函数才能调用 asm_print 。

  5. 第 14,15 行,将两个参数压栈,随后调用 c_print 。

  6. 第 18 行,由于 c_print() 是由 C 语言编写的函数,所以默认的调用约定是 cdecl,所以必须由调用者手动平栈。对此陌生的朋友可参考函数调用约定

    这里就体现出调用约定的重要性了。如果 c_print() 采用 stdcall(只需要在定义时在函数名前声明 __stdcall),则是被调函数平栈。如果不清楚调用约定,则会导致最终堆栈不平衡,引发程序错误。

  7. 第 24,25 行,也请参见函数调用约定

  8. 第 30 行,0x80 是 Linux 下系统调用的统一入口,具体的子功能在 eax 中指定。后续会详述该部分内容。

简单总结

  • 在汇编中导出符号供外部引用,使用关键字 glbal ;引用外部文件的符号使用 extern
  • 在 C 文件中只要将符号定义为全局就能供外部引用,无需额外关键字;引用外部符号时用 extern 声明。

编译
分别编译上述两个文件:

gcc -m32 -c cprint.c -o cprint.o
nasm -f elf32 asm_print.s -o asm_print.o

-m32-f elf32 是在指定编译器将源文件编译为 32 位的 ELF 文件格式。

链接

ld -m elf_i386 asm_print.o cprint.o -o print

-m elf_i386 同样是在指定指令架构。最终得到可执行文件 print 。

运行

./print
asm_print say hi youyifeng!

初识ELF文件

在以上过程中,我们链接 asm_print.o 和 cprint.o 这两个文件后便能直接运行该程序。问题是,计算机是怎么知道程序的入口在哪的呢?由于程序内的地址是在链接时就编排好了(重定位),所以链接阶段就必须确定好程序入口。于是链接器规定,默认只把名为 _start 的函数(或标号)作为程序的入口符号。如果要另行指定入口,则需要使用 -e 参数来指定:

#将入口符号指定为main
ld -m -e main elf_i386 asm_print.o cprint.o -o print

那么问题又来了,入口符号确定了,计算机又从哪获得该符号对应的地址呢?这就不得不提到 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 来保存主调函数现场的,但现在咋们就只需要保证这五个寄存器不变就好啦!另外:

  1. eax 用来储存返回值
  2. esp 一般无需压栈保存,它是通过内外平栈(见函数调用约定)来保证堆栈平衡(即调用前后 esp 不变)的 。下面举例为证:
int add(int a, int b)
{
	return a + b;
}
int main()
{
	int a = 1;
	int b = 2;
	int c = add(1, 2);
	return 0;
}
int add(int a, int b)                   
{
00FB1750  push        ebp  
00FB1751  mov         ebp,esp  
00FB1753  sub         esp,0C0h  
00FB1759  push        ebx  
00FB175A  push        esi  
00FB175B  push        edi  
00FB175C  mov         edi,ebp  
00FB175E  xor         ecx,ecx  
00FB1760  mov         eax,0CCCCCCCCh  
00FB1765  rep stos    dword ptr es:[edi]  
00FB1767  mov         ecx,offset _206B94B3_源@c (0FBC000h)  
00FB176C  call        @__CheckForDebuggerJustMyCode@4 (0FB130Ch)  
	return a + b;
00FB1771  mov         eax,dword ptr [a]  ;[ebp+8]
00FB1774  add         eax,dword ptr [b]  ;[ebp+0Ch]
}
00FB1777  pop         edi  
00FB1778  pop         esi  
00FB1779  pop         ebx  
00FB177A  add         esp,0C0h  
00FB1780  cmp         ebp,esp  
00FB1782  call        __RTC_CheckEsp (0FB1235h)  ;上行和本行,检查堆栈平衡(ebp==esp)
00FB1787  mov         esp,ebp  
00FB1789  pop         ebp  
00FB178A  ret  

看见第 3、6、7、8 行的压栈没?这就和上文很好地呼应了,不信你自己试试。

本文结束。

文章作者: 极简
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
自制操作系统 C/C++
喜欢就支持一下吧