实模式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
操作,栈指针(SP
或ESP
)会向下或向上移动 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 指令返回的地址 |
参数 a | 4 字节 | 第一个参数 |
参数 b | 4 字节 | 第二个参数 |
局部变量 | 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 字节。
- 每次
push
或pop
,栈指针会按 4 字节 移动,保持一致。 - 64 位架构下,栈操作的单位是 8 字节。
进程0 入口,首先配置成DPL3
旁边的进程是重叠的平坦模型
task切换
TSS 是一个特殊的段结构,用于保存任务状态和特权级切换时的栈信息,尤其是在从用户态进入内核态时,提供 Ring 0 的栈指针。
这三者的关系是:
组件 | 含义 | 关键作用 |
---|---|---|
TSS结构体 | Task State Segment | 存放特权切换用的栈指针(esp0、ss0)等 |
段描述符 | TSS 的“元数据” | 告诉 CPU TSS 的位置、大小、权限 |
GDT | 全局描述符表 | 保存所有段描述符,包括代码段、数据段、TSS段 |
任务寄存器 TR | Task Register | 指向当前使用的 TSS 描述符 |
进程切换
✅ 一句话回答:
我的程序(操作系统)中任务的切换是通过“手动保存和恢复寄存器 + 切换栈 + 修改 esp/eip”来实现的,
也就是我们通常说的:“软件实现的上下文切换”。
🧠 x86 有两个任务切换方式:
方式 | 用途 | 实际使用 |
---|---|---|
✅ 软件切换 | 自己写代码保存寄存器、切换堆栈、跳转 | 👍 主流 OS 使用(Linux、我的内核) |
❌ 硬件切换(jmp TSS ) | 由 CPU 自动切换任务、恢复上下文 | 🚫 现代 OS 基本不用了,过于复杂 |
✅ 软件任务切换的核心思想:
切换任务就是:
- 把当前任务的寄存器保存到它自己的“内核栈”或“任务结构体”里
- 加载下一个任务的上下文(esp、eip、通用寄存器等)
- 跳转到它的执行点继续运行(如
iret
或ret
)
🔧 示例(最小任务切换伪代码):
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)
}
🧠 实际调度流程(结合中断):
操作系统中一般这样切换任务(比如响应定时器中断):
- 时钟中断进入 → 保存当前任务寄存器(通过
pusha
) - 调度器决定下一个任务
- 保存当前任务栈 + 寄存器到任务结构
- 从下一个任务的任务结构中恢复寄存器、esp
- 使用
iret
或ret
返回到用户/内核代码中继续执行
✅ 用 TSS
支持内核栈切换
虽然我们不用硬件任务切换(jmp TSS
),但我们仍然需要:
- 设置 TSS 的
esp0/ss0
- 让 CPU 知道发生 Ring3 → Ring0 的中断时使用哪个内核栈
这个部分是自动完成的,是你设置好 TSS 后 CPU 帮你做的。
✅ 总结一句话:
现代操作系统(包括你正在写的)都使用“软件上下文切换”实现任务切换:
保存当前任务寄存器 → 加载下一个任务寄存器 → 切换栈 → 跳转到下一任务继续执行。
TSS 只用于特权切换时的内核栈设置,不做任务切换本身。
系统调用