操作系统从实模式到保护模式

实模式16位

实模式的“实”体现在:程序中用到的地址都是真实的物理地址,“段基址:段内偏移”产生的逻辑
地址就是物理地址,也就是程序员看到的完全是真实的内存。

从实模式到保护模式要打开 A20 地址线,从而访问1M大小的空间

实模式被保护模式淘汰的原因,最主要是安全隐患。
在实模式下,用户程序和操作系统可以说是同一特权的程序,因为实模式下没有特权级,它处处和操
作系统平起平坐,所以可以执行一些具有破坏性的指令。

push
压入数据的过程是:先将 SP 减去字长,目的是避免将栈顶的数据破坏,所得的差再存入 SP,栈顶在此被
更新。
pop 指令相反,既然是在栈中弹出数据,栈指针寄存器 SP 的值应该是增大一个数据单位。由于要弹
出的数据就在当前栈顶,所以在弹出数据后,才将 SP 加上字长,所得的和再存入 SP,从而更新了栈顶。
在这里插入图片描述

启动流程

在这里插入图片描述
在这里插入图片描述

保护模式32位

段描述符(64位,高32和低32位)

在这里插入图片描述
,您会发现 20 位的段界限属性,居然被拆分成两部分。段界限的低 16 位(0~15 位)存放在
段描述符的低 32 位,段界限的高 4 位(16~19 位)存放在段描述符的高 32 位。不止段界限这样,段基址更过
分,32 位的段基址居然被分拆成三份存放。这属于历史遗留问题。为了兼容(主要是 Intel 公司的战略是把兼
容放在第一位)CPU 不得不兼顾着过去的产品。

GDT表

一个段描述符只用来定义(描述)一个内存段。代码段要占用一个段描述符、数据段和栈段等,多个内存段也要各自占用一个段描述符,这些描述符放在哪里呢?答案是放在全局描述符表,就是本节所说的 GDT(Global DescriptorTable)。全局描述符表 GDT 相当于是描述符的数组,数组中的每个元素都是8 字节的描述符。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

gdt表

每个 GDT 表项(段描述符)的大小是 8 字节(64 位),这是在 x86 架构中规定的。
每个 GDT 表项用于描述一个**段(Segment)**的属性,例如:

  • 代码段 数据段 栈段 系统段(如 TSS)
    在这里插入图片描述✅ 总共刚好是 64 位 = 8 字节
    所以:
    每个 GDT 表项占 8 字节
    最多可以有 8192 个 GDT 表项(因为 GDT 的大小限制是 64KB)
    在这里插入图片描述

进入保护模式时,我们要关中断,lgdtr 进入加载gdt(全局部描述符表)表
在这里插入图片描述
我们在这个325384表中找到对CR0寄存器的一些描述
我们将CR0的最低位PE设置为0就可以进入到保护模式
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

打开分页机制

在这里插入图片描述在这里插入图片描述
有了虚拟地址,可以将某一块地址映射到空闲的实际物理内存,就可以看上虚拟存储空间很大

分页机制为什么可以让程序“看起来”有更大的内存?

📌 原因 1:每个进程都有独立的虚拟地址空间

  • 每个程序都认为自己拥有完整的 4GB(32位)或更大空间(64位下可以是 TB 级)
  • 实际上底层通过页表映射不同的物理内存页

📘 举例:

  • 程序 A 的虚拟地址 0x400000 → 页表映射到物理地址 0x1000
  • 程序 B 的虚拟地址 0x400000 → 映射到另一个物理地址 0x2000

两个程序互不干扰,却都能“看到”同样地址空间


📌 原因 2:按需分配页面(不必一次性分配全部内存)

  • 程序的虚拟地址空间虽然大,但只有真正访问的页才分配物理内存
  • 没访问的虚拟页并不占用实际内存

📌 原因 3:页面可以换出到磁盘(Swap)

  • 程序用的页太多,物理内存不够时,操作系统可以把某些冷门页写入磁盘
  • 程序继续访问时触发“缺页异常”,再从磁盘换回来

📘 举例:

  • 虚拟内存:4GB
  • 物理内存:1GB
  • 系统用页表 + Swap 管理,程序无感知

📌 原因 4:支持稀疏地址空间(Sparse Space)

  • 虚拟地址空间中间可以有大量未分配区域
  • 只有需要的才分配 → 节省内存 + 可支持大空间结构

总结句:

分页机制让程序“看起来”有更大的内存,是因为它构建了一种“虚拟地址空间”的抽象,

把实际物理内存、磁盘页、稀疏地址空间统一起来映射成一个“连续的大空间”,

并按需分配、动态换入换出,实现远大于物理内存的运行效果。

在这里插入图片描述
CR4的第低四位set 1,开启一级分页,程序中设置后,可以再qemu查看寄存器设置的值
在这里插入图片描述在这里插入图片描述
就可以看到CR0 CR1这些
在这里插入图片描述
在这里插入图片描述

打开定时中断

在这里插入图片描述

在保护模式下,如何开启定时中断(时钟中断)?

这是操作系统内核最重要的基础设施之一,用于:

  • 多任务调度
  • 系统心跳
  • 时间片轮转
  • 定时器调用

✅ 总目标:

使用 8253/8254 可编程定时器(PIT)

配置中断控制器 PIC

设置 IDT(中断描述符表)

编写 中断处理程序(ISR)

并启用 CPU 中断(sti


✅ 步骤总览(适用于保护模式)

步骤动作
1️⃣初始化 IDT,注册 IRQ0(定时器)
2️⃣设置 PIT 的频率(通过 I/O 端口)
3️⃣设置 PIC 的 IRQ 映射(主从中断控制器)
4️⃣编写时钟中断处理函数
5️⃣lidt 加载 IDT
6️⃣sti 启用中断标志

✅ Step-by-Step 实现(保护模式下)


🔹 1. 设置 PIT 定时器(8253)

设置为 100Hz(即每 10ms 触发一次):

nasm
复制编辑
mov al, 0x36          ; 模式:通道0,方波发生器
out 0x43, al

mov ax, 1193          ; 频率 = 1193180 / 100 ≈ 11931 → 16位
out 0x40, al          ; 低8位
mov al, ah
out 0x40, al          ; 高8位


🔹 2. 重映射 PIC(避免和异常冲突)

默认 IRQ0IRQ15 是中断号 0x080x0F,会和 CPU 异常冲突

改为从 0x20 开始:

nasm
复制编辑
mov al, 0x11
out 0x20, al      ; 主 PIC 初始化
out 0xA0, al      ; 从 PIC 初始化

mov al, 0x20
out 0x21, al      ; 主 PIC 起始中断号 IRQ0 = 0x20
mov al, 0x28
out 0xA1, al      ; 从 PIC 起始中断号 IRQ8 = 0x28

mov al, 0x04
out 0x21, al      ; 主 PIC 告知从 PIC 连接在 IRQ2
mov al, 0x02
out 0xA1, al      ; 从 PIC 告知主 PIC 在 IRQ2

mov al, 0x01
out 0x21, al
out 0xA1, al


🔹 3. 初始化 IDT 表项(IRQ0 对应中断号 0x20)

你需要设置中断描述符:

c

void set_idt_entry(int num, uint32_t base) {
    idt[num].offset_low  = base & 0xFFFF;
    idt[num].selector    = 0x08;        // 内核代码段
    idt[num].zero        = 0;
    idt[num].type_attr   = 0x8E;        // 中断门
    idt[num].offset_high = (base >> 16) & 0xFFFF;
}

然后设置你的定时器 ISR:

c

extern void isr_timer();   // ISR 汇编入口
set_idt_entry(0x20, (uint32_t)isr_timer);


🔹 4. 编写定时器中断处理函数(汇编)

nasm

[GLOBAL isr_timer]
isr_timer:
    pusha                 ; 保存所有通用寄存器

    call timer_callback   ; 调用 C 函数做计数、调度等

    ; 通知 PIC:中断结束
    mov al, 0x20
    out 0x20, al

    popa
    iretd


🔹 5. 加载 IDT 表

nasm

lidt [idt_descriptor]

结构:

c

struct {
    uint16_t limit;
    uint32_t base;
} __attribute__((packed)) idt_descriptor = {
    sizeof(idt) - 1,
    (uint32_t)&idt
};


🔹 6. 启用中断

nasm

sti        ; 开启 CPU 中断


✅ 总结关键点

元件作用
PIT(8253)每隔一定时间产生 IRQ0 中断
PIC(8259)控制中断号映射,通知中断结束
IDT把 IRQ 映射到你写的中断处理函数
ISR保存现场 → 调用 C 函数 → 通知 PIC → 恢复现场
sti允许 CPU 响应中断

✅ 一句话总结:

在保护模式下,开启定时中断的关键是:

配置定时器(PIT)→ 映射 IRQ 到 IDT → 编写 ISR → 开启中断标志(sti),

这样你就能实现“时钟滴答”来做任务调度、计时器、系统心跳等功能。

切换低特权级

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
手动模拟压栈
在这里插入图片描述

栈的单元通常是 4 字节(32 位),特别是在 32 位模式下

解释:

  • x86 32 位架构中,栈操作的单位是 4 字节(一个双字)。也就是说,每次**push* 或 pop 操作,栈指针(SPESP)会向下或向上移动 4 字节

为什么是 4 字节?

32 位模式下,CPU 使用 32 位的寄存器(如 EAX, EBX, ECX, EDX 等)。这些寄存器的大小是 4 字节(32 位),因此,栈的单元也是 4 字节,通常用于保存寄存器、函数参数、局部变量等数据。


1. 栈操作的例子:

  • push 操作:将值压入栈顶,栈指针 ESP 向下移动 4 字节
  • pop 操作:从栈顶弹出值,栈指针 ESP 向上移动 4 字节
nasm
复制编辑
push eax      ; 将 eax 寄存器的值压入栈(4 字节)
pop ebx       ; 将栈顶的值弹出到 ebx 寄存器


2. 栈帧结构(用于函数调用)

在函数调用过程中,栈帧(Stack Frame)会被创建,其中保存了返回地址、函数参数、局部变量等内容,每个单元通常是 4 字节。

假设一个函数如下:

c
复制编辑
int add(int a, int b) {
    return a + b;
}

栈帧大致如下:

内容大小描述
返回地址4 字节call 指令返回的地址
参数 a4 字节第一个参数
参数 b4 字节第二个参数
局部变量4 字节(或更多)如果函数有局部变量(如 int c

3. 64 位架构中的变化(x86_64)

x86_64 (64 位架构)中,栈操作的单元是 8 字节(64 位),因为寄存器的大小变为 64 位。

nasm
复制编辑
push rax      ; 压入 64 位寄存器
pop rbx       ; 弹出 64 位寄存器

但是在 32 位模式下,栈单元始终是 4 字节


4. 总结

  • 32 位架构下,栈操作的单位是 4 字节
  • 每次 pushpop,栈指针会按 4 字节 移动,保持一致。
  • 64 位架构下,栈操作的单位是 8 字节

在这里插入图片描述
进程0 入口,首先配置成DPL3
在这里插入图片描述
旁边的进程是重叠的平坦模型

task切换

在这里插入图片描述
在这里插入图片描述
TSS 是一个特殊的段结构,用于保存任务状态和特权级切换时的栈信息,尤其是在从用户态进入内核态时,提供 Ring 0 的栈指针。

这三者的关系是:

组件含义关键作用
TSS结构体Task State Segment存放特权切换用的栈指针(esp0、ss0)等
段描述符TSS 的“元数据”告诉 CPU TSS 的位置、大小、权限
GDT全局描述符表保存所有段描述符,包括代码段、数据段、TSS段
任务寄存器 TRTask Register指向当前使用的 TSS 描述符

进程切换

✅ 一句话回答:

我的程序(操作系统)中任务的切换是通过“手动保存和恢复寄存器 + 切换栈 + 修改 esp/eip”来实现的,

也就是我们通常说的:“软件实现的上下文切换”


🧠 x86 有两个任务切换方式:

方式用途实际使用
软件切换自己写代码保存寄存器、切换堆栈、跳转👍 主流 OS 使用(Linux、我的内核)
硬件切换jmp TSS由 CPU 自动切换任务、恢复上下文🚫 现代 OS 基本不用了,过于复杂

✅ 软件任务切换的核心思想:

切换任务就是:

  1. 把当前任务的寄存器保存到它自己的“内核栈”或“任务结构体”里
  2. 加载下一个任务的上下文(esp、eip、通用寄存器等)
  3. 跳转到它的执行点继续运行(如 iretret

🔧 示例(最小任务切换伪代码):

c
复制代码
// 当前任务结构体
struct Task {
    uint32_t esp;     // 保存任务的栈指针
    uint32_t eip;     // 可选:上次中断的返回地址
    uint32_t regs[8]; // eax, ebx, ecx... 通用寄存器
};

// 当前任务 → next 任务切换
void switch_task(struct Task* current, struct Task* next) {
    // 保存当前任务上下文
    asm volatile (
        "mov %%esp, %0\n"
        "mov %%ebp, %1\n"
        : "=m"(current->esp), "=m"(current->regs[5])
        :
    );

    // 加载下一个任务的上下文
    asm volatile (
        "mov %0, %%esp\n"
        "mov %1, %%ebp\n"
        :
        : "m"(next->esp), "m"(next->regs[5])
    );

    // 最终跳转回下一个任务(可能用 ret / iret / longjmp)
}


🧠 实际调度流程(结合中断):

操作系统中一般这样切换任务(比如响应定时器中断):

  1. 时钟中断进入 → 保存当前任务寄存器(通过 pusha
  2. 调度器决定下一个任务
  3. 保存当前任务栈 + 寄存器到任务结构
  4. 从下一个任务的任务结构中恢复寄存器、esp
  5. 使用 iretret 返回到用户/内核代码中继续执行

✅ 用 TSS 支持内核栈切换

虽然我们不用硬件任务切换(jmp TSS,但我们仍然需要:

  • 设置 TSS 的 esp0/ss0
  • 让 CPU 知道发生 Ring3 → Ring0 的中断时使用哪个内核栈

这个部分是自动完成的,是你设置好 TSS 后 CPU 帮你做的。


✅ 总结一句话:

现代操作系统(包括你正在写的)都使用“软件上下文切换”实现任务切换:

保存当前任务寄存器 → 加载下一个任务寄存器 → 切换栈 → 跳转到下一任务继续执行。

TSS 只用于特权切换时的内核栈设置,不做任务切换本身。

系统调用

在这里插入图片描述
在这里插入图片描述

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值