从0写自己的操作系统(4)实现简单的任务切换
这是前面的一些文章
从0写自己的操作系统(1) boot->loader引导程序
从0写自己的操作系统(2) loader->kernel加载操作系统内核
从0写自己的操作系统(3)x86操作系统的中断和异常处理
TSS结构
/*** tss描述符*/
typedef struct _tss_t {uint32_t pre_link;uint32_t esp0, ss0, esp1, ss1, esp2, ss2;uint32_t cr3;uint32_t eip, eflags, eax, ecx, edx, ebx, esp, ebp, esi, edi;uint32_t es, cs, ss, ds, fs, gs;uint32_t ldt;uint32_t iomap;
}tss_t;
esp一般为栈顶
要做任务切换,需要在GDT表中添加TSS描述符
TSS描述符结构
jmp 跟上tss选择子进行跳转 具体的状态转换和恢复都是硬件自动完成的
static int tss_init (task_t * task, int flag, uint32_t entry, uint32_t esp) {// 为TSS分配GDTint tss_sel = gdt_alloc_desc();if (tss_sel < 0) {log_printf("alloc tss failed.\n");return -1;}segment_desc_set(tss_sel, (uint32_t)&task->tss, sizeof(tss_t),SEG_P_PRESENT | SEG_DPL0 | SEG_TYPE_TSS);// tss段初始化kernel_memset(&task->tss, 0, sizeof(tss_t));// 分配内核栈,得到的是物理地址uint32_t kernel_stack = memory_alloc_page();if (kernel_stack == 0) {goto tss_init_failed;}// 根据不同的权限选择不同的访问选择子int code_sel, data_sel;if (flag & TASK_FLAG_SYSTEM) {code_sel = KERNEL_SELECTOR_CS;data_sel = KERNEL_SELECTOR_DS;} else {// 注意加了RP3,不然将产生段保护错误code_sel = task_manager.app_code_sel | SEG_RPL3;data_sel = task_manager.app_data_sel | SEG_RPL3;}task->tss.eip = entry;task->tss.esp = esp ? esp : kernel_stack + MEM_PAGE_SIZE; // 未指定栈则用内核栈,即运行在特权级0的进程task->tss.esp0 = kernel_stack + MEM_PAGE_SIZE;task->tss.ss0 = KERNEL_SELECTOR_DS;task->tss.eip = entry;task->tss.eflags = EFLAGS_DEFAULT| EFLAGS_IF;task->tss.es = task->tss.ss = task->tss.ds = task->tss.fs = task->tss.gs = data_sel; // 全部采用同一数据段task->tss.cs = code_sel; task->tss.iomap = 0;// 页表初始化uint32_t page_dir = memory_create_uvm();if (page_dir == 0) {goto tss_init_failed;}task->tss.cr3 = page_dir;task->tss_sel = tss_sel;return 0;
tss_init_failed:gdt_free_sel(tss_sel);if (kernel_stack) {memory_free_page(kernel_stack);}return -1;
}
tss_sel 是一个段选择子,而不是段描述符本身。它是一个 16 位结构,包含 GDT 索引、TI 位和特权级 RPL。CPU 通过 tss_sel >> 3 计算出 GDT 表项索引,从而找到真实的段描述符数据。我们设置 GDT 表项内容,用的是 segment_desc_set;而 tss_sel 是后续加载到 TR 寄存器中,让 CPU 识别当前任务 TSS 的唯一标识。
ESP EIP
esp 是栈顶指针,决定了函数调用时参数和返回地址的存储位置;而 eip 是指令指针,表示当前 CPU 执行的机器码位置。它们配合完成函数调用、返回、中断处理等流程,但并不指向彼此。TSS 中保存的 esp 和 eip 表示这个任务在被切换出去时的运行状态,后续切换回来时,CPU 会用 eip 恢复执行地址,用 esp 恢复栈环境,从而无缝继续执行。
相关理解:
说法 | 是否正确 | 解释 |
---|---|---|
esp 是用户态的栈顶 | ✅ | 是的,用于函数/系统调用等 |
esp0 是内核态的栈顶 | ✅ | 中断或系统调用进入内核时使用 |
“栈顶”是不是“最后结束的位置”? | ❌ | 栈顶是当前调用栈的起点(向低地址增长),不是终点 |
eip 是栈顶指向的代码地址? | ❌ | eip 和 esp 无直接指向关系,esp 里可能保存的是“函数返回地址”,但不是 “eip = *esp” |
✅ 那 esp
有什么用?
功能 | 场景 |
---|---|
调用函数时保存返回地址 | call 指令时 push eip |
保存参数、局部变量 | 栈帧 = 参数 + 变量 |
系统调用压栈用户态寄存器 | 保存中断前用户上下文 |
中断返回恢复 esp 状态 | iret 从栈恢复 esp |
✅ 总结:
esp 是栈顶指针,决定了函数调用时参数和返回地址的存储位置;而 eip 是指令指针,表示当前 CPU 执行的机器码位置。它们配合完成函数调用、返回、中断处理等流程,但并不指向彼此。TSS 中保存的 esp 和 eip 表示这个任务在被切换出去时的运行状态,后续切换回来时,CPU 会用 eip 恢复执行地址,用 esp 恢复栈环境,从而无缝继续执行
tr寄存器跳转下一个任务
TR 寄存器(Task Register) 是 x86 架构中实现任务管理和特权栈切换的核心机制之一,尤其在使用 TSS(任务状态段)时非常关键。TR 是 x86 中的 Task Register(任务寄存器),它保存了当前活动任务的 TSS 段选择子(Selector),通过它,CPU 可以访问当前任务的 TSS,实现任务切换、栈切换等功能。
TR 寄存器作用总结
功能 | 描述 |
---|---|
指向当前 TSS | 保存当前任务对应的 TSS 段选择子(即 GDT 中的哪一项) |
任务切换入口 | ljmp 到一个 TSS 段描述符时,CPU 会更新 TR |
栈切换时用到 esp0 | 中断时,CPU 会通过 TR 定位当前任务的 TSS,然后使用其中的 esp0 和 ss0 |
硬件任务切换支持 | x86 早期支持使用 TSS 做硬任务切换,TR 是其状态入口 |
ss0 是 TSS 中的内核态栈段选择子,当 CPU 从用户态(CPL=3)切换到内核态(CPL=0)时,自动将 ss0 加载到 SS 寄存器,用于切换到内核态栈。
🧠 TR 是怎么设置的?
✅ 使用 ltr
指令加载:
mov ax, tss_selector
ltr ax ; Load Task Register
- 你必须先把 TSS 的段描述符写入 GDT
- 然后用
ltr
将其加载到 TR - 之后 CPU 会自动使用它进行特权栈切换(用户态 → 内核态)
因为 tss_sel 是一个指向 TSS 段的 GDT 选择子,当你执行 ljmp tss_sel:0 时,不是普通的代码段跳转,而是触发了 硬件级任务切换机制 —— CPU 自动从目标 TSS 中加载完整上下文(EIP、ESP、CR3…)并跳转。
另一种任务切换的方式
手动保存
.text.global simple_switch
simple_switch:movl 4(%esp), %eax // 取from->stackmovl 8(%esp), %edx // 取to->stack// 保存前一任务的状态push %ebppush %ebxpush %esipush %edi// 切换栈mov %esp, (%eax) // from->stack = espmov %edx, %esp // esp = to->stack// 加载下一任务的栈pop %edipop %esipop %ebxpop %ebpret.global exception_handler_syscall.extern do_handler_syscall
看起来都是 mov
,但它们语义完全不同:
movl 4(%esp), %eax // 取from->stack
mov %esp, (%eax) // from->stack = esp
指令 | 类型 | 含义 |
---|---|---|
mov src, reg | 加载 | 把内存/立即数放进寄存器 |
mov reg, [addr] | 存储 | 把寄存器内容写入某个内存地址 |
汇编指令虽然语法形式看起来一致,但是由左右操作数结构决定含义的,类似于 C 语言的 a = b;
,你得看 a 和 b 的含义才能知道语义。
尽管两条 mov
指令语法形式相似,但它们的语义完全不同:第一条是读取参数,第二条是保存 esp。汇编是一种显式控制语言,赋值完全依赖操作数而不是上下文,所以只要我在使用 %eax
之前显式地把它赋值为 from->stack
,就不会有“旧值脏数据”的风险。这个流程非常重要,因为栈顶 esp 的准确保存关系到任务是否能正确恢复。
单独修改 esp 不会影响 eip,因为它们是两个独立寄存器。真正会让 eip 改变的,是像 ret 或 iret 这样的指令,它们会从 esp 指向的栈里弹出新的执行地址,赋值给 eip。所以在任务切换中,我们常先切 esp,再执行 ret 来跳转回新任务保存的 eip,实现上下文恢复。