目录
系统级I/O
简介
接口
文件描述符fd
重定向
缓冲区
文件系统
软硬链接
动静态库
静态函数库
动态库
输入/输出(I/O)是在主存和外部设备(磁盘驱动器、终端和网络)之间复制数据的过程。输入操作是从I/O设备复制数据到主存,而输出操作是从主存复制数据到I/O设备。
所有语言的运行时系统都提供执行I/O的较高级的工具。例如,ANSI C提供标准I/O库,比较常用的有:fopen()、fclose()、fread()、fwrite()、printf()、fprintf()等。
语言所提供的对操作系统I/O操作的封装,具有跨平台性,工作良好。而直接使用系统的文件调用接口会比较困难,并且不具有跨平台性。
但我们还是要学习系统的I/O操作。
首先,了解系统级I/O将帮助我们理解其他的系统概念。I/O在进程的创建和执行中扮演着关键的角色,进程创建又在不同进程间的文件共享中扮演着关键角色,因此,要真正理解I/O,必须先理解进程。
其次,有时只能使用系统级I/O。例如,标准I/O库中没有提供读取文件元数据的方式,例如文件大小或文件创建时间。另外,I/O库还存在一些问题,使得用它来进行网络编程非常冒险。
open、close
#include
#include
#include
int open(char *filename, int flags);
int open(char *filename, int flags, mode_t mode);
open函数将filename转换成一个文件描述符,并且返回描述符数字。
flags参数指明了进程打算如何访问这个文件
O_RDONLY:只读,相当于fopen里mode参数中的r。
O_WRONLY:只写,相当于mode参数中的w。
O_RDWR,可读可写,相当于mode参数中的r+。
flags参数也可以是一个或者更多位掩码的或,为写提供给一些额外的提示
O_CREAT:如果文件不存在,就创建它的一个截断的文件,相当于open里mode参数中的w+与r+之间的区别。
O_TRUNC:如果文件已存在,就截断它。
O_APPEND:在每次写操作前,设置文件位置到文件的结尾处,相当于open里mode参数中的a。
mode参数指定了新文件的访问权限位,同时,也需要注意有权限掩码umask的存在
#include
int close(int id);
进程通过调用close函数关闭一个打开的文件,而关闭一个已关闭的描述符会出错
#include
#include
#include
#include
#include
int main(){
int fd = open("file.txt", O_RDWR|O_CREAT, 0777);
close(fd);
return 0;
}
read、write
#include
ssize_t read(int fd, void *buf, size_t n);
ssize_t write(int fd, const void *buf, size_t n);
read函数从描述符为fd的当前文件位置复制最多n个字节到内存位置buf。返回值-1表示一个错误,而返回值为0表示EOF。否则,返回值表示的是实际传送的字节数量。
write函数从内存位置buf复制至多n个字节到描述符fd的当前文件位置。返回值-1表示一个错误,否则,返回值表示的是写的字节数。
#include
#include
#include
#include
#include
#include
int main(){
int fd = open("file.txt", O_RDWR|O_CREAT, 0777);
char str[10];
memset(str, '\0', sizeof str);
write(fd, "abcdef\n", 10);
close(fd);
fd = open("file.txt", O_RDWR);
read(fd, str, 4);
printf("%s\n", str);
close(fd);
return 0;
}
在open函数中,返回的是文件操作符
Linux进程默认情况下会有三个缺省打开的文件操作符,分别是标准输入0,标准输出1,标准错误2,所对应的文件一般是键盘、显示器、显示器
#include
#include
#include
#include
#include
#include
int main(){
char str[100];
int sz=read(0, str, 100);
write(1, str, sz);
return 0;
}
在新建文件时,其文件描述符总是未被使用的最小值
#include
#include
#include
#include
#include
#include
int main(){
int id1=open("file1.txt", O_RDWR|O_CREAT);
int id2=open("file2.txt", O_RDWR|O_CREAT);
close(0);
close(id1);
int id3=open("file3.txt", O_RDWR|O_CREAT);
int id4=open("file4.txt", O_RDWR|O_CREAT);
printf("%d %d %d %d\n", id1, id2, id3, id4);
return 0;
}
当我们进行输出重定向时,我们只会将标准输出重定向到文件内,而标准错误依旧会输出到显示器上。
#include
#include
#include
#include
#include
#include
int main(){
printf("stdout\n");
perror("stderror");
return 0;
}
我们可以通过 2> 来将标准错误重定向到另一个文件
也可以通过 2>&1 将标准错误重定向到标准输出所重定向的文件。
fd本质上是文件描述符表指针数组fd_array的下标,其中存储着file对象的指针,file对象中保存了文件相关的inode元信息。而该数组存储在files_struct结构体中,而该结构体的指针存储在task_struct(PCB)中。
当我们将文件描述符0、1、2所对应的文件关闭并新建文件后,会将文件内的数据作为输入、输出到显示器上的正常信息重定向到新创建的文件内、输出到显示器上的错误信息重定向到新创建的文件内,称作输入重定向、输出重定向(以及追加重定向)、错误重定向
#include
#include
#include
#include
#include
#include
int main(){
close(1);
open("file.txt", O_RDWR|O_CREAT, 0777);
printf("Hello world\n");
return 0;
}
#include
#include
#include
#include
#include
#include
char str[100];
int main(){
close(0);
int fd=open("file.txt", O_RDWR);
fgets(str, sizeof str, stdin);
printf("%s", str);
close(fd);
return 0;
}
#include
#include
#include
#include
#include
#include
char str[100];
int main(){
close(2);
int fd=open("file.txt", O_RDWR|O_CREAT, 0777);
perror("open");
close(fd);
return 0;
}
我们也可以通过dup2函数实现重定向
#include
int dup2(int oldfd, int newfd);
#include
#include
#include
#include
#include
#include
int main(){
int fd=open("file.txt", O_RDWR|O_CREAT, 0777);
dup2(fd, 1);
printf("Hello world\n");
close(fd);
return 0;
}
缓冲区是一段内存空间,用于缓冲输入输出的数据。
当输入输出的数据缓冲在缓冲区中,之后将缓冲区中的数据一并进行输入输出,这样会减少读写的次数,提高整机效率,叫做写回模式(WB)。而直接将输入输出数据进行读写的写透模式(WT)。
缓冲区分为用户级缓冲区(语言提供)和内核级缓冲区(操作系统提供),在我们编写程序时所接触到的缓冲区为用户级缓冲区。C语言提供的缓冲区就存储在struct FILE结构体中
缓冲区的刷新策略有三种:立即刷新(不带缓冲)、行刷新(行缓冲)、全刷新(全缓冲)。
当我们使用fllush函数或是当进程退出时,缓冲会被强制刷新。
绝大多数的设备都倾向于全缓冲,这样会最大程度的减少读写的次数,进而提高效率,当然也会有特殊情况,例如在我们进行显示器的输入时,人会倾向于一行一行的阅读,因此标准输出的缓冲策略为行缓冲。
例如这样一段代码
#include
#include
#include
#include
#include
#include
int main(){
//c语言提供的函数
printf("printf\n");
fprintf(stdout, "fprintf\n");
const char *s="fputs\n";
fputs(s, stdout);
//操作系统提供的
const char *ss="write\n";
write(1, ss, strlen(ss));
fork();
return 0;
}
当我们输出到显示器上时,进行的是行刷新,当创建子进程时,缓冲区中没有内容
而当我们将输出内容重定向到一个文件中时
会发现我们首先输出的是操作系统提供的函数write,之后将c语言提供的函数输出了两次
这是由于在与文件进行I/O时,进行的是全缓冲,fork创建子进程时缓冲区中的内容还没有被刷新,因此会将缓冲区进行写时拷贝,而当缓冲区的内容被写进磁盘时,无论是父子进程,子进程都会拷贝一份副本,因此父子进程都会进行输出,因此最终会在文件中输出两份。
没有被打开的文件处于磁盘中,被称为磁盘级文件。
整块磁盘的组成主要有:
碟片,用于记录数据
机械手臂(包括磁头),用于擦写碟片上的数据
主轴马达,转动碟片进而让磁头读写数据
每个碟片被划分为一个个磁道,每个磁道又划分为一个个扇区。
扇区是存储数据的基本单位,大小为512个字节(外围磁道的扇区和内围的扇区由于密度不同,大小均为512字节)。而操作系统与磁盘进行I/O的基本单位是4kb,一是为了提高效率,而是为了将其与扇区的基本单位区分开,实现磁盘与操作系统的解耦合。
而我们想要找到一个指定的扇区,就需要找到在哪一个盘面(H)、在哪一个磁道(C)、在哪一个扇区(S),称为CHS寻址。
我们可以把磁盘的空间想象成线性的结构。
首先,基于分治的原则,磁盘的整体空间首先被分区,而每个分区分为启动块(Boot Block)和一个个的group block 。
而每个group block又被划分为不同的区域
super block:存放文件系统本身的结构信息,主要包括block和inode的总量、未使用的inode的数量、一个block和inode的大小、最后一次挂载的时间、最后一次写入数据的时间、最后一次检验磁盘的时间等。
GDT:块组描述符,描述块组属性信息
Block Bitmap:通过位图描述block数据块的使用情况
inode Bitmap:通过位图描述inode表的使用情况
inode Table:存储inode,用于记录文件的属性,一个文件占用一个inode,同时记录此文件的数据所在的区块号码。
Data Blocks:记录文件的内容,根据文件的数据大小可以占用多个区块
当我们创建文件时,首先需要寻找一个空闲的inode节点,并将文件的属性信息存储在其中,之后,根据文件内容的大小,寻找相应数量的block,将数据分别存储到其中,并将block对应的下标存储在inode中,用于读写文件时寻找对应的块。最后将文件名添加到目录中。
而若是删除文件,我们所做的是将对应的block和inode在位图中对应的状态至为未被使用,而不需要去删除原本位置的内容,只需要在重新使用这块空间后将原本数据覆盖即可。因此在我们删除一个文件后,若是未被其他文件覆盖,我们可以做到将其恢复。
当我们在磁盘中寻找文件时,我们找的是文件的inode,而非文件名,而使用硬链接可以让多个inode对应同一个inode。
目前,我们新建的文件的硬链接数都为1,我们可以通过ln命令来建立硬链接
可以看到,该文件的硬链接数变成了2。
而在我们的目录当中
可以看到,目录的硬链接数初始为2,这是因为目录中的隐藏文件 .
可以看到,隐藏文件 . 的inode与目录相同。
而在 目录中创建目录时,外层目录的硬链接数会变成3,这是因为内层目录包含的隐藏文件 ..
而当我们把对应的硬链接文件删除时,只会让该inode文件的硬链接数减一,当减为0时才会真正的去删除。
而软链接创建的文件属于一个独立的文件,具有自己的inode,而该文件内容,就是指向文件的路径。我们可以通过ln -s 命令创建软链接。
在很多软件之间都会互相使用彼此提供的函数库来使用其特殊的功能,因此要使用到函数库。函数库依照是否被编译到程序内部而分为动态库与静态库。
这类函数库通常扩展名为libxxx.a,在编译的时候会直接整合到执行程序当中,因此利用静态库编译成的文件会比较大一些。而正因为将其整合到程序中,因此编译成功的可执行文件可以独立运行,不需要向外部要求读取函数库的内容。
若是我们想要形成动态库,首先,我们需要将函数所在的源文件进行预处理、编译、汇编(gcc -c),之后将形成的文件通过ar -rc命令生成静态库。
liboutput.a:myprint_a.o
ar -rc liboutput.a myprint_a.o
myprint_a.o:myprint.c
gcc -c myprint.c -o myprint_a.o
.PHONY:output
output:
mkdir -p output/lib
mkdir -p output/include
cp -rf *.h output/include
cp -rf *.a output/lib
.PHONY:clean
clean:
rm -rf *.a *.o output
若是我们想要使用库函数,我们需要进行库的安装
对于静态库,我们首先需要将头文件拷贝到头文件gcc的默认搜索路径 /usr/include 中,将库文件拷贝到库文件的路径 /usr/lib64 中。
之后,我们可以写一个文件来执行库函数
而在使用gcc进行编译时,我们需要加一些选项
gcc -I 头文件路径 -L 库文件路径 -l 库名
动态库通常扩展名为libxxx.so,与静态库被整个整合到程序中不同的是,动态库在编译的时候,在程序中只有一个指针的位置,当执行文件时,动态库会被加载到内存中并被映射到进程的虚拟地址空间中,当我们要使用到函数库的功能时,程序才会去读取函数库(通过库的起始地址加上所使用的函数的偏移量来寻找对应函数)来使用。
生成动态库首先依然需要形成汇编后的文件,但与静态库不同的是,我们需要通过-fPIC选项来生成与位置无关的目标二进制文件。之后通过gcc -shared 命令来形成库文件。
.PHONY:all
all:liboutput.a liboutput.so
liboutput.a:myprint_a.o
ar -rc liboutput.a myprint_a.o
liboutput.so:myprint_so.o
gcc -shared myprint_so.o -o liboutput.so
myprint_a.o:myprint.c
gcc -c myprint.c -o myprint_a.o
myprint_so.o:mypirnt.c
gcc -fPIC -c myprint.c -o myprint_so.o
.PHONY:output
output:
mkdir -p output/lib
mkdir -p output/include
cp -rf *.h output/include
cp -rf *.a output/lib
.PHONY:clean
clean:
rm -rf *.a *.o *.so output
除开像静态库一样需要对库进行安装,我们还可以更改环境变量LD_LIBRARY_PATH,在后面加上库文件所在的路径
而这种更改方式只能在本次登录有效
可以通过配置/etc/ld.so.conf.d/ 并用ldconfig命令来将/etc/ld.so.conf 的数据读入缓存当中。
也可以通过ln -s 命令建立软链接(注意要使用绝对路径)