文章目录
UNIX/Linux 的一个基本哲学是 一切皆文件
。不仅普通的文件,甚至连各种 字符设备、块设备、套接字 等都被当成文件对待,尽管它们的类型差异很大,但 UNIX/Linux 为它们提供的操作界面却是相同的。
1、Linux 的文件 I/O
Linux 把大部分系统资源当作文件并呈现给用户,用户只需按照文件 I/O 的方式,就能完成数据的输入输出。Linux 文件按其代表的具体对象,可大致分类为:
序号 | 第一个字符表示 | 含义 | 描述 |
---|---|---|---|
1 | - | 普通文件 | 一般意义上的文件、磁盘文件 |
2 | b | 块设备 | - |
3 | c | 字符设备 | - |
4 | d | 目录 | - |
5 | l | 链接文件 | - |
6 | p | 命名管道 | 一种特殊文件,常用于进程间通信 |
7 | s | Socket 文件 | 主要用在网络通信方面 |
文件 I/O 的常用操作方法有“打开”、“关闭”、“读”和“写”等。只要是文件,都可以用这套方法操作。系统提供了文件 I/O 的应用程序接口(API),以函数的形式提供给应用程序调用。
打开文件 对应的函数是 open(),读文件对应的函数是 read(),写文件对应的函数是 write(),关闭文件对应的函数是 close(),这些是文件常用函数。
Linux 系统提供的文件 I/O 接口函数,是以最基本的系统服务形式提供的,又称它们为基本 I/O 函数。这些函数有个共同的特点:它们都通过 文件描述符(file descriptor)来完成对指定文件的 I/O 操作。
2、文件描述符 fd(file descriptor)
文件描述符
fd(file descriptor)是进程中用来表示某个文件的整数,有的文献资料中又称它为 文件句柄
(file handle)。
文件描述符的作用,类似于在生活中排队取的号牌,业务员(进程)通过叫号(引用 文件描述符)就能找到来办事的人(打开的文件)。
有效的文件描述符 取值范围 从 0 开始,直到系统定义的某个极限值。这些指定范围的整数,实际上是 进程文件描述符表 的索引。文件描述符表 是进程用来保存它所打开的文件信息的、由操作系统维护的一个登记表,用户程序不能直接访问该表。
文件描述符 的 取值范围,反映了 文件描述符表 的大小,表示这个进程最多可以同时打开多少个文件。在大多数 Linux系统中,可通过命令ulimit -n
查询到这个数值的大小,如图:
对于内核而言,进程所打开的文件都由 文件描述符 引用。当进程 打开一个现存文件 或 创建一个新文件时,内核返回 一个文件描述符 给进程。当读、写一个文件时,先调用 open() 或 creat() 函数取得代表该文件的 文件描述符 fd,然后将 fd 作为参数传递给 read() 或 write() 等 文件操作函数。
通常情况下,文件描述符 0、1、2 在进程启动时已被占用,代表进程在启动过程中打开的文件。文件描述符 0、1、2 在桌面系统与嵌入式系统上,通常代表的文件如表:
序号 | 文件描述符 | 含义 | 桌面 / 服务器 Linux | 嵌入式 Linux |
---|---|---|---|---|
1 | STDIN_FILENO 0 | 标准输入(stdin) | 键盘 | 串口终端 |
2 | STDOUT_FILENO 1 | 标准输出(stdout) | 终端屏幕 | 串口终端 |
3 | STDERR_FILENO 2 | 标准错误(stderr) | 终端屏幕 | 串口终端 |
文件描述符
和 文件指针
的区别:
序号 | 分类 | 描述 |
---|---|---|
1 | 文件描述符 | 在Linux系统中,打开文件就会获得 文件描述符,它是个很小的正整数。 每个进程在 PCB(Process Control Block)中保存着一份 文件描述符表,文件描述符 就是这个表的索引,每个表项 都有一个 指向已打开文件 的指针 |
2 | 文件指针 | C语言中,使用文件指针 做为I/O的句柄。文件指针 指向进程用户区中的一个被称为 FILE结构的数据结构。 FILE结构 包括一个缓冲区和一个文件描述符。而 文件描述符 是 文件描述符表 的一个索引,因此从某种意义上说 文件指针 就是 句柄的句柄(在Windows系统上,文件描述符 被称作 文件句柄) |
3、常用文件 I/O 操作和函数
在 C 语言下进行文件 I/O 编程,一般要包含下表所列的头文件,这些头文件定义了文件 I/O 用到的数据类型
、函数原型
及其它要用到的符号常量
:
序号 | 头文件 | 描述 |
---|---|---|
1 | <sys/types.h> | 定义数据类型,如 ssize_t ,off_t 等 |
2 | <fcntl.h> | 定义 open() ,creat() 等函数原型,创建文件权限的符号常量 S_IRUSR 等 |
3 | <unistd.h> | 定义 read() ,write() ,close() ,lseek() 等函数原型 |
4 | <errno.h> | 与全局变量 errno 相关的定义 |
5 | <sys/ioctl.h> | 定义 ioctl() 函数原型 |
3.1 open 函数
open函数 可以打开或创建一个文件。
新打开文件返回文件描述符表中未使用的最小文件描述符。
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
返回值:成功返回新分配的文件描述符,出错返回-1并设置errno。
序号 | 参数 | 描述 |
---|---|---|
1 | pathname | 要打开或创建的文件名,和 fopen 一样,pathname 既可以是 相对路径 也可以是 绝对路径 |
2 | flags | 有一系列常数值可供选择,可以同时选择多个常数用 按位或运算符| 连接起来,所以这些常数的宏定义都以O_ 开头,表示or |
flags 参数:
必选项:以下三个常数中必须指定一个,且仅允许指定一个。
序号 | 常数 | 描述 |
---|---|---|
1 | O_RDONLY | 只读打开 |
2 | O_WRONLY | 只写打开 |
3 | O_RDWR | 可读可写打开 |
以下可选项可以同时指定 0个 或 多个,和必选项 按位或起来 作为 flags 参数。可选项有很多,这里只介绍一部分,其它选项可参考 open(2) 的 Man Page:
序号 | 常数 | 描述 |
---|---|---|
1 | O_APPEND | 表示追加 。如果文件已有内容,这次打开文件所写的数据附加到文件的末尾 ,而不覆盖原来的内容。 |
2 | O_CREAT | 若此文件不存在,则创建它。使用此选项时需要提供第三个参数mode,表示该文件的访问权限 |
3 | O_EXCL | 如果同时指定了 O_CREAT,并且文件已存在,则出错返回。 |
4 | O_TRUNC | 如果文件已存在,并且以只写或可读可写方式打开,则将其长度截断(Trun-cate)为0字节 |
5 | O_NONBLOCK | 对于设备文件,以O_NONBLOCK 方式打开可以做非阻塞I/O(Nonblock I/O) |
3.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() 就不会打印到屏幕上,而是写到这个文件中了。后面要讲的 dup2 函数 提供了另外一种办法在指定的文件描述符上打开文件。
3.3 最大打开文件个数
查看当前系统允许打开最大文件个数:
cat /proc/sys/fs/file-max
当前默认设置最大打开文件个数1024:
ulimit -a #1024
修改默认设置最大打开文件个数为4096:
ulimit -n 4096
3.4 read/write 函数
read() 函数从打开的设备或文件中读取数据。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
返回值:成功 返回 读取的字节数,出错 返回-1,并设置errno,如果在调 read() 之前已到达文件末尾,则这次 read() 返回 0。
参数 count 是请求读取的字节数,读上来的数据保存在缓冲区 buf 中,同时文件的当前读写位置向后移。注意这个读写位置和使用C标准I/O库时的读写位置有可能不同,这个读写位置是记在内核中的,而使用C标准 I/O 库时的读写位置是用户空间 I/O 缓冲区中的位置。比如用 fgetc 读一个字节,fgetc 有可能从内核中预读 1024 个字节到 I/O 缓冲区 中,再返回第一个字节,这时该文件在内核中记录的读写位置是1024,而在 FILE 结构体 中记录的读写位置是1。
注意,返回值类型是 ssize_t,表示 有符号的 size_t,这样既可以返回正的字节数 0(表示到达文件末尾)也可以返回负值-1(表示出错)。read() 函数返回时,返回值说明了 buf 中前多少个字节是刚读上来的。有些情况下,实际读到的字节数(返回值)会小于请求读的字节数 count。
例如:
(1)读常规文件时,在读到 count 个字节之前已到达文件末尾。例如,距文件末尾还有 30个字节 而请求读 100个字节,则 read() 返回30,下次 read() 将返回0。
(2)从终端设备读,通常以行为单位,读到换行符就返回了。
(3)从网络读,根据不同的传输层协议和内核缓存机制,返回值可能小于请求的字节数,后面 socket 编程部分会详细讲解。
write() 函数 向打开的设备或文件中写数据。
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
返回值:成功返回写入的字节数,出错返回-1并设置errno。
写 常规文件 时,write() 的返回值 通常等于请求写的字节数 count,而向 终端设备 或 网络写,则不一定。
4、阻塞和非阻塞
读常规文件是不会阻塞的,不管读多少字节,read() 一定会在有限的时间内返回。从终端设备或网络读 则不一定。如果从终端输入的数据没有换行符,调用 read() 读终端设备就会阻塞,如果网络上没有接收到数据包,调用 read() 从网络读就会阻塞,至于会阻塞多长时间也是不确定的,如果一直没有数据到达就一直阻塞在那里。
同样,写常规文件是不会阻塞的,而向终端设备或网络写则不一定。
现在明确一下阻塞(Block)这个概念。
当进程调用一个阻塞的系统函数时,该进程被置于 睡眠(Sleep)状态,这时 内核调度其它进程运行,直到该进程等待的事件发生了(比如网络上接收到数据包,或者调用sleep指定的睡眠时间到了)它才有可能继续运行。
与 睡眠状态 相对的是 运行(Running)状态,在Linux内核中,处于 运行状态 的进程分为两种情况:
(1)正在被调度执行。CPU 处于该进程的上下文环境中,程序计数器(eip)里保存着该进程的指令地址,通用寄存器 里保存着该进程运算过程的中间结果,正在执行该进程的指令,正在读写该进程的地址空间。
(2)就绪状态(Ready)。该进程不需要等待什么事件发生,随时都可以执行,但 CPU 暂时还在执行另一个进程,所以该进程在一个 就绪队列 中等待被 内核调度。
系统中可能同时有多个就绪的进程,那么该调度谁执行呢?
内核的调度算法(Schedule) 是基于优先级和时间片的,而且会根据每个进程的运行情况动态调整它的优先级和时间片,让每个进程都能比较公平地得到机会执行,同时要兼顾用户体验,不能让和用户交互的进程响应太慢。
4.1 阻塞 读终端
下面这个小程序从终端读数据 再写回终端。
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
char buf[10];
int n;
n = read(STDIN_FILENO, buf, 10);
if (n < 0) {
perror("read STDIN_FILENO");
exit(1);
}
write(STDOUT_FILENO, buf, n);
return 0;
}
如果在 open() 一个设备时指定了 O_NONBLOCK 标志,read/write 就不会阻塞。以 read() 为例,如果设备暂时没有数据可读就返回-1,同时置errno为EWOULDBLOCK(或者EAGAIN,这两个宏定义的值相同),表示本来应该阻塞在这里(would block,虚拟语气),事实上并没有阻塞而是直接返回错误,调用者应该试着再读一次(again)。这种行为方式称为轮询(Poll),调用者只是查询一下,而不是阻塞在这里死等,这样可以同时监视多个设备:
while(1) {
非阻塞read(设备1);
if(设备1有数据到达)
处理数据;
非阻塞read(设备2);
if(设备2有数据到达)
处理数据;
...
}
如果 read(设备1) 是阻塞的,那么只要设备1没有数据到达就会一直阻塞在设备1的 read 调用上,即使设备2有数据到达也不能处理,使用 非阻塞I/O 就可以避免设备2 得不到及时处理。
非阻塞 I/O 有一个缺点,如果所有设备都一直没有数据到达,调用者需要反复查询做无用功。如果阻塞在那里,操作系统可以调度别的进程执行,就不会做无用功了。在使用非阻塞I/O 时,通常不会在一个 while 循环中一直不停地查询(这称为 Tight Loop),而是每延迟等待一会儿来查询一下,以免做太多无用功,在延迟等待的时候可以调度其它进程执行。
while(1) {
非阻塞read(设备1);
if(设备1有数据到达)
处理数据;
非阻塞read(设备2);
if(设备2有数据到达)
处理数据;
...
sleep(n);
}
这样做的问题是,设备1有数据到达时可能不能及时处理,最长需延迟n秒才能处理,而且反复查询还是做了很多无用功。以后要学习的 select(2) 函数可以阻塞地同时监视多个设备,还可以设定阻塞等待的超时时间,从而圆满地解决了这个问题。
4.2 非阻塞 读终端
以下是一个非阻塞I/O的例子。目前学过的可能引起阻塞的设备只有终端,所以用终端来做这个实验。程序开始执行时在0、1、2文件描述符上自动打开的文件就是终端,但是没有O_NONBLOCK 标志。
可以重新打开一遍设备文件 /dev/tty(表示当前终端),在打开时指定 O_NONBLOCK标志。
#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 fd, n;
fd = open("/dev/tty", O_RDONLY | O_NONBLOCK);
if(fd<0) {
perror("open /dev/tty");
exit(1);
}
tryagain:
n = read(fd, buf, 10);
if (n < 0) {
if (errno == EAGAIN) {
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
goto tryagain;
}
perror("read /dev/tty");
exit(1);
}
write(STDOUT_FILENO, buf, n);
close(fd);
return 0;
}
4.3 非阻塞读终端 和等待超时
以下是用非阻塞I/O实现等待超时的例子。既保证了超时退出的逻辑又保证了有数据到达时处理延迟较小。
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <string.h>
#include <stdlib.h>
#define MSG_TRY "try again\n"
#define MSG_TIMEOUT "timeout\n"
int main(void)
{
char buf[10];
int fd, n, i;
fd = open("/dev/tty", O_RDONLY|O_NONBLOCK);
if(fd<0) {
perror("open /dev/tty");
exit(1);
}
for(i=0; i<5; i++) {
n = read(fd, buf, 10);
if(n>=0)
break;
if(errno!=EAGAIN) {
perror("read /dev/tty");
exit(1);
}
sleep(1);
write(STDOUT_FILENO, MSG_TRY, strlen(MSG_TRY));
}
if(i==5)
write(STDOUT_FILENO, MSG_TIMEOUT, strlen(MSG_TIMEOUT));
else
write(STDOUT_FILENO, buf, n);
close(fd);
return 0;
}
5、lseek
每个打开的文件都记录着当前读写位置,打开文件时读写位置是0,表示文件开头,通常读写多少个字节就会将读写位置往后移多少个字节。但是有一个例外,如果以 O_APPEND 方式打开,每次写操作都会在文件末尾追加数据,然后将读写位置移到新的文件末尾。
lseek 和标准I/O库的 fseek 函数类似,可以移动当前读写位置(或者叫偏移量)。
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
参数offset和whence的含义和fseek函数完全相同。只不过第一个参数换成了文件描述符。和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。
6、fcntl
先前我们以read终端设备为例介绍了非阻塞I/O,为什么我们不直接对STDIN_FILENO做非阻塞read,而要重新open一遍/dev/tty呢?因为STDIN_FILENO在程序启动时已经被自动打开了,而我们需要在调用open时指定O_NONBLOCK标志。这里介绍另外一种办法,可以用fcntl函数改变一个已打开的文件的属性,可以重新设置读、写、追加、非阻塞等标志(这些标志称为File Status Flag),而不必重新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的属性,加上O_NONBLOCK 选项,实现和例 28.3 “非阻塞读终端”同样的功能。
6.1 用 fcntl 改变 File Status Flag
#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;
}
7、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;
}
在图形界面的终端里多次改变终端窗口的大小并运行该程序,观察结果。