UNIX环境高级编程(三)——文件I/O

文件描述符
文件描述符是一个非负整数,范围是0~OPEN_MAX-1
,用于表征一个已打开的文件。内核用它来标识进程正在访问的文件。
当进程创建时,操作系统内核默认为它打开了3个文件描述符,它们都链接向终端:
- 0:标准输入
- 1:标准输出
- 2:标准错误
一般我们应使用STDIN_FILENO
,STDOUT_FILENO
和STDERR_FILENO
来替代这三个幻数,从而提高可读性。这三个常量在<unistd.h>中定义。
每个进程的标准输入、标准输出和标准错误是独立的,每个进程拥有自己的标准输入输出错误。
Paw5zx注:
文件描述符是进程级别的资源标识符,由各个进程独立管理。
Q:如果没有终端(如以守护进程方式启动的服务),且代码中没有显式重定向,此时在进程中向标准输出写入数据会怎样?
open和openat函数
open
和openat
函数用于打开或创建一个文件
1 |
|
参数:
path
:要打开或创建的文件的名字oflag
:用于指定文件状态标志,标志决定了文件打开的模式和行为。用下列一个或多个常量进行”或”运算构成oflag
参数O_RDONLY
:只读打开O_WRONLY
:只写打开O_RDWR
:读、写打开O_EXEC
:只执行打开O_SEARCH
:只搜索打开(应用于目录)。不常见,且一般Linux系统通常不实现O_SEARCH
标志。
上述五个访问模式标志必须指定且只能指定一个
下列常量是可选的:O_APPEND
:每次写时都追加到文件的尾端O_CLOEXEC
:将FD_CLOEXEC
常量设置为文件描述符标志O_CREAT
:若文件不存在则创建。在使用此选项时,需要同时说明参数mode(指定该文件的访问权限)O_DIRECTORY
:若path
引用的不是目录,则出错O_EXCL
:若同时指定了O_CREAT
时,且文件已存在则出错。用此可以测试一个文件是否存在。若不存在则创建此文件。这使得测试和创建两者成为一个原子操作O_NOCTTY
:若path
引用的是终端设备,则不将该设备分配为此进程的控制终端O_NOFOLLOW
:若path
引用的是一个符号链接,则出错O_NONBLOCK
:如果path
引用的是一个FIFO、一个块特殊文件或者一个字符特殊文件,则文件本次打开操作和后续的 I/O 操作将设为非阻塞模式O_SYNC
:每次write
等待物理I/O
完成,包括由write
操作引起的文件属性更新所需的I/O
O_TRUNC
: 如果此文件存在,且为O_WRONLY
或者O_RDWR
成功打开,则将其长度截断为0O_RSYNC
:使每一个以文件描述符作为参数的read
操作等待,直到所有对文件同一部分挂起的写操作都完成O_DSYNC
:每次write
等待物理I/O
完成,但不包括由write
操作引起的文件属性更新所需的I/O
mode
:文件访问权限,在<sys/stat.h>
中定义S_IRUSR
:用户读S_IWUSR
:用户写S_IXUSR
:用户执行S_IRGRP
:组读S_IWGRP
:组写S_IXGRP
:组执行S_IROTH
:其他读S_IWOTH
:其他写S_IXOTH
:其他执行
对于openat
函数:
- 如果
path
指定的是绝对路径名,则fd
被忽略。openat
等价于open
- 如果
path
指定的是相对路径名,fd
是有效的并指向一个目录。那么path
将被解释为相对于这个目录的路径。 - 如果
path
指定的是相对路径名,而fd
是常量AT_FDCWD
,则path
被解释为相对于当前工作目录。被打开文件将在当前工作目录中查找。
Paw5zx注:
虽然逻辑上看起来是先判断path
是绝对路径还是相对路径,但实际上在处理openat()
调用时,首先是确定fd
的状态,因为它决定了如何处理path
。如果fd
无效,且path
不是绝对路径,通常会导致错误。
creat函数
creat
函数用于创建一个新文件
1 |
|
参数:类似open函数
注意
- 此函数以只写方式打开文件,如需读取该文件,需要先关闭,然后以读方式打开文件
- 若文件已存在则将文件截断为0
Paw5zx注:
早期open
函数不支持创建新文件,因此提供了creat
函数。现在open
提供了O_CRAET
和O_TRUNC
,也就不需要单独的creat
函数了。
close函数
close
函数用于关闭一个已打开的文件
1 |
|
参数:
fd
:待关闭文件的文件描述符
注意:
- 进程关闭一个文件会释放该进程加在该文件上的所有记录锁。
- 当一个进程终止时,内核会自动关闭进程所有的打开的文件。
文件偏移量
文件偏移量通常是一个非负整数,用于度量从文件开始处计算的字节数。通常读写操作都从当前文件偏移量处开始。注意:
- 当打开一个文件时,除非指定
O_APPEND
选项,否则文件偏移量被设置为0 - 如果文件描述符指向的是一个管道、FIFO、或者网络套接字,则无法设定文件偏移量,
lseek
将返回-1 ,并且将errno
被设置为ESPIPE
- 对于普通文件,其当前文件偏移量必须是非负整数。但是某些设备可以允许负的偏移量
- 当前文件偏移量可以大于文件的当前长度。此时对该文件的下一次写操作将加长该文件,并且在文件中构成一个空洞。空洞中的内容位于文件中但是没有被写过,其字节被读取时都被读为0。这个空洞在文件中占位,但不占用磁盘空间
lseek函数
lseek
函数用于显式地为已打开文件设置偏移量
1 |
|
参数:
fd
:已打开文件的文件描述符offset
:- 如果
whence
是SEEK_SET
,则将该文件的偏移量设置为距离文件开始处offset
个字节 - 如果
whence
是SEEK_CUR
,则将该文件的偏移量设置为当前值加上offset
个字节,offset
可正,可负 - 如果
whence
是SEEK_END
,则将该文件的偏移量设置为文件长度加上offset
个字节,offset
可正,可负
- 如果
whence
:必须是SEEK_SET
、SEEK_CUR
、SEEK_END
三个常量之一
注意:
- 由于某些设备可以允许负的偏移量,因此对于
lseek
,不能判断它返回值是否小于0,而是要根据是否等于-1来判断是否出错。 lseek
并不会引起任何I/O操作,lseek
仅仅将当前文件的偏移量记录在内核中。
read函数
read
函数用于从已打开文件中读数据
1 |
|
参数:
fd
:已打开文件的文件描述符buf
:存放读取内容的缓冲区的地址(由程序员手动分配)nbytes
:期望读到的字节数
读操作从文件的当前偏移量开始,在成功返回之前,文件的当前偏移量会增加实际读到的字节数。
有多种情况可能导致实际读到的字节数少于期望读到的字节数:
- 读普通文件时,在读到期望字节数之前到达了文件尾端。例如,到达文件尾端之前有30字节,期望读100字节,则
read
返回30,再次调用会返回0 - 当从终端设备读时,通常一次最多读取一行(终端默认是行缓冲的)
- 当从网络读时,网络中的缓存机制可能造成返回值小于期望读到的字节数
- 当从管道或者FIFO读时,若管道包含的字节少于所需的数量,则
read
只返回实际可用的字节数 - 当从某些面向记录的设备(如磁带)中读取时,一次最多返回一条记录
- 当一个信号造成中断,而已读了部分数据时
write函数
write
函数用于向已打开文件写数据
1 |
|
参数:
fd
:已打开文件的文件描述符buf
:存放待写入内容的缓冲区的地址(由程序员手动分配)nbytes
:期望写入的字节数
write
的返回值通常都是与nbytes
相同。否则表示出错。write
出错的一个常见原因是磁盘写满,或者超过了一个给定进行的文件长度限制。
对于普通文件,写操作从文件的当前偏移量处开始。如果打开文件时指定了O_APPEND
选项,则每次写操作之前,都会将文件偏移量设置在文件的当前结尾处。在一次成功写之后,该文件偏移量增加实际写的字节数。
文件共享
内核使用三种数据结构表示打开文件。它们之间的关系决定了在文件共享方面,一个进程对另一个进程可能产生的影响。
- 每个进程在进程表中都有一个记录项,记录项中包含一个打开文件描述符表,可将其视为矢量,每个文件描述符占用一项。与每个文件描述符相关联的是:
- 文件描述符标志
- 指向一个文件表项的指针
- 内核为所有打开的文件维持一张文件表。每个文件表项包含:
- 文件状态标志(读、写、添写、同步和阻塞等)
- 当前文件偏移量
- 指向该文件v节点表项的指针
- 每个打开的文件(或设备)都有一个v节点(vnode)结构。v节点
- 包含文件类型和对此文件进行各种操作函数的指针。
- 对于大多数文件,v节点还包含了该文件的i节点(inode)
下图显示了一个进程对应的三张表之间的关系。该进程有两个不同的打开文件:一个文件从标准输入打开(fd为0),另一个从标准输出打开(fd为1)
现在讨论两个独立的进程各自打开同一个文件的情况。假设进程A在文件描述符3上打开这个文件,进程B在文件描述符2上打开这个文件。则有下图所示的关系
进程A和B都有自己的文件表项,这是因为这样可以使每个进程都有它自己的对该文件的当前偏移量。
现在再对前面的一些操作做进一步说明:
- 进程每次
write
之后,文件表项中当前文件偏移量即增加所写入的字节数。若这导致当前文件偏移量超过当前文件长度,则修改i节点的当前文件长度,设为当前文件偏移量 - 用
O_APPEND
标志打开一个文件,则相应标志也设置到文件表项的文件状态标志中。每次对具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先被置为i节点中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处 - 若用
lseek
定位到文件当前的尾端,则文件表项的当前文件偏移量设置为i节点中的当前文件长度(注意,这与用O_APPEND
标志打开文件是不同的,详见原子操作) lseek
函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作
对齐
注意:- 可能有多个文件描述符指向同一文件表项,请见dup函数和
- 文件描述符标志和文件状态标志是不同的,前者针对单个进程的单个文件描述符,后者则应用于所有持有该文件的任何进程的所有描述符
原子操作
当一个进程要将数据追加到文件尾端时,早期UNIX不支持O_APPEND
标志,所以需要
1 | if (lseek(fd, OL, 2) < 0) /*① position to EOF*/ |
但是对于多个进程,如进程A和B同时打开同一个文件,每个进程有自己的文件表项,但是它们共享一个v节点表项。假设A进行了步骤①,文件当前偏移量被设置为1500,此时内核切换进程。进程B进行步骤①也将当前偏移量设置为1500,然后进行步骤②写入100字节,此时文件当前偏移量为1600。然后内核又切换到进程A,A进行步骤②,但是A是在其当前文件偏移量1500处写入数据,这样就覆盖了进程B写入的数据。
究其原因就是步骤①和②是两个函数调用,不是原子操作。UNIX系统的解决方案就是提供了一种原子操作的方法,即设置O_APPEND
标志,使得内核在每次写操作前都会将当前偏移量设置到文件末尾,这样在调用write
之前就不需要调用lseek
了。
此外还有原子定位读写操作:
1 |
|
参数:
fd
:打开的文件描述符buf
:读出数据存放的缓冲区/写到文件的数据的缓冲区nbytes
:期望读出/写入文件的字节数offset
:指定的文件偏移量,从此值处开始读/写
注意:
pread
和pwrite
不更新当前文件偏移量
dup和dup2函数
dup
和dup2
函数用于复制一个现有的文件描述符
1 |
|
参数:
fd
:待复制的文件描述符fd2
:由用户指定的新文件描述符
对于dup
,返回的新文件描述符一定是当前可用的文件描述符中最小的
对于dup2
,可以使用fd2
指定新描述符的值;
- 如果
fd2
已经是被打开的文件描述符且不等于fd
,则先将其关闭,然后再打开(注意关闭再打开是一个原子操作) - 如果
fd2
等于fd
,则直接返回fd2
,而不作任何操作
任何情况下,返回的文件描述符都与参数fd
共享同一个文件表项(因此文件状态标志以及文件偏移量都会共享)。
dup
和dup2
不会复制文件描述符标志
sync、fsync和fdatasync函数
UNIX操作系统在内核中设有缓冲区高速缓存或也高速缓存,大多数磁盘I/O都通过缓冲区进行。当我们向文件写入数据时,内核通常先将数据复制到缓冲区中,然后排入队列,晚些时候再写入磁盘。这种方式称为延迟写(delayed write
)。
- 当内核需要重用缓冲区来存放其他数据时,它会把所有延迟写的数据块写入磁盘
- 用户也可以调用下列函数来显式的将延迟写的数据块写入磁盘
1 |
|
参数:
fd
:指定的打开的文件描述符
区别:
sync
:将所有修改过的块缓冲区排入写队列,然后返回,它并不等待时机写磁盘结束。(update
系统守护进程会周期性调用sync
函数;命令sync
也会调用sync
函数)fsync
:只对由fd指定的单个文件起作用,等待写磁盘操作结束才返回fdatasync
:类似fsync
,但是它只影响文件的数据部分。fsync
还会同步更新文件的属性
fcntl函数
fcntl
函数可以改变已打开文件的属性
1 |
|
参数:
fd
:已打开文件的描述符cmd
:F_DUPFD
:复制文件描述符fd。新文件描述符作为函数值返回。返回值是尚未打开的文件描述符中大于或等于arg的最小值。新文件描述符与fd共享同一个文件表项,但是新描述符有自己的一套文件描述符标志,其中FD_CLOEXEC
文件描述符标志不会被设置。F_DUPFD_CLOEXEC
:类似FD_CLOEXEC
,只不过,无论原文件描述符是否设置FD_CLOEXEC
,新文件描述符的FD_CLOEXEC
都会被设置。F_GETFD
:获取文件描述符标志。函数返回文件描述符fd的文件描述符标志。F_SETFD
:设置fd的文件描述符标志为arg,也可以用于清除文件描述符标志(如arg为~FD_CLOEXEC
),注意要先F_GETFD
,然后在返回值的基础上设置新标志值,不能直接调用F_SETFD
设置,这样会清除之前设置的其他标志F_GETFL
:获取文件描述符的状态标志。特别的,如何要获取文件访问模式标志,则需要将函数返回值与上O_ACCMODE
,其余状态标志则不需要1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24int fd_flags = fcntl(fd, F_GETFL);
if (-1 == fd_flags)
{
// 错误处理
}
else
{
// 检查访问模式标志
int acc_mode = fd_flags & O_ACCMODE;
if (O_RDONLY == acc_mode)
{
}
else if (O_WRONLY == acc_mode)
{
}
// 检查其他标志
if (fd_flags & O_NONBLOCK)
{
}
if (fd_flags & O_APPEND)
{
}
}F_SETFL
:设置fd的文件状态标志为arg。不允许改变文件的访问模式标志,可以更改的标志是:O_APPEND
、O_NONBLOCK
、O_SYNC
、O_DSYNC
、O_RSYNC
、F_ASYNC
、O_ASYNC
。类似F_SETFD
,要在F_GETFL
返回值基础上设置新标志值。1
2
3
4
5
6
7
8
9
10
11
12
13
14int flags = fcntl(fd, F_GETFL, 0); // 获取当前的标志
if (flags == -1)
{
// 错误处理
}
else
{
flags |= O_NONBLOCK; // 添加非阻塞标志
flags |= O_APPEND; // 添加追加模式标志
if (fcntl(fd, F_SETFL, flags) == -1)
{
// 错误处理
}
}F_GETOWN
:获取当前接收SIGIO
和SIGURG
信号的进程ID或者进程组ID,返回正数则为进程ID,负数则其绝对值为进程组IDF_SETOWN
:设置当前接收SIGIO
和SIGURG
信号的进程ID或者进程组ID为arg。若arg是正值,则设定进程ID;若arg是个负值,则设定进程组IDF_GETLK
、F_SETLK
、F_SETLKW
:获取/设置文件记录锁
arg
:依赖于具体命令
ioctl函数
ioctl
函数用于设备特定的输入/输出操作,它提供了一种操作设备文件(通常是设备驱动程序所代表的文件)的方法,这个操作无法使用上述系统调用(如read
、write
、open
、close
等)实现。
1 |
|
参数:
fd
:已打开文件的描述符request
:特定操作的编号,这个编号定义了ioctl
要执行的具体任务。这些任务可以是从设备读取配置、向设备发送命令,或是执行设备的启动、停止、重置等操作。...
:取决于请求码,通常是指向数据的指针或值
/dev/fd
/dev/fd
是一个目录,该目录下是名为0、1、2等的文件。打开文件/dev/fd/n
等效于复制描述符(假定描述符n是打开的)
对于fd=open("/dev/fd/0", mode)
- fd和文件描述符0共享同一个文件表项
- 大多数系统忽略
mode
参数,即使用已经打开的文件描述符的访问模式;另一些系统则要求mode
必须是已经打开的文件描述符访问模式的子集1
2// 若文件描述符0先前以只读打开,那么即使下述调用成功,我们也不能对fd进行写操作
fd = open("/dev/fd/0", ORDWR); - 也可以将
/dev/fd/n
作为参数调用creat
,这与调用open
时用O_CREAT
作为参数效果相同
Paw5zx注:
Linux系统中/dev/fd
是个例外,它把文件描述符映射为指向底层物理文件的符号链接。例如当打开/dev/fd/0
时,实际上是在尝试以新的模式直接打开与标准输入关联的文件。因此,即便原始的文件描述符(比如标准输入)是以某种特定的模式打开的(比如只读),Linux中的open("/dev/fd/0", ORDWR)
允许你尝试以不同的模式重新打开它。
此外,由于上述原因,当将/dev/fd/n
作为参数传入creat
时,对应的文件会被截断,从而丢失所有已存在的数据。
参考文章
- 标题: UNIX环境高级编程(三)——文件I/O
- 作者: paw5zx
- 创建于 : 2025-01-09 00:23:24
- 更新于 : 2025-02-09 09:50:22
- 链接: https://paw5zx.github.io/APUE-note-3/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。