《操作系统真象还原》——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所示。

为了证明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所示。

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所示。

图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中,大家可以自行查看。

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

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

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

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

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

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

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

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

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

时间: 2024-09-20 09:06:04

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

《操作系统真象还原》——导读

**前言**想象一下,如果是爱因斯坦那样的天才给我们讲物理知识,我们会觉得物理更容易理解吗?肯定是不会的,因为在爱因斯坦眼中比较容易的内容也许对我们来说非常深奥,他用B解释A的时候也许会让我们更迷惑,因为B我们也不懂,这就是基础的问题了.幸运的是阅读本书时读者只要有C语言和部分汇编语言的基础就行了,涉及的其他方面的知识我都会详细介绍,并以更易懂的方式去解释技术难点,读者不必担心看不懂本书. 回忆一下学车的经历:教练让学员先踩离合器再挂档,然后再踩油门,车子就开动啦.如果学员总是学不会这些,有可能

《操作系统真象还原》——0.5 应用程序是什么,和操作系统是如何配合到一起的

0.5 应用程序是什么,和操作系统是如何配合到一起的 应用程序是软件(似乎是废话,别急,往后看),操作系统也是软件.CPU会将它们一视同仁,甚至,CPU不知道自己在执行的程序是操作系统,还是一般应用软件,CPU只知道去cs:ip寄存器中指向的内存取指令并执行,它不知道什么是操作系统,也无需知道. 操作系统是人想出来的,为了让自己管理计算机方便而创造出来的一套管理办法. 应用程序要用某种语言编写,而语言又是编译器来提供的.其实根本就没有什么语言,有的只是编译器.是编译器决定怎样解释某种关键字及某种

《操作系统真象还原》——第0章 一些你可能正感到迷惑的问题 0.1操作系统是什么

第0章 一些你可能正感到迷惑的问题 正如计算机中数组下标是从0开始的,我们的内容也从0开始,尽量做到低基础学习(负责地说,不是0基础,而且还只是尽量),解释一些学习过程中经常被问到的问题. 0.1 操作系统是什么 我并没有给你提供教科书上对操作系统的定义,因为解释得太抽象了,看了之后似乎只是获得一些感性认识,好奇心强的读者反而会产生更多迷惑.为了说清楚问题,让我给您举个例子. 让我们扯点远的--在盘古开天之际,除动物以外,世界上只有土地.荒草.树木.石头等资源.人们为了躲避天灾.野兽攻击等危险,

《操作系统真象还原》——0.6 为什么称为“陷入”内核

0.6 为什么称为"陷入"内核 前面提到了用户进程陷入内核,这个好解释,如果把软件分层的话,最外圈是应用程序,里面是操作系统,如图0-1所示. 应用程序处于特权级3,操作系统内核处于特权级0.当用户程序欲访问系统资源时(无论是硬件,还是内核数据结构),它需要进行系统调用.这样CPU便进入了内核态,也称管态.看图中凹下去的部分,是不是有陷进去的感觉,这就是"陷入内核".

《操作系统真象还原》——0.8 代码中为什么分为代码段、数据段?这和内存访问机制中的段是一回事吗

0.8 代码中为什么分为代码段.数据段?这和内存访问机制中的段是一回事吗 首先,程序不是一定要分段才能运行的,分段只是为了使程序更加优美.就像用饭盒装饭菜一样,完全可以将很多菜和米饭混合在一起,或者搅拌成一体,哈哈,但这样可能就没什么胃口啦.如果饭盒中有好多小格子,方便将不同的菜和饭区分存放,这样会让我们胃口大开增加食欲. x86平台的处理器是必须要用分段机制访问内存的,正因为如此,处理器才提供了段寄存器,用来指定待访问的内存段起始地址.我们这里讨论的程序代码中的段(用section或segme

《操作系统真象还原》——0.15 局部变量和函数参数为什么要放在栈中

0.15 局部变量和函数参数为什么要放在栈中 局部变量,顾名思义其作用域属于局部,并不是像static那样属于全局性的.全局的变量,意味着谁都可以随时随地访问,所以其放在数据段中.而局部变量只是自己在用,放在数据段中纯属浪费空间,没有必要,故将其放在自己的栈中,随时可以清理,真正体现了局部的意义.这个就是堆栈框架,提到了就说一点吧,栈由于是向下生长的,堆栈框架就是把esp指针提前加一个数,原esp指针到新esp指针之间的栈空间用来存储局部变量.解释一个概念,堆是程序运行过程中用于动态内存分配的内

《操作系统真象还原》——0.25 指令集、体系结构、微架构、编程语言

0.25 指令集.体系结构.微架构.编程语言 指令集是什么?表面上看它是一套指令的集合.集合的意思显而易见,那咱们说说什么是指令. 在计算机中,CPU只能识别0.1这两个数,甚至它都不知道数是什么,它只知道要么"是",要么"不是",恰好用0.1来表示这两种状态而已. 人发明的东西逃不出人的思维,所以,先看看我们人类的语言是怎么回事. 不同的语言对同一种事物有不同的名字,这个名字其实就是代码.比如说人类的好朋友:狗,咱们在中文里称之为狗,但在英文中它被称为dog,虽然

《操作系统真象还原》——0.17 先有的语言,还是先有的编译器,第1个编译器是怎么产生的

0.17 先有的语言,还是先有的编译器,第1个编译器是怎么产生的 首先肯定的是先有的编程语言,哪怕这个语言简单到只有一个符号.先是设计好语言的规则,然后编写能够识别这套规则的编译器,否则若没有语言规则作为指导方向,编译器编写将无从下笔. 第1个编译器是怎么产生的?这个问题我并没有求证,不过可以谈下自己的理解,请大伙儿辩证地看. 这个问题属于哲学中鸡生蛋.蛋生鸡的问题,这种思维回旋性质的本源问题经常让人产生迷惑.可是现实生活中这样的例子太多了. (1)英语老师教学生英语,学生成了英语老师后又可以教

《操作系统真象还原》——0.27 转义字符与ASCII码

0.27 转义字符与ASCII码 计算机世界中是以二进制来运行的,无论是指令.数据,都是以二进制的形式提交给硬件处理的,字符也一样,必须转换成二进制才能被计算机识别.所以各种各样的字符编码产生,简单来说,字符编码就是用唯一的一个二进制串表示唯一的一个字符.其中最著名的字符编码就是ASCII码. ASCII码表中字符按可见分成两大类,一类是不可见字符,共33个,它们的ASCII码值是0-31和127,属于控制字符或通信专用字符.表中其余的字符是可见字符,它们的ASCII码值是32-126,属于数字