http://blog.csdn.net/yourtommy/article/details/7244660
这篇博客是我看英文版原书时,翻译成中文,并测试了书中的代码。纯粹是为了加深理解和记忆。真正想学习的,还是阅读原书。
1.1 引言(Introduction)
所有的操作系统都为应用程序的运行提供服务,典型的服务包括执行一个程序、打开一个文件、读取一个文件、分配一块内存区域、得到当前时间等等。
1.2 UNIX架构(Architecture)
一个操作系统的严格定义为:一个控制计算机硬件资源(hardware resource)以及为应用程序(programs)提供运行环境(environment)的软件。通常我们称这个软件(操作系统)为内核(kernel),因为它相对较小而且处于运行环境的核心(core)。
内核(kernel)的接口是一层被称为系统调用(system calls)的软件,系统调用的上层为公共函数库。应用程序可以调用库函数,也可以直接使用系统调用。shell是一个特殊的应用程序,它提供接口来运行其它的应用程序。Shell与库函数并级处于同一层(下层为kernel)。最上层为应用程序。
操作系统的广义定义为:内核(kernel)以及所有其它使得计算机有用(useful)和个性化(personality)的软件。这些软件包括系统工具(system utilities)、应用程序(applications)、各种shell、公共函数库(libraries of common functions)等等。举个例子,Linux是GNU操作系统的内核,一些人称其为GNU/Linux操作系统,但其更常用的名称就是Linux。尽管简单地称其为Linux并不是严格定义,但其是可理解的。
1.3 登录(Logging In)
当登录UNIX系统时,我们需要输入用户名和密码。用户名和密码信息存储在/etc/passwd文件里。该文本的每一条记录包含一个用户信息,记录用冒号分隔的多个域组成,包括用户名、加密后的密码、UID、GID、注释、主目录路径和登录后启动的shell程序。如今所有系统都把密码存储在另一个文件里。
Shells
当我们登录系统后,通常能看到一些系统信息的消息,接着就可以在shell程序里输入命令了。一些系统在你登录后启动一个窗口管理程序(window management program),但通常有一个shell窗口。shell是一个命令行解释器,它读取用户输入并执行命令。用户可以通过终端(terminal)或者脚本文件(shell script)来输入命令。通常使用的shell有Bourne Shell、Bourne-again Shell、C shell、Korn shell和TENEX C shell。
系统通过读取password文件(/etc/passwd)可以知道应该运行哪个shell。
1.4 文件和路径(Files and Directories)
文件系统(File System)是目录和文件的层次化排列(hierarchical arrangement)。所有的文件或目录都起始于一个被称为根(root)的目录,路径名是一个斜杠(/)。
目录(directory)是一个特殊的文件,它包含目录入口(directory entries)。逻辑上,我们可以认为目录入口包含了一个文件名(filename)和一个描述文件属性(attributes)的信息结构体(structure of information)。文件属性包括文件类型(type)-普通文件还是目录、文件大小(size)、文件的属主(owner)、文件的权限(permissions)-其他用户是否访问这个文件、以及文件的最后修改时间(last modified)。函数stat和fstat可以返加一个包含一个文件所有属性的信息结构体。
文件名(filename):目录下的名字都被称为文件名。只有两上字符不能出现在文件名里:斜杠(“/”)和空字符(null)。斜杠用于分隔路径名(pathname)中的各个文件名,而空字符表示一个路径名的结束。尽管如此,最好还是使用普通可打印(normal printing characters)的字符作为文件名,否则我们只能使用引用机制(quoting mechanism)来表示文件名,这样会比较复杂。
每当一个新的目录被创建时,两个默认的文件名会被自动创建:“.”(dot)和“..”(dot-dot),分别表示当前目录和父目录。根目录下的..与.一样,都表示根目录。
Research UNIX System和一些老的UNIX System V的文件系统只允许文件名包含14个字符。BSD扩展这个限制到255个字符。当今几乎所有的商业UNIX文件系统都支持至少255个字符的文件名。
路径名(pathname)是由斜杠分隔的一个或多个文件名。以斜杠开头的路径名称为绝对路径(absolute pathname),否则称为相对路径(relative pathname)。根目录的路径名(/)是一个特殊情况:它不含任何文件名。
工作目录(working directory):每个进程都有一个工作目录,它有时也被称为当前工作目录(current working directory)。这个目录用于解释所有的相对路径名。进程可以使用chdir函数来改变工作目录。
主目录(home directory):登录后的默认工作目录,这个目录可以在password文件里找到。
1.5 输入和输出(Input and Output)
文件描述符(file descriptors)通常是一个小的非负整型,内核用它来识别被一个特定进程访问的各个文件。无论何时打开来创建一个文件,内核都会返回一个文件描述符供我们读写文件使用。
标准输入、标准输出和标准错误(standard input, standard output and standard error):按协定所有shell都会在一个程序运行时打开这三个文件描述符,它们都默认绑定到终端。大多数shell都支持它们的重定向。
无缓冲I/O(Unbuffered I/O)由函数open、read、write、lseek和close提供。这些函数都使用文件描述符来工作。
标准I/O(Standard I/O)函数为无缓冲I/O提供缓冲接口。使用标准I/O可以不用担心选择最优缓冲区大小的问题,而且可以简化行输入的处理。比如read函数,读取指定数目的字节,而标准I/O库提供函数允许我们控制缓冲的风格(style of buffering)。最普遍的标准I/O函数是printf,它声明在
1.6 程序与进程(program and process)
程序(program)是一个在硬盘(disk)上某个目录里的一个可执行(executable)文件。内核把程序读入内存然后执行,通过使用6个exec函数中的一个。
进程和进程号(process and process ID):程序的一个正在执行的实例(instance)被称为一个进程,有些操作系统用术语任务(task)来描述一个正被执行的程序。UNIX系统保证所有的进程都有一个独一无二的数字ID:进程号,它总是一个非负整型。
进程控制(process control):有三个主要的进程控制函数:fork、exec和waitpid。exec函数有6个变种,但我们通常简单统称它们为exec函数。
线程与线程号(Threads and Thread IDs):一个进程可以有多个线程,在多核系统上,多线程可以发挥并行的可能性。同一进程内的所有线程都共享同一个地址空间、文件描述符、栈(stacks)、进程相关的属性。因为它们可以访问相同的内存,所以线程在访问共享数据时需要同步控制。每个线程都有一个线程号,这个线程号在所在进程内是唯一的。其它进程内的线程号对于当前进程而言是毫无意义的。
控制线程的函数与控制进程的函数不同,因为线程在进程模型建立很久以后才加入到UNIX系统。尽管如此,线程模型和进程模型有一些复杂的交集。
1.7 错误处理(Error Handling)
当UNIX系统函数出错时,经常会返回一个负数,同时我们一般能根据一个整型数errno的值得到更多的信息。比如,函数open成功后会返回一个非负数,否则返回-1。open可能的错误大约有15种,比如文件不存在、权限问题等等。有些函数使用一些协定,而不是返回负值。比如,多数函数成功时返回一个有效指针,否则返回一个null指针。
文件
POSIX和ISO C都把errno定义成一个可修改的整型左值。它既可以是一个错误号(error number),也可以是一个指向错误号的指针。早期的定义为extern int errno;,在多线程环境,errno被定义为:
extern int *__errno_location(void);
#define errno (*__errno_location())
关于errno有两条规则:一、如果没有错误发生,errno的数值不会被清零,所以我们应该只有在函数发生错误的时候才去检查这个值;二、errno的值永远都不可能是0,而且没有一个值为0的错误常数。
有两个打印错误信息的函数:
char *strerror(int errnum)(
void perror(const char *msg)(
错误恢复(Error Recovery)
定义在
典型的资源相关的非致命错误的恢复操作是等待一段时间再进行尝试。这种技术还可以应用在别的场景,比如网络连接错误时,等待后再尝试可能可以重新建立网络连接。一些应用程序使用指数退避算法(exponential backoff algorithm),每次尝试都会等待更长一段时间。
一个错误是否能恢复,最终还是取决于应用程序的开发人员,如果有一个合理的恢复方法,我们可以避免退出程序来提高程序的健壮性。
1.8 用户身份证明(User Identification)
用户ID(User ID)作为一个数值定义在password文件里,用来为我们向系统提供身份证明。这些用户ID由系统管理员在为我们分配用户名的时候分配,而且我们不能修改这个ID。用户ID一般对每个用户都是独一无二的。
我们称用户ID为0的用户为根用户(root)或超级用户(superuser)。我们可以在password文件里看到登录名为root的一行记录。一些操作系统函数只允许超级用户访问。超级用户可以掌管整个系统。
组ID(Group ID)同样作为一个数值定义在password文件里,同样也是在系统管理员为我们分配用户名的时候分配。一般在password文件里会有多行记录来描述同一个组ID。组(Groups)通常用来把多个用户集中到一个项目或一个部门,这样就可以在同一个组里共享资源,比如文件。我们可以设置一个文件的权限,使得它只能被一个组的成员访问,而组外的用户则不访问该文件。
还有一个组文件用来把组名(group names)映射到数值化的组ID,这个文件通常为/etc/group。
用户ID和组ID之所以用数值表示,是有历史原因的。对于每个文件,文件系统都要为它存储它的属主(owner)的用户ID和组ID,如果每个ID存储为2个字节的整型,则只要4个字节就够了,但完整的ASCII码登录名和组名需要更多的磁盘空间。此外,当检查权限时,比较字符串相对于比较整型数而言花费更大。
尽管如此,用户更喜欢名字而不是数字,所以password文件维护了登录名到用户ID的映射,而组文件提供了组名到组ID的映射。函数getuid和getgid可以用来得到用户ID和组ID。
补充组ID(Supplementary Group IDs)
自从4.2BSD开始,多数的UNIX系统版本都允许用户除了在password文件里指定的组ID外,还可以同时属于其它的组。4.2BSD允许用户同时有至多16个补充组ID。这些补充组ID在登录时通过文件/etc/group得到。POSIX要求系统至少支持8个补充组ID,而多数系统支持至少16个。
1.9 信号(Signals)
信号是用来在一些条件(conditions)发生的情况下通知进程的一种技术。比如当一个进程被0除时,一个被为SIGFPE(floating-point-exception)的信号会被发送给这个进程。这个进程有三种处理该信号的方式:
1、忽略该信号。当信号表示硬件异常,比如被零除或者进程引用地址空间外部的内存时,这种做法并不推荐,因为会导致未定义的结果。
2、让默认行为发生。比如被零除的条件,默认行为是终止进程。
3、当信号发生时,提供一个可被调用的函数(称为捕获信号,catching the signal)。通过提供一个函数,我们可以知道信号何时发生,同时可以根据我们自己的意愿来处理该信号。
许多条件会产生信号。两个终端按键--中断(interrupt)键(通常是DELETE键或Control-C)和退出(quit)键(通常是Control-backslash),常被用来中断正在运行的进程。另一种产生信号的方式是调用kill函数。我们可以在某个进程里调用kill函数来给另一个进程发送信号。这样做是有限制的:我们必须是另一个进程的属主或者超级用户才能给它发送信号。
1.10 时间值(Time Values)
很早之前开始,UNIX系统维护了两个不同的时间值:
日历时间(Calender time):这个值记录了从Epoch(1970/1/1-00:00:00,UTC)开始到现在的秒数。这个时间可以用来记录文件修改的时间等等。早期系统的数据类型time_t维护这个时间值。
进程时间(Process time):也叫CPU时间,用来测量一个进程使用的中央处理器资源。进程时间用时钟周期(clock ticks)来测量,历史上一秒钟的时钟周期为50、60或者100。早期系统的数据类型clock_t维护了这个时间值。
当我们测量一个进程的执行时间时,我们可以发现UNIX系统维护了三个值:时钟时间(Clock time)、用户时钟时间(User CPU time)和系统时钟时间(System CPU time)。
时钟时间,有时也称为挂钟时间(wall clock time)。是进程用来运行的时间量,这个值取决于其它正在运行的进程的数量。当在报告时钟时间的时候,我们都是指在没有其它活动进程的时候测量的结果。
用户CPU时间是用户指令(user instructions)所花去的CPU时间,而系统CPU时间是内核执行所花支的CPU时间。比如,当一个进程执行系统服务比如read或write时,该系统服务在内核里花费的时间也属于进程时间的一部分。用户CPU时间和系统CPU时间的总和经常被统称为CPU时间。
命令time可以简单地测量任何一个进程的时钟时间、用户时间和系统时间。它以一个命令作为参数:time -p grep _POSIX_SOURCE */*.h > /dev/null。可以看到输出:
real 0m0.81s
user 0m0.11s
sys 0m0.07s
输出取决于当前使用的shell。有的shell不运行/usr/bin/time,而是用别的内建函数一测量命令运行的时间。
1.11 系统调用和库函数(System Calls and Library Functions)
所有的操作系统都提供了应用程序可以调用内核服务的服务接口(service points)。UNIX系统的所有实现版本都提供一个良好定义的、数量有限的进入内核的入口点,称为系统调用(system calls)。Research UNIX系统的第7个版本提供了大给50个系统调用,4.4BSD提供了大约110个,SVR4有大约120个。Linux根据不同的版本有240到260个。FreeBSD有大约320个。
系统调用接口一直被定义在UNIX Programmre's Manual的Section 2。它们以C语言定义,但不规定它们的具体实现。这点与许多更老的操作系统不同,它们把内核入口定义为汇编语言。
每个UNIX系统调用在标准C库里都有同名函数的定义,用户进程用C语言的调用方式调用这些函数,而后这些函数会调用正确的内核服务,不管内部使用何种技术来实现这些函数。例如:函数可以接受一个或多个参数并把它们放到通用寄存器(general registers)里,然后执行一些机器指令在内核中产生一个软件中断(software interrupt)。我们可以简单地把系统调用看作是C函数。
UNIX Programmre's Manual的Section 3里定义了一个可用的通用(general-purpose)函数。虽然这些函数可能会调用一个或多个内核系统调用,但它们不是内核入口。比如:printf函数可能使用write系统调用来输出字符串,但strcpy和atoi函数不会使用系统调用。
从实现者的角度看,系统调用和库函数的区别是很重要的,但对于用户而言,这并没有那么重要--系统调用和库函数看起来都是普通的C函数。两者都为应用程序提供服务。尽管如此,我们必须意识到,我们可以根据需要替换库函数,但通常不能替换系统函数。
例如malloc函数。有很多方法可以实现内存分配和垃圾回收,没有一种技术对所有应用程序都是最优化的。分配内存的系统调用sbrk并不提供一个通用的内存管理:它让进程增加或减少指定数量字节的地址空间,这些地址空间如果管理则取决于进程。内存分配函数malloc实现了一种特殊的分配方式。如果我们不喜欢这个操作,我们可以定义我们自己的malloc函数,在实现时很可能会调用sbrk系统调用。事实上有不少软件包都使用sbrk系统调用实现他们自己的内存分配算法。
这里我们有一个很清晰的责任区分:内核里的系统调用为进程分配一大块内存,而库函数malloc在用户态(user level)管理这些内存。
另一个区分系统调用和库函数的例子是UNIX系统提供的获得当前时间和日期的接口。一些操作系统提供一个系统调用来返回时间,和另一个系统调用来返回日期。任何特殊操作,比如进行夏令时的转换,可以由内核处理,也可以是人为处理。UNIX系统提供了仅有的一个系统调用来返回从Epoch至今的秒数。这个值的任何解释,比如用本地时区把它转换成可读的时间和日期,都交给用户进程。标准C函数库提供了一些函数来处理大多数的转换,比如各种夏令时转换的算法。
应用程序既可以调用系统函数也可以调用库函数,而且许多库函数也都调用了系统函数。两类函数的另一个区别是系统调用通常提供一个最小化的接口,而库函数提供更智能的功能。比如sbrk和malloc,还有unbuffered I/O和standard I/O。
进程控制系统调用(fork、exec和wait)通常会被用户程序直接调用,然而一些函数库提供了简化普遍情况的方法,比如system库和popen库。
1.12 总结(summary)
本章对UNIX进行的简短的介绍。我们介绍了我们将会一遍又一遍看到一些基本的术语,以及一些小的编程例子。
2.1 引言(Introduction)
人们做了大量工作来标准化UNIX编程环境和C语言。尽管应用程序可以很好的移植到不同版本的UNIX系统,但自从上世纪80年代开始各种版本的出现和差异导致许多用户,包括美国政府,要求一个统一化的标准。
在这章我们先看下过去二十年为标准化的作出的各种努力。然后我们讨论下这些UNIX编程标准对操作系统实现的影响。在所有标准化的工作里,一个重要的部分是所有UNIX实现都必须定义的各种限制(limits),所以我们要看看这些限制以及决定这些限制的值的各种方法。
2.2 UNIX标准(UNIX Standardization)
ISO C:在1989年末,C语言的ANSI标准X3.159-1989就被通过了。这个标准同时被作为国际化标准ISO/IEC 9899:1990。ANSI全称为American National Standards Institute,ISO的美国成员。ISO的全称为Internetational Organization for Standardization。IEC全称为International Electrotechnical Commission。
C标准现在由ISO/IEC国际标准工作组维护和改进。这个工作组为ISO/IEC JTC1/SC22/WG14,简称为WG14。ISO C标准的目的是提供使C程序可以移植到各种不同的操作系统上,而不仅仅是UNIX。这个标准不仅定义了编程语言的语法和语义,同时还定义了一个标准库(standard library)[Chapter 7 of ISO1999; Plauger 1992; Appendix B of Kernighan and Ritchie 1988]。这个库非常重要,因为所有当代UNIX系统都提供了在C标准中定义的库函数。
在1999年,ISO C标准更新并作为ISO/IEC 9899:1999被采纳,大幅提升了对多进程应用程序的支持。除了在一些函数声明里加上了restrict关键字,这次更新没有影响到POSIX标准。restrict关健字通过指出一个指针所指的对象仅被该指针引用,来告诉编译器这个指针引用是可以被优化的。
和多数标准一样,从标准出台到软件修改以符合标准之间会有一个延迟。随着开发商的编译系统的升级,他们为最新版本的ISO标准加了更多的支持。
根据标准定义的头文件,ISO C库可以分为24个领域:
POSIX.1标准除了这些头文件,也包含了其它一些头文件。稍后会列出这部分文件。
IEEE POSIX是由IEEE(Institute of Electrical can Electronic Engineers)发展的一组标准集。POSIX全称为Portable Operating System Interface。最开始它只特指IEEE标准1003.1-1988--操作系统接口--但后来扩展后从而包含了称号1003(1003 designation)的许多标准和标准草稿,包括shell和实用工具(1003.2)。
本文专门讨论1003.1操作系统接口标准,它的宗旨是促进应用程序在各种UNIX系统环境上的可移植性。这个标准规定符合POSIX标准的操作系统都必须提供的服务。多数计算机厂商都采纳了这个标准。尽管这个标准是基于UNIX操作系统的,但它并不局限于UNIX和类UNIX系统。事实上,一些厂商在供应专利操作系统的时候,在提供专利特性的同时,仍声明他们的操作系统是符合POSIX标准的。
因为1003.1标准规定了接口而非实现,所以系统调用和库函数并没有区分开来。所有在标准里定义的指令(routines)都没称为函数(functions)。
标准总是持续发展,1003.1标准也不例外。这个标准的1988版本,IEEE标准1003.1-1998,被修改并提交给了IOS。没有添加新的接口或特性,但文字被修订了。修订好的文档作为IEEE标准1003.1-1990[IEEE 1990]发布,同时也作为国际化标准ISO/IEC 9945-1990。这个标准被普遍称为POSIX.1。
IEEE1003.1工作组仍在持续修改这个标准。在1993年,IEEE1003.1标准的一个修订版本发布,包含了1003.1-1990标准和1003.1b-1993实时扩展(real-time extensions)标准。在1996年,这个标准再次更新为ISO/IEC 9945-1:1996。它包含了多线程编程接口,被称为pthread,表示POSIX线程。随着IEEE标准1003.1d-1999的发布,更多的实时(real-time)接口在1999年被加入进来。一年后,IEEE标准1003.1j-2000发布了,包含更为丰富的real-time接口。同时IEEE标准1003.1q-2000也发布了,加入了事件跟踪扩展(event-tracing extensions)。
1003.1的2001版本与之前的版本有些不同,它集成了1003.1的一些修改、1003.2标准以及部分的Single UNIX Specification(SUS)的版本2(之后还有更新的版本)。这种集合后的标准,便是IEEE标准1003.1-2001,包含了以下其它的标准:
1、ISO/IEC 9945-1(IEEE标准1003.1-1996),包含IEEE标准1003.1-1990、IEEE标准1003.1b-1993(real-time的扩展)、IEEE标准1003.1c-1995(pthreads)和IEEE标准1003.1i-1995(real-time技术勘误);
2、IEEE P1003.1a标准草案(系统接口修订);
3、IEEE标准1003.1d-1999(高级real-time扩展);
4、IEEE标准1003.1q-2000(跟踪-tracing);
5、IEEE标准1003.2d-1994(批处理扩展);
6、IEEE P1003.2标准草案(补充工具);
7、部分IEEE标准1003.1g-2000(协议无关接口--protocol-independent interfaces);
8、ISO/IEC 9945-2(IEEE标准1003.2-1993);
9、Single UNIX Specification版本2的基本规范,包括系统接口定义(Issue 5)、命令和工具(Issue 5)、系统接口和头文件(Issue5);
10、开放组技术标准(Open Group Technical Standard),网络服务(Issue 5.2);
11、ISO/IEC 9899:1999,编程语言-C;
POSIX标准规定的必须包含的头文件有:
POSIX标准中XSI扩展的头文件有:
POSIX标准定义的可选头文件有:
因为POSIX.1包含了ISO C标准,所以之前列出的ISO C中的头文件一样也包含在POSIX.1中。POSIX.1的接口分为必需的和可选两部分,可选接口根据功能能进一步分成50个小项,这些项包括一些以下编程接口(每个接口都有一个选项代码--option code。选项代码是二个或三个字母组成的缩写,表示一个功能领域--functional area):
ADV、_POSIX_ADVISORY_INFO:咨询信息(real-time);
AIO、_POSIX_ASYNCHRONOUS_IO:异步输入输出(real-time);
BAR、_POSIX_BARRIERS:栅栏(real-time);
CPT、_POSIX_CPUTIME:进程CPU时间周期(real-time);
CS、_POSIX_CLOCK_SELECTION:时间选项(real-time);
CX、无:ISO C标准的扩展;
FSC、_POSIX_FSYNC:文件同步;
IP6、_POSIX_IPV6:IPv6接口;
MF、_POSIX_MAPPED_FILES:内存映射文件;
ML、_POSIX_MEMLOCK:进程内存锁(real-time);
MLR、_POSIX_MEMLOCK_RANGE:内存范围锁(real-time);
MON、_POSIX_MONOTONIC_CLOCK:单调锁(real-time);
MPR、_POSIX_MEMORY_PROTECTION:内存保护;
MSG、_POSIX_MESSAGE_PASSING:消息传递(real-time);
MX、无:IEC 60559浮点选项;
PIO、_POSIX_PRIORITIZED_IO:优先化输入输出;
PS、_POSIX_PRIORITIZED_SCHEDULING:进程调度(real-time);
RS、_POSIX_RAW_SOCKETS:裸套接字;
RTS、_POSIX_REALTIME_SIGNALS:real-time信号扩展;
SEM、_POSIX_SEMAPHORES:信号量(real-time);
SHM、_POSIX_SHARED_MEMORY_OBJECTS:共享内存对象(real-time);
SIO、_POSIX_SYNCHRONIZED_IO:同步输入输出(real-time);
SPI、_POSIX_SPIN_LOCKS:旋转锁(real-time);
SPN、_POSIX_SPAWN:量产(real-time);
SS、_POSIX_SPORADIC_SERVER:进程间断服务器(real-time);
TCT、_POSIX_THREAD_CPUTIME:线程CPU时间周期(real-time);
TEF、_POSIX_TRACE_EVENT_FILTER:追踪事件过滤器;
THR、_POSIX_THREADS:线程;
TMO、_POSIX_TIMEOUTS:timeout(real-time);
TMR、_POSIX_TIMERS:计时器(real-time);
TPI、_POSIX_THREAD_PRIO_INHERIT:线程优先级继承(real-time);
TPP、_POSIX_THREAD_PRIO_PROTECT:线程优先级保护(real-time);
TPS、_POSIX_THREAD_PRIORITY_SCHEDULING:线程执行调度(real-time);
TRC、_POSIX_TRACE:跟踪;
TRI、_POSIX_TRACE_INHERIT:跟踪继承;
TRL、_POSIX_TRACE_LOG:跟踪日志;
TSA、_POSIX_THREAD_ATTR_STACKADDR:线程栈地址属性;
TSF、_POSIX_THREAD_SAFE_FUNCTIONS:线程安全函数;
TSH、_POSIX_THREAD_PROCESS_SHARED:进程共享的线程同步;
TSP、_POSIX_THREAD_SPORADIC_SERVER:线程间断服务器(real-time);
TSS、_POSIX_THREAD_ATTR_STACKSIZE:线程栈地址大小;
TYM、_POSIX_TYPED_MEMORY_OBJECTS:分类内存对象(real-time);
XSI、_XOPEN_UNIX:X/Open扩展接口;
XSR、_XOPEN_STREAMS:XSI STREAMS。
POSIX.1并没有包含“超级用户”的概念。取而代之的是:有些操作需要“恰当的权限(appropriate privileges)”,POSIX把这个术语的定义交给具体的实现。遵守"Department of Defense”准则的UNIX系统有很多层安全等级。本文使用传统的术语以及谈论那些需要超级用户权限的操作。
经过近二十年的努力,标准已经变得成熟和稳定。POSIX.1标准现在被一个称为Austim Group的开放工作组维护。为了保证这些标准仍然有价值,它们需要不时地更新或重申。
单一UNIX规范(The Single UNIX Specification、SUS)是POSIX.1标准的超集,定义了一些额外的接口来扩展基本POSIX.1规范提供的功能。这个完整的系统接口集称为X/Open系统接口(X/Open System Interface,XSI)。_XOPEN_UNIX符号常量用来表示在XSI中而不在POSIX.1里定义的接口。
XSI同时来定义了遵守XSI标准的系统实现必须支持、而在POSIX.1描述为可选接口的那部分内容。这些内容包括文件同步、内存映射文件、内存保护、线程接口以及上面提到的标记为“强制SUS”的那些。只有遵守XSI标准的实现才能被称为UNIX系统。
The Open Group拥有UNIX的商标并且使用单一UNIX规范定义UNIX系统实现必须支持的接口。实现必须列出遵守的规范、通过测试来验证确实遵守了这些规范,然后获得许可来使用UNIX商标。
在XSI中定义的额外接口,有些是必须支持的,而另一些是可选的。这些接口根据功能可划分为几组:
加密(Encryption):由常量_XOPEN_CRYPT表示;
实时(real-time):由常量_XOPEN_REALTIME表示;
高级的实时(Advanced real-time);
实时线程(real-time threads):由常量_XOPEN_REALTIME_THREADS表示;
高级实时线程(Advanced real-time threads);
追踪(Tracing);
XSI STREAMS:由常量_XOPEN_STREAMS表示;
合法性:由常量_XOPEN_LEGACY表示;
单一UNIX规范(SUS)由The Open Group发布,该组织在1996年成立,由X/OPEN和Open Software Foundation(OSF)合并而成,两者同属工会(industry consortia)。X/Open常发布X/Open移植指导,该指导采纳专门的标准并填补缺失功能的空白。指导的目标是通过公布的标准,增加应用程序的可移植的可能性。
SUS的第一个版本由X/OPEN在1994年颁布。它也被熟知为“Spec 1170”,因为它有大约1170个接口。它是在Common Open Software Enviroment(COSE)的倡导下成长起来的,COSE的宗旨是进一步地提高应用程序在所有UNIX操作系统实现上的可移植性。COSE成员有:SUN、IBM、HP、Novell/USL和OSF。它不仅仅认可标准,还研究那些可能被普通商业应用使用的接口。这1170个接口便是从这些程序中选出的,而且还包含了X/Open Common Application Environment(CAE), Issue 4(根据它历史上的前身--X/Open Portability Guide,被熟知为“XPG4”)、系统V接口定义(System V Interface Definition,SVID),Edition 3, Level 1接口,还有OSF应用环境规范(Application Environment Specification,AES)Full Use接口。
SUS的第二个版本由The Open Group在1997年颁布。这个新版本加入了对线程、实时接口、64位处理、超大文件和增强双字节字符处理的支持。
SUS的第三个版本(简称SUSv3)由The Open Group在2001年颁布,SUSv3的基本标准与IEEE标准1003.1-2001一样而且被分为4个部分:基本定义、系统接口、Shell和Utilities、还有基本原理(rationale)。SUSv3还包含了X/Open Curses Issue 4, Version 2,但这个规范不是POSIX.1的一部分。
在2002年,ISO通过了这个版本作为国际标准ISO/IEC 9945:2002。The Open Group在2003年再次更新了1003.1标准,包含了技术修正,并由ISO通过作为国际标准ISO/IEC 9945:2003。在2004年4月,The Open Group颁布了单一UNIX规范的第三个版本,2004版。它主要是包含了更多的技术修正。
FIPS全称为Federal Information Processing Standard,联邦信息处理标准。它由美国政府公布,他们用它来进行电脑系统采购。FIPS151-1(1989年4月)基于IEEE标准1003.1-1998和ANSI C标准草案。接着FIPS 151-2(1993年5月)出台,基于IEEE标准1003.1-1990。FIPS 152-2要求一些POSIX.1中描述为可选的特性。所有这些可选部分作为必须支持的特性加入到POSIX.1-2001。
POSIX.1 FIPS导致任何想要卖遵守POSIX.1的计算机系统给美国政府的厂商都必须支持一些POSIX.1中的可选特性。POSIX.1 FIPS已经被撤回,所以本文不再讨论。
2.3 UNIX系统实现(UNIX System Implementations)
ISO C、IEEE POSIX和单一UNIX规范这三个标准由独立的机构创建。标准仅仅一个接口定义,那怎么把这些标准带到真实的世界呢?厂商接受这些标准并把它们转换成真实的实现。本文我们讨论标准也会讨论它们的实现。
McKusick et al. [1996]的1.1部分给出了UNIX系统族谱的详细历史和漂亮的图。所有的一切始于在PDP-11上的UNIX Time-Sharing系统的第六版(1976)和第七版(1979),通常被称为Version 6和Version 7。这些是在贝尔实验室之外广泛使用的第一个发行版。共有三个发展的分支:
1、AT&T的System III和System IV,被称为UNIX系统的商业版本;
2、伯克利的加利福尼亚大学的4.xBSD;
3、UNIX系统的实验版本,在AT&T贝尔实验室的计算机科学研究中心开发,发展成为之后的UNIX Time-Sharing系统第8版、第9版,终于1990年的第10版。
UNIX System V Release 4 (SVR4)是AT&T的UNIX系统实验室(UNIX System Laboratories,USL,前身为AT&T's UNIX Software Operation)。SVR4整合了AT&A UNIX System V Release 3.2(SVR3.2)、Sun Microsystems的SunOS操作系统、加利福尼亚大学的4.3BSD和Microsoft的Xenix系统的功能。(Xneix由Version 7发展而来,有许多功能都被System V整合)。SVR4的源代码在1989年末被公开,在1990年有第一个用户拷贝。SVR4同时遵守了PSOIX.1标准和X/Open Portability Guid, Issue 3 (XPG3)。
AT&T也发布了系统V接口定义(SVID) [AT&T 1989]。SVID的Issue 3定义了UNIX系统V Release 4的实现必须具有的功能。和POSIX.1一样,SVID描述了一个接口,而不是实现。SVID没有把系统调用和库函数区分开。必须查看真正实现的引用手册才能区分两者。
4.4BSD
伯克利软件产品(Berkeley Software Distribution,BSD)由伯克利的加利福尼亚大学的计算机系统研究组(Computer System Research Group,CSRG)生产与发行。4.2BSD在1983年发行而4.3BSD在1986年发行。两者都在VAX迷你计算机上运行。下一个版本4.3BSD Tahoe在1988年发行,同样也在一台名为Tahoe的特殊迷你计算机上运行。(Leffler等人1989年箸的书详述了4.3BSD Tahoe发行版)。接下来是1990年的4.3BSD Reno,支持了很多POSIX.1的特性。
最原始的BSD系统包含了AT&T的专利源代码,受到AT&T专利保护。你必须从AT&T拿到UNIX源码许可才可以得到BSD系统的源码。之后多年来事情发生了变化,由于很多加入Berkeley系统的新特性都是由非AT&T源继承,越来越多的AT&T源码由非AT&T源码替代。
在1989年,伯克利标识出在4.3BSD中的许多非AT&T源码,并且把它们作为BSD网络软件第一个发行版(BSD Networking Software, Release 1.0)公开。接着有1991年的第2版,继承自4.3BSD Reno版本。这样做的目的是基本上让4.4BSD系统不受AT&T许可的约束,使得任何人都能得到源码。
4.4BSD-Lite是CSRG的最终版本。由于与USL的合法战,它的出现延期了。在合法性的分歧被解决后,4.4BSD-Lite马上在1994年发行,完全没有阻碍,不需要任何UNIX源码许可就可以得到它。CSRG在1995年发行一个bug修正版。这个版本为4.4BSD-Lite, Release 2,是CSRG的最终的BSD版本。(McKusick等在1996年箸的书讲述了这个版本的BSD)。
这个由Berkeley开发的UNIX系统最开始运行在PDP-11上,接着移到VAX迷你计算机上,然后移到所谓的工作站上。在上世纪90年代,Berkeley为流行的基于80386的个人电脑提供支持,从而产生了386BSD。这是由Bill Jolitz完成的并且在1991年由Dobb博士的月刊上发表文档。它的许多代码都出现在BSD网络软件第2版中。
FreeBSD是基于4.4BSD-Lite的操作系统。在CSRG决定终止BSD的工作,而386BSD似乎被忽略太久的情况下,为了保持BSD的生产线,FreeBSD项目成立。
所有由FreeBSD生产的软件都免费公开binary和源码。FreeBSD5.2.1操作系统是本文要测试的四个系统中的一个。
Linux是一个提供丰富的UNIX编程环境的操作系统,而且在GNU公开许可下是免费的。Linux的流行是计算机工业的奇迹。Linux因为经常成为第一个支持新的硬件的操作系统而扬名。
Linux在1991年由Linux Torvalds创建,作为MINIX的替代品。草根努力迅速加入,世界各地的许多开发者都自愿奉献他们的时间来使用和增强它。
Linux的Mandrake 9.2版本是本文用来测试例子的系统之一。这个版本使用Linux操作系统内核的2.4.22版本。
Mac OS X基于与之前版本完全不同的技术。它的核心的操作系统被称为“Darwin”,基于Mach内核和FreeBSD的结合体。Darwin作为一个开源项目被管理,和FreeBSD与Linux一样。
Mac OS X 10.3版本(Darwin 7.4.0)是本文用于测试的另一个系统。
Solaris是由Sun Microsystems开发的UNIX系统版本。它基于系统V版本4,并经过了Sun Microsystems的工程师十多年的改善。它是唯一一个成功的商业化的SVR4的后裔,并且正式认证为UNIX系统。
Solaris 9 UNIX系统也是本文测试用的系统之一。
其它已经被认证的UNIX系统的版本有:
AIX:IBM的UNIX系统版本;
HP-UX:惠普(Hewlett-Packard)版本;
IRIX:由Silicon Graphics发行的UNIX系统版本;
UnixWare:SVR4的后裔,目前由SCO发售。
2.4 标准与实现的关系(Relationship of Standards and Implementations)
我们之前提过的标准定义了任何真实系统的子集。本文的重点在4个真实的系统:FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3和Solaris 9。尽管只有Solaris能真正称为UNIX系统,但四者都提供了UNIX编程环境。因为这四个系统在不同程度上都遵守POSIX,我们也只讨论POSIX.1标准规定的必需的特性,而不讨论那些POSIX与真正实现之间的不同之处。那些仅在某一特定实现上才有的特性和程序都会被指出。由于SUSv3是POSIX.1的超集,我们也会标出那些属于SUSv3而不属于POSIX.1的特性。
注意这些实现提供了对早期版本比如SVR3.2和4.3BSD的向后兼容性。比如Solaris同时支持POSIX.1的非阻塞I/O(O_NONBLOCK)和传统的系统V方法(O_NDELAY)。在本文中,我们只使用POSIX.1的特性,尽管我们会谈及它代替的非标准特性。类似地,SVR3.2和4.3BSD提供了与POSIX.1不同的可靠信号,我们只讨论POSIX.1的信号机制。
2.5 限量(Limits)
各种实现定义了很多魔数和常量。其中有许多使用临时特定的技术(ad hoc technique)被硬编码在程序里。随着各种标准化的努力,有更多的可移植的方法可以用来决定这些魔数和由实现定义的限量,极大地提高了我们软件的可移植性。
总共需要两种类型的限量:
1、编译期限量(比如short型的最大值);
2、运行期限量(比如文件名的最大字符数);
编译期限量可以被定义在头文件里,任何程序都可以在编译期引入,但是运行期限量需要进程调用函数来获得限量的值。
此外,有一些限量在一些实现上是固定的,它们能静态地定义在一个头文件里,然而在其它实现上是变化的,从而需要一个运行期的函数调用。这种类型的限量的例子有文件名的最大字符数。在SVR4前,历史上系统V只允许一个文件名里有14个字符,而BSD的后裔把这个数值增大到了255。大多数UHIX系统实现最近都支持多个文件系统类型,而且每种类型都有它自己的限量。这种情况下,一个文件名的运行期限量取决于它的文件系统。比如在root文件系统可以有14个字符的限量,而在另一个系统可以有255个字符的限量。
为了解决这样的问题,有三种类型的限量被提供:
1、编译期限量(头文件);
2、与文件和目录无关的运行期限量(sysconf函数);
3、与文件和目录有关的运行期限量(pathconf和fpathconf函数)。
如果一个特定的运行期限量在一个系统实现上是不变的,则它可以在头文件里静态定义。如果不定义在头文件里,则应用程序必须调用三个conf函数中的一个来得到运行期的值。
ISO C限量
所有在ISO C里定义的限量都是编译期限量。下表给出定义在
在 |
|||
名称 |
描述 |
可接受的最小值 |
典型值 |
CHAR_BIT | char里的位数 | 8 | 8 |
CHAR_MAX | char的最大值 | 稍后讨论 | 127 |
CHAR_MIN | char的最小值 | 稍后讨论 | -128 |
SCHAR_MAX | signed char的最大值 | 127 | 127 |
SCHAR_MIN | signed char的最小值 | -127 | -128 |
UCHAR_MAX | unsigned char的最大值 | 255 | 255 |
INT_MAX | int的最大值 | 32,767 | 2,147,483,647 |
INT_MIN | int的最小值 | -32,767 | -2,147,483,648 |
UINT_MAX | unsigned int的最大值 | 65,535 | 4,294,967,295 |
SHRT_MIN | short的最小值 | -32,767 | -32,768 |
SHRT_MAX | short的最大值 | 32,767 | 32,767 |
USHRT_MAX | unsigned short的最大值 | 65,535 | 65,535 |
LONG_MAX | long的最大值 | 2,147,438,647 | 2,147,483,647 |
LOGN_MIN | long的最小值 | -2,147,483,647 | -2,147,483,648 |
ULONG_MAX | unsigned long的最大值 | 4,294,967,295 | 4,294,967,295 |
LLONG_MAX | long long的最大值 | 9,223,372,036,854,775,807 | 9,223,372,036,854,775,807 |
LLONG_MIN | long long的最小值 | -9,223,372,036,854,775,807 | -9,223,372,036,854,775,808 |
ULLONG_MAX | unsigned long long的最大值 | 18,446,744,073,709,551,615 | 18,446,744,073,709,551,615 |
MB_LEN_MAX | 多字节字符常量的最大字节数 | 1 | 16 |
浮点数据类型定义在
另一个我们将碰到的ISO C常量是FOPEN_MAX,它表示一个系统实现可以保证的同时打开的标准I/O流的最小数量。这个值定义在
ISO C在
下表我们给出本文讨论的四个系统上FOPEN_MAX和TMP_MAX的值:
限量 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
FOPEN_MAX | 20 | 16 | 20 | 20 |
TMP_MAX | 308,915,776 | 238,328 | 308,915,776 | 17,576 |
ISO C还定义了常量FILENAME_MAX,但是我们应避免使用它,因为由于历史原因有些操作系统把它设得太小而不适合使用。
POSIX限量
POSIX.1定义了许多处理操作系统实现的限量的常量。不幸的是,这是一个更令人困惑特性。尽量POSIX.1定义了许多限量和常量,但我们仅仅关心影响基本POSIX.1接口的那些。这些限量和常量被分为五个各类:
1、不变的最小值:下表中的19个常量;
2、不变的值:SSIZE_MAX;
3、运行期可增长的值:CHARCLASS_NAME_MAX、COLL_WEIGHTS_MAX、LINE_MAX、NGROUPS_MAX、RE_DUP_MAX;
4、运行期可变化的值,可能不定:ARG_MAX、CHILD_MAX、HOST_NAME_MAX、LOGIN_NAME_MAX、OPEN_MAX、PAGESIZE、RE_DUP_MAX、STREAM_MAX、SYMLOOP_MAX、TTY_NAME_MAX和TZNAME_MAX;
5、路径名变量的值,可能不定:FILESIZEBITS、LINK_MAX、MAX_CANON、MAX_INPUT、NAME_MAX、PATH_MAX、PIPE_BUF和SYMLINK_MAX。
在 |
||
名称 | 描述:...的可接受的最小值 | 值 |
_POSIX_ARG_MAX | exec函数参数的长度 | 4,096 |
_POSIX_CHILD_MAX | 每个真实用户ID的子进程数 | 25 |
_POSIX_HOST_NAME_MAX | gethostname返回的主机名的最大长度 | 255 |
_POSIX_LINK_MAX | 一个文件的链接数 | 8 |
_POSIX_LOGIN_NAME_MAX | 登录名的最大长度 | 9 |
_POSIX_MAX_CANON | 终端最简洁的(canonical)输入队列的字节数 | 255 |
_POSIX_MAX_INPUT | 终端输入队列的可用空格 | 255 |
_POSIX_NAME_MAX | 文件名的字节数,不包括终止字符null | 14 |
_POSIX_NGROUPS_MAX | 一个进程的同步的补充组ID的数量 | 8 |
_POSIX_OPEN_MAX | 一个进程打开的文件数 | 20 |
_POSIX_PATH_MAX | 路径名的字节数,包括终止符null | 256 |
_POSIX_PIP_BUF | 可被原子写入管道的字节数 | 512 |
_POSIX_RE_DUP_MAX | 当使用间隔标记“\{m,n\}”时,被regexec和regcomp函数认可的基本正则表达式的重复出现次数 | 255 |
_POSIX_SSIZE_MAX | 可以存入ssize_t对象的值 | 32,767 |
_POSIX_STREAM_MAX | 一个进程能同时打开的标准I/O流的数量 | 8 |
_POSIX_SYMLINK_MAX | 符号链接的字节数 | 255 |
_POSIX_SYMLOOP_MAX | 路径名解析时可以转换的符号链接数 | 8 |
_POSIX_TTY_NAME_MAX | 终端设备名的长度,包括终止符null | 9 |
_POSIX_TZNAME_MAX | 时区名的字节数 | 6 |
在这44个限量和常量中,有些可能定义在
19个不变最小值定义在上表中。这些值不会因为系统不同而有所区别。它们为那些特性定义了最具有约束性的值。遵守POSIX.1的系统实现必须提供至少那么大的值,这也是为什么它们被称为最小值的原因,尽管它们名字里都含有“MAX”。还有,为了确保可移植性,一个严格遵守标准的应用程序一定不能要求一个更大的值。我们在本文会讨论所有这些常量。
一个严格遵守POSIX标准(srictly-conforming POSIX)的应用程序与一个仅遵守POSIX标准(merely POSIX conforming)的应用程序不同。一个严格遵守的应用程序只使用IEEE标准1003.1-2001的接口。一个严格遵守的应用程序是一个POSIX程序,而且不信赖于任何无定义的行为、不使用任何逐步废弃的接口、以及不使用比上表中的最小值更大的值。
不幸的是,不变最小值里有一些太小了,而不适合实际使用。比如,多数UNIX系统为每个进程打开远大于20个的文件。同样,_POSIX_PATH_MAX的255的限制也太小,路径名会超过这个限制。这意味着我们不能使用_POSIX_OPEN_MAX和_POSIX_PATH_MAX作为编译期的数组大小。
上表中的19个不变最小值中,每一个都有一个相应实现,名字为去掉_POSIX_前缀的常量名。没有_POSIX前绷的名字说明这是个真实系统实现上支持的值。这19个实现值是早先我们列出的第2~5项:不变值、运行期可增长的值、运行期不变值和路径名变量值。问题是这19个实现值并不保证一定定义在
比如,一个特定的值可能没有包含在这个头文件里,如果它的进程的真实值依赖于系统里的内存总量。如果值没有在头文件里定义,我们不能在编译期里把它作为数组边界。如以, POSIX.1决定为我们提供三个运行期函数:sysconf、pathconf和fpathconf,用来得到在运行时的真实实现值。尽管如此,仍然有个问题,POSIX.1定义的值里面有些被定义为“可能不确定的”,即理论上是“无限”的。这意味着这个值没有实际的上限。比如,在Linux上,可以被readv和writev使用的iovec结构的数量只限于系统的内存总量。为此,IOV_MAX在Linux被认为是“不确定”的,我们在讲述后面的运行期限量时再来讨论这个问题。
XSI 限量
XSI同样定义了处理实现限量的常数,包括:
1、不变最小量:下表中的10个常量;
2、数字限量:LONG_BIT和WORD_BIT;
3、运行期不变值,可能不确定:ATEXIT_MAX、IOV_MAX和PAGE_SIZE。
名称 |
描述 |
最小可接受值 |
典型值 |
NL_ARGMAX | printf和scanf调用数字的最大值 | 9 | 9 |
NL_LANGMAX | LANG环境变量的最大字节数 | 14 | 14 |
NL_MSGMAX | 最大消息数量 | 32,767 | 32,767 |
NL_NMAX | 多对一映射字符的最大字节数 | 未定义 | 1 |
NL_SETMAX | 最大集合数量 | 255 | 255 |
NL_TEXTMAX | 消息字符串的最大字节数 | _POSIX2_LINE_MAX | 2,048 |
NZERO | 默认的进程优先级 | 20 | 20 |
_XOPEN_IOV_MAX | readv和writev使用的iovec结构的最大数量 | 16 | 16 |
_XOPEN_NAME_MAX | 文件名的最大字节数 | 255 | 255 |
_XOPEN_PATH_MAX | 路径名的最大字节数 | 1,024 | 1,024 |
函数synconf、pathconf和fpathconf
我们已经列出一个系统实现必须支持的各种最小值,但是我们怎么知道一个特定系统真正支持了那些限量呢?如我们早先所述,这些限量中有些可以在编译期可用,而一些只有在运行期得到。我们也提到过有些限量在某个系统上是固定的,而其它可能是变化的,因为它们与文件或目录相关。运行期限量可以通过调用以下三个函数来得到:
#include
long sysconf(int name);
long pathconf(const char* pathname, int name);
long fpathconf(int filedes, int name);
这三个函数成功都返回一个相应的值,而失败则返回-1。
最后两个函数的区别在于一个接受路径名作为参数,而另一个接受文件描述符作为参数。
下表列出了sysconf得到系统限量所用的名字参数,都以_SC_开头:
sysconf的名字参数以及相应的限量 | ||
---|---|---|
限量名 |
描述 |
名字参数 |
AGR_MAX | exec函数参数的最大长度,以字节为单位 | _SC_ARG_MAX |
ATEXIT_MAX | 可以被atexit函数注册的函数的最大数量 | _SC_ATEXIT_MAX |
CHILD_MAX | 一个真实用户ID可以拥有的最大进程数 | _SC_CHILD_MAX |
clock ticks/second | 每秒钟的时钟周期 | _SC_CLK_TCK |
COLL_WEIGHTS_MAX | 在本地定义文件里可以赋值给LC_COLLATE命令关键字的重量的最大数量 | _SC_SOLL_WEIGHTS_MAX |
HOST_NAME_MAX | gethostname返回的主机名的最大长度 | _SC_HOST_NAME_MAX |
IOV_MAX | readv和writev使用的iovec结构的最大数量 | _SC_IOV_MAX |
LINE_MAX | 实用工具的输入行的最大长度 | _SC_LINE_MAX |
LOGIN_NAME_MAX | 登录名的最大长度 | _SC_LOGIN_NAME_MAX |
NGROUPS_MAX | 每个进程的同步补充组ID数 | _SC_NGROUPS_MAX |
OPEN_MAX | 一个进程打开文件的最大数量 | _SC_OPEN_MAX |
PAGESIZE | 系统内存页的字节数 | _SC_PAGESIZE |
PAGE_SIZE | 系统内存页的字节数 | _SC_PAGE_SIZE |
RE_DUP_MAX | regexec和regcomp函数使用间隔符\{m,n\}可以识别的基本表达式的重复次复 | _SC_RE_DUP_MAX |
STREAM_MAX | 在任何时候一个进程的标准流的最大数量;如果有定义,这个值必须与FOPEN_MAX一样 | _SC_STREAM_MAX |
SYMLOOP_MAX | 路径名解析时可以处理的符号链接数 | _SC_SYMLOOP_MAX |
TTY_NAME_MAX | 终端设备名的长度,包括终止符null | _SC_TTY_NAME_MAX |
TZNAME_MAX | 时区名的最大字节数 | _SC_TZNAME_MAX |
下表列出了pathconf和fpathconf函数使用的名字参数,都以_PC_开头:
pathconf和fpathconf的名字参数以及相应限量 | ||
限量名 | 描述 | 名字参数 |
FILESIZEBITS | 为了表示在指定目录下允许的普通文件大小的最大值,所使用的有符号整型值所需的最小比特位数 | _PC_FILESIZEBITS |
LINK_MAX | 一个文件链接数的最大值 | _PC_LINK_MAX |
MAX_CONON | 一个终端最简洁的输入队列的最大字节数 | _PC_MAX_CONON |
MAX_INPUT | 一个终端的输入队列可用空间的字节数 | _PC_MAX_INPUT |
NAME_MAX | 文件名的最大字节数,不包括终止符null | _PC_NAME_MAX |
PATH_MAX | 相对路径名的最大字节数,包括终止符null | _PC_PATH_MAX |
PIPE_BUF | 可以原子写入管道的最大字节数 | _PC_PIP_BUF |
SYMLINK_MAX | 一个符号链接的字节数 | _PC_SYMLINK_MAX |
下面再深入讨论下这三个函数的不同的返回值:
1、如果名字参数不正确,这三个函数都会返回-1并设置errno值为EINVAL。
2、一些名字参数可以返回一个变量的值,或者表明这个值是不确定的。通过返回-1但不改变erron的值来表示一个不确定的值;
3、_SC_CLK_TC的返回值是每秒钟的时钟周期,用来与times函数的返回值共同使用。
在传递路径名给pathconf和域参数给fpathconf时有些约束。如果这些约束中任意一个没有满足,则结果无定义:
1、_PC_MAX_CONON和_PC_MAX_INPUT的对应的文件并须是一个终端文件;
2、_PC_LINK_MAX的对应的文件既可以是文件也可以是目录。如果相应文件是一个目录,则返回值对应于目录本身,而不是目录下的文件;
3、_PC_FILESIZEBITS和_PC_NAME_MAX的相应文件必须是目录。返回值对应于这个目录下的文件;
4、_PC_PATH_MAX相应的文件必须是目录。当这个目录是工作目录时,返回值是相对路径名的最大长度。(不幸的是,这不是我们想知道的绝对路径的真实长度,稍后再来讨论这个问题。)
5、_PC_PIPE_BUF的相应文件必须是个管道,FIFO或者目录。前两种情况下返回值为相应管道或FIFO的限量。最后一种情况的返回值为指定目录下创建的任一FIFO的限量。
6、_PC_SYMLINK_MAX的相应文件必须是一个目录。返回值为目录里的符号链接可以包含的字符串的最大长度。
不确定的运行期限量(Indeterminate Runtime Limits)
我们之前提到了有运行期变量可以是不确定的。问题在于如果这些限量没有在
路径名(Pathname)
许多程序需要为路径名分配内存。典型地,内存在编译期分配,而一些魔数(没有一个是正确的值)被用来作为数组尺寸:256、512、1024或都标准I/O常量BUFSIZ。定义在
POSIX.1试图用PATH_MAX来解决这个问题,但如果这个值是不确定的,那我们仍然不走运。本文使用下面这个函数来动态地为路径名分配内存:
SUSv3之前的标准并没有清楚说明PATH_MAX最末尾是否包括一个null的字节。如果操作系统实现信赖于这些早期的标准,则我们需要加一个字节的内存,这样会更安全些。
处理不确定结果情况的正确方法取决于分配的空间怎样被使用。如果分配的内存是为了函数调用getcwd--比如:返回当前工作目录的绝对路径名--而且如果分配的空间太小的话,那errno会返回一个错误ERANGE。我们可以接着调用realloc来增加这块分配的空间并再次尝试。我们可以持续这样做,直到getcwd成功为止。
打开文件的最大数量(Maximum Number of Open Files)
一个后台进程(daemon process)--运行在后台,不与任何终端关联的进程--的一个普遍代码流程是关闭所有打开的文件。一些程序用以下代码,设想常量NOFILE定义在
#include
for (i = 0; i < NOFILE; i++)
close(i);
其它一些程序使用一些版本的
我们本希望用POSIX.1的值OPEN_MAX来决定这个值,但如果这个值是不确定的,我们仍然会有问题。如果我们把代码写成下面的模样,而同时OPEN_MAX是不确定的,那会造成死循环,因为sysconf会返回-1:
#include
for (i = 0; i < sysconf(_SC_OPEN_MAX); i++)
close(i);
我们最好的解决方案是仅仅关闭所有不超过一个任意上限(比如256)的描述符。和我们的pathname例子一样,这个并不保证可以在所有情况下都工作,但这是我们能做的最好的事情。看如下代码:
一些系统实现会返回LONG_MAX当作限值,来有效的表示没有限制。Linux的ATEXIT_MAX就是其一。这不是个好方法,因为它可能会导致程序很坏的行为。
比如,我们可以用Bourne-again shell的内置ulimit命令来改变我们进程可以同时打开的文件数的最大值。如果这个限制要设置成无限制的话,通常需要一个特殊(超级用户)的权限。但一旦它被设置为无限的话,sysconf会返回LONG_MAX作为OPEN_MAX的值。程序信赖于这个值作为关闭的文件描述符的上限,而尝试关闭2,147,483,647个文件描述符是非常浪费时间的,而它们之中大多数都没有被使用。
支持UNIX单一规范里的XSI扩展的系统会提供getrlimit函数。它能用来返回一个进程能打开的描述符的最大数量。利用这个函数,我们可以确保我们的进程没有打开超过预设的上限数的文件,从而避免上述问题。
OPEN_MAX被POSIX称为运行期不变量,意味着它的值不能在一个进程的生命周期内改变。但在支持XSI扩展的系统上我们可以调用setrlimit函数在为一个运行着的进程改变这个值。(这个值也可以在C shell上用limit命令改变,或在Bourne、Bourne-again和Knon shell上用ulimit函数。)如果我们的系统支持这个功能,我们可以改变上述代码:每次调用open_max函数时,都调用sysconf,而不仅仅在第一次调用时。
2.6 可选项(Options)
我们之前看过了一个POSIX.1的可选项列表,而且讨论过XSI的可选项组。如果我们要写需要选项被支持的特性的可移植的程序,我们需要一个可移植的方法来决定某个系统实现是否支持该选项。
和前面提到的限量一样,单一UNIX规范定义了三种方法来实现:
1、编译期选项,定义在
2、运行期选项,由sysconf函数得到,与特定文件或目录无关;
3、运行期选项,通过pathconf或fpathconf函数得到,与特定文件或目录有关。
可选项包含了前面在“POSIX可选接口列表”的选项,还包含了下面两个表列出的符号:
可选项以及对应sysconf的参数名 | ||
可选项名 | 描述 | 参数名 |
_POSIX_JOB_CONTROL | 指出实现是否支持job控制 | _SC_JOB_CONTROL |
_POSIX_READER_WRITER_LOCKS | 指出实现是否支持读写锁 | _SC_READER_WRITER_LOCKS |
_POSIX_SAVED_IDS | 指出实现是否支持保存的set-user-ID和保存的set-gropu-ID | _SC_SAVED_IDS |
_POSIX_SHELL | 指出实现是否支持POSIX shell | _SC_SHELL |
_POSIX_VERSION | 指出POSIX.1的版本 | _SC_VERSION |
_XOPEN_CRYPT | 指出实现是否支持XSI加密可选项组 | _SC_XOPEN_CRYPT |
_XOPEN_LEGACY | 指出实现是否支持XSI遗留可选项组 | _SC_XOPEN_LEGACY |
_XOPEN_REALTIME | 指出实现是否支持XSI实时可选项组 | _SC_XOPEN_REALTIME |
_XOPEN_REALTIME_THREADS | 指出实现是否支持XSI实时线程可选项组 | _SC_XOPEN_REALTIME_THREADS |
_XOPEN_VERSION | 指出XSI的版本 | _SC_XOPN_VERSION |
可选项以及对应的pathconf和fpathconf参数 |
||
可选项名 |
描述 |
参数名 |
_POSIX_CHOWN_RESTRICTED | 指出chown的使用是否有限制 | _PC_CHOWN_RESTRICTED |
_POSIX_NO_TRUNC | 指定路径名比NAME_MAX长时是否报错 | _PC_NO_TRUNC |
_POSIX_VDISABLE | 如果定义了,终端特殊字符可能用这个值禁掉 | _PC_VDISABLE |
_POSIX_ASYNC_IO | 指出异步I/O是否可以与相关文件使用 | _PC_ASYNC_IO |
_POSIX_PRIO_IO | 指出优先化I/O是否可以与相关文件使用 | _PC_PRIO_IO |
_POSIX_SYNC_IO | 指出同步I/O是否可以与相关文件使用 | _PC_SYNC_IO |
如果符号常量没有定义,我们必须使用syncconf、pathconf或fpathconf来确定这个可选项是否被支持。在这种情况下,函数的命名参数由替换符号名前的_POSIX为_SC或_PC而成。对于那些以_XOPEN开头的符号,命名参数必须在其之前添加_SC或_PC前缀,而非替换它。比如,如果常量_POSIX_THREADS没有定义,那我们以_SC_THREADS为参数调用sysconf来知道系统是否支持POSIX线程选项。如果常量_XOPEN_UNIX没有定义,我们可以用命名参数_SC_XOPEN_UNIX命名参数调用sysconf来知道系统是否支持XSI扩展。
如果符号常量有定义,则有三种可能性:
1、定义的值为-1,表示系统不支持该选项;
2、定义的值大于0,表示该选项被支持;
3、定义的值等于0,则我们必须调用sysconf、pathconf或fpathconf来决定这个值是否被支持。
和系统限量一样,根据sysconf、pathconf和fpathconf如何对待可选项,有几点需要注意的地方:
1、_SC_VERSION的返回值表明标准的4个数字的年份和2个数字的月份。这个值可以是198808L、199009L、199506L或一些其它可新版本标准的值。与SUS好v3相关的值为201112L。
2、_SC_XOPEN_VERSION的返回值表明系统编译的XSI版本。对应的SUSv3的值为600;
3、_SC_JOB_CONTROL、SC_SAVED_IDS和_PC_VDISABLE不再是可选的特性。从SUSv3开始,这些特性是必需的,尽管为了向后兼容这些符号仍被保留;
4、_PC_CHOWN_RESTRICTED和_PC_NO_TRUNC返回-1,但不改变errno的值,如果指定的路径名或域不支持该选项;
5、_PC_CHOWN_RESTRICTED相关的文件必须是文件或目录。如果是目录,返回值表明选项是否应用在该目录下的所有文件;
6、_PC_NO_TRUNC的相关文件必须是一个目录。返回值应用于该目录下的所有文件;
7、_PC_VDISABLE相关文件必须是一个终端文件。
在下表我们展示几个配置可选项以及它们在样例系统上的对应值。注意有些系统没有跟上SUS的最新版本。比如Mac OS X10.3支持POSIX线程,但把_POSIX_THREADS定义为:
#define _POSIX_THREADS
而没有任何的值。根据SUSv3,符号如果定义的话,其值必须为-1、0或200112。
配置可选项的例子 | |||||
限量 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 | |
UFS文件系统 | PCFS文件系统 | ||||
_POSIX_CHOWN_RESTRICTED | 1 | 1 | 1 | 1 | 1 |
_POSIX_JOB_CONTROL | 1 | 1 | 1 | 1 | 1 |
_POSIX_NO_TRUNC | 1 | 1 | 1 | 1 | 不支持 |
_POSIX_SAVED_IDS | 不支持 | 1 | 不支持 | 1 | 1 |
_POSIX_THREADS | 200112 | 200112 | 有定义 | 1 | 1 |
_POSIX_VDISABLE | 255 | 0 | 255 | 0 | 0 |
_POSIX_VERSION | 200112 | 200112 | 198808 | 199506 | 199506 |
_XOPEN_UNIX | 不支持 | 1 | 无定义 | 1 | 1 |
_XOPEN_VERSION | 不支持 | 500 | 无定义 | 3 | 3 |
标记为“无定义”的项表示这个特性没有定义,也就是说,系统没有定义这个符号常量,也没有相应的_PC或_SC参数名。相反,“有定义”的项表示符号常量被定义,但没有值,比如之前提到的_POSIX_THREADS。一个“不支持”的项表明系统定义了这个符号常量,但它的值为-1,或者它的值为0但对应的sysconf和pathconf调用的返回值为-1。
注意在Solaris上的PCFS文件系统上,_PC_NO_TRUNC的pathconf调用返回-1。PCFS文件系统支持(软盘的)DOS格式,而根据DOS文件系统的需求,DOS文件名都静默地转换为8.3的格式。
2.7 特性测试宏(Feature Test Macros)
正如我们之前所述,在头文件里定义了许多POSIX.1和XSI的符号。但多数系统实现在POSIX.1和XSI定义之外,还可加入它们自己的定义。如果我们想编译一个程序,使它只依赖于POSIX定义而不使用任何系统实现定义的限量,我们需要定义常量_POSIX_C_SOURCE。所有的POSIX.1的头文件都使用这个常量来排除所有实现定义。
POSIX.1标准的前一个版本定义了_POSIX_SOURCE常量。它在POSIX.1的2001版本被_POSIX_C_SOURCE取代而被废弃。
常量_POSIX_C_SOURCE和_XOPEN_SOURCE被称作特性测试宏。所有的特性测试宏都以下划线开头。当被使用时,它们被典型地定义在cc命令里,比如:
cc -D_POSIX_C_SOURCE=200112 file.c。
这样会在任何头文件被导入C程序之前定义特性测试宏。如果我们想要只使用POSIX.1的定义,我们可以在源文件的第一行写上:
#define _POSIX_C_SOURCE 200112
为了在程序里使用SUSv3的功能,我们必须定义常量_XOPEN_SOURCE为600。这与定义_POSIX_C_SOURCE为200112L引进POSIX.1的功能是一样的效果。
SUS定义了c99工具作为C编译环境的接口。使用它我们可以这样编译一个文件:
c99 -D_X_OPEN_SOURCE=600 file.c -o file
为了在gcc编译器启用1999 ISO C扩展,我们使用-std=99选项,比如:
gcc -D_XOPEN_SOURCE=600 -std=c99 file.c -o file
另一个特性测试宏是__STDC__,它被C编译器自动定义如果它遵守ISO C标准。这样允许我们编写在ISO C编译器和非ISO C都能编译的程序。例如,为了在编译器支持的情况下使用ISO C的原型特性,我们可以这样定义头文件:
#ifdef __STDC__
void *myfunc(const char *, int);
#else
void *myfunc();
#endif
尽管今天大多数C编译器都支持ISO标准,但__STDC__特性测试宏可以在许多头文件里找到。
2.8 原始系统数据类型(Primitive System Data Types)
历史上,特定的C数据类型与选定的UNIX系统变量相关联。比如,主设备号和从设备号被存储在一个16位的整型值里,其中8位表示主设备号,而另8位表示从设备号。然而更大的系统需要比256更大的值来表示设备号,所以需要不同的技术。(事实上,Solaris使用32位表示设备号:14位表示主设备而18位表示从设备。)
头文件
一些通用的原始系统数据类型 |
|
类型 |
描述 |
caddr_t | 核心地止(core address) |
clock_t | 时钟周期计数 |
comp_t | 压缩的时候周期 |
dev_t | 设备号(主设备和从设备) |
fd_set | 文件描述符集合 |
fpos_t | 文件位置 |
gid_t | 数字的组ID |
ino_t | i-node数 |
mode_t | 文件类型,文件创建模式 |
nlink_t | 目录项的链接数 |
off_t | 文件大小与偏移量(有符号的) |
pid_t | 进程ID和进程组ID(有符号的) |
ptrdiff_t | 两个指针的差值(有符号的) |
rlim_t | 资源限制 |
sig_atomic_t | 可以原子访问的数据类型 |
sigset_t | 信号集 |
size_t | 对象(比如字符串)的大小(无符号的) |
ssize_t | 函数的返回字节数(有符号的) |
time_t | 日历时间的秒数 |
uid_t | 数字的用户ID |
wchar_t | 表示所有不同的字符编码 |
通过用这种方式定义数据类型, 我们不在程序中包含根据系统不同而有所区别的实现细节。我们在本文后面会描述所有这些数据类型的用途。
2.9 标准之间的冲突(Conflicts Between Standards)
总的来说,这些不同的标准彼此都很好的相处。既然SUSv3是POSIX.1的超集,我们主要关心的ISO C和POSIX.1之间是任何差异。它们确实有些差异。
ISO C定义了函数clock来返回进程使用的CPU时间的量。这个返回值是clock_t值。为了把它转换成秒,我们用它除以CLOCKS_PER_SEC,它定义在
当ISO C标准规定一个函数但不像POSIX.1那样强硬地规定时,会有潜在的冲突。这种情况函数会要求在POSIX环境(在多进程环境下)和ISO C环境下(基本不设想当前的操作系统)有不同的实现。signal函数就是一个例子。如果我们不明就里地使用由Solaris提供的signal函数(期望写出可以运行在ISO C环境和更老的UNIX系统上的代码),它将提供与POSIX.1的sigaction函数不同的语义。后面在谈到signal时我们会更深入地讨论。
2.10 总结(summary)
近二十年来UNIX编程环境的标准化发生了很多事情。我们已经描述了占统治地位的标准--ISO C、POSIX和单一UNIX规范--和它们在4个实现上的影响:FreeBSD、Linux、Mac OS X和Solaris。这些标准试图定义一些可以在各个实现上改变的参数,但是我们已经看到这些限制并不完美。在本文我们会碰到很多这些限量和魔数。
3.1引言(Introduction)
我们从用来进行文件 I/O操作--打开文件、读文件、写文件等等--来开始我们关于UNIX系统的讨论。在UNIX系统上的大多数文件I/O都可以用仅仅5个函数实现:open、read、write、lseek和close。我们接着会检查下各种不同的缓冲区大小对read和write函数的影响。
这章描述的函数经常被视为未缓冲的I/O(Unbuffered I/O),与标准I/O函数相对。术语“未缓冲的”意思是每个read和write函数都会在内核进行系统调用。这些未缓冲的I/O函数不是ISO C的一部分,而是POSIX.1和SUS的一部分。
不管什么时候我们描述多个进程间的资源共享,原子操作的概念都变得很重要。我们根据文件I/O和传给open函数的参数来检查这个概念。这引发了关于文件如何在多个进程以及相关内核数据结构之间共享的讨论。在介绍完那么特性后,我们再讨论下dup、fcntl、sync、fsync和ioctl函数。
3.2 文件描述符(File Descriptor)
对内核来说,所有打开的文件都能用文件描述符来引用。一个文件描述符是一个非负整数。当我们打开一个已经存在的文件或创建一个新的文件,内核会返回一个文件描述符给进程。当我们想读或者写一个文件时,我们用open或create返回的文件描述符来标识这个文件,作为read或write的参数。
根据协定,UNIX系统外壳把文件描述符0与一个进程的标准输入关联,文件描述符1与标准输出关联,而文件描述符2与标准错误关联。这个协定被各个外壳以及很多应用程序使用。它不是UNIX内核的特性。尽管如此,如果这些相联没有的话,许多程序都会崩溃。
在POSIX程序里,魔数0、1和2应该用符号常量STDIN_FILENO、STDOUT_FILENO和STDERR_FILENO代替。这些常量在
文件描述符的取值范围从0到OPEN_MAX。早期的UNIX系统实现有19的上限,允许每个进程最多打开20个文件,但是许多系统把这个限量增加为63。
在FreeBSD 5.2.1、Mac OS X 10.3和Solaris 9,这个限量本质上是无限的,由系统内存量、整型大小和任何由系统管理员配置的软硬限量来界定。Linux 2.4.22对每个进程 的文件描述符数量给了一个硬性限量1,048,576。
3.3 open函数(open Function)
通过调用open函数可以打开或创建一个文件:
#include
int open(const char *pathname, int oflga, .../* mode_t mode */ );
如果成功返回文件描述符,错误返回-1。
第三个参数为...,是ISO C表示剩余参数的数量和类型可变的方法。对于这个函数来说,第三个参数仅当创建一个文件时使用,我们稍后讨论。我们在函数原型中把这个参数作为一个注释。
pathname是打开或创建的文件的名字,这个函数有一个选项群,由参数oflag描述。这个参数由一个或多个以下定义在
O_RDONLY:只读方式打开;
O_WRONLY:只写方式打开;
O_RDWR:读写方式打开。
为了与早期的程序兼容,多数实现把O_RDONLY定义为0、O_WRONLY定义为1、而O_RDWR定义为2。
这三个常量必须有且只能有一个被指定。以下的常量是可选的:
O_APPEND:每次写时添加到文件末尾;
O_CREAT:创建这个文件如果它不存在。这个选项需要为open函数提供第三个参数--打开模式--来指定这个新建文件的访问权限位
O_EXCL:如果O_CREAT也同时被指定而文件已存在的话会产生一个错误 。这个文件是否存在的测试以及当文件不存在时文件的创建是一个原子操作。
O_TRUNC:如果文件存在而且它以只写或读写方式被成功打开的话,把它的长度截为0;
O_NOCTTY:如果路径名指向一个终端设备,则不要为这个进程把这个设备作为控制终端分配;
O_NONBLOCK:如果文件名指向一个FIFO,一个块特殊文件或一个字符特殊文件,这个选项为打开文件及后续I/O设置非阻塞模式。
在系统V的早期版本,有个O_NDELAY(no delay)标志。这个选项和O_NONBLOCK(nonblocking)选项类似,但是会为一个读操作的返回值产生歧义。如果没有数据可从一个管道、FIFO或设备读入时,no-delay选项导致一个读操作返回0,这与碰到文件结束时返回0产生冲突。基于SVR4的系统仍然支持带有早期语义的no-delay选项,但是新的程序应该使用nonblocking选项。
下面三个标志同样是可选的。它们是SUS(也是POSIX.1)的同步输入输出选项:
O_DSYNC:让每个write都等待物理I/O的完成,但如果不影响读取已写入的数据,则不等待文件属性的更新;
O_RSYNC:让每个在文件描述符上的read操作都等待,直到任何正在执行的在文件的相同部分上的写操作都结束为止;
O_SYNC:让每个write都等待物理I/O的完成,包括write引起的文件属性更新的修改。
O_DSYNC和O_SYNC标志相似,但稍微有些不同。O_DSYNC标志仅当文件属性的更新反应了文件数据的改变时才会影响文件的属性(例如,更新文件大小来反映更多的数据。)对于O_SYNC标志,数据和属性一直都同步地更新。当使用O_DSYNC选项覆写一个文件已经存在的部分时,文件的时间不会同步更新。相反,如果我们以O_SYNC标志打开这个文件,每个write都会在write返回前更新这个文件的时间,而不管我们在覆写存在的字节还是添加到这个文件。
Solaris 9支持这三个标志。FreeBSD 5.2.1和Mac OS X 10.3有另一个与O_SYNC做相同事情的标志(O_FSYNC)。因为这两个标志是一样的,所以FreeBSD 5.2.1把它们定义成相同的值(但是很奇怪的是,Mac OS 10.3没有定义O_SYNC)。FreeBSD 5.2.1和Mac OS X 10.3没有支持O_DSYNC和O_RSYNC标志。Linux 2.4.22把这两个标志等同视为O_SYNC。
open返回的文件描述符被确保为可用描述符的最小值。一些应用程序利用这个事实在标准输入、标准输出或标准错误上打开一个新的文件。例如,一个程序可能关闭标准输出--通常情况下是文件描述符1--然后打开另一个文件,并知道它将作为描述符1被打开。我们将会看到让文件在给定描述符上打开一个文件的更好方法:dup2函数。
文件名和路径名截断(Filename and Pathname Truncation)
如果NAME_MAX是14而我们尝试在当前目录创建一个文件名包含15个字符的新文件会怎么样?System V的早期版本,比如SVR2,允许这种事发生,同时静默地把14位后的字符截断。基于BSD的系统返回一个错误状态,把errno设为ENAMETOOLONG。静默截断文件名会引起不只简单地影响文件创建的问题。如果NAME_MAX是14而有一个存在的名字为刚好14个字符的文件,任何接受路径参数的函数比如open或stat都无法知道文件原始的名字,因为原始名可能已经被截断了。
POSIX.1规定,常量_POSIX_NO_TRUNC决定是裁断长文件名和长路径名还是返回一个错误。是否返回错误是一个存在很久的问题。比如,基于SVR4的系统不会为传统的System V文件系统(S5)产生错误。然而,基于SVR4的系统却不会为BSD风格文件系统(UFS)产生错误。
另一个例子,Solaris为UFS返回错误,但不为PCFS(DOS兼容文件系统)返回错误,因为DOS静默地截断不符合8.3格式的文件名。
基于BSD的系统和Linux总是会返回错误。
如果_POSIX_NO_TRUNC起效,当整个文件名超过PATH_MAX或路径名中的任何文件名超过NAME_MAX,errno会被设为ENAMETOOLONG,而且一个错误状态会被返回。
3.4 creat函数(creat function)
一个新文件也可以通过调用creat函数来创建:
#include
int creat(cont char *pathname, mode_t mode);
成功则返回打开的只写文件描述符,错误返回-1
注意这个函数等同于:
open (pathname, O_WRONLY | O_CREAT | O_TRUNC, mode);
历史上早期的UNIX版本,open的第二个参数只能是0、1或2。当时不可能用open函数来打开一个不存在的文件。因此,一个独立的系统调用--creat,被用来创建新的文件。现在O_CREAT和O_TRUNC选项可以在open里使用,独立的creat已经不再需要了。
在4.5节当我们深入讨论文件访问权限时再来说明如何指定mode。
creat的一个缺点是打开的文件只能用来写。在open的新版本出现之前,如果我们想打开一个临时文件写入然后再读回,我们必须调用creat,close,然后再是open。一个更好的方式是使用open函数:
open (pathname, O_RDWR | O_CREAT | O_TRUNC, mode);
3.5 close函数(close function)
一个打开的文件可以通过调用close函数来关闭:
#include
int close(int filedes);
成功返回0,错误返回-1。
关闭一个文件会同时释放进程在这个文件上可能会有的任何记录锁(record locks)。我们在14.3节来讨论它。
当一个进程终止时,它所有的打开文件都被内核自动关闭。许多程序利用这个便利而不显式地关闭打开的文件。
3.6 lseek函数
每个打开的文件都有一个相应的“当前文件偏移量(current file offset)”,一般是一个表示从文件开头开始的字节数量的非负整数。(我们待会来讨论下“非负”的一个例外。)读和写的操作一般从当前文件偏移量开始,而且会根据读出或写入的字节数导致偏移量的增加。当文件打开时,这个偏移量默认初始化为0,除非O_APPEND选项被指定。
一个打开的文件的偏移量可以通过调用lseek显示地设置:
#include
off_t lseek(int fileds, off_t offset, int whence);
成功返回新的文件偏移,错误返回-1。
offset的解释取决于whence参数:
如果whence为SEEK_SET,那文件偏移量会设置为从文件开头之后的offset字节数的位置;
如果whence为SEEK_CUR,那文件偏移量会设置为当前的偏移量加上offset的值。offset可以是正也可以是负的;
如果whence为SEEK_END,那文件偏移量为文件的尺寸加上offset的值。offset可以是正也可以是负的。
因为一个成功的lseek调用会返回新的文件偏移量,我们可以通过从当前位置seek 0字节来决定当前的偏移量:
off_t currpos;
durrpos = lseek(fd, 0, SEEK_CUR);
这个技术同样也可以用来确定一个文件是否有seek的能力。如果文件描述符指向一个管道、FIFO或套接字,lseek把errno设置为ESPIPE并返回1。
这三个符号常量SEEK_SET、SEEK_CUR、SEEK_END在System V中被引入。在此之前,whence被指定为0(绝对的)、1(相对于当前偏移量)或2(相对于文件结尾)。许多软件仍然使用这些数字的硬编码。
lseek的字符“l”意思是“long integer”。在引入off_t数据类型前,offset参数和返回值是长整型。lseek在版本7当长整型被加入到C时引入。(版本6通过函数seek和tell提供类似的功能。)
下面的代码用来测试标准输入是否可以seek:
通常情况下,一个文件的当前偏移量必须是非负整数。尽管如此,有些设备可能允许负的偏移量。但对于普通文件,偏移量必须是非负的。因为负数偏移量是可能的,我们应该小心比较lseek的返回值,应测试它是否等于-1,而不能测试它是否小于0。
在Intel x86处理器上的FreeBSD的/dev/kmem设备运行负的偏移量。
因为偏移量(off_t)是一个有符号的数据类型,我们在最大文件尺寸里丢失了一个2的因子。如果off_t是32位整型,文件最大尺寸为2^32-1字节。
lseek只在内核里记录当前文件偏移量--它没有引起任何的I/O操作。这个偏移量在下次读或写操作时被使用。
文件的偏移量可以比文件当前尺寸更大,在这种情况下下次文件的write会扩展这个文件。这表示会在文件里创建一个空洞,而这是允许的。在文件里读任何没有写过的字节都会得到0。
文件的空洞不需要在磁盘上战用存储空间。取决于文件系统实现,当你找到超过文件末尾的位置然后写时,新的磁盘块可能会被分配用来存储数据,但没有必要为旧的文件末尾与你开始写的位置之间的数据分配磁盘空间。
下面的代码在创建了一个带空洞的文件:
它的大小是16394字节。再后od -c file.hole命令来查看文件的内容:
0000000 a b c d e f g h i j \0 \0 \0 \0 \0 \0
0000020 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0 \0
*
0040000 A B C D E F G H I J
0040012
可以看到没有写过的字节都被读回0。每行开头的7位数字,是表示文件偏移量的八进制数。
为了证明这个文件确实有个空洞,把它和另一个相同尺寸,没有空洞的文件进行比较:
ls -ls file file.hole file.nohole
16 -rw-r--r-- 1 tommy tommy 16394 2012-02-17 12:26 file.hole
20 -rw-r--r-- 1 tommy tommy 16394 2012-02-17 12:28 file.nohole
可以看到无洞的文件使用了20个磁盘块,而有洞的文件使用了16个块。3.8节会讨论write函数,而4.12节会深入讨论带有空洞的文件。
因为lseek使用的偏移量地址由off_t表示,所以系统实现被允许支持任何适用于它们平台的尺寸。今天多数平台提供两个操作文件偏移量的接口集:一个集合使用32位文件偏移量而另一个使用64位文件偏移量。
单一UNIX规范通过sysconf函数为应用程序提供了一种知道哪种环境被支持的方法。下表总结了定义的sysconf常量:
数据大小选项与对应在sysconf的命名参数 | ||
选项名 | 描述 | 命名参数 |
_POSIX_V6_ILP32_OFF32 | int、long、指针和off_t类型是32位的。 | _SV_V6_ILP32_OFF32 |
_POSIX_V6_ILP32_OFFBIG | int、long和指针类型是32位的,off_t是至少64位的。 | _SV_V6_ILP32_OFFBIG |
_POSIX_V6_LP64_OFF64 | int类型是32位的,long、指针和off_t类型是64位的。 | _SC_V6_LP64_OFF64 |
_POSIX_V6_LP64_OFFBIG | int类型是32位的,long、指针和off_t类型是至少64位的。 | _SC_V6_LP64_OFFBIG |
c99编译器需要我们使用getconf命令来把需要的数据大小模型映射到编译和链接我们程序时所需的标志。根据每个平台所支持的环境,可能需要不同的标志和库。
不幸的是,这是系统实现没有跟上标准的一个领域。更令人疑惑的事情是在SUS第2版和第3版之间的名字改变。
为了解决这个问题,应用程序可以通过设置_FILE_OFFSET_BITS常量的值为64来启用64位偏移。这样会把off_t的定义改变为64位有符号整型。把_FILE_OFFSET_BITS设置为32会启用32位偏移。尽管如此,要注意虽然本文讨论的四个平台都可以设置_FILE_OFFSET_BITS来同时支持32位和64位的偏移量,但这并不保证可以移植。
注意尽管你可能启用了64位偏移,你是否能创建大于2TB(2^31-1字节)的文件仍取决于底下的文件系统类型。
3.7 read函数(read Function)
可以使用read函数从打开的文件里读取数据:
#include
ssize_t read(int filedes, void *buf, size_t nbytes);
返回读取到的字节数,文件结尾则返回0,错误返回-1
以下几种情况会造成实际读取的字节数比请求的数量少:
1、在读一个普通文件时,如果在读入请求的字节数之前碰到了文件尾。比如,在请求读取100字节时,只剩30字节就到文件末尾了,read返回30。下次我们调用read时,它会返回0(文件末尾。)
2、当从一个终端设备读数据时。通常,一次最多只能读一行。(我们将在18章展示如何改变它。)
3、当从网络上读数据时。网络的缓冲区可能会导致读入的比请求的少。
4、当从一个管道或FIFO中读数据时。如果管道包含比请求量少的字节,read会只返回可用的字节数。
5、当从一个面向记录(record-oriented)设备读入数据时。一些面向记录的设备,比如磁带(magnetic type),一次可以最多返回一个记录。
6、当被一个信号中断,而只读了部分数据时。我们在10.5节深入讨论。
read操作从文件当前偏移量开始。在成功返回前,偏移量会增加实际读取的字节数。
POSIX.1改过几次这个函数的原型。经典的定义是:
int read(int filedes, char *buf, unsigned nbytes);
首先,第二个参数从char *变为void *来与ISO C保持一致:void *用来表示范型。
其次,返回值必须是一个有符号整型(ssize_t)来返回一个正的字节数、0(表示文件结尾)或-1(表示错误)。
最后,第三个参数在历史上曾经是一个无符号整型,这是为了允许16位系统实现可以一次读写最多65,534字节。1990 Posix.1标准把原始数据类型ssize_t引来来提供有符号的返回值,而无符号的size_t用作第三个参数。
3.8 write函数(write Function)
可能用write函数向一个打开的文件里写数据:
#include
ssize_t write(int filedes, const void *buf, size_t nbytes);
成功返回写入的字节数,错误返回-1。
返回值一般与nbytes参数相等,不然就说明出错了。write错误的一个常见的原因可能是磁盘写满或超过了一个进程的文件大小限制。
对于一个普通文件,写操作从文件当前偏移量开始。如果当文件被打开时O_APPEND选项被指定,每次写操作前,文件的当前偏移量会设在在文件末尾。在成功写入后,文件偏移量会增加真正写入的字节数。
3.9 I/O效率(I/O Efficiency)
下面的代码只使用read和write函数复制一个文件。
4、这个例子对于文本文件和字节文件都可以工作,因为对于UNIX内核两者没有任何区别。
我们仍有一个没有回答的问题:我们如何来选取BUFFSIZE的值?在回答这个问题之前,让我们用不同的BUFFSIZE值来运行下这个程序。我们使用上面的程序,把标准输出重定向到/dev/null。测试使用使用4,096字节的块的Linux ext2文件系统。下表显示了用20个不同的buffer值读取一个103,316,352字节的文件的结果:
在Linux上使用不同的buffer大小读取文件的结果 |
||||
BUFFSIZE |
用户CUP(秒数) |
系统CPU(秒数) |
时钟时间(秒数) |
循环数 |
1 | 124.89 | 161.65 | 288.64 | 103,316,352 |
2 | 63.10 | 80.96 | 145.81 | 51,658,176 |
4 | 31.84 | 40.00 | 72.75 | 25,829,088 |
8 | 15.17 | 21.01 | 36.85 | 12,914,544 |
16 | 7.86 | 10.27 | 18.76 | 6,457,272 |
32 | 4.13 | 5.01 | 9.76 | 3,228,636 |
64 | 2.11 | 2.48 | 6.76 | 1,614,318 |
128 | 1.01 | 1.27 | 6.82 | 807,159 |
256 | 0.56 | 0.62 | 6.80 | 403,579 |
512 | 0.27 | 0.41 | 7.03 | 201,789 |
1,024 | 0.18 | 0.23 | 7.84 | 100,894 |
2,048 | 0.05 | 0.19 | 6.82 | 50,447 |
4,906 | 0.03 | 0.16 | 6.86 | 25,223 |
8,192 | 0.01 | 0.18 | 6.67 | 12,611 |
16,384 | 0.02 | 0.18 | 6.87 | 6,305 |
32,768 | 0.00 | 0.16 | 6.70 | 3,152 |
65,536 | 0.02 | 0.19 | 6.92 | 1,576 |
131,072 | 0.00 | 0.16 | 6.84 | 788 |
262,144 | 0.01 | 0.25 | 7.30 | 394 |
524,288 | 0.00 | 0.22 | 7.35 | 198 |
可以看到当BUFFSIZE为4,096时,有一个最小的系统时间,超过这个大小的buffer没有更好的效果。
多数文件系统支持某种超前读入的方法来提高性能。当发现有一连串的读操作时,系统尝试读入比系统请求更多的数据,假设程序会在不久后读到它们。从上表中最后几行可以看到,在超过128K后ext2的超前读入就不再有效果了。
我们待会再回来讨论这个时间例子。在3.14节我们会展示同步写的效果;在5.8节我们会比较这些未缓冲I/O时间和标准I/O库。
当尝试测量读写文件的程序的性能时要注意,操作系统会尝试把文件缓存到主内存(incore,意思为main memory,过去系统主内存是用铁核--ferrite core做成的,这也是术语“core dump”的由来:一个程序的主内存映象存储到磁盘上的一个文件用来诊断),所以当你重复地测量这个程序的性能时,后面的时间会比第一次要少。这时因为第一次运行会导致文件被缓存到系统缓存中,而后续运行会从系统缓存而不是硬盘里访问文件。
在上表中的测试里,每次不同buffer尺寸的运行都是使用文件的不同拷贝,这样当前运行就不会找到上次运行的缓存数据。这些文件都已经有足够大,没有全部留在缓存中。(测试系统的配置是512M的RAM)。
3.10 文件共享(File Sharing)
UNIX系统支持多个进程共享打开的文件。在讨论dup函数前,我们需要讨论下共享。为此,我们将检查内核为所有I/O使用的数据结构。
接下来的描述是概念化的。它可能也可能不匹配于一个特定的实现。参考Bach[1986]对于System V里的这些结构的讨论。McKusick等[1996]描述了4.4BSD里的这些结构。McKusick和Neville-Neil[2005]讨论了FreeBSD 5.2。要看Solaris的类似讨论,参考Mauro和McDougall[2001]。
内核使用了三种数据结构来表示一个打开的文件,而它们之间的关系决定了一个进程在文件共享上对另一个进程的效果。
1、每个进程在进程表里都有一个项。这个项是一个打开文件描述符的表,我们可以把它看成一个向量,每个描述符对应一个项。与每个文件描述符相关的有:
a、文件描述符标志(close-on-exec;参考3.6节和3.14节。)
b、文件表项的一个指针。
2、内核为所有打开的文件维护了一个文件表。每个文件表项包含:
a、文件的文件状态标志,比如读、写、添加、同步和非阻塞;3.14节会讨论更多这方面内容;
b、当前文件偏移量;
c、文件的v-node表项的指针。
3、每个打开文件(或设备)有一个v-node结构,它包含关于文件类型和在文件上的操作的函数指针的信息。对于大多数文件,v-node同样包含了这个文件的i-node。当文件被打开时这些信息从硬盘读入,以便所有与该文件相关的信息都可读。比如,i-node包含了文件的属主,文件的大小,文件在硬盘上的真实数据块的指针,等等。(我们在4.14节深入描述典型UNIX文件系统时讨论更多关于i-nodes。)Linux没有v-node。相反,它使用一个通用i-node结构(generic i-node structure)。尽管实现不同,v-node与通用i-node在概念上是相同的。两者都指向一个特定文件系统的i-node结构。
我们会忽略一些不影响我们讨论的实现细节。比如,打开文件描述符的表可以存储在用户区域而不是进程表里。这些表可以用许多方式实现--它们不必是数组,也可以实现为结构的链表。这些实现细节都不会影响我们关于文件共享的讨论。
这三个表的排列方式自从UNIX系统的早期版本就已经存在了,而这种排列是文件在多个进程内共享的关键。当我们在本章末讨论文件共享的更多方式时,我们再回过来讨论这个排列。
v-node被发明用来支持在一个单一的计算机系统上支持多个文件系统类型。它由Peter Weinberger(贝尔实验室)和Bill Joy(Sun Microsystems)独立发明。Sun称之为虚拟文件系统(Virtual File System),而称i-node中文件系统无关的部分为v-node[Kleiman 1986]。v-node在各种厂商实现在传播开来,比如Sun的网络文件系统(Network File System,NFS)。当NFS被加入时,Berkeley的第一个提供v-node的版本是4.3BSD Reno版本。
在SVR4,v-node代替了SVR3的文件系统无关i-node。Solaris从SVR4继承而来,因此也使用v-node。
与把数据结构分为v-node和i-node不同,Linux使用一个文件系统无关的i-node和一个文件系统有关的i-node。
如果两个独立的进程有相同的打开的文件,我们可以看到以下排列:两个进程有两个不同的进程表项,每个进程表项指向不同的文件表项,但这两个不同的文件表项指向相同的一个v-node表项。每个进程都有它自己的文件表项的原因是它们都有自己的文件当前偏移量。
有了这些数据结构,我们现在需要更深入地探讨执行我们已经讨论过的特定操作时会发生什么:
1、在每个写操作完成后,文件表项里的当前文件偏移量都根据写入的字节数增加。如果这导致当前文件偏移量超过当前文件尺寸,在i-node表里的当前文件尺寸会被设置成当前文件偏移量(比如,这个文件被扩展)。
2、如果文件用O_APPEND标志打开,一个对应的标志会设置给文件表项的文件状态标志中。每次一个写操作使用这个append标志应用到一个文件上时,在文件表项的当前文件偏移量首先设置为从i-node表项中得到的当前文件尺寸。这强迫每个write都会添加到文件的当前结束位置。
3、如果文件使用lseek定位到它当前的文件末尾,所有发生的事情只有在文件表项时的当前文件偏移量被设置为从i-node表项中得到的当前文件尺寸。(注意这与以O_APPEND标志打开文件不同,我们会在3.11节看到。)
4、lseek函数仅修改在文件表项的当前文件偏移量。没有I/O发生。
多个描述符项指向同一个文件表项是可能的,正如我们将在3.12节讨论的dup函数。这同样在一个fork操作之后发生,这时父进程和子进程为每个打开的描述符共享同一个文件表项。
注意文件描述符标志和文件状态标志的范围的区别。前者仅应用于一个进程里的一个描述符,而后者应用于任何进程里指向给定文件表项的所有描述符。当我们在3.14节讨论fcntl函数时,将会看到如何获取和修改文件描述符标志和文件状态标志。
我们在本节至今讨论过的所有的东西在多进程读取相同的文件时都能良好工作。每个进程都有自己的文件表项以及自己的当前文件偏移量。尽管如此,当多个进程写入相同的文件时,可能会得到意想不到的结果。要知道怎样避免这种意外,我们需要了解原子操作的概念。
3.11 原子操作(Atomic Operations)
添加到文件
考虑一个想添加到文件末尾的单独进程。UNIX的早期版本没有为open支持O_APPEND选项,所以程序可能会以如下方式编码:
if (lseek(fd, 0L, 2) < 0)
err_sys("lseek error");
if (write(fd, buf, 100) != 100)
err_sys("write error");
这在单进程上可以工作,但如果多进程使用这种技术来添加到同一文件时会产生问题。(比如,如果一个程序的多个实例在一个日志文件上添加信息时,这种情形会发生。)
假设两个独立的进程,A和B,添加到同一个文件。每个进程打开文件但不使用O_APPEND标志。每个进程都有自己的文件表项,但它们共享一个单一的v-node表项。假设A进程用lseek设置当前偏移量为1500字节(当前的文件末尾)。然后内核交换进程,B进程继续运行。B进程然后使用lseek设置当前偏移量为1500字节(当前文件末尾)。然后B调用write,使得B的当前文件偏移量增加到1600。然后内核交换进程而A继续运行。当A调用write时,数据从A的当前文件偏移量开始写,它是1500字节。这样会覆盖掉B写入文件的数据。
这里的问题是我们的逻辑操作“定位到文件末尾并写入”需要两个函数调用。解决方法是让定位到文件末尾和写入成为一个对于其它进程而言的原子操作。任何需要两个函数调用的操作都不可能是原子的,因为内核总是可能在两个函数调用之间临时挂起一个进程。
UNIS系统提供了一个原子方式来实现这种操作:当打开文件时设置O_APPEND标志。正如我们在前一节描述的一样,这会导致内核在每次write之前定位到当前文件末尾。我们不再需要在每次write前调用lseek。
pread和pwrite函数
SUS包含了允许应用程序原子化定位并执行I/O的XSI扩展。这个扩展为pread和pwrite:
#include
ssize_t pread(int filedes, void *buf, size_t nbytes, off_t offset);
返回读取的字节数,文件末尾则返回0,错误返回-1。
ssize_t pwrite(int filedes, const void *buf, size_t nbytes, off_t offset);
成功返回写入的字节数,错误返回-1。
调用pread等价于调用lseek后再调用read,除了以下区别:
1、使用pread时不能中断这两个操作。
2、文件指针没有更新。
调用pwrite等价于调用lseek后再调用write,和上面有类似的区别。
创建一个文件
当描述open函数的O_CREAT和O_EXCL选项时,我们看过另一个原子操作的例子。当两个选项都被指定时,如果文件已经存在open会失败。我们也说过检查文件的存在和文件的创建是作为一个原子操作来执行的。如果我们没有这个原子操作,我们可能会尝试:
if ((fd = open(pathname, O_WRONLY)) <0) {
if (errno == ENOENT) {
if ((fd = creat(pathname, mode)) < 0)
err_sys("creat error");
}
}
如果在open和creat之间有另一个进程创建这个文件时会发生问题。如果在两个函数调用之间另一个进程创建了这个文件并写入了些数据,那这些数据会在creat执行时被擦除。把测试文件存在与创建合并成一个原子操作避免了这个问题。
一般来说,术语原子操作是指一个可能由多个步骤组成的操作。如果操作被原子执行,那要么多久的步骤都被执行,要么没有一个步骤被执行。不可能只有这些步骤的一个子集被执行。我们在4.15节讨论link函数时和在14.3节讨论记录锁时再回到这个原子操作的话题。
3.12 dup和dup2函数
一个存在的文件描述符可以由以下函数的其中一个复制:
#includde
int dup(int filedes);
int dup2(int filedes, int filedes2);
两者成功都返回新的文件描述符,错误返回-1。
由dup返回的新的文件描述符被保证为可用文件描述符里的最小数字。在dup2,我们用filedes2参数指定新的描述符的值。如果filedes2已经被打开,它会首先被关掉。如果filedes与filedes2相等,那么dup2返回filedes2,但不关闭它。
函数返回的新的文件描述符与filedes参数共享同一个文件表项。
我们假设一个进程启动时,执行了newfd = dup(1); 并假设下一个可用的文件描述符为3(因为0、1、2都被shell打开,所以很有可能)。因为两个描述符指向同一个文件表项,它们共享同一个文件状态标志--read、write、append等等--还有相同的当前文件偏移量。
每个描述符有自己的文件描述符标志。正如我们将在下节描述的那样,为新描述符为设的close-on-exec文件描述符标志总是被dup函数清除掉。
另一个复制描述符的方法是使用fcntl函数,我们会在3.14节讨论。事实上,函数调用dup(filedes);与fcntl(filedes, F_DUPFD, 0);等价。类似地,函数调用dup2(filedes, filedes2);与close(filedes2); fcntl(filedes, F_DUPFD, filedes2);等价。
在最后一种情况,dup2并不与close之后的fcntl完全相同。区别在于:
1、dup2是一个原子操作,而另一个调用了两个函数。后者可能会出现在close和fcntl操作之间,有一个信号捕获(signal catcher)被调用的情况。我们在第10章讨论信号。
2、dup2和fcntl有些不同的errno。dup2系统调用起源于第7版本并传播至所有的BSD版本。复制文件描述符的fcntl方法从System III开始出现,并在System V里延续。SVR3.2挑捡了dup2函数,而4.2BSD选择了fcntl函数和F_DUPFD功能。POSIX.1同时要求dup2和fcntl的F_DUPFD功能。
3.13 sync、fsync和fdatasync函数
传统的UNIX系统实现在内核有一个多数硬盘I/O操作通过的缓冲区。当我们向一个文件写入数据时,数据通常被内核拷贝到它的其中一个缓冲区,并排队以便在之后的某个时刻写入到磁盘中。这被称为延迟写入。(Bach[1986]的第3章深入讨论了缓冲缓存。)
内核最终把所有的延迟写入块写到磁盘中,一般在它需要为其它磁盘块重用这个缓冲区时。为了确保磁盘内容和缓冲区缓存的一致性,sync、fsync和fdatasync函数被提供:
#include
int fsync(int filedes);
int fdatasync(int filedes);
成功返回0,错误返回-1。
void sync(void);
sync函数简单地把所有修改过的块缓冲区为写入而排队,之后便返回。它不等待磁盘写操作的发生。
sync函数通常被一个系统daemon进程周期性地调用(通常为每30秒),这经常被称为update。这保证了内核块缓冲的一般强制输出。命令sync同样调用了sync函数。
函数fsync仅引用一个文件,由文件描述符指定,并且在返回前等待磁盘写的完成。fsync的使用是为了一个需要保证修改的块已经写入到磁盘的程序,比如数据库。
fdatasync函数与fsync相似,除了它只影响文件的数据部分。fsync会同样更新文件的属性。
本文讨论的四个平台都支持sync和fsync。但是,FreeBSD 5.2.1和Mac OS X 10.3并不支持fdatasync。
3.14 fcntl函数
fcntl函数可以改变一个已打开文件的属性:
#include
int fcntl(int fildes, int cmd, .../* int arg */);
成功返回相应的值(如下),错误返回-1。
在本节的例子里,第三个参数总是一个整型值,对应于前面函数原型的注释。但但我们在14.3节讨论记录锁时,第三个参数变为一个结构体指针。
使用fcntl函数有5种不同的目的:
1、复制一个存在的描述符(cmd=F_DUPFD)
2、得到/设置文件描述符标志(cmd=F_GETFD或F_SETFD)
3、得到/设置文件状态标志(cmd=F_GETFL或F_SETFL)
4、得到/设置异步I/O所有权(cmd=F_GEOWN或F_SETOWN)
5、得到/设置记录锁(cmd=F_GETLK、F_SETLK或F_SETLKW)
我们将讨论这十个cmd值的前7个。(我们要等到14.3节再讨论最后三个,来处理记录锁)。我们已经说明文件描述符标志与一个进程表项的各个文件描述符相关,而文件状态标志与各个文件表项相关。
F_DUPFD:复制文件描述符filedes。返回值为新的描述符。它是可用的未打开的文件描述符里的最小值,它大于或等于传入的第三个(整型)参数。新的描述符与filedes共享同一个文件表项。但新的描述符有它自己的文件描述符标志集,而且它的FD_CLOEXEC文件符标准被清除。(这意味着文件描述符会在经历一个exec的过程后仍然保持打开,我们会在第8章讨论)
F_GETFD:返回filedes的文件描述符标志。目前,只有一个文件描述符标志被定义:FD_CLOEXEC标志。
F_SETFD:为filedes设置文件描述符标志。新的标志值通过第三个(整型)参数设置。注意一些存在的处理文件描述符标志的程序没有使用常数FD_CLOSEXEC。相反,程序设置这个标志为或者0(退出时不关闭,默认值)或者1(退出时关闭)。
F_GETFL:为filedes返回文件状态标志。我们在讨论open函数时讨论过文件状态标志。下表列出各个标志:
fcntl的文件状态标志 | |
文件状态标志 | 描述 |
O_RDONLY | 打开为只读 |
O_WRONLY | 打开为只写 |
O_RDWR | 打开为读写 |
O_APPEND | 每次写时添加 |
O_NONBLOCK | 非阻塞模式 |
O_SYNC | 等待写完成(数据和属性) |
O_DSYNC | 等待写完成(仅数据) |
O_RSYNC | 同步读和写 |
O_FSYNC | 等待写完成(仅FreeBSD和Mac OS X) |
O_ASYNC | 异步I/O(仅FreeBSD和Mac OS X) |
下面代码:
当我们修改文件描述符标志或文件状态标志时,我们必须小心取存在的标志值,根据需要修改它,然后设置新的标志值。我们不能简单地执行一个F_SETFD或F_SETFL,因为这可能会关掉之前设置的标志位。
下面代码展示为文件描述符设置一个或多个文件状态标志的函数:
如果在前面测试缓冲区大小与I/O效率关系的代码里把下面这行:
set_fl(STDOUT_FILENO, O_SYNC);
加到代码的开头,我们便开启了同步写标志。这会导致每次写都会等待数据写入磁盘之后才会返回。一般来说在UNIX系统,一个write只把数据排队,而真正的磁盘写操作发生在之后的某刻。一个数据库系统很可能使用O_SYNC,以便在从wirte返回时它能知道数据已经真实写到磁盘上了,以防异常系统错误。
我们预测当程序运行时O_SYNC会增加时钟时间。为了测试它,我们可以运行之前测试I/O效率的代码,并从磁盘上的一个文件里拷贝98.5M的数据到另一个文件,同时与设置O_SYNC标志的版本相比较。比较结果如下表所示:
各种同步机制下Linux ext2时间测量结果 |
|||
操作 | 用户CPU(秒) | 系统CPU(秒) | 时钟时间(秒) |
之前测试BUFFSIZE=4,096的结果 | 0.03 | 0.16 | 6.86 |
一般的写入磁盘文件 | 0.02 | 0.30 | 6.87 |
设置O_SYNC,写入磁盘文件 | 0.03 | 0.30 | 6.83 |
在写入磁盘文件之后使用fdatasync | 0.03 | 0.42 | 18.28 |
在写入磁盘文件之后使用fsync | 0.03 | 0.37 | 17.95 |
设置O_SYNC,在写入磁盘文件后使用fsync | 0.05 | 0.44 | 17.95 |
当我们启用同步写时,系统时间和时钟时间本应该显著地增加。但如第三行显示的,同步写的时间与延迟写的时间基本相同。这蕴含着Linux ext2文件系统没有支持O_SYNC标志,这个猜测在第6行(和第5行)得到了证实:写磁盘文件后调用fsync在有无O_SYNC标志的情况下都一样大。在同步写一个文件后,我们预期fsync的调用将会没有效果。
下表展示了在Mac OS X 10.3上执行相同测试的时间结果:
各种同步机制下Mac OS X时间测量结果 |
|||
操作 | 用户CPU(秒) | 系统CPU(秒) | 时钟时间(秒) |
写入/dev/null | 0.06 | 0.79 | 4.33 |
一般的写入磁盘文件 | 0.05 | 3.56 | 14.40 |
设置O_FSYNC,写入磁盘文件 | 0.13 | 9.53 | 22.48 |
在写入磁盘文件之后使用fsync | 0.11 | 3.31 | 14.12 |
设置O_FSYNC,在写入磁盘文件后使用fsync | 0.17 | 9.14 | 22.12 |
注意时间与我们预期的一样:同步写比延迟写的耗费大了许多,而在同步写时使用fsync并没有得到不同的测量结果。同样注意到在延迟写后加入一个fsync调用同样没有测量结果上的不同。很有可能是当我们把新数据写到文件时,操作系统将先前的数据强制输出到磁盘中,所以在我们调用fsync时,已经没有多少剩余工作可以做了。
对比下更新文件内容的fsync和fdatasync,和每次写都更新文件内容的O_SYC标志。
在这个例子里,我们看到fcntl的作用。我们程序操作一个描述符(标准输出),而不知道shell通过该描述符打开的文件的名字。既然shell打开了文件,我们不能在文件被打开时设置O_SYNC标志。使用fcntl,我们可以在仅知道描述符的情况下,修改一个描述符的属性。我们可以在15.2节讨论非阻塞管道时看到fcntl的另一个用处,因为我们知道管道的所有信息就是一个描述符。
3.15 ioctl函数
ioctl函数总是包含了所有的I/O操作。任何不能用本章其它函数来表达的事情通常都需要ioctl来指定。终端I/O是这个函数的最大用户。(当我们到第18章时,我们将看到POSIX.1已经把终端I/O操作替换为单独的几个函数。)
#include
#include
#include
int ioctl(int filedes, int request, ...);
错误返回-1,成功返回其它值。
ioctl函数被作为一个处理STREAMS设备[Rago 1993]的扩展包含在单一UNIX规范里。然而UNIX系统实现却使用它执行许多混杂的设备操作。一些实现还把它扩展为可以操作普通文件。
我们展示的函数原型对应着POSIX.1。FreeBSD 5.2.1和Mac OS X 10.3把第二个参数声明为unsigned long。这种细节无关紧要,因为第二个参数总是一个头文件里的一个#defined名。
根据ISO C原型,省略号被用来表示剩余的参数。尽管如此,通常只有一个多余的参数,而它一般是指向一个变量或结构体的指针。
在这个原型里,我们只展示包含函数本身的头文件。通常需要额外的设备相关的头文件。比如超越了POSIX.1规定的基本操作的处理终端I/O的ioctl命令,都需要头文件
每个设备驱动器可以定义它自己的ioctl命令集。尽管如此,系统为不用类型的设备提供了一个通用的ioctl命令。下表给出一FreeBSD支持的这些通用iotcl的一些类目的例子:
通用FreeBSD ioctl操作 | |||
类目 | 常量名 | 头文件 | ioctl的数量 |
磁盘标签 | DIOXXX | 6 | |
文件I/O | FIOXXX | 9 | |
磁带I/O | MTIOXXX | 11 | |
套接字I/O | SIOXXX | 60 | |
终端I/O | TIOXXX | 444 |
我们在14.4节讨论STREAMS系统时、在18.12节得到和设置终端窗口的尺寸时、以及在19.7节访问伪终端的高级特性时会使用ioctl函数。
3.16 /dev/fd
更新的系统提供了一个名为/dev/fd的目录,它下面的文件名为0、1、2等。打开文件/dev/fd/n等价于假设描述符n已经打开并复制描述符n。
/dev/fd特性由Tom Duff开发,并在Research UNIX系统的第8版出现。本文提及的4个系统都支持这个特性:FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3和Solaris 9。但它不是POSIX.1的一部分。
在函数调用fd = open("dev/fd/0", mode);里,多数系统都忽略指定的mode,而其它系统要求mode为引用文件(本例为标准输入)在打开时的模式的子集。因为前面的open等价于fd = dup(0);,所以描述符0和fd共享同一个文件表项。比如,如果描述符0以只读方式打开,我们也只能通过fd来读。即使系统忽略了open模式,而且函数调用fd = open("/dev/fd/0", O_RDWR);成功了,我们也不在向fd中写入。
我们也可以用/dev/fd路径名作为creat函数和指定O_CREAT的open函数的参数。举个例子,这允许调用creat函数的程序仍然可以工作,如果路径名参数为/dev/fd/1。
一些系统提供了路径名/dev/stdin、/dev/stdout和/dev/stderr。这些路径名等价于/dev/fd/0、/dev/fd/1和/dev/fd/2。
/dev/fd文件的主要在shell里使用。它允许使用路径名参数的程序可以和处理其它路径名一样的方式,来处理标准输出和标准输入。比如,cat程序指定寻找一个名为“-”的文件名表示标准输入。命令filter file2 | cat file1 - file3 | lpr就是一个例子。首先,cat读取file1,接着是标准输入(从filter程序的file2的输出),然后是file3.如果/dev/fd被支持,那“-”的特殊处理可以从cat中移除,我们可以输入
filter file2 | cat file 1 /dev/fd/0 file3 | lpr。
“-”作为一个命令行参数表示标准输入或标准输出的特殊意思已经蔓延到很多程序。如果我们指定“-”为第一个文件同样会有问题,因为它看起来像另一个命令行选项的开头。使用/dev/fd会更加规范和清晰。
3.17 总结
这样讨论了UNIX系统提供的基本I/O函数。因为每个read和write引发一个内核的系统调用,这些通常被称为未缓冲的I/O函数。在只使用read和write的情况下,我们看到了各种I/O尺寸对于读取文件所需时间的影响。我们同样看到了强制输出已写入的数据到磁盘的几种方法,以及它们对程序性能的影响。
当多个进程添加到同一文件时,和多个进程创建一个新的文件时,原子操作被介绍了。我们也看到了内核用来共享打开文件信息所使用的数据结构。我们将在本文后面回来讨论这些数据结构。
我们也讨论了ioctl和fcntl函数。我们会在14章重回讨论这些函数,在那里我们在STREAMS I/O系统里使用ioctl,以及用fcntl来控制记录锁。
4.1 引言
在前一章我们讲述了基本的I/O函数。讨论主要集中在普通文件的I/O--打开文件、读文件或写文件。我们将看到一些文件系统和文件属性的更多特性。我们将从stat函数开始中并讲解stat结构体的每个成员,看看一个文件的所有属性。在这个过程里,我们也会讲述每个修改这些属性的函数:改变属主、改变权限等等。我们还会看到更多关于UNIX文件系统结构和符号链接的细节。我们会以操作目录的函数结束本章,同时会写一个遍历一个目录结构的函数。
4.2 stat、fstat和lstat函数
这章的讨论主要集中在这三个stat函数,以及它们返回的信息。
#include
int stat(const char *restrict pathname, struct stat *restrict buf);
int fstat(int filedes, struct stat *buf);
int lstat(const char *restrict pathname, struct stat *restrict buf);
三个函数成功都返回0,错误都返回-1。
给定一个路径名,stat函数返回一个关于名字对应的文件的信息。fstat函数得到关于已经在描述符filedes上打开的文件的信息。lstat函数与stat相似,但当文件是一个符号链接时,lstat函数返回关于符号链接的信息,而不是其所指向的文件。(我们在4.21节遍历目录结构时会用到lstat。在4.16节会更深入讲述符号链接。)
第二个参数是一个指向一个我们必须提供的结构体的指针。函数填充buf指向的结构体。结构体的定义在不同实现间会有不同,但它看起来大概是:
注意每个成员都由原始系统数据类型指定。我们将会遍历这个结构体的每个成员来检查一个文件的属性。
stat函数的最大的用户很可能是ls -l命令,用来知道一个文件的所有信息。
4.3 文件类型 (File Types)
迄今我们已经说过两个不同的文件类型:普通文件和目录。UNIX系统的多数文件都是普通文件或者目录,但也有其它的文件类型。文件类型有:
1、普通文件。最普遍的文件类型,以某种格式包含数据。对于UNIX内核而言,数据是文本还是二进制没有区别。一个普通文件内容的任何任何解释都留给处理这个文件的程序。一个显著的例外是二进制可执行文件。为了执行一个程序,内核必须了解它的格式。所有的二进制可执行文件都遵守一个允许内核来找到在哪里载入一个程序的代码和数据的格式。
2、目录文件。一个包含其它文件名和这些文件的信息指针的文件。任何从一个有目录文件读权限的进程都可以读取这个目录的内容,但只有内核才能直接向一个目录文件写入。进程必须使用这章介绍的来对一个目录进行修改。
3、块特殊文件。一种提供以固定尺寸单位的缓冲I/O方式访问设备(比如硬盘)的文件类型。
4、字符特殊文件。一种提供以可变尺寸单位的未缓冲I/O方式访问设备的文件类型。在一个系统上的所有设备不是块特殊文件就是字符特殊文件。
5、FIFO。一种用于进程间通信的文件类型。它有时被称为命令管道。我们在15.5节讨论FIFO。
6、套接字。一种用于进程间网络通信的文件类型。一个套接字也可以用来在同一主机上的进程间的非网络通信。我们在16章使用套接字来进程进程间的通信。
7、符号链接。一种指向另一个文件的文件类型。我们在4.16节更深入讨论符号链接。
一个文件的类型编码在stat结构体的st_mode成员里。我们可以使用下表的宏来决定文件类型。这些宏的参数都是stat结构体的st_mode成员:
宏 | 文件类型 |
S_ISREG() | 普通文件 |
S_ISDIR() | 目录文件 |
S_ISCHR() | 字符特殊文件 |
S_ISBLK() | 块特殊文件 |
S_ISFIFO() | 管道或FIFO |
S_ISLNK() | 符号链接 |
S_ISSOCK() | 套接字 |
POSIX.1允许系统实现把进程间通信(interprocess communication,IPC)对象,比如消息队列和信号量作为文件。在下表中的宏允许我们根据stat结构体决定IPC对象的类型。这些实验室与上表中的不同,它们的参数是指向stat结构体的指针,并非以st_mode成员:
宏 | 对象类型 |
S_TYPEISMQ() | 消息队列 |
S_TYPEISSEM() | 信号量 |
S_TYPEISSSHM() | 共享内存对象 |
消息队列、信号量和共享内存对象会在第15章讨论。尽管如此,在本文讨论的UNIX实现里没有一个把这些对象表示成文件。
下面的例子打印每个命令行参数的文件类型:
为了在Linux系统上编译这个程序,我们必须定义_GNU_SOURCE来包含S_ISSOCK宏的定义。
历史上,UNIX系统的早期版本并没有提供S_ISXXX宏。相反,我们必须对st_mode值和S_IFMT掩码进行逻辑与操作,然后比较与操作后的结果和名为S_IFxxx的常量。多数系统在
#define S_ISDIR(mode) (((mode) & S_IFMT) == S_IFDIR)
我们已经说过普通文件是占主导地位的,但看看在给定系统上每个文件类型的文件百分比是很有趣的。下表展示了使用Linux系统作为一个单一用户的工作站的数量和百分比。这个数据是通过4.21节的程序获得的:
不同文件类型的计数和百分比 |
||
文件 | 数量 | 百分比 |
普通文件 | 226,856 | 88.22% |
目录 | 23,017 | 8.95 |
符号链接 | 6,442 | 2.51 |
字符特殊 | 447 | 0.17 |
块特殊 | 312 | 0.12 |
套接字 | 69 | 0.03 |
FIFO | 1 | 0.00 |
4.4 设置用户ID和设置组ID(Set-User-ID and Set-Group-ID)
每个进程都有相关的6个或更多的ID,如下表所示:
每个进程相关的用户ID和组ID | |
真实用户ID(real user ID) 真实组ID(real group ID) |
我们真实的身份 |
有效用户ID(effective user ID) 有效组ID(effective group ID) 补充组ID(supplementary group ID) |
用于文件权限核查 |
保存的 set-user-ID(saved set-user-ID) 保存的 set-group-ID(saved set-group-ID) |
由exec函数保存 |
通常,用效用户ID和真实用户ID相等,而有效组ID和真实组ID相等。
每个文件都有一个属主和一个所属组。属主由stat结构的st_uid成员指定,而所属组由st_gid成员指定。
当我们执行一个程序文件,进程的有效用户ID常常是真实用户ID,而有效组ID常是真实组ID。然而我们可以为文件模式(st_mode)设置一个特殊的标志,表示“这个文件执行时,把进程的有效用户ID设置为文件的属主”。类似的,文件模式的另一位可以设置以便有效组ID成为文件的所属组(st_gid)。在文件模式字的这两个位称为set-user-ID位和set-group-ID位。
举个例子,如果文件的属组是超级用户,而如果文件的set-user-ID被设置,则当程序文件当作一个进程运行时,它便有了超级用户的权限,而不管执行这个文件的进程的真实用户ID。例如,允许任何人改变他或她的密码的UNIX系统程序passwd,就是一个set-user-ID程序。这是必需的,以便程序可以把新的密码写入密码文件,通常是/etc/passwd或/etc/shadow,而这些文件只应该对超级用户可写。因为一个运行set-user-ID的进程通常得到额外的权限,所以它必须小心地写。我们会在第8章更深入地讨论这种类型的程序。
回到stat函数,set-user-ID位和set-group-ID位包含在文件的st_mode值里。我们可以用常量S_ISUID和S_ISGID来测试这两个位。
4.5 文件访问权限(File Access Permission)
st_mode值也包含了文件的访问权限位。当我们说一个文件时,意思是任何我们早先提到的文件类型。所有文件类型--目录、字符特殊文件等--都有权限。许多人认为只有普通文件有访问权限。
每个文件有9个权限位,分为三个类别。如下表所示:
st_mode 掩码 | 含义 |
S_ISUSR | 用户可读 |
S_IWUSR | 用户可写 |
S_IXUSR | 用户可执行 |
S_IRGRP | 组可读 |
S_IWGRP | 组可写 |
S_IXGRP | 组可执行 |
S_IROTH | 其他人可读 |
S_IWOTH | 其他人可写 |
S_IXOTH | 其他人可执行 |
前三行的术语“用户”表示文件的属主。chmod命令,通常用来修改这9个权限位,允许我们指定u(用户,属组)、g(组)或o(其他人)。一些把这三个描述为owner、group和world,这是令人困惑的,因为chmod命令使用o表示other,而不是owner。我们将使用术语user、group和other,来与chomd命令保持一致。
上表中的三个类别--读、写和执行--被不同的函数用各种方法使用。我们在这里概括总结一下,然后在我们讨论真实的函数时再回过来讨论它们:
1、第一条规则是,无论何时我们想要通过名字打开任何类型的文件,我们必须有这个名字提及的目录的执行权限,包括当前路径,如果它是隐含的。这也是为什么目录的执行权限经常被称为查找位(search bit)。
比如,为了打开文件/usr/include/stdio.h,我们需要目录“/”、/usr、/usr/include的执行权限。根据我们如何打开这个文件(只读、读写等),我们然后需要文件本身恰当的权限。
如果当前目录是/usr/include,我们则需要当前目录的执行权限来打开文件stdio。这是个当前路径被隐含而非显示提及的例子。它等同于我们打开文件./stdio.h。
注意目录的读权限和执行权限是两码事。读权限让我们读这个目录,等到这个目录的所有文件名列表。当目录是一个我们要访问的路径名的一部分时,执行权限让我们解析这个目录。(我们需要查找这个目录来得到指定的文件名。)
另一个隐含目录的例子是:PATH环境变量(8.10节)指定了一个没有执行权限的目录。这种情况下,shell不会找到那个目录下的可执行文件。
2、文件的读权限决定了我们是否能打开一个存在的文件来读:open函数里指定O_RDONLY和O_RDWR标志。
3、文件的写权限决定了我们是否能打开一个存在的文件来写:open函数里指定O_WRONLY和O_RDWR标志。
4、我们必须有一个文件的写权限才能在open函数里指定O_TRUNC标志。
5、除非我们有一个目录的写权限和执行权限,否则我们不能在它下面创建一个新的文件。
6、为了删除一个存在的文件,我们需要包含这个文件的目录的写权限和执行权限。我们不需要文件本身的读权限或写权限。
7、如果我们想用6个exec函数(8.10节)中的任一个执行某文件,该文件的执行权限必须开启。
每当一个进程打开,创建或删除一个文件时,内核执行的文件的访问测试依赖于文件的属主和所属组(st_uid和st_gid)、进程的有效ID(用效用户ID和有效组ID)以及进程的补充组ID(如果支持的话)。属主ID和所属组ID是文件的属性,而两个有效ID和补充组ID是进程的属性。这些测试由内核以如下方式执行:
1、如果进程的有效组ID为0(超级用户),访问被允许。这给予了超级用户整个文件系统的自由。
2、如果进程的有效组ID与文件的属主ID一样(即进程拥有这个文件),且恰当的用户访问权限位被设置的话,访问被允许。否则,访问被拒绝。恰当的访问权限位,意思是进程打开文件来读时,用户读的位必须开启;如果进程打开文件来写,用户写的位必须开启;如果进程打开文件来执行,用户执行的位必须开启;等等。
3、如果进程的有效组ID或它其中一个补充组ID与文件的组ID相同,且恰当的组访问权限位被设置,访问被允许。否则访问被拒绝。
4、如果恰当的其它访问权限位被设置,访问被允许。否则访问被拒绝。
这四个步骤是依次被尝试。注意如果进程拥有这个文件(第2步),访问的接受或拒绝取决于用户访问权限,而组权限不会被查看。类似的,如果进程没有拥有这个文件,但属于一个恰当的组,访问的接受或拒绝取决于组访问权限,其它权限不会被查看。
4.6 新文件和目录的所属(Ownership of New File and Directories)
当我们在第3章描述仍用open或creat进行一个新文件的创建时,我们没有说过这个新文件的用户ID和组ID的值是多少。我们将在4.20节讨论mkdir时看到如何创建一个新的目录。一个新目录的所属规则与本节的新文件所属规则相同。
一个新文件的用户ID被设置为进程的有效用户ID。POSIX.1允许一个实现来选择以下选项来决定新文件的组ID:
1、新文件的组ID可以是进程的有效组ID;
2、新文件的组ID可以是被创建文件所在目录的组ID。FreeBSD 5.2.1和Mac OS X 10.3总是使用目录的组ID作为新文件的组ID。Linux ext2和ext3文件系统允许在POSIX.1的两个选项中选择,通过mount命令的一个特殊标志。在Linux 2.4.22(使用合适的mount选项)和Solaris 9,新文件的组ID取决于创建的文件所在的目录的set-group-ID是否被设置。如果这个位被设置,则新文件的用户ID设置为目录的用户ID,否则新文件的用户ID设置为进程的有效组ID。
使用每二个选项--从目录的组ID继承--向我们保证了在目录下创建的所有的文件和目录都会有属于该目录的组ID。文件和目录的所属组关系会随着该目录沿着目录结构往下蔓延。例如,这应用在Linuxr的/var/spool/mail目录。
如我们提到的,在FreeBSD 5.2.1和Mac OS X 10.3上,这个所属组关系是默认的,然而在Linux和Solaris上是可选的。在Linux 2.4.22和Solaris 9下,我们必须开启set-group-ID位,同时mkdir函数必须自动传播一个目录的set-group-ID位,才能达到这种所属组关系。(在4.20里会讨论。)
4.7 access函数
正如我们早先描述的那样,当我们打开 一个文件,内核会基于有效用户和组ID来进行访问测试。有时进程会想基于真实用户和组ID来执行它的访问测试。当进程使用set-user-ID或set-group-ID特性以其他人的身份运行时这非常有用。即使一个进程可能通过set-user-ID成为根用户,它可能仍想检查真实用户是否能访问给定的文件。access函数基于真实用户和组ID来测试。(把4.5节末尾的4个步骤里的“真实”替换为“有效”。)
#include
int access(const char *pathname, int mode);
成功返回0,错误返回-1。
mode为下表常量中的位或值:
mode | 描述 |
R_OK | 测试读权限 |
W_OK | 测试写权限 |
X_OK | 测试执行权限 |
F_OK | 测试文件存在 |
看下面的代码:
再运行a.out:
$ ./a.out /etc/shadow
access error for /etc/shadow
open error for /etc/shadow
把自己变为超级用户后,为a.out的设置set-userID权限:
$ su
密码:
# chown root a.out
# chmod u+s a.out
# ls -l a.out
-rwsr-xr-x 1 root tommy 7289 2012-02-22 10:26 a.out
退出root身份
# exit
exit
再执行程序(以普通用户的身份)
$ ./a.out /etc/shadow
access error for /etc/shadow
open for reading OK
在这个例子里,我们把a.out设置为set-user-ID程序,它可以查觉到真实用户(tommy)不能访问这个文件,虽然它可以使用open函数来访问该文件。
在前面的例子以及在第8章里,我们有时会变为超级用户,来证明事情是怎么工作的。如果你在一个多用户系统而没有超级用户权限的话,你无法完全重现这些例子。
4.8 umask函数
既然我们已经描述了每个文件相关的9个权限位,我们可以描述下与每个进程相关取的文件模式创建掩码(file mode creation mask)。
umask函数为进程设置文件模式创建掩码,并返回其之前的值。(这是很少的几个不返回错误值的函数之一。)
#include
mode_t umask(mode_t cmask);
返回前一个文件模式创建掩码
cmask参数由4.5节里的表中的9个常量的任意位或的值:S_IRUSR、S_IWUSR等。
每当进程创建一个新文件或一个新目录时,文件模式创建掩码就会被使用。(回想下3.3节和3.4节里我们对open和creat函数的描述。两者都接受一个mode参数来指定新文件的访问权限位。)我们在4.20节讲述如何创建一个新的目录。文件模式创建掩码的任何位都会在文件模式里关闭。
看下面的例子:
UNIX系统的多数用户从不处理他们的umask值。它通常在登录时被shell的start-up文件设置一次,然后不再改变。尽管如此,当写创建新文件的程序时,如果我们想保证特定的访问权限位被启用,我们则必须在进程运行时修改umask的值。比如,如果我们想要保证每个人都能读取一个文件,我们应该设置umask为0。不然,当我们进程运行时,起效的umask值会导致权限位被关闭。
在前一个例子里,我们使用shell的umask命令在我们运行程序前后打印文件模式创建掩码。这给我们展示了改变一人进程的文件模式创建掩码不会影响它父进程(通常是shell)的掩码。所有的shell都有一个内建的umask命令,我们可以用它来设置或打印当前的文件模式创建掩码。
用户可以设置umask值来控制他们创建的文件的默认权限。这个值以八进程表示,其中每个位表示一个屏蔽的权限,如下表所示:
umask的文件模式访问权限位 | |
掩码位 | 含义 |
0400 | 用户读 |
0200 | 用户写 |
0100 | 用户执行 |
0040 | 组读 |
0020 | 组写 |
0010 | 组执行 |
0004 | 其他人读 |
0002 | 其他人写 |
0001 | 其他人执行 |
单一UNIX规范要求shell支持umask命令的符号格式。不像八进制,符号格式指定哪些权限是被允许的(也就是说,清除文件创建掩码)而不是拒绝它(也就是设置文件创建掩码)。比较下面命令的两种格式:
$ umask
0022
$ umask -S
u=rwx,g=rx,o=rx
$ umask 027
$ umask -S
u=rwx,g=rx,o=
4.9 chmod和fchmod函数
这两个函数允许我们改变一个已存在的文件的访问权限。
#include
int chmod(const char *pathname, mode_t mode);
int fchmode(int filedes, mode_t mode);
两者成功都返回0,失败返回-1。
chmod操作一个指定的文件,而fchmod操作一个已打开的文件。
为了改变一个文件的权限位,进程的有效用户ID必须与文件的属主ID相同,或者进程必须有超级用户权限。
mode有下表的常量的位或值指定:
模式 | 描述 |
S_ISUID | 执行时的set-user-ID |
S_ISGID | 执行时的set-group-ID |
S_ISVTX | saved-text(粘滞位) |
S_IRWXU | 用户读、写、执行 |
S_IRUSR | 用户读 |
S_IWUSR | 用户写 |
S_IXUSR | 用户执行 |
S_IRWXG | 组读、写、执行 |
S_IRGRP | 组读 |
S_IWGRP | 组写 |
S_IXGRP | 组执行 |
S_IRWXO | 其他人读、写、执行 |
S_IROTH | 其他人读 |
S_IWOTH | 其他人写 |
S_IXOTH | 其他人执行 |
saved-text位(S_ISVTX)不是POSIX.1的一部分。这作为SUS的一个XSI扩展被定义。我们会在下节讲述它的用途。
看下面的代码:
在这个例子里,我们把文件bar的权限设置为一个绝对值,而不管它之前的权限位是什么。对于文件foo,我们设置它的权限位为一个相对值。为了完成这件事,我们首先调用stat来得到当前的权限然后修改它们。我们显示地打开设置组ID位并关闭组执行位。注意ls命令把组执行位设置为“S”来表示设置组ID位被设置,而组执行位没有被设置。(“s”表示两者都被设置。)
在Solaris,ls命令显示一个“1”而非“S”来表示受委托的文件和记录锁在这个文件上开启了。这只能应用在普通文件上,但我们会在14.3节讨论更多细节。
最后,注意ls命令列出的时间和日期并有在程序运行后改变。我们将在4.18节看到chmod函数只更新i-node上次改变的时间。默认情况下,ls -l会列出文件内容的最后修改时间。
chmod函数在下面的条件下会自动清除两个权限位:
1、一些系统,比如Solaris,当普通文件使用粘滞位时会赋予其特殊的意义。如果在这种系统上,我们尝试在普通文件上设置粘滞位(S_ISVTX)而没有超级用户的权限的话,模式里的粘滞位会被自动关闭。(我们在下节讨论粘滞位。)这意味着只有超级用户可以为普通文件设置粘滞位。原因是为了避免恶意用户设置粘滞位从而影响系统的性能。
在FreeBSD 5.2.1、Mac OS X 10.3和Solaris 9上,只有超级用户可以在普通文件上设置粘滞位。Linux 2.4.22没有这样的限制,因为在Linux上普通文件上的粘滞位没有任何意义。尽管FreeBSD和Mac OS X上当应用到普通文件时这个位也没有意义,但这些系统阻止除了用户外的任何人为普通文件设置这个位。
2、一个新建文件的组ID可能并不包含创建该文件的进程。回想下4.6节,有可能新建文件的组ID是父目录的组ID。特别地,如果新建文件的组ID不等于进程的有效组ID或进程的一个补充组ID,且进程没有超级用户权限,那么设置组ID位会被自动关闭。这避免了用户创建一个属于一个该用户不属于的组的设置组ID文件,
FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3和Solaris 9加入另一个安全特性,试图避免一些保护位的误用。如果一个没有超级用户权限的进程写一个文件时,设置用户ID和设置组ID位会自动关闭。如果恶意用户找到一个他们能写的设置组ID或设置用户ID文件,尽管他们能修改这个文件,但他们也失去了这个文件的特殊权限。
4.10 粘滞位(Sticky Bit)
S_ISVTX位有一个有趣的历史。在UNIX系统的提前所需页面高度的版本上,这个位被熟知为粘滞位。如果它被设置到一个可执行程序文件,那么程序第一次执行时,程序的代码(text)的一个复制会在程序终止时保留在交换区。(程序的代码部分是机器指令。)这会使程序下次运行时更快地载入到内存,因为相对于在一个普通UNIX文件系统里的数据块的可能的随机位置,交换区作为一个相接的(contiguous)文件被处理。粘滞位过支常为普通应用程序设置,比如文本编辑器和C编译器的各个阶段。自然地,在用光交换区空间前有一个可以包含在交换区里的粘滞文件数的限制,但它是个非常有用的技术。名字“粘滞”的由来是因为系统的代码段直到系统重启一直粘在交换区里。UNIX的吏后期的版本把这个指为“保存代码”位,从而常量为S_ISVTX。在今天UNIX系统的更新的版本上,多数都有虚拟内存系统和更快的文件系统,这种技术就不再需要了。
在当代系统上,粘滞位的使用被扩展。SUS允许为目录设置粘滞位。如果目录被设置粘滞位,用户只有在拥有目录的写权限并同时满足以下条件,才能删除或重命名目录里的文件:
1、拥有这个文件;
2、拥有这个目录;
3、是超级用户。
目录/tmp和/var/spool/uucppublic是粘滞位的典型侯选--它们是任何用户都能创建文件的目录。这两个目录的权限通常对所有人(用户、组、其他人)可读、可写和可执行。然而用户不该删除或重命名其他人拥有的文件。
代码保留位不是POSIX.1的一部分。它作为基本POSIX.1功能的XSI扩展定义在SUS,并被FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3和Solaris 9支持。
Soloaris9为普通文件的粘滞位赋予特殊的含义。这种情况下,如果没有执行位被设置,操作系统会缓存文件的内容。
4.11 chown、fchown和lchown函数
各chown函数允许我们改变一个文件的用户ID和组ID。
#include
int chown(const char *pathname, uid_t owner, gid_t group);
int fchown(int filedes, uid_t owner, gid_t group);
int lchown(const char *pathname, uid_t owner, gid_t group);
三者成功都返回0,失败返回-1。
这三个函数基本类似,除非引用的文件是一个符号链接。在这种情况下,lchown改变符号链接本身的属主,而不是符号链接指向的文件。
lchown函数是POSIX.1功能的XSI扩展,定义在SUS里。作为这样的身份,所有的UNIX系统实现都被期望来支持它。
如果owner或group参数的为-1,则对应的ID保持不变。
历史上,基于BSD的系统强制限制只有超级用户能改变文件的属主关系。这是为了避免用户放弃他们自己的文件给别人,从而打破硬盘空间限额的限制。尽管如此,系统V已允许任何用户改变他们拥有的任何文件的属主。
POSIX.1允许两种操作中的任何一个,取决于_POSIX_CHOWN_RESTRICTED的值。
Solaris 9上,这个功能是一个配置的选项,它的默认值是实行这个限制。FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3一直实行这个chown限制。
回想下2.6节_POSIX_CHOWN_RESTRICTED常量可以在头文件
如果_POSIX_CHOWN_RESTRICTED对指定的文件有效,那么:
1、只有超级用户进程可以改变文件的用户ID;
2、如果非超级用户进程拥有这个文件(有效用户ID和文件用户ID相等),则它可以改变文件的组ID。owner被指定为-1或与文件用户ID相同,而group与进程的有效组ID或进程某个补充组ID相同。
这意味着如果_POSIX_CHOWN_RESTRICTED有效时,你不能改变其他用户文件的用户ID。你可以改变你拥有的文件的组ID,而且你属于该组。
如果一个非超级用户进程调用了这三个函数,在成功返回后,设置用户ID和设置组ID位都会被清除。
4.12 文件尺寸(File Size)
stat结构体的st_size成员包含文件以字节为单位的尺寸。这个域只有普通文件、目录和符号链接有意义。
Solaris同样定义了管道的尺寸,表示管道可读的字节数。我们将在15.2节讨论管道。
对于一个普通文件,文件尺寸为0是允许的。我们将在第一次读取文件时得到一个文件结束标记。
对于一个目录,文件尺寸通常是一个数的倍数,比如16或512.我们在4.21讨论读目录。
对于符号链接,文件尺寸是文件名的字节数。比如,下面的情况下,文件尺寸为7,表示路径名/usr/lib的长度:
lrwxrwxrwx 1 tommy tommy 7 2012-02-22 13:54 lib -> usr/lib
(注意符号链接没有包含在名字末尾的通常C终止符,长度一直由st_size指定。)
多数当代UNIX系统提供了域st_blksize和st_blocks。前者是文件I/O最喜欢的块尺寸,而后者是所分配的512字节块的真实数量。回想下3.9节当我们使用st_blksize进行读操作时,我们碰到了读取文件所需的最小时间量。我们将在第5章讲述的标准I/O库,,同样为了效率一次读或写st_blksize字节。
注意UNIX不同的版本为st_blocks使用不是512字节块的其它值。使用这个值是不可移植的。
文件里的空洞
在3.6节,我们提到一个普通文件可以包含“空洞”。我们也展示了一个例子。空洞可以通过seek超过当前文件结束位置然后写一些数据来创建。举个例子,考虑下面:
$ ls -l file.hole
-rw-r--r-- 1 tommy tommy 8483258 2012-02-22 14:05 file.hole
$ du -s file.hole
8 file.hole
文件fle.hole的尺寸刚超过了8M,然而du命令显示文件使用的磁盘空间是8个1024字节块。(基于BSD的系统报告一个1024字节块的数量,Solaris报告512字节块的数据。)显然,这个文件有许多空洞。
正如我们在3.6节提到的那样,read函数为任何没有写过的字节部分返回字节0。如果我们执行下面的命令,我们能看到普通I/O操作通过文件尺寸来获得信息:
$ wc -c file.hole
8483258 file.hole
使用-c选项的wc命令返回文件的字符(字节)数。
如果我们使用一个比如cat的工具来复制这个文件,所有的这些空洞都会被真实的数据字节0写入:
$ cat file.hole > file.hole.copy
$ ls -l file.hole file.hole.copy
-rw-r--r-- 1 tommy tommy 8483258 2012-02-22 14:05 file.hole
-rw-r----- 1 tommy tommy 8483258 2012-02-22 14:14 file.hole.copy
$ du -s file.hole*
8 file.hole
8288 file.hole.copy
这里,新文件真正使用的字节数为8486912(8288 x 1024)。这个值和ls报告的值不同,因为文件系统使用一些块来保存指向真实数据块的指针。
4.13 文件裁切(File Truncation)
有时我们想要通过在文件末尾删除数据来裁切一个文件。我们可以在open时使用用O_TRUNC标志来清空一个文件,这是裁切的一个特殊例子。
#include
int truncate(const char *pathname, off_t length);
int ftruncate(int filedes, off_t length);
两者成功都返回0,失败返回-1。
这两个函数把已存在的文件裁切成length字节。如果文件之前的尺寸比length大,超过length后的部分便不再能被访问了。如果之前尺寸比length小,效果依系统而定,但遵守XSI的系统将会增加文件尺寸。如果实现没有扩展一个文件,文件旧末尾与新末尾之间会被读为0(也就是说,文件里很可能创建了一个空洞。)
ftruncate函数是POSIX.1的一部分,truncate函数是POSIX.1功能定义在SUS的XSI扩展。
4.4BSD之前的BSD版本通过truncate只能使文件变得更小。
Solaris还包含了一个fcntl的扩展(F_FREESP),允许我们释放文件的任何部分,而不仅仅是文件末的部分。
我们在13章里的一个程序里,需要在得到文件锁之后清空一个文件时,使用ftruncate。
4.14 文件系统
为了理解一个文件的链接的概念,我们需要UNIX文件系统结构的概念上的理解。理解i-node和指向一个i-node的目录项(directory entry)也是很有用的。
当今有各种UNIX文件系统的实现。比如,Solaris,支持几咱不同的磁盘文件系统类型:传统的基于BSD的UNIX文件系统(称为UFS)、一个(称为PCFS的)读写DOS格式磁盘的文件系统、一个(称为HSFS的)读取CD文件系统的文件系统。我们在第2章已经看过一个文件系统类型之间的区别。UFS是基于Berkely快速文件系统,我们将在本节介绍它。
我们可以认为一个磁盘驱动器分为一个或多个分区。每个分区可以包含一个文件系统,如下图所示:
i-nodes是一堆固定长度的项,包含一个文件的大多数信息。
如果我们更深入的检查磁道组(cylinder group)的i-node和数据块部分,我们可以得到下图:
我们研究下上图:
1、我们展示了两个目录项,它们指向同一个i-node项。每个i-node有一个链接数来包含指向该i-node的目录项的数量。只有当链接数减为0时文件才会被删除(也就是说,文件关联的数据块可以被释放。)这是为什么操作“解链接一个文件”并不总是意味着“删除文件相关的块”。这是为什么删除目录项的函数被称为“unlink”,而不是delete。在stat结构体里,链接数包含在st_nlink成员里。它的原始系统数据类型是nlink_t。这些链接的类型被称为硬链接。回想下2.5.2节的POSIX.1常量LINK_MAX表示一个文件链接数的最大值。
2、另一种类型的链接被称为符号链接。通过符号链接,文件的真实内容--数据块--存储着符号链接指向的文件的名字。在下面的例子里,在目录项里的文件名是3个字符的lib而在文件的数据里的7个字节是/usr/lib:
lrwxrwxrwx 1 tommy tommy 7 2012-02-22 13:54 lib -> usr/lib
i-node里的文件类型应该是S_IFLNK,以使系统知道这是个符号链接。
3、i-node包含文件的所有信息:文件类型、文件访问权限位、文件尺寸、指向文件数据块的指针、等等。stat结构体的多数信息都是从i-node里得到。只有两个有关的项是存储在目录项里:文件名和i-node号。其它项--文件名的长度和目录记录的长度--不在这里讨论。i-node数的数据类型为ino_t。
4、因为目录项指向的i-node号指向在同一文件系统的一个i-node,我们不能有一个指向另一个文件系统的i-node的目录项。这也是为什么ln命令(创建一个指向已存在文件的新的目录项)不能跨越文件系统。我们在下节讲述link函数。
5、当重命名一个文件而不改变文件系统时,文件的真实内容不需要被移动--所有需要做的就是加上一个指向已存在的i-node的新的目录项,然后解链接旧的目录项。链接数会保持不变。比如,为了重命名文件/usr/lib/foo为/usr/foo,如果目录/usr/lib和/usr在同一个文件系统上,文件foo的内容不必移动。这时mv命令通常如何执行的。
我们已经讨论过一个普通文件的链接数,但一个目录的链接数域是怎么样的呢?假设我们在工作目录创建一个新的目录,如:
$ mkdir testdir
下图显示了结果。注意这张图里我们显式地展示了.和..的项:
号为2549的i-node有一个“direcotry”的类型域,和等于2的链接数。任何叶目录(一个不包含任何其它目录的目录)总是有值为2的链接数。这个2值是从以这个目录为名(testdir)的目录项和该目录的.项而来。号为1267的i-node有一个“directory”类型域和一个大于等于3的链接数。我们知道链接数大于等于3的原因是,最低程度下,它被以目录为名的目录(图中没有展示)、点目录、和在testdir的点点目录。注意父目录里的每个子目录都导致父目录的链接数增一。
这个格式与UNIX文件系统的经典格式相似。
4.15 link、unlink、remove和rename函数
正如我们在前一节看到的,任何文件可以有多个目录项指向它的i-node。我们创建一个已存在的文件的链接的方法是使用link函数。
#include
int link(const char *existingpath, const char *newpath);
成功返回0,失败返回-1。
这个函数创建一个新的目录项newpath,指向已存在文件exsitingpath。如果newpath已存在,则返回一人错误。只有newpath的最后一个部分被创建。路径的其余部分必须已经存在。
新目录项的创建和链接接的增加必须是一个原子操作。(回想下3.11节关于原子操作的讨论。)
多数实现需要两个路径名在同一个文件系统,尽管POSIX.1允许一个实现支持跨越文件系统的链接。如果一个实现支持目录硬链接的创建,它只能由超级用户完成。原因是这样做可能会引起文件系统的回路,多数处理文件系统的工具没有能力处理。(我们在4.16节展示由一个符号链接引起回路的一个例子。)许多文件系统实现因为这个原因不允许目录的硬链接。
为了删除一个已存在的目录项,我们调用unlink函数。
#include
int unlink(const char *pathname);
成功返回0,错误返回-1。
函数删除目录项并减少pathname引用的文件的链接数。如果文件有其它链接,文件的数据仍可以通过别的链接访问。如果错误发生,文件不会改变。
我们已经提过在解链接文件之前,我们必须有包含该目录项的目录的写权限和执行权限,因为这是我们正删除的目录项。同样,我们在4.10节也提过如果目录设置了粘滞位,我们必须有这个目录的写权限和以下三者之一:拥有该文件、拥有该目录、有超级用户权限。
只有当链接数减为0时文件的内容才能删除。另一个阻止文件内容被删除的条件是:只要有一些进程正打开该文件,它的内容不能被删除。
当一个文件被关闭后,内核首先检查打开该文件的进程数量。如果进程数为0,内核检查链接数,如果链接数为0,文件内容被删除。
看下面的代码:
再看看可用的剩余空间
tommy@tommy-zheng-ThinkPad-T61:~/code/c/unix$ df /home
文件系统 1K-块 已用 可用 已用% 挂载点
/dev/sda1 73843936 9101884 60990936 13% /
后台方式执行程序
$ ./a.out &
[1] 4991
$ file unlinked
查看文件被unlinked后文件是否存在
ls -l tempfile
ls: 无法访问tempfile: 没有那个文件或目录
查看unlink后可用的空间(没有变化)
$ df /home
文件系统 1K-块 已用 可用 已用% 挂载点
/dev/sda1 73843936 9101884 60990936 13% /
程序运行完毕,查看文件关闭后可用的空间(已释放文件内容)
$ done
df /home
文件系统 1K-块 已用 可用 已用% 挂载点
/dev/sda1 73843936 9101816 60991004 13% /
unlink的属性通常被程序用来确保当崩溃的,它创建的临时文件不会留在那里。进程用open或creat创建一个文件然后立即调用unlink。尽管如此,文件并没有被删除,因为它仍被打开。只有当进程关闭这个文件或终止时(导致内核关闭它的所有的打开文件),文件才会被删除。
如果路径名是一个符号链接,unlink删除这个符号链接,而不是链接指向的文件。没有给定链接名删除其指向的文件的函数。
超级用户可以用目录的路径名调用unlink,但是函数rmdir函数应该被用来解链接一个目录,而不是unlink。我们在4.20节讲述rmdir函数。
我们还可以用remove函数解链接一个文件或目录。对于一个文件,remove与unlink相同。对于一个目录,remove和rmidir相同。
#include
int remove(const char *pathname);
成功返回0,失败返回-1。
ISO C规定了remove函数来删除一个文件。名字从历史上的UNIX名字unlink改变是因为那时实现C标准的多数非UNIX系统都不支持文件链接的概念。
一个文件或目录可以用rename函数来重命名。
#include
int rename(const char *oldname, const char *newname);
成功返回0,失败返回-1。
这个函数被ISO C为文件定义。(C标准没有处理目录。)POSIX.1扩展了这个定义,包含了目录和符号链接。
根据oldname是指向一个文件、目录还是符号链接,我们有几种条件来讲述下。我们必须说下如果newname已经存在时会发生什么:
1、如果oldname指定一个不是目录的文件,那我们就是重命名一个文件或符号链接。在这种情况下,如果newname已经存在,它不能是一个目录。如果newname已存在且不是一个目录,那么它会被删除,而且oldname会重命名为newname。我们必须有包含oldname的目录和包含newname的目录的写权限,因为我们改变了两个目录。
2、如果oldname指向一个目录,那么我们正在重命名一个目录。如果newname存在的话,它必须是一个目录,而且它必须为空。(当我们说目录为空时,我们指这个目录里的项只有点和点点。)如果newname存在且为空时,它被删除,oldname被重命名为newname。额外地,当我们重命名一个目录时,newname不能包含以oldname为前缀的路径。比如,我们不能重命名/usr/foo为/usr/foo/testdir,因为旧名字(/usr/foo)是新名字的路径前缀,且不能被删除。
3、如果oldname或newname指向一个符号链接,链接本身被处理,而不是它解析的文件。
4、作为一个特殊例子,如果oldname和newname是同一个文件,那么函数成功返回但不改变任何东西。
如果newname已经存在,我们需要有删除它的权限。同样,因为我们正在删除oldname的目录项而且可能创建newname的目录项,我们需要包含旧文件所在目录和新文件所在目录的写权限和执行权限。
4.16 符号链接
一个符号链接是一个文件的间接指针,不像上节的硬链接--直接指向文件的i-node。符号链接用来解决硬链接的限制。
1、硬链接通常需要链接和文件处在同一个文件系统上。
2、只有超级用户才能创建目录的硬链接。
符号链接和它指向的文件没有文件系统的限制,任何人都可以创建一个目录的符号链接。符号链接被典型用来把一个文件或整个目录结构移到系统的另一个位置。
符号链接在4.2BSD上引入,之后被SVR4支持。
当使用通过名字表示文件的函数时,我们问题需要知道函数是否解析一个符号链接。如果函数解析一个符号链接,函数的路径名参数就表示符号链接指向的文件。否则,路径名参数表示符号链接本身,而不是链接指向的文件。下表总结了本章函数是否解析一个符号链接:
函数 | 不解析符号链接 | 解析符号链接 |
access | * | |
chdir | * | |
chmod | * | |
chown | * | * |
creat | * | |
exec | * | |
lchown | * | |
link | * |
在Linux的早期版本(在2.1.81之前),chown没有解析符号链接。从2.1.81之后,chown开始解析符号链接。在FreeBSD 5.2.1和Mac OS X 10.3上,chown解析符号链接。(4.4BSD之前,chown没有解析符号链接,但这在4.4BSD上得以改变。)在Solaris 9,chown同样解析符号链接。所有的这些平台都提供一个lchown的实现来改变符号链接本身的属主。
上表中的一个例外是当open函数在O_CREAT和O_EXCL同时被设置时被调用。在这种情况下,如果路径名是一个符号链接,open会失败,errno被设为EEXIST。这种行为是为了关闭安全漏洞以便有权限的进程不会被愚弄导致写入一个错误文件。
使用符号链接有可能把回路引进文件系统。当它发生时,多数查询路径名的函数返回一个ELOOP的errno。考虑以下命令:
$ mkdir foo
$ touch foo/a
$ ln -s ../foo foo/testdir
$ ls -l foo
总用量 0
-rw-r--r-- 1 tommy tommy 0 2012-02-22 17:21 a
lrwxrwxrwx 1 tommy tommy 6 2012-02-22 17:22 testdir -> ../foo
这些命令创建了一个目录foo,它包含文件a,还有一个指向foo的符号链接。这样便产生了一个回路。如果使用Solaris的ftw标准函数来遍历文件结构,打印所有碰到的路径名,输出为:
foo
foo/a
foo/testdir
foo/testdir/a
foo/testdir/testdir/a
foo/testdir/testdir
foo/testdir/testdir/testdir/a
(更多的行直到我们遇到ELOOP错误。)
在4.21节,我们会提供我们自己的ftw函数的版本,使用lstat而不是stat,来避免解析符号链接。
同样使用Linux上的tree命令可以看到:
$ tree -l foo
foo
├── a
└── testdir -> ../foo [recursive, not followed]
在Linux上的ftw函数使用lstat,所以不会有上述行为发生。
这种格式的回路很容易删除。我们可以unlink文件foo/testdir,因为unlink不会解析一个符号链接。然后如果我们创建一个这种类型的回路的硬链接,它的删除将会困难的多。这是为什么除非进程有超级用户权限,否则link函数不会硬链接一个目录。
事实上,Rich Stevens(第一版的作者)在原版中写这节时在他自己的系统上作过实验。文件系统崩溃了,而且普通的fsck工具也不能修好。需要不被赞成使用的工具clri和dcheck来修复文件系统。
目录硬链接的需求很早之前就有了。有了符号链接和mkdir函数,任何不再需要创建目录的硬链接了。
当我们打开一个文件时,如果传入open的路径名是一个符号链接,open解析这个链接到指定的文件。如果指向的文件不存在,open返回错误表明它不能打开这个文件。这会使不熟悉符号链接的用户困惑。比如:
$ ln -s /no/such/file/ myfile
$ ls myfile
myfile
$ cat myfile
cat: myfile: 没有那个文件或目录
$ ls -l myfile
lrwxrwxrwx 1 tommy tommy 14 2012-02-22 17:45 myfile -> /no/such/file/
文件myfle确实存在,然而cat却说没有这个文件,因为myfile是一个符号链接而它指向的文件不存在。ls的-l选项给了我们两个提示:第一个字符是l,意思是符号链接,而符号->同样表示它是个符号链接。ls命令有另一个选项(-F),可以在符号链接的文件名后加上一个符号@,可以在不使用-l选项的时候帮助我们标记一个符号链接:
ls -F myfile
myfile@
4.17 symlink和readlink函数
symlink函数可以用来创建符号链接。
#include
int symlink(const char *actualpath, const char *sympath);
成功返回0,错误返回-1。
一个新的目录项sympath会被创建指向actualpath。当符号链接被创建时,并不需要actualpath存在。(在前一节的末尾的例子里我们已经看到这种情况了。)还有,actualpath和sympath不需要在同一个文件系统上。
因为open函数解析一个符号链接,我们需要一个打开链接本身以及读取链接里的名字的方法。readlink函数做这这件事情。
#include
ssize_t readlink(const char* restrict pathname, char *restrict buf, size_t bufsize);
成功返回字节数,失败返回-1。
这个函数合并了open、read和close的操作。如果函数成功,它返回buf里的字节数。返回到buf的符号链接的内容不以null终止。
4.18 文件时间(File Times)
每个文件维护了三个时间域。下表总结了它们的目的:
与每个文件相关的三个时间域 |
|||
域 | 描述 | 例子 | ls 选项 |
st_atime | 文件数据的最后访问时间 | read | -u |
st_mtime | 文件数据的最后修改时间 | write | 默认 |
st_ctime | i-node状态的最后改变时间 | chmod, chown | -c |
注意修改时间(st_mtime)和改变状态时间(st_ctime)的区别。修改时间是文件内容最后被修改的时间。状态改变时间是文件的i-node最后被修改的时间。在本章,我们已经描述过许多影响i-node而不改变文件真实内容的操作:改变文件的访问权限、改变用户ID、改变链接数、等等。因为i-node的信息与文件真实内容分开存放,所以我们在修改时间之外,需要状态改变时间。
注意系统没有为一个i-node维护最后访问时间。这是为什么函数access以及stat没有改变这三个时间的任一个的原因。
访问时间通常被系统管理员使用来删除有一定时间没有被访问过的文件。典型的例子是删除上星期没被访问过的名为a.out和core的文件。find命令经常被作为这种类型的操作。
修改时间和状态改变时间可以用来存档那些内容或i-node被修改过的文件。
ls命令显示或排序这三个时间值中的一个。当使用-l或-t选项时,它默认使用文件的修改时间。-u选项使它使用访问时间,而-c选项令它使用状态改变时间。
下表总结了我们描述过的操作这三个时间的各种函数。回想在4.14节,一个目录只是一个简单地包含目录项(文件名和相关的i-node号)的文件。增加、删除或修改这些目录项会影响目录相关的三个时间。这也是为什么下表包含了一行来展示文件或目录的三个时间,和另一行展示父目录相关的三个时间。例如,创建一个新文件影响包含该新建文件的目录,也会影响这个新文件的i-node。然而,读或写一个文件仅影响文件的i-node而不影响目录。(mkdir和rmdir函数在4.20节介绍。utime函数在下节介绍。6个exec函数在8.10节介绍。mkfifo和pipe函数在第15章介绍。)
各种函数对访问时间、修改时间和状态改变时间的影响 | ||||||||
函数 | 引用的文件或目录 | 引用文件或目录的父目录 | 章节 | 注释 | ||||
a | m | c | a | m | c | |||
chmod, fchmod | * | 4.9 | ||||||
chown, fchown | * | 4.11 | ||||||
creat | * | * | * | * | * | 3.4 | O_CREAT新文件 | |
creat | * | * | 3.4 | O_TRUNC已有文件 | ||||
exec | * | 8.10 | ||||||
lchown | * | 4.11 | ||||||
link | * | * | * | 4.15 | 第二个参数的父目录 | |||
mkdir | * | * | * | * | * | 4.20 | ||
mkfifo | * | * | * | * | * | 15.5 | ||
open | * | * | * | * | * | 3.3 | O_CREAT新文件 | |
open | * | * | 3.3 | O_TRUNC已有文件 | ||||
pipe | * | * | * | 15.2 | ||||
read | * | 3.7 | ||||||
remove | * | * | * | 4.15 | 删除文件=unlink | |||
remove | * | * | 4.15 | 删除目录=rmdir | ||||
rename | * | * | * | 4.15 | 两个参数的时间 | |||
rmdir | * | * | 4.20 | |||||
truncate, ftruncate | * | * | 4.13 | |||||
unlink | * | * | * | 4.15 | ||||
utime | * | * | * | 4.19 | ||||
write | * | * | 3.8 |
4.19 utime函数
文件的访问时间和修改时间可以用utime函数改变。
#include
int utime(const char *pathname, const struct utimebuf *times);
成功返回0,失败返回-1。
这个函数使用的结构体为:
struct utimbuf {
time_t actime; /* 访问时间 */
time_t modtime; /* 修改时间 */
};
在这个结构体里的两个时间值是日期时间,正如1.10节描述的那样,是从Epoch开始的秒数。
这个函数的操作和执行它所需的权限,取决于时间参数时否为NULL。
1、如果times为null指针,访问时间和修改时间都被设为当前时间。要这样做的话,进程的有效有用ID必须和文件的属主ID相同,或者进程必须有这个文件的写权限。2
2、如果times是一个非空指针,访问时间和修改时间设为times指向的结构体的值。在这种情况下,进程的有效用户ID必须和文件的属主ID相同,或者进程必须是一个超级用户进程。仅有文件的写权限是不够的。
注意我们不能为状态改变时间指定一个值,st_ctime--i-node最近修改的值--因为这个域utime函数被调用时会被自动更新。
UNIX系统的一些实现,touch命令使用了这个函数。还有,标准存档程序,tar和cpio,选择性地调用utime来把文件的时间设为文件被存档的时间。
下面的程序使用O_TRUNC选项的open函数把文件裁切成0长度,但不改变它们的访问或修改时间。要这样做,程序首先通过stat函数得到时间,接着裁切文件,然后用utime函数重设时间:
4.20 mkdir和rmdir函数
目录通过mkdir函数来创建,通过rmdir函数删除。
#include
int mkdir(const char *pathname, mode_t mode);
成功返回0,失败返回-1。
这个函数创建一个新的,空的目录。点和点点的目录项被自动创建。指定的文件访问权限,mode,被进程的文件模式创建掩码所修改。
一个常见的错误是指定一个和文件一样的mode:只是读和写权限。然而对于目录而言,我们通常想要至少一个执行位被启用,以便访问目录的文件名。
新目录的用户ID和组ID根据我们在4.6节描述的规则建立。
Solaris 9和Linux 2.4.22也让新目录继承父目录的set-group-ID位,以便新目录里创建的文件会继承那个目录的组ID。在Linux上,文件系统实现决定这是否被支持。例如,ext2和ext3文件系统允许这个行为被mount命令的一个选项控制。然而,在UFS文件系统的Linux实现上,这个行为是不可先的,它继承设置组ID位来效仿历史的BSD实现,在那目录的组ID从父目录继承而来。
基于BSD的实现不传播设置组ID位,它们简单地继承组ID作为一种策略。因为FreeBSD和Mac OS X 10.3是基于4.4BSD的,它们并不要求继承设置组ID位。在这些平台上,新创建的文件和目录总是从父目录继承组ID,而不管设置组ID位。
UNIX系统的早期版本没有mkdir函数。它在4.2BSD和SVR3中被引入。在早期版本,一个进程必须调用mknod函数来创建一个新的目录。然而mknod函数仅限于超级用户进程使用。为了绕过这个限制,创建目录的通用命令,mkdir,必须被root拥有,且要设置设置用户ID位。为了从一个进程里创建一个目录,mkdir命令必须通过system函数调用。
一个空的目录用rmdir函数删除。回想下一个空的目录是只包含点和点点目录项的目录。
#include
int rmdir(const char *pathname);
成功返回0,失败返回-1。
如果调用这个函数后目录的链接数变为0,而且没有其它进程打开这个目录,那么目录开辟的空间会被释放掉。如果当链接数达到0时有一个或多个进程打开了这个目录,则函数返回前最后的链接会被删除,而且点和点点项也会被删除。另外,这个目录里不能再创建新的文件。然而,目录没有被释放,最后一个进程关闭它。(即使有些其它进程打开了这个目录,它也不可能在这个目录里做很多事,因为目录必须为空rmdir函数才能成功。
4.21 读目录(Reading Directories)
目录可以被任何有这个目录读权限的人读。然而只有内核才能写一个目录,来保证文件系统的健全。回想4.5节目录的写权限位和执行权限位决定我们是否可以在该目录创建和删除文件--他们并没有指定我们是否可以写这个目录本身。
目录的真实格式取决于UNIS系统实现和文件系统的设计。早期系统,比如Version 7,有一个简单的结构:每个目录项有16字节,其中14字节为文件名,而2个字节为i-node号。当更长的文件名被加入到4.2BSD时,每个项变为可变长度,这意味着任何读目录的程序都依赖于系统。为了简化这个,一堆目录指令被发展起来,并成为POSIX.1的部分。许多系统避免程序使用read函数来访问目录的内容,因此更加隔离了程序和目录格式的实现相关的细节。
#include
DIR *opendir(const char *pathname);
成功返回指针,失败返回null。
struct dirent *readdir(DIR *dp);
成功返回指针,目录尾或失败返回null。
void rewinddir(DIR *dp);
int closedir(DIR *dp);
成功返回0,失败返回-1。
long telldir(DIR *dp);
返回dp相关的目录的当前位置。
void seekdir(DIR *dp, long loc);
telldir和seekdir函数不是基本POSIX.1标准的一部分,它们是SUS的XSI扩展,所以所有遵守协议的UNIX系统实现都被期望来提供它们。
看下面的代码,ls的简单实现:
d_ino项不在POSIX.1中定义,因为它是一个实现特性,但它定义在POSIX.1的XSI扩展中。POSIX.1只在这个结构体里定义了d_name项。
注意NAME_MAX在Solaris上不是一个定义好的常量--它的值取决于目录所在的文件系统,而且它的值通常是从fpathconf函数得到。NAME_MAX的一个普遍的值是255。(回想下第2节关于限量的表。)尽管如此,既然文件名是null终止的,数组d_name在头文件里怎么定义并不重要,因为数据尺寸并不表明文件名的长度。
DIR结构体是一个内部结构,被那六个函数用来维护关于被读目录的信息。DIR结构体的作用与标准I/O库维护的FILE结构体类似,我们会在第5章讲述。
从opendir返回的DIR结构体的指针被其它5个函数使用。opendir函数初始化所有事情,以便第一个readdir操作读到目录的第一项。在目录里的项的顺序是实现相关的,而且通常不是字母序。
我们将使用这些目录函数来写一个遍历文件层次的程序。目标是产生各种类型的文件的计数,就如4.3节末的表一样。程序接受一个参数--开始的路径名--并从那一点开始递归地遍历层次结构。Solaris提供了一个函数,ftw,来进行层次结构的真正遍历,并为每个文件调用用户定义的函数。这个函数的问题是它为每个文件调用stat函数,这会导致程序去解析符号链接。例如,如果我们从根目录开始,并有一个指向/usr/lib的名为/lib的符号链接,/usr/lib目录里的所有文件都会被计数两次。为了更正它,Solaris提供了一个额外的函数,nftw,带有一个阻止解析符号链接的可选项。尽管我们可以使用ntfw,但我们仍将写我们自己的简单的文件遍历器来展示目录函数的用法。
在SUS,ftw和nftw都包含在基本POSIX.1规范的XSI扩展中。Solaris 9和Linux 2.4.22包含了实现。基于BSD的系统有一个不同的函数,fts,提供了相似的功能。它在FreeBSD 5.2.1、Mac OS X 10.3和Linux 2.4.22中可用。
看下面的代码:
更多关于遍历文件系统和许多标准UNIX系统命令(find、ls、tar等)里这种技术的使用,参考Fowler, Korn, and Vo[1989]。
4.22 chdir、fchdir和getcwd函数
每个进程都有一个当前工作目录。这个目录是所有相对路径(不以斜杠开头的路径)开始查找的地方。当一个用户登录一个UNIX系统时,当前工作目录一般从文件/etc/passwd里的第6个域指定的目录--用户的主目录--开始。当前工作路径是进程的一个属性,主目录是登录名的一个属性。
我们可以用chdir和fchdir函数来改变进程的当前路径。
#include
int chdir(const char *pathname);
int fchdir(int filedes);
成功返回0,失败返回-1。
我们可以用路径名或文件描述符来指定一个新的当前工作目录。
fchdir函数不是基本POSIX.1规范的一部分,它是SUS的一个XSI扩展。本文讨论的4个平台都支持fchdir。
下面的代码把当前工作目录改为“/tmp":
因为内核必须维护当前工作目录的信息,我们应该可以得到它的当前值。不幸的是,内核并不维护这个目录的完全路径,而是关于目录的信息,比如指向目录v-node的指针。
我们需要的是一个从当前工作目录(点)开始,并使用点点往上遍历其层次的函数。每进入上一层目录时,函数读取它的目录项,直到找到对应于进入该目录前的目录的i-node的名字。重复这个过程,直到到达要目录,从而得到当前工作目录的整个绝对路径。幸运的是,已经有一个函数来为我们完成这个任务了。
#include
char *getcw(char *buf, size_t size);
成功返回buf,错误返回NULL。
我们必须给这个函数传递一个缓冲区,buf,的地址,以及它的(字节)大小。这个缓冲区必须足够大以容下绝对路径名加一个终止null字节,不然会返回错误。(回想下2.5.5节关于为最大尺寸的路径名分配空间的讨论。)
一些getcwd的早期实现允许第一个参数为NULL。这种情况下,函数调用malloc来动态分配size大小的字节。这不是POSIX.1的部分,也不是SUS的部分,所以应该避免使用。
看下面的代码:
当我们有一个需要返回到原来的文件系统的位置的程序时,getcwd函数有很有用的。我们可以在改变我们的工作目录之前调用getcwd来保存开始位置。在完成我们的操作后,我们可以把从getcwd得到的路径名传给chdir来返回到我们在文件系统的开始的位置。
fchdir函数提供我们完成这个任务的一个简单方法。不需要使用getcwd,我们可以在进入文件系统别的位置前用open当前目录并保存文件描述符。当我们想要返回我们开始的位置时,我们可以简单地把描述符会给fchdir。
4.23 设备特殊文件(Device Special Files)
st_dev和st_rdev这两个域经常令人困惑。我们将在18.9节写ttyname函数时需要这些域。规则很简单:
1、每个文件系统都有它的主要的和次要的设备号,它们有原始系统数据类型dev_t编码。主设备号标识了设备驱动,有时也编码为与其交流的外设主板,从设备号标识了指定的从设备。回想下4.14节关于文件系统的几个图,硬盘经常包含几个文件系统。在同一个硬盘上的每个文件系统通常有相同的主设备号,但是有不同的从设备号。
2、我们通常可以使用多数实现定义的两个宏:major和minor,来访问主从设备号。这意味着我们不必知道在dev_t对象的两个号是如何存储的。早期系统把设备号存储为16位整型,8位为主设备号,24位为从设备号。FreeBSD 5.2.1和Mac OS X 10.3用一个32位整型表示dev_t,14位为主设备号而18位为从设备号。在64位系统上,Solaris 9用64位整型表示dev_t,两者各32位。在Linux 2.4.22,尽管dev_t是一个64位整型,目前主从设备号都只有8位。
POSIX.1指出dev_t类型的存在,但并没有定义它包含的内容,以及如何获取它的内容。宏major和minor被多数实现所定义。它们定义的头文件也取决于实现。它们可以在基于BSD的系统的
3、系统上的每个文件名的st_dev的值是包含这个文件和它对应的i-node的文件系统的设备号。
4、只有字符特殊文件和块特殊文件才有一个st_rdev值。这个值包含了真实设备的设备号。
看下面的代码:
查看当前mount的设备,以及对应的目录
$ mount
/dev/sda1 on / type ext4 (rw,errors=remount-ro,user_xattr,commit=0)
通过ls来查看它们的设备号
$ ls -lL /dev/tty[01] /dev/sda[01]
brw-rw---- 1 root disk 8, 1 2012-02-23 14:36 /dev/sda1
crw--w---- 1 root tty 4, 0 2012-02-23 14:36 /dev/tty0
crw------- 1 root root 4, 1 2012-02-23 14:36 /dev/tty1
硬盘设备是块特殊文件,而两个终端设备是字符特殊文件。(通常,块特殊文件的设备唯一的类型是那么可以包含随机访问(random-access)文件系统:硬盘、软盘、CD-ROM等等。UNIX系统的一些早期版本支持磁带的文件系统,但这已经不再广泛使用了。)
注意两个终端设备(st_dev)的文件名和i-node是在设备0/5--实现/dev的devfs伪文件系统--然而它们真实的设备号是4/0和4/1。
4.24 文件访问权限位汇总(Summary of File Access Permission Bits)
我们已经完全介绍了所有的文件访问权限位,其中一些有多种功能。下表对它们进行了汇总,并给出当它们应用于普通文件或目录时的作用。
文件访问权限位汇总 | |||
常量 | 描述 | 对普通文件的影响 | 对目录的影响 |
S_ISUID | 设置用户ID | 在执行时设置有效用户ID | (没有使用) |
S_ISGID | 设置组ID | 如果组执行被设置的话那么在执行时设置有效组ID; 否则启用强制记录锁(如果支持的话) |
设置目录的新文件的组ID为目录的组ID |
S_ISVTX | 粘滞位 | 控制目录内容的缓存(如果支持的话) | 限制目录里文件的删除与重命名 |
S_IRUSR | 用户读 | 用户读文件的权限 | 用户读目录项的权限 |
S_IWUSR | 用户写 | 用户写文件的权限 | 用户在目录里删除和创建文件的权限 |
S_IXUSR | 用户执行 | 用户执行文件的权限 | 用户在目录中查找给定路径名的权限 |
S_IRGRP | 组读 | 组读文件的权限 | 组读目录项的权限 |
S_IWGRP | 组写 | 组写文件的权限 | 组在目录里删除和创建文件的权限 |
S_IXGRP | 组执行 | 组执行文件的权限 | 组在目录中查找给定路径名的权限 |
S_IROTH | 其他人读 | 其他人读文件的权限 | 其他人读目录项的权限 |
S_IWOTH | 其他人写 | 其他人写文件的权限 | 其他人在目录里删除和创建文件的权限 |
S_IXOTH | 其他人执行 | 其他人执行文件的权限 | 其他人在目录中查找给定路径名的权限 |
这最后的9个常量也可以分为三组,如:
S_IRWXU = S_IRUSR | S_IWUSR | S_IXUSR
S_IRWXG = S_IRGRP | S_IWGRP | S_IXGRP
S_IRWXO = S_IROTH | S_IWOTH | S_IXOTH
5.1 引言
本单说明标准I/O库。因数不仅在UNIX上,还有很多操作系统都实现了此库,所以它ISO C标准说明。SUS对ISO C标准进行了扩展,定义了另外一些接口。
标准I/O库处理很多细节,比如缓冲区分配,以优化的长度执行I/O等。这些处理使用户不必担心如何选择使用正确的块长度(如3.9节所述)。这使得它便于用户使用,然而,如果不较深入地了角I/O库函数的操作,也会带一些问题。
标准I/O库是由Dennis Ritchie在1975年左右编写,是由Mike Lesk编写的可移植I/O库的主要修改版本。令人惊讶的是,经过30年后,标准I/O库只做过极小的修改。
5.2 流和FILE对象(Streams and FILE Objects)
在第三章,所有的I/O函数都集中在文件描述符。当一个文件被打开时,会返回一个文件描述符,然后这个描述符会被所有的后续I/O操作使用。对于标准I/O库,讨论主要集中在流(stream)。(不要把标准I/O的术语“流”和作为System V的一部分的STREAMS I/O系统相混淆,后者在SUS的XSI STREAMS选项里被标准化。)当我们用标准I/O库打开或创建一个文件时,我们说我们有一这个文件的关联的流。
根据ASCII字符集,一个单一的字符由一个字节表示。根据国际字符集,一个字符可以由一个以上的字节来表示。标准I/O文件流可以用来操作单字节和多字节(宽,wide)字符集。一个流的方向(orientation)决定了字符是以单字节还是多字节的方式读取。初始地,当一个流被创建时,它没有方向。如果一个多字节I/O函数(参考
#include
#include
int fwide(FILE *fp, int mode);
流面向宽字符返回正值、面向字节返回负值、没有方向则返回0。
fwdie函数根据mode参数的值,执行不同的任务:
1、如果mode参数为负值,fwide将会尝试让流面向字节;
2、如果mode参数为正值,fwide将会尝试让流面向宽字符;
3、如果mode参数为0,fwid将不会设置方向,而是返回一个表示流的方向的值。
注意fwide不会改变一个已经有方向的流的方向。同样注意它不会返回错误。考虑如果流无效时会发生什么。我们仅有的资源是在调用fwide之前清除errno,然后在它返回时查看errno的值。在本文的剩余部分,我们将只处理面向字节的流。
当我们打开一个流时,标准I/O函数fopen返回一个指向一个FILE对象的指针。这个对象通常是一个包含所有标准I/O库管理流所需的所有信息的结构体。它包含:真实I/O使用的文件描述符、指向流的缓冲区的指针、缓冲区的大小、缓冲区当前字符数的计数器、一个错误标志,等等。
应用软件不应该需要检查FILE对象。为了引用流,我们把FILE指针作为一个参数传给每个标准I/O函数。在本文,我们将用一个FILE对象的指针(FILE *)作为文件指针。
在本章,我们在UNIX系统的上下文讲述标准I/O库。正如我们提到的那样,这个库已经广泛移植到其它操作系统上。然而为了展示这个库是怎么实现的,我们将讨论它在UNIX系统上的典型实现。
5.3 标准输入、标准输出、以及标准错误(Standard Input, Standard Output, and Standard Error)
预定义并对一个进程可用的三个流是:标准输入、标准输出和标准错误。这些流和文件描述符:STDIN_FILENO、STDOUT_FILENO、STDERR_FILENO指向同样的文件,我们在3.2节已经提过。
这三个标准I/O流被预定义的文件指针stdin、stdout和stderr引用。这些文件指针定义在
5.4 缓冲(Buffering)
标准I/O库提供的缓冲的目的是使用最少的read和write调用。(回想第3章我们展示了使用各种缓冲的尺寸执行I/O所需要的CPU时间。)此外,它尝试为每个I/O流进行缓冲,消除程序担心它的需要。不幸的是,标准I/O库产生最多混淆的就是缓冲。
有三种缓冲类型被提供:
1、完全缓冲(Fully buffered)在这种情况下,当标准I/O的缓冲被填满后,真实的I/O才会发生。在磁盘上的文件通常都被标准I/O库完全缓冲。这个被使用的缓冲通常被标准I/O库在第一次操作一个流时通过调用malloc得到(7.8节)。
术语“冲洗(flush)”描述了标准I/O缓冲的写操作。一个缓冲可以被标准I/O函数自动冲洗,比如在缓冲满时,或者我们可以调用函数fflush来冲洗一个流。不幸的是,在UNIX环境里,冲洗有两个意思。在标准I/O库里,它表示缓冲的内容写出,它可能是部分填充的。在终端设备的术语里,比如18章的tcflush函数,它表示丢弃缓冲里存储的数据。
2、行缓冲(Line buffered)。在这种情况下,标准I/O库在输入输出时碰到一个换行符时会执行I/O操作。这让我们可以一次输出单个字符(使用标准I/O的fputc函数),因为我们知道只当我们完成了每行的写操作时真实I/O才会发生。行缓冲被典型用在终端的流上:比如标准输入、标准输出。
行缓冲伴随两个警告。第一,标准I/O库用来收集各行的缓冲区大小是固定的,所以当我们在写一个换行符之前填满这个缓冲I/O可能会发生。第二、不管何时通过标准I/O库从a、一个未缓冲流或b、(需要从内核被请求数据的)一个行缓冲流请求一个输入时,所有行缓冲输出流都会被冲洗。b情况括号内的补充条件的原因是,请求的数据可能已经在缓冲里了,这时不再需要从内核中读取数据。显然,a情况下任何从未缓冲流的输入,都会要求从内核获取数据。
3、无缓冲(Unbuffered)。标准I/O库不缓冲字符。例如,如果我们用标准I/O函数fputs写15个字符,我们会期望这15个字符尽快地输出,它很可能使用了3.8节的write函数。
例如,标准错误流通常是无缓冲的。这是为了使任何错误消息都能尽快地显示,而不管它们是否包含一个换行符。
ISO C定义了以下缓冲性质:
1、当且仅当它们没有指向交互式的设备,标准输入和标准输出为完全缓冲的。
2、标准错误绝不是完全缓冲的。
然而,这里没有告诉我们如果标准输入和标准输出表示交互式的设备时,它们是无缓冲的还是行缓冲的;又或者标准错误是无缓冲的还是行缓冲的。多数实现默认使用以下的缓冲类型:
1、标准错误总是无缓冲的。
2、所有其它流,如果它们指向一个终端设备,都是行缓冲的;否则,它们是完全缓冲的。
本文讨论的四个平台遵守了这些协定:标准错误是无缓冲的,为终端设备打开的流是行缓冲的,而所有其它流是完全缓冲的。
我们会在5.12节更详细地介绍标准I/O的缓冲。
如果我们不喜欢任何给定流的这些默认行为,我们可以通过调用下面两个函数的其中一个来改变缓冲:
#include
void setbuf(FILE *restrict fp, char *restrict buf);
void setvbuf(FILE *restrict fp, char *restrict buf, int mode, size_t size);
成功返回0,失败返回非0值。
这些函数必须在流打开后(显然,因为它们第一个参数都是一个合法文件指针),但在这个流上执行任何操作之前调用。
通过setbuf,我们可以打开或关闭缓冲。要开启缓冲,buf必须是指向长度为BUFSIZ的缓冲的指针,这个常量定义在
通过setvbuf,我们精确地指定我们想要的缓冲类型。这通过mode参数来完成:
_IOFBF:完全缓冲的;
_IOLBF:行缓冲的;
_IONBF:无缓冲的。
如果我们指定一个无缓冲的流,buf和size参数会被忽略。如果我们指定完全缓冲或行缓冲,buf和size可以选择性地指定一个缓冲和它的尺寸。如果流是缓冲的而buf是NULL,标准I/O库会自动使用恰当的尺寸为这个流开辟它自己的缓冲。恰当的尺寸表示由BUFSIZ常量指定的值。
一些C库的实现使用stat结构体的st_blksize成员的值(参考4.2节)来决定优化的标准I/O缓冲大小。我们将在本章的后面部分看到,GNU C库使用了这种方法。
下表总结了这两个函数的行为以及它们的各种选项:
setbuf和setvbuf函数汇总 | ||||
函数 | mode | buf | 缓冲和长度 | 缓冲类型 |
setbuf | 非null | 长度为BUFSIZ的用户buf | 完全缓冲或行缓冲 | |
NULL | (没有缓冲) | 无缓冲的 | ||
setvbuf | _IOFBF | 非null | 长度为size的用户buf | 完全缓冲 |
NULL | 合适长度的系统缓冲 | |||
_IOLBF | 非null | 长度为size的用户buf | 行缓冲 | |
NULL | 合适长度的系统缓冲 | |||
_IONBF | (忽略) | (没有缓冲) | 无缓冲的 |
注意如果我们在一个函数里用一个自动变量(automatic variable)开辟一个标准I/O的缓冲作,我们必须在从函数返回前关闭这个流。(我们将在7.8节更多讨论。)还有,一些实现为内部记账(internal bookkeeping)而使用这个缓冲的一部分,所以可以存储在这个缓冲的数据的真实字节数要比它的尺寸小。一般来说,我们应该让系统选择缓冲尺寸并自动地开避这个缓冲。当我们这样做时,在我们关闭这个流时,标准I/O库自动地释放这个缓冲。
任何时候,我们可以强制一个流的冲洗。
#include
int fflush(FILE *fp);
成功返回0,失败返回EOF。
这个函数导致流中任何未写的数据都被传递级内核。作为一个特殊情况,如果fp为NULL,函数会冲洗所有的输出流。
5.5 打开一个流(Opening a Stream)
下面三个函数打开一个标准I/O流。
#include
FILE *fopen(const char *restrict pathname, const char *restrict type);
FILE *freopen(const char *restrict pathname, const char *restrict type, FILE *restrict fp);
FILE *fdopen(int filedes, const char *type);
成功返回文件指针,错误返回NULL。
这三个函数的区别在于:
1、fopen函数打开一个指定的文件。
2、freopen函数在一个指定的流上打开一个指定的文件,并关闭之前打开的流。如果之前的流有方向,freopen会清除它。这个函数被典型用来打开一个指定的文件,作为其中一个预定义的流:标准输入、标准输出或标准错误。
3、fdopen函数接受一个已有的文件描述符,它可以通过open、dup、dup2、fcntl、pipe、socket、socketpair或accept函数得到。这个文件描述符会和标准I/O流关联起来。这个函数经常和创建管道和网络通信渠道的函数的返回值一起使用。因为这些特殊类型的文件不能用标准I/O函数fopen打开,所以我们必须调用设备相关的函数来得到一个文件描述符,再通过fdopen函数把这个描述符与标准I/O流相联起来。
fopen和freopen两个都是ISO C的一部分。fdopen是POSIX.1的一部分,因为ISO C没有处理文件描述符。
下表显示了ISO C为type参数指定的15个值:
打开一个标准I/O流的type参数 |
|
type | 描述 |
r或rb | 打开来读 |
w或wb | 打开来写 |
a或ab | 添加;打开写入文件末尾,或创建来写 |
r+或r+b或rb+ | 打开来读写 |
w+或w+b或wb+ | 裁切成0长度或创建来读写 |
a+或a+b或ab+ | 打开或创建来在文件尾读写 |
使用字符b作为类型的一部分允许标准I/O系统来区分一个文本文件和二进制文件。因为UN不X内核不区分这两种文件类型,指定这个字符b没有效果。
在fdopen里,type参数的含义有一些小区别。因为描述符已经被打开了,所以打开来写不会裁切这个文件。(例如,如果描述符由open函数创建,而且这个文件已经存在,那么O_TRUNC标志会控制这个文件是否被裁切。fdopen函数不能简单的裁切任何它打开来写的文件。)还有,标准I/O添加模式不能创建这个文件(因为由一个描述符指向的这个文件必须存在)。
当一个文件用添加类型打开时,每次写都会发生在文件的当前末尾。如果多个进程使用标准I/O添加模式打开同一个文件,每个进程的数据都会被正确写入到这个文件。
在4.4BSD之前的Bdrkeley的fopen的各种版本,以及在Kernighan and Ritchie[1988]第177页上展示的简单版本,没有正确地处理添加模式。这些版本在流打开时用lseek到文件末尾。为了当多进程被引入时正确支持添加模式,文件必须用O_APPEND标志打开,我们已经在3.3节讨论过了。每次写前调用lseek也不能工作,正如我们在3.11节讨论的。
当一个文件打开来读写(类型中的+号)时,会有以下限制:
1、输出后,没有fflush、fseek、fsetpos或rewind的操作的话,不能直接进行输入。
2、输入后,没有fseek、fsetpos、rewind或一个碰到文件尾的读操作的话,不能直接进行输出。
下表总结了打开一个流的6种方式:
打开一个标准I/O流的6种方式 |
||||||
限制 | r | w | a | r+ | w+ | a+ |
文件必须存在 | * | * | ||||
文件之前的内容被丢弃 | * | * | ||||
流可以读 | * | * | * | * | ||
流可以写 | * | * | * | * | * | |
流只能在末尾写 | * | * |
注意如果一个新文件通过指定一个w或a的类型创建,我们不能指定这个文件的访问权限位,因为我们本可以在open函数或creat函数来做这件事(第3章)。
默认情况下,流以完全缓冲方式打开,除非它指向一个终端设备,在这种情况下它是行缓冲的。一旦流被打开,在我们对其做任何操作之前,如果愿意的话,我们可以改变这个缓冲,通过使用上节提到的setbuf和setvbuf函数。
一个打开的流可以调用fclose来关闭。
#include
int fclose(FILE *fp);
成功返回0,错误返回EOF。
任何缓冲的输出数据都在文件关闭前被冲洗。任何可能被缓冲的输入数据都会被舍弃。如果标准I/O库之前自动开辟了一个缓冲,这个缓冲会被释放。
当一个进程通过直接调用exit函数或从main函数返回而正常终止时,所有包含未写的缓冲数据的标准I/O流都会被冲洗,而且所有的标准I/O流都会关闭。
5.6 读写流(Reading and Writing a Stream)
一旦我们打开一个流,我们可以从以下三种无格式的I/O中选择:
1、一次一字符(Character-at-a-time)I/O。我们可以一次读写一个字符,而让标准I/O函数处理所有的缓冲事宜,如果流有缓冲的话。
2、一次一行(Line-at-a-time)I/O。如果我们读一次读写一行,我们使用fgets和fputs。每行都以一个换行符终止,而且当我们调用fgets时必须指定我们能处理的最大行长度。我们在5.7节讲述这两个函数。
3、直接(Direct)I/O。这种类型的I/O通过fread和fwrite函数支持。对于每个I/O操作,我们读写一些对象,而每个对象都有一个指定的尺寸。这两个函数经常用来处理二进制文件,那里我们每次操作读写一个结构。我们在5.9节讲述这两个函数。
术语“直接I/O”,从ISO C标准而来,有很多名字:二进制(binary)I/O、一次一对象(object-at-a-time)I/O、面向记录(record-oriented)I/O,或面向结构(structure-oriented)I/O。
(我们在5.11节讲述格式化I/O函数,比如printf和scanf。)
输入函数:
有三个允许我们一次读一个字符的函数。
#include
int getc(FILE *fp);
int fgetc(FILE *fp);
int getchar(void);
三者成功都返回下一个字符,失败都返回EOF。
函数getchar被定义为与getc(stdin)等价。前两个函数的区别是getc可以被作为一个宏而实现,而fgetc不能作为宏实现。这表明了三件事:
1、getc的参数不应该是有副作用的表达式。
2、因为fgetc被保证为一个函数,所以我们可以拿到它的地址。这允许我们把fgetc的地址作为另一个函数的参数。
3、fgetc的调用很可能比getc的调用时间长,因为调用一个函数的时间通常要更长。
这三个函数返回下一个字符,作为一个unsigned char并转换为一个int。指定无符号的原因是为了高位位如果设置的话,不会返回一个负值。返回一个int值的原因是为了可以返回所有可能的字符值,以及一个错误或碰到文件尾的标识。在
注意这些函数当碰到一个错误或到达文件尾时返回同一个值。为了区别这两者,我们必须调用ferror或feof。
#include
int ferror(FILE *fp);
int feof(FILE *fp);
两者如果条件成立的话返回非0(真),否则返回0(假)。
void clearerr(FILE *fp);
在多数实现上,在FILE对象里为每个流维护了两个标志:
1、一个错误标志;
2、一个文件结束标志。
两个标志都可以调用clearerr来清除。
在从一个流读入后,我们可以调用ungetc来把字符放回去。
#include
int ungetc(int c, FILE *fp);
成功返回c,否则返回EOF。
放回的字符会被后续在流上的读操作以放回的相反顺序返回。尽管如此,注意,虽然ISO C允许一个实现支持任意数量的放回,但一个实现只被要求提供单一字符的放回。我们不应该指望会多于一个字符。
我们放回的字符不必和我们之前读取的字符一样。我们可以放回EOF。但是当我们到达文件尾时,我们可以放回一个字符。下次读时会返回这个字符,再下次读会返回EOF。这得以工作是因为成功的ungetc操作会清除流的文件结束标识。
放回通常用在我们读一个输入流并把输入分为多个单词或一些格式的语素。有时我们需要预先查看下下一个字符来决定如何处理当前字符。我们可以很容易把预查的字符放回,以便下次的getc返回它。如果标准I/O库没有提供这种放回的能力,那么我们必须把这个字符存储到我们自己的变量里,以及一个告诉我们下次需要一个字符时使用这个字符而不是调用getc来获取字符的标志。
当我们用ungetc放回字符时,它们不会写入下层的文件或设备。它们被保存在标准I/O库的缓冲里。
输出函数
我们将找到对应于每个我们之前讨论过的输入函数的输出输出。
#include
int putc(int c, FILE *fp);
int fputc(int c, FILE *fp);
int putchar(int c);
三者成功都返回c,否则返回EOF。
和输入函数相似,putchar(c)和putc(c, stdout)等价,而putc可以作为一个宏实现,但fputc不能作为一个宏实现。
5.7 一次一行I/O(Line-at-a-Time I/O)
一次一行输入由以下两个函数提供。
#include
char *fgets(char *restrict buf, int n, FILE* restrict fp);
char *gets(char *buf);
两者成功返回buf,文件结束或错误返回NULL。
两都都指明一个把行读入的缓冲区的地址。gets函数从标准输入中读,而fgets从指定的流中论坛版主。
使用fgets,我们必须指定缓冲的尺寸,n。这个函数一直读到并包括下一个换行符,然不会多于n-1个字符,读入缓冲区里。这个缓冲由一个null字节终止。如果这行,包括终止的换行符比n-1长,那么只有行的一部分被返回,但缓冲总是null终止的。另一个fgets的调用会读取该行的剩余部分。
gets函数不应该被使用。问题是它不允许调用者指定缓冲大小。如果行比缓冲长的话,这会允许缓冲溢出,并覆写掉跟随在缓冲之后的内存。这个缺陷用来作为1988年的因特网蠕虫的一部分。gets的另一个区别是它不和fgets一样在buffer里存储一个换行符。
两个函数的这个处理换行符的区别可以追朔到UNIX系统的起源。Version 7的手册(1979)甚至指出“gets删除一个换行符,fgets保留它,都是以身后兼容的名义。”
尽管ISO C要求一个实现来提供gets,但还是使用fgets。
一次一行输出由fputs和puts提供:
#include
int fputs(const char *restrict str, FILE *restrict fp);
int puts(const char *str);
成功返回非负值,否则返回EOF。
函数fputs把一个null终止的字符串写入到指定的流中。末尾的null字节没有被写。注意这不必是一个一次一行输出,因为字符串最后的非null字符不必是一个换行符。虽然通常都是这种情况--最后一个非空字符为换行符--但这并不被要求。
puts函数在标准输出写一个空字符终止的字符串,但不会把这个空字节写出。不同的时puts接着会把一个换行符写入到标准输出。
puts函数并不会不安全,就像与它相当的gets一样。尽管如此,我们将避免使用它,以免记住它是否添加一个换行符。如果我们总是合适fgets和fputs,我们知道我们必须处理每行最后的换行符。
5.8 标准I/O效率(Standard I/O Efficiency)
使用上节讨论的函数,我们会知道标准I/O系统的效率。下面的程序和3.9节的程序相似:它使用getc和putc简单地把标准输入拷贝到标准输出。这两个函数可以作为宏实现:
最后,我们有一个读写行的版本:
使用标准I/O函数的耗时结果 |
||||
函数 | 用户CPU(秒) | 系统CPU(秒) | 时钟时间(秒) | 程序代码的字节 |
3.9节里最好的时间 | 0.01 | 0.18 | 6.67 | |
fgets, fputs | 2.59 | 0.19 | 7.15 | 139 |
getc, putc | 10.84 | 0.27 | 12.07 | 120 |
fgetc, fputc | 10.44 | 0.27 | 11.42 | 120 |
3.9节里的单一字节时间 | 124.89 | 161.65 | 288.64 |
对这三个标准I/O版本的每个,用户CPU时间都比3.9节最好的读版本要长,因为一次一字符标准I/O版本有一个执行了1亿次的循环,而一次一行版本的循环执行了3,14,984次。在read版本里,它循环只执行了12,611次(当缓冲尺寸为8,192时)。在时钟时间里的区别在于用户时间的区别,以及等待I/O完成所花时间的区别,而系统系统是可比的。
系统时间和之前的大致相同,这是因为内核请求的数量大致相同。注意使用标准I/O函数的优点是我们不用担心缓冲以及选择优化的I/O尺寸。虽然我们必须决定使用fgets的版本的最大行尺寸,但是这比选择优化的I/O尺寸简单。
使用一次一行I/O的版本比使用一次一字符的版本快了近一倍。如果fgets和fputs函数用getc和putc来实现的话,那我们会期望耗时与getc版本相似。事实上,我们可能会预期一次一行版本花的时间更长,因为我们在已有的600万次的基础上,加上了额外的200万次函数调用。这个例子里发生的是一次一行函数使用memccpy函数实现。通常,memccpy函数由汇编而非C实现,为了效率。
最后一个关于这些耗时的有趣的事是fgtc版本比BUFFSIZE=1的3.9节版本快了如此之多。两者都调用了相同数量的函数--大约2亿--然而fgetc版本在CUP时间上快了近12倍而在时钟时间上稍超出25倍。区别在于使用read的这个版本执行2亿次函数调用,从而造成2亿次系统调用。fgetc版本里,我们执行了2亿次函数调用,但只有25,222次系统调用。系统调用通常比普通函数调用耗时得多。
作为一个反对者,你应该已经注意到这些耗时结果只在它们运行的单一系统上有效。这个结果取决于在每个UNIX系统上都不一样的许多实现的特性。尽管如此,有这些类似的数字,并解释这些版本为什么不同,帮助我们更好地了解这个系统。从这节和3.9节,我们已经知道了标准I/O库并不比直接调用read和write慢很多。我们看到的大概的花销是大约0.11秒的时钟时间用getc和putc来拷贝一M的数据。对于多数的实际的程序,用户CPU时间最多被程序花费掉,而不是标准I/O函数。
5.9 二进制I/O(Binary I/O)
5.6节的函数一次操作一个字符,而5.7节的函数一次操作一行。如果我们执行二进制I/O,我们经常是想一次读写整个结构。为了使用getc或putc,我们必须在整个结构里循环,一次一个字节,读写各个字节。我们不能使用一次一行函数,因为fputs当碰到空字节时结束写,而在结构里可能会有空字节。类似的,fgets不能用来输入如果任何数据字节包含空字节或的换行符。因此,以下两个函数被用来执行二进制I/O。
#include
size_t fread(void *restrict ptr, size_t size, size_t nobj, FILE *restrict fp);
size_t fwrite(const void *restrict ptr, size_t size, size_t nobj, FILE *restrict fp);
两者都返回读或写的对象数。
这些函数有两种普遍的使用方法:
1、读写一个二进制数组。例如,写一个浮点数组的第2到第5人元素,我们可以写:
float data[10];
if (fwrite(&data[2], sizeof(float), 4, fp) != 4)
err_sys("fwrite error");
这里,我们指定size作为数组每个元素的尺寸而nobj作为元素的数量。
2、读写一个结构体。例如,我们可能这样写:
struct {
short count;
long total;
char name[NAMESIZE];
} item;
if (fwrite(&item, sizeof(item), 1, fp) != 1)
err_sys("fwrite error");
这里,我们指定size作为结构体的尺寸而nobj指定为1(写的对象的数量)。
这两种情况的明显的扩展是读写一个结构体数组。要这样做,size应该是结构体的sizeof,而nboj应该是数组的元素数量。
fread和fwrite都返回读入或写出的对象的数据。对于read,这个数量可以比nboj小,如果有错误发生或碰到文件结束。这些种情况下ferror和feof必须调用。对于write,如果返回值比所需的nobj少,表示发生错误。
二进制I/O的一个基本问题,是它只能用来读在同一系统上写的数据。这在很多年前,当所有UNIX系统都在PDP-11上时,是没问题的。但如今多种多样的系统用网络连接在一起。想要在一个系统上写数据而在另一个上处理它是很普遍的。这两个函数将不能工作,基于两个原因:
1、一个结构体里的成员的偏移量在编译器间和系统间可以不同,因为不同的对齐需要。事实上,一些编译器有一个选项允许结构体被紧凑打包,以运行时的性能为代价节省空间,或者精确地排列,来优化每个成员的运行访问时间。这意味着即使在同一个系统上,一个结构体的字节排列也可以不同,取决于编译器选项。
2、用来存储多字节整型和浮点型的值在各种机器架构上不同。
我们将在16章讨论套接字时碰到一些这样的问题。在不同系统上交换二进制数据的真实解决方案,是使用更高层的协议。我们将在8.14节回到fread函数,当我们用它来讯一个二进制结构体,UNIX进程记账记录的时候。
5.10 定位流(Positioning a Stream)
有三种方式来定位一个标准I/O流:
1、两个函数ftell和fseek。它们在Version 7出现,但它们假设一个文件位置可以用一个长整型存储;
2、两个函数ftello和fseeko。它们在SUS引入,来允许文件可能不适合长整型的文件偏移量。它们用off_t数据类型代码长整型;
3、两个函数fgetpos和fsetpos。它们被ISO C引入。它们用一个抽象数据类型fpos_t,来记录一个文件的位置。这个数据类型可以是记录一个文件位置所需而随意大。
想移到非UNIX系统的可移植的程序应该使用fgetpos和fsetpos。
#include
long ftell(FILE *fp);
如果成功返回当前文件位置指示器,错误返回-1L。
int fseek(FILE *fp, long offset, int whence);
成功返回0,否则返回非0值。
void rewind(FILE *fp);
对于一个二进制文件,一个文件的位置指示器是从文件开始的字节来衡量的。ftell为二进制文件的返回值是这个字节位置。为了用fseek定位一个二进制文件,我们必须指定一个字节偏移以及这个偏移如何被解释。whence的值和3.9节的lseek函数里的一样:SEEK_SET表示从文件开头开始;SEEK_CUR表示从当前文件位置开始;SEEK_END表示从文件末尾开始。ISO C没有要求一个实现支持二进制文件的SEEK_END,因为一些系统要求一个二进制文件在末尾添加一些0以及文件大小为一些魔数的整数。然而,在UNIX系统,SEEK_END用来支持二进制文件。
对于文本文件,文件的当前位置可能不能简单用字节偏移来测量。一些非UNIX系统下可能把文本文件存储成另一种格式。为了定位一个文本文件,whence必须是SEEK_SET,而且只有两个值被允许:0-表示回到开始,或一个由ftell返回的值。一个流也可以用rewind函数回到文件开头。
ftello函数和ftell函数相同,而fseeko函数和fseek函数相同,除了偏移量的类型是off_t而不是long。
#include
off_t ftello(FILE* fp);
成功返回当前文件位置,否则返回(off_t)-1。
int fseeko(File *fp, off_t offset, int whence);
成功返回0,失败返回非0.
回想下3.6节关于off_t数据类型的讨论。实现可以把off_t类型定义的比32位更大。
正如我们提到的,fgetpos和fsetpos函数由ISO C标准引入。
#inlcude
int fgetpos(FILE *restrict fp, fpos_t *restrict pos);
int fsetpos(FILE *fp, const fpos_t *pos);
两者成功都返回0,失败返回非0值。
fgetpos返回把文件位置指示器的值存储在由pos指向的对象里。这个值可以被之后的fsetpos调用使用,来重定位流的位置。
5.11 格式化I/O
格式化输出
格式化输出由4个printf函数处理
#include
int printf(const char *restrict format, ...);
int fprintf(FILE *restrict fp, const char *restrict format, ...);
两者成功都返回字符数,输出错误则返回负值。
int sprintf(char *restrict buf, const char *restrict format, ...);
int snprintf(char *restrict buf, size_t n, const char *restrict format, ...);
两者成功都返回在字节里存储的字符数量,编码错误返回负值。
printf函数向标准输出写,fprintf向一个指定的流写,而sprintf把格式化的字符串放入到数组buf里。sprintf函数在数组末自动将上一个空字节,但这个空字节没有包含在返回值里。
注意sprintf可能为溢出buf指向的缓冲区。这是调用者的执行来保证这个缓冲区足够大。因为这会导致缓冲区溢出的问题,于是snprintf被引入。用它可以把缓冲的尺寸作为一个显式的参数;任何超过缓冲区末尾的字符都会被舍弃掉。snprintf函数返回假如缓冲足够大时会写入的字符数。而sprintf一样,返回值不包括终止的空字节。如果snprintf返回一个比缓冲大小n更小的正值,那么输出则被裁切过了。如果编码出错,snprintf返回一个负值。
格式规范控制了剩下的参数如何被编码和最终显示。每个参数根据一个以百分号(%)开头的转换规格(conversion specification)来编码。除了转换规格,其它在格式里的字符都被无修改地复制。一个转换规格有4个可先部分,如下方括号里所示:
%[flags] [fldwidth] [precision] [lenmodifier] convtype
flags由下表汇总:
转换规格的flag部分 | |
Flag | 描述 |
- | 域里面左输出 |
+ | 总是显示一个正负号 |
(空格) | 如果没有正负号则以一个空格开头 |
# | 使用代替的格式转换(例如16进制格式中包含0x前缀) |
0 | 以0填充,而不是空格 |
fldwidth部分指定了最小的域宽度。如果转换结果有更少的字符,则以空格填充。域完是一个非负十进制整数或一个星号。
precision部分在整数转换时指定了整数中数字的最小数量,在浮点数转换时指定了小数点右侧的数字的最小数量,或者在字符串转换中指定字节的最大数量。precision是一个“.”接着一个可选的非负十进制整数或星号。
域宽和精度都可以是一个星号。这种情况下,一个指定这个值的参数被使用,它直接在出现在被转换的参数前。
lenmodifier部分指定了参数的尺寸。下表总结了可能的值:
转换格式的长度修改符 | |
Length modifier | 描述 |
hh | 有符号或无符号char |
h | 有符号或无符号short |
l | 有符号或无符号long或宽字符 |
ll | 有符号或无符号long long |
j | intmax_t或uintmax_t |
z | size_t |
t | ptrdiff_t |
L | long double |
convtype部分不是可选的。它控制了参数如何被解释。下表总结了各种转换类型:
转换规格的转换类型部分 | |
Conversion type | 描述 |
d, i | 有符号十进制 |
o | 无符号八进制 |
u | 无符号十进制 |
x, X | 无符号十六进制 |
f, F | double浮点数 |
e, E | 指数格式的double浮点数 |
g, G | 根据要转换的值,以f,F,e或E来解释 |
a, A | 十六进制指数格式的double浮点数 |
c | 字符(长度修改符l表示宽字符) |
s | 字符串(长度修改符l表示宽字符串) |
p | void指针 |
n | 指向表示目前为止已写字符数量的有符号整型的指针 |
% | 一个%字符 |
C | 宽字符(XSI扩展,等价于lc) |
S | 宽字符串(XIS扩展,等价于ls) |
下面4个printf家族的变体与前4个函数相似,只是参数列表(...)被arg代替。
#include
#include
int vprintf(const char *restrict format, va_list arg);
int vfprintf(FILE *restrict fp, const char *restrict format, va_list arg);
两者成功都返回输出的字符数,失败返回负值
int vsprintf(char *restrict buf, const char *restrict format, va_list arg);
int vsnprintf(char *restrict buf, size_t n, const char *restrict format, va_list arg);
两都成功都返回数组存储的字符数,编码错误返回负数。
注意可变长度的参数列表由ISO C提供,定义在
格式化输入
格式化输入由三个scanf函数处理
#include
int scanf(const char *restrict format, ...);
int fscanf(FILE *restrict fp, const char *restrict format, ...);
int sscanf(const char *restrict buf, const char *restrict format, ...);
三者都返回被赋值的输入项的数目,输入出错或碰到文件结尾时返回EOF。
scanf家族用来解析一个输入的字符串并转换成指定类型的变量。在格式后的参数包含需要用转换结果初绐化的变量的地址。
格式规格(format specification)控制了参数如何为赋值而被转换。百分号(%)表示格式规格的开始。除了转换规格和空格,其它在格式里的字符都用来匹配输入。如果有一个字符不匹配,操作停止,不再读剩下的输入。
转换规格中有三个可选部分,如下面方括号里所示:
%[*] [fldwidth] [lenmodifier] convtype
可选的开头的星号用来抑制转换。输入根据转换规格的其它部分转换,但结果不会存储在一个参数里。
fldwidth部分指定域的最大字符数。lenmodifier部分指定要以转换结果初始化的参数的尺寸。被printf家族支持的长度修改符同样也被scanf家族支持。
convtype域与printf家族所用的转换类型域类似,但有一些不同。一个区别是,存在一个无符号类型的结果在输入里可以是有符号的。下表总结了scanf家族支持的转换类型:
转换规格的转换类型部分 | |
Conversion type | 描述 |
d | 有符号十进制 |
i | 有符号数,根据输入格式决定进制 |
o | 无符号八进制(输入可以是有符号的) |
u | 无符号十进制(输入可以是有符号的) |
x | 无符号十六进制(输入可以是有符号的) |
a, A, e, E, f, F, g, G | 浮点数 |
c | 字符(长度修改符l表示宽字符) |
s | 字符串(长度修改符l表示宽字符串) |
[ | 匹配一个字符列表,以]结束 |
[^ | 匹配除列出的之外的所有字符,以]结束 |
p | void指针 |
n | 指向表示目前为止已写字符数量的有符号整型的指针 |
% | 一个%字符 |
C | 宽字符(XSI扩展,等价于lc) |
S | 宽字符串(XIS扩展,等价于ls) |
和printf家族一样,scanf家族也支持使用可变参数列表的函数,它们由
#include
#include
int vscanf(const char *restrict format, va_list arg);
int vfscanf(FILE *restrict fp, const char *restrict format, va_list arg);
int vsscanf(const char *restrict buf, const char *restrict format, va_list arg);
三者都返回赋好值的输入项的数量,错误或转换前遇到文件结尾时返回EOF。
参考你的UNIX系统手册来获得更多关于scanf家族函数的细节。
5.12 实现细节(Implementation Details)
正如我们提到过的,在UNIX系统下,标准I/O库最后会调用第3章描述的I/O程序。每个标准I/O流有一个相关的文件描述符,而我们可以调用fileno来得到流的文件描述符。
#include
int fileno(FILE *fp);
返回相关联的文件描述符。
注意fileno不是ISO C标准的一部分,而是POSIX.1支持的一个扩展。
例如,如果想调用dup或fcntl函数,我们需要这个函数。
要看你系统上标准I/O库的实现,从头文件
下面的代码打印三个标准流以及与一个普通文件关联的流的缓冲:
注意我们在打印每个流的缓冲状态前,都为它执行I/O,因为第一个I/O操作通常会导致流的缓冲被开辟。结构体成员_IO_file_flags、_IO_buf_base、和_IO_buf_end以及常量_IO_UNBUFFERED和_IO_LINE_BUFFERED由在Linux上使用的GNU标准I/O库定义。注意其它UNIX系统可能有不同的标准I/O库的实现。
看运行结果:
$ ./a.out
enter any character
a
one line to standard error
stream = stdin, line buffered, buffer size = 1024
stream = stdout, line buffered, buffer size = 1024
stream = stderr, unbuffered, buffer size = 1
stream = /tec/motd, fully buffered, buffer size = 4096
$ ./a.out
$ cat std.out
enter any character
stream = stdin, fully buffered, buffer size = 4096
stream = stdout, fully buffered, buffer size = 4096
stream = stderr, unbuffered, buffer size = 1
stream = /tec/motd, fully buffered, buffer size = 4096
$ cat std.err
one line to standard error
我们可以看到默认情况下,系统的标准输入和标准输出连接到终端时是行缓冲的。行缓冲有1024字节。注意这并不限制我们输出或输入1024字节的行,那只是缓冲的大小。向标准输出写一个2048字节的行将会请求两个系统调用。当我们把这两个流重定向到普通文件时,它们变为完全缓冲的了,而缓冲大小与文件系统的首选I/O尺寸一样--stat结构体里的st_blksize的值。我们也看到标准错误总是无缓冲的,正如它应该的那样。一个普通文件默认为完全缓冲的。
5.13 临时文件(Temporary Files)
ISO C标准定义了两个由标准I/O库提供的函数,用来协助创建临时文件。
#include
char *tmpnam(char *ptr);
返回指向唯一的路径名的指针。
FILE *tmpfile(void);
成功返回文件指针,失败返回NULL。
tmpname函数返回一个不与任何已有文件相同的合法路径名的字符串。这个函数每次调用会产生一个不同的路径名,最多TMP_MAX次。TMP_MAX定义在
尽管ISO C定义了TMP_MAX,但是C标准只要求它的值最少为25。然而SUS要求XSI系统支持一个最少10000的值。尽管这个最小值允许一个实现使用四位数(0000~9999),但多数UNIX系统的实现使用小写或大写的字符。
如果ptr为NULL,那么产生的pathname存储在一个静态 区域里,而这个区域的指针被作为这个函数的返回值。随后的tmpnam调用可以覆盖这个区域。(这意味着如果我们不只一次调用这个函数而又想保存这个路径名,我们必须存储这个路径名的拷贝,而这是指针的拷贝。)如果ptr不为NULL,它被假设为一个指向至少L_tmpnam字符的数组的指针。(常量L_tmpnam定义在
tmpfile创建一个临时二进制文件(wb+类型),当它被关闭时或程序终止时,这个文件会被自动删除。在UNIX系统,这个文件作为二进制文件并没有什么不同。
看下面的例子:
tmpfile函数经常使用的标准技术是调用tmpnam创建一个唯一的路径名,然后创建这个文件,之后立即unlink它。回想下4.15节,解链接一个文件在文件关闭前都不会删除它的内容。这种方法,进程显式关闭文件或进程终止时,文件的内容会被删除。
SUS定义了两个额外的函数,作为XSI扩展,来处理临时文件。第一个是tempnam函数。
#include
char *tmpnam(const char *directory, const char *prefix);
返回指向唯一路径名的指针。
tempnam函数是tmpnam的一个变体,允许调用者指定目录和生成路径名的前缀。directory有四种可能的选择,第一个满足的情况将作为这个目录:
1、如果环境变量TMPDIR定义了,它被作为这个目录。(我们将在7.9节描述环境变量。)
2、如果directory不为NULL,它被作为目录。
3、
4、一个本地目录,通常是/tmp,被作为这个目录。
如果prefix参数不为NULL,它应该是一个最多5字节的字符串,作为文件名的开头。
这个函数调用malloc函数来为生成的路径名开辟一个动态空间。我们可以在使用完这个路径名后释放这个空间。(我们将在7.8节讨论malloc和free函数。)
看下面的例子:
XSI定义的第二个函数是mkstemp。它也tmpfile相似,但返回这个临时文件打开后的文件描述符,而不是文件指针。
#include
int mkstemp(char *template);
成功返回文件描述符,失败返回-1。
返回的文件描述符能用来写和读。这个临时文件的名字通过template字符串来选择。这个字符串是最后6个字符被设置为XXXXXX的一个路径名。如果mkstemp返回成功,它会把template字符串修改成反映这个临时文件的名字。
和tmpfile不同,这个由mkstemp创建的临时文件不会自动删除。如果我们想从系统名字空间里删除它,我们需要自己解链接它。
使用tmpnam和tempnam有一个缺陷:在唯一路径名返回和程序用这个名字创建文件之间有一个间隙。在这个时间间隙里,另一个进程可以用同样的名字创建一个文件。应该使用tmpfile和mkstemp函数,因为它们没有这个问题。
mktemp函数和mkstemp相似,除了它只创建一个适用于一个临时文件的名字。mktemp函数不创建一个文件,所以它和tmpnam和tempnam有相同的问题。mktemp函数在SUS里被标记为一个遗留的接口。遗留接口在将来的SUS版本里可能会被删除,所以应该避免使用。
5.14 标准I/O的替代品(Alternative to Standard I/O)
标准I/O库并不完美。Korn and Vo[1991]列出了许多缺陷:一些在基本设计上,而多数在各种实现上。
一个在标准I/O库里的低效的是数据拷贝的量。当我们使用一次一行函数,fgets和fputs,数据通常被拷贝两次:一次在内核和标准I/O缓冲之间(当对应的read和write被使用时),另一次在标准I/O缓冲和我们的行缓冲之间。快速I/O库[fio in At&T 1990a]通过下面方式解决这个问题:让这个读取一行的函数返回一个行的指针,而不是把这行拷贝到另一个缓冲里。Hume[1988]指出,通过这个改变,grep工具的一个版本速度大幅提升。
Korn and Vo[1991]描述另一个标准I/O库的替代:sfio。这个包和fio库的速度相当,通常比标准I/O库快。sfio包还提供了一些在其它库里没有的新特性:生成的用于同时表示文件和内存区域的I/O流、可以写并可以在I/O流上压栈以改变流的操作的处理模块、和更好的异常处理。
Krieger、Stumm、和Unrau[1992]描述了另一个替代品,它使用映射文件--我们将在14.9节讲述的mmap函数。这个新包被称为ASI,Alloc Stream Interface。这个编程接口重汇集了UNIX系统内存分配函数(malloc、realloc和free,在7.8节讨论。)和sfio包一样,ASI试图通过使用指针最小化数据拷贝的量。
在为小内存系统设计的,比如嵌入式系统,的C库里有种标准I/O库的实现。这些实现适度内存请求,超过可移植性,速度,或者功能。两个这种实现是uClibs C库和newlibs C库。
5.15 总结
标准I/O库被许多UNIX程序使用。我们已经看过这个库提供的所有函数,以及一些实现细节的效率考虑。注意库使用的缓冲,这是产生最多问题和困惑的领域。
6.1 引言
UNIX系统需要许多通常操作所用的数据文件:密码文件/etc/passwd和组文件/etc/group是被各种程序经常使用的两个文件。例如,密码文件在每次用户登录UNIX系统和每次某人执行一个ls -l命令里被使用。
历史上,这些数据文件一直是ASCII文本文件,并由标准I/O库读取。但是对于更大的系统,密码文件的线性浏览变得很耗时。我们想能够把这些数据文件存储成ASCII文本之外的其它格式,但仍然提供一个接口,使工作在任何文件格式上的程序仍然可以工作。这个针对这些数据文件的可移植的接口就是本章的内容。我们还要包含系统辨认函数(system idendification functions),以及时间日期函数。
6.2 密码文件(Password File)
UNIX系统的密码文件,被POSIX.1称为用户数据库,包含了下表所示的域。这些域包含在定义于
/etc/passwd文件的域 | ||||||
描述 | 结构体passwd的成员 | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
用户名 | char *pw_name | * | * | * | * | * |
加密密码 | char *pw_passwd | * | * | * | * | |
数值化用户ID | uid_t pw_uid | * | * | * | * | * |
数值化组ID | gid_t pw_gid | * | * | * | * | * |
注解域 | char *pw_gecos | * | * | * | * | |
初始工作目录 | char *pw_dir | * | * | * | * | * |
初始外壳(用户程序) | char *pw_shell | * | * | * | * | * |
用户访问类 | char *pw_class | * | * | |||
下次修改密码的时间 | time_t pw_change | * | * | |||
帐户到时时间 | time_t pw_expire | * | * |
注意POSIX.1只指定了passwd结构里十个域里的五个。多数平台支持至少七个域。基于BSD的平台支持所有的域。
历史上,密码文件存储在/etc/passwd里,并且是个ASCII文件。每行包含着上表描述的域,以冒号分隔。例如,Linux上的/etc/passwd文件的四行可能是:
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/bin/sh
nobody:x:65534:65534:nobody:/nonexistent:/bin/sh
rwhod:x:119:65534::/var/spool/rwho:/bin/null
注意关于这些项的以下几点:
1、通常有一个名为root的项。这个项有个值为0的用户ID(超级用户)。
2、加密密码域包含单个字符,作为占位符。早期UNIX系统版本使用该域存储加密密码。因为在一个对所有人可读的文件里存储加密密码是一个安全漏洞,所以加密密码被存储在其它地方。我们将在下节讨论密码时更深入地讨论这个问题。
3、密码文件项的一些域可以为空。如果加密密码域为空,它通常表示这个用户没有密码。(这不被提倡。)项rwhod有一个空白域:注解域。一个空注解域没有效果。
4、shell域包含了可执行程序的路径,它作为用户的登录shell。一个空的shell域的默认值通常为/bin/sh。虽然这样,注意,项rwhod有一个/dev/null的登录shell。显然,这是一个设备,而且不能执行,所以它这里的使用是阻止任何人作为用户rwhod登录我们的系统。许多服务都为后台进程(第13章)有独立的用户ID,来帮助实现这个服务。
5、/dev/null有几个替代品,都可以阻止特定用户登录一个系统。常见的有/bin/false,它简单地退出并返回一个不成功(非0)的状态;shell视这个状态为false。另一个常见的是/bin/true,它做的只是返回一个成功(0)状态。一些系统提供nologin命令,它打印一个可定制的错误信息,并返回一个非0状态并退出。
6、nobody用户名可以用来登录一个系统,但它的用户ID(65534)和组ID(65534)没有提供任何权限。这个用户ID和组ID能访问的文件只有对所有人可读可写的文件。(它假设没有任何属于用户ID65534和组ID65534的文件,这也应该是这样。)
7、一些提供finger命令的系统在注解域支持额外的信息。这些域的由冒号分隔:用户名、办公地点、办公电话以及家庭电话。此外,在注释域里的一个与号(ampersand,&)被一个(转换的)用户名通过一些工具代替。例如,我们可能有:
someone:x:988:1004:Mr. Someone, Shang hai NanJing Rd., 021-12345678, 021-87654321:/home/here:/bin/null
通过finger命令来打印someone的信息:
$ finger -p someone
Login: someone Name: Mr. Someone
Directory: /home/here Shell: /bin/null
Office: Shang hai NanJing Rd. Office Phone: 021-12345678
Home Phone: 021-87654321
Never logged in.
No mail.
即使你的系统不支持finger命令,这些域仍然可以在注释域中,只是这些域只是简单一个注解,而不被系统工具解释。
一些系统提供了vipw命令,允许管理员修改密码文件。vipw命令把改变序列化到密码文件,并保证额外的文件与做过的改变一致。这在通过GUI提供相似功能的系统上也很普遍。
POSIX.1只定义了两个函数来从密码文件中得到项。这些函数允许我们通过用户名或用户ID查找一个项。
#include
struct passwd *getpwuid(uid_t uid);
struct passwd *getpwnam(const char *name);
两者成功都返回指针,失败返回NULL。
getpwuid函数被ls程序使用,来把包含在一个i-node里的用户ID数值映射到一个用户登录名。getpwnam函数被login函数使用,当我们输入我们的登录名的时候。
两个函数都返回一个指向passwd结构体的指针,并填满它。这个结构体通常是这个函数的一个静态变量,所以它的内容在每次我们调用这些函数的时候会被覆写。
这两个POSIX.1函数在我们要查找登录名或用户ID时都很好,但一些程序想遍历整个密码文件。有以下三个函数可以用:
#include
struct passwd *getpwent(void);
成功返回指针,失败或文件结尾返回NULL。
void setpwent(void);
void endpwent(void);
这三个函数不是基本POSIX.1的一部分,而是定义在SUS的XSI扩展里。如此,所有UNIX系统都需要支持它们。
我们调用getpwent函数来返回密码文件的下个项。和那两个POSIX.1函数一样,getpwent返回一个由它填满的结构体的指针。这个结构体通常在我们每次调用这个函数的时候被覆写。如果这是第一次调用这个函数,它会打开任何它使用的文件。当我们使用这个函数时没有隐含的顺序:这些项可以是任何顺序,因为一些系统使用文件/etc/passwd的哈希版本。
函数setpwent回退任何它使用的文件,而endpwent关闭这些文件。当使用getpwent时,我们必须总是保证在完成后调用endpwent来关闭它们。尽管getpwent足够智能,知道它何时必须打开它的文件(我们每一次调用它的时候),但它从不知道我们什么时候完成。
下面的代码是getpwnam的一个实现:
6.3 影子文件(Shadow Passwords)
加密密码是通过一个单向加密算法的用户密码的一个拷贝。因为这个算法是单向的,我们不能从加密的版本猜测原始密码。
历史上,这个使用的算法一直生成13个可打印的64字符集[a-zA-Z0-9./]中的字符。一些更新的系统使用一个MD5算法来加密密码,为每个密码生成31个字符。(使用越多字符存储加密密码,会有越多的组合方式,这样通过尝试所有可能的变体来猜测密码会更难。)当我们在加密密码域里放入单一字符时,我们保证一个加密密码绝不会匹配这个值。
给定一个加密密码,我们不能应用一个把它回转并返回普通文本密码的一算法。(普通文本密码是我们在Password:提示里输入的东西。)然而我们可以猜一个密码,通过一个单向算法加密它,然后把结果与加密密码比较。如果用户密码被随机选择,这种暴力方式不会太成功。然而,用户倾向于选择一些非随机密码,比如配偶的名字,街道名或宠物名。一些人得到密码文件拷贝的一个普遍经历就是尝试猜测这些密码。
为了更难获取裸材料(加密好的密码),系统现在把加密密码存在另一个文件里,通常被称为影子密码文件。这个文件最少必须包含用户名和加密密码。其它密码相关的信息同样也存在这里。看下表:
文件/etc/shadow的域 | |
描述 | 结构体spwd成员 |
用户登录名 | char *sp_namp |
加密密码 | char *sp_pwdp |
最后修改密码的自Epoch的天数 | int sp_lstchg |
直到改变允许的天数 | int sp_min |
需要改变之前的天数 | int sp_max |
警告密码到期的天数 | int sp_warn |
帐号失效前的天数 | int sp_inact |
帐号过期的自Epoch距今的天数 | int sp_expire |
保留 | unsigned int sp_flag |
影子密码文件不应该被所有人读。只有很少的程序需要访问加密密码--例如login和passwd--而这些程序通常是设置用户ID的根用户。有了影子密码、普通密码文件/etc/passwd可以被所有人读。
在Linux2.4.22和Solaris 9,一个独立的函数休可能用来访问影子密码文件,与访问密码文件的函数集相似。
#include
struct spwd *getspnam(const char *name);
struct spwd *getspent(void);
两者成功时都返回指针,否则返回NULL。
void setspent(void);
void endspent(void);
在FreeBSD 5.2.1和Mac OS X 10.3,没有影子密码结构体。额外的帐号信息被存储在密码文件里。
6.4 组文件
UNIX系统的组文件,被POSIX.1称为组数据库,包含了下表展示的域。这些域被一个group结构体包含,定义在
/etc/group文件的域 | ||||||
描述 | 结构体group的成员 | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
组名 | char *gr_name | * | * | * | * | * |
加密密码 | char *gr_passwd | * | * | * | * | |
数值化组ID | int gr_gid | * | * | * | * | * |
指向各个用户名的指针数组 | char **gr_mem | * | * | * | * | * |
域gr_mem是一个指向属于该组的用户名的指针数组。这个数组由空指针结束。
我们可以用下面两个函数查找组名或组ID,它们定义在POSIX.1里:
#include
struct group *getgrgid(gid_t gid);
struct group *getgrnam(const char *name);
两者成功都返回指针,否则返回NULL。
和密码文件函数一样,这两个函数通常都返回静态变量的指针,每次调用时都被覆写。
如果我们要查找整个组文件,我们需要一些额外函数。以下三个函数与它们相对的密码文件函数相似:
#include
struct group *getgrent(void);
成功返回指针,失败或文件尾返回NULL。
void setgrent(void);
void endgrent(void);
这三个函数不是基本POSIX.1标准的一部分,而是SUS的XSI扩展里定义的。所有UNIX系统都提供了它们。
setgrent函数打开组文件,哪果它还没有被打开的话。getgrent函数从组文件中读取下一个项,首先打开这个文件,如果它还没打开的话。endgrent函数关闭这个组文件。
6.5 补充组ID(Supplementary Group IDs)
UNIX系统里组的使用随着时间发生了变化。在版本7,每个用户在任何时刻只属于某一个组。当我们登录的时候,我们被分配给真实组ID,对应于我们密码文件项的数值组ID。我们可以在任何时候执行newgrp来修改它。如果newgrp命令成功(参考权限规则的手册页),我们的真实组ID被改为新组的ID,而这会被后续的所有文件访问权限检查所使用。我们总是可以回到我们原始的组,通过执行无任何参数的newgrp。
这个组成员关系直到4.2BSD(1983年前后)才发生变化。在4.2BSD里,引入了补充组ID的概念。我们不仅属于对应于密码文件项里组ID表示的组,同时还属于最多16个补充的组。文件访问权限检查被修改,以致不仅有效组ID会与文件组ID比较,同时所有的补充组ID也会和文件组ID比较。
补充组ID是POSIX.1所要求的一个特性。(在POSIX.1的早期版本,它们是可选的。)常量NGROUPS_MAX(第2章)指定了补充组ID的数量。一个普遍的值是16。
使用补充组ID的好处是我们不再需要显式地改变组。在同一时间同时属于多个组(也就是说,参与了多个项目)并不是不常见。
有三个函数用来得到和设置补充组ID。
#include
int getgroups(int gidsetsize, gid_t grouplist[]);
成功返回补充组ID的数量,错误返回-1。
#inlcude
#inlcude
int setgroups(int ngroups, const gid_t grouplist[]);
#inlcude
#inlcude
int initgroups(const char *username, gid_t basegid);
两者成功都返回0,否则返回-1。
在这三个函数中,只有getgroups是POSIX.1指定的。因为setgroups和initgroups是需要权限的操作,它们不是POSIX.1的一部分。然而,本文提到的四个平台都支持了这三个函数。
在Mac OS X 10.3,basegid被定义为int类型。
getgroups函数用补充组ID填充了数组grouplist。最多gidsetsize个元素被存储到这个数组里。函数返回被存储到数组的补充组ID的数量。
作为一个特殊情况,如果gidsetsize为0,函数只返回补充组ID的数量。数组grouplist不会被修改。(这允许调用都决定要分配的grouplist数组的大小。)
setgroups函数可以被超级用户调用来为调用进程设置补充组ID列表:grouplist包含了组ID的数组,而ngroups指定了数组元素的数量。ngroups的值不能比NGROUPS_MAX大。
setgroups唯一的使用经常是通过initgroups函数,它读取整个组文件--我们早先描述的通过函数getgrent、setgrent和endgrent--并决定username的组成员关系。它接着调用setgroups来为这个用户初始化补充组ID。必须是超级用户才能调用initgroups,因为它调用了setgroups。除了组文件里找到所有username所属的组,initgroups还把basegid包含在补充组ID列表里,basegid是密码文件的username的组ID。
initgroups只被很少的程序调用:例如loing程序当我们登录时调用它。
6.6 实现区别(Implementation Differences)
我们已经讨论过Linux和Solaris支持的影子密码文件。FreeBSD和Mac OS X用另一种方式存储加密密码。下表总结了这本文包括的四个平台如何存储用户和组信息:
帐号实现的区别 |
||||
信息 |
FreeBSD 5.2.1 |
Linux 2.4.22 |
Mac OS X 10.3 |
Solaris 9 |
帐号信息 | /etc/passwd | /etc/passwd | netinfo | /etc/passwd |
加密密码 | /etc/master.passwd | /etc/shadow | netinfo | /etc/shadow |
是否哈希密码文件 | 是 | 否 | 否 | 否 |
组信息 | /etc/group | /etc/group | netinfo | /etc/group |
在FreeBSD上,影子密码文件是/etc/master.passwd。特殊的命令被用来编辑它,从影子密码文件产生一个/etc/passwd的拷贝。此外,文件的哈希版本也被产生:/etc/pad.db是/etc/passwd的哈希版本,而/etc/spwd.db是/etc/master.passwd的哈希版本。这些为大的安装提供了很好的性能。
在Mac OS X上,/etc/passwd和/etc/master.passwd却只用在单一用户模式(当系统用于内部维护;单用记模式通常意味着没有系统服务被启用。)在多用户模式--在普通操作期间--netinfo目录服务提供了用户和组的帐号信息的访问。
尽管Linux和Solairs支持类似的影子密码接口,但仍然有些小区别。例如,/etc/shadow文件的域里的整型域在solaris里定义为int类型,而在Linux里定义为long int。另一个区别是帐号失效域。Solaris把它定义为自从用户上次登录至今的天数,而Linux把它定义为最大密码年龄到达之后的天数。
在许多系统上,用户和组数据库使用网络信息服务(Network Information Service,NIS)实现。这允许管理员编辑这些数据库的母拷贝(master copy)并把它们自动分发到一个组织里的所有服务器上。客户端系统与服务器联系来查询用户和组的信息。NIS+和轻量目录访问协议(Lightweight Directory Access Protocol, LDAP)提供了相似的功能。许多系统控制着用来管理配置文件/etc/nsswitch.conf的信息的所有类型的方法。
6.7 其它数据文件
我们迄今已经讨论过两个系统数据文件:密码文件和组文件。UNIX系统在日常操作中使用了许多其它文件。例如,BSD网络软件有一个由各种网络服务器提供为服务的一个数据文件(/etc/services),一个为协议提供的文件(/etc/protocols),和一个为网络提供的文件(/etc/networks)。幸运的是,这些各类文件的接口和我们已经讨论过的密码和组文件的接口相似。
基本原则是每个数据文件有至少三个函数:
1、get函数,读取下一个记录,必要时打开这个文件。这些函数通常返回一个结构体的指针。当到达文件尾时一个空指针被返回。多数get函数返回一个静态结构体的指针,所以我们如果想保存它则总是需要拷贝它。
2、set函数,如果文件没打开的话打开这个文件,并回退这个文件。这个函数在我们知道我们想从文件开头重新开始时被使用。
3、end项,关闭数据文件。正如我们早先提到的,我们总是需要在完成工作时调用它,来关闭所有的文件。
此外,如果数据文件支持一些关键字查找的格式,有些提供的函数可以根据关键字查找一个记录。例如,两个关键字查找函数为密码文件而提供:getpwnam根据用户名查找一个记录,而getpwuid根据用户ID查找一个记录。
下表展示了一些这样的函数,它们在UNIX上很普遍。在这个表时,我们展示密码文件和组文件的函数,我们在本章的更早部分讨论过,还有一些网络函数。这张表里有所有数据文件的get、set和end函数:
访问系统数据文件的类似函数 | ||||
描述 | 数据文件 | 头文件 | 结构体 | 补充的关键字查找函数 |
密码 | /etc/passwd | passwd | getpwnam, getpwuid | |
组 | /etc/group | group | getgrnam, getgrgid | |
影子 | /etc/shadow | spwd | getspanam | |
主机 | /etc/hosts/ | hostent | gethostbyname, gethostbyaddr | |
网络 | /etc/networks | netent | getnetbyname, getnetbyaddr | |
协议 | /etc/protocols | protoent | getprotobyname, getprotobynumber | |
服务 | /etc/services | servent | getservbyname, getservbyport |
在Solaris下,上表的最后四个文件是在目录/etc/inet下的同名文件的符号链接。多数UNIX系统实现有类似的补充函数,但这些补充函数倾向于处理系统管理文件并针对于每个实现。
6.8 登录帐号(Login Accounting)
由多数UNIX系统提供的两个数据文件是utmp文件,它跟踪所有当前登录的用户,和wtmp文件,它记录所有的登入与登出。在版本7,一个记录类型被这两个文件写,一个与下面结构体一致的二进制记录:
struct utmp {
char ut_line[8]; /* tty line: "tyh0", "ttyd0", "ttyp0", ... */
char ut_name[8]; /* login name */
long ut_time; /* seconds since Epoch */
};
当登录时,一个这样的结构体被填充,并由login程序写入到utmp文件里,而同样的结构体被添加到wtmp文件里。在登出时,utmp文件的项被删除--用空字节填充--由init进程,而一个新的项被添加到wtmp文件里。这个在wtmp文件的登出项的ut_name域被清零。一些特殊的项被添加到wtmp文件来指明系统何时重启,和系统时间和日期改变前后的时间。who程序读取utmp文件并以可读格式打印它的内容。UNIX系统的更新版本提供了last命令,来从wtmp文件里读取并打开选择的项。
UNIX系统的多数版本仍提供tmp和wtmp文件,但正如预期的,这些文件的信息量已经增长了。版本7的20字节的结构体在SVR2增长到了36字节,而SVR4的扩展的utmp结构体超过了350字节!
这些记录在Solaris的细节格式在utmpx手册页里给出。在Solaris 9,两个文件都在/var/adm目录下。Solaris在getutx里提供了许多函数来读取这两个文件。
在FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3上,utmp手册页给了这些登入记录的它们版本的格式。这两个文件的路径名为/var/run/utmp和/var/log/wtmp。
6.9 系统识别(System Identification)
POSIX.1定义了uname函数来返回当前主机和操作系统的信息。
#include
int uname(struct utsname *name);
成功返回非负值,失败返回-1。
我们传递一个utsname结构体的地址,而这个函数把它填满。POSIX.1只定义了这个结构体里的最少的域,它们都是字符数组,根据每个实现来设置各数组的尺寸。一些实现在这个结构体里提供了额外的域。
struct utsname {
char sysname[]; /* name of the operating system */
char nodename[]; /* name of hits node */
char release[]; /* current release of operating system */
char version[]; /* current version of this release */
char machine[]; /* name of hardware type */
};
每个字符串都以null终止。本文的四个平台的最大名字长度在下表中列出:
系统识别名的限制 | ||||
接口 | 最大名字长度 | |||
FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 | |
uname | 256 | 65 | 256 | 257 |
gethostname | 256 | 64 | 256 | 256 |
utsname结构体的信息通常可能用uname命令打印出来。
POSIX.1警告,nodename可能不足以用来引用一个联系网络的主机。这个函数从System V而来,而在过去,nodename元素被用来在一个UUCP网络标识主机。
还要意识到这个结构体里的信息没有给出关于POSIX.1级别的任何作息。它应该使用_POSIX_VERSION得到,如2.6节。
最后,这个函数只给了我们一种得到这个结构体里的信息的方法,而POSIX.1没有指定任何关于初始化这个信息的方法。
历史上,基于BSD的系统提供了gethostname函数来只返回主机的名字。这个名字通常是在TCP/IP网络上的主机名。
#include
int gethostname(char *name, int namelen);
成功返回0,失败返回-1。
namelen参数指定了name缓冲的尺寸。如果提供了足够的空间,返回的字符串以null结尾。如果没有提供足够的空间,没有指定这个字符串是否以null结尾。
gethostname函数现是POSIX.1的一部分,指定了最大主机名的长度为HOST_NAME_MAX。由本文四个平台支持的最大名字长度在前面的表里给出了。
如果主机连接到一个TCP/IP网络,那么主机名通常是这个主机的完整域名。
有一个hostname命令可以得到或设置主机名。(主机名由超级用户使用一个相似的函数sethostname来设置。)主机名通常在启动时,由/etc/rc或init引入的start-up文件设置。
6.10 时间日期函数(Time and Date routines)
由UNIX内核提供的基本时间服务计算自从Epoch:统一全球时间(Coordinated Universal Time, UTC)1970-1-1 00:00:00至今经过的秒数。在1.10节,我们说过这些秒数用time_t数据类型表示,而我们称它们为日历时间。这些日历时间同时表示时间和日期。UNIX系统总是和其它操作系统有所区别:a、保留UTC时间而不是本地时间;b、自动完成转换,比如夏令时时间;c、把时间和日期作为单一量存储。
time函数返回当前的时间和日期。
#include
time_t time(time_t *calptr);
成功返回时间值,错误返回-1。
这个时间值总是作为函数的值返回。如果参数是一个非空值,时间值也被存储在calptr指向的地点里。
我们没有说过内核如何初始化当前时间。历史上,在从System V继承下来的实现上,stime函数被调用,而基于BSD的系统使用settimeofday。
SUS没有指定系统如何设置它的当前时间。
gettimeofday函数提供了比time函数更好的精度(精确到微秒)。这对于一些应用程序很重要。
#include
int gettimeofday(struct timeval *restrict tp, void *restrict tzp);
总是返回0。
这个函数定义在SUS的XSI扩展里。tzp的唯一合法值是NULL。其它值会导致未指定的行为。一些平台通过使用tzp支持一个时区的指定,然而这是系统指定的而不是SUS定义的。
gettimeofday函数把以Epoch衡量的当前时间存储在由tp指向的内存里。这个时间被表示为一个timeval结构里,它存储秒数和微秒数:
struct timeval {
time_t tv_sec; /* seconds */
long tv_usec; /* microseconds */
}:
一但我们有了这个计算从Epoch至今的秒数的整型值,我们可以调用其它函数来把它转换成人可读的时间和日期。
localtime和gmtime这两个函数把日历时间转换了一个被称为分解时间(broken-down time)的一个结构体tm。
struct tm { /* a broken-down time */
int tm_sec; /* seconds after the minute: [0 - 60] */
int tm_min; /* minutes after the hour: [0-59] */
int tm_hour; /* hours after midnight:[0-23] */
int tm_mday; /* day of the month: [1-31] */
int tm_mon; /* months since January: [0-11] */
int tm_year; /* years since 1900 */
int tm_wday; /* days since sunday: [0-6] */
int tm_yday; /* days since January 1: [0-365] */
int tm_isdst; /* daylight saving time flag: <0, 0, >0 */
};
秒数能大于59的原因是为了允许闰秒(leap second)。注意除了月份日之外的所有域都是基于0的。夏令时标志为正表示夏令时有效,0表示无效,负值表示些信息不可用。
在SUS的前一个版本,双闰秒(double leap seconds)被允许。这样,tm_sec成员的合法范围是0-61。UTC的格式定义不允许双闰秒,所以秒的合法值现在被定义为0-60。
#include
struct tm *gmtime(const time_t *calptr);
struct tm *localtime(const time_t *calptr);
两者都返回分解时间的指针。
localtime和gmtime的区别在于第一个把日历时间转换为本地时间,根据时区和夏令时标志,而后者把日历时间转换成一个表示为UTC的分解时间。
函数mktime接受一个表示为本地时间的分解时间,并把它转换成一个time_t值。
#include
time_t mktime(struct tm *tmptr);
成功返回日历时间,错误返回-1
asctime和ctime函数生产熟悉的26字节字符串,它与date命令的默认输出类似:
2012年 02月 27日 星期一 17:15:51 CST
#include
char *asctime(const struct tm *tmptr);
char *ctime(const time_t *calptr);
两者都返回以null结尾的字符串。
asctime的参数是一个指向分解时间字符串的指针,而ctime的参数是一个指向日历时间的指针。
最后一个时间函数,strftime,是最复杂的。它是一个对于时间值类似于printf的函数。
#include
size_t strftime(char *restrict buf, size_t maxsize, const char *restrict format, const struct tm *restrict tmptr);
如果空间足够返回存储在数组里的字符数,否则返回0。
最后的参数是格式所需的时间值,由一个分解时间值的指针指定。格式化的结构存储在尺寸为maxsize的buf数组里。如果包括终止null的结果的尺寸,可以放入这个缓冲,那么函数返回在buf里存储的字符数,不包括终止null。否则,该函数返回0。
参数format控制这个时间值的格式化。和printf函数相似,转换指示符由一个百分号接着一个特殊的字符给定。格式化字符串的所有其它字符被拷贝到输出中。一行内的两个百分号在输出中产生单个百分号。不像printf函数,每个指定的转换产生一个不同的固定大小的输出字符串--在格式化字符串里没有域长度。下表描述了37个ISO C的转换指示符。该表的第三列是Linux下的strftime输出,对应时间日期为Tue Feb 10 18:27:38 EST 2004。
格式 | 描述 | 例子 |
%a | 缩写的星期名 | Tue |
%A | 完全星期名 | Tuesday |
%b | 缩写的月名 | Feb |
%B | 完全月名 | February |
%c | 日期与时间 | Tue Feb 10 18:27:38 2004 |
%C | 年份除以100: [00-99] | 20 |
%d | 月内日期:[01-31] | 10 |
%D | 日期[MM/DD/YY] | 02/10/04 |
%e | 月内日期(单个数字用空格开头)[1-31] | 10 |
%F | ISO 8601日期格式[YYYY-MM-DD] | 2004-02-10 |
%g | ISO 8601基于星期的年份的后两个数字[00-99] | 04 |
%G | ISO 8601基于星期的年份 | 2004 |
%h | 与%b相同 | Feb |
%H | 一天内的小时(24小时格式):[00-23] | 18 |
%I | 一天内的小时(12小时格式):[01-12] | 06 |
%j | 一年内的天数[001-366] | 041 |
%m | 月份:[01-12] | 02 |
%M | 分:[00-59] | 27 |
%n | 换行符 | |
%p | AM/PM | PM |
%r | 本地时间(12小时格式) | 06:27:38 PM |
%R | 和“%H:%M”相同 | 18:27 |
%S | 秒:[00-60] | 38 |
%t | 水平制表符 | |
%T | 和“%H:%M:%S”相同 | 18:27:38 |
%u | ISO 8601星期[周一=1,1-7] | 2 |
%U | 周日星期数:[00-53] | 06 |
%V | ISO 8601星期数:[01-53] | 07 |
%w | 星期:[0=周天,0-6] | 2 |
%W | 周一星期数:[00-53] | 06 |
%x | 日期 | 02/10/04 |
%X | 时间 | 18:27:38 |
%y | 年份后两位:[00-99] | 04 |
%Y | 年份 | 2004 |
%z | UTC以ISO 8601格式的偏移量 | -500 |
%Z | 时间区 | EST |
%% | 翻译为一个百分号 | % |
不明朗的指示符只有%U、%V和%W。%U指示符表示年内的星期数,包含第一个星期天的星期被作为第一个星期。%W指示符表示年内的星期数,包含第一个周一的是第一个星期。%V指示符不同。如果包含新年一月的第一天的星期有四天或更多天,这个星期被作为第一个星期。不然,它作为去年的最后一个星期。在两种情况下,周一都作为星期的第一天。
和printf一样,strftime支持了一些转换指示符的修改符。E和O修改符可以用来产生替代格式,如果本地化被支持的话。
一些系统在strftime的格式化字符串时支持额外的,非标准的扩展。
localtime、mktime、ctime和strftime被TZ环境变量影响。如果定义的话,这个环境变量的值被这些函数使用代替默认的时区。如果变量被定义为空字符串,比如TZ=,那么UTC通常被使用。这个环境变量的值通常是像TZ=EST5EDT的东西,但POSIX.1允许细节地多的指定。参考SUS的环境变量那一样来得到TZ变量的所有细节。
本节描述的所有的时间和日期函数,除了gettimeofday,都定义在ISO C标准里。然而,POSIX.1加入了TZ环境变量。在FreeBSD 5.2.1、Linux 2.4.2和Mac OS X 10.3上,TZ变量的更多信息可以在tzset手册页上找到。在Solaris 9,这个信息在environ手册页里。
6.11 总结
密码文件和组文件在所有UNIX系统上被使用。我们已经看过读这些文件的各种函数。我们也说过影子密码,它能帮助系统安全。补充组ID提供了一个在同一时刻参与多个组的方法。我们也看到由多数系统提供的访问其它系统相关的数据文件的函数是如何相似。我们讨论了程序可以用来标识他们正在运行的系统的POSIX.1函数。我们在本章末看了由ISO C和SUS提供的时间和日期函数。
7.1 引言
在下章讨论进程控制前,我们需要检查下单一进程的环境。在本章,我们将看到当程序执行时main函数是如何被调用的;命令行参数是如何传递给新的程序;典型的内存布局看起来如何,怎么开辟额外的内存;进程如何使用环境变量;进程终止的各种方法。此外,我们将看到longjmp和setjmp函数和它们与栈的交互。我们在本章末检查一个进程的资源限制。
7.2 main 函数
一个C函数从执行一个名为main的函数开始。main函数的原型为:
int main(int argc, char *argv[]);
argc是命令行参数的数量而argv是参数指针的数组。我们在7.4节讨论这些参数。
当一个C程序被内核执行时--通过一个exec函数,我们在8.10节讨论--一个特殊的启动程序在main函数调用前被调用。这个可执行程序文件指定了这个程序,作为程序的开始地址;这是由C编译器调用的链接编辑器设置的。这个启动程序从内核得到几个值--命令行参数和环境--然后把事情设置好以便main函数像之前所述的那样被调用。
7.3 进程终止
有8种方法终止一个进程。普通终止有5种:
1、从main函数中返回;
2、调用exit;
3、调用_exit或_Exit;
4、最后线程从start函数返回;(11.5节)
5、从最后线程里调用pthread_exit(11.5节)
异常终止有3种:
6、调用abort(10.17节)
7、收到一个信号(10.2节)
8、最后线程回应一个取消请求(11.5节和12.7节)
目前,我们将忽略这三个与线程相关的终止方法,直到我们在11章和12章讨论线程。
我们在前一节提到的启动程序通常被写为:当main函数返回时,exit函数被调用。如果启动程序用C编码(它通常用汇编来编码),main函数的调用可能看起来像:
exit(main(argc, argv));
Exit 函数
三个普通终止程序的函数:_exit和_Exit从内核立即返回;eixt执行特定清理处理然后从内核返回。
#include
void exit(int status);
void _Exit(int status);
#include
void _exit(int status);
我们将在8.5节讨论这三个函数在其它进程,比如终止进程的子进程和父进程,上的效果。
不同头文件的原因是exit和_Exit由ISO C规定而_exit由POSIX.1规定。
历史上,exit函数问题执行一个标准I/O库的清理:fclose函数被调用来关闭所有打开的流。回想下5.5节,这会使所有缓冲的输出数据被冲洗(写入文件)。
这三个exit函数都期望一个整型参数,我们称它为退出状态。多数UNIX系统的外壳提供一种检查进程退出状态的方法。如果a、这些函数中任何一个被调用而没有退出状态,b、main返回而没有返回值,或者c、main函数没有被声明为返回一个整型值,那么进程的退出状态是无定义的。然而,如果main的返回类型是一个整型数而main从函数掉出来(一个隐含的return),那么进程的退出状态为0。
这是在ISO C标准的1999版本的新行为。历史上,如果main函数到达结束位置,而没有显式调用一个return语句或exit函数,退出状态是无定义的。
从main函数里返回一个整型值等价于用相同值调用exit。因而exit(0);和main函数里的return(0);相同。
看下经典的hello world程序:
$ cc hello_world.c
$ ./a.out
hello, world
$ echo $?
13
启用1999 ISO C编译扩展:
$ cc -std=c99 hello_world.c
hello_world.c:3:1: warning: return type defaults to ‘int’
$ ./a.out
hello, world
$ echo $?
0
注意当雇用1999 ISO C扩展时编译器警告出现。这是因为main函数的返回值没有显式声明为一个整型。如果我们加上这个声明,那这个消息就不会出现。然而,如果我们启用所有推荐的错误(用-Wall标志),那么我们将会看到类似于“control reaches end of nonvoid function.”的消息。(我在gcc version 4.5.2 (Ubuntu/Linaro 4.5.2-8ubuntu4)上没有看到这个消息。)
main的作为整型返回的声明和使用exit而不是return从一些编译器和lint程序产生不需要的警告。问题是编译器不知道从main函数的exit和一个return是相同的。一种处理这些警告的方式,在一段时间后会变得很烦,是在main里用return而不是exit。但这样做会阻止我们使用UNIX系统的grep工具来定位一个程序里的所有的exit调用。另一个解决办法是把main声明为void,而不是int,然后继续调用exit。这处理了编译器警告,但看起来不对(特别是在编程文本里),而且能产生其它的编译错误,因为main的返回值应该是一个有符号的整型。在本文,我们让main返回一个整型,因为这是ISO C和POSIX.1共同定义的。
不同的编译器的警告有不同的信息。注意GNU C编译器通常不产生这些不重要的警告信息,除非额外的警告选项被指定。
atexit函数
在ISO C,一个进程可以最多注册32个自动被exit函数调用的函数。这些被称为exit处理器,并通过调用atexit来注册。
#include
int atexit(void (*func)(void));
成功返回0,错误返回非0值。
声明说我们传递一个函数地址作为atexit的参数。当这个函数被调用时,不传入任何参数也不返回任何值。exit函数以它们注册的顺序的相反顺序调用这些函数。每个函数都被调用和它被注册的一样多的次数。
这些退出处理器第一次出现在1989年的ANSI C标准里。在ANSI C之前的系统,比如SVR3和4.3BSD,没有提供这些退出处理器。
ISO C要求系统支持至少32个退出处理器。sysconf函数可能用来决定一个给定系统支持的退出处理器的最大数量。
在ISO C和POSIX.1,exit首先调用退出处理器,然后(通过fclose)关闭所有打开的文件,最后调用_exit或_Exit回到内核。POSIX.1扩展了ISO C标准,指出如果程序调用任何一个exec家族的函数,那么任何安装的退出处理器将被清理掉。
注意内核执行一个程序的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是调用_exit或_Eixt,不管是显式地还是隐式地(通过调用exit函数)。一个进程也可以被一个信号非自愿地终止。
下面的代码使用了atexit函数:
7.4 命令行参数
当一个程序被执行时,使用exec的进程可以传递命令行参数给这个新的程序。这是UNIX系统外壳的普通操作的一部分。我们在前面章节中的很多例子里已经看到过。
看下面的例子:
我们被ISO C和POSIX.1两者保证argv[argc]是一个空指针。这允许我们用另一种方式编码参数处理循环:
for (i = 0; argv[i] != NULL; i++)
7.5 环境列表(Environment List)
每个程序还被传入一个环境列表。就像参数列表那样,环境列表是一个字符指针的数组,每个指针包含一个以null终止的C字符串的地址。这个指针数组的地址包含在全局变量environ里:
extern char **environ;
例如,如果环境由5个字符串组成,那environ就指向一个长度为5的指针数组。数组里的每个地址都指向一个如“HOME=/home/tommy\0”形式的字符串。这里显式地给出最后的终止符。
我们称environ为环境指针,称指针数组为环境列表 。称它们指向的字符串为环境字符串。
根据协议,环境由字符串name=value组成。多数预定义的名字是完全大写的,但这只是一个协议而已。
历史上,多数UNIX系统 ,为main函数提供第三个参数,它是环境列表的地址:
int main (int argc, char *argv[], char *envp[]);
因为ISO C指出,main 函数有两个参数,而又因为第三个参数没有提供比全局变量environ更好的功能,所以POSIX.1 指出environ应该用来代替那个(可能的)第三个参数。访问特定的环境变量通常是通过getenv和putenv函数,在7.9节讨论,而不是通过environ变量。然而要遍历整个环境变量,environ指针必须被使用。
7.6 C程序的内存布局
历史上,一个C程序可以由以下部分组成:
1、代码段(text segment),CPU执行的机器指令。通过,代码段是可共享的,以便经常执行的程序只需在内存里单个拷贝,比如文本编辑器,C编译器,外壳,等等。还有代码段通常是只读的,为了阻止一个程序偶然修改了它的指令。
2、初始化的数据段(Initialized data segment),通常简称为数据段,包括在程序里特别初始化的变量。例如,C出现在任何函数外的声明int maxcount = 99;会导致这个变量以其初始值存储在初始数据段里。
3、未初始化的数据段(Uninitialized data segment),经常被称为“bss”段,在代表“block started by symbol”的古老的汇编操作之后命令。在这个段的数据被内核在程序开始执行前初始化为数字0或null指针。出现在任何函数外的C声明long sum[1000];导致这个变量被存储在未初始化的数据段里。
4、栈,存储自动变量和每次一个函数调用时保存信息的地方。每次一个函数被调用时,它要返回到的地址和关于调用者环境的特定信息,比如一些机器寄存器,被保存在栈里。新调用的函数然后在栈上为自动和临时变量开辟空间。这是在C里的递归函数如何工作的。每次一个递归函数调用它自身时,一个新的栈框架被使用,所以一堆变量不会和这个函数的其它实例的变量冲突。
5、堆,动态内存分配通常发生的地方。历史上,堆一直放在未初始化数据和栈之间。
这些段的典型布局是:最低地址是代码段,其上是初始化数据,再上是未初始化数据,最高地址是命令行参数和环境变量,其下是栈,在栈和bss段之间是堆。这是一个程序看起来的逻辑图,没有要求说一个给定的实现必须以这种风格排列它的内存。尽管如此,这给了我们一个可以描述的典型的排列。在Intel x86处理器上的Linux上,代码段从地址0x8048000开始中,而栈的底部从0xC0000000开始。(在这个特定的架构上,栈从高位地址向地位地址增长。)在堆的顶部和栈的顶部之间的空间是巨大的。
一些存在于一个a.out里的更多的段类型,包括符号表,调试信息,为动态共享库的链接表,等等。这些额外的段没有被载入作为被进程执行的程序映像的一部分。
注意未初始化数据段的内容没有存储在磁盘上的程序文件里。这是因为内核在程序开始运行时设置它为0。需要保存在程序文件部份只有代码段和初始化数据。
size命令报告代码、数据和bss段的(字节)尺寸。例如:
$ size /usr/bin/cc /bin/sh
text data bss dec hex filename
296400 2000 5736 304136 4a408 /usr/bin/cc
93358 900 10188 104446 197fe /bin/sh
第三和第四列是前三个尺寸的总数。分别以十进制和十六进制表示。
7.7 共享库(Shared Libraries)
当今多数UNIX系统支持共享库。Arnold[1986]描述了一个在System V下的早期实现,而Gingell等[1987]描述了在SunOS下的一个不同的实现。共享库从可执行文件中删除了通用的库程序,取而代之的是维护一个所有进程引用的内存里某处的库程序的单一拷贝。这减少了每个可执行程序文件的尺寸,但增加了运行时的开销,当程序第一次被执行或每个共享库函数被调用时。共享库的另一个好处是库函数可以用新的版本代替,而不用重链接每个使用这个库的程序。(这假设参数的数量和类型没有改变。)
不同的系统为一个程序提供了不同的方法来表明它是否想使用共享库。cc和ld命令的选项是典型的。作为一个尺寸区别的例子,下面的可执行文件--经典的hello world程序--不使用共享库而被创建:
$ cc -static hello_world.c
$ ls -l a.out
-rwxrwxr-x 1 tommy tommy 644429 2012-02-27 21:49 a.out
$ size a.out
text data bss dec hex filename
575523 2056 7048 584627 8ebb3 a.out
如果我们使用共享库来编译这个程序,这个可执行程序的代码和数据尺寸会大幅减少:
$ cc hello_world.c
$ ls -l a.out
-rwxrwxr-x 1 tommy tommy 7165 2012-02-27 21:51 a.out
$ size a.out
text data bss dec hex filename
1132 256 8 1396 574 a.out
7.8 内存分配(Memory Allocation)
ISO C为内存分配规定了三个函数:
1、malloc,它开辟指定字节数量的内存。内存的初始值是不确定的。
2、calloc、为指定数量的指定尺寸的对象开辟空间。这个空间被初始化为0。
3、realloc、增加或减少之前开辟的区域。当尺寸增加时,它可能会导致把之前开辟的空间移到其它地方,来在尾部提供额外的空间。还有,当尺寸增加时,在旧对空和新区域尾部之间的空间的初始值是不确定的。
#include
void *malloc(size_t size);
void *calloc(size_t nobj, size_t size);
void *realloc(void *ptr, size_t newsize);
三者成功都返回非空指针,错误返回NULL。
void free(void *ptr);
这三个分配内存的函数的返回值被保证为适当对齐的,以便它能用于任何数据对象。比如,如果在一个特定系统上的最限制的对齐请求要求double必须从8的倍数的内存地址开始,那么这三个函数返回的所有指针都会如此对齐。
因为这三个alloc函数返回一个通用的void *指针,所以如果我们#include
函数free导致ptr指向的空间被释放。这被释放的空间通常放入可用内存的池时,在下次这三个alloc函数的调用时可以被再次分配。
realloc函数让我们增加或减少前一个被分配区域的尺寸。(最普遍的使用是增加一个区域。)例如,如果我们在我们运行时填满的一个数组里为512个元素开辟空间,但是发现我们需要多于512个元素的空间,那么我们可调用realloc。如果已有区域末尾之后有可以符合请求的空间,那么realloc不必移动任何东西,它只简单地在末尾开辟额外的空间然后返回我们传递给它的同样的指针。但是如果在已有区域尾部没有足够的空间,realloc会开辟另一块足够大的空间,并把已有的512个元素的数组拷贝到这块新区域,并释放旧的数据,然后返回新区域的指针。因为区域可能会移动,所以我们不应该拥有这个区域的任何指针。第4章的练习展示了realloc和getcwd一起使用来处理任何长度的路径名。17章展示了一个使用realloc来避免一个固定的编译器尺寸的数组的例子。
注意realloc的最后一个参数是区域的新尺寸,而不是旧尺寸和新尺寸的差别。作为一个特殊的例子,如果ptr是一个空指针,realloc和malloc行为相似,开辟一个指定尺寸的区域。
这些函数的早期版本允许我们来realloc一个我们曾用malloc、realloc或calloc开辟过的但已经释放了的块。这个把戏在版本7上就有,使用malloc的查找策略来执行存储压缩。Solaris仍然支持这个特性,但许多其它平台已经不支持了。这个特性不赞成被使用,而且也不应该被使用。
内存分配函数通常用sbrk系统调用实现。这个系统调用扩展(或缩小)进程的堆。malloc和free的一个样本实现在Kernighan and Ritchie[1988]的8.7节给出。
尽管sbrk可以扩展和缩小进程的内存,malloc和free的多数版本都不减少它们的内存尺寸。我们释放的空间可以用作下次的分配,但释放的空间不会返回给内核,而是被malloc池保存。
多数实现开辟比所请求的稍多一点的空间并使用额外的空间来保存记录--分配了的块的尺寸、指向下一个分配了的块的指针等等。意识到这点是重要的。这意味着在一个分配了的区域末写可能会覆写之更后面的块的记录保存信息。这些错误类型经常是惨重的,却很难发现,因为这个错误可能会等到很晚才会出现。还有,在分配的区域开头之前写数据同样可能会覆写记录保存信息。
在一个动态分配的缓存的末尾后面或开头之前写可以破坏比内部记录保存信息更多。在一个动态分配的缓存之前或之后的内存可能潜在地用作其它的动态分配对象。这些对象可以和破坏它们的代码没有关系,这会导致更难找到破坏的源头。
其它可能的致命的错误是释放一个已经被释放的块,还有释放一个不是通过那三个alloc函数得到的一个指针。如果一个进程调用malloc,但忘记了调用free,它的内存使用会持续增长。这被称为泄露。不调用free来返回不使用的空间的话,进程地址空间的尺寸会缓慢增长,直到没有空闲内存剩余。在这段时间,性能会因为过量的换页开销而降低。
因为内存分配错误很难被追踪,所以一些系统在这些函数的版本上在每次调用这三个alloc函数或free函数时提供额外的检查。函数的这些版本经常通过为链接器引入一个特殊的库来指定。同样还有公开可用的源码,你可以用特殊的标志编译它们以启用额外的运行期检查。
FreeBSD,Mac OS X和Linux通过环境变量的设置来支持额外的调用。此外,选项可以通过符号链接/etc/malloc.conf传递给FreeBSD库。
替代的内存分配器
malloc和free有很多可用的替代品。一些系统已经包含了提供替代的内存分配实现的库。其它系统只提供了标准分配器,让软件开发员在需要时下载替代品。我们在这里讨论一些替代品。
libmalloc
基于SVR-4的系统,比如Solaris,包含了libmalloc库。它提供了一堆匹配ISO C内存分配函数的接口。libmalloc库包括mallopt,一个允许进程设置特定的控制存储分配器的变量的函数。一个称为mallinfo的函数同样可以用来提供内存分配器的统计资料。
vmalloc
Vo[1996]描述了一个允许进程未不同内存区域使用不同的技术的来分配内存的内存分配器。除了vmalloc相关的函数,这个库还提供了ISO C内存分配函数的模拟。
quick-fit
历史上,标准的malloc算法使用最佳适配(best-fit)或最先适配(first-fit)的内存分配策略。快速适配(Quick-fit)比它们都快,但会使用更多内存。Weinstock and Wulf[1988]描述了这个算法,它基于把内存分割为不同尺寸的缓冲,并根据缓冲的尺寸在不同的释放列表上维护未使用的缓冲。基于快速适配的malloc和free的免费实现在几个FTP站点上可读。
alloca函数
还有一个函数值提及。函数alloca有和malloc一样的调用顺序,然而,它不在堆上分配内存,而是在当前函数的栈框架上。好处是我们不必释放内存,它在函数返回时会自动释放。alloca函数增加了栈框架的尺寸。缺点是一些系统如果在函数调用后无法增加栈框架的尺寸,则不支持alloca。尽管如此,许多软件包使用它,而实现存在于各个系统上。
本文讨论的四个平台都支持alloca函数。
7.9 环境变量(Environment Variables)
正如我们早先提到的,环境字符串通常是这样的格式:
name=value
UNIX内核从不看这些字符串,它们的解释交给各个应用程序。例如,shell使用许多环境变量。一些比如HOME和USER的变量在登录时自动设置,而其它的是我们自己设置。我们通常在shell启动文件时设置环境亦是来控制shell的行为。例如,如果我们设置环境变量MAILPATH,它告诉Bourne shell,GNU Bourne-again shell和Korn shell哪里去查找邮件。
ISO C定义了一个我们可以用来从环境获取值的函数,但是标准说环境的内容是由实现定义的。
#include
char *getenv(const char *name);
返回和name相关的值的指针,没有找到则返回NULL。
注意这个函数返回一个name=value的字符串的指针。我们应该使用getenv来从环境得到指定的值,而不是直接访问environ。
一些环境变量在SUS里由POSIX.1定义,而其它的只当XSI扩展被支持时才定义。下表列出了由SUS定义的环境变量并标出哪些实现支持这些变量。任何定义在POSIX.1里的环境变量用*标记,否则它是一个XSI扩展。许多补充的实现相关的环境变量在文本讨论的四个平台上使用。注意ISO C没有定义任何环境变量。
SUS定义的环境变量 | ||||||
变量 | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 | 描述 |
COLUMNS | * | * | * | * | * | 终端宽度 |
DATEMSK | XSI | * | * | getdate模板文件路径名 | ||
HOME | * | * | * | * | * | 主目录 |
LANG | * | * | * | * | * | 本地名 |
LC_ALL | * | * | * | * | * | 本地名 |
LC_COLLATE | * | * | * | * | * | 校验本地名 |
LC_CTYPE | * | * | * | * | * | 字符分类本地名 |
LC_MESSAGES | * | * | * | * | * | 消息本地名 |
LC_MONETARY | * | * | * | * | * | 货币编辑本地名 |
LC_NUMERIC | * | * | * | * | * | 数值编辑本地名 |
LC_TIME | * | * | * | * | * | 时间日期格式本地名 |
LINES | * | * | * | * | * | 终端高度 |
LOGNAME | * | * | * | * | * | 登录名 |
MSGVERB | XSI | * | * | 进程的fmtmsg消息组件 | ||
NLSPATH | XSI | * | * | * | * | 消息种类的模板序列 |
PATH | * | * | * | * | * | 寻找可执行程序的路径前缀列表 |
PWD | * | * | * | * | * | 当前工作目录的绝对路径名 |
SHELL | * | * | * | * | * | 用户最喜欢的shell |
TERM | * | * | * | * | * | 终端类型 |
TMPDIR | * | * | * | * | * | 创建临时文件的目录的路径名 |
TZ | * | * | * | * | * | 时区信息 |
除了从一个环境变量获取值,有时我们还可能想设置一个环境变量。我们可能想改变一个已存在的变量或在环境里加上一个新的变量。(在下一章,我们将看到我们可以影响当前进程和任意我们能调用的子进程的环境。我们不能影响父进程的环境,它通常是shell。尽管如此,能够修改环境列表仍然是有用的。)不幸的是,不是所有系统都支持这种能力。下表展示了由各种标准和实现支持的函数:
各种环境列表函数的支持 | ||||||
函数 | ISO C | POSIX.1 | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
getenv | * | * | * | * | * | * |
putenv | XSI | * | * | * | * | |
setenv | * | * | * | * | ||
unsetenv | * | * | * | * | ||
clearenv | * |
clearenv不是SUS的一部分。它用来移除环境列表的所有的项。
上表中间的三个函数的原型是:
#include
int putenv(char *str);
int setenv(const char *name, const char *value, int rewrite);
int unsetenv(const char *name);
三者成功返回0,错误返回非0.
这三个函数的操作如下:
1、putenv函数接受一个如name=value格式的字符串,并把它放在环境列表里。如果name已经存在,它的旧的定义会首先被移除。
2、setenv函数给name设置一个value。如果name存在于环境中,那么a、如果rewrite为非0,则存在的name的定义首先被移除;b、如果rewrite为0,name的已存在的定义不被删除,name不会被设置为新的value,也没有错误发生。
3、unsetenv函数删除任何name的定义。如果没有定义存在,也不是一个错误。注意putenv和setenv的区别。setenv必须开辟内存来创建它参数的name=value的字符串,putenv可以把字符串直接传给环境。事实上,Linux和Solaris上,putenv的实现把我们传递给它的字符串的地址直接放入环境列表里。在这种情况下,传递一个在栈内分配的字符串将会是个错误,因为内存在当前函数返回后会被重用。
有意思的事是检查这些函数当修改环境列表时必须怎样操作。回想下,环境列表--指向真实name=value的字符串的指针的数组--和环境字符串通常存储在进程内存空间的顶部,在栈之上。删除一个字符串很简单,我们只需在环境列表内找到这个指针并把所有随后的指针都往下移一格,然而加上一个字符串或修改一个已经存在的字符串要更困难。栈顶部的空间是不能被扩展的,因为它经常在进程的地址空间的顶部,所以不能往上扩展;它也不能往下扩展,因为其下所有的栈框架都不能被移动。
1、如果我们修改一个已存在的名字:
a、如果新的值的尺寸小于或等于已存在的值的尺寸,那么我们可以仅把新字符串拷贝到旧字符串之上。
b、如果新值的尺寸大于旧的尺寸,我们并发调用malloc来为这个新字符串获得空间,把新值拷贝到这个区域,然后把环境列表里的旧指针替换为指向这个开辟的区域的指针。
2、如果我们添加一个新的名字,那这将更复杂。首先,我们并须调用malloc来为name=vlaue的字符串分配空间,然后复制这个字符串到这个区域。
a、然后,如果这是我们第一次加入一个新的名字,我们必须调用malloc来为新的指针列表分配空间。我们把旧的环境列表拷贝到这个新的区域,然后把name=value字符串保存到这个指针列表的末尾。当然,我们同样在这个列表的末尾存储一个空指针。最后,我们把environ设置为指向这个新的指针列表。注意如果原始环境列表存储在栈的顶部,就像普遍的那样,那么我们把这个指针列表移到了堆。但是这个列表的多数指针仍然指向在栈顶部的name=value字符串。
b、如果这不是我们第一次在环境列表里加入新的字符串,那么我们知道我们已经为这个列表在堆里分配了空间,所以我们只调用realloc来为一个更多的指针分配空间。指向这个新的name=value字符串的指针被存储在这个列表的末尾(之前放空指针的位置),随后接一个空的指针。
7.10 setjmp和longjmp函数
在C里,我们不能用goto进入另一个函数的标签。相反,我们必须使用setjmp和longjmp函数来执行这种类型的跳转。正如我们将看到的那样,这两个函数对于处理发生在嵌套地很深的函数调用里的错误情况很有用。
考虑下面代码的seketon。它由一个从标准输入读取行的主循环和处理各行的函数do_line调用组成。这个函数随后调用get_token来从输入行里获取下一个语素。一行的第一个语素被视为某种格式的一个命令,而switch语句选择每个命令。对显示的单个命令,cmd_add函数被调用。
正如我们已经说过的,这种栈的排列类型是典型的,但不是必需的。栈不必向更低内存地址增长。在没有支持栈的内置硬件的系统上,C实现可能用一个链表来作为它的栈框架。
就像上述代码一样的程序进程碰到的编码问题是如何处理非致命错误。例如,如果cmd_add函数碰到一个错误--比如一个无效数字--它可以想打印一个错误,忽略输入行的剩余部分,并返回到main函数来读取下个输入行。但是当我们从main函数很深地嵌套了很多层时,在C里面很难做到。(在这个例子里,在cmd_add函数,我们只从main里往下两层,但从我们从想要回到的地方到当前位置有五层或更多并不是不普遍的事。)如果我们必须在每个函数里返回一个特殊的值来返回一层会变得很凌乱。
这个问题的解决方案是使用一个非本地的的goto:setjmp和longjmp函数。形容词“非本地的”是因为我们不能在一个函数里用普通的C goto语句,相反,我们要通过调用框架来跳转到一个当前函数的调用路径里的一个函数。
#include
int setjmp(jmp_buf env);
如果直接调用返回0,如果从longjmp调用返回则返回非0。
void longjmp(jmp_buf env, int val);
我们从我们想回到的地点里调用setjmp,在这个例子里是main函数。这种情况下, setjmp返回0因为我们直接调用它。在这个setjmp的调用里,env参数是一个特殊的类型jmp_buf。这个数据类型是某种格式的数组,能够存储所有所需的信息,当我们调用longjmp时用来恢复栈的状态。通常,env变量是一个全局变量,因为我们将需要从另一个函数里引用它。
当我们碰到一个错误--比如在cmd_add函数里--我们用两个参数调用longjmp。第一个和我们在setjmp调用里一样的env,而第二个,是个作为setjmp的返回值的一个非0值。例如,我们可以从cmd_add用一个值为1的val调用longjmp,也可以从get_token以值为2的val调用longjmp。在main函数里,从setjmp返回的值是1或2,而如果我们愿意的话,可以测试这个值,来决定longjmp是从cmd_add还是get_token出来的。
让我们回到这个例子。下面的代码展示了main和cmd_add函数。(另两个函数,do_line和get_token,没有改变。)
自动、寄存器、和易变变量(Automatic, Register, and Volatile Variables)
我们已经看到调用longjmp之后的栈是怎么样的。下一个问题是:“在main函数里的自动变量和寄存器变量是什么状态?”当通过longjmp回到main,这些变量是当setjmp上次调用时对应的值(也就是说,它们的值被回滚),还是没有被干涉,以致它们的值是当do_line被调用时的任何一个值(do_line调用了cmd_add,而cmd_add调用了longjmp)?不幸的是,答案是“看情况”。多数实现都不尝试回滚这些自动变量和寄存器变量,但是标准只说它们的值是不确定的。如果你有一个不想回滚的自动变量,把它定义成易变变量。被声明为全局或静态的变量当longjmp被执行时不会被干涉。
看下面的例子:
不使用编译优化的结果:
$ cc longjmp_on_variables.c
$ ./a.outglobval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99
使用编译优化的结果:
注意优化不会影响全局、静态和易变变量。它们的值在longjmp之后和我们假设的最后的值一样。在一个系统上的setjmp手册页表明存储在内存里的变量将会有longjmp调用时的值,而CPU和浮点寄存器里的变量在setjmp调用时会回复它们的值。这些确实是我们上面代码所看到的。在没有优化的情况下,这五个变量都存储在内存里(register提示被忽略)。当我们启用优化时,autoval和regival都放入寄存器里,即使前者没有显式声明为register,而volatile变量保留在内存里。这个例子里要知道的事是你如果在写一个使用非本地跳转的可移植的程序时,你必须使用volatile属性。其它任何东西在各个系统上都会改变。
在上面代码里的一些printf格式字符串太长,不能在程序文本里很好地显示。我们依赖ISO C的字符串连接特性,而不是调用多次printf。代码"string1" "string2"等价于"string1string2"。
我们将在第10章讨论信号处理和它们的信号版本:sigsetjmp和siglongjmp时再回到这两个函数:setjmp和longjmp。
自动变量的潜在问题
看过栈框架通常被处理的方式,我们应该看下处理自动变量的潜在问题。基本原则是一个自动变量绝不能在声明它的函数返回后被引用。贯穿整个UNIX系统手册,有许多关于它的警告。
7.11 getrlimit和setrlimit函数
每个进程都有一堆资源限制,其中一些可以用getrlimit和setrlimit函数查询和改变。
#include
int getrlimit(int resource, struct rlimit *rlptr);
int setrlimit(int resource, const struct rlimit *rlptr);
两者成功都返回0,错误都返回非0。
这两个函数作为SUS的XSI扩展被定义。一个进程的资源限制通常在系统被初始化的时候被进程0建立,然后被每个后继进程继承。每个实现都有它自己调整各种限制的方法。
这两个函数的每次调用都指单个资源和一个指向以下结构体的指针:
struct rlimit {
rlim_t rlim_cur; /* soft limit: current limit */
rlim_t rlim_max; /* hard limit: maximum value for rlim_cur */
};
管理资源限制改变的三个准则:
1、一个进程可以改变它的软限制为一个小于或等于它的硬限制的值;
2、一个进程可以把它的硬限制降低为一个大于或等于它的软限制的值,这个硬限制的降低对普通用户来说是不可逆的。
3、只有超级用户才能提升一个硬限制。
一个无限的限制由常量RLIM_INFINITY指定。
resource参数为以下的某个值。下表展示了哪些限制由SUS定义,和被各实现支持。
资源限制的支持 | |||||
限制 | XSI | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
RLIMIT_AS | * | * | * | ||
RLIMIT_CORE | * | * | * | * | * |
RLIMIT_CPU | * | * | * | * | * |
RLIMIT_DATA | * | * | * | * | * |
RLIMIT_FSIZE | * | * | * | * | * |
RLIMIT_LOCKS | * | ||||
RLIMIT_MEMLOCK | * | * | * | ||
RLIMIT_NOFILE | * | * | * | * | * |
RLIMIT_NPROC | * | * | * | ||
RLIMIT_RSS | * | * | * | ||
RLIMIT_SBSIZE | * | ||||
RLIMIT_STACK | * | * | * | * | * |
RLIMIT_VMEM | * | * |
资源限制影响了调用它的进程并由它的子进程继承。这意味着资源限制的设定需要在shell里完成以影响我们所有的将来的进程。确实,在Bourne shell、GNU Bourne-again shell和Korn shell有内置的ulimit命令,而C shell有内置的limit命令。(umask和chdir函数同样必须作为shell内置的。)
下面的代码打印了所有在系统上支持的资源限制的软限制和硬限制。为了在所有不同的实现上编译这个程序,我们必须选择性的包含不同的资源名。注意我们在把rlim_t定义为一个unsigned long long而不是unsigned long的平台上必须使用一个不同的printf格式。
程序输出(Linux 2.6.38-13-generic):
RLIMIT_AS (infinite) (infinite)
RLIMIT_CORE 0 (infinite)
RLIMIT_CPU (infinite) (infinite)
RLIMIT_DATA (infinite) (infinite)
RLIMIT_FSIZE (infinite) (infinite)
RLIMIT_LOCKS (infinite) (infinite)
RLIMIT_MEMLOCK 65536 65536
RLIMIT_NOFILE 1024 4096
RLIMIT_NPROC (infinite) (infinite)
RLIMIT_RSS (infinite) (infinite)
RLIMIT_STACK 8388608 (infinite)
我们在讲完信号后,会在第10章的练习里继续讨论资源限制。
7.12 总结
了解UNIX系统环境下的C程序的环境是了解UNIX系统的进程控制特性的先决条件。在这章,我们已经看到进程如何被启动,它能如何终止,和它怎么样被传递一个参数列表和一个环境。尽管两者者不被内核解释,但确实是内核把这两才从exec的调用者那传递给新的进程。
我们也已经检查过C程序的典型布局,和进程怎样动态分配和释放内存。它值得我们看下处理环境的可用的函数的细节,因为它们调用内存分配。函数setjmp和longjmp也被讨论了,提供了一种在进程内执行非本地话跳转。本章末描述了各种实现支持的资源限制。
8.1 引言
我们现在进入UNIX系统提供的进程控制。这包含了新的进程的创建,程序的执行,和程序的终止。我们也看到进程的各种ID属性--真实的,有效的,和保存的;用户和组ID--以及它们怎么样被进程控制概念影响。解释文件和system函数同样也被介绍。我们通过看下多数UNIX系统提供的进程报告来结束本章。这让我们从另一个角度看进程控制函数。
8.2 进程识别(Process Identifiers)
每个进程都有一个独一无二的进程ID,一个非负整型。因为进程ID是一个总是独一无二的进程的仅有的被熟知的标识,所以它经常用来作为其它标识符的一部分,来保证唯一性。例如,应用程序有时包含进程ID作为文件名的一部分,来产生独一无二的文件名。
尽管唯一,进程ID是被重用的。当进程终止后,它们的ID变为可重用的侯选者。然而,多数UNIX系统实现算法来延迟重用,以便新创建的进程会分配一个与最近终止的进程不一样的ID。这避免了一个新的进程因为相同的ID被错当成之前的进程。
虽然有一些特殊的进程,但是各个实现上的细节都不同。进程ID 0通常是调用进程,并经常被熟知为对换程序(swapper)。在硬盘上没有对应于这个进程的程序,它是内核的一部分而被熟知为一个系统进程。进程ID 1通常是init进程,在启动过程结束后被内核调用。这个进程的程序文件在UNIX系统的早期版本是/etc/init,而在更新的版本在/sbin/init。这个进程负责在内核已经被启动后引入一个UNIX系统。init通常读取系统相关初始化文件--/etc/rc*文件或/etc/inittab和在/etc/init.d里的文件--和把系统引入一个特殊的状态,比如多用户。init进程绝对不死。这是普通的用户进程,不是内核的系统进程,不像swapper,尽管它需要超级用户权限来运行。本章稍后,我们将看到init是如何变为任何孤立的子进程的父进程。
每个UNIX系统实现有它自己的内核进程集,来提供操作系统服务。例如,在UNIX系统上的一些虚拟内存实现,进程ID 2是换页后台。这个进程负责支持虚拟内存系统的换页。
除了进程ID,每个进程还有其它的标识符。以下的函数返回这些标识符。
#include
pid_t getpid(void);
返回调用进程的进程ID。
pid_t getppid(void);
返回调用进程的父进程ID。
uid_t getuid(void);
返回调用进程的真实用户ID。
uid_t geteuid(void);
返回调用进程的有效用户ID。
gid_t getgid(void);
返回调用进程的真实组ID。
gid_t getegid(void);
返回调用进程的有效组ID。
注意这些函数中没有一个有错误返回。我们将在下节讨论fork函数时回到父进程ID。真实和有效有户和组ID在4.4节讨论过了。
8.3 fork函数
一个存在的进程可以调用fork函数来创建一个新的进程
#include
pid_t fork(void);
在子进程里返回0,在父进程里返回子进程ID,错误返回-1。
由fork函数创建的新进程被称为子进程。函数被调用一次却被返回两次。返回的唯一的区别是子进程的返回值是0,而父进程的返回值是新的子进程的ID。子进程ID被返回给父进程的原因是一个进程可以有不只一个子进程,而没有函数允许一个进程获得它的子进程的ID。fork在子进程里返回0的原因是一个进程只能有一个父进程,而子进程总是可以调用getppid来得到它的父进程ID。(进程ID 0被内核预留使用,所以它不可能是一个子进程的进程ID。)
子进程和父进程都持续执行fork调用之后的指令。子进程是父进程的一个复制品。例如,子进程得到父进程数据空间,堆和栈的拷贝。注意这对一个子进程来说是一个拷贝。父进程和子进程没有共享内存。父进程和子进程共享代码段。
当前实现不执行一个父进程数据、栈和堆的完全拷贝,因为fork后通常接着一个exec。相反,一个称为拷贝时写(copy-on-write, COW)被使用。这些区域被父进程和子进程共享,而且它们的保护机制由内核改变为只读。如果任何一个进程尝试修改这些区域,内核就只拷贝这块内存,一般是虚拟内存系统的一个页。
fork函数的变种由一些平台提供。本文讨论的4个平台都支持下节讨论的vfork的变体。
Linux2.4.22还通过clone系统调用支持新进程的创建。这上fork的衍生形式,允许调用者控制在父进程和子进程之间什么是共享的。
FreeBSD 5.2.1提供了rfork系统调用,它和Linux的clone系统调用相似。rfork调用由Plan 9操作系统继承。
Solaris 9提供了两个线程库:一个为了POSIX线程(pthreads),一个为了Solaris线程。fork的行为在这个两个库之间有所不同。对于POSIX线程,fork创建一个只包含调用线程的进程,而对于Solaris线程,fork创建一个包含调用线程所在的进程所里所有线程的拷贝的进程。为了提供和POSIX线程相似的语义,Solaris提供了fork1函数,它可以用来创建一个只复制调用线程的进程,而无视使用的线程库。线程在第11章和第12章深入讨论。
下面的代码演示了fork函数的使用,展示了一个子进程里的变量的改变是如何不影响父进程里的变量的值的:
当我们向标准输出写入里,我们把buf的尺寸减一来避免写入终止的空字节。尽管strlen在计算字符串的长度时不包括这个终止的空字节,然而sizeof计算缓冲的尺寸时会包括这个终止的空字节。另一个区别是使用strlen需要一个函数调用,而sizeof在编译期计算缓冲的长度,因为缓冲由一个已知的字符串初始化,而它的尺寸是固定的。
注意上面代码里fork和I/O函数的交互。回想下第三章,write函数是没有缓冲的。因为write在fork之前调用,所以它的数据只写入标准输出一次。然而,标准I/O函数是缓冲的。回想下5.12节,如果标准输出与一个终端设备连接,那么它是行缓冲的,不然,它是完全缓冲的。当我们交互式地运行这个程序时,我们只得到prinf行的一份拷贝。在第二种情况,fork之前的printf被调用了一次,但是当fork被调用时这行仍保留在缓冲里。然后这个缓冲在父进程的数据空间拷贝到子进程时,被拷贝到子进程里。父进程和子进程现在都有一个包含这行的缓冲。在exit之前的第二个printf,把它的数据添加到已有的缓冲里。当进程终止时,它的缓冲的拷贝最终被冲洗了。
文件共享(File Sharing)
的前面的代码里,当我们重定向父进程的标准输出的时候,子进程的标准输出也被重定向了。事实上,fork的一个特性是所有被父进程打开的文件描述符都被复制到子进程。我们说“复制”因为每个描述上都好像调用了dup函数。父进程和子进程为每个打开的描述符都共享一个文件表项。
考虑一个进程有三个不同的打开的文件,对应标准输入、标准输出和标准错误。当从fork返回时,两个进程的三个描述符会指向同样的文件表项。表项里有文件状态标志,当前文件偏移量和v-node指针。
父进程和子进程共享同样的文件偏移量是很重要的。考虑一个fork一个子进程的进程,它然后wait子进程的结束。假定两个进程都向标准输出写入,作为它们的普通处理。如果父进程把它的标准输出重定向(通过外壳,也许),则当子进程写入到标准输出时,更新父进程的文件偏移量是必要的。在这种情况下,当父进程在用wait等待它时,子进程可以向标准输出写入;当子进程完成时,父进程可以继续向标准输出写入,并知道它的输出会添加到子进程写的任何东西的后面。如果父进程和子进程没有共享同样的文件偏移,这类交互将会更难完成而且需要父进程显式的行动。
如果父进程和子进程都向同一个描述符写入,在没有任何形式的同步下,比如让父进程wait子进程,它们的输出会混在一起(假设描述符在fork之前被打开。)尽管这是可能的,但它不是操作的普通模式。
在fork之后有两种处理描述符的普通情况:
1、父进程等待子进程完成。在这种情况下,父进程不用对它的描述符做任何事情。当子进程终止时,子进程写过或读过的任何共享的描述符都有相应地更新它们的偏移量。
2、父进程和子进程独立工作。这里,在fork之后,父进程关闭它不需要的描述符,而子进程做同样的事。这样,两者都不干涉另一个打开的描述符。这种情景通常是网络服务的情况。
除了打开的文件,父进程还有很多其它属性被子进程继承
1、真实用户ID、真实组ID、有效用户ID、有效组ID
2、补充组ID
3、进程组ID
4、会话ID
5、控制终端
6、设置用户ID和设置组ID
7、当前工作目录
8、根目录
9、文件模式创建掩码
10、信号掩码和配置
11、任何打开文件描述符的执行时关闭标志
12、环境
13、附加共享内存段
14、内存映射
15、资源限制
父进程和子进程间的区别有
1、fork的返回值
2、进程ID
3、父进程ID
4、子进程的tms_utime, tms_stime, tms_cutim和tms_cstime值被设为0
5、父进程设置的文件锁不被继承
6、子进程的pending alram被清除
7、子进程的pending的信号集被设为空集
这些特性中有许多还没讨论过--我们会在随后各章讨论它们。
fork会失败的两个主要原因是a、系统已经有太多进程,这通常意味着有些其它错误;b、如果真实用户ID的进程总数超过了系统限制。回想下第二章CHILD_MAX指定了每个真实用户ID的最大进程数。
有两种fork的使用:
1、当一个进程想复制它自己以便父进程和子进程可以同一时间执行不同的代码段。这在网络服务很普遍--父进程等待从客户端的请求。当请求到达时,父进程调用fork并让子进程处理这个请求。父进程回去等待下一个服务请求的到达。
2、当一个进程想执行一个不同的程序时。这对shell很普遍。在这种情况下,子进程在从fork返回后执行一个exec(我们在8.10节讨论)。
一些操作系统合并第2步的操作--在fork后执行exec--成一个称为spawn的单个操作。UNIX系统把它们分开,因为有许多情况使用一个没有exec的fork会很有用。还有,分开这两个操作允许子进程在fork和exec之前来改变进程的属性,比如I/O重定向,用户ID,信号配置,等等。我们将在第15章看到很多例子。
SUS确实在高级实时选项组包含了spawn接口。但这些接口不被作为fork和exec的替代品。它们用来支持高效实现fork比较困难的系统,尤其是没有内存管理的硬件支持的系统。
8.4 vfork函数
vfork函数和fork有相同的调用顺序和相同的返回值。然而这两个函数的语义不同。
vfork函数起源于2.9BSD。一些把它视为一个瑕疵,但本文讨论的所有平台都支持它。事实上,BSD开发者把它从4.4BSD版本移除,但继承自4.4BSD的所有开源BSD版本都在它们自己的版本里把它支持回来。vfork函数在SUS的第三版本被标记为废弃的。
vfork函数倾向于创建一个新的进程,当这个新进程的目的是exec一个新的程序时。vfork函数创建这个进程,就像是fork一样,并不把父进程的地址空间拷贝到子进程里,因为子进程不会引用那个地址空间。子进程在vfork后马上简单地调用exec(或exit)。相反,当子进程在运行时并直到它调用exec或exit,子进程在它父进程的地址空间运行。这种优化在UNIX的一些换页虚拟内存实现上提供了一个效率上的收获。(如我们在上一节看到的,实现用写时复制来提高fork之后紧接exec的效率,然而完全不拷贝仍然比做一些拷贝要快。)
两个函数之间的另一个区别是vfork保证子进程先运行,直到子进程调用exec或exit。当子进程调用这些函数的任一个时,父进程恢复执行。(如果子进程在调用这两个函数的任一个之前依赖于父进程更多的操作,这可能会造成死锁。)
下面的代码在使用vfork函数。我们不用让父进程调用sleep,因为我们被保证它会被内核进入睡眠直到子进程调用exec或exit:
这里,子进程里完成的变量增长会改变父进程里的值。因为子进程运行在父进程的地址空间时,所以这并不让我们吃惊。然而,这种行为与fork不同。
注意上面的代码里我们调用_exit而不是exit。正如我们在7.3节里描述的,_exit不会执行任何标准I/O缓冲的冲洗。如果我们调用exit,那么结果会不确定。根据标准I/O库的实现,我们可能会在输出上看不到区别,或者我们可能会发现父进程的printf的输出消失了。
如果子进程调用exit,实现会冲洗标准I/O流。如果这是库唯一的动作,那么我们看不到和调用_exit的区别。然而,如果实现还关闭了流,表示标准输出的FILE对象的内存会被清理。因为子进程在借用父进程的地址空间,所以当父进程恢复并调用printf时,没有输出显示而且printf会返回-1。注意父进程的STDOUT_FILENO仍然有效,因为子进程得到了父进程的文件描述符数组的一份拷贝。
多数当代的exit的实现都不会关闭流。因为进程即将退出,所以内核将会关闭进程所有打开的文件描述符。在库里关闭它只是增加开销而没有任何好处。
8.5 exit函数
正如我们在7.3节描述的,一个进程可以通过以下5种方式退出:
1、从main函数里执行一个return。正如我们在7.3节看到的,这和调用exit等价。
2、调用exit函数。这个函数由ISO C定义并包含调用所有通过调用atexit注册的退出处理器,和关闭所有的标准I/O流。因为ISO C不处理文件描述符、多进程(父进程和子进程)、和工作控制,所以这个函数的定义对于UNIX系统来说是不完全的。
3、调用_exit或_Exit函数。ISO C定义_Exit为一个进程提供不运行exit处理器或信号处理器的终止方法。标准I/O流是否被冲洗取决于实现。在UNIX系统上,_Exit和_exit是同义词,并不冲洗标准I/O流。_exit函数被exit函数调用并处理UNIX系统相关的细节;_exit由POSIX.1规定。在多数UNIX系统实现里,exit在标准C库里是一个函数,而_exit是一个系统调用。
4、在进程的最后一个线程的start函数里执行一个return。但是这个线程的返回值不被用途进程的返回值。当最后一个线程从它的start函数返回时,进程以终止状态0退出。
5、在进程的最后一个线程里调用ptherad_exit函数。和上一种情况一样,进程的返回状态始终为0,而不管传递给pthread_exit的参数。我们将在11.5节看到更多关于pthread_exit的细节。
以下是三种异常退出的形式:
1、调用abort。这是下一项的特殊情况,因为它产生SIGABRT信号。
2、当进程收到特定信号时。(我们在第10章描述更多细节。)系统可以由进程本身产生--例如,通过调用abort函数--由别的进程产生,或者由内核产生。由内核产生信号的例子包括进程引用一块不在其地址空间的内存地址,或者尝试除以0。
3、最后一个线程响应一个取消请求。默认情况下,取消发生在一个延后的行为:一个线程请求另一个取消,在一段时间后,目标线程终止。我们将在11.5节和12.7节更详细讨论取消请求。
不管进程如何终止,内核总是执行相同的代码。内核代码为进程关闭所有打开的描述符,释放它使用的内存,等等。
对于之前任何一种情况,我们都想终止的进程能够通知它的父进程它是如何终止的。对于三个exit函数(exit,_exit和_Exit),这是通过函数的参数传递退出状态来完成的。然而,在异常终止情况,内核,而不是进程,产生一个指明异常终止原因的终止状态。在任何情况下,进程的父进程都可以从wait或waitpid函数(在下节描述)里获得终止状态。
注意我们把由exit函数的参数或main的返回值表示的退出状态,和终止状态区分开来。当_exit最终被调用时,内核把退出状态转换成终止状态。下表描述了父进程可以检查一个子进程的终止状态的各种方法。如果子进程正常终止,父进程可以得到子进程的退出状态。
检查wait和waitpid返回的终止状态的宏 | |
宏 | 描述 |
WIFEXITED(status) | 如果status由正常终止的子进程返回时为真。在这种情况下,我们可以执行WEXITSTATUS(status)来得到子进程传给exit、_exit或_Exit的参数的低8位。 |
WIFSIGNALED(status) | 如果status由因为收到一个它没有catch的信号而异常终止的子进程返回时为真。在这种情况下,我们可以执行WTERMSIG(status)来得到导致终止的信号号。此外,一些实现(而不是SUS)定义了宏WCOREDUMP(status),如果一个终止进程的核心文件被产生时会返回true。 |
WIFSTOPPED(status) | 如果status由当前停止的子进程返回时为真。在这种情况下,我们可以执行WSTOPSIG(status)来得到导致子进程停止的信号号。 |
WIFCONTINUED(status) | 如果status由在工作控制停止后继续的子进程返回时为真(POSIX.1的XSI扩展,仅限于waitpid。) |
当我们在讨论fork函数的时候,很明显在fork调用后子进程有一个父进程。现在我们正在讨论的是把终止状态返回给这个父进程。然而如果父进程比子进程更早终止会发生什么呢?答案是init进程变成任何其父进程终止的进程的父进程。我们说进程已经由init继承。通常发生的事是不管何时一个进程终止,内核会遍历所有的活动进程来看正在终止的进程是否是任何仍存在的进程的父进程。如果是的话,存活下来的进程的父进程ID被变为1(init的进程ID)。通过这种方法,我们被保证每个进程都有一个父进程。
另一个我们必须担心的情况是当一个子进程在其父进程之前终止。如果当父进程准备来来检查子进程是否已经终止时,子进程已经完全消失,那么父进程不能得到它的终止状态。内核为每一个终止的进程保存了一小量的信息,所以当终止进程的父进程调用wait或waitpid时可以使用那个信息。这个信息至少由进程ID、进程的终止状态和这个进程使用过的CPU时间。内核可以丢弃这个进程使用的所有的内存并关闭它打开的文件。在UNIX系统术语里,一个已终止而其父亲还未等待它的进程,被称为僵尸进程。ps命令用Z打印一个僵尸进程的状态。如果我们写了一个长时间运行并fork了很多子进程的程序,除非我们等待它来得到它们的终止状态,否则它们都变为僵尸进程。
一些系统提供了避免创建僵尸进程的方法,我们会在10.7节介绍。
最后需要考虑的情况是当一个由init继承的进程终止时会发生什么?它会就一个僵尸吗?答案是“否”,因为init被设定为无论何时它的子进程终止,init调用某个wait函数来得到终止状态。通过这样做,init避免系统被僵尸阻碍。当我们说“init的某个子进程”时,我们表示任何一个由init直接产生的进程(比如getty,在9.2节讨论),或一个其父进程终止随后继承init的进程。
(我在Linux2.6.38-13-generic上用ps查看一个僵尸进程:tommy 3296 3295 0 11:32 pts/0 00:00:00 [a.out]
8.6 wait和waitpid函数
当一个进程终止时,不管是正常还是异常地,内核通过向父进程发送SIGCHLD来通知父进程。因为子进程的终止是一个异步事件--它可以在父进程运行时的任何时间发生--所以这个信号是从内核发送给父进程的异步通知。父进程可以选择忽略这个信号,或它提供一个当信号发生时被调用的函数:一个信号处理器。这个信号的默认行为是被忽略。我们在第10章讲述这些选项。至于现在,我们需要知道一个调用wait或waitpid的进程会:
1、阻塞,如果它所有的子进程都还在运行;
2、如果一个子进程终止并等待它的终止状态被获取,则随着子进程的终止状态立即返回;
3、如果它没有任何子进程,则立即返回一个错误。
如果进程是因为收到SIGCHLD信号而正调用wait,那么我们预期wait会立即返回。但是如果我们在任何随机的时间点时调用它,那么它会阻塞。
#include
pid_t wait(int *statloc);
pit_t waitpid(pid_t pid, int *statloc, int options);
两者成功返回进程ID,失败返回0或-1
这两个函数的区别如下:
1、wait函数可以阻塞调用者,直到一个子进程终止,而waitpid有选项可以避免它阻塞;
2、waitpid函数不等待最先终止的子进程;它有许多选项来控制进程等待哪个进程。
如果一个子进程已经终止并成为一个僵尸,那么wait会用子进程的状态立即返回。否则,它会阻塞调用者,直到子进程终止。如果调用者阻塞并有多个子进程,那么wait当某个进程终止时返回。我们总是可以知道哪个子进程终止了,因为这个函数返回这个进程ID。
对于两个函数,参数statloc是一个整型指针。如果参数不是一个空指针的话,那么终止的进程的终止状态由这个参数指向的地址存储。如果我们不关心终止状态,我们可以简单地传为一个空指针作为参数。
传统地,这两个函数返回的整型状态由实现来定义。(对于正常返回)其中的某些位来指明退出状态,(对于一个异常返回)另一些位指明信号号,一个位来指明是否有核心文件产生,等等。POSIX.1规定了这个终止状态要用各种定义在
我们将在9.8节讨论工作控制时讨论一个进程如何可以被停止。
下面的代码展示了各种终止状态:
FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3,和Solaris 9都支持WCOREDUMP宏。
下面是程序运行的结果:
normal termination, exit status = 7
abnormal termination, signal number = 6
abnormal termination, signal number = 8
不幸的是,没有一个可移植的方法来把从WTERMISG得到的信号号映射到可描述的名字。(10.21节有一种方法。)我们并须查看
正如我们已经提过的,如果我们有不只一个子进程,wait在任何一个子进程终止时返回。如果我们想等一个指定进程终止是会发生什么呢(假定我们知道我们要等待的进程ID)?在UNIX系统的早期版本,我们必须调用wait并把返回的进程ID和我们感兴趣的那个进行比较。如果终止的进程不是我们想要的,那我们必须保存进程ID和终止状态,然后再次调用wait。我们需要持续这样做,直到需要的进程被终止。下一次我们想等一个特定的进程时,我们需要遍历已经终止的进程的列表来看我们是否已经等待过它,如果没有,则再次调用wait。我们需要的是一个等待特定进程的函数。这种功能(和更多功能)由POISX.1的waitpid函数提供。
waitpid的pid参数的解释取决于它的值:
pid = -1:等待任何一个子进程。这种用法等同于wait。
pid >0:等待进程ID为pid的子进程。
pid == 0:等待任何进程组ID和调用进程相同的子进程(我们在9.4节讨论进程组ID)
pid <1:等待任何进程组ID等于pid的绝对值的子进程。
waitpid函数返回终止的子进程的ID,并把子进程的终止状存储到statloc指向的内存地址里。对于wait,唯一的真实的错误是调用进程没有子进程。(其它错误返回也是可能的,万一函数调用被一个信号中断。我们将在第十章讨论这个。)然而对于waitpid,还有可能当指定的进程或进程组不存在,可不是调用进程的子进程时会得到错误。
options参数让我们更多地控制waitpid的操作。这个参数是0或者由下表里的常量的与或值组成:
常量 | 描述 |
WCONTINUED | 如果实现支持工作控制,那么返回任何由pid指定的、在停止后又继续的、但其状态还没有被报告的子进程的状态。(POISX.1的XSI扩展。) |
WNOHANG | 如果pid指定的子进程不是立即可用的,waitpid函数不会阻塞。这种情况下,返回值为0. |
WUNTRACED | 如果实现支持工作控制,那么返回任何由pid指定的、其状态在停止后还未被报告的子进程的状态。WIFSTOPPED宏决定了返回值是否对应于一个停止的子进程。 |
Solaris支持一个补充的,但不是标准的选项常量,WNOWAIT,它让系统保存这个进程,它的终止状态由waitpid返回,以便它可以被再次等待。
waitpid函数提供了三个不被wait函数提供的特性:
1、waitpid函数让我们等待一个特定的进程,而wait函数返回任何终止的子进程的状态。我们将在讨论popen函数里再回到这个特性。
2、waitpid函数提供了一个wait的非阻塞版本。有时我们想得到子进程的状态,而不想阻塞。
3、waitpid函数用WUNTRACED和WCONTINUED选项提供了对工作控制的支持。
下面的代码避免僵尸程序:
我们在第二个子进程里调用sleep,保证第一个子进程在打印父进程ID之前退出。在fork之后,父进程或子进程可以继续执行,我们不会知道两个会先继续执行。如果我们不让第二个子进程睡眠,而且它在fork后比它的父进程更早继续执行,那么它打印的父进程ID就会是它的父进程,而不是进程ID 1。
运行结果:
$ ./a.out
$ second child, parent pid = 1
注意shell当原始进程终止时打印它的命令提示符,它发生在第二个子进程打印它父进程ID之前。
8.7 waitid函数
SUS的XSI扩展包含了一个补充的函数来获取一个进程的退出状态。waitid函数和waitpid相似,但提供了额外的灵活性。
#include
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);
成功返回0,失败返回-1
和waitpid相似,waitid允许一个进程指定要等待哪些子进程。不是把这个信息和进程ID或进程组ID一起合并到单个参数里,而是使用两个单独的参数。参数id的解释基于idtype的值。被支持的类型在下表汇总:
waitid的idtype常量 | |
常量 | 描述 |
P_PID | 等待特定的进程:id包含等待的子进程ID |
P_PGID | 等待任何在特定进程组里的子进程:id包含等待的子进程的进程组ID |
P_ALL | 等待所有子进程:id被忽略 |
waitid的options常量 |
|
常量 | 描述 |
WCONTINUED | 等待之前停止但被继续的,但其状态还没有被报告的进程 |
WEXITED | 等待已经退出的进程 |
WNOHANG | 在没有可用的子进程退出状态时,立即返回,而不是阻塞 |
WNOWAIT | 不摧毁子进程的退出状态。子进程的退出状态可以被随后的wait、waitid或waitpid得到 |
WSTOPPED | 等待一个停止的且状态还没有被报告的进程 |
infop参数是一个指向siginfo结构体的指针。这个结构体包括关于导致子进程状态改变的产生的信号的细节信息。siginfo结构体在10.14节更深入地讨论。
本文讨论的4个平台里,只有Solaris提供对waitid的支持。
8.8 wait3和wait4函数
多数UNIX系统实现提供两个补充的函数:wait3和wait4。历史上,这两个变体从UNIX的BSD分支传承下来。这两个函数提供的而wait、waitid和waitpid函数没有提供的唯一的特性是一个额外的参数,来允许内核返回终止的进程和它的子进程使用的资源的汇总。
#include
#include
#include
#include
pid_t wait3(int *statloc, int options, struct rusage *rusage);
pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);
两者成功都返回进程ID,失败返回0或-1。
资源信息包含许多统计信息,比如用户CPU时间量、系统CPU时间量、页错误的数量、收到的信号数量、等等。参考getrusage手册页来得到更多细节。(这个资源信息和我们在7.11节描述的资源限制不同。)下表给出wait函数支持的各种参数。
各种系统上wait函数支持的参数 | ||||||||
函数 | pid | options | rusage | POSIX.1 | Free BSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 |
wait | * | * | * | * | * | |||
waitid | * | * | XSI | * | ||||
waitpid | * | * | * | * | * | * | * | |
wait3 | * | * | * | * | * | * | ||
wait4 | * | * | * | * | * | * | * |
wait3函数在SUS的早期版本被引入。在版本2,wait3被移到遗留的种类,wait3在版本3里被规范删除。
8.9 竞争条件
为了我们的目的,竞争条件当多个进程尝试用共享数据时发生,而最终的结果取决于进程运行的顺序。fork函数是一个滋生竞争条件的土壤,如果在fork之后有任何逻辑显式地或隐式地依赖于是父进程先运行还是子进程先运行。一般说来,我们不能预测哪个进程会先运行。即使我们知道哪些进程会先运行,进程开始运行后发生的事情取决于系统负载和内核的调度算法。
我们看到在8.6节的代码里有潜在的竞争条件,当第二个子进程打印它的父进程的ID时。如果第二个子进程在第一个子进程之前运行,那么它的父进程就会是第一个子进程。但是如果第一个子进程先运行并有足够的时间exit,那么第二个子进程的父进程就是init。就算调用sleep,就像我们做的那样,也不能保证任何事情。如果系统高负重,在第一个子进程有机会运行前,第二个子进程可能在sleep返回后恢复。这种形式的问题很难调试,因为它们倾向于“多数时间”工作。
想要等待一个子进程终止的进程必须调用某一个wait函数。如果进程想等待它的父进程终止,比如8.6节里的代码,那以下形式的循环可以被使用:
while (getppid() != 1)
sleep(1);
这种被称为轮询的循环类型的问题,是它浪费CPU时间,因为调用者每秒钟都要很傻地测试这个条件。
为了避免轮询和竞争条件,多个进程间需要一些形式的信号。信号可以使用,我们在10.16节介绍一种使用信号的方法。其它形式的进程间通信也同样可以使用。我们将在第15章和第17章讨论它们中的一些。
对于一个父进程和子进程的关系,我们经常有以下的情景。在fork之后,父进程和子进程都有一些事情做。例如,父进程可能利用子进程ID更新一个日志文件的记录,而子进程可能必须为父进程创建一个文件。在这个例子里,我们要求每个进程告诉对方它们何时完成它们的初始操作集,并且每个都在继续自己的工作前,等待对方完成。以下的代码证明了这种情形:
我们将在之各章展示实现这些TELL和WAIT程序的各种方法。10.16节展示了一个使用信号的实现。15章展示了一个用管道的实现。我们在看看使用这五个程序的例子:
我们把标准输出设为未缓冲的,所以每个字符输出都产生一个wirte。这个例子的目的是允许内核在两个进程之间尽快地切换以证明竞争条件。(如果我们不这样做的话,我们可能看不到以下的输出。看不到错误输出不表示竞争条件不存在;它只简单地表示我们不能在这个特定的系统上看到而已。)下面的真实输出展示了结果可以如何改变。运行一百次中,发生两次错误。一次是:
output from parenotu
tput from child
另一次是:
output from opuatrpeuntt
from child
我们需要用事TELL和WAIT来修改上面的代码。下面的代码便是这样做的。+号开头的是新加的行。
我们在8.3节提到fork的一个用法是创建一个新进程(子进程)然后调用某个exec函数然执行另一个程序。当一个进程调用某个exec函数时,这个进程被新程序完全取代,而新程序开始执行它的main函数。在调用exec时进程的ID并没有发生变化,因为没有一个新的进程被创建;exec只是把当前的进程--它的代码、数据、堆和栈--替换为从硬盘而来的全新的程序。
有6个不同的exec函数,但是我们经常简单地说“exec函数”,它表示我们可以使用这6个函数中的任一个。这6个函数使用UNIX系统的原始进程控制丰满了起来。通过fork,我们可以创建新的进程,而使用exec函数,我们可以启动新的程序。exit函数和wait函数处理了终止和终止的等待。这些我们仅需的原始进程控制。我们将用这些原始操作在后面各节中建立更多的函数,比如popen和system。
#include
int execl(const char *pathname, const char *arg0, ... /* (char *) 0 */ );
int execv(const char *pathname, char * const argv[]);
int execle(const char *pathname, const char *arg0, ... /* (char *)0, char *const envp[] */ );
int execve(const char *pathname, char *const argv[], char *const envp[] );
int execlp(const char *filename, const char *arg0, ... /* (char *)0 */ );
int execvp(const char *filename, char *const argv[]);
六个函数错误都返回-1,成功不返回。
这些函数的第一个区别是前四个接受一个路径名参数,而后两个接受一个文件名参数。当一个文件名参数被指定时:
1、如果filname包含一个斜杠,那它这被作为一个路径名;
2、否则,可执行程序会在PATH环境变量指定的目录里查找。
PATH变量包含一个路径的列表,被称为路径前缀,它们由冒号分隔。例如,下面的name=value环境字符串
PATH=/bin:/usr/bin:/usr/local/bin/:.
指定了四个用于查找的路径。最后一个路径前缀指定了当前的目录。(一个零长度的前缀同样表示当前目录。它可以通过value的开头的冒号、一行中的两个冒号、或value结尾的冒号来表示。)
基于一些安全性的原因,绝不要把当前目录包含在查找路径里。
如果execlp或execvp使用某个路径前缀找到了一个可执行文件,但文件不是由链接器产生的机器执行文件,那么函数会假定这个程序是一个外壳脚本并尝试调用/bin/sh来执行它。
下一个区别是参数列表的传递(l表示列表而v表示矢量)。函数execl、execlp和execle要求新程序的每个命令行参数都由分隔的参数指定。我们用一个空指针标记参数的末尾。对于其它三个函数(execv、execvp、和execve),我们必须创建一个参数指针的数组,然后把这个数组的地址传给这三个函数。
在使用ISO C原型前,为execl、execle和execlp展示命令行参数的通常作法是:char *arg0, char *arg1, ..., char *argn, (char *)0。这指明了最后的命令行参数后面是一人空指针。如果这个空指针由常量0指定,我们必须显式把它转换成一个指针,如果我们不这样做的话,它会被解释为一个整型参数。如果一个整型的尺寸和char *的尺寸不同,那么exec函数的真实参数将会出错。
最后的区别是给新程序的环境变量的传递。这两个以一个e结尾的函数(execle和execve)允许我们传递一个环境字符串指针数组的指针。然而,另外四个函数在调用进程里使用environ变量来为进程序拷贝已有的环境。(回想下我们在7.9节里的环境字符串的讨论。我们提到过如果系统支持如setenv和putenv的函数的话,我们可以改变当前环境和任何后续的子进程的环境,但是我们不会影响父进程的环境。)通常,一个进程允许它的环境被传播给它的子进程,但在一些情况下,一个进程想为一个子进程指定一个特殊的一间。后者的一个例子是当一个登录外壳被初始化时的login程序。通常,login只用几个定义的变量创建一个指定的环境,并让我们通过外壳启动文件,当我们登录时在环境里加入变量。
在使用ISO C原型前,execle的参数为:char *pathname, char *arg0, ..., char *argn, (char*)0, char *envp[]。
这指名了最后的参数是环境字符串的字符指针的数组的地址。ISO C原型没有这样显示,因为所有的命令行参数、空指针和envp指针都用省略号显示。
这6个函数的参数很维记住。函数名里的字母某种程序上帮助记忆。字母p表示函数接受一个文件名参数并使用PATH环境变量来查找可执行文件。字母l表示函数接受一个参数列表并和表示接受一个argv[]矢量的字母v互斥。最后,字母e表示函数接受一个envp[]数组而不是使用当前环境。下表显示了这6个函数的区别:
6个exec函数的区别 | ||||||
函数 | pathname | filename | 参数列表 | argv[] | environ | envp[] |
execl | * | * | * | |||
execlp | * | * | * | |||
execle | * | * | * | |||
execv | * | * | * | |||
execvp | * | * | * | |||
execve | * | * | * | |||
名字里的字母 | p | l | v | e |
每个系统都有参数列表和环境列表总尺寸的限制。从2.5.2节可以看到,这个限制由ARG_MAX给定。这个值在POSIX.1系统上必须至少为4096字节。当使用外壳的文件名扩展我来产生一个文件名列表时,我们有时会碰到这个限制。例如,在一些系统上,命令grep getrlimit /usr/share/man/*/*可能产生一个这种形式的外壳错误:Argument list too long。
历史上,在系统V的早期实现上里的限制是5120字节。早期BSD系统有20480字节的限制。当前系统的限制高得多了。
为了解决参数列表尺寸的限制,我们可以使用xargs命令来分解长参数列表。为了查找所有在我们系统上man页里的getrlimit的出现, 我们可以使用
find /usr/share/man -type f -print | xargs grep getrlimit
然而,如果我们系统上的man页被压缩,我们可以尝试
find /usr/share/man -type f -print | xargs bzgrep getrlimit
我们在find命令里使用-type f选项来限制列表只包含普通文件,因为grep命令不会在目录里想找pattern,而我们要避免不必要的错误信息。
我们也提到进程ID在一个exec后不会改变,但是新的程序从调用进程继承了额外的属性:
1、进程ID和父进程ID;
2、真实用户ID和真实组ID;
3、补充组ID;
4、进程组ID;
5、会话ID;
6、控制终端;
7、闹钟响的剩余时间;
8、当前工作目录;
9、根目录;
10、文件模式创建掩码;
11、文件锁;
12、进程信号掩码;
13、资源限制;
14、tms_utime、tms_stime、tms_cutime和tms_cstime的值。
对打开文件的处理取决于每个操作符的close-on-exec标志。回想下第三章,我们在3.14节提过FD_CLOEXEC标志,它让一个进程里每个打开的描述符都有一个close-on-exec标志。如果这个标志被设置的话,一个exec会关闭这个描述符。否则,在exec时描述符会保持打开。默认行为是在exec时保持描述符打开,除非我们用fcntl来设置close-on-exec标志。
POSIX.1规定打开的目录流(回想下4.21节的opendir函数)在exec时必须关闭。这通常由opendir通过调用fcntl设置对应于打开的目录的流的文件描述符的close-on-exec标志来完成。
注意真实用户ID和真实组ID在exec时保持不变,但是有效ID可以改变,取决于被执行的程序的设置用户ID和调用组ID位的状态。如果新程序的设置组ID位被设置,那么有效用户ID就变为程序文件属主ID。否则,有效组ID不变(它没有被设为真实用户ID)。组ID的处理方式一样。
在许多UNIX系统实现里,这6个函数里只有一个execve是内核的系统调用。其它5个只是最终调用这个系统调用的库函数。我们可以想像这6个函数之间的关系:
execlp把参数列表转换为数组,传递给execvp。同样,execl的列表转换为数组传给execv,execle的列表转换为数组传给execve(系统调用)。execvp通过各个PATH前缀找到程序的位置,交给execv,而execv使用environ调用execve(系统调用)。
下面的代码的展示了exec函数:
我们首先调用execle,它需要一个路径名和一个指定的环境。下一个调用是execlp,它使用一个文件名并传入调用者的环境给新的程序。execlp调用工作的唯一原因是目录/home/tommy/bin是其中一个当前路径前缀。还要注意我们把第一个参数,新程序的argv[0],设置为路径名的文件名部分。一些外壳把这个参数设置为完整的路径名。这只是一个协议。我们可以把arg[0]设置成任何我们喜欢的字符串。login命令在它执行外壳时就是这样做的。在执行外壳前,login在arg[0]前加个一个连接符作为前缀,来指定这个程序是作为一个登录外壳被调用的。一个登录外壳将会执行启动profile命令,而非登录的外壳不会。
程序echoall被前面的程序执行了两次。它是一个普通的程序,打印它所有的命令行参数和它的整个环境列表。下面是echoall的代码:
在UNIX系统里,权限,比如当前日期的系统标记,和访问控制,比如读写一个特殊文件,都基于用户和组ID。当我们的程序需要额外的权限或需要访问它们当前不被允许访问的资源,它们需要改变它们的用户或组ID成为一个有合适权限或访问的ID。相似地,当我们的程序需要降低它们的权限或阻止特定资源的访问,那么它们通过改变它们的用户ID或组ID成一个没有权限或资源访问能力的ID来完成。
一般说来,我们尝试使用最低权限模式,当我们设计我们的应用程序的时候。根据这个模式,我们的程序应该使用完成任何任务所需的最低权限。这会降低恶意用户尝试用它们的权限用意想不到的方式欺骗我们程序来破坏安全的可能性。
我们可以用setuid函数来设置真实用户ID和有效用户ID。相似地,我们可以用setgid函数设置真实组ID和有效组ID。
#include
int setuid(uid_t uid);
int setgid(gid_t gid);
两者成功都返回0,失败返回-1.
有些为那些能改变ID的人准备的规则。我们现在只考虑用户ID。(所有我们为用户ID的描述同样适用于组ID。)
1、如果进程有超级用户权限,那么setuid函数设置真实用户ID、用效用户ID和保留的设置用户ID。
2、如果进程没有超级用户权限,但是uid等于真实用户ID或保存的设置用户ID,那么setuid只把有效用户ID设置为uid。真实的用户ID和保存的设置用户ID不会改变。
3、如果这两个条件没有一个成立,那么errno被设为EPERM,并返回-1.
这里,我们假设_POSIX_SAVED_IDS为真。如果这个特性没有被支持,那么删除之前所有对保存的设置用户ID的描述。
保存的ID不是POSIX.1的2001版本里必需的特性。它们常是POSIX早期版本的可先项。要看一个实现是否支持这个特性,一个应用可以在编译器测试常量_POSIX_SAVED_IDS,或者在运行时用_SC_SAVED_IDS参数调用syncof。
我们可以给出关于内核维护的这三个用户ID的描述:
1、只有一个超级用户进程可以改变真实用户ID。通常,真实用户ID在我们登录是由login程序设置并不再改变。因为login是一个超级用户进程,所以当它调用setuid时它设置所有的三个用户的ID。
2、有效用户ID由exec函数设置,仅当程序文件的设置用户ID被设置。如果设置用户ID位没有被设置,那么exec函数不改变有效用户ID,继续保持当前值。我们可以在任何时间调用setuid来把有效用户ID设置为真实用户ID或保存的用户ID。自然地,我们不能把有效用户ID设置为任何随机值。
3、保存的设置用户ID通过exec函数由有效用户ID拷贝而来。如果文件的设置用户ID位被设置,那么这个拷贝在exec从文件的用户ID存储有效用户组ID之后被保存。
下表总结了这三个ID可能被改变的各种方式:
改变三个用户ID的方法 | ||||
ID | exec | setuid(uid) | ||
设置用户ID位关 | 设置用户ID位开 | 超级用户 | 没有特权的用户 | |
真实用户ID | 不变 | 不变 | 设置为uid | 不变 |
有效用户ID | 不变 | 从程序文件的用户ID拷贝 | 设置为uid | 设置为uid |
保存的设置用户ID | 从有效用户ID拷贝 | 从有效用户ID拷贝 | 设置为uid | 不变 |
例:为了看到保存的设置用户ID特性的工具,让我们检查下使用它的程序的操作。我们将看到man程序,它被用来显示在线手册页。man程序可以被安装为它的设置用户ID或设置组ID被设为指定的用户或组,通常是由man为它本身预留的一个。man程序可以被用来读和可能地写文件,在我们通过一个配置文件(通常为/etc/man.config或/etc/manpath.confg)或使用一个命令行选项来选择的位置。
man程序可能必须执行几个其它命令来处理包含要显示的手册页的文件。为了避免被欺骗以致运行错误的命令或覆写错误的文件,man命令必须在两个权限集里交换:运行man命令的用户和拥有man可执行文件的用户。下面的步骤会发生:
1、假设man程序文件被用户名man拥有并设置了设置用户ID,当我们exec它时,我们有:真实用户ID=我们的用户ID;有效用户ID=man;保存的设置用户ID=man。
2、man程序访问所需的配置文件和手册页。这些文件被用户名man拥有,但是因为有效用户ID是man,所以文件访问被允许。
3、在man为我们执行任何一个程序时,它调用setuid(getuid())。因为我们不是超级用户进程,所以这只改变有效用户ID。我们有:真实用户ID=我们的用户ID(不变);有效用户ID=我们的用户ID;保存的设置用户ID=man(不变)。现在man进程用我们的用户ID作为它的有效用户ID运行。这意味着我们只能访问我们有普通权限的文件。我们没有更多的权限。它可以为我们执行任何过滤器。
4、当过滤完成时,man调用setuid(euid),这里euid是用户名man的数值用户ID。(这是我们为什么需要保存的设置用户ID。)现在我们有:真实用户ID=我们的用户ID(不变);有效有用ID=man;保存的设置用户ID=man(不变)。
5、man程序现在可以在它的文件上进行操作,因为它的用效用户ID是man。
通过用这种方式使用保存的设置用户ID,我们可以使用在进程开始和结束时由设置用户ID赋予的额外的权限。然而,在这期间,进程用我们的通过权限来运行。如果我们不能在最后切换回保存的设置用户ID,那么我们可能会在我们运行的整个时间里都保持额外的权限(这会导致问题)。
让我们看到下如果man在运行时为我们产生一个shell会发生什么。(这个shell是用fork和exec产生的。)因为真实用户ID和有效用户ID都是普通用户ID(第3步),所以shell没有额外的权限。当man运行时,shell不能文件设置为man的设置用户ID,因为shell的保存的设置用户ID被exec从有效用户ID拷贝过来。所以在执行exec的子进程里,所有的三个用户ID都是我们的普通用户ID。
我们关于man如何使用setuid函数的描述不正确,如果程序的设置用户ID为根用户,因为一个用超级用户权限的setuid的调用会设置三个用户ID。为了上面的例子工作,我们需要setuid来仅设置有效用户ID。
setreuid和setregid函数
历史上,BSD支持使用setreuid函数来交换真实用户ID和有效用户ID。
#include
int setreuid(uid_t ruid, uid_t euid);
int setregid(gid_t rgid, gid_t egid);
两者成功返回0,错误返回-1.
我们可以为任何参数返回-1的值来指定对应ID应该保持不变。
规则很简单:一个没有权限的用户总是可以在真实用户ID和有效用户ID之间交换。这允许一个设置用户ID程序来交换用户的普通权限并在之后为设置用户ID交换回来。当这个保存的设置用户ID特性在POSIX.1被引入时,规则被增强为同样允许一个无权限的用户来把它的有效用户ID设置为保存的设置用户ID。
setreuid和setregid都是SUS里的XSI扩展。这样,所有UNIX系统实现都应该提供对它们的支持。
4.3BSD没有之前讨论的保存的设置用户ID特性。相反它使用setreuid和setregid。这允许一个无权限的用户来在这两个值之间切换来切换去。然而,注意当使用这个特性的程序产生一个外壳时,它们必须把真实用户ID在exec之前设置为普通用户ID。如果我们不能这样做,真实用户ID可能被授权(由setreuid完成的交换而来)而且外壳进程可能调用setreuid来交换这两者来假设更高权限用户的权限。作为一个解决这个问题的健壮的程序,程序在子进程里exec调用之前设置真实用户ID和有效用户ID两者。
seteuid和setegidbiov
POSIX.1包含了两个函数seteuid和setegid。这些函数和setuid和setgid相似,但是只改变有效用户ID和有效组ID。
#include
int seteuid(uid_t uid);
int setegid(gid_t gid);
两者成功都返回0;错误返回-1.
一个没有特权的用户可以把它的有效用户ID设置为真实用户ID或它的保存的设置用户ID。对于一个特权用户,只有有效用户ID被设为产学研。(这和setuid函数不同,它改变所有三个用户ID)。
下面总结了所有我们这里讨论过的修改这三个用户ID的函数:
超级用户:setreuid(ruid, euid)设置真实用用户ID和有效用户ID;setuid(uid)设置真实用户ID、有效用户ID和保存的设置用户ID;seteuid(uid)设置有效用户ID。
普通用户:setuid或seteuid把真实用户ID或保存的设置用户ID设置为有效用户ID;setreuid交换真实用户ID和有效用户ID或把保存的设置用户ID设置为用效有户ID。
设置用户ID程序的exec:同时设置有效用户ID和保存的设置用户ID。
组ID
我们在本节至今说过的所有事同样也以相似的方式应用于组ID。补充组ID不被setgid、setregid或setegid影响。
8.12 解释文件
当前所有的UNIX系统都支持解释文件。这些文件是以下面形式的行开头的文本文件:
#! pathname [ optional-argument]
在惊叹号和路径名间的空格是可选的。这些解释文件最普遍是由下行开头:
#!/bin/sh
路径名通常是一个绝对路径名,因为基于它之上的特殊操作被执行(也就是说,PATH没有被使用)。这些文件的识别由内核作为处理exec系统调用的一部分完成。被内核执行的真实文件不是解释文件,而是由解释文件第一行的路径名指定的文件。需要区分解释文件--以#!开头的一个文本文件--和解释器,它由解释文件的第一行的路径名指定。
注意系统在一个解释文件的第一行上有一个尺寸设置。这个限置包括#!、路径名、可选参数、终止的换行符、和任何空格。
在FreeBSD 5.2.1上,这个限制为128字节。Mac OS X 10.3把这个限制扩展为512字节。Linux 2.4.22支持127字节的限制,而Solaris 9把限制定为1023字节。
让我们看一个例子,来看下当被执行的文件是一个解释文件时,内核如何处理传给exec函数的参数和解释文件第一行的可选参数。下面的程序exec了一个解释文件:
(这个解释器)echoarg程序只打印它命令行参数。(这个程序在7.4节)。注意当内核exec这个解释器(/home/tommy/bin/echoarg),argv[0]是解释器的路径名,argv[1]是解释文件的可先参数,而剩下的参数是路径名(/home/tommy/bin/testinterp)和在上面代码里的execl调用的第二和第三个参数(myarg1和MY ARG2)。execl调用里的arg[1]和arg[2]都往又移动了两个位置。注意内核从execl调用了接受了路径名而不是第一个参数(testinterp),假设路径名可能包含比第一个参数更多的信息。
在解释器路径名之后的可选参数的一个普遍用法是为支持这个-f选项的程序指定这个选项。例如,一个awk程序可以执行为:
awk -f myfile
它告诉awk从myfile文件读取awk程序。
从UNIX系统V继承的系统经常包含两个版本的awk语言。在这些系统上,awk经常被称为“老awk”并对应在于版本7的原始版。相反,nawk(新awk)包含许多升级并对应于Aho、Kernighan、和Weinberger[1988]描述的语言。这个更新的版本提供了命令行参数的访问,我们需要它来展示下面的例子。
awk程序是POSIX在它的1003.2标准里包含的工具,它现在是SUS的基本POSIX.1规范的一部分。这个工具也是基于Aho、Kernighan,和Weinberger[1988]的语言。
Mac OS X 10.2的awk版本是基于贝尔实验室的版本,由Lucent放入公共域。FreeBSD 5.2.1和Linux 2.4.22与GNU awk(称为gawk,被链接为awk)一同发行。gawk版本遵守POSIX标准,但也包含了其它的扩展。因为更新,所以贝尔实验室的awk版本和gawk比nawk或老awk更受欢迎。
在解释文件里使用-f选项让我们可以写出:
#!/bin awk -f
(awk program follows in the interpreter file)
例如,下面展示了一个解释文件:
当/usr/bin/awk被执行时,它的命令行参数为
/usr/bin/awk -f /home/tommy/bin/akwexample file1 FILENAME2 f3
解释文件(/home/tommy/bin/akwexample)的路径名被传入给解释器。这个(我们在shell里输入的)路径名的文件名部分并不够,因为解释器(这个例子为/usr/bin/awk)并不会使用PATH变量来定位文件。当它读取解释文件时,awk忽略第一行,因为井号是awk的注释字符。
我们可以用以下命令验证这些命令行参数:
$ /bin/su
密码:
# mv /usr/bin/awk /usr/bin/awk.save
# cp /home/tommy/bin/echoarg /usr/bin/awk
# suspend <--挂起su
[1]+ 已停止 /bin/su
$ ./awkexample file1 FILENAME2 f3
argv[0]: /usr/bin/awk
argv[1]: -f
argv[2]: ./awkexample
argv[3]: file1
argv[4]: FILENAME2
argv[5]: f3
tommy@tommy-Calistoga-ICH7M-Chipset:~/code/unix$ fg <--恢复su
/bin/su
# mv /usr/bin/awk.save /usr/bin/awk
root@tommy-Calistoga-ICH7M-Chipset:/home/tommy/code/unix# exit <--退出su
exit
在这个例子里,解释器的-f选项是必需的。正我们说过的,这告诉awk哪里去查找awk程序。如果我们从解释文件里删除了-f选项,当我们尝试运行它时通常会导致一个错误信息。错误的确切信息取决于解释文件在哪里存储和剩余的参数是否表示存在的文件。这是因为在这种情况下命令行参数是:
/bin/awk /usr/local/bin/awkexample file1 FILENAME2 f3
而awk作为一个awk程序尝试去解释字符串/usr/local/bin/awkexample。如果我们不能传递至少一个可选参数(这种情况下是-f)给解释器的话,那么这些解释器文件只能和shell使用。
解释器文件是必需的吗?不算是。它们以内核的花费为用户赚取效率(因为是内核识别这些文件)。解释器文件因为以下原因而有用:
1、它们隐藏了特定程序是某些语言的脚本。例如,为了执行上面的代码,我们仅说:awkexample optional-arguments而不需要知道程序其实是一个awk脚本,不然我们必须这样执行:awk -f awkexample optional-arguments。
解释器脚本获得了效率。再考虑前一个例子,我们可以仍然隐藏程序是一个awk脚本,通过把它包装在一个外壳脚本里:
ark 'BEGIN {
for (i = 0; i < ARGC; i++)
printf "ARGV[%d] = %s\n", i, ARGV[i];
exit
}' $*
这个程序的问题是它需要更多的工作。首先,外壳读取命令并尝试execlp文件名。因为外壳脚本是一个可执行文件,但不是一个机器可执行程序,一个错误返回,而execlp假设文件是一个外壳脚本(它确实是)。然后/bin/sh被执行,用外壳脚本的文件名作为它的参数。这个外壳正确地运行我们的脚本,但是为了运行awk程序,外壳执行了一个fork、exec和wait。因此,有更多的开销用在用一个外壳脚本代替一个解释器脚本。
2、解释器脚本让我们使用不是/bin/sh的脚本。当它发现一个可执行文件不是机器指令,那么execlp必须选择一个外壳来调用,而它通常使用/bin/sh。然而,使用一个解释器脚本,我们可以简单地写:
#!/bin/sh
(C shell script follows in the interpreter file)
再次申明,我们可以把它们包装在一个/bin/sh脚本里(来调用C外壳),然而,正如我们已经描述过的,需要更多的花销。
正如我们展示的,如果这三个外壳和awk不使用井号作为它们的注释字符,这些没有一个可以工作。
8.13 system函数
在一个程序里执行一个命令字符串是很方便的。例如,假定我们想把一个时间日期戳放入一个特定的文件。我们可以使用在6.10节描述的函数来完成:调用time来得到当前日历时间,然后调用localtime把它转换成分解时间,接着调用strftime来格式化这个结果,并把结果写入到这个文件里。然而,这样做会容易得多:
system("date > file");
ISO C定义了system函数,但是它的操作有很强的系统依赖性。POSIX.1包含了system接口,基于ISO C定义展开来描述在一个POSIX环境里的它的行为。
#include
int system(const char *cmdstring);
返回值如下。
如果cmdstring是一个空指针,system仅当一个命令处理器可用时返回非0值。这个特性决定了system函数是否被指定的操作系统支持。在UNIX系统下,system一直可用。
因为system是通过调用fork、exec和waitpid来实现的,所以有三种返回值的类型:
1、如果fork失败或waitpid返回一个错误而不是EINTR,system返回-1,并设置errno为指定错误。
2、如果exec失败,暗示shell不能被执行,返回值就好像shell执行了exit(127)。
3、否则,所有三个函数--fork、exec和waitpid--成功,从system的返回值是外壳的终止状态,以waitpid指定的格式。一些system的早期实现返回一个错误(EINTR),如果waitpid被一个捕获到的信号中断。因为没有一个程序可以使用的清理策略来从这种错误类型返回,POSIX随后加上需求,system在这种情况不返回一个错误。(我们在10.5节讨论中断的系统调用)。
下面的代码展示了一个system函数的实现。它不处理的特性就是信号。我们将在10.8节用信号处理更新这个函数。
如果我们没有使用外壳来执行这个命令,但是自己尝试执行这个命令,那会更难。首先,我们想要调用execlp而不是execl,来使用PATH变量,就和shell一样。我们也必须为execlp的调用把这个null终止的字符串分解为分隔的命令行参数。最终,我们不能使用任何外壳元字符。
注意我们调用_exit而不是exit。我们这样是避免任何通过fork从父进程拷贝到子进程的标准I/O缓冲被冲洗到子进程。
我们可以用下面的代码来测试这个版本的system。(pr_exit函数是8.5节定义的。)
使用system而不直接使用fork和exec的优点是,system处理所有的错误和(在我们10.18节的这个函数的版本里)所有需要的信号处理。
早期系统,包括SVR3.2和4.3BSD,没有waitpid函数可用。相反,父进程等待子进程,使用一个如下的语句:
while ((lastpid = wait(&status)) != pid && lastpid != -1)
;
如果调用system的进程在调用system之前产生了它自己的子进程,则会发生一个问题。因为上面的while语句持续循环,直到system产生的子进程终止,如果进程的任何子进程在被pid标识的进程前终止,那么进程ID和其它进程的终止状态会被while语句舍弃掉。事实上,无法等待一个指定的子进程是POSIX.1 Rationale引入waitpid函数的原因之一。我们将在15.3节看到发生popen和pclose函数上的同样的问题,如果系统没有提供一个waitpid函数。
设置用户ID程序
如果我们从一个设置用户ID程序调用system会发生什么呢?这样做是一个安全漏洞,而且不该这样。下面的代码展示一个简单的程序,只为它的命令行参数调用system:
下面代码展示了另一个简单的程序,打印它的真实和有效用户ID:
当/bin/sh是bash版本2时,前一个例子不会工作,因为bash将会把有效用户ID设置为真实用户ID,当它们不匹配时。
如果它用特殊权限运行时--设置用户ID或设置组ID--并想产生另一个进程,那么一个进程应该直接直接使用fork和exec,可以确保在调用exec前和fork后变回普通权限。system函数不应该从一个设置用户ID或设置组ID程序使用。
劝告的原因是system调用shell来解析命令字符串,shell使用它的IFS变量作为输入域的分隔。shell的早期版本当被调用时没有把这个变量重置为一个普通的字符集。这允许一个恶意用户在system被调用前设置IFS,导致system来执行一个不同的程序。
8.14 进程记帐(Process Accounting)
多数UNIX系统提供了一个执行进程记帐的选项。当被启动时,内核每次在一个进程终止时写一个记帐记录。这些记帐记录一般是命令名的少量二进制数据、使用的CPU时间量、用户ID和组ID、开始时间,等等。我们将在本节更深入地看下这些记帐信息,因为它给我们一个机会,再次看下进程并使用5.9节的fread函数。
进程记账不被任何标准规定。因而,所有实现都有恼人的区别。例如,Solaris 9维护的I/O计数以字节为单位,而FreeBSD 5.2.1和Mac OS X 10.3维护块的单位,尽管没有不同块尺寸的区别,但仍使得计数器完全没用。另一方面,Linux2.4.22完全不维护I/O计数。
每个实现都有它自己的管理命令集来处理裸记账信息。例如,Solaris提供了runacct和acctcom,而FreeBSD提供了sa命令来处理和总结裸记账信息。
一个我们没有提过的函数(acct)启用和禁用记账。这个函数的唯一用户是accton命令(它碰巧是各平台之间仅有的几个相似点之一。)一个超级用户用路径名参数执行accton来启用记账。记账记录被写到指定文件,通常在FreeBSD和Mac OS X上是/var/account/acct,在Linux上是/var/account/pacct,在Soaris上是/var/adm/pacct。记账通过没有执行没有参数的accton来关闭。
记账记录的结构体定义在
typedef u_short comp_t; /* 3-bit base 8 exponent; 13-bit fraction */
struct acct
{
char ac_flag; /* flag (see following Figure) */
char ac_stat; /* termination status (signal & core flag only) */ /* Solaris Only */
uid_t ac_uid; /* real user ID */
gid_t ac_gid; /*real group ID */
dev_t ac_tty; /* controlling terminal */
time_t ac_btime; /* starting calendar time */
comp_t ac_utime; /* user CPU time (clock ticks) */
comp_t ac_stime; /* system CPU time (clock ticks) */
comp_t ac_etime; /* elapsed time (clock ticks) */
comp_t ac_mem; /* average memory usage */
comp_t ac_io; /* bytes transfered (by read and write) */ /* "blocks on BSD systems */
comp_t ac_rw; /* blocks read or written */ /* (not present on BSD systems) */
char ac_comm[8]; /* command name: [8] for Solaris, [10] for Mac OS X, [16] for FreeBSD, and [17] for Linux */
};
ac_flag成员记录了在进程执行期间的特定事件。这些事件在下表中描述。
ac_flag | 描述 | FreeBSD 5.2.1 | Linux 2.4.22 | Linux OS X 10.3 | Solaris 9 |
AFORK | 进程是fork的结果,但没有调用来exec | * | * | * | * |
ASU | 进程使用了超级用户特权 | * | * | * | |
ACOMPAT | 进程使用了兼容模式 | ||||
ACORE | 进程dump了核心 | * | * | * | |
AXSIG | 进程被一个信号杀死 | * | * | * | |
AEXPND | 扩展的记账项 | * |
记账记录所需的信息,比如CPU时间和字符传输的数量,被内核保存在进程表里,并当任何一个新进程创建时被初始化,比如在一个fork后的子进程里。每个记账记录当一个进程终止时都被写入。这意味着在记账文件里记录的顺序对应着进程终止的顺序,而不是它们启动的顺序。为了知道启动顺序,我们需要遍历记账文件并通过开始日历时间排序。但这样并不完美,因为日历时间以秒做单例,而可能许多进程在同一秒内被启动。另一个方案是,逝去的时间以时钟滴嗒为单位,它通常是每秒60到128个滴嗒。但是我们不知道一个进程的终止时间,我们知道的所有事是它的开始时间和终止顺序,我们仍然不能根据一个记账文件的数据重组出各种进程的确切的开始顺序。
记账记录对应着进程,而不是程序。一个新的记录由内核在fork后为子进程初始化,而不是当一个新的程序被执行时。虽然exec没有创建一个新的记账记录,但命令名改变了,而且AFORK标志也被清除。这意味着如果我们有三个程序的链--A exec B,然后B exec C,而C退出--只有一个记账记录被写入。这个记录的命令名对应着程序C,但CPU时间却是A、B、C的总和。
下面的代码检查了一些记账数据:
我们将运行这个测试程序,然后使用下面的程序来打印从记账记录选定的域:
BSD衍生的平台不支持ac_flag成员,所以我们定义HAS_SA_STAT常量来支持这个成员。令定义的符号基于特性而不是平台会更好地读,并允许我们通过加入额外的定义到我们的编译命令是来简单修改这个程序。另一种替代方案是使用:
#if defined(BSD) || defined(MACOS)
它变得笨拙,因为我们要移植我们的应用程序到其它的平台。
我们定义了类似的常量来决定平台是否支持ACORE和AXSIG记账标志。我们不能使用标志符号本身,因为在Linux,它们被定义为enum值,这样我们不能使用一个#ifdef表达式。
为了执行我们的测试,我们进行如下操作:
1、成为超级用户并开启记账,通过使用accton命令。注意当这个命令终止时,记账应该开启;因此,记账文件里的第一个记录应该是从这个命令来的。
2、退出超级用户shell并运行上面的第一个程序。这应该添加6个记录到记账文件:一个是超级用户shell的,一个是测试父进程的,剩下是四个测试子进程的。在第二个子进程里一个新进程不是由execl创建的。对于第二个子进程只有一个记账记录。
3、成为超级用户并关闭记账。因为当accton命令终止时记账被关半,它不应该出现在记账文件里。
4、运行上面的第二个程序来打印记账文件的选定的域。
根据上面的步骤在Linux上的运行结果为(文件名被打印为乱码……,数值好像是错误的……):
� e = 2686, chars = 2684, S
< e = 2684, chars = 2043, S
� e = 2689, chars = 2688,
� e = 2687, chars = 2043,
� e = 2688, chars = 1, F
� e = 2691, chars = 2690, F
� e = 2690, chars = 1, F
逝去时间(e)值以时钟嘀嗒为单位测量。例如,系统上这个值为100的话,在父进程的sleep对应202时钟嘀嗒的逝去时间,对于第一个子进程,sleep(4)变为407个时候嘀嗒。注意进程睡眠的时间量是不精确的。(我们将在第十章回到sleep函数。)同样,fork和exit的调用也消耗了一些时间量。
注意ac_stat成员不是进程真实的终止状态,而是对应于我们在8.6节讨论的终止状态的一部分。在这个字节里的唯一信息是一个核心标志位(通常是高顺序位)和信号号(通常是7个低顺序位),如果进程异常终止。如果进程正常终止,我们不能从记账文件里得到exit状态。对于第一个子进程,这个值是128+6。128是核心标志位,而6碰巧是SIGKILL的值。我们不能从记账信息得到父进程的exit的参数是2,和第三个进程的exit参数是0.
在第二个子进程里dd进程复制的文件/etc/termcap的尺寸为136663字节。I/O的字符数量仅比这个值的两倍稍高一点。它是这个值的两倍,因为136663字节被读入,然后136663字节被写出。即使输出进入空设备,字节仍然被计算。
ac_flag值正如我们期望的。除了执行execl的第二个子进程,其它所有子进程都设置了F标志。父进程没有设置F标志,因为执行父进程的交互shell执行了一个fork,然后是a.out文件的exec。第一个子进程调用abort,它产生了一个SIGABRT信号来产生核心dump。注意X和D标志都没有被设置,因为它们在Solaris上没有被支持;它们表示的信息可以从ac_stat域继承。第四个子进程同样因为一个信号终止,但是SIGKILL没有产生一个核心dump;它只终止这个进程。
最后注意的是,第一个子进程有一个为I/O的字符数的0的计数,然而这个进程产生了一个核心文件。它表示需要用来写core文件的I/O不被这个进程负责。
8.15 用户识别
任何进程可以找到它的真实和有效用户ID和组ID。然而,有时我们想找到运行这个程序的用户的登录名。我们可以调用getpwuid(getuid()),但是一个用户有多个登录名而且都使用同一个用户ID时怎么办呢?(有人可能在密码文件里有多个项对应同一个用户ID来为每个项提供一个不同的登录shell。)系统通常跟踪我们的登录的名字,而getlogin函数提供一个方法来得到这个用户名。
#include
char *getlogin(void);
成功返回登录名的指针,错误返回NULL。
如果进程没有被附着在一个用户登录的终端,那么函数会失败。我们通常称这些进程为后台进程。我们在13章讨论它们。
给定登录名,我们可以使用它在密码文件里查找这个用户--来决定登录的外壳。例如--使用getpwnam。
要找到登录名,UNIX系统历史上调用ttyname函数(18.9节)然后尝试在utmp文件里找到一个匹配项。FreeBSD和Mac OS X把这个登录名存储在与这个进程表项相关联的会话结构体,并指定获取和存储这个名字的系统调用。
系统V提供过cuserid函数来返回登录名。这个函数调用getlogin,如果失败,还会再调用一次getpwuid(getuid())。IEEE标准1003.1-1988规定了cuserid,但是它使用有效用户ID,而不是真实用户ID。POSIX.1的1990版本放弃了cuserid函数。
环境变量LOGNAME通常随同用户的登录名被login初始化,并由登录外壳继承。但是,注意一个用户可以修改环境变量,所以我们不该使用LOGNAME来验证这个用户。相反,应该使用getlogin。
8.16 进程时间
在1.10节,我们描述过三次我们可以测量:挂钟时间、用户CPU时间和系统CPU时间。任何进程可以调用times函数来为它自己和任何终止的子程序来获得这些值。
#include
clock_t times(struct tms *buf);
如果成功返回逝去的挂钟时间,错误返回-1
这个函数填充由buf指向的tms结构体:
struct tms {
clock_t tms_utime; /* user CPU time */
clock_t tms_stime; /* system CPU time */
clock_t tms_cutime; /* user CPU time, terminated children */
clock_t tms_cstime; /* system CPU time, terminated children */
};
注意这个结构体没有包含任何挂钟时间的测量。相反,函数每次被调用时都返回这个挂钟时间。这个值从过去的一个任意时间测量,所以我们不能使用它的绝对值。相反,我们使用它的相对值。例如,我们调用times并保存返回值。一段时间后,我们再次调用times并将新的返回值减去更早的返回值。这个差值就是挂钟返回时间。(虽然不太可能,但对于一个长时间运行的进程还是有可能溢出挂钟时间。)
为子进程提供的两个结构体域只包含了我们用wait、waitid或waitpid等待过的子进程的值。
这个函数返回的所有clock_t值都使用每秒的嘀嗒数--sysconf返回的_SC_CLK_TCK值--转换成秒。
多数实现提供了getrusage函数。这个函数返回CPU时间和14个其它指明资源使用的值。历史上,这个函数在BSD操作系统上起源,所以继承BSD的实现通常支持比其它实现更多的域。
下面的代码执行每个命令行参数,作为一个外壳命令字符串,为这个命令计时并从tms结构体打印这些值:
8.17 总结
UNIX系统的进程控制的一个彻底的了解是高级编程的本质。只有几个函数要掌握:fork、exec家族、_exit、wait和waitpid。这些始祖被许多应用程序使用。fork函数也给我们一个看到竞争条件的机会。
我们对于system函数和进程记帐的检查让我们再看了下所有这些进程控制函数。我们也看看到exec函数的另一个变体:解释文件和它如何操作。被提供的各种用户ID和组ID--真实的、有效的、和保存的--的理解也对写安全设置用户ID程序很关键。
有了对单个进程和它的子进程的了解,在下一章我们检查一个进程和其它进程的关系--会话和工作控制。我们在第10章讨论信号后便完成了我们对进程的讨论。
9.1 引言
我们在上一章学到了进程之间是有关系的。首先,每个进程有一个父进程(初始的内核级进程通常是它自己的父进程)。当子进程终止时,父进程被通知,而且父进程可以获得子进程的退出状态。我们也提到进程组,当我们在8.6节讨论waitpid函数,和我们怎么等待在一个进程组的任何进程的终止。
在这章,我们将更深入看下进程组和POSIX.1介绍的会话的概念。我们也将看到当我们登录时调用的登录外壳,和我们从登录外壳启动的所有进程。
不谈论信号而描述这些关系是不可能的。为了谈论信号,我们需要这章概念中的许多。如果你不熟悉UNIX系统信号机制,那么你可能想在现在略过第10章。
9.2 终端登录
让我们从当我们登录UNIX系统执行的程序开始。在早期UNIX系统,比如版本7,用户使用用硬线连接到主机的哑终端登录。这个终端或是本地的(直接连接的)或远程的(通过猫连接)。在任一情况下,这些登录经过内核里的终端设备驱动。例如,在PDP-11上的通用设备是DH-11和DZ-11.一个主机有固定数量的这些终端设备,所以在同时登录数量上有一个已经的上限。
当位映射图形化终端变得可用时,窗口系统被开发出来以提供用户与主机计算机交互的新方式。应用程序被开发出来创建“终端窗口”来模拟基于字符的终端,以允许用户用熟悉的方式与主机交互(也就是说,通过外壳的命令行。)
今天,一些平台允许你在登录后开启一个窗口系统,而其它平台自动为你启动窗口系统。在后面一种情况里,你可能仍然必须登录,取决于窗口系统如何被配置(一些窗口系统可以被配置为为你自动登录。)
我们现在描述的过程被用来使用一个终端登录一个UNIX系统。这个过程是相似的,而不管我们系统的终端类型--它可以是一个基于字符的终端,一个模拟一个简单基于字符的终端的图形化终端,或者运行一个窗口系统的一个图形化终端。
BSD 终端登录
这个过程在过去30多年没有改变很多。系统管理员创建一个文件,通常是/etc/ttys,每个终端设备有一行。每行指定设备名和传入getty程序的其它参数。例如一个参数是终端的波特率。当系统被引导启动时,内核创建了进程ID 1,init进程,而就是init引入系统多用户。init进程读取/etc/ttys文件,并为每个允许一个登录的终端设备执行一个fork,再执行一个程序getty的exec。
为每个终端fork出的运行getty的子进程,都有一个值为0的真实用户ID和值为0的有效用户ID(也就是说,它们都有超级用户权限)。init进程还用空环境exec getty程序。
正是getty为终端设备调用open。终端被打开来读和写。如果设备是一个猫,open可能在设备驱动里面延迟,直到猫被拨号及拨号被回答。一旦设备被打开,文件描述符0、1和2被设置给设备。然后getty输出一些像login:的东西,然后等待我们输入我们的用户名。如果终端支持多重速度,getty可以察觉用来改变终端速度(波特率)的特殊字符。参考你的UNIX系统手册来获得getty程序的更多细节,和可以驱动它的动作的数据文件(gettytab)。
当我们输入我们的用户名,getty的工作便完成了,然后它接着调用login程序,类似于:
execle("/bin/login", "login", "-p", username, (char *)0, envp);
(在gettytab文件里可以用让它调用其它程序的选项,但是默认是login程序。)init用一个空环境调用getty;getty用终端的名字(像TERM=foo的东西,终端foo的类型从gettytab文件得到)和任何由gettytab指定的环境字符串来创建为login的一个环境。login的-p标志告诉它保留它被传入的环境,和加入到那个环境而不是代替它。下面是在login被调用后的这些进程的状态:
1、进程ID为1的init进程读取/etc/ttys,为每个终端fork一次,创建空的环境变量;
2、每个子进程通过exec执行getty,打开终端设备(文件描述符0、1、2),读取用户名,初始环境集;
3、getty通过exec执行login。
上面提到的所有进程都有超级用户权限,因为原始的init进程有超级用户权限。由进程ID 1 fork出来的进程init、getty和login都有相同的用户ID,因为进程ID通过一个exec不会被改变。同样,除了原始init进程的其它所有进程的父进程ID都为1。
login程序做了许多事情。既然它有我们的用户名,它可以调用getpwnam来得到我们的密码文件项。然后login调用getpass来显示提示Password:并读取我们的密码(当然,打印被关闭)。它调用crypt来加密我们输入的密码并把加密结果与我们的影子密码文件项的pw_passwd域比较。如果登录尝试(在几次尝试后)因为一个无效密码失败,那么login调用参数为1的exit。这个终止会被其父进程(init)注意到,而且它会执行另一个fork再执行一个getty的exec,为这个终端启动这个过程。
这是在UNIX系统上使用的传统认证过程。当代UNIX系统已经进化到支持多个认证过程。例如,FreeBSD、Linux、Mac OS X和Solaris都支持一个名为PAM(Pluggable Authentication Modules,可插拔认证模块)的更灵活的设计。PAM允许一个管理员来配置访问服务使用的认证方法来,这些访问服务被编写以使用这个PAM库。
如果我们的应用程序想要验证一个用户有执行一个任务的恰当的权限,那么我们可以在应用程序里硬编码认证机制,或者使用PAM来得到等价的功能。使用PAM的好处是管理员可以配置为不同任务配置不同的认证用户的方法,基于本地策略。
如果我们成功登录,login将:
1、改变到我们的主目录(chdir);
2、改变我们终端设备的属主(chown)所以我们拥有它;
3、改变我们终端设备的访问权限,所以我们有读取它的权限;
4、通过调用setgid和initgroups来设置我们的组ID;
5、用login有的所有信息初始化环境:我们的主目录(HOME)、外壳(SHELL)、用户名(USER和LOGNAME)、和一个默认路径(PATH);
6、改变到我们的用户ID(setuid)并调用我们的登录外壳,如execl("/bin/sh", "-sh", (char *)0); 作为argv[0]的第一个字符的负号是所有外壳的一个标志,标示它们被作为一个登录外壳被调用。这个外壳可以查看这个这个字符,并相应地修改它们的启动。
login程序真正做的比我们在这描述的多。它选择性地打开当天消息文件,查看新的邮件,和执行其它任务。我们只对我们描述过的特性感兴趣。
从我们8.11节关于setuid函数的讨论里回想下,既然它被一个超级用户处理,setuid改变所有的三个用户ID:真实用户ID、有效用户ID和保存的设置用户ID。被login更早调用的setgid对所有三个组ID有相同的效果。
现在,我们的登录外壳在运行了。它的父进程ID是原始init进程(进程ID 1),所以当我们的登录外壳终止时,init被通知(它被发送了一个SIGCHLD信号),而它可以为这个终端再次开始一个完整的过程。文件描述符0、1、2为我们的登录外壳被设置为终端设备。
在所有事情为一个终端登录被设置好后的进程排列为:进程ID为1的init通过getty和login启动登录外壳;外壳为终端设备驱动打开描述符0、1、2;终端的用户通过硬线连接操作终端。
我们的登录外壳现在读取它的启动文件(Bourne shell和Korn shell的.profile、GNU Bourne-again shell的.bash_profile、.bash_login或.profile文件;和C shell的.cshrc和.login)。这些启动文件通常改变一些环境变量并加入许多额外的变量到环境里。例如,多数用户设置他自己的PATH并经常为真实的终端类型(TERM)提示。当启动文件被完成时,我们最终得到shell的提示并可以输入命令。
Mac OS X 终端登录
在Mac OS X上,终端登录处理遵从和在BSD登录处理里一样的步骤,因为Mac OS X是部分基于FreeBSD的。然而,在Mac OS X,我们在启动时用基于图形的登录屏幕显示。
Linux 终端登录
Linux登录过程和BSD过程非常相似。事实上,Linux的login命令由4.3BSD的login命令继承。BSD登录过程和Linux登录过程间的区别在于终端配置被指定的方法。
在Linux上,/etc/inittab包含了配置信息,指定init应该为哪个终端设备启动一个getty处理,和在系统V上的方式相似。取决于被使用的getty的版本,终端特性在命令行上(如agetty)或在文件/etc/gettydefs(如mgetty)里指定。
Solaris 终端登录
Solaris支持两种形式的终端登录:a、getty风格,如前面为BSD的描述,和b、ttymon登录,一个在SVR4引入的特性。通常,getty为控制台使用,而ttymon被用在其它登录上。
ttymon命令是一个更大的术语为SAF(Service Access Facility,服务访问设施)的一部分。SAF的目标是提供一个一致的方法来管理提供系统访问的服务。为了我们的目的,我们和前面描述的进程的排列一样,除了在init和登录外壳之间的不同的步骤集。init是sac(服务访问控制器)的父结点,sac执行一个fork和ttymon的exec,当系统进入多用户状态时。ttymon程序监控所有列在配置文件里的终端端口,并当我们已经输入我们的用户名时执行一个fork。ttymon的子进程执行一个login的exec,而login提示我们输入我们的密码。一旦这被完成,login exec我们的登录外壳,于是我们得到各前面一样的排列。一个区别是我们登录外壳的父进程现在是ttymon,而从getty登录的登录外壳的父进程是init。
9.3 网络登录
通过一个序列化终端登录系统和通过一个网络登录系统之间的主要的(物理的)区别是,终端和电脑之间的连接不是点对点的。在这种情况下,login只简单地是一个可用的服务,就像任何其它的网络服务,比如FTP或SMTP。
使用上一节我们讨论的终端登录,init知道哪个终端设备为登录开启并为每个设备产生一个getty进程。而在网络登录的情况下,所有的登录从内核的网络接口驱动而来(比如,以太驱动),而我们事先不知道有多少将会发生。我们没有一个等待每个可能的登录的进程,而必须等待一个网络连接请求的到来。
为了允许相同的软件来处理终端和网络上的登录,一个被称为伪终端的软件驱动被用来模拟一个序列化终端并映射终端操作到网络操作,反过来也是如此。(在19章,我们将深入探讨伪终端。)
BSD网络登录
在BSD里,单个进程等待多数网络连接:inetd进程,有时被称为Internet超级服务。在这节,我们将看到为BSD系统登录而调用的进程序列。我们不对这些进程的细节的网络编程外貌感兴趣。
作为系统启动的一部分,init调用一个执行外壳脚本/etc/rc的外壳。这个外壳脚本启动的其中一个后台进程是inetd。一旦外壳脚本终止,inetd的父进程则变为init;inetd等待到达主机的TCP/IP的连接请求。当一个连接请求等待它的处理时,inetd执行一个fork和恰当程序的exec。
让我们假定TCP连接请求为TELNET服务器而到达。TELNET是一个使用TCP协议的远程登录应用程序。在另一个(通过某种形式的网络连接到服务器主机的)主机的一个用户或在同一个主机上通过启动TELNET客户端来初始化login:telnet hostname。
客户端打开一个对主机名的TCP连接,而主机名启动的这个程序被称为TELNET服务器。客户端和服务器然后使用TELNET应用程序协议通过TCP连接来交换数据。发生的事情是启动客户端程序的用户现在登录到服务器主机了。(当然,这假定用户在服务器主机上有一个有效的账号。)下面是执行TELNET服务器而调用的进程序列,被称为telnetd:
1、进程ID 1的init通过fork和exec启动/bin/sh,它在当用户进入多用户时执行外壳脚本/etc/rc;产生inetd;
2、从TELNET客户端到来的TCP连接请求,使inetd通过fork产生一个子进程inetd,并通过exec执行telnetd。
telnetd进程然后打开一个伪终端设备并用fork分为两个进程。父进程处理网络连接的通信,而子进程执行login程序的exec。父进程和子进程通过这个伪终端连接。在执行exec前,子进程为伪终端设置文件描述符0、1、2。如果我们正确登录,login执行我们在9.2节描述的相同的步骤:它改变到我们的主目录并设置我们的组ID、用户ID和我们的初始环境。login通过调用exec用我们的登录外壳代替它自己。下面是此时进程的布局:
1、进程ID 1的init通过inetd、telnetd、和login,得到了子进程登录外壳;
2、登录外壳为伪终端设备驱动打开文件描述符0、1、2。
3、终端用户通过网络连接经过telnetd服务器和telnet客户商操作伪终端设备驱动。
显然,在伪终端设备驱动和终端的真实用户之间有许多正在执行的事情。我们将在19章深入讨论伪终端时展示这种布局类型所调用的所有进程。
需要了解的重要的事是,无论我们是通过一个终端还是一个网络登录,我们都有和终端设备或伪终端设备连接的带有标准输入、标准输出和标准错误的登录外壳。我们将在后面各节里看到这个登录外壳是POSIX.1会话的开始,以及终端或伪终端是这个会话的控制终端。
Mac OS X Network登录
通过网络登录到一个Mac OS X系统和BSD系统一样,因为Mac OS X是部分基于FreeBSD的。
Linux网络登录
Linux下的网络登录和BSD下的相同,除了一个inetd的替代器被使用,它被称为扩展Internet服务,xinetd。xinetd进程对它启动的服务提供一个比inetd更好等级的控制。
Solaris网络登录
在Solaris下的网络登录场景也BSD和Linux下的步骤基本相同。一个inetd服务器被使用,和BSD版本的相似。Solaris版本有额外的在服务访问设施框架下运行的能力,尽管它没有被配置成这样做。相反,inetd服务器由init启动。任一种方法,我们都会得到相同的进程布局。
9.4 进程组
除了进程ID,每个进程同样属于一个进程组。我们将在第10章讨论信号时再次碰到进程组。
一个进程组是一个或多个进程的集合,通过和相同的工作联系起来(工作控制在9.8节讨论),它们可以从相同的终端获取信号。每个进程组有一个唯一的进程组ID。进程组ID和进程ID相似:它们是正的整型,并以pid_t类型存储。函数getpgrp返回调用进程的进程组ID。
#include
pid_t getpgrp(void);
返回调用进程的进程组ID。
在BSD后代系统的早期版本,getpgrp函数接受一个pid参数并返回这个进程的进程组。SUS定义getpgid函数作为一个XSI扩展来效仿这种行为。
#include
pid_t getpgid(pid_t pid);
成功返回进程组ID,错误返回-1
如果pid为0,返回调用进程的进程组ID。因而getpgid(0)等价于getpgrp();
每个进程组可以有一个进程组长。这个组长和它的进程组ID相等的进程ID于来标识。
可能一个进程组长会创建一个进程组、创建这个组的进程,然后终止。只要至少有一个进程在这个组里,这个进程组便仍然存在,不管进程组长是否终止。这被称为进程组生命周期--由组被创建开始到最后一个进程离开组为止的时间期。组里最后的进程可以终止或进程一些其它的进程组。
一个进程加入一个存在的进程组或创建一个新的进程组,通过调用setpgid。(在下节,我们将看到setsid同样创建一个新的进程组。)
#include
int setpgid(pid_t pid, pid_t pgid);
成功返回0,错误返回-1
这个函数设置进程ID为pid的进程的进程组ID为pgid。如果两个参数相同,这个由pid指定的进程成为进程组长。如果pid为0,那么调用者的进程ID被使用。同样,如果pgid为0,由pid指定的进程ID作为进程组ID被使用。
一个进程可以设置它自己的或者它任一子进程的进程组ID。更甚,它不能在子进程调用某个exec函数后改变这个子进程的进程组ID。
在多数工作控制外壳里,这个函数在一个fork后被调用来让父进程设置子进程的组ID,以及让子进程设置它自己的进程组ID。这些调用中有一个是多余的,但是通过调用两者,在父或子进程假定它已经发生前,我们被保证子进程被安置在它自己的进程组里。如果我们不这样做,我们会有一个竞争条件,因为子进程的进程组成员可能取决于哪个进程先执行。
当我们讨论信号时,我们将看到我们怎么发送一个信号给单一进程(由它的进程ID指定),或一个进程组(由它的进程组ID指定)。类似地,8.6节的waitpid函数让我们等待单一进程或一个指定进程给的一个进程。
9.5 会话(Sessions)
一个会话是一个或多个进程组的集合。比如,我们有下面的布局:会话包含三个进程组,第一个进程组包含登录外壳;第二个进程组包含进程proc1和proc2;第三个进程组包含进程proc3、proc4、proc5。
在一个进程组里的进程通常通过管道被放置在那。例如,上面的布局可能由下面形式的外壳命令产生:
proc1 | proc2 &
proc3 | proc4 | proc 5
一个进程通过调用setsid函数来建立一个新的会话。
#include
pid_t setsid(void);
成功返回进程组ID;错误返回-1
如果调用进程不是一个进程组长,那么这个函数创建一个新的会话。有三件事发生:
1、进程变为这个新会话的会话领导。(一个会话领导是创建一个会话的进程。)这个进程是这个新会话里的唯一进程。
2、进程变为一个新进程组的进程组长。这个新进程组ID是调用进程的进程ID。
3、进程没有控制终端。(我们将在下节讨论进程终端。)如果进程在调用setsid之前有一个控制终端,那么这个关联被打破。
如果调用者已经是一个进程组长,那么这个函数返回一个错误。为了保证不是这种情况,通常的做法是调用fork并让父进程终止而让子进程继续。我们被保证这个子进程不是一个进程组长,因为父进程的进程组ID被子进程继承,但是子进程得到一个新的进程ID。因此,子进程ID不可能和继承下来的进程组ID相同。
USU只提到“会话领导”。而没有和一个进程ID或进程组ID相似的“会话ID”。显然,一个会话领导是有一个唯一进程ID的单一进程,所以我们可以把会话领导的进程ID作为会话ID。进程ID的概念在SVR4引入。历史上,基于BSD的系统没有支持这个符号,但是被更新为引入了它。getsid函数返回一个进程的会话领导的进程组ID。getsid函数是SUS的XSI扩展。
一个实现,比如Solaris,用避免短语“会话ID”而使用“会话领导的进程组ID”来向SUS靠拢。这两者等价,既然会话领导一直是进程组长。
#include
pid_t getsid(pid_t pid);
成功返回会话领导的进程组ID,错误返回-1
如果pid为0,getsid返回调用进程的会话领导的进程组ID。由于安全原因,一些实现可能限制调用进程不能得到会话领导的进程组ID,如果pid不属于和调用者相同的会话。
9.6 控制终端
会话和进程组有几个其它的特性:
1、一个会话可以有单一的控制终端。这通常是在我们登录的终端设备(在一个终端登录的情况下)或者伪终端设备(在网络登录的情况下)。
2、会话领导建立控制终端的连接,并被称为控制进程。
3、一个会话里的进程组可以被分为单个前台进程组和一个或多个后台进程组。
4、如果一个会话有一个控制终端,那么它有单个前台进程组,而在这个会话里的所有其它进程组都是后台进程组。
5、每当我们输入终端的中断键(经常是DELETE或Control-C),这会导致中断信号发送给前台进程组的所有进程。
6、每当我们输入终端的退出键(经常是Control-backslach),这会导致退出信号发送给前台进程组的所有进程。
7、如果一个猫(或网络)连接断开被终端接口察觉,一个挂起信号被发送给控制进程(会话领导)。
在前一节提到的会话布局里:
1、会话里的登录外壳是在后台进程组里,它是会话领导,也是控制进程;
2、proc1和proc2在后台进程组里;
3、proc3、proc4和proc5在前台进程组里。
4、控制终端在猫断开时向登录外壳发送挂起信号;终端输入和终端产生的信号被发送给前台进程组(proc3、proc4和proc5)。
通常,我们不必担心控制终端。它在我们登录时会自动建立。
POSIX.1把分配一个控制终端的机制的选择留给了每个独立的实现。我们将在19.4看到真实的步骤。
UNIX系统V的后代在会话领导打开第一个还未与某个会话关联的终端设备时,为一个会话分配这个控制终端。这假定会话领导的open的调用没有指定O_NOCTTY标志(3.3节)。
基于BSD的系统在会话领导使用TIOCSCTTY的请求参数(第三个参数为空指针)调用ioctl时,为一个会话分配这个控制终端。这个会话不能已经有一个控制终端,才能成功。(一般,调用ioctl前会调用setsid,它会保证进程是一个没有控制终端的会话领导。)open里的POSIX.1的O_NOCTTY标志没有被基于BSD的系统使用,除了在支持其它系统的兼容模式里。
有时一个程序想和控制终端通话,不管标准输入或标准输出是否被重定向。一个程序保证它正和控制终端通话的方法是打开谁的/dev/tty。这个特殊文件是内核里控制终端的同义词。自然地,如果程序没有一个控制终端,这个设备的open会失败。
经典的例子是getpass函数,它读取一个密码(当然,终端输出被关闭)。这个函数被crypt程序调用,必可以在一个管道里使用。例如:crypt < tommy | lpr解密文件tommy并把输出用管道传输到打印假脱机程序(print spooler)。因为crypt在它的标准输入上读取它的输入文件,所以标准输入不能用来输入密码。还有,crypt被设计为我们必须在每次运行程序时输入加密密码,来避免我们在一个文件里保存密码(这是一个安全漏洞)。
有几种熟知的方法来破坏crypt程序使用的编码。
9.7 tcgetpgrp、tcsetpgrp和tcgetsid函数
我们需要一种方法来告诉内核哪个进程组是前台进程组,以便终端设备驱动知道发送终端输入和终端产生的信号到哪里。
#include
pid_t tcgetpgrp(int filedes);
成功返回前台进程组的进程组ID;错误返回-1
int tcsetpgrp(int filedes, pid_t pgrpid);
成功返回0,错误返回-1
函数tcgetpgrp返回和在filedes上打开的终端相关联的前台进程组的进程组ID。
如果进程有一个控制终端,进程可以调用tcsetpgrp来设置前台进程组ID给pgrpid。pgrpid的值必须是相同会话里的一个进程组的进程组ID,filedes必须引用会话的控制终端。
多数应用程序没有直接调用这两个函数。它们通过用工作控制算过调用。
SUS定义了一个XSI扩展,被称为tcgetsid,来允许一个应用程序来得到会话领导的进程组ID,给定一个控制TTY的文件描述符。
#include
pid_t tcgetsid(int filedes);
成功返回会话领导的进程组ID。错误返回-1
需要管理控制终端的应用程序可以使用tcgetsid来标识控制终端领导的会话ID(,等价于会话领导的进程组ID)。
9.8 工作控制
工作控制是在1980年前后加入到BSD的一个特性。这个特性允许我们在单个终端开启多个工作(进程组),并控制哪些工作可以访问终端和哪些工作在后台运行。工作控制需要三种形式的支持:
1、一个支持工作控制的外壳;
2、内核里的终端驱动必须支持工作控制;
3、内核必须支持特定的工作控制信号。
SVR3提供了另一种形式的工作控制,被称为外壳层。BSD形式的工作控制,被POSIX.1选择,也是我们在这讨论的。在这个标准的更早的版本里,工作控制支持是可选的,但是现在POSIX.1要求平台来支持它。
从我们的角度,从一个外壳使用工作控制,我们可以在前台或后台开始一个工作。一个工作是简单的一个进程集合,经常是一个进程的管道。例如:vi main.c打开了一个由一个前台进程组成的工作。命令pr *.c | lpr &
make all &
在后台开启了两个工作。后台工作调用的所有进程都在后台里。
正如我们说过的,为了使用工作控制提供的特性,我们需要使用支持工作控制的一个外壳。在早期系统,说出哪些外壳支持而哪些外壳不支持工作控制很简单。C外壳支持工作控制、在Bourne外壳不支持、而在Korn外壳里是一个可选项,取决于主机是否支持工作控制。但是C外壳被移植到不支持工作控制的系统(例如,系统V的早期版本),而SVR4 Bourne外壳,当使用名字jsh而不是sh时,会支持工作控制。Korn外壳继续支持工作控制,如果主机支持的话。Bourne-again外壳也支持工作控制。我们将只泛泛地说一个支持工作控制,对应不支持工作控制的那些,当各种外壳的区别无关紧要时。
当我们开始一个后台工作进,外壳给它分配一个工作标识符并打印一个或多个进程ID。下面的脚本展示Korn外壳如何处理这个:
$make all > Make.out &
[1] 1475
$ pr *.c | lpr &
[2] 1490
$ (只输入回车)
[2] + Done
[1] + Done
make的工作号为1而开始进程ID为1475.下一个管道的工作号为2而第一个进程的进程ID为1490。当工作完成时,而我们按下回车,外壳告诉我们工作完成了。我们必须按回车的原因是让外壳打印它的提示。外壳不在任何随机时间打印后台工作的改变状态--只是在它打印它的提示前,来让我们输出一个新的命令行。如果外壳不这样做,它可能在我们输入一个输入行时输出。
和终端驱动的交互由于一个特殊终端字符影响了前台工作而发生:挂起键(典型的Control-Z)。输出这个字符导致终端驱动发送SIGTSTP信号给前台进程组的所有进程。任何后台进程进程组不被影响。终端驱动查找三个特殊的字符,它们产生对前台进程组的信号:
1、中断字符(一般是DELETE或Control-C)产生SIGINT;
2、退出字符(一般是Control-backslash)产生SIGQUIE;
3、挂起字符(一般是Control-Z)产生SIGTSTP。
在18章,我们将看到我们怎样改变这三个字符成为我们选择的任意字符,以及我们如何禁止终端驱动处理这些特殊字符。
另一个必须由终端驱动处理的工作控制可以发生。既然我们可以有一个前台工作和一个或多个后台工作,这些中的哪台接受我们在终端输入的字符呢?只有前台工作接受终端输入。后台工作尝试从终端读不是一个错误,但是终端驱动察觉到它并向后台工作发送一个特殊的信号:SIGTTIN。这个信号一般停止后台工作。通过使用外壳,我们收到这个通知并可以把这个工作带入前台,以便它可以从终端读取。证明如下:
$ cat > temp.foo &
[1] 2333
$ (回车)
[1]+ 已停止 cat > temp.foo
$ fg %1
cat > temp.foo
hello, world
$ cat temp.foo
hello, world
外壳在后台启动cat进程,但是当cat尝试读取它的标准输入(控制终端),终端驱动知道它是一个后台工作,发送SIGTTIN信号给这个后台工作。外壳察觉它子进程状态的改变(回想8.6节wait和waitpid函数的讨论)并告诉我们工作已经被停止了。我们然后用外壳的fg命令把停止的工作移到前台来。(参考shell的手册而来得到工作控制命令的所有细节,比如fg和bg,以及标识不同工作的各种方法。)这样做导致外壳把工作移到前台进程组(tcsetpgrp)并发送继续信号(SIGCONT)给这个进程组。既然它现在在前台进程组时,这个工作可以从控制终端读取。
如果一个后台工作输出到控制终端会发生什么呢?这是一个我们可以允许或禁止的可选项。通常,我们使用stty命令来改变这个选项。(我们将在18章看到我们如何可以从一个程序里改变这个选项。)下面展示这如何工作:
$ cat temp.foo &
[1] 2385
$ hello, world
(输入回车)
[1]+ 完成 cat temp.foo
$ stty tostop
$ cat temp.foo &
[1] 2387
$ (输入回车)
[1]+ 已停止 cat temp.foo
$ fg 1
cat temp.foo
hello, world
当我们禁止前台工作向控制终端写时,cat会在其尝试向标准输出写时阻塞,因为终端驱动标识了这个写是从一个后台进程来的,并向这个工作发送SIGTTOU信号。正如前面的例子,当我们使用外壳的fg命令把工作带入到前台时,工作完成。
下面总结了我们已经描述过的工作控制的一些特性:
1、init或inetd启动一个新的会话。这个会话包含后面的内容;
2、由init或inetd创建的getty或telnetd进程,在调用setsid后,建立控制终端,并通过exec执行login;
3、login通过exec执行登录外壳;
4、登录外壳调用setpgid创建前台进程组和后台进程组,并向终端驱动调用tcsetpgrp来为控制终端设置进程组;
5、终端上的用户与终端驱动交互,终端输入和终端产生的信号(SIGINT、SIGQUIT和SIGTSTP)被发送给前台进程;前台进程向终端驱动发送终端输出;
6、后台进程读取终端输入时,终端驱动向它发送SIGTTIN信号;后台进程向终端输出时,终端驱动可能向它发送SIGTTOU信号;
7、前台进程和后台进程的状态改变时,登录外壳会收到通知。
工作控制是必需的还是可取的?工作控制最初在窗口终端被广泛使用前被设计和实现。一些人指出一个良好设计的窗口系统移除了工作控制的需求。一些人则抱怨工作控制的实现--需要内核、终端驱动、外壳和一些应用程序的支持--是一个hack。一些人在一个窗口系统里使用工作控制,要求两者都需要。不管你的意见如何,工作控制是POSIX.1的一个必需的特性。
9.9 程序的外壳执行
让我们检查下外壳如何执行程序以及它怎么和进程组、控制终端和会话的概念联系。为了这样做,我们必须再次使用ps命令。
首先,我们使用一个不支持工作控制的外壳--在Solaris上运行的典型Sourne外壳。如果我们执行ps -o pid,ppid,pgid,sid,comm,输出为
PID PPID PGID SID COMMAND
949 947 949 949 sh
1774 949 949 949 ps
ps命令的父进程是外壳,这是我们意料之中的。shell和ps命令在同一个会话和同一个进程组(949)里。我们说949是一个前台进程组是因为那是我们用一个不支持工作控制的外壳执行一个命令时得到的。
一些平台支持一个可选项来让ps命令打印和会话控制终端相关的进程组ID。这个值会被显示在TPGID列里。不幸的是,ps命令的输出通常在UNIX系统的版本之间不同。例如,Solaris不支持这个选项。在FreeBSD 5.2.1和Mac OS X 10.3下,命令ps -o pid,ppid,pgid,sess,tpgid,command和Linux2.4.22下的命令ps -o pid,ppid,pgrp,session,tpgid,comm精确打印出我们想要的信息。
注意把一个进程和一个终端进程组ID(TPGID列)关联是不当的。一个进程属于一个进程组,而这个进程组属于一个会话。这个会话可能也可能没有一个控制终端。如果会话有一个控制终端的话,那么然后终端设备知道前台进程的进程组ID。这个值可以用tcsetpgrp函数在终端驱动里设置。前台进程组ID是终端的一个属性,而不是进程。这个从终端设备驱动而来的值是ps作为TPGID打印的。如果它找到会话没有一个控制终端,ps打印-1.
如果我们在后台执行这个命令ps -o pid,ppid,pgid,sid,comm &,那么唯一改变的值是命令的进程ID
PID PPID PGID SID COMMAND
949 947 949 949 sh
1812 949 949 949 ps
这个外壳不知道工作控制,所以后台工作没有放到它的进程组里而且控制终端没有从后台工作移走。
我们现在看下Bourne外壳如何处理一个管道。当我们执行ps -o pid,ppid,pgid,sid,comm | cat1时,输出为:
PID PPID PGID SID COMMAND
949 947 949 949 sh
1823 949 949 949 cat1
1824 1823 949 949 ps
(程序cat1只是标准cat程序的一个拷贝,有着不同的名字。我们有cat的另一个拷贝,名字为cat2,我们将在本节稍后使用。当我们在一个管道里有cat的两份拷贝时,不同的名字让我们区分这两个程序。)注意管道里的最后一个进程是外壳的子进程,而管道的第一个进程是最后的进程的子进程。它显示外壳fork了它自己的一个拷贝,这个拷贝然后用fork来创建每个在管道里的前面的进程。
如果我们在后台执行这个管道,ps -o pid,ppid,pgid,sid,comm | cat1 &,只有进程ID改变。既然这个外壳不支持工作控制,那么后台进程的这个进程组ID仍然是949,会话的进程组ID也保持不变。
如果一个后台进程要从它的控制终端尝试读取的情况下会发生什么?例如,假设我们执行cat > temp.foo &。使用工作控制,这通过把后台工作放入后台进程组来处理,它导致如果后台工作尝试从控制终端读时,信号SIGTTIN被产生。没有工作控制的处理方式是,外壳自动把后台的标准输入重定向到/dev/null,如果进程自己没有重定向标准输入的话。从/dev/null的读产生一个文件结尾。这意味着我们的后台cat进程立即读到一个文件末尾并正常返回。
前一段大量处理了后台进程通过它的标准输入访问控制终端的情况,但是如果一个后台进程指定地打开了/dev/tty并从控制终端读取会发生什么呢?答案是“看情况”,但是它很可能不是我们想要的。例如:crypt < tommy | lpr &就是这样的一个管道。我们在后台运行它,但是crypt程序打开/dev/tty,改变了终端属性(来关闭输出),从这个设备读取,然后重置终端属性。当我们执行这个后台管道时,crypt的提示Password:被打印在终端上,但是我们输入的东西(加密的密码)被外壳读取,它尝试执行这个名字的一个命令。我们输入到外壳的下一行被作为一个密码,文件没有被正确加密,把假货发送给打印机。这里我们有两个同时尝试从相同设备读取的两个进程,而结果取决于系统。工作控制,正如我们之前描述的,在多个进程间用更好的风格处理了单个终端的复用。
回到我们的Bourne外壳的例子,如果我们在管道里执行三个进程,我们可以检查这个外壳使用的进程控制:ps -o pid,ppid,pgid,sid,comm | cat1 | cat2产生如下输出:
PID PPID PGID SID COMMAND
949 947 949 949 sh
1888 949 949 949 cat2
1889 1823 949 949 ps
1890 1988 949 949 cat1
如果在你的系统上没有输出恰当的命令名时不要惊讶。有时你可以得到下面的结果:
PID PPID PGID SID COMMAND
949 947 949 949 sh
1831 949 949 949 sh
1832 1831 949 949 ps
1833 1931 949 949 sh
这里发生的是ps进程和外壳有竞争,外壳分叉并执行cat命令。在这种情况下,当ps已经得到要打印的进程列表时外壳还没有完成exec的调用。
再次,管道的最后的进程是外壳的子进程,而管道里所有之前的进程都是最后进程的子进程。既然管道的最后的进程是登录外壳的子进程,当进程(cat2)终止时外壳被通知。
现在我们来使用运行在Linux上的工作控制外壳检查下控制的相同的例子。这展示了这些外壳处理后台工作的方法。在这个例子里,我们将使用Bourne-again外壳。其它工作控制外壳的结果几乎都相同。
ps -o pid,ppid,pgrp,session,tpgid,comm的结果是:
2588 2580 2588 2588 2643 bash
2643 2588 2643 2588 2643 ps
我们从立即得到一个和Bourne外壳例子的区别。Bourne-again外壳把前台工作(ps)放置到它自己的进程组(2643)里,ps命令是这个进程组的进程组长和唯一一个进程。
更甚,这个进程组是前台进程组,因为它有控制终端。当ps命令执行时,我们的登录外壳在一个后台进程组里。然而,注意,两个进程组2588和2643,是同一个会话里的成员。事实上,我们将在本节的例子里看到会话绝不会改变。
在后台执行这个进程,ps -o pid,ppid,pgrp,session,tpgid,comm &,结果为:
PID PPID PGRP SESS TPGID COMMAND
2588 2580 2588 2588 2588 bash
2649 2588 2649 2588 2588 ps
再次,ps命令被放置到它自己的进程组里,但是这次进程组(2649)不再是前台进程组。它是一个后台进程组。2588的TPGID指明前台进程组是我们的登录外壳。
在一个管道里执行两个进程,如:ps -o pid,ppid,pgrp,session,tpgid,comm | cat,结果为:
PID PPID PGRP SESS TPGID COMMAND
2588 2580 2588 2588 3519 bash
3519 2588 3519 2588 3519 ps
3520 2588 3519 2588 3519 cat
ps和cat这两个进程都被放入一个新的进程组(3519),而且是前台进程组。我们还可以看到这个例子和前面相似的Bourne外壳的例子之间的另一个区别。Bourne外壳首先创建管道里的最后一个进程,而这个最后的进程是第一个进程的父进程。这里Bourne-again外壳是两个进程的父进程。如果我们在后台执行这个管道:ps -o pid,ppid,pgrp,session,tpgid,comm | cat &,结果相似,只是现在ps和cat被放在相同的后台进程组里:
PID PPID PGRP SESS TPGID COMMAND
2588 2580 2588 2588 2588 bash
3524 2588 3524 2588 2588 ps
3525 2588 3524 2588 2588 cat
注意一个外壳创建进程的顺序取决于使用的特定的外壳可以有所不同。
9.10 孤立进程组
我们已经提到过一个其父进程终止的进程被称为一个孤儿,被init进程收养。我们现在看下可以被孤立的整个进程组以及POSIX.1如何处理这种情况。
考虑一个fork一个子进程然后终止的进程。尽管这不是什么异常的事(它一直都发生),然而如果当父进程终止时子进程(使用工作控制)被停止了会发生什么?子进程要怎样被继续,子进程知道它已经被孤立了吗?下面的代码展示了这个状况:父进程调用fork创建了一个停止的子进程,然后这个父进程准备退出:
下面是程序的运行结果:
parent: pid = 3691, ppid = 2588, pgrp = 3691, tpgrp = 3691
child: pid = 3692, ppid = 3691, pgrp = 3691, tpgrp = 3691
ISGHUP received, pid = 3692
child: pid = 3692, ppid = 1, pgrp = 3691, tpgrp = 2588
read error from controlling TTY, errno = 5
正如我们意料的,子进程的父进程ID变为1.
在子进程里调用pr_ids后,程序尝试从标准输入里读取。正如我们在这章更早时看到的,当一个后台进程组尝试从它的控制终端读取时,SIGTTIN会为这个后台进程组产生。但是这里我们有一个孤立进程组;如果内核要用这个信号停止它的话,这个进程组里的进程将很有可能不再继续。POSIX.1规定在这种情况下read要返回一个错误,errno被设为EIO(在这个系统上的值为5)。
最后,注意当父进程终止时,我们的子进程被放在一个后台进程组里,因为父进程作为一个前台工作被外壳执行。
我们将在19.5节里的pty程序里看到另一个孤立进程组的例子。
9.11 FreeBSD实现
我们已经谈过一个进程、进程组、会话和控制终端的各种属性,现在值得看下这些是如何实现的。我们将简明地看下FreeBSD使用的实现方式。SVR4上这些特性的实现的一些细节可以在Williams[1989]里找到。下表显示了FreeBSD使用的各种数据结构:
让我们看看我们标出的所有的域,从session结构体开始。每个会话都会分配一个这样的结构体(例如,每次setsid被调用时)。
1、s_count是会话里的进程组数量。当这个数量被减为0时,结构体会被释放。
2、s_leader是会话领导的proc结构体的指针。
3、s_ttyvp是控制终端的v-node结构体的指针。
4、s_ttyp是控制终端的tty结构体的指针。
5、s_sid是会话ID。回想下会话ID的概念不是SUS的一部分。
当setsid被调用时,一个新的session结构体在内核里被分配。现在s_count被设为1,s_leader被设为调用进程的proc结构体的指针,s_sid被设置成进程ID,s_ttyvp和s_ttyp被设为空指针,因为新的会话没有一个控制终端。
让我们移到tty结构体。内核为每个终端设备和伪终端设备包含一个这样的结构体。(我们将在19章更多讨论伪终端。)
1、t_session指向把这个终端作为它的控制终端的会话结构体。(注意tty结构体指向session结构体,反过来也是这样。)这个指针被终端用来向会话领导发送挂起信号,如果终端失去了I/O(比如网络断开连接)。
2、t_pgrp指向前台进程组的pgrp结构体。这个域被终端驱动器用来向前台进程组发送信号。通过输入特殊字符产生的三个信号(中断、退出和挂起)被发送给前台进程组。
3、t_termios是一个结构体,包含所有特殊字符,和这个终端相关的信息,比如波特率,回声是否被打开,等等。我们将在18章回到这个结构体。
4、t_winsize是一个winsize结构体,包含终端窗口的当前尺寸。当终端窗口的尺寸改变时,SIGWINCH信号被发送给前台进程组。我们将在18.12节显示如何设置和获取终端的当前窗口尺寸。
注意为了找到特定会话的前台进程组,内核必须从会话结构体开始,跟随s_ttyp来得到控制终端的tty结构体,然后跟随t_pgrp来得到前台进程组的pgrp结构体。pgrp结构体包括一个特定进程组的信息。
1、pg_id为进程组ID。
2、pg_session指向该进程组所属的会话的session结构体。
3、pg_members是指向该进程组成员的proc结构体列表的指针。在proc结构体里的p_pglist结构体是一个双向链表项,指向组里的前一个和后一个进程,等等,直到在进程组里的最后的进程的proc结构体里碰到一个空指针。
proc结构体包含单个进程的所有信息。
1、p_pid包含进程ID。
2、p_pptr是指向父进程的proc结构体的一个指针。
3、p_pgrp指向这个进程所属的进程组的pgrp结构体。
4、p_pglist是一个结构体,包含进程组里的前一个和后一个进程的指针,如我们之前提到的。
最后,我们有vnode结构体。这个结构体在控制终端被打开时被分配。一个进程里的所有/dev/tty的引用都经过这个vnode结构体。我们展示了真实i-node是v-node的一部分。
9.12 总结
这章描述了进程组之间的关系:由进程组组成的会话。工作控制是被当今多数UNIX系统支持的一个特性,我们也介绍了它是如何被一个支持工作控制的外壳实现的。一个进程的控制终端,/dev/tty,也在这些进程关系里被涉及了。
我们引入了很多在所有这些进程关系里使用的信号。下一章继续信号的讨论,深入研究所有UNIX系统的信号。
10.1 引言
信号是软件中断。多数不平凡的应用程序需要处理信号。信号提供了一种处理异步事件的方式:终端的一个用户敲入中断键来停止一个程序,或管道的下一个进程过早地终止。
信号自从UNIX系统的早期版本就被提供,但如版本7的系统提供的信号模型是不可靠的。信号可能会丢失,而且进程当执行代码的临界区域时很难关闭选定的信号。4.3BSD和SVR3都改变了信号模型,加入了被称为可靠的信号。但是这种改变使用Berkeley和AT&T不能兼容。幸运的是,POSIX.1标准化了可靠信号例程,这也是我们在这描述的。
在这章,我们从信号的总览和每个信号通常用来做什么的描述开始。然后我们看到更早的实现的问题。在看如何正确做事前,了解某个实现的问题,是很重要的。本章包括了不完全对的许多例子和缺陷的讨论。
10.2 信号概念
首先,每个信号有个名字。这些名字都以字符串SIG开头。例如SIGABRT是当一个进程调用abort函数时产生的终止信号。SIGALRM是当alram函数设置的计时器到时时产生的闹铃信号。版本7有15个不同的信号;SVR4和4.4BSD有31个不同的信号。FreeBSD 5.2.1、Mac OS X 10.3和Linux2.4.22支持31个不同的信号,而Sloaris 9支持38个不同的信号。然而,Linux和Solaris都支持应用定义的信号作为实时扩展(POSIX里的实时扩展没有被本文包括)。
这些名字都用正常量(信号号)定义在头文件
实现真正把独立的信号定义在一个替代的头文件里,但是这个头文件被包含在
没有一个信号的信号号为0。我们将在10.9节看到kill函数使用信号号0用作特殊情况。POSIX.1称这个值为空信号。
许多条件可以产生一个信号。
1、终端产生的信号在用户被按下特定终端键时发生。在终端上按下DELETE键(或在许多系统上的Control-C)一般会导致中断信号(SIGINT)被产生。这是停止失控程序的方法。(我们将在18章看到这些信号如何被映射到终端的任一字符。)
2、硬件异常产生信号:由0除,无效内存引用,等等。这些条件通常被硬件察觉,而内核被通知。内核然后在条件发生的时刻为运行的进程产生恰当的信号。例如,SIGSEGV为执行一个无效内存引用的进程而产生。
3、kill命令允许一个进程发送任何信号给其它进程。这个程序只是一个kill函数的接口。这个命令经常用来终止一个失控的后台进程。
4、当发生一些进程应该被通知的事情时,软件条件可以产生信号。这不是硬件产生的条件(如被0除),而是软件条件。例子是SIGURG(当通过网络连接到达的不同频道的数据时产生)、SIGPIPE(当进程在管道的读者终止之后向管道写入时产生)和SIGALRM(当进程设置的闹铃过期时产生)。
信号是异步事件的经典例子。信号在进程的任意时间点发生。这个进程不能简单地测试一个变量(比如errno)来看一个信号是否发生;相反,进程比如告诉内核“如果信号发生时,做如下的事”。
我们可以告诉内核当一个信号发生时,做如下三件事中的一件。我们称它为信号的布署(disposition),或都一个信号的关联动作。
1、忽略这个信号。这对于多数信号都工作,但是两个信号决不能被忽略:SIGKILL和SIGSTOP。这两个信号不能被忽略的原因是为了向内核和超级用户提供杀死或停止任何进程的必定成功的方法。同样,如果我们忽略了一些由硬件异常产生的信号(比如非法内存引用或被0除),进程的行为会没有定义。
2、捕获这个信号。为了这样做,我们告诉内核每当信号发生时调用一个我们的函数。在我们的函数里,我们可以做任何我们要做的事来处理这个条件。例如,如果我们在写一个命令解释器,当用户从键盘产生中断信号时,我们可能想从程序的主循环里返回,终止我们为这个用户执行的任何命令。如果SIGCHLD信号被捕获,它表示一个子进程已经终止了,所以信号捕获函数可以调用waitpid来获取这个子进程ID和终止状态。另一个例子是,如果进程已经创建了临时文件,我们可能想要为SIGTERM信号写一个信号捕获函数(由kill命令发送的默认的终止信号)来清理临时文件。注意SIGKILL和SIGSTOP这两个信号不能被捕获。
3、让默认行为发生。每个信号有一个默认的动作,如下表所示。注意多作信号的默认动作是终止这个进程。
名字 | 描述 | ISO C | SUS | FreeBSD 5.2.1 | Linux 2.4.22 | Mac OS X 10.3 | Solaris 9 | 默认动作 |
---|---|---|---|---|---|---|---|---|
SIGABRT | 异常终止(abort) | * | * | * | * | * | * | 终止+核心 |
SIGALRM | 计时器到期(alarm) | * | * | * | * | * | 终止 | |
SIGBUS | 硬件错误 | * | * | * | * | * | 终止+核心 | |
SIGCANCEL | 线程库内部使用 | * | 忽略 | |||||
SIGCHLD | 子进程的状态改变 | * | * | * | * | * | 忽略 | |
SIGCONT | 继续停止的进程 | * | * | * | * | * | 继续/忽略 | |
SIGEMT | 硬件错误 | * | * | * | * | 终止+核心 | ||
SIGFPE | 算术异常 | * | * | * | * | * | * | 终止+核心 |
SIGFREEZE | 检查点冻结 | * | 忽略 | |||||
SIGHUP | 挂起 | * | * | * | * | * | 终止 | |
SIGILL | 非法指令 | * | * | * | * | * | * | 终止 |
SIGINFO | 键盘状态请求 | * | 忽略 | |||||
SIGINT | 终端中断字符 | * | * | * | * | * | * | 终止 |
SIGIO | 异步I/O | * | * | * | * | 终止/忽略 | ||
SIGIOT | 硬件错误 | * | * | * | * | 终止+核心 | ||
SIGKILL | 终止 | * | * | * | * | * | 终止 | |
SIGLWP | 线程库内部使用 | * | 忽略 | |||||
SIGPIPE | 向没有读者的管道写 | * | * | * | * | * | 终止 | |
SIGPOLL | 可查询的事件(poll) | XSI | * | * | 终止 | |||
SIGPROF | 轮廓时间闹铃(setitimer) | XSI | * | * | * | * | 终止 | |
SIGPWR | 电源失败/重启 | * | * | 终止/忽略 | ||||
SIGQUIT | 终端退出字符 | * | * | * | * | * | 终止+核心 | |
SIGSEGV | 无效内存引用 | * | * | * | * | * | * | 终止+核心 |
SIGSTKELT | 协处理器栈错误 | * | 终止 | |||||
SIGSTOP | 停止 | * | * | * | * | * | 停止进程 | |
SIGSYS | 无效系统调用 | XSI | * | * | * | * | 终止+核心 | |
SIGTERM | 终止 | * | * | * | * | * | * | 终止 |
SIGTHAW | 检查点解冻 | * | 忽略 | |||||
SIGTRAP | 硬件错误 | XSI | * | * | * | * | 终止+核心 | |
SIGSTP | 终端停止字符 | * | * | * | * | * | 停止进程 | |
SIGTTIN | 后台从控制tty读 | * | * | * | * | * | 停止进程 | |
SIGTTOU | 后台向控制tty写 | * | * | * | * | * | 停止进程 | |
SIGURG | 紧急条件(套接字) | * | * | * | * | * | 忽略 | |
SIGUSR1 | 用户定义信号 | * | * | * | * | * | 终止 | |
SIGUSR2 | 用户定义信号 | * | * | * | * | * | 终止 | |
SIGVTALRM | 虚拟时间响铃(setitimer) | XSI | * | * | * | * | 终止 | |
SIGWAITNG | 线程库内部使用 | * | 忽略 | |||||
SIGWINCH | 终端窗口尺寸改变 | * | * | * | * | 忽略 | ||
SIGXCPU | CPU限制超过(setrlimit) | XSI | * | * | * | * | 终止+核心/忽略 | |
SIGXFSZ | 文件尺寸限制超过(setrlimit) | XSI | * | * | * | * | 终止+核心/忽略 | |
SIGXRES | 资源控制超过 | * | 忽略 |
上表列出了所有信号的名字,哪些系统支持该信号的指示,和信号的默认动作。如果信号是基本POSIX.1的一部分则SUS列包含“*”,如是是基础的XSI扩展则包含“XSI”。
当默认的动作被标为“终止+核心”时,它意思是进程的一个内存映像留在了进程的当前工作目录里的名为core的文件里。(因为文件被命名为core,所以可以看出这个特性作为UNIX系统的一部分有多久。)这个文件被多数UNIX系统调试器使用来检查进程终止时的状态。
core文件的产生是多数UNIX系统版本的一个实现特性。尽管这个特性不是POSIX.1的一部分,但是被提到为SUS的XSI扩展的潜在动作。
核心文件在不同的实现上有所区别。例如,在FreeBSD 5.2.1上,核心文件被命名为cmdname.core,cmdname是对应于收到信号的进程的命令名。在Mac OS X 10.3,核心文件被命名为core.pid,pid是收到信号的进程的ID。(这些系统允许核心文件名通过sysctl参数配置。)
多数系统实现把核心文件放在对应进程的当前工作目录里;但Mac OS X把所有核心文件放在/cores里。
核心文件不会被产生,如果a、进程是设置用户ID的,而当前用户不是这个程序文件的属主,或者b、进程是设置组ID的,而当前用户不是文件所属组,c、用户没有在当前工作目录写的权限;d、文件已经存在而用户没有权限去写它;或e、文件太大(回想下7.11节的RLIMIT_CORE限制。)核心文件的权限(假定这个文件原本不存在)通常是用户读和用户写的,尽管Mac OS X设置为用户只读。
在上表时,用“硬件错误”描述的信号对应于实现定义的硬件错误。这些名字里的许多都从UNIX系统的原始PDP-11实现而来。检查你的系统手册来决定这些信号精确地对应着哪种类型的错误。
我们现在更细节地描述每个信号。
SIGABRT:这个信号通过调用abort函数产生(10.17节)。进程异常终止。
SIGALRM:这个信号当一个用alarm函数设置的计时器过期时产生(10.10节)。这个信号在通过setitime设置的间隔计时器过期时也会产生。
SIGBUS:这指定一个实现定义的硬件错误。实现通常在内存错误的特定类型是产生这个信号。(14.9节)。
SIGCANCEL:这个信号被Solaris线程库内部使用,不被广泛使用。
SIGCHLD:每当一个进程终止或停止,SIGCHLD信号被发送给父进程。默认情况下,这个信号被忽略,所以父进程并须捕获这个信号,如果它想在子进程状态改变时得到通知。信号捕获函数里的通常动作是调用某个wait函数来获取子进程的ID和终止状态。
系统V的早期版本有一个名为SIGCLD(没有H)的类似的信号。这个信号的语义和其它信号不同,早在SVR2,手册页强烈反对它在新程序里使用。(足够奇怪,这个警告在手册而的SVR3和SVR4版本里消失了。)应用应该使用标准的SIGCHLD信号,但是要知道多数系统定义SIGCLD为和SIGCHLD相同以保持向后兼容。如果你维护一个使用SIGCLD的软件,你必须检查系统手册页来看它遵循哪种语义。我们在10.7节讨论这两个信号。
SIGCONT:这个工作控制信号被发送给一个停止的进程,当它被继续时。默认动作是继续一个停止的进程,但是如果这个进程没有停止过则忽略这个信号。例如,一个全屏的编辑器,可以捕获这个信号并使用信号处理器在终端作一个重绘的注释。10.20节有更多细节。
SIGEMT:这指定一个实现定义的硬件错误。
名字EMT从PDP-11的“emulator trap”指令而来。不是所有平台都支持这个信号。例如在Linux上,SIGEMT只被选定的架构支持,比如SPARC、MIPS和PA-RISC。
SIGFPE:这个信号是一个算术异常,比如被0除,浮点溢出,等等。
SIGFREEZE:这个信号只被Solaris定义。它用来通知需要在冻结系统状态之前采取特殊动作的进程,比如当一个系统进入冬眠(hibernation)或暂停(suspended)模式。
SIGHUP: 这个信号被发送给控制终端关联的控制进程(会话领导),如果一个连接断开没终端接口察觉。在第9章,我们看到信号被发送给由session结构体的s_leader域指向的进程。这个信号仅当终端的CLOCAL标志没有被设置时被产生。(一个进程的CLOCAL标志当附加的终端是本地的时被设置。这个标志告诉终端驱动器来忽略所有猫状态行。我们将在第18章描述如何设置这个标志。
注意收到这个消息的会话领导可以在后台。这不同于普通的终端产生的信号(中断、退出和挂起),它们总是分发给前台进程组。
如果会话领导终止时,这个信号也会被产生。在这种情况下,信号被发送给前台进程组的每个进程。
这个信号通常用来通知守护进程(第13章)来重新读取它们的配置文件。SIGHUP被选择用作这种目的的原因是一个守护进程不应该有一个控制终端,而且一般不会收到这个信号。
SIGILL:这个信号指出进程已经执行了一个非法硬件指令。4.3BSD从abort函数产生这个信号。SIGABRT现在用作这个。
SIGINFO:这个BSD信号当我们输入状态键(通常是Control-T)时由终端驱动器产生。这个信号被发送给前台进程组的所有进程。这个信号通常导致前台进程组里的进程的状态信息被显示在终端上。
Linux没有提供对SIGINFO的支持,除了在Alpah平台上,那么它被定义为和SIGPWR一样的值。
SIGINT:这个信号当我们输入中断键(通常是DELETE或Control-C)时由终端驱动器产生。这个信号被发送给前台进程组的所有进程。这个信号经常用来终止一个失控的程序,特别是当它在屏幕上产生许多不被期望的输出。
SIGIO:这个信号指出一个异步I/O事件。我们将在14.6.2节讨论。
在上表,我们把SIGIO的默认动作标为“终止”或者“忽略”。不幸的是,这个默认动作取决于系统。在系统V下,SIGIO与SIGPOLL相同,所以它的默认动作是终止这个进程。在BSD下,默认是忽略这个信号。
Linux2.4.22和Soloaris 9定义SIGIO为和SIGPOLL一样的值,所以默认行为是终止这个进程。在FreeBSD 5.2.1和Mac OS X 10.3上,默认是忽略这个信号。
SIGIOT:它指明一个实现定义的硬件错误。
名字IOT从PDP-11的“input/output TRAP”指令而来。系统V的早期版本从abort函数产生。现在SIGABRT被使用在这里。
在FreeBSD 5.2.1、Linux 2.4.22、Mac OS X 10.3和Solaris 9上,SIGIOT被定义为和SIGABRT一样的值。
SIGKILL:这个信号是不能被捕获或忽略的两个信号之一。它为系统管理员提供了一个杀死任何进程的必定成功的方式。
SLGLWP:这个信号被Solaris线程库内部使用,不被广泛使用。
SIGPIPE:如果我们向读者已经终止的管道写时,SIGPIPE被产生。我们在15.2节描述管道。这个信号当一个进程向不再连接的SOCK_STREAM类型的套接字写时也会产生。我们在16章描述套接字。
SIGPOLL:这个信号可以当一个特定事件发生在可查询的设备上时产生。我们在14.5.2节用poll函数讨论这个信号。SIGPOLL起源于SVR3,并松散地对应于BSD的SIGIO和SIGURG信号。在Linux和Solaris,SIGPOLL被定义为和SIGIO一样的值。
SIGPROF:这个信号当一个通过setittimer函数设置的轮廓间隔计时器过期时产生。
SIGPWR:这个信号是系统相关的。它的主要用在有一个不可中断的电源供给(uninterruptible power supply,UPS)的一个系统上。如果电源失败,UPS接管而软件通常会被通知。此时不必做什么事,因为系统仍在电池电源上运行。但是如果电池电量很少(如果电源被关闭了很长时间),那么软件会被再次通知。此时,它适应系统在15-30秒内关闭所有东西。这是SIGPWR应该被发送的时间。多数系统让被低电量条件通知到的进程发送一个SIGPWR信号给init进程,init处理关闭事宜。
Linux2.4.22和Solaris 9在inittab文件里有为这个目的的项:powerfail和powerwait(或powerokwait)。
在上表,我们标出SIGPWR的默认动作为“终止”或“忽略”。不幸的是,默认行为取决于系统。Linux上的默认行为是终止进程,在Solaris上,信号默认被忽略。
SIGQUIT:这个信号当我们输入终端退出键(通常是Control-backslash)时由终端驱动发出。这个信号被发送给前台进程组的所有进程。这个信号只终止前台进程组(和SIGINT一样),但也产生一个核心文件。
SIGSEGV:这个信号指出进程做了一个无效的内存引用。名字SEGV表示“segmentation violation”。
SIGSTKFLT:这个信号只被Linux定义。这个信号在Linux的最早版本里出现,意图为数学协处理器的栈错误所使用。这个信号不由内核产生,但是为向后兼容而被保留。
SIGSTOP:这个工作控制信号停止了一个fjkt。它像交互的停止信号(SIGTSTP),但SIGSTOP不能被捕获或忽略。
SIGSYS:这个信号是无效系统调用。然而,信号执行了一个内核本以为是一个系统调用的机器指定,但是这个指定的参数指出系统调用的类型是无效的。如果你创建一个使用新的系统调用的程序然后尝试在一个老版本的没有这个系统调用的操作系统上运行相同的机器码时会发生。
SIGTERM:这是由kill命令默认发出的终止信号。
SIGTHAW:这个信号只被Solaris定义,被用来通知需要在系统在挂起后恢复操作后采取特殊动作的进程。
SIGTRAP:这指明一个实现定义的硬件错误。
信号名从PDP-11的TRAP指定而来。实现经常使用这个信号把控制传递给一个调试器,当一个中断(breakpoint)指定被执行时。
SIGTSTP:这个交互的停止信号在我们输入终端暂停键时(经常是Control-Z)由终端驱动产生。这个信号被发送给前台进程组的所有进程。
不幸的是,术语“停止”有不同的含义。当讨论工作控制和信号时,我们说停止和继续工作。然而,终端驱动历史上使用这个术语来指热线服务Control-S和Control-Q字符来停止和启动终端输出。因此,终端驱动称这个产生交互停止信号的字符为暂停(suspend)字符,而不是停止字符。
SIGTTIN:这个信号当后台进程组里的进程尝试从它的控制终端读时由终端驱动产生。(加想9.8节关于这个话题的讨论。)作为特殊的情况,如果a、读进程正忽略或阻塞这个信号或b、读进程的进程组是孤立的,那么信号不会产生。相反,读操作返回一个错误,errno为EIO。
SIGTTOU:这个信号当一个后台进程组里的进程尝试向控制终端写时由终端驱动产生。不像刚刚提到的SIGTTIN信号,一个进程可以选择允许后台像控制终端写。我们将在18章描述如何改变这个选项。
如果后台写不被允许,那么像SIGTTIN信号,有两种特殊情况:如果a、写进程正忽略或阻塞信号或b、写进程的进程组是孤立的,那么信号不会被产生,相反,写操作会返回一个错误,errno为EIO。
不管后台写是否被允许,特定的终端操作(除了写)也可以产生SIGTTOU信号:tcsetattr、tcsendbreak、tcdrain、tcflush、tcflow和tcsetpgrp。我们在18章描述这些终端操作。
SIGURG:这个信号通知进程一个紧急条件发生。这个信号当不同频道的数据在一个网络连接上收到是可选地产生。
SIGUSR1:这是一个用户定义的信号,为应用程序的用户。
SIGUSR2:这是另一个用户定义的信号,和SIGUSR1相似,为应用程序的用户。
SIGVTALRM:这个信号当由setitimer设置的一个虚拟间隔计时器过期时产生。
SIGWAITING:这个信号被Sloaris线程库内部使用,外部不可用。
SIGWINCH:内核维护每个终端和伪终端关联的窗口尺寸。一个进程可以用ioctl函数得到或设置窗口尺寸,18.12节。如果一个进程使用ioctl设置窗口尺寸命令来改变了窗口尺寸,内核为前台进程组产生SIGWINCH信号。
SIGXCPU:SUS支持资源限制的概念,作为一个XSI扩展。7.11节。如果进程超过了它的软CPU时间限量,SIGXCPU信号被产生。
在上表,我们标出SIGXCPU的默认动作是“带有核心文件的终止”或“忽略”。不幸的是,默认行为取决于操作系统。Linux和Solaris支持带有核心文件终止的默认动作,而FreeBSD和Mac OS X支持忽略的默认动作。SUS要求默认动作为异常终止这个进程。核心文件是否产生取决于实现。
SIGXFSZ:信号如果进程超过它的软文件尺寸限量时产生。7.11节。
和SIGXCPU一样,SIGXFSZ的默认动作取决于操作系统。Linux和Solairs默认终止进程和创建一个核心文件。FreeBSD和Solaris默认忽略。SUS要求默认动作是异常终止这个进程。是否产生核心文件取决于实现
SIGXRES:信号只被Solaris定义。信号可选地用来通知进程已经超过预配置的资源值。Solaris资源控制机制是一个控制独立应用集之间共享资源的使用的通用设施。
10.3 signal函数
UNIX系统的信号特性的最简单的接口是signal函数。
#include
void (*signal(int signo, void (*func)(int)))(int)
成功返回前一个信号布署,错误返回SIG_ERR。
signal函数由ISO C定义,它不包含多进程、进程组、终端I/O、等等。因此,它对信号的定义是足够模糊的,对于UNIX系统的几乎无用的。
从UNIX系统V继承的实现支持signal函数,但是它支持老的不可靠信号的语义(我们将在10.4节讨论这些更老的语义。)这个函数提供了需要更老语义的应用的向后兼容。新的应用不应该使用这些不可靠的信号。
4.4BSD也提供了signal函数,但是它以sigaction函数的方式定义(我们在10.4节讨论),所以在4.4BSD下使用它提供了更新的可靠信号语义。FreeBSD和Mac OS X跟随了这种策略。
Solaris有系统V和BSD两者的根,但是它对于signal函数选择了跟随系统V的语义。
在Linux上,signal的语义可以跟随BSD或系统V,取决于C库的版本和你如何编译你的应用。
因为signal的语义在实现间不同,所以最好使用sigaction函数。当我们在10.14节描述sigactoin函数时,我们提供一个使用它的signal的实现。
signo参数只是上一节的信号名。func的值为a、常量SIG——IGN,b、常量SIG_DFL,或c、当信号发生时要调用的函数的地址。如果我们指定SIG_IGN,那么我们告诉系统忽略这个信号。(记住我们不能忽略SIGKILL和SIGSTOP)。当我们指定SIG_DFL时,我们设置信号相关的动作为默认值。当我们指定信号发生时所调用的函数的地址时,我们是尝试捕获这个信号。我们称这个函数为系统处理器或信号捕获函数。
signal函数的原型指定函数需要两个参数并返回一个无返回的函数的指针。signal函数的第一个参数,signo是一个整型。第二个参数是一个接受一个整型参数而无返回的函数的指针。signal返回的指针指向接受单个整型参数(信号号)而无返回的函数。当我们调用signal来建立信号处理器时,第二个参数是一个函数指针。signal的返回值是前一个信号处理器的指针。
许多系统用额外的,系统相关的参数来调用信号处理器。我们在10.14节讨论。
本节开头展示的令人疑惑的signal函数原型可以通过typedef变得更简单:typedef void Sigfunc(int);
然后原型变为:Sigfun *signal(int, Sigfunc *);
如果我们检查系统的头文件
/* Fake signal functions. */
#define SIG_ERR ((__sighandler_t) -1) /* Error return. */
#define SIG_DFL ((__sighandler_t) 0) /* Default action. */
#define SIG_IGN ((__sighandler_t) 1) /* Ignore signal. */
这些常量被用来代替“指向带一个整型参数而无返回的函数的指针”,signal的第二个参数和signal的返回值。这三个值不必是-1、0、1。它们必须是三个不可能是任何声明的函数的地址的值。多数UNIX系统使用了展示的值。
下面的代码展示了一个简单的信号处理器,它捕获两个用户定义的信号的任一个并打印信号号:
我们在后台运行这个程序,然后使用kill命令向它发送信号。注意在UNIX系统的术语“kill”是一个不恰当的名字。kill命令和kill函数只是向一个进程或进程组发送一个信号。信号是否终止进程取决于哪个信号被发送和进程是否捕获这个信号。运行结果为:
$ ./a.out &
[1] 3594
$ kill -USR1 3594
received SIGUSR1
$ kill -USR2 3594
received SIGUSR2
$ kill 3594
[1]+ 已终止 ./a.out
当我们发送SIGTERM信号时,进程被终止,因为它不捕获这个信号,而这个信号的默认动作是终止。
程序启动
当一个程序执行时,所有信号的状态都是默认或忽略。通常,所有信号被设置被它们的默认动作,除非调用exec的进程正忽略这个信号。确切地说,exec函数改变任何被被捕获的的信号的布署为它们的默认动作,而不改变其它信号的状态。(自然地,一个被一个调用exec的进程捕获的信号不能被新程序的相同函数捕获,因为调用者的信号处理函数很可能在新的被执行的程序文件里没有意义。)
一个确切的例子是一个交互外壳如何为后台进程处理中断和退出信号。对于一个不支持工作控制的外壳,当我们在后台执行一个进程时,比如cc main.c &,外壳自动把后台进程的中断和退出信号的布署设置为忽略。这样如果我们输入中断字符,它不会影响后台进程。如果不这么做而我们输入中断字符,那么它不会只终止前台进程,还有所有的后台进程。
许多捕获这两个信号的交互程序有如下的代码:
void sig_int(int), sig_quit(int);
if (signal(SIGINT, SIG_IGN) != SIG_IGN)
signal(SIGINT, sig_int);
if (signal(SIGQUIT, SIG_IGN) != SIG_IGN)
signal(SIGQUIT, sig_quit);
这样做,进程只捕获当前没有被忽略的信号。
这两个signal的调用也显示了signal函数的局限:我们不能不改变布署来决定当前的布署。我们在本章后面看到sigaction函数如何允许我们不改变它也能决定一个信号的布署。
进程创建
当一个进程调用fork时,子进程继承了父进程的信号布署。这里,既然子进程从父进程的内存映像的一份拷贝开始,那么信号处理函数的地址在子进程里是有意义的。
10.4 不可靠的信号(Unreliable Signals)
在UNIX系统的早期版本(比如版本7),信号是不可靠的。我们的意思是信号可能会丢失:一个信号可能发生而进程可能不知道。还有,一个进程不能很好地控制信号:一个进程可以捕获或忽略它。有时,我们想告诉进程来阻塞一个信号:不要忽略它,如果它发生时只是记住,稍后在我们准备好时再告诉我们。
在4.2BSD时的修改提供了可靠信号。一堆不同的改变在SVR3上发生了,以提供在系统V下的可靠信号。POSIX.1选择BSD模式来标准化。
这些早期版本的一个问题是每次信号发生时,信号的动作被重置为它的默认值。(在上一节的例子,我们只捕获每个信号一次来避免这个细节。)编程书上的描述这些更早系统的经典例子关心如何处理中断信号。被描述的代码通常看起来像:
int sig_int(); /* my signal handling funciton */
...
signal(SIGINT, sig_int); /* establish handler */
...
sig_int()
{
signal(SIGINT, sig_int); /* reestablish handler for next time */
... /* process the signal ... */
}
信号处理器被声明为返回整型的原因是早期系统不支持ISO C的void数据类型。
这个代码片段的问题是有一个时间间隙--在信号发生后,但在信号处理器里的signal调用前--这时中断信号可能再次发生。第二个信号可能导致默认的动作发生,也就是终止这个进程。这是多数时间正确工作的一个情况,导致我们以为它是正确的,然而它不是。
这些早期系统的另一个问题是当它不想信号发生时,进程不能关闭信号。进程可以做的所有事是忽略这个信号。有时我们想告诉系统“阻止后续信号发生,但是记住它们的发生”。演示这个缺点的经典的例子,由捕获一个信号并为进程设置标志以指明信号发生过的一段代码展示:
int sig_int_flag; /* set nonzero when siganl occurs */
main()
{
int sig_int(); /* my signal handling function */
...
signal(SIGINT, sig_int); /* establish handler */
...
while (sig_int_flag == 0)
pause(); /* go to sleep, waiting for signal */
...
}
sig_int()
{
signal(SIGINT, sig_int); /* reestablish handler for next time */
sig_int_flag = 1; /* set flag for main loop to examine */
}
这里,进程调用pause函数来使它睡眠,直到一个信号被捕获。当信号被捕获时,信号处理器只是把标志sig_int_flag设置为非0值。进程在信号处理器返回时自动被内核唤醒,注意标志是非0值,并做任何它所需要做的事。但是有一个时间间隙,事情可能出错。如果信号在sig_int_flag测试之后发生,但在pause调用之前,那么进程可能永远地睡过去了(假定这个信号不再产生)。这个信号的出现被丢失了。这是不对的代码多数时间工作的另一个例子。调试这种类型的问题会比较困难。
10.5 中断系统调用(Interrupted System Calls)
早期UNIX系统的一个特性是如果一个进程在被一个“慢的”系统调用阻塞时捕获了一个信号,那么系统调用会被中断。这个系统调用返回一个错误而errno被设为EINTR。这是基于下面的假定才这么做的:既然一个信号发生了而进程捕获了它,那么应该唤醒这个系统调用的事情很可能已经发生了。
这里,我们必须区分一个系统调用和一个函数。当一个信号被捕获时是内核的系统调用被中断了。
为了支持这个特性,系统调用被分为两种:“慢”系统调用和其它的。慢的系统调用是那么可以永远阻塞的。这种类型的系统调用包括:
1、读操作可以永远阻塞住调用者,如果特定文件类型的数据没有出现(管道、终端设备、网络设备);
2、写操作可以永远阻塞调用者,如果数据不能被这些相同的文件类型立即接受;
3、打开操作阻塞,直到到在特定文件类型上的一些条件发生时(比如一个终端设备的打开等待一个附加的猫回答电话。)
4、pause函数(定义为令调用进程入睡直到一个信号被捕获)和wait函数
5、特定的ioctl操作;
6、一些进程间通信函数(第15章)。
这些慢系统调用的值得注意的例外是任何与硬盘I/O相关的东西。尽管磁盘文件的一个读或写可暂阻塞调用者(当磁盘驱动排列请求而后请求被执行时),除非一个硬件错误发生,I/O操作总是返回并快速地非阻塞调用者。
例如,由中断的系统调用处理的一种情况是,当一个进程从一个终端设备开始读而终端用户从终端走开了一段很长的时间。在这个例子里,进程可能被阻塞数小时或数天,而且除非系统关闭,它仍然停留。
POSIX.1关于中断读和写的语义在标准的2001版本改变了。早期版本给了实现一个选择,如何处理已经处理部分数据的读和写。如果读收到并传送数据到应用的缓冲,但是还没有收到应用请求的所有数据然后中断,操作系统可以令系统调用失败,把errno设为EINTR,或允许系统调用成功,返回已经收到的部分数据。相似的,如果写在传输一些数据给应用的缓冲后中断,操作系统可以令系统调用失败,把errno设为EINTR,或允许系统调用成功,返回已经写出的部分数据。历史上,从系统V继承的实现令系统调用失败,而继承BSD的实现返回部分成功。在POSIX.1标准的2001版本,BSD风格的语义被要求。
中断的系统调用的问题是,我们现在必须处理显式返回的错误。典型的代码(假定一个读操作,我们想即使它中断也重新开始这个读)会是:
again:
if ((n = read(fd, buf, BUFFSIZE)) < 0) {
if (errno == EINTR)
goto again; /* just an interrupted system call */
/* handle other errors */
}
为了避免应用不得不处理中断的系统调用,4.2BSD引进了特定中断的系统调用的自动重启。自动重启的系统调用是ioctl、read、readv、write、writev、wait和waitpid。正如我们已经提过的,这函数中的前五个只在一个慢设备上操作时才会被一个信号中断;wait和waitpid总是当一个信号被捕获时会被中断。既然这对一些不想在中断后重启这个操作的应用来说会导致问题,于是4.3BSD允许进程为每个信号禁止这个特性。
POSIX.1允许实现重启系统调用,但不是必需的。SUS定义SA_RESTART标志作为sigaction的一个XSI扩展来允许应用请求重启中断的系统调用。
系统V默认决不重启系统调用。另一方面,BSD重启被信号中断的系统调用。默认下,FreeBSD5.2.1、Linux2.4.22、MacOS X 10.3重启被信号中断的系统调用。然而,在Solaris 9上,默认返回一个错误(EINTR)。
4.2引进自动重启特性的原因是有时我们不知道输入或输出设备是一个慢设备。如果我们写的程序可以交互地使用,那么它可能在读写一个慢设备,因为中断属于这种类型。如果我们在这个程序捕获信号,且系统不提供重启的能力,那么,我们必须为返回的中断错误测试每个读写,并处理它们。
下表总结了信号函数和各种实现提供的它们的语义。
函数 | 系统 | 信号处理器保持被安装状态 | 阻塞信号的能力 | 自动重启中断的系统调用 |
---|---|---|---|---|
signal | ISOC, POSIX.1 | 无规定 | 无规定 | 无规定 |
V7,SVR2,SVR3,SVR4,Solaris | 从不 | |||
4.2BSD | * | * | 总是 | |
4.3BSD,4.4BSD,FreeBSD,Linux,Mas OS X | * | * | 默认 | |
sigset | XSI | * | * | 无规定 |
SVR3,SVR4,Linux,Solaris | * | * | 决不 | |
sigvec | 4.2BSD | * | * | 总是 |
4.3BSD,4.4BSD,FreeBSD,Mac OS X | * | * | 默认 | |
sigaction | POSIX.1 | * | * | 无规定 |
XSI,4.4BSD,SVR4,FreeBSD,MacOS X,Linux, Solaris | * | * | 可选 |
我们不讨论较老的sigset和sigvec函数。它们的使用已经被sigaction函数替代了。它们只是为了完整性被包含。相对的,一些实现促使signal作为sigaction的简化的接口。
要知道其它厂商的UNIX系统可以以和上表显示的不同的值。例如,SunOS 4.1.2下的sigaction默认重启一个中断的系统调用。
在本章后面,我们将提供我们自己版本的自动尝试重启中断的(除了为SIGALRM信号的)系统调用的signal函数。随后,我们将提供另一个函数,signal_intr,它尝试决不重启。
我们在14.5节里更多讨论中断的系统调用,以及select和poll函数。
10.6 再入函数(Reentrant Functions)
当一个被捕获的信号被一个进程处理时,进程执行的普通的指令序列会被一个信号处理器暂时地中断。然后进程继续执行,但是信号处理器里的指令现在被执行。如果信号处理器返回(而不是调用exit或longjmp),那么当信号被捕获时进程正在执行的普通指令序列继续执行。(这和当一个硬件中断发生是所发生的事情相似。)但是在信号处理器里,我们并不知道当信号被捕获时进程正在执行哪里的代码。如果进程正使用malloc在它的堆上分配额外的内存,并执行到一半,而我们在信号处理器里调用malloc会发生什么呢?或者,如果进程正调用一个把结果存储在一个静态区域里的函数到一半,比如getpwnam,而我们在信号处理器里调用相同的函数,又会发生什么呢?在malloc的例子里,进程可能会遭到严重破坏,因为malloc通常维护它所有分配过的区域的链表,而它可能正在改变这个链表并执行到一半。在getpwnam的例子里,返回给普通调用者的信息可能被返回给信号处理器的信息覆盖。
SUS规定了必须保证是可以再入的函数。下表列出了这些再入函数:
accept | fchmod | lseek | sendto | stat |
access | fchown | lstat | setgid | symlink |
aio_error | fcntl | mkdir | setpgid | sysconf |
aio_return | fdatasync | mkfifo | setsid | tcdrain |
aio_suspend | fork | open | setsockopt | tcflow |
alarm | fpathconf | pathconf | setuid | tcflush |
bind | fstat | pause | shutdown | tcgetattr |
cfgetispeed | fsync | pipe | sigaction | tcgetpgrp |
cfgetospeed | ftruncate | poll | sigaddset | tcsendbreak |
cfsetispeed | getegid | posix_trace_event | sigdelset | tcsetattr |
cfsetospeed | geteuid | pselect | sigemptyset | tcsetpgrp |
chdir | getgid | raise | sigfillset | time |
chmod | getgroups | read | sigismenber | timer_getoverrun |
chown | getpeername | readlink | signal | timer_gettime |
clock_gettime | getpgrp | recv | sigpause | timer_settime |
close | getpid | recvfrom | sigpending | times |
connect | getppid | recvmsg | sigprocmask | umask |
creat | getsockname | rename | sigqueue | uname |
dup | getsockopt | rmdir | sigset | unlink |
dup2 | getuid | select | sigsuspend | utime |
execle | kill | sem_post | sleep | wait |
execve | link | send | socket | waitpid |
_Exit & _exit | listen | sendmsg | socketpair | write |
要知道即使我们在一个信号处理器里调用上表列出的一个函数,每个线程仍然只有一个errno变量,而我们可能修改这的值。考虑一个在设置好errno的main之后调用的信号处理器。例如,如果信号处理器调用read,这个调用可以改变errno的值,从而清扫掉之间在main里存储的值。因此,作为一个通用规则,当在一个信号处理器里调用上表列出的函数时,我们应该保存并恢复errno。(要知道一个普遍被捕获的信号是SIGCHLD,而它的信号处理器通常调用某个wait函数。所有的wait函数都可以改变errno。)
注意longjmp(7.10节)和siglongjmp(10.15节)不在上表中,因为当主例程正在以一种不再入的方式更新的一个数据结构时,信号可能已经发生了。这个数据结构可能会被更新一半,如果我们调用siglongjmp而不是从信号处理器里返回。如果当捕获的信号导致sigsetjmp被执行时,一个应用正要做一些诸如更新全局数据结构的事情,就像我们这里描述的,那么它必须在更新数据结构时阻塞这个信号。
下面的代码在一个每秒种调用一次的信号处理器里调用一个不可再入的函数getpwnam。我们在10.10节讨论alarm函数。这里我们使用它在每秒产生一个SIGALRM信号:
10.7 SIGCLD语义
两个不停产生混淆的信号的SIGCLD和SIGCHLD。首先,SIGCLD(没有H)是系统V的名字,这个信号有着和名为SIGCHLD的BSD信号不同的语义。POSIX.1信号也被命名为SIGCHLD。
BSD的SIGCHLD的语义是普通的,因为它的主义和其它所有信号的相似。当信号发生时,一个子进程的状态已经改变了,而我们必须调用某个wait函数来决定发生了什么事。
然而,系统V,传统地又与其它信号不同的方式来处理SIGCLD信号。基于SVR4的系统继续这个可置疑的传统(也说是说,兼容性的限制),如果我们用signal或sigset(更老的,与SVR3兼容的设置一个信号的布署的函数)来设置它的布署。SIGCLD的更老的处理由下面组成:
1、如果进程指定设置它的布署为SIG_IGN,那么调用进程的子进程将不会产生僵尸进程。注意它和默认动作(SIG_DFL)不同,而是忽略这个信号。相反,在终止时,子进程的状态被舍弃。如果它接着调用某个wait函数,那么调用进程会阻塞,直到它所有的子进程终止,然后wait返回-1,errno被设为ECHILD。(这个信号的默认布署是忽略,但是这个默认动作不会导致上述语义发生。相反,我们必须确切设置它的布署为SIG_IGN)。
POSIX.1不指定当SIGCHLD被忽略时会发生什么,所以这个行为被允许。SUS包含了一个XSI扩展,指定这个行为要被SIGCHLD支持。
4.4BSD总是产生僵尸,如果SIGCHLD被忽略。如果我们想要阻止僵尸,我们必须等待我们的子进程。FreeBSD 5.2.1和4.4BSD一样工作。然而,Mac OS X 10.3当SIGCHLD被忽略时不创建僵尸。
在SVR4上,如果signal或sigset被调用并设置SIGCHLD的布署为忽略,那么僵尸不会被产生。Solaris 9和Linux 2.4.22遵循SVR4的行为。
使用sigaction,我们可以设置SA_NOCLDWAIT标志来避免僵尸。这个动作被所有四个平台支持。
2、如果我们设置SIGCLD为被捕获,那么内核立即检查是否有任何子进程准备好被等待,如果有,调用SIGCLD处理器。
第二项改变了我们必须为这个信号写一个信号处理器的方式,正如下面的例子证明的。回想下10.4节,一个处理器的入口里要做的第一件事是再次调用signal,来重新建立这个处理器。(这个动作是使时间间隙最小化,当信号被重置为它的默认值并可能丢失时。)我们展示下面的代码:
这个程序在一些平台上不能工作。如果我们在一个传统的系统V平台上编译和运行它,比如OpenServer 5或UnixWare 7,那么输出是连续的SIGCLD received的字符行。最终,进程用尽栈空间并异常退出。
FreeBSD 5.2.1和Mac OS X 10.3没有这个问题,因为基于BSD的系统一般不支持历史上的系统V的SIGCLD的语义。Linux2.4.22也不会有这个问题,因为当一个进程布置好要捕获SIGCHLD而子进程准备好被等待了的时候,它不会调用SIGCHLD的信号处理器,即使SIGCLD和SIGCHLD被定义为相同的值。另一方面,Solaris 9,在这个情况下确实会调用信号处理器,但是在内核包含额外的代码来避免这个问题。
尽管本文的四个平台都解决了这个问题,但是要知道仍然有平台(比如UnixWare)没有解决它。
这个程序的问题是在信号处理器的开头的signal调用导致了前面讨论的第二项--内核查看一个子进程是否需要被等待(因为我们正在处理一个SIGCLD信号,所以是有),所以它产生另一个信号处理器的调用。这个信号处理器调用signal,于是整个处理又重新开始。
为了解决这个问题,我们必须把signal的调用移到wait调用之后。通过这样做,我们在得到子进程的终止状态后调用signal,仅当其它子进程终止时内核会再次产生信号。
POSIX.1指出当我们建立一个SIGCHLD的信号处理器而已经有一个我们还未等待的已终止的子进程时,信号是否被产生是没有规定的。这允许前面描述的行为。但是因为当信号发生时,POSIX.1没有把一个信号的布署重置为默认值,(假定我们使用POSIX.1的sigaction函数来设置它的布署,)所以我们不需要在这个处理器里建立一个信号处理器。
要知道在你的实现里SIGCHLD的语义。特别要知道一些定义SIGCHLD为SIGCLD或反过来的系统。改变这个名字可能允许你编译在另一个系统上之写的程序,但是如果程序依赖于其它语义,它可能不能工作。
本文讨论的四个平台上,SIGCLD等价于SIGCHLD。
10.8 可靠信号术语和语义(Reliable-Signal Terminology and Semantics)
我们需要一些贯穿我们信号讨论的术语。首先,当导致信号的事件发生时,为一个进程产生(或发送给一个进程)信号。这个事件可以是硬件异常(例如,被0除),一个软件情况(比如,一个alarm计时器过期),一个终端产生的信号,或一个kill函数的调用。当信号产生时,内核通常在进程表里以一些形式设置一个标志。
我们说一个信号被分发给一个进程,当进程的动作被采纳时。在一个信号产生和它被分发之间,这个信号称为待定的(pending)。
一个进程有阻塞一个信号分发的选项。如果一个被阻塞的信号为一个进程产生,而如果这个信号的动作是默认动作或捕获,那么这个信号为进程保持待定,直到进程a、反阻塞这个信号,或b、改变动作为忽略这个信号。系统决定当信号分发时,而不是当它被产生时,如何处理一个阻塞的信号。这允许进程在这个信号分发前为它改变改变动作。sigpending函数(10.13节)可以被一个进程调用来决定哪些函数被阻塞和待定。
如果一个阻塞的信号在进程反阻塞它前被产生了不只一次会发生什么呢?POSIX.1允许系统分发这个信号一次或者多次。如果系统分发这个信号多于一次,我们称这个信号被排队。然而,多数UNIX系统,不会排队信号,除非它们支持POSIX.1的实现扩展。相反,UNIX内核简单地分发这个信号一次。
SVR2的手册页声明SIGCLD信号被排队,当进程正在执行它的SIGCLD信号处理器时。尽管这可能在概念层次上是真的,但是真实实现却不同。相同,这个信号被内核重新产生,如我们在10.7节描述的那样。在SVR3,手册被修改为指明当进程正在执行SIGCLD的信号处理器时SIGCLD信号被忽略。SVR4删除了任何关于当一个进程正在执行SIGCLD的信号处理器时发生什么的内容。
SVR4的在AT&T[1990e]的sigaction手册页声明SA_SIGINFO标志导致信号被可靠排队。这是错的。显然,这个特性在内核里是部分实现的,但是不在SVR4里启用。很奇怪地,SVID没有做出可靠排队的声明。
如果多于一个的信号准备分发给一个进程会发生什么呢?POSIX.1没有规定信号分发给进程的顺序。然而,POSIX.1的Rationale确实建议,与进程当前状态相关的信号要在其它信号之前分发。(SIGSEGV是这样一个信号)。
每个进程有一个信号掩码,定义了当前阻塞的信号集。我们可以把这个掩码视为每个位对应每个可能的信号。如果对于给定信号的位是打开的,那么这个信号当前被阻塞。一个进程可以检查和改变当前的信号掩码,通过调用sigprocmask,10.12节。
既然信号的数量可能超过一个整型的位数,那POSIX.1定义了一个数据类型,称为sigset_t,来存储一个信号集。例如,信号掩码被存储在这些信号集的某一个里。我们在10.11节描述操作信号集的5个函数。
10.9 kill和raise函数
kill函数发送一个信号给一个进程或一个进程组。raise函数允许一个进程给它自己发送一个信号。
raise最开始被ISO C定义。POSIX.1包含了它来与ISO C标准靠齐。但是POSIX.1扩展了raise的规范来处理线程(我们在12.8节讨论线程如何与信号交互。)因为ISO C不处理多进程,它不能定义一个需要一个进程ID参数的函数,比如kill。
#include
int kill(pid_t pid, int signo);
int raise(int signo);
两者成功都返回0,错误返回-1
调用raise(signo)等价于kill(getpid(), signo);
kill的pid参数有四种情况:
1、pid > 0,信号被发送给进程ID为pid的进程;
2、pid == 0,信号被发送给与发送者同组、且发送者对其有发送信号的权限的所有进程。注意术语“所有进程”不包括实现定义的系统进程集。对于多数UNIX系统,这个系统进程集包括内核进程和init(pid 1);
3、pid < 0,信号被发送给ID为pid的绝对值的进程组里的,且发送者对其有发送信号的权限的所有进程。再次,所有进程集不包括系统进程,如上所述。
4、pid == -1,信号被发送给系统上发送者对其有发送信号的权限的所有的进程。和前面一样,不包含特定的系统进程。
正如我们已经提到的,一个进程需要权限来发送信号给另一个进程。超级用户可以发送一个信号给任何进程。对于其它用户,基本规则是发送者的真实或有效用户ID必须等于接收者的真实或有效的用户ID。如果实现支持_POSIX_SAVED_IDS(如POSIX.1现在要求的),那么接收者的设置用户ID被核查,而不是它的有效用户ID。权限测试还有一个特殊的例子:如果被发送的信号是SIGCONT,那么一个进程可以把它发送给相同会话里的任何其它进程。
POSIX.1定义信号号0作为空信号。如果signo参数为0,那么kill会执行普通的错误检查,但是没有信号被发送。这通常用来检查一个特定进程是否仍存在。如果我们给进程发送一个空信号而它不存在,那么kill返回-1而errno被设为ESRCH。但是要小心,UNIX系统在一些时间后会回收进程ID,所以一个给定进程ID的进程的存在性不表示它就是你认为的那个进程。
还要理解进程存在性的测试不是原子的。在kill返回回答给调用者的时候,被查询的进程可能已经退出了,所以这个回答的价值有限。
如果kill调用导致信号为调用进程产生且信号没有被阻塞,那么signo或其它待定的非阻塞的信号被发送给进程,在kill返回之前。(对于线程有其它的情况,12.8节。)
10.10 alarm和pause函数
alarm函数允许我们设置一个计时器,它将在一个指定的时间到期。当这个计时器到期时,SIGALRM信号被产生。如果我们忽略或不捕获这个信号,它的默认行为是终止这个进程。
#include
unsigned int alarm(unsigned int seconds);
返回0或上次设置的警报到现在的时间。
seconds值是将来这个信号应该产生时的时钟秒数。小心当时间发生时,信号被内核产生,但是在进程得到处理信号的控制前可以有额外的时间,因为处理器的调度延迟。
早期UNIX系统实现警告信号也可能早最多1秒被发送。POSIX.1不允许这个。
每个进程只有一个这样的警报。如果当我们调用alarm时一个之前为进程注册的闹钟还没有过期,那个闹钟还剩的秒数被返回。前一个注册的闹钟被新的值替代。
如果前一个注册的闹钟没有过期而seconds的值为0,那么前一个闹钟被取消。然后如果进程想要终止,它可以在终止前执行任何所需的清理。如果我们要捕获SIGALRM,那要小心在调用alarm之前安装它的信号处理器。如果在安装信号处理器前,我们先调用alarm并被发送一个SIGALRM,我们的进程会终止。
pause函数暂停调用进程,直到一个信号被捕获。
#include
int pause(void);
返回-1,errno设置为EINTR。
pause返回的唯一时机是一个信号处理器被执行并返回。在这种情况下,pause返回-1,errno设置为EINTR。
通过使用alarm和pause,我们可以把进程催眠一个指定的时间。下面的sleep1函数做了这件事(但是它有问题,稍后我们会看到):
sleep的早期实现看起来像我们的程序,但是修正了描述的第一个和第二个问题。有两种方法可以修正问题3。第一个使用setjmp,我们在下面的例子里展示。另一个是使用sigprocmask和sigsuspend,在10.19节讨论。
下面的代码使用setjmp和longjmp(10.7节)实现sleep,避免前一个例子描述的问题3。一个称为sleep2的另一个简单版本。(为了减少代码,我们不处理问题1和问题2。):
然而,涉及其它信号交互的sleep2有另一个诡异的问题。如果SIGALRM中断了其它一些信号处理器,当我们调用longjmp时,我们会中止其它的信号处理器。下面的代码展示了这个场景:
sleep1和sleep2这两个例子的目的,是展示天真地处理信号时的问题。后面几节将展示解决所有这些问题的方法,所以我们可以可靠地处理信号,而不影响其它代码。
alarm函数的一个普遍用法,除了实现sleep,还有为一个可以阻塞的操作加上时间上限。例如,如果我们在一个可以阻塞的设备上有一个读操作(一个“慢”设备,10.5节),我们可能想这个读操作在一段时间后到时。下面的代码做了这件事,从标准输入里读取一行并写到标准输出上:
这段代码在UNIX应用上很普遍,但这个程序有两个问题。
1、程序有和sleep1一样的瑕疵:第一次调用alarm和read的调用之间有竞争条件。如果内核在这两个函数之间阻塞了比闹钟期限更长的时间,read可能会永远阻塞。多数这种类型的操作使用一个长的闹钟期,比如一分钟或更多,使它不太可能发生。尽管如此,它是一个竞争条件。
2、如果系统调用自动重启,当SIGALRM信号处理器返回时read不被中断。这种情况下,计时不做任何事。
这里,我们明确地想要一个系统调用被中断。POSIX.1没有给我们一个可移植的方法来做这件事,但是SUS的XSI扩展可以。我们将在10.4节讨论更多。
让我们重新用longjmp实现前一个例子,这种方法,我们不用担心一个慢的系统调用是否被中断:
10.11 信号集
我们需要一个数据类型来表示多个信号--一个信号集。我们在函数比如sigprocmask(下节)里使用它来告诉内核不允许这个集里的任何信号发生。正如我们之前提到的,不同信号的数量可以超过整型的位数,所以一般来说,我们不能用一个整型来以为个信号一位的方式表示。POISX.1定义了数据类型sigset_t来包含一个信号集,下面的5个函数用来操作信号集。
#include
int sigemptyset(sigset_t *set);
int sigfillset(sigset_t *set);
int sigaddset(sigset_t *set, int signo);
int sigdelset(sigset_t *set, int signo);
成功返回0,错误返回-1.
int sigismember(const sigset_t *set, int signo);
真返回1,假返回0,错误返回-1.
函数sigemptyset初始化set指向的信号集,以便信号被排除。函数sigfillset初始化信号集以便所有的信号被包含。所有的应用都必须为每个信号集调用一次sigemptyset或sigfillset,在使用信号集之前,因为我们不假定C对外部和静态变量的0的初始化对应于一个给定系统上的信号集的实现。
一旦