从实模式到保护模式笔记9程序的动态加载和执行

从实模式到保护模式9程序的动态加载和执行

0x00 代码

​ 硬盘主引导扇区代码,本章代码很长,但还是手写了一遍理清思路

core_base_address equ 0x00040000 ;常数,内核加载的起始物理地址
core_start_sector equ 0x00000001 ;常数,内核的起始逻辑扇区

mov ax,cs
mov ss,ax
mov sp,0x7c00

;计算GDT所在的逻辑段地址
mov eax,[cs:pgdt+0x7c00+0x02] ;GDT的32位物理地址
xor edx,edx
mov ebx,16
div ebx

mov ds,eax ;GDT所在的逻辑段地址
mov ebx,edx ;段内起始偏移地址

;0#段描述符
mov dword [ebx+0x00],0x00000000
mov dword [ebx+0x04],0x00000000;理论上这两句可以不写

;1#段描述符 数据段4GB 
mov dword [ebx+0x08],0x0000ffff;基地址0x00000000
mov dword [ebx+0x0c],0x00cf9200;粒度为4KB

;2#段描述符 初始化代码段
mov dword [ebx+0x10],0x7c0001ff;基地址0x00007c00
mov dword [ebx+0x14],0x00409800;粒度为1字节 界限为0x1ff也就是大小为512KB

;3#段描述符 堆栈段描述符
mov dword [ebx+0x18],0x7c00fffe;基地址0x00007c00
mov dword [ebx+0x1c],0x00cf9600;粒度4KB 界限 0xffffefff

;4# 显存映射段描述符
mov dword [ebx+0x20],0x80007fff ;基地址0x000b8000
mov dword [ebx+0x24],0x0040920b ;粒度为字节

mov word [cs:pgdt+0x7c00],39 ;描述符表的界限
lgdt [cs:pgdt+0x7c00]

in al,0x92
or al,0000_0010B
out 0x92,al;打开a20

cli;保护模式下中断尚未初始化,先屏蔽

mov eax,cr0
or eax,1
mov cr0,eax
;进入保护模式

jmp dword 0x0010:flush;[16位描述符选择子,32位偏移]

[bits 32]
flush:
mov eax,0x0008;00000000_00001_000,所以选择子的描述符索引是1 是4GB数据段那个(刚好段选择子和对应描述符在GDT内的偏移一样)
mov ds,eax;

mov eax,0x0018
mov ss,eax
xor esp,esp

;加载系统核心程序到内存
mov edi,core_base_address
mov eax,core_start_sector
mov ebx,edi
call read_hard_disk_0;读核心程序的第一个扇区

;判断还剩下多少要读
mov eax,[edi];读核心程序的物理地址处的双字节(因为核心程序在汇编地址0处定义了一个双字节:核心程序的大小)
xor edx,edx
mov ecx,512
div ecx
or edx,edx
jnz @1;如果余数不是0,也就是说 总共有【商+1】个扇区,但是之前读了第一个扇区,还需要读 【商】个扇区
dec eax;减去之前读了一个扇区
@1:
or eax,eax
jz setup;如果商也是0,说明之前一个扇区就读完了,没有剩下的了

;继续读剩下的
mov ecx,eax ;32位下ecx控制loop
mov eax,core_start_sector
inc eax;从下一个逻辑扇区开始接着读

@2:
call read_hard_disk_0
inc eax;只需要手动改变eax,ebx在read_hard_disk_0里会自动+512
loop @2

;读完了就开始设置了
setup:
mov esi,[0x7c00+pgdt+0x02] ;通过4GB数据段访问pgdt的表的物理地址

;建立公用例程段描述符
mov eax,[edi+0x04];edi还是指向核心程序的起始物理地址呢(ds的段基址为0 偏移地址就是物理地址了)[edi+0x04]记录着 公共例程的汇编地址
mov ebx,[edi+0x08];核心数据段汇编地址
sub ebx,eax ;公用例程段的长度
dec ebx;ebx即段界限
add eax,edi ;共用历程段的世界物理地址
mov ecx,0x00409800
call make_gdt_descriptor
mov [esi+0x28],eax
mov [esi+0x2c],edx

;建立核心数据段描述符
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] ;保护模式下共读6字节 前4字节是偏移地址 后两字节是 段选择子
;----------------------------------------------------------
read_hard_disk_0:;输入参数 eax 逻辑扇区号 ds:ebx 目标缓冲区地址
push eax		;返回 ebx=ebx+512
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 ;15-8

inc dx ;0x1f5
shr eax,cl
out dx,al ;23-16

inc dx;0x1f6
shr eax,cl
or al,0xe0 ; 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
ret

;----------------------------------------------------------
make_gdt_descriptor: ;输入 eax 线性基地址 ebx 段界限 ecx 属性(各属性都在原始位置 高32位中的对应位置,没有用到的位置就为0) 返回 edx:eax 完整的64位描述符
mov edx,eax
shl eax,16
or ax,bx;构造描述符的低32位

and edx,0xffff0000;获得段界限的高16位
rol edx,8;循环左移  ,但是这导致段界限的高16位 中高8位和低8位的位置反了
bswap edx ;交换成正确的

xor bx,bx;ebx低16位清0
or edx,ebx;装配段界限的高4位

or edx,ecx;装配属性
ret

;----------------------------------------------------------
pgdt dw 0
dd 0x00007e00 ;gdt的物理地址
;----------------------------------------------------------
times 510-($-$$) db 0
db 0x55,0xaa

​ 内核代码:

;以下常量定义。内核的大部分内容都应该固定
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数据段选择子

;==========================================================

SECTION header vstart=0; align=16可省略
core_length dd core_end ;核心程序总长度#00
sys_routine_seg dd section.sys_routine.start;共用例程段位置4
core_data_seg dd section.core_data.start;核心数据段位置8
core_code_seg dd section.core_code.start;核心代码段位置c
core_entry dd start  ;代码入口地址(有效地址) 0x10
dw core_code_seg_sel ;核心代码段 选择子

;==========================================================
[bits 32]
SECTION sys_routine vstart=0;系统公共例程段

;----------------------------------------------------------
put_string:;字符串显示	输入ds:ebx 串地址 串以0为结束符
push ecx
.getc:
mov cl,[ebx]
or cl,cl
jz .exit
call put_char
inc ebx
jmp .getc

.exit:
pop ecx
retf ;段间返回 pop ip pop cs

;----------------------------------------------------------
put_char:;在当前光标处显示一个字符,并推进光标,仅用于段内调用 输入cl ascii码
pushad;压入所有通用寄存器 push eax push ebx ... push edi

;以下取光标位置
mov dx,0x3d4
mov al,0x0e
out dx,al
inc dx
in al,dx
mov ah,al;光标位置高16位

dec dx
mov al,0x0f
out dx,al
inc dx
in al,dx;光变位置低16位
mov bx,ax;bx即光标位置的16位数字

cmp cl,0x0d ;回车符?
jnz .put_0a
mov ax,bx
mov bl,80
div bl
mul bl
mov bx,ax;通过先/80 商再* 80实现光标到最后一行开头
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;bx=bx*2
mov [es:bx],cl
pop es

;推进光标
shr bx,1;bx恢复
inc bx

.roll_screen:
cmp bx,2000;?要滚屏
jl .set_cursor ;less 小于 可以用于有符号数 jb无符号数

push ds
push es
mov eax,video_ram_seg_sel
mov ds,eax
mov es,eax
cld;df位置0 正向串传输
mov esi,0xa0 ;从第二行开始
mov edi,0x00 ;传到第一行
mov ecx,1920 ;传24行
rep movsw ;书中这句是movsd我认为错了,因为总共要传送80*24个字符,一个字符加一个属性共一个字 所以是 movsw 等会去试试看看谁错了
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
out dx,al
dec dx
mov al,0x0f
out dx,al
inc dx
mov al,bl
out dx,al

popad
ret

;----------------------------------------------------------
read_hard_disk_0:;从硬盘读取一个扇区 输入 eax 逻辑扇区号 ds: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                               ;段间返回 

;----------------------------------------------------------
put_hex_dword:;在当前光标处以16进制来显示一个双字并推进光标 输入 edx 要显示的数字,可以用来调试
pushad
push ds
mov ax,core_data_seg_sel
mov ds,ax

mov ebx,bin_hex;ebx为数字-ascii码转换表首地址
mov ecx,8
.xlt:
rol edx,4;将最高4位放到最低4位
mov eax,edx
and eax,0x0000000f
xlat;将[ds:bx+al]送入al,也就是查表指令,找到对应的16进制数的ascii码
 
push ecx
mov cl,al
call put_char
pop ecx
loop .xlt

pop ds
popad
retf

;----------------------------------------------------------
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;eax即使下一次分配时的起始地址
;应当由检测可用内存数量的指令,但是这里没有写

mov ecx,[ram_alloc];返回分配的起始地址
mov ebx,eax
and ebx,0xfffffffc ;11111111_11111111_11111111_11111100
add ebx,4;强制4字节对齐
test eax,0x00000003;test 相当于or当时结果不赋值 00000011,如果结果为0说明4字节对齐了
cmovnz eax,ebx;test eax,3不为0时,也就是eax本身不对齐时就用ebx
mov [ram_alloc],eax

pop ebx
pop eax
pop ds
retf

;----------------------------------------------------------
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];将GDTR寄存器的基地址和边界信息保存到指定的内存位置,48位

mov ebx,mem_0_4_gb_seg_sel
mov es,ebx

movzx ebx,word [pgdt] ;movzx用于将较短位数的源操作数传送到较大的目的操作数,用0扩展高位
inc bx;GDT表的字节长度,也是新的描述符的偏移
add ebx,[pgdt+2] ;新描述符的线性地址

mov [es:ebx],eax
mov [es:ebx+4],edx

add word [pgdt],8;修改描述符表界限

lgdt [pgdt]

mov ax,[pgdt] ;得到界限
xor dx,dx
mov bx,8
div bx;例如两个描述符 0 1则 段界限15 15/8=1.。。7所以可以直接当作索引
mov cx,ax
shl cx,3;左移3位 成为正确的段选择子

pop es
pop ds

pop edx
pop ebx
pop eax
retf

;----------------------------------------------------------
make_seg_descriptor:;构造段描述符
;输入 eax 线性地址 ebx 段界限 ecx 属性(高32位属性的记录,非属性为0) 返回edx:eax 描述符
mov edx,eax
shl eax,16;线性地址低16位
or ax,bx;描述符低32位构造完成

and edx,0xffff0000;线性地址高16位
rol edx,8;32位 最高8位是线性地址的16-23 最低8位是线性地址的24-31位,和正确的位置相反
bswap edx;交换成正确的

xor bx,bx;ebx低16位清0,因为低16位已经装到描述符的低32位里了,界限只剩下高4位没装配了
or edx,ebx ;装配高4位

or edx,ecx;装配属性
retf

;==========================================================
SECTION core_data vstart=0 ;系统核心数据段

;----------------------------------------------------------
pgdt dw 0
dd 0 ;用于设置和修改GDT
ram_alloc dd 0x00100000 ;记录下次分配内存时的起始地址

;符号地址检索表
salt:
;----------------------------------------------------------
salt_1: db '@PrintString'
times 256-($-salt_1) db 0;开头256个字节用于保存函数名
dd put_string   
dw sys_routine_seg_sel
;----------------------------------------------------------
salt_2: db '@ReadDiskData'
times 256-($-salt_2) db 0;开头256个字节用于保存函数名
dd read_hard_disk_0   
dw sys_routine_seg_sel
;----------------------------------------------------------
salt_3: db '@PrintDwordAsHexString'
times 256-($-salt_3) db 0;开头256个字节用于保存函数名
dd put_hex_dword   
dw sys_routine_seg_sel
;----------------------------------------------------------
salt_4: db '@TerminateProgram'
times 256-($-salt_4) db 0;开头256个字节用于保存函数名
dd return_point  
dw core_code_seg_sel
;----------------------------------------------------------
salt_item_len equ $-salt_4;单项长度常数 256+4+2=262
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_5 db  '  Loading user program...',0

do_status db 'Done.',0x0d,0x0a,0 ;先回车再换行 \r\n

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

;==========================================================
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 ;切换到内核数据段

mov eax,esi 
mov ebx,core_buf
call sys_routine_seg_sel:read_hard_disk_0

;以下判断整个程序有多大
mov eax,[core_buf];用户程序起始位置的4字节就是大小
mov ebx,eax
and ebx,0xfffffe00;使其512字节对齐
add ebx,512
test eax,0x000001ff;程序的大小正好是512的倍数吗?
cmovnz eax,ebx ;如果不是就用对齐后+512的ebx 
mov ecx,eax;如果是对齐就直接用 (相当于之前用的/512看有没有余数)

call sys_routine_seg_sel:allocate_memory;申请内存
mov ebx,ecx;申请到的首地址
push ebx
xor edx,edx
mov ecx,512
div ecx
mov ecx,eax;总扇区数

mov eax,mem_0_4_gb_seg_sel
mov ds,eax

mov eax,esi;起始扇区号
.b1:
call sys_routine_seg_sel:read_hard_disk_0
inc eax
loop .b1 ;循环读完整个用户程序(虽然一开始读了1个头部扇区,但是那是读到内核缓冲区的,不是最终的内存地址,所以这次还是带上第一个扇区,没有-1)

;建立程序头部段描述符
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;将原来是header段长度的0x04有效地址的内容改成段选择子

;建立程序代码段描述符
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;写入段选择子

;建立程序数据段描述符
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;写入段选择子

;建立程序堆栈段描述符
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;写入段选择子

;重定位salt
mov eax,[edi+0x04];用户程序header段选择子
mov es,eax;es->用户程序header段
mov eax,core_data_seg_sel
mov ds,eax

;下面代码是对用户程序内调用的函数地址重定向,讲用户程序header段的salt每个项都拿到内核salt中比对,如果发现函数名完全一样,就重写用户程序内部的函数地址

cld;置DF位0 esi edi 递增
mov ecx,[es:0x24];用户程序的salt条目数 外循环次数
mov edi,0x28;0x28是用户程序salt的有效地址
.b2:
push ecx
push edi

mov ecx,salt_items;内核中salt条目数 内循环次数
mov esi,salt;内核salt有效地址
.b3:
push edi
push esi
push ecx

mov ecx,64;函数表中,每个表项的名称区的比对次数 64
repe cmpsd;每次比较4字节(cmpsd ds:esi es:edi 以4字节位单位比较)
jnz .b4
mov eax,[esi];如果匹配,结束时esi即指向地址数据
mov [es:edi-256],eax;用户程序salt没有专门给函数地址和段选择子留下空间,所以找到内核中对应函数地址后,直接写在函数名里
mov ax,[esi+4]
mov [es:edi-252],ax

.b4:
pop ecx
pop esi
add esi,salt_item_len;比较下一个内核salt项目
pop edi;而用户程序条目由于没找到对应的 不变继续找
loop .b3

pop edi
add edi,256
pop ecx
loop .b2

mov ax,[es:0x04];返回用户程序header段选择子

pop es
pop ds

pop edi
pop esi
pop edx
pop ecx
pop ebx
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 ebx,message_5
call sys_routine_seg_sel:put_string
mov esi,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];控制权交给用户程序
;堆栈可能切换

return_point:
mov eax,core_data_seg_sel
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

;==========================================================
SECTION core_trail
;----------------------------------------------------------
core_end:

​ 用户程序:

;==========================================================
SECTION header vstart=0
program_length dd program_end ;程序总长度 0x00
head_len dd header_end  ;header段的总长度 0x04
stack_seg dd 0           ;用于接收堆栈选择子8
stack_len dd 1			;用于接收堆栈大小 c 

prg_entry dd start ;程序入口 10
code_seg dd section.code.start ;代码段位置 14
code_len dd code_end ;代码段长度 0x18

data_seg dd section.data.start;数据段位置 1c
data_len dd data_end ;数据段长度 20

;----------------------------------------------------------
;符号地址检索表(函数名)
salt_items dd (header_end-salt)/256;项数  24
salt: ;28
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:

;==========================================================
SECTION data vstart=0
buffer times 1024 db 0 ;缓冲区
message_1 db 0x0d,0x0a,0x0d,0x0a
db '**********User program is runing**********'
db 0x0d,0x0a,0
message_2 db '  Disk data:',0x0d,0x0a,0

data_end:

;==========================================================
[bits 32]

;==========================================================
SECTION code vstart=0
start:
mov eax,ds;ds是用户程序header段
mov fs,eax

mov eax,[stack_seg]
mov ss,eax
mov esp,0

mov eax,[data_seg]
mov ds,eax

mov ebx,message_1
call far [fs:PringString]

mov eax,100
mov ebx,buffer
call far [fs:ReadDiskData]

mov ebx,message_2
call far [fs:PrintString]

mov ebx,buffer
call far [fs:PringString]
jmp far [fs:TerminateProgram]

code_end:

;==========================================================
SECTION trail
;----------------------------------------------------------
program_end:

SharedScreenshot12

​ 上述代码运行正确(引导-内核-用户程序-读100扇区ascii文本)

0x01 代码详解

内核结构

​ 内核分为四个部分:初始化代码、内核代码段、内核数据段和公共例程段。

​ 初始化代码从BIOS那里接过处理器和计算机硬件的控制权,安装最基本的描述符,初始化最初的执行环境。然后从硬盘上读取和加载内核的剩余部分,创建组成内核的各个内存段的段描述符(内核的描述符必须在初始化部分创建,因为内核要用)

​ 内核数据段提供一段可读可写的内存空间,供内存自己使用

​ 公共例程段用于提供各种用途和功能的子过程以简化用户程序代码的编写。这些例程既可以内核使用,也可以用户程序使用。

​ 除此以外,内核还包括一个头部header段,记录各个段的汇编位置,用来高速初始化代码如何创建和安装内核的段描述符。

​ 内核通过在header段的约定位置,告诉初始化代码整个内核的大小,各个段的汇编地址和代码段入口的汇编地址,方便初始化程序加载内核,另外,初始化代码创建了内核段描述符后,并没有把描述符写回header段的头部,而是通过约定的方式,固定内核每个段的段选择子,并在内核代码开头,用equ常量宏(不占内存)来声明段选择子,这样即使初始化代码段修改了内核段描述符,内核也很容易修改。

内核的加载:

​ 代码开始的前两行定义了两个宏常熟,分别时内核加载的起始的内存地址和内核的起始逻辑扇区,方便修改。

​ 然后就是就是根据pgdt 标号所在内存空间给出的GDT的物理地址,来计算GDT的逻辑段地址和有效地址了,用的是64位除法。

​ 然后就是向GDT所在空间写入段描述符了。0#是处理器要求的,拦截空白选择子的;1#描述符是一个能随心所欲读写0-4GB空间的数据描述符,初始化代码和内核用的;2#描述符是初始化代码段,也是引导程序所在的段;

​ 3#则是粒度为4KB的栈段,从0x7c00-0x7c00+0xffffe*0x1000+0xfff,也就是0x6bff-0x7c00又因为边界不能达到,所以实际下边界是0x6c00,也就是0x6c00-0x7c00。这个栈段初始化代码使用,内核也使用。

​ 4#是数据段,0xb8000-0xcffff,用于显示字符,这个段进入内核后,也被使用

​ GDT安装好后,进入保护模式,开始加载内核,首先是先读取全部内核到内存,这又分两步完成。先读取内核的起始的第一个逻辑扇区,这个扇区里有内核的大小信息,然后通过这个信息,我们把剩下的扇区读完。(我们选择的内核地址是0x00040000,注意0-0x0009ffff 和 0x00100000-0xffeffff都可以使用,而中间的0x000a0000-0x000fffff和0xffff0000-0xffffffff是bios的rom)

​ 由于我们先读了一个扇区,而且ebx也在read_hard_disk_0里自动加上了512,而剩余的扇区我们也准备加载到第一个扇区所在的内存块的后面,所以,对于我们读取到的内核大小,除512后,先看余数是不是0,余数如果是0,说明整除了,那么商-1,就是我们还需要读取的扇区数,如果商业为0,那就不用再读了,如果余数不是0,我们512字节一读取,所以我们还需要读取商+1-1个扇区。(判断是不是0 常用 or eax,eax ,注意test是和逻辑与and运算类似,只不过不保存结果)

​ 我们进入保护模式后,对数据的操作都是受限的,我们读内核时地址是0x00040000,以及我们对gdt地址的读取,都是用的0-4GB这个可读可写的数据段,其他的段要么受到界限的限制,要么受到读写属性的限制(比如代码段)。

​ 要使内核运行起来,就要安装内核的各段的段描述符,那就要拿到GDT的物理地址,我们用的是mov esi,[0x7c00+pgdt+0x02],注意,这里的ds是0-4GB的数据段,为什么不用代码段,然后mov esi,[cs:pgdt+0x02],因为代码段不可读写,另外就是因为0-4GB数据段的段基址是0x00000000,所以,偏移(有效)地址就是物理地址,由于pgdt是编译时确定的相对于程序开头的汇编地址,所以,其实际的相对0x0000000的有效地址是0x7c00+pgdt,再+0x02偏移到gdt地址。

​ 后面就是安装段描述符,为了程序的灵活性,我们没有把段描述符写死,而是写了一个make_gdt_descriptor函数,根据输入的段基地址、段界限和段属性,来合成段描述符,因为有时候段的这三个信息,可能是程序运行时才确定。

​ 合成段描述符,使用了大量的位运算,其中有一个rol,是循环左位移,也就是最右边的一位数左移一位就到了最左边,还有一个bswap,是交换位数。其交换过程如下:bswap des -> des=>temp -> temp[31:24]=>des[7:0] -> temp[23:16]=>des[15:8] -> temp[15:8]->des[23:16] -> temp[7:0]=>des[31:24]

​ 注意我们用make_gdt_descriptor合成好段描述符后,还要安装,安装即是写入到GDT中对应的地址,(段选择字索引号),这个地址是和内核提前约定好的,也就是内核程序开头的常数。

​ 最后修改GDT的段界限,使用lgdt指令重新转入GDTR。至此内核安装结束,用jmp 指令转入 内核代码段运行,内核header段的0x10处6个字节 低4字节是内核入口在内核代码段的偏移地址,高2字节是内核代码段的选择子。而edi 仍记载着在0-4GB段内 内核程序起始地址相对于该段基地址的有效地址,现在ds还是该段,所以直接jmp [edi+0x10] 实现跳转(现在是32位保护模式,不用dword)

内核的执行

​ 代码段的入口处,先是将ds指向内核数据段(core_data_seg_sel是内核数据段选择子),然后将有效地址指向message_1 使用了call sys_routine_seg_sel:put_string 这一远调用。put_string是公共例程段的一个例程,在内部又调用了put_char,使用的是段内近调用,注意put_string 是以retf返回的,所以必须使用远调用(给出段选择子和偏移地址)

​ 接下来是显示cpu信息,使用的是 cpuid 指令,80486后的cpu支持该指令。在使用该指令之前,需要赋值eax以决定返回什么信息,返回的信息在eax ebx ecx edx中。信息以ascii码的形式显示。注意例如ebx里返回的值是 0x756e6547(对应字符’Genu’,’G’在最低为bl里)

​ 显示完cpu信息后就开始加载用户程序了,先是从硬盘读用户程序的起始逻辑扇区,注意我们这里用的是写死的50,最好的办法是用equ宏定义常量。由于内核的主要任务就是加载用户程序,会反复加载用户程序,所以我们把加载用户程序定义成了一个可以反复调用的功能load_relocate_program,也在内核代码段内。

用户程序结构:用户程序必须符合规定的格式,才能被内核识别和加载,主流的操作系统都会规定自己的可执行文件格式。在我们的用户程序中,可执行文件有个头部段,在头部段的指定位置记录对应的信息, 用户程序的大小、头部的大小、栈的段选择子(默认为0,由内核加载用户程序时写入)、栈的大小(用户程序希望分配的大小,粒度为4KB,由内核读取)、代码段入口的汇编地址、代码段的汇编地址(内核加载时会根据其计算最终代用户程序代码段基地址 并写回段选择子)、用户程序代码段长度、用户程序数据段汇编地址(同样回填)、用户程序数据段长度

​ 同时,操作系统给用户程序提供例程,因为例程的段地址和偏移地址可能会变化,所以用符号名(对应高级语言里的函数名)来列出使用的例程。

​ 所以用户程序头部还有一部分列举其调用的系统例程,每个符号名(例程名)占256字节,不足256用0填充。先是调用例程数量 再是每个例程的符号名(256字节一个)

​ 加载用户程序判断用户程序大小时,用的方法和在前面加载内核时不同:

(上面时判断用户程序大小,下面是判断内核大小)

mov eax,[core_buf]                 ;程序尺寸
mov ebx,eax
and ebx,0xfffffe00                 ;使之512字节对齐(能被512整除的数, 
add ebx,512                        ;低9位都为0 
test eax,0x000001ff                ;程序的大小正好是512的倍数吗? 
cmovnz eax,ebx                     ;不是。使用凑整的结果 

mov ecx,eax                        ;实际需要申请的内存数量
xor edx,edx
mov ecx,512
div ecx
or edx,edx
jnz @1;如果余数不是0,也就是说 总共有【商+1】个扇区,但是之前读了第一个扇区,还需要读 【商】个扇区
dec eax;减去之前读了一个扇区

​ 两种写法的区别在于,上面判断是用户程序不是512字节对齐,先做好没有对齐的准备,就是把大小eax复制一份到ebx,然后and ebx,0xfffffe00(11111111_11111111_11111110_0000_0000),这样就相当于先除512再用商乘512,去掉了余数,再加上512,这样ebx就是512字节对齐的eax,然后再判断eax是不是本来就512字节对齐的,test eax,0x000001ff (相当于and),cmovnz eax,ebx,该指令会在ZF=0,也就是不为0时 传送,而test为0就说明eax本来就是512对齐,如果不对其,那么把对齐后的ebx传送到eax。这样的好处是什么呢?不使用jmp,虽然现代处理器由分支预测计算,但是jmp对流水线的影响还是很大的,cmovnz cmovz cmove cmovng(not greater)等指令可以减少jmp的使用,应该替代下面的写法

​ 并且,我们这里读了用户程序的第一扇区,是读到内核定义的一个缓冲区里(core_buff标号处),拿到用户程序的大小信息后,我们并不把剩余的扇区继续读到第一扇区的后面,而是根据大小,把用户程序的所有扇区都读入内存(包括之前读的第一扇区),只不过要读入到的内存,是由内核调用allocate(分配)_memory例程动态分配来的

​ 在主流操作系统里,内存管理相当复杂,当需要分配内存时,内存管理程序将查找并分配大小相符的空闲块,当分配的块不再使用时,还要负责收回他们,以便再分配。内存空间紧张找不到空闲块或者空闲块的大小不能满足需求时,内存管理程序还要将很少使用的块换出到硬盘中,腾出空间满足当前需求,当下次这些块再被用到时,再用同样的方法从硬盘调回内存。

​ 而本章中的allocte_memory例程是一个极为简陋的动态分配内存的程序,在内核数据段有个标号[ram_alloc],对应着一个双字的偏移地址(初始为0x00100000,刚好在1MB之外),这个双字记录这下次分配的起始地址,每次当allocate_memory被调用时,就将这个地址返回,并将地址+分配出去的长度,作为下一次分配的地址重新写入ram_alloc对应地址的双字。值得注意的是,32位处理器建议内存地址是4字节对齐的,初始地址0x00100000是对齐的,如果分配出去的长度不是4字节对齐的,那么计算下一次分配的地址时,就给他4字节对齐:

mov ebx,eax;eax是忽略对齐要求时计算得到的下一次分配的地址
and ebx,0xfffffffc
add ebx,4                          ;强制对齐 
test eax,0x00000003                ;下次分配的起始地址最好是4字节对齐
cmovnz eax,ebx   
mov [ram_alloc],eax

​ 同样 我们使用test 和cmovnz 来避免jmp指令,注意allocate_memory也是retf返回,所以应该用 远调用 jmp 段选择子:偏移地址

​ 在load_relocate_program例程中,除了读取用户程序,还将用户程序的头部的段描述符生成并安装到GDT里了 安装用的时set_up_gdt_descriptor例程,该例程里有一条新指令 sgdt mem48

mov ebx,core_data_seg_sel          ;切换到核心数据段
mov ds,ebx
sgdt [pgdt]                        ;以便开始处理GDT

​ 该指令会向一个长度为6字节的内存空间写入GDTR的内容,这样我们就可以拿到GDT的起始线性地址和界限,以便添加新的段描述符。还有另一条新的**指令movzx ebx,word [pgdt]

 movzx ebx,word [pgdt]              ;GDT界限 

​ 为什么要把16为的GDT的界限传送到32为的ebx里?因为ebx还要+1再+32位的GDT的起始线性地址得到新的段描述符因该写入的地址,所以要放在32位的ebx里,而movzx 指令,就是用来将较小位数的源操作数送到较大位数的目的操作数,并以0补充高位(mov with zero-Extension),类似的指令还有 movzs 指令,也是较小位源操作数送到较大位目的操作数,但是以符号位补充高位(mov with sign-Extension)

​ 修改GDT后,要重写其界限,然后用lgdt mem48重新装载

mov ax,[pgdt]                      ;得到GDT界限值
xor dx,dx
mov bx,8
div bx                             ;除以8,去掉余数
mov cx,ax                          
shl cx,3                           ;将索引号移到正确位置 

​ 书中用了这样一段代码,在安装完段描述符后再来拿段选择子,私以为不必要,最终段界限(8*n-1,n为描述符个数)先除8用商再乘8,得到结果为8n-8,也就是 8 * (n-1)-1+1,实际上这个结果在算新的段描述符地址出现过:

movzx ebx,word [pgdt]
inc bx;

​ 这时的bx即是第n个段在GDT里的偏移地址,同时也恰好等于段选择子(假定段选择子第三位TI为0,RPL为00)

​ 用户程序头部段描述符 代码段描述符 数据段描述符都是根据已经读入内存的用户程序头部的信息生成并安装的,安装完后还要将段选择子写回对应的位置,值得注意的是,堆栈段用户程序没有创建,只在头部声明了大小,由内核通过allocate_memory来动态分配内存,提供栈段基址,而栈界限的生成就需要注意,用户程序头部关于堆栈大小的单位是4KB,所以我们也将用户程序堆栈段的G位设置1,粒度4KB,有 段基址-(段界限* 0x1000+0xfff+段基址+1)=用户堆栈大小*0x1000 (注意0x1000即是4KB),所以我们得到 0-(段界限+1)=用处程序堆栈大小 —》 0xfffff-用户程序堆栈大小=段界限,所以我们在程序中有:

mov ecx,[edi+0x0c]                 ;4KB的倍率 
mov ebx,0x000fffff
sub ebx,ecx      ;即得到段界限                  

​ 当将用户程序读入动态分配的内存、安装段描述符并回写段选择子后,load_rellocate_program例程最后要做的就是将 用户程序引用内核的例程表(书中称为salt:Symbol-Address Lookup Table 符号地址查找表),每一个表项(也就是声明的例程名)在内核数据段的salt表中对比,如果有相同的例程名,就将对应的 有效地址和段选择子写回用户程序header段的salt对应表项的例程名(因为表项只分配了例程名的空间,没有单独保存,所以写到例程名的指定空间。)

​ 这是一个典型的内外两层循环,实现起来不难。

​ 至此,用户程序加载完毕,并将用户程序header段的段选择子储存在ax里返回 内核代码段。

​ 内核代码段将当前esp保存在内核数据区的[esp_pointer]处,因为ss即将切换到用户程序的栈段,然后就是将ds指向用户程序header段,再利用header段0x10偏移处的用户程序代码段入口地址偏移 和 用户程序代码段选择子,jmp跳转,转到用户程序。至此内核工作结束。

//2020-7-9补充,在内核有个公共例程put_hex_dword,用来将一个双字以16进制显示,这个例程里用了一个xlat(table lookup translation)查表指令该指令的使用方法是,将ds:(e)bx 指向一张线性数据表,然后将会以al寄存器里的值作为偏移从表中读一个字节送回al

0x02 检测题

​ 在本章代码中,用户程序只给出建议的栈大小,但并不提供栈空间。现在,修改内核程序和用户程序,改由用户程序自行提供栈空间。要求:栈段必须定义在用户程序头部之后。

​ 思路,由用户程序自行提供,那就在编写用户程序时,在header后面加一个栈段,用 resb伪指令 保留对应大小的栈空间(4KB为单位),并在header段对应位置记录stack段的汇编地址和大小。

​ 而内核在加载时,load_rellocate_program不再申请内存来产生堆栈空间,而是和其他用户程序段一样,计算得到线性地址和界限,并添加属性,合成描述符并安装,最后回写选择子:

​ 用户程序修改如下(主要是header段)

;==========================================================
SECTION header vstart=0
program_length dd program_end ;程序总长度 0x00
head_len dd header_end  ;header段的总长度 0x04
stack_seg dd section.stack.start           ; ******修改 0x08
stack_len dd 1			;用于接收堆栈大小 0x0c 

prg_entry dd start ;程序入口 10
code_seg dd section.code.start ;代码段位置 14
code_len dd code_end ;代码段长度 0x18

data_seg dd section.data.start;数据段位置 1c
data_len dd data_end ;数据段长度 20

;----------------------------------------------------------
;符号地址检索表(函数名)
salt_items dd (header_end-salt)/256;项数  24
salt: ;28
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:

;******新增段
;==========================================================
SECTION stack align=16 vstart=0
resb 4096

内核修改如下(主要是load_rellocate_program):

load_relocate_program:;加载并重定位用户程序
		...
;建立程序堆栈段描述符
mov eax,edi
add eax,[edi+0x08]
mov ecx,[edi+0x0c]                 ;4KB的倍率 
mov ebx,0x000fffff
sub ebx,ecx                        ;得到段界限
mov ecx,0x00c09600
call sys_routine_seg_sel:make_seg_descriptor
call sys_routine_seg_sel:set_up_gdt_descriptor
mov [edi+0x08],cx;写入段选择子
		...

SharedScreenshot2020792

SharedScreenshot2020791

​ 上述代码运行正确