编辑
2025-07-23
个人笔记
00

目录

进程
进程管理API
进程的地址空间
文件
访问操作系统中的对象
Shell
什么是终端
启动
Session和Process Group——如何优雅地终止当前进程
Unix Shell
Libc——C语言标准库和实现
动态内存管理
可执行文件
ELF
静态链接和加载
操作系统和加载器
动态链接和加载
实现动态链接
操作系统的Tricks
构建应用生态
  • 虚拟化
    • ==进程,操作系统为程序提供了一个虚拟环境,让程序 “好像” 独占了CPU==

进程

  • 除了程序状态,还会保存一些额外的(只读)状态

进程中的程序

  • 程序是状态机的静态描述
    • 描述了在程序运行时,所有可能的程序状态
    • 程序运行,就变成了进程

进程管理API

  • 创建状态机
    • Windows系统
      • 创建状态机spawn(path, argv)
      • 销毁状态机_exit()
    • UNIX
      • 复制状态机fork()
        • 会 ==完整地拷贝状态==(寄存器&每一个字节的内存)
        • 包括这个进程在操作系统中的状态(ppid,文件,信号)
        • 区分
          • 新创建进程返回 0
          • 执行 fork 的进程返回子进程的进程号——“父子关系”
      • ==复位状态机execve()== UNIX 选择只给一个复位状态机的 API
        • 将当前进程重置成一个可执行文件描述状态机的初始状态
        • 操作系统维护的状态不变:进程号、目录、打开的文件……
          • (程序员总犯错,因此打开文件有了 O_CLOEXEC)
      • 销毁状态机
        • 立即摧毁状态机,允许有一个返回值
        • 返回值可以被父进程获取

进程的地址空间

  • 程序的初始状态
  • Memory Map 系统调用,分配内存给予程序
    • mmap()
      • 在状态机状态上增加一段可访问的内存
      • 把物理磁盘地址,映射到虚拟地址空间。让进程通过虚拟地址,间接操作磁盘文件
    • munmap()
      • 释放
    • mprotect()
      • 修改
  • 入侵进程的地址空间

文件

访问操作系统中的对象

==In UNIX, everything is a file== 文件——有"名字"的数据对象

  • 字节流

  • 字节序列

  • File Descriptor 文件描述符

    • FD是操作系统为进程维护的一个int索引值
    • 一个进程的所有FD,被私有地存放在进程的 文件描述符表。在这里,文件描述表相当于一个==struct file的数组==,而fd则是这里的idx。而struct file则是存储文件信息的结构体
    • 但是FD的所有操作都是借助OS API进行的
    • 操作
      • open()
        • 在FD表中,找一个最小IDX的空闲位置,分配一个新的FD
      • write() & read()
        • 通过FD找到对应struct file中的f_pos(文件偏移量),读取偏移count个字节
      • dup()
        • 先和open()一样分配一个FD,然后复制指定FD的内容
  • offset 文件偏移量

    • f_pos,Linux下的offset
      • 无论是在dup()中,还是在fork()中,offset都是继承的。
      • 为了便利开发者选择继承机制,后来引入了fcntl()
      • 共用一个文件的同一个offset
  • Handle,Windows下的FD - 在Windows中,handle默认是不继承的(面向工程的设计,=="最小权限原则"==)

  • pipe() 管道

    • 返回两个FD
      • write port
      • read port
    • 父子进程实现同步
  • Unix中的管道符" | "实现

  • FD适合什么

    • 字节流,顺序读写,无数据等待
    • 而对于字节序列
  • In UNIX, everything is a file

    • 优点
    • 一套API访问所有对象
      • 一切都可以 | grep
    • 缺点
      • 耦合

Shell

什么是终端

  • 终端原理

    • 作为输出设备
      • 接受 UART 信号并显示 (Escape Sequence 就非常自然了)
    • 作为输入设备
      • 把按键的 ASCII 码输出到 UART (所以有很多控制字符)
  • 历史上的终端——电子打字机VT100

    • 同时作为输入和输出设备,打字到上面,从上面看见
  • 今天的伪终端

    • 通过一个pipe实现双向通信
      • 主设备 PTY Master
        • 连接到终端模拟器(就是现在的终端)
      • 从设备 PTY Slave
        • 连接到shell应用(zsh、fish、bash)或者其他程序(vim)

[!NOTE] 终端模拟器 & Shell应用 两者的关系类似硬件和软件,终端模拟器负责键盘输入和屏幕显示。而Shell应用负责指令处理。 在对终端输入指令的时候,终端会把收到的信息传到Shell中,Shell处理后的信息会发送到终端上(也就是Pipe在此处的作用,对接两者的FD stdin和stdout)

  • 终端与应用的配对 终端与应用并不是天然固定连接的(比如终端就是Bash这样),而是负责一个屏幕和键盘的IO功能(输入/输出),也就是可以像一个屏幕连个键盘一样,在上面打开各种应用程序(Bash,Vim),并进行输入

    • 配对原理:[[用户登录的起点涉及系统启动和远程登录的底层机制,主要通过进程间通信和终端管理实现。以下是详细解释]]
  • 常用的伪终端

    • shh,tmux.......
    • 创建 openpty()
      • 通过/dev/ptmx 申请一个新终端 - 返回两个FD%% 文件描述符 %%(master/slave)

启动

  • 用户登录的起点
    • 系统启动
      • frame加载内核,创建第一个进程,调用init程序agetty,并且调用login接口执行登录程序
    • 远程登录
      • sshd收到请求,fork一个子进程,打开一个新的终端,修改stdin/stdout/stderror的FD指向新的终端
    • VSCode终端

Session和Process Group——如何优雅地终止当前进程

在我们对当前终端上的前台应用使用Ctrl + C强制关闭时,我们需要关闭前台应用的所有进程,但是又不能影响后台的进程。也就是说,我们需要一个区分应用进程的方法——==Session和Process Group==

  • Session ID,大分组
    • 子进程会继承父进程的 Session ID
    • 一个 Session 关联一个控制终端 (controlling terminal)
    • Leader 退出时,全体进程收到 Hang Up (SIGHUP)
  • Process Group,小分组
    • 只能有一个前台进程组
    • 操作系统收到 Ctrl-C,向前台进程组所有进程发送 SIGINT

Unix Shell

![[Pasted image 20250401093521.png]]

Libc——C语言标准库和实现

“非必要不实现”(“机制与策略分离”、最小完备性原则) 第一级抽象

  • 包装
    • OS->syscall->libc->sh/cat/gcc/python3

动态内存管理

  • malloc基于系统API mmap()
    • mmap一次只能分配一大段内存,对于小内存分配, 只能由程序自己通过实现一个数据结构来实现

可执行文件

ELF

对于ELF,本质上是可执行文件的一个==数据载体==,通过在系统内核中的加载器执行

[!quote] ELF 是 Executable and Linkable Format 的缩写,是 Linux 系统上的一种可执行文件格式。

  • ELF文件中主要包含的信息

    • 基本信息(版本、体系结构)
    • 内存布局
    • 其他(调试信息、符号表)
  • ELF内容的数据结构

    • 由二进制字符构成
    • 主要通过offset来组成数据结构,机器友好,人类不友好(人类更适合平坦的信息)
  • Core Dump(核心转储)

    • 程序崩溃时,操作系统自动生成的一个文件,记录了程序崩溃瞬间的内存状态、寄存器信息、调用栈等关键数据。它类似于程序崩溃时的“内存快照”,帮助开发者定位和调试问题。
    • 通过Core Dump,可以在gdb中复现程序奔溃

静态链接和加载

学前读物,宝宝读了都说好(迫真)结合CSAPP的内容——[[静态链接与加载]]

[!tip] #### More than “可执行文件”

  • E = Excutable
  • L = Linkable ->
  • F = File
  • 链接到加载的三要素

    • 代码
    • 符号
    • 重定位
  • 需要的工具

    • objdump/readfle/nm (显示)
    • cc/as (编译)
    • ld (链接)

操作系统和加载器

[!tip] 小彩蛋 UNIX系统中通过注释#!加载和执行脚本语言


动态链接和加载

  • 对比动态链接与静态链接

对比维度静态链接动态链接
链接时间编译时完成(编译阶段一次性链接所有依赖库)运行时完成(程序启动时或函数调用时动态加载库)
文件大小可执行文件较大(包含所有库代码)可执行文件较小(仅包含库引用,依赖外部库)
内存占用高(每个进程独立加载库代码,无法共享)低(多个进程共享同一份库代码,仅数据段独立)
启动时间快(无需运行时解析和加载库)较慢(需加载和解析库,但可通过延迟绑定优化)
符号解析编译时完成符号解析和重定位运行时动态解析符号(如Linux的PLT/GOT机制,Windows的延迟绑定)
依赖管理无外部依赖(自包含,无需额外库文件)依赖外部库文件(需确保库版本兼容性和存在性)
程序更新维护需重新编译整个程序才能更新库代码(维护成本高)可独立更新库文件,无需重新编译程序(维护灵活,但需处理版本兼容性)
安全性更安全(无外部依赖,避免库劫持风险)存在风险(如DLL劫持、路径劫持、版本冲突等)
适用场景嵌入式系统、资源受限环境、需要独立部署的程序(如关键基础设施、恢复工具)通用应用程序、服务器环境、需要频繁更新的程序(如Web服务、容器化应用)
代码共享性代码不共享(每个程序独立携带库代码)代码共享(所有进程共享库代码段,节省内存)
版本兼容性无版本冲突(库代码固定在可执行文件中)可能引发版本冲突(需依赖系统库的兼容性管理,如Windows的Side-by-Side机制)
性能优化无运行时开销(符号解析已完成)存在轻微运行时开销(动态加载和解析,但可通过缓存优化)
  • 拆解应用程序——应用之间的库共享
    • 可以实现运行库与应用分开独立升级
  • 大型项目的拆解
    • 改一部分代码不用重新链接2GB的文件
  • "Dependence Hell"

实现动态链接

[[动态链接]]

  • 读jyy的ld代码

  • 共享库的加载

    • 动态链接器
      • 所有动态链接库,都由lib64/ld-linux-x86-64.so.2这个程序加载

    [!tip]

    • 一个动态链接的程序,不能直接从他的a.out开始执行,因为如果从a.out开始执行就需要执行库函数了
    • a.out执行的时候,libc还没有加载
    • libc 是用 mmap 系统调用加载的
    • libc的加载步骤
      1. 内存映射 (mmap)
        • 多进程共享 libc 的机制
      2. 符号解析
      3. 重定位

[!important] 多进程共享 libc 的机制libc加载到内存后,多线程共享使用libc的两种数据,只读数据可读写数据

  • 只读数据 -> 只读共享
  • 可读写数据 -> 写时复制(Copy-On-Write) 两者原理详见[[虚拟化#操作系统的Tricks]]

操作系统的Tricks

  • Virtual Memory,动态链接中的内存分配
    • 延迟加载

      • 先通过页表逻辑上虚拟分配进程内存空间,但是物理上的内存分配,需要在使用时才实际分配到手
      • 按需加载策略:程序在启动或运行时,仅加载必要的数据到物理内存,其他数据在真正需要时才动态加载 先画大饼,需要吃的时候再分
      • “用时再取,不用不取”,避免一次性加载所有数据,减少内存占用和启动时间
      • 实现步骤
        1. 初始加载绝对必要的代码和数据到物理内存
        2. 访问未加载的页面,触发缺页中断(Page Fault)
        3. 缺页中断处理,加载物理内存
    • 写时复制 Copy-on-Write

      1. 父进程与子进程的虚拟地址空间共享
        • 当父进程fork()时,系统为子进程分配新的页表(虚拟内存)
        • 父子进程共享物理内存,但子进程页表中页面的权限被设置为只读
      2. 触发写操作时的复制
        • 写保护异常(Write Protection Fault),当父进程或子进程尝试修改共享页面时,CPU 检测到页面是只读的,触发缺页异常(Page Fault)
        • 复制物理页面

[!example]

  • 对于一个100GB的==可读写数组==,在fork时,子进程很难快速复制一个
  • 因此,对于这个==可读写的数组==,子进程中对应的fd,会指向父进程的==这个数组==,但只设置为只读
  • 当子进程中需要对==这个数组==进行写时,这个数组才会在子进程的空间中被==拷贝==一份
  • 而在拷贝后,这个进程之后的==这个数组==读写操作,都在这个==新的拷贝==上进行

[!hint] 共享内存回收

  • 页表项中有一个标志位,存储着一些页表信息
    • 只读位(Read-Only):标记页面是否可写。
    • COW 标志位:标识该页面是否参与 COW 机制(部分系统实现)。
    • 引用计数(Reference Count):记录共享页面的引用次数,用于释放物理页。 对于上文中fork()时,父子进程共同指向的物理内存 当这个块内存没有被任何进程使用时才会释放,而不是在父进程关闭后释放

引用计数机制

  • 初始共享:父进程和子进程的共享页引用计数设为 2。
  • 写操作触发复制后:新页的引用计数为 1,原页的计数减 1(变为 1)。
  • 释放物理页:当引用计数为 0 时,回收物理页。
  • Memory Deduplication 数据去重 —— 压缩&交换

    • 反正都是虚拟内存了,悄悄扫描内存
      • 如果有重复的 read-only pages,合并
      • 发现 cold pages,可以压缩/swap 到硬盘 啊我草,操作系统咋这么坏啊,偷我的拼好存

构建应用生态

  • Linux内核initramfs
    • 基本的命令工具,系统内核

    • 先启动内核初始化到初始状态

    • 创建设备文件

    • 加载磁盘

  • 操作系统 = 对象的集合

    • 本质上除了系统开机初始化后,一切都是其他程序执行