文件IO
本节主要介绍不带缓冲的I/O(unbuffered I/O),不带缓冲指的是每个read
和write
都会调用内核中的一个系统调用。
不带缓冲的I/O函数不是ISO C的标准函数,但是是符合POSIX的。
原子操作在多进程之间贡献文件变得相当重要。
主要涉及的函数有oepn, read, write, lseek, close; dup, fcntl, sync, fsync, ioctl
。
文件描述符
对于内核而言,所有打开的文件都通过文件描述符引用。
文件描述符是一个非负整数,取值范围是0~1024
$ulimit -n
1024
当进程要打开文件调用open
函数时,会触发一次内核的系统调用,内核会向进程返回一个文件描述符。
在符合POSIX.1的环境中,STDIN_FILENO, STDOUT_FILENO, STDERR_FILENO
分别表示文件描述符0,1,2。这些常量定义在<unistd.h>
中。
常用函数
open()
#include <fcntl.h> // file control header file
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
// 成功返回最小的未用文件描述符数值,失败返回-1。
验证open函数返回的总是当前进程未使用的最小的文件描述符。
验证方法是关掉标准输入流,使用open函数打开文件,查看返回的文件描述符是否等于0,如下所示:
$ cat open_ret_test.c
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
//关闭标准输流
close(STDIN_FILENO);
//打开任意文件
int fd = open("./fd_test", O_RDWR);
if (fd != -1)
{
printf("fd=%d\n", fd);
}
else
{
perror("oepn failed");
}
}
$ touch fd_test
$ ./open_ret_test
fd=0
path
参数是要打开或创建的文件的名称,oflag
参数可用来说明此函数的多个选项。由下列一个或多个常量或运行之后构成oflag
参数,常量定义在<fcntl.h>
头文件中。
oflag 参数 | 含义 |
---|---|
O_RDONLY | 只读打开 |
O_WRONLY | 只写打开 |
O_RDWR | 读写打开 |
O_APPEND | 追加模式打开文件,移动文件指针偏移和写操作是一个原子操作 |
O_CLOEXEC | close_on_exec, 将FD_CLOEXEC设置为文件描述符标志。也可以先打开文件,再使用fcntl 函数设置,但存在并发风险。该标志的作用是在运行exec 相关程序关闭设置了此标志的文件描述符,防止访问无权限的文件。大部分系统中没有此定义常量 |
O_CREAT | 文件不存在则创建文件,同时需要指定mode 参数,mode 的取值如下。 |
O_DIRECTORY | 打开目录,如果不是目录则返回-1 |
O_TRUNC | 文件存在,则截断为0 |
O_SYNC | write 时需要等待物理I/O完成,包括由该write 引起的文件属性更新所需的I/O。 |
O_DSYNC | write 时需要等待物理I/O完成,但写入操作不影响读取的内容时,无需等待文件属性更新。 当文件用O_DSYNC标志打开,在重写现有部分内容时,文件时间属性不会同步变化。O_SYNC只要写入内容,数据和属性总是同步更新。 |
O_RSYNC | read 操作等待,直至所有对文件同一部分挂起的写操作完成。 |
下面验证O_CLOEXEC标志的作用,当有效用户变为普通用户时也能够读取打开的文件,因此存在漏洞,设置O_CLOEXEC则可以避免此问题。
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main()
{
//输出当前的有效用户名
//保证当前用户为root
printf("EUID=%d, UID=%d\n", geteuid(), getuid());
int fd = open("/etc/shadow", O_RDONLY);
if (fd != -1)
{
printf("/etc/shadow open successfully\n");
}
else
{
perror("open failed");
return 0;
}
pid_t pid = fork();
if (pid == 0)
{
//进入到子进程中
//设置用户ID和有效用户ID
seteuid(1000);
setegid(1000);
char buf[32] = {0};
printf("EUID=%d, UID=%d\n", geteuid(), getuid());
//从父进程中泄漏的文件描述符,可以读取文件
while (read(fd, buf, sizeof(buf)) > 0)
{
printf("%s", buf);
}
close(fd);
//普通用户无权限打开
fd = open("/etc/shadow", O_RDONLY);
if (fd != -1)
{
printf("/etc/shadow open successfully\n");
close(fd);
}
else
{
perror("open failed");
}
}
sleep(3);
close(fd);
return 0;
}
如果两个基于文件的函数调用,其中第二个调用依赖于第一个调用的结果,那么程序很脆弱。因为两个调用不是原子操作,在两个调用之间文件可能被改变。这就是TOCTTOU错误。
create()
#include <fcntl.h>
int creat(const char* path, mode_t mode);
// 成功返回文件描述符,失败返回-1。
等价于open(path, O_WRONLY|O_CREAT|O_TRUNC, mode)
。
缺点在于以只写方式打开文件。如果先写文件在读文件,则需要分别调用creat, close, open
。
代替方式open(path, O_RDWR | O_CREAT | O_TRUNC, mode)
。
close()
#include <unistd.h>
int close(int fd);
// 成功返回0,失败返回-1。
关闭文件会释放该进程加在该文件上的所有记录锁。
lseek()
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
成功返回文件偏移量,失败返回-1。
每个打开的文件都有一个与之相关的值为非负整数的文件偏移量。
读写操作都是从文件偏移量处开始,并使偏移量增加所读写的字节数。
默认情况下,打开的文件的偏移量为0,除非指定O_APPEND
选项。 lseek
函数显示设置文件偏移量。
whence取值 | offset含义 |
---|---|
SEEK_SET | 文件偏移量设置为距文件开始处offset个字节 |
SEEK_CUR | 文件偏移量设置为当前位置加offset,offset可正或负 |
SEEK_END | 文件偏移量设置为文件长度加offset,offset可正或负 |
验证标准输入,管道是否可以设置偏移,示例如下:
#include <stdio.h>
#include <unistd.h>
int main()
{
if (lseek(STDIN_FILENO, 0, SEEK_CUR) == -1)
{
perror("cannot seek");
}
else
{
printf("seek ok\n");
}
return 0;
}
结果如下:
$ ./lseek_test < /etc/passwd
seek ok
$ cat /etc/passwd | ./lseek_test
cannot seek: Illegal seek
由此标准输入可以设置文件偏移,但是管道不可以。
当文件偏移量大于文件长度时,写入文件将会造成文件空洞,但文件空洞是否占用磁盘空间取决于文件系统。
下面代码将验证空洞文件是否会占用磁盘空间:
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
int main()
{
char buf1[] = "abcdefghij";
char buf2[] = "ABCDEFGHIJ";
int fd = open("file.hole", O_RDWR | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
if (fd < 0)
{
perror("create file failed");
}
//写入文件
if (write(fd, buf1, strlen(buf1)) != 10)
{
perror("write failed");
return 0;
}
// offset now = 10;
// lseek 制造空洞
if (lseek(fd, 16384, SEEK_SET) == -1)
{
perror("lseek failed");
}
// offset now=16384
if (write(fd, buf2, strlen(buf2)) != strlen(buf2))
{
perror("write failed");
return 0;
}
// offset now=16394
//创建非空洞文件
fd = open("file.nohole", O_RDWR | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
if (fd < 0)
{
perror("create file failed");
}
int i = 0;
while (i < 1639)
{
if (write(fd, buf1, strlen(buf1)) != strlen(buf1))
{
perror("write failed");
}
i++;
}
write(fd, "abcd", strlen("abcd"));
close(fd);
return 0;
}
从测试结果中可以看出,无空洞的文件占用的磁盘空间为20,存在空洞的文件占用磁盘空间为8。如下所示:
$ ls -ls file.*
8 -rw-rw---- 1 blduan blduan 16394 Feb 27 01:20 file.hole
20 -rw-rw---- 1 blduan blduan 16394 Feb 27 01:20 file.nohole
read()
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t nbytes);
// 返回值:读到的字节数,若已到文件尾,返回0;若出错,返回-1。
有许多情况,read
实际读到的字节数小于要读取的字节数:
- 读普通文件时,在读到要求字节数之前到达文件尾。返回实际读到的字节数,下次在调用
read
时会返回0。 - 当从终端设备读取时,通常一次最多读一行。
- 当从网络读取时,网络中的缓冲机制可能会造成返回值小于要求读的字节数。
- 当从管道或FIFO读时,管道包含的字节少于所需的数量,
read
会返回实际读到的字节数。
write()
#include <unistd.h>
ssize_t write(int fd, const void* buf, size_t nbytes);
// 返回值:成功返回写入的字节数,失败返回-1。
常见的错误是磁盘已写满。
I/O效率
先说结论,read
读取缓冲区和磁盘块大小一致时,所用时间最短。
文件共享和原子操作
Unix进程支持在不同进程间共享打开文件。
内核进程管理打开文件的方式
内核用于I/O的数据结构,一共有四个,分别是task_struct, files_struct, fdtable, file
。
进程的抽象概念是基于struct task_struct
结构体,每个进程中都有一个管理该进程所有打开文件的结构体,即struct files_struct
。
// /usr/include/sched.h
struct task_struct{
...
/* Open file information */
struct files_struct;
...
}
// /usr/include/fdtable.h
/*
* Open file table structure
*/
struct files_struct {
// 读相关字段
atomic_t count;
bool resize_in_progress;
wait_queue_head_t resize_wait;
// 打开的文件管理结构
struct fdtable __rcu *fdt;
struct fdtable fdtab; //优化策略,默认情况下fdt指向fdtab,只有真正需要时才会动态分配
// 写相关字段
unsigned int next_fd;
unsigned long close_on_exec_init[1];
unsigned long open_fds_init[1];
unsigned long full_fds_bits_init[1];
struct file * fd_array[NR_OPEN_DEFAULT];
};
进程中管理打开文件的结构体files_struct
是通过文件指针数组来管理打开的文件,分别是fd_array
和*fdt
。这两种分别是静态数组和动态数据。
当打开文件数量小于NR_OPEN_DEFAULT=BITS_PER_LONG
(值为系统中long
型的位长)时,采用静态数据,这是出于性能考虑。
实际上,文件描述符指的就是文件指针数组fd_array
和fdt
中的索引。
进程所有打开的文件构成的文件指针数组中的每一项都是一个file
指针类型,其内容如下:
struct file {
// ...
struct path f_path;
struct inode *f_inode;
const struct file_operations *f_op;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode;
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
// ...
}
f_path
:标识文件名。
f_inde
:i节点指针,文件系统中每个文件仅对应1个i节点。
f_pos
:当前打开的文件的偏移。
Linux进程是通过这3层结构来对文件进行I/O。
即进程通过管理结构体files_struct
中的文件描述符表fd_table
或文件指针数组fd_array
来保存打开的文件,并将索引返回作为文件描述符。文件描述表中的每一行都代表一个已打开的文件。
对每个打开文件创建一个单独的文件表file
。 文件表中包含了打开文件的偏移以及该文件在文件系统的节点。
有了这些数据结构之后,以下操作就比较明确了。
- 在每次完成
write
之后,在文件描述符表项file
结构体中的当前文件偏移量会增加所写入的字节数,如果这个文件偏移量大于i节点中的文件长度,则i节点信息更新。 - 如果使用
O_APPEND
打开文件,则相应标志也会被设置到file
结构体中的文件状态标志中,每次对这种具有追加写标志的文件执行写操作时,都会先将文件偏移量设置为i节点中的文件长度。 lseek
定位到文件末尾时,会修改file
结构体中的文件偏移量为i节点中的文件长度。
文件共享
多个进程打开同一文件时,每个进程会创建各自的文件指针struct file
,但是文件指针指向的i节点是相同的。 这样每个进程都会有各自的文件偏移量,这就是文件共享的内部机制
原子操作
原子操作指的是由多步组成的一个操作。
如果该原子操作要执行,则要么执行完所有步骤,要么一步也不执行,不能只执行步骤的一个子集。
例如追加数据到文件结尾,如果没有O_APPEND
,则需要先使用lseek
定位到文件尾端,再使用write
写数据到文件。
这样的话如果多进程操作同一个文件时,在一个进程执行完lseek
后,另一个进程已经调用完write
写入数据了,这样当第一个进程再写入时就会覆盖第二个进程写入的数据。
功能函数
dup()和dup2()
#include <unistd.h>
int dup(int fd);
int dup2(int fd, int fd2);
// 成功返回新的文件描述符,失败返回-1。
dup
函数返回的文件描述符一定是可用文件描述符中的最小数值。
dup2
可以使用参数fd2
指定新文件描述符的值。如果fd2
已经打开,则先关闭。如果fd==fd2
,则直接返回fd2
,这样fd2
的FD_CLOEXEC
标志就不会被清楚。
这些函数返回的新文件描述符和参数fd
共享同一个文件表项,即同一个file
结构体。
下面代码验证dup
函数返回最小可用文件描述符
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
int main()
{
// 0,1,2分别是标准输,标准输出,标准错误的文件描述符,并且进程默认是打开。
// open函数会返回最小未用的文件描述符,如果打开正常,则fd1==3;
int fd1 =
open("./test", O_CREAT | O_RDWR, S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
if (fd1 != -1)
{
printf("%d\n", fd1);
}
else
{
perror("open failed");
return 0;
}
int fd2 = open("./test1", O_CREAT | O_RDWR,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP); // 如果正常打开,则fd2==4
if (fd2 != -1)
{
printf("%d\n", fd2);
}
else
{
perror("open failed");
return 0;
}
//现在关闭fd1,则文件描述符3为最小可用的。
close(fd1);
int fd3 = dup(STDIN_FILENO);
if (fd3 != -1)
{
printf("%d\n", fd3); //正常情况下 fd==3
}
else
{
perror("open failed");
return 0;
}
close(fd2);
close(fd3);
return 0;
}
执行结果如下
$ ./dup_return_min_fd
3
4
3
dup2
函数将关闭fd2
和复制fd
作为一个原子操作。
sync(),fsync()和fdatasync()
Unix系统在内核实现中设有缓冲区高速缓存或页高速缓存。
当我们向文件中写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式叫做延迟写。
当内核需要重用缓冲区存放其他磁盘数据时,就需要将现有的延迟写的数据全部写入到磁盘中。
为了保存磁盘文件系统中的数据和缓冲区数据的一致性,系统提供了3个接口,如下:
#include <unistd.h>
int fsync(int fd);
int fdatasync(int fd);
// 成功返回0,失败返回-1
void sync(void);
区别如下:
函数名 | 是否写入磁盘 | 是否同步文件属性 |
---|---|---|
sync | 否,仅排入队列 | 否 |
fsync | 是 | 是 |
fdatasync | 是 | 否 |
fcntl()
文件描述符标志
每个进程为打开的文件维护对应的文件描述符标志,当前的文件描述符标志仅有一个,即FD_CLOEXEC
。
当进程采用静态文件指针数组fd_array
保存打开的文件时,文件管理结构体中的参数close_on_exec_init
的对应位的值0或1代表着打开文件的FD_CLOEXEC
的状态。静态数组的长度和close_on_exec_init
的位长一致。
动态数组fdtable
中有专门的参数unsigned long* close_on_exec
来保存FD_CLOEXEC
的状态。
文件状态标志
- 访问方式标志:
O_RDONLY, O_WRONLY, O_RDWR, O_EXEC, O_SEARCH
- 打开时标志:
O_CREAT, O_EXCL, O_TRUNC, O_NONBLOCK
- I/O操作方式标志:
O_APPEND, O_ASYNC, O_SYNC, O_DSYNC, O_RSYNC
了解了文件描述符标志和文件状态标志的区别之后,下面才是fcntl
函数的说明:
#include <fcntl.h>
int fcntl(int fd, int cmd, ...);
// 成功返回值依赖于cmd,失败返回值为-1。
fcntl
函数有5个功能:
- 复制一个已有的描述符(cmd=F_DUPFD/F_DUPFD_CLOEXEC)
- 获取或设置文件描述符标志(cmd=F_GETFD/F_SETFD)
- 获取或设置文件状态标志(cmd=F_GETFL/F_SETFL)
- 获取或设置异步I/O所有权(cmd=F_GETOWN/F_SETOWN)
- 获取或设置记录锁(cmd=F_GETLK,F_SETLK,F_SETLKW)
cmd=F_DUPED
时,如果第3个参数是整形,那么返回新的文件描述符,新的文件描述符大于或等于第3个参数。
新的文件描述符和fd
拥有共同的文件表项(file
指针),但有其自己的文件描述符标志,其中FD_CLOEXEC
描述符会被清除。
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
int main()
{
// fcntl会返回大于或等于第3个参数的新文件描述符
// 会清除新文件描述符的FD_CLOEXEC标志
int fd = fcntl(STDIN_FILENO, F_DUPFD, 5); //正常情况下fd的值为5
if (fd == -1)
{
perror("fcntl failed");
}
else
{
printf("%d\n", fd);
}
int fdFlags = fcntl(fd, F_GETFD);
printf("%d\n", fdFlags); // FD_CLOEXEC标志被清除,值为0
close(fd);
return 0;
}
结果如下:
$ ./fcntl_F_DUPFD_test
5
0
cmd=F_DUPFD_CLOEXEC
时,返回新的文件描述符,并设置与新文件描述符相关联的FD_CLOEXEC
文件描述符标志,其他和F_DUPFD
相同。
cmd=F_GETFD
,fd
的文件描述符标志作为函数值返回。当前只定义了一个文件描述符标志FD_CLOEXEC
。
cmd=F_SETFD
, 给fd
的文件描述符设置标志,当前仅支持FD_CLOEXEC
。
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
int main()
{
int fd = open("./test", O_RDWR | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
if (fd == -1)
{
perror("open failed");
}
else
{
printf("%d\n", fd);
}
int fdFlags = fcntl(fd, F_GETFD);
printf("%d\n", fdFlags); // 默认未设置,值为0
fcntl(fd, F_SETFD, FD_CLOEXEC);
fdFlags = fcntl(fd, F_GETFD);
printf("%d\n", fdFlags); // 已设置FD_CLOEXEC,值为1
close(fd);
return 0;
}
执行结果如下:
$ ./fcntl_F_GETFD_test
3
0
1
cmd=F_GETFL
获取对应于fd
的文件状态标志。
cmd=F_SETFL
对文件描述符fd
设置文件状态标志。
#include <fcntl.h>
#include <stdio.h>
#include <sys/stat.h>
#include <unistd.h>
int main()
{
int fd = open("./test", O_RDWR | O_CREAT | O_TRUNC,
S_IRUSR | S_IWUSR | S_IRGRP | S_IWGRP);
if (fd == -1)
{
perror("open failed");
}
else
{
printf("%d\n", fd);
}
int flags = fcntl(fd, F_GETFL);
if (flags < 0)
{
perror("fcntl failed");
}
switch (flags & O_ACCMODE)
{
case O_RDONLY:
printf("read only\n");
break;
case O_WRONLY:
printf("write only\n");
break;
case O_RDWR:
printf("read and write\n");
break;
default:
printf("unkown\n");
}
close(fd);
}
cmd=F_GETOWN
获取当前接收SIGIO
和SIGURG
信号的进程ID或进程组ID。
cmd=F_SETOWN
设置当前接收SIGIO
和SIGURG
信号的进程ID或进程组ID。
ioctl()
ioctl
函数是I/O操作的杂物箱。
#include <sys/ioctl.h>
int ioctl(int fd, unsigned long request, ...);
// 出错返回-1
每个设备驱动程序可以定义它自己专用的ioctl
命令,系统则为不同种类的设备提供通用的ioctl
命令。
类别 | 常量名 | 头文件 | ioctl数 |
---|---|---|---|
盘标号 | DIOxxx | <sys/disklabel.h> | 4 |
文件I/O | FIOxxx | <sys/filio.h> | 14 |
磁带I/O | MTIOxxx | <sys/mtio.h> | 11 |
套接字I/O | SIOxxx | <sys/sockio.h> | 73 |
终端I/O | TIOxxx | <sys/ttycom.h> | 43 |
- 原文作者:生如夏花
- 原文链接:https://blduan.top/post/%E8%AF%BB%E4%B9%A6%E7%AC%94%E8%AE%B0/apue/%E6%96%87%E4%BB%B6io/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。