1. 引言:CPU虚拟化的核心问题
让多个进程看似同时运行在一个物理CPU上。核心思想是时分共享 (time sharing) CPU。为了实现高效且可控的时分共享,本章介绍了一种关键机制,称为受限直接执行 (Limited Direct Execution, LDE)。
1.1 LDE的基本思想
- 直接执行 (Direct Execution): 为了性能,让用户程序尽可能直接在硬件CPU上运行。
- 受限 (Limited): 操作系统必须施加限制,以确保它能保持对系统的控制权,并且用户程序不能执行危险或非授权的操作。
1.2 问题推导
LDE协议有两个阶段:
- 第一阶段(在系统引导时):内核初始化陷阱表,并且CPU记住它的位置以供随后使用。内核通过特权指令来执行此操作。
- 第二阶段(运行进程时):在使用从陷阱返回指令开始执行进程之前,内核设置了一些内容(例如,在进程列表中分配一个节点,分配内存)。这会将CPU切换到用户模式并开始运行该进程。
2. 问题1:如何执行受限制的操作?
2.1 背景
直接执行时,如果进程需要执行I/O等特权操作怎么办?不能让用户进程随意操作硬件。
2.2 解决方案:引入特权级别 (Processor Modes)
- 用户模式 (User Mode): 运行用户程序,权限受限,不能执行特权指令(如I/O)。
- 内核模式 (Kernel Mode): 运行操作系统内核,拥有最高权限,可以执行任何指令,访问任何硬件。
2.3 解决方案:受保护的控制权转移 (Protected Control Transfer)
- 系统调用 (System Calls): 用户程序通过执行特殊的
trap
指令,陷入(trap)到内核。 - 陷入过程:
trap
指令会提升CPU权限到内核模式,并跳转到操作系统预设的代码地址。 - 陷阱表 (Trap Table): 操作系统在启动时设置一个陷阱表,告诉硬件在发生特定事件时应该跳转到内核中的哪个处理程序。
- 返回: 内核完成工作后,执行
return-from-trap
指令,降低CPU权限回用户模式。
3. 问题2:如何在进程之间切换?
3.1 背景
实现了LDE后,当一个进程在CPU上运行时,操作系统本身并没有运行。那么操作系统如何夺回控制权以切换到另一个进程,实现时分共享呢?
3.2 子问题:如何重获CPU的控制权?
方案1 (协作式 Cooperative):
- 依赖进程"自觉"
- 进程通过发起系统调用或产生错误将控制权交还给OS
- 缺陷: 如果进程陷入死循环且不进行系统调用或出错,OS将失去控制权
方案2 (非协作式/抢占式 Preemptive):
- 硬件支持——时钟中断 (Timer Interrupt)
- OS在启动时设置并启动一个硬件时钟
- 时钟会定期产生中断信号,强制打断当前运行的进程
- CPU控制权转移给OS预设的中断处理程序
3.3 机制:上下文切换 (Context Switch)
当OS决定切换进程时,执行上下文切换:
- 保存当前进程的状态到其进程结构(如PCB)或内核栈中
- 加载下一个要运行进程的状态
- 切换内核栈指针
- 通过
return-from-trap
指令返回,CPU开始执行新加载的进程
4. 潜在问题:并发问题
提出担忧: 如果在处理系统调用或中断时,又发生了一个中断怎么办?
初步解答: 这是并发问题,将在后续章节详细讨论。常用方法包括在处理中断时禁止中断 (disable interrupts),或使用复杂的**锁 (locking)**机制来保护内核数据结构。
5. 深入理解Trap和Trap Table
5.1 Trap (陷阱)
在操作系统和计算机体系结构的语境下,"陷阱"是一种机制,它允许将处理器的控制权从用户模式安全地转移到内核模式。
目的:
- 执行受限操作: 通过系统调用实现
- 处理异常和错误: 如除零、访问无效内存
- 响应硬件中断: 如时钟中断、I/O完成信号
过程:
- 触发: 由
trap
指令、CPU错误/异常或硬件中断触发 - 硬件动作: 保存状态、提升权限、查询陷阱表
- 内核执行: 跳转到陷阱处理程序
- 返回: 通过
return-from-trap
指令返回用户模式
5.2 Trap Table (陷阱表)
陷阱表是一个由操作系统内核创建和维护的数据结构,通常是一个数组。
内容: 每一项包含内核中特定处理程序代码的内存地址
设置: 操作系统在启动过程中初始化陷阱表
作用: 指导硬件在发生事件时应该跳转到哪里
重要性: 确保控制权转移的安全性和可控性
6. 陷阱表的技术实现
6.1 陷阱表概述
- 陷阱表本质上是一个数组结构
- 数组的索引对应中断/异常/陷阱编号
- 数组的每个元素包含处理该事件的内核代码地址和控制信息
6.2 x86架构的实现:中断描述符表(IDT)
在x86架构中,陷阱表被称为中断描述符表(IDT),最多包含256个条目。
门描述符结构:
struct idt_entry {uint16_t offset_low; // 处理程序地址低16位uint16_t selector; // 段选择子uint8_t ist; // IST索引和保留位uint8_t type_attr; // 类型和属性uint16_t offset_mid; // 处理程序地址中16位uint32_t offset_high; // 处理程序地址高32位uint32_t zero; // 保留位
} __attribute__((packed));
6.3 IDT初始化代码示例
void initialize_idt() {// 设置除零错误处理程序set_idt_gate(0, (uint64_t)÷_by_zero_handler, KERNEL_CS, 0x8E, 0);// 设置缺页错误处理程序set_idt_gate(14, (uint64_t)&page_fault_handler, KERNEL_CS, 0x8E, 0);// 设置时钟中断处理程序set_idt_gate(32, (uint64_t)&timer_interrupt_handler, KERNEL_CS, 0x8E, 0);// 设置系统调用处理程序(注意DPL=3)set_idt_gate(128, (uint64_t)&system_call_handler, KERNEL_CS, 0xEE, 0);// 设置IDT指针idt_pointer.limit = sizeof(idt_table) - 1;idt_pointer.base = (uint64_t)&idt_table;// 加载IDT寄存器load_idt(&idt_pointer);
}
6.4 处理程序实现
C语言处理函数:
void timer_interrupt_handler_c() { /* 时钟中断逻辑 */ }
uint64_t system_call_handler_c(uint64_t syscall_num, uint64_t arg1, ...) { /* 处理系统调用 */ }
汇编包装器:
timer_interrupt_handler_asm:pushaq ; 保存所有通用寄存器call timer_interrupt_handler_c ; 调用C函数popaq ; 恢复所有通用寄存器iretq ; 中断返回
7. 总结
通过LDE机制解释了如何虚拟化CPU的核心思想是让程序直接运行,但预先设置好硬件限制(用户/内核模式、陷阱处理、时钟中断),就像给房间做"宝宝防护 (baby proofing)"一样。这样既保证了效率,又维持了OS的控制权。
关键机制包括:
- 特权级别分离
- 受保护的控制权转移
- 时钟中断
- 上下文切换
- 陷阱表
实验:测量上下文调度时间
创建两个进程。
创建两个管道 (pipe) 用于这两个进程间双向通信。
管道 1:进程 A -> 进程 B
管道 2:进程 B -> 进程 A
关键: 将这两个进程绑定到同一个 CPU 核心上运行。这可以使用 sched_setaffinity() (Linux) 或类似系统调用。这是为了确保测量的是单个 CPU 上的上下文切换,而不是因为进程在不同 CPU 间迁移。
进行乒乓通信:
进程 A 向管道 1 写入少量数据(例如 1 字节)。
进程 A 尝试从管道 2 读取数据。由于管道 2 是空的,进程 A 会阻塞 (block)。
操作系统检测到进程 A 阻塞,执行上下文切换,调度进程 B 运行。
进程 B 尝试从管道 1 读取数据(读取 A 写入的数据)。
进程 B 向管道 2 写入少量数据。
进程 B 尝试从管道 1 读取数据。由于管道 1 现在是空的,进程 B 会阻塞。
操作系统检测到进程 B 阻塞,执行上下文切换,调度进程 A 运行(此时 A 可以读到 B 写入的数据,解除阻塞)。
这样完成了一个来回 (round trip)。
重复这个来回很多次。
记录总时间,除以总的来回次数,得到平均每次来回的时间。
注意: 一个完整的来回包含两次上下文切换(A->B 和 B->A)。所以,用平均来回时间除以 2,得到单次上下文切换的估算成本。
2.2 工具
pipe(): 创建 UNIX 管道。
fork(): 创建子进程。
write(), read(): 通过管道读写数据。
sched_setaffinity(): (Linux) 将进程绑定到指定 CPU 核心。需要包含 <sched.h> 并可能需要定义 _GNU_SOURCE。
wait() 或 waitpid(): 父进程等待子进程结束。
计时函数: 使用 clock_gettime() 。
#define _GNU_SOURCE // 需要这个宏才能使用 sched_setaffinity
#define _POSIX_C_SOURCE 199309L
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <sched.h>
#include <time.h>
#include <string.h> // For memset#define ITERATIONS 100000 // 上下文切换测量的迭代次数
#define CPU_TO_BIND 0 // 绑定到 CPU 核心 0,确保你的系统有这个核心// Helper function to calculate time difference in nanoseconds
long long timespec_diff_ns(struct timespec start, struct timespec end) {return (end.tv_sec - start.tv_sec) * 1000000000LL + (end.tv_nsec - start.tv_nsec);
}int main() {int pipe1[2]; // Pipe: Parent -> Childint pipe2[2]; // Pipe: Child -> Parentpid_t child_pid;struct timespec start_time, end_time;long long total_elapsed_ns;double avg_round_trip_ns, avg_context_switch_ns;char buffer = 'x'; // 用于在管道中传输的单个字节// 创建两个管道if (pipe(pipe1) == -1 || pipe(pipe2) == -1) {perror("pipe creation failed");return 1;}// 创建子进程child_pid = fork();if (child_pid == -1) {perror("fork failed");return 1;}// 设置 CPU 亲和性 (Affinity)cpu_set_t cpu_set;CPU_ZERO(&cpu_set);CPU_SET(CPU_TO_BIND, &cpu_set);if (sched_setaffinity(0, sizeof(cpu_set_t), &cpu_set) == -1) {perror("sched_setaffinity failed");// 不一定是致命错误,但测量结果可能不准}if (child_pid == 0) {// --- 子进程代码 ---close(pipe1[1]); // 关闭 pipe1 的写端close(pipe2[0]); // 关闭 pipe2 的读端for (long i = 0; i < ITERATIONS; ++i) {// 从父进程读if (read(pipe1[0], &buffer, 1) != 1) {perror("Child read failed");exit(1);}// 写回给父进程if (write(pipe2[1], &buffer, 1) != 1) {perror("Child write failed");exit(1);}}close(pipe1[0]);close(pipe2[1]);exit(0);} else {// --- 父进程代码 ---close(pipe1[0]); // 关闭 pipe1 的读端close(pipe2[1]); // 关闭 pipe2 的写端// 获取开始时间 (在循环开始前)if (clock_gettime(CLOCK_MONOTONIC, &start_time) == -1) {perror("clock_gettime start failed");return 1;}// 执行乒乓通信循环for (long i = 0; i < ITERATIONS; ++i) {// 写给子进程if (write(pipe1[1], &buffer, 1) != 1) {perror("Parent write failed");exit(1);}// 从子进程读 (会阻塞,触发 A->B 切换)if (read(pipe2[0], &buffer, 1) != 1) {perror("Parent read failed");exit(1);}// 读完后,子进程会阻塞,触发 B->A 切换}// 获取结束时间if (clock_gettime(CLOCK_MONOTONIC, &end_time) == -1) {perror("clock_gettime end failed");return 1;}// 等待子进程结束wait(NULL);close(pipe1[1]);close(pipe2[0]);// 计算总耗时total_elapsed_ns = timespec_diff_ns(start_time, end_time);// 计算平均每次来回耗时avg_round_trip_ns = (double)total_elapsed_ns / ITERATIONS;// 计算平均每次上下文切换耗时 (来回时间 / 2)avg_context_switch_ns = avg_round_trip_ns / 2.0;printf("Measured %ld round trips between two processes on CPU %d.\n", (long)ITERATIONS, CPU_TO_BIND);printf("Total time: %lld ns\n", total_elapsed_ns);printf("Average round trip time: %.2f ns\n", avg_round_trip_ns);printf("Average context switch cost: %.2f ns\n", avg_context_switch_ns);}return 0;
}// 编译: gcc -o measure_ctxsw measure_ctxsw.c -lrt -D_GNU_SOURCE
// 运行: ./measure_ctxsw
8. 参考资料
- 陷阱、中断、异常、信号
- OSTEP