C标准I/O库函数与Unbuffered I/O函数
一、C标准I/O库函数、Unbuffered I/O函数
1. C标准I/O库函数是如何用系统调用的
fopen(3)
调用open(2)打开制定的文件,返回一个文件描述符(一个int类型的编号),分配一个FILE结构体,其中包含该文件的描述符、I/O缓冲区和当前读写位置等信息,返回这个FILE结构体的地址。
fgetc(3)
通过传入的FILE *参数找到该文件的描述符、I/O缓冲区和当前读写位置,判断能否从I/O缓冲区读到下一个字符,如果能就直接返回该字符,否则调用read(2)把文件描述符传进去,让内核读取该文件的数据到I/O缓冲区,然后返回下一个字符。(对于C标准I/O 来说打开的文件由FILE *指针表示,对于内核来说,打开的文件由文件描述符标示,文件描述符从open系统调用获得,在使用read、write、close系统调用时都需要传文件描述符。)
fputc(3)
判断该文件的I/O缓冲区是否有空间再存放一个字符,如果有则直接保存在I/O缓冲区中并返回,如果I/O缓冲区中已满就调用write(2),让内核把I/O缓冲区的内容写回文件。
fclose(3)
如果I/O缓冲区中还有数据没写回文件,就调用write(2)写回文件然后再调用close(2)关闭文件,释放FILE结构体和I/O缓冲区。
open、read、write、close等系统函数称为无缓冲I/O(Unbuffered I/O)函数,用户程序在读写文件时既可以调用C标准I/O库函数,也可以直接调用底层的Unbuffered I/O函数,那个各自使用场景是什么呢?
- 用Unbuffered I/O函数每次读写都要进内核,调一个系统调用比调一个用户控件的函数要慢很多,所以在用户程序开辟I/O缓冲区还是必要的,用C标准I/O库函数比较方便。
- 用C标准I/O库函数要时刻注意I/O缓冲区和实际文件有可能不一致,在必要时调用fflush(3)。
- UNIX的传统是Everything is a file,I/O函数不仅可以读写文件还可以读写设备。在读写设备时通常是不希望有缓冲的。比如网络设置的读写就希望是实时读写。
C标准库函数是C标准的一部分,而Unbuffered I/O函数是UNIX标准的一部分。只有在UNXI平台上才能用Unbuffered I/O函数,windows上不行。
2. 文件描述符
每个进程在linux内核中都有一个task_struct结构体来维护进程相关的信息,称为进程描述符(Process Descriptor),而在操作系统理论中称为进程控制块(PCB, Process Control Block)。task_struct中有一个指针指向files_struct结构体,称为文件描述符表,其中每个表项包含一个指向已打开的文件的指针,如下图所示:
用户程序不能直接访问内核中的文件描述符表,而只能使用文件描述符表的索引(即0、1、2、3这些数字),这些索引就称为文件描述符,用int型变量保存。当调用open打开一个文件或创建一个新文件时,内核分配一个文件描述符并返回给用户程序,该文件描述符边项中的指针指向新打开的文件。当读写文件时,用户程序把文件描述符传给read或write,内核根据文件描述符找到相应的表项,再通过表项中的指针找到相应的文件。
程序启动时会自动打开三个文件:标准输入、标准输出和标准错误输出。在C标准中分别用FILE *指针stdin、stdout、stderr表示。这三个文件的描述符分别是0、1、2,保存在相应的FILE结构体中。头文件unistd.h中有如下的宏定义来表示这三个文件描述符:
#define STDIN_FILENO 0 #define STDOUT_FILENO 1 #define STDERR_FILENO 2
二、open/close
1. open
open函数可以打开或创建一个文件。
int open(const char *pathname, int flags); int open(const char *pathname, int flags, mode_t mode);
在Man Page中open函数有两中形式,一种带2个参数,一种带3个参数,在C代码中open函数的声明是这样的:
int open(const char *pathname, int flags, ...);
最后的可变参数可以是0个或1个,由flags参数中的标志位决定。
pathname参数是要打开或创建的文件名,可以是相对路径也可以是绝对路径。flags参数有一系列常数值可供选择,可以同时选择多个常数用按位或运算符连接起来,所以这些常数的定义都以O_开头,表示or。
必选项:以下三个常数中必须指定一个,且仅允许指定一个。
- O_RDONLY 只读打开
- O_WRONLY 只写打开
- O_RDWR 可读可写文件
以下选项可以同时制定0个或多个,和必选项按位或起来作为flags参数,可选项有很多,以下是其中一部分可选项:
- O_APPEND 表示追加。如果文件已有内容,这次打开文件所写的数据附加到文件的末尾而不覆盖原来的内容。
- O_CREAT 若此文件不存在则创建它。使用此选项时需要提供第三个参数mode,表示文件的访问权限
- O_EXCL 若同时指定了O_CREAT,并且文件已存在,则出错返回。
- O_TRUNC 如果文件已存在,并且以只写或可读可写方式打开,则将其长度截断为0字节。
- O_NONBLOCK 对于设备文件,以O_NONBLOCK方式打开可以做非阻塞I/O(Nonblock I/O)。
open函数与C标准I/O库的fopen函数有些细微的区别:
- 以可写的方式fopen一个文件时,如果文件不存在则自动创建,而open需要制定O_CREAT才会创建文件,否则文件不存在就出错返回。
- 以w或w+方式fopen一个文件时,如果文件已存在就截断为0字节,而open一个文件必须明确指定O_TRUNC才会截断文件,否则直接在原来的数据上改写。
第三个参数mode指定文件权限,可以用八进制数表示,比如0644表示-rw-r--r--,也可以用S_IRUSR、S_IWUSR等宏定义按位或起来表示。注意:文件权限由open的mode参数和当前进程的umask掩码共同决定。
Shell进程的umask掩码可以用umask命令查看:
$ umask 0022
用touch命令创建一个文件时,创建权限是0666,而touch进程继承了Shell进程的umask掩码,所以最终的文件权限是0666&~022 = 0644。
2. close
close函数关闭一个已打开的文件:
#include <unistd.h> int close(int fd); 返回值:成功返回0,出错返回-1并设置errno
参数fd是要关闭的文件描述符。当一个进程结束时,内核对该进程所有尚未关闭的文件描述符调用close关闭,所以即使用户程序不调用close在终止时内核也会自动关闭它打开的所有文件。
由 open 返回的文件描述符一定是该进程尚未使用的最小描述符。由于程序启动时自动打开文件描述符0、1、2,因此第一次调用 open 打开文件通常会返回描述符3,再调用 open 就会返回4。可以利用这一点在标准输入、标准输出或标准错误输出上打开一个新文件,实现重定向的功能。例如,首先调用 close 关闭文件描述符1,然后调用 open 打开一个常规文件,则一定会返回文件描述符1,这时候标准输出就不再是终端,而是一个常规文件了,再调用 printf 就不会打印到屏幕上,而是写到这个文件中了。
三、 read/write
1. read
read函数从打开的设备或文件读取数据
#include <unistd.h> ssize_t read(int fd, void *buf, size_t count); 返回值:成功返回读取的字节数,出错返回-1并设置errno,如果在调read之前已达到文件末尾,则这次read返回0
参数count是请求读取的字节数,读上来的数据保存在缓冲buf中,同时文件的当前读写位置向后移。这个读写位置和使用C标准库时的读写位置有可能不同。返回值类型为ssize_t表示有符号的size_t,这样既可以返回正的字节数(正数)、0(到达文件末尾)也可以返回负值-1(出错)。read返回时,返回值说明了buf中前多少字节是刚读上来的。有些情况下实际读到的字节数(返回值)会小于请求读的字节数count,例如:
- 读常规文件时,在读到count字节之前就到达文件末尾了。
- 从终端设备读,通常以行为单位,读到换行符就返回了。
- 从网络读数据,根据不同的传输层协议和内核缓存机制,返回值可能小于请求的字节数。
2. write
write函数向打开的设备或文件中写数据。
#include <unistd.h> ssize_t write(int fd, const void *buf, size_t count); 成功返回写入的字节数,出错返回-1并设置errno
写常规文件时,write的返回值通常等于请求写的字节数,而向终端设备或网络写则不一定。
3. 阻塞
读常规文件是不会阻塞的,不管读多少字节,read一定会在有限的时间内返回。从终端设备或网络读则不一定,如果从终端输入的数据没有换行符,调用read读终端设备就会阻塞,如果网络上没有接收到数据包,调用read从网络读就会阻塞,至于会阻塞多长时间也是不确定的。如果一直没有数据就一直阻塞在那里。写操作同理。
当进程调用一个阻塞的系统函数时,该进程被置于睡眠状态,这时内核调度其他进程运行,直到该进程等待的事件发生了(比如网络上接收到了数据包,或者调用sleep指定的睡眠时间到了)它才有可能继续运行。与睡眠状态相对的是运行状态。在linux内核中,处于运行状态的进程分为两种情况:
- 正在被调度执行。CPU处于该进程的上下文环境中,程序计数器(eip)里保存着该进程的指令地址,通用寄存器里保存着该进程运算过程的中间结果,正在执行该进程的指令,正在读写该进程的地址空间。
- 就绪状态。该进程不需要等待什么事件发生,随时可以执行,但CPU暂时还在执行另一个进程,所以该进程在一个就绪队列中等待被内核调度。
如果在open一个设备时指定了O_NONBLOCK标志,read/write就不会阻塞。
四、lseek
lseek和标准I/O库的fseek函数类似,可以移动当前读写位置(或者叫偏移量)。
#include <sys/types.h> #include <unistd.h> off_t lseek(int fd, off_t offset, int whence);
参数offset和whence的含义和fseek函数完全相同。对于whience的设置有三种形式,SEEK_SET,SEEK_CUR,SEEK_END,和fseek一样,偏移量允许超过文件末尾,这种情况下对该文件的下一次写操作将延长文件,中间空洞的部分读出来都是0。
若lseek成功执行,则返回新的偏移量,因此可用以下方法确定一个打开文件的当前偏移量:
off_t currpos; currpos = lseek(fd, 0, SEEK_CUR);
设备一般是不可以设置偏移量的。如果设备不支持lseek则lseek返回-1,并将errno设置为ESPIPE。注意fseek和lseek在返回值上有细微的差别,fseek成功时返回0失败时返回-1。要返回当前偏移量需调用ftell,而lseek成功时返回当前偏移量失败时返回-1。
五、fcntl
STDIN_FILENO在程序启动时已经被自动打开,所以我们要改变STD_FILENO的打开方式(比如设置O_NONBLOCK)必须用open函数重新打开。另外一种方法就是用fcntl函数改变一个一打开的文件的属性,可以重新设置读、写、追加、非阻塞等标志,而不必重新open文件。
#include <unistd.h> #include <fcntl.h> int fcntl(int fd, int cmd); int fcntl(int fd, int cmd, long arg); int fcntl(int fd, int cmd, struct flock *lock);
这个函数和open一样,也是用可变参数实现的,可变参数的类型和个数取决于前面的cmd参数。
下面的程序使用F_GETFL和F_SETFL这两种fcntl命令改变STDIN_FILENO的属性:
#include <unistd.h> #include <fcntl.h> #include <errno.h> #include <string.h> #include <stdlib.h> #define MSG_TRY "try again\n" int main(void) { char buf[10]; int n; int flags; flags = fcntl(STDIN_FILENO, F_GETFL); flags |= O_NONBLOCK; if (fcntl(STDIN_FILENO, F_SETFL, flags) == -1) { perror("fcntl"); exit(1); } tryagain: n = read(STDIN_FILENO, buf, 10); if (n < 0) { if (errno == EAGAIN) { sleep(1); write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY)); goto tryagain; } perror("read stdin"); exit(1); } write(STDOUT_FILENO, buf, n); return 0; }
六、ioctl
ioctl用于向设备发控制和配置命令,有些命令也需要读写一些数据,但这些数据是不能用read/write读写的,称为Out-of-band数据。也就是说,read/write读写的数据是in-band数据,是I/O操作的主体,而ioctl命令传送的是控制信息,其中的数据是辅助的数据,例如在串口线上收发数据通过read/write操作,而串口的的波特率、校验位、停止位通过ioctl设置,A/D转换的结果通过read读取,而A/D转换的精度和工作频率通过ioctl设置。
#include <sys/ioctl.h> int ioctl(int d, int request, ...);
d是某个设备的文件描述符。request是ioctl的命令,可变参数取决于request,通常是一个指向变量或结构体的指针。若出错则返回-1,若成功则返回其他值,返回值也是取决于request。下面的程序使用TIOCGWINSZ命令获得终端设备的窗口大小。
#include <stdio.h> #include <stdlib.h> #include <unistd.h> #include <sys/ioctl.h> int main(void) { struct winsize size; if (isatty(STDOUT_FILENO) == 0) exit(1); if(ioctl(STDOUT_FILENO, TIOCGWINSZ, &size)<0) { perror("ioctl TIOCGWINSZ error"); exit(1); } printf("%d rows, %d columns\n", size.ws_row, size.ws_col); return 0; }
七、mmap
mmap可以把磁盘文件的一部分直接映射到内存,这样文件中的位置直接就有对应的内存地址,对文件的读写可以直接用指针来做而不需要read/write函数。
#include <sys/mman.h> void *mmap(void *addr, size_t len, int prot, int flag, int filedes, off_t off); int munmp(void *addr, size_t len);
该函数各参数的作用图示如下:
如果addr参数为NULL,内核会自己在内存地址空间中选择合适的地址建立映射。如果addr不是NULL,内给内核一个提示应该从什么地址开始映射,内核会选择addr之上的某个合适的地址开始映射。建立映射后,真正的映射首地址通过返回值可以得到。len参数是需要映射的那一部分文件的长度。off参数是从文件的什么位置开始映射,必须是页大小的整数倍(在32位体系统结构上通常是4K)。filedes是代表该文件的描述符。
prot参数有四种取值:
- PROT_EXEC 表示映射的这一段可执行,例如映射共享库
- PROT_READ 表示映射的这一段可读
- PROT_WRITE 表示映射的这一段可写
- PROT_NONE 表示映射的这一段不可访问
flag参数有很多种取值,以下是其中两种:
- MAP_SHARED 多个进程对同一个文件的映射是共享的,一个进程对映射的内存做了修改,另一个进程也会看到这种变化。
- MAP_PRIVATE 多个进程对同一个文件的映射不是共享的,一个进程对映射的内存做了修改,另一个进程并不会看到这种变化,也不会真的写到文件中去。
如果mmap成功则返回映射首地址,如果出错则返回常数MAP_FAILED。当进程终止时,该进程的映射内存会自动解除,也可以调用munmap解除映射。munmap成功返回0,出错返回-1。
mmap函数的底层也是一个函数调用,在执行程序时经常要用到这个系统调用来映射共享库到改进程的地址空间。