目录
1、简介
2、文件描述符的本质
3、文件IO操作
3.1 open
3.2 close
3.3 read
3.4 write
3.5 lseek
4、文件IO与标准IO的区别
5、IO的效率问题
关键补充:进程的内存空间布局
代码区
常量区
全局区
.bss段
.data段
堆区
栈区
6、文件共享
7、原子操作
8、程序中的重定向
9、同步
10、很牛很牛的函数
10.1 fcntl
10.2 ioctl
11、/dev/fd/ 目录
1、简介
在标准IO中,我们是通过 FILE 结构体进行读写操作,FILE 结构体贯穿始终;
而在文件IO中,文件描述符(fd,file descriptor)为贯穿始终的类型
文件IO操作:
- oepn
- close
- read
- write
- lseek
标准IO的一切操作,底层都是基于文件IO操作实现的(封装)
2、文件描述符的本质
本质是数组下标!
先来看看标准IO中的 FILE
这个 FILE 对象(结构体)表征了一个通过标准 IO 打开的文件
我们在这里强调:标准IO提供直接获取指向 FILE 结构体指针的接口!
而在文件IO中不同。虽然文件IO也会有一个结构体
与标准IO不同,文件IO的接口不会给你指向所产生结构体的指针,只会将该结构体指针存在某个数组的某个索引位置中,这个索引就是文件描述符fd
我们之前说过,标准IO的fopen基于文件IO中的open。因此标准IO所得到的流——FILE结构体中,很容易想到里面应该有个 int 类型的 fd,用于表征文件IO所产生的结构体
因为标准IO封装了系统调用IO,默认情况下有这样的一些关系:
- 流 stdin 指向一个结构体,结构体内部的 fd 值为 0,索引到某个数组的位置 0 里面有个指针,该指针指向系统 IO 所产生的结构体
- 流 stdout 指向一个结构体,其内部的 fd 值为 1,索引到某个数组的位置 1 里面有个指针,该指针指向系统 IO 所产生的结构体
- 流 stderr 指向一个结构体,其内部的 fd 值为 2,索引到某个数组的位置 2 里面有个指针,该指针指向系统 IO 所产生的结构体
几个注意点:
- 文件描述符本质上是整型数0、1、2、3、...,表示数组下标
- 文件描述符优先使用当前可用范围内最小的一个。比如已用的 fd 为0、1、2,则新 open 一个文件的 fd 应该为3(open产生结构体,指向该结构体的指针存放在某个数组的下标3处)
- 存放 open 所产生结构体的指针的数组位于进程的进程空间。每个进程都有这样的数组。产生的结构体也位于进程空间,不同进程空间中的结构体是不一样滴
- 存放 open 所产生结构体的指针的数组大小可通过 ulimit -a 查看,即为最大的打开文件数量
思考:
问题1:利用 open 打开一个文件两次,会怎么样?
第一次调用 open,产生一个结构体用来表征某个具有唯一inode的文件
第二次调用 open,又产生另一个结构体用来表征该文件
因为不同的结构体指针存在于数组的不同索引处
因此出现不同 fd 值代表同一个文件的情况是有可能出现的
问题2:如果数组不同索引处的指针指向了相同结构体,会怎么样
试想:如果我们对某个 fd 调用 close,如果没有特殊的机制,可以遇见的是系统会释放结构体所在内存空间。这样的话,数组另一个 fd 下标内的指针就指向了一个已经被释放的空间!即出现了悬空指针的情况
因此,文件IO产生的结构体中有一个成员用于记录引用计数,用来记录有多少指针指向它。当结构体中的引用计数变为 0,其所在空间才会被释放,这样就不会出现悬空指针的情况了
3、文件IO操作
3.1 open
使用手册,man 2 open
系统调用 I/O 在 man 手册第二部分
#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);
// 若flags设置了O_CREAT标志,则需要用mode设置权限
功能: 打开(可能创建)文件
- pathname — 待打开的文件或设备的名字
- 返回一个文件描述符。失败则返回 -1,并设置 errno
- flags — 其实就是位图,每一位用 0 或 1 来表示包含/不包含某种属性
再看一下 flags 参数 man 手册中的介绍:
参数标志必须包括以下访问模式之一:O_RDONLY、O_WRONLY 或 O_RDWR。它们分别要求以只读、只写或读/写方式打开文件。
此外,还可以按照二进制或的方式,将零或多个文件创建标志和文件状态标志包括进参数标志。
文件创建标志包括 O_CLOEXEC、O_CREAT、O_DIRECTORY、O_EXCL、O_NOCTTY、O_NOFOLLOW、O_TMPFILE 和 O_TRUNC。
文件状态标志包括上述标志外的所有标志。
这两组标志的区别在于:文件创建标志会影响打开操作本身的语义,而文件状态标志则会影响后续 I/O 操作的语义。
必须部分 | 可选部分 |
---|---|
O_RDONLY:以只读方式打开 | O_CREAT:按照参数 mode 给决定的权限设置创建文件 |
O_WRONLY:以只写方式打开 | O_EXCL:与 O_CREAT 一起使用,确保创建出文件,避免两个程序同时创建同一个文件,如文件存在则 open 调用失败 |
O_RDWR:以读写方式打开 | O_APPEND:把写入数据追加在文件的末尾 |
O_TRUNC:把文件长度设置为0,丢弃原有内容 | |
O_NONBLOCK:以非阻塞模式打开文件 |
(上面只介绍了常用的标志)
- mode — 主要是搭配 O_CREAT 使用,这个参数规定了用户、用户所在组和其他人对所创建文件的文件操作权限
关于权限:
权限公式:mode & ~umask
LINUX 默认的 umask 值:八进制数 0022 (注意第一个0只是说明这个数是八进制,是个符号)
则:
LINUX 默认创建文件的实际权限:0666 & ~umask = 0666 & ~0022 = 0644
LINUX 默认创建文件夹的实际权限:0777 & ~umask = 0777 & ~0022 = 0755
使用 open 系统调用创建文件的实际权限:mode & ~umask
验证如下:
open 函数引发的思考:
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
看看这个形式,像不是像 C++ 中的函数重载?
但是 LINUX C 没有函数重载!
那是怎么实现的呢?答:变参函数
变参数函数的原型声明为:
Vtype VAFunction(type arg1, type arg2, ...);
变参函数可以接受不同类型的参数,也可以接受不同个数的参数。
参数可以分为两部分:个数确定的固定参数和个数可变的可选参数。函数至少需要一个固定参数,固定参数的声明和普通函数一样;可选参数由于个数不确定,声明时用 ... 表示。固定参数和可选参数共同构成一个函数的参数列表
以 printf 为例,它就是一个变参函数:
int printf(const char *fmt, ...){
int i;
int len;
va_list args; /* va_list 即 char * */
va_start(args, fmt);
/* 内部使用了 va_arg() */
len = vsprintf(g_PCOutBuf,fmt,args);
va_end(args);
for (i = 0; i < strlen(g_PCOutBuf); i++)
{
putc(g_PCOutBuf[i]);
}
return len;
}
若是在 C++ 下,区分重载和变参函数,只需要调用时多传入几个参数就行。
3.2 close
#include <unistd.h>
int close(int fd);
功能:关闭一个文件描述符(使数组某个下标重新可被使用)
- fd — 待关闭的文件描述符
- 成功返回 0 ;出错返回 -1,并设置 errno
3.3 read
man 2 read
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
// read() attempts to read up to count bytes from file descriptor fd into the buffer starting at buf.
功能:读一个文件描述符
- fd — 从哪个文件描述符读
- buf — 将读到的字符放到哪个位置去
- count — 限制本次读取最多读多少个字符(字节,一个字符等于一个字节)
- 成功则返回实际读取到的字符个数;失败返回 -1,并设置 errno
一个注意点:调用 read 后,文件位置指针会前进读取到的字符个数步
3.4 write
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
// write() writes up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.
功能:向一个文件描述符写内容
- fd — 向哪个文件描述符写
- buf — 所写内容的来源,将位于 buf 的字符写进 fd
- count — 限制本次写入最多写多少个字符
- 成功则返回实际写入的字符个数;失败返回 -1,并设置 errno
这里引入一个拓展点, 一个写代码时候的关键技术:坚持写够 len 字节
// 假如我们希望保证能够写入 len 个字节
// 从 buf 中获取字符,写入 fd 所表征的文件或设备
pos = 0;
while(len > 0)
{
ret = write(fd, buf + pos, len); // 本次已写入的字节数
if (ret < 0)
{
perror("write()");
exit(1);
}
pos += ret; // 更新下次继续写,所需要的数据源位置
len -= ret; // 更新剩余的待写入字节数
}
3.5 lseek
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
功能:设置文件位置指针
- fd — 设置 fd 所表征的文件
- whence && offset — 将文件位置设置在:从 whence 给定的位置加上偏移 offset 的位置
- 返回设置后的文件位置指针所指位置(从文件起始位置开始,并以字节为单位度量,相对起始位置的偏移)
whence 取值:
字段 | 含义 |
---|---|
SEEK_SET | 文件首个字符位置 |
SEEK_END | 文件最后一个字符的下一个位置 也可以理解为:将整个文件中的字符看成存放在数组里的字符,则 SEEK_END 代表索引为文件大小的位置 |
SEEK_CUR | 文件位置指针的当前位置 |
代码示例:文件位置指针对读取的影响
4、文件IO与标准IO的区别
主要区别就是:标准IO封装了文件IO,且引入了缓冲机制
- 标准IO写入Buffer,在适当的时候才会调用系统IO将Buffer的内容实际写入磁盘
- 标准IO从磁盘读到Cache,在适当的时候才会调用系统IO将Cach的内容实际读取到进程空间
响应速度:文件IO更快,调用一次文件IO,就会从用户态立即陷入内核态写入磁盘
吞吐量:标准IO更大,每次系统调用IO可能作用于多个字节
文件IO不使用缓存,每次调用读写函数时,从用户态切换到内核态,对磁盘上的实际文件进行读写操作,因此响应速度快,坏处是频繁的系统调用会增加系统开销(用户态和内核态来回切换),例如调用write写入一个字符时,磁盘上的文件中就多了一个字符
标准IO使用缓存,未刷新缓冲前的多次读写时,实际上操作的是内存上的缓冲区,与磁盘上的实际文件无关,直到刷新缓冲时,才调用一次文件IO,从用户态切换到内核态,对磁盘上的实际文件进行操作
优先选用标准IO
举例:办理合同盖章业务
小F拿到一份合同后,可能立即跑去盖章。这样跑一趟只能盖一个章
小Y拿到一份合同后,先把这个合同攒着,等合同攒到一定数量了,才拿着一沓合同拿去盖章。这样跑一趟就能一次性盖多个章了
显然优先选用小Y的方法,更高效
总结一下,我们在操作 FILE 结构体指针时,使用的是标准 IO;在操作文件描述符时,使用的是系统调用 IO。其实这两种 IO 是可以相互转换的
相互转换:(需要结合文件描述符的本质介绍的那张图)
#include <stdio.h>
int fileno(FILE *stream);
获取并返回一个 FILE 对象中的文件描述符,这样之前是通过标准 IO 操作 FILE 结构体指针,现在可以通过系统调用 IO 操作文件描述符了
#include <stdio.h>
FILE *fdopen(int fd, const char *mode);
将文件描述符与一个 FILE 对象关联并返回,这样之前是通过系统调用 IO 操作文件描述符,现在可以通过标准 IO 操作 FILE 结构体指针了
注意:两种 IO 能够相互转换,但是不能混用!
也就是说对同一个文件,不要混用两种IO,否则容易发生错误
原因:标准 IO 所使用的 FILE 结构体中的文件位置指针(pos)与文件 IO 所采用的结构体中的文件位置指针基本上不一样
FILE *fp;
// 连续写入两个字符
fputc(fp); // 导致 pos++
fputc(fp); // 导致 pos++但是,文件 IO 所关注的结构体中的 pos 并未加2;只有刷新缓冲区时,该 pos 才会加 2
代码示例:
putchar 是标准 IO,write 是文件 IO,打印结果如下:
怎么回事呢?
可以通过 strace 追踪程序运行过程,能够显示所有由用户空间程序发出的系统调用
strace ./a.out
解析:遇到文件 IO 则立即调用 write 写入磁盘,遇到标准 IO,则需要等待缓冲区刷新的时机,这里是遇到了换行符进行了刷新,将 3 个 a 字符和换行符输出到终端上。
如果想按照代码编写顺序打印,可以手动强制刷新
5、IO的效率问题
我们希望测试: 如下实现拷贝文件的代码,BUFSIZE 设置为多少,程序运行效率最高
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#define BUFSIZE 1024 // 缓冲区大小
int main(int argc, char **argv) {
// 源文件和目标文件的文件描述符
int sfd, dfd;
// 读写缓冲
char buf[BUFSIZE];
// len:读文件的返回字节数
// ret:写文件的返回字节数
// pos:写文件的当前位置
int len, ret, pos;
if(argc < 3) {
fprintf(stderr, "Usage...\n");
exit(1);
}
// 以只读方式打开文件,打开文件失败
if((sfd = open(argv[1], O_RDONLY)) < 0) {
perror("open()");
exit(1);
}
// 以只读方式打开文件,有则清空,无则创建
// 打开文件失败
if((dfd = open(argv[2], O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
close(sfd);
perror("open()");
exit(1);
}
while(1) {
if((len = read(sfd, buf, BUFSIZE)) < 0) {
perror("read()");
break;
}
// 读完文件
if(len == 0)
break;
pos = 0;
// 防止读到的字节没有完全写入文件
// 保证读多少,就写多少
while(len > 0) {
if((ret = write(dfd, buf + pos, len)) < 0) {
perror("write()");
exit(1);
}
pos += ret;
len -= ret;
}
}
// 关闭文件描述符
close(dfd);
close(sfd);
exit(0);
}
我们需要如下的测试进程运行时间的方法
可以在命令前加上指令 time,从而获得进程运行所需要的时间
字段 | 含义 |
real | 总时间=用户层消耗时间、内核层消耗时间及调度等待的时间之和 |
user | 用户层面消耗的时间 |
sys | 内核层面消耗的时间 |
对于使用程序的用户,关注的是 real;而对于程序员,关注的是 user + sys,因为操作系统的调度策略不是由程序员的意志所决定的
关键补充:进程的内存空间布局
内存地址是一个编号,通常由16进制表示,它代表一个内存空间。在计算机中存储器的容量是以字节为基本单位的,也就是说一个内存地址代表一个字节(8bit)的存储空间,即按字节寻址。
假设一个int类型的变量x占用4个字节,则会占用4个连续的内存空间,x的内存地址则为第一个内存空间的地址。
对于32位操作系统,内存地址长度为32位,则可以表示2的32次方个内存空间(可寻址空间),即4GB;
计算:2^32 * 1B = 2^32B = 2^22 KB = 2^12 MB = 2^2 GB = 4GB
对于64位操作系统,内存地址长度为64位,则可以表示2的64次方个内存空间(16777216TB);但实际上,主板和CPU的限制导致一般的电脑所支持的内存最大只有16GB而已。
C程序(例如a.out)运行时会被加载入内存中,每个进程所占用的物理内存可抽象为该进程的地址空间。那么这块空间由哪几部分构成呢?
代码区
存放代码的
常量区
- 存放生命周期等同于程序运行周期的只读量
- 字符串、数字等常量
全局区
存放生命周期等同于程序运行周期的可读写变量
.bss段
- 未被人为初始化的全局变量及静态变量存放在.bss段
- .bss段不占用可执行文件空间,其内容由操作系统初始化
.data段
- 已人为初始化的全局变量及静态变量存放在.data段
- .data段占用可执行文件空间,其内容由程序初始化
堆区
- 堆区变量由程序员分配内存和释放
- 堆区按内存地址由低到高方向生长,其大小由系统内存/虚拟内存上限决定,速度较慢,但自由性大,可用空间大
栈区
- 用于存放生命周期被限定在块中的局部变量
- 栈区按内存地址由高到低方向生长,其最大大小由编译时确定,速度快,但自由性差,最大空间不大
6、文件共享
含义:多个任务共同操作一个文件或者协同完成任务
一个应用:如何写程序删除一个文件的第十行?
一种朴素的想法
如图所示,从第 11 行开始,不断读取下一行的内容,写入上一行
别忘了调用 read/write 后,文件位置指针会随之前进
上一步完毕后,会导致多出一行,再利用下述函数将文件的最后一行删除即可
#include <unistd.h>
#include <sys/types.h>
int truncate(const char *path, off_t length);
int ftruncate(int fd, off_t length);
// The truncate() and ftruncate() functions cause the regular file
// named by path or referenced by fd to be truncated to a size of precisely length bytes
path — 用于指定文件路径
fd — 文件描述符,用于表征某已打开的文件
length — 将文件截断至 length 字节长度
7、原子操作
原子:不可分割的最小单位
原子操作:不可分割的操作
原子操作的作用:解决竞争和冲突
例如:tmpname 存在并发问题,就是因为操作不是原子的
8、程序中的重定向
重定向:本来操作的是文件A,重定向后操作的是文件B
本质:文件描述符的复制(数组中所存内容的复制)
回顾一下,文件描述符的本质是数组下标,数组不同下标位置内所存放指针指向的结构体代表了不同的用来表征文件的结构体。
#include <unistd.h>
int dup(int oldfd); // 将oldfd索引位置中的内容复制到最小可用索引位置
int dup2(int oldfd, int newfd); // 将oldfd索引位置中的内容复制到newfd位置处
几个关键点:
- 复制的是数组 fd 索引中的内容:即指向结构体的指针
- dup2 分两种情况:如果 newfd 已经打开,则先将其关闭(可以理解为先把 newfd 指向位置中的内容清空),再将 oldfd 中所存的结构体指针拷贝过去;如果 newfd 等于 oldfd,则什么也不做
- 调用成功返回复制到的目标位置索引(新文件描述符);调用失败返回 -1,并设置 errno
- 一个编程的好习惯:假装自己没有在 main 函数编程。这样,假如重定向了默认打开的文件描述符,最好在退出函数的时候恢复到重定向之前的状态
例:将 puts 重定向到一个文件中
方法一:朴素方法
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
#define FNAME "/tmp/out"
int main(void) {
int fd;
close(1); // 关闭stdout,使描述符1空闲
if((fd = open(FNAME, O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) { // 此时文件描述符1(数组下标1中所存的结构体指针)指代的是打开的文件
perror("open()");
exit(1);
}
puts("Hello World"); // puts将输出写到数组下标为1中所存的结构体指针所指代的文件
exit(0);
}
方法二:利用 dup
#define FNAME "/tmp/out"
int main(void) {
int fd;
if((fd = open(FNAME, O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
perror("open()");
exit(1);
}
// 关闭stdout
close(1);
// 复制fd,让其占据1的描述符
dup(fd);
// 关闭fd
close(fd);
puts("Hello World");
exit(0);
}
方法三:利用 dup2
#define FNAME "/tmp/out"
int main(void) {
int fd;
if((fd = open(FNAME, O_WRONLY|O_CREAT|O_TRUNC, 0600)) < 0) {
perror("open()");
exit(1);
}
// 如果fd = 1,则什么也不做,返回fd
// 如果fd != 1,则关闭1指向的结构体,再打开1,指向fd的结构体,返回1
dup2(fd, 1);
if(fd != 1) {
close(fd);
} // 如果有两个文件描述符位置所存的指针指向同一个结构体,则close一个
puts("Hello World");
exit(0);
}
9、同步
暂时不详细解释
man 2 sync
man 2 fsync
man 2 fdatasync
补充:
- 文件的数据:文件中的有效内容
- 文件的亚数据:文件的最后修改时间、属性等等
10、很牛很牛的函数
10.1 fcntl
功能:操纵文件描述符
#include <unistd.h>
#include <fcntl.h>
int fcntl(int fd, int cmd, ... /* arg */ );
返回值:若成功,则依赖于 cmd,若失败,则返回 -1
函数功能(部分):
- 复制一个已有的描述符(
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,代表对文件描述符不同的操纵选项。文件描述符相关操作函数几乎都对该函数进行了一定程度的封装
10.2 ioctl
功能:控制设备
将所有设备看成文件,会很方便,方便了绝大多数程序员,但是一切皆文件的设计理念无法对设备提供更精细的控制
ioctl 是和设备开发相关的函数,不符合“一切皆文件”的设计思路,使用 ioctl 控制不同设备,使用方式会有较大差异
11、/dev/fd/ 目录
是一个虚目录,显示的是当前进程的文件描述符信息
对于每个进程,内核都提供有一个特殊的虚拟目录 /dev/fd/,该目录中包含 "/dev/fd/n" 形式的文件名,其中 n 是与进程中打开文件描述符相对应的编号。也就是说,/dev/fd/0 就对应于进程的标志输入
打开 /dev/fd/ 目录中的一个文件等同于复制对应的文件描述符,所以下面两行代码是等价的:
fd = open("/dev/fd/1", O_WRONLY);
fd = dup(1);