输入/输出(I/O)是在主存和外部设备(如磁盘驱动器、终端和网络)之间拷贝数据的过程。输入操作是从I/O设备拷贝数据到主存,而输出操作是从主存拷贝数据到I/O设备
写文件:
#include
#include
int main()
{
FILE *fp = fopen("./log.txt", "a"); //a:追加
if(NULL == fp){
perror("fopen");
return 1;
}
const char *msg = "hello world\n";
fwrite(msg, strlen(msg), 1, fp);
fclose(fp);
return 0;
}
fwrite:
size_t fwrite ( const void * ptr, size_t size, size_t count, FILE * stream );
其中参数size是期望写入的每个单元的大小,而count是期望写入的单元的个数。而返回值是实际写入的单元的个数。
注意:
w:写入,每次写入都是重新写入,意味着之前的文件内容都会被清空。
a:append 追加。也是写入,但不清空原始文件内容,在文件最后进行写入,是使数据增多的过程。
以上代码中,strlen(msg)
不需要加1,因为’\0’本质是C的规定,和文件没有关系。文件只关心写入的内容。如果加上1,打开文件发现会乱码。由于\0本来就是不显示的,cat log.txt并不会显示乱码。
在打开文件时,默认就在当前路径下创建文件。
当前路径: 每个进程都有一个内置的属性cwd。进程认为自己所处的路径就是当前路径 (./)。例如以上代码,如果把ctl_file移动到上级目录,进程的当前路径也会随之移动。
在任何C程序,都会默认打开三个“文件”,分别叫做标准输入(stdin),标准输入(stdout),标准错误(stderr)
在Linux内,一切皆文件。
默认情况下,标准输入叫做键盘文件,标准输出:显示器文件,标准错误:显示器文件。
所有的外设硬件,本质对应的核心操作是read和write。比如键盘文件,有它对应的读方法和写方法,没有写方法,可以把写方法设置为空。
不同的硬件,对应的读写方式肯定是不一样的。
在C语言中,让一个结构体中既有属性又有方法,可以定义函数指针。
OS要管理这么多的文件,就要先描述,再组织。而所有的底层的差异,经过这一层软件的虚拟,就变成了一组同样的东西。当我们具体访问某一组设备时,就用函数指针去执行不同的方法,就可以做不同的操作。
假如我们有一个struct file *curr,当要访问某个设备时,就可以让其指对应的struct file,其中的函数指针再去执行对应的操作。
在C中,通过函数指针的方案,把操作进行虚拟化,让不同的结构体中的函数指针指向不同的底层,包括普通文件,上层就可以以一个统一的视角去看待这些底层,这就是Linux中实现一切皆文件的原理。
fwrite(msg, strlen(msg), 1, stdout);
如果直接往显示器写入,就不用再通过打开文件显示内容,因为C默认直接打开显示器文件。
所以我们可以通过曾经的C接口,直接对stdin,stdout,stderr进行读写。
为什么C程序默认会打开stdin,stdout,stderr?
仅仅是C吗?
像scanf,本质是向键盘输入的,printf -> 显示器;perror -> 显示器
所以在调用scanf这些接口之前,就要先把对应的文件(键盘等)打开,如果没有打开是不可以直接调用这些接口的。
大部分情况下,都有输入输出的需求,默认打开stdin,stdout,stderr,就是为了便于我们上手使用语言,否则在输入输出之前还要先打开键盘、显示器等。
fprintf、fscanf等接口其实是和scanf、printf和一样的,只是fprintf、fscanf显示地暴露出了stdin、stdout
几乎任何语言都会默认打开,如C++的cin、cout、cerr,所以这就不仅仅是语言层的功能了,而是操作系统的。
站在系统角度,理解文件
1.文件和进程的关系
2.系统调用
像fopen、fclose、fread、fwrite这些函数都是库函数,去掉f就是所对应的系统调用接口。操作系统对上要提供一堆方法,而这些方法就类似于结构体中的函数指针。系统会暴露出像open、close这样的方法。在操作系统之上,有各种语言,每种语言都有自己的接口,但这些接口本质就是系统调用接口的封装。在任何一款操作系统中,所有语言自己的接口都会转化成像open这样的系统调用接口。
open:
*pathname:打开哪个文件
flags:打开文件的方式
mode:打开文件的权限
返回值:
file descriptor:文件描述符
int fd = open("log.txt", O_WRONLY|O_CREAT, 0644);//0可以理解成8进制
printf("%d\n", fd);
fopen中的w方式其实就是O_WRONLY|O_CREAT
文件权限默认是644
const char *msg = "hello system call!\n";
write(fd, msg, strlen(msg));
int main()
{
int fd = open("log.txt", O_RDONLY);
if(fd < 0){
perror("open");
return 1;
}
char buffer[1024];
ssize_t s = read(fd, buffer, sizeof(buffer)-1);
if(s > 0){
buffer[s] = '\0';
printf("%s\n", buffer);
}
close(fd);
return 0;
}
int main()
{
int fd = open("log.txt", O_WRONLY|O_APPEND);//0可以理解成8进制
if(fd < 0){
perror("open");
return 1;
}
const char *msg = "hello system call!\n";
write(fd, msg,strlen(msg));
close(fd);
return 0;
}
系统喜欢用宏所定义出来的比特位(一个)不重叠的二进制序列,来传一种标志。我们可以通过按位或来组合标志位,向系统导入多个选项。系统内部用按位与来检测是否设置了某个标志。
为什么语言都喜欢对系统调用做封装?
兼容自身语法特征,系统调用使用成本较高,而且不具备可移植性。比如在windows下,open这套接口就不能用,因为这套接口是Linux提供的。如果直接使用系统调用接口,写出来的代码是没有可移植性的。 而像fopen这些接口可以自动根据平台,选择自己底层对应的文件接口。
int main()
{
int fd1 = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
int fd2 = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
int fd3 = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
int fd4 = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
int fd5 = open("log.txt", O_WRONLY|O_APPEND|O_CREAT, 0644);
printf("fd1: %d\n", fd1);
printf("fd2: %d\n", fd2);
printf("fd3: %d\n", fd3);
printf("fd4: %d\n", fd4);
printf("fd5: %d\n", fd5);
return 0;
}
可以看到这里的文件描述符是一些连续的小整数,其实就是数组的下标。
而0,1,2已经被使用了,分别就是stdin、stdout、stderr (默认被打开)
所有的文件,如果要被使用,首先必须被打开。一个进程可以打开多个文件,系统内被打开的文件一定是有多个的,这多个被打开的文件需要被操作系统管理起来,就要先描述,再组织。在Linux操作系统中,描述一个打开文件的数据结构叫做struct file,而这个struct file里包括目标文件的基本操作与部分属性。
用户层看到的fd(文件描述符),本质是系统中维护进程和文件对应关系的数组的下标。 如read(fd),当前正在运行的进程调用read,传入fd,此时这个进程就通过自己的PCB找到对应的struct files_struct结构体,再在结构体中找到数组,再找到对应下标(fd),根据fd下标的指针找到对应的文件,调用read方法,来让数据进行读取。
对进程来讲,对所有的文件进行操作,统一使用一套接口(一组函数指针)。在进程看来,就是一切接文件。
所谓的默认打开文件,标准输入,标准输出,标准错误,其实就是由底层系统支持的,默认一个进程在运行时就打开了0,1,2
系统中,分配文件描述符的规则:最小的,没有被使用的,进行分配。
int main()
{
close(1);
int fd = open("log.txt", O_CREAT|O_WRONLY, 0644);
if(fd < 0){
perror("open");
return 1;
}
printf("hello world!: %d\n", fd);
fflush(stdout);
close(fd);
return 0;
}
此时,本来应该输出到显示器上的内容,输出到了文件log.txt当中。其中,fd=1.这种现象叫做输出重定向 > 。
FILE结构体:
typedef struct _IO_FILE FILE; 在/usr/include/stdio.h
在/usr/include/libio.h
struct _IO_FILE {
int _flags;
...
在这个结构体内部包含了一个文件描述符的字段 int fileno;
read(fp->fileno);
struct FILE内部包含
1.底层对应的文件描述符下标 _fileno
2.应用层,C语言提供的缓冲区数据
printf输出数据时,其实是先暂存到C语言的缓冲区(语言级),碰到\n或fflush(stdout)才会刷新到系统,而刷新才是通过fd找到对应的文件,把数据写到对应文件的缓冲区(系统级),再刷新到磁盘。如果在进程退出前没有刷新就把fd关了,我们将看不到数据信息。
普通文件的刷新策略是全缓冲,当数据在缓冲区写满时才会刷新。而显示器文件的刷新策略是行刷新。如果以上代码只写了\n,由于已经close(1),内容被重定向到普通文件log.txt中,而普通文件的刷新策略是全缓冲,所以也不会刷新出去,必须要fflush。
为什么要有用户缓冲区?
彻底让用户和底层屏蔽开
提高效率
内核中的缓冲区也是一种解耦和提高效率的方法。
int main()
{
//C
printf("hello printf\n");
fprintf(stdout, "hello fprintf\n");
fputs("hello fputs\n", stdout);
//system call
const char *msg = "hello write\n";
write(1, msg, strlen(msg));
fork();
return 0;
}
可以看到,向普通文件写入时,C语言的库函数会打印两次,而系统调用接口只打印一次。
重定向之后,文件的刷新策略发生了更改,C语言的若干接口在用户层,打印完后数据会暂存到C的缓冲区中。而缓冲区里的数据也是数据,调用fork之后,父子进程在return之前要强制刷新数据,就立马在退出前发生写时拷贝,所以在文件中是两份数据。而系统调用接口是直接在系统内核中刷新出去。
重定向的本质其实就是文件描述符表中数组的内容的互相拷贝。
new是old的一份拷贝,拷贝的内容是fd对应的数组中的内容。
dup2(fd, 1) 也就是把新打开的文件的文件描述符对应的数组中的内容拷贝到1对应的内容。
int main()
{
int fd = open("test.txt", O_CREAT|O_WRONLY, 0644);
if(fd < 0){
perror("open");
return 1;
}
dup2(fd, 1);
const char *msg = "hello dup2\n";
int i = 0;
while(i < 10){
write(1, msg, strlen(msg));
i++;
}
close(fd);
return 0;
}
输出重定向:dup2(fd, 1); >
输入重定向:dup2(fd, 0); <
追加重定向:dup2(fd, 1); >>
文件:打开的文件:属性与操作方法的表现就是struct file{},是内存级文件。普通的未打开的文件:就在磁盘上面,未被加载到内存。
打开的文件需要被管理,未打开的文件也要被管理,两种管理合起来就是操作系统中的文件系统。
进程 vs 程序
进程就像是一个被打开的文件,要被加载到内存中。而程序就是一个简单的磁盘上的文件。
使用ls -l时除了看到文件名,还看到了文件元数据。
ls -l其实是把磁盘中对应文件的元信息,属性信息,搬到操作系统内核,再通过内核放到用户空间,用户空间再显示出来。
这里说的主要是机械磁盘。
从圆心向外画直线,可以将磁道划分为若干个弧段,每个磁道上一个弧段被称之为一个扇区。扇区是磁盘的最小组成单元,通常是512字节。
内存在OS的角度,使用的时的基本单位是4KB(把内存看作一个大数组,每一个小的内容都是4KB)。
磁盘存储也有基本单位,也就是扇区。扇区的大小一般是512字节(乘4就是4KB)。
内存的基本单位是1字节。
内存和磁盘之间是要交互的,实际上IO是要通过文件系统完成的,也就是要通过OS完成。在IO时,基本单位一般就是4KB(也有些是1KB)。
当我们一次要从磁盘上读取数据到内存时,如果是文件系统完成的,它一次要读8个扇区。
操作系统要读取磁盘时,要知道盘面、磁道、扇区这三个参数。但如果我们将机械磁盘换成ssd,操作系统访问数据的方式就变了,不再有盘面等,操作系统对于机械磁盘设计好的逻辑就要发生改变,这显然不合理。所以需要在操作系统上对磁盘做抽象化。
我们可以将磁盘想象成线性结构。比如二维数组,虽然可以将其画成一个矩阵的样子,但在计算机里依旧是线性结构。
这样操作系统就不用关心磁道盘面等结构,只需要知道抽象出来的数组的下标,这种地址就叫做LBA(逻辑地址)。
一个磁盘的空间是非常大的,我们将其分为一块一块的过程,这也就是就是分区的过程。这也就是我们电脑上有C盘、D盘等的原因。
格式化:将数据和方法写入文件系统。也就是将文件系统的属性信息、方法等这些信息
Linux ext2文件系统,上图为磁盘文件系统图(内核内存映像肯定有所不同),磁盘是典型的块设备,硬盘分区被划分为一个个的block。一个block的大小是由格式化的时候确定的,并且不可以更改。例如mke2fs的-b选项可以设定block大小为1024、2048或4096字节。而上图中启动块(Boot Block)的大小是确定的。
超级块Super Block:文件系统的核心结构,用来描述文件系统的属性。一般当计算机启动时,Super Block会加载到操作系统内。
Block Group:ext2文件系统会根据分区的大小划分为数个Block Group。而每个Block Group都有着相同的结构组成。
GDT,Group Descriptor Table:块组描述符,描述块组属性信息。
块位图(Block Bitmap):每个bit标识一个inode是否空闲可用。
inode位图(inode Bitmap):每个bit表示一个inode是否空闲可用。
i节点表:存放文件属性如文件大小,所有者,最近修改时间等。
每一个文件都对应一个inode节点,一个inode占一个扇区大小。一个文件的所有属性都会写入到inode节点中。而文件的内容全部都放到Data blocks中。
一个数据块中可以保存文件的数据,也可以同时保存其它数据块的编号。
1.基本上,一个文件一个inode(包括目录)
2.inode是一个文件的所有的属性集合(没有文件名;也是数据,也要占据空间)
3.真正标识文件的不是文件名,而是inode编号。
4.inode是可以和特定的数据块产生关联的。
我们通过路径,即目录定位一个文件。而目录也是文件,有独立的inode,也要有自己的数据块。目录的数据块里保存的是文件名和inode的映射关系。当定位一个文件时,通过目录的inode找到对应的若干数据块,数据块中保存有文件名和inode的映射关系,由此找到文件的inode,再找到文件inode对应的数据块。
通过位图可以快速完成inode本身的申请和释放,同时确认当前磁盘的使用情况。
touch一个空文件时,首先在inodebitmap里申请没有被使用的(bit位为0)位图,然后把空文件对应的属性信息写入inode table。然后在该空文件所在的目录的数据块中保存文件名和inode的映射关系。
大多数OS同一个目录下,不允许存在同名文件,否则不知道对应哪个文件。
如何理解删除文件?
把对应空间的两个位图改为0,就是无效了,下一次再申请创建文件时直接覆盖;删除目录下文件名和inode的映射关系
删除文件是轻量的,不需要清空该文件占据的所有空间的数据。
回收站其实就是一个目录,当删除文件时即把该文件移动到该目录下(移动目录中的映射关系,文件本身在磁盘中并不改变)
Linux下属性和内容是分离的,属性由inode保存,内容由data block保存
当inode被占完时,需要删除空目录和空文件夹,释放inode
软链接:就是一个普通的正常文件,有自己独立的inode编号。
软链接相当于它保存了所指向文件的路径,但不会直接显示出来。
作用类似于windows下的快捷方式。
硬链接:没有自己独立的inode。只是又建立了一个文件名和inode的映射关系,相当于原来文件的别名。
为什么创建一个普通文件时它的硬链接数为1,创建一个目录时硬链接数为2?
lesson17这个目录中本身包含了file文件名和inode的一组映射关系,所以硬链接为1.
dir本身是个文件名,和inode为一组。cd dir时,.(当前路径)相当于它自己的别名。如果在dir中创建一个目录other,那么dir的硬链接数就再多一个,因为other的上级目录 . . 也是dir目录的别名。
所以硬链接可以方便进行相对路径的路径设置
1.软硬链接的根本区别:
是否是独立文件,有没有独立inode
2.作用:
软链接:指向特定的文件,方便快速索引。硬链接:进行路径设置。
文件的三个时间:
Access 文件最后访问时间
Modify 文件内容最后修改时间
Change 文件属性最后修改时间
touch可以刷新文件的acm
当我们vim进入文件不修改或cat访问文件时,会发现Access时间并没有改变。我们Modify和Change文件的动作并不高频,但访问文件是比较频繁的,如果每显示一下就更新一下时间,时间也是在磁盘上的数据,当频繁更新数据时可能会导致系统效率的降低。所以在进行有效访问时,Access时间才会刷新,比如访问并修改了文件内容;或访问多次才会发生修改,不同的Linux版本策略不同。
Makefile会自动检测到源文件是否做了修改,其实就是通过时间来甄别文件是否做了修改的。如果发现源文件test.c的最近修改时间在可执行程序mytest之前,即mytest已经执行过了,不能再make。
我们使用别人(一般是顶尖的工程师)的代码是为了开发效率和鲁棒性(健壮性)。
如何使用别人的功能?
1.库
2.开源代码
3.基本的网络功能调用(各种网络接口,如百度的语音识别)
库一般分为动态库和静态库。
1.命名:取消前缀lib,去掉.之后的内容, 剩下的就是库的名字
2.生成可执行程序的方式有两种:
动态库(.so):如libc.so,去掉lic和.so,就叫c库。程序在运行的时候采取链接动态库的代码,多个程序共享使用库的代码。
一个动态库链接的可执行文件仅仅包含它用到的函数入口地址的一个表,而不是外部函数所在目标文件的整个机器码。
在可执行文件开始运行以前,外部函数的机器码由操作系统从磁盘上的该动态库中复制到内存中,这个过程称为动态链接。
动态库可以在多个程序间共享,所以动态链接使得可执行文件更小,节省了磁盘空间。操作系统采用虚拟内存机制允许物理内存中的一份动态库被要用到该库的所有进程共用,节省了内存和磁盘空间。
静态库(.a):如libc.a
程序在编译链接的时候把库的代码链接到可执行文件中,程序运行时将不再需要静态库。
Linux中,默认情况下,形成的可执行程序是动态链接。
在Makefile中添加-static,形成的就是静态链接
显然,静态链接的文件大小比动态链接大。
一般,为了更好地支持开发,第三方库或者语言库,都必须提供两个库,一个叫做动态库,一个叫做静态库,方便程序员根据需要进行bin的生成。
动态链接的特点:体积小,节省资源(磁盘,内存),一旦库丢失,bin不可执行。
静态链接的特点:体积大,浪费资源(磁盘,内存),不依赖库,库丢失,不影响。
1、 如何打包静态库?(不想暴露自身源代码)
我们让别人能使用我们的库,前提是别人首先需要知道你的库能提供什么方法(通过头文件可以知道)。
将源文件进行预处理、编译、汇编,不链接:gcc -c Add.c
打包:ar -rc libmymath.a Add.o Sub.o
只需要头文件(.h)和库文件(.a)即可形成自己的可执行程序
2.如何使用静态库?
-I:告诉gcc除了默认路径以及当前路径,在指定路径下也找一下头文件。
默认头文件路径:/usr/include
当前路径:
-L:告诉gcc除了默认路径以及当前路径之外,在指定的路径下也找一下库文件。
默认库文件路径:/lib或/lib64
-l(L的小写)+库名称:具体要链接的库
为什么C语言在编译时,从来没有明显使用过 -L、-I、-l 等选项呢?
1.库文件和头文件,在默认路径下gcc能找到
2.gcc编译C代码,默认就链接libc
如果我们也不想使用这些选项,可以将头文件库文件分别拷贝到默认路径下,这个过程叫做库的安装。
我们自己写的库叫做第三方库,一般也要带上-lname
1.如何制作打包动态库?
gcc选项,fPIC:产生与位置无关码
-fPIC 作用于编译阶段,告诉编译器产生与位置无关代码(Position-Independent Code),则产生的代码中,没有绝对地址,全部使用相对地址,故而代码可以被加载器加载到内存的任意位置,都可以正确的执行。这正是共享库所要求的,共享库被加载时,在内存的位置不是固定的。
如果是静态链接,最终形成的进程就没有用到共享区,代码区中就包括了库的代码。如果有10个程序都是C写的,就会出现至少10份重复的代码。
而如果是动态库, 把库加载到内存后,所有使用同一种库的进程可以把库和自己进程地址空间映射起来,每个进程访问库代码时使用的是同一份库代码,就不出现重复代码了。
-shared:形成共享库
引入库,编译程序:
gcc main.c -o mytest -I ./lib -L ./lib/ -lmymath
此时运行时报错与编译器gcc没有关系,属于运行问题,所以现在我们也要能够让系统帮我们找到运行时需要使用的动态库。而之前动态链接的其它程序,可以直接运行,是因为库在默认路径下,可以直接找到。
解决方法如下:
1、使用环境变量LD_LIBRARY_PATH
(内存级变量,关闭Xshell重新打开就没了)
2、拷贝.so文件到系统共享库路径下,一般指/usr/lib
3、ldconfig 配置/etc/ld.so.conf.d/
,ldconfig更新