zl程序教程

您现在的位置是:首页 >  硬件

当前栏目

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

内核进程操作系统 用户 还原 库函数 桥梁
2023-09-11 14:17:30 时间

本节书摘来自异步社区《操作系统真象还原》一书中的第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);

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

main.c

1 extern int printf (__const char *__restrict __format,...);

2 #include "func_inc.d"

4 void main() {

5 myfunc("hello world\n");

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


《操作系统真象还原》——0.14 为什么Linux系统下的应用程序不能在Windows系统下运行 先说说格式。其实格式也算是协议,就是在某个固定的位置有固定意义的数据。Linux下的可执行程序格式是elf,也就是 “Executable and Linking Format”平时咱们用readelf命令可以查看elf文件头,里面有节(section)信息、段(segment)信息、程序入口(entry_point)、哪个段由哪些节组成等信息。
《操作系统真象还原》——导读 以上情况对我们学习操作系统来说也同样存在,比如当老师介绍中断发生时的上下文保护时,我们更多的疑问不是如何保存CPU的上下文数据,而是想知道为什么在不同的特权级下会使用不同的栈,这背后的原理是什么,并且这是如何做到的。
《操作系统真象还原》——2.3 让MBR先飞一会儿 虽说主引导记录mbr是咱们能够掌控的第一个程序,但这并不是让我们为之激动的理由。我们平时所写的程序都要依赖于操作系统,而我们即将实现的这个程序是独立于操作系统的,能够直接在裸机上运行,这才是让我们激动的理由,对咱们来说这无疑是历史性的一刻。
《操作系统真象还原》——2.2 软件接力第一棒,BIOS Intel 8086有20条地址线,故其可以访问1MB的内存空间,即2的20次方=1048576=1MB,地址范围若按十六进制来表示,是0x00000到0xFFFFF。不知道硬件工程师当时设计的初衷是什么,总之人家有自己的理由,这1MB的内存空间被分成多个部分。
《操作系统真象还原》——第2章 编写MBR主引导记录,让我们开始 掌权 2.1 计算机的启动过程 所以,都在内存中运行程序,操作系统和硬件设计都省事了,这可能也是为了方式的统一吧,否则总不能出现某种存储介质后,操作系统和硬件就要付出额外努力去支持。当然,具体原因只有硬件工程师才知道,咱们在此先打住,继续咱们的内容。
《操作系统真象还原》——1.5 运行bochs 我们键入的是上面长方形框框中的部分:bochsrc.disk。由于我们刚刚把此文件放到了bochs的安装路径下,bochs找到了它并加载成功。紧接着下面给出的默认选项变成了[6],也就是Begin simulation选项,开始模拟x86硬件平台。
《操作系统真象还原》——1.4 配置bochs Bochs既然是模拟硬件的,它就得知道,您需要它模拟的计算机是什么样的,换句话说,在这个虚拟机中有哪些硬件,启动顺序是什么,是从软盘开始,还是从硬盘开始?人家也得像模像样地跟BIOS差不多才行。
《操作系统真象还原》——1.3 操作系统的宿主环境 虚拟机在当今已经不是陌生的概念了,要是在几年前,我还得搬出个概念放在这给大家看看。个人觉得,要解释一个东西是什么,不如直接解释这个东西解决了哪些问题,这样大家自然就从本质上真正理解了它是什么。
《操作系统真象还原》——1.2 我们需要哪些编译器 GCC是由理查德·马修·斯托曼在1985年开始的。他首先扩展一个旧有的编译器,使它能编译C,这个编译器一开始是以Pastel语言所写的。Pastel是一个不可移植的Pascal语言特殊版,这个编译器也只能编译Pastel语言。
《操作系统真象还原》——第1章 部署工作环境 1.1工欲善其事,必先利其器 软件是由编程语言来实现的,即使是编译器本身,它的开发人员都不愿意用底层语言去构建(GCC是用C语言完成的),只有到万不得已的时候才会用汇编语言来写。我们也是一样,能用省事的方法就不要自找麻烦,如果某位大神能直接写机器码,小弟真心恳求与您见上一面,希望您收我为徒,我要当面磕头拜师
异步社区 异步社区(www.epubit.com)是人民邮电出版社旗下IT专业图书旗舰社区,也是国内领先的IT专业图书社区,致力于优质学习内容的出版和分享,实现了纸书电子书的同步上架,于2015年8月上线运营。公众号【异步图书】,每日赠送异步新书。