The C Programming Language - annotation, 8

UNIX System Interface

UNIX操作系统通过一系列的系统调用提供服务,这些系统调用实际上是操作系统内的函数,它们可以被用户程序调用。…因为,我们经常需要借助于系统调用以获得最高的效率,或者访问标准库中没有的某些功能。但是,即使读者是在其他操作系统上使用C语言,本章的例子也将会帮助你对C语言程序设计有更深入 的了解。不同系统中的代码具有相似性,只是一些细节上有区别而已。因为ANSI C标准库函数是以UNIX系统为基础建立起来的,所以,学习本章中的程序还将有助于更好地理解标准库。

…输入/输出、文件系统和存储分配。其中,前两部分的内容要求读者对UNIX系统的外部特性有一定的了解。

(标准)输入/输出接口对任何操作系统都是一样的。在任何特定的系统中,标准库函数的实现必须通过宿主系统提供的功能来实现。

1. 文件描述符

在UNIX操作系统中,所有的外围设备(包括键盘和显示器)都被看作是文件系统中的文件,因此,所有的输入/输出都要通过读文件或写文件完成。也就是说,通过一个单一的接口就可以处理外围设备和程序之间的所有通信。

通常情况下,在读或写文件之前,必须先将这个意图通知系统,该过程称为打开文件。如果是写一个文件,则可能需要先创建该文件,也可能需要丢弃该文件中原先已存在的内容。系统检查你的权力(该文件是否存在?是否有访问它的权限?),如果一切正常,操作系统将向程序返回一个小的非负整数,该整数称为文件描述符。任何时候对文件的输入/输出都是通过文件描述符标识文件,而不是通过文件名标识文件。(文件描述符类似于标准库中的文件指针或MS-DOS中的文件句柄。)系统负责维护已打开文件的所有信息,用户程序只能通过文件描述符引用文件。

因为大多数的输入/输出是通过键盘和显示器来实现的,为了方便起见,UNIX对此做了特别的安排。当命令解释程序(即“shell”)运行一个程序的时候,它将打开3个文件,对应的文件描述符分别为0、1、2,依次标识标准输入、标准输出和标准错误。如果程序从文件0中读,对1和2进行写,就可以进行输入/输出而不必关心打开文件的问题。

程序的使用者可通过<>重定向程序的I/O:

1
prog <输入文件名 >输出文件名

这种情况下,shell把文件描述符0和1的默认赋值改变为指定的文件。通常,文件描述符2仍与显示器关联,这样,出错信息会输出到显示器上。与管道相关的输入/输出也有类似的特性。在任何情况下,文件赋值的改变都不是由程序完成的,而是由shell完成的。只要程序使用文件0作为输入,文件1和2作为输出,它就不会知道程序的输入从哪里来,并输出到哪里去。

2. 低级I/O——readwrite

输入与输出是通过readwrite系统调用实现的。在C语言程序中,可以通过函数readwrite访问这两个系统调用。这两个函数中,第一个参数是文件描述符,第二个参数是程序中存放读或写的数据的字符数组,第三个参数是要传输的字节数。

1
2
int n_read = read(int fd, char *buf, int n);
int n_write = write(int fd, char *buf, int n);

每个调用返回实际传输的字节数,在读文件时,函数的返回值可能会小于请求的字节数。如果返回值为0,则表示已到达文件的结尾;如果返回值为-1,则表示发生了某种错误。在写文件时,返回值是实际写入的字节数。如果返回值与请求写入的字节数不相等,则说明发生了错误。

在一次调用中,读出或写入的数据的字节数可以为任意大小。最常用的值为1,即每次读出或写入1个字符(无缓冲),或是类似于1024或4096这样的与外围设备的物理块大小相应的值。用更大的值调用该函数可以获得更高的效率,因为系统调用的次数减少了。

…程序可以将任意输入复制到任意输出,因为输入/输出可以重定向到任何文件或设备。

除了默认的标准输入、标准输出和标准错误文件外,其他文件都必须在读或写之前显式地打开。系统调用opencreat用于实现该功能。

openfopen很相似,不同的是,前者返回一个文件描述符,它仅仅只是一个int类型的数值,而后者返回一个文件指针。如果发生错误,open将返回-1。

1
2
3
4
5
6
#include <fcntl.h>

int fd;
int open(char *name, int flags, int perms);

fd = open(name, flags, perms);

fopen一样,参数name是一个包含文件名的字符串。第二个参数flags是一个int类型的值,它说明以何种方式打开文件,主要的几个值如下所示:

1
2
3
O_RDONLY 以只读方式打开文件
O_WRONLY 以只写方式打开文件
O_RDWR 以读写方式打开文件

在System V UNIX系统中,这些常量在头文件<fcntl.h>中定义,而在Berkeley(BSD)版本中则在<sys/file.h>中定义。

如果用open打开一个不存在的文件,则将导致错误。可以使用creat系统调用创建新文件或覆盖已有的旧文件,如下所示:

1
2
3
int creat(char *name, int perms);

fd = creat(name, perms);

如果creat成功地创建了文件,它将返回一个文件描述符,否则返回-1。如果此文件已存在,creat将把该文件的长度截断为0,从而丢弃原先已有的内容。使用creat创建一个已存在的文件不会导致错误。

如果要创建的文件不存在,则creat用参数perms指定的权限创建文件。

一个程序同时打开的文件数是有限制的(通常为20)。相应地,如果一个程序需要同时处理许多文件,那么它必须重用文件描述符。函数close(int fd)用来断开文件描述符和已打开文件之间的连接,并释放此文件描述符,以供其他文件使用。close函数与标准库中的fclose函数相对应,但它不需要清洗(flush)缓冲区。如果程序通过exit函数退出或从主程序中返回,所有打开的文件将被关闭。

函数unlink(char *name)将文件name从文件系统中删除,它对应于标准库函数remove

4. 随机访问——lseek

输入/输出通常是顺序进行的:每次调用readwrite进行读写的位置紧跟在前一次操作的位置之后。但是,有时候需要以任意顺序访问文件,系统调用lseek可以在文件中任意移动位置而不实际读写任何数据:

1
long lseek(int fd, long offset, int origin);

将文件描述符为fd的文件的当前位置设置为offset,其中,offset是相对于origin指定的位置而言的。随后进行的读写操作将从此位置开始。origin的值可以为0、1或2,分别用于指定offset从文件开始、从当前位置或从文件结束处开始算起。例如,为了向一个文件的尾部添加内容(在UNIX shell程序中使用重定向符>>或在系统调用fopen中使用参数“a”),则在写操作之前必须使用下列系统调用找到文件的末尾:

1
lseek(fd, 0L, 2);

若要返回文件的开始处(即反绕),则可以使用下列调用:

1
lseek(fd, 0L, 0);

请注意,参数0L也可以写为(long) 0,或仅仅写为0,但是系统调用lseek的声明必须保持一致。

使用lseek系统调用时,可以将文件视为一个大数组,其代价是访问速度会慢一些。

lseek系统调用返回一个long类型的值,此值表示文件的新位置,若发生错误,则返回-1。标准库函数fseek与系统调用lseek类似,所不同的是,前者的第一个参数是FILE *类型,且在发生错误时返回一个非0值。

5. 实例——fopengetc函数的实现

6. 实例——目录列表

我们常常还需要对文件系统执行另一种操作,以获得文件的有关信息,而不是读取文件的具体内容。目录列表程序便是其中的一个例子,比如UNIX命令ls,它打印一个目录中的文件名以及其他一些可选信息,如文件长度,访问权限等等。MS-DOS操作系统中的dir命令也有类似的功能。

由于UNIX中的目录就是一种文件,因此,ls只需要读此文件就可获得所有的文件名。但是,如果需要获取文件的其他信息,比如长度等,就需要使用系统调用。在其他一些系统中,甚至获取文件名也需要使用系统调用,例如在MS-DOS系统中即如此。无论实现方式是否同具体的系统有关,我们需要提供一种与系统无关的访问文件信息的途径。

在UNIX系统中,目录就是文件,它包含了一个文件名列表和一些指示文件位置的信息。“位置”是一个指向其他表(即i结点表)的索引。文件的i结点是存放除文件名以外的所有文件信息的地方。目录项通常仅包含两个条目:文件名和i结点编号。

系统调用stat以文件名作为参数,返回文件的i结点中的所有信息;若出错,则返回-1。如下所示:

1
2
3
4
5
char *name;
struct stat stbuf;
int stat(char *, struct stat *);

stat(name, &stbuf);

它用文件name的i结点信息填充结构stbuf。头文件<sys/stat.h>中包含了描述stat的返回值的结构。

…等类型在头文件<sys/types.h>中定义。

…它的确说明了一些重要的思想。首先,许多程序并不是“系统程序”,它们仅仅使用由操作系统维护的信息。对于这样的程序,很重要的一点是,信息的表示仅出现在标准头文件中,使用它们的程序只需要在文件中包含这些头文件即可,而不需要包含相应的声明。其次,有可能为与系统相关的对象创建一个与系统无关的接口。标准库中的函数就是很好的例子。

7. 实例——存储分配程序

malloc在必要时调用操作系统以获取更多的存储空间。

malloc并不是从一个在编译时就确定的固定大小的数组中分配存储空间,而是在需要时向操作系统申请空间。因为程序中的某些地方可能不通过malloc调用申请空间(也就是说,通过其他方式申请空间),所以,malloc管理的空间不一定是连续的。这样,空闲存储空间以空闲块链表的方式组织,每个块包含一个长度、一个指向下一块的指针以及一个指向自身存储空间的指针。这些块按照存储地址的升序组织,最后一块(最高地址)指向第一块。

当有申请请求时,malloc将扫描空闲块链表,直到找到一个足够大的块为止。该算法称为“首次适应”(first fit);与之相对的算法是“最佳适应”(best fit),它寻找满足条件的最小块。如果该块恰好与请求的大小相符合,则将它从链表中移走并返回给用户。如果该块太大,则将它分成两部分:大小合适的块返回给用户,剩下的部分留在空闲块链表中。如果找不到一个足够大的块,则向操作系统申请一个大块并加入到空闲块链表中。

释放过程也是首先搜索空闲块链表,以找到可以插入被释放块的合适位置。如果与被释放块相邻的任一边是一个空闲块,则将这两个块合成一个更大的块,这样存储空间不会有太多的碎片。因为空闲块链表是以地址的递增顺序链接在一起的,所以很容易判断相邻的块是否空闲。

…即确保由malloc函数返回的存储空间满足将要保存的对象的对齐要求。虽然机器类型各异,但是,每个特定的机器都有一个最受限的类型:如果最受限的类型可以存储在某个特定的地址中,则其他所有的类型也可以存放在此地址中。在某些机器中,最受限的类型是double类型;而在另外一些机器中,最受限的类型是intlong类型。

空闲块包含一个指向链表中下一个块的指针、一个块大小的记录和一个指向空闲空间本身的指针。位于块开始处的控制信息称为“头部”。为了简化块的对齐,所有块的大小都必须是头部大小的整数倍,且头部已正确地对齐。

malloc函数中,请求的长度(以字符为单位)将被舍入,以保证它是头部大小的整数倍。实际分配的块将多包含一个单元,用于头部本身。实际分配的块的大小将被记录在头部的size字段中。malloc函数返回的指针将指向空闲空间,而不是块的头部。用户可对获得的存储空间进行任何操作,但是,如果在分配的存储空间之外写入数据,则可能会破坏块链表。

UNIX系统调用sbrk(n)返回一个指针,该指针指向n个字节的存储空间。如果没有空闲空间,尽管返回NULL可能更好一些,但sbrk调用返回-1。必须将-1强制转换为char *类型,以便与返回值进行比较。而且,强制类型转换使得该函数不会受不同机器中指针表示的不同的影响。但是,这里任然假定,由sbrk调用返回的指向不同块的多个指针之间可以进行有意义的比较。ANSI标准并没有保证这一点,它只允许指向同一个数组的指针间的比较。因此,只有在一般指针间的比较操作有意义的机器上,该版本的malloc函数才能够移植。