目录
一、C文件操作函数:
二、输入 / 输出 / 错误流:
三、系统文件 I/O
open函数:
write:
read:
close:
具体应用:
四、文件描述符(fd):
1、概念:
2、文件管理:
3、内存文件:
4、FILE:
5、全缓冲与行缓冲:
五、重定向:
1、重定向原理:
2、dup2系统调用:
3、inode:
六、计算机存储器:
概览:
磁盘:
物理内存:
虚拟内存:
七、动静态库:
软链接与硬链接:
静态库与动态库:
静态库:
动态库:
一、C文件操作函数:
在我们学习C语言的时候就学习过一些基础的I / O,其实就是所谓的文件操作,以下是一些我们常见的一些文件操作函数:
例如如下代码:向 log.txt 文件中覆盖写入Hello Linux!(如果没有 log.txt文件就在当前目录下创建一个名为 log.txt 的文件再执行覆盖写入操作)
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main()
{
// 默认在当前路径下进行
FILE *fp = fopen("log.txt" , "w");// w: 覆盖写入 a: 追加写入
if(fp == NULL)
{
perror("fopen error!");
exit(1);
}
// size_t fwrite(const void *ptr, size_t size, size_t count, FILE *stream)
// ptr:要写入文件的数据的地址。
// size:这表示要写入的每个元素的大小,以字节为单位。
// count:这是要写入的元素个数,即要进行写入size字节的数据项的个数。
// stream:这是一个指向要写入数据的文件的指针。
const char* str = "Hello Linux!\n";
fwrite(str, strlen(str), 1, fp);
fclose(fp);
return 0;
}
二、输入 / 输出 / 错误流:
- 标准输入流:extern FILE *stdin;
- 标准输出流:extern FILE *stdout;
- 标准错误流:extern FILE *stderr;
其中,标准输入流对应的设备就是键盘,标准输出流和标准错误流对应的设备都是显示器。而且这三都是FILE*类型的。
在Linux中我们有一个一切皆文件的概念,我们所看到的显示器输出的数据在本质上是:电脑从“电脑文件” 读取字符,然后再对“显示器文件”进行输出。
打开文件是在进程运行的时候打开的,任何进程在运行的时候都会默认打开三个流,就是上面提到的那三个,在语言层面上来讲,例如C语言的 stdin、stdout、stderr;C++的 cin、cout、cerr,许多编程语言和框架都遵循这种标准输入输出的做法,为的就是在不同操作系统和环境中具有更好的跨平台兼容性和一致性。
举个”栗子“:
我们可以直接调用scanf、printf函数对键盘和显示器进程对应的输入输出操作,其原因就是在程序运行时操作系统使用 C 的接口将这三个输入输出流打开。
例如我们在使用 fputs 函数时直接将其第二个参数设置为 stdout,此时fputs 函数就会直接将数据显示到显示器上:
// int fputs(const char *s, FILE *stream);
fputs("Hello Linux!\n", stdout);
因为此时就是用 fputs 向显示器文件进行了写入操作。
三、系统文件 I/O
操作系统也有一套文件操作的接口,且更加的贴合底层,其他语言的接口本质上也是对操作系统的接口的封装,例如C代码,在不同的平台下都能调取对应系统的接口,其实就是通过条件编译使得语言具有了跨平台性,也方便后续的二次开发。
open函数:
open函数用于打开或创建文件,并返回一个文件描述符(fd)。
#include <fcntl.h> // 对于 open 函数和文件控制标志 #include <sys/types.h> // 对于文件类型定义 #include <sys/stat.h> // 对于文件状态标志 int open(const char *pathname, int flags, mode_t mode);
函数参数:
pathname:指向要打开或创建的文件的字符串指针。
flags:一个或多个标志的按位或(bitwise OR),用于指定文件的打开模式。
mode_t mode:可变参数列表,通常与O_CREAT标志一起使用,用于指定新文件的权限,使用mode_t类型的值
补充:
若pathname以路径的方式给出,则当需要创建该文件时,就在pathname路径下进行创建
若pathname以文件名的方式给出,则当需要创建该文件时,默认在当前路径下进行创建
flags 的可调用参数如下:
关于函数返回值:open函数的返回值就是打开文件的文件描述符(fd),失败返回-1。
write:
write函数是一个系统调用,用于向文件或设备写入数据。
#include <unistd.h>
ssize_t write(int fd, const void *buf, size_t count);
- fd:文件描述符,它是一个非负整数,用于标识打开的文件、设备或套接字。
- buf:指向要写入数据的缓冲区的指针。
- count:要写入的数据的字节数。
函数返回值:成功写入返回写入的字节数,失败返回-1。
read:
read函数是一个系统调用,用于从文件描述符读取数据。
#include <unistd.h>
ssize_t read(int fd, void *buf, size_t count);
- fd:文件描述符,它是一个非负整数,用于标识打开的文件、设备或套接字。
- buf:指向用于存储读取数据的缓冲区的指针。
- count:最多要读取的字节数。
函数返回值:成功读取返回实际读取的字节数。如果到达文件末尾,返回0。失败返回-1。
close:
close函数用于关闭一个已打开的文件描述符。
#include <unistd.h>
int close(int fd);
- fd:要关闭的文件描述符。
函数返回值:成功关闭返回0,失败返回-1.
具体应用:
例如:当前路径下的long.txt文件(不存在则创建),向文件中写入 Hello World!,然后读取文件内容并打印到标准输出中。
#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>
#include <string.h>
int main()
{
int fd = open("log.txt", O_RDWR | O_CREAT, 0666);
if(fd == -1)
{
perror("open error!");
exit(-1);
}
const char* str = "Hello World!\n";
// 写入操作
ssize_t write_size = write(fd, str,strlen(str));
if(write_size == -1)
{
perror("write error!");
close(fd);
return -1;
}
// 刷新文件到磁盘
fsync(fd);
lseek(fd, SEEK_SET, 0);
// 读操作
char buffer[50];
ssize_t read_size = read(fd, buffer,sizeof(buffer)-1 );
if(read_size == -1)
{
perror("read error!");
close(fd);
return -1;
}
buffer[read_size] = '\0';
printf("File Contents: %s",buffer);
close(fd);
return 0;
}
四、文件描述符(fd):
1、概念:
文件描述符(fd)是一个用于表述指向文件的引用的抽象化概念,它是一个非负整数,通常是一个小整数,用于内核标识一个特定进程正在访问的文件。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。然后该文件描述符被用于后续的读写操作。
2、文件管理:
正常情况下,系统中会存在着大量的进程,换而言之,这就表示系统可以在任何时刻存在大量已经打开的文件。那这些文件如何管理的呢:
操作系统管理任何东西,都一定是遵循着 “先描述,再组织” 的策略:使用结构体对一个对象进行描述,然后将它用一个数据结构组织起来,这样就能达到一种状态:在操作系统中,管理任何对象,最终都可以转化成为对某种数据结构的增删查改。
所以在实际上,系统会为大量的文件描述一个 file struct 的结构体,里面存放着这些文件的主要信息,然后将结构体以双链表的形式进行组织,这就是将文件的管理具象成对双链表的增删查改。
而在大量进程和大量已打开的文件里,我们要找到每个文件的归属进程的话,系统就应该建立对应关系:
当一个程序运行起来时,操作系统会将该程序的代码和数据加载到内存,然后为其创建对应的task_struct、mm_struct、页表等相关的数据结构,并通过页表建立虚拟内存和物理内存之间的映射关系:
如图,在task_struct 里有一个指针,它指向一个名为 file_struct 的结构体,在这个结构体里面又有一个 fd_array 的指针数组,这个数组的下标就是我们熟悉的文件描述符。例如:当进程打开一个log.txt文件时会将其先加载进内存中形成struct file,然后将struct file放入一个文件的双链表中,struct file的首地址再放入链表中下标为3 的地方,最后再返回它的文件描述符。
通过上面的表述我们就能知道为什么进程创建时会默认打开0、1、2了:
操作系统能够识别硬件,也能管理硬件、也就意味着键盘、显示器都有着自己对应的struct_file,将这三个struct_file放入链表中就会自动填入到下标为:0、1、2 的位置,也就默认的打开了标准输入流、标准输出流、标准错误流。
【文件描述符是从最小的 0 开始且未被分配的开始分配的,例如:系统默认打开0、1、2,当我关闭0、1,然后再打开三个文件,此时对应的就不是3,4,5,而是0,1,3了】
3、内存文件:
磁盘文件和内存文件的关系就如同程序和进程的关系一样,当程序运行起来就成了进程,而当磁盘文件加载到内存后就成了内存文件。
文件 = 文件属性 + 文件内容:内存文件和磁盘文件也是如此,当磁盘文件加载到内存时,一般先加载文件的属性信息,当需要对文件的内容进行读取、输入、输出等操作时再加载文件数据。
4、FILE:
我们在使用C的fopen接口时会有一个返回值,而这个返回值的类型就是FILE*,在<stdio.h>头文件的源码中有这么一句代码,也就是说,FILE实际上就是struct _IO_FILE结构体。
typedef struct _IO_FILE FILE;
再转到struct _IO_FILE的定义中有一个_fileno的成员,这个成员实际上就是文件描述符(fd)。实际上就是:
IO相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过fd访问的。
此外:struct _IO_FILE结构体中还包含着C语言提供的缓冲区,如下:
5、全缓冲与行缓冲:
首先我们先来复习一下缓冲的概念:
缓冲是一种用于提高性能的技术,它允许程序将数据累积在内存中的缓冲区,然后一次性地写入(或读取)到磁盘(或其他I/O设备)上。这样可以减少与I/O设备的交互次数,从而提高效率。
对于普通文件执行的是全缓冲,对于显示器文件执行的是行缓冲:
全缓冲(对磁盘文件写入数据):
1、当缓冲区满时,数据才会被写入(或读取)到磁盘。
2、通常用于磁盘文件的I/O操作。
3、缓冲区的大小通常是系统特定的,但可以通过某些方法(如setvbuf函数)进行设置。
4、由于全缓冲是在缓冲区满时才进行I/O操作,因此如果程序崩溃或异常终止,缓冲区中的数据可能会丢失。
行缓冲(对显示器文件刷新数据):
1、当在输入或输出中遇到换行符时,缓冲区的内容会被写入(或读取),但如果缓冲区已满但尚未遇到换行符,则数据仍会在缓冲区满时写入。
2、通常用于与终端(如命令行界面)的交互。因为行缓冲允许用户逐行地输入和查看数据,这在交互式应用中能比全缓冲提供更好的实时性和用户反馈。
五、重定向:
1、重定向原理:
其实重定向的本质就是修改 fd(文件描述符)下标对应的struct file* 内容,例如我们说的输出重定向就是将本该输出到A文件的数据输出到了B文件中。例如下面代码:
正常来讲"fd 1" 就是默认打开的标准输出流,此时我们可以使用printf向显示器打印数据,但此时我们如果关闭"fd 1",然后再打开一个文件"log.txt"后再使用printf打印的话,此时就能将要往显示器上输出的数据输出到log.txt中,因为我们后续打开 log.txt 文件时所分配到的文件描述符就是 1:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
close(1);
int fd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (fd < 0){
perror("open");
return 1;
}
printf("Hello!\n");
printf("Linux!\n");
return 0;
}
2、dup2系统调用:
想要完成重定向我们就只需要对fd_array数组当中的元素进行拷贝即可,Linux对于重定向也有一个接口:dup2,我们可以直接使用这个接口完成重定向的操作:
int dup2(int oldfd, int newfd);
dup2会将fd_array[oldfd]的内容拷贝至fd_array[newfd]中,我们可以先close(关闭)newfd,如果dup2调用成功则返回newfd,失败则返回-1。
需要注意的是:
- 如果oldfd不是有效的文件描述符则dup2调用会失败,且newfd的文件不会被关闭。
- 如果oldfd是一个有效的文件描述符的同时,oldfd = newfd 的话,dup2不做任何操作并返回newfd。
以如下代码为例:
#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <fcntl.h>
int main()
{
int oldfd = open("log.txt", O_WRONLY | O_CREAT, 0666);
if (oldfd < 0)
{
perror("oldfd error");
return 1;
}
close(1);
dup2(oldfd, 1);
printf("hello world!\n");
fprintf(stdout, "hello linux!\n");
// 往1中输出重定向为往log.txt文件中输出
return 0;
}
3、inode:
文件由文件内容和文件属性两部分构成,例如文件名,文件大小,文件创建时间等这些都是文件属性,文件属性又被称为元信息。在Linux操作系统中,文件的元信息和内容是分离存储的,其中保存元信息的结构称之为inode,也就是说inode是一个文件的属性集合,每个文件(包括目录)都有一个inode,为了区分系统中大量的inode,每个inode都有对应的inode编号。
使用ls -i命令可查看当前目录下的文件和文件的indoe编号:
文件内容和文件属性都存储于磁盘中。
六、计算机存储器:
概览:
存储器是计算机的核心部件之一,在完全理想的状态下,存储器应该要同时具备以下三种特性:
- 速度足够快:存储器的存取速度应当快于 CPU 执行一条指令,这样 CPU 的效率才不会受限于存储器
- 容量足够大:容量能够存储计算机所需的全部数据
- 价格足够便宜:价格低廉,所有类型的计算机都能配备
但是现实往往是残酷的,我们目前的计算机技术无法同时满足上述的三个条件,所以现代计算机的存储器设计采用了一种分层次的结构:
现代计算机里的存储器类型分别有:寄存器、高速缓存、主存、磁盘,存取数据速度越快的存储器容量一般越低,同时造价也会越昂贵,这里分层级讲解:
第一层是寄存器:
是存取速度最快的存储器,因为寄存器的制作材料和 CPU 是相同的,所以速度和 CPU 一样快,CPU 访问寄存器是没有时延的,然而因为价格昂贵,因此容量也极小,一般32位的CPU配备的寄存器容量为32 × 32 bit,64位的CPU则是64 × 64 bit,但是无论是32位还是64位的CPU,寄存器的容量都小于1KB,而且寄存器也必须通过软件自行管理。
第二层是高速缓存:
也就是我们平时了解的 CPU 高速缓存 L1、L2、L3,一般 L1 是每个 CPU 独享,L3 是全部 CPU 共享,而 L2 则根据不同的架构设计会被设计成独享或者共享两种模式之一,比如 Intel 的多核芯片采用的是共享 L2 模式而 AMD 的多核芯片则采用的是独享 L2 模式。
第三层则是主存:
也即主内存,通常称作随机访问存储器(Random Access Memory, RAM)。是与 CPU 直接交换数据的内部存储器。它可以随时读写(刷新时除外),而且速度很快,通常作为操作系统或其他正在运行中的程序的临时资料存储介质。
最后一层为磁盘:
磁盘和主存相比,每个二进制位的成本低了两个数量级,因此容量比之会大得多,为GB、TB,但是访问速度则比主存慢了大概三个数量级。机械硬盘速度慢主要是因为机械臂需要不断在金属盘片之间移动,等待磁盘扇区旋转至磁头之下,然后才能进行读写操作,因此效率很低。
磁盘:
重要的部件:
盘片:磁盘的核心存储介质,通常由多个重叠的金属圆片组成,表面涂有磁性物质。盘片从下往上进行编号,每个盘片都有两个面(称为磁面),每个面都可以存储数据。
磁头:用于读取或写入盘片表面数据的装置,磁头可以将数据转换成磁信号并写入盘片,或将盘片上的磁信号转换成数据。每个盘面的每个存储面都有一个对应的磁头。
数据读写:
磁盘通过磁头在旋转的盘片表面进行精确定位和读写操作来读写数据。当系统需要读取或写入数据时,磁盘控制器会驱动磁头组件移动到指定的磁道上,并选取相应的磁头。然后,磁盘会旋转直到目标扇区经过磁头下方,磁头会感应或写入扇区上的磁信号,完成数据的读写过程。
基本概念:
扇区:金属盘片被磁道划分成的若干个扇形区域,用以存储数据。 每个扇区可以存放512个字节的数据,磁盘驱动器在向磁盘读取和写入数据时,以扇区为单位。
磁道:盘片上以盘片中心为圆心,不同半径的同心圆。
柱面:硬盘中,不同盘片相同半径的磁道组成的圆柱。
这里简略带一下物理内存和虚拟内存:
物理内存:
物理内存就是上文中对应的第三种计算机存储器,RAM 主存,它在计算机中以内存条的形式存在,嵌在主板的内存槽上,用来加载各式各样的程序与数据以供 CPU 直接运行和使用。
虚拟内存:
计算机的虚拟内存是计算机系统内存管理的一种技术,它允许应用程序认为其拥有连续且完整的内存空间,而实际上这个空间可能由多个物理内存碎片组成,部分还可能暂时存储在外部磁盘存储器上。
虚拟内存技术通过将逻辑地址空间划分为固定大小的页或不同大小的段,并通过页表或段表实现逻辑地址到物理地址的映射,从而实现虚拟内存到物理内存的转换。当物理内存不足时,虚拟内存可以扩展内存容量,提高资源利用率,并支持更大的应用程序运行。从而提高系统的整体性能和稳定性。
七、动静态库:
软链接与硬链接:
软链接:其实通俗一点的讲:软链接就是创建一个快捷方式,其次软链接是一个独立的文件,因为其拥有独立的inode number,软链接的文件内容为:目标文件所对应的路径字符串。
硬链接:硬链接不是一个独立的文件,因为其没有独立的inode number,用的是目标文件的inode。硬链接就是建立了一个文件名和inode的映射关系,建立硬链接,就是在指定目录下,添加一个新的文件名和inode number的映射关系(硬链接实际上根本就没有新建文件,所以就不会有对应inode)相当于对文件的重命名。
(建立硬链接,文件的引用计数加一,当(进行删除)引用计数减少到0时,文件才会被删除)
文件的磁盘级引用计数:也就是文件的硬链接数,表示的是有多少个文件名字符串通过inode number指向它。
补充:任何一个目录新建时的引用计数一定是2,一个目录内有几个目录就是其的引用计数减二,Linux系统中不允许给目录建立新链接(避免形成路径环绕)
静态库与动态库:
库的概念:
库就是已经写好的、成熟的、可以直接使用的代码,例如C语言中的<stdio.h>库、C++中的<iostream>库、算法库<algorithm>等,从本质上来讲库是一种可执行代码的二进制形式,可以被操作系统载入内存执行。
静态库、动态库在链接阶段形成可执行文件时的过程也被称为静态链接和动态链接。
静态库:
静态库在链接阶段,会将汇编生成的目标文件.o与引用到的库一起链接打包到可执行文件中。因此对应的链接方式称为静态链接。
静态库的特点:
1、静态库对函数库的链接是放在编译时期完成的。
2、程序在运行时就不再需要静态库了,方便程序移植。
3、浪费空间和资源,因为所有相关的目标文件与关系到的函数库被链接合成一个可执行文件,例如:这个静态库占用1M内存,有一千个这样的程序的话,就会占用1GB的空间。
动态库:
动态库在程序编译时并不会被链接到目标代码中,而是在程序运行是才被载入。
不同的应用程序如果调用相同的库,那么在内存里只需要有一份该共享库的实例,规避了空间浪费问题。
其次,静态库还有一个问题是:静态库对程序的更新、部署和发布页会带来麻烦,例如静态库a.lib更新了,那么所有使用它的应用程序都需要重新编译再发布给用户,有可能是一个很小很小的改动,却要导致整个程序重新下载,全量更新。
所以动态库在程序运行被载入也解决了静态库对程序的更新、部署和发布页会带来麻烦。用户只需要更新动态库即可,增量更新。
动态库的特点:
1、动态库在内存中只存在一份拷贝,避免了静态库浪费空间的问题。
2、动态库可以实现进程之间的资源共享。(动态库也称为共享库)
3、对程序进行更新时只需更新动态库即可。
动态库与静态库的生成和使用方法讲解起来有点点多,所以就放在下一章节,这里就不多赘述哈。