保护模式程序的动态加载和执行
01. 本章的目标和内容提要
引入保护模式、描述符、描述符表等并没有对用户程序的编写增加什么负担
- 因为对于系统来说这些是必须的,对于运行在系统上的程序来说不需要关心这些
02. 内核的结构和加载前的准备工作
内核的加载包含c13_mbr.asm全部和c13_core. asm一小部分
c13_mbr.asm用于:
- 从BIOS中接管处理器以及硬件的控制权
- 安装最基本的段描述符
- 初始化最初的执行环境
- 然后从硬盘上读取和加载内核的剩余部分
- 创建组成内核的每一个内存段
c13_core.asm中定义的内核的分段信息:
- 第一个分段:内核的头部,用于记录内核总长度,每个段的相对位置、以及入口点信息,以上统计信息用于告诉初始化代码如何加载内核;
- 第二个分段:公共例程段,本质上是一个代码段,包括一些可以反复使用的子过程,比如显示字符串例程、硬盘读写例程、内存分配例程、描述符创建和安装例程。这些子过程可以为内核自己使用也可以提供给用户程序使用;
- 第三个分段:数据段,包括系统核心数据,供内核自己使用;
- 第四个分段:代码段,包含进入内核之后首先要执行的代码、以及用于内存分配、读取和加载用户程序、控制用户程序的代码。
03. 准备为内核的每个段创建和安装描述符
上一节中已经将内核全部读入内存, 这一节是找到内核的每一个段为他们创建并安装描述符。在保护模式下,内核访问自己的段也需要通过描述符。
内核的段描述符安装在GDT中,之前已经使用lgdt指令加载了GDTR,在这里我们就需要为GDT安装新的描述符。那我们的任务就是从标号pgdt处取得GDT的基地址,为其添加新的描述符,并修改新的界限值,之后使用lgdt指令再次加载GDTR使其生效。
但是,现在程序处于保护模式,对代码段的保护导致不能通过代码段描述符来修改代码段中的内容。但是可以使用占全部4G空间的数据段描述符(或者创建代码段的别名描述符)来修改与代码段重叠部分的内容。
如图所示:大括号是4G字节的数据段、小括号是现在主引导程序所在的代码段,两个段有重叠的部分。
问:内核代码段的描述符还没有创建和安装,如何知道器选择子是什么?
答:内核常驻内存地址不会改变,内核在加载自己时会提前规划好每个段描述符的没醒,以及他们在描述符表中的位置,这样一来就知道每个段选择子的具体数值,因此为了方便可以将每个段的选择子定义为常数。
此时的内存布局:
04. 段描述符的创建和BSWAP指令
本节使用到的代码片段:
c13_mbr.asm
setup:
mov esi,[0x7c00+pgdt+0x02] ;不可以在代码段内寻址pgdt,但可以
;通过4GB的段来访问
;建立公用例程段描述符
mov eax,[edi+0x04] ;公用例程代码段起始汇编地址
mov ebx,[edi+0x08] ;核心数据段汇编地址
sub ebx,eax
dec ebx ;公用例程段界限
add eax,edi ;公用例程段基地址
mov ecx,0x00409800 ;字节粒度的代码段描述符
call make_gdt_descriptor
mov [esi+0x28],eax
mov [esi+0x2c],edx
c13_core.asm
make_seg_descriptor: ;构造存储器和系统的段描述符
;输入:EAX=线性基地址
; EBX=段界限
; ECX=属性。各属性位都在原始
; 位置,无关的位清零
;返回:EDX:EAX=描述符
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
内存布局如下,使用的是4G字节的数据段从0开始,那么公共例程段的逻辑地址就等于EDI指定的地址。
使用make_gdt_descriptor子程序构造描述符的低32位:
高32位的基地址部分:
数据反转指令:
将32位寄存器分成四个八位头尾反转,中间相邻反转:
高32位的段界限部分:
05. 进入内核执行
本节用到的代码片段:
;建立核心数据段描述符
mov eax,[edi+0x08] ;核心数据段起始汇编地址
mov ebx,[edi+0x0c] ;核心代码段汇编地址
sub ebx,eax
dec ebx ;核心数据段界限
add eax,edi ;核心数据段基地址
mov ecx,0x00409200 ;字节粒度的数据段描述符
call make_gdt_descriptor
mov [esi+0x30],eax
mov [esi+0x34],edx
;建立核心代码段描述符
mov eax,[edi+0x0c] ;核心代码段起始汇编地址
mov ebx,[edi+0x00] ;程序总长度
sub ebx,eax
dec ebx ;核心代码段界限
add eax,edi ;核心代码段基地址
mov ecx,0x00409800 ;字节粒度的代码段描述符
call make_gdt_descriptor
mov [esi+0x38],eax
mov [esi+0x3c],edx
mov word [0x7c00+pgdt],63 ;描述符表的界限
lgdt [0x7c00+pgdt]
jmp far [edi+0x10]
内核加载之后的GDT布局:
4G字节字段内存布局:
执行jmp far[edi+0x10] 指令时:处理器使用 DS描述符高速缓存器中的基地址0 加上 [edi+0x10]生成的有效偏移地址 得到内存中的线性地址去访问内存取出32位段内偏移和 16位段选择子,然后用段选择子到GDT中取出对应的段描述符在经过检查后放入CS描述符高速缓存器中,同时将32位段内偏移传送到EIP,之后处理器使用CS描述符高速缓存器中的段基地址加上EIP中的偏移作为逻辑地址去执行指令,即到内核入口点取指令并执行。
06. 进入内核之后显示文本
进入内核之后显示文本信息,本节代码:
SECTION sys_routine vstart=0 ;系统公共例程代码段
;-------------------------------------------------------------------------------
;字符串显示例程
put_string: ;显示0终止的字符串并移动光标
;输入:DS:EBX=串地址
push ecx
.getc:
mov cl,[ebx]
or cl,cl
jz .exit
call put_char
inc ebx
jmp .getc
.exit:
pop ecx
retf ;段间返回
;-------------------------------------------------------------------------------
put_char: ;在当前光标处显示一个字符,并推进
;光标。仅用于段内调用
;输入:CL=字符ASCII码
pushad
;以下取当前光标位置
mov dx,0x3d4
mov al,0x0e
out dx,al
inc dx ;0x3d5
in al,dx ;高字
mov ah,al
dec dx ;0x3d4
mov al,0x0f
out dx,al
inc dx ;0x3d5
in al,dx ;低字
mov bx,ax ;BX=代表光标位置的16位数
cmp cl,0x0d ;回车符?
jnz .put_0a
mov ax,bx
mov bl,80
div bl
mul bl
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 ;0xb8000段的选择子
mov es,eax
shl bx,1
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 ;小心!32位模式下movsb/w/d
mov edi,0x00 ;使用的是esi/edi/ecx
mov ecx,1920
rep movsd
mov bx,3840 ;清除屏幕最底一行
mov ecx,80 ;32位程序应该使用ECX
.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 ;0x3d5
mov al,bh
out dx,al
dec dx ;0x3d4
mov al,0x0f
out dx,al
inc dx ;0x3d5
mov al,bl
out dx,al
popad
ret
start:
mov ecx,core_data_seg_sel ;使ds指向核心数据段
mov ds,ecx
mov ebx,message_1
call sys_routine_seg_sel:put_string
进入start标号之后,先将内核自己的数据段选择子core_data_seg_sel传送给DS,之后将文本信息的标号message_1传送给ebx,之后使用call指令进行过程调用,处理器将段寄存器CS和指令指针寄存器EIP(下一条指令的有效地址)进行压栈保存,使用指令中的系统公共例程代码段的选择子sys_routine_seg_sel取得描述符,经过检查之后加载到CS描述符高速缓存器,同时使用指令中的偏移量put_string改变指令指针寄存器EIP,这样就转到目标过程内部执行。
其中movsd指令的操作和当前默认操作尺寸相关:
1. 当前操作尺寸是16位,源操作数由DS:SI指定、目的操作数由ES:DI指定;
2. 当前操作尺寸是32位,源操作数由DS:ESI指定、目的操作数由ES:EDI指定;
且由于当前是在32位操作尺寸下,所以重复传送的次数由ECX指定。
07. 用CPUID指令显示处理器品牌信息并显示
cpuid(CPU identification)指令用于返回处理器的标识和特性信息,需要使用EAX指定要返回什么样的信息,指定功能号。
cpuid指令是在80486后期版本引入,在执行cpuid指令之前要检测处理器是否支持该指令:
EFLAGS的位21是ID位,为0表示不支持cpuid指令,为1表示支持cpuid指令
本节代码:
cpu_brnd0 db 0x0d,0x0a,' ',0
cpu_brand times 52 db 0
cpu_brnd1 db 0x0d,0x0a,0x0d,0x0a,0
;显示处理器品牌信息
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
08. 准备加载用户程序
接上一节,加载用户程序:
mov ebx,message_5
call sys_routine_seg_sel:put_string
mov esi,50 ;用户程序位于逻辑50扇区
call load_relocate_program
SECTION header vstart=0
program_length dd program_end ;程序总长度#0x00
head_len dd header_end ;程序头部的长度#0x04
stack_seg dd 0 ;用于接收堆栈段选择子#0x08
stack_len dd 1 ;程序建议的堆栈大小#0x0c
;以4KB为单位
prgentry dd start ;程序入口#0x10
code_seg dd section.code.start ;代码段位置#0x14
code_len dd code_end ;代码段长度#0x18
data_seg dd section.data.start ;数据段位置#0x1c
data_len dd data_end ;数据段长度#0x20
主要讲了用户程序的构造,由哪些部分组成起始和结束地址在哪,具体代码看c13.asm
09. 预读用户程序并得到它的大小
内容:读取用户程序的第一个扇区,取得用户程序的长度
SECTION core_code vstart=0
;-------------------------------------------------------------------------------
load_relocate_program: ;加载并重定位用户程序
;输入:ESI=起始逻辑扇区号
;返回:AX=指向用户程序头部的选择子
push ebx
push ecx
push edx
push esi
push edi
push ds
push es
mov eax,core_data_seg_sel
mov ds,eax ;切换DS到内核数据段
mov eax,esi ;读取程序头部数据
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0
10. 条件传送簇CMOVcc
mov指令传统方式:会影响流水线效率,因为条件转移指令发生就会使流水线编译的译码失效需要重新编译,虽然现代处理器有了分支预测技术但是分支预测并不总是对的。
使用CMOVcc指令简化传送指令:ne表示Not Equal
Intel手册中有CMOV指令文档
CMOV指令格式
条件传送指令是从P6处理器开始引入的,使用1功能号通过查看EDX的返回值可以查看处理器是否支持这个指令簇
11. 计算以512字节为单位的用户程序总长度
在二进制中,512整数倍的数,低9位均为0。
判断用户程序的大小是否为512的整倍数
;以下判断整个程序有多大
mov eax,[core_buf] ;程序尺寸
mov ebx,eax
and ebx,0xfffffe00 ;使之512字节对齐(能被512整除的数,
add ebx,512 ;低9位都为0
test eax,0x000001ff ;程序的大小正好是512的倍数吗?
cmovnz eax,ebx ;不是。使用凑整的结果
12. 内存分配的基本策略和方法
操作系统必须记录所有可以分配的物理内存,当一个程序要求分配内存时,内存管理程序从可分配的内存中切割出一段将其标记为已使用。已分配内存在使用之后还需要负责回收它们,将其标记为空闲以便再次分配。
当内存空间紧张时,内存管理程序还需要查找那些很久未使用的程序,将其移出到硬盘上,腾出空间给需要使用内存的程序使用。下次用到这些内存时再将其加载到内存中。这就是虚拟内存管理。
现在的内存布局:
若一次分配512字节内存,从0x00100000处开始分配,则下一次从0x00100200处开始继续分配。
13. 内存分配的简易实现过程
本节代码:
allocate_memory: ;分配内存
;输入:ECX=希望分配的字节数
;输出:ECX=起始线性地址
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
add ebx,4 ;强制对齐
test eax,0x00000003 ;下次分配的起始地址最好是4字节对齐
cmovnz eax,ebx ;如果没有对齐,则强制对齐
mov [ram_alloc],eax ;下次从该地址分配内存
;cmovcc指令可以避免控制转移
pop ebx
pop eax
pop ds
retf
14. 加载用户程序
本节代码:
mov ecx,eax ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov ebx,ecx ;ebx -> 申请到的内存首地址
push ebx ;保存该首地址
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax ;总扇区数
mov eax,mem_0_4_gb_seg_sel ;切换DS到0-4GB的段
mov ds,eax
mov eax,esi ;起始扇区号
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b1
15. 准备安装用户程序的段描述符
本节代码:
;建立程序头部段描述符
pop edi ;恢复程序装载的首地址
mov eax,edi ;程序头部起始线性地址
mov ebx,[edi+0x04] ;段长度
dec ebx ;段界限
mov ecx,0x00409200 ;字节粒度的数据段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x04],cx
使用make-seg_descriptor创建描述符、使用set_up_gdt_descriptor安装描述符。
其中头部段描述符:
- G位:0表示段界限以字节为单位
- P位:1表示段存在
- S位:1表示这是一个存储器的段描述符
- X位:0表示这是一个数据段
- E位:0表示段向上扩展
- W位:0表示可读可写
16. 用SGDT和MOVZX指令确定GDT的位置
sgdt m指令:将GDTR的内容保存到内存地址m处
movzx指令:0扩展传送指令
movsx:符号位扩展传送指令
本节代码:GDTR是一个48位的寄存器高32位存储的内容是GDT起始线性地址低16位存储的内容是GDT的界限值,通过sgdt指令获取到GDTR的值之后提取低16位的界限值将界限值加1再加上GDT的起始线性地址即可得到下一个描述符的线性地址,后面就可以使用这个线性地址来安装新的描述符。
set_up_gdt_descriptor: ;在GDT内安装一个新的描述符
;输入:EDX:EAX=描述符
;输出:CX=描述符的选择子
push eax
push ebx
push edx
push ds
push es
mov ebx,core_data_seg_sel ;切换到核心数据段
mov ds,ebx
sgdt [pgdt] ;以便开始处理GDT
mov ebx,mem_0_4_gb_seg_sel
mov es,ebx
movzx ebx,word [pgdt] ;GDT界限
inc bx ;GDT总字节数,也是下一个描述符偏移
add ebx,[pgdt+2] ;下一个描述符的线性地址
17. 安装新描述符并生成选择子
接上一节代码:
mov [es:ebx],eax
mov [es:ebx+4],edx
add word [pgdt],8 ;增加一个描述符的大小
lgdt [pgdt] ;对GDT的更改生效
mov ax,[pgdt] ;得到GDT界限值
xor dx,dx
mov bx,8
div bx ;除以8,去掉余数
mov cx,ax
shl cx,3 ;将索引号移到正确位置
pop es
pop ds
pop edx
pop ebx
pop eax
retf
安装了新的描述符之后使用lgdt[pgdt]指令使得新的描述符生效,然后将GDT的界限值除以8得到描述符的索引号,再将索引号左移三位右边补零生成新的选择子。
18. 安装用户程序的段描述符并回填选择子
代码如下:
;建立程序头部段描述符
pop edi ;恢复程序装载的首地址
mov eax,edi ;程序头部起始线性地址
mov ebx,[edi+0x04] ;段长度
dec ebx ;段界限
mov ecx,0x00409200 ;字节粒度的数据段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x04],cx
回填选择子:
之后用户程序头部的0x04处就是选择子了,而且只用到了低32位,高32位还是原来的值
接着创建代码段描述符:
;建立程序代码段描述符
mov eax,edi
add eax,[edi+0x0c] ;代码起始线性地址
mov ebx,[edi+0x10] ;段长度
dec ebx ;段界限
mov ecx,0x00409800 ;字节粒度的代码段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x14],cx
之后就进行安装和加载描述符,后面还有数据段和栈段描述符:流程基本都是一样的
;建立程序数据段描述符
mov eax,edi
add eax,[edi+0x14] ;数据段起始线性地址
mov ebx,[edi+0x18] ;段长度
dec ebx ;段界限
mov ecx,0x00409200 ;字节粒度的数据段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x14],cx
;建立程序堆栈段描述符
mov eax,edi
add eax,[edi+0x1c] ;栈段起始线性地址
mov ebx,[edi+0x20] ;段长度
dec ebx ;段界限
mov ecx,0x00409200 ;字节粒度的数据段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x1c],cx
19. 用户程序的执行和退出
从load_relocate_program程序返回之前将用户程序头部段的选择子保存在AX中。
SECTION core_code vstart=0
;-------------------------------------------------------------------------------
load_relocate_program: ;加载并重定位用户程序
;输入:ESI=起始逻辑扇区号
;返回:AX=指向用户程序头部的选择子
push ebx
push ecx
push edx
push esi
push edi
push ds
push es
mov eax,core_data_seg_sel
mov ds,eax ;切换DS到内核数据段
mov eax,esi ;读取程序头部数据
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0
...
...
...
mov ax,[es:0x04]
pop es ;恢复到调用此过程前的es段
pop ds ;恢复到调用此过程前的ds段
pop edi
pop esi
pop edx
pop ecx
pop ebx
ret
从load_relocate_program程序返回内核程序:
call load_relocate_program
mov ebx,do_status
call sys_routine_seg_sel:put_string
mov [esp_pointer],esp ;临时保存堆栈指针
mov ds,ax ;从load_relocate_program程序返回时,用户程序头部段选择子存在AX中
call far [0x08] ;控制权交给用户程序(入口点)
;堆栈可能切换
这条call far [0x08] 指令执行时,处理器用段寄存器DS访问用户程序头部段,从偏移为8的地方取出32位偏移量传送到指令指针EIP,再取出16位段选择子传送到段寄存器DS,然后处理器进入用户程序内部执行。
用户程序start代码:
SECTION code vstart=0
start:
mov eax,ds ;此时DS指向用户程序头部段
mov fs,eax ;但是我们不能失去对DS的追踪,所以使用其他FS段寄存器重新设置DS
;mov eax,[stack_seg]
;mov ss,eax
;mov esp,stack_end
;mov eax,[data_seg]
;mov ds,eax
;用户程序要做的事情(省略)
retf ;将控制权返回到系统
code_end:
从用户程序返回到内核代码段:
call far [0x08] ;控制权交给用户程序(入口点)
;堆栈可能切换
return_point: ;用户程序返回点
mov eax,core_data_seg_sel ;使ds指向核心数据段
mov ds,eax
mov eax,core_stack_seg_sel ;切换回内核自己的堆栈
mov ss,eax
mov esp,[esp_pointer]
mov ebx,message_6
call sys_routine_seg_sel:put_string
;这里可以放置清除用户程序各种描述符的指令
;也可以加载并启动其它程序
hlt ;在主引导程序进入保护模式之前使用cli指令清除了中断,
;所以在这里用hlt指令让处理器停机之后不会被中断唤醒
20. 在虚拟机上观察内核的加载以及用户程序的执行与推出
起始LBA扇区号:
- 主引导程序:0
- 内核程序:1
- 用户程序:50
VirtualBox虚拟机执行成功:
Bochs虚拟机执行成功:
可以自行调试观察EFLAGS、GDT、内核程序头部和用户程序头部内容以及栈的变化。
- 标题: 保护模式程序的动态加载和执行
- 作者: xiaoeryu
- 创建于 : 2022-12-11 22:43:00
- 更新于 : 2023-10-03 12:59:44
- 链接: https://github.com/xiaoeryu/2022/12/11/25-保护模式程序的动态加载和执行/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。