目录
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/ 目录
在标准IO中,我们是通过 FILE 结构体进行读写操作,FILE 结构体贯穿始终;
而在文件IO中,文件描述符(fd,file descriptor)为贯穿始终的类型
文件IO操作:
标准IO的一切操作,底层都是基于文件IO操作实现的(封装)
本质是数组下标!
先来看看标准IO中的 FILE
这个 FILE 对象(结构体)表征了一个通过标准 IO 打开的文件
我们在这里强调:标准IO提供直接获取指向 FILE 结构体指针的接口!
而在文件IO中不同。虽然文件IO也会有一个结构体
与标准IO不同,文件IO的接口不会给你指向所产生结构体的指针,只会将该结构体指针存在某个数组的某个索引位置中,这个索引就是文件描述符fd
我们之前说过,标准IO的fopen基于文件IO中的open。因此标准IO所得到的流——FILE结构体中,很容易想到里面应该有个 int 类型的 fd,用于表征文件IO所产生的结构体
因为标准IO封装了系统调用IO,默认情况下有这样的一些关系:
几个注意点:
思考:
问题1:利用 open 打开一个文件两次,会怎么样?
第一次调用 open,产生一个结构体用来表征某个具有唯一inode的文件
第二次调用 open,又产生另一个结构体用来表征该文件
因为不同的结构体指针存在于数组的不同索引处
因此出现不同 fd 值代表同一个文件的情况是有可能出现的
问题2:如果数组不同索引处的指针指向了相同结构体,会怎么样
试想:如果我们对某个 fd 调用 close,如果没有特殊的机制,可以遇见的是系统会释放结构体所在内存空间。这样的话,数组另一个 fd 下标内的指针就指向了一个已经被释放的空间!即出现了悬空指针的情况
因此,文件IO产生的结构体中有一个成员用于记录引用计数,用来记录有多少指针指向它。当结构体中的引用计数变为 0,其所在空间才会被释放,这样就不会出现悬空指针的情况了
使用手册,man 2 open
系统调用 I/O 在 man 手册第二部分
#include
#include
#include
int open(const char *pathname, int flags);
int open(const char *pathname, int flags, mode_t mode);
// 若flags设置了O_CREAT标志,则需要用mode设置权限
功能: 打开(可能创建)文件
再看一下 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 & ~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++ 下,区分重载和变参函数,只需要调用时多传入几个参数就行。
#include
int close(int fd);
功能:关闭一个文件描述符(使数组某个下标重新可被使用)
man 2 read
#include
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.
功能:读一个文件描述符
一个注意点:调用 read 后,文件位置指针会前进读取到的字符个数步
#include
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.
功能:向一个文件描述符写内容
这里引入一个拓展点, 一个写代码时候的关键技术:坚持写够 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; // 更新剩余的待写入字节数
}
#include
#include
off_t lseek(int fd, off_t offset, int whence);
功能:设置文件位置指针
whence 取值:
字段 | 含义 |
---|---|
SEEK_SET | 文件首个字符位置 |
SEEK_END | 文件最后一个字符的下一个位置 也可以理解为:将整个文件中的字符看成存放在数组里的字符,则 SEEK_END 代表索引为文件大小的位置 |
SEEK_CUR | 文件位置指针的当前位置 |
代码示例:文件位置指针对读取的影响
主要区别就是:标准IO封装了文件IO,且引入了缓冲机制
响应速度:文件IO更快,调用一次文件IO,就会从用户态立即陷入内核态写入磁盘
吞吐量:标准IO更大,每次系统调用IO可能作用于多个字节
文件IO不使用缓存,每次调用读写函数时,从用户态切换到内核态,对磁盘上的实际文件进行读写操作,因此响应速度快,坏处是频繁的系统调用会增加系统开销(用户态和内核态来回切换),例如调用write写入一个字符时,磁盘上的文件中就多了一个字符
标准IO使用缓存,未刷新缓冲前的多次读写时,实际上操作的是内存上的缓冲区,与磁盘上的实际文件无关,直到刷新缓冲时,才调用一次文件IO,从用户态切换到内核态,对磁盘上的实际文件进行操作
优先选用标准IO
举例:办理合同盖章业务
小F拿到一份合同后,可能立即跑去盖章。这样跑一趟只能盖一个章
小Y拿到一份合同后,先把这个合同攒着,等合同攒到一定数量了,才拿着一沓合同拿去盖章。这样跑一趟就能一次性盖多个章了
显然优先选用小Y的方法,更高效
总结一下,我们在操作 FILE 结构体指针时,使用的是标准 IO;在操作文件描述符时,使用的是系统调用 IO。其实这两种 IO 是可以相互转换的
相互转换:(需要结合文件描述符的本质介绍的那张图)
#include
int fileno(FILE *stream);
获取并返回一个 FILE 对象中的文件描述符,这样之前是通过标准 IO 操作 FILE 结构体指针,现在可以通过系统调用 IO 操作文件描述符了
#include
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 字符和换行符输出到终端上。
如果想按照代码编写顺序打印,可以手动强制刷新
我们希望测试: 如下实现拷贝文件的代码,BUFSIZE 设置为多少,程序运行效率最高
#include
#include
#include
#include
#include
#include
#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)运行时会被加载入内存中,每个进程所占用的物理内存可抽象为该进程的地址空间。那么这块空间由哪几部分构成呢?
存放代码的
存放生命周期等同于程序运行周期的可读写变量
含义:多个任务共同操作一个文件或者协同完成任务
一个应用:如何写程序删除一个文件的第十行?
一种朴素的想法
如图所示,从第 11 行开始,不断读取下一行的内容,写入上一行
别忘了调用 read/write 后,文件位置指针会随之前进
上一步完毕后,会导致多出一行,再利用下述函数将文件的最后一行删除即可
#include
#include
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 字节长度
原子:不可分割的最小单位
原子操作:不可分割的操作
原子操作的作用:解决竞争和冲突
例如:tmpname 存在并发问题,就是因为操作不是原子的
重定向:本来操作的是文件A,重定向后操作的是文件B
本质:文件描述符的复制(数组中所存内容的复制)
回顾一下,文件描述符的本质是数组下标,数组不同下标位置内所存放指针指向的结构体代表了不同的用来表征文件的结构体。
#include
int dup(int oldfd); // 将oldfd索引位置中的内容复制到最小可用索引位置
int dup2(int oldfd, int newfd); // 将oldfd索引位置中的内容复制到newfd位置处
几个关键点:
例:将 puts 重定向到一个文件中
方法一:朴素方法
#include
#include
#include
#include
#include
#include
#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);
}
暂时不详细解释
man 2 sync
man 2 fsync
man 2 fdatasync
补充:
功能:操纵文件描述符
#include
#include
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
)cmd=F_GETOWN或F_SETOWN
)cmd=F_GETLK、F_SETLK或F_SETLKW
)不同的 cmd,代表对文件描述符不同的操纵选项。文件描述符相关操作函数几乎都对该函数进行了一定程度的封装
功能:控制设备
将所有设备看成文件,会很方便,方便了绝大多数程序员,但是一切皆文件的设计理念无法对设备提供更精细的控制
ioctl 是和设备开发相关的函数,不符合“一切皆文件”的设计思路,使用 ioctl 控制不同设备,使用方式会有较大差异
是一个虚目录,显示的是当前进程的文件描述符信息
对于每个进程,内核都提供有一个特殊的虚拟目录 /dev/fd/,该目录中包含 "/dev/fd/n" 形式的文件名,其中 n 是与进程中打开文件描述符相对应的编号。也就是说,/dev/fd/0 就对应于进程的标志输入
打开 /dev/fd/ 目录中的一个文件等同于复制对应的文件描述符,所以下面两行代码是等价的:
fd = open("/dev/fd/1", O_WRONLY);
fd = dup(1);