玩具内核
书上这一章是按照文件讲解的,先概述了内核源代码的组成,然后是主引导扇区,然后是用户程序
感觉不如跟随指令流的顺序更清晰,从主引导扇区开始分析
主引导扇区结构
主引导扇区的任务就是设置全局段描述符表并且让处理器转变成保护模式,并且加载内核并转让控制权
前面的已经学习过了,如何加载内核呢?
我们需要先把内核写到虚拟磁盘上的一个固定的地方,并且主引导程序可以找到这个地方,主引导程序还得知道从磁盘中搬出内核来,应该放到内存中的什么地方
实模式
内核常数定义
1 | core_base_address equ 0x00040000 ;常数,内核加载的起始内存地址 |
core_base_address定义了内核应该被加载内存的到什么地方,
core_start_sector定义了内核在磁盘中被放到了什么地方.
设置堆栈
1 | mov ax,cs |
堆栈还是从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 | ;计算GDT所在的逻辑段地址 |
取出地址值放到eax,edx:eax除以16即这个地址值整体右移四个单位,商放到eax作为段地址,余数放到edx作为段内偏移量
即
edx:eax/16=16*eax+edx
,此时eax值直接放到段寄存器中,寻址的时候自动左移4位加上偏移量获得物理地址
eax给ds,edx该ebx,这样ds:ebx就指向GDT基地址了
创建GDT
1 | ;跳过0#号描述符的槽位 |
跳过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 | in al,0x92 ;南桥芯片内的端口 |
关中断
1 | cli ;中断机制尚未工作 |
设置控制寄存器CR0的PE位
1 | mov eax,cr0 |
此后处理器就工作在保护模式了
保护模式
清空流水线,串行化处理器
1 | ;以下进入保护模式... ... |
这里远跳转的目标是一个实模式下的表达方法,段地址:偏移量
然而现在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 | mov eax,0x0008 ;加载数据段(0..4GB)选择子 |
selector=0x0008表示一个Index=0x1的全局0环段选择子,对应到GDT一号槽,4G数据段
加载堆栈段
1 | mov eax,0x0018 ;加载堆栈段选择子 |
selector=0x0018表示一个Index=0x11的全局0环段选择子,对应GDT三号槽,堆栈段
加载内核
1 | core_base_address equ 0x00040000 ;常数,内核加载的起始内存地址 |
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 | ;内核代码的第一个双字: |
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 | @1: |
加载内核使用的时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 | mov esi,[0x7c00+pgdt+0x02] ;不可以在代码段内寻址pgdt,但可以 |
这里[0x7c00+pgdt+0x02]
寻址时默认使用ds段,而ds段选择子对应的是一个从0开始,遍布整个4G内存空间的数据段.
所以0x7c00+pgdt+0x02得到的正好是gdt的物理地址
此后esi就指向GDT表的物理地址了
建立内核段
此后要在GDT中再建立三个内核的段,即
公用例程段
核心数据段
核心代码段
建立这些内核段的信息,已经在内核的最开始给出了
1 | ;以下是系统核心的头部,用于加载核心程序 |
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即可
返回值:
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 | ;建立公用例程段描述符 |
由于公用例程段后面紧跟着就是核心数据段地址,因此有
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存放描述符低四个字节
下面只需要把两个寄存器分别写到内存中GDT的第5个槽位中即可
这就是两个mov干的事情
1 | mov [esi+0x28],eax |
建立核心数据段描述符
1 | core_data_seg dd section.core_data.start |
由于数据段后面紧跟着代码段,因此代码段汇编地址-数据段汇编地址=数据段大小
这个值再减一得到数据段界限,放到ebx中作为参数
[edi+0x08]是数据段的汇编地址,
[edi+[edi+0x08]]是数据段的线性地址,放到eax作为参数
该数据段包括了pgdt,allocate_memory使用的ram_alloc(下一次分配内存的起始地址),符号表,字符串表,内核缓冲区,内核栈指针暂存区,cpu版本号信息
这里的符号表有固定的格式
1 | ;符号地址检索表 |
每个符号都是这种结构:
1 | 标号 256byte 符号名字符串(多余填0) |
这个符号表在用户程序链接时将会发挥重大作用,装载器将会根据内核符号表设置用户符号表,落实用户程序对内核函数的引用
每个符号256byte,这意味着最大可以有\(2^{16}=64K=65536\)个符号,显然这对于玩具内核来说,是用不完的
建立核心代码段描述符
1 | ;建立核心代码段描述符 |
修改描述符表界限
在建立三个内核段之前,有四个在实模式下建立的段还有下标为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建立完毕了
转让控制权
到此主引导记录的任务就完成了,也给内核铺好了路,下面就到了内核发挥作用了
1 | jmp far [edi+0x10] |
edi指向内核基址,edi+0x10就是内核代码入口点的指针地址,[edi+0x10]相当于解引用,就是内核代码入口点,
本远jmp指令就将控制转移到该入口点位置,即设置eip=[edi+0x10] ,顺便cs也改了
玩具内核
主引导记录最后一个远跳转将控制转移到了内核的[edi+0x10]即start标号处,start标号在内核源代码的第531行,从这里开始分析,看看内核干了什么
设置数据段
1 | core_data_seg_sel equ 0x30 ;内核数据段选择子 |
core_data_seg_sel=0x30->ecx->ds
即把selector=0x30这么一个Index=0x6的全局0环选择子放到ds里,
对应的是GDT偏移0x30处的核心数据段描述符
打印字符串
1 | message_1 db ' If you seen this message,that means we ' |
调用了公用例程段的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 | cpu_brnd0 db 0x0d,0x0a,' ',0 |
使用cpuid获取信息并拷贝到内存后,用put_string函数以此打印出来
加载并执行用户程序
首先还是打印一句废话表示现在在干什么
1 | core_entry dd start ;核心代码段入口点#10 |
然后调用了load_relocate_program,这个函数就在本段之内,所以不是段间调用,不需要修改段寄存器
该函数作用是加载用户程序,类似于加载elf程序
其调用约定是,esi保存需要加载的程序在磁盘中的逻辑扇区号
执行完毕后将该用户程序头段选择子放在ax寄存器返回
然后打印一个"Done"字符串意思是加载完了,可以执行了
下面临时保存当前堆栈指针,并且把数据段换成ax返回值,即用户程序头段选择子
然后远跳转到[0x10],默认使用段寄存器ds,由于先前已经将ds修改为用户程序头段选择子,用户程序头中[0x10]位置存放的是该程序的入口点地址prgentry,这就相当于间接跳转将控制转移到程序的入口点
但是吧,这里ds到底指向谁,也就是说从load_relocate_program返回的时候,ax到底是啥,到现在不是很确定,
后面详细分析一下这个加载器函数干了啥吧
从用户程序返回
用户程序返回时也是远jmp指令跳转到内核的return_point,至于怎么跳转的现在不做讨论,只需要知道它跳转到了return_point
1 | return_point: ;用户程序返回点 |
回来之后把ds从用户数据段改成内核数据段,把ss从用户堆栈改成内核堆栈,并且设置好执行用户程序之前的内核栈顶指针
然后打印废话
1 | message_6 db 0x0d,0x0a,0x0d,0x0a,0x0d,0x0a |
意思是从用户程序回来了
最后hlt停机,使处理器处于停止状态,不再执行任何指令
下面着重研究一下加载器是怎么工作的
用户程序的加载过程
协议
加载器和用户程序头必须有相同的协议,加载器认为用户程序头的第10个字节是入口点,用户程序头也得这样认为
这个协议集中体现在用户程序头上,比如elf头,coff头,pe头等等
这里适用于玩具内核的用户程序也有自己的头,它长这样
1 | SECTION header vstart=0 |
加载器工作前的协议头
项目 | 值 | 大小(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 | sys_routine_seg_sel:read_hard_disk_0 |
这些函数的功能从名字上就能看出来,用到时再分析函数逻辑和调用约定
start中调用加载器时使用esi传递了唯一的参数,该参数是用户程序所在的逻辑扇区号,在这个玩具内核中被写死为50
函数开端
函数开端保存了一众寄存器,将ds段切换到内核数据段
(实际上调用这个函数之前ds一直也是内核数据段,这里切换一下就不用管先前是啥了,以防万一)
1 | load_relocate_program: ;加载并重定位用户程序 |
读取程序头
1 | mov eax,esi ;读取程序头部数据 |
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 | ;以下判断整个程序有多大 |
[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 | mov ecx,eax ;实际需要申请的内存数量 |
实际需要申请的内存数量->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 | xor edx,edx |
edx置零,eax中存放的是申请的字节数,
edx:eax÷512得到扇区数,由于eax已经对扇区向上取整过了,这里一定得到整数,没有余数,商在eax中
扇区数(商)->eax->ecx
这里把扇区数放到ecx是有目的的,后面循环拷贝扇区时就需要用ecx作为循环变量计数
加载数据段
由于要给程序分配空间,需要访问内核数据段之外的内存,显然使用内核数据段会触发访问越界异常,因此需要将数据段改成可以访问全部4G空间的那个数据段
1 | mov eax,mem_0_4_gb_seg_sel ;切换DS到0-4GB的段 |
拷贝整个程序到内存
1 | mov eax,esi ;起始扇区号 |
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_descriptormake_seg_descriptor
该函数的调用约定是,EAX传递线性基地址,EBX传递段界限,ECX传递属性,各属性位的位置和段描述符中相同
返回EDX:EAX完整的段描述符
set_up_gdt_descriptor
该函数的调用约定是,EDX:EAX传递完整的段描述符,输出CX作为段选择子
该函数会修改内存中的GDT,并且增大GDT界限,修改GDTR寄存器
至于函数实现,一看就明白,不用分析了
建立程序头部段描述符
1 | ;建立程序头部段描述符 |
上来就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 | ;建立程序代码段描述符 |
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 | ;建立程序数据段描述符 |
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 | ;建立程序堆栈段描述符 |
[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 | ;重定位SALT |
[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 | .b2: ;外圈循环,遍历用户符号表 |
翻译成伪代码就是
1 | for(s1:用户符号表){ |
函数尾声
设置返回值
加载器函数约定的是ax作为返回值,此时es指向的是用户程序头段,即es就是用户程序头段选择子
1 | mov ax,[es:0x04] |
[es:0x04]指向用户程序头段选择子,现在可以肯定地说,加载器函数的返回值是用户程序头段选择子
恢复被调用者保存寄存器
1 | pop es ;恢复到调用此过程前的es段 |
返回
1 | ret |
到此加载器工作完成,用户程序已经加载进入内存
用户程序的执行过程
内核让权
加载器工作完成后,内核通过一个远跳转,将控制交给用户程序的入口点start
1 | call load_relocate_program ;返回时ax中存放程序头段选择子 |
[0x10]默认使用ds段,此时的ds是程序头段选择子,因此[0x10]就是程序入口点
程序还权
1 | start: |
程序头段选择子ds->eax->fs
TerminateProgram在装载时被解析为'@TerminateProgram'这个符号,在内核符号表中,这个符号长这样
1 | salt_4 db '@TerminateProgram' |
也就是说,符号解析把core_code_seg_sel:return_point
放到了[fs:TerminateProgram]
这里间接远跳转就跳到了内核中的return_point标号处
1 | ;内核中: |
总结
到此,实模式到保护模式的转换,玩具内核的加载,用户程序的加载就都实现了
还有几个遗留的小问题,就是如何访问IO端口,read_hard_disk_0和put_char两个函数如何实现的,留作后话