UNIX环境高级编程
第1章 UNIX基础知识
1.1 Unix体系结构
- 内核:一种软件,控制计算机硬件资源,提供程序运行环境
- 系统调用:内核的接口
- shell,公用函数库
- 应用程序

1.2 不带缓冲的I/O
函数open、read、write、lseek以及close提供了不带缓冲的I/O。这些函数都使用文件描述符。
1.2.1 文件描述符
STDIN_FILENO、STDOUT_FILENO为两个宏,值分别为0、1,表示标准输入和标准输出的文件描述符。
1.2.1 read
read函数返回读取的字节数,当到达文件的尾端时,read返回0;如果发生了一个读错误,read返回-1。
1.3 标准I/O
标准I/O函数为那些不带缓冲的I/O函数提供了一个带缓冲的接口。使用标准I/O函数无需担心如何选取最佳的缓冲区大小。使用标准I/O还简化了对输入行的处理,如fgets读取一个完整的行,而read函数读取指定的字节数。
stdin、stdout、getc、putc等均在stdio.h中定义。
1.4 进程控制
程序(program)是一个存储在磁盘上的某个目录中的可执行文件。程序的执行实例被称为进程(process)。每个进程都有一个唯一的数字标识符,称为进程ID,进程ID总是一个非负整数。
- getpid()
- getgid()
- fork()
- waitpid()
- exec()
一个进程内的所有线程共享同一地址空间、文件描述符、栈以及与进程相关的属性,它们能访问同一存储区,因此各个线程在访问共享数据时需要采取同步措施以避免不一致性。
1.5 出错处理
头文件errno.h中定义了errno以及可以赋与它的各种常量,这些常量都以字符E开头。当UNIX系统函数出错时,通常会返回一个负值,而且整形变量errno通常被设置为具有特定信息的值。
对于errno应当注意两条规则:
- 如果没有出错,其值不会被例程清除。因此,仅当函数的返回值指明出错时,才检验其值。
- 任何函数都不会将
errno值设置为0,而且errno.h中定义的所有常量都不为0。
C标准定义了2个函数,它们用于打印出错信息:
1 - strerror
1 |
|
strerror函数将errnum(通常就是errno值)映射为一个出错消息字符串,并且返回此字符串的指针。
2 - perror
perror函数基于errno的当前值,在标准错误上产生一条出错消息,然后返回。
1 |
|
1.6 时间值
Unix系统使用过两种不同的时间值:
日历时间,即UTC,格林尼治标准时间,以秒为单位,可以用于记录文件的最近修改时间,对应类型为
time_t。进程时间,也被称为CPU时间,用以度量进程使用的中央处理器资源。进程时间以时钟滴答计算。对应数据类型为
clock_t。
当度量一个进程的执行时间时,UNIX系统为一个进程维护了3个进程时间值:
时钟时间,是进程运行的时间总量,其值与系统中同时运行的进程数有关。
用户CPU时间,是执行用户指令所用的时间量。
系统CPU时间,是该进程执行内核程序所经历的时间。
用户CPU时间和系统CPU时间之和常被称为CPU时间。
time命令用于获取任一进程的时钟时间、用户时间和系统时间。
1.7 信号
可以使用kill函数产生信号,在一个进程中调用此函数就可以向另一个进程发送一个信号。当然,这样做也有些限制:当向一个进程发送信号时,我们必须是那个进程的所有者或者是超级用户。
第3章 文件I/O
3.1 引言
本章内容:
文件I/O。本章介绍的I/O函数通常被称为不带缓冲的I/O(unbuffered I/O),所谓不带缓冲指的是每个read和write都调用内核中的一个系统调用。
- open
- read
- write
- lseek
- close
多个进程间共享资源。
- dup
- fcntl
- sync
- fsync
- ioctl
3.2 文件描述符
对内核而言,所有打开的文件都通过文件描述符引用。文件描述符是一个非负整数。当打开一个现有文件或创建一个新文件时,内核向进程返回一个文件描述符。
- 文件描述符0——标准输入,
STDIN_FILENO - 文件描述符1——标准输出,
STDOUT_FILENO - 文件描述符2——错误输出,
STDERR_FILENO
上述三个符号常量存储在头文件unistd.h中。
3.3 函数open和openat
1 |
|
若成功,返回文件描述符;若出错,返回-1。
...表示该函数的余下参数的数量及类型是可变的。对于open函数而言,仅当创建新文件时才使用最后这个参数。
oflag:
- O_RDONLY:只读
- O_WRONLY:只写
- O_RDWR:可读可写
- O_EXEC:只执行打开
- O_SEARCH:只搜索打开(应用于目录)
上述五个常量中必须指定一个且只能指定一个,下列常量则是可选的:
- O_APPEND:追加模式
- O_CREATE:若文件不存在则创建它,此时需要指定mode
O_TRUNC:文件存在,且为只写或读-写成功打开,则将其长度截断为0,于O_APPEND对应。
O_DIRECTORY:如果path引用的不是目录,则出错
- O_EXCL:如果同时指定了O_CRAET,而文件已经存在,则出错。可以用于测试一个文件是否存在,如果不存在则创建,这使测试和创建两者成为一个原子操作。
- O_SYNC:使每次write等待物理I/O操作完成,包括由该write操作引起的文件属性更新所需的I/O。
- O_DSYNC:使每次write要等待物理I/O操作完成,但是如果该写操作并不影响读取刚写入的数据,则不需等待文件属性被更新。
- O_RSYNC:使每一个以文件描述符作为参数进行的read操作等待,直至所有对文件同一部分挂起的写操作都完成。
- O_CLOEXEC:把FD_CLOEXEC常量设置为文件描述符标志
- O_NOCTTY:如果path引用的是终端设备,则不将该设备分配作为此进程的控制终端。
- O_NOFOLLOW:如果path是一个链接符号,则出错。
- O_NONBLOCK:如果path是一个FIFO、一个快特殊文件或者一个字符特殊文件,则此选项为文件的本次打开操作和后续的I/O操作设置非阻塞模式。
- O_TTY_INIT:如果打开一个还未打开的终端设备,设置非标准termios参数值,使其符合Single UNIX Specification。
关于openat的三种情况:
- 如果path为绝对路径,则fd没有任何作用,传任何值都可以,此时与open函数无异。
- 如果path为相对路径,而fd为
AT_FDCWD,则指定目录为当前目录。 - 如果path为相对路径,fd不为
AT_FDCWD,则需给fd传入指定的目录的文件描述符。
TOCTTOU(time-of-check-to-time-of-use),如果有两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序是脆弱的,因为两个基于文件的函数调用,在两个函数调用之间文件可能改变了。
3.4 函数create
1 |
|
若成功,返回一个只写打开的文件描述符;若出错,返回-1。
此函数等效于:
1 | open(path, O_WRONLY | O_CREAT | O_TRUNC, mode); |
create只能以只写方式打开所创建的文件,要想是写可读可写必须用open函数:
1 | open(path, O_RDWR | O_CREAT | O_TRUNC, mode); |
3.5 函数close
1 |
|
若成功,返回0;若出错,则返回-1。
3.6 函数lseek
1 |
|
若成功,返回新的文件偏移量;若出错,返回-1。
按系统默认情况,当打开一个文件时,除非指定O_APPEND选项,否则该偏移量被设置为0。
whence的三种情况:
- whence 为 SEEK_SET,则将文件偏移量设置为距文件开始处offset字节。
- whence 为 SEEK_CUR,则将文件偏移量设置为当前值加offset,offset可为正或负。
- whence 为 SEEN_END,则将文件偏移量设置为文件长度加offset,offset可正可负。
确定打开文件的当前偏移量:
1 | off_t currpos; |
这种方法也可以用来测试所涉及的文件是否可以设置偏移量,如果文件描述符指向的是一个管道、FIFO或网络套接字,则lseek返回-1,并将errno设置为ESPIPE。
在比较lseek的返回值时应当谨慎,不要测试它是否小于0,而要测试它是否等于-1。
lseek仅将当前的文件描述符记录在内核中,它并不引起任何I/O操作。然后,该偏移量用于下一个读或写操作。
文件偏移量可以大于文件的当前长度,在这种情况下,对该文件的下一次写将加长该文件,并在文件中构成一个空洞,这一点是允许的。位于文件中但没有写过的字节都被读为0。文件中的空洞并不要求在磁盘上占用存储区。具体处理方式与文件系统的实现有关,当定位超出文件尾端后写时,对于新写的数据需要分配磁盘快,但对于原文件尾和新开始写位置之间的部分则不需要分配磁盘块。
命令od -c以字符方式查看文件内容。

3.7 函数read
1 |
|
fd为文件描述符,buf为缓冲区,nbytes为要求读的字节数。返回读到的字节数,若到达文件尾,返回0;若出错,返回-1。
3.8 函数write
1 |
|
fd为文件描述符,buf为包含数据的缓冲区,nbytes为要写入的字节数。若成功,返回已经写的字节数;若出错,返回-1。
返回值通常与nbytes一致,否则表示出错。write出错的一个常见原因时磁盘已写满,或者超过了一个给定进程的文件长度限制。
3.9 I/O的效率

1 |
|
测试结果:

3.10 文件共享
内核使用3种数据结构表示打开文件:
- 每个进程在进程表中都有一个记录项(进程表项),记录项中包含一张打开文件描述符表,可将其视为一个矢量,每个描述符占用一项。每一项包含:
- 文件描述符标志
- 指向一个文件表项的指针
- 内核为所有打开文件维持一张文件表,每个文件表项包含:
- 文件状态标志
- 当前文件偏移量
- 指向该文件v节点表项的指针
- 每个打开文件都有一个v节点(v-node)结构。v节点包含了文件类型和对此文件进行各种操作函数的指针。对于大多数文件,v节点还包含了该文件的i节点(i-node,索引节点)。i-node包含了文件的所有者、文件长度、指向文件实际数据块在磁盘上所在位置的指针等。


我们假定第一个进程在文件描述符3上打开该文件,而另一个进程在文件描述符4上打开该文件。打开该文件的每个进程都获得各自的一个文件表项,但对一个给定的文件只有一个v节点表项。之所以每个进程都获得自己的文件表项,是因为这可以使每个进程都有它自己的对该文件的当前偏移量。
- 在完成每个write后,在文件表项中的当前偏移量即增加所写入的字节数。如果这导致当前文件偏移量超过了当前文件长度,则将i节点表项中的当前文件长度设置为当前文件偏移量(也就是该文件加长了)。
- 如果使用O_APPEND标志打开一个文件,则相应标志也被设置到文件表项的文件标志状态中。每次对这种具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为i节点表项中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。
- 若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置成i节点表项中的当前文件长度。(注意,这与用O_APPEND标志打开文件是不同的。)
- lseek函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作。
可能有多个文件描述符项指向同一个文件表项,如fork后父子进程各自的每一个打开文件描述符共享同一个文件表项。
3.11 原子操作
一个原子操作的例子:两个进程打开同一个文件,都先调用lseek,再调用write写文件,可能会导致覆盖。
函数pread和pwrite:
1 |
|
调用pread相当于调用lseek后调用read,但pread是一个原子操作,即:
- 调用pread时,无法中断其定位和读操作。
- 不更新当前文件的偏移量。
调用pwrite相当于调用lseek后调用write,但同样,是一个原子操作。
另一个原子操作的例子:判断一个文件是否存在,若存在,则open失败;若不存在,则创建该文件。open提供了oflag的O_EXCL和O_CREAT,使得这两个操作成为一个原子操作。如果没有O_EXCL,则可能要这样写代码:
1 | if ((fd = open(pathname, O_WRONLY)) < 0) |
上述代码带来的问题是,如果在open和creat之间,另一个进程创建了该文件,就会出现问题。
一般而言,原子操作(atomic operation)指的是由多步组成的一个操作。如果该操作原子的执行,则要么执行完所有步骤,要么一步也不执行,不可能只执行所有步骤的一个子集。
3.12 函数dup和dup2
1 |
|
上面两个函数都可用来复制一个现有的文件描述符。若成功,返回新的文件描述符;若出错,返回-1。
由dup返回的新文件描述符一定是当前可用文件描述符中的最小值。对于dup2,可以用fd2参数指定新描述符的值。如果fd2已经打开,则先将其关闭;如果fd等于fd2,则dup2返回fd2,而不关闭它。否则,fd2的FD_CLOEXEC文件描述符标志就被清除,这样fd2在进程调用exec时是打开状态。
这些函数返回的新文件描述符与参数fd共享一个文件表项。

每个文件描述符都有它自己的一套文件描述符标志。
3.13 函数 sync、fsync 和 fdatasync
延迟写:传统的Unix系统实现在内核中设有缓冲区高速缓存或页高速缓存,大多数磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。通常,当内核需要重用缓冲区来存放其他磁盘块数据时,它会把所有延迟写数据写入磁盘。为了保证磁盘上实际文件系统与缓冲区中内容的一致性,Unix系统提供了sync、fsync和fdatasync三个函数。
1 |
|
fsync、fdatasync若成功,返回0;若出错,返回-1。
- sync只是将所有修改过的块缓冲区排入队列,然后就返回,它并不等待实际写磁盘操作结束。通常,称为update的系统守护进程周期性地调用(一般每隔30秒)sync函数,这就保证了定期冲洗(flush)内核的块缓冲区。命令sync(1)也调用sync函数。sync对所有文件打开的文件描述符执行。
- fsync函数只是对由文件描述符fd指定的一个文件起作用,并且等待写磁盘操作结束才返回。fsync可用于数据库这样的应用程序,这种应用程序需要确保修改过的块立即写到磁盘上。
- fdatasync类似于fsync,但它只影响文件的数据部分。而除数据外,fsync还会同步更新文件的属性。
3.14 函数fcntl
1 |
|
fcntl函数可以改变已经打开文件的属性。若成功,则依赖cmd;若出错,返回-1。
fcntl函数有以下五种功能:
(1) 复制一个已有的描述符(cmd = F_DUPFD 或 F_DUPFD_CLOEXEC)
(2) 获取/设置文件描述符标志(cmd = F_GETFD 或 F_SETFD)
(3) 获取/设置文件状态标志(cmd = F_GETFL 或 F_SETFL)
(4) 获取/设置异步I/O所有权(cmd = F_GETOWN 或 F_SETOWN)
(5) 获取/设置记录锁 (cmd = F_GETLK、F_SETLK 或 F_SETLKW)
11种cmd中的8种:
F_DUPFD :复制文件描述符fd。新文件描述符作为函数值返回。它是尚未打开的各描述符中大于或等于第3个参数值(取整)中各值的最小值。新描述符与fd共享同一文件表项。但是新描述符有它自己的一套文件描述符标志。
F_DUPFD_CLOEXEC :复制文件描述符,设置与新描述符关联的FD_CLOEXEC文件描述符标志的值,返回新文件描述符。
F_GETFD :对应于fd的文件描述符标志作为函数值返回。当前只定义了一个文件描述符标志FD_CLOEXEC。
F_SETFD :对于fd设置文件描述符标志。新标志值按第3个参数(取为整型值)设置。
F_GETFL :对应于fd的文件状态标志作为函数返回值返回。文件状态标志参考3.3节open函数的第2个参数。遗憾的是,5个访问标志(O_RDONLY、O_WRONLY、O_RDWR、O_EXEC以及O_SEARCH)并不各占1位(这5个值互斥,一个文件的访问方式只能取这5个值之一)。因此首先必须用屏蔽字O_ACCMODE取得访问方式位,然后将结果与这5个值中的每一个相比较。
F_SETFL :将文件状态标志设置为第3个参数的值(取为整型值)。可以更改的几个标志是:O_APPEND、O_NOBLOCK、O_SYNC、O_DSYNC、O_RSYNC、O_FSYNC、O_ASYNC。
F_GETOWN :获取当前接收SIGIO和SIGURG信号的进程ID或进程组ID。
F_SETOWN :设置接收SIGIO和SIGURG信号的进程ID或进程组ID。正的arg指定一个进程ID,负的arg表示等于arg绝对值的一个进程组ID。
关于复制文件描述符,前面3.12节介绍了dup和dup2两个函数。实际上,执行dup(fd);等效于执行fcntl(fd, F_DUPFD, 0);,而调用dup2(fd, fd2);相当于执行了:
1 | close(fd); |
但是并不完全等效,dup2是一个原子操作,而且dup2和fcntl有一些不同的errno。
在修改文件描述符标志或文件状态标志时必须谨慎,先要获得现在的标志值,然后按照期望修改它,最后设置新标志值。不能只是执行F_SETFD或F_SETFL命令,这样会关闭以前设置的标志位。
1 |
|
打开状态用val |= flags;,关闭状态用val &= ~flags;。
当使用上述函数打开O_SYNC时,write需要等待缓冲区中的数据写入磁盘块完成后返回,增加了程序执行的时间。

在这里需要注意,设置文件状态标志位O_SYNC与sync函数的作用不是一样的,前者等待物理I/O操作完整再返回,而后者则是将缓冲块排入队列就返回,不等待物理操作完成。
O_DSYNC与O_SYNC的区别是前者不等待文件属性更新完就返回。
sync函数与fsync函数的区别是fsync只针对某个特定的文件描述符,等待物理I/O完成。
3.15 函数ioctl
1 |
|
若出错,返回-1;若成功,返回其他值。ioctl函数一直是I/O操作的杂物箱。不能用本章中其他函数表示的I/O操作通常都能用ioctl表示。终端I/O是使用ioctl最多的地方。通常还要用到头文件termios.h。
嵌入式常用。
3.16 /dev/fd
较新的系统都提供名为/dev/fd的目录,其目录项是名为0、1、2等的文件。打开文件/dev/fd/n等效于复制描述符n(假定描述符n是打开的)。
在下列函数调用中:
1 | fd = open("/dev/df/0", mode); |
大多数系统忽略它所指定的mode,而另外一些系统则要求mode必须是所引用的文件(在这里是标准输入)初始打开时所使用的打开模式的一个子集。因为上面的打开等效于:
1 | fd = dup(0); |
所以描述符fd和0共享同一文件表项。例如,若描述符0先前被打开为只读,那么我们也只能对fd进行读操作。即使系统忽略打开模式,而且下列调用是成功的:
1 | fd = open("/dev/fd/0", O_RDWR); |
我们仍然不能对fd进行写操作。
Linux实现中的/dev/fd是个例外。它把文件描述符映射成指向底层物理文件的符号链接。例如,当打开/dev/fd/0时,事实上正在打开与标准输入关联的文件,因此返回的新文件描述符的模式与/dev/fd文件描述符的模式其实并不相关。
某些系统提供文件/dev/stdin、/dev/stdout 和 /dev/stderr,这些等效于/dev/fd/0、/dev/fd/1和/dev/fd/2
作为命令行参数的-特指标准输入或输出。
1 | filter file2 | cat file1 - file3 | lpr |
cat先读file1,然后读其标准输入(即filter file2的输出),然后读file3,如果支持/dev/fd,可以删除cat对-的特殊处理:
1 | filter file2 | cat file1 /dev/fd/0 file3 | lpr |
3.17 练习
3.6 如果使用追加标志打开一个文件以便读、写,能否仍用lseek在任一位置开始读?能否用lseek更新文件中任一部分的数据?请编写一段程序验证。
1 | 答:可以从任一位置开始读,但不能从任一位置开始写。写需要O_WROLY或O_RDWR,单独的O_APPEND不具有写的功能。 |
第4章 文件和目录
4.1 引言
本章将描述文件系统的其他特征和文件的性质。
4.2 函数stat、fstat、fstatat和lstat
1 |
|
这四个函数在函数执行成功时返回0,出错时返回-1。
一旦给出pathname,stat函数将返回与此命名文件有关的信息结构。
fstat函数获得已在文件描述符fd上打开文件的有关信息。
lstat类似于stat,但是命名的文件是一个符号链接时,lstat返回该符号链接的有关信息,而不是由该符号链接引用的文件信息。
fstatat函数为一个相对于当前打开目录(由dirfd参数指向)的路径名返回文件统计信息。flags参数控制着是否跟随着一个符号链接。当
AT_SYMLINK_NOFOLLOW标志被设置时,fstatat不会跟随符号链接,而是返回符号链接本身的信息。否则,在默认情况下,返回的是符号链接所指向的实际文件信息。如果fd被设置为AT_FDCWD,并且pathname参数是一个相对路径名,fstatat会计算相对于当前目录的pathname参数。如果pathname是一个绝对路径,fd参数就会被忽略。在这两种情况下,根据flag 的取值,fstatat的作用就跟stat或lstat一样。
参数buf是一个指针,指向的结构为stat,定义如下:
1 | struct stat { |
stat函数用的最多的地方可能就是ls -l命令。
4.3 文件类型
文件类型包括如下几种:
- 普通文件(regular file)
- 目录文件(directory file):这种文件包含了其他文件的名字以及指向与这些文件有关信息的指针。对一个目录文件具有读权限的任一进程都可以读该目录的内容,但只有内核可以直接写目录文件。
- 块特殊文件(block special file):提供对设备(如磁盘)带缓冲的访问,每次访问以固定长度为单位进行。
- 字符特殊设备(character special file):提供对设备的不带缓冲访问,每次访问长度可变。系统中的所有设备要么是字符特殊文件,要么是块特殊设备。
- FIFO:用于进程间通信,有时也称为命名管道(named pipe)。
- 套接字(socket):用于进程间的网络通信。套接字也可用于在一台宿主机上进程之间的非网络通信。
- 符号链接(symbolic link):指向另一个文件。
文件类型信息包含在stat结构中的st_mode成员中。下列宏可以用于st_mode类型的参数:

下列宏可以用于stat结构来确定IPC(进程间通信)对象:

4.4 设置用户ID和设置组ID
与一个进程相关联的ID有6个或更多,如下图所示:

- 实际用户ID和实际组ID标识我们究竟是谁。这两个字段在登录时取自口令文件中的登录项。通常,在一个登录会话期间这些值并不改变,但是超级用户进程有方法改变它们。个人理解为启动该进程的用户ID、组ID就是实际用户ID和实际组ID。
- 有效用户ID、有效组ID以及附属组ID决定了我们的文件访问权限。
- 保存的设置用户ID和保存的设置组ID在执行一个程序时包含了有效用户ID和有效组ID的副本。设置用户ID和设置组ID是两个特殊的权限位,用于为启动某进程的用户或用户组设置在运行时与文件所属用户和用户组所拥有的权限,对应文件权限标志
s。
通常,有效用户ID等于实际用户ID,有效组ID等于实际组ID。
每个文件有一个所有者和组所有者,所有者由stat结构中的st_uid指定,组所有者由st_gid指定。
文件模式字中的特殊两位:设置用户ID(set-user-ID)位和设置组ID(set-group-ID)位。
- 设置用户ID(set-user-ID)位:当执行此文件时,将进程的有效用户ID设置为文件所有者的用户ID(st_uid)。
- 设置用户组ID(set-group-ID)位:当执行此文件时,将进程的有效用户ID设置为文件的组所有者ID(st_gid)。
例如,若文件的所有者是超级用户,而且设置了该文件的设置用户ID位,那么当该程序文件由一个进程执行,该进程具有超级用户权限,不管执行此文件的进程的实际用户ID是什么。
一个最简单的例子,用户可以使用passwd命令修改自己的密码。该程序是一个设置用户ID程序,因为该程序应能将用户的新口令写入文件中(一般是/etc/passwd或/etc/shadow),该文件的所有这为root,且这个文件的权限一般为-rw-r--r--,其他用户没有写的权限,所以需要使用设置用户ID功能才能是普通用户使用passwd命令改密码。
设置用户ID位及设置组ID位都包含在文件st_mode值中,分别使用S_ISUID和S_ISGID进行位与测试可以检测出是否有对应权限。
4.5 文件访问权限
st_mode值也包含了对文件的访问权限位。每个文件有9个访问权限位,可将它们分成3类:

用户是指文件所有者(owner),组指文件所有组(group),其他指其他用户(other)。chmod命令可以修改这9个权限位。
我们用名字打开任一类型的文件时,对该名字中包含的每一个目录,包括它可能隐含的当前工作目录都应具有执行权限。这就是为什么对于目录执行权限位常被称为搜索位的原因。
注意,对于目录的读权限和执行权限的意义是不同的。读权限允许我们读目录,获得在该目录中所有文件名的列表。当一个目录是我们要访问文件的路径名的一个组成部分时,对该目录的执行权限使我们可以通过该目录。
对于一个文件的读权限决定了我们是否能打开现有文件进行读操作。这与open函数的O_RDONLY和O_RDWR标志相关。
对于一个文件的写权限决定了我们是否能打开现有文件进行写操作。这与open函数的O_WRONLY和O_RDWR标志相关。
为了在open函数中对一个文件指定O_TRUNC标志,必须对该文件具有写权限。
为了在一个目录中创建一个新文件,必须对该目录具有写权限和执行权限。
为了删除一个现有文件,必须对包含该文件的目录具有写权限和执行权限。对该文件本身则不需要有读、写权限。
如果用7个exec函数中的任何一个执行某个文件,都必须对该文件具有执行权限。该文件还必须是一个普通文件。
文件所有者(st_uid)和(st_gid)是文件的性质,两个有效ID(有效用户ID和有效组ID)和附属组ID是进程的性质。
内核进行的测试具体如下:
- 若进程的有效用户ID是0(超级用户),则允许访问。这给予了超级用户对整个文件系统进行处理的最充分的自由。
- 若进程的有效用户ID等于文件的所有者ID(也就是进程拥有此文件),那么如果所有者适当的访问权限被设置,则允许访问;否则拒绝访问。
- 若进程的有效组ID或进程的附属组ID之一等于文件的组ID,那么如果组适当的访问权限被设置,则允许访问;否则拒绝访问。
- 若其他用户适当的访问权限位被设置,则允许访问;否则拒绝访问。
4.6 新文件和目录的所有权
新文件的用户ID设置为进程的有效用户ID。关于组ID,POSIX.1允许实现选择下列之一作为新文件的组ID:
- 新文件的组ID可以是进程的有效组ID
- 新文件的组ID可以是它所在目录的组ID
4.7 函数access和faccessat
access和faccessat函数是按实际用户ID和实际组ID进行访问权限测试的。
1 |
|
两个函数若执行成功,返回值为0,失败返回-1。
即使一个进程可能已经通过设置用户ID或设置组ID以超级用户权限运行,它仍可能想验证其实际用户能否访问一个给定的文件。
测试文件是否已经存在,mode就为F_OK;否则,mode是图4-7中所列常量的按位或。
| mode | 说明 |
|---|---|
| R_OK | 测试读权限 |
| W_OK | 测试写权限 |
| X_OK | 测试执行权限 |
至于faccessat函数的fd参数和pathname参数,其用法和openat函数的fd参数和pathname一致。
flag参数可以用于改变faccessat的行为,如果flag设置为AT_EACCESS,访问检查用的是调用进程的有效用户ID和有效组ID,而不是实际用户ID和实际组ID。
4.8 函数umask
1 |
|
umask函数为进程设置文件模式创建屏蔽字,并返回之前的值。该函数是少数几个没有出错返回函数中的一个。参数cmask是由图4-6中列出的9个常量(S_IRUSR、S_IWUSR等)中的若干按位“或”构成的。在文件模式创建屏蔽字中为1的位,在文件mode中的相应位一定被关闭。
Unix系统的大多数用户从不处理他们的umask值。通常在登录时,由shell的启动文件设置一次,然后不再改变。尽管如此,当编写创建新文件的程序时,如果我们想确保指定的访问权限位已经激活,那么必须在进程运行时修改umask值。
umask命令
umask命令用于查看或显示被屏蔽的位的八进制或许可的权限,还可用于设置屏蔽位,以8进制表示。
1 | $ umask |
4.9 函数chmod、fchmod和fchmodat
1 |
|
chmod、fchmod和fchmodat这3个函数使我们可以更改现有文件的访问权限。若执行成功,则返回0,执行失败则返回-1。flag参数可以用于改变fchmodat的行为,当设置了AT_SYMLINK_NOFOLLOW标志时,fchmodat并不会跟随符号链接。
为了改变一个文件的权限位,进程的有效用户ID必须等于文件的所有者ID,或该进程必须具有超级用户权限。参数mode时图4-11中所示常量的按位或。

chmod函数更新的只是i节点最近一次被更改的时间。按系统默认方式,ls -l列出的是最后修改文件内容的时间。
chmod函数在下列条件下自动清除两个权限位:
- Solaris等系统对用于普通文件的粘着位赋予了特殊含义,在这些系统上如果我们试图设置普通文件粘着位(S_ISVTX),而且又没有超级用户权限,那么mode中的粘着位自动被关闭。这意味着只有超级用户才能设置文件的粘着位。这样做的理由是防止恶意用户设置粘着位,由此影响系统性能。
- 新创建文件的组ID可能不是调用进程所属的组。新文件的组ID可能是父目录的组ID。特别地,如果新文件的组ID不等于进程的有效组ID或者进程附属组ID中的一个,而且进程没有超级用户权限,那么设置ID位会被自动关闭。这就防止了用户创建一个设置组ID文件,而该文件是由非该用户所属的组拥有的。
4.10 粘着位
S_ISVTX。
4.11 函数chown、fchown、fchownat和lchown
chown函数可用于更改文件的用户ID和组ID。如果两个参数owner或group中的任意一个是-1,则对应的ID不变。
1 |
|
四个函数的返回值:若成功,返回0;若出错,返回-1。
除了所引用的文件是符号链接外,这四个函数的操作类似。在符号链接情况下,lchown和fchownat(设置了AT_SYMLINK_NOFOLLOW标志)更改符号链接本身的所有者,而不是符号链接所指向的文件所有者。
超级用户可以更改文件的所有者和所有组,而有些系统支持任一用户更改他们所拥有的文件所有者。
4.12 文件长度
stat结构成员st_size表示以字节为单位的文件的长度。此字段只对普通文件、目录文件和符号链接有意义。
对于普通文件,其文件长度可以是0,在开始读这种文件时,将得到文件结束符(EOF)。对于目录,文件长度通常是一个数(如16或512)的整数倍。对于符号链接,文件长度是指向的文件名中的实际字节数。
现今,大多数现代的UNIX系统提供字段st_blksize和st_blocks。前者是对文件I/O较合适的块长度,后者是所分配的实际512字节块块数。
文件中的空洞

文件core的长度稍稍超过8MB,可是du命令报告该文件所使用的磁盘空间总量是272个512字节块(即139264字节)。很明显,此文件中有很多空洞。

使用wc命令,可以看出正常的I/O操作读整个文件的长度。

如果使用cat复制这个文件,那么所有的这些空洞都会被填满,其中所有实际数据字节皆填写为0。
4.13 文件截断
在打开文件时使用O_TRUNC标志截断为0是一个特例。为了截断文件,可以使用下列函数:
1 |
|
若成功,返回0;若失败,返回-1。
这两个函数将现有文件截断为length。若原来的文件长度大于length,则截断后大于length的部分无法访问。若原来问价你的长度小于length,则截断后文件长度将增加,在以前的文件尾端和新的文件尾端之间的数据将读作0(也就是可能在文件中创建了一个空洞)。
4.14 文件系统

我们可以把一个磁盘分成一个或多个分区。每个分区可以包含一个文件系统。i节点时固定长度的记录项,它包含有关文件的大部分信息。更仔细地观察一个柱面的i节点和数据块部分,可以看到下图所示情况:

上图中,有两个目录项指向同一个i节点。每个i节点中都有一个链接计数,其值是指向该i节点的目录项数。只有当链接计数减少至0时,才可删除该文件(也可以释放该文件占用的数据块)。这就是为什么“解除一个文件的链接”操作并不总是意味着“释放该文件占用的磁盘块”的原因。这也是为什么删除一个目录项的函数被称为unlink而不是delete的原因。在stat结构中,链接计数包含在st_nlink成员中,其基本系统数据类型是nlink_t,这种链接类型称为硬链接。
另一种链接类型称为符号链接。符号链接文件的实际内容包含了该链接所指向的文件的名字。符号链接i节点中文件类型是S_IFLNK,于是系统知道这是一个符号链接。
- i节点包含了文件有关的所有信息:文件类型、文件访问权限位、文件长度和指向文件数据块的指针等。stat结构中的大多数信息都取自i节点。只有两项重要数据存放在目录项中:文件名和i节点编号。i节点编号的数据类型是ino_t。
- 因为目录项中的i节点编号指向同一文件系统中的相应i节点,一个目录项不能指向另一个文件系统的i节点。因此ln命令不能跨越文件系统。
- 当在不更换文件系统的情况下为一个文件重命名时,该文件的实际内容并未移动,只需构造一个指向现有i节点的新目录项,并删除老的目录项。链接计数不会改变。这就是mv命令的通常操作方式。
上述时普通文件的链接计数的概念,对于目录文件的链接计数字段如下所示:

我们在当前目录下创建一个testdir目录。testdir目录的i节点的链接计数项为2,一个来自创建该目录的父目录,即当前工作目录,还有一个来自于testdir目录中的.。而当前工作目录的i节点的链接计数项至少为3,一个来自于其父目录,一个来自于当前目录下的.,还有一个来自testdir目录的..。注意,在父目录中每一个子目录都使该父目录的链接计数增加1。
4.15 函数link、linkat、unlink、unlinkat和remove
创建一个指向现有文件的链接的方法是使用link函数或linkat函数。
1 |
|
若成功,返回0;若出错,返回-1。
这两个函数创建一个新目录项newpath,它引用现有文件existingpath。如果newpath已经存在,则返回出错。只创建newpath中最后一个分量,路径中的其他部分应当已经存在。
linkat函数使用flag参数控制指向现有符号链接还是指向现有符号链接所指向的文件。(flag = AT_SYMLINK_FOLLOW说明指向符号链接指向的文件)。
为删除一个现有的目录项,可以调用unlink函数:
1 |
|
若成功,返回0;若出错,返回-1。
这两个函数删除目录项,并将由pathname所引用的文件的链接计数减1。如果对该文件还有其他链接,则仍可通过其他链接访问该文件的数据。如果出错,则不对该文件做任何更改。
只有当链接计数达到0时,该文件的内容才可被删除。另一个条件也会阻止删除文件的内容——只要有进程打开了文件,其内容也不能删除。关闭一个文件时,内核首先检查打开该文件的进程个数,如果这个计数达到0,内核再去检查其链接计数;如果计数也是0,那么就删除该文件的内容。
unlinkat的flag参数给出一种方法,使调用进程可以改变unlinkat函数的默认行为。当AT_REMOVEDIR标志被设置时,unlinkat函数可以类似于rmdir一样删除目录。如果这个标志被清除,unlinkat与unlink执行同样的操作。
我们也可以使用remove函数解除对一个文件或目录的链接。对于文件,remove功能与unlink相同。对于目录,remove的功能与rmdir相同。
1 |
|
若成功,返回0;若出错,返回-1。
4.16 函数rename和renameat
文件或目录可以用rename函数或renameat函数进行重命名。
1 |
|
若成功,返回0;若出错,返回-1。

4.17 符号链接
硬链接直接指向文件的i节点,且有以下限制:
- 硬链接通常要求链接和文件位于同一文件系统中
- 只有超级用户才能创建指向目录的硬链接
符号链接以及它指向的何种对象无文件系统的限制,任何用户都可以创建指向目录的符号链接。符号链接一般用于将一个文件或整个目录结构移到系统中的另一位置。

使用符号链接可能在文件系统中引入循环。大多数查找路径名的函数在这种情况发生时都将返回出错,errno值为ELOOP。

这样的循环很容易消除。因为unlink并不跟随符号链接,所以可以unlink文件foo/testdir。但是如果创建了一个构成这种循环的硬链接,那么就很难消除它。
ls命令使用-l参数可以查看文件的较详细信息,包括符号链接,使用-F参数会在符号链接后加上@。
4.18 创建和读取符号链接
可以使用symlink或symlinkat函数创建一个符号链接。
1 |
|
若成功,返回0;若出错,返回-1。函数创建一个指向actualpath的新目录项sympath。在创建此符号链接时,并不要求actualpath已经存在。并且,actualpath和sympath并不需要位于同一文件系统中。
symlinkat函数中的fd参数为已打开的文件描述符,使得sympath的路径相对于该文件描述符。如果sympath为绝对路径或fd设置为AT_FDCWD,则symlinkat与symlink等同。
由于open函数跟随符号链接,所以需要一种方法打开链接本身,并读该链接中的名字。readlink和readlinkat函数提供了这种功能:
1 |
|
两个函数组合了open、read和close的所有操作。如果函数成功,则返回读入buf的字节数。出错返回-1。在buf中返回的符号链接的内容不以null字节终止。
4.19 文件的时间
| 字段 | 说明 | 例子 | ls(1)选项 |
|---|---|---|---|
| st_atime | 文件数据的最后访问时间 | read | -u |
| st_mtime | 文件数据的最后修改时间 | write | 默认 |
| st_ctime | i节点状态的最后更改时间 | chmod、chown | -c |
注意,修改时间(st_mtime)和状态更改时间(st_ctime)之间的区别。修改时间时文件内容最后一次被修改的时间。状态更改时间是该文件的i节点最后一次被修改的时间。更改文件的访问权限、更改用户ID、更改连接数等都将影响到i节点,但这些操作并没有修改文件的内容。文件的内容与文件的状态信息是分开存访的,所以,除了要记录文件数据修改时间外,还需要记录状态更改时间。
注意,系统并不维护对一个i节点的最后一次访问时间,所以access和stat函数并不更改上述3个时间中的任意一个。

4.20 函数futimens、utimensat和utimes
一个文件的访问和修改时间可以用以下几个函数更改。futimens和utimensat函数可以指定纳秒级精度的时间戳。用到的数据结构是与stat函数族相同的timespec结构。
1 |
|
若成功,返回0;否则,返回-1。times数组第一个元素包含访问时间,第二个元素包含修改时间。这两个时间是日历时间,即从(1970年1月1日00:00:00)以来所经过的秒数。不足秒的部分用纳秒表示。
时间戳可以按下列4中方式之一进行指定:
- 如果times参数是一个空指针,则访问时间和修改时间两者都设置成当前时间。
- 如果times参数指向两个timespce结构的数组,任一数组元素的tv_nsec字段的值为
UTIME_NOW,相应的时间戳就设置为当前时间,忽略相应的tv_sec字段。 - 如果times参数指向两个timespec结构的数组,任一数组元素的tv_nsec字段的值为
UTIME_OMIT,相应的时间戳保持不变,忽略相应的tv_sec字段。 - 如果times参数指向两个timespec结构的数组,且tv_nsec字段的值为既不是
UTIME_NOW也不是UTIME_OMIT,在这种情况下,相应的时间戳设置为相应的tv_sec和tv_nsec字段的值。
1 | struct timespec { |
utimensat默认行为是跟随符号链接,如果将flag参数设置为AT_SYMLINK_NOFOLLOW标志,则符号链接本身的时间就会被修改(如果路径名是符号链接)。
1 |
|
utimes函数对路径名进行操作。timeval结构如下:
1 | struct timeval { |
timeval结构的两个成员一个表示秒,一个表示微秒。timeval同样也是表示时间戳。调用utimes时,st_ctime字段会被自动更新。
4.21 函数mkdir、mkdirat和rmdir
mkdir、mkdirat用于创建目录,rmdir用于删除目录。
1 |
|
若成功返回0,出错返回-1。这两个函数将创建一个新的空目录,.和..是自动创建的。所指定的文件访问权限mode由进程的文件模式创建屏蔽字修改。
常见的错误是指定与文件相同的mode(只指定读、写权限)。但是,对于目录通常至少要设置一个执行权限位,以允许访问该目录中的文件名。
rmdir函数可以删除一个空目录。空目录是只能包含.和..这两项的目录。
1 |
|
4.22 读目录
对某个目录具有访问权限的任一用户都可以读该目录,但是,为了防止文件系统产生混乱,只有内核才能写该目录。一个目录的写权限位和执行权限位决定了在该目录中能否创建新文件以及删除文件,它们并不表示能否写目录本身。
1 |
|
dirent结构体至少包含以下两个成员:
1 | ino_t d_ino; /* i-node number */ |
DIR结构是一个内部结构,上述7个函数用这个内部结构保存当前正在被读的目录的有关信息。
DIR结构由fdopendir创建时,readdir返回的第一项取决于传给fdopendir函数的文件描述符相关联的文件偏移量。
4.23 函数chdir、fchdir和getcwd
进程调用chdir或fchdir函数可以更改当前工作目录。
1 |
|
若成功,返回0;若出错,返回-1。
getcwd从当前目录开始,用..找到其上一级目录,然后读其目录项,直到该目录项中的i节点编号与工作目录i节点编号相同,这样就找到了其对应的文件名。按照这种方法逐层上移,直到遇到根,这样就得到了当前工作目录完整的绝对路径名。
1 |
|
若成功,返回buf;若出错,返回NULL。必须向该函数传递两个参数,一个是缓冲区地址buf,另一个是缓冲区长度size(以字节为单位)。该缓冲区必须有足够的长度以容纳绝对路径名再加上一个终止null字节,否则返回出错。
chdir跟随符号链接,但是getcwd沿目录树向上溯源时,并不了解某个目录由符号链接所指向。
4.24 设备特殊文件
每个文件系统所在的存储设备都由其主、次设备号表示。设备号所用的数据类型是基本系统数据类型dev_t。主设备号标识设备驱动程序,有时编码为与其通信的外设备板;次设备号标识特定的子设备。一个磁盘驱动器经常包含若干个文件系统。在同一磁盘驱动器上的各文件系统通常具有相同的主设备号,但是次设备号却不同。
系统中与每个文件名关联的st_dev值是文件系统的设备号,该文件系统包含了这一文件名以及与其对应的i节点。
只有字符特殊文件和块特殊文件才有st_rdev值。此值包含实际的设备号。
major与minor用于查看主、次设备号,是两个宏。
1 | printf("dev = %d/%d\n", major(buf.st_dev), minor(buf.st_dev)); |
第5章 标准I/O库
5.1 引言
标准I/O库处理很多细节,如缓冲区分配、以优化的块长度执行I/O等。这些处理使用户不必担心如何选择使用正确的块长度。
5.2 流和FILE对象
第三章中的I/O操作通过打开文件得到文件描述符,进而对文件描述符进行后续I/O操作。标准I/O则是将文件与流相关联。
流的定向决定了所读写的字符是单字节还是多字节的。当一个流被创建时,它并没有定向。如果在一个未定向的流上使用多字节I/O函数,则将该流的定向设置为宽定向的。如果在一个未定向的流上使用单字节I/O函数,则将流的定向设置为单字节定向的。
有两个函数可以改变流的定向:freopen清除一个流的定向,fwide用于设置流的定向。
1 |
|
若流是宽定向的,返回正值;若流是字节定向的,返回负值;若流是未定向的,返回0。
mode参数:
- mode为正,fwide将试图使指定的流为宽定向的;
- mode为负,fwide将试图使指定的流为字节定向的;
- mode为0,不设置流的定向,返回流的定向。
注意,fwide并不改变已经定向的流的定向。
5.3 标准输入、标准输出和标准错误
头文件stdio.h中定义:stdin、stdout、stderr。三个均为FILE *类型,即文件指针。
5.4 缓冲
标准I/O库提供缓冲的目的是尽可能减少使用read和write调用的次数,它也对每个I/O流自动地进行缓冲管理,从而避免了应用程序需要考虑这一点所带来的麻烦。
标准I/O提供了3种类型的缓冲:
- 全缓冲:在填满标准I/O缓冲区后才进行实际I/O操作。在缓冲区未满时,可使用fflush函数冲洗一个流,将缓冲区中的内容写到磁盘上。
- 行缓冲:当在输入和输出中遇到换行符或行缓冲区满时,标准I/O库执行I/O操作。任何时候只要通过标准I/O库要求从一个不带缓冲的流,或者一个行缓冲的流得到输入数据,那么就会冲洗所有行缓冲输出流。
- 不带缓冲:不对字符进行缓冲存储。
标准错误流stderr通常是不带缓冲的,这就使得出错信息可以尽快显示出来。
ISO C要求下列缓冲特征:
- 当且仅当标准输入和标准输出并不指向交互设备时,它们才是全缓冲的
- 标准错误决不会是全缓冲的
很多系统默认使用下列类型的缓冲:
- 标准错误是不带缓冲的
- 若是指向终端设备的流,则是行缓冲的;否则是全缓冲的
对于一个给定的流,如果我们并不喜欢这些系统默认,则可调用下列两个函数中的一个更改缓冲类型:
1 |
|
若成功,返回0;若出错,返回非0。这些函数必须要在对文件指针执行任何一个其他操作之前调用。
setbuf用于打开或关闭缓冲,若buf参数为NULL,则为不带缓冲。否则,参数buf必须指向一个长度为BUFSIZ的缓冲区,执行后流就是全缓冲的,但如果一个流与一个终端设备相关,那么也可能将其设置为行缓冲。
setvbuf可以精确设置所需的缓冲类型,mode参数:
- _IOFBF:全缓冲
- _IOLBF:行缓冲
- _IONBF:不带缓冲

任何时候,我们都可强制冲洗一个流:
1 |
|
若成功,返回0;若出错,返回EOF。
5.5 打开流
1 |
|
三个函数若成功,返回文件指针;若出错,返回NULL。
- fopen打开路径名pathname为一个指定的文件流
- freopen在一个指定的流上打开一个指定的文件,若该流已经打开,则先关闭该流。若流已经定向,则使用freopen清除该定向。
- fdopen取一个已有的文件描述符,并使一个标准的I/O流与该描述符相结合。
type参数:


关闭流:
1 |
|
返回值:若成功,返回0;若出错,返回EOF。在该文件被关闭之前,冲洗缓冲区中的输出数据。缓冲区中的任何输入数据被丢弃。如果标准I/O库已经为该流自动分配了一个缓冲区,则释放此缓冲区。
5.6 读和写流(每次一个字符)
一旦打开了流,则可在3种不同类型的非格式化I/O中进行选择,对其进行读、写操作:
- 每次一个字符:getc(), fgetc(), getchar()
- 每次一行:fgets(), fputs()
- 直接I/O:fread(), fwrite()。每次I/O操作读或写某种数量的对象,而每个对象具有指定的长度。这两个函数常用于从二进制文件中每次读或写一个结构。
1. 输入函数
1 |
|
三个函数若成功,返回下一个字符;若已达文件尾端或出错,返回EOF。
getc可被实现为宏,而fgetc不能实现为宏,是一个函数。调用fgetc所需的时间可能比getc要长,因为调用函数所需的时间通常长于调用宏。getchar相当于getc(stdin)。

注意,不管是出错还是到达文件尾端,这3个函数都返回同样的值。为了区分这两种不同的情况,必须调用ferror或feof。
1 |
|
在大多数实现中,为每个流在FILE对象中维护了两个标志:
- 出错标志
- 文件结束标志
调用clearerr可清楚这两个标志。
从流中读取数据后,可调用ungetc将字符再押送回流中。
1 |
|
若成功,返回c;若出错,返回EOF。压送回流中的字符以后又可从流中读出,但读出字符的顺序与压送回的顺序相反。
2. 输出函数
1 |
|
若成功,返回c;若出错,返回EOF。
5.7 每次一行I/O
1 |
|
若成功,返回buf;若已到达文件尾或出错,返回NULL。这两个函数都指定了缓冲区的地址,读入的行将送入其中。gets从标准输入读,而fgets则从指定的流读。
fgets必须指定缓冲区的长度n,此函数一直读到下一个换行符为止,但是不超过n-1个字符,读入的字符被送入缓冲区。该缓冲区以null字节结尾。
gets是一个不推荐使用的函数,其问题是调用者在使用gets时不能指定缓冲区的长度。这样就可能造成缓冲区溢出。gets与fgets的另一个区别是,gets并不将换行符存入缓冲区中。

1 |
|
出错返回EOF。函数fputs将一个以null字节终止的字符串写到指定的流,尾端的终止符null不写出。

puts函数会在额外输出一个换行符。
5.8 标准I/O的效率
5.9 二进制I/O
1 |
|
两个函数的返回值:读或写的对象数。size为每个元素的长度,nobj为欲写的元素个数。
对于读,如果出错或到达文件尾,返回的数字可能小于nobj,应调用ferror或feof以判断究竟是哪一种情况。对于写,如果返回值小于nobj,则出错。
5.10 定位流
1 |
|
ftell和fseek使用长整型long来存储和设置字节位置。rewind可以将一个流设置到文件的起始位置。where参数值可为SEEK_CUR、SEEK_SET、SEEK_END。
为了定位一个文本文件,whence一定要是SEEKj_SET,且offset只有两种值:0,或是对该文件的ftell所返回的值。
1 |
|
ftello、fseeko与ftell、fseek的区别就在于ftello和fseeko使用了off_t类型来存储位置。
1 |
|
两个函数的返回值:若成功,返回0;若出错,返回非0。
fgetpos和fsetpos使用一个抽象数据类型fpos_t记录文件的位置。这种数据类型可以根据需要定义一个足够大的数,用以记录文件位置。
5.11 格式化I/O
1. 格式化输出
1 |
|
- printf
- fprintf 写至指定的流
- dprintf 写至指定的文件描述符
- sprintf 将格式化的字符送入数组buf中,sprintf 在该数组的尾端自动加一个null字节,但该字符不包括在返回值中
- snprintf 解决了sprintf的缓冲区溢出问题,超过缓冲区尾端写的所有字符都被丢弃。
转换说明:
1 | %[flags][fldwidth][precision][lenmodifier]convtype |
flags:

lenmodifier:

convtype:

2. 格式化输入
1 |
|
scanf族用于分析输入字符串,并将字符序列转换成指定类型的变量。格式之后的个参数包含了变量的地址,用转换结果对这些变量赋值。
5.12 实现细节
标准I/O库最终都要调用第3章说明中的I/O例程。每个标准I/O流都有一个与其相关联的文件描述符,可以对一个流调用fileno函数以获得其描述符。
1 |
|
5.13 临时文件
1 |
|
tmpnam函数产生一个与现有文件名不同的一个有效路径名字符串。每次调用它时,都产生一个不同的路径名,最多调用次数是TMP_MAX。TMP_MAX定义在stdio.h中。如果ptr参数为NULL,则所产生的路径名存放在一个静态区中,指向静态区的指针作为函数值返回。后续调用tmpnam时,会重写该静态区。如果prt不是NULL,则认为它应该是指向长度至少是L_tmpnam个字符的数组(L_tmpnam是一个常量,定义在stdio.h中)。所产生的路径名存放在该数组中,prt也作为函数值返回。
tmpfile创建一个临时二进制文件(类型wb+),在关闭该文件或程序结束时将自动删除这种文件。
1 |
|
mkdtemp函数创建了一个目录,该目录有一个唯一的名字;mkstemp函数创建了一个文件,该文件有一个唯一的名字。名字是通过template字符串进行选择的。这个字符串是后6位设置为XXXXXX的路径名。函数将这些占位符替换成不同的字符串来构建一个唯一的路径名。如果成功的话,这两个函数将修改template字符串反映临时文件的名字。
mkdtemp函数创建的目录使用下列访问权限位:S_IRUSR、S_IWUSR、S_IXUSR。mkstemp函数以唯一的名字创建一个普通文件并且打开该文件,该函数返回的文件描述符以读写方式打开,访问权限位设置为:S_IRUSR、S_IWUSR。
与tmpfile不同的是,mkstemp创建的临时文件并不会自动删除。
第6章 系统数据文件和信息
6.1 引言
系统文件:
- 口令文件:/etc/passwd
- 组文件:/etc/group
UNIX系统的正常运作需要使用大量与系统有关的数据文件,这些数据文件都是ASCII文本文件,并且使用标准I/O库读这些文件。但是,对于较大的系统,顺序扫描口令文件很花费时间,我们需要能够以非ASCII文本格式存放这些文件,但仍向使用其他文件格式的应用程序提供接口。
为应用程序提供对数据文件的可移植接口是本章的主题。本章也包括了系统标识函数、时间和日期函数。
6.2 口令文件
头文件pwd.h中定义的passwd结构:

/etc/passwd(Linux):


- 空白注释字段不产生任何影响
- /usr/sbin/nologin 打印可定制的出错信息,然后以非0状态终止,可阻止一个特定用户登陆系统
- nobody用户名使任何人都可登陆至系统,但其用户ID(65534)和组ID(65534)不提供任何特权。该用户ID和组ID只能访问人人皆可读、写的文件
两个获取口令文件项的函数:
1 |
|
若成功,返回指向passwd结构的指针;若出错,返回NULL。
passwd结构通常是函数内部的静态变量,只要调用任一相关函数,其内容就会被重写。
有些程序要查看整个口令文件:
1 |
|
调用getpwend时,它返回口令文件中的下一个记录项。每次调用此函数都重写passwd结构。第一次调用时,它打开所使用的各个文件。在使用getpwent查看完口令文件后,一定要调用endpwent关闭这些文件。getpwent对返回记录项的顺序并无要求。
setpwent反绕它所使用的文件。
6.3 阴影口令
加密口令是经单向加密算法处理过的用户口令副本。对于一个加密口令,找不到一种算法可以将其反变换到明文口令。
阴影口令(shadow password)文件中存放这些加密过的口令,与之相关的结构:

只有用户登陆名和加密口令这两个字段是必须的。阴影口令文件(/etc/shadow)文件只有root用户可读,而/etc/passwd文件可由各用户自由读取。
1 |
|
6.4 组文件
/etc/group为组文件。与之对应的C结构体是在头文件grp.h中的group:

gr_men是一个指针数组,其中每一个指针指向一个属于该组的用户名。该数组以null指针结尾。
可以用下列两个函数来获取一个group的信息:
1 |
|
如同对口令文件进行操作的函数一样,这两个函数通常也返回指向一个静态变量的指针,在每次调用时都重写该静态变量。
搜索整个组文件,使用另外几个函数:
1 |
|
6.5 附属组ID(supplementary group ID)
我们不仅可以属于口令文件记录项中组ID所对应的组,也可属于多至16各另外的组。文件访问权限检查修改为:不仅将进程的有效组ID与文件的组ID相比较,而且也将所有附属组ID与文件的组ID进行比较。
使用附属组ID的优点是不必再显式地经常更改组。一个用户会参与多个项目,因此也就要同时属于多个组,此类情况是常有的。
6.6 实现区别

6.7 其他数据文件

一般情况下,对于每个数据文件,至少有3各函数:
- get函数:读下一个记录,如果需要,还会打开该文件。此种函数通常返回指向一个结构的指针。当已达到文件尾端时返回空指针。大多数get函数返回指向一个静态存储类结构的指针,如果要保存它,则需复制它。
- set函数:打开相应的数据文件(如果尚未打开),然后反绕该文件。如果希望在相应文件起始处开始处理,则调用此函数。
- end函数:关闭相应数据文件。
另外,还可支持某种形式的键搜索,如getpwnam、getpwuid等等。
6.8 登陆账户记录
大多数UNIX系统都提供下列两个数据文件:
- utmp:记录当前登陆到系统的各个用户,对应命令who(/var/run/utmp)
- wtmp:跟踪各个登陆和注销事件,对应命令last(/var/log/wtmp)
对应的结构体:
1 | struct utmp { |
登陆时,login程序填写此类型结构,然后将其写入到utmp文件中,同时也将其填写到wtmp文件中。注销时,init进程将utmp文件中相应的记录擦除(每个字节都填以null字节),并将一个新记录填写到wtmp文件中。
6.9 系统标识
1 |
|
uname函数返回与主机和操作系统有关的信息,若成功,返回非负值;若出错,返回-1。

utsname结构中,每个字段都是一个字符数组,每个数组的长度由实现确定。每个字符串都以null字节结尾。

BSD派生的系统提供gethostname函数,它只返回主机名,该名字通常就是TCP/IP网络上主机的名字。
1 |
|
若成功,返回0;若出错,返回-1。namelen参数指定name缓冲区长度,如若提供足够的空间,则通过name返回的字符串以null字节结尾。如果没有提供足够的空间,则没有说明通过name返回的字符串是否以null结尾。相关联的文件是/etc/hostname。
6.10 时间和日期例程
time函数用于获取日历时间(从UTC到现在的秒数):
1 |
|
若成功,返回时间值;若出错,返回-1。如果参数非空,则时间值也存放在由calptr指向的单元内。
clock_gettime函数可用于获取指定时钟的时间,将时间存在timespce结构中,它把时间表示为秒和纳秒:
1 |
|
clock_id的类型为clockid_t:

clock_id设置为CLOCK_REALTIME时,clock_gettime函数提供了与time函数类似的功能,不过在系统支持高精度时间值的情况下,clock_gettime可能比time函数得到更高精度的时间值。
clock_getres函数把参数tsp指向的timespec结构初始化为与clock_id参数对应的时钟精度。
1 |
|
要对特定的时钟设置时间,可以调用clock_settime函数:
1 |
|
gettimeofday函数以距特定时间(1970年1月1日00:00:00)的秒数的方式将当前时间存放在tp指向的timeval结构中,而该结构将当前时间表示为为秒和微妙。
1 |
|
tzp的唯一合法值是NULL,其他值将产生不确定的结构。某些平台支持用tzp说明时区,但这完全依赖于实现。
一旦获取这种从上述特定时间经过的秒数的整型时间值后,通常要调用函数将其转换为分解的时间结构,然后调用另一个函数生成人们可读的时间和日期。

上图中表示了时间转换关系,虚线表示受到环境变量TZ的影响。点划线标示了如何从时间相关的结构获得日历时间。
1 |
|
localtime和gmtime将日历时间转换成分解的时间,并将这些存放在一个tm结构中:

除了月日字段,其他字段的值都以0开始。如果夏令时生效,则夏令时标志值为正;如果为非夏令时时间,则该标志值为0,如果此信息不可用,则其值为负。
localtime和gmtime的区别是:localtime将日历时间转换成本地时间(考虑本地时区和夏令时标志),而gmtime则将日历时间转换成协调统一时间的年、月、日、时、分、秒、周日分解结构。
函数mktime以本地时间的年、月、日等作为参数,将其变换成time_t值。
1 |
|
函数strftime是一个类似于printf的时间值函数,非常复杂,可以通过可用的多个参数来定制产生的字符串。
1 |
|
若有空间,返回存入数组的字符数(在末尾加上null终止符,但该字符不算在返回字符数中);否则,返回0。strftime_l允许调用者将区域指定为参数,除此之外,strftime和strftime_l函数是相同的。strftime使用通过TZ环境变量指定的区域。

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

第7章 进程环境
7.1 引言
本章将学习:
- 当程序执行时,其main函数是如何被调用的
- 命令行参数是如何传递给新程序的
- 典型的存储空间布局是什么样式的
- 如何分配另外的存储空间
- 进程如何使用环境变量
- 进程的各种不同终止方式
- longjum和setjmp函数以及它们与栈的交互作用
- 进程的资源限制
7.2 main函数
C程序的main函数原型:
1 | int main(int argc, char *argv[]); |
当内核执行C程序时(使用一个exec函数),在调用main前先调用一个特殊的启动例程。可执行程序文件将此启动例程指定为程序的起始地址——这是由连接编辑器设置的,而连接编辑器则由C编译器调用。启动例程从内核取得命令行参数和环境变量值,然后为按上述方式调用main函数做好安排。
7.3 进程终止
8种终止进程的方式,前5种为正常终止:
- 从 main 返回
- 调用 exit
- 调用 _exit 或 _Exit
- 最后一个线程从其启动例程返回
- 从最后一个线程调用 pthread_exit
3种异常终止方式:
- 调用abort
- 接到一个信号
- 最后一个线程对取消请求做出响应
上一节提及的启动例程是这样编写的,使得从main返回后立即调用exit函数。如果将启动例程以C代码形式表示(实际上该例程常常使用汇编语言编写),则它调用main函数的形式可能是:
1 | exit(main(argc, argv)); |
1. 退出函数
3个函数用于正常终止一个程序:_exit和_Exit立即进入内核,exit则先执行一些清理处理,然后返回内核。
1 |
|
exit函数总是执行一个标准I/O库的清理关闭操作:对于所有打开的流,调用fclose函数,这造成输出缓冲中的所有数据都被冲洗(写到文件上)。
3个退出函数都有一个整型参数,称为终止状态。大多数UNIX系统shell都提供检查进程终止状态的方法。如果:
- 调用这些函数时不带终止状态
- main执行了一个无返回值的return
- main没有声明返回类型为整型
则该进程的终止状态是未定义的。但是,若main的返回类型是整型,并且main执行到最后一条语句(隐式返回),那么该进程的终止状态是0。
2. 函数atexit
atexit函数用于注册终止处理程序(exit handler),这些函数注册后由exit自动调用。
1 |
|
exit调用这些函数的顺序与它们注册时候的顺序相反,同一函数若登记多次,也会被调用多次。

exit首先调用各终止处理程序,然后关闭所有打开流。内核使程序执行的唯一方法是调用一个exec函数。进程自愿终止的唯一方法是显式或隐式地调用_exit或_Exit。进程也可非自愿地由一个信号使其终止。
7.4 命令行参数
当执行一个程序时,调用exec的进程可将命令行参数传递给该新程序。
解析命令行参数:
1 |
|
7.5 环境表
每个程序都接收到一张环境表。与参数表一样,环境表也是一个字符指针数组,其中每个指针包含一个以空字符结尾的C风格字符串的地址。全局变量environ则包含了该指针数组的地址:
1 | extern char **environ; |

我们称 environ 为环境指针,各指针指向的字符串为环境字符串。通常使用getenv和putenv函数来访问特定的环境变量,而不是用environ变量。但是,如果要查看整个环境,则必须使用environ指针。
7.6 C程序的存储空间布局(内存模型)

历史沿袭至今,C程序一直由以下几部分组成:
正文段:这是由CPU执行的机器指令部分。通常,正文段是可共享的,所以即使是频繁执行的程序(如编译器、shell等)在存储器中也只需有一个副本。另外,正文段常常是只读的,以防止程序由于意外而修改其指令。
初始化数据段:通常将此段称为数据段,它包含了程序中需明确地赋初值的变量。例如,C程序中任何函数之外的声明:
1
int maxcount = 99;
未初始化数据段:通常将此段称为bss段,在程序开始执行之前,内核将此段中的数据初始化为0或空指针。如函数外的声明:
1
long sum[1000];
栈:自动变量以及每次函数调用时所需保存的信息都存放在此段中。每次函数调用时,其返回地址以及调用者的环境信息都存放在栈中。然后,最近被调用的函数在栈上为其自动和临时变量分配存储空间。通过以这种方式使用栈,C递归函数可以工作。递归函数每次调用自身时,就用一个新的栈帧,因此一次函数调用实例中的变量集不会影响另一次函数调用实例中的变量。
堆:通常在堆中进行动态内存分配。
从图7-6中可以看出,未初始化数据段的内容并不存放在磁盘程序文件中。其原因是,内核在程序开始运行前将它们都设置为0。需要存放在磁盘程序文件中的段只有正文段和初始化数据段。
size命令报告正文段、数据段和bss段的长度(以字节为单位):

7.7 共享库
共享库使得可执行文件中不再需要包含公用的库函数,而只需在所有进程都可引用的存储区中保存这种例程的一个副本。程序第一次执行或第一次调用某个函数库时,用动态链接方法将程序与共享库函数相链接。这减少了每个可执行文件的长度,但增加了一些运行时间开销。这种时间开销发生在该程序第一次被执行时,或者每个共享库函数第一次被调用时。
共享库的另一个优点是可以用库函数的新版本代替老版本而无需对使用该库的程序重新连接编辑。
下面是一个例子,不使用共享库:

使用共享库:

使用共享库后,执行文件的正文段和数据段的长度都显著减小。
7.8 存储空间分配
1 |
|
- malloc 分配指定字节数的存储区,此存储区的初始值不确定(垃圾数据)。
- calloc 为指定数量指定长度的对象分配空间,该空间中每一位都初始化为0。
- realloc 增加或减少以前分配区的长度。当增加长度时,可能需要将以前分配区的内容移到另一个足够大的区域(该过程由realloc自动完成),以便在尾端提供增加的存储区,而新增区域内的初始值则不确定。注意,realloc的第二个参数是存储区的新长度,不是新、旧长度之差。作为一个特例,若ptr是一个空指针,则realloc的功能与malloc相同,用于分配一个指定长度为newsize的存储区。
致命性错误:
- 释放一个已经释放了的块;
- 调用free时所使用的指针不是3个alloc函数的返回值
替代的存储空间分配程序 - alloca
alloca是在当前函数的栈帧上分配存储空间,而不是在堆中。
优点:当函数返回时,自动释放它所使用的栈帧,所以不必再为释放空间而费心。
缺点:alloca函数增加了栈帧的长度,有些系统在函数被调用后不能增加栈帧长度,于是也就不支持alloca函数。
7.9 环境变量
1 |
|
我们应当使用getenv从环境变量中取一个指定环境变量的值,而不是直接访问environ。
环境变量:

修改环境变量:
1 |
|
- putenv 取形式为 name=value 的字符串,将其放到环境表中。如果name已经存在,则先删除其原来的定义。
setenv 将 name 设置为 value。如果环境中 name 已经存在,那么使用 rewrite 来设置是否删除现有定义(rewrite = 1,删除现有定义)。
unsetenv 删除 name 的定义。即使不存在这种定义也不算出错。
注意,putenv 和 setenv 之间的区别。setenv 必须分配存储空间,以便依据其参数创建 name = value 字符串。putenv 可以自由地将传递给它的参数字符串直接放到环境中。确实,许多实现就是这么做的,因此,将存访在栈中的字符串作为参数传递给 putenv 就会发生错误,其原因是,从当前函数返回时,其栈帧占用的存储区可能被重用。
环境表在进程的内存空间中处于顶部,删除一个环境字符串很简单,只要将后续指针前移。增加一个环境字符串则困难的多,既不能向上扩展,也不能向下扩展(下为栈),该空间的长度不能再增加。
- 修改现有的name
- 如果新 value 的长度少于或等于现有的 value 的长度,只要将新字符串复制到原字符串所用的空间中
- 如果新 value 的长度大于现有的 value 的长度,必须调用malloc为新字符串分配空间并将其复制到新分配的空间中,接着使用环境表中针对 name 的指针指向新分配区
- 增加一个新的 name,首先,必须调用malloc为 name=value 字符串分配空间,然后将此字符串复制到新分配空间中
- 如果这是第一次增加一个新 name,必须调用malloc为新的指针表分配空间,接着,将原来的环境表复制到新分配区,并将指向新 name=value 字符串的指针存放在该指针的表尾,然后又将一个空指针存放其后。最后使用environ指向新的指针表。
- 如果不是第一次增加新name,只需调用realloc,分配比原空间多存放一个指针的空间。然后将新向 name=value 字符串的指针放在该表表尾,后面跟着一个空指针。
7.10 setjmp 和 longjmp
在C中,goto语句是不能跨越函数的,而执行这种类型跳转功能的是非局部goto——setjmp 和 longjmp函数。非局部指的是在栈上跳过若干调用帧,返回到当前函数调用路径上的某一个函数中。
1 |
|
自动变量、寄存器变量和易失变量
使用 longjmp 回到 setjmp 处时,自动变量、寄存器变量状态如何?看情况。大多数实现并不回滚这些自动变量和寄存器的值。
- 静态变量、全局变量、volatile修饰的自动变量、存储在内存上的自动变量不会回滚
- 寄存器变量、自动变量(使用优化编译后存储在寄存器上)会回滚
或者可以说存储在内存中的变量不会回滚,而存储在寄存器上的变量会回滚。
7.11 getrlimit 和 setrlimit
每个进程都有一组资源限制,其中一些可以使用 getrlimit 和 setrlimit 函数查询和更改。
1 |
|
1 | struct rlimit { |
进程的资源限制通常是在系统初始化时由0进程建立的,然后由后续进程继承。每种实现都可以用自己的方法对资源限制做出调整。
软限制可以被任何进程修改,修改后其值必须小于或等于其硬限制。
硬限制可以被任何进程降低,但必须大于等于其软限制值,且这种降低对普通用户而言是不可逆的。
只有超级用户进程可以提升硬限制值。
常量RLIM_INFINITY指定了一个无限量的限制。
第8章 进程控制
8.1 引言
- 进程控制:创建新进程、执行程序、进程终止
- 进程属性的各种ID——实际、有效和保存的用户ID和组ID
- 解释器文件
- system函数
- 进程会计机制
8.2 进程标识
每个进程都有一个非负整型表示的唯一进程ID。虽然是唯一的,但进程ID是可复用的,当一个进程终止后,其进程ID就成为复用的候选者。
- 0:调度进程,常被称为交换进程,是内核的一部分,不执行任何磁盘上的程序。
1:init进程,在自举过程结束时由内核调用。init通常读取与系统有关的初始化文件,并将系统引导到一个状态。init进程绝不会终止。它是一个普通的用户进程,但是它以超级用户特权运行。
2:页守护进程,负责支持虚拟存储器系统的分页操作。
1 |
|
8.3 函数 fork
一个现有的进程可以调用fork函数创建一个新进程。
1 |
|
fork 函数被调用一次,但返回两次。进程ID 0总是由内核交换进程使用,所以一个子进程的进程ID不可能为0。
子进程和父进程继续执行fork调用之后的指令。子进程是父进程的副本,子进程获得父进程的数据空间、堆和栈的副本。注意,这是子进程所拥有的副本。父进程和子进程并不共享这些存储空间部分。父进程和子进程共享正文段。
写时复制(Copy-On-Write,COW):由于fork之后经常跟随着exec,所以现在很多实现并不执行一个父进程数据段、栈和堆的完全副本。这些区域由父进程和子进程共享,并且内核将它们的访问权限改变为只读。如果父进程和子进程中的任一个试图修改这些区域,则内核只为修改区域的那块内存制作一个副本,通常是虚拟存储系统中的一“页”。
文件共享
在重定向父进程的标准输出时,子进程的标准输出也被重定向。实际上,fork的一个特性是父进程的所有打开文件描述符都被复制到子进程中。我们说“复制”是因为对每个文件描述符来说,就好像执行了dup函数。父进程和子进程每个相同的打开描述符共享一个文件表项,其文件偏移量是共享的。
fork之后处理文件描述有以下两种常见的情况:
- 父进程等待子进程完成。在这种情况下,父进程无需对其描述符做任何处理。当子进程终止后,它曾进行读、写操作的任一共享描述符的文件偏移量已做了相应更新。
- 父进程和子进程各自执行不同的程序段。在这种情况下,在fork之后,父进程和子进程各自关闭它们不需使用的文件描述符,这样就不会干扰对方使用文件描述符。这种方法是网络服务进程经常使用的。
除了打开文件之外,父进程的很多属性也由子进程继承,包括:
- 实际用户ID、实际组ID、有效用户ID、有效组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 这是用户ID标志和设置组ID标志
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 信号屏蔽和安排
- 对任一打开文件描述符的执行时关闭标志
- 环境
- 连接的共享存储段
- 存储映像
- 资源限制
父进程和子进程之间的区别具体如下:
- fork的返回值不同
- 进程ID不同
- 这两个进程的父进程ID不同
- 子进程的tms_utime、tms_stime、tms_cutime和tms_ustime的值设置为0
- 子进程不继承父进程设置的文件锁
- 子进程的未处理闹钟被清除
- 子进程的未处理信号集被设置为空集
8.4 函数vfork
vfork函数用于创建一个新进程,而该新进程的目的是exec一个新程序。且vfork保证子进程先运行,在它调用exec或exit之后父进程才可能被调度运行,当子进程调用这两个函数中的任意一个时,父进程会恢复运行。(如果调用这两个函数之前子进程依赖父进程的进一步动作,则会导致死锁。)
vfork并不将父进程的地址空间完全复制到子进程中,因为子进程会立即调用exec(或exit),于是就不会引用该地址空间。不过在子进程调用exec或exit之前,它在父进程的空间中运行。因此,在子进程中改变fork之前的变量,会使得父进程中的变量更新。
8.5 函数exit
如果父进程在子进程之前终止,对于父进程已终止的所有进程,它们的父进程都改变为 init 进程。我们称这些进程由 init 进程收养。其操作过程大致是:在一个进程终止时,内核逐个检查所有活动进程,以判断它是否是正要终止进程的子进程,如果是,则该进程的父进程ID就更改为1。这种处理方式保证了每个进程有一个父进程。
如果子进程在父进程之前终止,那么父进程如何能在做相应检查时获得子进程的终止状态呢?如果子进程完全消失了,父进程在最终准备好检查子进程时是无法获取它的终止状态的。内核为每个终止子进程保存了一定量的信息,所以当终止进程的父进程调用wait或waitpid时,可以得到这些信息。这些信息至少包括进程ID、该进程的终止状态以及该进程使用的CPU时间总量。
一个已经终止、但是其父进程尚未对其进行善后处理(获取终止子进程的有关信息、释放它仍占用的资源)的进程被称为僵死进程。ps命令将僵死进程的状态打印为Z。
一个由init进程收养的进程终止时会发生什么?它会不会编程一个僵死进程?答案是不会。因为init被编写成无论何时只要有一个子进程终止,init就会调用一个wait函数取得其终止状态。这样也防止了系统中塞满僵死进程。
8.6 函数wait和waitpid
当一个进程正常或异常终止时,内核就向其父进程发送SIGCHLD信号,该信号是内核向父进程发送的异步通知。对于这种信号系统默认动作是忽略它。
调用wait或waitpid的进程可能会发生什么:
- 如果它没有任何子进程,则立即出错返回
- 如果其所有子进程还在运行,则阻塞
- 如果一个子进程已终止,正等待父进程获取其终止状态,则取得该子进程的终止状态立即返回。
1 |
|
在一个子进程终止前,wait使其调用者阻塞,而waitpid有一选项,可使调用者不阻塞。
waitpid并不等待在其调用之后的第一个终止子进程,它有若干个选项,可以控制它所等待的进程。

以上是用于检查wait获取的statloc的宏。
如果一个父进程有几个子进程,那么只要一个子进程终止,wait就返回。waitpid函数提供了等待一个特定进程的功能。对于waitpid中的pid参数:
- -1:等待任一子进程,此时与wait等效
- >0:等待进程ID与pid相等的子进程
- ==0:等待组ID等于调用组进程ID的任意子进程
- pid<-1:等待组ID等于pid绝对值的任一子进程
对于wait,其唯一的出错就是调用进程没有子进程。但是对于waitpid,如果指定的进程或进程组不存在,或者参数pid指定的进程不是调用进程的子进程,都可能出错。
waitpid的options参数使我们能进一步控制waitpid的操作。此参数或者为0,或者为下图中常量按位或运算的结果。

如果一个进程fork一个子进程,但不要等待子进程终止,也不希望子进程处于僵死状态直到父进程结束,实现这一要求的诀窍是调用fork两次,让第一个子进程先终止并等待,使得第二个子进程的父进程变成init,而不等待第二个子进程。
1 |
|
8.7 函数waitid
1 |
|
与waitpid相似,waitid允许一个进程指定要等待的子进程。
8.8 函数wait3和wait4
wait3、wait4 比wait、waitpid、waitid所提供的功能要多一个,这与附加参数有关。该参数允许内核返回由终止进程及其所有子进程使用的资源概况。
1 |
|
8.9 竞争条件
当各进程都企图对共享数据进行某种处理,而最后的结果又取决于进程运行的顺序时,我们认为这发生了竞争条件(race condition)。
如果一个父进程要等待其子进程终止,使用wait函数中的一个。
如果一个子进程要等待其父进程终止,则可以使用轮询:
1 | while (getppid() != 1) |
但这种方式浪费了时间。为避免竞争条件和轮询,可使用信号机制或IPC。
8.10 函数exec
exec并不创建新程序,所以前后的进程ID并未改变,exec只是用磁盘上的一个新程序替换了当前进程的正文段、数据段、堆段和栈段。
基本进程控制原语:
- fork:创建进程
- exec:初始执行新的程序
- exit:处理终止
- wait:等待终止
1 |
|
前四个以路径名作为参数,后两个以文件名作为参数,最后一个以文件描述符作为参数。当以文件名作为参数时:
- 如果filename包含/,则就将其视为路径名pathname。
- 否则,按PATH环境变量,在个目录下搜索可执行文件

简记:
- l:表示列表,命令的参数分开传递,以(char *)0结尾
- v:表示矢量,命令的参数使用char *数组传递
- e:传递环境变量
- p:使用filename而不是路径名
- f:使用文件描述符
前面曾提及,在执行exec后,进程ID没有改变。但新程序从调用进程继承了的下列属性:
- 进程ID和父进程ID
- 实际用户ID和实际组ID
- 附属组ID
- 进程组ID
- 会话ID
- 控制终端
- 闹钟尚余留的时间
- 当前工作目录
- 根目录
- 文件模式创建屏蔽字
- 文件锁
- 进程信号屏蔽
- 未处理信号
- 资源限制
- nice值
- tms_utime、tms_stime、tms_cutime以及tms_cstime值
对打开文件的处理与每个描述符的执行时关闭(close-on-exec)标志值有关。FD_CLOEXEC标志,进程中每个打开描述符都有一个执行时关闭标志。若设置了此标志,则在执行exec时关闭该描述符;否则该描述符任然打开。除非特地用fcntl设置了该执行时关闭标志,否则系统的默认操作是在exec后仍保持这种描述符打开。对于目录流,明确要求exec时关闭。
在exec前后实际用户ID和实际组ID保持不变,而有效ID是否改变则取决于所执行程序文件的设置用户ID和设置组ID位是否设置。如果新程序的设置用户ID位已设置,则有效用户ID变为程序文件所有者的ID;否则有效用户ID不变。对组ID的处理方式与此相同。

7个函数中只有execve是系统调用,其他6个是库函数。
8.11 更改用户ID和更改组ID
UNIX系统中,特权以及访问控制是基于用户ID和组ID的。一般而言,在设计应用时,我们总是试图使用最小特权模型。
可使用setuid函数设置实际用户ID和有效用户ID,用setgid函数设置实际组ID和有效组ID。
1 |
|
更改用户ID的规则:
- 若进程具有超级用户特权,则 setuid 函数将实际用户ID、有效用户ID以及保存的设置用户ID设置为 uid。
- 若进程没有超级用户特权,但是 uid 等于实际用户ID或保存的设置用户ID,则 setuid 只将有效用户ID设置为 uid。不更改实际用户ID和保存的设置用户ID。
- 如果上面两个条件都不满足,则 errno 设置为 EPERM,并返回-1。
关于内核维护的3个用户ID,还需要注意以下几点:
- 只有超级用户进程可以更改实际用户ID。通常,实际用户ID是在用户登陆时,由login程序设置的,而且决不会改变它。因为login是一个超级用户进程,当它调用 setuid 时,设置所有3个用户ID。
- 仅当对程序文件设置了设置用户ID位时,exec 函数才设置有效用户ID。如果设置用户ID位没有设置,exec函数不会改变有效用户ID,而将维持现有值。任何时候都可以调用setuid,将有效用户ID设置为实际用户ID或保存的设置用户ID。自然地,不能将有效用户ID设置为任一随机值。
- 保存的设置用户ID是由exec复制有效用户ID而得到的。如果设置了文件的设置用户ID,则在exec根据文件的用户ID设置了进程的有效ID后,这个副本就被保存起来了。

函数 setreuid 和 setregid
setreuid用于交换实际用户ID和有效用户ID的值。
1 |
|
若其中任一参数值为-1,则表示相应的ID应当保持不变。
规则很简单:一个非特权用户总能交换实际用户ID和有效用户ID。这就允许一个设置用户ID程序交换成用户的普通权限,以后又可再次交换回设置用户ID权限。
函数 seteuid 和 setegid
只更改有效用户ID和有效组ID。
1 |
|
一个非特权用户可将其有效用户ID设置为其实际用户ID或其保存的设置用户ID。对于一个特权用户,则可以将有效用户ID设置为uid。

8.13 函数system
1 |
|
如果cmdstring是一个空指针,仅当系统支持system函数时,返回非0值。这一特征用来检测在给定的操作系统上是否支持system函数。
system在其实现中调用了fork、exec和waitpid,因此有3中返回值:
- fork失败或者waitpid返回除EINTR之外的出错,则system返回-1,并且设置errno以指示错误类型。
- 如果exec失败,则其返回值如同shell执行了
exit(127)。 - 否则所有3个函数都成功,那么system的返回值是shell的终止状态。
设置用户ID程序
如果在一个设置用户ID程序中调用system,那会发生什么呢?这是一个安全方面的漏洞,决不应当这样做。
8.17 进程时间
在1.10节中我们介绍了可以度量的3个时间:时钟时间、用户CPU时间和系统CPU时间。任一进程都可调用times函数获得它自己以及已终止子进程的上述值。
1 |
|
此函数填写由 buf 指向的tms结构,该结构定义如下:
1 | struct tms { |
注意,此结构没有包含墙上时钟时间,times函数返回墙上时钟时间作为其函数值。此值是相对于过去的某一时刻度量的,所以不能用绝对值而必须使用其相对值,即两次调用times,用两次返回的差值作为墙上时钟时间。
所有由此函数返回的clock_t值都用_SC_CLK_TCK转换成秒数。
1 |
|

第9章 进程关系
9.1 引言
本章将更详细地说明进程组以及会话的概念,还将介绍登陆shell和所有从登陆shell启动的进程之间的关系。
9.2 终端登陆
先说明当我们登陆到UNIX系统时所执行的各个程序。终端或者是本地的,或者是远程的,登陆都经由内核中的终端设备驱动程序。因为连接到主机上的终端设备数是固定的,所以同时的登陆数也就有了已知的上限。

系统管理者创建通常名为/etc/ttys的文件,其中,每个终端设备都有一行,每一行说明设备名和传到getty程序的参数。当系统自举时,内核创建进程ID为1的init进程。init进程是系统进入多用户模式。init读取文件/etc/ttys,对每一个允许登陆的终端设备,init调用一次fork,它所生成的子进程则exec getty程序。图9-1中所有进程的实际用户ID和有效用户ID都是0,init以空环境exec getty程序。
getty对终端设备调用open函数,以读、写方式将终端打开。如果设备是调制解调器,则open可能会在设备驱动程序中滞留,直到用户拨号调制解调器,并且线路被接通。一旦设备被打开,则文件描述符0、1、2就被设置到该设备。然后getty输出“login:”之类的信息,并等待用户键入用户名。
当用户键入用户名后,getty的工作就完成了。然后它以类似于下列的方式调用login程序:
1 | execle("/bin/login", "login", "-p", username, (char*)0, envp); |
init以一个空环境调用getty,getty以终端名和在gettytab中说明的环境字符串为login创建一个环境(envp参数)。-p标志通知login保留传递给它的环境,也可将其他环境字串加到该环境中,但是不要替换它。

因为最初的init进程具有超级用户权限,所以图9-2中的所有进程都有超级用户权限。图9-2中底部3个进程的进程ID相同,因为进程ID不会因执行exec而改变。并且,除了最初的init进程,所有进程的父进程ID都是1。
login能处理多项工作。因为它得到了用户名,所以能调用getpwnam取得相应的口令文件登录项。然后getpass以显示提示“Password:”,接着读用户键入的口令。它调用crypt将用户键入的口令加密,并与该用户在阴影口令文件中登录想的pw_passwd字段相比较。如果用户几次键入的口令都无效,则login以参数l调用exit表示登陆过程失败。父进程(init)了解到子进程的终止情况后,将再次调用fork,其后又执行了getty,对终端重复上述过程。

如果用户登陆正确,login就将完成如下工作:
将当前工作目录更改为该用户的起始目录
调用chown更改该终端的所有权,使登陆用户成为它的所有者
将对该终端设备的访问权限改变成“用户读和写”
调用setgid及initgroups设置进程的组ID
用login得到的所有信息初始化环境:起始目录(HOME)、shell(SHELL)、用户名(USER和LOGNAME)以及一个系统默认路径(PATH)
login进程更改登陆用户的用户ID(setuid)并调用该用户的登陆shell,其方式类似于:
1
execl("/bin/sh", "-sh", (char *)0);
argv[0]的第一个字符负号“-”是一个标志,表示该shell被作为登陆shell调用。
至此,登陆用户的登陆shell开始运行。
9.3 网络登陆
网络登陆时,在终端和计算机之间的连接不再是点到点的。在网络登陆情况下,login仅仅是一种可用的服务,这与其他网络服务(如FTP或SMTP)的性质相同。所有登录都经由内核的网络接口驱动程序(如以太网驱动程序),并且事先并不知道将会有多少这样的登陆。
系统使用伪终端的软件驱动程序,仿真串行终端的运行行为,并将终端操作映射为网络操作。

作为系统启动的一部分,init调用一个shell,使其执行shell脚本/etc/rc,由此shell脚本启动一个守护进程inetd。一旦此shell脚本终止,inetd的父进程就变成init。inetd等待TCP/IP连接请求到达主机,而当一个连接请求到达时,它执行一次fork,然后生成的子进程exec适当的程序。
telnetd进程打开一个伪终端设备,并用fork分成两个进程。父进程处理网络连接的通信,子进程则执行login程序。父进程和子进程通过伪终端相连接。在调用exec之前,子进程使用其文件描述符0、1、2与伪终端相连。如果登陆正确,login就执行9.2节中所述的同样步骤——更改当前工作目录为起始目录、设置登陆用户的组ID、用户ID以及初始环境。然后login调用exec将其自身替换为登陆用户的登陆shell。

需要理解的重点是:当通过终端或网络登陆时,我们得到一个登陆shell,其标准输入、标准输出和标准错误要么连接到一个终端设备,要么连接到一个伪终端设备上。这一登陆shell是一个会话的开始,而此终端或伪终端则是会话的控制终端。
9.4 进程组
进程组是一个或多个进程的集合,通常,它们是在同一作业中结合起来的,同一进程组中的各进程接受来自同一终端的各种信号。每个进程组有一个唯一的进程组ID。
1 |
|
getpgid(0)等效于getpgrp()。
每个进程组都有一个组长进程。组长进程的进程组ID等于其进程ID。
进程组组长可以创建一个进程组、创建该组中的进程,然后终止。只要在某个进程组中有一个进程存在,则该进程组就存在,这与其组长进程是否终止无关。从进程组创建开始到其中最后一个进程离开为止的时间称为进程组的生命期。某个进程组中的最后一个进程可以终止,也可以转移到另一个进程组。
进程调用setpgid可以加入一个现有的进程组或者创建一个新进程组。
1 |
|
setpgid函数将pid进程的进程组ID设置为pgid。如果这两个参数相等,则由pid指定的进程变成进程组组长。如果pid是0,则使用调用者的进程ID。另外,如果pgid是0,则由pid指定的进程ID用作进程组ID。一个进程只能为它自己或它的子进程设置进程组ID。在它的子进程调用了exec后,它就不再更改该子进程的进程组ID。
9.5 会话
会话(session)是一个或多个进程组的集合。

通常是由shell的管道将几个进程编成一组的。上图所示的安排可能是由下列形式的shell命令形成的:
1 | proc1 | proc2 & |
进程调用setsid函数建立一个新会话:
1 |
|
如果调用此函数的进程不是一个进程组的组长,则函数创建一个新会话,具体会发生以下三件事:
- 该进程变成新会话的首进程。此时,该进程是新会话中的唯一进程。
- 该进程成为一个新进程组的组长进程。新进程组ID是该调用进程的进程ID。
- 该进程没有控制终端。如果在调用setsid之前该进程有一个控制终端,那么这种联系也被切断。
如果该调用进程已经是一个进程组的组长,则此函数返回出错。为了保证不处于这种情况,通常先调用fork,然后使其父进程终止,而子进程则继续。因为子进程继承了父进程的进程组ID,而其进程ID则是新分配的,两者不可能相等,这就保证了子进程不是一个进程组的组长。
1 |
|
如若pid是0,getsid返回调用进程的会话首进程的进程组ID。如果pid并不属于调用者所在的会话,那么调用进程就不能得到该会话首进程的进程组ID。
9.6 控制终端
会话和进程组还有一些其他特性:
- 一个会话可以有一个控制终端。这通常是终端设备或伪终端内设备。
- 建立与控制终端连接的会话首进程被称为控制进程。
- 一个会话中的几个进程组可以被分成一个前台进程组以及一个或多个后台进程组。
- 如果一个会话有一个控制终端,则它有一个前台进程组,其他进程组为后台进程组。
- 无论何时键入终端的中断键,都会将中断信号发送至前台进程组的所有进程。
- 无论何时键入终端的退出键,都会将退出信号发送至前台进程组的所有进程。
- 无论终端接口检测到调制解调器(或网络)已经断开,则将挂断信号发送至控制进程(会话首进程)。

登陆时,将自动建立控制终端。
9.8 作业控制
作业控制允许在一个终端上启动多个作业(进程组),它控制哪一个作业可以访问该终端以及哪些作业在后台运行。作业控制要求以下3种形式的支持:
- 支持作业控制的shell
- 内核中的终端驱动程序必须支持作业控制
- 内核必须提供对某些作业控制信号的支持

第10章 信号
10.1 引言
信号是软件中断。信号提供了一种处理异步事件的方法。
10.2 信号概念
每个信号有一个名字,这些名字都以3个字符SIG开头,如:
- SIGABRT:夭折信号,当进程调用abort函数时产生这种信号
- SIGALRM:闹钟信号,由alarm函数设置定时器超时后将产生此信号
头文件signal.h中,信号名都被定义为正整数常量。不存在编号为0的信号,POSIX.1将此信号编号值称为空信号。
信号产生:
- 终端产生(Ctrl+C)
- 硬件异常(除以0)
- kill函数或kill命令
- 软件条件(管道读进程已终止后,一个进程写此管道)
信号处理方式:
- 忽略此信号:大多数信号都可用这种方式处理,但有两种信号却绝不能被忽略:SIGKILL和SIGSTOP。
- 捕捉信号:为做到这一点,要通知内核在某种信号发生时,调用一个用户函数,执行事件处理。
- 执行系统默认动作:大多数信号的系统默认动作是终止该进程。
10.3 函数signal
1 |
|
signo是上一节所说的信号名。func的值是常量SIG_IGN、常量SIG_DFL或当接到此信号后要调用的函数的地址,该函数被称为信号处理程序或信号捕捉函数。
signal函数返回一个函数指针,而该指针所指向的函数无返回值,带一个整型参数。
上述声明太复杂,下面是简化的声明:
1 | typedef void Sigfunc(int); |
当执行一个程序时,所有信号的状态都是系统默认或忽略。通常,所有信号都被设置为它们的默认动作,除非调用exec的进程忽略该信号。确切地讲,exec函数将原先设置为要捕捉的信号都更改为默认动作,其他信号的状态则不变(一个进程原先要捕捉的信号,当其执行一个新程序后,就不能再捕捉了,因为信号捕捉函数的地址很可能在所执行的新程序文件中已无意义)。
signal函数的限制:不改变信号的处理方式就不能确定信号的当前处理方式。
10.4 不可靠信号
早期的UNIX版本中,信号是不可靠的,信号可能会丢失:一个信号发生了,但进程却可能不知道这一点。同时,进程对信号的控制能力也很差,它能捕捉信号或忽略它。
有时用户希望通知内核阻塞某个信号:不要忽略该信号,在其发生时记住它,然后在进程做好了准备时再通知它。这种阻塞信号的能力当时并不具备。
10.5 中断的系统调用
早期UNIX系统的一个特性是:如果进程在执行一个低速系统调用而阻塞期间捕捉到一个信号,则该系统调用就被中断不再继续执行。该系统调用返回出错,其errno设置为EINTR。这样处理是因为一个信号发生了,进程捕捉到它,这意味着已经发生了某些事情,所以是个好机会应当唤醒阻塞的系统调用。
低速系统调用:可能会使进程永远阻塞的一类系统调用。
与被中断的系统调用相关的问题是必须显式地处理出错返回:
1 | again: |
为帮助应用程序使其不必处理被中断地系统调用,引进了某些被中断系统调用的自动重启动:ioctl、read、readv、write、writev、wait和waitpid。

10.6 可重入函数

没有列入上表的大多数函数是不可重入的。
10.8 可靠信号术语和语义
当造成信号的事件发生时,为进程产生一个信号,这个过程叫做信号产生。信号产生时,内核通常在进程表中以某种形式设置一个标志,这个过程叫做信号递送。
在信号产生和递送之间的时间间隔内,称信号是未决的(pending)。
进程可以选用“阻塞信号调用”。如果为进程产生了一个阻塞的信号,而且对该信号的动作是系统默认动作或捕捉该信号,则为该进程将此信号保持为未决状态,直到该进程对此信号解除了阻塞,或者将对此信号的动作更改为忽略。内核在递送一个原来被阻塞的信号给进程时,才决定对它的处理方式。于是进程在信号递送给它之前仍可改变对该信号的动作。
进程调用sigpending函数来判定哪些信号是设置为阻塞并处于未决状态的。
10.9 函数kill和raise
kill函数将信号发送给进程或进程组。raise函数则允许进程向自身发送信号。
1 |
|
调用raise(signo)等价于调用kill(getpid(), signo)。
kill的pid参数有以下4种不同的情况
- pid > 0:将该信号发送给进程ID为pid的进程
- pid == 0:将该信号发送给与发送进程属于同一进程组的所有进程(这些进程的进程组ID等于发送进程的进程组ID),而且发送进程具有权限向这些进程发送信号。
- pid < 0:将该信号发送给其他进程组ID等于pid绝对值,而且发送进程具有权限向其发送信号的所有进程。(所有进程不包括系统进程集中的进程)
- pid == -1:将该信号发送给发送进程有权限向他们发送信号的所有进程。(所有进程并不包括系统进程集中的进程)
如上所述,进程将信号发送给其他进程需要权限。
POSIX.1将信号编号为0定义为空信号。如果signo参数是0,则kill仍执行正常的错误检查,但不发送信号。这常被用来确定一个特定进程是否仍然存在。如果向一个并不存在的进程发送空信号,则kill返回-1,errno被设置为ESRCH。
10.10 函数alarm和pause
alarm函数可以设置一个定时器,在将来的某个时刻该定时器会超时。当定时器超时时,产生SIGALRM信号。如果忽略或不捕捉信号,则其默认动作是终止调用该alarm函数的进程。
1 |
|
每个进程只能有一个闹钟时间。如果在调用alarm时,之前已为该进程注册的闹钟时间还没有超时,则该闹钟时间的余留值作为本次alarm函数调用的值返回。以前注册的闹钟时间则被新值代替。如果本次调用的seconds值为0,则取消以前的闹钟时间,将其余留值作为本次alarm调用的返回值。
pause函数使调用进程挂起直至捕捉到一个信号。
1 |
|
只有执行一个信号处理程序并从其返回时,pause才返回。在这种情况下,pause返回-1,errno设置为EINTR。
10.11 信号集
我们需要一个能表示多个信号——信号集的数据类型。sigset_t用于表示信号集,且定义了下列5种处理信号集的函数。
1 |
|
所有应用程序在使用信号集前,要对该信号集调用sigemptyset或sigfillset一次。
这些函数以及sigset_t实际都是使用位操作实现的,某一位代表一个信号。
10.12 函数sigprocmask
一个进程的信号屏蔽字规定了当前阻塞而不能递送给该进程的信号集。调用函数sigprocmask可以检测或更改,或同时进行检测和更改进程的信号屏蔽字。
1 |
|
how参数指示如何修改当前信号屏蔽字,可选值如下表:

SIG_BLOCK是或操作,而SIG_SETMASK则是赋值操作。注意,不能阻塞SIGKILL和SIGSTOP信号。
如果oset是非空指针,那么进程的当前信号屏蔽字通过oset返回。
若set是一个非空指针,则参数how指示如何修改当前信号屏蔽字。
如果set是一个空指针,则不改变该进程的信号屏蔽字,how的值也无意义。
10.13 函数ispending
sigpending函数返回一信号集,对于调用进程而言,其中的各信号是阻塞不能递送的,因而也一定是当前未决的。该信号集通过set参数返回。
1 |
|
10.14 sigaction
sigaction函数的功能是检查或修改(或检查并修改)与指定信号相关联的处理动作。
1 |
|
signo是要检测或修改其具体动作的信号编号。若act指针非空,则要修改其动作。如果oact指针非空,则系统经由oact指针返回该信号的上一个动作。
1 | struct sigaction { |
10.15 函数sigsetjmp和函数siglongjmp
sigsetjmp、siglongjmp与setjmp、longjmp之间的区别在于sigsetjmp多了一个参数savemask,当此参数为非0值使,sigsetjmp在env中保存进程的当前信号屏蔽字。当从setlongjmp跳回时,恢复保存的信号屏蔽字。
1 |
|
10.16 函数sigsuspend
在一个原子操作中先恢复信号屏蔽字,然后使进程休眠,这种功能使由sigsuspend函数提供的。
1 |
|
sigsuspend相当于先屏蔽sigmask,接着pause,在接收到一个信号后并处理,pasue返回,然后将屏蔽字恢复到设置sigmask之前。注意,是设置屏蔽字SIG_SETMASK,而不是添加SIG_BLOCK。

例子1:保护代码临界区,不被特定信号中断
1 |
|

例子2:等待一个信号处理程序设置一个全局变量:
1 |
|

例子3:使用信号实现父、子进程之间的同步:




第11章 线程
11.1 引言
本章将进一步深入理解进程,了解如何使用多个控制线程在单进程环境中执行多个任务。一个进程中的所有线程都可以访问该进程的组成部件,如文件描述符和内存。本章的最后将讨论目前可用的同步进制,防止多个线程在共享资源时出现不一致的问题。
11.2 线程概念
每个线程都包含有表示执行环境所必须的信息,其中包括进程中标识线程的线程ID、一组寄存器值、栈、调度优先级和策略、信号屏蔽字、errno变量以及线程私有数据。一个进程的所有信息对该进程的所有线程都是共享的,包括可执行代码、程序的全局内存、栈以及文件描述符。
11.3 线程标识
每个线程有一个线程ID,线程ID只有在它所属的进程上下文中才有意义。
线程ID是用pthread_t数据结构来表示的。
1 |
|
线程可以调用pthread_self函数获得自身的线程ID:
1 |
|
11.4 线程创建
1 |
|
当pthread_create成功返回时,新创建线程的线程ID会被设置成tidp指向的内存单元。attr参数用于定制各种不同的线程属性,为NULL时表示创建一个具有默认属性的线程。新创建的线程从start_rtn函数的地址开始运行,该函数只有一个无指针类型参数arg。如果需要向start_rtn函数传递的参数有一个以上,那么需要把这些参数放到一个结构中,然后把这个结构的地址作为arg参数传入。
线程运行时并不能保证哪个线程会先运行:是新创建的线程,还是调用线程。新创建的线程可以访问进程的地址空间,并且继承调用线程的浮点环境和信号屏蔽字,但是该线程的挂起信号集会被清除。
11.5 线程终止
如果进程中的任意线程调用了exit、_Exit或者_exit,那么整个进程就会终止。
单个线程可以通过3种方式退出,因此可以在不中止整个进程的情况下,停止它的控制流。
- 线程可以简单地从启动例程中返回,返回值是线程地退出码。
- 线程可以被同一进程中地其他线程取消。
- 线程调用pthread_exit。
1 |
|
rval_ptr参数是一个无类型指针,与传给启动例程的单个参数类似。进程中的其他线程也可以通过调用pthread_join函数访问到这个指针。
1 |
|
例子1:获取已终止的线程的退出码
1 |
|

例子2:自动变量作为pthread_exit的参数时出现的问题
1 |
|

线程可以通过调用pthread_calcel函数来请求取消同一进程中的其他线程。
1 |
|
默认情况下,pthread_calcel函数会使得由tid标识的线程的行为表现为如同调用了参数为PTHREAD_CANCELED的pthread_exit函数,但是,线程可以选择忽略取消或者控制如何被取消。
线程可以安排它退出时需要调用的函数,这与进程在退出时可以调用atexit函数安排退出是类似的。这样的函数称为线程清理处理程序。一个线程可以建立多个清理处理程序。处理程序记录在栈中,也就是说,它们的执行顺序与它们注册时相反。
1 |
|
当线程执行以下动作时,清理函数rtn是由pthread_cleanup_push函数调度的,调用时只有一个参数arg:
- 调用pthread_exit时
- 响应取消请求时
- 用非零execute参数调用pthread_cleanup_pop时
如果execute参数设置为0,清理函数将不被调用。不管发生上述哪种情况,pthread_cleanup_pop都将删除上次pthread_cleanup_push调用建立的清理处理程序。
例子:线程清理程序
1 |
|

线程函数与进程函数的相似之处:

默认情况下,线程的终止状态会保存直到对该线程调用ptrhead_join。如果线程已经被分离,线程的底层存储资源可以在线程终止时立即被收回。在线程被分离后,我们不能用pthread_join函数等待它的终止状态,因为对分离状态的线程调用pthread_join会产生未定义行为。可以调用pthread_detach分离线程:
1 |
|
11.6 线程同步


11.6.1 互斥量
互斥量(mutex)从本质上来说是一把锁,在访问共享资源前对互斥量进行设置(加锁),在访问完成后释放(解锁)互斥量。在对互斥量进行加锁后,任何其他试图再次对互斥量加锁的线程都会阻塞直到当前线程释放该互斥锁。pthread_mutex_t要初始化为PTHREAD_MUTEX_INITIALIZER(只适用于静态分配的互斥量),或通过pthread_mutex_init函数进行初始化。
1 |
|
加锁、解锁:
1 |
|
例子:
1 |
|
11.6.2 避免死锁
可以通过仔细控制互斥量的顺序来避免发生死锁。有时候,应用程序的结构使得对互斥量进行排序是很困难的。如果涉及了太多的锁和数据结构,可用的函数并不能把它转换成简单的层次,那么就需要采用另外的方法。这种情况下,可以先释放占用的锁,然后过一段时间再试。如果不能获取锁,可以先释放已经占用的锁,做好清理工作,然后过一段时间再重新试。
11.6.3 函数pthread_mutex_timedlock
当线程试图获得一个已加锁的互斥量时,pthread_mutex_timedlock互斥量原语允许绑定线程阻塞时间。pthread_mutex_timedlock函数与pthread_mutex_lock函数基本等价,但是在到达超时时间值时,pthread_mutex_timedlock不会对互斥量进行加锁,而是返回错误码ETIMEDOUT。
1 |
|
例子:用pthread_mutex_timedlock避免永久阻塞
1 |
|

11.6.4 读写锁
读写锁(reader-writer lock)与互斥量类似,不过读写锁允许更高的并行性。互斥量要么是锁住状态,要么就是不加锁状态,而且一次只有一个线程可以对其加锁。读写锁可以有3种状态:
- 读模式下加锁状态
- 写模式下加锁状态
- 不加锁状态
一次只有一个线程可以占用写模式的读写锁,但是多个线程可以同时占有读模式的读写锁。
当读写锁处于写加锁状态时,在这个锁被解锁之前,所有试图对这个锁加锁的线程都会被阻塞。当读写锁处于读加锁状态时,所有试图以读模式对它进行加锁的线程都可以得到访问权,但是任何希望以写模式对此锁进行加锁的线程都会阻塞,直到所有的线程释放它们的读锁为止。
读写锁非常适合对于数据结构读的次数远大于写的情况。
读写锁也叫共享互斥锁(shared-exclusive lock)。当读写锁是读模式锁住时,就可以说成是以共享模式锁住的。当它是写模式锁住时,就可以说成是以互斥锁模式锁住的。
1 |
|
除了可以使用pthread_rwlock_init函数对其进行初始化之外,还可对静态变量使用PTHREAD_RWLOCK_INITIALIZER常量进行初始化。
1 |
|
读写锁原语的条件版本:
1 |
|
可以获取锁时,这两个函数返回0。否则,它们返回错误EBUSY。这两个函数可以用于我们前面讨论的遵守某种锁层次但还不能完全避免死锁的情况。
例子:作业请求队列由单个读写锁保护
1 |
|
11.6.5 带有超时的读写锁
与互斥量一样,Single UNIX Specification提供了带有超时的读写锁加锁函数,使应用程序在获取读写锁时避免陷入永久阻塞状态。这两个函数是pthread_rwlock_timedrdlock和pthread_rwlock_timedwrlock。
1 |
|
超时返回ETIMEDOUT错误。
11.6.6 条件变量
初始化与反初始化:
1 |
|
可使用PTHREAD_COND_INITIALIZER对pthread_cond_t的静态变量进行初始化。
等待条件变量变为真:
1 |
|
传递给pthread_cond_wait的互斥量对条件进行保护。调用者把锁住的互斥量传给函数,函数然后自动把调用线程放到等待条件的线程列表上,对互斥量解锁。这就关闭了条件检查和线程进入休眠状态等待条件改变这两个操作之间的时间通道,这样线程就不会错过条件的任何变化。pthread_cond_wait返回时,互斥量再次被锁住。
有两个函数可以用于通知线程条件已经满足。pthread_cond_signal函数至少能唤醒一个等待该条件的线程,而pthread_cond_broadcast函数则能唤醒等待该条件的所有线程。
1 |
|
在调用pthread_cond_signal或者pthread_cond_broadcast时,我们说这是在给线程或者条件发信号。必须注意,一定要在改变条件状态以后再给线程发信号。
例子:结合条件变量和互斥量对线程进行同步
1 |
|
11.6.7 自旋锁
自旋锁与互斥量类似,但它不是通过休眠使进程阻塞,而是在获取锁之前已知处于忙等阻塞状态。自旋锁可以用于以下情况:锁被持有的时间短,而且线程并不希望在重新调度上花费太多的成本。
当线程自旋锁等待锁变为可用时,CPU不能做其他的事情。这也是自旋锁只能够被持有一小段时间的原因。
1 |
|
11.6.8 屏障
屏障(barrier)是用户协调多个线程并行工作的同步机制。屏障允许每个线程等待,直到所有的合作线程都能到达某一个点,然后从该点继续执行。pthread_join函数就是一种屏障,允许一个线程等待,直到另一个线程退出。
屏障对象的概念更广,它们允许任意数量的线程等待,直到所有的线程完成处理工作,而线程不需要退出。所有线程达到屏障后可以接着工作。
1 |
|
初始化屏障时,可以使用count参数指定,在允许所有线程继续运行之前,必须达到屏障的线程数目。
可以使用pthread_barrier_wait函数来表明,线程已完成工作,准备等所有其他线程赶上来。
1 |
|
调用pthread_barrier_wait的线程在屏障计数未满足条件时,会进入休眠状态。如果该线程是最后一个调用pthread_barrier_wait的线程,就满足了屏障计数,所有的线程都被唤醒。
1 |
|
第16章 网络IPC:套接字
16.1 引言
本章将描述套接字网络进程间通信接口,进程用该接口能够和其他进程通信,无论是在同一台计算机上还是在不同的计算机上。
16.2 套接字描述符
应用程序用套接字描述符访问套接字,套接字描述符被当作一种文件描述符,许多处理文件描述符的函数可以用于处理套接字描述符。
为创建一个套接字,调用socket函数:
1 |
|
domain参数确定通信的特征,包括地址格式:

type参数确定套接字的类型:

参数protocol通常为0,标识为给定的域和套接字类型选择默认协议,如domain=AF_INET且type=SOCK_STREAM时默认协议是TCP协议,domain=AF_INET且type=SOCK_DGRAM时默认协议是UDP协议。

虽然套接字描述符本质上是一个文件描述符,但不是所有参数为文件描述符的函数都可接受套接字描述符,如lseek不能以套接字为描述符参数,因为参数不支持文件偏移量的概念。
调节字通信是双向的。可以采用shutdown函数来禁止一个套接字的I/O:
1 |
|
how参数:
- SHUT_RD:关闭读端
- SHUT_WR:关闭写端
- SHUT_RDWR:关闭读写端
16.3 寻址
如何标识一个目标通信进程?IP地址+端口号。
16.3.1 字节序

字节序是一个处理器架构特性,用于指示数据类型内部的字节如何排序。
- 大端字节序:最大字节地址出现在最低有效字节上
- 小端字节序:最低有效字节包含最小字节地址
注意,不管字节如何排序,最高有效字节(MSB)总是在左边,最低有效字节(LSB)总是在右边。如0x04030201,无论字节序如何,最高有效字节都将包含4,最低有效字节都将包含1。接下来想将一个字符指针(cp)强制转换到这个整数地址,就会看到字节序的不同。小端字节序,cp[0]将包含1,大端字节序,cp[0]将包含4。
网络协议执行了字节序,因此异构计算机系统能够交换协议信息而不会被字节序所混淆。TCP/IP协议栈使用大端字节序。应用程序交换格式化数据时,字节序问题就会出现。对于TCP/IP地址用网络字节序来表示,所以应用程序有时需要在处理器的字节序与网络字节序之间转换他们。
1 |
|
16.3.2 地址格式
一个地址标识一个特定通信域的套接字端点,地址格式与这个特定的通信域相关。为使不同格式地址能够传入到套接字函数,地址会被强制转换成一个通用的地址结构sockaddr:
1 |
|
IPv4的地址使用sockaddr_in表示,IPv6的地址使用sockaddr_in6表示:
1 |
|
in_addr_t定义成uint32_t,in_port_t定义成uint16_t。

有时,需要打印出能被人理解而不是计算机所理解的地址格式。BSD网络软件包含函数inet_addr和inet_ntoa,用于二进制地址格式与点分十进制字符表示之间的互相转换,但这些函数仅适用于IPv4地址。有两个新函数inet_ntop和inet_pton具有相似的功能,而且同时支持IPv4地址和IPv6地址。
1 |
|
inet_ntop将网络字节序的二进制地址转换成文本字符串格式。inet_pton将文本字符串格式转换成网络字节序的二进制。
16.3.3 地址查询
getaddrinfo函数允许将一个主机名和一个服务名映射到一个地址:
1 |
|
需要提供主机名、服务名,或者两者都提供。如果仅仅提供一个名字,另外一个必须是一个空指针。主机名可以是一个节点名或点分格式的主机地址。getaddrinfo函数返回一个链表结构addrinfo,可用freeaddrinfo来释放一个或多个这种结构,这取决于ai_next字段链接起来的结构有多少。
addrinfo结构定义:

hint是一个用于过滤的模板,包含ai_family、ai_flags、ai_protocal和ai_socktype字段。剩余的整数字段必须设置为0,指针字段必须为空。ai_flags:



实例:
1 |
|

16.3.4 将套接字与地址关联
使用bind函数来关联地址和套接字:
1 |
|
可以调用getsockname函数来发现绑定到套接字上的地址:
1 |
|
如果套接字已经和对等方连接,可以调用getpeername函数来找到对方的地址:
1 |
|
16.4 建立连接
如果要处理一个面向连接的网络服务(SOCK_STREAM或SOCK_SEQPACKET),那么在开始交换数据以前,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。使用connect函数建立连接:
1 |
|
addr是我们想与之通信的服务器地址。如果sockfd没有绑定到一个地址,connect会给调用者绑定一个默认地址。
服务器调用listen函数来宣告它愿意接受连接请求:
1 |
|
一旦服务器调用了listen,所用的套接字就能接受连接请求。使用accept函数获得连接请求并建立连接:
1 |
|
16.5 数据传输
1 |
|
flags:

sendto可以在无连接的套接字上指定一个目标地址,sendmsg使用msghdr结构指定多重缓冲区。

1 |
|


实例:面向连接的ruptime
客户端:
1 |
|
服务器1:
1 | /************************************************************************* |
服务器的第二种方式:
1 |
|
实例2:无连接的ruptime
客户端:
1 |
|
服务器:



