任务的隔离
任务
程序时记录在载体上的指令和数据,其正在执行中的一个副本就叫做任务
简单来说,我们为了完成某项特定的工作,写了一个程序,然后这个程序有可以分成几个小程序,程序执行时,每个小程序也在内存中运行,那这个小程序就是一个任务了
任务的LDT
LDT 叫做 局部描述符表,之所以叫局部是因为它是独属于某个任务的,换句话说,LDT中存放的应该是某个任务的私有内存段描述符,而且 LDT 的0号槽位也是可以用的,不想 GDT 那样0号槽位必须是空描述符
任务的TSS
TSS 叫做 任务状态段,这是 每个任务用来保存相关信息的额外使用的额外内存段,最小尺寸是 104字节,TSS中的每个元素能被处理器固件识别,这主要是为了实现任务切换
以下是TSS的示意图,知道它大概记录了啥就行了
多任务系统
以下是多任务系统的组成示意图
我们看到处理器内部:
- 处理器使用 GDTR寄存器 追踪GDT,GDT是 全局性 的,为所有任务服务,即是 所有任务共有 的,所以只有1个
- 处理器使用 LDTR寄存器 追踪LDT,LDT是 局部性 的,为单个任务服务,是 任务私有 的,所以LDT的数量不止一个,视任务多少而定。LDTR 指向的是当前执行的任务
- 处理器使用 TR寄存器 追踪TSS,TSS也是单个任务私有的,记录当前任务的状态,TR 指向的是当前执行的任务的状态段
有了这些概念后,我们再来简单的谈谈任务切换:
- 保护当前任务的运行状态,这个就是靠TSS实现啦,主要是一些寄存器的状态
- 切换LDTR和TR,让他们指向新的任务
- 恢复旧任务的运行状态,其实就是切换LDTR和TR,让他们指向旧的任务
全局空间和局部空间
单任务
每个任务实际包括两个部分:全局部分和私有部分
任务是在内存中运行的,所以全局/私有部分本质上还是 地址空间的划分------全局/局部(地址)空间
地址空间的访问是依靠 分段机制 进行的,也就是通过描述符表中的描述符来访问内存段,故而:
- 全局地址空间由GDT定义
- 局部地址空间由LDT定义
理清了这些概念后,全局/局部空间中的内容是什么呢:
- 全局空间:含有操作系统的软件和库程序,以及可以调用的系统服务和数据
- 局部空间:每个任务各自的数据和代码,与要解决的问题有关,各不相同
现在让我们粗浅的来看一下一个任务是如何执行的:
通常,任务会在自己的局部空间运行,当它需要操作系统提供的服务时,转入全局空间执行
多任务
可以看到多个任务就有多个局部空间,然后他们都共享一个全局空间
特权级保护概述
试着想象一下,我有一个任务A,它很大胆的修改了全局空间中的部分内容并且成功了,当任务B运行在全局空间时,它实际上运行的不是它认知中的内容了,而是任务A修改后的内容
这个例子是为了说明,虽然我们引进了LDT和TSS,进一步强化了分段机制,将任务与任务之间隔离从而实现多任务,但它还是不够安全,所以我们引入了 特权级保护机制,特权级就是通过级别 限制段的访问和修改,简单来说就是让处理器知道哪个段才是老大
上图是Intel处理器提供的4级环状保护结构,数值越小,级别越高,可靠性越高
DPL
DPL叫做 描述符特权级,是描述符中的两比特的字段,用于表示描述符所指向段的特权级
两比特可以取值00,01,10,11,分别对应特权级0,1,2,3
对于数据段 来说,DPL决定了访问该段所应当具备的最低特权级别
CPL
当处理器正在一个 代码段 中取指令和执行指令时,该段的特权级叫做 当前特权级,即CPL;正在执行的这个代码段的选择子位于CS寄存器中,其 最低两位 就是当前特权级的数值
操作系统代码执行时CPL是0,这 能够保证它的安全性,并使它能够访问所有软硬件资源
用户程序在局部空间中执行时,CPL是3,调用系统服务,进入操作系统内核,在全局空间中执行时,CPL是0。所以说,任务的特权级别并不是固定的,要具体看待(CPL取决于CS寄存器)
特权指令
只有在当前特权级CPL为0时才能执行的指令,叫做 特权指令
例如:
- 加载GDT的 lgdt 指令
- 加载LDT的 lldt 指令
- 加载TSS的 ltr 指令
IOPL
IOPL 叫做 输入/输出特权级,代表当前任务的I/O特权级别
处理器允许对各个特权级别所能执行的I/O操作进行控制,通常是 端口访问的许可权(设备的访问通过端口进行)
所以我们可以简单将 IOPL 理解为 访问外设的能力
IOPL位 位于 标志寄存器EFLAGS中
RPL
RPL 叫做 请求特权级
不管是实施控制转移,还是访问数据段,都可以看成是一个请求---请求者提供一个段选择子,请求访问指定的段
所以,我们可以 将RPL看成是请求者(提供选择子)的特权级别
这里要先了解一些特权级检查的知识,可以先移步下一小节
大多时候 RPL=CPL,当有时候,经过特权级转移,比如使用调用门,会使得 CPL 发生改变,从而让 RPL不等于CPL
为什么要引入RPL呢:
考虑以下情况,假设应用程序知道了操作系统数据段的选择子,并希望通过该选择子访问修改。低级访问高级,我们很容易就知道不可能,但这实际上是有可能的(如果没有RPL)
应用程序想要从硬盘读取数据并写入操作系统的数据段中,应用程序的CPL为3,硬件访问权限不够,所以通过调用门调用操作系统提供的例程,此时CPL为0,然后操作系统根据提供的目标数据段选择子,检查CPL=0,和目标数据段的DPL=0一样,可以写入,于是就将数据写入了
应用程序将数据写入到了操作系统的数据段中,这无疑是不能忍受的,所以就引入了RPL,这是为了搞清楚真正的请求者是谁,如果一开始发现特权级位3的请求者想要访问特权级位0的数据段,那可以直接引发异常中断,不就安全了
门
段描述符用于描述内存段,们描述符用于描述可执行的代码,比如一段程序、一个例程或一个任务
门的类型
- 调用门:不同特权级之间的过程调用
- 中断门/陷阱门:作为中断处理过程使用的
- 任务门:对应着单个任务,用来执行任务切换
调用门
以上是调用门描述符的格式
以下是对各个部分的解析:
- 调用门描述符包含了 目标代码段的选择子,这方便了对代码段描述符 有效性、段界限和特权级的检查
- 目标例程在代码段内的 偏移量 是直接指定的,所以当我们通过调用门选择子跳转时,在指令中给出的偏移量会被忽略
- TYPE字段用于标识门的类型,1100 表示调用门
调用门之所以叫做门不是随便取的,我们可以看到上图,这其实就是使用调用门的检查,只有在 门内 的特权级才能够使用这个门
具体来说只有满足以下两个条件才能使用调用门:即 数值 上要求
- CPL<=调用门描述符的DPL
RPL<=调用门描述符的DPL - CPL>=目标代码段描述符的DPL
有了这个理性的认识,在结合上图,我们就能够知道,调用门的DPL是特权级检查的下限,目标代码段的DPL是特权级检查的上限,一个上限一个下限,是不是很像一个门呢
特权级检查
特权级的检查发生在段选择子传送到段寄存器时,代码段的检查是CS,数据段的检查是DS、ES、FS、GS,栈段的检查是SS
代码段的特权级检查
-
一般来说,控制转移只允许发生在 两个特权级相同的代码段之间,即
- CPL=目标代码段的DPL
- RPL=目标代码段的DPL
-
低特权级转移到高特权级:主要是为了让 特权级低的用户程序可以调用特权级高的操作系统例程
-
将高特权级代码段定义为 依从的 : 两个条件
- 代码段描述符的 TYPE字段有C位 ,C=0表示代码段只能供同特权级的程序使用,C=1表示代码段为依从代码段
- 当前特权级CPL和RPL必须低于或等于目标代码段描述符的DPL,即 数值 上要求
- CPL>=目标代码段的DPL
- RPL>=目标代码段的DPL
需要注意的是,依从的代码段是在调用程序的特权级运行,简单来说就是当控制转移时,CPL不变,被调用过程的特权级依从于调用者的特权级
-
使用门(门描述符):特指调用门
- jmp far 调用门的选择子: 将控制通过门转移到比当前特权级高的代码段,但 不改变当前特权级别
- call far 调用门的选择子: 当前特权级会提升到目标代码段的特权级别
-
-
高特权级到低特权级:
除了从高特权级别的例程返回外,不允许从特权级高的代码段将控制转移到特权级低的代码段,这是因为特权级越高,可靠性越高,操作系统不会引用可靠性比自己低的代码
数据段的特权级检查
- 高特权级别的程序可以访问低特权级别的数据段, 反之不行,即 数值 上要求
- CPL<=目标数据段的DPL
- RPL<=目标数据段的DPL
栈段的特权级检查
- 任何时候,栈段的特权级别必须和当前特权级CPL相同,即 数值 上要求
- CPL=目标栈段的DPL
- RPL=目标栈段的DPL
栈切换
当一个用户程序进行特权级切换后,使用的栈也要进行切换
比如说特权级位3的用户程序通过调用门进入到特权级0,那么对应的栈也要跟着切换,从3特权级栈切换到0特权级栈
总之,栈段的特权级必须同当前特权级保持一致,这是为了 防止因栈空间不足而产生不可预料的问题,同时也是为了防止栈数据的交叉引用
额外定义的栈
为了切换栈,每个任务除了自己固有的栈之外,还必须额外定义几套栈
例如,3特权级需要 额外 定义 0/1/2 特权级的栈,2特权级 额外 需要定义 0/1 特权级的栈......
这些额外的栈是由 操作系统加载程序时自动创建的,描述符位于LDT中,同时还必须在任务的TSS中登记
栈切换是处理器固件根据TSS信息自动完成的
参数复制
调用者通常会将例程所需要的参数压入栈中,调用门描述符有一个参数个数字段用来记录所需参数个数
栈切换时,处理器会自动替换 SS和ESP的内容,使它指向新栈,同时还会将旧栈的参数赋值到新栈中
内核程序的初始化
MBR加载内核程序并初始化GDT
调用门的安装
这里的目的就是将内核数据段中的SALT表(存储对外公开的例程)的入口点替换为 特权级为3的调用门选择子 ,而不是之前写入的 公共例程段+段偏移量
然后将调用门描述符安装进 GDT中
用户程序就可以通过例程的名称在C-SALT(内核数据段中的SALT表)找到对应的选择子,然后通过访问GDT/LDT找到对应的描述符,最后就能够使用例程了
;以下开始安装为整个系统服务的调用门。特权级之间的控制转移必须使用门
mov edi,salt ;C-SALT表的起始位置
mov ecx,salt_items ;C-SALT表的条目数量
.b3:
push ecx
mov eax,[edi+256] ;该条目入口点的32位偏移地址
mov bx,[edi+260] ;该条目入口点的段选择子
mov cx,1_11_0_1100_000_00000B ;特权级3的调用门(3以上的特权级才
;允许访问),0个参数(因为用寄存器
;传递参数,而没有用栈)
call sys_routine_seg_sel:make_gate_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+260],cx ;将返回的门描述符选择子回填
add edi,salt_item_len ;指向下一个C-SALT条目
pop ecx
loop .b3
安装调用门后的GDT布局
任务控制块
任务控制块,即 Task Control Block 简称 TCB
TCB是内核为每一个任务创建的一个内存区域,用来记录任务的信息和状态
你可能会疑惑,不是有TSS了吗?TSS是任务切换时处理器要求的,而TCB是内核创建任务时需要的。两者面向的对象和使用的目的并不相同
如下是TCB的结构:
TCB链
内核为了追踪所有任务,也就是所有TCB,使用的是链表这种数据结构,也就是TCB链,我们能够观察到TCB结构的第一个双字存储的是 下一个TCB基地址,这其实就是指针,该字段为0表示后面没有任务了
我们要先创建一个双字大小的 tcb_chain 用来表示TCB链的头部,使它指向第一个任务,如果为0表示没有任务
这里会有一个小问题,那就是链首指针是在内核数据段中声明并初始化的,所以只能知道它的偏移量(即让ds指向内核数据段,通过 [ds:tcb_chain] 的方式访问);而链上的每个TCB是动态分配的,只能通过线性地址访问(即让es指向4G内存段,通过 [es:线性地址] 的方式访问)
TCB链上追加TCB的流程
访问栈中过程参数
之所以叫过程参数,是因为我们调用例程的时候,通常是将参数压入栈中存储,而不是通过寄存器传递
隐式访问
就是通过push、pop、call、ret等指令,这需要用到ESP寄存器,比较固定,要遵守先进后出的机制
显示访问
这种方式是将栈看成一般的数据段,直接访问其中的任何内容,这要用到栈基址寄存器EBP,使用EBP进行寻址时,默认使用段寄存器SS,不需要加上段超越前缀
如上图右侧所示,我们可以使用EBP访问栈中的任何地方的数据,至于为什么不使用ESP,可能是因为,ESP必须指向栈顶元素吧,显示的使用ESP可能不安全
pushad指令
pushad指令是压入8个双字,其实就是8个通用寄存器的内容,分别是EAX/EBX/ECX/EDX/ESP/EBP/EDI/ESI 这8个
popad指令
这就是与pushad对应的弹出指令了
加载用户程序并创建任务
当用户程序被读入内存,并处于运行或者等待运行的状态时,就视为一个任务,任务通过描述符来引用自己的代码段和数据段(包括栈)
准备工作
- 分配一块内存作为LDT
- 分配内存并加载用户程序
- TCB中登记
- LDT的界限和基地址
- 需要注意的是,初始的时候,LDT的总字节数是0,那么界限就是总字节数减1,也就是 0xFFFF
- 用户程序加载基地址
- LDT的界限和基地址
创建LDT(用户程序的段描述符)
- 建立头部段描述符,并将其安装到LDT中,并登记 头部选择子 到 TCB 和回填到 头部内
- 建立代码段描述符,并将其安装到LDT中,并回填 代码段选择子 到 头部内
- 建立数据段描述符,并将其安装到LDT中,并回填 数据段选择子 到 头部内
- 建立堆栈段描述符,并将其安装到LDT中,并回填 堆栈段选择子 到 头部内
重定位U-SALT表(用户程序的SALT表)
和之前将SALT表中的例程名字符串替换为 段选择子和段内偏移 一样,只不过这里是将其替换为 调用门选择子和段内偏移,需要注意的是调用门选择子的RPL字段应该为3
创建0/1/2特权级的栈
- 分配内存作为栈空间
- 创建栈描述符---栈段、4KB粒度、DPL
- 安装到LDT中
- TCB登记
- 以4KB为单位的堆栈长度
- 堆栈使用高端地址作为基地址
- 堆栈选择子
- 堆栈初始ESP(一般为0)
mov esi,[ebp+11*4] ;从堆栈中取得TCB的基地址
;创建0特权级堆栈
mov ecx,4096
mov eax,ecx ;为生成堆栈高端地址做准备
mov [es:esi+0x1a],ecx
shr dword [es:esi+0x1a],12 ;登记0特权级堆栈尺寸到TCB
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;堆栈必须使用高端地址为基地址
mov [es:esi+0x1e],eax ;登记0特权级堆栈基地址到TCB
mov ebx,0xffffe ;段长度(界限)
mov ecx,0x00c09600 ;4KB粒度,读写,特权级0
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB的基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0000 ;设置选择子的特权级为0
mov [es:esi+0x22],cx ;登记0特权级堆栈选择子到TCB
mov dword [es:esi+0x24],0 ;登记0特权级堆栈初始ESP到TCB
安装LDT描述符到GDT中
以上是LDT描述符的结构,基本上和GDT的一样
S位为0表示是系统的段描述符或者门描述符,S=1表示是普通的段描述符
TYPE字段为 0010 表示这是一个LDT描述符
- 安装LDT描述符到GDT中
- TCB登记LDT选择子
;在GDT中登记LDT描述符
mov eax,[es:esi+0x0c] ;LDT的起始线性地址
movzx ebx,word [es:esi+0x0a] ;LDT段界限
mov ecx,0x00408200 ;LDT描述符,特权级0
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [es:esi+0x10],cx ;登记LDT选择子到TCB中
任务状态段TSS的格式
大多数看TSS的结构图就能知道是什么意思,这里说几个比较模糊的
-
CR3和分页机制有关,没有使用分页,可以为0
-
T位用于软件调试
-
I/O映射基地址用于决定当前任务是否可以访问特定的硬件端口
I/O许可位串
我们知道标志寄存器中的IOPL位决定了当前任务的I/O特权级别,当CPL级别不低于IOPL时,所有I/O操作都允许,当CPL级别低于IOPL时,处理器规定大部分不允许,哪个端口允许就要 找到当前任务的TSS,并检索I/O许可串,这也就是I/O映射基地址所指向的地方啦
I/O许可串是一个 比特序列,最多雨荨65536比特,也就是8KB,第1个比特表示0号端口,第2个比特表示1号端口......
并且0表示允许访问,1表示禁止访问
I/O映射基地址位于TSS内偏移为102的 字单元,保存着I/O许可串(I/O许可位映射区)的起始地址,如下图所示
TSS的界限值包括了I/O许可位映射区在内,如果TSS中的I/O映射基地址所在的字单元的内容大于或者等于TSS的段界限,就表明没有I/O许可位串
I/O端口按字节编址
I/O端口按字节编址的意思是 每个端口仅被设计用来读写一个字节的数据,当以字或双字访问时,实际上是访问连续的2个或4个端口,这也意味着 会检查I/O许可位串的连续2个或4个位
所以这就可能导致越界问题,当我们按字访问最后一个字节时,两字节的读操作会越界,所以处理器要求I/O许可位映射区最后额外加了一个字节 0xFF(位于TSS界限内)
创建TSS并安装到GDT中
-
创建TSS
- 分配内存作为TSS
- TCB登记TSS的基地址和界限值(界限值必须至少是103字节)
;创建用户程序的TSS mov ecx,104 ;tss的基本尺寸 mov [es:esi+0x12],cx dec word [es:esi+0x12] ;登记TSS界限值到TCB call sys_routine_seg_sel:allocate_memory mov [es:esi+0x14],ecx ;登记TSS基地址到TCB
- TSS登记表格内容(暂存在TCB中)
- 指向前一个任务的指针填写为0,表示是唯一的任务
- 0/1/2特权级堆栈的选择子和初始ESP
- 当前任务的LDT选择子
- 填写I/O许可位映射区的地址
;登记基本的TSS表格内容 mov word [es:ecx+0],0 ;反向链=0 mov edx,[es:esi+0x24] ;登记0特权级堆栈初始ESP mov [es:ecx+4],edx ;到TSS中 mov dx,[es:esi+0x22] ;登记0特权级堆栈段选择子 mov [es:ecx+8],dx ;到TSS中 mov edx,[es:esi+0x32] ;登记1特权级堆栈初始ESP mov [es:ecx+12],edx ;到TSS中 mov dx,[es:esi+0x30] ;登记1特权级堆栈段选择子 mov [es:ecx+16],dx ;到TSS中 mov edx,[es:esi+0x40] ;登记2特权级堆栈初始ESP mov [es:ecx+20],edx ;到TSS中 mov dx,[es:esi+0x3e] ;登记2特权级堆栈段选择子 mov [es:ecx+24],dx ;到TSS中 mov dx,[es:esi+0x10] ;登记任务的LDT选择子 mov [es:ecx+96],dx ;到TSS中 mov dx,[es:esi+0x12] ;登记任务的I/O位图偏移 mov [es:ecx+102],dx ;到TSS中 mov word [es:ecx+100],0 ;T=0
-
安装TSS描述符到GDT中
;在GDT中登记TSS描述符 mov eax,[es:esi+0x14] ;TSS的起始线性地址 movzx ebx,word [es:esi+0x12] ;段长度(界限) mov ecx,0x00408900 ;TSS描述符,特权级0 call sys_routine_seg_sel:make_seg_descriptor call sys_routine_seg_sel:set_up_gdt_descriptor mov [es:esi+0x18],cx ;登记TSS选择子到TCB
以下是TSS描述符的格式,
TYPE字段中的B位表示忙位,任务刚创建时为0,表示任务不忙;当任务开始执行时或者处于挂起状态,由处理器置1
TSS的DPL和RPL均为0
带参数的返回指令
在过程返回之后,需要将调用过程前压入的参数弹出,这时候,我们可以使用带参数的返回指令可以一步达成目的
ret/retf 16位立即数
表示返回到调用者前,从栈中弹出多少字节的数据
用户程序的执行
- 加载用户程序并创建任务,这一步主要使用到例程 load_relocate_program,代码如下
;创建任务控制块。这不是处理器的要求,而是我们自己为了方便而设立的
mov ecx,0x46
call sys_routine_seg_sel:allocate_memory
call append_to_tcb_link ;将任务控制块追加到TCB链表
push dword 50 ;用户程序位于逻辑50扇区
push ecx ;压入任务控制块起始线性地址
call load_relocate_program
- 让设置生效,主要是任务的TSS和LDT
ltr [ecx+0x18] ;加载任务状态段
lldt [ecx+0x10] ;加载LDT
- 将控制权交给用户程序,也就是执行用户程序
mov eax,mem_0_4_gb_seg_sel
mov ds,eax
;以下假装是从调用门返回。摹仿处理器压入返回参数
push dword [0x08] ;调用前的堆栈段选择子
push dword 0 ;调用前的esp
push dword [0x14] ;调用前的代码段选择子
push dword [0x10] ;调用前的eip
retf
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构