线程概念与控制 [ 上 ] 📅 2026/7/4 9:03:01 概念角度感性的理解线程在探索线程的奥秘之前我们脑海中总会浮现出之前学习过的进程。说到进程教材上是这样定义的进程 内核数据结构 代码和数据执行流。我们可以把进程想象成一家公司公司有办公场地内存空间、办公设备文件、信号等资源当创建一个进程时就好比是在搭建一家公司需要先创建PCB相当于公司的营业执照划分地址空间建立页表加载对应的代码和数据构建映射关系。学了文件和信号之后我们还知道进程一旦启动还需要为我们创建打开的文件对信号进行识别甚至加载动静态库这一系列操作归根结底都是在占用CPU资源。所以我们对进程的第一印象往往不是执行流的概念而是创建进程需要耗费大量的资源内存资源属于硬件资源是有上限的申请就意味着消耗资源因此进程被称为承担分配系统资源的基本实体。那么线程又是什么呢它是进程内部的执行分支执行流。如果把进程比作一家公司那么线程就像是公司里的员工员工在公司内部开展具体的工作任务多个员工可以同时工作提高公司的整体工作效率这就好比多个线程可以在一个进程中并发执行提高程序的执行效率。从内核和资源的角度来看线程是 CPU 调度的基本单位。我们先将线程的概念聚焦到 Linux 上等我们能理解 Linux 上的线程之后再将相关知识推广到其他领域。进程访问的大部分资源都是通过地址空间访问的这主要是由于现代计算机系统的设计和内存管理机制的需要。首先地址空间为进程提供了一个逻辑上的内存视图虚拟地址实现了内存的抽象和隔离。每个进程都有独立的地址空间操作系统通过页表将虚拟地址映射到物理地址这样可以防止进程之间相互干扰提高系统的稳定性和安全性。其次地址空间支持虚拟内存管理允许进程使用比实际物理内存更大的地址空间增强了内存分配的灵活性。同时地址空间的结构如代码段、数据段、堆、栈使得代码和数据分离便于操作系统更好地管理内存提高内存的使用效率。此外地址空间还支持共享库的加载和文件映射等功能进一步优化了资源的使用和访问效率。总之地址空间在现代操作系统中扮演着至关重要的角色它不仅提高了系统的安全性和稳定性还提升了内存的使用效率和程序的执行效率。我们可以看出进程地址空间是进程访问大部分资源的“窗口”打开的文件还有信号呢进程地址空间确实是进程访问大部分资源的“窗口”但并不是所有资源都通过地址空间访问。比如打开的文件和信号它们是通过进程控制块PCB中的文件描述符表和信号相关表结构来管理的。不过进程的核心资源如代码、数据、使用的库以及内核/操作系统的代码和数据确实主要是通过地址空间来访问的。进程地址空间是进程访问大部分核心资源的“窗口”。它为进程提供了一个逻辑上的内存视图将代码、数据、动态链接库等资源统一映射到一个连续的虚拟地址范围内。进程通过地址空间来访问这些资源操作系统则负责管理地址空间与物理内存之间的映射关系确保每个进程都能安全、高效地使用所需的资源。虽然打开的文件和信号等资源是通过进程控制块PCB中的文件描述符表和信号相关表结构来管理的但地址空间仍然是进程访问核心资源的主要途径。每一次创建进程都是需要创建一大堆资源的所以才说进程是承担分配系统资源的基本实体在 Linux 当中使用“进程”模拟线程我们今天创建一个“进程”共享”窗口“呢不给他分配独立的地址空间页表 ...我们只需要让所有的新创建的“进程”指向同一份地址空间那么所有的“进程”就注定了会看到同一份资源因为进程所对应的资源是通过地址空间看到的如果我们将资源再分配给不同的 task_struct那么我们就用今“进程”模拟出来线程了。我们所谓的资源分配其实就是划分地址空间说白了就是本来的正文代码段【010000】是第一个 task_struct 的资源现在 该段被分成了诺干个区域然后让诺干进程分别执行不同的代码本来串行执行的代码现在通过线程就可以实现并行执行了。那么怎么才能过实现如上的代码区划分呢将来地址的映射都是通过页表的所以划分地址空间本质上就是在划分虚拟地址对应的范围也就是在划分页表。对于代码划分我们这样理解数据划分也是如此这样我们就可以将整个进程的资源整体划分成不同的子区域然后分配给不同的执行流了。总结上面就是我们用 Linux 来设计的线程下面我们先给出结论我们 Linux “线程”可以采用 “进程来模拟”对资源的划分本质就是对地址空间虚拟地址范围的划分虚拟地址就是资源的代表我们该如何理解代码区划分不管是什么语言写的代码我们 C/C 知道其实我们自己的代码归根结底就是由一个一个的函数构成而每一个函数都有他的入口地址不是代表说函数只有一个地址所以我们在语言上才有了回调函数指针的概念10 行 C 语言代码 - 100 行汇编代码函数本质就是代码块函数代码块里的每一行代码都有地址只不过第一份地址称为函数的入口地址我们之前讲过 ELF当他编址时函数统一采用的是平坦模式来进行整体编址的。也就是函数本身就是代码块而代码块当中又是虚拟地址磁盘上称为逻辑地址的集合数据也是如此正因为资源是通过“窗口”来访问的所以资源反向的就是“窗口”函数的本质就是虚拟地址空间的集合就是让 线程 未来执行 ELF 程序的不同函数即可我们不用关心是如何划分的只要让不同的执行流将来执行不同的函数就可以了只不过之前是只有一个进程PCB 执行的是 main 函数未来为不同的进程设置不同的入口函数依旧是属于进程的某一个函数天然的让他们执行不同的函数加载到内存之后天然的他们就会访问虚拟地址空间的不同区域天然的资源就被划分好了所以资源划分并不是人为的是天然如此的进程和线程的理解我们将上面的视为线程那么我们该如何面对历史上讲过的进程呢传统进程与现代进程的关系传统进程的定义是一个包含独立地址空间、资源分配以及执行流通常是main函数的实体。这种定义在早期的操作系统中非常常见因为当时的操作系统设计主要关注的是单个程序的独立运行而没有过多地考虑并发执行和资源共享。历史的进程是内部只有一个线程的进程随着多核处理器的发展和对并发编程的需求增加操作系统引入了线程的概念。线程是进程内部的执行单元它共享所属进程的地址空间但有自己独立的执行流。这种设计使得多个线程可以在同一个地址空间内并发执行从而提高了程序的执行效率。在现代操作系统中进程的定义变得更加灵活。一个进程可以包含一个或多个线程。当一个进程只有一个线程时它就类似于传统进程。但当一个进程包含多个线程时这些线程共享同一个地址空间从而实现了高效的资源共享和并发执行。其他平台比如 Windows也是这样吗有没有自己的实现方案呢我们上面讲的都是 Linux 操作系统特有的实现方案当然内部还有非常多的细节需要我们后续慢慢雕琢。不同的平台对于进程的实现方案是大同小异的但是对于线程的实现不同的平台差别还是比较大的在 Windows 系统中进程控制块PCB用于描述和管理进程而线程控制块TCB则是 PCB 的一部分用于描述和管理线程。每个线程都有自己的 TCB这些 TCB 记录了线程的执行状态、上下文信息、优先级等关键信息。PCB 中包含了对所有线程的 TCB 的引用使得操作系统能够高效地管理和调度线程。线程的数量通常大于等于进程数量线程的管理操作如调度、终止、挂起等都是通过 TCB 来实现的。通过 PCB 和 TCB 的协同工作Windows 系统能够灵活地支持多线程并发执行同时保证系统的稳定性和资源的有效利用。可以看出复杂Windows 线程的设计是比较复杂的因为线程也需要像进程一样进行调度、同步、通信等操作这使得线程的代码设计面临诸多挑战。一方面线程的调度算法需要与进程的调度算法协同工作以确保线程能够高效地利用 CPU 资源这就要求在代码设计上进行精细的协调避免冲突和资源竞争。另一方面为了避免代码冗余线程和进程的调度算法需要在一定程度上共享代码逻辑但又要考虑到线程和进程在资源占用、执行上下文等方面的差异这就需要在代码设计上进行巧妙的抽象和封装以实现代码的复用和高效管理。这种既要考虑协同又要避免冗余的设计需求使得线程的代码设计变得复杂且富有挑战性。Linux 线程的设计确实有其独特之处与 Windows 线程设计相比Linux 线程的设计更加倾向于复用进程的数据结构。在 Linux 系统中并不存在一个专门的线程控制块TCB因为线程在 Linux 中被当作一种特殊的进程来处理。每个线程都有自己的task_struct结构这是 Linux 内核用来描述进程和线程的通用数据结构。这种设计使得线程和进程在内核层面具有极大部分的属性是重叠的从而减少了代码冗余也避免了为线程单独设计一套复杂的调度和管理机制。通过这种方式Linux 线程能够高效地共享资源同时保持独立的执行流而且在调度和管理上可以复用大部分进程的代码逻辑。在 Linux 操作系统软件视角进程和线程都是执行流还是需要判断该执行流是线程还是进程的从硬件 CPU 的视角来看CPU 并不关心执行流是来自一个线程还是一个进程。对于 CPU 来说它只是按照当前分配给它的上下文task_struct去执行指令。这个上下文包含了当前执行流的所有必要信息包括程序计数器PC、寄存器状态等。CPU 并不关心这个上下文是属于一个独立的进程还是一个线程它只是按照上下文中的指令序列去执行。在 Linux 系统中线程被实现为一种特殊的进程称为轻量级进程Lightweight ProcessLWP或者轻量级进程来模拟的。这种设计使得线程和进程在内核层面具有极大部分的属性是重叠的从而减少了代码冗余也避免了为线程单独设计一套复杂的调度和管理机制。每个线程都有自己的task_struct结构这是Linux内核用来描述进程和线程的通用数据结构。线程共享所属进程的地址空间但有自己独立的执行流。因此从硬件 CPU 的视角来看线程和进程的执行流并没有本质区别。CPU 只是按照当前分配给它的上下文去执行指令而这个上下文可以是线程的也可以是进程的。Linux 通过将线程实现为轻量级进程使得线程在内核层面与进程具有相同的调度和管理机制从而提高了系统的灵活性和效率。总结来说对于 CPU 来说线程和进程的执行流没有区别它们都被视为轻量级进程来处理。这种设计使得 Linux 能够高效地支持多线程并发执行同时保持传统进程的独立性和资源隔离。进程作为承担分配系统资源的基本实体就是为轻量级进程做准备的之不过传统的进程只有一个线程。现在我们应该更好的理解操作系统和具体操作系统的关系了操作系统是一个抽象的概念它提供设计思想和实现思路定义了计算机系统的基本功能和行为模式如资源管理、进程调度、内存分配等。而具体操作系统如 Linux则是这些抽象概念的具体实现它提供了具体的实现方法和代码将设计思想转化为实际运行的系统为用户提供具体的运行环境和功能支持。传统进程与线程的补充关系传统进程和线程的概念并不是孤立的而是互相补充的。传统进程可以看作是一个内部只有一个线程的进程而线程则是现代进程内部的执行单元。这种设计使得操作系统能够更好地支持并发编程同时保持传统进程的独立性和资源隔离。总结传统进程和线程的概念在现代操作系统中是互相补充的。传统进程可以看作是一个内部只有一个线程的进程而线程则是现代进程内部的执行单元。这种设计使得操作系统能够更好地支持并发编程同时保持传统进程的独立性和资源隔离。因此传统进程和线程的概念并不是对立的而是共同构成了现代操作系统的基础。线程是在进程地址空间内运行的。分页式存储管理我们对资源和资源划分的理解可能还不够接下来我们来谈谈页表即相关概念更好的理解资源与资源划分虚拟地址和页表的由来思考一下如果在没有虚拟内存和分页机制的情况下每一个用户程序在物理内存上所对应的空间必须是连续的。如下图因为每一个程序的代码、数据长度都是不一样的按照这样的映射方式物理内存将会被分割成各种离散的、大小不同的块。经过一段运行时间之后有些程序会退出那么它们占据的物理内存空间可以被回收导致这些物理内存都是以很多碎片的形式存在。怎么办呢我们希望操作系统提供给用户的空间必须是连续的但是物理内存最好不要连续。此时虚拟内存和分页便出现了如下图所示把物理内存按照一个固定的长度的页框进行分割有时叫做物理页。每个页框包含一个物理页page。一个页的大小等于页框的大小。大多数 32 位体系结构支持 4KB 的页而 64 位体系结构一般会支持 8KB 的页。区分一页和一个页框是很重要的页框是一个存储区域页是一个数据块可以存放在任何页框或磁盘中。有了这种机制CPU 便并不是直接访问物理内存地址而是通过虚拟地址空间来间接的访问物理内存地址。所谓的虚拟地址空间是操作系统为每一个正在执行的进程分配的一个逻辑地址在Linux其实逻辑地址就已经是虚拟地址了这是平坦模式带来的结果在 32 位机上其范围从0 ~ 4G-1。操作系统通过将虚拟地址空间和物理内存地址之间建立映射关系也就是页表这张表上记录了每一对页和页框的映射关系能让 CPU 间接的访问物理内存地址。总结一下其思想是将虚拟内存下的逻辑地址空间分为若干页将物理内存空间分为若干页框通过页表便能把连续的虚拟内存映射到若干个不连续的物理内存页。这样就解决了使用连续的物理内存造成的碎片问题。下面来更好的解释。物理内存管理我们曾经有个外设磁盘。磁盘当中有分区分区里有分组分组中有各种结构化信息。磁盘是以4KB为基本单位进行数据块划分的可执行程序本就是文件存储的时候天然就是以4KB单位存储的无论是属性还是内容。物理内存并不是一个字节一个字节进行管理的使用上是可以单字节单字节去使用因为访问内存的最小单位就是字节但是物理内存必须按照 4KB 为单位进行管理是被操作系统划分成多个 4KB 的内存块因为 4KB 大小的规定叫做数据块不仅仅是对磁盘数据的规定这个规定也会影响物理内存。4KB页框Page Frame是物理内存中划分的固定大小的块通常为4KB。它是物理内存的基本分配单元用于存储实际的数据。页针是磁盘上的 4KB 数据块但是现在也不是很区分这两者了物理内存和磁盘进行数据交换我们称为是以 4KB 为单位进行 IO 操作的从外设导入内存时以 4KB 的方式进行管理和加载的换出内存写入到外设也是以 4KB 为单位刷新到外设或者磁盘的对于 4KB 划分都是由操作系统去划分的不管是磁盘文件系统还是内存。那么操作系统是如何划分的假设内存空间是 4GB那么怎么去管理这 4GB / 4KB 1048576 的这么多4KB这么多页框哪些正在被占用哪些又是某种现状因此操作系统为了高效管理这些 4KB的内存块操作系统引入了页这一数据结构。先描述再组织描述结构体内容看起来很多但其实是要特别小的因为本身也是要占用 page 的大小/* include/linux/mm_types.h */ struct page { /* 原子标志有些情况下会异步更新 */ unsigned long flags;// /* 联合体用于不同的内存管理场景 */ union { struct { /* 换出页列表例如由zone-lru_lock保护的active_list */ struct list_head lru; /* 如果最低位为0则指向inode的address_space或为NULL * 如果页映射为匿名内存最低位置位且该指针指向anon_vma对象 */ struct address_space* mapping; /* 在映射内的偏移量 */ pgoff_t index; /* 由映射私有不透明数据 * 如果设置了PagePrivate通常用于buffer_heads * 如果设置了PageSwapCache则用于swp_entry_t * 如果设置了PG_buddy则用于表示伙伴系统中的阶 */ unsigned long private; }; /* 用于slab, slob和slub内存分配器 */ struct { union { struct list_head slab_list; /* 使用lru */ struct { /* Partial pages */ struct page* next; #ifdef CONFIG_64BIT int pages; /* 剩余页面数 */ int pobjects; /* 近似对象数 */ #else short int pages; short int pobjects; #endif }; }; struct kmem_cache* slab_cache; /* 不用于slob */ /* 双字边界对齐 */ void* freelist; /* 第一个空闲对象 */ union { void* s_mem; /* slab: 第一个对象 */ unsigned long counters; /* SLUB */ struct { /* SLUB */ unsigned inuse : 16; /* SLUB分配器对象的数量 */ unsigned objects : 15; unsigned frozen : 1; }; }; }; }; /* 内存管理子系统中映射的页表项计数用于表示页是否已经映射还用于限制逆向映射搜索 */ union { atomic_t _mapcount; unsigned int page_type; unsigned int active; /* SLAB */ int units; /* SLOB */ }; #ifdef WANT_PAGE_VIRTUAL /* 内核虚拟地址如果没有映射则为NULL即高端内存 */ void* virtual; #endif /* WANT_PAGE_VIRTUAL */ };flags用来存放页的状态。这些状态包括页是不是脏的是不是被锁定在内存中等。flag 的每一位单独表示一种状态所以它至少可以同时表示出 32 种不同的状态。这些标志定义在 linux/page-flags.h 中。其中一些比特位非常重要如 PG_locked 用于指定页是否锁定 PG_uptodate 用于表示页的数据已经从块设备读取并且没有出现错误。_mapcount表示在页表中有多少项指向该页也就是这一页被引用了多少次。当计数值变为 -1 时就说明当前内核并没有引用这一页于是在新的分配中就可以使用它。virtual是页的虚拟地址。通常情况下它就是页在虚拟内存中的地址。有些内存即所谓的高端内存并不永久地映射到内核地址空间上。在这种情况下这个域的值为NULL需要的时候必须动态地映射这些页。我们现在已经有了描述结构体那么该如何组织呢在 Linux 内核中struct page是用于描述物理页面的结构体而物理页面的组织方式通常是通过一个全局数组来管理的。这个数组通常被称为mem_map或page_array它是一个全局的struct page数组用于管理所有物理页面。#define MAX_PAGES (1 20) // 假设系统有 1MB 的物理内存每个页面 4KB struct page mem[MAX_PAGES];这个是比较底层的基于数组之上还有许多算法比如伙伴系统算法他可以做相邻 4KB相邻节点的申请合并....但是我们今天重点不在内存管理不在算法上。我们对每一个页表的管理就转化成为对了数组的增删查改所以每个 page 都有对用的编号因为内存是连续存放的所以我们知道了每一个 page 对应的下标这就导致了每一个 page 的起始物理地址就天然知道了。index*4KB那么具体访问的 物理地址 起始物理地址 页内4KB的偏移量。也就是说我们不需要在 page 结构体里面保存该 page 所对应的物理起始地址。所以之前说过的写时拷贝一定是某一个变量在某个页当中当这个变量要发生写时拷贝并不是对该单个变量进行申请而是将该变量所对应的整个4KB的页整体进行写时拷贝。综上所述申请物理内存就是在查数组改 page建立内核数据结构的对应关系。我们之前说过我们打开一个文件我们在内核里就有对应的内存缓冲区和文件对应文件是如何找到对应的缓冲区的在 Linux 系统中文件能够找到对应的缓冲区主要是因为文件系统和内核的页缓存Page Cache机制紧密协作通过页来管理文件数据。以下是基于页的机制解释文件如何找到对应的缓冲区1.文件与页缓存的关系文件在内存中的数据是通过页缓存Page Cache来管理的。当文件被读取或写入时数据首先被加载到页缓存中。页缓存是以页通常是4KB为单位来存储文件数据的。每个文件都有一个与之关联的address_space对象该对象通过一个 radix 树page_tree来管理文件的页缓存。所以这就是 page 描述结构体的不同组织形式2.页缓存如何关联文件数据当文件被访问时文件系统会根据文件的偏移量将文件数据映射到页缓存中的具体页。这个映射关系由文件的inode对象和address_space对象共同维护。文件的inode对象包含了文件的元数据而address_space对象则管理文件数据在内存中的具体存储位置。3.缓冲区的作用缓冲区Buffer Cache在现代 Linux 内核中已经与页缓存融合。在早期版本的Linux内核中缓冲区缓存是独立于页缓存的用于缓存磁盘块的数据。但从Linux 2.4版本开始缓冲区缓存被整合到页缓存中通过buffer_head结构来描述页中的块。4.文件如何找到对应的缓冲区当文件需要读取或写入数据时文件系统会通过文件的inode和address_space找到对应的页缓存。如果需要写入数据数据首先被写入到页缓存中的相应页。如果这个页之前是共享的例如通过写时拷贝机制那么在写入时会触发写时拷贝整个页会被复制以确保数据的一致性。5.数据刷新到磁盘当页缓存中的数据需要刷新到磁盘时数据会通过块设备层写入到磁盘。在这个过程中虽然现代内核不再有独立的缓冲区缓存但buffer_head结构仍然被用来描述页中的块信息包括块所在的设备和块号。所以文件能够找到对应的缓冲区是因为文件系统通过页缓存机制将文件数据以页为单位存储在内存中。文件的inode和address_space对象共同管理这些页确保文件数据能够被高效地读取和写入。现代 Linux 内核中缓冲区缓存已经与页缓存融合通过buffer_head结构来管理页中的块信息页表上面主要是对硬件的理解接下来我们慢慢来对软件进行理解来认识一下页表页表中的每一个表项指向一个物理页的开始地址。在 32 位系统中虚拟内存的最大空间是 4GB这是每一个用户程序都拥有的虚拟内存空间。既然需要让 4GB 的虚拟内存全部可用那么页表中就需要能够表示这所有的 4GB 空间那么就一共需要 4GB / 4KB 1048576 个表项。虚拟内存看上去被虚线“分割”成一个个单元其实并不是真的分割虚拟内存仍然是连续的。这个虚线的单元仅仅表示它与页表中每一个表项的映射关系并最终映射到相同大小的一个物理内存页上。页表中的物理地址与物理内存之间是随机的映射关系哪里可用就指向哪里物理页。虽然最终使用的物理内存是离散的但是与虚拟内存对应的线性地址是连续的。处理器在访问数据、获取指令时使用的都是线性地址只要它是连续的就可以了最终都能够通过页表找到实际的物理地址。在 32 位系统中地址的长度是 4 个字节那么页表中的每一个表项就是占用 4 个字节。所以页表占据的总空间大小就是1048576 * 4 4MB 的大小。也就是说映射表本身就要占用 4MB / 4KB 1024个物理页。这会存在哪些问题呢回想一下当初为什么使用页表就是要将进程划分为一个个页可以不用连续的存放在物理内存中但是此时页表就需要1024个连续的页框似乎和当时的目标有点背道而驰了……此外根据局部性原理可知很多时候进程在一段时间内只需要访问某几个页就可以正常运行了。因此也没有必要一次让所有的物理页都常驻内存。解决需要大容量页表的最好方法是把页表看成普通的文件对它进行离散分配即对页表再分页由此形成多级页表的思想。为了解决这个问题可以把这个单一页表拆分成1024个体积更小的映射表。这样一来1024每个表中的表项个数*1024表的个数仍然可以覆盖 4GB 的物理内存空间。这里的每一个表就是真正的页表所以一共有1024个页表。一个页表自身占用4KB那么1024个页表一共就占用了4MB的物理内存空间和之前没差别啊从总数上看是这样但是一个应用程序是不可能完全使用全部的4GB空间的也许只要几十个页表就可以了。例如一个用户程序的代码段、数据段、栈段一共就需要 10MB 的空间那么使用3个页表就足够了。计算过程每一个页表项指向一个4KB的物理页那么一个页表中1024个页表项一共能覆盖4MB的物理内存那么10MB的程序向上对齐取整之后4MB的倍数就是12MB就需要3个页表就可以了。在32位下二进制是被分为三部分的其实细节是我们接下来来逐一认识理解。页目录结构到⽬前为⽌每⼀个页框都被⼀个页表中的⼀个表项来指向了那么这1024个页表也需要被管理起来。管理页表的表称之为页⽬录表形成⼆级页表。如下图所⽰每个页表的内容称之为表项页目录表的内容是指向二级页表的地址二级页表的表项是指向对应物理地址的地址。所有页表的物理地址被页目录表项指向。页目录的物理地址被 CR3 寄存器指向这个寄存器中保存了当前正在执行任务的页目录地址。所以操作系统在加载用户程序的时候不仅仅需要为程序内容来分配物理内存还需要为用来保存程序的页目录和页表分配物理内存。我们查页表就是先查前10位中10位就可以找到对应的物理页框的起始地址了。那么现在就有了要访问的地址的起始页框地址然后拿剩下的低12位来做页内偏移就可以找到页内部的任意一个字节了我们需要清楚页目录的内容不是虚拟地址二级页表的内容不是物理地址页目录和页表的内容不直接存储虚拟地址或物理地址而是存储页表项Page Table EntriesPTEs这些页表项包含了虚拟页到物理页框的映射关系。页目录项通常包含指向页表的物理地址以及一些状态信息如存在位、访问权限等。页表项包含指向物理页框Page Frame的物理地址以及一些状态信息如存在位、访问权限、修改位等。当处理器需要访问一个虚拟地址时它首先使用虚拟地址的高几位来索引页目录找到对应的页目录项。页目录项指向一个页表的物理地址。然后处理器使用虚拟地址的中间几位来索引可以看成是对应的下标这个页表找到对应的页表项。页表项包含指向物理页框的物理地址。最后处理器使用虚拟地址的低几位页内偏移与物理页框地址组合形成完整的物理地址。两级页表的地址转化下面以一个逻辑地址为例。将逻辑地址0000000000,0000000001,11111111111转换为物理地址的过程在32位处理器中采用4KB的页大小则虚拟地址中低12位为页偏移剩下高20位给页表分成两级每个级别占10个bit1010。CR3寄存器CPU当中指向当前进程的页目录读取页目录起始地址再根据一级页号查页目录表找到下一级页表在物理内存中存放位置。CR3是当前进程的硬件上下文进程切换了对应的页表也就切换了当操作系统进行进程切换时它会更新CR3寄存器使其指向新进程的页目录的起始地址。这样即使多个进程在物理内存中共享相同的物理页每个进程也只能看到自己页表中定义的虚拟地址到物理地址的映射从而实现了进程间的内存隔离。当一个进程在CPU上执行时操作系统确保CR3寄存器包含了该进程页目录的物理地址。这个页目录是多级页表结构中的顶级数据结构用于管理该进程的虚拟地址空间到物理地址空间的映射。根据二级页号查表找到最终想要访问的内存块号。结合页内偏移量得到物理地址。注一个物理页的地址一定是4KB对齐的最后的12位全部为0所以其实只需要记录物理页地址的高20位即可。其实上面这些操作动作的执行者不是我们通过软件执行的不是的页表结构是软件里的在CPU内部也集成了一个组件以上其实就是 MMU 的工作流程MMUMemory Management Unit是一种硬件电路其速度很快主要工作是进行内存管理地址转换只是它承接的业务之一。操作是比较简单的通过索引和加法操作所以进入到CPU的是虚拟地址从CPU出来的其实已经是物理地址了。这就是一个直连总线那么什么是查找失败就是给一个虚拟地址通过不同比特位的映射查找页目录发现页目录所对应的页表不存在可是我们判定这个虚拟地址是一个合法地址凭什么判定是合法地址因为这个地址在我们进程的地址空间是合法的说明曾经在磁盘中的代码和数据还没有加载到内存所以要发生写时拷贝操作系统就触发了虚拟地址到物理地址转化失败的中断机制操作系统执行相关算法页面调度页面申请然后帮我们申请内存申请内存的时候就会访问对应的描述内存的数据结构 page发现对应标志位 flag 是没有被占用的在这之前找到相应 page那么对应 struct page mem[ ] 下标就有了那么整个页框物理地址也就有了。接着操作系统会在必要的页目录和页表中创建新的页表项并将新分配的物理页框地址填入这些页表项中完成虚拟地址到物理地址的映射。这个过程确保了即使初始时物理内存中没有对应的页表或页框操作系统也能动态地创建和管理这些结构以支持进程对内存的访问。我们现在大框架已经比较完善了但是对于页内偏移为什么是低12位数字低数字为什么是12位数字是因为页框大小就是4KB【04095】这样就可以使用12位来充分覆盖一个页框的整个范围低在磁盘上的可执行程序和ELFExecutable and Linkable Format文件中数据和代码是按照偏移量来编址的。这种偏移量是基于平坦的地址空间模型其中每个区域都是通过起始地址加上偏移量来定位的。当程序被加载到内存中时这种偏移量仍然有效因为它们指向的是页内的具体位置。数据是按照字节存储的而字节序通常从最低有效位即最右边的位开始编号。因此虚拟地址的最低12位自然就是用来表示页内偏移的。前20位已经确定是在同一个4KB了。局部性原理所以执行流看到的资源本质就是在合法的情况下你拥有多少虚拟地址虚拟地址是资源的代表虚拟地址空间mm_structmm_area_struct 本质就是进行资源的统计数据和整体数据页表是一张虚拟到物理地址的地图。资源划分本质就是地址空间划分资源共享本质就是虚拟地址的共享。线程的深刻理解通过上面的学习我们可以知道线程进行资源划分本质就是划分地址空间获得一定范围的合法虚拟地址再本质就是在划分页表线程进行资源共享本质就是对地址空间的共享再本质就是对页表条目的共享。到这里其实还有个问题MMU 要先进⾏两次页表查询确定物理地址在确认了权限等问题后MMU 再将这个物理地址发送到总线内存收到之后开始读取对应地址的数据并返回。那么当页表变为 N 级时就变成了 N 次检索1次读写。可见页表级数越多查询的步骤越多对于 CPU 来说等待时间越长效率越低。让我们现在总结一存下单级页表对连续内存要求高于是引入了多级页表但是多级页表也是一把双刃剑在减少连续储要求且减少存储空间的同时降低了查询效率。有没有提升效率的办法呢计算机科学中的所有问题都可以通过添加一个中间层来解决。MMU引入了新武器江湖人称快表的 TLB其实就是缓存当 CPU 给 MMU 传新虚拟地址之后MMU 先去问 TLB 那边有没有如果有就直接拿到物理地址发到总线给内存齐活。但 TLB 容量比较小难免发生Cache Miss这时候 MMU 还有保底的老武器页表在页表中找到之后 MMU 除了把地址发到总线传给内存还把这条映射关系给到 TLB让它记录一下刷新缓存。缺页异常其实上面已经提到过了我们来梳理一下设想CPU 给 MMU 的虚拟地址在 TLB 和页表都没有找到对应的物理页该怎么办呢其实这就是缺页异常Page Fault它是一个由硬件中断触发的可以由软件逻辑纠正的错误。假如目标内存页在物理内存中没有对应的物理页或者存在但无对应权限CPU 就无法获取数据这种情况下 CPU 就会报告一个缺页错误。由于 CPU 没有数据就无法进行计算CPU 罢工了用户进程也就出现了缺页中断进程会从用户态切换到内核态并将缺页中断交给内核的 Page Fault Handler 处理。缺页中断会交给 Page Fault Handler 处理其根据缺页中断的不同类型会进行不同的处理Hard Page Fault 也被称为 Major Page Fault翻译为硬缺页错误 / 主要缺页错误这时物理内存中没有对应的物理页需要 CPU 打开磁盘设备读取到物理内存中再让 MMU 建立虚拟地址和物理地址的映射。Soft Page Fault 也被称为 Minor Page Fault翻译为软缺页错误 / 次要缺页错误这时物理内存中是存在对应物理页的只不过可能是其他进程调入的发出缺页异常的进程不知道而已此时 MMU 只需要建立映射即可无需从磁盘读取写入内存一般出现在多进程共享内存区域。Invalid Page Fault 翻译为无效缺页错误比如进程访问的内存地址越界访问又比如对空指针解引用内核就会报 segment fault 错误中断进程直接挂掉。相关问题如何理解我们之前的new和mallocnew和malloc都是用于动态内存分配的机制。其实底层是使用系统调用brk / mmap这是基于文件的用来修改数据段的大小也就是 new/malloc 底层只需要更改堆空间的范围改的是虚拟地址物理地址根本就没有申请我们尝试 new/malloc 得到一个虚拟地址我们并不一定会立马使用这个申请出来的堆空间操作系统也就没必要立马去申请物理空间所以当我正真想要使用虚拟地址去访问的时候系统再触发缺页中断然后再做内存的二次申请本质就是一种延迟申请。充分提供内存使用的充分度。如何理解我们之前学习的写时拷贝写时拷贝Copy-On-WriteCOW是一种优化策略用于减少内存复制的开销。当多个进程或线程需要访问同一份数据时它们最初共享相同的物理内存。只有当其中一个进程或线程尝试修改数据时才会创建数据的副本从而避免不必要的复制提高效率。申请内存究竟是在干什么申请内存实际上是请求操作系统分配一定量的虚拟内存空间。操作系统通过页表将虚拟地址映射到物理内存地址从而允许程序使用这部分内存。这个过程涉及到内存管理单元MMU的操作确保程序能够安全、有效地访问内存。如何区分是缺页了还是真的越界了缺页异常通常发生在合法的虚拟地址空间内但由于物理内存中没有对应的页需要从磁盘加载。越界访问则是访问了不属于程序的虚拟地址空间这是非法的通常会导致程序崩溃或异常终止。操作系统通过检查虚拟地址是否在进程的地址空间内以及对应的页表项是否存在来区分这两种情况。一个问题越界了一定会报错吗我们的代码如果出现野指针一定会报错吗答案是不一定的越界访问不一定立即报错。在某些情况下如访问的地址恰好映射到有效的物理内存程序可能会暂时正常运行。然而这种行为是未定义的可能导致数据损坏或程序崩溃。正确的做法是始终确保访问的地址在进程的合法地址空间内。我们之前看过一个可能死循环的代码int i0; int arr[10]; for(int i0;i10;i) { arr[i]0; }代码中的死循环是因为数组 arr 只有10个元素索引从0到9但循环条件 i 10 允许索引 i 达到11导致对数组越界访问。在C语言中数组越界可能会导致未定义行为但在这个特定情况下如果越界的访问恰好覆盖了循环变量 i 的内存位置就可能意外地修改了 i 的值使得循环条件始终为真从而造成死循环。这种情况是不可预测的因为未定义行为的具体结果依赖于程序的内存布局和当时的内存状态。通过这些解释我们可以看到内存管理和异常处理在操作系统中的重要性以及它们如何影响程序的运行和性能。