dustland

dustball in dustland

玩具内核

玩具内核

书上这一章是按照文件讲解的,先概述了内核源代码的组成,然后是主引导扇区,然后是用户程序

感觉不如跟随指令流的顺序更清晰,从主引导扇区开始分析

主引导扇区结构

主引导扇区的任务就是设置全局段描述符表并且让处理器转变成保护模式,并且加载内核并转让控制权

前面的已经学习过了,如何加载内核呢?

我们需要先把内核写到虚拟磁盘上的一个固定的地方,并且主引导程序可以找到这个地方,主引导程序还得知道从磁盘中搬出内核来,应该放到内存中的什么地方

实模式

内核常数定义

1
2
core_base_address equ 0x00040000   ;常数,内核加载的起始内存地址 
core_start_sector equ 0x00000001 ;常数,内核的起始逻辑扇区号

core_base_address定义了内核应该被加载内存的到什么地方,

core_start_sector定义了内核在磁盘中被放到了什么地方.

设置堆栈

1
2
3
mov ax,cs      
mov ss,ax
mov sp,0x7c00

堆栈还是从0x0000到0x7c00,栈顶一开始在0x7c00

给GDT找位置

在第11章中用字gdt_size和双字gdt_pose规定了gdt的位置,并且用lgdt [cs:gdt_size+0x7c00]将两个数据放到了gdtr中

在本章中两个变量只留了一个标号pgdt,高双字GDT物理地址直接写死不用修改,只需要修改低字的gdt界限

此时cs:pgdt+0x7c00+0x02指向的就是高双字,即GDT地址

1
2
3
4
5
6
7
8
9
10
11
12
13
;计算GDT所在的逻辑段地址
mov eax,[cs:pgdt+0x7c00+0x02] ;GDT的32位物理地址
xor edx,edx
mov ebx,16
div ebx ;分解成16位逻辑地址

mov ds,eax ;令DS指向该段以进行操作
mov ebx,edx ;段内起始偏移地址
...
lgdt [cs: pgdt+0x7c00]
...
pgdt dw 0
dd 0x00007e00 ;GDT的物理地址

取出地址值放到eax,edx:eax除以16即这个地址值整体右移四个单位,商放到eax作为段地址,余数放到edx作为段内偏移量

edx:eax/16=16*eax+edx,此时eax值直接放到段寄存器中,寻址的时候自动左移4位加上偏移量获得物理地址

eax给ds,edx该ebx,这样ds:ebx就指向GDT基地址了

创建GDT

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
;跳过0#号描述符的槽位 
;创建1#描述符,这是一个数据段,对应0~4GB的线性地址空间
mov dword [ebx+0x08],0x0000ffff ;基地址为0,段界限为0xFFFFF
mov dword [ebx+0x0c],0x00cf9200 ;粒度为4KB,存储器段描述符

;创建保护模式下初始代码段描述符
mov dword [ebx+0x10],0x7c0001ff ;基地址为0x00007c00,界限0x1FF
mov dword [ebx+0x14],0x00409800 ;粒度为1个字节,代码段描述符

;建立保护模式下的堆栈段描述符 ;基地址为0x00007C00,界限0xFFFFE
mov dword [ebx+0x18],0x7c00fffe ;粒度为4KB
mov dword [ebx+0x1c],0x00cf9600

;建立保护模式下的显示缓冲区描述符
mov dword [ebx+0x20],0x80007fff ;基地址为0x000B8000,界限0x07FFF
mov dword [ebx+0x24],0x0040920b ;粒度为字节

跳过0号槽,建立了四个段描述符

一号槽

一号槽是数据段的描述符,包含了整个4G内存空间

项目 数值 意义
段基地址 0x00000000 基址从0开始
段界限 0xfffff 段最大4G
G粒度 1 4K为粒度
D默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XEWA 0010 不可执行,
往大地址生长
可读写
二号槽

二号槽是代码段描述符

项目 数值 意义
段基地址 0x00007c00 基址从0x7c00开始
段界限 0x001fff 段最大8KB
G粒度 0 1B为粒度
B默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XCRA 1000 可执行
不可读写
往大地址生长
三号槽

三号槽是堆栈段描述符

项目 数值 意义
段基地址 0x00007c00 基址从0开始
段界限 0xffffe 段最大1MB
G粒度 0 1B为粒度
D默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XEWA 0110 不可执行,
往小地址生长
可读写
四号槽

四号槽是显存映射段

项目 数值 意义
段基地址 0x000b8000 基址从0开始
段界限 0x07fff 段最大32KB
G粒度 0 1B为粒度
D默认操作数大小 1 32位段
L64位代码标记 0 非64位段
P段存在位 1 段在内存中
DPL段特权级 00 0环段
S段描述符类型 1 非系统段
TYPE子类型XEWA 0010 不可执行,
往大地址生长
可读写

使能A20

1
2
3
in al,0x92                         ;南桥芯片内的端口 
or al,0000_0010B
out 0x92,al ;打开A20

关中断

1
2
cli                                ;中断机制尚未工作

设置控制寄存器CR0的PE位

1
2
3
mov eax,cr0
or eax,1
mov cr0,eax ;设置PE位

此后处理器就工作在保护模式了

保护模式

清空流水线,串行化处理器

1
2
3
4
;以下进入保护模式... ...
jmp dword 0x0010:flush ;16位的描述符选择子:32位偏移
;清流水线并串行化处理器

这里远跳转的目标是一个实模式下的表达方法,段地址:偏移量

然而现在CPU已经处在保护模式了,会用保护模式理解这条指令

段地址0x0010将被理解为段选择子,意思是一个

Index=0x10的全局0环段选择子,对应到GDT表的二号槽,代码段描述符

偏移量flush交给ip

作用相当于

1
2
mov ax,0x0010
mov cs,ax

但是保护模式下不允许任何给代码段寄存器cs显式赋值的操作,只能使用jmp far或者call far等指令改变cs的值

[bits 32]

此后所有指令都使用32位模式编译

flush标号

加载各段

加载数据段
1
2
mov eax,0x0008                     ;加载数据段(0..4GB)选择子
mov ds,eax

selector=0x0008表示一个Index=0x1的全局0环段选择子,对应到GDT一号槽,4G数据段

加载堆栈段
1
2
3
mov eax,0x0018                     ;加载堆栈段选择子 
mov ss,eax
xor esp,esp ;堆栈指针 <- 0

selector=0x0018表示一个Index=0x11的全局0环段选择子,对应GDT三号槽,堆栈段

加载内核

1
2
3
4
5
6
7
8
9
10
core_base_address equ 0x00040000   ;常数,内核加载的起始内存地址 
core_start_sector equ 0x00000001 ;常数,内核的起始逻辑扇区号
...
;以下加载系统核心程序
mov edi,core_base_address

mov eax,core_start_sector
mov ebx,edi ;起始地址
call read_hard_disk_0 ;以下读取程序的起始部分(一个扇区)

read_hard_disk_0是一个自定义过程,其调用约定是

两个参数

将内核所在的硬盘第一个逻辑扇区号放到eax寄存器,

将内核希望加载到的内存地址放到ds:ebx寄存器,

返回的时候ebx将+512字节

其他寄存器不发生变化

此前ds已经被作为数据段的段选择子加载好了,指向一个从0开始,界限4G的数据段

这里

core_base_address=0x40000->edi->ebx

core_start_sector=1->eax

两个参数都设置好了,然后调用call read_hard_disk_0,其效果是,

内核的头512个字节已经放到内存中0x40000开始的512个字节,此时ds:ebx指向该512个字节的结尾处即0x40512

计算内核占用扇区数

之前core_base_address=0x40000->edi,edi寄存器存放的是内核的基址,此后edi也一直指向内核的基址

经过加载内核之后,内核的头512个字节已经被放到edi指向的0x40000上了

而内核代码最开始的一个双字就是内核的大小,因此此时edi指向的又是内核的大小

1
2
3
;内核代码的第一个双字:
;以下是系统核心的头部,用于加载核心程序
core_length dd core_end ;核心程序总长度#00
1
2
3
4
5
6
7
8
;以下判断整个程序有多大
mov eax,[edi] ;核心程序尺寸
xor edx,edx
mov ecx,512 ;512字节每扇区
div ecx
or edx,edx
jnz @1 ;未除尽,因此结果比实际扇区数少1
dec eax ;已经读了一个扇区,扇区总数减1

[edi]=core_length-->eax

edx:eax÷512即计算内核的总长度用多少个扇区可以放开

结果商放到eax,余数放到edx

显然eax是向下取整的商,如果有余数即edx!=0,

由于后面希望eax中存放的是剩余需要加载的扇区数,即除了头一个扇区,剩下需要加载的扇区数

如果没有余数,则eax本来表示的是包括第一个扇区的内核总扇区数,现在需要给他-1,表示除了头一个扇区外的剩余扇区数

如果有余数,则eax本来需要加上1表示包括第一个扇区和最后一个不完整扇区的内核总扇区数,现在不给他加就表示除了头一个扇区但是包含最后一个不完整扇区的剩余扇区数

因此余数不为零时jnz跳转实现,不执行dec eax.余数为0时jnz跳转不实现,执行dec eax

读取全部内核

进入@1时,eax中存放的是内核剩余没有加载的扇区数,说剩余因为头一个扇区已经加载了

需要首先考虑一个问题,如果内核足够小,只占用了头512个字节,那么eax就是0,此时就不需要再加载其他扇区了

当eax>0时就意味着内核除了头512个字节还有剩余部分没有加载

1
2
3
4
5
6
7
8
9
10
11
12
@1:
or eax,eax ;考虑实际长度≤512个字节的情况
jz setup ;EAX=0 ?;如果内核果真小于512字节,则直接setup
;否则先读取剩余扇区再setup
;读取剩余的扇区
mov ecx,eax ;32位模式下的LOOP使用ECX
mov eax,core_start_sector
inc eax ;从下一个逻辑扇区接着读
@2:
call read_hard_disk_0
inc eax
loop @2 ;循环读,直到读完整个内核

加载内核使用的时loop循环,进入循环之前的初始化是这样的:

eax(剩余需要加载的扇区数)-->ecx作为循环变量

头512字节所在逻辑扇区core_start_sector-->eax-->eax+1表示第二个逻辑扇区

ebx自从加载完头一个内核扇区之后再也没有改动过,因此ebx自然指向内存中0x40512位置

循环体中每次先call read_hard_disk_0然后再增加eax逻辑扇区号

这就把整个内核加载进了内存中

read_hard_disk_0函数实现设计访问IO端口,目前尚未了解,留作后话

安装内核setup

加载GDT基址

1
2
3
4
5
       mov esi,[0x7c00+pgdt+0x02]         ;不可以在代码段内寻址pgdt,但可以
;通过4GB的段来访问
...
pgdt dw 0
dd 0x00007e00 ;GDT的物理地址

这里[0x7c00+pgdt+0x02]寻址时默认使用ds段,而ds段选择子对应的是一个从0开始,遍布整个4G内存空间的数据段.

所以0x7c00+pgdt+0x02得到的正好是gdt的物理地址

此后esi就指向GDT表的物理地址了

建立内核段

此后要在GDT中再建立三个内核的段,即

公用例程段

核心数据段

核心代码段

建立这些内核段的信息,已经在内核的最开始给出了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
;以下是系统核心的头部,用于加载核心程序 
core_length dd core_end ;核心程序总长度#00

sys_routine_seg dd section.sys_routine.start
;系统公用例程段位置#04

core_data_seg dd section.core_data.start
;核心数据段位置#08

core_code_seg dd section.core_code.start
;核心代码段位置#0c


core_entry dd start ;核心代码段入口点#10
dw core_code_seg_sel
0x00 内核总长度指针汇编地址,指向的也是内核总长度的汇编地址,不是物理地址
[edi+0x00] 内核总长度的汇编地址
[edi+0x00]+edi 内核总长度的基地址
[[edi+0x00]+edi] 内核总长度

一定要注意怎么获取内核总长度的

make_gdt_descriptor调用约定

在主引导记录中专门定义了一个过程make_gdt_descriptor,

用来在保护模式下创建全局段描述符,这个函数的调用约定为:

参数:

eax传递该段描述符的线性基地址

ebx传递该段界限

ecx传递属性,原来在啥位置在ecx中就放在啥位置

关于ecx属性的描述

由于描述符的属性只在高32位出现,而ecx也是32位的,因此直接把高32位放到ecx即可

image-20220830173245724

返回值:

edx:eax存放完整的描述符,高四个字节放在edx,低四个字节放到eax

该函数只是用寄存器返回描述符应该什么样,它不会自动写到内存中,何况参数中也没有给定往内存哪里写,将描述符写入内存是我们需要另外写的

函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
make_gdt_descriptor:                     ;构造描述符
;输入:EAX=线性基地址
; EBX=段界限
; ECX=属性(各属性位都在原始
; 位置,其它没用到的位置0)
;返回: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 ;装配属性

ret

线性基地址->eax->edx

eax左移16位,相当于保留了线性基地址的低16位,然后eax的低16位左移补0,然后和bx按位或,这就设置好了段描述符的低双字

edx和0xffff0000按位与,只保留了高16位,然后rol循环位移8位,意思是将高16位中的高8位放到低8位,高16位的低8位放到高八位(有点绕)

bswap将字节序反过来,于是刚才edx中的低8位和高8位又换过来了,这就设置好了edx中的基址高16位

xor bx将ebx的低16位置零,然后edx和ebx按位或,把段界限高4位放到edx中

最后edx和ecx按位或,把属性附加到edx中

建立公用例程段描述符

edi指向内核基地址,

dword ptr [edi+0x04] 内核公用例程段物理地址

dword ptr [edi+0x08] 内核核心数据段物理地址

1
2
3
4
5
6
7
8
9
10
;建立公用例程段描述符
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

由于公用例程段后面紧跟着就是核心数据段地址,因此有

ebx核心数据地址-eax公用例程地址=公用例程段大小

即eax-ebx-->ebx存放公用例程段大小

由于段界限总是比段大小少一个字节,因此dec ebx之后ebx存放的就是公用例程段的界限了

[edi+0x04]->eax,此时eax存放的是公用例程段的汇编地址

add eax,edi相当于[edi+[edi+0x04]]->eax,此时eax存放的才是公用例程段的线性地址

0x00409800-->ecx,表示的段属性为:

G D L AVL P DPL S TYPE(XCRA)
0 1 0 0 1 00 1 1000
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 只能执行
不可读写
地址增大

此时

eax存放公用例程段的线性地址

ebx存放公用例程段的界限

ecx存放公用例程段的属性

三个参数均已准备完毕

调用函数call make_gdt_descriptor,注册这个段描述符

返回时edx存放描述符高四个字节,eax存放描述符低四个字节

image-20220830180249500

下面只需要把两个寄存器分别写到内存中GDT的第5个槽位中即可

这就是两个mov干的事情

1
2
mov [esi+0x28],eax
mov [esi+0x2c],edx
建立核心数据段描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
 core_data_seg    dd section.core_data.start
;核心数据段位置#08

core_code_seg dd section.core_code.start
;核心代码段位置#0c
...
;建立核心数据段描述符
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

由于数据段后面紧跟着代码段,因此代码段汇编地址-数据段汇编地址=数据段大小

这个值再减一得到数据段界限,放到ebx中作为参数

[edi+0x08]是数据段的汇编地址,

[edi+[edi+0x08]]是数据段的线性地址,放到eax作为参数

该数据段包括了pgdt,allocate_memory使用的ram_alloc(下一次分配内存的起始地址),符号表,字符串表,内核缓冲区,内核栈指针暂存区,cpu版本号信息

这里的符号表有固定的格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
;符号地址检索表
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

每个符号都是这种结构:

1
2
3
标号	256byte 符号名字符串(多余填0)
dd 符号地址
dw 符号段地址

这个符号表在用户程序链接时将会发挥重大作用,装载器将会根据内核符号表设置用户符号表,落实用户程序对内核函数的引用

每个符号256byte,这意味着最大可以有\(2^{16}=64K=65536\)个符号,显然这对于玩具内核来说,是用不完的

建立核心代码段描述符
1
2
3
4
5
6
7
8
9
10
;建立核心代码段描述符
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
修改描述符表界限

在建立三个内核段之前,有四个在实模式下建立的段还有下标为0的空槽.因此之前GDT的界限是40-1=39

现在多了三个新的段描述符,GDT的界限就成了64-1=63

1
mov word [0x7c00+pgdt],63          ;描述符表的界限
重新加载GDTR

GDT的基址没有变化,但是界限增大了,而GDTR中保存的界限还是之前的39,需要重新加载GDTR更新界限

1
lgdt [0x7c00+pgdt]      

到此新的GDT建立完毕了

image-20220830192340336

转让控制权

到此主引导记录的任务就完成了,也给内核铺好了路,下面就到了内核发挥作用了

1
jmp far [edi+0x10]  

edi指向内核基址,edi+0x10就是内核代码入口点的指针地址,[edi+0x10]相当于解引用,就是内核代码入口点,

本远jmp指令就将控制转移到该入口点位置,即设置eip=[edi+0x10] ,顺便cs也改了

玩具内核

主引导记录最后一个远跳转将控制转移到了内核的[edi+0x10]即start标号处,start标号在内核源代码的第531行,从这里开始分析,看看内核干了什么

设置数据段

1
2
3
4
core_data_seg_sel     equ  0x30    ;内核数据段选择子 
...
mov ecx,core_data_seg_sel ;使ds指向核心数据段
mov ds,ecx

core_data_seg_sel=0x30->ecx->ds

即把selector=0x30这么一个Index=0x6的全局0环选择子放到ds里,

对应的是GDT偏移0x30处的核心数据段描述符

打印字符串

1
2
3
4
5
6
7
     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
...
mov ebx,message_1
call sys_routine_seg_sel:put_string

调用了公用例程段的put_string函数

这样使用call指令会把冒号前面的sys_routine_seg_sel(0x28)作为段选择子放到cs寄存器并查GDT,写入高速缓存器

然后将eip设置为段内偏移量put_string标号

call会自动把call前下一条指令的地址的段:偏移 地址压栈,方便retf时从栈里退出来还给cs:eip

该函数的调用约定是用ds:ebx指向串地址作为唯一的参数

函数实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
put_string:                                 ;显示0终止的字符串并移动光标 
;输入:DS:EBX=串地址
push ecx ;函数开端,被调用者保存寄存器
.getc:
mov cl,[ebx] ;ebx相当于字符串指针,[ebx]解引用取一个字符放到cl字节寄存器上
or cl,cl ;判断这个字节是否是0
jz .exit ;如果是0则函数返回
call put_char ;不是0则调用子函数put_char
inc ebx ;ebx指针向后移动一位,轮到打印下一个字符
jmp .getc ;重复getc过程

.exit:
pop ecx ;返还被调用者保存寄存器
retf ;段间返回

put_string相当于循环调用了put_char,

put_char的调用约定是CL作为参数传递需要打印字符的ASCII码

put_char设计IO操作,需要访问端口,目前尚未学习,留作后话

显式处理器信息

cpuid指令

cpuid指令需要eax作为参数,根据eax的不同值决定不同的返回结果,返回值使用eax,ebx,ecx,edx四个寄存器承担,

然后拷贝到内存里[cpu_brand]上

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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

使用cpuid获取信息并拷贝到内存后,用put_string函数以此打印出来

加载并执行用户程序

首先还是打印一句废话表示现在在干什么

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 core_entry       dd start          ;核心代码段入口点#10
dw core_code_seg_sel

....
mov ebx,message_5
call sys_routine_seg_sel:put_string
mov esi,50 ;用户程序位于逻辑50扇区,这是写死的,实际的操作系统不这样
call load_relocate_program
mov ebx,do_status
call sys_routine_seg_sel:put_string

mov [esp_pointer],esp ;临时保存堆栈指针

mov ds,ax

jmp far [0x10] ;控制权交给用户程序(入口点)
;堆栈可能切换

然后调用了load_relocate_program,这个函数就在本段之内,所以不是段间调用,不需要修改段寄存器

该函数作用是加载用户程序,类似于加载elf程序

其调用约定是,esi保存需要加载的程序在磁盘中的逻辑扇区号

执行完毕后将该用户程序头段选择子放在ax寄存器返回

然后打印一个"Done"字符串意思是加载完了,可以执行了

下面临时保存当前堆栈指针,并且把数据段换成ax返回值,即用户程序头段选择子

然后远跳转到[0x10],默认使用段寄存器ds,由于先前已经将ds修改为用户程序头段选择子,用户程序头中[0x10]位置存放的是该程序的入口点地址prgentry,这就相当于间接跳转将控制转移到程序的入口点

但是吧,这里ds到底指向谁,也就是说从load_relocate_program返回的时候,ax到底是啥,到现在不是很确定,

后面详细分析一下这个加载器函数干了啥吧

从用户程序返回

用户程序返回时也是远jmp指令跳转到内核的return_point,至于怎么跳转的现在不做讨论,只需要知道它跳转到了return_point

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

回来之后把ds从用户数据段改成内核数据段,把ss从用户堆栈改成内核堆栈,并且设置好执行用户程序之前的内核栈顶指针

然后打印废话

1
2
message_6        db  0x0d,0x0a,0x0d,0x0a,0x0d,0x0a
db ' User program terminated,control returned.',0

意思是从用户程序回来了

最后hlt停机,使处理器处于停止状态,不再执行任何指令

下面着重研究一下加载器是怎么工作的

用户程序的加载过程

协议

加载器和用户程序头必须有相同的协议,加载器认为用户程序头的第10个字节是入口点,用户程序头也得这样认为

这个协议集中体现在用户程序头上,比如elf头,coff头,pe头等等

这里适用于玩具内核的用户程序也有自己的头,它长这样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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:

加载器工作前的协议头

项目 大小(bytes) 文件偏移(bytes) 意义
program_length program_end 4 0 程序总大小
head_len header_end 4 4 程序头大小
stack_seg -- 4 8 接收栈段选择子
stack_len -- 4 12 程序建议的栈长度
prgentry start 4 16 入口点文件文件偏移
code_seg section.code.start 4 20 代码段文件偏移
code_len code_end 4 24 代码段长度
data_seg section.data.start 4 28 数据段文件偏移
data_len data_end 4 32 数据单长度
salt_items (header_end-salt)/256 4 36 符号表项数
符号1... 符号值(字面量) 256Bytes 40 第一个符号
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号
...

加载器工作后的协议头

项目 大小(bytes) 文件偏移(bytes) 原意义 新意义
program_length program_end 4 0 程序总大小 --
head_len header_end 4 4 程序头大小 程序头段选择子
stack_seg -- 4 8 接收栈段选择子 程序堆栈段选择子
stack_len -- 4 12 程序建议的栈长度 --
prgentry start 4 16 入口点文件文件偏移 --
code_seg section.code.start 4 20 代码段文件偏移 程序代码段选择子
code_len code_end 4 24 代码段长度 --
data_seg section.data.start 4 28 数据段文件偏移 程序数据段选择子
data_len data_end 4 32 数据单长度 --
salt_items (header_end-salt)/256 4 36 符号表项数 --
符号1... 符号值(字面量) 256Bytes 40 第一个符号 --
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号 --
...

为啥加载器工作后有些变量会有新的意义?需要详细研究加载器工作过程之后再说

salt不是密码学上的盐,是符号表的意思

符号表是链接使用的,这些符号都是操作系统内核函数的桩,装载程序时需要把函数的实际地址写到符号表中.

可以把这些由用户程序调用的位于内核中的函数视为libc一样的存在,也可以视为系统调用函数.反正现在内核还没有分的这么清.

既然链接发生在程序装载时,那么是否可以认为这是动态链接呢?

内核和加载器不需要知道用户程序的逻辑是什么,只需要清楚用户程序是否按照协议规定了自己的文件头,并从该文件头获取入口点,各区段地址等信息

加载器工作流程

内核代码的387到528行是加载器函数的源代码

它调用了很多其他函数

1
2
3
4
5
sys_routine_seg_sel:read_hard_disk_0
sys_routine_seg_sel:allocate_memory
sys_routine_seg_sel:read_hard_disk_0
sys_routine_seg_sel:make_seg_descriptor
sys_routine_seg_sel:set_up_gdt_descriptor

这些函数的功能从名字上就能看出来,用到时再分析函数逻辑和调用约定

start中调用加载器时使用esi传递了唯一的参数,该参数是用户程序所在的逻辑扇区号,在这个玩具内核中被写死为50

函数开端

函数开端保存了一众寄存器,将ds段切换到内核数据段

(实际上调用这个函数之前ds一直也是内核数据段,这里切换一下就不用管先前是啥了,以防万一)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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到内核数据段

读取程序头

1
2
3
mov eax,esi                        ;读取程序头部数据 
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0

esi中是start传过来的目标程序头所在的逻辑扇区号,放到eax上作为参数

core_buf是一个2048字节的缓冲区的标号,内核用它来暂时存放程序头一个扇区512字节

1
core_buf   times 2048 db 0         ;内核用的缓冲区

core_buf->ebx即把该缓冲区地址放到ebx上作为另一个参数

read_hard_disk_0这个函数的调用约定是

eax=逻辑扇区号

ds:ebx=目标缓冲区地址

返回值ebx=ebx+512

由于函数设计IO操作,暂时不分析其逻辑,留作后话

这里跨段调用read_hard_disk_0,cs寄存器修改为sys_routine_seg_sel段选择子

但是ds不变,因此调用函数和加载器共享数据段

函数调用完毕后从core_buf指向的内存地址开始的512个字节就是用户程序的头512个字节的拷贝

计算程序总大小

1
2
3
4
5
6
7
;以下判断整个程序有多大
mov eax,[core_buf] ;程序尺寸
mov ebx,eax
and ebx,0xfffffe00 ;使之512字节对齐(能被512整除的数,
add ebx,512 ;低9位都为0
test eax,0x000001ff ;程序的大小正好是512的倍数吗?
cmovnz eax,ebx ;不是。使用凑整的结果

[core_buf]->eax->ebx即将程序开始的四个字节放到ebx中,从协议上可知这四个字节就是程序总大小

ebx与0xfffffe00按位与,即与

1111'1111'1111'1111'1111'1110'0000'0000按位与

即保留高23位,低9位置0,即舍弃不满\(2^9B=512B\)的部分,向下取整

然后ebx+512->ebx相当于刚才的操作向上取整,即程序如果不是512B的倍数,则向上取整到最近的512B倍

如果程序本来的大小就是512B的倍数,则其低9位都是0,那么test eax,0x000001ff会置ZF=0,如果真是这样则此时eax就作为加载总字节数,否则将经过取整的ebx作为加载总字节数

上述过程说了这么多,实际上就干了一个事,计算程序占用的所有扇区的总字节数,最后一个扇区要是不满则按一个扇区算

为程序申请内存空间

由于程序执行需要有自己的代码段,数据段,堆栈段,并且还要和内核的独立,那么就需要另外申请

1
2
3
4
mov ecx,eax                        ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov ebx,ecx ;ebx -> 申请到的内存首地址
push ebx ;保存该首地址

实际需要申请的内存数量->eax->ecx

allocate_memory函数使用ecx作为参数,需要申请的内存大小,用ecx寄存器返回申请到的内存的首地址

返回值->ecx->ebx压栈保存

allocate_memory函数实现

allocate_memory用一个ram_alloc记录当前内存用到哪里了,

allocate_memory函数十分滴简单,他只会从ram_alloc开始分配希望的大小,然后更新ram_alloc为下一次调用做准备

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
allocate_memory:                            ;分配内存
;输入:ECX=希望分配的字节数
;输出:ECX=起始线性地址
push ds
push eax
push ebx

mov eax,core_data_seg_sel
mov ds,eax

mov eax,[ram_alloc] ;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

计算总扇区数

1
2
3
4
5
6
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax ;总扇区数


edx置零,eax中存放的是申请的字节数,

edx:eax÷512得到扇区数,由于eax已经对扇区向上取整过了,这里一定得到整数,没有余数,商在eax中

扇区数(商)->eax->ecx

这里把扇区数放到ecx是有目的的,后面循环拷贝扇区时就需要用ecx作为循环变量计数

加载数据段

由于要给程序分配空间,需要访问内核数据段之外的内存,显然使用内核数据段会触发访问越界异常,因此需要将数据段改成可以访问全部4G空间的那个数据段

1
2
mov eax,mem_0_4_gb_seg_sel         ;切换DS到0-4GB的段
mov ds,eax

拷贝整个程序到内存

1
2
3
4
5
       mov eax,esi                        ;起始扇区号 
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b1 ;循环读,直到读完整个用户程序

esi是程序所在的第一个逻辑扇区号,拷贝到eax作为参数,

每次循环将eax指定的扇区从硬盘拷贝到ds:ebx指定的内存位置,

然后ebx+512指向下一块内存空间,eax自增1表示拷贝下一个逻辑扇区,ecx每次loog自动减1,表示循环计数-1,直到ecx降为0完成整个程序的拷贝

建立四个程序段描述符

建立每个段描述符时用到了两个函数

1
2
sys_routine_seg_sel:make_seg_descriptor
sys_routine_seg_sel:set_up_gdt_descriptor
make_seg_descriptor

该函数的调用约定是,EAX传递线性基地址,EBX传递段界限,ECX传递属性,各属性位的位置和段描述符中相同

返回EDX:EAX完整的段描述符

set_up_gdt_descriptor

该函数的调用约定是,EDX:EAX传递完整的段描述符,输出CX作为段选择子

该函数会修改内存中的GDT,并且增大GDT界限,修改GDTR寄存器

至于函数实现,一看就明白,不用分析了

建立程序头部段描述符
1
2
3
4
5
6
7
8
9
;建立程序头部段描述符
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 ;返回段选择子放到edi+0x04即程序头预留好的

上来就pop,将栈顶弹给edi,这栈顶是啥呢?是allocate_memory返回的申请到的内存首地址->ecx->ebx->压栈

堆栈顶的历史沿革

1
2
3
4
mov ecx,eax                        ;实际需要申请的内存数量
call sys_routine_seg_sel:allocate_memory
mov ebx,ecx ;ebx -> 申请到的内存首地址
push ebx ;保存该首地址

此处edi->eax作为段基地址

[edi+0x04]即程序的第4个字节,由协议可知是head_len,即程序头长度->ebx然后ebx-1->ebx作为段界限

0x00409200->ecx作为段属性

G D/B L AVL P DPL S TYPE(XEWA)
0 1 0 0 1 00 1 0010
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 可读写
不可执行
正向增长

最后段选择子放到[edi+0x04],这里原来是程序头大小,程序装载后就不需要了,把程序头段选择子放到这里

建立程序代码段描述符
1
2
3
4
5
6
7
8
9
;建立程序代码段描述符
mov eax,edi
add eax,[edi+0x14] ;代码起始线性地址
mov ebx,[edi+0x18] ;段长度
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

edi还是指向程序起始地址

[edi+0x14]是代码段的文件偏移(或者说汇编地址)

[edi+[edi+0x14]]是代码段的线性地址,放到eax中作为参数

[edi+0x18]是代码段长度,减一后放到ebx作为参数

0x00409800作为段属性放到ecx上作为参数

G D/B L AVL P DPL S TYPE(XCRA)
0 1 0 0 1 00 1 1000
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 可执行
不可读写
正方向生长

eax,ebx,ecx作为参数传递给make_seg_descriptor后,edx:eax返回完整的段描述符,立刻用set_up_gdt_descriptor写入到内存GDT并更新GDTR

最后把段选择子放到[edi+0x14],原来是代码段偏移,现在放代码段选择子

建立程序数据段描述符
1
2
3
4
5
6
7
8
9
10
;建立程序数据段描述符
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

edi还是程序基址

[edi+0x1c]是数据段的汇编地址

[edi+[edi+0x1c]]是数据段的线性地址,放到eax作为参数

[edi+0x20]是数据段长度,减一后放到ebx作为参数

0x00409200描述段属性,放到ecx作为参数

G D/B L AVL P DPL S TYPE(XEWA)
0 1 0 0 1 00 1 0010
粒度1B 32位段 非64位段 内存中存在 0环 非系统段 可读写
不可执行
正向生长

最后把段选择子放到[edi+0x1c],这里本来是数据段的汇编地址,程序装载后就没用了,现在放上数据段选择子

建立程序堆栈段描述符
1
2
3
4
5
6
7
8
9
10
11
12
13
;建立程序堆栈段描述符
mov ecx,[edi+0x0c] ;4KB的倍率
mov ebx,0x000fffff
sub ebx,ecx ;得到段界限
mov eax,4096
mul dword [edi+0x0c]
mov ecx,eax ;准备为堆栈分配内存
call sys_routine_seg_sel:allocate_memory
add eax,ecx ;得到堆栈的高端物理地址
mov ecx,0x00c09600 ;4KB粒度的堆栈段描述符
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x08],cx

[edi+0x0c]是程序建议的段长度(粒度是4K),放到ecx中

0xfffff->ebx

0xfffff-[edi+0x0c]->ebx得到段界限

即堆栈段栈顶在0x100000位置,栈底在0xfffff-[edi+0x0c]

4K×[edi+0x0c]->eax->ecx作为参数,希望申请的空间大小

调用allocate_memory函数,ecx返回该申请地址的首地址

返回值->ecx->eax

0x00c09600->ecx作为段属性

G D/B L AVL P DPL S TYPE(XEWA)
1 1 0 0 1 00 1 0110
粒度4KB 32位段 非64位段 内存中存在 0环 非系统段 可读写
不可执行
反向生长

此时eax存放段基地址,ebx存放段界限,ecx存放段属性,均已准备完毕,调用make_seg_descriptor,edx:eax返回完整的描述符

然后set_up_gdt_descriptor写入内存,更新GDTR

最后把堆栈段选择子放到[edi+0x08],这里是给堆栈段选择子预留的空间

四个程序段建立后的程序头

项目 大小(bytes) 文件偏移(bytes) 原意义 新意义
program_length program_end 4 0 程序总大小 --
head_len header_end 4 4 程序头大小 程序头段选择子
stack_seg -- 4 8 接收栈段选择子 程序堆栈段选择子
stack_len -- 4 12 程序建议的栈长度 --
prgentry start 4 16 入口点文件文件偏移 --
code_seg section.code.start 4 20 代码段文件偏移 程序代码段选择子
code_len code_end 4 24 代码段长度 --
data_seg section.data.start 4 28 数据段文件偏移 程序数据段选择子
data_len data_end 4 32 数据单长度 --
salt_items (header_end-salt)/256 4 36 符号表项数 --
符号1... 符号值(字面量) 256Bytes 40 第一个符号 --
符号2... 符号值(字面量) 256Bytes 40+256 第二个符号 --
...

重定位符号表

1
2
3
4
5
6
7
8
9
;重定位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处

[edi+0x04]现在是程序头段选择子,放到es中es就指向了程序头

为啥要指向程序头?不指向程序数据段或者代码段?

因为这里要进行符号拷贝,而符号在程序头中

core_data_seg_sel是内核数据段选择子,放到ds上

看样子设置ds和es,是要进行跨段串拷贝了,方向是ds:esi->es:edi

此时es指向程序基址

[es:0x24]是salt_items,程序符号表符号数,放到ecx中作为循环变量

[es:0x28]是第一个符号的起始地址,因此edi置为0x28,这样es:edi就指向第一个符号了,

此时目的地就设置好了,还差一个源操作数的esi没有设置好

为啥没有立刻设置呢?

因为内核中的符号数量可能大于等于用户程序的符号数量,并且符号的排列顺序也有可能不相同,如果要重定位程序的符号表,可以想到的两种方法,一是根据程序符号表遍历内核符号表,即对每一个程序符号,都遍历内核符号表,找到其在内核中的地址然后写到该程序符号上

另一种是根据内核符号表遍历程序符号表

这里装载器采用的是根据用户符号表查内核符号表的方式,具体实现如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
.b2: ;外圈循环,遍历用户符号表
push ecx ;保存用户程序符号数
push edi ;保存用户符号在程序头段的偏移量

mov ecx,salt_items ;内核中的符号总数量,大于等于程序使用到的符号数量
mov esi,salt ; ;esi指向内核符号表
.b3:;中圈循环,对每一个用户符号表,都要遍历一遍内核符号表
push edi ;
push esi ;中圈循环中的源地址
push ecx ;中圈循环的循环变量,在进入内圈循环之前压栈保存

mov ecx,64 ;检索表中,每条目的比较次数 ,内圈循环变量
;内圈循环,字符串匹配,判断两个符号名是否相同
repe cmpsd ;每次比较4字节 ,64*4=256,恰好是每个符号的长度
;内圈循环结束,如果两个字符串256个字节完全相同则ZF=1,否则ZF=0
jnz .b4 ;不相同,即不是同一个符号,不匹配,跳转
mov eax,[esi] ;若匹配,esi恰好指向其后的地址数据,
;内核符号表中,每个符号的256个字节的符号名之后紧跟着符号的段偏移量,因此此时[esi]就是符号的段偏移量
mov [es:edi-256],eax ;将字符串改写成偏移地址 ;由于比较的时候edi和esi会同时增大,这里要把edi倒回来,然后把eax中的符号段偏移量放到程序符号名这里,原来程序符号表存放的是符号名,符号解析后就成了符号地址
mov ax,[esi+4] ;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 ;重复外圈循环,遍历程序符号表项,每次解析一个程序符号
;外圈循环结束,所有程序符号都已解析

翻译成伪代码就是

1
2
3
4
5
6
7
8
for(s1:用户符号表){
for(s2:内核符号表){
if(strcmp(s1,s2)==0){
s2段选择子:s2段内偏移 解析到s1;
break;
}
}
}

函数尾声

设置返回值

加载器函数约定的是ax作为返回值,此时es指向的是用户程序头段,即es就是用户程序头段选择子

1
mov ax,[es:0x04]

[es:0x04]指向用户程序头段选择子,现在可以肯定地说,加载器函数的返回值是用户程序头段选择子

恢复被调用者保存寄存器
1
2
3
4
5
6
7
8
pop es                             ;恢复到调用此过程前的es段 
pop ds ;恢复到调用此过程前的ds段

pop edi
pop esi
pop edx
pop ecx
pop ebx
返回
1
ret

到此加载器工作完成,用户程序已经加载进入内存

用户程序的执行过程

内核让权

加载器工作完成后,内核通过一个远跳转,将控制交给用户程序的入口点start

1
2
3
4
5
6
7
8
9
      call load_relocate_program	;返回时ax中存放程序头段选择子
...

mov [esp_pointer],esp ;临时保存堆栈指针

mov ds,ax ;ds获取程序头段选择子

jmp far [0x10] ;控制权交给用户程序(入口点)
;堆栈可能切换

[0x10]默认使用ds段,此时的ds是程序头段选择子,因此[0x10]就是程序入口点

程序还权

1
2
3
4
5
6
start:
mov eax,ds
mov fs,eax
...;程序具体干了啥不重要了,无非读取一些信息然后打印

jmp far [fs:TerminateProgram] ;将控制权返回到系统

程序头段选择子ds->eax->fs

TerminateProgram在装载时被解析为'@TerminateProgram'这个符号,在内核符号表中,这个符号长这样

1
2
3
4
salt_4           db  '@TerminateProgram'
times 256-($-salt_4) db 0
dd return_point
dw core_code_seg_sel

也就是说,符号解析把core_code_seg_sel:return_point放到了[fs:TerminateProgram]

这里间接远跳转就跳到了内核中的return_point标号处

1
2
3
4
5
6
       ;内核中:
jmp far [0x10] ;控制权交给用户程序(入口点);内核让权
;堆栈可能切换
;程序让权,跳转到return_point
return_point:
...

总结

到此,实模式到保护模式的转换,玩具内核的加载,用户程序的加载就都实现了

还有几个遗留的小问题,就是如何访问IO端口,read_hard_disk_0put_char两个函数如何实现的,留作后话