0%

ANSI-The UNIX System Interface

The Unix System Interface

Unix系统提供了一系列的系统调用,这些系统调用其实是操作系统内部的程序,其提供了用户程序调用的接口.我们将会经常借助系统调用来实现一些高效率的操作,或者访问标准库内没有的功能.

File Descriptors

对于Unix系统而言,输入输出可以认为都是对文件进行读/写操作.这是因为对于Unix系统,所有的外围设备,如键盘,显示器等,都是Unix文件系统下的文件.由于输入输出都可以被认为是对文件的操作,因此Unix系统只需要提供一个统一的接口来处理所有外围设备和程序的通信交互.

在任何的操作系统下,如果用户想要操作文件系统里的文件,需要向系统发送打开文件的请求.无论什么系统,只要是对文件进行操作,打开文件是必须的.以写文件为例,文件系统会先去查找文件是否存在,如果不存在,会在指定路径或默认路径下创建一个新文件并允许写入;如果存在,文件系统会检查用户权限,如果用户有写入权限,那么文件系统就会给用户程序返回一个小的非负整形数据,这个数据就是文件描述符.在文件系统提供文件描述符后,以后程序的所有输入输出操作都是基于文件描述符进行,而不是通过文件名来指示某个文件.程序只需要通过文件描述符来调用文件,具体的底层文件信息维护是通过操作系统隐藏封装的.

由于一般来说,标准输入输出都是通过键盘和显示器实现的,因此Unix系统在shell运行程序的时候,会自动打开三个文件,对应三个文件描述符:0为标准输入(stdin),1为标准输出(stdout),2为标准错误(srderr).同样我们可以像前面提到的那样通过<和>来重定向程序的输入和输出文件,

1
prog <infile >outfile

这样的话,他就需要在程序运行时打开infile和outfile两个文件,并且将文件描述符0和1分别指向这两个文件.如果我们想要将标准错误重定向到一个文件,可以使用2>outfile的方式,这样就会将文件描述符2指向outfile文件.值得注意的是,所有的文件改变都与程序无关,而是在shell里完成的,文件只需要知道对应的文件描述符是0,1,2,而至于文件描述符指向的文件这是由操作系统去维护的.

Low Level I/O-Read and Write

低级I/O操作是指直接与操作系统内核进行数据交互的操作,其无需经过标准库的缓冲区;而高级I/O操作则是需要先将数据写入到标准库的缓冲区,根据缓冲更新策略,再一起提交给操作系统内核进行处理.

一般的缓冲区的更新策略有如下三种:

  1. 全缓冲: 如果缓冲区写满了就写回系统内核.常规文件的读写都是全缓冲的.这是因为全缓冲可以减少读写时调用系统内核的次数,从而加快读写速度.
  2. 行缓冲: 如果缓冲区中出现了换行符就直接写回系统内核,或者如果出现缓冲区写满了也会写回系统内核.标准输入和标准输出对应的终端设备一般都是行缓冲的.这是因为行缓冲可以在输入输出时提供更好的交互体验,如在终端输入时可以实时显示输入内容.
  3. 无缓冲: 用户每次调用I/O直接与系统内核交互,不经过缓冲区.一般用于即时展示结果的程序.如标准错误输出一般都是无缓冲,为了方便用户及时看到错误信息.

输入和输出是通过read和write系统调用实现的.在C语言中,会通过read和write访问这两个系统调用,其函数原型为

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

我们发现这二者的函数参数都是一样的,因此我们直接合并介绍其函数参数即可.第一个参数是文件描述符,read函数用来指代读取的文件,write函数用来指代写入的文件;第二个参数是一个char数组,他用于接收read函数的读取和提供write函数的写入内容;第三个参数是一个整数,其用于表示读取和写入的最大字符数目.其返回值为一个整型数据,其表示实际读写的字符数目.这是因为在读取时,可能缓冲区或文件中的字符数目小于规定的最大读取数目,因此其返回值可能会小于n.但是如果返回值为0,则表示读取完成;如果返回值为-1,那么表示读取过程中出现了错误.对于写入过程来说一般n_written和n的数值都是相等的,除非在写入过程中发生了错误,才会导致二者不相等.

对于这个最大读写字符数目n的选取,其实有一些建议:一般来说,我们会选择1,这代表着我们每次读取1个字符,其实也就是我们前面提到的无缓冲读取;其次,我们一般会选择1024或4096这种与外围设备的物理块内存大小相对应的值.值得注意的是,其实我们选取更大的最大读写字符数目是可以有效的提高计算机的读写效率,这是我们选择更大的读写字符数目,在读写中所需要的读写交互次数更少,因此可以更有效的提高程序运行效率.

本书所用的系统是Unix系统,因此其与Linux系统仍然存在一定的区别.如本书中提及的read和write函数在头文件syscalls.h但实际上Linux系统的应该是在是unistd.h内.我们可以通过利用man来查找read的指南,其结果为

1
2
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);

因此其表示read定义在unistd,而且我们发现他的返回值类型并不是前文提到的int,而是ssize_t.值得注意的是在windows系统下read和write函数定义在io.h下.

这里需要解释一下ssize_t和size_t的含义.虽然二者都有size_t的名称,但其实其表示的东西不同,size_t用于反映内存中对象的大小(以字节为单位),ssize_t则是供返回字节计数或错误提示的函数使用.

size_t的定义其实是为了增强程序的可移植性,是为了方便系统间的移植而定义的,不同系统上的size_t定义可能有所不同.一般在32位系统上其被定义为unsigned int,而在64位系统上其被定义为unsigned long.其一般用来表示一种计数,例如有多少字节的东西被使用了,sizeof操作符的结果类型是size_t,这个类型保证能容纳实现所建立的最大对象的字节大小,用来度量内存中可容纳数据项目个数的无符号整数类型,因此其被广泛应用于内存管理函数.

而ssize_t则有所不同,其用来表示可以被执行读写操作的数据块大小,他的第一个s表明他是一个signed类型,也就是signed size_t类型.如果size_t是unsigned int,那么其表示int类型.因此其实虽然类型看似不同,最后都是整型,所以也可以用int.

其次,我们发现BUFSIZ是在syscalls.h里给了#define,这里我们通过shell的grep命令来对/usr/include/*的文件进行查找,有如下结果

1
2
/usr/include/stdio.h:#define BUFSIZ 8192
/usr/include/stdio.h: Else make it use buffer BUF, of size BUFSIZ. */

因此我们需要引入一个stdio.h,来保证BUFSIZ的定义.BUFSIZ是一个对于当前操作系统比较合适的数值.如果文件大小不是BUFSIZ的倍数,则对read的某次调用会返回一个较小的字节数,write再按这个字节数写入,而后再调用read函数返回0.(这段主要是在介绍Ch8_io.cpp).

对于低级I/O和高级I/O混用的内容,并不太建议出现在同一个程序中,具体的使用可以通过代码Ch8_io_v2.cpp看到比较.

open函数是用于打开指定的文件,其功能与前文提及的fopen类似,但不同的是open函数更加底层,其返回值为一个整型数据,也就是所谓的文件描述符,而fopen返回的则是一个文件指针.如果open的返回值为-1,那么就表示open函数并未正确打开指定文件.

1
2
3
4
#include <fcntl.h>
int fd;
int open(char* name,int flags,int perms);
fd=open(name,flags,perms);

其中第一个参数是一个字符串,用来指示需要操作的文件名与其路径.第二个参数则是表示打开文件的所需的权限,其值已经在fcntl.h中定义了一组宏,如下所示:

  1. O_CREAT:在文件打开过程中创建新文件
  2. O_RDONLY:以只读的方式打开文件
  3. O_WRONLY:以只写的方式打开文件
  4. O_RDWR:以读写方式打开文件
  5. O_APPEND:在文件末尾追加数据,而不是覆盖原内容
  6. O_TRUNC:如果文件已经存在,将其截断为空文件

这里我们需要解释一下 O_TRUNC, 其的作用是将原有文件内容清空,因此其一般是搭配 O_WRONLY 和O_RDWR 使用,如果使用O_RDONLY 与之搭配,那么系统会报错,出现逻辑冲突,这是因为只读表示你不想修改文件,而截断却会破坏原文件结构,这是两个冲突的操作.

1
int fd = open("file.txt", O_RDWR | O_TRUNC);

这里的|其实就是按位取或运算,因此上述的这些其实就是二进制掩码.第三个参数是要搭配 O_CREAT 来使用的,因为O_CREAT的作用是如果读取不到文件,那么就需要创建一个文件,而perms则表示创建的文件的各用户的权限.后续会进一步解释perms的具体内容,在此不再赘述.

如果用open函数打开一个不存在的文件(在不使用O_CREAT的情况),那么其会报错.此时我们需要用CREAT来创建一个新文件,或者用来覆盖没有权限的旧文件.其函数原型为:

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

与open一样,creat的返回值也是文件描述符,如果运行成功,那么会返回一个文件描述符,但是存在一个特殊情况,也就是返回值为-1的时候,表示文件创建失败.如果文件已经存在的话,那么creat会将文件的内容做一个截断,并不会报错.这里有一个有趣的point需要关注一下,对于不存在的文件,我们调用creat时,如果文件不存在,会按照给的这个perms分配用户权限.在Unix系统下,文件系统的权限用户有文件所有者,文件所有组和其他成员对文件的读,写,执行访问.因此Unix系统中采用九个比特位来控制这些权限,1表示有权限,0表示没有权限.但是九个比特位太过繁琐,因此我们选用三个八进制数来表示用户的不同权限.例如755表示文件所有者对文件有读写执行的权限,文件所有组的成员和其他成员则只能读和执行,并不能写入.这里我们需要强调一下为了保证文件的安全性,一切提供权限都以权限最小的原则提供,也就是尽可能对外开放小的权力,以保证文件的安全性.但是实际上我们运行了程序会发现,如果我们给他的perms是0666,但是他的权限却是0664,

1
2
ls -l test.cpp
-rw-rw-r-- 1 lyd lyd 870 Jul 7 15:03 test.cpp

这是因为其实unix系统中还存在权限掩码umask,其作用是在创建文件的读写权限时需要减去其定义的权限,方才得到真正的权限.因此creat的真正权限计算公式是perms&~ umask.umask的值可以用shell的umask直接得到,本人所使用的是002,因此0666&~002=0666&775=0664.但是以上的权限方式均只针对文件不存在需要创建的情况,如果文件已经存在,那么我们用creat首先需要该文件本身可写,因为我们后续需要将其内容截断,所以可写入是基本要求,其次creat并不会对原有权限做任何改动,除了如果原权限对文件所有者不可读,他会帮你置为可读,即如果我们的文件是240,运行后他会帮你置成640.

对于umask,我们介绍一下程序上的操作方式.C/C++中提供了一个mask函数,用于重新设置umask,并且返回老的umask.其需要导入如下头文件

1
2
3
#include <sys/types.h>
#include <sys/stat.h>
mode_t umask(mode_t mask);

umask会将程序调用的umaks设为mask&0777后的值,然后返回原有的umask值.这里的mode_t是一个用于表示文件模式或权限的数据类型.其为一个无符号整数类型,通常是一个32位的整数.其用于表示文件的访问权限,文件类型以及其他与文件相关的属性.

printf的介绍我们已在前面做了比较全面的介绍,这里我们介绍printf家族的另一个函数vprintf.其的使用与printf实际上是十分类似,其函数原型如下:

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

format是一个字符串,包含了写入的包含了要被写入到标准输出stdout的文本.它可以包含嵌入的format标签,format标签可被随后的附加参数中指定的值替换,并按需求进行格式化.arg表示一个可变参数列表对象,需要用stdarg.h中定义的va_start宏初始化.

一般来说我们都是用printf,只有在我们自己写一个专有的输出函数才会需要使用vptintf,例如我们需要写一个专门的错误输出函数:

1
2
3
4
5
6
7
8
9
10
void error(char *fmt,...)
{
va_list ap;
va_start(ap,fmt);
fprintf(stderr,"error: ");
vfprintf(stderr,fmt,ap);
fprintf(stderr,"\n");
va_end(ap);
exit(1);
}

这里我们发现我们实际上是不可以直接将参数转发给printf的,这是因为printf是可以接收不同数目的参数,但是并不能接受变长参数列表.因此只能采用vprintf类的函数,这里我们没有用vprintf是因为我们写的这个是报错函数,如果用vprintf函数会将输出导出到stdout,而stdout可能会被修改到了其他文件,因此我们需要用stderr来保证我们的报错信息不会污染输出文件.

close函数的作用是断开文件描述符和已打开文件的联系,并且释放文件描述符使之可以用于描述其他的文件.与fclose不同的是,close只是将文件描述符关闭,但是对于需要写入的数据并不能保证写入了磁盘,这是所谓的缓冲机制,他会在某些条件下再重新写入,但是一旦关闭电源,那么就会出现数据丢失的现象.而fclose会直接调用fflush,刷新所有缓冲区,将未写入的数据写入文件.

unlink函数的作用是断开指定路径的文件的链接,值得强调的是,这里我们是断开文件的链接,并非直接删除某个文件,只是说如果链接为1的文件,我们会直接删除.其函数原型如下:

1
int unlink(const char* pathname);

执行unlink函数并不是真正的删除文件,他会先检查文件系统中此文件的链接数是否为1,如果不是1,那其表明文件还有其他的链接对象,因此对文件的链接数做减1操作.若链接数为1,并且此时没有进程打开文件,那么内容就会被删除,如果有进程打开文件,那么暂时不会删除,直到所有打开该文件的所有进程全部结束时,文件就会删除.

这里我们深入介绍一下对于软链接和硬链接的不同.软链接本质上其实就是一个符号链接,他与源文件在文件系统中是不同的文件,类似与Windows系统的快捷方式.

1
2
3
4
$ ln -s init.el test.txt
$ ls -l test.txt
lrwxrwxrwx 1 lyd lyd 7 Jul 23 16:52 test.txt -> init.el
$ unlink test.txt

这里的unlink可以直接删除test.txt,且不对init.el有任何影响.相反如果我们unlink的对象是init.el,则结果如下:

1
2
3
$ unlink init.el
$ cat test.txt
cat: test.txt: 没有那个文件或目录

这相当于将test.txt做成了一个悬挂的没有指向的符号链接.

硬链接是将两个独立文件联系在一起,其为直接引用,而并不是软链接通过名称引用,且硬链接是通过文件副本的形式存在,但不占用实际空间.

1
2
3
4
5
6
$ ln log.txt test.txt
$ ls -l log.txt
-rw-rw-r-- 2 lyd lyd 96265 Sep 21 2024 log.txt
$ ls -l test.txt
-rw-rw-r-- 2 lyd lyd 96265 Sep 21 2024 test.txt
$ unlink test.txt

这里的unlink可以直接删除test.txt,且不对log.txt有任何影响.如果我们unlink的对象是log.txt,仍然可以顺利的打开test.txt,这是因为如果我们用硬链接test.txt其实也是文件的一个副本,他和log.txt其实就是同一个文件,因此我们用unlink释放了log.txt,但是文件系统其实还是存在test.txt这个副本的.

Random Access-Lseek

前面我们提到的输入输出方式都是顺序输入,也就是读写都在上一次读写位置之后.但是我们可能需要在文件中移动读写位置,并不希望这个操作附带着读写功能.系统中存在一个调用lseek,他的作用是将读写位置在文件中移动,但是不附带读写数据的操作,其函数原型如下:

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

他是将文件描述符fd指代的文件的现在读写位置移动到以origin为基准偏移offset的位置.其实可以通过数组的角度来理解,origin表示偏移量计算的基准点,一般为0,1,2,分别表示文件开头,当前位置和文件结尾.offset表示偏移量,如果为正数,则表示向后移动,如果为负数,则表示向前移动,如果为0,那么就是origin指代的位置.但值得注意的是如果我们在origin为2的时候,仍然用正数作为偏移量,那么会导致越过EOF可能会导致错误.同理,如果我们在origin为0的时候,用负数作为偏移量,那么会导致越过文件开头,同样可能会导致错误.而lseek的返回值是新的读写位置,如果出错则返回-1,并且设置errno.根据我们上面提及的理解方式,实际上lseek就是将文件视作了一个数组,但是这样的代价就是读写操作的效率会降低.

我们已经有了lseek函数,那么我们其实可以修改读写位置来达成一些特殊的目的,例如我们需要在文件末尾追加数据,也就是所谓的append操作,我们可以用lseek来达成,
我们可以将读写位置移动到文件末尾,然后再进行写入操作.其代码如下:

1
lseek(fd,0,2); //将读写位置移动到文件末尾

这里的2表示origin为2,也就是文件末尾,而offset为0,因此我们将读写位置移动到文件末尾.如果我们需要在文件开头写入数据,那么可以将offset设为0,origin设为0,这样就可以将读写位置移动到文件开头,

1
lseek(fd,0,0); //将读写位置移动到文件开头