3 系统调用
该节对应APUE的第三章------文件IO
3.1 简介
UNIX系统的大多数文件IO只需用到5个函数:
open
close
read
write
lseek
3.2 文件描述符
3.2.1 FILE结构体
查看stdio.h头文件中,有FILE结构体的定义:
cs
//stdio.h
typedef struct _iobuf {
char* _ptr; //文件输入的下一个位置
int _cnt; //当前缓冲区的相对位置
char* _base; //文件初始位置
int _flag; //文件标志
int _file; //文件有效性
int _charbuf; //缓冲区是否可读取
int _bufsiz; //缓冲区字节数
char* _tmpfname; //临时文件名
} FILE;
其中_file就是文件描述符。
3.2.2 文件描述符
文件描述符(fd,file descriptor)是文件IO(也系统IO)中贯穿始终的类型。如下图所示:
1 当某一个进程执行系统调用open函数,会创建一个结构体,该结构体类似于FILE结构体,其中最基本的成员有一个指针pos,用于指向inode文件的某一个位置;
2 同时,该进程会维护一个数组(文件描述符表),该数组存储上述结构体的地址,而数组下标就是文件描述符fd,即文件描述符的本质就是一个整型数;
该数组默认大小为1024,即可以打开的最大文件数量为1024,但可以设置ulimit来更改数组大小;注意该数组和对应产生的结构体只存在于这个进程空间内,而不是进程间共享;
当调用open函数时,系统会自动打开三个流stdin,stdout和stderr,这三个流分别占据该数组的0,1,2号位置;
结构体FILE中的成员_file就是整型数组下标fd,即文件描述符
每打开一个新文件,则占用一个数组空间,而且是空闲的最小的数组下标。即文件描述符优先使用当前可用范围内最小的。同一个文件可以被多次打开,但是每打开一次都需要一个新的文件描述符和新的结构体,例如图中的结构体1和结构体2,指向了同一个inode;
3 执行系统调用close时,就将对应fd下标的数组空间清除掉,并清除该地址指向的结构体;
4 结构体中有一个成员用于记录引用计数,例如图中,将5号位置的0x006地址复制一份存储在6号位置,此时有两个指针指向了同一个结构体3,此时结构体3的引用计数为2,当5号指针free时,结构体3的引用计数减1为1,不为0,则不会释放掉,否则6号位置的指针将成为野指针;
3.3 open和close
3.3.1 文件权限
① rwx
Linux下一切皆文件,不同的用户对文件拥有不同的权限。
文件具有三种权限:
cs
rwx 可读可写可执行,转换为数字就是421
|---|------------|-------------------------------------------------------------|
| | 针对文件 | 针对目录 |
| r | 是否可以查看文件内容 | 是否能够列出ls目录内容 |
| w | 是否可以编辑文件内容 | 是否能够创建、删除、复制、移动文档 |
| x | 是否能够执行该文件 | 是否可以进入目录和获得目录下文件的列表,要对目录下存在的文件进行读取和修改,必须要能进入目录,所以必须要目录有执行权限 |
② 文件属性
查看当前目录下的所有文件的属性:
命令:ll
或者可以查看单个文件权限:
cs
ll mycpy.c
基本的文件属性格式如下:
cs
类型 权限 链接数 属主 属组 大小 修改日期 文件名
类型和权限:
第1列表示文件的类型:dcb-lsp
d:目录
-:普通文件
l:软链接(类似Windows的快捷方式)
b:块设备文件(例如硬盘、光驱等)
p:管道文件
c:字符设备文件(例如屏幕等串口设备)
s:套接口文件
第2至10列为文件或目录的权限,分为3组:
拥有者权限owner:文件和文件目录的所有者
所属组group:文件和文件目录的所有者所在的组的其他用户
其它用户other:不属于上面的两个的用户
链接数:有多少文件名链接到此节点(i-node);每个文件都会将它的权限与属性记录到文件系统的i-node中,不过我们使用的目录树却是使用文件名来记录,因此每个文件名就会连接到一个i-node,这个属性记录的就是有多少不同的文件名链接到相同的一个i-node号码。
3.3.2 修改文件权限
参考下面文章:linux修改文件权限
3.3.3 open
open用于打开或创建一个文件或者设备。
所在头文件:
cs
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
函数原型1:
cs
int open(const char *pathname, int flags);
将准备打开的文件或是设备的名字作为参数path传给函数,flags用来指定文件访问模式。
open系统调用成功返回一个新的文件描述符,失败返回-1。
其中,flags是由必需文件访问模式和可选模式一起构成的(通过按位或|):
|------------------|------------------------------------------------------------|
| 必需部分 | 可选部分(只列出常用的) |
| O_RDONLY:以只读方式打开 | O_CREAT:按照参数mode给出的访问模式创建文件 |
| O_WRONLY:以只写方式打开 | O_EXCL:与O_CREAT一起使用,确保创建出文件,避免两个程序同时创建同一个文件,如文件存在则open调用失败 |
| O_RDWR:以读写方式打开 | O_APPEND:把写入数据追加在文件的末尾 |
| | O_TRUNC:把文件长度设置为0,丢弃原有内容 |
| | O_NONBLOCK:以非阻塞模式打开文件 |
其中,对于可选部分,又分为文件创建选项和文件状态选项:
文件创建选项:O_CREAT,O_EXCL,O_NOCTTY,O_TRUNC
文件状态选项:除文件创建选项之外的选项
fopen和open的文件访问模式的联系
cs
r -> O_RDONLY // 只读存在的文件
r+ -> O_RDWR // 读写存在的文件
w -> O_WRONLY|O_CREAT|O_TRUNC // 只写,并且有则清空,无则创建
w+ -> O_RDWR|O_CREAT|O_TRUNC // 读写,并且有则清空,无则创建
// ...
函数原型2:
cs
int open(const *path, int flags, mode_t mode);
在第一种调用方式上,加上了第三个参数mode,主要是搭配O_CREAT使用,这个参数规定了属主、同组和其他人对文件的文件操作权限。只列出部分:
|---------|-----------|
| 字段 | 含义 |
| S_IRUSR | 读权限 |
| S_IWUSR | 写权限--文件属主 |
| S_IXUSR | 执行权限 |
注意mode还要和umask计算才能得出最终的权限;
例如:
cs
int fd = open("./file.txt",O_WRONLY | O_CREAT, 0600);
创建一个普通文件,权限为0600,拥有者有读写权限,组用户和其他用户无权限。
补充:变参函数
变参数函数的原型声明为:
cs
type VAFunction(type arg1, type arg2, ...);
变参函数可以接受不同类型的参数,也可以接受不同个数的参数。
参数可以分为两部分:个数确定的固定参数和个数可变的可选参数。函数至少需要一个固定参数,固定参数的声明和普通函数一样;可选参数由于个数不确定,声明时用 ... 表示。固定参数和可选参数共同构成一个函数的参数列表。
以printf为例,它就是一个变参函数:
cs
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;
}
3.3.4 close
close:关闭一个文件描述符
cs
#include <unistd.h>
int close(int fd);
返回 0 表示成功,或者 -1 表示有错误发生,并设值errno;
3.4 read,write和sleek
read所在头文件和函数原型:
cs
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
从与文件描述符fd相关联的文件中读取前count字节的内容,并且写入到数据区buf中
read系统调用返回的是实际读入的字节数,发生错误返回-1
write所在头文件和函数原型:
cs
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
把缓存区buf中的前count字节写入到与文件描述符fd有关的文件中
write系统调用返回的是实际写入到文件中的字节数,发生错误返回-1,注意返回0不是发生错误,而是写入的字节数为0
lseek所在头文件和函数原型:
cs
#include <sys/types.h>
#include <unistd.h>
off_t lseek(int fd, off_t offset, int whence);
lseek设置文件位置为给定的偏移 offset,参数 offset 意味着从给定的 whence 位置查找的字节数。
whence取值:
lseek设置文件位置为给定的偏移 offset,参数 offset 意味着从给定的 whence 位置查找的字节数。
whence取值:
|----------|--------|
| 字段 | 含义 |
| SEEK_SET | 文件开头 |
| SEEK_CUR | 文件当前位置 |
| SEEK_END | 文件末尾 |
代码示例
用系统调用io实现mycpy的功能。
cs
#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);
}
// 以只读方式打开文件,有则清空,无则创建
// 打开文件失败
dfd = open(argv[2], O_WRONLY|O_CREAT|O_TRUNC, 0600
if(dfd < 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);
}
3.5 IO效率
**文件I/O:**即系统调用IO,又称为无缓冲IO,低级磁盘I/O,遵循POSIX相关标准。任何兼容POSIX标准的操作系统上都支持文件I/O。
**标准I/O:**标准I/O是ANSI C建立的一个标准I/O模型,又称为高级磁盘I/O,是一个标准函数包和stdio.h头文件中的定义,具有一定的可移植性。标准I/O库处理很多细节。例如缓存分配,以优化长度执行I/O等。标准的I/O提供了三种类型的缓存(行缓存、全缓存和无缓存)。
Linux 中使用的是GLIBC,它是标准C库的超集。不仅包含ANSI C中定义的函数,还包括POSIX标准中定义的函数。因此,Linux 下既可以使用标准I/O,也可以使用文件I/O。
缓存 是内存上的某一块区域。缓存的一个作用是合并系统调用 ,即将多次的标准IO操作合并为一个系统调用操作。
文件IO不使用缓存 ,每次调用读写函数时,从用户太切换到内核态,对磁盘上的实际文件进行读写操作 ,因此响应速度快,坏处是频繁的系统调用会增加系统开销(用户态和内核态来回切换),例如调用write写入一个字符时,磁盘上的文件就会多一个字符。
标准IO使用缓存,未刷新缓冲前的多次读写时,实际上操作的是内存上的缓冲区 ,与磁盘上的实际文件无关 ,直到刷新缓冲时,才调用一次文件IO,从用户态切换到内核态,对磁盘上的实际文件进行操作。因此标准IO的吞吐量大 ,相应的响应时间比文件IO长 。但是差别不大,优先建议使用标准IO来操作文件。
两种IO可以相互转化:
fileno:返回结构体FILE的成员_file,即文件描述符。标准IO->文件IO
cs
int fileno(FILE *stream);
fdopen:通过文件描述符fd,返回FILE结构体。文件IO->标准IO
cs
FILE *fdopen(int fd, const char *mode);
注意:即使对同一个文件,也不要混用两种IO,否则容易发生错误。
原因:FILE结构体的pos和进程中的结构体的pos基本上不一样。
cs
FILE *fp;
// 连续写入两个字符
fputc(fp) -> pos++
fputc(fp) -> pos++
但是,进程维护的结构体中的pos并未加2;只有刷新缓冲区时,该pos才会加2;
代码示例
cs
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
putchar('a');
write(1, "b", 1);
putchar('a');
write(1, "b", 1);
putchar('a');
write(1, "b", 1);
exit(0);
}
打印结果:
bbbaaa
解析:putchar为标准IO函数,write为文件IO函数,遇到文件IO则立即输出,遇到标准IO,则需要等待缓冲区刷新的时机,这里是进程结束后,进行了强制刷新,将3个a字符输出到终端上。
cs
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
putchar('a');
fflush(stdout);
write(1, "b", 1);
putchar('a');
fflush(stdout);
write(1, "b", 1);
putchar('a');
fflush(stdout);
write(1, "b", 1);
exit(0);
}
打印结果:
ababab
strace命令能够显示所有由用户空间程序发出的系统调用。
以上面第一个程序为例:
cs
strace ./ab
打印结果:
BUFSIZE对IO效率的影响
图中用户CPU时间是程序在用户态下的执行时间;系统CPU时间是程序在内核态下的执行时间;时钟时间是两个时间的总和;
BUFSIZE受栈大小的影响;此测试所用的文件系统是Linux ext4文件系统,其磁盘块长度为4096字节。这也证明了图中系统 CPU 时间的几个最小值差不多出现在BUFFSIZE 为4096 及以后的位置,继续增加缓冲区长度对此时间几乎没有影响。
3.6 文件共享
如果两个独立进程各自打开了同一文件:
在完成每个write后,在文件表项(即类似于FILE的结构体)中的当前文件偏移量即增加所写入的字节数。如果这导致当前文件偏移量超出了当前文件长度,则将i节点表项中的当前文件长度设置为当前文件偏移量(也就是该文件加长了)。
如果用O_APPEND标志打开一个文件,则相应标志也被设置到文件表项的文件状态标志中。每次对这种具有追加写标志的文件执行写操作时,文件表项中的当前文件偏移量首先会被设置为i节点表项中的文件长度。这就使得每次写入的数据都追加到文件的当前尾端处。
若一个文件用lseek定位到文件当前的尾端,则文件表项中的当前文件偏移量被设置为i节点表项中的当前文件长度。lseek函数只修改文件表项中的当前文件偏移量,不进行任何I/O操作。
可能有多个文件描述符指向同一个文件表项(例如使用dup),对于多个进程读取同一文件都能正确工作。每个进程都有它自己的文件表项,其中也有它自己的当前文件偏移量。但是,当多个进程写同一文件时,则可能产生预想不到的结果。为了说明如何避免这种情况,需要理解原子操作的概念。
原子操作:不可分割的操作;
原子操作的作用:解决竞争和冲突;
3.7dup和dup2
dup函数用于复制文件描述符,重定向输入输出。
cs
#include <unistd.h>
int dup(int oldfd);
int dup2(int oldfd, int newfd);
返回值:
成功:
dup函数返回当前系统可用的最小整数值,并且该描述符对应的文件与参数描述符oldfd所对应的文件一致,即指向同一个结构体;
dup2函数返回第一个不小于newfd的整数值,分两种情况:
如果newfd已经打开,则先将其关闭,再指向文件描述符oldfd的结构体;
如果newfd等于oldfd,则什么也不做;
失败:dup和dup2函数均返回-1,并设置errno。
代码示例
需求:将puts重定向到一个文件中
cs
#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) {
perror("open()");
exit(1);
}
puts("Hello World");
exit(0);
}
结果:
cs
[root@HongyiZeng sysio]# ./dup
[root@HongyiZeng sysio]# cat /tmp/out
Hello World
方法2:使用dup
cs
#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);
}
图示:
注意结构体中有引用计数,当fd=3被关闭时,还有fd=1指向这个结构体,因此结构体不会被销毁掉。存在并发问题。
方法3:使用dup2
cs
#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);
}
puts("Hello World");
exit(0);
}
dup2是一个原子操作,相当于:
cs
dup2(fd, 1);
// 相当于:
close(1);
dup(fd);
3.8 fcntl和ioctl
fcntl针对文件描述符提供控制。
cs
#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)
ioctl:用于控制设备
cs
#include <sys/ioctl.h>
int ioctl(int d, int request, ...);
ioctl函数一直是IO操作的杂物箱。不能用本章中其他函数表示的I/O操作通常都能用ioctl表示。终端I/O是使用ioctl最多的地方。
3.9 /dev/fd目录
对于每个进程,内核都提供有一个特殊的虚拟目录/dev/fd。
该目录中包含/dev/fd/n形式的文件名,其中n是与进程中打开文件描述符相对应的编号。也就是说,/dev/fd/0就对应于进程的标准输入stdin。
打开/dev/fd目录中的一个文件等同于复制对应的文件描述符,所以下面两行代码是等价的:
cs
fd = open("/dev/fd/1", O_WRONLY);
// 等价于:
fd = dup(1);
3.10 补充:几个文件的区别
3.10.1 用户变量
~/.bashrc和~/.bash_file这两个看到~,应该明白,这是用户目录下的,即里面的环境变量也叫shell变量,是局部的,只对特定的shell有效,用vim在用户目录下的.bash_profile文件中增加变量,变量仅会对当前用户有效,并且是"永久的"。
要让刚才的修改马上生效,需要执行以下代码:
cs
source ~/.bash_profile
两个的区别:.bash_profile只在会话开始时被读取一次,而.bashrc则每次打开新的终端时,都会被读取。
当shell是交互式登录shell时,读取.bash_profile文件,如在系统启动、远程登录或使用su -切换用户时;
当shell是交互式登录和非登录shell时都会读取.bashrc文件,如:在图形界面中打开新终端或使用su切换用户时,均属于非登录shell的情况。
3.10.2 全局变量
/etc/profile 和/etc/profile.d,前面的是文件,后面一看也就明白.d表示目录, /etc/profile里面的变量是全局的,对所有用户的shell有效。
用vim在文件/etc/profile文件中增加变量,该变量将会对Linux下所有用户有效,并且是"永久的"。
要让刚才的修改马上生效,需要执行以下代码
cs
source /etc/profile