用户程序编程接口及其实现
01.内核与用户程序之间的栈切换问题
从内核进入用户程序时,使用的是call far指令,这是一个过程调用指令,是需要使用retf指令返回的。过程调用需要隐式的栈操作,使用栈来保存返回地址,因此当call far指令执行时需要将下一条指令的地址压入当前正在使用的栈中保存,也就是内核的栈中。
用户程序:
start:
mov eax,ds ;此时DS指向用户程序头部段
mov fs,eax ;但是我们不能失去对DS的追踪,所以使用其他FS段寄存器重新设置DS
mov ss, [fs:stack_seg]
mov esp,stack_end
;mov eax,[data_seg]
;mov ds,eax
;用户程序要做的事情(省略)
retf
一旦将栈切换到用户程序自己的栈中,将无法返回内核,因为retf指令执行时要从栈中弹出内核的栈状态到CS和ESP。但是当前使用的时用户程序的栈而非内核的栈,这些地址时保存在内核的栈中的。
修改用户程序,在进入用户程序之后先保存内核栈的SS和ESP再使用用户程序自己的栈,在使用retf返回内核时再恢复栈为内核的栈地址:
添加用户栈常量:
SECTION data vstart=0
os_ss dw 0 ;用于保存内核栈的状态
os_esp dd 0
message_1 db 0x0d,0x0a,0x0d,0x0a
db '**********User program is runing**********'
db 0x0d,0x0a,0
data_end:
retf时恢复SS和ESP
start:
mov eax,ds ;此时DS指向用户程序头部段
mov fs,eax ;但是我们不能失去对DS的追踪,所以使用其他FS段寄存器重新设置DS
mov ds,[fs:data_seg] ;切换到用户程序自己的数据段
mov [os_ss],ss ;将ss的当前值保存到os_ss和os_esp
mov [os_esp],esp
mov ss,[fs:stack_seg] ;将栈切换到用户程序自己的栈
mov esp,stack_end
mov ss,[os_ss] ;恢复内核栈的状态
mov esp,[os_esp]
;用户程序要做的事情(省略)
retf ;将控制权返回到系统
因为我们在用户代码中保存了内核的栈状态SS、ESP,所以就不用在内核中再重复写代码恢复这个状态了。
- 进入用户程序之前临时保存堆栈指针的指令
- 回到内核之后切换回内核自己堆栈的指令
虚拟机验证:修改是可行的
虽然可以这样做,但是需要禁止这样的做法。
- 首先因为用户程序不需要为内核提供服务,比如为内核保存栈状态等等
- 其次内核必须是稳定的,不能依赖于用户程序否则用户程序一旦出错内核也将崩溃是不行的
02. 内核中位用户程序提供编程支持
在用户程序中暂时无法访问现存,在进入内核之前创建过一个显存的描述符由内核使用。因此我们知道显存的段选择子,在原则上就可以在用户程序中访问显存显示文本。也可以使用内核中的put_string例程来显示字符串。
代码如下:
- 定义的字符串
SECTION data vstart=0
os_ss dw 0 ;用于保存内核栈的状态
os_esp dd 0
message_1 db 0x0d,0x0a,0x0d,0x0a
db '**********User program is runing**********'
db 0x0d,0x0a,0
data_end:
- 使用显存的描述符
start:
mov eax,ds ;此时DS指向用户程序头部段
mov fs,eax ;但是我们不能失去对DS的追踪,所以使用其他FS段寄存器重新设置DS
mov ds,[fs:data_seg] ;切换到用户程序自己的数据段
mov [os_ss],ss ;将ss的当前值保存到os_ss和os_esp
mov [os_esp],esp
mov ss,[fs:stack_seg] ;将栈切换到用户程序自己的栈
mov esp,stack_end
mov ebx,message_1
call 0x28:0 ;0x28是内核公共历程段的选择子
;0是put_string例程在内核公共历程段内的偏移
mov ss,[os_ss] ;恢复内核栈的状态
mov esp,[os_esp]
;用户程序要做的事情(省略)
retf ;将控制权返回到系统
虚拟机运行成功:
但是通常用户程序不可能知道内核的段选择子和内核中的例程,所以上述代码一般不会出现。但是用户程序也可以自己安装一些描述符在GDT中,如显存的段描述符,不过一般不能这样做,GDT只能给内核使用不然有可能会造成内核的崩溃。
所以处理器有了特权级DPL的概念,来限制用户程序的权限。但是内核会给用户程序提供一些例程,供其使用(也可以在一定程度上减少用户程序重复造轮子),也叫用户程序接口(API:Applicatio Programming Interface)。
03. 用户程序中的符号地址检索表
本节使用c13_mbr0.asm作为引导程序:首先创建一些初始的段描述符、进入保护模式、加载内核、对内和进行设置和重定位、最后将控制权交给内核跳转执行内核程序。
内核发布时需要向程序员发布一个内核编程手册,对于我们这个c13_core1.asm内核也有一个编程手册:
符号地址检索表位于用户程序头部,当内核加载用户程序时需要对这个表进行处理,会将每一个字符串的名字替换成其在内核中的地址,包括段选择子和段内偏移量。如此一来标号处存储的就是对应例程在内核中的地址。在用户程序头部定义符号地址检索表SALT(Symbol Address Lookup Table)。
如下:
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
;-------------------------------------------------------------------------------
;符号地址检索表
salt_items dd (header_end-salt)/256 ;#0x24
salt: ;#0x28
PrintString db '@PrintString'
times 256-($-PrintString) db 0
TerminateProgram db '@TerminateProgram'
times 256-($-TerminateProgram) db 0
ReadDiskData db '@ReadDiskData'
times 256-($-ReadDiskData) db 0
header_end:
04. 内核程序中的符号地址检索表
本节主要讲了内核中的符号地址检索表SALT的内容,在c13_core1.asm中的定义如下:
SECTION core_data vstart=0 ;系统核心的数据段
;-------------------------------------------------------------------------------
pgdt dw 0 ;用于设置和修改GDT
dd 0
ram_alloc dd 0x00100000 ;下次分配内存时的起始地址
;符号地址检索表
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
05. 串比较指令CMPS
内核启动流程:
- 从主引导程序进入内核
- 显示一些文本信息、显示处理器品牌信息
- 调用load_relocate_program加载和重定位用户程序
- 显示文本信息
- 调转执行用户程序
其中load_relocate_program中添加了对符号地址检索表的处理:
- 读硬盘扇区,将用户程序读入内存,为用户程序的每个段创建描述符;
- 处理符号地址检索表,即将符号出的字符串替换成其对应例程在内存中的地址,替换成例程在内存中的段选择子和段内偏移量。如何知道每个字符串所代表的例程在内存中的地址呢? 将用户程序中的符号取出,依次和内核中的符号地址检索表中的内容进行比较,相同的话就是用内核中对应的后六个字节的段选择子和段内偏移量覆盖用户程序原先的符号,覆盖之后用户程序中存放的就是对应例程的段选择子和段内偏移量。
- 处理完SALT之后返回
其中,使用字符串比较指令cmpsb、cmpsw、cmpsd、cmpsq进行字符串比较:
06. 串比较的方向和重复前缀
CMPS指令只会执行一次,重复比较需要使用指令重复前缀rep。
rep重复前缀:指令每执行一次,CX/ECX/RCX值减1。
如果总字节数除不尽的话,应该使用cmpsb,而不是cmpsw指令,CX、ECX、RCX设置的总字节数是字符串的长度,所以两个字符串长度要相等。
重复前缀rep指令不会在比较字符串时做出判断,这个问题使用如下方法解决:
例如指令,repe cmpsw,执行过程是:以字节为单位进行字符串比较,若相等则设置零标志位ZF = 1,也将CX/ECX/RCX减1,若CX/ECX/RCX不为零且ZF = 1,表示字符串相等继续比较,若在C
X/ECX/RCX不为零但是ZF = 0则表示当前字符串不相等,比较终止。
字符串比较的完整过程:
07. 使用外循环依次取得用户SALT表中的每个条目
接05节:
在load_relocate_program中,添加了符号地址检索表SALT的重定位过程:
SECTION core_code vstart=0
;-------------------------------------------------------------------------------
load_relocate_program: ;加载并重定位用户程序
;输入:ESI=起始逻辑扇区号
;返回:AX=指向用户程序头部的选择子
...
...
...
;重定位SALT
mov eax,[edi+0x04]
mov es,eax ;es -> 用户程序头部
mov eax,core_data_seg_sel
mov ds,eax
cld
mov ecx,[es:0x24] ;用户程序的SALT条目数
mov edi,0x28 ;用户程序内的SALT位于头部内0x2c处
.b2:
push ecx
push edi
mov ecx,salt_items
mov esi,salt
.b3:
push edi
push esi
push ecx
mov ecx,64 ;检索表中,每条目的比较次数
repe cmpsd ;每次比较4字节
jnz .b4
mov eax,[esi] ;若匹配,esi恰好指向其后的地址数据
mov [es:edi-256],eax ;将字符串改写成偏移地址
mov ax,[esi+4]
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 ax,[es:0x04]
pop es ;恢复到调用此过程前的es段
pop ds ;恢复到调用此过程前的ds段
pop edi
pop esi
pop edx
pop ecx
pop ebx
ret
其中:在用户程序中,偏移为4的地方之前是用户程序头部段的长度,现在被修改为用户程序头部段的选择子。
其中比较过程使用循环进行,外循环遍历用户程序、内存换遍历内核程序:
外循环:每执行一次都会使得EDI指向代表下一个例程名称字符串的首地址。
08. 使用内循环依次取得内核SALT表中的每个条目并进行比较
在上一节中:EDI增量为256字节、ESI增量为262字节。
内循环:
其中,字符串比较:
执行比较之前:
- 现在段寄存器ES指向用户程序数据段;
- EDI指向用户程序符号地址检索表的某个条目;
- 段寄存器DS指向内核数据段;
- ESI指向内核符号地址检索表的某个条目;
在检索表中,每个字符串的长度是256个字节 = 64个双字;
如果两个字符串相同,比较操作要进行64次,结束后零标志位ZF = 1;
如果两个字符串不同,比较操作中止,此时零标志位ZF = 0。
如果比较结束之后ZF = 1,表示字符串相同,执行重定位操作:
具体过程看下面代码:
mov ecx,64 ;检索表中,每条目的比较次数
repe cmpsd ;每次比较4字节
jnz .b4
mov eax,[esi] ;若匹配,esi恰好指向其后的地址数据
mov [es:edi-256],eax ;将字符串改写成偏移地址
mov ax,[esi+4]
mov [es:edi-252],ax ;以及段选择子
09. 用户程序中使用内核编程接口读硬盘和显示文本
其中在内核跳转执行用户程序时,使用jmp far [0x08]指令,不需要在栈中压入返回地址;之前使用call指令需要在栈中压入返回地址,所以有栈的切换问题。
进入用户程序执行,使用内核例程。
SECTION code vstart=0
start:
mov eax,ds
mov fs,eax
mov ss,[fs:stack_seg] ;ss指向用户程序自己的栈段
mov esp,stack_end
mov ds,[fs:data_seg] ;ds指向用户程序自己的数据段
mov ebx,message_1 ;ebx指向偏移地址
call far [fs:PrintString] ;调用内核put_string例程
mov eax,100 ;逻辑扇区号100
mov ebx,buffer ;缓冲区偏移地址
call far [fs:ReadDiskData] ;段间调用
mov ebx,message_2
call far [fs:PrintString]
mov ebx,buffer
call far [fs:PrintString] ;too.
jmp far [fs:TerminateProgram] ;将控制权返回到系统
code_end:
10. 虚拟机验证程序执行
先用Vhd Writer讲文件写入要读取的100号扇区
再使用VirtualBox虚拟机测试代码
11. 以16进制形式显示一个双字以及PUSHAD、POPAD和XLAT指令的使用
在内核中实现了put_hex_dword例程:
;汇编语言程序是极难一次成功,而且调试非常困难。这个例程可以提供帮助
put_hex_dword: ;在当前光标处以十六进制形式显示
;一个双字并推进光标
;输入:EDX=要转换并显示的数字
;输出:无
pushad
push ds
mov ax,core_data_seg_sel ;切换到核心数据段
mov ds,ax
mov ebx,bin_hex ;指向核心数据段内的转换表
mov ecx,8
.xlt:
rol edx,4
mov eax,edx
and eax,0x0000000f
xlat
push ecx
mov cl,al
call put_char
pop ecx
loop .xlt
pop ds
popad
retf
使用上述例程显示一个双字的16进制形式。
在内核数据段中定义了一个字符串:
每个字符在字符串中的偏移正好可以代表其作为16进制的数字字符。
将32位二进制数分成8个4位,将每个4位二进制数据转化为16进制,再用值作为偏移去取出对应的字符显示出来即可。
其中pushad:每次先将ESP减4,ESP是先保存再压入。
对应的popad指令:
其中xalt指令:
运行过程:将EDX循环左移4位,再备份到EAX中,EAX除了低四位外全部置零,就可以获取AL的值了。
课后作业:
使用put_hex_dword在屏幕上输出一串数字:
- 标题: 用户程序编程接口及其实现
- 作者: xiaoeryu
- 创建于 : 2022-12-11 22:44:00
- 更新于 : 2023-10-03 12:59:52
- 链接: https://github.com/xiaoeryu/2022/12/11/26-用户程序编程接口及其实现/
- 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。