《操作系统真象还原》——0.26 库函数是用户进程与内核的桥梁-阿里云开发者社区

开发者社区> 开发与运维> 正文
登录阅读全文

《操作系统真象还原》——0.26 库函数是用户进程与内核的桥梁

简介: 头文件被包含进来后,其内容也是原样被展开到include所在的位置,就是把整个头文件中的内容挪了过来,所以在头文件中的内容是什么都可以,未必一定要是函数声明,你愿意的话完全可以把函数定义在头文件中,而且也可以不用.h作为文件名。

本节书摘来自异步社区《操作系统真象还原》一书中的第0章,第0.26节,作者:郑钢著,更多章节内容可以访问云栖社区“异步社区”公众号查看

0.26 库函数是用户进程与内核的桥梁

在讨论此问题之前,我们应该明白此问题的始作俑者是操作系统本身。我们用了操作系统,就理应遵守它的规范。任何操作系统都有自己的一套做事规则,在其上的所有应用程序,都按照它定下的规矩做事。

我们讨论的环境是Linux,所以,以下所有的内容都是在Linux系统的规则之中讨论,我们所讨论的内容便是搞清楚这些规则。

在Linux下C编程时,我们写的程序通常是用户级程序。为了输出文本,我们一般会在文件开始include ,这样程序就可以使用printf这样的函数完成打印输出。这背后的原理是什么?为什么简单包含stdio.h后就能够打印字符呢?

揭晓这些答案必须要交待一个事实,用户程序不具备独立打印字符的功能,它必须借助操作系统的力量才可以,如何借助呢?操作系统提供了一套系统调用接口,用户进程直接调用这些接口就行啦。简单来说,接口就是某个功能模块的入口,通过接口给该模块一个输入,它就返回一个输出,模块内部实现的过程就像个黑盒子一样,咱们看不到,也无需关心。我们能够打印字符的原因就是调用了系统调用,但是大家确实没有亲手写下调用系统调用的代码(后面章节会说),这就是库函数的功劳,它帮你写下了这些。

但我们并没有看到库函数的实现,我们只是包含了所需要的库函数所在的头文件,该头文件中有这样一句函数的声明。比如printf函数所在的头文件是stdio.h,该文件位于磁盘/usr/include/目录下,其中第361行是对printf的声明。

extern int printf (__const char *__restrict __format,...);
注意上面括号中的“…”不是我人为加上的省略号,并不是函数声明太长我省略了,这是变长参数的语法。有了这句声明,咱们可以直接把它贴在调用printf的文件中就可以啦,不用把整个stdio.h包含进来了,毕竟里面声明的函数太多了,stdio.h文件共942行,无关的内容太多会给我们带来困扰。

头文件被包含进来后,其内容也是原样被展开到include所在的位置,就是把整个头文件中的内容挪了过来,所以在头文件中的内容是什么都可以,未必一定要是函数声明,你愿意的话完全可以把函数定义在头文件中,而且也可以不用.h作为文件名。来,咱们做个实验。

func_inc.d

1 void myfunc(char* str){
2     printf(str);
3 }

您看,我们的测试文件名为func_inc.d,它甚至都不是以.c结尾的。说明include指令不关心所包含的文件名是啥,只是原方不动地将所包含的文件内容在此处展开。它只包含这三行代码。再看函数main.c。

main.c

1 extern int printf (__const char *__restrict __format,...);
2 #include "func_inc.d"
3
4 void main() {
5    myfunc("hello world\n");
6 }

main.c中第1行声明了外部函数printf,平时我们include 就是这个目的,只不过咱们这里让其精简了。

第2行将func_inc.d包含进来,之后第4~6行调用定义在func_inc.d中的myfunc函数进行打印。

不说别的,先看执行结果,如图0-15所示。

screenshot

为了证明include指令确实与所包含的文件名无关,咱们看看预处理后的文件内容。gcc编译时加-E参数就可以获取预处理后的文件内容。

[work@localhost tmp]$ gcc  -E main.c
# 1 "main.c"
# 1 "<built-in>"
# 1 "<命令行>"
# 1 "main.c"
extern int printf (__const char *__restrict __format, ...);
# 1 "func_inc.d" 1
void myfunc(char* str){
    printf(str);
}
# 3 "main.c" 2
void main() {
   myfunc("hello world\n");
}
[work@localhost tmp]$

您看到了,确实include功能只是将文件搬运过来。另外说明一下,如果main.c中添加了include,此处通过-E生成的文件可老长了,所以咱们只加了printf函数的声明。

到现在为止,似乎还没有进入正题,只是想告诉大家头文件中可以写任何内容,甚至是函数体。

一下子就进入正题了,再交待另外一个事实,函数一定要有函数体才能被调用,必须有相应的函数实现,仅仅凭个头文件中的声明肯定是不行的。

如果在头文件中定义的是printf函数的实现,也许就容易理解头文件帮我们做了什么,可是事实不是这样的,头文件中一般仅仅有函数声明,这个声明告诉编译器至少两件事。

(1)函数返回值类型、参数类型及个数,用来确定分配的栈空间。

(2)该函数是外部函数,定义在其他文件,现在无法为其分配地址,需要在链接阶段将该函数体所在的目标文件一同链接时再安排地址。

这第二件事是我们所说的重点。

如果预处理后,主调函数所在的文件中找不到所调用函数的函数体,一定要在链接阶段把该函数体所在的目标文件链接进来,否则程序在道理上都讲不通,怎么能通过编译呢。

您看到了,main.c中我把func_inc.d包含进来,include后面并不是尖括号而是双引号“?”,这用的是自定义文件的包含,并不是包含标准文件(也就是平时我们所说的标准库头文件)。如果用了尖括号,系统就会到默认路径下去搜索该头文件。搜索到头文件后,找到其中被调函数的声明,再到另一默认文件中找该函数体的实现。

另一默认文件,按理来说应该是目标文件。它到底在哪里呢?

gcc编译时加-v参数会将编译、链接两个过程详细地打印出来,如图0-16所示。

screenshot

gcc内部也要将C代码经过编译、汇编、链接三个阶段。

(1)编译阶段是将C代码翻译成汇编代码,由最上面的框框中的C语言编译器cc1来完成,它将C代码文件main.c翻译成汇编文件ccymR62K.s。

(2)汇编阶段是将汇编代码编译成目标文件,用第二个框框中的汇编语言编译器as完成,as将汇编文件ccymR62K.s编译成目标文件cc0yJGmy.o。

(3)链接阶段是将所有使用的目标文件链接成可执行文件,这是用左边最下面框框中的链接器collect2来完成的,它只是链接命令ld的封装,最终还是由ld来完成,在这一堆.o文件中,有咱们上面的目标文件cc0yJGmy.o。

以上我们想展开说的是第3点:链接阶段。

大家看到了,实际参与链接的有多个.o文件,这些都是目标文件,也就是函数体所在的文件。printf的函数体就在这里面其中某个.o文件中,而且,printf中也要调用其他函数,这些被调用的函数也分布在这些.o文件之中。

这些咱们不认识的.o文件从哪来?为什么链接器要链接它们?

大家看中间框框中的LIBRARY_PATH,这是个库路径变量,里面存储的是库文件所在的所有路径,这就是编译器所说的标准库的位置,自动到该变量所包含的路径中去找库文件。以上所说的.o文件就是在这些路径下找到的。

不知道大家注意到了没有,在图-16中的链接阶段,链接器collect2的参数除了有咱们的main.c生成的目标文件cc0yJGmy.o以外,还有以下这几个以crt开头的目标文件:crt1.o,crti.o,crtbegin.o,crtend.o,crtn.o。

crt是什么?CRT,即C Run-Time library,是C运行时库。

什么是运行时库?

运行时库是程序在运行时所需要的库,该库是由众多可复用的函数文件组成的,由编译器提供。

所以,C运行时库,就是C程序运行时所需要的库文件,在我们的环境中,它由gcc提供。

大家这下应该明白了,我们在程序中简单地一句include <标准头文件>之所以有效,是因为编译器提供的C运行库中已经为我们准备好了这些标准函数的函数体所在的目标文件,在链接时默默帮我们链接上了。

顺便说一句,这些目标文件都是待重定位文件,重定位文件意思是文件中的函数是没有地址的,用file命令查看它们时会显示relocatable,它们中的地址是在与用户程序的目标文件链接成一个可执行文件时由链接器统一分配的。所以C运行时库中同样的函数与不同的用户程序链接时,其生成的可执行文件中分配给库函数的地址都可能是不同的。每一个用户程序都需要与它们链接合并成一个可执行文件,所以每一个可执行文件中都有这些库文件的副本,这些库文件相当于被复制到每个用户程序中。所以您清楚了,即使咱们的代码只有十几个字符,最终生成的文件也要几KB,就是这个道理。

还有一点内容要解释,前面说过用户程序要使用系统调用才能使用操作系统的功能,我们的func_inc.d中,也用到了printf函数,照我这么说的话,打印字符是内核的功能,那么生成的main.bin文件在执行printf函数时,内部一定会执行系统调用?没错!我们来验证一下。

我们可以用ltrace命令跟踪一下程序main.bin的执行过程就好啦。ltrace命令用来跟踪程序运行时调用的库函数,我们的printf函数绝对是个标准的库函数,让我们先尝尝鲜,看看不加参数执行时的输出是否是我们想要的。走起,如图0-17所示。

screenshot

图0-17中用方框框出来的printf就是咱们调用的函数。大家机器上若没有这个命令,可以在http://www.ltrace.org/下载,目前最新版本是0.7.3,下载后的包是ltrace_0.7.3.orig.tar.bz2,我把它放在了ltrace目录中,大家可以执行这样的命令一次性搞定。

tar jxvf ltrace_0.7.3.orig.tar.bz2 && cd ltrace-0.7.3 && ./configure --prefix=/your_path/ltrace && make && make install

验证通过之后,咱们再看看printf用了哪些系统调用。-S参数查看系统调用,命令执行走起,如图0-18所示。

大家看到了方框中的SYS_write了吧,这个就是系统调用啦。Linux的系统调用号定义在/usr/include/asm/ unistd_32.h中,大家可以自行查看。

screenshot

如果大家不想安装ltrace命令,可以用本机自带的strace命令代替,它是专门用来查看系统调用和信号的命令,不过它查看的并不是最终的系统调用,而是系统调用的封装函数。不解释啦,大家眼见为实吧,如图0-19所示。

screenshot

如图0-19所示,画框框的write是系统调用。原本输出的信息非常多,这里我只截了部分。write函数是系统调用SYS_write的封装,所以你懂了我更喜欢用ltrace的原因。

顺便说一句,大家可以用-e trace=write来限制只看write系统调用,免得输出无关的信息太多。

该说的都说啦,现在总结一下。

(1)操作系统有自己支持、加载用户进程的规则,而C运行时库是针对此操作系统的规则,为了让用户程序开发更加容易,用来支持用户进程的代码库。大家要明白,之所以我们写个程序又链接这又链接那的,完全是因为操作系统规定这样做,人在屋檐下,不得不低头。

(2)用户进程要与C运行时库的诸多目标文件链接后合并成一个可执行文件,也就是说我们的用户进程被加进了大量的运行库中的代码。

(3)C运行时库作用如其名,是提供程序运行时所需要的库文件,而且还做了程序运行前的初始化工作,所以即使不包含标准库文件,链接阶段也要用到c运行时库。

(4)用户程序可以不和操作系统打交道,但如果需要操作系统的支持,必须要通过系统调用,它是用户进程和操作系统之间的“钩子”,用户进程顶多算是个半成品,只有通过钩子挂上了操作系统,加了上所需要的操作系统的那部分代码,用户程序才能做完一件事,这才算完整,后面章节会有详解。

(5)尽管系统调用封装在库函数中,但用户程序可以直接调用“系统调用”,不过用库函数会比较高效(后面章节会有详解)。

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享: