从实模式到保护模式笔记10任务及特权级保护
从实模式到保护模式笔记10任务及特权级保护
0x00 代码
以下是本章内核部分代码纯手写,较长,可以下拉右侧导航条跳过。
;以下是内核段选择子的常量,由内核引导程序和内核协商
core_code_seg_sel equ 0x38;内核代码段选择子
core_data_seg_sel equ 0x30;内核数据段选择子
sys_routine_seg_sel equ 0x28;系统公共例程的选择子
video_ram_seg_sel equ 0x20;显存区的选择子
core_stack_seg_sel equ 0x18;内核堆栈选择子
mem_0_4_gb_seg_sel equ 0x08;整个0-4GB内存的段选择子
;===========================================================
;以下是系统的头部段,提供用于加载核心程序的信息
core_length dd core_end ;内核总长度
sysy_routine_seg dd section.sys_routine.start
core_data_seg dd section.core_data.start
core_code_seg dd section.core_code.start
core_entry dd start
dw core_code_seg_sel
;===========================================================
[bits 32]
;===========================================================
;以下是系统公共例程代码段
SECTION sys_routine vstart=0
;-----------------------------------------------------------
;字符串显示例程 显示0终止的字符串并移动光标 输入ds:ebx串地址
put_string:
push ecx
.getc:
mov cl,[ebx]
or cl,cl
jz .exit;为0就终止
call put_char;不为0就显示
inc ebx
jmp .getc
.exit:
pop ecx
retf ;段间返回 所以只能远距离段间调用
;-----------------------------------------------------------
;光标处显示一个字符,并推进光标 输入cl为字符ascii码
put_char:
pushad;压入所有通用寄存器
;以下取光标位置
mov dx,0x3d4
mov al,0x0e;光标位置是16位,往3d4写0x0e 拿到高8位 写0x0f是低8位
out dx,al
inc dx;0x3d5
in al,dx;
mov ah,al
mov dx,0x3d4
mov al,0x0f
out dx,al
inc dx
in al,dx
mov bx,ax;bx即光标
cmp cl,0x0d;\r\n recycle 回车 newline 换行 先判断回车
jnz .put_0a
mov ax,bx
mov bx,80
div bl
mul bl;先除80再用商乘80回到开头位置
mov bx,ax
jmp .set_cursor;设置光标
.put_0a:
cmp cl,0x0a
jnz .put_other
add bx,80
jmp .roll_screen
.put_other:
push es
mov eax,video_ram_seg_sel
mov es,eax
shl bx,1;光标 *2 得到显存位置
mov [es:bx],cl
pop es
shr bx,1
inc bx
.roll_screen:
cmp bx,2000;先显示字符或者换行,再看看是不是超出了屏幕滚屏
jl .set_cursor
push ds
push es
mov eax,video_ram_seg_sel
mov ds,eax
mov es,eax
cld
mov esi,0xa0;从0xa0 到0x00
mov edi,0x00
mov ecx,1920;24行
rep movsw ;要移动24*80*2个字节 所以是1920个movsw 书中写的是movsd
;我认为是错的
mov bx,3840;显存地址到最后一行开头
mov ecx,80
.cls:
mov word [es:bx],0x0720
add bx,2
loop .cls;最后一行清除
pop es
pop ds
mov bx,1920
.set_cursor:
mov dx,0x3d4
mov al,0x0e
out dx,al
inc dx
mov al,bh;写入新光标高8位
out dx,al
dec dx
mov al,0x0f
out dx,al
inc dx
mov al,bl
out dx,al;写入新光标低8位
popad
ret
;-----------------------------------------------------------
read_hard_disk_0: ;从硬盘读取一个逻辑扇区
;EAX=逻辑扇区号
;DS:EBX=目标缓冲区地址
;返回:EBX=EBX+512
push eax
push ecx
push edx
push eax
mov dx,0x1f2
mov al,1
out dx,al ;读取的扇区数
inc dx ;0x1f3
pop eax
out dx,al ;LBA地址7~0
inc dx ;0x1f4
mov cl,8
shr eax,cl
out dx,al ;LBA地址15~8
inc dx ;0x1f5
shr eax,cl
out dx,al ;LBA地址23~16
inc dx ;0x1f6
shr eax,cl
or al,0xe0 ;第一硬盘 LBA地址27~24
out dx,al
inc dx ;0x1f7
mov al,0x20 ;读命令
out dx,al
.waits:
in al,dx
and al,0x88
cmp al,0x08
jnz .waits ;不忙,且硬盘已准备好数据传输
mov ecx,256 ;总共要读取的字数
mov dx,0x1f0
.readw:
in ax,dx
mov [ebx],ax
add ebx,2
loop .readw
pop edx
pop ecx
pop eax
retf ;段间返回
;-----------------------------------------------------------
;以16进制形式显示一个双字 输入 edx 待显示的双字
put_hex_dword:
pushad
push ds
mov ax,core_data_seg_sel
mov ds,ax
mov ebx,bin_hex;指向内核数据段的转换表
mov ecx,8;8位16进制数
.xlt:
rol edx,4;循环左移 ror循环右移
mov eax,edx
and eax,0x0000000f;取最低4位
xlat ;查表
push ecx
mov cl,al
call put_char
pop ecx
loop .xlt
pop ds
popad
retf
;-----------------------------------------------------------
;动态分配内存 输入 ecx 希望分配的字节数 输出ecx 起始线性地址
allocate_memory:
push ds
push eax
push ebx
mov eax,core_data_seg_sel
mov ds,eax
mov eax,[ram_alloc]
add eax,ecx ;下一次分配的起始地址
;可以检测可用内存数量的指令(即新地址是否越界,越界说明本次分配内存不足)
mov ecx,[ram_alloc];返回起始地址
mov ebx,eax
and ebx,0xfffffffc;最低c 12 1100即最低2位置0 强制4字节对齐
add ebx,4
test eax,0x00000003;看看eax是不是本来就4字节对齐0011
cmovnz eax,ebx ;如果上面结果不是0,就说明没对齐,就用对齐后的ebx
mov [ram_alloc],eax ;写入新地址
;用cmovnz 主要是为了减少jmp的使用 类似还有cmovz
pop ebx
pop eax
pop ds
retf
;-----------------------------------------------------------
;GDT安装新的描述符,输入 edx:eax 描述符64位 返回cx描述符选择子
set_up_gdt_descriptor:
push eax
push ebx
push edx
push ds
push es
mov ebx,core_data_seg_sel
mov ds,ebx
sgdt [pgdt]
mov ebx,mem_0_4_gb_seg_sel
mov es,ebx
movzx ebx,word [pgdt];mov with zero extension 扩展传送,不加ptr
;将较小位数的源操作数传送到较大位数的目的操作数 高位以0填充
inc bx;GDT的长度,同时这刚好等于新描述符的选择子
mov cx,bx
add ebx,[pgdt+2];新描述符的线性地址
mov [es:ebx],eax
mov [es:ebx+4],edx
add word [pgdt],8;界限+8
lgdt [pgdt];重新装载gdt
pop es
pop ds
pop edx
pop ebx
pop eax
retf
;-----------------------------------------------------------
;构造一个段描述符 输入 eax 线性基址 ebx 段界限 ecx 属性 返回
;edx:eax为描述符
make_seg_descriptor:
mov edx,eax
shl eax,16
or ax,bx ;描述符前32位(EAX)构造完毕
and edx,0xffff0000 ;清除基地址中无关的位
rol edx,8
bswap edx ;装配基址的31~24和23~16 (80486+)
xor bx,bx
or edx,ebx ;装配段界限的高4位
or edx,ecx ;装配属性
retf
;-----------------------------------------------------------
;构造一个门描述符(调用门等都行) 输入 eax 门代码所在的段内偏移地址 bx门代码所在段的段选择子 cx 段类型即属性 返回 edx:eax描述符
make_gate_descriptor:
push ebx
push ecx
mov edx,eax
and edx,0xffff0000;得到偏移地址的高16位
or dx,cx;将属性装入 edx 得到描述符的高32位(dx为0,所以edx的低16为是cx了)
and eax,0x000ffff;偏移地址低16位
shl ebx,16
or eax,ebx;组装段选择子
;即整体结构 高32位 31-16:偏移地址高16位 15-0:属性
; 低32位 31-15:段选择子 15-0: 偏移地址低16位
pop ecx
pop ebx
retf
;-----------------------------------------------------------
sys_routine_end:
;===========================================================
;以下是内核数据段
SECTION core_data vstart=0
pgdt dw 0
dd 0
ram_alloc dd 0x00100000;下次分配内存的起始地址
;符号地址检索表(sign-address lookup table)
salt:
salt_1 db '@PrintString'
times 256-($-salt_1) db 0
dd put_string
dw sys_routine_seg_sel
salt_2 db '@ReadDiskData'
times 256-($-salt_2) db 0
dd read_hard_disk_0
dw sys_routine_seg_sel
salt_3 db '@PrintDwordAsHexString'
times 256-($-salt_3) db 0
dd put_hex_dword
dw sys_routine_seg_sel
salt_4 db '@TerminateProgram'
times 256-($-salt_4) db 0
dd return_point
dw core_code_seg_sel
salt_item_len equ $-salt_4 ;宏定义常数
salt_items equ ($-salt)/salt_item_len
message_1 db ' If you seen this message,that means we '
db 'are now in protect mode,and the system '
db 'core is loaded,and the video display '
db 'routine works perfectly.',0x0d,0x0a,0
message_2 db ' System wide CALL-GATE mounted.',0x0d,0x0a,0
message_3 db 0x0d,0x0a,' Loading user program...',0
do_status db 'Done.',0x0d,0x0a,0
message_6 db 0x0d,0x0a,0x0d,0x0a,0x0d,0x0a
db ' User program terminated,control returned.',0
bin_hex db '0123456789ABCDEF'
;put_hex_dword子过程用的查找表
core_buf times 2048 db 0;内核缓冲区,用于加载程序时暂时保存第一个扇区
esp_pointer dd 0 ;内核用来临时保存自己的栈指针,因为切换到用户程序
;时要切换到用户程序的栈,而切换来又要恢复,所以用这个空间保存栈指针
cpu_brnd0 db 0x0d,0x0a,' ',0
cpu_brand times 52 db 0
cpu_brnd1 db 0x0d,0x0a,0x0d,0x0a,0
;任务控制块链
tcb_chain dd 0
core_data_end:
;===========================================================
;以下是内核代码段
SECTION core_code align=16 vstart=0
;-----------------------------------------------------------
;ldt里安装新的描述符 输入 edx:eax描述符 ebx为tcb基地址
;输出 cx描述符选择子
fill_descriptor_in_ldt:
push eax
push edx
push edi
push ds
mov ecx,mem_0_4_gb_seg_sel
mov ds,ecx
mov edi,[ebx+0x0c];获得ldt基地址
xor ecx,ecx
mov cx,[ebx+0x0a];获得LDT界限
inc cx;ldt总长度
mov [edi+ecx+0x00],eax
mov [edi+ecx+0x04],edx;安装描述符
add cx,8
dec cx;新界限
mov [ebx+0x0a],cx; 更新界限
mov ax,cx
xor dx,dx
mov cx,8
div cx
mov cx,ax
shl cx,3
or cx,0000_0000_0000_0100B;使TI位1 指向LDT 最后使RPL=00
pop ds
pop edi
pop edx
pop eax
ret
;-----------------------------------------------------------
;加载并重定位用户程序 输入 push逻辑扇区号 push 任务控制块基地址
;输出 无
load_relocate_program:
pushad;push8个通用寄存器
push ds
push es
mov ebp,esp;为访问堆栈参数做准备(esp/sp不能直接访问)
mov ecx,mem_0_4_gb_seg_sel
mov es,ecx
mov esi,[ebp+11*4];取得传入参数 tcb基地址
;以下申请创建LDT所需的内存
mov ecx,160 ;允许安装20个描述符的空间
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x0c],ecx ;登记LDT基地址到TCB中
mov word [es:esi+0x0a],0xffff;登记LDT初始界限到TCB中
;为什么是0xffff,因为段界限总是表长-1,初始时表长为0
;以下开始加载用户程序
mov eax,core_data_seg_sel
mov ds,eax
;读用户程序第一个扇区到缓存区
mov eax,[ebp+12*4];从堆栈取得传入参数 起始逻辑扇区号
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0
;以下判断整个程序有多大
mov eax,[core_buf];程序大小
mov ebx,eax
and ebx,0xfffffe00
add ebx,512 ;给eax的备份ebx 512字节对齐了
test eax,0x000001ff;看eax是不是512字节对齐
cmovnz eax,ebx ;不是就用对齐后的ebx
mov ecx,eax
call sys_routine_seg_sel:allocate_memory
mov [es:esi+0x06],ecx ;将程序加载的基地址写入TCB
mov ebx,ecx;eax是总大小 ebx是地址
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax ;eax是总扇区数
mov eax,mem_0_4_gb_seg_sel
mov ds,eax
mov eax,[ebp+12*4];起始扇区号
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax;ebx不用变,read_hard_disk_0会自动+512
loop .b1
mov edi,[es:esi+0x06];从TCB读用户程序基地址
;建立用户程序header段描述符
mov eax,edi
mov ebx,[edi+0x04];段长度
dec ebx;段界限
mov ecx,0x0040f200 ;字节粒度 数据段 特权级3
call sys_routine_seg_sel:make_seg_descriptor
;安装描述符到LDT
mov ebx,esi ;esi为tcb基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011B;设置选择子特权级为3
mov [es:esi+0x44],cx ;将用户程序header段选择子写道TCB
mov [edi+0x04],cx ;也写道程序头部
;程序代码段描述符
mov eax,edi
add eax,[edi+0x14];代码起始线性地址
mov ebx,[edi+0x18];代码段长度
dec ebx ;段界限
mov ecx,0x0040f800 ;字节粒度 代码段 特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi;TCB基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011b ;设置选择子的特权级为3
mov [edi+0x14],cx;登记代码段选择子到头部
;数据段
mov eax,edi
add eax,[edi+0x1c];数据段线性地址
mov ebx,[edi+0x20];段长度
dec ebx
mov ecx,0x0040f200 ;字节粒度 数据段 特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi;tcb 基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011b ;设置选择子特权级3
mov [edi+0x1c],cx;数据段选择子写回头部
;栈段
mov ecx,[edi+0x0c];栈段大小,4KB粒度
mov ebx,0x000fffff
sub ebx,ecx ;真正的段界限由0x000fffff-x得到
mov eax,4096
mul ecx
mov ecx,eax ;申请分配的带下
call sys_routine_seg_sel:allocate_memory
add eax,ecx; 堆栈 高端 的物理地址(eax是大小 ecx是起始地址)
mov ecx,0x00c0f600;4KB粒度,向下扩展数据段 特权级3
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;TCB基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0011b;特权级3
mov [edi+0x08],cx;写回头部
;以下重定位salt
mov eax,mem_0_4_gb_seg_sel
mov es,eax;和上一章不同,这里时程序的头部段描述符已经安装了,但是
;还没生效,所以没有通过程序header段描述符访问,而是用的4gb数据段
mov eax,core_data_seg_sel
mov ds,eax
cld
mov ecx,[es:edi+0x24];u-salt条目数 外循环次数
add edi,0x28;将edi由程序的起始有效地址(相对于4gb数据段基地址)指向u-salt的起始有效地址
.b2:
push ecx
push edi
mov ecx,salt_items;内循环次数
mov esi,salt
.b3:
push edi
push esi
push ecx;将三个寄存器保存起来,重复使用
mov ecx,64
repe cmpsd;比较256字节 64*4
jnz .b4
mov eax,[esi]
mov [es:edi-256],eax ;将找到的例程在段中的偏移地址写入u-salt
;对应项的字符串里,其实偏移地址不用写回,因为用的是门描述符选择子
;会忽略偏移地址
mov ax,[esi+4];门描述符选择子
or ax,00000000_0000_0011b ;以用户程序自己的特权级使用调用门
mov [es:edi-252],ax;回填调用门选择子
.b4:
pop ecx
pop esi
add esi,salt_item_len
pop edi
loop .b3;小循环
pop edi
add edi,256
pop ecx
loop .b2;大循环
mov esi,[ebp+11*4];从堆栈取得TCB基地址
;创建0特权级堆栈
mov ecx,4096
mov eax,ecx
mov [es:esi+0x1a],ecx
shr dword [es:esi+0x1a],12
call sys_routine_seg_sel:allocate_memory
add eax,ecx;使用高端地址作为基地址(因为分配的地址是较低的那一侧,+长
;度,即使高端地址,用高端地址是因为堆栈向下扩展)
mov [es:esi+0x1e],eax
mov ebx,0xffffe;段界限
mov ecx,0x00c09600 ;4kb粒度 向下扩展 特权级0
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi
call fill_descriptor_in_ldt
;or cx,00000000_00000000;设置选择子特权级为0(原本就为0)
mov [es:esi+0x22],cx;登记0特权级堆栈选择子到TCB
mov dword [es:esi+0x24],0;登记0特权级堆栈初始esp到TCB
;创建1特权级堆栈
mov ecx,4096
mov eax,ecx
mov [es:esi+0x28],ecx
shr [es:esi+0x28],12
call sys_routine_seg_sel:allocate_memory
add eax,ecx
mov [es:esi+0x2c],eax
mov ebx,0xffffe;段界限
mov ecx,0x00c0b600 ;4kb粒度 向下扩展数据段 特权级1
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi ;tcb基地址
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0001b;设置选择子特权级1
mov [es:esi+0x30],cx
mov dword [es:esi+0x32],0
;创建2特权级堆栈
mov ecx,4096
mov eax,ecx
mov [es:esi+0x36],ecx
shr [es:esi+0x36],12
call sys_routine_seg_sel:allocate_memory
add eax,ecx
mov [es:esi+0x3a],ecx
mov ebx,0xffffe
mov ecx,0x00c0d600
call sys_routine_seg_sel:make_seg_descriptor
mov ebx,esi
call fill_descriptor_in_ldt
or cx,0000_0000_0000_0010;特权级2
mov [es:esi+0x3e],cx;特权级2选择子登记到tcb
mov dword [es:esi+0x40],0;初始化esp登记到tcb
;以下在GDT中登记ldt描述符
mov eax,[es:esi+0x0c];ldt起始线性地址
movzx ebx,word [es:esi+0x0a];段界限movzx mov with zero extionsion
mov ecx,0x00408200 ;数据段 特权级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
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表格内容
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
mov edx,[es:esi+0x32];获得特权级1堆栈初始esp
mov [es:ecx+12],edx
mov dx,[es:esi+0x30];获得特权级1堆栈段选择子
mov [es:ecx+16],dx
mov edx,[es:esi+0x40];获得特权级2堆栈初始esp
mov [es:ecx+20],edx
mov dx,[es:esi+0x3e] ;获得特权级2堆栈段选择子
mov [es:ecx+24],dx
mov dx,[es:esi+0x10];获取ldt在gdt里的选择子
mov [es:ecx+96],dx
mov dx,[es:esi+0x12];获得i/o位图偏移
mov [es:ecx+102],dx
mov word [es:ecx+100],0 ;T=0
;以下在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在gdt里的选择子到tcb
pop es
pop ds
popad
ret 8;ret 8 是先pop eip 再丢弃调用前压入的两个参数(也就是丢弃8字节)
;-----------------------------------------------------------
;在TCB链上追加任务控制块
;参数 ecx 即 tcb线性基地址
append_to_tcb_link:
push eax
push edx
push ds;对于段寄存器 push进去的确实是段选择子,不过处理器还是push了4字节
push es
mov eax,core_data_seg_sel
mov ds,eax
mov eax,mem_0_4_gb_seg_sel
mov es,eax
mov dword [es:ecx+0x00],0;当前tcb指针域清零,表示这是最后一个tcb
mov eax,[tcb_chain];tcb表头指针
or eax,eax ;链表为空?
jz .notcb
.searc:
mov edx,eax
mov eax,[es:edx+0x00]
or eax,eax
jnz .searc
mov [es:edx+0x00],ecx
jmp .retpc
.notcb:
mov [tcb_chain],ecx ;如果为空表,直接令表头指针指向tcb
.retpc:
pop es
pop ds
pop edx
pop eax
ret
;-----------------------------------------------------------
;内核入口
start:
mov ecx,core_data_seg_sel
mov ds,ecx
mov ebx,message_1
call sys_routine_seg_sel:put_string
;显示处理器品牌信息
mov eax,0x80000002
cpuid
mov [cpu_brand + 0x00],eax
mov [cpu_brand + 0x04],ebx
mov [cpu_brand + 0x08],ecx
mov [cpu_brand + 0x0c],edx
mov eax,0x80000003
cpuid
mov [cpu_brand + 0x10],eax
mov [cpu_brand + 0x14],ebx
mov [cpu_brand + 0x18],ecx
mov [cpu_brand + 0x1c],edx
mov eax,0x80000004
cpuid
mov [cpu_brand + 0x20],eax
mov [cpu_brand + 0x24],ebx
mov [cpu_brand + 0x28],ecx
mov [cpu_brand + 0x2c],edx
mov ebx,cpu_brnd0 ;显示处理器品牌信息
call sys_routine_seg_sel:put_string
mov ebx,cpu_brand
call sys_routine_seg_sel:put_string
mov ebx,cpu_brnd1
call sys_routine_seg_sel:put_string
;以下开始安装为整个系统服务的调用门。特权级之间的控制转移必须
;使用门
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的调用门(0 1 2 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
;对门进行测试
mov ebx,message_2
call far [salt_1+256]
mov ebx,message_3
call sys_routine_seg_sel:put_string;在内核中调用不需要通过门也可以
;创建任务控制块
mov ecx,0x46
call sys_routine_seg_sel:allocate_memory
call append_to_tcb_link ;将任务控制块追加到tcb链表
push dword 50
push ecx
call load_relocate_program
mov ebx,do_status
call sys_routine_seg_sel:put_string
mov eax,mem_0_4_gb_seg_sel
mov ds,eax
ltr [ecx+0x18];加载任务状态段
lldt [ecx+0x10];加载ldt
mov eax,[ecx+0x44]
mov ds,eax;切换到用户程序头部段
;以下假装是从调用门返回。模仿处理器压入返回参数
push dword [0x08];调用前的堆栈选择子
push dword 0 ;调用前的esp
push dword [0x14];调用前的代码段选择子
push dword [0x10];调用前的eip
retf;通过这个跳转到用户程序
;-----------------------------------------------------------
;应用程序返回点
return_point:
mov eax,core_data_seg_sel
mov ds,eax
mov ebx,message_6
call sys_routine_seg_sel:put_string
hlt
core_code_end:
;==========================================================
SECTION core_trail
;----------------------------------------------------------
core_end:
以上代码编译通过,运行正确(内核引导程序和用户程序仍是上一章的)
0x01 代码详解
任务和特权级保护引入
在保护模式下,通过将内存分为大小不等的段,并用描述符对每个段的用途、类型和长度进行指定,就可以在程序运行时由处理器硬件施加访问保护。当程序试图去写一个可执行的代码段时,或者访问超过段界限时,处理器会引发异常中断,阻止这种企图。
段保护是处理器提供的基本保护功能,但是远远不够,因为程序仍然可以通过SGDT等指令获得GDT位置,修改或者添加段描述符来访问内核的私有数据,并且多任务系统中,这一问题尤为重要。
任务、任务的LDT和TSS
任务:程序是记录在载体(通常指硬盘)上的指令和数据,而其被载入内存并执行的一个副本,即是任务,也就是一个程序可以对应多个任务。
LDT:为了有效的在人物之间实施隔离,处理器建议每个任务都应该有自己的描述符表,即LDT,每个任务的私有段放在LDT里而不是GDT里,这样每个任务都被设计成只能访问自己的LDT,即可保证任务之间的隔离和安全。不同于GDT,LDT 0#描述符也是可用的。
GDT是全局性的,只有一个,用GDTR追踪其位置和界限。而LDT每个任务一个,同样为了访问追踪这些LDT,处理器使用了LDTR(local descriptor table register)。在一个多任务系统中,多个任务轮流执行,正在执行中的那个任务,称为当前任务(current task),因为LDTR只有一个,所以LDTR总是执行当前任务的LDT,每当任务切换时,LDTR的内容被更新,指向新任务的LDT。LDT同样是低16位表示界限,高32位为线性基地址。
在11章中介绍到描述符选择子时,第三位TI位如果为0,则该段描述符在GDT中,如果为1则在LDT中。
TSS:多任务系统中,任务发生切换时,必须保存旧任务的运行状态,或者说是保存现场,保存的内容包括:通用寄存器、段寄存器、栈指针寄存器ESP、指令指针寄存器EIP和标志寄存器EFLAGS等等,否则等下次该任务恢复执行时,便只能重新开始。为了保存任务状态,每个任务都有一个额外的内存区域,任务状态段(task state segment:tss)。tss具有固定的格式(处理器约定),最小尺寸是104字节:
途中所标注的偏移是10进制,处理器能够识别TSS中的每个元素,并在任务切换时读取其中的信息。
类似 LDT,处理器用TR(task register)寄存器来指向当前任务的TSS,TR、LDTR、GDTR都只有一个,当任务发生切换时,处理器自动将当前任务的现场信息保存到由TR寄存器指向的TSS,然后再使TR寄存器指向新任务的TSS,并从新任务的TSS中恢复现场。
全局空间和局部空间
多任务系统中,操作系统负责任务的创建,以及在任务之间的调度和切换的工作。更繁重的是管理处理器、设备和储存器。操作系统负责加载程序到内存,并管理设备以及提供历程和数据供应用程序调用,简化了程序的编写。
因此每个任务实际上分为两个部分:全局部分和私有部分,全局部分含有操作系统的软件和库程序,以及可调用的系统服务和数据;私有部分则是每个任务的数据和代码,不同任务间彼此各不相同。
代码和数据实际上都在内存里,所以实际是全局地址空间和局部地址空间的区别。而地址空间的访问是通过段描述符来访问,所以全局地址空间用GDT来指定,而局部地址空间用LDT来指定。
程序在自己的局部空间运行,调用操作系统提供的服务时,转入全局空间执行。
GDT和LDT的界限都是2^16,由于一个描述符站8字节,所以都最大支持2^13个描述符,而每个描述符最大能表示4GB的内存空间,所以理论上一个任务的最大空间为2^13 * 4GB * 2=64TB,实际上32位处理器物理地址最大只有4GB,但是可以通过将不用的段换出到磁盘,用时再换回来这种虚拟内存的方式,实现64TB的线性地址。
特权级保护概述
引入LDT和TSS只是从任务层面进一步强化了分段机制,但是应用程序仍然可以选择不遵守,比如应用程序直接用SGDT等指令获得GDT位置,获得描述符访问对应的数据段和代码段。
为此,处理器在分段机制的基础上,引入了特权级(Priviledge Level),是存在于描述符(Descriptor Priviledge Level:DPL)及其选择子(Request Priviledge Level:RPL)的一个数值,当这些描述符或者选择子所指向的对象要进行某种操作,或者被别的对象访问时,该数值用于控制它们所能进行的操作,或者限制它们的可访问性。(通过特权级来保障任务只能访问指定的段,分段是建立制度,而特权级是负责执行这个制度)。
Intel处理器有0-3这4个特权级别,数值越大特权级别越低。
操作系统的主体部分对软硬件有完全控制权,被称为内核,必须有特权级0;而可靠性不如内核的系统服务程序,如驱动则在特权级1、2,当然现在很多操作系统中驱动也是特权级0(实际上特权级1、2几乎没人用);应用程序的可靠性被视为最低,而且不需要直接访问硬件和敏感的系统资源,调用设备驱动程序或者操作系统例程就能完成绝大多数工作,操作系统赋予他们最低特权级3。(由不得程序不愿意)
DPL:每个描述符内都有一个DPL(Descriptor Priviledge Level)字段,是目标对象的特权级。对于数据段,DPL决定访问它们应该具有的最低特权级别,即DPL为2的数据段,只有DPL为0、1、2的代码段执行时才能访问它,DPL为3的代码段执行时访问会被处理器阻止,并引发中断。
CPL:处理器正在执行的代码,其代码段的特权级即是CPL(Current Priviledge Level),其段选择子在寄存器CS中,其最低两位即是当前特权级的数值。进入保护模式时开启特权及保护,刚进入保护模式时的代码处理器默认为特权级0,然后通过jmp指令跳到操作系统自身设定的代码段,由于GDT是系统设置的,所以操作系统必然给自己的代码段设定CPL为0。而应用程序的LDT是由操作系统设置加载的,因此会被操作系统放到特权级3上,所以应用程序开始执行时,其CPL为3。
任务在自己局部空间执行时,其CPL是3,当通过调用系统服务进入操作系统内核时,在全局空间执行时,其当前特权级为0。
特权指令:特权级除了控制代码对段的访问,代码的调用之外,最重要的就是控制对某些重要指令的执行。类似于hlt停机指令、对cr0的写操作这种影响整个系统的指令,只能由当前特权级CPL为0时才能执行,这类指令称为特权指令。典型的特权指令包括:加载全局描述符表的lgdt(实模式下也可执行)、加载局部描述符表的lldt、加载任务寄存器的指令ltr、读写控制寄存器cr0-8的mov指令、停机指令hlt等十几条。
IOPL:标志寄存器EFLAGS中位13、12是IOPL(Input Output Priviledge Level),代表着任务要访问IO端口需要的特权级级别。EFALGES随着任务的切换,在TSS不断保存和恢复。CPL必须小于当前的IOPL才能访问端口进行IO操作
代码段之间的转移控制
一般来说,只允许特权级相同的代码段之间转移,也就是如果A代码段的CPL为2,那么只允许转移到DPL为2的代码段。(注意是发起访问请求看的是CPL,而被访问看的是DPL,两者都是描述特权级,不过是从访问时和被访问两个方面来看待特权级)。
当时为了让特权级较低的应用程序可以调用特权级较高的操作系统例程,处理器也给出了相应的解决办法。
方法一,将高特权级的目标代码段定义为依从的,也就是将段描述符的TYPE字段里的C位设置为1,这样就可以从特权级比它低的程序调用,但是此种情况要求CPL<=目标代码段描述符的DPL,如果C位为0,则不依从,要求CPL=DPL。跳转到依从代码后,其特权级不改变,也就是仍然是原来代码的特权级。
方法二,利用调用门,门是另一种形式的描述符,按照类型分为中断(陷阱门)、调用门。所有描述符都是64位的,调用门描述符也是64位,高32位:高16位偏移地址+16位属性,低32位是目标代码选择子+低16位偏移地址。最后将调用门安装到GDT或者LDT里,得到调用门的描述符选择子,即可用jmp far或者call far+调用门选择子,即可实现从低特权级跳转到高特权级,但是jmp跳转后不会改变CPL,而call才能以目标代码的CPL执行! 直接用jmp/call +调用门选择子:偏移地址,也是可以的,会忽略偏移地址。(这是为什么低13章的用户程序jmp 选择子:偏移地址 调用系统例程不用修改即可在本章使用的原因)
RPL:待访问的段选择子的最低两位,是真正的请求者的特权级。为什么这么说呢?例如应用程序通过磁盘读写例程,传入数据段的段选择子,最终访问数据段的CPL是系统服务的CPL,不是用户程序的CPL,而传入的数据段的段选择子最低2位,才是,为什么这么说呢?因为按照原则,应用程序只会访问自己的数据段,所以传入的数据段的段选择子的RPL为3,是真正的请求者的特权级。如果有人说,如果应用程序不遵守规则,传入的RPL是0呢?不用担心,系统服务例程会检查传入的段选择子的RPL的,对于应用程序不接受其RPL<=3。
每当处理器执行一个将段选择子传送到段寄存器(cs ds es ss )的指令,比如mov ds,cx,会检查以下两个条件: CPL<=DPL RPL<=DPL,必须要两个条件同时满足才能够执行。
特权级总结,如果非依从,不使用调用门,讲控制直接转移到其他代码段,要求CPL=DPL RPL=DPL;如果转移到依从代码,要求 CPL>=DPL RPL>=DPL;如果使用调用门访问高特权级代码,要求 CPL<=门描述符的DPL(注意门描述符也有DPL,这里指的不是目标代码段的描述符DPL) RPL<=门描述符的DPL;
如果是数据段,只遵守一个就是,高特权级的代码段可以访问低特权级的数据段。;
如果是栈段,也就是对ss寄存器进行修改,总是要求CPL=DPL,RPL=DPL,也就是不论如何修改ss 栈段的特权级总是和当前代码段的CPL一样,如果经过系统调用等导致进入不同特权级的代码段,则要切换到对应的特权级的栈段(之所以要切换,是为了安全起见,防止应用程序访问高特权级的栈)。
检测点
数值越小特权级别越高,如果不依从 那么要求 CPL=DPL RPL=DPL,如果是依从的,那么CPL>=DPL RPL>=DPL ,也就是只能转移到比当前特权级高或者相同的代码段。 如果当前特权级别CPL为2,那么可以访问同级或者比它级别低的数据段,因此可以访问DPL为2、3的数据段。
内核程序的初始化
刚进入保护模式时,代码的CPL被处理器设置为0,而之前已经把内核引导程序的几个段的DPL设置为0了然后jmp 代码段选择子:偏移地址,所以内核引导部分的CPL为0,同样的,初始化内核时,穿件的内核的代码段 数据段 栈段的描述符DPL也为0。
设置为0,则内核代码段执行时CPL为0,可以任意访问其他数据段和执行其他代码段,同时指令执行和IO访问不受限。
调用门:和第13章一样,本章同样提供了c_salt记录了内核提供的例程的段选择子和偏移量,用户程序则定义了u_salt表来声明使用了那些例程,在加载用户程序时,由内核将u_salt中的符号名转换成入口对应段选择子和段内偏移地址。但是本书中对用户程序的代码段、数据段、栈段的描述符DPL都为3,切在用retf转移到用户程序时,传入到CS里的、对应用户程序代码段的选择子,其最低两位为3。所以,如果回写到u_salt里的还是DPL为0的例程的选择子,那么用户程序是无法直接调用的,所以,在本章里改成了调用门的描述符的选择子。
调用门的描述符中不再是目标代码段的基地址,而是其选择子(16位)和偏移地址(32位)、和16位的属性,其中P位是有效位,通常为1,即有效,如果为0,则会产生异常中断,(中断过后会重新执行,所以可以用来统计调用次数)。其中的DPL是调用门自身的特权级,所有描述符都DPL,type则是门的类型1100即是调用门,最低5位是用栈传递的参数的个数,如果没有即是0,最多可以传递31个。
通过jmp/call far+调用门选择子的方式可以跳转到调用门指向的代码,但是jmp不会改变CPL,而call会变成目标代码的CPL!当然jmp +调用门选择子:偏移地址 也可以跳转,会忽略偏移地址,因为调用门描述符里已经有偏移地址了,这也是第13章用户代码不用修改即可调用新版内核里的例程的原因。
栈切换:在特权级不同的代码段之间切换时,栈段由于要和代码段CPL一致,因此栈段也要切换,目的主要是防止栈空间不足(比如用户程序调用系统服务,不切换栈不知道系统服务需要用多少栈空间,可能会越界)。
为了切换栈,每个任务除有自己固有的栈,还必须额外定义几套栈,数量取决于任务的特权级别,0特权级任务不许要额外的栈,它自己的固有的栈足够使用,因为除了调用返回外,不可能将控制转移到低特权级的段;1特权级的任务需要定义DPL为0和DPL为1的栈,2特权级的任务需要定义3个 0、1、2,特权级3的任务需要定义4个0、1、2、3的栈。
这些额外的栈,都由操作系统加载程序时自动创建。为什么每个特权级的所有任务不共用一个栈呢?为什么特权级3的任务需要定义0、1、2额外的栈,而不是利用已有的0、1、2的栈呢 主要是防止栈空间不足,并隔离各个任务。这些额外创建的栈,描述符在任务自己的LDT里,并且还在TSS里登记,处理器根据TSS里的栈自动切换栈。
任务寄存器TR总是指向当前任务的TSS,其内容是TSS的基地址和界限,切换栈时,处理器从TR找到当前任务的TSS,从TSS里获得新栈的信息。
通过调用门使用高特权级的例程时,如果通过栈传递参数,调用者会把参数压入栈,参数在旧栈中,处理器会将参数从旧栈复制到新栈,复制的个数(参数的个数)定义在调用门描述符里。切换栈前,旧栈栈顶指针ESP指向最后一个压入的参数,切换后新栈栈顶指针ESP还是指向最后一个压入的参数,这一过程对程序编写者是透明的。
上图是通过调用门调用目标代码时CPL和RPL的要求:即 CPL<=调用门的DPL RPL<=调用门的DPL CPL>=目标代码段描述符的DPL(都是数值上的比较)
调用门的安装和测试:本章所有的例程都位于公共例程段,该段DPL为0,为了使其他特权级的程序能够使用这些例程,C-SALT表中的例程的段选择子要换成调用门描述符的选择子,通过make_gate_descriptor函数构造调用门描述符,并用set_up_gdt_descriptor来安装创建的调用门描述符。
;对门进行测试
mov ebx,message_2
call far [salt_1+256] ;通过门显示信息(偏移量将被忽略)
上述代码即对安装好的调用门进行测试,通过call far +门描述符选择子,调用例程,并以目标代码CPL执行(由于内核本身也是0,满足 CPL >= 目标代码DPL, CPL<=调用门的DPL ,RPL <=调用门的DPL,所以可以调用)。
由于上面测试时是从特权级0转移到特权级0的代码段,所以,控制转移过程不发生栈切换,仅仅是把调用时的CS、EIP入栈,当执行retf指令后,处理器从栈中恢复CS和EIP的恢复,回到调用处继续执行。(jmp也行,但是jmp不改变当前特权级)
通过调用门进行控制转移时既要在转移前进行特权级检查,又要在retf控制返回时进行特权级检查。
检测点
答案 1. 数值上 CPL>目标代码段DPL,而CPL<=调用门描述符DPL RPL<=调用门描述符DPL
2. 不是的,可能在LDT中,如果驱动程序的所有段都在LDT中,其提供的例程的调用门描述符,即在LDT中。高32位:0x0000cc00 低32位:0x00552fc0,段选择子在低32位的高16位即0x0055 ,段内偏移高16位在高32位高16位,低16位在低32位低16位,因此段内偏移位0x00002fc0。属性在高32位的低16位,为0xcc00,DPL在第15 第 14位 0xcc00 ->1100_1100_0000_0000,所以其DPL为2,目标代码选择子0x0055 -> 0000_0000_0101_0101,所以目标代码的特权级为1,要通过此门进行控制转移: CPL>=1 CPL<=2 RPL<=2。
加载用户程序并创建任务
与上一章具有明显不同的是,为了实现多任务系统,在任务之间切换和轮转,必须能够追踪到所有正在运行的任务,记录他们的状态,或者根据他们的当前状态来采取适当的操作。(TSS只是负责记录某一个任务的状态,而现在要记录所有任务的状态)。
TCB任务控制块:为了满足以上要求,内核创建了一个人内存区域记录任务的信息和状态,称为任务控制块(Task Control Block:TCB)。任务控制块不是处理器的要求,是我们为了方便记录追踪所有任务设立的。(下图是任务控制块的结构,窄格子是16位数据,宽格子是32位数据)
可以看到,任务控制块TCB是一个链表结构,每个节点的开头的双字指向下一个TCB的基地址,如果是最后一个节点,那么该双字为0x00000000 。而本章代码在内核数据段定义了一个tcb_chain标号,标号指向了一个双子,记录了第一个TCB的首地址,初始为0x00000000,表明链表空,所以最终的结构如下:
通过一个append_to_tcb_link子程序来向TCB链中添加TCB的流程如下:
在安装了例程的门描述符并测试后,动态分配第一个TCB的内存,然后调用load_relocate_program子程序,开始加载用户程序和其对应的TCB TSS LDT。
用栈传递参数:load_relocate_program需要用栈传入两个参数,第一个是用户程序的LBA扇区,第二个是其对应的TCB的基地址。通过push顺序(包含隐式的call),可以先mov ebp,esp,再在函数内部用[ebp+n4]来访问对应参数,其中n为参数在栈里距离栈顶的距离(以4字节为单位),所以最终用[ebp+124]拿到program首扇区号,[ebp+11*4]拿到动态分配的TCB首地址。
32位模式下,栈指针用的是ESP,每次栈操作的默认操作数大小是双字(即使指定一个字节,最终也会压如双字),如果发现指令的操作数是段寄存器CS,DS,SS等,那么会将段寄存器里的段选择子由16位扩展成32位,高位用0填充,pop时相反,32位截成16放入段寄存器的选择子里。
加载用户程序:当任务被读入内存时,就要把它自己的代码段和数据段(栈)通过描述符来引用,既可以放在GDT里也可以放在LDT里,但是GDT用于存放各个任务的共有描述符(比如公共的数据段和任务段),所以最好放在LDT里。
包含如下个步骤:1分配内存作为LDT,2将LDT的大小和起始线性地址登记在该任务的TCB中,3分配内存并加载用户程序,将其大小和起始线性地址都登记到TCB中,4将用户程序里的段和用于切换的额外栈段安装到LDT中,5将LDT安装到GDT中。
在上述几个步骤中,需要注意的是:虽然LDTR中有关LDT的段界限有16位,能达到2^16字节大小,但是我们对于每个任务不必分配这么大的空间给LDT,160字节足矣。另外就是,所有的栈的粒度都是4KB,所以其段界限都是0xfffff-x,x是栈大小,4KB为单位。
对于用用户程序的段描述符,本章和上一章不同的区别是,本章将用户程序的所有私有段DPL都设置为3,并且,对于其返回的段选择子,在写回用户程序header段(供用户程序自己使用)之前,先将其or cx,0000_0000_0000_0011,RPL变为3,让用户程序在访问数据段时,RPL为3,由于对段的访问,既看RPL又看CPL,所以即使用户程序自行将其RPL改为0,试图访问内核段,也是不行的,因为跳转到用户程序时,用的retf模拟调用门的返回,从高特权级CPL 0 回到低特权级 DPL 为 3的用户代码,并且push的用户代码段的选择子低两位为11,所以进入用户代码执行时,其CPL为11,一段代码的CPL是无法由代码自身改变的。
和GDT不同,LDT的0号位置也是可用的,对于额外的堆栈(特权级3即0、1、2),生成并安装描述符到LDT后,还需要将其初始化ESP(一般为0,由于基地址是其上界,所以ESP为0,即初始为上界,然后向下扩展,直到下界)登记到TCB(主要是为了后面登记到TSS)。为什么特权级3,即应用程序自生的栈段不用指定初始化ESP呢,因为这个堆栈由应用程序自行管理。
对U-SALT表的重定位和上一章一样,没什么变化,仍然是用两层的嵌套循环,把C-SALT表中对应的项的调用门选择子写回U-SALT的符号区。
安装LDT描述符:尽管LDT和GDT里都用来存放各种描述符,但是他们本身一个是一个内存段。GDT由于全局唯一,所以只需要一个GDTR就可以时刻追踪和访问GDT,而LDT则不一样,LDT每个任务一个,而LDTR只有一个,所以必须要把每个LDT段的描述符安装到GDT里。访问LDT时,要通过LDT在GDT里的选择子访问,再将LDT描述符加载到LDTR(只加载段界限和段基地址48位)。
LDT描述符和普通的数据段(广义 包含代码 数据 栈)没什么不同,如图上,其D/B位固定为0,因为LDT只在32位保护模式起作用,L位为0,不是64位系统, G仍为粒度,AVL仍然是多余位,P位仍然是段存在位,为1表示在内存中,为0则不在内存,引发中断。S位固定为0,表示这是一个系统段,而不是数据段(广义上的 包含代码、数据、栈)TYPE则为0010固定的,可读可写。
任务状态段TSS 至此,所有的内存段都创建完毕,除了TSS。
当操作系统有多个任务同时存在,任务切换时,切换后的任务通过TSS开头的4字节指针记录前一个TSS的位置。
ss0 ss1 ss2 分别是012特权级的栈段选择子,ESP0 ESP1 ESP2 分别是对应的栈顶指针(之前记录在任务TCB里),这些内容都是由任务的创建者填写(指内核)。当通过门进行特权级之间的控制转移时,处理器会用这些信息切换栈。
CR3和分页有关,暂时不深入。偏移为32-92的区域是各个寄存器的快照(flash)部分,用于任务切换时,保存处理器的状态以便将来恢复现场。多任务系统中,新创建一个任务时,内核至少要填写EIP、EFLAGS、ESP、CS、SS、DS、ES、FS 、GS,当任务第一次获得执行时,处理器从这里加载初始执行环境
,并从CS:EIP处开始执行。此后的任务运行期间,该区域的内容由处理器更改(切换时向老任务的TSS写入,切换后在任务的TSS读取)。
在本章中,只有一个任务,而且自进入保护模式就开始运行了,只不过一开始是在0特权级的全局空间执行(内核就是任务的全局空间),所以TSS基本没啥用,所以没有填写寄存器部分,而是手动跳转到用户程序代码段的入口地址,等用户程序执行后再由其自己初始化寄存器。
LDT段选择子是当前任务的LDT描述符选择子,由内核或者操作系统填写,以指向当前任务的LDT,由处理器在任务切换时使用。
T位用于软件调试,在多任务环境中,如果T为位是1,那么每次切换到该任务时,将引发一个调试异常中断,有利于调试程序接管该中断以显示任务的状态,并执行一些调试操作。现在置0即可。
I/O映射基地址:太长,暂时不去了解它(虽然书里有)。只需要填写TSS的段界限103即可,意味着不使用这一特性。
安装TSS描述符到GDT: 和LDT一样,也必须在GDT中安装TSS的描述符,目的是1为了对TSS进行段和特权级的检查,另一方面,是为了使用call far 和jmp far指令+TSS描述符选择子,让处理器进行任务的切换。
TSS描述符和LDT描述符类似,除了type位。
TSS中的TYPE中有一个B位(busy),在任务创建时,应该由内核设置为1001,即B位应该设置为0,表示任务不忙,而任务开始执行时,应该处于挂起状态,即1,表示正在执行,该操作由处理器完成。任务是不可重入的,也就是不能由自己切换到自己,B位的存在,方便处理器在切换时检查是不是已经存在了。
带参数的过程返回:load_relocated_program过程用push传入了两个参数,所以导致在retf之前(如果pop恢复了相关寄存器)栈顶应该是 EIP CS 参数二 参数一,所以retf后栈并不平衡,可以手动pop 两次,也可以用 retf 8即在返回之后将ESP+8,也就是移动栈顶让两个参数出栈。哪个参数先入栈哪个参数后入栈应该由调用者和程序编写者约定,但是有一个流行的规范stdcall,该规范约定参数由右到左入栈。且由过程里的retf + idata 的当时出栈,而不用调用者手动出栈。
用户程序的执行
通过调用门转移控制的完整过程:完成load_relocate_program后,我们已经①安装并测试了调用门,②创建了TCB块,并加入到了TCB链,中途不断填充该任务的信息到TCB里,③创建LDT,并将用户程序的段和额外的栈段登记到安装到LDT,最终安装了LDT描述符到GDT,④创建了TSS,并向TSS中写入额外堆栈的选择子和初始ESP,最终在GDT里安装了TSS描述符,⑤重定位了用户程序中C-SALT里的例程的符号为调用门选择子。接下来我们要讲控制转移到用处程序那里(也就是开始转移到任务的局部空间)
通常情况下不允许从高特权级转移到低特权级,(因为这一过程要用调用门,而且必然是call,那么就要返回,而返回时用的retf回到高特权级,那么低特权级的代码就有修改栈,以实现返回到高特权级代码的任意位置的风险,因此不允许从高特权级转移到低特权级)。
但是办法还是有的,就是假装从调用门返回(假装低特权级通过调用门调用了高特权级的代码,然后用retf回到低特权级)。
完整的调用门过程:
通过jmp/call far +调用门选择子 实现调用门控制转移,其特权检查规则如下:
jmp far指令调用门控制转移时,要求当前特权级和目标特权级相同(非依从代码),原因是jmp far指令通过调用门控制转移时,不改变当前特权级CPL。而call far指令则可以转移到较高特权级别的代码段。(如果是依从情况下,可以控制转移但不该变特权级)。
如果call far通过调用门控制转移时改变了当前特权级别,则必须切换栈,即从当前任务的固有段切换到与目标代码段特权级别相同的栈上。而该切换是由处理器自动进行的。
栈的切换过程如下:
①使用目标代码段的DPL(也就是新的CPL),到当前任务的TSS中选择一个对应的栈,读取对应的栈段选择子和栈指针。
②用读取的栈选择子去LDT里读取段描述符,如果发现违反段界限检查(初始ESP出了问题),则引发处理器异常中断(无效TSS)
③检查新的栈段描述符的特权级和类型,如果违反栈的特权级规则或者类型,引发异常中断(无效TSS)
④临时保存当前栈(旧栈)段寄存器选择子SS和栈指针ESP的内容(不是保存到栈)
⑤把新的栈段选择子带入SS和ESP寄存器,切换到新栈
⑥将刚刚临时保存的旧栈的SS和ESP压如新栈
⑦依据调用门描述符“参数个数”字段(0-4),从旧栈中将所有参数按顺序复制到新栈,如果参数个数为0,不复制参数。
⑧把当前段寄存器CS和指令指针寄存器EIP的内容压入新栈(段寄存器压入实际上是压如的16位段选择子,高16位0填充),通过调用门的控制转移一定是远调用,所以要压入CS和EIP
⑨从调用门描述符中一次将目标代码段选择子和段内偏移地址传送到CS和EIP,开始执行被调用过程。
以下是控制转移时原来栈和新栈的内容:
如果没有改变特权级别,则不切换栈,也就没有压入旧栈cs ip这回事,也就没有复制参数这回事(调用者压入一次就够了),只有压入原来的CS和EIP,和实模式下的call far 栈内容变化没区别。
以下是没改变特权级的call far 调用门 控制转移时堆栈变化:
jmp far铁定不发生特权级变化,而且连压入CS和EIP都没有。
从同一特权级返回时(retf),处理器从栈中弹出调用者的代码段选择子和指令指针,为了安全起见,处理器仍然会进行特权级检查(同一特权级,检查能够通过)
特权级变化的远返回,只能返回到较低特权级的代码上,控制返回的全部过程如下:
①检查栈中保存的CS选择子得内容,根据其RPL决定返回时是否要改变特权级别。
②从当前栈中读取调用者保存在栈中的CS和EIP并试图写入CS EIP寄存器,并对要返回的代码段描述符DPL和代码段选择子RPL是是特权级检查
③如果返回指令是带参数的(自己作为被调用的例程当然知道自己用不要参数),例程编写者应该使用retf +字节数 的方式来跳过栈中参数部分,那么最后ESP(被调用例程特权级的栈的ESP)栈顶指向调用者的特权级的栈保存的SS和SP
④如果返回指令需要改变特权级,从栈中将SS和ESP的压栈值代入寄存器SS和ESP,切换到调用者的栈。在此期间,一旦检测到有任何界限违例(可能不止看ESP)都会引起处理器异常中断。
⑤如果返回指令是带参数的,会将跳过参数(这里不知道是处理器自动完成还是需要调用者自己处理,后面几章继续看看有没有说明),同样是ESP(此时已经是调用者特权级的栈的ESP)+足够的字节,恢复堆栈平衡。
⑥如果返回时改变特权级,会检查DS\ES\FS\GS寄存器的内容,根据他们找到相应的段描述符,要是有任何一个段描述符的DPL数值<调用者(返回后新的)CPL,那么该寄存器数值会变为0(也就是段选择子为0,直接指到GDT 0)
为什么会在高特权级返回到低特权级之后对所有段寄存器全面检查呢(CS 和 SS先检测了)?因为特权级检查只发生在将选择子带入段寄存器时进行,一旦段寄存器里已经成功写入了段选择子,后面内存访问时再也不检查。
那么,假设用户程序调用了高特权级的例程后,例程执行时将DS指向了高特权级的数据段,那么retf返回来时,如果不再检查DS和新的CPL(也就是调用者的),那么意味着用户程序可以通过DS访问高特权级的数据段!违背了数据段的特权级保护原则。
同时需要注意的是,除非手动修改TSS里的各个特权级栈段的初始ESP,处理器是不会自动修改的,也就是说,如果之前切换到高特权级的栈如SS0,执行时改变了ESP0,retf返回后,不会把改变后的ESP0回写到TSS,因为ESP0是初始值,而不是状态值。
进入3特权级的用户程序的执行
切换任务时,既要使任务寄存器TR指向新任务的TSS,又要使LDTR指向新任务的LDT。但是 本章中我们只有一个任务,不能用任务切换的方法(下一章学习)来使他开始运行,如何从任务的0特权级全局空间转移到它自己的3特权级空间正常执行呢?
解决方案是:先让TR和LDTR寄存器指向这个任务,然后假装从调用门返回。
本章中我们自己定义了一个TCB链,每个TCB都记录了对应任务的全部关键信息,里面就有任务的LDT和TSS的选择子、线性基地址、段界限等
TR寄存器和LDTR寄存器都类似于段寄存器而不是GDTR寄存器,因为GDT是全局唯一的,不用描述符表示,而LDT、TSS在GDT中用描述符表示,所以TR和LDTR里才会有选择器 以及描述符高速缓存部分。
使用 ltr r/mem16 和 lldt r/mem16 分别装载TR和LDTR,r/mem16的意思是,可以是一个16位的通用寄存器或者指向16位单元的内存地址。
用ltr将TSS选择子加载到TR后,处理器用该选择子访问GDT中对应的TSS描述符,将段界限和基地址加载到TR的高速缓存部分,同时处理器将该TSS描述符中的B位 置“1”,也就是标志为“忙”,但并不执行任务切换 ,只有在call/jmp far TSS描述符选择子,才直接任务跳转。ltr指令不影响EFLAGS寄存器的任何标志位,但只能在0特权级下执行。
用lldt指令将LDT段选择子加载到LDTR后,处理器用该选择子访问GDT中对应的LDT段描述符,将段界限和基地址加载到LDTR的高速缓存部分。该指令同样不影响EFLAGS,也是特权级0下执行。需要注意的是,即使用lldt更新了ldt,但是cs、ss、ds、es、fs、gs段寄存器的内容不会改变,不会自动切换到新ldt里的对应段,需要被切换后的任务自己调整段寄存器(或者处理器按照TSS里设定好的来?),包括TSS中的LDT选择子字段也不会自动修改。
如果lldt 选择子无效,比如高14位全0(意味着索引0 且在GDT里)那么LDTR里的内容被标记为无效,后续对LDT的所有操作都会引发异常。
下图是一个任务有关的各个组成部分:
现在LDT已经生效了,对应的LDT里的私有段描述符也已经生效了,可以通过他们访问用户程序私有段了。
我们利用retf指令,从高特权级的全局空间跳转到低特权级的局部空间,是假装从调用门返回,而调用门返回后会切换栈。按照调用门的全过程顺序,retf返回之前,任务的高特权级的额外栈(这里是内核的0特权级栈,而不是任务额外的0特权级栈,但是没关系,处理器不在意,但是如果程序自己调用例程时,会切换到自己额外的0特权级栈)里应该从高到低依从是 :应用程序自己的栈的SS ESP 、参数、应用程序调用例程时的 CS、EIP。
因此我们要模拟返回,就要往特权级0的栈里写入这些,注意SS和CS都是写入段选择子即可。
;以下假装是从调用门返回。摹仿处理器压入返回参数
push dword [0x08] ;调用前的堆栈段选择子
push dword 0 ;调用前的esp
push dword [0x14] ;调用前的代码段选择子
push dword [0x10] ;调用前的eip
retf
返回控制权到内核
通过retf通过特权级检查后,控制跳转到任务的局部空间了。用户程序通过调用门请求系统服务来显示字符串,或者读取硬盘均没问题了。唯一的问题是,上一章中的返回内核代码不再正确了:
jmp far [fs:TerminateProgram]
jmp far虽然也可以使用调用门,但是不会改变特权级,因此进入系统例程后还是特权级3,例程里的指令无法正确执行,会引发异常中断。(关于中断,会在后面详细介绍)
进入特权级为3的用户程序局部空间后,I/O特权级IOPL仍然是0,而CPL是3,因此应用程序无法进行IO操作(注意除了CPL和IOPL的关系决定能不能访问IO外,TSS里还有一个IO映射作为特例,记录了即使不满足CPL<=IOPL,也能访问的端口)。
检查调用者的RPL:
根据RPL和CPL的定义,绝大部分情况下RPL=CPL,即应用程序的代码访问其局部数据段,或者内核代码访问内核数据。
然而也有特例: CPL < RPL,典型情况就是磁盘的读例程,需要例程以CPL=0 来访问磁盘,并将内容内容写入到用户程序局部空间的数据段,这个时候数据段的选择子RPL > CPL , 需要注意的是 这个 数据段的选择子 是用户程序调用时提供的,但是此时总体仍然满足 RPL<= DPL CPL <= DPL。
危险的是 :如果用户程序的编写者知道了内核的数据段,他不能直接读写内核数据段,因为CPL>内核数据段的DPL,但是通过调用门调用系统读磁盘例程,CPL=0, RPL=0 (内核数据段的低2位是0) DPL也同样为了0,那么用户程序就可以往内核数据段写入数据了!
那怎么办呢?直接规定例程不能访问数据段不能访问内核数据段又是不可取的,因为有时候是内核自己需要在例程里访问自己。
RPL的引入是为了解决只有CPL导致处理器无法区分到底是谁想要访问对应的描述符,是内核自己调用了该例程?还是应用程序调用了该例程?对于调用门的例程,最终CPL都表现为0,处理器无法区分。
然而开头的分析中,加入了RPL后也好像没有效果,用户程序自己也可以往例程里传入RPL为0的选择子啊!所以我们可以在在例程里阻止这种行为!
对于需要用户程序传入其私有空间LDT的选择子时,我们可以用arpl指令来阻止其传入全局空间GDT选择子。
arpl r/mem16 r16,该指令会把目的操作数和源操作数的最低两位比较,注意,源操作数只能是寄存器,如果目的操作数的最低两位 小于 源操作数 (也就是传入的RPL优先级高于我们设定的特权级),那么会让ZF置1,并将目的操作数最低两位和源操作数低两位一致。恢复其RPL。这样我们将源操作数设置为调用者的CS(代码段选择子,在栈中可以找到),这样就保证了调用者的CPL和其传入的数据段选择子的RPL的一致性。
0x02 新增调试指令
本章引入了LDT和TSS,增加了两个新寄存器 LDTR TR,我们可以在bochs中用sreg 查看 LDTR TR的内容(实锤这两寄存器是段寄存了,hhh),可以用info ldt
info tss查看当前LDT TSS的内容
引导程序在0x7d38处jmp到内核主体代码,内核主体代码在0x4146e跳转到任务的局部空间,以下是执行lldt和ltr后的调试信息:
sreg:
info ldt info tss:
0x03 检测题
因为这些寄存器是为了保存任务状态,在该任务被切换出去时保存到该任务的tss,在切换到该任务时,从该任务的tss里读取并加载。而本章代码只有一个任务,无非是从全局空间跳转到局部空间,任务的加载并不是通过切换实现的,所以并不需要填写。
1:要正常返回内核,需要通过门调用return_point_例程,但是要用call,u-salt里每个条目经过重定向后,前4字节是偏移地址,前5-6字节是门调用描述符选择子。call far [fs:TerminateProgram]中 fs指向的是用用户程序Header段,而TerminateProgram标号对应的地址空间 低4字节是偏移量,高2字节是return_point的调用门选择子。所以call far [fs:TerminateProgram] 直接可用。
修改后编译通过运行正确。
2:本章中内核提供的读硬盘扇区,传入的参数是ds:ebx作为目标缓冲区地址,实际上ds里就保存着用户程序要读的数据段的段选择子,而且由于我们从高特权级的全局空间用retf切换到低特权级的局部空间后,对cs ds ss es fs gs都和新的cpl做了比较检查,将可能保存着特权级为0的段选择子的寄存器都清零了,所以到局部空间时ds绝对不可能指向全局空间,其cpl也不足以向ds里传入全局空间,所以用ds:ebx的方式传入目标地址倒是挺安全的,现在改成用栈传递参数,那么需要改动以下四个方面的代码:
①我们要修改对应调用门描述符,改变里面参数个数段
②用push栈传入,获取参数的方式有变化,我们要进入例程保存现场后 再push ebp,然后mov ebp,esp 让ebp也指向栈顶,然后搞清楚 栈里面的情况 从栈顶往上依次是: [ebp 现场的寄存器 eip cs 倒数第一个push传入的参数 …第一个push传入的参数 ESP SS (如果有栈切换的话) ], 所以我们计算 参数到栈顶的位置N 然后用[ebp+N * 4]即可访问对应参数,然后把参数放到原来保存对应参数的寄存器,需要注意的是,我们要对参数[段选择子]进行arpl指令 ,指令的源操作数是保存在栈中的 调用者自身的cs,arpl指令 源操作数和目的操作数都是16位,但是源操作数只能是16位寄存器。
③retf返回之前 栈里 依然是:[ebp 现场的寄存器 eip cs 倒数第一个push传入的参数 …第一个push传入的参数 ESP SS],我们要保持栈平衡,所以要一次pop ebp pop 相关现场寄存器 ,然后retf 参数个数 * 4,以跳过参数,保持堆栈平衡
④调用时,我们有 参数1 逻辑扇区,参数2段选择子,参数3偏移地址,要传入,按照stdcall,我们先push 参数3, 再push 参数2,再push 参数1。
最终修改代码如下:
函数修改如下:
read_hard_disk_0: ;从硬盘读取一个逻辑扇区
;EAX=逻辑扇区号
;DS:EBX=目标缓冲区地址
;返回:EBX=EBX+512
;从栈传递参数 第一个参数是eax 逻辑扇区号 第二个参数是目标段描述符选择子 第三个参数是偏移地址
push eax
push ebx
push ecx
push edx
push ebp
mov ebp,esp
mov ebx,[ebp+28];偏移地址
mov dx,[ebp+24];代码段选择子
arpl word [ebp+32],dx;
mov eax,[ebp+32];
mov ds,eax;
mov eax,[ebp+36]
;ebp edx ecx ebx eax eip ecs 参数3 参数2 参数1 esp ss
push eax
.....;中间的没变
loop .readw
pop ebp
pop edx
pop ecx
pop ebx
pop eax
retf 12 ;段间返回
调用门描述符安装修改:
;将循环拆了,因为第二个例程要改属性
mov eax,[edi+256] ;该条目入口点的32位偏移地址
mov bx,[edi+260] ;该条目入口点的段选择子
mov cx,1_11_0_1100_000_00011B ;特权级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条目
调用处修改:
push eax
push ds
push ebx
call sys_routine_seg_sel:read_hard_disk_0
最终编译通过 运行正确:
本章历时整整4天,终于写完了 撒花!