5.9 二进制I/O

5.6节和5.7节中的函数以一次一个字符或一次一行的方式进行操作。如果进行二进制I/O操作,那么我们更愿意一次读或写一个完整的结构。如果使用getc或putc读、写一个结构,那么必须循环通过整个结构,每次循环处理一个字节,一次读或写一个字节,这会非常麻烦而且费时。如果使用fputs和fgets,那么因为fputs在遇到null字节时就停止,而在结构中可能含有null字节,所以不能使用它实现读结构的要求。相类似,如果输入数据中包含有null字节或换行符,则fgets也不能正确工作。因此,提供了下列两个函数以执行二进制I/O操作。

#include <stdio.h>

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,nobj应是该数组中的元素个数。

fread和fwrite返回读或写的对象数。对于读,如果出错或到达文件尾端,则此数字可以少于nobj。在这种情况,应调用ferror或feof以判断究竟是那一种情况。对于写,如果返回值少于所要求的nobj,则出错。

使用二进制I/O的基本问题是,它只能用于读在同一系统上已写的数据。多年之前,这并无问题(那时,所有UNIX系统都运行于PDP-11上),而现在,很多异构系统通过网络相互连接起来,而且,这种情况已经非常普遍。常常有这种情形,在一个系统上写的数据,要在另一个系统上进行处理。在这种环境下,这两个函数可能就不能正常工作,其原因是:

(1)在一个结构中,同一成员的偏移量可能随编译程序和系统的不同而不同(由于不同的对齐要求)。确实,某些编译程序有一个选项,选择它的不同值,或者使结构中的各成员紧密包装(这可以节省存储空间,而运行性能则可能有所下降);或者准确对齐(以便在运行时易于存取结构中的各成员)。这意味着即使在同一个系统上,一个结构的二进制存放方式也可能因编译程序选项的不同而不同。

(2)用来存储多字节整数和浮点值的二进制格式在不同的系统结构间也可能不同。

在第 16 章讨论套接字时,我们将涉及某些相关问题。在不同系统之间交换二进制数据的实际解决方法是使用互认的规范格式。关于网络协议使用的交换二进制数据的某些技术,请参阅Rogo[1993]的8.2节或者Stevens、Fenner和Rudoff[2004]的5.18节。

在8.14 节中,我们将再回到fread 函数,那时将用它读一个二进制结构——UNIX 的进程会计记录。

5.10 定位流

有3种方法定位标准I/O流。

(1)ftell 和fseek 函数。这两个函数自 V7 以来就存在了,但是它们都假定文件的位置可以存放在一个长整型中。

(2)ftello和fseeko函数。Single UNIX Specification引入了这两个函数,使文件偏移量可以不必一定使用长整型。它们使用off_t数据类型代替了长整型。

(3)fgetpos和fsetpos函数。这两个函数是由ISO C引入的。它们使用一个抽象数据类型fpos_t记录文件的位置。这种数据类型可以根据需要定义为一个足够大的数,用以记录文件位置。

需要移植到非UNIX系统上运行的应用程序应当使用fgetpos和fsetpos。

#include <stdio.h>

long ftell(FILE *fp);

返回值:若成功,返回当前文件位置指示;若出错,返回-1L

int fseek(FILE *fp, long offset, int whence);

返回值:若成功,返回0;若出错,返回−1

void rewind(FILE *fp);

对于一个二进制文件,其文件位置指示器是从文件起始位置开始度量,并以字节为度量单位的。ftell用于二进制文件时,其返回值就是这种字节位置。为了用fseek定位一个二进制文件,必须指定一个字节offset,以及解释这种偏移量的方式。whence的值与3.6节中lseek函数的相同:SEEK_SET表示从文件的起始位置开始,SEEK_CUR表示从当前文件位置开始,SEEK_END表示从文件的尾端开始。ISO C并不要求一个实现对二进制文件支持SEEK_END规格说明,其原因是某些系统要求二进制文件的长度是某个幻数的整数倍,结尾非实际内容部分则填充为 0。但是在UNIX中,对于二进制文件,则是支持SEEK_END的。

对于文本文件,它们的文件当前位置可能不以简单的字节偏移量来度量。这主要也是在非UNIX系统中,它们可能以不同的格式存放文本文件。为了定位一个文本文件,whence一定要是SEEK_SET,而且offset只能有两种值:0(后退到文件的起始位置),或是对该文件的ftell所返回的值。使用rewind函数也可将一个流设置到文件的起始位置。

除了偏移量的类型是off_t而非long以外,ftello函数与ftell相同,fseeko函数与fseek相同。

#include <stdio.h>

off_t ftello(FILE *fp);

返回值:若成功,返回当前文件位置;若出错,返回(off_t)-1

int fseeko(FILE *fp, off_t offset, int whence);

返回值:若成功,返回0;若出错,返回−1

回忆3.6节中对off_t数据类型的讨论。实现可将off_t类型定义为长于32位。

正如我们已提及的,fgetpos和fsetpos两个函数是ISO C标准引入的。

#include <stdio.h>

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

1.格式化输出

格式化输出是由5个printf函数来处理的。

#include <stdio.h>

int printf(const char *restrict format, ...);

int fprintf(FILE *restrict fp, const char *restrict format, ...);

int dprintf(int fd, const char *restrict format, ...);

3个函数返回值:若成功,返回输出字符数;若输出出错,返回负值

int sprintf(char *restrict buf, const char *restrict format, ...);

返回值:若成功,返回存入数组的字符数;若编码出错,返回负值

int snprintf(char *restrict buf, size_t n, const char *restrict format, ...);

返回值:若缓冲区足够大,返回将要存入数组的字符数;若编码出错,返回负值

printf将格式化数据写到标准输出,fprintf写至指定的流,dprintf写至指定的文件描述符,sprintf 将格式化的字符送入数组buf中。sprintf 在该数组的尾端自动加一个 null字节,但该字符不包括在返回值中。

注意,sprintf函数可能会造成由buf指向的缓冲区的溢出。调用者有责任确保该缓冲区足够大。因为缓冲区溢出会造成程序不稳定甚至安全隐患,为了解决这种缓冲区溢出问题,引入了snprintf函数。在该函数中,缓冲区长度是一个显式参数,超过缓冲区尾端写的所有字符都被丢弃。如果缓冲区足够大,snprintf函数就会返回写入缓冲区的字符数。与sprintf相同,该返回值不包括结尾的null字节。若snprintf函数返回小于缓冲区长度n的正值,那么没有截断输出。若发生了一个编码的错误,snprintf返回负值。

虽然 dprintf 不处理文件指针,但我们仍然把它包括在处理格式化输出的函数中。注意,使用dprintf不需要调用fdopen将文件描述符转换为文件指针(fprintf需要)。

格式说明控制其余参数如何编写,以后又如何显示。每个参数按照转换说明编写,转换说明以百分号%开始,除转换说明外,格式字符串中的其他字符将按原样,不经任何修改被复制输出。一个转换说明有4个可选择的部分,下面将它们都示于方括号中:

%[flags][fldwidth][precision][lenmodifier]convtype

图5-7总结了各种标志。

图5-7 转换说明中的标志部分

fldwidth说明最小字段宽度。转换后参数字符数若小于宽度,则多余字符位置用空格填充。字段宽度是一个非负十进制数,或是一个星号(*)。

precision 说明整型转换后最少输出数字位数、浮点数转换后小数点后的最少位数、字符串转换后最大字节数。精度是一个点(.),其后跟随一个可选的非负十进制数或一个星号(*)。

宽度和精度字段两者皆可为*。此时,一个整型参数指定宽度或精度的值。该整型参数正好位于被转换的参数之前。

lenmodifier说明参数长度。其可能的值示于图5-8中。

图5-8 转换说明中的长度修饰符

convtype不是可选的。它控制如何解释参数。图5-9中列出了各种转换类型字符。

根据常规的转换说明,转换是按照它们出现在 format参数之后的顺序应用于参数的。一种替代的转换说明语法也允许显式地用%n$序列来表示第n个参数的形式来命名参数。注意,这两种语法不能在同一格式说明中混用。在替代的语法中,参数从 1 开始计数。如果参数既没有提供字段宽度和也没有提供精度,通配符星号的语法就更改为*m$,m指明提供值的参数的位置。

图5-9 转换说明中的转换类型

下列5种printf族的变体类似于上面的5种,但是可变参数表(...)替换成了arg。

#include <stdarg.h>

#include <stdio.h>

int vprintf(const char *restrict format, va_list arg);

int vfprintf(FILE *restrict fp, const char *restrict format, va_list arg);

int vdprintf(int fd, const char *restrict format, va_list arg);

所有3个函数返回值:若成功,返回输出字符数;若输出出错,返回负值

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);

函数返回值:若缓冲区足够大,返回存入数组的字符数;若编码出错,返回负值

在附录B的出错处理例程中,将使用vsnprintf函数。

关于ISO C标准中有关可变长度参数表的详细说明请参阅Kernighan和Ritchie[1988]的7.3节。应当了解的是,由ISO C提供的可变长度参数表例程(<stdarg.h>头文件和相关的例程)与由较早版本UNIX提供的<varargs.h>例程是不同的。

2.格式化输入

执行格式化输入处理的是3个scanf函数。

#include <stdio.h>

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, ...);

3个函数返回值:赋值的输入项数;若输入出错或在任一转换前已到达文件尾端,返回EOF

scanf族用于分析输入字符串,并将字符序列转换成指定类型的变量。在格式之后的各参数包含了变量的地址,用转换结果对这些变量赋值。

格式说明控制如何转换参数,以便对它们赋值。转换说明以%字符开始。除转换说明和空白字符外,格式字符串中的其他字符必须与输入匹配。若有一个字符不匹配,则停止后续处理,不再读输入的其余部分。

一个转换说明有3个可选择的部分,下面将它们都示于方括号中:

%[*][fldwidth][m][lenmodifier]convtype

可选择的星号(*)用于抑制转换。按照转换说明的其余部分对输入进行转换,但转换结果并不存放在参数中。

fldwidth说明最大宽度(即最大字符数)。lenmodifier说明要用转换结果赋值的参数大小。由printf函数族支持的长度修饰符同样得到scanf族函数的支持(见图5-8中的长度修饰符表)。

convtype字段类似于printf族的转换类型字段,但两者之间还有些差别。一个差别是,作为一种选项,输入中带符号的可赋予无符号类型。例如,输入流中的-1可被转换成4 294 967 295赋予无符号整型变量。图5-10总结了scanf族函数支持的转换类型。

在字段宽度和长度修饰符之间的可选项m是赋值分配符。它可以用于%c、%s以及%[转换符,迫使内存缓冲区分配空间以接纳转换字符串。在这种情况下,相关的参数必须是指针地址,分配的缓冲区地址必须复制给该指针。如果调用成功,该缓冲区不再使用时,由调用者负责通过调用free函数来释放该缓冲区。

scanf函数族同样支持另外一种转换说明,允许显式地命名参数:序列%n$代表了第n个参数。与printf函数族相同,同一编号的参数在格式串中可引用多次。但Single UNIX Specification指出,这种情况在scanf函数族中如何作用还未定义。

图5-10 转换说明中的转换类型

与printf族相同,scanf族也使用由<stdarg.h>说明的可变长度参数表。

#include <stdarg.h>

#include <stdio.h>

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);

3个函数返回值:指定的输入项目数;若输入出错或在任一转换前文件结束,返回EOF

关于scanf函数族的详细情况,请参阅UNIX系统手册。

5.12 实现细节

正如前述,在UNIX中,标准I/O库最终都要调用第3章中说明的I/O例程。每个标准I/O流都有一个与其相关联的文件描述符,可以对一个流调用fileno函数以获得其描述符。

注意,fileno不是ISO C标准部分,而是POSIX.1支持的扩展。

#include <stdio.h>

int fileno(FILE *fp);

返回值:与该流相关联的文件描述符

如果要调用dup或fcntl等函数,则需要此函数。

为了了解你所使用的系统中标准 I/O 库的实现,最好从头文件<stdio.h>开始。从中可以看到FILE对象是如何定义的、每个流标志的定义以及定义为宏的各个标准I/O例程(如getc)。Kernighan和Ritchie[1988]中的8.5节含有一个示例实现,从中可以看到很多UNIX实现的基本样式。Plauger[1992]的第12章提供了标准I/O库一种实现的全部源代码。GNU标准I/O库的实现也是公开可用的。

实例

图5-11程序为3个标准流以及一个与普通文件相关联的流打印有关缓冲的状态信息。

图5-11 对各个标准I/O流打印缓冲状态信息

注意,在打印缓冲状态信息之前,先对每个流执行I/O操作,第一个I/O操作通常就造成为该流分配缓冲区。本例中的结构成员和常量是由本书中使用的4种平台实现的标准I/O库定义的。应当了解,标准I/O库实现在不同的系统中可能有所不同,像本例中的程序是不可移植的,因为它们嵌入了与特定实现相关的内容。

如果运行图5-11的程序两次,一次使3个标准流与终端相连接,另一次使它们重定向到普通文件,则所得结果是:

$ ./a.out            stdin、stdout和stderr都连至终端

enter any character

键入换行符

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 = /etc/passwd, fully buffered, buffer size = 4096

$ ./a.out < /etc/group > std.out 2> std.err

3个流都重定向,再次运行该程序

$ cat std.err

one line to standard error

$ 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 = /etc/passwd, fully buffered, buffer size = 4096

从中可见,该系统的默认是:当标准输入、输出连至终端时,它们是行缓冲的。行缓冲的长度是 1 024 字节。注意,这并没有将输入、输出的行长限制为 1 024 字节,这只是缓冲区的长度。如果要将 2 048 字节的行写到标准输出,则要进行两次 write 系统调用。当将这两个流重新定向到普通文件时,它们就变成是全缓冲的,其缓冲区长度是该文件系统优先选用的 I/O 长度(从 stat 结构中得到的 st_blksize 值)。从中也可看到,标准错误如它所应该的那样是不带缓冲的,而普通文件按系统默认是全缓冲的。

5.13 临时文件

ISO C 标准 I/O 库提供了两个函数以帮助创建临时文件。

#include<stdio.h>

char *tmpnam(char *ptr);

返回值:指向唯一路径名的指针

FILE *tmpfile(void);

返回值:若成功,返回文件指针;若出错,返回NULL

tmpnam 函数产生一个与现有文件名不同的一个有效路径名字符串。每次调用它时,都产生一个不同的路径名,最多调用次数是TMP_MAX。TMP_MAX 定义在<stdio.h>中。

虽然ISO C定义了TMP_MAX,但该标准只要求其值至少应为25。但是,Single UNIX Specification却要求符合XSI的系统支持其值至少为10 000。虽然此最小值允许一个实现使用4位数字(0000~9999)作为临时文件名,但是,大多数UNIX实现使用的却是大、小写字符。

tmpnam 函数在 SUSv4 中被标记为弃用,但是 ISO C 标准还继续支持它。

若ptr是NULL,则所产生的路径名存放在一个静态区中,指向该静态区的指针作为函数值返回。后续调用 tmpnam 时,会重写该静态区(这意味着,如果我们调用此函数多次,而且想保存路径名,则我们应当保存该路径名的副本,而不是指针的副本)。如若ptr不是NULL,则认为它应该是指向长度至少是L_tmpnam 个字符的数组(常量L_tmpnam定义在头文件<stdio.h>中)。所产生的路径名存放在该数组中,ptr 也作为函数值返回。

tmpfile 创建一个临时二进制文件(类型wb+),在关闭该文件或程序结束时将自动删除这种文件。注意,UNIX对二进制文件不进行特殊区分。

实例

图5-12程序说明了这两个函数的应用。

图5-12 tmpnam和tmpfile函数实例

执行图5-12的程序,可得:

$ ./a.out

/tmp/fileT0Hsu6

/tmp/filekmAsYQ

one line of output

tmpfile函数经常使用的标准UNIX技术是先调用tmpnam产生一个唯一的路径名,然后,用该路径名创建一个文件,并立即unlink它。请回忆4.15节,对一个文件解除链接并不删除其内容,关闭该文件时才删除其内容。而关闭文件可以是显式的,也可以在程序终止时自动进行。

Single UNIX Specification为处理临时文件定义了另外两个函数:mkdtemp和mkstemp,它们是XSI的扩展部分。

#include <stdlib.h>

char *mkdtemp(char *template);

返回值:若成功,返回指向目录名的指针;若出错,返回NULL

int mkstemp(char *template);

返回值:若成功,返回文件描述符;若出错,返回−1

mkdtemp函数创建了一个目录,该目录有一个唯一的名字;mkstemp函数创建了一个文件,该文件有一个唯一的名字。名字是通过template字符串进行选择的。这个字符串是后6位设置为XXXXXX 的路径名。函数将这些占位符替换成不同的字符来构建一个唯一的路径名。如果成功的话,这两个函数将修改template字符串反映临时文件的名字。

由mkdtemp函数创建的目录使用下列访问权限位集:S_IRUSR | S_IWUSR | S_IXUSR。注意,调用进程的文件模式创建屏蔽字可以进一步限制这些权限。如果目录创建成功,mkdtemp返回新目录的名字。

mkstemp函数以唯一的名字创建一个普通文件并且打开该文件,该函数返回的文件描述符以读写方式打开。由mkstemp创建的文件使用访问权限位S_IRUSR | S_IWUSR。

与tempfile不同,mkstemp创建的临时文件并不会自动删除。如果希望从文件系统命名空间中删除该文件,必须自己对它解除链接。

使用tmpnam和tempnam至少有一个缺点:在返回唯一的路径名和用该名字创建文件之间存在一个时间窗口,在这个时间窗口中,另一进程可以用相同的名字创建文件。因此应该使用tmpfile和mkstemp函数,因为它们不存在这个问题。

实例

图5-13程序显示了如何使用mkstemp函数。

图5-13 mkstemp函数的应用

运行图5.13中的程序,得到:

$ ./a.out

trying to create first temp file...

temp name = /tmp/dirUmBT7h

file exists

trying to create second temp file...

Segmentation fault

两个模板字符串声明方式的不同带来了不同的运行结果。对于第一个模板,因为使用了数组,名字是在栈上分配的。但第二种情况使用的是指针,在这种情况下,只有指针自身驻留在栈上。编译器把字符串存放在可执行文件的只读段,当 mkstemp 函数试图修改字符串时,出现了段错误(segment fault)。

5.14 内存流

我们已经看到,标准I/O库把数据缓存在内存中,因此每次一字符和每次一行的I/O更有效。我们也可以通过调用setbuf或setvbuf函数让I/O库使用我们自己的缓冲区。在SUSv4中支持了内存流。这就是标准I/O流,虽然仍使用FILE指针进行访问,但其实并没有底层文件。所有的I/O都是通过在缓冲区与主存之间来回传送字节来完成的。我们将看到,即便这些流看起来像文件流,它们的某些特征使其更适用于字符串操作。

有3个函数可用于内存流的创建,第一个是fmemopen函数。

#include <stdio.h>

FILE *fmemopen(void *restrict buf, size_t size, const char *restrict type);

返回值:若成功,返回流指针;若错误,返回NULL

fmemopen 函数允许调用者提供缓冲区用于内存流:buf 参数指向缓冲区的开始位置,size参数指定了缓冲区大小的字节数。如果buf参数为空,fmemopen函数分配size字节数的缓冲区。在这种情况下,当流关闭时缓冲区会被释放。

type参数控制如何使用流。type可能的取值如图5-14所示。

图5-14 打开内存流的type参数

注意,这些取值对应于基于文件的标准I/O流的type参数取值,但其中有些微小差别。第一,无论何时以追加写方式打开内存流时,当前文件位置设为缓冲区中的第一个null字节。如果缓冲区中不存在null字节,则当前位置就设为缓冲区结尾的后一个字节。当流并不是以追加写方式打开时,当前位置设为缓冲区的开始位置。因为追加写模式通过第一个null字节确定数据的尾端,内存流并不适合存储二进制数据(二进制数据在数据尾端之前就可能包含多个null字节)。

第二,如果buf参数是一个null指针,打开流进行读或者写都没有任何意义。因为在这种情况下缓冲区是通过fmemopen进行分配的,没有办法找到缓冲区的地址,只写方式打开流意味着无法读取已写入的数据,同样,以读方式打开流意味着只能读取那些我们无法写入的缓冲区中的数据。

第三,任何时候需要增加流缓冲区中数据量以及调用fclose、fflush、fseek、fseeko以及fsetpos时都会在当前位置写入一个null字节。

实例

有必要看一下对内存流的写入是如何在我们自己提供的缓冲区上进行操作的。图5-15给出了用已知模式填充缓冲区时流写入是如何操作的。我们在Linux 上运行该程序,得到如下结果:

图5-15 观察内存流的写入操作

$ ./a.out

initial buffer contents:         fmemopen在缓冲区开始处放置null字节

before flush:

after fflush: hello, world

len of string in buf = 12        null字节加到字符串结尾

用a字符改写缓冲区

流冲洗后缓冲区才会变化

现在用b字符改写缓冲区

after fseek: bbbbbbbbbbbbhello, world fseek引起缓冲区冲洗

len of string in buf = 24        再次追加写null字节

现在用c字符改写缓冲区after fclose: hello, worldcccccccccccccccccccccccccccccccccc

len of string in buf = 46        没有追加写null字节

这个例子给出了冲洗内存流和追加写null字节的策略。写入内存流以及推进流的内容大小(相对缓冲区大小而言,该大小是固定的)这个概念时,null 字节会自动追加写。流内容大小是由写入多少来确定的。

在本书所讨论的4个平台中,只有Linux 3.2.0支持内存流。这是具体实现还没有跟上最新的标准,相信随着时间的推移,这种情况会有所改变。

用于创建内存流的其他两个函数分别是open_memstream和open_wmemstream。

#include <stdio.h>

FILE *open_memstream(char **bufp, size_t *sizep);

#include <wchar.h>

FILE *open_wmemstream(wchar_t **bufp, size_t *sizep);

两个函数的返回值:若成功,返回流指针;若出错,返回NULL

open_memstream函数创建的流是面向字节的,open_wmemstream函数创建的流是面向宽字节的(回忆5.2节中对于多字节字符的说明)。这两个函数与fmemopen函数的不同在于:

•创建的流只能写打开;

•不能指定自己的缓冲区,但可以分别通过bufp和sizep参数访问缓冲区地址和大小;

•关闭流后需要自行释放缓冲区;

•对流添加字节会增加缓冲区大小。

但是在缓冲区地址和大小的使用上必须遵循一些原则。第一,缓冲区地址和长度只有在调用fclose或fflush后才有效;第二,这些值只有在下一次流写入或调用fclose前才有效。因为缓冲区可以增长,可能需要重新分配。如果出现这种情况,我们会发现缓冲区的内存地址值在下一次调用fclose或fflush时会改变。

因为避免了缓冲区溢出,内存流非常适用于创建字符串。因为内存流只访问主存,不访问磁盘上的文件,所以对于把标准I/O流作为参数用于临时文件的函数来说,会有很大的性能提升。

5.15 标准I/O的替代软件

标准I/O库并不完善。Korn和Vo[1991]列出了它的很多不足之处,其中,某些属于基本设计,但是大多数则与各种不同的实现有关。

标准I/O库的一个不足之处是效率不高,这与它需要复制的数据量有关。当使用每次一行函数fgets和fputs时,通常需要复制两次数据:一次是在内核和标准I/O缓冲区之间(当调用read和write时),第二次是在标准I/O缓冲区和用户程序中的行缓冲区之间。快速I/O库[AT&T 1990a中的 fio(3)]避免了这一点,其方法是使读一行的函数返回指向该行的指针,而不是将该行复制到另一个缓冲区中。Hume[1988]报告:由于做了这种更改,grep(1)实用程序的速度提升了3倍。

Korn和Vo[1991]说明了标准I/O库的另一种替代版:sfio。这一软件包在速度上与fio相近,通常快于标准I/O库。sfio软件包也提供了一些其他标准I/O库所没有的新特征:推广了I/O流,使其不仅可以代表文件,也可代表存储区;可以编写处理模块,并以栈方式将其压入I/O流,这样就可以改变一个流的操作;较好的异常处理等。

Krieger、Stumm和Unrau[1992]说明了另一个替代软件包,它使用了映射文件——mmap函数,我们将在14.8节中说明此函数。该新软件包称为ASI(Alloc Stream Interface)。其编程接口类似于UNIX系统存储分配函数(malloc、realloc和free,这些函数将在7.8节中说明)。与sfio软件包相同,ASI使用指针力图减少数据复制量。

许多标准I/O库实现在C函数库中可用,这种C函数库是为内存较小的系统,如嵌入式系统设计的。这些实现对于合理内存要求的关注超过对可移植性、速度以及功能性等方面的关注。这种类型函数库的两种实现是:uClibc C库(参阅http://www.uclibc.org)和Newlib C库(http://www. source.redhat.com/newlib)。

5.16 小结

大多数UNIX应用程序都使用标准I/O库。本章说明了该库提供的很多函数以及某些实现细节和效率方面的考虑。应该看到,标准I/O库使用了缓冲技术,而它正是产生很多问题、引起许多混淆的部分。

习题

5.1 用setvbuf实现setbuf。

5.2 图5-5中的程序利用每次一行I/O(fgets和fputs函数)复制文件。若将程序中的MAXLINE改为4,当复制的行超过该最大值时会出现什么情况?对此进行解释。

5.3 printf返回0值表示什么?

5.4 下面的代码在一些机器上运行正确,而在另外一些机器运行时出错,解释问题所在。

#include <stdio.h>

int

main(void)

{

char c;
while ((c = getchar()) != EOF)
putchar(c);

}

5.5 对标准I/O流如何使用fsync函数(见3.13节)?

5.6 在图1-7和图1-10程序中,打印的提示信息没有包含换行符,程序也没有调用fflush函数,请解释输出提示信息的原因是什么?

5.7 基于BSD的系统提供了funopen的函数调用使我们可以拦截读、写、定位以及关闭一个流的调用。使用这个函数为FreeBSD和Mac OS X实现fmemopen。

第6章 系统数据文件和信息

6.1 引言

UNIX系统的正常运作需要使用大量与系统有关的数据文件,例如,口令文件/etc/passwd和组文件/etc/group就是经常被多个程序频繁使用的两个文件。用户每次登录UNIX系统,以及每次执行ls -l命令时都要使用口令文件。

由于历史原因,这些数据文件都是ASCII文本文件,并且使用标准I/O库读这些文件。但是,对于较大的系统,顺序扫描口令文件很花费时间,我们需要能够以非ASCII文本格式存放这些文件,但仍向使用其他文件格式的应用程序提供接口。对于这些数据文件的可移植接口是本章的主题。本章也包括了系统标识函数、时间和日期函数。

6.2 口令文件

UNIX 系统口令文件(POSIX.1 则将其称为用户数据库)包含了图 6-1 中所示的各字段,这些字段包含在<pwd.h>中定义的passwd结构中。

注意,POSIX.1只指定passwd结构包含的10个字段中的5个。大多数平台至少支持其中7个字段。BSD派生的平台支持全部10个字段。

图6-1 /etc/passwd文件中的字段

由于历史原因,口令文件是/etc/passwd,而且是一个 ASCII 文件。每一行包含图 6-1 中所示的各字段,字段之间用冒号分隔。例如,在Linux中,该文件中可能有下列4行:

root:x:0:0:root:/root:/bin/bash

squid:x:23:23::/var/spool/squid:/dev/null

nobody:x:65534:65534:Nobody:/home:/bin/sh

sar:x:205:105:Stephen Rago:/home/sar:/bin/bash

关于这些登录项,请注意下列各点:

•通常有一个用户名为root的登录项,其用户ID是0(超级用户)。

• 加密口令字段包含了一个占位符。较早期的UNIX系统版本中,该字段存放加密口令字。将加密口令字存放在一个人人可读的文件中是一个安全性漏洞,所以现在将加密口令字存放在另一个文件中。在下一节讨论口令字时,我们将详细涉及此问题。

• 口令文件项中的某些字段可能是空。如果加密口令字段为空,这通常就意味着该用户没有口令(不推荐这样做)。squid登录项有一空白字段:注释字段。空白注释字段不产生任何影响。

• shell字段包含了一个可执行程序名,它被用作该用户的登录shell。若该字段为空,则取系统默认值,通常是/bin/sh。注意,squid登录项的该字段为/dev/null。显然,这是一个设备,不是可执行文件,将其用于此处的目的是,阻止任何人以用户squid的名义登录到该系统。

很多服务对于帮助它们得以实施的不同守护进程使用不同的用户ID(见第13章),squid项是为实现squid代理高速缓存服务的进程设置的。

• 为了阻止一个特定用户登录系统,除使用/dev/null外,还有若干种替代方法。常见的一种方法是,将/bin/false 用作登录 shell。它简单地以不成功(非 0)状态终止,该shell将此种终止状态判断为假。另一种常见方法是,用/bin/true禁止一个账户。它所做的一切是以成功(0)状态终止。某些系统提供 nologin 命令,它打印可定制的出错信息,然后以非0状态终止。

• 使用nobody用户名的一个目的是,使任何人都可登录至系统,但其用户ID(65534)和组ID(65534)不提供任何特权。该用户ID和组ID只能访问人人皆可读、写的文件。(假定用户ID 65534和组ID 65534并不拥有任何文件,而实际情况就应如此。)

• 提供 finger(1)命令的某些 UNIX 系统支持注释字段中的附加信息。其中,各部分之间都用逗号分隔:用户姓名、办公室地点、办公室电话号码以及家庭电话号码等。另外,如果注释字段中的用户姓名是一个&,则它被替换为登录名。例如,可以有下列记录:

sar:x:205:105:Steve Rago, SF 5-121, 555-1111, 555-2222:/home/sar:/bin/sh

使用finger命令就可打印Steve Rago的有关信息。

$ finger -p sar

Login: sar                Name: Steve Rago

Directory: /home/sar          Shell: /bin/sh

Office: SF 5-121, 555-1111       Home Phone: 555-2222

On since Mon Jan 19 03:57 (EST) on ttyv0 (messages off)

No Mail.

即使你所使用的系统并不支持finger命令,这些信息仍可存放在注释字段中,该字段只是一个注释,并不由系统实用程序解释。

某些系统提供了 vipw 命令,允许管理员使用该命令编辑口令文件。vipw 命令串行化地更改口令文件,并且确保它所做的更改与其他相关文件保持一致。系统也常常经由图形用户界面(GUI)提供类似的功能。

POSIX.1定义了两个获取口令文件项的函数。在给出用户登录名或数值用户ID后,这两个函数就能查看相关项。

#include<pwd.h>struct passwd *getpwuid(uid_t uid);struct passwd *getpwnam(const char *name);

两个函数返回值:若成功,返回指针;若出错,返回NULL

getpwuid函数由ls(1)程序使用,它将i节点中的数字用户ID映射为用户登录名。在键入登录名时,getpwnam函数由login(1)程序使用。

这两个函数都返回一个指向passwd结构的指针,该结构已由这两个函数在执行时填入信息。passwd 结构通常是函数内部的静态变量,只要调用任一相关函数,其内容就会被重写。

如果要查看的只是登录名或用户ID,那么这两个POSIX.1函数能满足要求,但是也有些程序要查看整个口令文件。下列3个函数则可用于此种目的。

#include <pwd.h>

struct passwd *getpwent(void);

返回值:若成功,返回指针;若出错或到达文件尾端,返回NULL

void setpwent(void);

void endpwent(void);

基本POSIX.1标准没有定义这3个函数。在Single UNIX Specification中,它们被定义为XSI扩展。因此,可预期所有UNIX实现都将提供这些函数。

调用getpwent时,它返回口令文件中的下一个记录项。如同上面所述的两个POSIX.1函数一样,它返回一个由它填写好的 passwd 结构的指针。每次调用此函数时都重写该结构。在第一次调用该函数时,它打开它所使用的各个文件。在使用本函数时,对口令文件中各个记录项的安排顺序并无要求。某些系统采用散列算法对/etc/passwd 文件中各项排序。

函数setpwent反绕它所使用的文件,endpwent则关闭这些文件。在使用getpwent查看完口令文件后,一定要调用endpwent关闭这些文件。getpwent知道什么时间应当打开它所使用的文件(第一次被调用时),但是它并不知道何时关闭这些文件。

实例

图6-2程序给出了getpwnam函数的一个实现。

图6-2 getpwnam函数

在函数开始处调用setpwent是自我保护性的措施,以便确保如果调用者在此之前已经调用getpwent打开了有关文件情况下,反绕有关文件使它们定位到文件开始处。getpwnam和getpwuid完成后不应使有关文件仍处于打开状态,所以应调用endpwent关闭它们。

6.3 阴影口令

加密口令是经单向加密算法处理过的用户口令副本。因为此算法是单向的,所以不能从加密口令猜测到原来的口令。

历史上使用的算法总是在64字符集[a-zA-Z0-9./]中产生13个可打印字符(见Morris和Thompson [1979])。某些较新的系统使用其他方法,如MD5或SHA-1算法,对口令加密,产生更长的加密口令字符串。(加密口令的字符越多,这些字符的组合也就越多,于是用各种可能组合来猜测口令的难度就越大。)当我们将单个字符放在加密口令字段中时,可以确保任一加密口令都不会与其相匹配。

对于一个加密口令,找不到一种算法可以将其反变换到明文口令(明文口令是在Password:提示后键入的口令)。但是可以对口令进行猜测,将猜测的口令经单向算法变换成加密形式,然后将其与用户的加密口令相比较。如果用户口令是随机选择的,那么这种方法并不是很有用。但是用户往往以非随机方式选择口令(如配偶的姓名、街名、宠物名等)。一个经常重复的实验是先得到一份口令文件,然后用试探方法猜测口令。(Garfinkel等[2003]的第4章对UNIX口令及口令加密处理方案的历史情况及细节进行了说明。)

为使企图这样做的人难以获得原始资料(加密口令),现在,某些系统将加密口令存放在另一个通常称为阴影口令(shadow password)的文件中。该文件至少要包含用户名和加密口令。与该口令相关的其他信息也可存放在该文件中(图6-3)。

图6-3 /etc/shadow文件中的字段

只有用户登录名和加密口令这两个字段是必须的。其他的字段控制口令更改的频率,或者说口令的衰老以及账户仍然处于活动状态的时间。

阴影口令文件不应是一般用户可以读取的。仅有少数几个程序需要访问加密口令,如login(1)和 passwd(1),这些程序常常是设置用户 ID 为 root 的程序。有了阴影口令后,普通口令文件/etc/passwd可由各用户自由读取。

在Linux 3.2.0和Solaris 10中,与访问口令文件的一组函数相类似,有另一组函数可用于访问阴影口令文件。

#include <shadow.h>

struct spwd *getspnam(const char *name);

struct spwd *getspent(void);

两个函数返回值:若成功,返回指针;若出错,返回NULL

void setspent(void);

void endspent(void);

在FreeBSD 8.0和Mac OS X 10.6.8中,没有阴影口令结构。附加的账户信息存放在口令文件中(见图6-1)。

6.4 组文件

UNIX组文 件(POSIX.1称其为组数据库)包含了图6-4中所示字段。这些字段包含在<grp.h>中所定义的group结构中。

图6-4 /etc/group文件中的字段

字段gr_mem是一个指针数组,其中每个指针指向一个属于该组的用户名。该数组以null指针结尾。可以用下列两个由POSIX.1定义的函数来查看组名或数值组ID。

#include <grp.h>

struct group *getgrgid(gid_t gid);

struct group *getgrnam(const char *name);

两个函数返回值:若成功,返回指针;若出错,返回NULL

如同对口令文件进行操作的函数一样,这两个函数通常也返回指向一个静态变量的指针,在每次调用时都重写该静态变量。

如果需要搜索整个组文件,则须使用另外几个函数。下列3个函数类似于针对口令文件的3个函数。

#include <grp.h>

struct group *getgrent(void);

返回值:若成功,返回指针;若出错或到达文件尾端,返回NULL

void setgrent(void);

void endgrent(void);

这3个函数不是基本POSIX.1标准的组成部分。Single UNIX Specification的XSI扩展定义了这些函数。所有UNIX系统都提供这3个函数。

setgrent函数打开组文件(如若它尚末被打开)并反绕它。getgrent函数从组文件中读下一个记录,如若该文件尚未打开,则先打开它。endgrent函数关闭组文件。

6.5 附属组ID

在UNIX系统中,对组的使用已经做了些更改。在V7中,每个用户任何时候都只属于一个组。当用户登录时,系统就按口令文件记录项中的数值组 ID,赋给他实际组 ID。可以在任何时候执行newgrp(1)以更改组ID。如果newgrp命令执行成功(关于权限规则,请参阅手册),则实际组 ID 就更改为新的组 ID,它将被用于后续的文件访问权限检查。执行不带任何参数的newgrp,则可返回到原来的组。

这种组成员形式一直维持到1983年左右。此时,4.2BSD引入了附属组ID(supplementary group ID)的概念。我们不仅可以属于口令文件记录项中组ID所对应的组,也可属于多至16个另外的组。文件访问权限检查相应被修改为:不仅将进程的有效组ID与文件的组ID相比较,而且也将所有附属组ID与文件的组ID进行比较。

附属组 ID 是 POSIX.1 要求的特性。(在较早的 POSIX.1 版本中,该特性是可选的。)常量NGROUPS_MAX(见图2-11)规定了附属组ID的数量,其常用值是16(见图2-15)。

使用附属组 ID 的优点是不必再显式地经常更改组。一个用户会参与多个项目,因此也就要同时属于多个组,此类情况是常有的。

为了获取和设置附属组ID,提供了下列3个函数。

#include <unistd.h>

int getgroups(int gidsetsize, gid_t grouplist[]);

返回值:若成功,返回附属组ID数量;若出错,返回-1

#include <grp.h> /* on Linux */

#include <unistd.h> /* on FreeBSD, Mac OS X, and Solaris */

int setgroups(int ngroups, const gid_t grouplist[]);

#include <grp.h> /* on Linux and Solaris */

#include <unistd.h> /* on FreeBSD and Mac OS X */

int initgroups(const char *username, gid_t basegid);

两个函数的返回值:若成功,返回0;若出错,返回-1

在这3个函数中,POSIX.1只说明了getgroups。因为setgroups和initgroups是特权操作,所以它们并非POSIX.1的组成部分。但是,本书说明的所有4种平台都支持这3个函数。在Mac OS X 10.6.8中,basegid 被声明为int类型。

getgroups将进程所属用户的各附属组ID填写到数组grouplist中,填写入该数组的附属组ID数最多为gidsetsize个。实际填写到数组中的附属组ID数由函数返回。

作为一种特殊情况,如若gidsetsize为0,则函数只返回附属组ID数,而对数组grouplist则不做修改。(这使调用者可以确定grouplist数组的长度,以便进行分配。)

setgroups可由超级用户调用以便为调用进程设置附属组ID表。grouplist是组ID数组,而ngroups说明了数组中的元素数。ngroups的值不能大于NGROUPS_MAX。

通常,只有initgroups函数调用setgroups,initgroups读整个组文件(用前面说明的函数getgrent、setgrent和endgrent),然后对username确定其组的成员关系。然后,它调用setgroups,以便为该用户初始化附属组ID表。因为initgroups要调用setgroups,所以只有超级用户才能调用 initgroups。除了在组文件中找到 username 是成员的所有组, initgroups也在附属组ID表中包括了basegid。basegid是username在口令文件中的组ID。

只有少数几个程序调用initgroups,例如login(1)程序在用户登录时调用该函数。

6.6 实现区别

我们已讨论了Linux和Solaris支持的阴影口令文件。FreeBSD和Mac OS X则以不同方式存储加密口令字。图6-5总结了本书涉及的4种平台如何存储用户和组信息。

图6-5 账户实现的区别

在FreeBSD中,阴影口令文件是/etc/master.passwd。可以使用特殊命令编辑该文件,它会从阴影口令文件产生/etc/passwd 的一个副本。另外,也产生该文件的散列副本。/etc/pwd.db是/etc/passwd的散列副本,/etc/spwd.db是/etc/master.passwd的散列版本。这些为大型安装的系统提供了更好的性能。

但是,Mac OS X只在单用户模式下使用/etc/passwd和/etc/master.passwd(在维护系统时,单用户模式通常意味着不能提供任何系统服务)。在正常运行期间的多用户模式,目录服务守护进程提供对用户和组账户信息的访问。

虽然Linux和Solaris支持类似的阴影口令接口,但两者之间存在某些细微的差别。例如,图6-3中所示的整数字段在Solaris中定义为int类型,而在Linux中则定义为long int。另一个差别是账户-不活动字段:Solaris将其定义为自用户上次登录后到下次账户自动失效之间的天数,而Linux则将其定义为达到最大口令年龄尚余天数。

在很多系统中,用户和组数据库是用网络信息服务(Network Information Service,NIS)实现的。这使管理人员可编辑数据库的主副本,然后将它自动分发到组织中的所有服务器上。客户端系统联系服务器以查看用户和组的有关信息。NIS+和轻量级目录访问协议(Lightweight Directory Access Protocol,LDAP)提供了类似功能。很多系统通过配置文件/etc/nsswitch.conf控制用于管理每一类信息的方法。

6.7 其他数据文件

至此仅讨论了两个系统数据文件——口令文件和组文件。在日常操作中,UNIX系统还使用很多其他文件。例如,BSD网络软件有一个记录各网络服务器所提供服务的数据文件(/etc/services),有一个记录协议信息的数据文件(/etc/protocols),还有一个则是记录网络信息的数据文件(/etc/networks)。幸运的是,对于这些数据文件的接口都与上述对口令文件和组文件的相似。

一般情况下,对于每个数据文件至少有3个函数。

(1)get函数:读下一个记录,如果需要,还会打开该文件。此种函数通常返回指向一个结构的指针。当已达到文件尾端时返回空指针。大多数get函数返回指向一个静态存储类结构的指针,如果要保存其内容,则需复制它。

(2)set 函数:打开相应数据文件(如果尚末打开),然后反绕该文件。如果希望在相应文件起始处开始处理,则调用此函数。

(3)end函数:关闭相应数据文件。如前所述,在结束了对相应数据文件的读、写操作后,总应调用此函数以关闭所有相关文件。

另外,如果数据文件支持某种形式的键搜索,则也提供搜索具有指定键的记录的例程。例如,对于口令文件,提供了两个按键进行搜索的程序:getpwnam 寻找具有指定用户名的记录;getpwuid寻找具有指定用户ID的记录。

图6-6中列出了一些这样的例程,这些都是UNIX常用的。在图中列出了针对口令文件和组文件的函数,这些已在前面说明过。图中也列出了一些与网络有关的函数。对于图中列出的所有数据文件都有get、set和end函数。

图6-6 访问系统数据文件的一些例程

在 Solaris 中,图 6-6 中的最后 4 个数据文件都是符号链接,它们都链接到目录/etc/inet下的同名文件上。大多数UNIX系统实现都有类似于图中所列的附加函数,但是这些附加函数都旨在处理系统管理文件,专用于各个实现。

6.8 登录账户记录

大多数UNIX系统都提供下列两个数据文件:utmp文件记录当前登录到系统的各个用户;wtmp文件跟踪各个登录和注销事件。在V7中,每次写入这两个文件中的是包含下列结构的一个二进制记录:

struct utmp {

char ut_line[8]; /* tty line: "ttyh0", "ttyd0", "ttyp0", ... */
char ut_name[8]; /* login name */
long ut_time;  /* seconds since Epoch */

};

登录时,login 程序填写此类型结构,然后将其写入到 utmp 文件中,同时也将其添写到wtmp文件中。注销时,init进程将utmp文件中相应的记录擦除(每个字节都填以null字节),并将一个新记录添写到wtmp文件中。在wtmp文件的注销记录中,ut_name字段清除为0。在系统再启动时,以及更改系统时间和日期的前后,都在wtmp文件中追加写特殊的记录项。who(1)程序读取utmp文件,并以可读格式打印其内容。后来的UNIX版本提供last(1)命令,它读wtmp文件并打印所选择的记录。

大多数UNIX版本仍提供utmp和wtmp文件,但正如所期望的,其中的信息量却增加了。V7中写入的20字节的结构在SVR2中已扩充为36字节,而在SVR4中,utmp结构已扩充为多于350字节。

在Solaris中,这些记录的详细格式请参见手册页utmpx(4)。Solaris 10中这两个文件都在目录/var/adm中。Solaris提供了很多函数(见getutx(3))读或写这两个文件。

在FreeBSD 8.0和Linux 3.2.0中,登录记录的格式请参见手册页utmp(5)。这两个文件的路径名是/var/run/utmp和/var/log/wtmp。在Mac OS X 10.6.8中,utmp和wtmp文件不存在。在Mac OS X 10.5中,wtmp文件中的信息可以从系统登录工具中获得,utmpx文件包含了活动的登录会话的信息。

6.9 系统标识

POSIX.1定义了uname函数,它返回与主机和操作系统有关的信息。

#include <sys/utsname.h>

int uname(struct utsname *name);

返回值:若成功,返回非负值;若出错,返回-1

通过该函数的参数向其传递一个 utsname 结构的地址,然后该函数填写此结构。POSIX.1只定义了该结构中最少需提供的字段(它们都是字符数组),而每个数组的长度则由实现确定。某些实现在该结构中提供了另外一些字段。

struct utsname {

char sysname[ ]; /* name of the operating system */
char nodename[ ]; /* name of this node */
char release[ ]; /* current release of operating system */
char version[ ]; /* current version of this release */
char machine[ ]; /* name of hardware type */

};

每个字符串都以null字节结尾。本书讨论的4种平台支持的最大名字长度(包含终止null字节)列于图6-7中。utsname结构中的信息通常可用uname(1)命令打印。

POSIX.1警告nodename元素可能并不适用于在通信网络上引用主机。此函数来自于System V,在早期,nodename元素适用于在UUCP网络上引用主机。

还要认识到,在此结构中并没有给出有关POSIX.1版本的信息。应当使用2.6节中所说明的_POSIX_VERSION获得该信息。

最后,此函数只给出了一种获取该结构中信息的方法,至于如何初始化这些信息,POSIX.1没有给出任何说明。

历史上,BSD派生的系统提供gethostname函数,它只返回主机名,该名字通常就是TCP/IP网络上主机的名字。

#include <unistd.h>

int gethostname(char *name, i n t namelen);

返回值:若成功,返回0;若出错,返回-1

namelen参数指定name缓冲区长度,如若提供足够的空间,则通过name返回的字符串以null字节结尾。如若没有提供足够的空间,则没有说明通过name返回的字符串是否以null结尾。

现在,gethostname函数已在POSIX.1中定义,它指定最大主机名长度是HOST_NAME_MAX。图6-7中总结列出了本书讨论的4种实现支持的最大名字长度。

图6-7 系统标识名限制

如果宿主机联接到TCP/IP网络中,则此主机名通常是该主机的完整域名。

hostname(1)命令可用来获取和设置主机名。(超级用户用一个类似的函数 sethostname来设置主机名。)主机名通常在系统自举时设置,它由/etc/rc或init取自一个启动文件。

6.10 时间和日期例程

由UNIX内核提供的基本时间服务是计算自协调世界时(Coordinated Universal Time,UTC)公元1970年1月1日00:00:00这一特定时间以来经过的秒数。1.10节中曾提及这种秒数是以数据类型time_t表示的,我们称它们为日历时间。日历时间包括时间和日期。UNIX在这方面与其他操作系统的区别是:(a)以协调统一时间而非本地时间计时;(b)可自动进行转换,如变换到夏令时;(c)将时间和日期作为一个量值保存。

time函数返回当前时间和日期。

#include <time.h>

time_t time(time_t *calptr);

返回值:若成功,返回时间值;若出错,返回-1

时间值作为函数值返回。如果参数非空,则时间值也存放在由calptr指向的单元内。

POSXI.1的实时扩展增加了对多个系统时钟的支持。在Single UNIX Specification V4中,控制这些时钟的接口从可选组被移至基本组。时钟通过clockid_t类型进行标识。图6-8给出了标准值。

图6-8 时钟类型标识符

clock_gettime函数可用于获取指定时钟的时间,返回的时间在4.2节介绍的timespec结构中,它把时间表示为秒和纳秒。

#include <sys/time.h>

int clock_gettime(clockid_t clock_id, struct timespec *tsp);

返回值:若成功,返回0;若出错,返回-1

当时钟ID设置为CLOCK_REALTIME时,clock_gettime函数提供了与time函数类似的功能,不过在系统支持高精度时间值的情况下,clock_gettime可能比time函数得到更高精度的时间值。

#include <sys/time.h>

int clock_getres(clockid_t clock_id, struct timespec *tsp);

返回值:若成功,返回0;若出错,返回-1

clock_getres函数把参数tsp指向的timespec结构初始化为与clock_id参数对应的时钟精度。例如,如果精度为1毫秒,则tv_sec字段就是0,tv_nsec字段就是1 000 000。

要对特定的时钟设置时间,可以调用clock_settime函数。

#include <sys/time.h>

int clock_settime(clockid_t clock_id, const struct timespec *tsp);

返回值:若成功,返回0;若出错,返回-1

我们需要适当的特权来更改时钟值,但是有些时钟是不能修改的。

历史上,在System V派生的系统实现中,调用stime(2)函数来设置系统时间,而在BSD派生的系统中调用settimeofday(2)设置系统时间。

SUSv4指定gettimeofday函数现在已弃用。然而,一些程序仍然使用这个函数,因为与time函数相比,gettimeofday提供了更高的精度(可到微秒级)。

#include <sys/time.h>

int gettimeofday(struct timeval *restrict tp, void *restrict tzp);

返回值:总是返回0

tzp的唯一合法值是NULL,其他值将产生不确定的结果。某些平台支持用tzp说明时区,但这完全依实现而定,Single UNIX Specification对此并没有定义。

gettimeofday函数以距特定时间(1970年1月1日00 : 00 : 00)的秒数的方式将当前时间存放在tp指向的timeval结构中,而该结构将当前时间表示为秒和微秒。

一旦取得这种从上述特定时间经过的秒数的整型时间值后,通常要调用函数将其转换为分解的时间结构,然后调用另一个函数生成人们可读的时间和日期。图6-9说明了各种时间函数之间的关系。(图中以虚线表示的3个函数localtime、mktime和strftime都受到环境变量TZ的影响,我们将在本节的最后部分对其进行说明。点划线表示了如何从时间相关的结构获得日历时间。)

两个函数localtime和gmtime将日历时间转换成分解的时间,并将这些存放在一个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的理由是可以表示润秒。注意,除了月日字段,其他字段的值都以0开始。如果夏令时生效,则夏令时标志值为正;如果为非夏令时时间,则该标志值为0;如果此信息不可用,则其值为负。

Single UNIX Specification的以前版本允许双润秒,于是,tm_sec值的有效范围是0~61。

UTC的正式定义不允许双润秒,所以,现在tm_sec值的有效范围定义为0~60。191

图6-9 各个时间函数之间的关系

#include <time.h>

struct tm *gmtime(const time_t *calptr);

struct tm *localtime(const time_t *calptr);

两个函数的返回值:指向分解的tm结构的指针;若出错,返回NULL

localtime和gmtime之间的区别是:localtime将日历时间转换成本地时间(考虑到本地时区和夏令时标志),而 gmtime 则将日历时间转换成协调统一时间的年、月、日、时、分、秒、周日分解结构。

函数mktime以本地时间的年、月、日等作为参数,将其变换成time_t值。

#include <time.h>

time_t mktime(struct tm *tmptr);

返回值:若成功,返回日历时间;若出错,返回-1

函数strftime是一个类似于printf的时间值函数。它非常复杂,可以通过可用的多个参数来定制产生的字符串。

#include <time.h>

size_t strftime(char *restrict buf, size_t maxsize,

const char *restrict format,
const struct tm *restrict tmptr);

size_t strftime_l(char *restrict buf, size_t maxsize,

const char *restrict format,
const struct tm *restrict tmptr, locale_t locale);

两个函数的返回值:若有空间,返回存入数组的字符数;否则,返回0

两个较早的函数——asctime和ctime能用于产生一个26字节的可打印的字符串,类似于date(1)命令默认的输出。然而,这些函数现在已经被标记为弃用,因为它们易受到缓冲区溢出问题的影响。

strftime_l允许调用者将区域指定为参数,除此之外,strftime和strftime_l函数是相同的。strftime使用通过TZ环境变量指定的区域。

tmptr参数是要格式化的时间值,由一个指向分解时间值tm结构的指针说明。格式化结果存放在一个长度为maxsize个字符的buf数组中,如果buf长度足以存放格式化结果及一个null终止符,则该函数返回在buf中存放的字符数(不包括null终止符);否则该函数返回0。

format参数控制时间值的格式。如同printf函数一样,转换说明的形式是百分号之后跟一个特定字符。format中的其他字符则按原样输出。两个连续的百分号在输出中产生一个百分号。与printf函数的不同之处是,每个转换说明产生一个不同的定长输出字符串,在format字符串中没有字段宽度修饰符。图6-10中列出了37种ISO C规定的转换说明。

图6-10 strftime的转换说明

图中第3列的数据来自于在Mac OS X中执行strftime函数所得的结果,它对应的时间和日期是:Thu Jan 19 21:24:52 EST 2012。

图 6-10中的大多数格式说明的意义很明显。需要略做解释的是%U、%V和%W。%U是相应日期在该年中所属周数,包含该年中第一个星期日的周是第一周。%W 也是相应日期在该年中所属的周数,不同的是包含第一个星期一的周为第一周。%V 说明符则与上述两者有较大区别。如果包含了1月1日的那一周包含了新一年的4天或更多天,那么该周是一年中的第一周;否则该周被认为是上一年的最后一周。在这两种情况下,周一都被视作每周的第一天。

同printf一样,strftime对某些转换说明支持修饰符。可以使用E和O修饰符产生本地支持的另一种格式。

某些系统对strftime的format字符串提供另一些非标准的扩充支持。

实例

图6-11演示了如何使用本章中讨论的多个时间函数。特别演示了如何使用strftime打印包含当前日期和时间的字符串。

图6-11 使用strftime函数

回顾图6-9中的不同时间函数的关系。在以人们可读的格式打印时间之前,需要获取时间并将其转换成分解的时间结构。图6-11程序的输出如下:

$ ./a.out

buffer length 16 is too small

time and date: 11:12:35 PM, Thu Jan 19, 2012

strptime函数是strftime的反过来版本,把字符串时间转换成分解时间。

#include <time.h>

char *strptime(const char *restrict buf, const char *restrict format,

struct tm *restrict tmptr);

返回值:指向上次解析的字符的下一个字符的指针;否则,返回NULL

format参数给出了buf参数指向的缓冲区内的字符串的格式。虽然与strftime函数的说明稍有不同,但格式说明是类似的。strptime函数转换说明符列在图6-12中。

图6-12 strptime函数的转换说明

我们曾在前面提及,图6-9中以虚线表示的3个函数受到环境变量TZ的影响。这3个函数是localtime、mktime和strftime。如果定义了TZ,则这些函数将使用其值代替系统默认时区。如果 TZ定义为空串(即TZ=),则使用协调统一时间UTC。TZ的值常常类似于TZ=EST5EDT,但是 POSIX.1 允许更详细的说明。有关 TZ 变量的详细情况,请参阅 Single UNIX Specification [Open Group 2010]中的环境变量章节。

关于TZ环境变量的更多信息可参见手册页tzset(3)。

6.11 小结

所有UNIX系统都使用口令文件和组文件。我们说明了读这些文件的各种函数。本章也介绍了阴影口令,它可以增加系统的安全性。附属组ID提供了一个用户同时可以参加多个组的方法。我们还介绍了大多数系统所提供的访问其他与系统有关数据文件的类似函数。我们讨论了几个POSIX.1的系统标识函数,应用程序使用它们以标识它在何种系统上运行。最后,说明了ISO C和Single UNIX Specification提供的与时间和日期有关的一些函数。

习题

6.1 如果系统使用阴影文件,那么如何取得加密口令?

6.2 假设你有超级用户权限,并且系统使用了阴影口令,重新考虑上一道习题。

6.3 编写一程序,它调用uname并输出utsname结构中的所有字段,将该输出与uname(1)命令的输出结果进行比较。

6.4 计算可由time_t数据类型表示的最近时间。如果超出了这一时间将会如何?

6.5 编写一程序,获取当前时间,并使用 strftime 将输出结果转换为类似于 date(1)命令的默认输出。将环境变量TZ设置为不同值,观察输出结果。

第7章 进程环境

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节将说明exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址——这是由连接编辑器设置的,而连接编辑器则由C编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。

7.3 进程终止

有8种方式使进程终止(termination),其中 5种为正常终止,它们是:(1)从main返回;

(2)调用exit;

(3)调用_exit或_Exit;

(4)最后一个线程从其启动例程返回(11.5节);

(5)从最后一个线程调用pthread_exit(11.5节)。

异常终止有3种方式,它们是:

(6)调用abort(10.17节);

(7)接到一个信号(10.2节);

(8)最后一个线程对取消请求做出响应(11.5节和12.7节)。

在第11章和第12章讨论线程之前,我们暂不考虑专门针对线程的3种终止方式。

上节提及的启动例程是这样编写的,使得从main返回后立即调用exit函数。如果将启动例程以C代码形式表示(实际上该例程常常用汇编语言编写),则它调用main函数的形式可能是:

exit(main(argc, argv));

1.退出函数

3个函数用于正常终止一个程序:_exit和_Exit立即进入内核,exit则先执行一些清理处理,然后返回内核。

#include <stdlib.h>

void exit(int status);

void _Exit(int status);

#include <unistd.h>

void _exit(int status);

我们将在8.5节中讨论这3个函数对其他进程(如正在终止进程的父进程和子进程)的影响。

使用不同头文件的原因是exit和_Exit是由ISO C说明的,而_exit是由POSIX.1说明的。

由于历史原因,exit 函数总是执行一个标准 I/O 库的清理关闭操作:对于所有打开流调用fclose函数。回忆5.5节,这造成输出缓冲中的所有数据都被冲洗(写到文件上)。

3个退出函数都带一个整型参数,称为终止状态(或退出状态,exit status)。大多 数UNIX系统shell都提供检查进程终止状态的方法。如果(a)调用这些函数时不带终止状态,或(b)main执行了一个无返回值的return语句,或(c)main没有声明返回类型为整型,则该进程的终止状态是未定义的。但是,若main的返回类型是整型,并且main执行到最后一条语句时返回(隐式返回),那么该进程的终止状态是0。

这种处理是ISO C标准1999版引入的。历史上,若main函数终止时没有显式使用return语句或调用exit函数,那么进程终止状态是未定义的。

main函数返回一个整型值与用该值调用exit是等价的。于是在main函数中

exit(0);

等价于

return(0);

实例

图7-1中的程序是经典的“hello, world”实例。

图7-1 经典C程序

对该程序进行编译,然后运行,则可见到其终止码是随机的。如果在不同的系统上编译该程序,我们很可能得到不同的终止码,这取决于main函数返回时栈和寄存器的内容:

$ gcc hello.c

$ ./a.out

hello, world

$ echo $?            打印终止状态

13

现在,我们启用1999 ISO C编译器扩展,则可见到终止码改变了:

$ gcc -std=c99 hello.c     启用 gcc的1999 ISO C扩展

$ echo $?            打印终止状态

hello.c:4: warning: return type defaults to 'int'

$ ./a.out

hello, world

0

注意,当我们启用1999 ISO C扩展时,编译器发出警告消息。打印该警告消息的原因是:main函数的类型没有显式地声明为整型。如果我们增加了这一声明,那么此警告消息就不会出现。但是,如果我们使编译器所推荐的警告消息都起作用(使用-Wall标志),则可能见到类似于“control reaches end of nonvoid function.”(控制到达非void函数的尾端)这样的警告消息。

将main声明为返回整型,但在main函数体内用exit代替return,对某些C编译器和UNIX lint(1)程序而言会产生不必要的警告信息,因为这些编译器并不了解main中的exit与return语句的作用相同。避开这种警告信息的一种方法是在main中使用return语句而不是exit。但是这样做的结果是不能用UNIX的grep实用程序来找出程序中所有的exit调用。另一个解决方法是将main说明为返回void而不是int,然后仍然调用exit。这样做可以避免编译器的警告,但从程序设计角度看却并不正确,而且会产生其他的编译警告,因为 main 的返回类型应当是带符号整型。本章将main表示为返回整型,因为这是ISO C和POSIX.1所定义的。

不同的编译器产生警告消息的详细程度是不一样的。除非使用警告选项,否则GNU C编译器不会发出不必要的警告消息。

下一章我们将了解进程如何造成程序被执行,如何等待进程完成,然后又如何获取其终止状态。

2.函数atexit

按照ISO C的规定,一个进程可以登记多至32个函数,这些函数将由exit自动调用。我们称这些函数为终止处理程序(exit handler),并调用atexit函数来登记这些函数。

#include <stdlib.h>

int atexit(void (*func)(void));

返回值:若成功,返回0;若出错,返回非0

其中,atexit 的参数是一个函数地址,当调用此函数时无需向它传递任何参数,也不期望它返回一个值。exit调用这些函数的顺序与它们登记时候的顺序相反。同一函数如若登记多次,也会被调用多次。

终止处理程序这一机制是由ANSI C标准于1989年引入的。早于ANSI C的系统,如SVR3和4.3BSD,都不提供这种终止处理程序。

ISO C要求,系统至少应支持32个终止处理程序,但实现经常会提供更多的支持(参见图2-15)。为了确定一个给定的平台支持的最大终止处理程序数,可以使用sysconf函数(如图2-14所示)。

根据ISO C和POSIX.1,exit首先调用各终止处理程序,然后关闭(通过fclose)所有打开流。POSIX.1扩展了ISO C标准,它说明,如若程序调用exec函数族中的任一函数,则将清除所有已安装的终止处理程序。图7-2显示了一个C程序是如何启动的,以及它终止的各种方式。

图7-2 一个C程序是如何启动和终止的

注意,内核使程序执行的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是显式或隐式地(通过调用 exit)调用_exit 或_Exit。进程也可非自愿地由一个信号使其终止(图 7-2中没有显示)。

实例

图7-3的程序说明如何使用atexit函数。

图7-3 终止处理程序实例

执行该程序产生:

$ ./a.out

main is done

first exit handler

first exit handler

second exit handler

终止处理程序每登记一次,就会被调用一次。在图7-3的程序中,第一个终止处理程序被登记两次,所以也会被调用两次。注意,在main中没有调用exit,而是用了return语句。

7.4 命令行参数

当执行一个程序时,调用exec的进程可将命令行参数传递给该新程序。这是UNIX shell的一部分常规操作。在前几章的很多实例中,我们已经看到了这一点。

实例

图7-4 所示的程序将其所有命令行参数都回显到标准输出上。注意,通常的 echo(1)程序不回显第0个参数。

图7-4 将所有命令行参数回显到标准输出

编译此程序,并将可执行代码文件命名为echoarg,则得到:

$ ./echoarg arg1 TEST foo

argv[0]: ./echoarg

argv[1]: arg1

argv[2]: TEST

argv[3]: foo

ISO C和POSIX.1都要求argv[argc]是一个空指针。这就使我们可以将参数处理循环改写为:

for (i = 0; argv[i] != NULL; i++)

7.5 环境表

每个程序都接收到一张环境表。与参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以null结束的C字符串的地址。全局变量environ则包含了该指针数组的地址:

extern char **environ;

例如,如果该环境包含5个字符串,那么它看起来如图7-5中所示。其中,每个字符串的结尾处都显式地有一个null字节。我们称environ为环境指针(environment pointer),指针数组为环境表,其中各指针指向的字符串为环境字符串。

图7-5 由5个字符串组成的环境

按照惯例,环境由

name = value

这样的字符串组成,如图7-5中所示。大多数预定义名完全由大写字母组成,但这只是一个惯例。

在历史上,大多数UNIX系统支持main函数带3个参数,其中第3个参数就是环境表地址:

int main(int argc, char *argv[], char *envp[]);

因为ISO C规定main函数只有两个参数,而且第3个参数与全局变量environ相比也没有带来更多益处,所以 POSIX.1 也规定应使用 environ 而不使用第 3 个参数。通常用 getenv 和putenv函数(见7.9节)来访问特定的环境变量,而不是用environ变量。但是,如果要查看整个环境,则必须使用environ指针。

7.6 C程序的存储空间布局

历史沿袭至今,C程序一直由下列几部分组成:

•正文段。这是由CPU执行的机器指令部分。通常,正文段是可共享的,所以即使是频繁执行的程序(如文本编辑器、C编译器和shell等)在存储器中也只需有一个副本,另外,正文段常常是只读的,以防止程序由于意外而修改其指令。

•初始化数据段。通常将此段称为数据段,它包含了程序中需明确地赋初值的变量。例如, C程序中任何函数之外的声明:

int maxcount = 99;

使此变量以其初值存放在初始化数据段中。

•未初始化数据段。通常将此段称为bss段,这一名称来源于早期汇编程序一个操作符,意思是“由符号开始的块”(block started by symbol),在程序开始执行之前,内核将此段中的数据初始化为0或空指针。函数外的声明:

long sum[1000];

使此变量存放在非初始化数据段中。

•栈。自动变量以及每次函数调用时所需保存的信息都存放在此段中。每次函数调用时,其返回地址以及调用者的环境信息(如某些机器寄存器的值)都存放在栈中。然后,最近被调用的函数在栈上为其自动和临时变量分配存储空间。通过以这种方式使用栈,C递归函数可以工作。递归函数每次调用自身时,就用一个新的栈帧,因此一次函数调用实例中的变量集不会影响另一次函数调用实例中的变量。

•堆。通常在堆中进行动态存储分配。由于历史上形成的惯例,堆位于未初始化数据段和栈之间。

图 7-6 显示了这些段的一种典型安排方式。这是程序的逻辑布局,虽然并不要求一个具体实现一定以这种方式安排其存储空间,但这是一种我们便于说明的典型安排。对于 32 位 Intel x86 处理器上的 Linux,正文段从 0x08048000 单元开始,栈底则在0xC0000000 之下开始(在这种特定结构中,栈从高地址向低地址方向增长)。堆顶和栈顶之间未用的虚地址空间很大。

图7-6 典型的存储空间安排

a.out中还有若干其他类型的段,如包含符号表的段、包含调试信息的段以及包含动态共享库链接表的段等。这些部分并不装载到进程执行的程序映像中。

从图7-6还可注意到,未初始化数据段的内容并不存放在磁盘程序文件中。其原因是,内核在程序开始运行前将它们都设置为 0。需要存放在磁盘程序文件中的段只有正文段和初始化数据段。

size(1)命令报告正文段、数据段和bss段的长度(以字节为单位)。例如:

$ size /usr/bin/cc /bin/sh

text  data   bss   dec  hex  filename

346919  3576  6680  357175  57337  /usr/bin/cc

102134  1776  11272  115182  1c1ee  /bin/sh

第4列和第5列是分别以十进制和十六进制表示的3段总长度。

7.7 共享库

现在,大多数UNIX系统支持共享库。Arnold[1986]说明了System V上共享库的一个早期实现,Gingell等[1987]则说明了SunOS上的另一个实现。共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种库例程的一个副本。程序第一次执行或者第一次调用某个库函数时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑(假定参数的数目和类型都没有发生改变)。

在不同的系统中,程序可能使用不同的方法说明是否要使用共享库。比较典型的有 cc(1)和ld(1)命令的选项。作为长度方面发生变化的例子,先用无共享库方式创建下列可执行文件(典型的hello.c程序):

$ gcc -static hello1.c     阻止gcc 使用共享库

-rwxrwxr-x 1 sar    879443 Sep 2 10:39 a.out

text  data   bss   dec  hex  filename

787775  6128  11272 805175 c4937  a.out

$ ls -l a.out

$ size a.out

如果再使用共享库编译此程序,则可执行文件的正文和数据段的长度都显著减小:

$ gcc hello1.c         gcc 默认使用共享库

-rwxrwxr-x 1 sar    8378 Sep 2 10:39 a.out

text  data  bss  dec   hex  filename
1176   504   16  1696   6a0  a.out

$ ls -l a.out

$ size a.out

7.8 存储空间分配

ISO C说明了3个用于存储空间动态分配的函数。

(1)malloc,分配指定字节数的存储区。此存储区中的初始值不确定。

(2)calloc,为指定数量指定长度的对象分配存储空间。该空间中的每一位(bit)都初始化为0。

(3)realloc,增加或减少以前分配区的长度。当增加长度时,可能需将以前分配区的内容

移到另一个足够大的区域,以便在尾端提供增加的存储区,而新增区域内的初始值则不确定。

#include <stdlib.h>

void *malloc(size_t size);

void *calloc(size_t nobj, size_t size);

void *realloc(void *ptr, size_t newsize);

3个函数返回值:若成功,返回非空指针;若出错,返回NULL

void free(void *ptr);

这3个分配函数所返回的指针一定是适当对齐的,使其可用于任何数据对象。例如,在一个特定的系统上,如果最苛刻的对齐要求是,double必须在8的倍数地址单元处开始,那么这3个函数返回的指针都应这样对齐。

因为这 3 个 alloc 函数都返回通用指针 void *,所以如果在程序中包括了#include<stdlib.h>(以获得函数原型),那么当我们将这些函数返回的指针赋予一个不同类型的指针时,就不需要显式地执行强制类型转换。未声明函数的默认返回值为int,所以使用没有正确函数声明的强制类型转换可能会隐藏系统错误,因为int类型的长度与函数返回类型值的长度不同(本例中是指针)。

函数free 释放ptr指向的存储空间。被释放的空间通常被送入可用存储区池,以后,可在调用上述3个分配函数时再分配。

realloc函数使我们可以增、减以前分配的存储区的长度(最常见的用法是增加该区)。例如,如果先为一个数组分配存储空间,该数组长度为 512,然后在运行时填充它,但运行一段时间后发现该数组原先的长度不够用,此时就可调用 realloc 扩充相应存储空间。如果在该存储区后有足够的空间可供扩充,则可在原存储区位置上向高地址方向扩充,无需移动任何原先的内容,并返回与传给它相同的指针值。如果在原存储区后没有足够的空间,则 realloc 分配另一个足够大的存储区,将现存的512个元素数组的内容复制到新分配的存储区。然后,释放原存储区,返回新分配区的指针。因为这种存储区可能会移动位置,所以不应当使任何指针指在该区中。习题4.16和图C-3显示了在getcwd中如何使用realloc,以处理任何长度的路径名。图17-27的程序是使用realloc的另一个例子,用其可以避免使用编译时固定长度的数组。

注意,realloc的最后一个参数是存储区的新长度,不是新、旧存储区长度之差。作为一个特例,若ptr是一个空指针,则realloc的功能与malloc相同,用于分配一个指定长度为newsize的存储区。

这些函数的早期版本允许调用realloc分配自上次malloc、realloc或calloc调用以来所释放的块。这种技巧可回溯到 V7,它利用 malloc 的搜索策略,实现存储器紧缩。Solaris仍支持这一功能,而很多其他平台则不支持。这种功能不被赞同,不应再使用。

这些分配例程通常用sbrk(2)系统调用实现。该系统调用扩充(或缩小)进程的堆(见图7-6)。malloc和free的一个样例实现请见Kernighan和Ritchie[1988]的8.7节。

虽然sbrk可以扩充或缩小进程的存储空间,但是大多数malloc和free的实现都不减小进程的存储空间。释放的空间可供以后再分配,但将它们保持在malloc池中而不返回给内核。

大多数实现所分配的存储空间比所要求的要稍大一些,额外的空间用来记录管理信息——分配块的长度、指向下一个分配块的指针等。这就意味着,如果超过一个已分配区的尾端或者在已分配区起始位置之前进行写操作,则会改写另一块的管理记录信息。这种类型的错误是灾难性的,但是因为这种错误不会很快就暴露出来,所以也就很难发现。

在动态分配的缓冲区前或后进行写操作,破坏的可能不仅仅是该区的管理记录信息。在动态分配的缓冲区前后的存储空间很可能用于其他动态分配的对象。这些对象与破坏它们的代码可能无关,这造成寻求信息破坏的源头更加困难。

其他可能产生的致命性的错误是:释放一个已经释放了的块;调用free时所用的指针不是3个alloc函数的返回值等。如若一个进程调用malloc函数,但却忘记调用free函数,那么该进程占用的存储空间就会连续增加,这被称为泄漏(leakage)。如果不调用free函数释放不再使用的空间,那么进程地址空间长度就会慢慢增加,直至不再有空闲空间。此时,由于过度的换页开销,会造成性能下降。

因为存储空间分配出错很难跟踪,所以某些系统提供了这些函数的另一种实现版本。每次调用这3个分配函数中的任意一个或free时,它们都进行附加的检错。在调用连接编辑器时指定一个专用库,在程序中就可使用这种版本的函数。此外还有公共可用的资源,在对其进行编译时使用一个特殊标志就会使附加的运行时检查生效。

FreeBSD、Mac OS X以及Linux通过设置环境变量支持附加的调试功能。另外,通过符号链接/etc/malloc.conf可将选项传递给FreeBSD函数库。

替代的存储空间分配程序

有很多可替代malloc和free的函数。某些系统已经提供替代存储空间分配函数的库。另一些系统只提供标准的存储空间分配程序。如果需要,软件开发者可以下载替代函数。下面讨论某些替代函数和库。

1.libmalloc

基于SVR4的UNIX系统,如Solaries,包含了libmalloc库,它提供了一套与ISO C存储空间分配函数相匹配的接口。libmalloc库包括mallopt函数,它使进程可以设置一些变量,并用它们来控制存储空间分配程序的操作。还可使用另一个名为mallinfo的函数,以对存储空间分配程序的操作进行统计。

2.vmalloc

Vo[1996]说明一种存储空间分配程序,它允许进程对于不同的存储区使用不同的技术。除了一些vmalloc特有的函数外,该库也提供了ISO C存储空间分配函数的仿真器。

3.quick-fit

历史上所使用的标准 malloc 算法是最佳适配或首次适配存储分配策略。quick-fit(快速适配)算法比上述两种算法快,但可能使用较多存储空间。Weinstock和Wulf[1988]对该算法进行了描述,该算法基于将存储空间分裂成各种长度的缓冲区,并将未使用的缓冲区按其长度组成不同的空闲区列表。现在许多分配程序都基于快速适配。

4.jemalloc

jemalloc函数实现是FreeBSD 8.0中的默认存储空间分配程序,它是库函数malloc族在FreeBSD中的实现。它的设计具有良好的可扩展性,可用于多处理器系统中使用多线程的应用程序。Evans[2006]说明了具体实现及其性能评估。

5.TCMalloc

TCMalloc函数用于替代malloc函数族以提供高性能、高扩展性和高存储效率。从高速缓存中分配缓冲区以及释放缓冲区到高速缓存中时,它使用线程-本地高速缓存来避免锁开销。它还有内置的堆检查程序和堆分析程序帮助调试和分析动态存储的使用。TCMalloc库是开源可用的,是Google-perftools工具中的一个。Ghemawat和Menage[2005]对此做了简单介绍。

6.函数alloca

还有一个函数也值得一提,这就是alloca。它的调用序列与malloc相同,但是它是在当前函数的栈帧上分配存储空间,而不是在堆中。其优点是:当函数返回时,自动释放它所使用的栈帧,所以不必再为释放空间而费心。其缺点是:alloca 函数增加了栈帧的长度,而某些系统在函数已被调用后不能增加栈帧长度,于是也就不能支持alloca函数。尽管如此,很多软件包还是使用alloca函数,也有很多系统实现了该函数。

本书中讨论的4个平台都提供了alloca函数。

7.9 环境变量

如同前述,环境字符串的形式是:

name=value

UNIX内核并不查看这些字符串,它们的解释完全取决于各个应用程序。例如,shell使用了大量的环境变量。其中某一些在登录时自动设置(如HOME、USER等),有些则由用户设置。我们通常在一个shell启动文件中设置环境变量以控制shell的动作。例如,若设置了环境变量MAILPATH,则它告诉Bourne shell、GNU Bourne-again shell和Korn shell到哪里去查看邮件。

ISO C定义了一个函数getenv,可以用其取环境变量值,但是该标准又称环境的内容是由实现定义的。

#include <stdlib.h>

char *getenv(const char *name);

返回值:指向与name关联的value的指针;若未找到,返回NULL

注意,此函数返回一个指针,它指向name=value字符串中的value。我们应当使用getenv从环境中取一个指定环境变量的值,而不是直接访问environ。

Single UNIX Specification中的POSIX.1定义了某些环境变量。如果支持XSI扩展,那么其中也包含了另外一些环境变量定义。图7-7列出了由Single UNIX Specification定义的环境变量,并指明本书讨论的4种实现对它们的支持情况。由POSIX.1定义的各环境变量标记为•,否则为XSI扩展。本书讨论的4种UNIX实现使用了很多依赖于实现的环境变量。注意,ISO C没有定义任何环境变量。

图7-7 Single UNIX Specification定义的环境变量

除了获取环境变量值,有时也需要设置环境变量。我们可能希望改变现有变量的值,或者是增加新的环境变量。(在下一章将会了解到,我们能影响的只是当前进程及其后生成和调用的任何子进程的环境,但不能影响父进程的环境,这通常是一个shell进程。尽管如此,修改环境表的能力仍然是很有用的。)遗憾的是,并不是所有系统都支持这种能力。图7-8列出了由不同的标准及实现支持的各种函数。

图7-8 对于各种环境表函数的支持

clearenv不是Single UNIX Specification的组成部分。它被用来删除环境表中的所有项。在图7-8中,中间3个函数的原型是:

#include <stdlib.h>

int putenv(char *str);

函数返回值:若成功,返回0;若出错,返回非0

int setenv(const char *name, const char *value, int rewrite);

int unsetenv(const char *name);

两个函数返回值:若成功,返回0;若出错,返回−1

这3个函数的操作如下。

•putenv取形式为name=value的字符串,将其放到环境表中。如果name已经存在,则先删除其原来的定义。

•setenv将name设置为value。如果在环境中name已经存在,那么(a)若rewrite非0,则首先删除其现有的定义;(b)若rewrite为0,则不删除其现有定义(name不设置为新的value,而且也不出错)。

•unsetenv删除name的定义。即使不存在这种定义也不算出错。

注意,putenv和setenv之间的差别。setenv必须分配存储空间,以便依据其参数创建name=value字符串。putenv可以自由地将传递给它的参数字符串直接放到环境中。确实,许多实现就是这么做的,因此,将存放在栈中的字符串作为参数传递给putenv就会发生错误,其原因是,从当前函数返回时,其栈帧占用的存储区可能将被重用。

这些函数在修改环境表时是如何进行操作的呢?对这一问题进行研究、考察是非常有益的。回忆图7-6,其中,环境表(指向实际name=value字符串的指针数组)和环境字符串通常存放在进程存储空间的顶部(栈之上)。删除一个字符串很简单——只要先在环境表中找到该指针,然后将所有后续指针都向环境表首部顺次移动一个位置。但是增加一个字符串或修改一个现有的字符串就困难得多。环境表和环境字符串通常占用的是进程地址空间的顶部,所以它不能再向高地址方向(向上)扩展:同时也不能移动在它之下的各栈帧,所以它也不能向低地址方向(向下)扩展。两者组合使得该空间的长度不能再增加。

(1)如果修改一个现有的name:

a.如果新value的长度少于或等于现有value的长度,则只要将新字符串复制到原字符串所用的空间中;

b.如果新value的长度大于原长度,则必须调用malloc为新字符串分配空间,然后将新字符串复制到该空间中,接着使环境表中针对name的指针指向新分配区。

(2)如果要增加一个新的name,则操作就更加复杂。首先,必须调用malloc为name=value字符串分配空间,然后将该字符串复制到此空间中。

a.如果这是第一次增加一个新name,则必须调用malloc为新的指针表分配空间。接着,将原来的环境表复制到新分配区,并将指向新name=value字符串的指针存放在该指针表的表尾,然后又将一个空指针存放在其后。最后使environ指向新指针表。再看一下图7-6,如果原来的环境表位于栈顶之上(这是一种常见情况),那么必须将此表移至堆中。

但是,此表中的大多数指针仍指向栈顶之上的各name=value字符串。

b.如果这不是第一次增加一个新name,则可知以前已调用malloc在堆中为环境表分配了空间,所以只要调用 realloc,以分配比原空间多存放一个指针的空间。然后将指向新name=value字符串的指针存放在该表表尾,后面跟着一个空指针。

7.10 函数setjmp和longjmp

在C中,goto语句是不能跨越函数的,而执行这种类型跳转功能的是函数setjmp和longjmp。这两个函数对于处理发生在很深层嵌套函数调用中的出错情况是非常有用的。

考虑图7-9程序的骨架部分。其主循环是从标准输入读一行,然后调用do_line处理该输入行。do_line函数调用get_token从该输入行中取下一个标记。一行中的第一个标记假定是一条某种形式的命令,switch语句就实现命令选择。对程序中示例的命令调用cmd_add函数。

图7-9 进行命令处理程序的典型骨架部分

图7-9的程序的骨架部分在读命令、确定命令的类型,然后调用相应函数处理每一条命令这类程序中是非常典型的。图7-10显示了调用了cmd_add之后栈的大致使用情况。

自动变量的存储单元在每个函数的栈桢中。数组line在main的栈帧中,整型cmd在do_line的栈帧中,整型token在cmd_add的栈帧中。

如上所述,这种形式的栈安排是非常典型的,但并不要求非如此不可。栈并不一定要向低地址方向扩充。某些系统对栈并没有提供特殊的硬件支持,此时一个 C实现可能要用链表实现栈帧。

在编写图7-9 这样的程序时经常会遇到的一个问题是,如何处理非致命性的错误。例如,若 cmd_add 函数发现一个错误(比如一个无效的数),那么它可能先打印一个出错消息,然后忽略输入行的余下部分,返回main函数并读下一输入行。但是如果这种情况出现在main函数中的深层嵌套层中时,用C语言难以做到这一点(在本例中,cmd_add函数只比main低两个层次,在有些程序中往往低5个层次或更多)。如果我们不得不以检查返回值的方法逐层返回,那就会变得很麻烦。

图7-10 调用cmd_add后的各个栈帧

解决这种问题的方法就是使用非局部goto——setjmp和longjmp函数。非局部指的是,这不是由普通的C语言goto语句在一个函数内实施的跳转,而是在栈上跳过若干调用帧,返回到当前函数调用路径上的某一个函数中。

#include <setjmp.h>

int setjmp(jmp_buf env);

返回值:若直接调用,返回0;若从longjmp返回,则为非0

void longjmp(jmp_buf env, int val);

在希望返回到的位置调用setjmp,在本例中,此位置在main函数中。因为我们直接调用该函数,所以其返回值为0。setjmp参数env的类型是一个特殊类型jmp_buf。这一数据类型是某种形式的数组,其中存放在调用 longjmp 时能用来恢复栈状态的所有信息。因为需在另一个函数中引用env变量,所以通常将env变量定义为全局变量。

当检查到一个错误时,例如在cmd_add函数中,则以两个参数调用longjmp函数。第一个就是在调用setjmp时所用的env;第二个参数是具非0值的val,它将成为从setjmp处返回的值。使用第二个参数的原因是对于一个setjmp可以有多个longjmp。例如,可以在cmd_add中以val为1调用longjmp,也可在get_token中以val为2调用longjmp。在main函数中,setjmp的返回值就会是1或2,通过测试返回值就可判断造成返回的longjmp是在cmd_add还是在get_token中。

再回到程序实例中,图7-11中给出了经修改过后的main和cmd_add函数(其他两个函数do_line和get_token未更改)。

图7-11 setjmp和longjmp实例

执行main时,调用setjmp,它将所需的信息记入变量jmpbuffer中并返回0。然后调用do_line,它又调用cmd_add,假定在其中检测到一个错误。在 cmd_add 中调用 longjmp 之前,栈如图 7-10 中所示。但是longjmp使栈反绕到执行main函数时的情况,也就是抛弃了cmd_add和do_line的栈帧(见图 7-12)。调用 longjmp 造成 main 中setjmp 的返回,但是,这一次的返回值是 1 (longjmp的第二个参数)。

图7-12 在调用longjmp后的栈帧

1.自动变量、寄存器变量和易失变量

我们已经了解在调用 longjmp 后栈帧的基本结构,下一个问题是:“在main函数中,自动变量和寄存器变量的状态如何?”当longjmp返回到main 函数时,这些变量的值是否能恢复到以前调用setjmp时的值(即回滚到原先值),或者这些变量的值保持为调用do_line时的值(do_line调用cmd_add,cmd_add 又调用longjmp)?遗憾的是,对此问题的回答是“看情况”。大多数实现并不回滚这些自动变量和寄存器变量的值,而所有标准则称它们的值是不确定的。如果你有一个自动变量,而又不想使其值回滚,则可定义其为具有volatile属性。声明为全局变量或静态变量的值在执行longjmp时保持不变。

实例

下面我们通过图7-13程序说明在调用longjmp后,自动变量、全局变量、寄存器变量、静态变量和易失变量的不同情况。

图7-13 longjmp对各类变量的影响

如果以不带优化和带优化选项对此程序分别进行编译,然后运行它们,则得到的结果是不同的:

$ gcc testjmp.c       不进行任何优化的编译

$ ./a.out

in f1():

globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99

after longjmp:

globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99

$ gcc -O testjmp.c     进行全部优化的编译

$ ./a.out

in f1():

globval = 95, autoval = 96, regival = 97, volaval = 98, statval = 99

after longjmp:

globval = 95, autoval = 2, regival = 3, volaval = 98, statval = 99

注意,全局变量、静态变量和易失变量不受优化的影响,在 longjmp 之后,它们的值是最近所呈现的值。在某个系统的setjmp(3)手册页上说明,存放在存储器中的变量将具有longjmp时的值,而在CPU和浮点寄存器中的变量则恢复为调用setjmp时的值。这确实就是运行图7-13程序时所观察到的值。不进行优化时,所有这5个变量都存放在存储器中(即忽略了对regival变量的register存储类说明)。而进行了优化后,autoval和regival都存放在寄存器中(即使autoval并未说明为register),volatile变量则仍存放在存储器中。通过这一实例我们可以理解到,如果要编写一个使用非局部跳转的可移植程序,则必须使用volatile属性。但是从一个系统移植到另一个系统,其他任何事情都可能改变。

在图7-13中,某些printf的格式字符串可能不适宜安排在程序文本的一行中。我们没有将其分成多个printf调用,而是使用了ISO C的字符串连接功能,于是两个字符串序列

"string1" "string2"

等价于

"string1string2"

第 10 章讨论信号处理程序及 sigsetjmp 和 siglongjmp 时,将再次涉及 setjmp 和longjmp函数。

2.自动变量的潜在问题

前面已经说明了处理栈帧的一般方式,现在值得分析一下自动变量的一个潜在出错情况。基本规则是声明自动变量的函数已经返回后,不能再引用这些自动变量。在整个UNIX手册中,关于这一点有很多警告。

图7-14中给出了一个名为open_data的函数,它打开了一个标准I/O流,然后为该流设置缓冲。

图7-14 自动变量的不正确使用

问题是:当open_data返回时,它在栈上所使用的空间将由下一个被调用函数的栈帧使用。但是,标准I/O库函数仍将使用这部分存储空间作为该流的缓冲区。这就产生了冲突和混乱。为了改正这一问题,应在全局存储空间静态地(如static或extern)或者动态地(使用一种alloc函数)为数组databuf分配空间。

7.11 函数getrlimit和setrlimit

每个进程都有一组资源限制,其中一些可以用getrlimit和setrlimit函数查询和更改。

#include <sys/resource.h>

int getrlimit(int resource, struct rlimit *rlptr);

int setrlimit(int resource, const struct rlimit *rlptr);

两个函数返回值:若成功,返回0;若出错,返回非0

这两个函数在Single UNIX Specification的XSI扩展中定义。进程的资源限制通常是在系统初始化时由0进程建立的,然后由后续进程继承。每种实现都可以用自己的方法对资源限制做出调整。

对这两个函数的每一次调用都指定一个资源以及一个指向下列结构的指针。

struct rlimit {

rlim_t rlim_cur; /* soft limit: current limit */
rlim_t rlim_max; /* hard limit: maximum value for rlim_cur */

};

在更改资源限制时,须遵循下列3条规则。

(1)任何一个进程都可将一个软限制值更改为小于或等于其硬限制值。

(2)任何一个进程都可降低其硬限制值,但它必须大于或等于其软限制值。这种降低,对普通用户而言是不可逆的。

(3)只有超级用户进程可以提高硬限制值。

常量RLIM_INFINITY指定了一个无限量的限制。

这两个函数的 resource 参数取下列值之一。图 7-15 显示哪些资源限制是由 Single UNIX Specification定义并由本书讨论的4种UNIX系统实现支持的。

图7-15 对资源限制的支持

RLIMIT_AS 进程总的可用存储空间的最大长度(字节)。这影响到 sbrk 函数(1.11节)和mmap函数(14.8节)。

RLIMIT_CORE core文件的最大字节数,若其值为0则阻止创建core文件。

RLIMIT_CPU CPU时间的最大量值(秒),当超过此软限制时,向该进程发送SIGXCPU信号。

RLIMIT_DATA 数据段的最大字节长度。这是图 7-6 中初始化数据、非初始以及堆的总和。

RLIMIT_FSIZE 可以创建的文件的最大字节长度。当超过此软限制时,则向该进程发送SIGXFSZ信号。

RLIMIT_MEMLOCK 一个进程使用mlock(2)能够锁定在存储空间中的最大字节长度。

RLIMIT_MSGQUEUE 进程为POSIX消息队列可分配的最大存储字节数。

RLIMIT_NICE 为了影响进程的调度优先级,nice值(8.16节)可设置的最大限制。

RLIMIT_NOFILE 每个进程能打开的最多文件数。更改此限制将影响到sysconf函数在参数_SC_OPEN_MAX中返回的值(见2.5.4节),亦见图2-17。

RLIMIT_NPROC 每个实际用户 ID 可拥有的最大子进程数。更改此限制将影响到sysconf函数在参数_SC_CHILD_MAX中返回的值(见2.5.4节)。

RLIMIT_NPTS 用户可同时打开的伪终端(第19章)的最大数量。

RLIMIT_RSS 最大驻内存集字节长度(resident set size in bytes,RSS)。如果可用的物理存储器非常少,则内核将从进程处取回超过RSS的部分。

RLIMIT_SBSIZE 在任一给定时刻,一个用户可以占用的套接字缓冲区的最大长度(字节)。

RLIMIT_SIGPENDING 一个进程可排队的信号最大数量。这个限制是sigqueue函数实施的(10.20节)。

RLIMIT_STACK 栈的最大字节长度。见图7-6。

RLIMIT_SWAP 用户可消耗的交换空间的最大字节数

RLIMIT_VMEM 这是RLIMIT_AS的同义词。

资源限制影响到调用进程并由其子进程继承。这就意味着,为了影响一个用户的所有后续进程,需将资源限制的设置构造在shell之中。确实,Bourne shell、GNU Bourne-again shell和Korn shell具有内置的ulimit命令,C shell具有内置limit命令。(umask和chdir函数也必须是shell内置的。)

实例

图7-16的程序打印由系统支持的所有资源当前的软限制和硬限制。为了在各种实现上编译该程序,我们已经条件地包括了各种不同的资源名。注意,有些平台定义rlim_t为unsigned long long 而非 unsigned long。在同一系统中这个定义可能也会变动,这取决于我们在编译程序候是否支持 64 位文件。有些限制作用于文件大小,因此 rlim_t 类型必须足够大才能表示文件大小限制。为了避免使用错误的格式说明而导致编译器警告,通常会首先把限制复制到 64 位整型,这样只需处理一种格式。

图7-16 打印当前资源限制

注意,在doit宏中使用了ISO C的字符串创建算符(#),以便为每个资源名产生字符串值。例如:

doit(RLIMIT_CORE);

这将由C预处理程序扩展为:

pr_limits("RLIMIT_CORE", RLIMIT_CORE);

在FreeBSD下运行此程序,得到:

$ ./a.out

RLIMIT_AS      (infinite) (infinite)

RLIMIT_CORE     (infinite) (infinite)

RLIMIT_CPU (infinite) (infinite)

RLIMIT_DATA      536870912 536870912

RLIMIT_FSIZE     (infinite) (infinite)

RLIMIT_MEMLOCK    (infinite) (infinite)

RLIMIT_NOFILE       3520    3520

RLIMIT_NPROC        1760    1760

RLIMIT_NPTS     (infinite) (infinite)

RLIMIT_RSS      (infinite) (infinite)

RLIMIT_SBSIZE    (infinite) (infinite)

RLIMIT_STACK      67108864  67108864

RLIMIT_SWAP     (infinite) (infinite)

RLIMIT_VMEM (infinite) (infinite)

在Solaris下运行此程序,得到:

$ ./a.out

RLIMIT_AS (infinite) (infinite)

RLIMIT_CORE (infinite) (infinite)

RLIMIT_CPU (infinite) (infinite)

RLIMIT_DATA (infinite) (infinite)

RLIMIT_FSIZE (infinite) (infinite)

RLIMIT_NOFILE 256 65536

RLIMIT_STACK 8388608 (infinite)

RLIMIT_VMEM (infinite) (infinite)

在介绍了信号机制后,习题10.11将继续讨论资源限制。

7.12 小结

理解UNIX系统环境中C程序的环境是理解UNIX系统进程控制特性的先决条件。本章说明了一个进程是如何启动和终止的,如何向其传递参数表和环境。虽然参数表和环境都不是由内核进行解释的,但内核起到了从exec的调用者将这两者传递给新进程的作用。

本章也说明了C程序的典型存储空间布局,以及一个进程如何动态地分配和释放存储空间。详细地了解用于维护环境的一些函数是有意义的,因为它们涉及存储空间分配。本章也介绍了setjmp 和 longjmp 函数,它们提供了一种在进程内非局部转移的方法。最后介绍了各种实现提供的资源限制功能。

习题

7.1 在Intel x86系统上,使用Linux,如果执行一个输出“hello, world”的程序但不调用exit或return,则程序的返回代码为13(用shell检查),解释其原因。

7.2 图7-3中的printf函数的结果何时才被真正输出?

7.3 是否有方法不使用(a)参数传递、(b)全局变量这两种方法,将main中的参数argc和argv传递给它所调用的其他函数?

7.4 在有些 UNIX 系统实现中执行程序时访问不到其数据段的 0 单元,这是一种有意的安排,为什么?

7.5 用C语言的typedef为终止处理程序定义了一个新的数据类型Exitfunc,使用该类型修改atexit的原型。

7.6 如果用calloc分配一个long型的数组,数组的初始值是否为0?如果用calloc分配一个指针数组,数组的初始值是否为空指针?

7.7 在7.6节结尾处size命令的输出结果中,为什么没有给出堆和栈的大小?

7.8 为什么7.7节中两个文件的大小(879 443和8 378)不等于它们各自文本和数据大小的和?

7.9 为什么7.7节中对于一个简单的程序,使用共享库以后其可执行文件的大小变化如此巨大?

7.10 在7.10节中我们已经说明为什么不能将一个指针返回给一个自动变量,下面的程序是否正确?

int

f1(int val)

{

}

int   num = 0;
int   *ptr = &num;
if (val == 0) {
int   val;
val = 5;
ptr = &val;
}
return(*ptr + 1);

第8章 进程控制

8.1 引言

本章介绍UNIX系统的进程控制,包括创建新进程、执行程序和进程终止。还将说明进程属性的各种ID—实际、有效和保存的用户ID和组ID,以及它们如何受到进程控制原语的影响。本章还包括了解释器文件和system函数。本章最后讲述大多数UNIX系统所提供的进程会计机制,这种机制使我们能够从另一个角度了解进程的控制功能。

8.2 进程标识

每个进程都有一个非负整型表示的唯一进程ID。因为进程ID标识符总是唯一的,常将其用作其他标识符的一部分以保证其唯一性。例如,应用程序有时就把进程 ID 作为名字的一部分来创建一个唯一的文件名。

虽然是唯一的,但是进程ID是可复用的。当一个进程终止后,其进程ID就成为复用的候选者。大多数UNIX 系统实现延迟复用算法,使得赋予新建进程的 ID 不同于最近终止进程所使用的ID。这防止了将新进程误认为是使用同一ID的某个已终止的先前进程。

系统中有一些专用进程,但具体细节随实现而不同。ID为 0的进程通常是调度进程,常常被称为交换进程(swapper)。该进程是内核的一部分,它并不执行任何磁盘上的程序,因此也被称为系统进程。进程ID 1通常是init进程,在自举过程结束时由内核调用。该进程的程序文件在UNIX的早期版本中是/etc/init,在较新版本中是/sbin/init。此进程负责在自举内核后启动一个UNIX系统。init通常读取与系统有关的初始化文件(/etc/rc*文件或/etc/inittab文件,以及在/etc/init.d中的文件),并将系统引导到一个状态(如多用户)。init 进程决不会终止。它是一个普通的用户进程(与交换进程不同,它不是内核中的系统进程),但是它以超级用户特权运行。本章稍后部分会说明init如何成为所有孤儿进程的父进程。

在Mac OS X 10.4中,init进程被launchd进程替代,执行的任务集与init相同,但扩展了功能。可参阅Singh[2006]在5.10节中的讨论来了解launchd是如何操作的。

每个UNIX系统实现都有它自己的一套提供操作系统服务的内核进程,例如,在某些UNIX的虚拟存储器实现中,进程ID 2是页守护进程(page daemon),此进程负责支持虚拟存储器系统的分页操作。

除了进程ID,每个进程还有一些其他标识符。下列函数返回这些标识符。

#include <unistd.h>

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。在4.4节中已讨论了实际和有效用户ID及组ID。

8.3 函数fork

一个现有的进程可以调用fork函数创建一个新进程。

#include <unistd.h>

pid_t fork(void);

返回值:子进程返回0,父进程返回子进程ID;若出错,返回−1

由fork创建的新进程被称为子进程(child process)。fork函数被调用一次,但返回两次。两次返回的区别是子进程的返回值是 0,而父进程的返回值则是新建子进程的进程 ID。将子进程ID返回给父进程的理由是:因为一个进程的子进程可以有多个,并且没有一个函数使一个进程可以获得其所有子进程的进程 ID。fork 使子进程得到返回值 0 的理由是:一个进程只会有一个父进程,所以子进程总是可以调用 getppid 以获得其父进程的进程 ID(进程ID 0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0)。

子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本。例如,子进程获得父进程数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段(见7.6节)。

由于在fork之后经常跟随着exec,所以现在的很多实现并不执行一个父进程数据段、栈和堆的完全副本。作为替代,使用了写时复制(Copy-On-Write,COW)技术。这些区域由父进程和子进程共享,而且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。Bach[1986]的9.2节和 McKusick等[1996]的5.6节和5.7节对这种特征做了更详细的说明。

某些平台提供 fork 函数的几种变体。本书讨论的 4 种平台都支持下节将要讨论的vfork(2)。

Linux 3.2.0 提供了另一种新进程创建函数—clone(2)系统调用。这是一种fork的推广形式,它允许调用者控制哪些部分由父进程和子进程共享。

FreeBSD 8.0提供了rfork(2)系统调用,它类似于Linux的clone系统调用。rfork调用是从Plan 9操作系统(Pike等[1995])派生出来的。

Solaris 10提供了两个线程库:一个用于POSIX线程(pthreads),另一个用于Solaris线程。在这两个线程库中,fork 的行为有所不同。对于 POSIX 线程,fork 创建一个进程,它仅包含调用该fork的线程,但对于Solaris线程,fork创建的进程包含了调用线程所在进程的所有线程的副本。在Solaris 10中,这种行为改变了。不管使用哪种线程库,fork创建的子进程只保留调用线程的副本。Solaris也提供了fork1函数,它创建的进程只复制调用线程。还有forkall函数,它创建的进程复制了进程中所有的线程。第11章和第12章将详细讨论线程。

实例

图8-1程序演示了fork函数,从中可以看到子进程对变量所做的改变并不影响父进程中该变量的值。

如果执行此程序则得到:

图8-1 fork函数实例

$ ./a.out

a write to stdout

before fork

pid = 430, glob = 7, var = 89 子进程的变量值改变了

pid = 429, glob = 6, var = 88 父进程的变量值没有改变

$ a.out > temp.out

$ cat temp.out

a write to stdout

before fork

pid = 432, glob = 7, var = 89

before fork

pid = 431, glob = 6, var = 88

一般来说,在fork之后是父进程先执行还是子进程先执行是不确定的,这取决于内核所使用的调度算法。如果要求父进程和子进程之间相互同步,则要求某种形式的进程间通信。在图8-1程序中,父进程使自己休眠2 s,以此使子进程先执行。但并不保证2 s已经足够,在8.9节讲述竟争条件时还将谈及这一问题及其他类型的同步方法。在10.16节中,我们将说明在fork之后如何使用信号使父进程和子进程同步。

当写标准输出时,我们将buf长度减去1作为输出字节数,这是为了避免将终止null字节写出。strlen 计算不包含终止 null 字节的字符串长度,而 sizeof 则计算包括终止 null字节的缓冲区长度。两者之间的另一个差别是,使用 strlen 需进行一次函数调用,而对于sizeof 而言,因为缓冲区已用已知字符串进行初始化,其长度是固定的,所以 sizeof 是在编译时计算缓冲区长度。

注意图8-1所示的程序中fork与I/O函数之间的交互关系。回忆第3章中所述,write函数是不带缓冲的。因为在fork之前调用write,所以其数据写到标准输出一次。但是,标准I/O库是带缓冲的。回忆一下5.12节,如果标准输出连到终端设备,则它是行缓冲的;否则它是全缓冲的。当以交互方式运行该程序时,只得到该printf输出的行一次,其原因是标准输出缓冲区由换行符冲洗。但是当将标准输出重定向到一个文件时,却得到printf输出行两次。其原因是,在fork之前调用了printf一次,但当调用fork时,该行数据仍在缓冲区中,然后在将父进程数据空间复制到子进程中时,该缓冲区数据也被复制到子进程中,此时父进程和子进程各自有了带该行内容的缓冲区。在exit之前的第二个printf将其数据追加到已有的缓冲区中。当每个进程终止时,其缓冲区中的内容都被写到相应文件中。

文件共享

对图8-1程序需注意的另一点是:在重定向父进程的标准输出时,子进程的标准输出也被重定向。实际上,fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。我们说“复制”是因为对每个文件描述符来说,就好像执行了dup函数。父进程和子进程每个相同的打开描述符共享一个文件表项(见图3-9)。

考虑下述情况,一个进程具有3个不同的打开文件,它们是标准输入、标准输出和标准错误。在从fork返回时,我们有了如图8-2中所示的结构。

重要的一点是,父进程和子进程共享同一个文件偏移量。考虑下述情况:一个进程fork了一个子进程,然后等待子进程终止。假定,作为普通处理的一部分,父进程和子进程都向标准输出进行写操作。如果父进程的标准输出已重定向(很可能是由 shell 实现的),那么子进程写到该标准输出时,它将更新与父进程共享的该文件的偏移量。在这个例子中,当父进程等待子进程时,子进程写到标准输出;而在子进程终止后,父进程也写到标准输出上,并且知道其输出会追加在子进程所写数据之后。如果父进程和子进程不共享同一文件偏移量,要实现这种形式的交互就要困难得多,可能需要父进程显式地动作。

图8-2 fork之后父进程和子进程之间对打开文件的共享

如果父进程和子进程写同一描述符指向的文件,但又没有任何形式的同步(如使父进程等待子进程),那么它们的输出就会相互混合(假定所用的描述符是在fork之前打开的)。虽然这种情况是可能发生的(见图8-2),但这并不是常用的操作模式。

在fork之后处理文件描述符有以下两种常见的情况。

(1)父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行过读、写操作的任一共享描述符的文件偏移量已做了相应更新。

(2)父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用的文件描述符。这种方法是网络服务进程经常使用的。

除了打开文件之外,父进程的很多其他属性也由子进程继承,包括:

•实际用户ID、实际组ID、有效用户ID、有效组ID

•附属组ID

•进程组ID

•会话ID

•控制终端

•设置用户ID标志和设置组ID标志

•当前工作目录

•根目录

•文件模式创建屏蔽字

•信号屏蔽和安排

•对任一打开文件描述符的执行时关闭(close-on-exec)标志

•环境

•连接的共享存储段

•存储映像

•资源限制

父进程和子进程之间的区别具体如下。

•fork的返回值不同。

•进程ID不同。

•这两个进程的父进程ID不同:子进程的父进程ID是创建它的进程的ID,而父进程的父进程ID则不变。

•子进程的tms_utime、tms_stime、tms_cutime和tms_ustime的值设置为0(这些时间将在8.17节中介绍)。

•子进程不继承父进程设置的文件锁。

•子进程的未处理闹钟被清除。

•子进程的未处理信号集设置为空集。

其中很多特性至今尚未讨论过,我们将在以后几章中对它们进行说明。

使fork失败的两个主要原因是:(a)系统中已经有了太多的进程(通常意味着某个方面出了问题),(b)该实际用户ID的进程总数超过了系统限制。回忆图2-11,其中CHILD_MAX规定了每个实际用户ID在任一时刻可拥有的最大进程数。

fork有以下两种用法。

(1)一个父进程希望复制自己,使父进程和子进程同时执行不同的代码段。这在网络服务进程中是常见的—父进程等待客户端的服务请求。当这种请求到达时,父进程调用fork,使子进程处理此请求。父进程则继续等待下一个服务请求。

(2)一个进程要执行一个不同的程序。这对 shell 是常见的情况。在这种情况下,子进程从fork返回后立即调用exec(我们将在8.10节说明exec)。

某些操作系统将第 2 种用法中的两个操作(fork 之后执行 exec)组合成一个操作,称为spawn。UNIX系统将这两个操作分开,因为在很多场合需要单独使用fork,其后并不跟随exec。另外,将这两个操作分开,使得子进程在fork和exec之间可以更改自己的属性,如I/O重定向、用户ID、信号安排等。在第15章中有很多这方面的例子。

Single UNIX Specification在高级实时选项组中确实包括了spawn接口。但是该接口并不想替换fork和exec。它们的目的是支持难于有效实现fork的系统,特别是对存储管理缺少硬件支持的系统。

8.4 函数vfork

vfork函数的调用序列和返回值与fork相同,但两者的语义不同。

vfork 起源于较早的 2.9BSD。有些人认为,该函数是有瑕疵的。但是本书讨论的 4 种平台都支持它。事实上,BSD 的开发者在 4.4BSD 中删除了该函数,但 4.4BSD 派生的所有开放源码BSD版本又将其收回。在SUSv3中,vfork被标记为弃用的接口,在SUSv4中被完全删除。我们只是由于历史的原因还是把它包含进来。可移植的应用程序不应该使用这个函数。

vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序(如上一节末尾的(2)中一样)。图1-7程序中的shell基本部分就是这类程序的一个例子。vfork与fork一样都创建一个子进程,但是它并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),于是也就不会引用该地址空间。不过在子进程调用exec或exit之前,它在父进程的空间中运行。这种优化工作方式在某些UNIX系统的实现中提高了效率,但如果子进程修改数据(除了用于存放vfork返回值的变量)、进行函数调用、或者没有调用 exec 或 exit 就返回都可能会带来未知的结果。(就像上一节中提及的,实现采用写时复制技术以提高fork之后跟随exec操作的效率,但是不复制比部分复制还是要快一些。)

vfork和fork之间的另一个区别是:vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果在调用这两个函数之前子进程依赖于父进程的进一步动作,则会导致死锁。)

实例

图8-3中的程序是图8-1中的程序的修改版,其中用vfork代替了fork,删除了对于标准输出的write调用。另外,我们也不再需要让父进程调用sleep,因为我们可以保证,在子进程调用exec或exit之前,内核会使父进程处于休眠状态。

图8-3 vfork函数实例

运行该程序得到:

$.la.out

before vfork

pid = 29039, glob = 7, var = 89

子进程对变量做增1的操作,结果改变了父进程中的变量值。因为子进程在父进程的地址空间中运行,所以这并不令人惊讶。但是其作用的确与fork不同。

注意,在图8-3程序中,调用了_exit而不是exit。正如7.3节所述,_exit并不执行标准I/O缓冲区的冲洗操作。如果调用的是exit而不是_exit,则该程序的输出是不确定的。它依赖于标准I/O库的实现,我们可能会看到输出没有发生变化,或者发现没有出现父进程的printf输出。

如果子进程调用 exit,实现冲洗标准 I/O 流。如果这是函数库采取的唯一动作,那么我们会见到这样操作的输出与子进程调用_exit所产生的输出完全相同,没有任何区别。如果该实现也关闭标准I/O 流,那么表示标准输出FILE 对象的相关存储区将被清 0。因为子进程借用了父进程的地址空间,所以当父进程恢复运行并调用 printf 时,也就不会产生任何输出,printf返回−1。注意,父进程的STDOUT_FILENO仍然有效,子进程得到的是父进程的文件描述符数组的副本(参见图8-2)。

大多数exit的现代实现不再在流的关闭方面自找麻烦。因为进程即将终止,那时内核将关闭在进程中已打开的所有文件描述符。在库中关闭这些,只是增加了开销而不会带来任何益处。

McKusick等[1996]的5.6节中包含了fork和vfork实现方面的更多信息。习题8.1和习题8.2将继续对vfork进行讨论。

8.5 函数exit

如7.3节所述,进程有5种正常终止及3种异常终止方式。5种正常终止方式具体如下。

(1)在main函数内执行return语句。如在7.3节中所述,这等效于调用exit。

(2)调用exit函数。此函数由ISO C定义,其操作包括调用各终止处理程序(终止处理程序在调用atexit函数时登记),然后关闭所有标准I/O流等。因为ISO C并不处理文件描述符、多进程(父进程和子进程)以及作业控制,所以这一定义对UNIX系统而言是不完整的。

(3)调用_exit或_Exit函数。ISOC定义_Exit,其目的是为进程提供一种无需运行终止处理程序或信号处理程序而终止的方法。对标准 I/O 流是否进行冲洗,这取决于实现。在 UNIX系统中,_Exit 和_exit 是同义的,并不冲洗标准 I/O 流。_exit 函数由 exit 调用,它处理UNIX系统特定的细节。_exit是由POSIX.1说明的。

在大多数UNIX系统实现中,exit(3)是标准C库中的一个函数,而_exit(2)则是一个系统调用。

(4)进程的最后一个线程在其启动例程中执行return语句。但是,该线程的返回值不用作进程的返回值。当最后一个线程从其启动例程返回时,该进程以终止状态0返回。

(5)进程的最后一个线程调用 pthread_exit 函数。如同前面一样,在这种情况中,进程终止状态总是0,这与传送给pthread_exit的参数无关。在11.5节中,我们将对pthread_exit做更多说明。

3种异常终止具体如下。

(1)调用abort。它产生SIGABRT信号,这是下一种异常终止的一种特例。

(2)当进程接收到某些信号时。(第10章将较详细地说明信号。)信号可由进程自身(如调用abort函数)、其他进程或内核产生。例如,若进程引用地址空间之外的存储单元、或者除以0,内核就会为该进程产生相应的信号。

(3)最后一个线程对“取消”(cancellation)请求作出响应。默认情况下,“取消”以延迟方式发生:一个线程要求取消另一个线程,若干时间之后,目标线程终止。在 11.5 节和 12.7 节,我们将详细讨论“取消”请求。

不管进程如何终止,最后都会执行内核中的同一段代码。这段代码为相应进程关闭所有打开描述符,释放它所使用的存储器等。

对上述任意一种终止情形,我们都希望终止进程能够通知其父进程它是如何终止的。对于 3个终止函数(exit、_exit和_Exit),实现这一点的方法是,将其退出状态(exit status)作为参数传送给函数。在异常终止情况,内核(不是进程本身)产生一个指示其异常终止原因的终止状态(termination status)。在任意一种情况下,该终止进程的父进程都能用wait或waitpid函数(将在下一节说明)取得其终止状态。

注意,这里使用了“退出状态”(它是传递给向3个终止函数的参数,或main的返回值)和“终止状态”两个术语,以表示有所区别。在最后调用_exit时,内核将退出状态转换成终止状态(回忆图7-2)。图8-4说明父进程检查子进程终止状态的不同方法。如果子进程正常终止,则父进程可以获得子进程的退出状态。

在说明fork函数时,显而易见,子进程是在父进程调用fork后生成的。上面又说明了子进程将其终止状态返回给父进程。但是如果父进程在子进程之前终止,又将如何呢?其回答是:对于父进程已经终止的所有进程,它们的父进程都改变为 init 进程。我们称这些进程由 init进程收养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程ID就更改为1(init进程的ID)。这种处理方法保证了每个进程有一个父进程。

另一个我们关心的情况是,如果子进程在父进程之前终止,那么父进程又如何能在做相应检查时得到子进程的终止状态呢?如果子进程完全消失了,父进程在最终准备好检查子进程是否终止时是无法获取它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些信息。这些信息至少包括进程ID、该进程的终止状态以及该进程使用的CPU时间总量。内核可以释放终止进程所使用的所有存储区,关闭其所有打开文件。在 UNIX 术语中,一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程(zombie)。ps(1)命令将僵死进程的状态打印为Z。如果编写一个长期运行的程序,它fork了很多子进程,那么除非父进程等待取得子进程的终止状态,不然这些子进程终止后就会变成僵死进程。

236

某些系统提供了一种避免产生僵死进程的方法,这将在10.7中介绍。

最后一个要考虑的问题是:一个由init进程收养的进程终止时会发生什么?它会不会变成一个僵死进程?对此问题的回答是“否”,因为init被编写成无论何时只要有一个子进程终止, init 就会调用一个 wait 函数取得其终止状态。这样也就防止了在系统中塞满僵死进程。当提及“一个init的子进程”时,这指的可能是init直接产生的进程(如将在9.2节说明的getty进程),也可能是其父进程已终止,由init收养的进程。

8.6 函数wait和waitpid

当一个进程正常或异常终止时,内核就向其父进程发送 SIGCHLD 信号。因为子进程终止是个异步事件(这可以在父进程运行的任何时候发生),所以这种信号也是内核向父进程发的异步通知。父进程可以选择忽略该信号,或者提供一个该信号发生时即被调用执行的函数(信号处理程序)。对于这种信号的系统默认动作是忽略它。第10章将说明这些选项。现在需要知道的是调用wait或waitpid的进程可能会发生什么。

•如果其所有子进程都还在运行,则阻塞。

•如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。

•如果它没有任何子进程,则立即出错返回 。

如果进程由于接收到SIGCHLD信号而调用wait,我们期望wait会立即返回。但是如果在随机时间点调用wait,则进程可能会阻塞。

#include <sys/wait.h>

pid_t wait(int *statloc);

pid_t waitpid(pid_t pid, int *statloc, int options);

两个函数返回值:若成功,返回进程ID;若出错,返回0(见后面的说明)或−1

这两个函数的区别如下。

•在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项,可使调用者不阻塞。

•waitpid并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。

如果子进程已经终止,并且是一个僵死进程,则wait立即返回并取得该子进程的状态;否则wait使其调用者阻塞,直到一个子进程终止。如调用者阻塞而且它有多个子进程,则在其某一子进程终止时,wait就立即返回。因为wait返回终止子进程的进程ID,所以它总能了解是哪一个子进程终止了。

这两个函数的参数statloc是一个整型指针。如果statloc不是一个空指针,则终止进程的终止状态就存放在它所指向的单元内。如果不关心终止状态,则可将该参数指定为空指针。

依据传统,这两个函数返回的整型状态字是由实现定义的。其中某些位表示退出状态(正常返回),其他位则指示信号编号(异常返回),有一位指示是否产生了core文件等。POSIX.1规定,终止状态用定义在<sys/wait.h>中的各个宏来查看。有4个互斥的宏可用来取得进程终止的原因,它们的名字都以WIF开始。基于这4个宏中哪一个值为真,就可选用其他宏来取得退出状态、信号编号等。这4个互斥的宏示于图8-4中。

图8-4 检查wait和waitpid所返回的终止状态的宏

在9.8节中讨论作业控制时,将说明如何停止一个进程。

实例

图8-5中的函数pr_exit使用图8-4中的宏以打印进程终止状态的说明。本书中的很多程序都将调用此函数。注意,如果定义了WCOREDUMP宏,则此函数也处理该宏。

图8-5 打印exit状态的说明

FreeBSD 8.0、Linux 3.2.0、Mac OS X 10.6.8以及Solaris 10都支持WCOREDUMP宏。但是如果定义了_POSIX_C_SOURCE常量,有些平台就隐藏这个定义(回忆2.7节)。

图8-6中程序调用pr_exit函数,演示终止状态的各种值。

图8-6 演示不同的exit值

运行该程序可得:

$ ./a.out

normal termination, exit status = 7

abnormal termination, signal number = 6 (core file generated)

abnormal termination, signal number = 8 (core file generated)

现在,我们可以从WTERMSIG中打印信号编号。可以查看<signal.h>头文件验证SIGABRT的值为6,SIGFPE的值为8。我们将在10.22节中看到一种可移植的方式进行信号编号到说明性名字的映射。

正如前面所述,如果一个进程有几个子进程,那么只要有一个子进程终止,wait 就返回。如果要等待一个指定的进程终止(如果知道要等待进程的ID),那么该如何做呢?在早期的UNIX版本中,必须调用wait,然后将其返回的进程ID和所期望的进程ID相比较。如果终止进程不是所期望的,则将该进程ID和终止状态保存起来,然后再次调用wait。反复这样做,直到所期望的进程终止。下一次又想等待一个特定进程时,先查看已终止的进程列表,若其中已有要等待的进程,则获取相关信息;否则调用wait。其实,我们需要的是等待一个特定进程的函数。POSIX.定义了waitpid函数以提供这种功能(以及其他一些功能)。

对于waitpid函数中pid参数的作用解释如下。

pid ==−1 等待任一子进程。此种情况下,waitpid与wait等效。

pid > 0 等待进程ID与pid相等的子进程。

pid == 0 等待组ID等于调用进程组ID的任一子进程。(9.4节将说明进程组。)

pid <−1 等待组ID等于pid绝对值的任一子进程。

waitpid函数返回终止子进程的进程ID,并将该子进程的终止状态存放在由statloc指向的存储单元中。对于 wait,其唯一的出错是调用进程没有子进程(函数调用被一个信号中断时,也可能返回另一种出错。第10章将对此进行讨论)。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。

options参数使我们能进一步控制waitpid的操作。此参数或者是0,或者是图8-7中常量按位或运算的结果。

FreeBSD 8.0和Solaris 10支持另一个非标准的可选常量WNOWAIT,它使系统将终止状态已由waitpid返回的进程保持在等待状态,这样它可被再次等待。

图8-7 waitpid的options常量

waitpid函数提供了wait函数没有提供的3个功能。

(1)waitpid可等待一个特定的进程,而wait则返回任一终止子进程的状态。在讨论popen函数时会再说明这一功能。

(2)waitpid提供了一个 wait 的非阻塞版本。有时希望获取一个子进程的状态,但不想阻塞。

(3)waitpid通过WUNTRACED和WCONTINUED选项支持作业控制。

实例

回忆8.5节中有关僵死进程的讨论。如果一个进程fork一个子进程,但不要它等待子进程终止,也不希望子进程处于僵死状态直到父进程终止,实现这一要求的诀窍是调用fork两次。图8-8程序实现了这一点。

图8-8 fork两次以避免僵死进程

第二个子进程调用sleep以保证在打印父进程ID时第一个子进程已终止。在fork之后,父进程和子进程都可继续执行,并且我们无法预知哪一个会先执行。在fork之后,如果不使第二个子进程休眠,那么它可能比其父进程先执行,于是它打印的父进程ID将是创建它的父进程,而不是init进程(进程ID 1)。

执行图8-8程序得到:

$ ./a.out

$ second child, parent pid = 1

注意,当原先的进程(也就是exec本程序的进程)终止时,shell打印其提示符,这在第二个子进程打印其父进程ID之前。

8.7 函数waitid

Single UNIX Specification包括了另一个取得进程终止状态的函数—waitid,此函数类似于waitpid,但提供了更多的灵活性。

#include <sys/wait.h>

int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

返回值:若成功,返回0;若出错,返回−1

与 waitpid 相似,waitid 允许一个进程指定要等待的子进程。但它使用两个单独的参数表示要等待的子进程所属的类型,而不是将此与进程ID或进程组ID组合成一个参数。id参数的作用与idtype的值相关。该函数支持的idtype类型列在图8-9中。

图8-10 waitid的options常量

WCONTINUED、WEXITED或WSTOPPED这3个常量之一必须在options参数中指定。

infop参数是指向siginfo结构的指针。该结构包含了造成子进程状态改变有关信号的详细信息。10.14节将进一步讨论siginfo结构。

本书讨论的4种平台中,Linux 3.2.0、Mac OS X 10.6.8和Solaris 10支持waitid。但要注意的是,Mac OS X 10.6.8并没有设置siginfo结构中的所有信息。

8.8 函数wait3和wait4

大多数UNIX系统实现提供了另外两个函数wait3和wait4。历史上,这两个函数是从UNIX系统的BSD分支延袭下来的。它们提供的功能比POSIX.1函数wait、waitpid和waitid所提供功能的要多一个,这与附加参数有关。该参数允许内核返回由终止进程及其所有子进程使用的资源概况。

#include <sys/types.h>

#include <sys/wait.h>

#include <sys/time.h>

#include <sys/resource.h>

pid_t wait3(int *statloc, int options, struct rusage *rusage);

pid_t wait4(pid_t pid, int *statloc, int options, struct rusage *rusage);

两个函数返回值:若成功,返回进程ID;若出错,返回−1

资源统计信息包括用户CPU时间总量、系统CPU时间总量、缺页次数、接收到信号的次数等。有关细节请参阅 getrusage(2)手册页(这种资源信息与 7.11 节中所述的资源限制不同)。图8-11列出了各个wait函数所支持的参数。

图8-11 不同系统上各个wait函数所支持的参数

Single UNIX Specification的早期版本包括wait3函数。在SUSv2中,wait3被移到了遗留目录下,在SUSv3中,则删去了wait3。

8.9 竞争条件

当多个进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为发生了竞争条件(race condition)。如果在 fork 之后的某种逻辑显式或隐式地依赖于在fork 之后是父进程先运行还是子进程先运行,那么 fork 函数就会是竞争条件活跃的滋生地。通常,我们不能预料哪一个进程先运行。即使我们知道哪一个进程先运行,在该进程开始运行后所发生的事情也依赖于系统负载以及内核的调度算法。

在图8-8程序中,当第二个子进程打印其父进程ID时,我们看到了一个潜在的竞争条件。如果第二个子进程在第一个子进程之前运行,则其父进程将会是第一个子进程。但是,如果第一个子进程先运行,并有足够的时间到达并执行exit,则第二个子进程的父进程就是init。即使在程序中调用sleep,也不能保证什么。如果系统负载很重,那么在sleep返回之后、第一个子进程得到机会运行之前,第二个子进程可能恢复运行。这种形式的问题很难调试,因为在大部分时间,这种问题并不出现。

如果一个进程希望等待一个子进程终止,则它必须调用wait函数中的一个。如果一个进程要等待其父进程终止(如图8-8程序中一样),则可使用下列形式的循环:

while(getppid() != 1)

sleep(1);

这种形式的循环称为轮询(polling),它的问题是浪费了CPU时间,因为调用者每隔1 s都被唤醒,然后进行条件测试。

为了避免竞争条件和轮询,在多个进程之间需要有某种形式的信号发送和接收的方法。在UNIX 中可以使用信号机制,在 10.16 节将说明它在解决此方面问题的一种用法。各种形式的进程间通信(IPC)也可使用,在第15章和第17章将对此进行讨论。

在父进程和子进程的关系中,常常出现下述情况。在fork之后,父进程和子进程都有一些事情要做。例如,父进程可能要用子进程 ID 更新日志文件中的一个记录,而子进程则可能要为父进程创建一个文件。在本例中,要求每个进程在执行完它的一套初始化操作后要通知对方,并且在继续运行之前,要等待另一方完成其初始化操作。这种情况可以用代码描述如下:

#include "apue.h"

TELL_WAIT(); /* set things up for TELL_xxx & WAIT_xxx*/

if ((pid = fork()) < 0) {

err_sys("fork error");

} else if (pid == 0) {      /* child*/

/* child does whatever is necessary ...*/
TELL_PARENT(getppid());     /* tell parent we're done*/
WAIT_PARENT();         /* and wait for parent*//* and the child continues on its way ...*/
exit(0);

}

/* parent does whatever is necessary ...*/

TELL_CHILD(pid);        /* tell child we're done*/

WAIT_CHILD();          /* and wait for child*/

/* and the parent continues on its way ...*/

exit(0);

假定在头文件 apue.h 中定义了需要使用的各个变量。5 个例程 TELLWAIT、TELL PARENT、TELL_CHILD、WAIT_PARENT以及WAIT_CHILD可以是宏,也可以是函数。

在后面几章中会说明实现这些TELL和WAIT例程的不同方法:10.16节中说明使用信号的一种实现,图15-7程序说明使用管道的一种实现。下面先看一个使用这5个例程的实例。

实例

图8-12程序输出两个字符串:一个由子进程输出,另一个由父进程输出。因为输出依赖于内核使这两个进程运行的顺序及每个进程运行的时间长度,所以该程序包含了一个竞争条件。

图8-12 带有竞争条件的程序

在程序中将标准输出设置为不带缓冲的,于是每个字符输出都需调用一次write。本例的目的是使内核能尽可能多次地在两个进程之间进行切换,以便演示竞争条件。(如果不这样做,可能也就决不会见到下面所示的输出。没有看到具有错误的输出并不意味着竞争条件不存在,这只是意味着在此特定的系统上未能见到它。)下面的实际输出说明该程序的运行结果是会改变的。

$ ./a.out

ooutput from child

utput from parent

$ ./a.out

ooutput from child

utput from parent

$ ./a.out

output from child

output from parent

修改图8-12中的程序,使其使用TELL和WAIT函数,于是形成了图8-13中的程序。行首标以+号的行是新增加的行。

图8-13 修改图8-12程序以避免竞争条件

运行此程序则能得到所预期的输出—两个进程的输出不再交叉混合。

图8-13中的程序是使父进程先运行。如果将fork之后的行改成:

else if (pid == 0) {

charatatime("output from child\n");
TELL_PARENT(getppid());

} else {

WAIT_CHILD();    /* child goes first */
charatatime("output from parent\n");

}

则子进程先运行。习题8.4将继续这一实例。

8.10 函数exec

8.3节曾提及用fork函数创建新的子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程执行的程序完全替换为新程序,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。

有7种不同的exec函数可供使用,它们常常被统称为exec函数,我们可以使用这7个函数中的任一个。这些exec函数使得UNIX系统进程控制原语更加完善。用fork可以创建新进程,用exec可以初始执行新的程序。exit函数和wait函数处理终止和等待终止。这些是我们需要的基本的进程控制原语。在后面各节中将使用这些原语构造另外一些如popen和system之类的函数。

#include <unistd.h>

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[]);

int fexecve(int fd, char *const argv[], char *const envp[]);

7个函数返回值:若出错,返回−1;若成功,不返回

这些函数之间的第一个区别是前4个函数取路径名作为参数,后两个函数则取文件名作为参数,最后一个取文件描述符作为参数。当指定filename作为参数时:

•如果filename中包含/,则就将其视为路径名;

•否则就按PATH环境变量,在它所指定的各目录中搜寻可执行文件。

PATH 变量包含了一张目录表(称为路径前缀),目录之间用冒号(:)分隔。例如,下列name=value环境字符串指定在4个目录中进行搜索。

PATH=/bin:/usr/bin:/usr/local/bin:.

最后的路径前缀.表示当前目录。(零长前缀也表示当前目录。在value的开始处可用:表示,在行中间则要用::表示,在行尾以:表示。)

出于安全性方面的考虑,有些人要求在搜索路径中决不要包括当前目录。请参见Garfinkel等[2003]。

如果execlp或execvp使用路径前缀中的一个找到了一个可执行文件,但是该文件不是由连接编辑器产生的机器可执行文件,则就认为该文件是一个shell脚本,于是试着调用/bin/sh,并以该filename作为shell的输入。

fexecve函数避免了寻找正确的可执行文件,而是依赖调用进程来完成这项工作。调用进程可以使用文件描述符验证所需要的文件并且无竞争地执行该文件。否则,拥有特权的恶意用户就可以在找到文件位置并且验证之后,但在调用进程执行该文件之前替换可执行文件(或可执行文件的部分路径),具体可参考3.3节TOCTTOU的讨论。

第二个区别与参数表的传递有关(l表示列表list,v表示矢量vector)。函数 execl、execlp和execle要求将新程序的每个命令行参数都说明为一个单独的参数。这种参数表以空指针结尾。对于另外4个函数(execv、execvp、execve和fexecve),则应先构造一个指向各参数的指针数组,然后将该数组地址作为这4个函数的参数。

在使用ISO C原型之前,对execl、execle和execlp三个函数表示命令行参数的一般方法是:

char *arg0, char *arg1, ..., char *argn, (char *)0

这种语法显式地说明了最后一个命令行参数之后跟了一个空指针。如果用常量0来表示一个空指针,则必须将它强制转换为一个指针;否则它将被解释为整型参数。如果一个整型数的长度与char *的长度不同,那么exec函数的实际参数将出错。

最后一个区别与向新程序传递环境表相关。以e结尾的3个函数(execle、execve和fexecve)可以传递一个指向环境字符串指针数组的指针。其他4个函数则使用调用进程中的environ变量为新程序复制现有的环境(回忆7.9节及图7-8中对环境字符串的讨论。其中曾提及如果系统支持setenv和putenv这样的函数,则可更改当前环境和后面生成的子进程的环境,但不能影响父进程的环境)。通常,一个进程允许将其环境传播给其子进程,但有时也有这种情况,进程想要为子进程指定某一个确定的环境。例如,在初始化一个新登录的shell时,login程序通常创建一个只定义少数几个变量的特殊环境,而在我们登录时,可以通过shell启动文件,将其他变量加到环境中。

在使用ISO C原型之前,execle的参数是:

char *pathname, char *arg0, ..., char *argn, (char *)0, char *envp[]

从中可见,最后一个参数是指向环境字符串的各字符指针构成的数组的指针。而在ISO C原型中,所有命令行参数、空指针和envp指针都用省略号(...)表示。

这7个exec函数的参数很难记忆。函数名中的字符会给我们一些帮助。字母p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件。字母l表示该函数取一个参数表,它与字母v互斥。v表示该函数取一个argv[ ]矢量。最后,字母e表示该函数取envp[ ]数组,而不使用当前环境。图8-14显示了这7个函数之间的区别。

图8-14 7个exec函数之间的区别

每个系统对参数表和环境表的总长度都有一个限制。在 2.5.2 节和图 2-8 中,这种限制是由ARG_MAX给出的。在POSIX.1系统中,此值至少是4 096字节。当使用shell的文件名扩充功能产生一个文件名列表时,可能会受到此值的限制。例如,命令

grep getrlimit /usr/share/man/*/*

在某些系统上可能产生如下形式的shell错误:

Argument list too long

由于历史原因,System V中此限制值是5 120字节。早期BSD系统的此限制值是20 480字节。当前系统中,此限制值要大得多。(如图2-14所示的程序的输出,图2-15总结列出了限制值。)

为了摆脱对参数表长度的限制,我们可以使用xargs(1)命令,将长参数表断开成几部分。为了寻找在我们所用系统手册页中的getrlimit,我们可以用

find /usr/share/man -type f -print | xargs grep getrlimit

如果所用的系统手册页是压缩过的,则可使用

find /usr/share/man -type f -print | xargs bzgrep getrlimit

对于find命令,我们使用选项-type f,以限制输出列表只包含普通文件。这样做的原因是, grep命令不能在目录中进行模式搜索,我们也想避免不必要的出错消息。

前面曾提及,在执行exec 后,进程ID没有改变。但新程序从调用进程继承了的下列属性:

• 进程ID和父进程ID

•实际用户ID和实际组ID

•附属组ID

•进程组ID

•会话ID

•控制终端

•闹钟尚余留的时间

•当前工作目录

•根目录

•文件模式创建屏蔽字

•文件锁

•进程信号屏蔽

•未处理信号

•资源限制

•nice值(遵循XSI的系统,见8.16节)

•tms_utime、tms_stime、tms_cutime以及tms_cstime值

对打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志值有关。回忆图3-7以及3.14节中对FD_CLOEXEC标志的说明,进程中每个打开描述符都有一个执行时关闭标志。若设置了此标志,则在执行exec 时关闭该描述符;否则该描述符仍打开。除非特地用fcntl设置了该执行时关闭标志,否则系统的默认操作是在exec后仍保持这种描述符打开。

POSIX.1明确要求在exec时关闭打开目录流(见4.22节中所述的opendir函数)。这通常是由 opendir 函数实现的,它调用 fcntl 函数为对应于打开目录流的描述符设置执行时关闭标志。

注意,在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID位和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变成程序文件所有者的ID;否则有效用户ID不变。对组ID的处理方式与此相同。

在很多UNIX实现中,这7个函数中只有execve是内核的系统调用。另外6个只是库函数,它们最终都要调用该系统调用。这7个函数之间的关系示于图8-15中。

图8-15 7个exec函数之间的关系

在这种安排中,库函数 execlp 和 execvp 使用 PATH 环境变量,查找第一个包含名为filename的可执行文件的路径名前缀。fexecve库函数使用/proc把文件描述符参数转换成路径名,execve用该路径名去执行程序。

这描述了在FreeBSD 8.0和Linux 3.2.0中是如何实现fexecve的。其他系统采用的方法可能不同。例如,没有/proc和/dev/fd的系统可能把fexecve实现为系统调用,把文件描述符参数转换成i节点指针,把execve实现为系统调用,把路径名参数转换成i节点指针,然后把execve和fexecve中剩余的exec公共代码放到单独的函数中,调用该函数时传入执行文件的i节点指针。

实例

图8-16中的程序演示了exec函数。

图8-16 exec函数实例

在该程序中先调用execle,它要求一个路径名和一个特定的环境。下一个调用的是execlp,它用一个文件名,并将调用者的环境传送给新程序。execlp 在这里能够工作是因为目录/home/sar/bin 是当前路径前缀之一。注意,我们将第一个参数(新程序中的 argv[0])设置为路径名的文件名分量。某些shell将此参数设置为完全的路径名。这只是一个惯例。我们可将argv[0]设置为任何字符串。当login命令执行shell时就是这样做的。在执行shell之前,login在argv[0]之前加一个/作为前缀,这向shell指明它是作为登录shell被调用的。登录shell将执行启动配置文件(start-up profile)命令,而非登录shell则不会执行这些命令。

图8-16中的程序要执行两次的echoall程序如图8-17所示。这是一个很普通的程序,它回显所有命令行参数及全部环境表。

图8-17 回显所有命令行参数和所有环境字符串

执行图8-16中的程序得到:

$ ./a.out

argv[0]: echoall

argv[1]: myarg1

argv[2]: MY ARG2

USER=unknown

PATH=/tmp

argv[0]: echoall

$ argv[1]: only 1 arg

USER=sar

LOGNAME=sar

SHELL=/bin/bash

还有47行没有列出

HOME=/home/sar

注意,shell 提示符出现在第二个 exec 打印 argv[0]之前。这是因为父进程并不等待该子进程结束。

8.11 更改用户ID和更改组ID

在UNIX系统中,特权(如能改变当前日期的表示法)以及访问控制(如能否读、写一个特定文件),是基于用户ID和组ID的。当程序需要增加特权,或需要访问当前并不允许访问的资源时,我们需要更换自己的用户ID或组ID,使得新ID具有合适的特权或访问权限。与此类似,当程序需要降低其特权或阻止对某些资源的访问时,也需要更换用户ID或组ID,新ID不具有相应特权或访问这些资源的能力。

一般而言,在设计应用时,我们总是试图使用最小特权(least privilege)模型。依照此模型,我们的程序应当只具有为完成给定任务所需的最小特权。这降低了由恶意用户试图哄骗我们的程序以未预料的方式使用特权造成的安全性风险。

可以用setuid函数设置实际用户ID和有效用户ID。与此类似,可以用setgid函数设置实际组ID和有效组ID。

#include <unistd.h>int setuid(uid_t uid);

int setgid(gid_t gid);

两个函数返回值:若成功,返回0;若出错,返回−1

关于谁能更改ID有若干规则。现在先考虑更改用户ID的规则(关于用户ID我们所说明的一切都适用于组ID)。

(1)若进程具有超级用户特权,则setuid函数将实际用户ID、有效用户ID以及保存的设置用户ID(saved set-user-ID)设置为uid。

(2)若进程没有超级用户特权,但是uid等于实际用户ID或保存的设置用户ID,则setuid只将有效用户ID设置为uid。不更改实际用户ID和保存的设置用户ID。

(3)如果上面两个条件都不满足,则errno设置为EPERM,并返回−1。

在此假定_POSIX_SAVED_IDS 为真。如果没有提供这种功能,则上面所说的关于保存的设置用户ID部分都无效。

在POSIX.1 2001版中,保存的ID是强制性功能。而在较早版本中,它们是可选择的。为了弄清楚某种实现是否支持这一功能,应用程序在编译时可以测试常量_POSIOX_SAVED_IDS,或者在运行时以_SC_SAVED_IDS参数调用sysconf函数。

关于内核所维护的3个用户ID,还要注意以下几点。

(1)只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登录时,由login(1)程序设置的,而且决不会改变它。因为login 是一个超级用户进程,当它调用setuid时,设置所有3个用户ID。

(2)仅当对程序文件设置了设置用户ID位时,exec函数才设置有效用户ID。如果设置用户ID位没有设置,exec函数不会改变有效用户ID,而将维持其现有值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。自然地,不能将有效用户ID设置为任一随机值。

(3)保存的设置用户ID是由exec复制有效用户ID而得到的。如果设置了文件的设置用户ID位,则在exec根据文件的用户ID设置了进程的有效用户ID以后,这个副本就被保存起来了。

图8-18总结了更改这3个用户ID的不同方法。

图8-18 更改3个用户ID的不同方法

注意,8.2节中所述的getuid和geteuid函数只能获得实际用户ID和有效用户ID的当前值。我们没有可移植的方法去获得保存的设置用户ID的当前值。

FreeBSD 8.0和LINUX 3.2.0提供了getresuid和getresgid函数,它们可以分别用于获取保存的设置用户ID和保存的设置组ID。

1.函数setreuid和setregid

历史上,BSD支持setreuid函数,其功能是交换实际用户ID和有效用户ID的值。

#include <unistd.h>

int setreuid(uid_t ruid, uid_t euid);

int setregid(gid_t rgid, gid_t egid);

两个函数返回值:若成功,返回0;若出错,返回-1

如若其中任一参数的值为−1,则表示相应的ID应当保持不变。

规则很简单:一个非特权用户总能交换实际用户ID和有效用户ID。这就允许一个设置用户ID程序交换成用户的普通权限,以后又可再次交换回设置用户ID权限。POSIX.1引进了保存的设置用户ID特性后,其规则也相应加强,它允许一个非特权用户将其有效用户ID设置为保存的设置用户ID。

seteuid和setregid两个函数都是Single UNIX Specification的XSI扩展。因此,可以期望所有UNIX系统实现都将对它们提供支持。

4.3BSD并没有上面所说的保存的设置用户ID特性,而是使用setreuid和setregid来代替。这就允许一个非特权用户交换这两个用户ID的值,但是要注意,当使用此特性的程序生成shell进程时,它必须在exec之前先将实际用户ID设置为普通用户ID。如果不这样做的话,实际用户ID就可能是具有特权的(由setreuid的交换操作造成),然后shell进程可能会调用setreuid交换两个用户ID值并取得更多权限。作为一个保护性的解决这一问题的编程措施,程序在子进程调用exec之前,将子进程的实际用户ID和有效用户ID都设置成普通用户ID。

2.函数seteuid和setegid

POIX.1包含了两个函数seteuid和setegid。它们类似于setuid和setgid,但只更改有效用户ID和有效组ID。

#include <unistd.h>

int seteuid(uid_t uid);

int setegid(gid_t gid);

两个函数返回值:若成功,返回0;若出错,返回−1

一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。对于一个特权用户则可将有效用户ID设置为uid。(这区别于setuid函数,它更改所有3个用户ID。)

图8-19给出了本节所述的更改3个不同用户ID的各个函数。

图8-19 设置不同用户ID的各函数

3.组ID

本章中所说明的一切都以类似方式适用于各个组 ID。附属组 ID 不受 setgid、setregid和setegid函数的影响。

实例

为了说明保存的设置用户 ID 特性的用法,先观察一个使用该特性的程序。我们所观察的是at(1)程序,它用于调度将来某个时刻要运行的命令。

在Linux 3.2.0上安装的at程序的设置用户ID是daemon用户。在FreeBSD 8.0、Mac OS X 10.6.8以及Solaris 10上安装的at程序的设置用户ID是root用户。这允许at命令对守护进程拥有的特权文件具有写权限,守护进程代表用户运行at命令。在Linux 3.2.0上,程序是用atd(8)守护进程运行的。在FreeBSD 8.0和Solaris 10上,程序通过cron(1M)守护进程运行。在Mac OS X 10.6.8上,程序通过launchd(8)守护进程运行。

为了防止被欺骗而运行不被允许的命令或读、写没有访问权限的文件,at命令和最终代表用户运行命令的守护进程必须在两种特权之间切换:用户特权和守护进程特权。下面列出了其工作步骤。

(1)程序文件是由root用户拥有的,并且其设置用户ID位已设置。当我们运行此程序时,得到下列结果:

实际用户ID=我们的用户ID(未改变)

有效用户ID=root

保存的设置用户ID=root

(2)at 程序做的第一件事就是降低特权,以用户特权运行。它调用 setuid 函数把有效用户ID设置为实际用户ID。此时得到:

实际用户ID=我们的用户ID(未改变)

有效用户ID=我们的用户ID

保存设置用户ID=root(未改变)

(3)at 程序以我们的用户特权运行,直到它需要访问控制哪些命令即将运行,这些命令需要何时运行的配置文件时,at 程序的特权会改变。这些文件由为用户运行命令的守护进程持有。at命令调用setuid函数把有效用户ID设为root,因为setuid的参数等于保存的设置用户ID,所以这种调用是许可的(这就是为什么需要保存的设置用户ID的原因)。现在得到:

实际用户ID=我们的用户ID(未改变)

有效用户ID=root

保存的设置用户ID=root(未改变)

因为有效用户ID是root,文件访问是允许的。

(4)修改文件从而记录了将要运行的命令以及它们的运行时间以后,at命令通过调用seteuid,把有效用户ID设置为用户ID,降低它的特权。防止对特权的误用。此时我们可以得到:

实际用户ID=我们的用户ID(未改变)

有效用户ID=我们的用户ID

保存的设置用户ID=root(未改变)

(5)守护进程开始用 root 特权运行,代表用户运行命令,守护进程调用 fork,子进程调用setuid将它的用户ID更改至我们的用户ID。因为子进程以root特权运行,更改了所有的ID,所以

实际用户ID=我们的用户ID

有效用户ID=我们的用户ID

保存的设置用户ID=我们的用户ID

现在守护进程可以安全地代表我们执行命令,因为它只能访问我们通常可以访问的文件,我们没有额外的权限。

以这种方式使用保存的设置用户ID,只有在需要提升特权的时候,我们通过设置程序文件的设置用户 ID 而得到的额外权限。然而,其他时间进程在运行时只具有普通的权限。如果进程不能在其结束部分切换回保存的设置用户ID,那么就不得不在全部运行时间都保持额外的权限(这可能会造成麻烦)。