操作系统 / 系统编程复习目录
- 一、进程概念
- 1. 冯诺依曼
- 1.1 外设
- 1.2 IO
- 1.3 数据流
- 1.4 存储分级 && IO效率
- 2. OS
- 2.1 作用:管理
- 2.2 管理:先描述,再组织
- 2.3 内存管理、进程管理、文件系统、驱动管理
- 3. 进程
- 3.1 什么是进程
- 3.2 为什么要有 PCB(task_struct)
- 3.3 task_struct 内容
- 3.4 PID / PPID,getpid() / getppid()
- 3.5 状态:R / S / Z / D / X / T
- 3.6 优先级
- 3.7 进程地址空间
- 3.7.1 是什么?
- 3.7.2 为什么?
- 3.7.3 怎么办?
- 二、进程控制
- 1. 进程创建
- 1.1 fork
- 1.2 返回值
- 1.3 写时拷贝
- 1.4 fork 目的
- 1.5 系统产生新进程的方式
- 1.6 创建进程的过程
- 2. 进程终止
- 2.1 进程退出场景
- 2.2 操作:_exit / exit / main return
- 2.3 进程终止系统做了什么
- 3. 进程等待
- 3.1 为什么要等待:内存泄漏
- 3.2 如何等待?- wait / waitpid
- 3.3 signal / exit code
- 3.4 阻塞等待 vs 非阻塞等待
- 4. 进程替换
- 4.1 替换原理
- 4.2 execl、execle、execlp、execv、execve、execvp
- 4.3 my shell
- 三、进程通信
- 1. IPC
- 2. System V IPC
- 3. 管道
- 4. 共享内存
- 5. 消息队列
- 6. 信号量
- 四、基础 IO
- 1. FILE*
- 2. 认识 fd
- 3. fd 的本质
- 3.1 数组下标:fd_array[]
- 3.2 fd 分配规则
- 3.3 dup、dup2
- 3.4 输出重定向,输入重定向,追加重定向的本质与操作
- 4. fd vs FILE*
- 4.1 包含关系
- 4.2 缓冲区与刷新方式
- 4.3 系统调用 write VS 库函数 fwrite
- 4.4 用户级缓冲区
- 5. 文件系统
- 5.1 磁盘
- 5.2 分区 vs 格式化
- 5.3 Block / Block Group / Super Block / Inode Bitmap / Inode Table / Data Blocks
- 5.4 inode 理解:文件=内容(data)+属性(inode)
- 5.5 软链接
- 5.6 硬链接
- 6. 动静态库
- 6.1 如何打包静态库?ar rc
- 6.2 如何打包动态库?gcc、g++ -shared、-fPIC
- 6.3 如何使用静态库?lib*.a
- 6.4 如何使用动态库?lib*.so
- 6.5 include lib
- 6.6 `-I / -L / -l`
- 6.7 动态库:LD_LIBRARY_PATH
- 五、信号
- 1. 信号概念
- 2. 信号产生的方式
- 3. 信号发送给进程的本质
- 4. core dump:核心转储
- 5. 信号相关概念
- 6. 信号相关操作
- 7. 信号的处理
- 8. 竞态条件
- 9. volatile
- 10. SIGCHLD,SIG_IGN
- 六、多线程
- 1. 线程理论
- 2. 线程控制
- 3. 线程同步与互斥
- 4. 其他概念
一、进程概念
1. 冯诺依曼
我们常见的计算机、笔记本、服务器,大部分都遵守冯诺依曼体系。
1.1 外设
- 输入单元:话筒,摄像头,键盘,鼠标,磁盘,网卡,…
- 中央处理器(CPU):运算器 && 控制器;
- 输出单元:声卡,显卡,网卡,磁盘,显示器,打印机,…
1.2 IO
程序在运行的时候,必须把程序先加载到内存,为什么?
- 冯诺依曼体系结构是这么规定的!
- 程序 -> 文件 -> 磁盘 -> 外设 -> 内存 -> CPU;
- 程序 -> 指令和数据 -> CPU;
- 在数据层面,CPU 只和 内存 打交道,外设 只和 内存 打交道。
1.3 数据流
在冯诺依曼体系中,数据流是指数据在计算机系统中的流动过程。以用户通过键盘输入数据并显示在显示器上为例,数据流的过程大致如下:
- 用户通过键盘输入数据,键盘作为输入设备将数据捕获并转换为电信号。
- 电信号通过接口电路传输到计算机内部,并被存储在内存中。
- CPU从内存中读取数据,并进行处理(如加密、编码等)。
- 处理后的数据再次被写入内存,并准备输出。
- 显示器作为输出设备从内存中读取数据,并将其转换为可视的图像显示在屏幕上。
1.4 存储分级 && IO效率
冯诺依曼体系中的存储系统通常被划分为多个层次,以满足不同速度和容量的需求:
- 距离 CPU 越近的存储单元,效率越高,造价贵,单体容量越小;
- 距离 CPU 越远的存储单元,效率越低,造价便宜,单体容量大;
- 内存看作一个非常大的缓存,介于 设备 和 CPU 之间,利用内存,把效率问题,转化成为了软件问题!
- 计算机的效率最终就变成了以内存效率为主;
- 利用高速缓存来减少对主存的访问次数,提高数据访问速度。
2. OS
任何计算机系统都包含一个基本的程序集合,称为操作系统(OS)。操作系统包括:
- 内核(内存管理、进程管理、文件系统、驱动管理);
- 其他程序(函数库、shell 程序、…)。
2.1 作用:管理
- 与硬件交互,管理所有的软硬件资源;
- 为用户程序(应用程序)提供一个良好(稳定、高效、安全)的执行环境。
2.2 管理:先描述,再组织
- 描述起来,用 struct 结构体;
- 组织起来,用链表或其他高效的数据结构。
- 把你对数据的管理场景转化成为:对特定数据结构的增删查改!
2.3 内存管理、进程管理、文件系统、驱动管理
内存管理:
- 内存分配:为新进程或进程中的新数据块分配内存空间。
- 内存回收:当进程结束或数据不再需要时,回收其占用的内存空间。
- 内存保护:确保每个进程只能访问自己被分配的内存区域,防止内存越界和非法访问。
- 内存映射:将磁盘上的文件或数据块映射到进程的地址空间中,实现快速访问。
- 内存交换(Swapping):当物理内存不足时,将部分不常用的内存数据交换到磁盘上,以释放内存空间。
进程管理:
- 进程创建:根据系统调用或程序启动请求创建新进程。
- 进程调度:按照一定的调度算法(如时间片轮转、优先级调度等)为进程分配CPU资源。
- 进程同步与通信:确保多个进程在并发执行时能够正确、有序地共享数据和资源。
- 进程终止:正常或异常地结束进程的执行,回收其占用的资源。
文件系统:
- 文件存储:将用户数据以文件的形式存储在磁盘上。
- 文件检索:根据文件名、路径等信息快速找到并访问文件。
- 文件保护:通过权限控制、加密等手段保护文件数据的安全性和完整性。
- 文件共享:允许多个用户或进程同时访问同一个文件。
- 文件系统的恢复与备份:在发生数据丢失或损坏时,能够恢复或备份文件系统中的数据。
驱动管理:
- 驱动加载与卸载:在系统启动时加载必要的驱动程序,并在不需要时卸载它们。
- 设备识别与配置:识别连接到系统的硬件设备,并根据配置信息设置其工作参数。
- 设备通信:通过驱动程序与硬件设备进行通信,实现数据的读写和控制。
- 错误处理:当硬件设备发生错误时,通过驱动程序向操作系统报告错误信息,并协助进行错误恢复。
3. 进程
3.1 什么是进程
- 课本概念:程序的一个执行实例,正在执行的程序…
- 内核观点:担当分配系统资源(CPU 时间,内存)的实体;
- 进程信息被放在一个叫做进程控制块的数据结构中,可以理解为进程属性的集合;
- 课本上称之为 PCB(process control block),Linux 操作系统下的 PCB 是
task_struct
;struct PCB { // 状态 // 优先级 // 内存指针字段 // 标识符 // ...包含进程几乎所有的属性字段 struct PCB* next; }
3.2 为什么要有 PCB(task_struct)
Linux 中的 PCB(task_struct)
- 在 Linux 中描述进程的结构体叫做
task_struct
; - task_struct 是 Linux 内核的一种数据结构,它会被装载到 RAM(内存)里,并包含进程的信息;
PCB 有什么用?
- 操作系统需要管理加载到内存的程序(进程),怎么管理?先描述,再组织!
- 进程 = 内核 PCB 对象 + 可执行程序!
- 未来,所有对进程的控制和操作,都只和进程的 PCB 有关,和进程的可执行程序没有关系!!
- 对进程的管理,转化为对 PCB 对象的管理,也就是对链表的增删查改!
3.3 task_struct 内容
- 标识符:PID(进程标识符)、TGID(线程组标识符)、UID/GID(用户/组标识符);
- 状态信息:状态标志、退出代码和信号;
- 优先级和调度信息:优先级、调度策略、调度实体;
- 链接信息:进程亲缘关系、双向循环链表;
- 内存和地址空间信息:内存管理信息、程序代码和数据指针;
- 上下文数据:寄存器状态、堆栈信息;
- I/O 状态信息:I/O 请求,包括分配给进程的 I/O 设备和正在被进程使用的文件列表;
- 文件和文件系统信息:文件描述符、文件系统状态;
- 其他信息…
3.4 PID / PPID,getpid() / getppid()
- PID:进程 ID;
- PPID:父进程 ID;
getpid()
/getppid()
:系统调用,获取进程标识符;fork()
:系统调用,创建子进程。
3.5 状态:R / S / Z / D / X / T
- R (Running):进程正在运行或者准备运行(即处于就绪队列中等待CPU);
- S (Sleeping):进程正在睡眠中,等待某个事件发生;
- Z (Zombie):僵尸进程。这是一个已经结束(terminated)的进程,但是其父进程还没有通过
wait()
或waitpid()
系统调用来读取它的结束状态。僵尸进程仍然保留在进程表中,但已经不再占用系统资源(除了进程表中的一个条目); - D (Disk Sleep):不可中断睡眠状态。这通常表示进程正在等待 I/O 操作,而且这个等待不能被信号中断。这种状态通常用于等待磁盘 I/O 操作的进程;
- X (Dead):死亡状态。这个状态只是一个返回状态,你不会在任务列表里看到这个状态;
- T (Stopped):进程被停止或追踪。一般通过发送 SIGSTOP 信号来停止进程,并且可以发送 SIGCONT 信号让进程继续运行。
3.6 优先级
ps -l # 查看进程信息
F S UID PID PPID C PRI NI ADDR SZ WCHAN TTY TIME CMD
0 S 1000 1670858 1670857 0 80 0 - 2069 do_wai pts/0 00:00:00 bash
0 R 1000 1670910 1670858 0 80 0 - 2202 - pts/0 00:00:00 ps
- UID:代表执行者的身份;
- PID:这个进程的代号;
- PPID:这个进程的父进程代号;
- PRI:优先级,值越小越早被执行;
- NI:进程的 nice 值;
PRI(Priority)and NI(Nice value)
- PRI 代表进程的优先级(Priority),是 Linux 内核用于决定先处理哪些进程的一个指标;
- 一般来说,PRI 的值越小,表示进程的优先级越高,越有可能被优先执行。
- NI 代表进程的 nice 值,是一个用于动态调整进程优先级的数值;
- nice 值的取值范围是 -20 到 19。其中,-20 表示最高优先级(即最低 nice 值),而 19 表示最低优先级(即最高 nice 值);
- 作用:nice 值通过影响 PRI 值来间接影响进程的优先级。具体来说,PRI 值等于某个基准值(如 120)加上 nice 值;
- 所以,在 Linux 下调整进程优先级,就是调整进程 nice 值!
nice # 用于在启动新进程时设置其nice值
renice # 用于更改已运行进程的nice值
3.7 进程地址空间
mm_struct
- mm_struct 是 Linux 内核中用于管理进程内存空间的一个关键数据结构,也被称为内存描述符(memory descriptor);
- 用于描述一个进程的虚拟地址空间,包括进程的内存映射情况、内存区域的属性、内存使用情况、页表等信息;
- 进程间共享内存空间,实际就是在共享 mm_struct!结构内部会使用引用计数,来记录当前有几个进程共享此空间,引用计数为 0 则销毁该结构。
3.7.1 是什么?
- 核心:以软件方式模拟内存!
- 进程地址空间是进程在运行时所拥有的一个独立的虚拟内存区域,它包含了进程运行所需的代码、数据、堆栈等;
3.7.2 为什么?
- 内存独占:每个进程都有自己独立的地址空间,这保证了进程间的内存隔离,防止了一个进程对另一个进程内存的非法访问;
- 保护内存:Linux为 每个内存区域(如代码段、数据段、栈等)设置了访问权限,进程只能按照设定的权限访问这些内存区域;而虚拟内存的设计防止了进程直接访问物理内存,增加了系统的安全性和稳定性;
- 统一布局:Linux 进程地址空间具有统一的结构和布局(代码段、数据段、堆、栈、…),这种设计简化了内存管理,提高了系统的运行效率。
3.7.3 怎么办?
- 虚拟地址:是程序运行时所使用的地址,对程序来说是透明的,每个进程都认为自己拥有完整的地址空间。虚拟地址不是直接映射到物理内存上的,而是通过一系列的机制(如页表)来间接访问物理内存;
- 物理地址:是真实存在的内存地址,用于 CPU 直接访问物理内存。物理地址由内存管理单元(MMU)将虚拟地址转换而来;
- 页表:是虚拟地址到物理地址的映射表,存储了虚拟页号(VPN)到物理页号(PPN)的映射关系。每个进程都有自己独立的页表,确保了进程的独立性和隔离性;
- 页表项(PTE):页表中的每一个条目称为页表项,包含了有效位、物理页号等信息。有效位用于标识该虚拟页是否已在物理内存中,物理页号则指向实际的物理内存地址;
- r(Read):读属性。在页表中,每个页表项(PTE)可以包含读权限标志位。如果设置了读权限(r=1),则允许对该页进行读取操作;如果未设置(r=0),则尝试读取该页将引发异常;
- w(Write):写属性。与读属性类似;
- readonly:只读属性。这不是页表项中的一个直接属性,但可以通过设置页表项的读权限(r=1)和清除写权限(w=0)来实现;
- 脏页:当页的内容在内存中被修改后,该页被视为“脏”的,直到其内容被写回磁盘;
- 多级页表:当虚拟地址空间非常大时,使用单级页表会导致页表过大,难以管理。因此,引入了多级页表结构。多级页表将虚拟地址空间划分为多个层次,每一级页表都指向下一级页表的地址或物理页的地址;
- 页帧/页框(4KB):在物理内存中,页帧(或页框)是指用于存储页内容的连续内存块。页表项中存储的是页帧的物理地址,用于将虚拟地址映射到物理地址。
- MMU(内存管理单元):是负责虚拟地址到物理地址转换的硬件单元。当 CPU 执行指令时,会产生一个虚拟地址,这个地址被传递给 MMU。MMU 通过查询页表,找到对应的物理地址,并将其返回给 CPU 进行内存访问。
- 正常转化:CPU 生成虚拟地址 -> MMU 查询页表 -> 构建物理地址 -> CPU 访问物理内存;
- 错误转化:缺页异常,权限检查;
- 映射机制:通过页表和 MMU 的协作,实现了虚拟地址到物理地址的映射。这种映射机制确保了进程的独立性和隔离性,同时也提高了内存的使用效率和安全性;
- 动态映射:在程序运行过程中,系统可以根据需要动态地加载和卸载内存页,实现了对内存资源的有效管理。这种动态映射机制也支持了虚拟内存的实现,使得程序可以访问比物理内存更大的内存空间。
虚拟地址空间布局问题
- 虚拟地址空间通常被划分为用户空间(User Space)和内核空间(Kernel Space)两部分;
- 用户空间通常包含:代码段、数据段、BSS段、堆、栈、件映射和匿名映射段;
- 采用多级页表结构来减少内存的使用并提高页表查找的效率;
- 页表项中存储的是页帧的物理地址,用于将虚拟地址映射到物理地址。
二、进程控制
1. 进程创建
1.1 fork
#include <unistd.h>
pid_t fork(void);
fork()
是 Linux 中很重要的一个函数,它可以从已存在的进程中创建一个新进程,新进程为子进程,而原进程为父进程;
进程调用 fork(),当控制权转移到内核中的 fork 代码后,内核会:
- 分配新的内存块和内核数据结构给子进程;
- 将父进程部分数据结构内容拷贝给子进程;
- 添加子进程到系统进程列表当中;
- fork 返回,开始调度器调度…
1.2 返回值
- 子进程中返回 0;
- 父进程中返回 子进程的 pid;
- 出错返回 -1。
1.3 写时拷贝
- 通常,父子代码是共享的;
- 父子在不写入时,数据就是共享的,当任意一方试图写入,就会以写时拷贝的方式,各自持有一份副本;
1.4 fork 目的
- 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如:父进程等待客户端请求,生成子进程来处理请求;
- 一个进程要执行一个不同的程序。例如:子进程从 fork 返回后,调用 exec(进程替换)函数;
- 守护进程也会用到 fork。
1.5 系统产生新进程的方式
- 操作系统直接创建,如:系统进程;
- 由父进程创建:
fork()
:通过复制当前进程(父进程)来创建一个新的子进程。子进程会继承父进程的大部分属性和数据,但拥有独立的进程ID(PID)和内存空间;vfork()
:与 fork() 类似,但子进程会共享父进程的内存空间,并且父进程会被阻塞,直到子进程调用 exec() 或 exit();clone()
:这是一个更灵活的系统调用,允许在创建新进程时选择性地共享父进程的资源;exec()
系列函数:虽然 exec() 本身不直接创建新进程,但它可以将当前进程替换为新的可执行文件,从而间接地创建了一个新的执行环境;- …
1.6 创建进程的过程
- 系统调用;
- 分配进程标识符(PID);
- 创建进程控制块(PCB);
- 复制或共享资源;
- 初始化新进程;
- 调度新进程;
- 执行新程序(可选);
- 父进程和子进程的交互。
2. 进程终止
2.1 进程退出场景
- 正常:程序执行完毕,或遇到特定的退出指令(return),进程会正常结束;
- 代码结束结果不对;
- 异常:程序执行过程中遇到无法处理的错误或异常,导致进程非正常终止。例如,访问非法内存地址、除零错误等;
- exit code(退出码):是进程终止时返回给操作系统的一个整数值,用于表示进程的执行结果或状态。退出码为
0
通常表示成功或正常退出,而非零值则表示某种形式的错误或异常情况。
2.2 操作:_exit / exit / main return
_exit
#include <unistd.h>
void _exit(int status);
参数: status定义了进程的终止状态, 父进程通过wait来获取该值
- _exit 直接通过系统调用进入内核,终止进程;
- 立即终止进程,不执行任何清理操作;
- 调用 _exit 后,进程占用的资源将被操作系统回收。
exit
#include <unistd.h>
void exit(int status);
- exit 是一个 C 库函数,用于终止当前进程;
- 执行清理操作,包括调用退出处理程序和刷新 I/O 缓冲区;
- 最后调用 _exit 终止进程。
return
- return 是一种更常见的退出进程方法;
- 执行
return 0
等价于exit(0)
,因为调用 main 的运行时函数会将 main 的返回值当作 exit 的参数。
2.3 进程终止系统做了什么
- 终止遗留线程,如“孤儿”线程;
- 释放资源:系统会释放进程所分配的所有资源,包括内存、文件描述符、内核对象等;
- 执行清理操作:例如,刷新标准 I/O 流的缓冲区,确保所有待输出的数据都被写出;
- 设置进程状态:系统会将终止的进程设置为僵死状态,直到其父进程通过某种方式(wait、waitpid)回收其资源并获取其退出状态;
- 进行 CPU 再分配:将 CPU 分配给其他等待运行的进程。
3. 进程等待
3.1 为什么要等待:内存泄漏
- 子进程退出,父进程如果不管不顾,就可能造成僵尸进程的问题,进而造成内存泄漏;
- 并且我们无法再去杀死一个僵尸进程;
- 父进程也需要通过子进程的退出码,了解子进程的执行情况;
- 父进程就通过进程等待的方式,回收子进程资源,获取子进程退出信息。
3.2 如何等待?- wait / waitpid
wait
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
返回值:
成功返回被等待进程pid, 失败返回-1
参数:
输出型参数, 获取子进程退出状态, 不关心则可以设置为NULL
wait pid
pid_t waitpid(pid_t pid, int* status, int options);
返回值:
正常返回时, waitpid返回收集到的子进程的进程ID
如果设置了选项WNOHANG, 而调用中waitpid发现没有已退出的子进程可收集, 返回0
如果调用中出错, 则返回-1, 这时errno会被设置为对应的错误值
参数:
pid:
pid = -1, 等待任意一个子进程, 类似wait
pid > 0, 等待进程ID与pid相等的子进程
status:
WIFEXITED(status): 若子进程为正常终止, 则为真。(查看进程是否正常退出)
WEXITSTATUS(status): 若WIFEXITED为非零, 提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束, 则waitpid()函数返回0, 不予以等待。 若正常结束, 则返回该子进程的ID
注意事项:
- 如果子进程已经退出,调用 wait / waitpid 时,会立即返回并释放资源,获得子进程的退出信息;
- 如果调用 wait / waitpid 时,子进程存在且正常运行,则进程可能阻塞;
- 如果不存在该子进程,则立刻出错返回。
3.3 signal / exit code
- Signal 是一种进程间通信机制,用于通知进程发生了某个事件;
- Exit Code 是进程结束执行时返回给操作系统的一个整数值,用于表示进程的退出状态;
- Signal 用于进程间的异步通信,通知进程发生了某个事件;而 Exit Code 用于表示进程的退出状态,供父进程判断子进程的执行结果;
- Signal 可以在进程执行的任何时刻由系统或其他进程产生;而 Exit Code 只在进程结束执行时产生;
- …
3.4 阻塞等待 vs 非阻塞等待
阻塞等待
- 挂起线程:在等待期间,当前线程无法执行其他操作,必须等待条件满足;
- 资源占用:阻塞的线程会占用系统资源,包括 CPU 调度时间片等,直到它被唤醒;
- 性能影响:在高并发场景下,大量的阻塞等待可能导致系统资源耗尽,影响程序的性能和响应速度。
非阻塞等待
- 线程继续执行:在等待期间,当前线程可以执行其他操作,提高了程序的并发性和响应速度;
- 轮询机制:非阻塞模式通常需要程序自己实现轮询机制来检查条件是否满足;
- 复杂度高:与阻塞模式相比,非阻塞模式的编程复杂度更高,需要处理更多的逻辑和状态管理。
4. 进程替换
4.1 替换原理
- 父进程用
fork()
创建子进程后,子进程可以通过调用exec()
系列函数以执行另外一个程序; - 当子进程调用 exec() 函数时,该进程的代码和数据完全被新程序替换,替换完成后,新程序将在当前进程的上下文中开始执行;
- 调用 exec() 并不创建新进程,所以调用 exec() 前后该进程的 id 并未改变。
会不会创建新进程
- 不会!
- 创建一个进程,是先创建PCB、地址空间、页表等,再把程序加载到内存;
- 而程序替换所做的本质工作,就是加载!
后续代码如何处理
- 程序替换一旦成功,原进程的后续代码将不再执行,代码和数据都会被丢弃!
- 如果 exec 调用失败(即返回 -1),那么原程序将继续执行 exec 之后的代码。
4.2 execl、execle、execlp、execv、execve、execvp
#include <unistd.h>
int execl(const char* path, const char* arg, ...);
int execlp(const char* file, const char* arg, ...);
int execle(const char* path, const char* arg, ..., char* const envp[]);
int execv(const char* path, char* const argv[]);
int execvp(const char* file, char* const argv[]);
int execve(const char* path, char* const argv[], char* const envp[]);
函数解释
- 这些函数如果调用成功,则执行新的程序,不再返回;
- 如果调用出错,返回 -1,继续执行原程序;
- exec() 只有出错的返回值,没有成功的返回值。
命名理解
- l(list):表示参数采用列表;
- v(vector):参数采用数组;
- p(path):自动搜索环境变量PATH;
- e(env):表示自己维护环境变量。
4.3 my shell
实现一个简易 shell …
三、进程通信
1. IPC
2. System V IPC
3. 管道
4. 共享内存
5. 消息队列
6. 信号量
四、基础 IO
1. FILE*
- FILE* 是 C 语言中用于文件操作的一个非常重要的指针类型,FILE* 指向一个 FILE 类型的对象,这个对象包含了进行文件操作所需的信息;
- 在 C 语言中,几乎所有的文件操作(打开文件、读取文件、写入文件、定位文件指针、关闭文件等)都是通过 FILE* 类型的指针来进行的;
C 中的文件操作
-
打开文件:使用
fopen()
函数打开文件,该函数返回一个FILE*
类型的指针,指向打开的文件。如果文件打开失败,则返回 NULL;FILE *fp = fopen("example.txt", "r"); // 打开文件以只读模式 if (fp == NULL) { // 错误处理 }
-
读写文件:使用如
fgetc()
、fgets()
、fputc()
、fputs()
、fread()
、fwrite()
等函数进行文件的读写操作;char buffer[100]; if (fgets(buffer, 100, fp) != NULL) { // 成功读取一行 } fputs("Hello, World!", fp); // 写入文件
-
定位文件指针:使用
fseek()
、ftell()
、rewind()
等函数可以移动文件指针到指定位置、获取当前文件指针的位置或重置文件指针到文件开头;fseek(fp, 0, SEEK_END); // 将文件指针移动到文件末尾 long pos = ftell(fp); // 获取当前文件指针的位置 rewind(fp); // 将文件指针重置到文件开头
-
关闭文件:使用
fclose()
函数关闭文件;fclose(fp);
2. 认识 fd
- 文件描述符(File Descriptor,简称 fd)是一个非负整数;
- fd 用于在操作系统中唯一标识一个 打开的文件 或 其他输入 / 输出资源(如管道、套接字等);
- Linux 进程默认会打开三个文件描述符,分别为标准输入 0、标准输出 1、标准错误 2;
3. fd 的本质
3.1 数组下标:fd_array[]
- 文件描述符(fd)的本质就是数组下标!
- 操作系统要管理我们打开的文件,就是创建了相应的数据结构(file 结构体)来描述目标文件,然后把它们组织起来(
files_struct
); - 进程中包含了一个指针(
*files
)指向files_struct
这张表,这张表内部包含了一个指针数组(file* fd_array[]
),其中每个元素都是一个指向已打开文件的指针; - 所以,文件描述符就是该指针数组(
file* fd_array[]
)的下标,只要拿着文件描述符,就可以找到对应的文件。
3.2 fd 分配规则
- 最小的没有被使用的数组下标,会分配给最新打开的文件!
3.3 dup、dup2
- 在 Linux 系统编程中,
dup()
和dup2()
是两个非常有用的系统调用,它们用于复制文件描述符;
dup()
#include <unistd.h>
int dup(int oldfd);
参数:
oldfd: 是想要复制的文件描述符
返回值:
成功时,返回一个新的文件描述符(非负整数),这个新描述符是oldfd的副本
出错时,返回-1,并设置errno以指示错误
- dup() 系统调用用于创建一个新的文件描述符,该描述符是调用进程中某个现有文件描述符的副本。新文件描述符与原始文件描述符指向相同的打开文件,共享相同的文件偏移量、文件状态标志和文件模式;
- dup() 的主要用途是当需要额外的文件描述符来引用同一文件时,或者是在进行文件描述符重定向时。
dup2()
#include <unistd.h>
int dup2(int oldfd, int newfd);
参数:
oldfd:是想要复制的文件描述符
newfd:是新的文件描述符的数值
返回值:
成功时,返回newfd
出错时,返回-1,并设置errno以指示错误
- dup2() 系统调用类似于dup(),但它允许调用者指定新文件描述符的数值。如果指定的新文件描述符 newfd 已经打开,则 dup2() 会先关闭它,然后再创建 oldfd 的副本;
- dup2() 主要用于重定向一个文件描述符到另一个已存在的文件描述符上,这在处理文件描述符时提供了更大的灵活性。例如,在子进程中重定向标准输出(stdout,文件描述符为1)到一个文件或管道。
3.4 输出重定向,输入重定向,追加重定向的本质与操作
- 输出重定向是指将原本应该输出到屏幕(通常是标准输出STDOUT,文件描述符为1)的数据信息写入到指定的文件中;
- 输入重定向是指将原本应该从标准输入(STDIN,文件描述符为0)读取的数据来源改为从指定的文件中读取;
- 追加重定向与输出重定向类似,但它不会覆盖目标文件的内容,而是将新的数据追加到文件的末尾。
重定向类型 | 符号 | 本质 | 操作示例 |
---|---|---|---|
输出重定向 | > | 将标准输出重定向到文件 | command > file.txt |
错误输出重定向 | 2> | 将错误输出重定向到文件 | command 2> error.log |
输入重定向 | < | 将标准输入重定向到文件 | command < file.txt |
追加重定向 | >> | 将输出追加到文件末尾 | command >> file.txt |
4. fd vs FILE*
4.1 包含关系
- 因为 IO 相关函数与系统调用接口对应,并且库函数封装系统调用,所以本质上,访问文件都是通过
fd
访问的; - 所以 C 库中的 FILE 结构体内部,必定封装了
fd
!
4.2 缓冲区与刷新方式
- 调用系统调用是有时间成本的,缓冲区设计是一种以空间换时间的方法;
- C 库函数(
printf
、fwrite
)会自带缓冲区,而系统调用(write
)没有缓冲区; - 库函数在系统调用的“上层”,是对系统调用的封装,所以缓冲区就是在封装时被加上的,由 C 标准库提供;
- 我们这里说的缓冲区都是用户级缓冲区。
刷新方式
- 缓冲区会在特定条件下被刷新,例如:缓冲区写满、遇到换行符、显式调用
fflush
函数等; - 进程退出的时候,也会自动刷新缓冲区!
4.3 系统调用 write VS 库函数 fwrite
- write 系统调用:是 Linux 系统中的一个系统调用,用于将数据写入文件描述符指向的文件。它工作在较低级别,不涉及用户级缓冲;
- fwrite 库函数:是 C 标准库中的函数,用于向 FILE* 指定的文件写入数据块。fwrite 操作会利用 FILE* 结构体中的缓冲区,并在内部使用 write 系统调用来将数据从缓冲区刷新到文件中。
4.4 用户级缓冲区
- 我们常说的缓冲区一般都是指用户级缓冲区,它是语言层面的缓冲区,C 语言自带缓冲区;
- 使用
FILE*
时,用户级缓冲区由标准 I/O 库自动管理; - 使用
文件描述符(fd)
时,虽然内核中可能也存在缓冲区,但这些缓冲区对用户透明,用户无法直接控制,所以不在我们讨论范围之内。
5. 文件系统
5.1 磁盘
- 系统中大部分的文件都是没有被打开的,它们保存在磁盘(SSD)中;
- OS 需要管理磁盘上的文件,那么如何在磁盘上快速定位一个文件?通过 CHS 定位法!
- 通过磁头定位:磁道 / 柱面 - Cylinder
- 使用哪一个磁头 - Head
- 哪一个扇区 - Sector
- 那么任何一个文件,不就是多个扇区承载的数据吗?
- 把每个扇区(4KB),简单理解为一个数组的元素,那么操作系统对磁盘的管理,就变成了对数组的增删查改!
5.2 分区 vs 格式化
分区:将硬盘或其他存储设备划分为一个或多个逻辑区域的过程。每个分区都被视为一个独立的存储设备,拥有自己的文件系统和存储空间。
- 方便数据组织和管理;
- 减少文件碎片,提升访问速度;
- 提高系统安全和稳定,某个分区出问题,其他分区不受影响;
- 支持多操作系统,可以在不同分区安装多个操作系统;
- 简化备份和恢复过程。
格式化:指在分区上创建文件系统的过程。它主要是创建文件系统的结构和元数据信息,为分区提供一个可读写的文件系统,使操作系统能够有效地与硬盘交互。
- 创建一个可用于存储数据的文件系统结构;
- 初始化分区的元数据,如文件节点表、目录项等;
- 清理分区上的数据,为新的数据存储做准备。
5.3 Block / Block Group / Super Block / Inode Bitmap / Inode Table / Data Blocks
- Block:是文件存取的最小单位,也是操作系统读取硬盘时的基本单位;
- Block Group:ext2 文件系统会将分区划分为数个 Block Group,方便对其进行管理;
- Super Block(超级块):存放文件系统本身的结构信息,如果 Super Block 的信息被破坏,整个文件系统结构就被破坏了;
- Inode Bitmap:是一个位图,每个 bit 位表示一个 inode 是否空闲可用;
- Inode Table:存放文件属性(文件大小、所有者、最近修改时间等);
- Data Blocks:存放文件内容。
5.4 inode 理解:文件=内容(data)+属性(inode)
inode
就是文件系统中的一个数据结构,其中存储了 除文件名和数据内容之外 的所有文件或目录的信息;- 每个文件或目录都有一个唯一的 inode,通过 inode 可以快速定位和管理文件;
- 可以使用
ls -i
查看文件/目录的 inode;
$ touch fileT
$ ls -i
263466 fileT
解释一下上图中创建新文件所需的 4 个操作
- 存储属性:内核先找到一个空闲的 inode(假设是 263466),并把文件信息记录到其中;
- 存储数据:假设这个文件需要占用三个磁盘块(Block),内核找到三个空闲块:300、500、800,将内核缓冲区中的内容复制到其中;
- 记录分配情况:内核在 inode 中的磁盘分布区记录了文件所占用的块列表;
- 添加文件名到目录:我们刚才创建的文件名为 fileT,Linux 如何在当前目录中记录这个文件?内核将(263466,fileT)这组数据添加到目录文件中;
这样一来,文件名 和 inode 之间的对应关系,就将文件名和文件内容及属性连接起来了;
但是我们上面说道 文件=内容(data)+属性(inode),又该如何理解?
inode
中是不包含 文件名 和 文件内容 的,那么一个文件应该=文件名+内容+属性才合理呀;- 实际上目录也是一个文件(Linux 下一切皆文件)!目录中存储的内容(data)正是该目录下 文件名 与 inode 之间的映射关系!
- 所以我们之前对于文件名的理解是不全面的,文件名 与 inode 的映射关系已经被存储在目录中了,它们是一体的!
5.5 软链接
- 独立文件,有独立的 inode
- 软链接本质是一个独立文件,这个文件中保存了 目标文件的路径!
- 在访问软链接时,系统会解析软链接中存放的路径,并使用这个路径访问被链接的文件;
- 可以对目录创建软链接;
- 软链接主要用于在不同位置快速访问文件或目录。
ln -s
$ touch test.txt $ ln -s test.txt link.soft # 创建软链接 $ ls -l total 0 lrwxrwxrwx 1 ubuntu ubuntu 8 Aug 22 23:05 link.soft -> test.txt -rw-rw-r-- 1 ubuntu ubuntu 0 Aug 22 15:27 test.txt
- 使用
ln -s
命令为 test.txt 创建一个软链接;
- 使用
- 快捷方式
- 软链接类似于 Windows 下的快捷方式!
5.6 硬链接
-
非独立文件,没有独立的 inode
- 硬链接本质就是在指定的目录下,插入新的 文件名与目标文件的映射关系,并让 inode 的 引用计数++;
- 在磁盘上,找到文件靠的不是文件名,而是 inode,在 Linux 上允许将多个文件名对应(硬链接)于同一个 inode;
- 不能对目录创建硬链接(因为这会引入循环引用的复杂性和安全问题);
-
ln
$ touch test.txt $ ln test.txt link.hard # 创建硬链接
- 使用
ln
命令为 test.txt 创建一个硬链接; - 现在 test.txt 和 link.hard 是同一个 inode 的文件名,这两个文件名代表同一个文件;
- 一个 inode 对应的所有文件名(硬链接)都被删除,文件内容才会被删除(引用计数思想);
- 使用
-
ls -l
$ ls -l total 0 -rw-rw-r-- 2 ubuntu ubuntu 0 Aug 22 15:27 link.hard -rw-rw-r-- 2 ubuntu ubuntu 0 Aug 22 15:27 test.txt ^ 表示该文件有2个硬链接
- 判断一个目录下有多少个子目录:硬链接数 -2 即可得到!