x86单核OS
2025-06-20 20:33:33 1 举报
AI智能生成
描述:本文档阐述了基于x86架构的单核操作系统的基础架构和关键特性。文档详细解析了操作系统内核的工作机制,包括内存管理、进程调度、设备驱动程序以及用户接口的设计原理。此外,文档还涉及了针对单核处理器的优化策略,比如中断处理和同步机制,以及如何确保系统的稳定性和性能。 文件类型:.pdf 修饰语:全面、详尽、技术性、参考性
作者其他创作
大纲/内容
疑惑问题
网卡接收到数据之后,操作系统层面会发生什么?<br>
带你玩转汇编
汇编学到什么程度才能熟练使用?<br>1.熟练写出函数:无参无返回值、无参有返回值、有参有返回值<br>2.声明变量:全局变量、局部变量<br>3.实现运算:基本运算、位运算<br>4.熟练实现条件判断<br>5.熟练实现循环结构<br>
CPU架构、指令集、汇编、硬编码之间的关系。<br>1.CPU架构(CPU Architecture)<br># 指CPU的整体设计和内部结构(比如寄存器数量、缓存层级、流水线结构等)<br># 比如: x86、ARM、RISC-V是三种主流的CPU架构<br># 决定了CPU能支持什么指令集、性能特征和扩展能力<br>2.指令集(Instruction Set Architecture, ISA)<br># 是CPU能理解的机器语言指令集合,定义了每条指令的功能、格式和编码方式<br># 不同的CPU架构通常有各自的指令集。例如<br>## x86架构使用x86指令集<br>## ARM架构使用ARM指令集<br>## RISC-V架构使用RISC-V指令集<br># 指令集是软件与硬件之间的接口<br>3.汇编语言(Assembly Language)<br># 是指令集的人类可读的文本表示<br># 每条汇编指令通常对应一条机器指令(opcode)<br># 例如mov ax, 1 就是把数字1放到寄存器ax中,编译器或汇编器会把它翻译为对应的机器码,比如 B8 01 00(x86中)<br>4.硬编码(Hardcoding)<br># 是一个编程术语,指在程序中直接写死某些值或逻辑(通常是魔法数字、路径等)<br># 跟上面三个不是同一个层次的概念,比如int bufferSize = 1024; // 1024是硬编码<br># 在底层代码或驱动中,也可能用汇编语言应变或某些地址或寄存器值<br>
<br>
汇编、CPU架构、指令集、硬编码之间的关系<br>
汇编、C语言、C++、Java之间的关系<br>
MASM、NASM、ATT、ARM之间的关系。<br>MASM、NASM、AT&T是汇编语言的语法风格或汇编器工具,而ARM是一种CPU架构(以及对应的指令集)。它 与x86不同<br>1.MASM: Microsoft Macro Assembler (MASM)是微软公司开发的一款汇编语言编译器。它为x86架构(如Intel和AMD的处理器)提供了一个汇编环境,并且使用了Intel语法,它还提供了丰富的宏功能,允许用户编写复杂的汇编程序<br>2.NASM:Netwide Assembler(NASM)是一个开源的x86架构汇编语言编译器,它可以用于编写操作系统、驱动程序等底层程序。和MASM一样,NASM一样,NASM也使用了Intel语法。<br>3.ATT: ATT语法是一种汇编语言的语法,主要在UNIX和Linux系统的GNU Assembler(GAS)中使用。与Intel语法相比,ATT语法的特点是源操作数和目标操作数的顺序相反,即源操作数在钱,目标操作数在后<br>4.ARM:ARM并非一种汇编语言或语法,而是一种处理器架构。ARM架构呗广泛应用在嵌入式系统和移动设备中。ARM架构的汇编语言有自己的一套语法规则,与Intel和ATT语法都有所不同。<br><br>总结来说,MASM和NASM是为x86架构编写汇编代码的工具,它们使用Intel语法;而ATT则是另一种汇编语言的语法,主要在GNU Assembler中使用,ARM则是一种完全不同的处理器架构,有自己的汇编语言和语法规则<br><br>总结一句话。<br>MASM/NASM/AT&T是写汇编代码的语法或工具主要用于x86架构,而ARM是一种完全不同的CPU架构,它有自己的指令集与汇编风格<br>
四者核心身份对照表
MASM、NASM、AT&T的比较(x86汇编风格)<br><br>ARM != MASM/NASM/AT&T.ARM是一个指令集架构(ISA),与x86完全不同。ARM汇编有自己的一套语法风格,比如<br>mov R0, #10 ; 将10移入R0<br>ADD R1, R0, R2 ; R1 = R0 + R2<br>LDR R3, [R4] ; 加载内存中 [R4]的内容到R3<br>其特点如下<br># 使用R0~R15寄存器<br># 定长32-bit指令<br># 无寄存器前缀(不像%eax)<br><br>
四者示例对比<br>
MASM和NASM的区别。<br>MASM是微软官方的汇编器,转为Windows平台设计,NASM是开源跨平台汇编器,灵活性更高。两者支持的语法风格类似,但并不兼容。<br><br>MASM更适合在Windows环境配合Visual Studio做系统层编程;NASM更灵活、跨平台、适合做操作系统、嵌入式、裸机开发<br>
最核心区别:出身不同<br>
语法层面不同
输出与兼容性
使用体验差异
实际使用场景对比<br>
总结对比
寄存器、CPU缓存、内存之间的关系。<br>1.寄存器:寄存器是CPU内部的存储单元,它们是最快的存储设备。由于寄存器直接集成在CPU内部,CPU可以在一个时钟周期内从寄存器读取或写入数据。寄存器的数量和大小由CPU架构决定,通常数量非常优先,寄存器用于存储当前执行的指令所需的数据和计算结果,以及其他控制信息<br>2.CPU缓存: CPU缓存是一种位于CPU和内存之间的快速存储设备,用于缓存内存中的数据,以减少CPU访问内存的时间。当CPU需要读取内存中的数据时,它会首先查看这个数据是否在缓存中。如果在(这称为"缓存命中").CPU可以直接读取缓存中的数据,这比读取内存快得多。如果不在(这称为"缓存未命中"),CPU需要从内存读取数据,并将其缓存起来以备后用,CPU缓存的大小比寄存器打,但比内存小<br>3.内存: 内存(也称为主存或RAM)是一种大容量的存储设备,用于存储正在运行的程序和数据。内存的大小比寄存器和CPU缓存都大得多,但速度比它们慢。当CPU需要执行一个程序时,这个程序会从硬盘加载到内存中,然后CPU从内存中读取指令和数据进行执行。<br><br>这三者之间的关系可以总结为:寄存器最快但数量最少,主要用于当前指令的执行;CPU缓存的速度比寄存器慢,但比内存快,它作为寄存器和内存之间的缓冲,提供了数据访问的效率;内存的速度最慢,但它提供了大量的存储空间,用于存储正在运行的程序和数据<br>
核心三者关系
举个例子说明:<br>比如程序中有如下代码:<br>int a = b + c;<br>执行流程可能是这样<br>1.b和c的值在内存中<br>2.CPU要执行加法,它会<br># 把b、c从内存读取到L3/L2/L1 Cache<br># 然后读入CPU的寄存器中(如eax, ebx)<br># 在ALU中完成add运算<br># 结果暂存寄存器中,然后写回Cache,最终同步回内存<br>
为什么需要这三层?<br>#为什么不能只用寄存器?<br>数量极少,只有几十个,不足以存储大数据<br># 为什么不能直接访问内存?<br>内存比CPU慢很多,频繁访问回导致性能瓶颈<br># Cache是做什么的?<br>把内存中常用数据缓存到更快的位置,减少访问延迟
访问速度对比。<br>
硬编码与机器码的区别<br>硬编码:是变成习惯问题,是开发者把值或逻辑写死在代码里<br>机器码:是CPU能直接执行的二进制指令,是最终运行在硬件上的代码<br>
1.硬编码(Hardcoding)<br>定义: 在源代码中写死某个具体数值、逻辑、路径,而不是用变量、配置文件或参数<br>语言层级:出现在高级语言(C/C++、Java、Python)或汇编语言中<br>优缺点:<br>--优点:简单直接、性能好<br>--缺点:灵活性差、维护困难、复用性低<br>example:<br>// 硬编码了1024和文件路径<br>#define BUFFER_SIZE 1024<br>char* filepath = "/etc/config.txt"<br>在汇编中也可能看到:<br>mov eax, 0x3F8; 把串口地址写死进去了(COM1端口)
2.机器码(Machine Code)<br>定义: CPU可以执行的二进制指令(0和1的组合),表示具体的操作(如假发、跳转、内存读写)<br>语言层级:位于最底层,直接运行在CPU上<br>与汇编的关系:汇编语言是一种对机器码的可读性表示,编译器或汇编器会将汇编指令翻译成机器码<br>example:<br>mov eax, 1 ; 汇编语言<br>B8 01 00 00 00 ; 对应 的机器码(十六进制形式)<br>机器码本质是:<br>10111000 00000001 00000000 00000000 00000000 (二进制)
CPU架构<br>1.按体系结构类型划分为:CISC与RISC<br>CISC类型:Complex Instruction Set Computer,复杂指令集,比如x86.每条指令功能强大,能做很多事情。<br>RISC类型: Reduced Instruction Set Computer,精简指令集,比如ARM、RISC-V.每条指令执行更快,设计简单,适合低功耗设备和高性能场景<br>2.主流通常CPU架构<br>如图所示
CPU与指令集之间的关系。<br>一句话概括:指令集是CPU架构的一部分,是软件与硬件之间的契约接口<br>1.CPU架构<br># 是什么?<br>CPU的硬件设计与实现方式<br># 作用<br>决定CPU执行执行指令(寄存器、流水线、缓存等)<br># 关系<br>包含指令集<br># 举例:<br>interl Core i9、Apple M1、AMD Ryzen<br>2.指令集ISA<br># 是什么?<br>一组CPU可以识别并执行的指令集和规范<br># 作用<br>决定CPU能执行什么指令、指令格式、操作码等<br># 关系<br>是架构中对软件接口的定义<br># 举例<br>x86, x86-64,ARMv8, RISC-V,MIPS<br><br>CPU架构实现了指令集,指令集定义了软件与硬件之间的语言接口。开发者面向的是指令集,芯片厂商负责实现它<br>
机器码与指令集的关系<br>机器码(Machine Code)与指令集(ISA, Instruction Set Architecture)之间的关系可以总结为:<br>机器码是指令集的具体"二进制实现",每条机器码对应一条指令集定义的指令<br><br># 基本概念解释:<br>1.指令集(ISA)<br>含义:一组定义好的、CPU能够识别的指令(格式、功能、操作数等规范)<br>2.机器码(Machine Code)<br>含义:每条指令的二进制编码,即按指令集规则编码后的0和1组合,CPU直接执行<br><br># 关系类比:<br>指令集是"语法和单词表"<br>机器码是“编码后的内容”或"编译后的代码"<br>比如mov ax,1 是一条x86指令(x86指令集的一部分),它的机器码可能是: B8 01 00<br>这段机器码,就是把mov ax,1 转成符合x86 ISA的二进制格式<br><br># 从汇编到机器码的过程(以x86为例)<br>汇编语言: mov ax,1<br>操作码格式: 按照ISA规则定义: B8 + 立即数<br>机器码(二进制): 10111000 00000001 00000000<br>机器码(十六进制): B8 01 00<br>这说明:<br>## 指令集规定了mov操作该如何表示(如操作码为B8,后面跟2字节立即数)<br>## 机器码就是根据这些规则生成的二进制码<br><br># 指令集决定什么?<br>指令集规范通常包括以下内容:<br>## 定义的内容: 支持的操作。比如加法、跳转、内存加载、IO等<br>## 指令格式/位数。每条指令几个字节、操作码怎么排布<br>## 操作数规则。支持多少寄存器、能不能用立即数、内存寻址方式<br>## 二进制编码方式。 每种指令如何转换成机器码(opcodes)<br>所以,机器码=按照指令集规范,对汇编指令编码后的结果<br><br># 关系总结<br>## 是什么?<br>指令集ISA:一套规范、说明书<br>机器码Machine Code: 具体指令的二进制编码<br>## 作用<br>指令集ISA: 定义CPU支持哪些指令、怎么编码<br>机器码: 让CPU按ISA执行真实动作(硬件可执行)<br>## 面向谁:<br>指令集:编译器、程序员<br>机器码: CPU硬件电路<br>## 举例<br>指令集: mov ax,1<br>机器码: B8 01 00<br><br># 总结一句话<br>指令集是规则,机器码是结果<br>指令集定义了有哪些指令、这些指令的格式和编码方式;机器码是根据这些定义产生的实际的0和1<br>
CISC与RISC的优缺点。<br>CISC(复杂指令计算)与RISC(精简指令计算)是两种不同的CPU指令集架构设计理念,它们在指令设计、执行效率、硬件复杂度等方面有显著区别。<br>CISC架构优缺点(如x86):<br># 优点<br>## 程序更小:复杂功能能用一条指令表示,占用代码空间少<br>## 编程友好:开发者或汇编器不需要关心指令组合逻辑<br>## 向后兼容强: 如x86可以执行早期8086代码<br># 缺点:<br>## 解码复杂:变长指令+多种寻址模式,导致硬件电路复杂<br>## 流水线低效:指令长短不一,增加调度难度<br>## 性能受限: 现代优化受限,指令往往拆成多条微指令执行<br><br>RISC架构优缺点(如:ARM, RISC-V, MIPS)<br># 优点<br>## 执行快: 大多数指令一个周期完成,便于流水线和superscalar执行<br>## 易于优化:简洁一致的结构有利于编译器优化和预测执行<br>## 低功耗、高能效:对移动设备非常优化<br>## 指令解码简单:硬件负担小,成本低<br>#缺点<br>## 代码体积大:复杂功能需要多条指令组合完成<br>## 编译器负担重:需要聪明的编译器做更多优化<br>## 初学门槛高:写汇编程序时要"自己拼积木"<br><br>发展方向<br>x86(CISC):已内部转为微操作RISC-like核心(如Intel的uops)<br>ARM(RISC):支持Thumb、NEON等扩展,也加入复杂指令优化<br>RISC-V:纯粹RISC,模块化,逐渐称为新兴架构代表。<br>现代CPU都在借鉴对方有点,界限逐渐模糊。但在特定场景,差异依然显著:<br># 服务器/桌面: x86(CISC)仍占主导,兼容旧生态<br># 移动/嵌入式: ARM(RISC)因功耗优势大行其道<br># 新平台/学术研究: RISC-V成为轻量、安全、自主可控的热门之选。<br><br>总结一句话:<br>CISC更像功能强大的多合一工具,RISC更像高效灵活的乐高积木,前者靠硬件处理复杂,后者靠软件和并行优化取胜<br>
机器码结构<br>机器码(Machine Code)的结构,取决于它所属的指令集架构(ISA),不同架构对机器码的结构设计方式不同。但本质上,机器码是一组位(bit)来编码一条指令的各个部分,比如:操作码、寄存器、立即数、地址等。<br><br># 通用机器码结构(抽象)<br>一条机器码=指令的二进制编码,大致结构如下:<br>## 操作码(Opcode):表示"执行什么操作"(如假发、跳转、加载),是这条机器码的核心指令编号<br>## 寄存器字段: 哪些寄存器参与了运算(如目标寄存器/源寄存器)<br>## 立即数字段: 常量(如add r0, r1, #10 中的10)<br>## 地址字段: 用于访问内存(跳转地址、读写内存地址等)<br><br># 不同体系的机器码结构示例:<br>## x86指令结构(变长, CISC架构)<br>x86指令可以是1~15字节不等。通常包括:前缀(Prefix) + 操作码(Opcode) + ModR/M + SIB + displacement + immediate<br>Prefix: 可选,如加锁、段选择、操作数大小等。 1B<br>Opcode: 操作指令,如B8表示mov ax, immediate. 1~3B<br>ModR/M: 指明源/目标寄存器或内存地址。1B, R/M【0-2bit】、Reg/Opcode【3-5bit】、Mod【5-7bit】<br>SIB: 用于复杂寻址。1B Base【0-2bit】、Index【3-5】、Scale【5-7bit】<br>Displacement: 内存偏移量(位移地址) 1~4B<br>Immediate: 立即数. 1~4B<br>例如:<br>mov eax, 0x12345678; 汇编<br>B8 78 56 34 12; 机器码(B8 = mov eax, 后4字节是立即数)<br>## ARM指令结构(定长,RISC架构)<br>ARM(32位)中的每条指令固定位4字节(32位)<br>通常结构如下:<br>Cond:条件码(Condition code)指令是否执行(如EQ,NE,AL) 4bit<br>00: 2bit<br>I: 立即数标位 1bit<br>Opcode: 操作码(如加法ADD是0100) 4bit<br>S:是否影响条件码(Status Flag),如是否更新CPSR中的NZCB标志 1bit<br>Rn: 第一个源寄存器 4bit<br>Rd:目标寄存器(结果存这里) 4bit<br>Operand2: 第二个操作数(可以是立即数或寄存器+位移) 12 bit<br>例如:<br>ADD R0, R1, R2<br>含义: R0 = R1 + R2<br>条件执行码: 1110 (AL,总是执行)<br>I = 0 (第二个操作数是寄存器)<br>opcode = 0100(表示ADD)<br>S=0(不影响标志位)<br>Rn = R1 = 0001<br>Rd = R0 = 0000<br>Operand2 = R2 = 0000 0000 0000 0010 (Rm = 2)<br>对应机器码(二进制):<br>1110 00 0 0100 0 0001 0000 0000 0000 0010 换成十六进制:E0800002<br>
CPU包含哪些寄存器。<br>1.通用寄存器<br>2.段寄存器<br>3.指令指针寄存器(x86 EIP、x64 RIP)<br>4.标准寄存器(x86 eflags、 x64 rflags)状态寄存器<br>5.控制寄存器(CR0-Cr4, CR3页表)<br>6.调试寄存器(DR0-DR7)<br>7.描述符寄存器(GDTR、LDTR、IDTR)<br>8.任务寄存器(TR)<br><br><br>寄存器常见用途<br>1.eax 函数返回值<br>2.ecs: 循环次数; this指针<br>3.ebp、esp栈底指针与栈顶指针<br>4.esi、edi:拷贝数据用,源地址、目标地址
eflags标志位详解<br>
常用标志位说明
常见影响标志位的指令<br>1.ZF=1因为结果为0<br>mov eax, 2<br>sub eax, 2<br><br>2.SF=0, ZF=1, OF=0<br>mov eax, -1<br>add eax, 1<br><br>3.CF=1 说明发生进位(无符号溢出)<br>mov al, 0xFF<br>add al, 0x01;<br>
JCC指令。JCC指令是x86架构中用于条件跳转(Jump if Condition is met)的汇编指令家族的统称,CC代表具体的条件代码(Condition Code),比如JE(等于时跳转)、JG(大于时跳转)等。<br>这些跳转指令的判断依据是EFLAGS(状态标志寄存器)中某些标志位的状态,尤其是:<br># ZF(Zero Flag)<br># SF(Sign Flag)<br># OF(Overflow flag)<br># CF(Carry Flag)<br><br>使用示例:<br>cmp eax, ebx ; 比较eax和ebx<br>je equal_lable ; 如果相等(ZF=1),跳转到equal_label<br><br>mov ecx, 1<br>jmp end<br><br>equal_label:<br>mov ecx, 0<br><br>end:<br>这个例子说明了<br># cmp实际上是eax-ebx,但结果不会保存,只影响标志位<br># je判断的是ZF是否为1(即eax == ebx)<br>
寄存器总分类
通用寄存器
指令控制相关
段寄存器(Segment Registers)<br># 在现代操作系统中,段寄存器已经很少主动操作,内存基本是分页管理<br>
控制寄存器(用于内核态/虚拟化)<br>
浮点与SIMD寄存器(现代CPU核心)<br>
练习代码示例。<br>1.使用同一个寄存器实现1+2<br>mov eax,1 // 把数字1放入eax寄存器<br>add eax, 2 // 把数字2加到eax上(变成3)<br>2.使用两个不同的寄存器计算1+2<br>mov eax, 1 // 将1存入eax寄存器<br>mov ebx, 2 // 将2存入ebx寄存器<br>add eax, ebx ; eax = eax + ebx (eax = 1 + 2)<br>此时eax = 3<br>
int main() {<br> int a = 10;<br> if (a != 0) {<br> a = 100;<br> }<br> a = 1000;<br> return 0;<br>}<br>对应的汇编代码:<br>【00251795】 mov [ebp-8], 0Ah<br>【0025179C】 cmp [ebp-8], 0<br>【002517A0】 je 002517A9<br>【002517A2】 mov [ebp-8], 64h<br>【002517A9】 mov [ebp-8], 3E8h<br><br>使用jne做条件判断的汇编指令<br>mov [ebp-8], 0Ah ; a = 10<br>cmp [ebp-8], 0 ; 比较 a 和 0<br>jne label_then ; 如果 a != 0,跳到 then 执行块<br>jmp label_continue ; 否则跳过 then 块,继续往下<br><br>label_then:<br>mov [ebp-8], 64h ; a = 100<br><br>label_continue:<br>mov [ebp-8], 3E8h ; a = 1000<br><br>对比发现使用JNE做条件判断,比使用JE多了一条jmp指令。JE的执行路径更直接,可读性/生成效率更好。JE是编译器优化的一种常见策略,用于避免不必要的跳转<br>
int main() {<br> int a = 10;<br> if (a == 0) {<br> a = 100;<br> }<br> a = 1000;<br> return 0;<br>}<br>【00CE1795】 mov dword ptr [ebp-8],0Ah <br>【00CE179C】 cmp dword ptr [ebp-8],0 <br>【00CE17A0】 jne 00CE17A9 <br>【00CE17A2】 mov dword ptr [ebp-8],64h <br>【00CE17A9】 mov dword ptr [ebp-8],3E8h<br>用je用来做条件判断的汇编指令如下:<br>00CE1795 mov dword ptr [ebp-8], 0Ah ; a = 10<br>00CE179C cmp dword ptr [ebp-8], 0 ; 比较<br>00CE17A0 je 00CE17A4 ; 若等于 0,跳到 then 块<br>00CE17A2 jmp 00CE17A9 ; 否则跳过 then 块<br>00CE17A4 mov dword ptr [ebp-8], 64h ; then 块:a = 100<br>00CE17A9 mov dword ptr [ebp-8], 3E8h ; a = 1000(最终赋值)<br><br>
int main() {<br> int a = 10;<br> if (a == 0) {<br> a = 100;<br> } else {<br> a = 17;<br> }<br> a = 1000;<br> return 0;<br>}<br>对应的汇编代码如下<br>00291795 mov dword ptr [ebp-8],0Ah <br>0029179C cmp dword ptr [ebp-8],0 <br>002917A0 jne 002917AB <br>002917A2 mov dword ptr [ebp-8],64h <br>002917A9 jmp 002917B2 <br>002917AB mov dword ptr [ebp-8],11h <br>002917B2 mov dword ptr [ebp-8],3E8h<br><br>
je版本1对应<br>mov [a], 10<br>cmp [a], 0<br>je 0x111 ; 如果 a == 0,跳到 then 块<br>jmp 0x222 ; 否则跳到 else 块<br><br>0x111:<br>mov [a], 100 ; then 块<br>jmp 0x333 ; 跳过 else 块,进入 if-else 后续<br><br>0x222:<br>mov [a], 17 ; else 块<br><br>0x333:<br>mov [a], 1000 ; 最终赋值<br>
je版本2对应<br>mov dword ptr [ebp-8], 0Ah ; a = 10<br>cmp dword ptr [ebp-8], 0 ; 判断 a == 0?<br>je 002917A2 ; 如果 a == 0,跳转执行 then 块<br><br>mov dword ptr [ebp-8], 11h ; else 块:a = 17<br>jmp 002917B2 ; 跳过 then 块,进入最后赋值<br><br>002917A2: ; then 块起始地址<br>mov dword ptr [ebp-8], 64h ; a = 100<br><br>002917B2: ; if/else 后续通路<br>mov dword ptr [ebp-8], 3E8h ; a = 1000
int main() {<br> int a = 10;<br> for (int i = 0; i < 100; i++) {<br> a++;<br> }<br> return 0;<br>}<br>对应的汇编代码如下:<br>00C71795 mov dword ptr [ebp-8],0Ah <br>00C7179C mov dword ptr [ebp-14h],0 <br>00C717A3 jmp 00C717AE <br>00C717A5 mov eax,dword ptr [ebp-14h] <br>00C717A8 add eax,1 <br>00C717AB mov dword ptr [ebp-14h],eax <br>00C717AE cmp dword ptr [ebp-14h],64h <br>00C717B2 jge 00C717BF <br>00C717B4 mov eax,dword ptr [ebp-8] <br>00C717B7 add eax,1 <br>00C717BA mov dword ptr [ebp-8],eax <br>00C717BD jmp 00C717A5<br>00C717BF xor eax,eax
汇编解释<br>00C71795 mov dword ptr [ebp-8], 0Ah ; a = 10<br>00C7179C mov dword ptr [ebp-14h], 0 ; i = 0<br><br>00C717A3 jmp 00C717AE ; 跳到 for 循环判断 i < 100<br><br>; --------- for 循环迭代点:i++ ----------<br>00C717A5 mov eax, dword ptr [ebp-14h] ; eax = i<br>00C717A8 add eax, 1 ; eax += 1<br>00C717AB mov dword ptr [ebp-14h], eax ; i = eax<br><br>; --------- for 循环判断点:i < 100 ----------<br>00C717AE cmp dword ptr [ebp-14h], 64h ; i 和 100 比较<br>00C717B2 jge 00C717BF ; 如果 i >= 100,跳出循环<br><br>; --------- 循环体:a++ ----------<br>00C717B4 mov eax, dword ptr [ebp-8] ; eax = a<br>00C717B7 add eax, 1 ; eax += 1<br>00C717BA mov dword ptr [ebp-8], eax ; a = eax<br><br>00C717BD jmp 00C717A5 ; 回到 i++,继续下一轮循环
总结:<br><font color="#a23735">编译器偏向于使用【if为false就跳过】的结构,这样就可以少一个跳转</font><br>
实现OS微内核<br>
C语言的不同版本(C90、C99、C11)的特点是什么?<br>它们的主要区别体现再增加的新特性和修改的标准中,下面是每个版本的主要特点<br>1.C90(也被称为C89)<br>C90是C语言的第一个标准版本,由美国国家标准化协会(ANSI)于1989年发布,C90中定义了C语言的语法,数据类型,函数库等基本框架。主要的内容包括函数原型,类型转换规则,标准库等等<br>2.C99<br>C99引入了许多新特性,包括行内函数、单行注释(//)、新的数据类型(例如long long int 和_Bool)、变长数组、复合字面值(compound literals)、指定初始值设定项(designated initializers)、函数宏定义等。此外,C((标准页扩展了库函数的数量,比如增加了复数运算和对IEEE 754浮点运算的支持<br>3.C11<br>C11在C99的基础上进一步增强了C语言的功能。它引入了新的并发(concurrency)模型,增加了原子操作(atomic operations), _Thread_local存储类,_Alignas和_Alignof关键字等。C11还提供了一种更安全的可替代函数库,用来减少常见的编程错误,例如缓冲区溢出。这些更安全的函数一半以"_s"极为。例如sprint_s、strcpy_s等。C11还溢出或者废弃了一些C99中引入的特性。<br>在选择使用哪个版本的C语言时,一半需要考虑编译器的支持程度,以及项目需求是否需要新版本引入的特性<br>
CPU的运行模式<br>CPU在设计的时候,内置两套运行模式:实模式、保护模式,当然,不是绝对的,单片机就没有保护模式一说<br>1.实模式(Real Mode)<br># 特点: 最初的8086处理器模式,16位寻址,最大访问1MB内存<br># 用途: 主要用于系统启动(如BIOS引导),非常简单<br># 缺点: 不支持多任务‘、内存保护、虚拟内存等<br>2.保护模式(Protected Mode)<br># 特点: 从80286开始支持,32位寻址(可寻址最大4GB内存),支持分页、段式管理<br># 优势:<br>## 支持多任务<br>## 内存保护(防止程序越界访问)<br>## 支持分页机制和虚拟内存<br># 常用于:现代操作系统(如Windows、Linux)在内核加载后切换到该模式<br>3.虚拟8086模式(V86 Mode)<br># 特点:在保护模式下模拟实模式,用于运行老旧的DOS程序<br># 用途:兼容旧软件,通常运行在Ring3(用户态)<br>4.长模式(Long Mode)<br># 特点:x86-64(64位)CPU的工作模式<br># 包含两个子模式:<br>## 兼容模式:运行32位程序<br>## 64位模式:支持64位地址空间(理论支持16EB)<br># 现代操作系统启动后最终进入该模式<br>
x86-64和x64有什么区别吗?<br>x86-64和x64本质上指同一种64位CPU架构,区别仅在于命名方式、使用场合略有不同。<br>一句话结论:<br>x86-64=x64=AMD64——都是指64位的x86架构处理器<br>
起源和背景<div></div>
BIOS中断是什么?<br>BIOS中断是指由计算机的BIOS(基本输入输出系统)提供的一组中断服务,它们在系统启动早期和实模式下用于与底层硬件(如键盘、显示及、磁盘等)进行交互。在x86架构的实模式或虚拟8086模式下,BIOS中断允许程序员使用中断指令(如INT 10h、INT 13h)来访问硬件设备,而不需要直接操作硬件寄存器。<br>
什么是中断?<br>在x86汇编中,INT n 指令会触发中断向量表中的第n项对应的处理程序。<br># BIOS将很多硬件服务映射为中断号<br># 例如: INT 13h表示调用磁盘服务,13h是16进制的19<br>
常见的BIOS中断及功能<br>
示例:使用INT 10h输出字符<br>mov ah, 0x0E; BIOS teletype output<br>mov al, 'A'; 输出字符A<br>int 0x10 ;调用BIOS中断<br><br>这个程序会使用BIOS的INT 10中断,在屏幕上显示一个字符‘A’<br>
BIOS中断的特性<br>1.易于使用<br>用中断调用即可访问硬件,无需知道寄存器或端口细节<br>2.与硬件无关<br>屏蔽了底层硬件差异,统一接口<br>3.性能较低<br>不适合高性能实时程序<br>4.仅限实模式使用<br>进入保护模式后不能直接调用BIOS中断(除非进入V86模式)<br>
BIOS中断的使用场景<br>1.操作系统启动前的引导程序(如bootloader)<br>比如GRUB、MBR阶段会用INT 13h从磁盘读取数据<br>2.DOS操作系统或裸机程序<br>在没有驱动和OS支持的情况下通过BIOS与硬件通信<br>3.调试与硬件测试程序<br>快速访问串口‘、打印、视频等接口
为什么现在操作系统不再使用BIOS中断?<br>1.现代操作系统运行在保护模式/长模式,不能再使用实模式的BIOS中断<br>2.现代操作系统用自己的驱动访问硬件,性能更好、更灵活<br>3.BIOS中断仅在开机早期阶段(比如bootloader)中使用<br>
总结<br>
NASM常用伪指令。<br>在NASM(Netwide Assembler)中,伪指令(Pseudo-instructions)是汇编器提供的一类"辅助指令",它们不会直接生成机器码,但在编译时控制代码组织、段定义、变量分配等行为。伪指令是大小写不敏感的,建议大写<br>
关键伪指令详解:<br>1.section/ segment定义程序代码或数据所属的段<br>section .text ; 代码段<br>section .data ; 初始化数据段<br>section .bss ; 未初始化数据段<br>
2.global和extern<br>用于链接多个模块时定义/引用符号<br>global _start ; 对_start 对外可见<br>extern printf ; 引用外部C函数<br>
3.ORG<br>设置代码起始地址,常用于裸机开发,如bootloader<br>org 0x7c00; BIOS加载地址<br>
4.equ(等价于define常量)<br>定义常量池,替代magic number<br>SECTOR_SIZE equ 512<br>
5.times<br>重复生成数据或指令。常用于填充数据(如bootloader填满512字节)<br>times 510 - ($ - $$) db 0 ; 补零<br>dw 0xAA55; 加引导签名<br>
6.数据定义伪指令<br>db: 定义一个字节<br>dw:定义一个字(2字节)<br>dd:定义一个双字(4字节)<br>dq:定义一个四字(8字节)<br>resb, resw, resd:预留字节、字、双字空间<br><br>msg db 'Hello, world', 0<br>buf resb 64
7.incbin<br>将外部二进制文件插入当前文件中,例如操作系统核心<br>incbin "kernel.bin"<br>
8.%define与%include<br># %define用于宏定义(简单文本替换)<br>%define CR 13<br># %include 用于代码模块拆分<br>%include "bootmacros.inc"<br>
NASM中的$和$$分别表示什么意思?<br>$:它表示当前之林的地址。例如,在编写跳转指令的时候,我们可以使用$来表示当前指令的地址<br>$$: 它表示当前段的开始地址。这个符号通常在需要计算当前指令相对于当前段开始的偏移量的情况下<br><br>常用方式<br>times 510 - ($ - $$) db 0<br>dw 0xAA55<br><br>它的作用是:填充当前代码段,使其长度变为510字节。<br>在bootloader里,BIOS只加载512字节,其中最后两个字节必须是0x55AA引导标志,所以我们用这行代码填充前510字节,其后再加上2字节签名。<br>times N: 重复执行后面的语句N次<br>db 0: 定义一个字节常量<br>所以times N db 0 就是:填充N个字节,值为0<br><br>焦点在510 - ($ - $$)<br>$(单个美元符号): 表示当前地址(当前位置的偏移量)<br>$$(两个美元符号): 表示当前section的起始地址<br>($- $$): 表示当前这段代码从段开始到当前位置已经用了多少字节<br>510 - ($ - $$):就是我们还差多少个字节才能到510字节<br><br>所以,times 510 - ($-$$) db 0就表示,从当前偏移填充0,直到总大小510字节为止。<br><br>dw 0xAA55<br># 这是BIOS规定的引导扇区标志,必须放在第511和512字节(最后两个字节)<br># BIOS检查它来判断扇区是否为可引导扇区<br><br><br>
实用例子(Bootloader)<br>org 0x7c00<br>bits 16<br><br>section .text<br> mov ah, 0x0E<br> mov al, 'A'<br> int 0x10<br><br> jmp $<br><br>times 510 - ($ - $$) db 0<br>dw 0xAA55<br><br>生成二进制后会得到一个完整的512字节的bootloader<br>
微内核结构/内核是怎么运行起来的<br>
实模式内存布局
实模式内存布局区域划分
地址总线是什么?<br>地址总线是CPU用来告诉内存或设备,她要访问哪个地址的通道。<br>举个通俗类比,把计算机想象成一个大仓库:<br># 货架编号 = 内存地址<br># 地址总线 = 告诉你要去哪个货架的通道<br># 数据总线 = 从货架上取东西或把东西放回去的通道<br># 控制总线 = 告诉系统你要读还是写的控制命令<br><br>地址总线的特性<br># 单项传输: CPU->设备(因为地址是由CPU发出的)<br># 位宽决定寻址能力: 位数越多,能访问的地址空间越大<br># 每根线代表一个二进制位: 1根地址线 = 2个地址, n根地址线= 2^n个地址<br>
常见的地址总线宽度和寻址能力<br>
地址总线与数据总线的区别
示例:8086的地址总线<br># 8086是16位CPU,但有20位地址总线<br># 可以访问的最大内存空间是1MB<br># 寄存器段(segment)配合偏移(offset)形成20位地址<br>
地址总线不只是用于访问内存<br>归根结底的原因是这样的:在计算机中,并不是只有咱们插在主板上的内存条需要通过地址总线访问,还有一些外设同样是需要通过地址总线来访问的,这类设备还很多呢。若把全部的地址总线都指向物理内存,那其他设备改如何访问呢?由于这个原因,只好在地址总线上提前预留出来一些空间给这些外设用,这片连续的地址给显存,这片连续的地址给硬盘控制器等。留够了以后。,地址总线上其余的可用地址再指向DRAM,也就是指插在主板上的内存条、我们眼中的物理内存。
显卡内存映射<br>在计算机系统中,显卡内存映射通常是通过物理地址映射或内存映射I/O(MMIO)来实现的。以下是其基本流程:<br>1.初始化<br>在启动过程中,BIOS或UEFI会初始化硬件并在物理地址空间中预留一段区域给显卡用作MMIO,显卡的驱动程序在加载时会从操作系统获取这些信息<br>2.映射到虚拟地址空间<br>操作系统的内核会为这些物理地址设置页表条目,从而将其映射到进程的虚拟地址空间。这使得应用程序可以通过访问特定的虚拟地址来读取或写入显卡的内存<br>3.应用程序和显卡的交互<br>应用程序可以通过在映射的地址上进行读/写操作来与显卡进行交互。例如,一个图形应用程序可能会将像素数据写入显卡的显存中,然后显卡将这些数据渲染到屏幕上。另一方面,应用程序可以通过读取映射的地址来获取显卡的状态信息<br><br>显卡内存映射是一个复杂的过程,涉及到操作系统、硬件和驱动程序之间的紧密协作。它使得应用程序能够直接与显卡进行交互,而无需知道显卡的具体实现细节。这使得显卡能够以更高的性能运行,同时简化了应用程序的设计。<br>需要注意的是,虽然这个过程位应用程序提供了直接访问显卡的能力,但是为了防止误操作和安全问题,访问通常还是需要通过操作系统和驱动程序的一些API,例如在Windows系统中的DirectX,或者在Unix-like系统中的OpenGL或Vulkan。这些API为应用程序提供了一种更高级、更易用、更安全的方式来操作显卡<br><br>MMIO是Memory Mapped I/O(内存映射/输出)的缩写,是一种将设备的寄存器映射到CPU的物理地址空间,从而使得CPU可以通过对这些地址进行读写操作来控制设备的一种方法<br>
为什么是0x7C00?<br>BIOS如何加载和执行引导扇区。在计算机启动时,BIOS会执行POST(Power-On Self Test)过程,检测和初始化系统硬件,然后开始引导过程。引导过程的一部分是寻找启动设备(如硬盘,U盘等),并从这个设备的第一个扇区(也就是引导扇区)读取512字节的数据<br>这512字节的数据被BIOS加载到内存地址`0x7C00`。这个特定的地址(0x7C00)是由IBM的原始PC设计中确定的,这个惯例一直沿用到至今。<br>然后BIOS将执行权限交给位于0x7C00的代码,此时的代码通常是一个引导加载程序,如GRUB、LILO或WIndows的bootmgr.引导加载程序接着会加载操作系统内核,开始操作系统的启动过程。<br>为什么选择0x7C00这个特定的地址并没有公开的官方解释。一种可能的解释是,改地址位于实模式下1MB内存的末尾,并且提供了足够的空间(约32KB)共引导加载程序使用<br>
ROM和RAM的区别是什么?<br>ROM(Read-Only Memory)和RAM(Random Access Memory)是两种基本的计算机存储类型,它们在操作和用途上有很大的差别.<br>1.ROM(只读存储器):<br>ROM是非易失性存储器,也就是说即时在断电情况下,它主要被用于存储固定不变或者很少改变的数据,例如操作系统的引导加载程序、固件或者硬件的配置信息。这些信息是在制造过程中被写入的,因此被为只读存储器。然而,有一些类型的ROM(例如EPROM、EEPROM、Flash Meory等)可以被重新编程,尽管这个过程通常比RAM中的读写操作要复杂和耗时得多。<br>2.RAM(随机访问)<br>RAM是易失性存储器,也就是说它需要持续供电才能保持器存储得信息。一旦断电,RAM中得所有信息都会被丢失。RAM允许你读取和写入数据,而且无论数据位于内存得哪个位置,访问速度都是相同得,这就是所谓得随机访问,RAM主要用于存储操作系统,应用程序和运行中得数据。其中RAM又分为两种类型:静态RAM(SRAM)和动态RAM(DRAM).SRAM不需要定期刷新就能保持器数据,但是比DRAM更昂贵,因此它通常用于CPU得告诉缓存。儿DRAM需要定期刷新来保持其数据,但是比SRAM更偏移,因此它被广泛用作主内存<br>
BIOS例程。<br>BIOS启动过程(也称为引导过程)是电脑开机时的一个关键阶段。这个过程可以大致分为以下几个步骤:<br>1.电源开机自检(POST):当电脑启动时,BIOS首先进行电源开机自检(Power-On Self Test, POST)。在这个过程中,BIOS会检查并测试系统中的硬件组件,包括CPU,RAM,键盘,鼠标,硬盘等,确保它们都正常工作<br>2.检查BIOS设置: BIOS接着会检查CMOS RAM中存储的BIOS设置。这些设置包括系统日期和时间,以及各种硬件配置<br>3.启动设备选择:根据BIOS设置中的启动顺序,BIOS会确定要从哪个设备启动系统。这个设备可能是硬盘,光盘驱动器,U盘,或者网络<br>4.引导扇区加载:一旦确定了启动设备,BIOS会尝试从该设备的引导扇区读取引导加载程序。引导加载程序通常是操作系统的一部分,负责加载操作系统的剩余部分。<br>5.转交控制权:当引导加载程序被加载到内存中后,BIOS将控制权限转交给它。引导加载程序接着会开始加载操作系统<br><br>值得注意的是,虽然BIOS仍然在许多计算机上使用,但在一些更现代的系统中,它已经被UEFI(Unified Extensible Firmware Interface,统一可扩展固件接口)取代。UEFI提供了更现在的功能和更大的灵活性,同时保持了与BIOS的向后兼容性<br>
汇编执行流(一)<br>
什么是执行流?<br>1.使用Java、C++写程序,基本单位是类的方法<br>2.使用C语言写程序,基本单位是函数<br>3.使用汇编写程序,基本单位就称为执行流(CPU执行引擎执行程序也称为执行流)<br>
完整地掌握执行流需要对如下知识点无比熟悉:<br>1.一个函数的汇编结构<br>2.下面这几种情况有何不同:无参无返回值、无参有返回值、有参有返回值<br>3.堆栈相关的指令<br>4.函数的调用约定<br>5.堆栈平衡: 内平栈、外平栈<br>6.保存现场、恢复现场<br>7.ebp寻址、esp寻址<br>
一个函数的汇编结构<br>#include <stdio.h><br>#include <tchar.h><br>using namespace std;<br><br><br>void add(int) {<br> int a = 10;<br> int b = 20;<br> int sum = a + b;<br>}<br><br>int _tmain(int argc, _TCHAR* argv[]) {<br> add(10);<br><br> return 0;<br>}
调用add函数之前的动作<br>
调用完add函数之后的动作,会有一个add esp,4指令。在x86架构中表示:<br>把栈顶指针esp增加4个字节,也就是弹出一个4字节(即1个int/DWORD)的数据单元。<br>add esp, 4 指令语法解释<br># esp: 栈顶指针寄存器,始终指向当前栈顶<br># add esp, 4: 把esp增加4,表示从栈中"跳过"4个字节,即弹出一个值,但不把数据读取出来<br># 栈式向下增长的(低地址方向),所以add相当于出栈<br><br>对比栈操作:<br>1.压栈push<br>汇编指令: push eax<br>含义说明:esp -= 4,然后把eax值存到[esp]<br>2.出栈pop<br>汇编指令: pop eax<br>含义说明:从[esp]取出内容给eax, 然后esp+=4<br>3.简单弹栈<br>汇编指令: add esp, 4<br>含义说明:只增加esp,丢弃栈顶内容<br>
典型用途<br>1.丢弃函数参数(函数调用后)<br>call my_function ; 压入返回地址 & 参数<br>add esp, 4 ; 丢弃1个参数(清理栈)<br>编译器设置为__cdecl时,调用者清理参数,送一常见add esp, n<br><br>2.清理临时局部变量或栈空间<br>sub esp, 8 ; 开辟8字节局部变量空间<br>...<br>add esp, 8 ; 释放局部变量空间<br>
举个完整函数栈帧的例子:<br>push ebp ; 保存上一个栈帧基址<br>mov ebp, esp; 建立当前栈帧<br>sub esp, 8 分配局部变量空间<br><br>.... ; 执行逻辑<br><br>add esp, 8; 回收局部变量<br>pop ebp ; 恢复上一个栈帧<br>ret<br>
执行流基本结构<br>int _tmain(int argc, _TCHAR* argv[]) {<br> int a = 0;<br><br> return 0;<br>}<br>
执行流的精简结构
堆栈指令<br>1.push<br>等于两条基本指令的组合<br>sub esp, 4<br>mov dword ptr[esp], 10<br>dword ptr表示要操作的数据大小是4字节(DWORD)<br>2.pop<br>等于两条基本指令的组合<br>mov eax, [esp]<br>add esp,4<br>3.jmp:无条件跳转<br>4.call<br>等于两条基本指令的组合<br>sub esp, 4<br>mov dword ptr[esp], return address<br>jmp printf【要调用的function】<br>call指令会把当前指令的返回地址压入栈中,然后跳转到目标函数的地址执行。执行完函数后,通过ret返回到原位置继续执行。<br>它做了两件事:<br># 压栈保存返回地址(当前eip或rip寄存器+ 指令长度)<br># 跳转到函数起始地址执行<br>5.ret<br>等于两条基本指令的组合<br>mov eip, [esp]: // 这是伪代码,现实中是不能这样写的<br>add esp, 4<br>jmp eip
函数调用约定是什么?<br>#.函数调用约定(Calling Convention)定义了函数调用时参数如何传递、返回值怎么处理、栈谁来清理、哪些寄存器谁负责保存的一系列规则。它是编译器、汇编器、操作系统和CPU之间协作的约定.<br># 函数调用约定解决的问题<br>当你调用函数foo(a,b,c)时,系统必须知道以下这些问题<br>## 参数如何传入函数?是通过栈?还是通过寄存器(比如eax, edi等)?<br>## 参数传递顺序? 从左到右还是从右到左?<br>## 谁负责清理栈?是caller(调用者)还是callee(被调用者)<br>## 返回值放在哪里? 一般用eax或rax寄存器返回<br>## 哪些寄存器必须保存? 有的寄存器需要保留原值,有的可以随意改动<br><br><br>示例代码如下,接下来我们分别加上不同的函数调用约定来看参数传递<br>void test(int a,int b, int c,int d) {<br> int x = 10;<br>}<br><br>int _tmain(int argc, _TCHAR* argv[]) {<br> test(10,20,30,40);<br><br> return 0;<br>}
__cdecl(C Declaration)<br>参数传递: 从右到左压入栈中<br>栈清理: 调用者Caller负责清理栈<br>返回值: eax<br>应用:默认用于C编译器(GCC/MSVC)下的函数<br><br>
被调用函数的执行流
<br>
__stdcall(Standard Call)<br>参数传递: 从右到左压栈<br>栈清理:被调用者Callee负责清理栈<br>返回值: eax<br>应用: Windows API默认约定<br><br>
被调用函数的执行流
<br>
__fastcall:快速调用方式。所谓快速,这种方式选择将参数优先从寄存器传入(ECX和EDX),<br>剩下的参数再从右向左从栈传入。因为栈式位于内存的区域,而寄存器位于CPU内,<br>故存取方式快于内存,故其名曰__fastcall。由被调函数进行栈清理,返回值: eax寄存器<br>参数传递:部分参数通过寄存器(如ecx, edx)其余参数走栈<br>栈清理: 被调用者callee<br>返回值: eax<br>应用: Windows下的一种优化调用方式<br><br><br>在x64下(也称为x86-64或AMD64),所有的编译器都忽略__fastcall,因为它们都使用了一种新的默认的调用约定。<br>在Windows平台下,这种调用约定被称为__fastcall或x64 calling convention.根据这种约定。函数的前4个整数或指针参数<br>(不包括浮点数参数)会在寄存器中传递,参数会按照顺序传递给以下寄存器:<br>RCX<br>RDX<br>R8<br>R9<br>如果有更多的参数,或者有浮点数参数,它们会被放在堆栈上。返回值将被放在RAX寄存器中。这些规则在Unix和类Unix<br>的系统(例如Linux和macOS)下也是相似的,不过这些系统下的调用约定通常被称为System V AMD64 ABIT,并且传递参数的寄存器不同。在System V AMD64 ABI中,前6个整数或指针参数会在以下寄存器中传递:<br>RDI<br>RSI<br>RDX<br>RCX<br>R8<br>R9<br>浮点参数会在XMM寄存器中传递,其余的参数会在堆栈上传递。返回值会放在RAX或XMM0寄存器中。总的来说,具体的规则会依赖于你使用的操作系统和编译器<br>
被调用函数的执行流
<br>
什么是裸函数?<br>裸函数(Naked Function)指的是一种不由编译器自动生成函数前后栈帧代码的函数,函数体中所有汇编内容由程序员手动控制。这类函数通常用于系统底层开发,如操作系统内核、驱动、异常/中断处理等<br><br>用途:<br># 编写中断服务例程ISR<br>必须完全控制寄存器、栈结构<br># 操作系统内核<br>手动设置特权级、页表、切换上下文<br># 启动代码Bootloader<br>没有运行时库,需要手动设置堆栈<br># 内敛汇编跳板<br>保证跳转前后栈一致性<br><br>使用裸函数的注意事项<br># 不能在裸函数中使用本地变量(因为没有栈帧)<br># 不能直接调用普通函数(因为栈不一致)<br># 必须显式保存/恢复寄存器<br># 必须手动写返回指令<br><br>总结一句话:<br>裸函数 = 关闭编译器自动生成栈帧的"全手工模式"函数,适用于你需要精确控制CPU执行流的场合(如操作系统、裸机开发、汇编桥接)<br>
裸函数是什么(补充)<br>裸函数(naked function)是一种特殊的函数类型,它告诉编译器不要为这个函数生成任何形式的Prolog或Epilog代码。换句话说,裸函数只包含开发者提供的代码,编译器不会自动产生函数的进入和退出基址,这些都需要开发者自行处理。这通常在底层编程中非常游泳,比如在写操作系统内核、驱动、嵌入式系统等需要精确控制的地方。然而,裸函数也带来了很多复杂性和风险。由于缺少编译器生成的Prolog和Epilog,开发者必须自己负责所有的堆栈管理和寄存器保存/恢复工作,这要求开发者有身后的底层只是,且容易出错,一旦出错可能导致严重问题。<br>此外,裸函数的行为在不同的编译器和平台之间可能会有所不同。一些编译器可能不支持裸函数,或者支持的方式不同。这使得使用裸函数的代码在跨平台上的兼容性较差。现代的操作系统和编译器通常更倾向于使用更标准、更可靠、更易于理解和调试的编程模式。随着硬件性能的提高,我们往往可以接受编译器在Prolog和Epilog中插入的一些额外的开销,以换取代码的可维护性和可移植性。<br>因此,现代的操作系统和编译器可能不再支持裸函数,这是一个权衡和取舍的结果。在某些情况下,你仍然可以使用特定的编译器扩展或者直接使用汇编语言来实现类似裸函数的功能
示例对应的汇编指令
常见写法。<br>1.直接将__declspec关键字加载方法名前<br>void __declspec(naked) function()<br>2.第二种是宏定义,加载方法名前<br>#define NAKED __declspec(naked)<br>void NAKED code() {}<br>
练习示例:<br>1.无参有返回值的函数<br>2.无参无返回值的函数<br>3.有参有返回值的函数<br>
1.无参有返回值<br># 代码如下<br>// 无参有返回值<br>int testReturnInt() {<br> return 3;<br>}<br># 其对应的汇编指令如图所示<br>
2.无参无返回值<br># 代码如下<br>void testWithoutReturn() {<br> int x = 10;<br> int y = 20;<br>}<br>其对应的汇编指令如图所示<br>
3.有参有返回值<br># 代码如下:<br>int testReturnIntWithParam(int a, int b) {<br> return a + b;<br>}<br>汇编指令如图所示<br>
汇编执行流(二)<br>
内联汇编是什么?哪些场景使用?<br>内联汇编(inline Assembly)是将汇编代码直接嵌入到C/C++等高级语言中的一种技术,主要用于直接访问硬件、操作特殊的处理器指令,或者完成一些高级语言难以弯沉的特定任务。以下是一些可能需要使用内联汇编的情况:<br>1.直接访问底层硬件<br>在一些特殊的情况下,高级语言可能无法提供对某些硬件的直接访问。比如,如果你需要控制特定的硬件寄存器,或者你需要进行某种硬件的特殊配置,那么你可能需要使用内联汇编<br>2.使用特定的处理器指令<br>有时候,处理器厂商会提供一些特殊的指令,这些指令可以实现一些高级语言无法完成的操作。例如SIMD(单指令,多数据)指令通常需要使用内联汇编来编程<br>3.性能优化<br>在某些极端的情况下,内联汇编可以提供比高级语言更好的性能。这通常是因为你可以直接控制处理器的操作,这样可以减少一些不必要的操作。但是,请注意这种优化往往需要对底层硬件有很深的理解,而且这种优化往往是非常复杂的<br>4.实现特殊的程序逻辑<br>有时候,你可能需要实现一些高级语言无法完成的特殊的程序逻辑。例如,你可能需要手动控制栈的布局,或者你可能需要创建一些特殊的数据结构(比如跳跃表)<br><br>但是,需要注意的是,内联汇编需要对汇编语言和底层硬件有深入的理解,并且难以跨平台使用。同时,使用内联汇编编写的代码通常难以理解和维护。因此,除非必须,否则尽量避免使用内联汇编<br><br>总结一句话:<br>内联汇编就i是"在C/C++中夹杂汇编代码",让你在高级语言中,享受底层"硬核操作"的自由。适合精细化或系统级编程,但不建议大面积使用<br>
Windows下MSVC x86架构下内联汇编案例。x64不再支持内联汇编,需要使用【汇编文件+ __declspec(naked) + instrinisics】<br>
内联汇编的语法格式<br>__asm__(汇编语句模板: 输出部分: 输入部分: 破坏描述部分)<br>最后一个参数,详细介绍下:<br>内联汇编的最后一个参数(又被称为"clobbered regsiter list")用于告诉编译器哪些寄存器在汇编语句执行后的值可能会被改变(被"clobber", 即破坏)。这是重要的,因为它帮助编译器优化代码。如果编译器知道哪些寄存器可能会被破坏,那么它可以提前保存哪些寄存器中的值,或者选择不在这些寄存器中存储值<br><br>总的来说,这个列表允许编译器做出更好的决策,避免寄存器的值在不期望的时候被改变,从而导致程序错误
使用内联汇编的注意事项<br># 可移植性<br>差,写汇编时强依赖平台和架构(x86 vs x64)<br># 维护性<br>不如纯C/C++清晰,调试难度大<br># 安全性<br>汇编容易引发错误或安全漏洞
内联汇编vs裸函数vs汇编文件<br>
内联汇编如何调用函数?<br>从这三个方面说明:<br>1.调用无参无返回值函数<br>2.调用无参有返回值函数<br>3.调用有参有返回值函数<br>
无参无返回值<br>解释:将函数print的内存地址放到rax寄存器中,在内联汇编的代码部分调用它<br>
内联汇编中还有哪些这样的标记呢?<br>内联汇编中的一些约束和它们对应的寄存器<br>1. "a":`%rax`, `%eax`, `%ax`, `%al`<br>2. "b":`%rbx`, `%ebx`, `bx`, `%bl`<br>3. ”c“: `%rcx`, `%ecx`, `%cx`, `%cl`<br>4. "d":`%rdx`, `%edx`, `%dx`, `%dl`<br>5. "S": `%rsi`, `%esi`, `%si`, `%sil`<br>6."D":`%rdi`, `%edi`, `%di`, `%dil`<br><br>此外,还有一些通用约束,例如”r“,他告诉GCC可以使用任何通用寄存器。这允许GCC在所有可用的寄存器中选择一个,这可能有助于产生更优的代码。例如,你可以这样写:<br>int input = 10;<br>asm ("movl %0, %%eax" :: "r"(input) : "%eax");<br>在这里,"r"表示任意寄存器,GCC会选择一个合适的寄存器用于input值,然后把该寄存器的值移动到”%eax“中,另一个通用约束是"g",它允许使用寄存器、内存或立即数。然而,在实际引用中,你应该尽可能地使用具体的约束,因为这会使GCC产生更优的代码<br>
无参无返回值<br>注: 输出部分比起输入部分,有个符号: =<br>asm volatile("call %%rax;" : "=r"(v) : "a"(get))<br><br>表达意思是:<br># 让CPU调用rax寄存器中指向的函数,这个函数是get()<br># 把get()的返回值,作为输出,存储到变量v中<br>
各部分解析:<br># "call %%rax;"<br>汇编指令,调用rax中的地址的函数(间接调用)<br># “=r”(v)<br>输出操作数,表示将返回值写入v,冰然编译器选一个合适的寄存器来放v的值<br># “a”(get)<br>输出操作数,表示把get函数地址放入rax寄存器(a代表rax)
实际执行流程:<br># get是一个函数名,本质是个函数地址=>传入rax<br># call %%rax执行: 跳转到get(),并压栈返回地址<br># get()返回100,根据x86调用约定,返回值保存在eax中<br># V<=eax(通过输出约束"=r"(v))
注意事项:<br># 这是合法的间接调用函数的方法<br># 编译器会自动将get函数地址放入rax,并将函数返回值(eax)保存回v<br># 函数返回的是int,和uint32_t v类型相兼容<br>
有参有返回值<br>先来个错误代码说明问题.这段代码会报错。<br>吐槽一下,内联汇编如果你写的有问题,看报错信息是看不出来的,这里原因是局部变量是64位,<br>汇编中你用了32位寄存器,言外之意就是你用寄存器的宽度需要与对应的参数宽度相同,将edi改为rdi即可<br><br>这里的%1、%2是什么意思呢?<br>将传出参数、传入参数组合在一起看,从0开始数<br>%0对应的是传入参数的第一个(是的,传出参数也可以有很多,用逗号隔开)<br>%1对应的传入参数的第一个<br>%2对应的就是传入参数的第二个<br><br>起始代码写到这个程度,是可以正常运行了,但是需要编译器在背后运算你会用到哪些寄存器<br>为了提升编译性能,我们可以改成这样,就是把第三个参数用起来,就是告诉编译器rdi寄存器我要用,你编译时注意一下<br>比如不要把他用作中转寄存器,代码如右图所示
因为Linux下默认是通过寄存器传参,第一个参数让在rdi中,所以代码可以优化成如下图所示<br>
<br>
如何调试。<br>跟调试C语言代码是一样的。不过需要说一下,比如你的代码只是写了三行,GCC编译器在编译的时候,为了保证你写的汇编代码与C语言编译生成的汇编代码融合会自动插入部分代码<br>
内联汇编与裸函数的区别<br>内联汇编(Inline Assembly)和裸函数(Naked Function)都是让你在C/C++中编写汇编代码的方式,但它们的目标、用途和控制粒度完全不同。下面是对这两者的系统性对比<br><br>总结一句话<br>内联汇编是在C/C++中夹杂一点汇编,适合"插刀"优化<br>裸函数是彻底接管函数的实现逻辑,适合需要控制到每条指令、每个字节的"地狱级"底层编程<br>
核心区别对比
示例对比。<br>内联汇编(Inline Assembly): 编译器仍然生成函数栈帧,自动管理返回值、参数传递、寄存器保存等<br>裸函数(Naked Function):没有push ebp、 mob ebp, esp,你需要自己写函数prologue/epilogue(建立栈帧/恢复栈帧)<br>
使用场景对比。<br>
注意事项:<br># __declspec(naked)只能用于MSVC+x86,在x64上呗废弃<br># 在x64架构下推荐使用:汇编.asm文件 +extern "C"<br>
让你的OS尽情发挥<br>
如何让内核突破512B瓶颈。<br>前面我们在bochs中将写的OS内核运行了起来,但是有个问题,就i是你的内核只能512字节,超过了就没法运行了,我们一个正常的OS,不可能只有512字节,所以我们要让OS突破512字节<br><br>突破512字节需要满足两个条件:<br># 我们写的OS内核超过512B在存储介质上如何存储?<br># 超过512B,在存储介质上,肯定存储在多个扇区,如何读入到内存中呢?(扇区是存储介质存储的最小单位)<br>
<br>
认识软盘。<br><br>这里主要了解下软盘的工作原理、如何读写软盘。<br>接下来我们先了解下几个专业术语(同样适用于硬盘):磁头、柱面、扇区<br># 磁头:一个存储盘片上有两个面,软盘驱动器有两个磁头用来读写这两个面<br># 柱面:也称为磁道。可以理解称在存储盘片上的一个面上花了79个同心圆,将一个面分成了80份。每一份称为一个柱面。编号由内向外:0,1,2....78,79<br># 扇区:可以理解称在存储盘片的一个面上竖切了17到,将一个面均匀分成了18份,每一份称为一个扇区。每个扇区的大小都是512B.我们之前写的内核就存储在0磁头0柱面1扇区,这里是默认位置。操作系统入口程序不放在这里,操作系统就无法启动<br><br>1.软盘是什么<br>软盘(Floppy Disk)是一种历史上的可移动磁性存储介质,层广泛用于数据的存储和传输,尤其在1980~2000年代之间的计算机中非常常见。它的英文名"Floppy"意为软的,因为早期的软盘外壳是柔软的塑料材料<br>2.工作原理<br># 使用磁头在磁盘表面读写磁性位"(bit)<br># 顺序地按扇区(sector)组织数据(一般每面有80个磁道,每磁道有18个扇区,每扇区512字节)<br># 插入软盘后,软驱旋转盘片,移动磁头来读取对应磁道与扇区地数据<br><br>3.常见用途(历史上)<br># 启动操作系统(如DOS)<br># 安装程序或驱动(如Windows 95)<br># 存储文档、代码、图片等小文件<br># 传递数据(“插拔U盘”之前的方式)<br><br>4.为什么淘汰了?<br># 容量太小(只有1.44MB)<br># 易损坏(磁性介质老化、受热、弯曲)<br># 读写速度慢<br># 易受磁场干扰<br># USB闪存盘、CD、互联网传输普及<br>5.软盘在今天还有用吗?<br># 极少部分BIOS/UEFI还支持软盘启动(比如加载某些底层驱动)<br># 软盘镜像(.img文件)仍用于模拟启动盘或旧系统(如DOS、FreeDOS、Windows95)<br><br>
软盘启动扇区的结构。<br>许多学习操作系统开发(OSDev)的人,都会从一个软盘引导扇区(512字节,bootloader)开始写汇编程序<br>[BITS 16]<br>[ORG 0x7C00]<br><br>mov ah, 0x0E<br>mov al, 'H'<br>int 0x10<br><br>jmp $<br>times 510 - ($ - $$) db 0<br>dw 0xAA55<br>上面这段就是一个软盘引导扇区程序,BIOS启动时会加载软盘的前512字节到0x7C00,并执行。<br>
如何用代码来读写软盘呢?<br>; 读盘<br>mov ch, 0 ; 0柱面<br>mov dh, 0 ; 0 磁头<br>mov cl, 2 ; 2扇区<br>mov bx, BOOT_MAIN_ADDR ; 数据往哪儿读<br><br>mov ah, 0x02 ; 读盘操作<br>mov al, 1 ; 连续读几个扇区<br>mov dl, 0 ; 驱动器编号<br><br>int 0x13
代码如何设计?<br>现将一个概念:扇区坐标,CHS,三个英文的缩写: cylinder(柱面)、head(磁头)、section(扇区)<br>再来一个:MBR(主引导记录),CHS=001的位置就是MBR区域,只有512字节。<br>所以一般操作系统的设计就是MBR驱动就干一件事,将真正的操作系统载入内存,并移交执行权限。MBR中的代码,一般称为boot loader<br><br>所以我们的代码设计就是如图所示<br>boot.asm,身份是bootloader,作用是将真正的OS内核,即setup.asm中的程序,读入内存<br>setup.asm,身份是OS内核,即真正要执行的代码<br>
0柱面0磁道0扇区说的是MBR区域吗?<br>0柱面0磁头1扇区(C=0,H=0,S=1)就是指硬盘上的第一个扇区,也就是MBR(Master Boot Record)所在的位置。<br>为什么不是扇区0?<br>在传统的CHS(柱面-磁头-扇区)寻址方式中,扇区号是从1开始技术的,不是从0<br>所以CHS(0,0,1)才表示磁盘的第一个扇区(LBA 0)<br>而CHS(0,0,0)是非法的寻址,不会使用<br>
实现目标<br>我们写的OS内核,实现了在软盘上的存储,如何载入内存,让它跑起来呢?是如何做到的?图画的很清楚<br>
数据写入软盘or硬盘<br>Linux工具:: dd if=$(BUILD)/boot/boot.o of=hd.img bs=512 seek=0 count=1 conv=notrunc<br><br>上述命令解析<br>当使用dd命令时,可以根据需求使用不同的参数来执行各种操作。以下是dd命令的一些常用参数:<br>if=input_file: 指定输入文件,可以是设备文件(如/dev/sda)或普通文件<br>of=output_file:指定输出文件,可以是设备文件或普通文件<br>bs=block_size:指定块大小。可以使用不同的单位,如字节、千字节(K)、兆字节(M)等<br>count=num:指定要赋值的块数<br>skip=num:跳过输入文件的前几个块<br>seek=num:在输出文件中跳过前几个块<br>status=progess:显示操作的进度<br>iflag=flags:设置输入标志,可以使用的标志包括direct(绕过缓存)和sync(每个块后执行fsync)等<br>oflag=flags:设置输出标志,可以使用的标志包括direct(绕过缓存)和sync(每个块后执行fsync)等<br>conv=conversion:设置转换选项,可以使用的选项包括ascii(使用ASCII模式)、ebcdic(使用EBCDIC模式)和unblock(去除记录标记)等<br><br>conv=notrunc是dd命令的一个选项,用于指定在写入输出文件时不截断(不清空)输出文件的内容。以下是对conv=notrunc的详细解释:<br>当使用dd命令将数据写入输出文件时,默认情况下,如果输出文件已经存在,dd会先清空输出文件的内容,然后写入新的数据。这意味着输出文件的旧内容将会丢失。使用conv=notrunc选项,可以告诉dd命令在写入输出文件时不清空(不截断)输出文件的内容。换句话说,dd会在输出文件中保留旧数据,并将新的数据追加到现有内容的末尾。这对于在已有文件的末尾添加数据或追加数据非常有用,而不会丢失文件中已有的内容。然而,需要注意的是,如果输出文件不存在,conv=notrunc选项将不起作用,dd命令会创建一个新的文件并写入数据<br><br><br>这些是dd命令的一些常见参数,请注意,dd是一种强大但也很危险的工具,因为它直接操作二进制数据,所以在使用时要小心,确保正确指定输入和输出文件以及其他参数。错误的使用可能会导致数据丢失或系统损坏。建议在使用dd命令时,仔细阅读相关文档并谨慎操作<br>
从软盘读取扇区<br>mov ah ,0x02 ; AH = 02h - 读取扇区<br>mov al, 1 ; AL = 扇区数<br>mov ch, 0 ; CH = 磁道号<br>mov cl , 1 ; CL = 扇区号<br>mov dh, 0 ; DH = 磁头号<br>mov dl , 0 ; DL = 驱动器号(0 = A; 1 = B)<br>mov es, segment_to_read; ES:BX -> Buffer<br>mov bx, buffer_offset<br>int 0x13<br>jc disk_error<br>; 成功读取扇区的代码在这里 <br>
向软盘写入扇区<br>mov ah, 0x03 ; AH = 03h - 写入扇区<br>mov al, 1 ; AL = 扇区数<br>mov ch, 0 ; CH = 磁道号<br>mov cl, 1 ; CL = 扇区号<br>mov dh, 0 ; DH = 磁头号<br>mov es, segment_to_write ; ES:BX -> Buffer<br>mov bx, buffer_offset<br>int 0x13<br>jc disk_error<br>;成功写入后的代码在这里<br>
读软盘<br>0x13通常表示BIOS磁盘服务中断。这是一个非常关键的中断,主要用于:<br># 读取扇区<br># 写入扇区<br># 控制软盘、硬盘<br>
CMOS、BIOS、MBR、GPT、GRUB、UEFI是什么?<br>1.CMOS(Complementary Metal-Oxide-Semiconductor)<br>CMOS是一种广泛用于制造集成电路(IC)的技术。在计算机中,CMOS是指使用该技术制造的微电子设备。CMOS的最大有点是它的高噪音免疫和低静态功耗。在PC中,CMOS RAM是一个易失性存储器,它保存计算机的重要信息,如硬件设置,系统时间和日期等<br>2.BIOS(Basic Input/Output System)<br>BIOS是计算机上的一种固件,用于在开机后执行硬件初始化,并为操作系统提供运行时服务。BIOS是装载在非易失性存储器(如ROM,EPROM,或flash存储器)上的一种软件,计算机在启动时自动调用这个软件。<br>3.MBR(Master Boot Record)<br>MBR是一种计算机硬盘的分区表结构。它存在于可引导设备(如硬盘,闪存驱动器等)的第一个扇区,MBR中包含一个引导加载程序和一个简单的分区表。然而,由于MBR的设计限制,他不能处理大于2TB的硬盘,也只能处理最多四个主分区<br>4.GPT(GUID Partition Table)<br>GPT是一个物理硬盘的分区表标准,是UEFI标准的一部分,用于替代旧的MBR。GPT支持超过2TB的硬盘,并且可以支持多达128个分区(取决于操作系统),不再受限于MBR的四个主分区的限制<br>5.GRUB(GNU GRand Unified Bootloader)<br>GRUB是一个来自GNU项目的多操作系统引导加载程序,它是许多现代操作系统,包括Linux和Unix的默认引导加载程序。GRUB允许用户在计算机启动时选择从哪个操作系统或特定内核版本启动<br>6.UEFI(Unified Extensible Firmware Interface)<br>UEFI是一种解耦,它填补了操作系统和系统固件之间的空白,提供了引导过程中所需要的服务,它是BIOS的现代替代品,可以处理更大的硬盘(使用GPT),并提供更快的引导时间和其他特性。UEFI支持驱动程序和引导加载程序在预引导环境中使用网络,这使得远程故障排查和设置成为可能<br>
BIOS<br>
UEFI<br>
MBR VS GPT<br>1.MBR(Master Boot Record):<br>MBR是一种最早的,也是最广泛使用的硬盘分区表结构,它存在于可引导设备(如硬盘,闪存驱动器等)的第一个扇区(扇区0)。MBR的主要组成部分包括一个位于MBR的开始位置的引导加载程序,以及一个位于MBR的结束位置的分区表。分区表可以定义最多四个住区,或者三个主分区加一个扩展分区。扩展分区可以进一步划分为更多的逻辑分区。然而,MBR存在一些限制。例如,它只能处理最大2TB的硬盘(对于现代大容量硬盘来说是一个限制),而且只能定义最多四个主分区<br>2.GPT(BUID Partition Table)<br>GPT是一种现代的,更强大的硬盘分区表结构,它是为了解决MBR的限制而设计的。GPT是UEFI(统一可扩展固件接口)标准的一部分吗,主要用于替代MBR<br>GPT的主要有点包括:<br># 对大硬盘的支持: GPT可以处理超过2TB的硬盘。事实上,它可以支持的硬盘大小几乎没有实际的限制<br># 多分区支持: GPT支持多大128个主分区(在Windows中),远超过MBR的四个主分区限制<br># 更强的数据完整性: GPT包含分区表的CRC校验和备份,可以提高数据的可靠性和完整性<br><br>在现代计算机系统中,特别是使用大容量硬盘和UEFI固件的系统中,GPT通常是推荐的分区表结构。<br><br>总结:<br>1.MBR是老板,GPT是新版<br>2.MBR仅支持四个分区,GPT支持128个分区<br>3.MBR支持最大2TB,GPT最大支持无限制<br><br>一般搭配:<br>1.BIOS+MBR<br>2.UEFI +GPT<br>
UEFI+MBR可以吗?<br>在大多数情况下,UEFI(Unified Extensible Frimware Interface)和GPT(GUID Partition Table)是一起使用的,而MBR(Master Boot Record)则常常与传统的BIOS一起使用。这是因为UEFI和GPT之间的设计是互补的,它们可以支持更大的硬盘(超过2TB),而MBR则不支持这种容量的硬盘<br>然而,UEFI固件的某些时间可以支持MBR,也就是说,它们可以在UEFI模式下引导MBR磁盘。这种情况下,UEFI将使用一个名为"兼容性支持模块"(CSM)的组件来模拟传统的BIOS环境,从而使其可以引导MBR磁盘,然而,这种模式会失去一些UEFI的有点,如快速启动和安全启动等<br><br>总的来说,尽管UEFI和MBR可以在某些情况下一起使用,但通常请开给你下推荐的配置是UEFI配合GPT,或者BIOS配合MBR,这些配置可以最大化利用各自的有点,而避免可能的兼容性问题<br>
GRUB提供了哪些支持?GRUB的界面如图所示<br>GRUB(GNU GRand Unified Bootloader)是一个非常强大且灵活的引导加载程序,它提供了许多有用的功能和特性:<br># 多操作系统支持: GRUB支持直接引导不同类型的操作系统,包括Linux、UNIX和Windows.你可以在同一台机器上安装多个操作系统,并在启动时选择启动哪一个。<br># 交互式命令接口:GRUB提供了一个命令行接口,可以在启动是进行交互操作。这意味着用户可以在启动过程中更改或编辑引导条目,例如更改引导参数或修复引导问题<br># 网络引导: GRUIB支持网络引导,这意味着它可以从网络服务器加载并启动操作系统。而不是从本地硬盘。这对于某些服务器环境或远程故障排除来说非常游泳<br># 非常灵活的配置: GRUB的配置文件格式非常灵活,可以用来描述复杂的引导情况,用户可以创建自定义的引导条目,或者调整GRUB的行为<br># 支持多种文件系统: GRUB支持多种不同的文件系统,包括FAT32、NTFS、HFSm Ext2/3/4等,这意味着它可以从几乎任何类型的磁盘分区启动<br># 支持UEFI和MBR:GRUB可以在传统的BIOS/MBR系统中工作,也可以在现代的UEFI/GPT系统中工作<br># 图形化界面:尽管默认情况下GRUB使用文本模式,但它也支持图形化的界面,用户可以自定义GRUB的外观,甚至使用图像作为背景<br><br>这些功能和特性使得GRUB成为一种非常强大且灵活的引导加载程序,特别是对于运行多种操作系统的系统来说<br>
磁盘寻址<br>1.CHS(圆柱Cylinder 、磁头Head 和扇区Sector)<br>2.LBA28<br>3.LBA48<br><br>LBA28(28-bit Logical Block Addressing)<br>LBA48 (48-bit Logical Block Addressing)<br>它们都是通过扇区编号来访问硬盘,而不是传统的CHS(柱面-磁头-扇区)寻址<br>
CHS寻址的最大容量<br>CHS寻址方式的容量由CHS三个参数决定:<br>1.磁头数最大为255(用8个二进制位存储),从0开始编号<br>2.柱面数最大为1023(用10个二进制位存储)。从0开始编号<br>3.扇区数最大64(用6个二进制位存储),从1开始编号<br>所以CHS寻址方式的最大寻址范围为:<br>255*1023*63 * 512 / 1048576 = 7.837GB(1M = 1048576 Bytes)<br>
为什么要引入LBA?<br><br>IO操作读取硬盘的三种方式:<br># CHS方式:小于8G<br># LBA28方式: 小于137GB<br># LBA48方式: 小于144,000,000GB 144PB<br>在LBA模式下,系统把所有的物理扇区都按照某种方式或规则看作是一线性编号的扇区,即从0到某个最大值方式排列,并连成一条线,把LBA作为一个整体来对待,而不再是具体到实际的CHS值,这样只用一个序数旧能确定一个唯一的物理扇区,这就是线性地址的又来,,显然线性地址是物理扇区的逻辑地址<br><br>LBA方式访问使用了data寄存器、LAB寄存器(总共3个),device寄存器,command寄存器来完成。<br>LBA28方式使用28位来描述一个扇区地址,最大支持128GB<br>最大扇区=2^28=268,435,456<br>容量=268,435,456 * 512 B = 137,438,953,472B=137.4GB,但是硬盘都是以2的整数倍出现的,所以不会有一块硬盘大小正好是137.4GB,所以是128G<br>
地址结构:<br>LBA28:<br># 使用寄存器方式访问,最多支持28位地址<br># 高4位在Device/Head寄存器中(位24~27)<br># 剩下的24位在LBA Low/ Mid /High 三个寄存器中<br><br>LBA48:<br># 扩展为48位,需要写入两次寄存器(高24位 + 低24位)<br># 支持更大容量硬盘<br># 使用SET FEATURES指令激活LBA48模式<br>
为何引入LBA48?<br>随着硬盘容量的爆炸增长(>137GB),LBA28无法寻址更大的硬盘,于是ATA-6标准引入了LBA48来支持高容量硬盘<br><br>如果有一个1TB硬盘,旧必须使用LBA48才能完全访问<br>
硬盘寄存器。<br>硬盘读写还是蛮复杂的。它不像读软盘,借助BIOS中断就能实现,我们需要通过它对外提供的controller寄存器去操纵它,比较复杂的硬盘都需要这么干,比如网卡,比硬盘还复杂<br>
IO端口地址分配<br>
如何填充硬盘寄存器呢?<br># 0x1F0/Data, 唯一一个16bit的寄存器,用来传输数据<br># 0x1F/Error,读的时候表示错误,8bit,每一位表示一种错误,这里不展开了<br># 0x1F2/Sector Count, 表扇区总数,读写的时候指定要操作的扇区总数<br># 0x1F3,0x1F4, 0x1F5 分别表示LBA地址的低中高8位,LBA地址有24位,还有顶部的4位如下所示<br>
0x1F6/Device/Head:<br># 0~3位为LBA地址的最高4位<br># DRV位为0表示该盘是主盘,1表示该盘是从盘<br># LBA位为1表示采用LBA寻址,0表示采用CHS寻址,现金一般都采用LBA寻址,所以0x1F3-5表示LBA地址的24位,否则赢表示CHS三个指标<br># bit 5 和bit 7 固定为1<br>
0x1F7/Status<br># ERR: 有错误发生,错误码放在错误寄存器中(0x1F1)<br># WFT: 检测到有写错误<br># RDY: 表示硬盘就绪,这是对硬盘诊断的时候用的,表示硬盘检测正常,可以继续执行一些命令<br># BSY: 表示硬盘是否繁忙,1表示繁忙,此时其他所有位无效<br><br>0x1F7/Command, 向这个寄存器写入命令来操作硬盘,具体下面<br>
0x3F6/DeviceCOntrol,这个寄存器只用到了低2位:<br># RST(bit2):设置为1时表示发送复位reset信号,正常情况下此位应为0<br># IEN(bit1):设置为1时键盘控制器将不会发送中断信号,正常情况应为0,使得键盘再完成某些命令之后能够发送中断给CPU,然后CPU进行后续处理<br>
特殊的寄存器0x1F7<br>1.如果写这个寄存器,代表向硬盘发命令: 0x20(读) 、0x30(写)、 0xec(硬盘检测)<br>2.如果读这个寄存器,读出来的值的含义如图所示<br>
CPU段页门<br>
为什么要学CPU的段页门?<br>OS是面向CPU的段页门实现的。<br>1.什么是段,就是经常听到的:代码段、数据段<br>2.什么是页,就是经常听到的:虚拟内存,又叫CPU分页基址<br>3.什么是门,与经常听到的用户态切内核态有关<br>4.想让CPU由实模式进入保护模式,必须构建段<br>
为什么CPU要进入保护模式?<br>到目前为止,手写的OS还只能运行在实模式下,只能用物理地址,内存寻址能力只能到1M,寄存器只能用16位,即便机器配置再高,都没办法充分发挥它的性能。所以目标是充分发挥机器的性能<br><br>如何才能充分发挥呢? 进入保护模式<br><br>什么是保护模式呢?或者换个问法,保护什么?<br>无论是段页,还是分页模式,其实保护的都是内存,控制对内存的访问权限,可见于CPU、OS而言,内存的重要性,可以这样说,一切程序都是围绕内存运行的<br><br>进入保护模式前需要做环境准备,三件事:<br>1.构建好GDT表,至少要有代码段、数据段<br>2.开A20总线<br>3.调整CPU至保护模式<br><br>这里面最复杂的就是构建GDT表。GDT又名全局描述符表,有全局的自然有局部的,局部描述符表,即LDT.<br><br>如何界定全局、局部呢?以进程为界限<br>全局描述符表,即约束所有进程<strike><br></strike>局部描述符表,即约束当前设置LDT表的进程。<br>如果同时设置,以LDT为准<br>
为什么说实模式下的寻址空间最大是1M?<br>这是因为在实模式下(Real Mode), x86 CPU使用的是一种特殊的段地址+偏移地址的寻址方式,其寻址能力受到段寄存器和偏移寄存器位宽的限制。<br>1.实模式寻址机制<br>在实模式中,内存地址是由:<br>物理地址 = 段地址 x16 + 偏移地址<br>段地址和偏移地址各自是16位的值(即最大是0xFFFF)<br>2.最大可寻址地址计算<br>最大段地址:0xFFFF<br>最大偏移地址:0xFFFF<br>所以最大物理地址是:<br>物理地址 = 0xFFFF x 0x10 + 0xFFFF =0xFFFF0 + 0xFFFF = 0x10FFEF=1114095(十进制)<br>即最大寻址范围是0x00000到0x10FFEF,但只有前1MB是有效可寻址的<br><br>3.为什么说是最大1MB而不是1MB多一点?<br>这是因为:<br># 虽然通过0xFFFF:0xFFFF理论上可以访问到0x10FFEF,即约1MB+64KB - 16 ,<br># 但实模式只使用低20位作为物理地址线,也就是说CPU会把地址的高位截断为20位<br>0x10FFEF(二进制) = 0001 0000 1111 1111 1110 1111<br>取低20位 = 0000 1111 1111 1110 1111 = 0x0FFEF<br>因此地址会回绕到低1MB范围内<br>4.A20地址线的由来<br># Intel为了保证和早期8086兼容(只能访问1MB),在80286以后的CPU中引入了A20地址线控制<br># 默认关闭A20, 会把地址高出20位的部分屏蔽<br># 所以,物理地址被强制限制在0x00000~0xFFFFF(即1MB)范围内<br># 若开启A20,就能访问1MB以上地址(这在进入保护模式或高内存访问中很关键)<br>
5.实模式寻址空间VS内存容量<br><br>6.总结<br>实模式最大寻址空间是1MB,因为地址线最多只允许20位地址(即2^20=1MB),高位被阶段或屏蔽。即时段:偏移组合算出来的地址超过1MB,最终物理地址仍然回绕在1MB范围内,除非开启A20地址线或进入保护模式<br>
CPU为什么要引入段机制?<br>段机制(Segmentation)是一种内存管理技术,它在Intel的8086微处理器中首次引入,该处理器于1978年发布。段机制的引入有几个主要原因:<br>1.内存保护:通过分割内存到不同的段,每个段可以有自己的访问权限,例如读/写/执行权限。这可以防止程序访问到它不应该访问的内存区域,增加了系统的稳定性和安全性<br>2.方便模块化编程:在早期的编程事件中,程序往往被分割称不同的模块,例如代码段、数据段、堆栈段等,每个模块可以被加载到内存的任意位置,并且可以独立地增长或缩小,这使得程序设计和内存管理变得更加灵活<br>3.突破物理内存限制:对于8086这样的16位处理器来说,它的地址线只有20跟,最大只能寻址1MB的物理内存,然而,通过段基址,CPU可以使用一个段基址加上一个偏移量的方式来寻址内存,这样就可以突破1MB的物理内存限制,访问更大的虚拟内存空间。虽然段机制在当时解决了一些问题,但也引入了新的问题。例如段间通信的复杂性、内存碎片等问题。后来,随着Intel 80386微处理器的退出,CPU开始引入了更加复杂的内存管理基址,例如分页基址(Pagin),已解决段基址的一些问题。现代的操作系统,如Linux和Windows,主要使用分页基址进行内存管理,而段基址主要用于实现某些特定的功能,例如用户级线程、内存保护等<br>
CPU引入段基址(Segmentation)的根本原因是:解决早期处理器地址空间优先、内存管理不灵活的问题,并提供一种更高效、更安全、更模块化的内存访问方式:<br>1.历史背景: 8086的硬件限制<br># 8086 CPU的寄存器都是16位->最大只能表示0x0000~0xFFFF(即64KB)<br># 但8086有20条地址线->最大可以访问1MB(2^20=1048576字节)的内存<br>问题来了:如何在只用16位寄存器的条件下访问20位地址?<br>解决方案:<br>引入段机制:通过段寄存器+偏移寄存器组合,频出20位地址<br><br>2.为什么段基址是好办法?<br># 突破寻址能力限制(技术层面)<br>## 用两个16位寄存器(段+偏移)拼出一个20位物理地址<br>物理地址= 段寄存器 x 16 + 偏移<br>## 这让程序可以在每个段内访问最多64KB,而整个系统最多能访问1MB内存<br><br># 支持程序模块化(软件架构)<br>## 可以把不同的程序逻辑或数据,放在不同的"段"里:<br>### 代码段(CS)<br>### 数据段(DS)<br>### 栈段(SS)<br>### 附加段(ES)<br>## 每个段独立寻址,结构清晰,支持程序分模块设计<br><br># 内存保护与安全性(尤其在保护模式下)<br>## 在保护模式中(如80286+),段机制不仅仅是计算地址<br>### 每个段有一个描述符<br>### 包含段的其实地址、段长、访问权限<br>## 操作系统可以通过段机制实现内存隔离、权限控制,防止程序互相干扰<br><br># 动态加载与共享内存(操作系统层面)<br>## 不同进程的段可以映射到相同的物理地址,实现共享内存<br>## 操作系统可以灵活重定位段,提升内存利用率<br><br>
3.段机制的局限<br>虽然段机制最初解决了很多问题,但随着硬件和软件的发展,它也暴露了一些缺点:<br># 复杂<br>编程和编译器都要处理段寄存器,逻辑复杂<br># 不利用大内存模型<br>每段最大只能访问64KB,不够用<br># 不利于现代内存管理<br>页式管理更灵活、效率更高<br><br>4.后来为什么被分页机制取代?<br>从80386开始,x86引入了分页机制(Pagin)可以<br># 更精细地控制内存访问(4KB为单位)<br># 更容易实现虚拟内存<br># 程序不需要关心段寄存器,简化了编程<br>现在主流操作系统(如Linux、 Windows)在实际运行中几乎完全依赖分页,段机制几乎只保留最低限度支持(如CS、DS仍存在,但一般不再切换段)<br>
总结<br><br>
CPU为什么要引入页机制?<br>页机制(Paging)是一种内存管理技术,主要用于支持虚拟内存系统,它的引入有几个主要原因:<br>1.简化内存管理:页机制允许系统将物理内存划分为固定大小的块(称为页或页面),并将这些页映射到虚拟内存的任意位置。这样,操作系统只需要跟踪哪些页被分配和哪些页可用,而无需担心内存碎片等问题<br>2.支持虚拟内存:页机制可以将部分内存内容存储到硬盘上,从而使得程序可以使用更大的地址空间,超出物理内存的限制。当程序访问到未在物理内存中的数据时,系统会自动将数据从硬盘读取到内存中,这个过程对程序是透明的<br>3.内存保护:每个页面都有自己的访问权限,例如读/写/执行权限。这可以防止程序访问到它不应该访问的内存区域,提供更强的内存保护<br>4.支持内存共享和赋值:页机制使得实现内存共享和赋值更为简单。例如,多个进程可以共享同一个页面,或者通过赋值页面来实现进程的复制(如Unix中的fork系统调用)<br>页机制在Intel的80386微处理器中首次引入,该处理器于1985年发布。80386是Intel第一个支持分页的32位微处理器,它增加了页表和相关的硬件支持,使得操作系统可以更有效地管理内存<br>
CPU引入页机制(Paging),是为了解决段基址难以克服的几个核心问题,特别是在多任务、内存保护、虚拟内存和大内存程序管理中。一下是为什么要引入分页机制的详细解析:<br>1.段机制的局限<br>虽然段机制提供了模块化与内存保护,但它有以下致命缺点<br># 每段最大只能64KB<br>## 在实模式下,每段最多访问64KB<br>## 即时到了保护模式,每段可以扩展到4GB,但访问方式仍然不灵活<br># 地址连续性要求高<br>## 程序或数据需要在内存中占据连续地址,碎片管理困难<br># 程序重定位复杂<br>## 如果程序要换到别的内存地址,段寄存器要调整,逻辑麻烦<br># 多进程隔离能力有限<br>## 段机制下,很难做到强制、细粒度的进程隔离和安全防护<br><br>2.分页机制的优势<br>分页机制的设计解决了上述问题,提供了现代操作系统急需的能力:<br># 支持虚拟内存<br>## 把程序使用的地址空间(虚拟地址)和实际物理地址隔离<br>## 可以运行超出物理内存大小的程序(借助硬盘交换)<br># 不需要连续的物理内存<br>## 一个进程的虚拟地址空间可以是连续的<br>## 实际在物理内存中可以是离散分布的,避免内存碎片<br># 进程隔离、安全性高<br>## 每个进程都有自己的页表<br>## 访问别的进程地址会造成缺页或权限异常(Page Fault)<br># 页面共享方便<br>## 多个进程可以共享相同的代码页(如libc.so)<br>## 提高缓存命中率,减少内存占用<br># 内存按页分配,简单高效<br>## 通常一页大小是4KB、2MB、1GB<br>## 页式分配让内存管理逻辑更清晰(Page Frame)<br><br>
3.分页机制是如何工作的?<br># 分页基本单位:页(Page)<br>## 虚拟内存划分为等大小的页(如4KB)<br>## 物理内存划分为页框(Page Frame)<br># 中央角色: 页表(Page Table)<br>## 存储虚拟页号->物理页号的映射关系<br>## 每个进程有自己的页表(保护和隔离)<br># 访问流程<br>## 程序发出虚拟地址0x8048123<br>## CPU拿到前面的页号部分,查页表找到对应的物理页<br>## 用页号+页内偏移 拼出物理地址<br><br>4.分页 + 分段: 如何共存?<br># 在现代x86架构(如32位保护模式)中,分页和分段可以并存,但:<br>## 段机制仅用作粗粒度划分(如代码段、数据段)<br>## 分页才是精确控制、虚拟内存、权限隔离的主力<br># 在64位模式下(x86-64):<br>## 段基址被弱化,除了FS/GS之外,几乎不用<br>## 分页基址是唯一可用的内存管理机制<br>
总结对比<br><br>CPU引入分页机制,是为了实现更灵活、高效、安全的内存管理,并支持虚拟内存、多任务隔离等现代操作系统的核心功能。分页机制可以说是从x86到x86-64所有主流平台不可或缺的底层能力<br>
认识CPU特权。<br>CPU在设计的时候有特权分别的,R0>R1>R2>R3.<br>应用程序运行在用户态,从CPU的角度来说,就是R3,<br>内核运行在内核态,从CPU的角度来说,就是R0<br>R1、R2操作系统没用,有些CPU虚拟化程序会用。<br>大家经常听到的用户态切内核态,本质就是R3切R0.<br><br>内核跑在R0,软件跑在R3,就是利用CPU的段隔离实现的。<br>CPU的段的配置信息就是写在GDT表里。<br>
CPU运行模式切换<br>
实模式寻址<br>
16位CPU下<br>
32位CPU下<br>
保护模式下段的大小是怎么突破64KB的?<br>在x86保护模式(Protected Mode)下,段的大小之所以能突破64KB的限制,是因为段地址的计算方式与实模式完全不同:它不再是简单的段寄存器x16 + 偏移,而是依赖于段描述符中定义的长度限制(Limit字段),并且支持一个重要的机制——粒度位(Granularity, G位)<br>1.保护模式下段地址的形成方式<br># 段寄存器(如DS,CS,SS等)<br>在保护模式中,不在直接保存段基址,而是作为一个段选择子(Selector),用来索引GDT/LDT中的段描述符(Segment Descriptor)<br># 段描述符结构(8字节)包含:<br>## Base(段基址):32位(段的起始地址)<br>## Limit(段界限):20位(描述段的大小)<br>## G(Granularity 粒度位): 1位(用于扩大Limit的单位)<br><br>
2.关键点:粒度位G是如何突破64KB限制的?<br>## Limit字段位20位,最大值是0xFFFFF(约1MB)<br>## 粒度位(G)控制Limit的单位是字节还是4KB页<br>例子:<br># Limit = 0xFFFFF, G = 0 ->段大小=1MB<br># Limit = 0xFFFFF, G = 1->段大小 = 0xFFFFF x 4KB = 4GB<br>
3.保护模式段基址突破64KB的核心逻辑<br><br>
4.开发实践中的应用<br>在现代操作系统中,虽然保护模式段机制共嗯那个强大,但通常做法是<br># 所有段都设置为: 基址为0,Limit为最大值(4GB)<br># 所有进程共用一个"平坦模型"内存空间<br># 真正的地址隔离靠分页基址实现(而非段)<br><br>示例:段描述符结构(GDT)<br>// GDT段描述符结构<br>struct SegmentDescriptor {<br> uint16_t limit_low; // Limit 0 ~15<br> uint16_t base_low; // Base 0~15<br> uint8_t base_mid; // Base 16~23<br> uint8_t access; // 段访问权限<br> uint8_t granularity; // 高4位Limit, G/D/B/AVL 位<br> uint8_t base_high; // Base 24~31<br>}<br><br>granularity的高位包含G位:<br># G = 1: 段限制以4KB为单位<br># G = 0: 段限制以1字节为单位<br>
总结
GDT表是干啥的?|<br>GDT表存储了OS定义的所有段信息。<br>GDT(Global Descriptor Table.全局描述符表)是x86架构保护模式下用来管理内存访问权限和内存分段的核心数据结构之一。<br>1.一句话定义:<br>GDT是一个内存中存放"段描述符(Segment Descriptors)"的表,高速CPU每个段的起始地址、大小、访问权限等信息。<br>在保护模式(Protected Mode)中,CPU不再使用实模式那种"段寄存器 x 16 + 偏移"计算地址方式,而是使用GDT来获取段的详细定义<br>2.GDT的作用有哪些?<br># 段地址映射<br>通过段选择子(如CS, DS)索引GDT,获取段的基址和大小<br># 访问权限控制<br>每个段有权限字段,限制读写、执行权限<br># 用户态与内核态隔离<br>段描述符有权限等级(DPL),用户态不能访问内核态段<br># 特殊段支持<br>如TSS(任务状态段)、LDT(本地描述符表)描述符<br>3.GDT表项结构(段描述符)<br>每个GDT表项是一个8字节(64位)的结构体,包含段的全部元数据<br># Base字段,占32位,段的起始地址<br># Limit字段, 占20位, 段的长度<br># G位(粒度),占1位,是否以4KB为单位(G=1)<br># DPL字段, 占2位,段的权限等级(0-3)<br># S位, 占1位,是否是代码/数据段<br># Type, 占4位,是代码/数据?可读写?执行?<br># P位, 占1位,段是否存在<br>
4.GDT如何使用(工作基址)<br># 系统启动饼切换到把耦合模式后,会先构建GDT<br># 加载GDT的地址和大小到CPU的GDTR寄存器<br>lgdt [gdtr]<br># 使用段寄存器(如CS, DS)中的段选择子(Selector)索引GDT中的条目<br>mov ax, 0x10 ; 0x10是GDT中的某段的选择子<br>mov ds, ax ; 加载段寄存器, 段基址和权限生效<br>
5.GDT的使用示意图<br>Segement Selector(如DS = 0x10)<br>-> index = 0x10 >> 3 = 2<br>-> 从GDT第2项读取段信息<br>
6.简单示例: GDT描述符表项(逻辑环境)<br>gdt_start:<br> dq 0 ; 空段(必须有)<br><br>gdt_code:<br> dw 0xFFFF ; Limit (Low)<br> dw 0x0000 ; Base(Low)<br> db 0x00 ; Base(Middle)<br> db 10011010b ; Access Byte(代码段)<br> db 11001111b ; Granularity (G = 1, D = 1)<br> db 0x00 ; Base(High)<br>gdt_end:<br>
7.总结<br>
OS如何用GDT?<br>CPU在执行程序或者读写内存的时候,都需要先看下段寄存器中的数值来判断权限、内存边界、可执行权限、可读写权限等,只有这些检测都通<br>过了,我们让OS做的事情,CPU才允许执行<br>1.特权指令<br>CPU对于GDT表提供了两个特权指令<br># lgdt(load gdt)用于获取GDT表的地址、大小<br># sgdt(store gdt)用于设置GDT表的地址、大小<br>为什么叫特权指令呢?因为只能在r0执行,其他CPU权限级别执行都会报错。这个报错是由CPU发出的,而不是OS.我们想通过写代码操作GDT,就需要用到这两个指令<br>2.CPU提供了哪些段寄存器呢?<br>ES、CS、SS、DS、FS、GS 一共6个段寄存器,比较重要的是这3个: CS、SS、DS<br>CS: Code Segment 代码段<br>SS: Stack Segment栈段<br>DS: Data Segment 数据段<br><br>继续回到OS如何使用GDT这个话题,CPU就是对段寄存器中的数值进行解析拿到索引,然后查GDT表那些特定的描述符。如果CPU现在在执行某段代码,比如你在你写的应用程序中使用到了lgdt指令,前面说了这个是特权指令,r3的应用程序是无权限执行的,那CPU在执行这个指令的时候就会看下CS寄存器中的权限位,如果是0就可以执行,如果不是就触发异常。CPU执行出错都会以触发异常的形式反馈给OS<br>
CPU如何解析段寄存器的数值呢?<br>对了,段寄存器中的数值跟通用寄存器不一样,它是有名字的: 段选择子。<br>段选择子的组成结构如图所示:<br># Index: 在GDT数组或LDT数组的索引号(3~15位)<br># TI: Table Indicator,这个值标识查找GDT, 1则查找LDT<br># RPL: 请求特权级,以什么样的权限去访问段<br>
当CPU解析出段选择子的INdex,去GDT表中拿到一个8字节的段描述符,又是如何解析的呢?<br>BASE:段基址,由上图中的两部分组成(BASE 31~24和BASE 23~0)组成<br>G: LIMIT的单位,改位0表示单位是字节,1表示单位是4KB<br>D/B: 该位位0表示这是一个16位的段,1表示这是一个32位段<br>AVL:该位是用户位,可以被用户自由使用<br>LIMIT:段的界限,单位由G位决定,树枝上(经过单位换算后的值)等于段的长度(字节)-1<br>P: 当P=1时,段描述符有效,当P=0时,段描述符无效<br>DPL:段权限<br>S:该位为1表示这是一个数据段或者代码段。为0表示这是一个系统段(比如调用门,中断门等)<br>TYPE:根据S位的结果,再次对段类型进行细分<br><br>由于Intel考虑向前兼容,我们会发现段描述符的结构比较混乱,东一块西一块的,Attribute在段描述符高43字节的8-23位,Base在段描述符的低4字节的16-31位。高4字节的0-7位和24-31位。上述两个属性没有数据丢失,段寄存器和段描述符内的位数时一样多的,而Limit在段寄存器内占32位,在段描述符内只有20位<br>
S=1代码段或数据段的描述符<br>
E、W、A解释<br>在x86架构中,段描述符的属性包含了一些特定的标志位,用来表示i段的各种属性,对于EWA,它们的意义如下<br>1.E(Expand-Down):此位用于数据段描述符中,标志位被设为1则表示这是一个扩展向下(expand-down)的段。在扩展向下的数据段中,偏移量大于等于段界限的字节是可访问的,偏移量小于段界限的字节是不可访问的。相反,如果标志位设为0,那么它就是一个普通的数据段,偏移量小于等于段界限的字节是可访问的,偏移量大于段界限的字节是不可访问的<br>2.W(Writable):此位用于数据段描述符中,表示数据段是否科协。如果标志位被设为1,那么这个数据段是可写的,如果标志位被设为0,那么这个数据段是只读的<br>3.A(Accessed): 此位用于段描述符中,表示段是否被访问过。当CPU首次访问该段(无论是读、写还是执行)时,处理器会将A位设置位1。操作系统可以清除此位,然后检查它是否在后续被设置为1,以此来检测段在某个时间段是否被访问过<br>需要注意的时,对于代码段描述符,E位代表的时Executable,表示这是一个代码段,W位代表的是Readable,标识代码段是否可读,A位的意义与上述相同<br>
Expand-down Segment<br>在x86架构中,“扩展向下的段或向下扩展段的段”(Expand-down Segment)是一种特殊类型的数据段,其工作方式与普通数据段略有不同。在一个普通的数据段中,偏移地址从0开始,增加到段的界限。也就是说,低地址处的偏移量是0,高地址处的偏移量是段界限。只有当偏移量小于或等于段界限时,这个偏移量才是有效的。如果尝试访问超过界限的偏移量,CPU会产生一个异常。然而,在一个扩展向下的数据段中,情况恰好相反。这种类型的段的偏移量从段界限开始,并增加到最大偏移量。这就意味着,偏移量小于或等于段界限的部分时不可访问的,而大于段界限的部分时可访问的。例如如果段界限0x1000,那么偏移量0x1000到0xFFFF时可访问的,而偏移量0x0000到0xFFFF是不可访问的.<br>扩展向下的段在某些特殊场合很有用。例如,当你需要一个可以动态增长的数据结构,如栈,但又不希望它覆盖某个特定地址范围的内存时,你可以使用扩展向下的段。如果该位置为1,则标识该段为扩展向下的段
C、R、A<br>在x86架构中的段描述符(Segment Descriptor)中,C、R、Afenbei daibiao Conforming、Readable和Accessed标志位<br>1.C(Conforming)位:此位在代码段描述符中使用,用于标识此代码段是否是conforming的,如果设置为1,任何低于此代码段特权级别的程序都可以访问这个段。如果设置为0,只有与代码段同样特权级别的程序才能访问这个段<br>2.R(Readable)位:此位在代码段描述符中使用,用于标识此代码是否可读。如果设置为1,可以从此代码段中读取数据(例如,通过跳转指令)。如果设置为0,这个代码段只能执行,不能读取<br>3.A(Accessed)位:此位用于表示此段是否已被CPU访问。当CPU首次访问此段(无论是读/写/执行)时,硬件会自动将此位设置为1。这个位可以被操作系统用于各种优化,例如页面替换算法<br><br>以上位都在创建段描述符时由操作系统设置,并且在程序运行期间由硬件自动处理。不同的位组合可以表示不同类型的段,例如数据段、代码段、系统段
S=0系统段的描述符<br>
DPL:描述符特权(Descriptor Privilege Level)<br>存储在描述符中的权限位,用于 描述代码的所属的特权等级,也就是代码本身真正的特权级。一个程序可以使用多个段(Data、Code、Stack)也可以只用一个code段等。正常情况下,当程序的环境建立好后,段描述符都不需要改变——当热按DPL也不需要改变,因此每个段的DPL值时固定的<br>
RPL:请求特权级(Request Privilege Level)<br>RPL保存在选择子的最低两位,RPL说明的时进程对段访问的请求权限,意思时当前进程想要的请求权限,RPL的值由程序员自己来自由的设置,并不一定RPL>=CPL.但是当RPL<CPL时,实际起作用的就是CPUL了,因为访问时的特权检查是判断EPL = max(RL, CPL) <= DPL是否成立,所以RPL可以堪称是每次访问时的附加限制,RPL=0时附加限制最小,RPL=3时附加限制最大。所以你不要想通过来随便设置一个RPL来访问一个比CPL更内层的段 。<br>因为你不可能得到比自己更高的权限,你申请的权限一定要比你实际权限低才能通过CPU的审查,才能对你放行。所以实际上RPL的作用是程序员可以把自己的程序降级运行——有些时候为了更好的安全性,程序可以在适当的时机把自身降低权限(RPL设成更大的值)<br>
CPL:当前任务特权(Current Privilege Level)<br>表示当前正在执行的代码所处的特权级。CPL保存在CS中的最低两位,是针对CS而言的。当选择子成功装入CS寄存器后,相应的选择子中的RPL就变成了CPL。因为它的位置变了,已经被装入到CS寄存器中了,所表达的意思也发生了变化——原来的要求等级已经得到了满足,就是当前自己的等级。<br>选择子可以有许多个,因此RPL也就有许多个,而CPL就不同了,正在执行的代码在某一时刻就只有这个值唯一的代表程序的CPL<br>
如何控制CPU由实模式进入保护模式?<br>1.配置GDT表,至少包含一个代码段一个数据段<br>2.开A20总线<br>3.设置控制寄存器CR0<br>4.设置段寄存器<br>
配置GDT表,至少包含一个代码段一个数据段<br><br>
A20总线与地址环绕。<br>A20线是指在x86体系结构的计算机中,处理引脚上用于标识第21个地址的线。也就是说,通过A20线可以访问的内存地址范围从0到2^21-1,即到2MB.这条线的名字源于它用于访问内存地址的第20位(计数从0开始)<br>在早期的8086和8088微处理器中,只有20跟地址线(A0~A19),因此这些处理器只能访问1MB的内存。因此,当地址达到1MB时,它将会绕到0,这称为地址环绕。所以,当8086处理器试图访问物理地址100000h(1MB)时,实际访问的时00000h(0MB),因为只有20位地址线。然而,当Intel推出80286处理器时,它增加了更多的地址线,允许处理器访问超过1MB的内存。然而,为了保持对8086模式的兼容性,A20线被默认关闭,使得处理器仍然处于地址环绕的状态,这就是所谓的A20门,它可以通过键盘控制器或其他特殊的硬件基址开启或关闭。当A20门开启时,A20线被激活,处理器可以访问超过1MB的内存,地址环绕被进制。这在80286的保护模式和更高级的处理器模式中非常有用。总的来说,A20线和A20门是为了解决x86架构在扩展内存支持时的历史遗留问题,同时保持对早期软件的兼容性<br>
控制寄存器CR0。<br>控制寄存器CR0时Intel x86架构中最重要的控制寄存器之一,它用于控制CPU的全局运行状态和行为模式,包括是否启用保护模式、分页基址、协处理器、缓存等功能<br>1.CR0是什么?<br>CR0是x86架构中四个控制寄存器之一(CR0~CR4),是一个32位或64位的寄存器(根据CPU架构)。它的每一位都对应某个控制功能开关位<br><br>
<br>
2.CR0各个位含义(重点位)<br>3.CR0最常用的两个位:PE和PG<br># PE(位0):Protection Enable<br>## 0: 实模式(Real Mode)<br>## 1: 保护模式(Protected Mode)<br>## 设置PE为1时从实模式切换到保护模式的第一步<br># PG(位31): Paging<br>## 0: 未开启分页机制,段机制生效<br>## 1:开启分页机制,启用页表地址转换(虚拟内存)<br>## 必须在PE =1(已进入保护模式)的前提下设置<br>4.示例:设置保护模式(汇编代码)<br>mov eax, cr0<br>or eax, 0x1 ; 设置 PE = 1<br>mov cr0, eax ; 写回CR0,开启保护模式<br><br>开启分页:<br>mov eax, cr0<br>or eax, 0x80000000 ; 设置PG = 1(最高位)<br>mov cr0, eax
5.CR0与操作系统开发<br>在写操作系统或裸机程序时:<br><br>
6.总结<br>
设置段寄存器
CPU是如何找到数据并读写的?<br><br>
CPU与OS的关系<br>OS是面向CPU的段页门实现的。<br>CPU与操作系统(OS)之间的关系可以用一句话概括:<br>CPU是计算机的"执行核心",而操作系统是"管理和控制"这个核心的程序。它们之间是一种密切协作的关系——CPU提供能力,OS负责调度与管理。<br><br>总结一句话<br>CPU是执行的引擎,操作系统是调度的司机,没有操作系统,CPU就像发动机空转,没有CPU,操作系统就是一堆代码<br>
1.两者角色定义<br>
2.CPU提供基址,OS负责利用它们<br>
3.它们是如何配合工作的?<br>以下是一个从用户按下回车键到程序执行的完整流程:<br># 用户按下键盘<br># 键盘控制器发出中断信号给CPU<br># CPU保存当前状态,调用OS的中断处理程序<br># 操作系统读取案件信息并处理<br># 用户程序被调度运行<br># CPU加载该程序上下文并执行其代码<br>
4.CPU运行程序的两个模式:用户态和内核态<br>
让你的内核进入保护模式
进入保护模式前为什么要关闭中断? 哪些时机需要关闭中断?<br>编写操作系统是,可能需要在以下几种情况下关闭中断:<br>1.临界区:当CPU正在执行设计到对共享资源(如全局变量、数据结构等)进行修改的敏感代码(临界区)时,为了防止数据的不一致性,通常需要关闭中断<br>2.系统调用:系统调用是用户空间到内核空间的一个切换过程。为了防止系统调用过程中被打断,从而导致系统的不稳定,也需要关闭中断<br>3.上下文切换:当操作系统需要进行进程或线程的上下文切换时,也需要关闭中断。否则在保存和恢复上下文的过程中,如果发生中断可能会引发问题<br>4.内存管理:在进行页面置换等操作时,可能需要关闭中断,以防止在操作过程中被中断打断,导致数据的不一致性<br><br>然而,需要指出的是,长时间关闭中断会导致系统的响应性降低,因此在实际操作中,我们应该尽量减少关闭中断的时间,并使用其他同步机制(如互斥锁、信号量等)来保证数据的一致性。此外,现代多核处理器中的多线程环境也使得关闭中断并不能解决所有的同步问题。因此更多的 是倾向于使用更为复杂的并发控制机制<br>
构建GDT表<br>
写入GDTR寄存器<br>
加载gdt表<br>
生成段选择子
开关A20总线的方式。<br>开启和关闭A20线主要有以下两种方式:<br>1.通过键盘控制器(8042):这是最初IBM设计的方案,因此大多数系统都支持,关闭和开启A20线的方法如下:<br># 关闭A20线<br>## 向端口0x64写入0xD1<br>## 向端口0x60写入0xDD<br># 开启A20线<br>## 向端口0x64写入0xD1<br>## 向端口0x60写入0xDF<br><br>2.通过系统控制寄存器(Fast A20 Gate):在PS/2和后续的计算机中,可以通过系统控制寄存器(System Control Port A,位于端口0x92)来开启或关闭A20线,具体的操作步骤如下:<br># 关闭A20线<br>## 从端口0x92读取一个字节<br>## 将这个字节与0xFE进行AND运算(即清楚最低位)<br>## 将结果写回端口0x92<br># 开启A20线<br>## 从端口0x92读取一个字节<br>## 将这个字节与0x01进行OR运算(即设置最低位)<br>## 将结果写回端口0x92<br>
设置段寄存器、内核栈<br>
C语言调用汇编<br>1.汇编层面的函为什么要为global<br>2.C语言层面通过函数原型去调用(extern 可以不写,但是为了代码可读性,建议写)<br>
<br>
汇编调用C语言<br>1.C语言层面的函数必须是全局函数,即不能有static修饰<br>2. 汇编层面通过extern引入<br>
<br>
<br>
单步调试内核之生成内核<br>1.生成内核时,要指定内核的起始地址(必须)<br>2.内核依赖的所有.o文件,需要带调试符号<br>
<br>
ELF文件是什么?<br>ELF(Executable and Linkable Format)文件是一种通用的可执行文件格式,广泛用于Unix/Linux系统中,包括可执行程序、目标文件(.o)、共享库(.so)、核心转储等,它是Linux下的"可执行程序的容器"<br>
1.ELF文件的用途类型<br>
2.ELF文件的结构(逻辑视角)<br><br>
3.各个部分详解<br># ELF Header(文件头)<br>## 文件表示(魔数: 0x7F'E''L''F')<br>## 是32位还是64位<br>## 大小端信息<br>## 文件类型(可执行、共享库、目标文件)<br>## 起始的ProgramHeader表和Section Header表的偏移地址<br><br># Program Header Table(程序头)<br>## 用于加载程序<br>## 供操作系统加载用(运行时用)<br>## 每一项叫一个segment(段),如.text、.data等对应一个加载段<br><br># Section Header Table(节头)<br>## 用于链接器查看<br>## 供开发工具/链接器使用(编译时用)<br>## 包含.text .data .bss .symtab .strtab. rel .text等节的信息<br>
4.常见节区(Section)<br><br>
5.ELF文件查看工具<br># readelf<br>## readelf -h a.out // 查看ELF头部<br>## readelf -S a.out // 查看节区头信息<br>## readelf -l a.out // 查看段(Program Header)<br><br># objdump<br>## objdump -d a.out // 反汇编 .text区<br>## objdump -x a.out // 查看头、节、符号表等信息<br><br># nm<br>nm a.out // 查看符号(函数/变量)地址<br>
6.ELF文件结构图(简化)<br><br>
7.ELF的优点<br># 支持可执行文件、目标文件和共享库的统一格式<br># 支持静态与动态链接<br># 灵活的节区/段结构,支持扩展<br># 与平台无关的结构,支持x86、ARM、RISC-V等架构<br>
8.总结
ELF文件跟裸机开发写内核有什么关系?<br>ELF文件与裸机开发中写内核(如写一个操作系统的内核或引导程序)有非常紧密的关系,主要体现在以下几个方面:<br>1.简要结论<br>ELF是你编写内核时,编译出来的产物格式。它本身不能直接被BIOS/UEFI加载运行,但可以作为中间产物bootloader加载或供调试使用<br>2.裸机内核通常是一个ELF文件<br>当你用GCC编译一个C或汇编写的内核程序时,默认会生成一个.elf格式文件,例如<br>gcc -ffreestanding -m32 -c kernel.c -o kernel.o<br>ld -T linker.ld -o kernel.elf kernel.o<br>这就是一个内核的ELF格式文件,它包含了以下内容<br># 包含了代码段、数据段<br># 包含段表(.text./.data/.bss)<br># 可以被调试器(如GDB)理解<br># 但不能直接运行在裸机上,BIOS/UEFI不能直接识别ELF文件<br>3.ELF文件不能直接放在启动扇区<br>BIOS引导阶段读取的是:<br># 第一个扇区(MBR)中的512字节机器码<br># 它期望的是纯二进制格式,而非带结构的ELF文件<br><br>所以不能直接用:<br>dd if=kernel.efl of=/dev/sdx<br>而是需要通过一个bootloader(如GRUB、自制bootloader)区加载ELF文件<br>4.自写Bootloader + 解析ELF<br>你可以写自己的bootloader,用16位实模式加载硬盘中的kernel.efl文件,并解析其中的ELF Header和Program Header表,手动把.text .data等段加载进内存,再跳转到entry point这种做法更底层,适合深入理解ELF格式和裸机加载的机制<br><br>5.如果不用ELF,可以用Binary<br>对于早期bootloader或极简内核开发,可以用objcopy将ELF文件转为裸二进制文件:<br>objcopy -O binary kernel.elf kernel.bin<br>这条指令干的事情就是把ELF文件中的这么几个节copy出来生成内核文件:.text .data. bss、字符串常来给你吃<br>然后bootloader只需加载kernel.bin到某个内存地址,跳过去即可<br>mov si, kernel_load_addr<br>jmp si<br>缺点是不能识别短信息、不利于调试<br>6.调试时ELF的优势<br># 可以用gdb kernel.elf启动调试器<br># 可以看到函数名、变量、符号表<br># 与QEMU联动可实现断点、单步调试<br>
让内核支持C语言开发与调试。<br>如果我们只能用汇编写内核,那效率实在太低了,引入C语言是必然的。但是在Linux下编译C语言程序生成的是ELF文件格式。GCC默认编译时,为了程序能够稳定运行,默认做了很多事情<br>比如如果有include,会将头文件合并编译<br>比如会增加栈保护代码<br>比如会进行代码优化<br>做了这些事情,在一个裸机上都是无法运行的<br><br>所以如果想引入C语言开发,我们的目标就是要得到我们写的C语言程序生成的指令,其他的都不需要,如果要做到此,需要你对ELF文件、gcc有足够的了解。如果想要支持调试,还得在gcc编译时增加选项-g 生成调试符号<br>
实现内核打印函数printk<br>
实现思路<div></div><div></div>
控制硬件的方式<br>1.端口(硬件内部寄存器,典型的: 硬盘)<br>2.共享内存(最典型的:显卡)<br>3.BIOS中断(硬盘、软盘)<br>
CGA是什么?<br>CGA是IBM在1981年推出的一种显卡标准:<br># 支持文本模式和图形模式<br># 最多支持4色图形显示<br># 分辨率: 最多320x200(4色)或640x200(2色)<br>
CGA内存<br>
CGA显存地址范围<br># 注意:CGA显存的默认起始地址是: 0xB8000(CGA卡)<br># 另一种显示卡MDA(单色卡)使用地址: 0xB0000<br>
举个例子:向CGA显存写数据<br>在80x25文本模式下,每个字符占用两个字节<br># 第一个字节是ASCII码<br># 第二个字节是颜色属性<br><br>mov ax, 0xb800 ; CGA显存段<br>mov es, ax<br>mov di, 0 ; 显存偏移<br>mov al, 'A' ; 要显示的字符<br>mov ah, 0x0F ; 白底黑字<br>stosw ;写入ES:DI<br>这段代码会把字符A写道屏幕左上角<br>
为什么叫CGA内存?<br>因为它是CGA显卡专用的显存地址i空间,CPU可以通过对0xB8000段进行内存读写,直接控制屏幕显示内容,这在操作系统引导、BIOS变成、裸机开发中非常常见<br>
封装操作硬件的IO接口。<br>前面开A20总线我们用了两个特殊的指令:in、out<br>这两个之前就是访问硬件的IO接口用的。<br><br>CPU访问外部硬件的有两个方式<br>1.将某个外设的内存映射到一定范围的地址空间中,CPU通过地址总线访问该内存区域时会落到外设的内存中,这种映射让CPU访问外设的内存就如同访问主板上的物理内存一样.有的设备是这样做的,比如显卡,显卡是显示器的适配器,CPU不直接和显示器交互,它只和显卡通信。显卡上有片内存叫显存,它被映射到主机物理内存上的地段IMB的0xB8000~0xBFFFF.CPU访问这篇内存就是访问显存,往这片内存上写字节便是往屏幕上打印内容。看上去这么高大上的做法是怎么实现的,我们就不关心了,前面说过,计算机中处处是分层,我们要充分相信上层的工作<br>2.外设是通过IO接口与CPU通信的,CPU访问外设,就是访问IO接口,由IO接口将信息传递给另一端的外设,也就是说,CPU从来不知道有这些设备的存在,它只知道自己操作的IO接口<br>
我们先封装一套IO接口,这样后面在用的时候直接调用即可<br>global in_byte<br>in_byte:<br> push ebp;<br> mov ebp, esp<br> <br> xor eax, eax<br><br> mov edx [ebp + 8] ; port<br> in al, dx<br> <br> leave<br> ret<br><br>global out_byte<br>out_byte:<br> push ebp;<br> mov ebp, esp<br><br> mov edx, [ebp + 8] ; port<br> mov eax, [ebp + 12] ; value<br> out dx, al<br><br> leave<br> ret
实现中断输出模块。<br>我们学任何一门语言,第一件事情就是在屏幕上输出hello world.<br>拿C语言来说,用的是printf函数,如今OS内核走到这里,是时候实现自己的打印函数了。别看小小的print函数,实现起来很不容易呢。<br>我们先实现它的地基:屏幕输出模块<br><br>玩过Windows的都知道,Windows的图形界面是很强大的<br>玩过服务器的都知道,服务器为了节省性能,是不装图形界面的,只有黑窗口<br>这两种显示模式,都是OS控制显卡后的结果。这里我们先不涉及图形界面,感兴趣的话,《30天自制操作系统》<br><br>什么叫终端输出模块?<br>拿Linux服务器来说,你可能习惯了它的输出自动滚屏<br>但你可能不知道,这些都需要我们写OS内核自己实现<br><br>分三个节奏完成:<br>1.了解显卡<br>2.写代码控制光标、屏幕输出、背景色前景色控制<br>3.写代码实现成熟的输出模块<br><br>这里是基于CGA的文本模式实现输出模块的<br>
关于显卡。<br>显卡大家都很熟悉了,与显示相关。<br>显卡的好坏,当然受很多参数影响,其中最重要的莫过于显存。<br>我们想让屏幕显示的东西,并不是发送给屏幕,<br>而是将要显示的内容写入显存,屏幕会自动显示出来<br><br>显卡的CGA、VGA、EGA模式有什么区别?<br>总结一句话:<br>CGA是彩色的起点,EGA是过渡的提升,VGA则是进入现代图形显示的里程碑<br>
1.简介对比
2.显示接口 & 特征差异<br><br>
3.实际使用意义<br>VGA是目前所有现代显卡BIOS和操作系统启动时最低兼容标准,也常用于裸机显示程序或内核调试输出。<br>
CGA<br>CGA文本模式下,屏幕分成25行,每行80个字。每个字由两部分组成:ASCII码+字符颜色<br><br>通过这些可以算出如下的数据。<br>满屏能显示的字符数:25*80<br>满屏能显示的行数:25
如何操作显卡?<br>显卡是通过IO端口进行操作的,IO端口是与硬件内部的寄存器通信,有哪些IO端口呢?<br><br>显卡提供了哪些寄存器呢?<br>CGA使用的MC6845芯片,重要的寄存器有<br># CRT 地址寄存器0x3D4<br># CRT数据寄存器0x3D5<br># CRT光标位置 - 高低0xE<br># CRT光标位置 - 低位0xF<br># CRT显示开始位置 - 高位0xC<br># CRT显示开始位置 - 低位0xD<br><br>
VGA寄存器<br>
CRT Controller Data Registers<br>
让我们的OS接管BIOS中断<br>
中断在操作系统中的作用。<br>中断(Interrupt)是操作系统中最核心的机制之一,它的作用是使CPU能在处理当前任务时被外部事件打断,转去处理中断事件,然后再返回原任务继续执行。这让操作系统具备了响应异步时间、进行多任务调度、设备管理等关键能力。<br><br># 硬件通信: 当外部设备(如鼠标、键盘、打印机、网络适配器等)需要处理器的注意时,它们可以通过发送中断信号来通知处理器.这可能是由于它们通过发送中断信号来通知处理器。这可能是由于它们已经完成了一项任务,或者需要处理器进行一些操作(比如,处理新的用户输入或发送数据)<br># 任务切换: 在多任务环境中,操作系统使用中断来切换不同的任务或进程。当一个任务的时间片已经过去或者需要立即处理一个更高优先级的任务时,操作系统会触发一个中断,然后切换到另一个任务<br># 系统调用:中断还用于实现系统调用。当用户空间的应用程序需要执行某种可能影响整个系统的操作(比如,读写文件或创建新的进程)时,它们会发起一个系统调用。这实际上就是通过触发一种特殊的中断(通常称为软中断或异常),将控制权交给内核,让内核代表应用程序执行这些操作<br># 异常处理: 当处理器遇到某种错误或异常条件时,他会通过触发异常中断来通过操作系统。比如,当程序试图除以零或访问无效的内存地址时,处理器会触发一个异常中断<br># 定时器和时钟中断: 操作系统使用中断来追踪事件和实现定时器功能。这是通过时钟中断实现的,它在固定的事件间隔内反复触发。时钟中段也适用于调度和任务切换<br># 功耗管理: 在某些情况下,操作系统可以使用中断来管理系统的功耗。例如,当系统处于控线状态时,操作系统将处理器置于低功耗状态。然后,当有新的任务需要处理时,处理器可以通过中断来唤醒。<br><br>总的来说,中断时操作系统实现并发处理、硬件通信和异常处理等核心功能的重要工具。它们提供了一种高效的方式,让处理器可以在处理其他任务的同时,快速响应外部设备和系统事件的需要。<br>
1.中断在操作系统中的主要作用<br># 设备驱动与IO处理<br>## 外部设备(如键鼠、鼠标、硬盘、网络)通过中断通知CPU有事件需要处理<br>## 操作系统通过中断服务程序(ISR)来处理设备的数据<br>## 例子:<br>### 用户按下键盘,产生中断->OS调用键盘驱动程序读取键值<br>### 硬盘读完数据->发起中断->OS数据复制到内存<br><br># CPU时间片切换(进程调度)<br>## 时钟中断周期性触发, 让操作系统有机会打断当前进程,根据调度算法切换到另一个进程<br>## 保证每个进程都能公平使用CPU时间,避免“饿死”<br><br># 异常处理<br>## 当CPU在执行过程中遇到异常情况(如除零错误、非法内存访问等)时,会触发异常中断<br>## 操作系统通过中断向用户报告错误,或者终止当前程序以保护系统<br><br># 系统调用(软中断)<br>## 用户程序无法直接访问内核资源,因此使用int 0x80(Linux) 或syscall指令引发软中断,切换到内核态执行服务<br>## 操作系统通过中断机制实现用户态与内核态的隔离和安全切换<br>
2.中断的分类<br>
3.操作系统中断处理流程(简化版)<br># 中断发生(如键盘按下、定时器超时)<br># CPU暂停当前执行,保存上下文<br># 查中断向量表,找到对应中断号的处理程序地址<br># 跳转到对应的中断处理程序(ISR)执行<br># 处理完成后通过iret指令恢复上下文<br># 返回原来的程序继续执行<br>
4.图示(简化流程)<br>
5.总结一句话。<br>中断时连接硬件、内核和进程调度的桥梁,是实现多任务和设备响应的基础机制。
中断控制芯片有哪些?<br>有人说,总段是操作系统的灵活,也不是没有道理,程序的异常与错误、与硬件通信,全是依赖中断实现。Linux下的信号机制、定时器等也都是借助中断实现的.<br><br>中断是由中断控制芯片控制的,主流的控制芯片有8259a、ioapic、lapic、i8259、mix,这里就学最具有代表性的8259a.<br>中断的实现逻辑是:激活8259a芯片后,如果有中断发生,就会调用相关处理逻辑,所以我们就来学习以下内容:<br>1.如何激活8259a芯片<br>2.在哪定义处理逻辑<br>3.如何理解可屏蔽与不可屏蔽、硬中断与软中断、内中断与外中断<br>4.几个有代表性的中断学习:时钟中断、键盘中断、0x80号中断、0x03号中断<br>
常见的中断控制器。<br>中断控制器是负责管理和处理来自系统中各种设备的中断请求的硬件设备。在早期的计算器中,处理器只能处理一个固定数量的中断源。但是,随着硬件设备的增加,中断控制器的需求变得更为迫切.以下是一些常见的中断控制器。<br>1.可编程中断控制器(Programmable Interrupt Controller, PIC): PIC是早期PC中使用的中断控制器,如Intel 8259A。它可以处理8个中断源。对于需要更多中断的系统,可以将多个8259A级联在一起。<br>2.高级可编程中断控制器(Advanced Programmable Interrupt Controller, APIC):随着多喝处理器的出现,单个PIC无法满足需求,于是出现了APIC.APIC在每个处理器核心中都有一个局部APIC,用于处理来自该核心的中断。此外,还有一个IO APIC, 用于处理来自IO设备的中断<br>3.中断控制优先级器(Priority Interrupt Controller, PIC):这是一种可以根据优先级处理中断的控制器,常见于嵌入式系统中<br>4.可配置中断控制器(Configurable Interrupt Controller, CIC):CIC可以根据系统的需求和硬件的配置进行动态配置<br>5.分布式动态可共享中断控制器(Distributed Dynamic Shared Interrupt Controller, DDSI):DDSI是一种可以在多个处理器之间动态共享中断负载的控制器<br>6.中断线路控制器(Interrupt Line Controller, ILC):ILC可以直接管理硬件设备的中断线路,而无需通过软件<br><br>以上所属的中断控制器各有其特点和用途,设计者会根据系统的需求选择格式的中断控制器,现代的计算机系统中,通常使用的是APIC或其更先进的版本<br>
CPU响应中断的流程。<br>CPU响应中断的流程是操作系统的基本工作机制之一。当CPU接收到中断信号后,会采取一些列步骤来处理这个中断,以下是一个简化的描述,描述了典型的中断处理过程:<br>1.中断发生:中断可以由外部硬件设备、定时器或者软件触发。这会给CPU发送一个信号,表明有某种需要处理的事件<br>2.中断响应:当CPU接收到中断信号后,会在当前指令执行完毕后立即停止正在进行的任务<br>3.保存状态:为了能在中断处理完毕后恢复正常的执行流程,CPU会保存当前正在执行的任务的状态。通常,这包括了寄存器的值,程序计数器(PC),以及其他相关的状态信息<br>4.设置中断向量:中断向量是一个指向中断处理程序的指针,它的位置在中断向量表中。CPU会根据触发的中断类型找到相应的中断向量<br>5.执行中断服务例程:中断服务例程(ISR)是用来处理特定中断的一段代码。CPU会跳转到该例程的地址,并开始执行<br>6.恢复状态:一旦中断服务例程完成,CPU需要恢复到被中断的任务。它会再如在步骤3中保存的状态信息<br>7.返回到被中断的程序: 最后,CPU将返回并继续执行被中断的程序,就好像没有发生中断一样。<br><br>这是一个通用的过程,具体的实现可能会根据CPU架构和操作系统的不同而有所差异。另外,有些复杂的中断处理机制,比如嵌套中断,可能会需要更多的步骤来正确处理<br>
<br>
LINT0、LINT1、NVMI<br>LINT0(Local Interrupt)是指高级可编程中断控制器(APIC)系统中的一个局部中断信号,在多处理器系统中,每个处理器都有自己的局部APIC,它负责针对该处理器的中断。LINT0和LINT1是两个特殊的中断输入先,它们可以被配置为接收不同类型的中断信号。在许多系统中,LINT0通常被配置为接受非屏蔽中断(NMI)。而LINT1被配置为接收其他类型的中断。<br>非屏蔽中断是一种特殊的中断信号,它不能被常规的中断屏蔽操作组织。这种中断通常用于处理一些严重的硬件错误,比如内存校验错误,需要操作系统立即处理。在某些系统中,LINT0和LINT1还可以被配置为接收外部中断,或者作为内部中断信号使用,以便在多处理器系统中同步不同的处理器。要注意的是,具体的配置可能会根据系统的需求和设计有所不同,所上述的仅为其中一种常见的配置方式<br>
中断向量表。<br>保存中断处理逻辑德地方叫中断向量表,即IDT表,与GDT表比较相似。IDT表中每个描述符称为中断门,结构如下<br>
有些中断是确定的,有的可以由用户自定义,如图所示。<br>其中0-19,是特定的异常与错误<br>20-31,映射中断控制芯片的IRQ15<br>32-255,用户自定义使用。比如系统调用,不管是Windows还是Linux,都是用的0x80中断<br>
8259a芯片。<br>硬件中断,是由8259a芯片告知CPU.<br>对于Linux内核来说,中断信号通常分为两类:硬件中断和软件中断(异常)。每个中断是由0-255之间的一个数字来标识。对于中断int0-int31(0x00--0x1f),每个中断的功能由Intel公司固定设定或保留用,属于软件中断,但Intel公司称之为异常,因为这些总段是CPU执行指令时探测到异常情况而引起的,通常还可以分为故障(Fault)和陷阱(traps)两类。中断int32--int255(0x20--0xff)可以由用户自己设定,在Linux系统中,则将int32--int47(0x20--0x2f)对应于8259A中断控制芯片发出的硬件中断请求信号IRQ0-IRQ15,并把程序编程发出的系统调用(system_call)中断设置为int128(0x80)<br><br>在8259A内部由两组寄存器,一组是初始化命令寄存器组,用来保存初始化命令字(Initialization Command Words, ICW)ICW共4个,ICW1~ICW~4.另一组寄存器是操作命令寄存器组,用来保存操作命名字(Operation COmmand Word, OCW), OCW共3个,OCW1~OCW3。所以,我们对8259A的编程,也分为初始化和操作两部分。<br># 一部分是用ICW做初始化,用来确定是否需要级联,设置起始中断向量号,设置中断结束模式,其编程就是往8259A的端口发送一些列ICW.由于从一开始就要决定8259A的工作状态,所以要一次性写入狠毒哦设置,某些设置之间是具有关联、依赖性的,也许后面的某个设置会依赖前面某个ICW写入的设置。所以这部分要求严格的顺序,比如一次写入ICW1、ICW2、ICW3、ICW4<br># 另一部分是用OCW来操作控制8259A,前面所说的中断屏蔽和中断结束,就是通过往8259A端口发送OCW实现的,OCW的发送顺序不固定,3个之中先发送哪个都可以<br>
如何激活8259A?<br>8259A中有两块芯片,操作是: 主8259A对应的端口地址是20h,21h,从8259A对应的端口是A0h和A1h<br>必须按照这个顺序发送才能正确激活82594:<br>1.往端口20h(主片)或A0h(从片)发送ICW1<br>2.往端口21h(主片)或A1h(从片)发送ICW2<br>3.往端口21h(主片)或A1h(从片)发送ICW3<br>4.往端口21h(主片)或A1h(从片)发送ICW4<br>
ICW1[0]设置为1表示需要发送ICW4, 0表示不需要发送ICW4<br>ICW1[1]设置为1表示单个8259, 0表示级联8259<br>ICW1[2]设置为1表示4字节中断向量, 0 表示8字节中断向量(这里指的是中断描述符的长度,是8B)<br>ICW1[3]设置为1表示中断形式是水平触发,0表示边缘触发<br>ICW1[4]必须设置为1<br>ICW1[5,6,7]必须设置为0<br><br>; 向主发送ICW1<br>mov al, 11h // 0001 0001<br>out 20h, al<br><br>; 向从发送ICW1<br>out 0a0h, al<br><br>0x11表示:需要发送ICW4、级联8259、8B中断描述符、边缘触发<br>
ICW2[0,1,2]对于80x86架构必须设置为0<br>ICW[3-7]:表示当前芯片从哪个中断号开始<br><br>; 向主发送ICW2<br>mov al, 20h ; 主片中断号从0x20开始<br>out 21h, al<br><br>; 向从发送ICW2<br>mov al, 28h ; 从片中断号从0x28开始<br>out 0a1h, al<br>
ICW3[0-7]主片:<br>ICW3[0]: 设置为1, IR0级联从片 0无从片<br>ICW3[1]: 设置为1, IR1级联从片 0无从片<br>ICW3[2]: 设置为1, IR2级联从片 0无从片<br>ICW3[3]: 设置为1, IR3级联从片 0无从片<br>ICW3[4]: 设置为1, IR4级联从片 0无从片<br>ICW3[5]: 设置为1, IR5级联从片 0无从片<br>ICW3[6]: 设置为1, IR6级联从片 0无从片<br>ICW3[7]: 设置为1, IR7级联从片 0无从片<br><br>ICW3[0-7]:<br>ICW3[0,1,2]从片连接主片的IR号<br>ICW3[3-7]必须是0<br><br>; 向主发送ICW3<br>mov al, 04h ; 主片的级联位是3 其他位为0,所以是0x04<br>out 21h, al<br><br>; 向从发送ICW3<br>mov al, 02h ; 从片的级联位是2, 其他位为0, 所以是0x02<br>out 0A1h, al<br>
ICW4[0]设置为1,表示x86模式,0 表示MCS 80/85模式<br>ICW4[1]设置为1,自动EOI, 0 正常EOI<br>ICW4[2,3]表示主从缓冲模式<br>ICW4[4] 1表示SFNM模式, 0 sequential模式<br>ICW4[5,6,7]设置为0<br><br>; 向主发送ICW4<br>mov al, 003h<br>out 021h, al<br><br>; 向从发送ICW4<br>out 0A1h, al<br><br>0x03表示:x86模式、自动EOI<br>
OCW1、OCW2、OCW3,一般只配置OCW1,用于屏蔽中断<br>
实现物理内存管理模块
物理内存管理模块
分页机制。<br>操作系统中的分页机制(Paging)是一种内存管理机制,它将程序的虚拟地址空间划分位固定大小的页(Page),并将这些页映射到物理内存中的物理页框(Page Frame),从而实现:<br># 程序开起来有连续的内存空间(虚拟地址空间)<br># 实际上操作系统将其分散加载到物理内存的任意位置<br># 实现内存隔离、内存保护和虚拟内存等功能<br>
1.为什么要分页?<br># 解决内存碎片问题(替代早期的分段管理)<br># 支持虚拟内存(可通过页换入/患处实现程序超出物理内存)<br># 实现进程隔离<br># 简化内存: 程序员只看虚拟地址,实际分配由操作系统管理<br>
2.分页的核心思想<br><br>
3.地址转换过程(以32为例)<br>假设虚拟地址是0x12345678,页大小为4KB(2^12):<br># 高10位(31-22)->页目录索引(Page Directory Index)<br># 中10位(21-12)->页表索引(Page Table Index)<br># 低12位(11~0)->页内偏移(Offset)<br><br>地址翻译过程如图所示<br>
4.举个例子<br>假设访问的虚拟地址为0x12345678<br># CPU使用CR3查到当前页目录基地址<br># 用高位索引页目录,找到页表地址<br># 用中位索引页表,找到页框地址<br># 加上页内偏移,形成物理地址<br>
5.分页机制的扩展:<br># 多级页表(二级/三级/四级)——节省内存<br># 大页机制(Huge Page): 支持2MB或1GB的大页,提高性能<br># 硬件支持分页: x86 CPU内置MMU和TLB实现分页机制<br># 与段机制共存:分页是对段机制的进一步抽象(吸纳带系统中段常被简化或禁用)<br>
6.分页机制的优点总结
段机制存在的缺点。<br>段基址虽然是早期x86架构中用于内存管理的重要手段,但随着系统复杂度和需求的提升,它逐渐暴露出一系列缺点和局限性,这也正是现代操作系统普遍转向分页基址(paging)的重要原因。它的主要缺点如下<br>
1.段大小不统一,易造成碎片。<br># 每个段可以有不同的长度,不像分页固定大小(如4KB)<br># 程序在运行过程中频繁申请/释放段,容易成型外部碎片,难以回收和整理<br>
2.地址转换复杂,性能开销高<br># 每次访问内存都要进行【段地址 + 偏移地址->线性地址】的转换<br># 段寄存器 + 段描述符 + 特权级检查,步骤较多,不如分页机制那样高效和直接<br>
3.不利于进程隔离和内存保护的实现<br># 段基址是面向模块的,不太适合按页划分的细粒度权限控制<br># 不如分页基址灵活,比如分页可以对每一页设置"只读","只执行"等权限<br>
4.段表结构不易动态扩展<br># 段表(GDT、LDT)是固定大小的结构,每个段表项管理一个段<br># 程序若频繁创建/销毁段,会造成段表频繁更新,效率低下,不利于动态内存管理<br>
5.不利于实现虚拟内存<br># 分段虽然支持逻辑地址空间的划分,但不具备虚拟地址的页面换入/换出能力<br># 难以像分页一样,将不常用内存页换出到磁盘,节省物理内存<br>
6.跨段访问逻辑复杂<br># 如果一个数组或栈跨越了段边界,访问会非常麻烦,甚至会触发段错误<br># 分页则没有这个问题,多个连续虚拟页可以映射到不连续的物理页上<br>
7.现代操作系统基本不用它进行内存隔离<br># 如Linux在开启分页后,段机制基本"被废用"或仅作为一个flat模型存在<br>## 各段的基址被统一设为0<br>## 各段的长度设为4GB<br>## 等价于"关闭段机制",全面依赖分页基址进行内存管理<br>
总结<br><br>
为什么说段大小不统一?<br>1.实模式下,段最大可访问64KB.<br>在16位实模式(Real Mode)中<br># CPU使用的是20位物理地址总线,最大寻址空间是1MB(2^20=1MB)<br># 地址表示为: 物理地址 = 段地址 x 16 + 偏移地址<br># 段地址通过段寄存器给出,偏移地址是16位寄存器(如SI、DI、BX、IP等)<br># 由于偏移地址最大只能是0xFFFF(65535),所以每个段最多访问64KB空间<br>所以,在实模式下,每个段的访问最多64KB<br><br>2.保护模式下,段可以远远大于64KB<br>在32位保护模式(Protected Mode)中<br>段机制发生了重大变化:<br># 段不再由"段寄存器 X16"定义,而是通过GDT/LDT中的段描述符控制<br>## 每个段描述符中包含段的起始地址(Base)和段的限制(Limit)<br># Limit 是一个20位的值,最多可以设置位0xFFFFF = 1MB<br># 段描述符还包含一个粒度位(G位)<br>## 如果G = 0,单位是字节->最大段大小是1MB<br>## 如果G = 1,单位是4KB->最大段大小是4KB * 1MB = 4GB<br>也就是说,在保护模式下,每个段最大可以达到4GB(而不是64KB)<br><br>
段机制中,段表项最大为多少?<br>在x86的段基址中,段表(GDT或LDT)中段表项(即段描述符)的数量并不是无限的,是由段选择子的格式和硬件规定共同角色的<br>1.段选择子结构如下(16位)<br># Index(13位):用于索引GDT/LDT表项<br># TI(1位): 0表示GDT,1表示LDT<br># RPL(2位):请求特权级<br>因为Index是13位,段表最多可以有2^13=8192个表项<br>2.段表结构中每个表项占用8字节<br># 每个段描述符(段表项)是8字节<br># 所以一个GDT表最多为 8 x 8192 = 65536字节=64KB<br>这是为什么加载GDT时的LGDT指令只允许GDT limit最大位0xFFFF(即64KB)<br><br>
TLB<br>TLB(Translation Lockaside Buffer),即快表,是CPU中用于加速虚拟地址到物理地址转换的一个告诉缓存,是分页基址的关键组件。<br>1.TLB是什么?<br>当操作系统启用了分页机制后,CPU在访问内存时需要将虚拟地址(Virtual Address)转换成物理地址(Physical Address).这个转换过程依赖页表,但查页表非常耗时,因此CPU内置了一个小型高速缓存——TLB,来缓存页表项。<br>2.TLB的作用<br># 缓存虚拟页号到物理页框号的映射(即页表项)<br># 避免频繁访问内存中的页表,加快地址转换速度<br>简化流程如下:<br>#.程序访问虚拟地址<br># TLB查找该虚拟地址的页表项<br>## 命中(HIt): 直接使用物理页框号<br>## 未命中(Miss):查页表,更新TLB,再访问内存<br># 使用物理地址访问内存<br>3.TLB的结构<br>TLB通常是一个内容可关联存储器(CAM),可以并行查找页号<br># 虚拟页号(VPN) 0x1234<br># 物理页号(PPN): 0x9ABC<br># 权限位(R/W/U): Read/Write<br># 有效位: valid<br>4.TLB的性能关键<br># TLB命中率越高,CPU地址转换越快<br># TLB未命中(Miss)会触发页表访问,甚至可能导致页错误(Page Fault)<br>5.TLB 相关的术语<br># TLB Miss: 找不到映射,需要查页表<br># TLB Flush, 清空TLB(比如切换进程时)<br># TLB Shootdown: 多核CPU中同步更新TLB(如换页)<br>6.TLB的大小核类型<br># 通常每个核心都有自己的TLB<br>## 指令TLBZ(iTLB):缓存指令地址<br>## 数据TLB(dTLZB):缓存数据地址<br>## 联合TLB(Unified TLB)<br><br>总结一句话:<br>TLB是CPU内的一个缓存页表项的小表,加速虚拟地址到物理地址的转换,避免频繁访问内存中的也i包,提高系统整体性能<br>
段式内存管理
页式内存管理
页部件。<br>操作系统中的页部件“(Page Frame或Page)是分页管理机制下的一个核心概念,表示内存被操作系统划分成的一块固定大小的区域。你也可能听说过与其对应的术语"页框"、"页帧", "页面"<br>1.什么页部件<br># 页部件是物理内存被划分的最小管理单元<br># 在分页机制中:<br>## 虚拟内存->被划分为若干页(Pages)<br>## 物理内存->被划分为若干 页部件/页框(Page Frames)<br><br>2.为什么要有页部件?<br>分页机制的目标是让虚拟地址和物理地址空间分离,实现<br># 更高效的内存管理<br># 内存共享<br># 隔离保护<br># 虚拟内存技术(比如磁盘换入换出)<br>因此,操作系统将物理内存以页部件为单位进行分配和管理<br>3.页与页部件的对应关系<br>虚拟页(Page)<-->物理页部件(Page Frame)<br>通过页表(Page Table)进行映射<br><br>虚拟地址(Virtual Address)<br>->页号(Page Number) + 页内偏移(Offset)<br>->页表查找->物理页部件<br>->最终物理地址=物理页框地址 + 偏移<br><br>
4.页部件的大小<br># 通常为4KB,但也支持:<br>## 2MB、4MB(大页)<br>## 1GB(超大页)<br># 也大小是CPU架构 & 操作系统共同决定的<br>
5.页部件在OS中的作用<br>
6.页部件vs页表<br><br>
总结一句话。<br>页部件是分页内存管理中物理内存的基本单元,用于存储进程的数据或代码段,是虚拟页与物理地址映射的最终目标地址
内存结构。<br>
物理内存管理。<br>物理内存的管理是操作系统内核最核心的职责之一。它直接决定了系统资源的利用核运行性能。下面从多个维度系统性地介绍物理内存是如何被操作系统管理地。<br>1.管理目标<br>操作系统管理物理内存的核心目标包括:<br># 分配与释放物理内存<br># 支持虚拟内存<br># 防止内存冲突核越界<br># 提高内存使用效率<br># 支持进程隔离核保护<br>2.物理内存的划分方式<br># 按页划分(分页机制)<br>## 最主流的方式<br>## 将物理内存划分为固定大小的块,称为页框(Page Frame).大多数系统页大小为4KB<br>## 优点:便于管理,支持虚拟内存<br># 按段划分(段式机制)<br>## 将内存按逻辑功能划分(代码段、数据段、栈段)<br>## 已不常用于现代操作系统(多数进保留段机制以支持早期x86兼容)<br>3.常用的内存管理方式<br># 位图(Bitmap)法<br>## 每一位表示一个页是否被占用<br>## 优点: 快速、简单,适用于页大小一致的场景<br># 链表法(Free List)<br>## 将空闲页/页框组织成链表,适合管理不等大小的内存块<br>## 缺点: 容易产生碎片<br># 伙伴系统(Buddy System)<br>## 将内存分为2^n大小的块,分配时尽量合并相邻空闲块<br>## Linux内核使用此机制管理高端内存 <br>
4.分区(Zone)管理策略(Linux示例)<br>
5.页面管理结构(以Linux为例)<br>Linux中每个物理页由一个struct page结构描述:<br>struct page {<br> unsigned long flags;<br> atomic_t _count;<br> void *virtual; // 映射到的虚拟地址<br> ...<br>}<br>操作系统通过这些结构追踪页的分配状态、所属进程、映射关系等
6.分配算法(Page Allocator)<br>Linux中常见内存分配器包括如图所示<br>
7.内存保护与隔离<br># 通过分页机制(Page Table)或段机制实现<br># 设置访问权限(读写/只读/不可知性)<br># 不同进程的虚拟空间互不干扰<br># 页表 + TLB实现高效地址转换<br>
8.内存的分配流程(简化)<br>以分页方式为例:<br>用户申请内存->内核查找空闲页框->建立虚拟页<--->物理页框的映射->更新页表->返回虚拟地址<br><br>释放时:<br>释放虚拟页->释放物理页框->更新页表、回收页描述符<br>
9.总结
MMU是什么?<br>MMU(Memory Management Unit, 内存管理单元)是CPU中的一个重要硬件模块,它负责将虚拟地址转换为物理地址,同时提供内存保护、缓存控制等功能,是现代操作系统支持虚拟内存机制的关键组件。<br>
1.MMU的核心作用<br># 地址转换(虚拟地址->物理地址)<br>当程序访问一个地址是,它访问的是"虚拟地址",而实际访问内存时,必须转换为"物理地址“。MMU自动完成这一转换过程。<br>示例:用户程序访问0x8048000,MMU可能将它映射到物理地址0x00120000<br># 内存保护<br>MMU通过页表提供对每个页的访问权限控制:<br>## 只读、科协、可控制<br>## 用户态与内核态权限隔离<br>如果程序访问了不该访问的内存页(如写只读内存、访问未映射内存),MMU会触发页异常(Page Fault)<br># 支持分页机制<br>MMU通过使用页表(Page Table)实现分页机制,常见支持如图所示:<br># TLB 缓存(Translation Lookaside Buffer)<br>## MMU中通常包含一个TLB(快表)<br>## 用于缓存最近的虚拟地址到物理地址映射,加速地址转换<br>## 如果TLB未命中,就要查完整页表,效率会变低<br>
2.MMU的组成(简化模型)<br>
3.开启MMU的流程(操作系统中)<br>在裸机或内核中开启分页机制,通常包括以下步骤:<br># 构建页目录核页表(手动分配页表内存并填写映射关系)<br># 将页目录基地址写入CR3寄存器<br># 设置CR0寄存器中的分页位(PG=1)以启用分页<br># 此时MMU开始工作,实现虚拟地址->物理地址转换<br>
4.小结
内存检测。<br>在操作系统或裸机开发中,内存检测(Memory Detection)指的是在启动时探测系统中可用的物理内存区域及其大小。这是操作系统启动过程中的一个关键步骤,决定了它如何分配和管理内存<br><br>1.为什么要作内存检测?<br># 确定物理内存总大小<br># 识别可用与保留区域<br># 初始化内存管理系统(如页表、内存分配器)<br># 避免使用BIOS、显卡、ACPI等保留区域<br>
内存检测的方式(按平台):<br>1.BIOS下的内存检测(16位实模式)<br>使用INT 0x15中断服务<br>方法一: INT 0x15, AX=0xE820(推荐,现代BIOS支持)<br>mov ax, 0xE820<br>mov di, buffer ; 存储检测结果的缓冲区<br>mov cx, 20 ;每个描述符大小<br>mov bx, 0 ; Continuation value = 0<br>mov edx, 0x534D4150 ; 'SMAP' 签名<br><br>int 0x15<br>jc error ; 出错跳转<br>返回的结构:<br>也称ARDS(Address Range Descirptor Structure)<br>struct E820Entry {<br> uint64_t base_addr;<br> uint64_t length;<br> uint32_t type; // 1=可用 2=保留 3=ACPI reclaimable 4=ACPI NVS 5 = bad memroy<br>}<br><br>调用多次(根据EBX返回值)直到EBX返回0<br><br>方法二: INT 0x15, AX=0x88(旧式)<br>mov ah, 0x88<br>int 0x15<br># 返回AX=以KB为单位的内存大小(最多64MB)<br># 太旧、受限,现代不推荐<br><br>2.UEFI的内存检测(32/64位)<br># 使用UEFI Boot Services: GetMemoryMap()获取内存映射<br># 适用于基于EFI的启动(如modern x86_64、ARM)<br><br>
示例(x86, 汇编使用E820)<br>xor ebx ,ebx<br>mov di, memory_map_buffer<br>.next:<br> mov eax, 0xE820<br> mov edx, 0x534D4150<br><br> mov ecx, 20<br> int 0x15<br> jc .error<br> add di, 20<br> cmp ebx, 0<br> jne .next
检测后的用途<br># 初始化物理页管理器<br># 标记哪些页是可用的(通常页大小为4KB)<br># 用于分配内核页、用户页、页表页<br># 显示内存信息(如bootlog)<br>
汇编/内核实现补充(BIOS环境):<br>typedef struct {<br> uint64_t base;<br> uint64_t size;<br> uint32_t type;<br>}__attribute__((packed))e820_entry_t;<br>
小结
内存检测
检测结果<br>ARDS结构中的字段大小都是4字节,共5个字段,所以此结构大小为20字节,每次int 0x15 之后,BIOS就返回这样一个结构的数据。注意,ARDS结构中用64位宽度的属性来描述这段内存基地址(起始地址)及其长度,所以表中的基地址和长度都分为低32位和高32位两部分。<br>其中的Type字段用来描述这段内存的类型,这里所谓的类型是说明这段内存的用途,即其是可以被操作系统使用,还是保留起来不能用,Type字段的具体意义如图所示<br>
CPU的分页模式。<br>CPU的分页模式(Paging Mode)是现代操作系统内存管理的核心机制之一,它允许将虚拟地址空间映射到物理内存,实现内存保护、进程隔离、地址空间扩展等关键能力。<br>
1.什么是分页模式?<br>分页模式指的是CPU通过页表机制将虚拟地址(Virtual Address)转换为物理地址(Physical Address)的一种工作方式。开启分页后,操作系统可使用虚拟地址访问内存,而CPU自动通过页表进行地址映射。分页机制通过控制器CR0中的第31位(PG位)启用:<br>mov eax, cr0<br>or eax, 0x80000000 ; 设置PG位<br>mov cr0, eax<br>
2.分页机制的核心结构<br><br>
3.不同的分页模式类型<br># 32位传统分页(x86)<br>## 启用: 设置CR0.PG=1<br>## 使用: 2层页表结构(页目录 + 页表)<br>## 页大小: 通常是4KB(也支持4MB大页)<br>## 虚拟地址空间:4GB(2^32)<br><br>虚拟地址(32位)分解为:<br>【10位目录】【10位页表】【12位业内偏移】<br><br># PAE(Physical Address Extension)分页<br>## 启用: 设置CR4.PAE =1(同时CR0.PG=1)<br>## 支持超过4GB的物理内存访问(最多64GB)<br>## 页大小仍为4KB(也支持2MB大页)<br>## 页表层级:3层(PDPT -> Page Direction -> Page Table)<br><br># 64位分页(Long Mode)<br>## 启用: 必须设置CR0.PG=1、CR4.PAE=1,并进入Long Mode<br>## 页表层级: 4层(PML4 -> PDRP->PD->PT)<br>## 支持虚拟地址:最多256TB(2^48)<br>## 页大小支持:4KB、2MB、1GB<br>虚拟地址(48位有效)分解为<br>【9】【9】【9】【9】【13】=>PML4, PDPD, PD,PT, offset<br>
4.分页机制的作用<br>
5.常见寄存器与分页的关系
小结
CR0寄存器结构图<br>PE位:启用保护(Protection Enable)标志<br>PE=1:保护模式<br>PE=0:实地址模式<br>这个标志仅开启段级保护,而没有启用分页机制<br>若要启用分页机制,那么PE和PG标志都要置位<br>
CPU会先对内存地址进行解析,取出高10位,去查PDT表,找到PDE.PDE就是PTT的首地址,内存地址的中10位就是PTT表的数组下标,这一步找到的是数据存储的物理页,拿数据在物理页上的偏移就是低12位?<br><br>CPU是如何找到PDT表的呢?CR3寄存器。该寄存器专门用于存储页目录表的内存地址。每个进程的CR3是不一样的,所以在切换进程的时候,是需要切CR3的。理论上说,你获得了一个进程的CR3,就等于获得了一个进程的所有内存数据<br>
PDE、PTE<br>属性含义:<br>P:有效位。0表示当前表项无效,缺页异常就是借助这个位实现的<br>R/W: 0表示只读。1表示可读可写<br>U/S: 0表示3特权级级程序可访问,1表示只能0、1、2特权级可访问<br>PWT、PCD:忽略<br>A: 0表示该页未被访问,1表示已被访问<br>D:脏位,0表示该页未写过,1表示该页被写过<br>PS:只存在于页目录。0表示这是4KB页,指向一个页表。1表示这是4MB大页,直接指向物理页<br>PAT:这个不管<br>G:如果G位为1刷新TLB时将不会刷新PDE/PET的G为1的页,G=1切换进程该PTE仍然有效<br>有效位:由软件控制的位,此位仅使用于多处理器系统,指令了可读写或只读<br>
PDT和PTT全称是什么?<br>在操作系统的分页机制中,PDT和PTT通常指的是页表结构中的两个重要层级:<br>1.PDT: Page Direction Table(也称:页目录表<br># 作用: 它是分页机制中最顶层的页表结构(在传统32位分页中)<br># 每个进程在内存中都有一个页目录表,里面保存着指向页表(PTT)的指针<br>2.PTT: Page Table Table(实际上应叫Page Table, 也称为:页表)<br># 作用:存放页表项(PTE, Page Table Entry),每个页表项指向一个物理页框(物理内存页)<br># 一个页表管理多个虚拟页<br>
结构关系(传统32位分页):<br>虚拟地址(32位)被分成三部分:<br># 10bits: Directory: 作为页目录(PDT)的索引<br># 10bits: Table 作为页表(PTT)的索引<br># 12bits: offset in page:页内偏移<br>所以,总结构如下
总结。<br><br>
虚拟地址结构(32位分页)<br>
映射过程结构图
PDT为什么最大是1024项?<br>这是由于32位分页机制下的设计结构决定的,根本原因可以归结为一下几点:<br>1.虚拟地址结构是32位<br># 在32位模式下,虚拟地址是32bits<br># 操作系统分页机制将这32位拆成三部分<br><br>2.页目录索引是10位<br># 10位二进制数最多表示2^10=1024项<br># 所以页目录表(PDT)最多就有1024个表项<br><br>3.每个页目录项指向一个页表<br># 每个页表也有1024个表项(也是10位索引)<br># 每个表项映射一个页(4KB)<br>## 所以一个页表可映射1024 x 4KB = 4MB内存<br># 整个页目录表共可映射:<br>## 1024 X 4MB = 4GB虚拟地址空间<br>
总结<br><br>
缺页异常。<br>缺页异常本来是一种错误,现在也被用于对内存不足的一种补救措施
开启分页,实现虚拟内存管理
<br>
<br>
内存结构
<br>
PDE、PTE<br>属性含义:<br>P:有效位。0表示当前表项无效,缺页异常就是借助这个位实现的<br>R/W: 0表示只读。1表示可读可写<br>U/S: 0表示3特权级级程序可访问,1表示只能0、1、2特权级可访问<br>PWT、PCD:忽略<br>A: 0表示该页未被访问,1表示已被访问<br>D:脏位,0表示该页未写过,1表示该页被写过<br>PS:只存在于页目录。0表示这是4KB页,指向一个页表。1表示这是4MB大页,直接指向物理页<br>PAT:这个不管<br>G:如果G位为1刷新TLB时将不会刷新PDE/PET的G为1的页,G=1切换进程该PTE仍然有效<br>有效位:由软件控制的位,此位仅使用于多处理器系统,指令了可读写或只读<br>
PCD和PWT的含义。<br>PCD(Page Cache Dsiable)和PWT(Page Write-Through)是与Intel架构CPU中页表条目相关的两个标志位,它们都与处理器的缓存策略有关。<br>1.PCD(Page Cache Disable):此标志位用来控制某个特定的页是否可以被CPU缓存。如果PCD位为1,那么该页将不会被CPU进行缓存。如果PCD位为0,那么该页可以被缓存。这个标志位主要用在IO设备映射的内存页上,因为这些页的数据可能会频繁的改变,缓存这些页可能会导致CPU中的数据和物理内存总的数据不一致。<br>2.PWT(Page Write-Through):此标志位用来控制特定的页的写策略是写回(Write-Back)还是写通(Write-Through)。如果PWT位为1,那么该页的写策略为写通,也就是每次写操作都会直接写入到物理内存中,如果PWT位为0,那么该页的写策略为写回,写操作首先会写入到换粗那种,然后在某个时间点再一次性写入到物理内存中。写通策略可以保证数据的一致性,但是每次写操作都要访问物理内存,所以速度较慢,写回策略可以提高速度,但是如果系统突然断电,缓存中的数据可能会丢失。<br><br>这两个标志位都是在页表条目中设定的,所以可以为每个页独立的设定这些属性。这种灵活性使得操作系统可以根据不同的页的特性选择最合适的缓存策略
CR0寄存器。<br>PE位:启用保护(Protection Enable)标志<br>PE=1: 保护模式<br>PE=0:实地址模式<br>这个标志仅开启段级保护,而没有启用分页机制。若要启用分页机制,那么PE和PG标志都要置位<br>
内存映射
OS能使用的最大物理内存与什么有关?<br>操作系统(OS)能使用的最大物理内存容量,取决于以下个关键因素:<br>
1.CPU架构与寻址能力<br>
2.是否启用PAE(Physical Addres Extension)<br># 32位操作系统默认只能访问4GB物理地址空间<br># 启用PAE后,可以扩展到36位物理地址线->64GB<br># 需要操作系统内核支持,如:<br>## Windows XP PAE<br>## Linux内核 CONFIG_X86_PAE<br>
3.操作系统的内核设计限制<br>
4.主板/固件限制<br># 主板上的内存槽个数、芯片组设计决定了支持的最大容量<br># BIOS/UEFI的物理地址映射能力也可能有上线<br>
5.设备内存映射区域(MMIO)<br># 比如显卡内存、ACPI表等会占用一部分地址空间<br># 在32位无PAE的系统中,这部分区域可能会吃掉你的一部分4GB,使用户可用RAM更少<br>
6.总结<br>
32位OS如何突破4G瓶颈?<br>32位操作系统的地址空间理论上最多位4GB(2^32),但实际上可以通过几种方式突破4GB物理内存访问限制。这涉及硬件支持(CPU)+软件支持(OS)的配合<br>
1.启用PAE(Physical Address Extension)<br>最主流的方式<br># PAE是什么<br>## PAE是Intel提供的一种物理地址扩展机制<br>## 虽然CPU仍工作在32位模式下,但页表使用36位地址,使得物理寻址能力从4GB提升到64GB<br># 地址结构变化<br>## 原来是:页目录10 bits + 页表10bits + 页内偏移12bits=32bits<br>## 使用PAE后:<br>### 页目录指针表(PDPT)->页目录(PD)->页表(PT)<br>### 总物理地址数变为36位,但虚拟地址仍是32位<br># 操作系统支持<br>## Linux: 完整支持,CONFIG_X86_PAE<br>## Windows: WIndows Server支持,32位家庭版不支持<br>## macOS: 老版本支持,现已全面转向64位<br><br># 限制<br>虽然可以访问>4GB内存,但单个进程的虚拟地址空间仍然受限于4GB(通常是2~3GB)<br>
2.使用AWE(Address Windowing Extensions)<br>Windows提供的一种用户态API接口<br># 允许应用程序申请超过4GB的物理内存,并通过映射窗口(window)方式访问内存<br># 实际是将大内存切片挂载到用户空间地址区间上,显式映射、切换访问窗口<br># 仅限Windows环境,编程复杂<br>
3.使用物理地址映射(在内核/驱动中)<br># 在内核中使用页表映射机制,可以将高于4GB的物理地址映射到特定的虚拟地址<br># 常见于设备驱动、操作系统内核(如Linux的ZONE_HIGNMEM管理)<br>
4.升级到64位操作系统(最彻底的解决方案)<br># 虽然不是突破,但64位系统天然支持大于4GB的虚拟与物理地址空间<br># 单个进程地址空间可达TB~PB级别(受限于系统版本和硬件)<br>
CPU为什么引入PAE paging?<br>PAE, 全称Physical Address Extension,物理地址扩展,是一种在32位的x86架构的处理器中使用的技术,能使得处理器支持超过4GB(即超过32位所能表示的最大值)的物理内存。32位的x86架构的处理器最多可以直接访问4GB的物理内存。因为32位的地址总线可以表示的地址空间为2^32,即4GB.但随着计算机硬件的发展,特别是物理内存容量的增长,4GB的限制成为了一个瓶颈,于是Intel在其处理器中引入了PAE技术,使得处理器可以支持支持最多64GB的物理内存。<br>PAE通过增加页表条目的大小和页目录的级数,扩展了物理地址的位数。在启用了PAE的系统中,页表条目的大小从32位增加到了64位,虚拟地址的分解方式也有所不同。这样就可以让处理器使用更多的物理内存。<br>然而,虽然PAE可以让32位处理器支持更多的物理内存,但是每个进程的虚拟地址空间仍然被限制在了4GB以内。这是因为虚拟地址的长度仍然是32位,所以无法表示超过4GB的地址。<br>总的来说,引入PAE是为了突破32位处理器的物理内存访问限制,以满足更大内存需求,但随着64位处理器的普及,PAE的优势已经不再明显,因为64位处理器可以直接访问更多的物理内存,PAE的优势已经不再明显,因为64位处理器可以直接访问更多的物理内存<br>
2-9-9-12分页结构<br><br>
虚拟地址:32位 = 2 + 9 + 9 + 12<br>
PDPTE结构(Page-Directory-Pointer-Table Entry)<br>P位:第0位,有效位<br>Avail:这部分供操作系统软件随意使用,CPU不适用<br>Base Addr:指向PDT表地址,有两部分组成<br>第一部分: 高4位32~35<br>第二部分:低20位 12~31位<br>这两部分加起来共24位,后12位补0<br>灰色部分:保留位<br><br>在PAE模式下,一共有4个PDPTE,每个PDPTE映射1GB虚拟空间,所以总共仍然是4GB.<br>
PDE结构<br>特别说明:<br>1.当PS=1时是大页,35-21位是大页的物理地址,这样36位的低21位为0,这就意味着页的大小为2MB,且都是2MB对齐<br>2.当PS=0时,35-12位时页表基址,低12位补0,共36<br><br>PAT位:页属性表<br>只有当PS=1时,PAT位才是有意义的(页属性表只针对页)<br>
2M<br>
4KB<br>
PTE结构<br>
总结要点<br># PAE模式下,虽然还是32位虚拟地址,但支持最大64GB物理地址<br># PDPTE是PAE Table的第一层,最多有4项,每项可映射1GB虚拟空间<br># 每次访问虚拟地址时,分页结构如下:<br>PDPTE->PDE->PTE->最终物理地址<br>
在32位操作系统下,PAE模式支持64GB最大物理内存,是怎么计算的来的?<br>32位CPU开启PAE(Physical Address Extension)模式后,虽然虚拟地址仍然是32位(最多4GB虚拟地址),物理地址却能扩展到最多64GB.下面我们来分析为什么是64GB,以及它是怎么计算出来的。<br><br>
1.背景:PAE是什么?<br>PAE是Intel引入的一种基址,让32位CPU能访问超过4GB的物理内存,其本质是把页表中用于表示物理地址的位数从32位扩展到了36位(实际上有些扩展到了40位或更多,但标准PAE为36位)<br>
2.为什么是64GB?<br>PAE模式下,页表项的物理地址指针变成了36位:<br>2^36 bytes = 64GB<br>也就i是说,最左可以寻址2^36个字节的物理内存空间,这就是最大支持64GB物理内存的由来<br>
3.页表结构对比.<br>这里需要结合PDE32普通分页模式和PAE模式的PDE结构结合起来看。<br># 在PAE模式下,页表项的大小从4字节扩展为8字节<br># 高12位保留,中间的24位用于指向页框地址(物理页帧)2^24 x 4KB = 64GB<br>
4.PAE模式分页结构图<br>总页框数2^24=16,777,216个<br>每个页框大小:4KB<br>总物理内存容量=2^24 x 4KB =64GB<br>
5.总结<br>
内存池算法。<br>1.空闲列表<br>2.指针碰撞<br>3.伙伴算法<br>
Linux系统的内存管理主要使用了称为伙伴系统(Buddy System)的策略,而不是使用传统的空闲列表或指针碰撞。伙伴系统是一种特殊的内存分配策略,它可以快速地分配和合并给定大小的内存块。在伙伴系统中,内存被划分为一系列大小为2的n次方的块。当请求一个大小为2的n次方的内存块时,伙伴系统会找到一个最小的、足够大的块来满足这个请求。当释放一个块时,伙伴系统会尝试将这个块与它的"伙伴"块合并,以形成一个更大的块。<br>伙伴系统的有点是它可以很快地分配和释放内存,而且,由于它总是分配大小为2的n次方的内存块,因此它可以有效地防止内存碎片。然而,伙伴系统也有一些缺点。例如,如果请求的内存大小不是2的n次方,那么就会产生内存浪费。<br>另外,为了解决为内核对象分配内存的问题,Linux还引入了Slab分配器。这个分配器把一系列大小相同的内核对象放入一个缓存(slab)中,当需要一个新的内核对象时,可以直接从slab中取出,这极大地提高了分配的效率。<br>因此,总的来说,Linux使用的是伙伴系统和Slab分配器,而不是空闲列表或指针碰撞来进行内存管理<br>
实现任务切换(一)<br>
实现一套完整任务机制的完美节奏<br>1.想好你想实现怎样的协程机制(非抢占式还是抢占式)<br>2.创建一个任务,每次从头执行<br>3.接入时钟中断实现协程调度<br>4.让任务接着执行<br>5.引入优先级、时间片<br>6.实现协程的急着执行(保存、恢复上下文)<br>7.实现协程的阻塞、唤醒、sleep<br>8.引入多核调度<br>
进程、线程、协程、纤程<br>1.进程(Process)<br>进程是操作系统资源分配的最小单位,是程序的一次执行实例。<br># 特点:<br>## 拥有独立的地址空间<br>## 拥有自己的堆栈、数据段、代码段<br>## 多个进程之间内存是隔离的<br>## 进程切换开销大(上下文切换要切内存页表、缓存刷新等)<br>## 操作系统调度(由内核完成)<br># 示例<br>## 打开一个浏览器窗口是一个进程<br>## 同时打开两个Word文档,是两个进程<br>2.线程(Thread)<br>线程是CPU调度和执行的最小单位,是进程内部的一个执行流<br># 特点:<br>## 同一个进程的多个线程共享地址空间(包括代码段、堆)<br>## 每个线程有自己的栈和寄存器上下文<br>## 线程通信成本低<br>## 切换速度比进程快,但仍需内核参与调度<br>## 操作系统调度<br>#示例<br>## 一个浏览器进程中的多个标签页,各自用一个线程渲染<br>## 下载、渲染、JS执行都用不同线程<br><br>
3.协程(Coroutine)<br>协程是用户态的线程,是一种可暂停、可恢复的轻量级线程,由用户手动调度<br># 特点:<br>## 全部运行在用户态,无内核参与<br>## 切换非常快(仅函数调用+上下文保存)<br>## 协程之间共享进程资源<br>## 适用于大量IO密集的并发线程<br>## 常用于异步编程(如async/await)<br># 示例<br>## Go语言的goroutine<br>## Python的async def<br>## C++20de co_await<br>## JavaScirpt的async/await<br>4.纤程(Fiber)<br>纤程不是标准术语,通常指Windows提出的纤维纤程(Fiber),或某些语言/系统自定义的轻量级纤程.<br>纤程是更底层、更轻量级的协程实现,协作式调度,用户完全控制切换点<br># 特点<br>## 由用户创建/管理调度(比纤程更轻)<br>## 本质上是可手动调度的协程<br>## 在Windows上调用ConvertThreadsToFiber<br>## 一般不自动抢占,谁让出控制权谁才能切换<br># 示例<br>## Windows Fiber API<br>## C++ Boost Fiber<br>## Rust Tokio中的底层任务模型<br>## 游戏引擎中的任务系统<br>
协程与纤程的相同点<br>协程(Coroutine)与纤程(Fiber)确实有很多相似之处,但也有本质区别。<br>
协程与纤程的不同点。<br>举个例子(形象理解)<br># 协程就像Go的goroutine、Python的async函数,你只需要调用await或gof(),调度器帮你管理<br># 纤程就像你亲自切换工作角色,每个角色你都得手动说"现在你来执行",控制力度更细但也更麻烦<br>
线程为什么会被系统打断?<br>线程之所以会被系统打断,本质上是因为操作系统采用了"抢占式调度"机制。<br>1.线程被打断是怎么回事?<br>当我们说线程被系统打断,其实指的是:<br>当前正在运行的线程被操作系统暂停(挂起),操作系统把CPU控制权切换给另一个线程运行。这叫做线程上下文切换。<br>2.为什么操作系统要这样做?<br>操作系统必须管理多个线程/进程的并发执行,为了公平性、响应性和系统资源最大化利用率,引入了线程调度算法,常见的包括<br># 抢占式调度(Preemptive)<br>强制中断线程,切换到其他线程,常见于现代操作系统<br># 非抢占式调度(Cooperative)<br>线程主动让出CPU,比较少见,易造成线程霸占<br><br>现代操作系统(如Linux、Windows、macOS)都使用的是抢占式调度<br>3.那线程会被系统打断的原因有哪些呢<br># 时间片用完<br>## 每个线程被分配一个时间片(如几毫秒)<br>## 时间到了,操作系统强制打断,切换下一个线程<br>## 防止一个线程一直霸占CPU<br># 被更高优先级的线程抢占<br>## 如果有高优先级线程准备好运行,当前线程会被立即暂停,让出CPU<br># 系统调用IO阻塞<br>## 线程访问文件、网络、键盘等资源时会阻塞<br>## 操作系统会将其挂起,切换到其他线程运行<br># 中断处理器响应<br>## 硬中断发生(如定时器/键盘/网卡中断)时,操作系统可能切换线程<br>4.被打断时发生了什么?<br># 操作系统保存当前线程的上下文(寄存器、栈指针、程序计数器等)<br># 恢复另一个线程的上下文,继续运行<br># 整个过程称为"上下文切换(Context Switch)"<br>这非常消耗资源,所以频繁的线程切换会影响性能
5.协程不会被打断?<br>是的。协程是一种非抢占式调度机制,只有在协程主动让出CPU(yield/await)时,才会切换到其他协程,因此:<br># 更加可控<br># 没有线程打断的问题<br># 也不会被CPU抢走,必须主动放权<br>6.总结一句话<br>线程被打断,是因为操作系统采用了抢占式调度机制,以保证公平、高效地利用CPU资源<br>
为什么说协程更适合高并发IO场景,高并发CPU场景不适合?<br>协程非常适合高并发IO密集型场景;对于CPU密集型场景并不适合,甚至可能表现较差。<br>1.为什么协程适合IO密集型场景?<br># IO密集型场景的特点:<br>## 大量请求主要等待IO结果,如<br>### 等待网络响应<br>### 等待磁盘读取<br>### 等待数据库操作<br>## CPU不忙,IO在等待<br># 如果用线程处理<br>## 每个连接一个线程<br>## 一旦read()/recv()被阻塞,这个线程就卡住,站着线程池<br>## 切换线程上下文(内核调度)代价高<br># 协程如何优化?<br>## 协程在用户态,通过await/yield主动让出CPU<br>## 等待期间不占线程、不阻塞CPU<br>## 切换非常快,几乎就是函数跳转<br>## 单线程就能承载成千上万并发连接(如Go的goroutine、Python的asyncio)<br># 示例场景<br>## 高并发Web场景:大量连接等待网络响应<br>## WebSocket服务:多链接、低CPU消耗<br>## 爬虫系统:大量HTTP请求 + 解析<br>## 微服务网关: 高并发IO转发、路由调度<br><br>
2.为什么协程不适合CPU密集型场景?<br># CPU密集型任务的特点<br>## 主要消耗CPU<br>## 很少等待IO<br>## 如大规模图形处理、加密运算、科学计算、及其学习模型训练等<br># 协程的问题<br>## 协程默认时单线程调度的(比如Python asyncio、Lua协程);<br>### 如果一个协程占用CPU不释放,其他协程完全卡死<br>## 不能利用多核CPU(除非自己开启多个进程或线程)<br>## 操作系统不会打断协程,只能靠协程自己让出控制权<br># 正确做法<br>对于CPU密集型任务,你应该<br>## 用线程池<br>## 或者用多进程/多核并发<br>## 或者用任务队列 + 并发执行引擎(如Rust的Rayon, Python的multiprocessing)<br>
3.总结对比<br>4.类比一句话理解<br>协程就像是"多任务切换员"在一个CPU上安排任务去做等待型工作,但如果你让所有协程都做计算,调度员就成了单人苦工干不过多核CPU<br>
协程就像是"多任务切换员"在一个CPU上安排任务去做等待型工作,但如果你让所有协程都做计算,调度员就成了单人苦工干不过多核CPU。<br>详细分析:<br>1.协程的基本工作机制<br>协程是一种用户态调度的"轻量级线程",通常工作如下:<br>## 所有协程在一个线程中调度(也就是一个CPU核心)<br>## 协程主动yield/await(等待IO)时让出控制权<br>## 协程切换非常快(因为不用进入内核,也不用保存复杂上下文)<br>因此,它非常适合这类任务<br>while(1) {<br> data = await read_from_socket();<br> process(data)<br>}<br>协程等待网络IO,CPU可以切换去执行别的协程,整体吞吐量非常高。<br>2.类比:协程调度 vs 多核调度<br># 场景1: IO密集型——协程表现优秀<br>## 假设你有10000个任务都在等待网络响应<br>## 协程只用一个线程就能处理它们<br>## 因为大多数时间都在"等",CPU不用切换线程、不用做上下文保存<br>## 你就像一个任务调度员,安排谁先干、谁在等,非常高效<br><br>## 类比说明<br>协程像一个单CPU上的聪明调度员,安排很多IO型工作,自己不干活,只管派活核收结果,效率很高<br># 场景2:CPU密集型——协程性能下滑。<br>比如你写了一个协程处理图像数据<br>async def process_image():<br> for i in range(10_000_000):<br> do_some_calculation()<br>## 每个协程都在消耗CPU,没有人yield<br>## 没人释放控制权,其他协程也跑不了<br>## 所有协程堵在一个线程里,无法并行利用多个CPU核心<br>## 你成了一个全职苦工,需要一个人干完所有事,而不是当调度员了<br><br># 结果<br>## CPU跑满单效率低<br>## 多核系统无法发挥并行性能<br>## 性能反而不如用多线程或多进程<br><br>## 类比说明:<br>你原本是调度员安排别人干活,现在你自己上手干所有CPU密集任务,又没人能来帮你(因为你只有一个线程),你自然干不过多和团队<br>
多线程VS协程在CPU密集型场景。<br><br>
总结一句话。<br>协程适合当"调度员"安排异步任务(如网络IO),如果你让它们自己当苦工做重计算,它们就无法高校发挥作用,因为协程天生只有一个CPU帮它干活,不能抢占,也不能并行<br>
线程和协程的关系。<br>线程是操作系统调度的基本单位,协程是用户空间调度的轻量级线程。
1.概念对比:线程VS协程<br>
2.线程和协程的关系<br># 协程运行在线程之上<br>## 协程必须依附于线程运行,你不能离开线程创建协程<br>## 一个线程可以运行多个协程(通常数千到数百万)<br>## 协程之间在用户空间通过调度器切换,线程调度由操作系统管理<br>【操作系统线程】<br>->协程A<br>->协程B<br>->协程C<br><br>
# 调度模型关系: M:N模型<br>## “M”个协程->"N"个线程的调度模型<br>## 常见模式如图所示<br>
3.类比理解:线程VS协程<br><br>
4.使用关系和组合方式<br># 方式一:单线程+多协程(IO密集优选)<br>一个线程调度成千上万个协程 // Python asyncio,Node.js, Lua等<br># 方式二:多线程+多协程(并发并行结合)<br>多个线程,每个线程跑多个协程->并发+并行 // Go、Rust tokio、Java loom等<br>
5.总结一句话。<br>线程是系统级调度单位,协程是用户级调度单位;协程依赖线程存在,但比线程更轻量,适合大规模高并发的任务,特别是IO密集型应用<br>
一次时间片是多长时间。<br>一次时间片(Time Slice/ Time Quantum)的长短,取决于操作系统的涉及和配置。不同系统、不同线程优先级、调度策略下的时间片长度都可能不同<br>
1.什么时间片?<br>时间片是操作系统分配给线程(或进程)使用CPU的连续时间段<br># 每当线程被调度执行,OS会让它执行一个时间片<br># 时间片到期后,操作系统会抢占该线程,把CPU分配给其他线程<br># 这是时间片轮转调度(Round Robin)或抢占式多任务的核心<br>
2.时间片的典型长度。<br>注:现代Linux内核使用CFS(完全公平调度器)不使用固定时间片,而是"虚拟运行时间"计算调度,但最终效果<br>
3.影响时间片长短的因素<br># 调度策略<br>Linux支持SCHED_OTHER(默认)、SCHED_FIFIO、SSCHED_RR,每种策略时间片不同<br># 线程优先级<br>高优先级线程可能被分配更长时间或频繁调度<br># 系统复杂<br>多线程/多进程竞争越激烈,每个线程的可用时间越短<br># 内核配置参数<br>如Linux的CONFIG_HZ、sched_latency_ns等,影响时间片计算<br>
4.是否越长越好?<br>不一定<br>因此,操作系统通常会动态调整时间片,兼顾效率与响应性
5.查看时间片的方法:<br># Linux查看线程时间片<br>[root@iZ2zebzamzc7izr0zeh6u0Z ~]# cat /proc/sys/kernel/sched_min_granularity_ns<br>100 00000 (即100ms)<br># Windows:<br>## Windows使用量化等级管理时间片,一般为10ms-20ms<br>## 无法直接查看,但可以通过调试工具观察调度频率<br>
6.总结一句话<br>一次时间片的长度通常在1ms到100ms之间,操作系统会根据线程优先级、系统复杂、调度策略等动态分配;对我们来说,就是OS让线程轮流跑的最小单位<br>
实现任务切换(二)<br>
进入用户态为什么要引入TSS?<br>引入TSS(Task State Segment,任务状态段)是为了再进入用户态(Ring3)时,安全、正确的切换堆栈并支持特权级转换。<br>进入用户态要引入TSS,是为了再从用户态中断返回内核时,CPU能知道使用哪个内核栈(Ring0的栈)<br>
1.用户态和内核态的栈式完全分离的<br># 用户态(Ring3)不能直接访问内核态(Ring0)的栈<br># 当CPU从用户态陷入内核态(如系统调用或中断),必须切换到内核态专属的栈<br><br>这个栈地址就是从TSS中获取的<br>
2.TSS提供了Ring0栈的位置。<br>TSS式由操作系统初始化的一个特殊结构,主要包含,如图所示。<br>当CPU检测到特权级从Ring3->Ring0时(比如中断发生),他就会自动使用TSS中的SS0和ESP0切换栈<br>原Ring3栈->自动切换->Ring0栈(TSS.ESP0)<br>
3.TSS的作用不是调度任务,而是<br># 告诉CPU:发生中断或系统调用后,内核栈从哪开始<br># 保证不同用户进程切换时,使用不同的内核栈<br># 每个用户线程可有一个独立的TSS或共享一个模板并动态切换ESP0<br>
4.举个例子<br>x86架构+分段保护+中断门:<br>当你设置一个中断门(int 0x80)用于系统调用,且该门的DPL=3(允许用户调用),那么<br># 用户态执行int 0x20<br># CPU检查从Ring3->Ring0的转换<br># CPU自动从当前GDT中的TSS描述符中取出ESP0和SS0<br># 切换栈并跳入内核执行中断处理函数<br>
总结一句话。<br>TSS是CPU进行用户态到内核态栈切换时必不可少的结构,它告诉CPU在进入内核时应该使用哪个栈,确保系统安全与稳定<br>
TSS结构<br>
进入用户态,实现系统调用
1.由内核态切用户态<br>2.会出现虚拟机崩溃,引出TSS<br>3.构建TSS描述符,写入CPU tr寄存器<br>4.实现0x80号中断门<br>5.实现系统调用框架,实现内核态切用户态<br>6.实现系统调用
在x86架构中,从用户态(通常是特权级3)切换到内核态(特权级0)有几种方法。这些切换通常发生在一些特定的事件或操作中,以便CPU可以执行需要更高特权的操作,以下是从用户态进入内核态的主要方式<br>1.中断(Interrupt):当外部设备(例如键盘、鼠标或网卡)需要CPU的注意时,它会发送一个中断信号,这回导致CPU暂停当前的执行流程,跳转到预定义的中断服务例程(ISR)地址,并在特权级0下执行该ISR<br>2.异常(Exception):当发生某些特定的错误或异常条件(例如除以零或页面错误)时,CPU会触发异常,和中断一样,这会导致CPU跳转到预定义的异常处理例程并在特权级0下执行<br>3.系统调用(System Call):应用程序通常需要请求操作系统服务,如文件IO、网络访问或内存分配。为了实现这些请求,应用程序执行一个系统调用。x86架构为此提供了几种机制,如int 0x80(在早期Linux中使用)或syscall/sysret(在x86-64中使用)和sysenter/sysexit(在现代32位和64位x86 CPU中使用)<br>4.任务切换:尽管现代操作系统很少使用硬件任务切换,但x86架构确实提供了支持。通过这种方式,CPu可以从一个任务切换到另一个任务,涉及从一个TSS(Task State Segment)切换到另一个TSS<br>为了确保安全性,从用户态切换到内核态的所有这些机制都需要特定的硬件和软件支持。例如,IDT(中断描述符表)保存中断和异常的地址,二系统调用则由操作系统内核提供相应的入口点。<br>当操作完成后,内核需要返回到用户态。这通常时通过一些与上面对应指令或机制来实现的,例如iret(从中断或异常返回)或sysret/sysexit(从系统调用中返回)<br>
用户态进入内核态的方式。<br>用户态(Ring3)进入内核态(Ring0)常见的三种方式如下<br><br>
1.系统调用<br>最常见、最规范的方式:发起一个系统调用,中断陷入内核<br># 方法1: int 0x80(中断指令)<br>## 适用于: 早期Linux x86<br>## 用户程序触发:<br>mov eax, 1 ; syscall number<br>int 0x80 ; trigger system interrupt<br>## 内核设置了IDT中的中断门(DPL=3,允许用户调用),CPU自动切换栈、切换到内核态执行<br># 方法2: sysenter/sysexit(Intel 快速调用)<br>## 适用于:Pentium II 以后的CPU<br>## 优点: 比int 0x80 快(无中断门开销)<br>## 内核必须设置好<br>### MSR_IA32_SYSENTER_CS->内核代码段<br>### MSR_IA32_SYSENTER_ESP->内核栈<br>### MSR_IA_SYSENTER_EIP->syscall handler地址<br>## 用户调用方式<br>mov eax, syscall_number<br>sysenter<br># 方法3:syscall/ sysret (x86-64专用)<br>## 适用于:x86-64架构(64位Linux)<br>## 用户程序使用syscall指令触发<br>## MSR寄存器实现配置好内核入口<br>## 更快、结构更清晰,是现代Linux的主流方式<br>
2.中断/异常<br>用户代码执行中出现<br># 除零错误(#DE)<br># 缺页异常(#PF)<br># 非法访问(#GP)<br># 设备IO完成中断<br>这些都会强制陷入内核态,由CPU执行对应的IDT中的中断服务程序(ISR).<br>注意:这类转移是被动的、由硬件或异常引发,不是主动发起<br>
3.陷阱指令(Trap/Call Gate)<br>虽然很少用了,但也可以通过设置<br># GDT中的调用门(Call Gate)<br># 用户态调用lcall<br>这样会把调用权交给内核代码,并自动完成特权级切换
栈切换机制:TSS的作用<br>只要发送从Ring3->Ring0的切换,CPU会<br># 查找当前GDT中的TSS<br># 从TESS.ESP0和TSS.SS0读取内核态栈<br># 切换栈、切换代码段、执行内核代码<br>
总结<br><br>
常见的中断都有哪些<br>常见的中断可分为三大类:硬件中断、软件中断、异常(陷阱),它们在操作系统和底层开发中都非常重要<br>
硬件中断(Hardware Interrupts)<br>硬件设备发出引号,引发CPU中断<br><br>现代系统中尝试用APIC/IOAPIC替代传统8259 PIC,IRQ分配不再固定,改为动态中断向量号<br>
软件中断(Software Interrupts)<br>程序通过int n指令主动触发,常用于系统调用、调试、模拟中断<br>
3.异常/陷阱(Exception/Trap)<br>由CPU检测错误自动触发,表示程序或硬件行为异常。常用于调试、错误处理和页异常处理<br>
补充:中断向量号分布(x86)<br><br>
4.总结一句话<br>中断可分为硬件中断(设备)‘、软件中断(系统调用、BIOS)和异常中断(CPU报错),每类都有固定向量编号和用途,是操作系统实现多任务调度、系统调用、异常处理的核心机制<br>
fork函数实现机制。<br>Linux中的fork()函数是创建新进程的核心机制,它的实现体现了操作系统在进程管理上的效率与精妙设计。<br>一句话总结:<br>fork()会复制当前进程的PCB、页表、文件描述符等内核态结构,但不会立刻复制所有内存页,而是采用了写时复制(Copy-On-Write, COW)的优化策略.<br><br>核心流程详解<br>1.sys_fork()->do_fork():开始复制父进程<br># 分配一个新的task_struct(进程控制块PCB)<br># 复制父进程的内核结构(文件描述符、打开的文件表、信号处理表等)<br># 设置新进程的PID<br># 设置返回值:父进程中返回新子进程的PID,子进程中返回0<br># 调用copy_mm()(复制内存空间)、copy_files()(复制文件表)、copy_sighand()(复制信号表)等子函数<br>2.copy_mm()->虚拟内存空间复制(但不复制物理内存)<br># 不复制整个用户空间的物理内存<br># 仅复制页表结构,并设置所有页位只读<br># 同时设置COW标志位(写时复制)<br># 父子进程共享内存页,直到有写操作才复制真实内存页<br>这就是fork()的高效指出L:不实际复制所有内容而是延迟复制(懒复制)<br>3.子进程初始化并加入调度器<br># 初始化CPU上下文(内核栈、程序计数器等)<br># 将子进程设置为TASK_RUNNING,加入调度队列<br># 等待调度器进行调度运行<br>
Copy-On-Write(写时复制)<br>为什么这么做?<br># 父子进程的内存大部分在fork()后不会立即修改<br># 若立刻复制全部内存页会非常耗资源<br># 所以内存页+ 设置为只读+ 写时复制(触发页错误)<br><br>触发过程:<br># 父或子对某内存页写操作<br># 硬件页保护机制产生 #PF(页错误异常)<br># 内核检查是否为COW页<br># 如果是:复制一份新的物理页,解除共享,继续执行<br>
用户态切内核态压栈参数的顺序。<br>在用户态切换到内核态的过程中,CPU会自动压栈一组关键参数,以便内核正确处理中断/系统调用,并能安全地返回用户态。<br>一句话总结:<br>用户态切内核态时,CPU会按照固定顺序压入段选择子、指令指针、标志寄存器、栈指针等信息,用于返回用户态时恢复现场<br>
1.压栈场景:用户态->内核态(特权级从Ring3到Ring0)<br>常见触发方式:<br># 软件中断(如int 0x80)<br># 异常(如缺页异常#PF)<br># 硬件中断(键盘、定时器等)<br># 系统调用指令(如sysenter/syscall)<br>
2.压栈顺序(特权级变化时)<br>当用户态执行int n(或触发异常、中断)导致特权级从Ring3->Ring 0,CPU自动将以下内容压入内核态栈(来自TSS的ESP)。<br>这个顺序是由CPU硬件固定的,目的时让内核能够安全地执行iret回到用户态<br>
3.内核态返回用户态:用iret弹出这5个值<br>内核态执行完中断或系统调用后,调用iret(或iretq)指令会自动将这个值按相反顺序弹出,返回用户态<br>iret:<br>pop eip <- EIP<br>pop cs <- CS<br>pop eflags<br>pop esp <-ESP<br>pop ss <- SS<br>从而恢复用户态上下文,继续用户程序执行
4.如果没有特权级变化(Ring0->Ring0)<br>如果中断是从内核态进入(比如内核内部发生了异常),那么不会压入SS和ESP,.只压入<br>
5.实战举例:系统调用触发压栈<br>mov eax, 1; 系统调用号<br>int 0x80 ; 触发陷入内核态<br># CPU检测到中断门目标段DPL<CPL(即Ring3 -> Ring0)<br># 查找当前TSS中的ESP0,切换到内核栈<br># 按上述顺序自动压栈,再跳转到中断处理函数地址<br>
总结。<br>只有当从低级特权(如Ring3)陷入高特权级(如Ring0),才会额外压入ESP和SS<br>
0 条评论
下一页