从0开始写内核(五)保护模式进阶
参考:
操作系统真像还原
https://wiki.osdev.org/Expanded_Main_Page
源码:https://github.com/wutiaojian000/AFKernel.git
本文地址:https://www.cnblogs.com/angel-fish/p/18870649
1. 获取物理内存容量
首先我们先学习一下物理内存是如何管理的,在linux2.6中使用detect_memory来获取内存容量,本质上是用BIOS中断0x15实现的,用了它的三个子功能,子功能号放在EAX或AX中:
(1)EAX=0xE820:遍历主机上所有内存。
(2)AX=0xE801:分别检测低15MB和16MB-4GB的内存,最大支持4GB。
(3)AH=0x88:最多检测出64MB内存,实际内存超出此容量也按64MB返回。
之前有说过,BIOS中断是实模式下的方法,我们在进入保护模式之前调用0x15中断来获取内存信息。
1.1 0x15中断子功能0xE820获取内存信息
BIOS按内存各部分的类型属性来划分系统内存,使用子功能0xe820能够迭代返回所有类型的内存信息,不同类型的内存信息统一用地址范围描述符ARDS来保存。

每个字段都是4个字节,一共20字节。每次0x15后BIOS都会返回一个这样的结构。其中的type用来描述内存的类型:

我们只用到低32位的属性。
以下是0xe820子功能的参数:


表格里说的很清楚,CF位为0且返回后EBX为0时的结果为最后一个ARDS结构,否则就一直调用0x10来获取,其他的就不过多介绍了。
1.2 0x15中断子功能0xE801获取内存信息
这个方法最大只能识别4GB的内存,分两组寄存器存放结果,低15MB内存以1KB为单位,大小存放在AX和CX寄存器中,两个寄存器的值是一样的,最大0x3c00,即0x3c00 * 1024 = 15MB;16MB-4GB的部分是以64KB为单位的(中间15-16MB是缺的,后面会讲),大小存放在BX和DX寄存器中,两个寄存器的值也是一样的。

这里我们会发现几个问题,一个是中间15MB-16MB内存并没有返回,另一个是为什么AX和CX一样,BX和DX一样。先回答第一个问题,书中修改了bochsrc.disk文件的megs参数来观察检查内存的大小:

可以看到检测到的内存总是比实际内存要小1MB,这是因为当时80286寻址能力是16MB空间,有些ISA设备需要访问15MB以上的内存空间做为缓冲区,也就是15-16MB这部分,后面为了兼容就保留下来了,这部分操作系统是没法访问的,被成为内存空洞memory hole。
至于第二问题,貌似没有确切的回答,咱就先用着吧。
调用步骤为:
(1)将AX写入0xe801;
(2)执行中断0x15;
(3)CF为0的情况下,返回后对应的寄存器就是结果。
1.3 0x15中断子功能0x88获取内存信息
子功能号0x88只能检测到64MB内存,不过显示时只显示1MB以上的内存,使用时需要把这1MB加上。

调用步骤:
(1)AX寄存器写入0x88;
(2)执行中断0x15;
(3)CF为0的情况下,返回后对应的寄存器就是结果。
1.4 实战内存容量检测
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
;jmp loader_start
;构建gdt
GDT_BASE: dd 0x00000000
dd 0x00000000
CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ;limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ;预留60个描述符的空位
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ;相当于((CODE_DESC - GDT_BASE) / 8 << 3) + TI_GDT + RPL0 原文这里的注释感觉不对,没有左移
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
total_mem_bytes dd 0 ;记录内存容量 位置为0xb00
;gdt指针 前两字节是GDT界限,后四个字节是GDT基址 这里还是实模式下,dw是16位
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
ards_buf times 244 db 0
ards_nr dw 0
loader_start: ;0xc00
;先是0xe820
xor ebx, ebx ;第一次ebx赋值为0
mov edx, 0x534d4150 ;edx只赋值一次,后面不用动
mov di, ards_buf ;缓冲区
.e820_mem_get_loop:
mov eax, 0x0000e820 ;执行0x15后,eax的值变成0x534d4150
mov ecx, 20 ;地址范围描述符结构是20字节
int 0x15
jc .e820_failed_so_try_e801 ;cf位为1则有错误发生,跳转下一个方法
add di, cx
inc word [ards_nr] ;自增指令
cmp ebx, 0 ;cf为0且ebx为0则全部返回
jnz .e820_mem_get_loop
;找出内存最大的结构,原文这里应该写错了,这里只是简单找出最大值,不是冒泡排序
mov cx, [ards_nr]
;遍历每一个结构
mov ebx, ards_buf
xor edx, edx
.find_max_mem_area:
mov eax, [ebx]
add eax, [ebx + 8] ;获取最大内存值,直接基址+对应块长度就行
add ebx, 20
cmp edx, eax
jge .next_ards
mov edx, eax
.next_ards:
loop .find_max_mem_area ;ecx为循环次数
jmp .mem_get_ok
;功能号0xe801
.e820_failed_so_try_e801:
mov ax, 0xe801
int 0x15
jc .e801_failed_so_try_88
;先算出低15MB内存
mov cx, 0x400 ;ax现在是内存容量,1KB为单位
mul cx
shl edx, 16 ;16MB以上以64KB为单位
and eax, 0x0000ffff ;之前记成mul16位乘法结果存eax,然后这里给我看懵了。mul16位结果存dx:ax。
or edx, eax
add edx, 0x100000 ;
mov esi, edx
;再将16MB以上的内存算出来
mov ecx, 0x10000
xor eax, eax
mov ax, bx
mul ecx
add esi, eax ;只能算出4g的内存
mov edx, esi
jmp .mem_get_ok
.e801_failed_so_try_88:
mov ah, 0x88
int 0x15
jc .error_hlt
and eax, 0x0000ffff
mov cx, 0x400
mul cx
shl edx, 16
or edx, eax
add edx, 0x100000
.mem_get_ok:
mov [total_mem_bytes], edx
.error_hlt:
jmp $
改一下mbr.S最后的跳转
mov ax, LOADER_BASE_ADDR
add ax, 0x300
jmp ax
nasm -I include/ -o mbr.bin mbr.S
dd if=mbr.bin of=/home/zcm/bochs/hd60M.img bs=512 count=1 conv=notrunc
nasm -I include/ -o loader.bin loader.S
dd if=loader.bin of=/home/zcm/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc

看到total_mem_bytes处确实是32MB。
这里查看一下e820返回的ARDS表。

先看看表里有几个结构,这里显示有6个。

看到倒数第二项,0x01ff0000+0x00010000=0x02000000,是32MB,最后那项加起来超过4GB了。
2. 启用分页
之前有提到过段描述符有一个A字段,用于表示该段是否有被访问过。每次访问到该段时,该位由CPU置1,由操作系统清0,在一个周期内操作系统统计出该段为1的次数就知道它的使用频率了,很多换入换出的策略就需要这个频率。
2.1 一级页表
一级页表:访问页表(页表地址存在cr3中)->根据索引找到对应页表项->找到对应页的物理地址->页物理地址+页内偏移得到实际物理地址。页表地址和页表项中的地址都是物理地址,不会是虚拟地址,否则虚拟地址又要查找页表,这样就递归了。1页是4KB,20位的索引,一个页表项是4字节。
2.2 二级页表
一级页表下每个进程全满都会有4MB大小的页表(1MB个*4字节),体积太大;操作系统需要占用虚存的高1GB空间,进程的页表需要提前建好。
二级页表是1MB个页平均放在1KB个页表(第二级)中每个表项4字节,然后这1KB个页表再由一个4KB大小的页目录表管理(第一级),每个表项也是4字节。一二级页表中的地址都是物理地址。

页目录项与页表项

高20位用于记录对应页表或页面的物理地址。
P代表存在位,为1代表在内存中,0代表不在。通过P位和pagefault来实现虚存管理。
RW为读写位,1代表可读可写,0代表可读不可写。
US为普通用户/超级用户位,为1时处于user级,任意特权级别可以访问该页;0代表supervisor级,只允许特权级为0,1,2访问该页。
PWT为页级通写位/页级写透位,为1时代表该页采用通写方式,不仅是普通内存还是高速缓存(后面看看怎么用)。
PCD为页级高速缓存禁止位,1代表该页启用高速缓存,0表示禁止将该页缓存。
A代表访问位,1代表被访问过了,与段描述符的A和P差不多,可以用于访问频率的统计。该位由CPU设置。
D为脏页位,只对页表项有效,对页目录项无效,对内存中页面进行写操作后就将此位置1。
PAT为页属性表位,能在页面一级的粒度上设置内存属性(后面看看)。
G为全局位,为1表示该页为全局页,0表示不是,全局页表示该页在TLB(快表)中缓存。清空快表的方式是invlpg对单独的虚拟地址条目清理,或重新加载cr3直接清空TLB(这里清空TLB是否还会清空对应条目的G位?)。
AVL表示可用,可选。
开启分页的步骤:
(1)准备好页目录表和页表。
(2)将页表地址写入控制寄存器cr3.
(3)寄存器cr0的PG位置1(开启分页,cr0在第4章有介绍)。

页目录基址寄存器cr3和页目录表项很像,页目录项低12位全部设置为0即可,只要把页目录表的物理地址高20位填到cr3的该20位即可。控制寄存器可以和通用寄存器传输数据,所以直接用mov指令赋值就行。
cr0寄存器的PG位是开启分页的开关,开启后段部件输出的线性地址变成虚拟地址。
2.3 规划页表并启用分页
首先操作系统是共享与用户进程的,包括页表也是。我们模仿linux,操作系统使用高1GB的内存空间,即所有进程的高1GB虚拟地址实际指向同一片物理内存。
页目录表我们放在0x100000,页表挨着页目录表放。

我们的内核不大,mbr、loader和内核不超过1MB,存放在物理内存的低1MB空间内。
;include/boot.inc
PAGE_DIR_TABLE_POS equ 0x100000
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start
;构建gdt
GDT_BASE: dd 0x00000000
dd 0x00000000
CODE_DESC: dd 0x0000FFFF
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000FFFF
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ;limit=(0xbffff-0xb8000)/4k=0x7
dd DESC_VIDEO_HIGH4
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ;预留60个描述符的空位
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ;相当于((CODE_DESC - GDT_BASE) / 8 << 3) + TI_GDT + RPL0 原文这里的注释感觉不对,没有左移
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
;gdt指针 前两字节是GDT界限,后四个字节是GDT基址 这里还是实模式下,dw是16位
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
loadermsg db '2 loader in real'
loader_start:
mov sp, LOADER_BASE_ADDR
mov bp, loadermsg
mov cx, 17
mov ax, 0x1301
mov bx, 0x001f
mov dx, 0x1800
int 0x10
;准备进入保护模式
;1.打开A20
;2.加载gdt
;3.cr0的pe为置1
in al, 0x92
or al, 0000_0010b
out 0x92, al
lgdt [gdt_ptr]
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线
[bits 32]
p_mode_start:
mov ax, SELECTOR_DATA
mov ds, ax
mov es, ax
mov ss, ax
mov esp, LOADER_STACK_TOP
mov ax, SELECTOR_VIDEO
mov gs, ax
mov byte [gs:160], 'P' ;第二行打印字符'P',一行是80个字符
;创建页目录表并初始化页内存位图
call setup_page
;将描述符表地址及偏移量写入内存gdt_ptr
sgdt [gdt_ptr]
;将gdt描述符中的视频段描述符段基址+0xc0000000
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000 ;0x18是第4条段描述符,4字节处是段基址高8位
;gdt基址加上0xc0000000,使其成为内核所在的高地址
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000;栈指针也映射到内核地址
;把页目录地址赋给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
;打开cr3的pg位
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
;开启分页后,用gdt新的地址重新加载
lgdt [gdt_ptr]
mov byte [gs:320], 'V'
jmp $
setup_page:
;先把页目录占用空间清0
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
;创建页目录表项
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000
mov ebx, eax
;下面将目录项0和0xc00都存成第一个页表的地址
;对应0-0xc03fffff和0-0xc00fffff对应的地址都指向相同的页表
;为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P
;用户属性所有特权都可以访问 可读可写 在内存中存在
mov [PAGE_DIR_TABLE_POS + 0x0], eax
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ;0xc00以上对应内核空间页表 0xc0000000-0xffffffff
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ;页目录表项最后一项放页目录表自己的地址
;创建页表项
mov ecx, 256 ;1MB低端内存,1MB/4KB=256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P
.create_pte:
mov [ebx + esi * 4], edx ; ebx是第一个页表的地址
add edx, 4096
inc esi
loop .create_pte
;创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;第二个页表的位置
or eax, PG_US_U | PG_RW_W | PG_P
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ;c00和最后一项都写过了,所以只需要在写254项
mov esi, 769 ;768目录项写过了
.create_kernel_pde:
mov [ebx + esi * 4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
nasm -I include/ -o mbr.bin mbr.S
dd if=mbr.bin of=/home/zcm/bochs/hd60M.img bs=512 count=1 conv=notrunc
nasm -I include/ -o loader.bin loader.S
dd if=loader.bin of=/home/zcm/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc

成功,中间还出现一个小插曲,mov ecx, 254这里当时写成256,把第一个页表的第一项给更新掉,导致后面寻址出现错误,还调了半天。

这里将虚拟地址的0xc0000000-0xc00fffff和0x00000000-0x000fffff映射到了同一个区域,个人理解是为了让段寄存器的内容在开启分页后仍然有效,否则会由于寻址方式的改变导致代码跑飞。0x00000000-0x000fffff的映射相当于低1MB地址的映射保持不变。
将最后一个页目录项指向页目录表本身,说明要想访问页目录表,需要访问虚存中的最后一个页面。
代码中还初始化了所有内核页表项,为所有内核页表项分配了对应的页表,这是为了防止之后内核空间需要添加页面时,还要单独为每一个进程的内核空间拷贝,毕竟内核空间是共享的。

书中修改了mbr的跳转指令,我这里没改,所以gdt基址是0xc0000903。
2.4 用虚拟地址访问页表
开启分页后,我们访问内存都要通过虚拟地址,页表也是一样。

看到这里的映射,前两条好说,第三条指0xffc00000(映射到页目录表)|0x00000000(映射到页目录表的第一项)|0x00000xxx,最后映射到的是第一个页表;
第四条指内核空间1GB,不过少了两个页面;
第五条指0xffc00000(映射到页目录表)|0x003ff000(映射到页目录表的最后一项)|0x00000xxx,最后映射到的是页目录表本身。
提炼一下就可以得到修改页表的方法:
(1)0xfffff000|页目录表项偏移,得到页目录表的表项;
(2)0xffc00000|页目录表项索引<<12|页表项偏移。
2.5 快表TLB
TLB想必大家都有所耳闻,这里就直入主题。

TLB存储虚拟地址到物理地址高20位的映射,还有一些属性信息。只有p为1即在内存中的页表项才有机会载入的TLB中。当有页表项发生修改时,TLB需要及时更新,与原页表保持同步,不过更新操作是由操作系统完成的。更新TLB的方法有两种:
(1)重新加载cr3,这会使整个TLB失效;
(2)invlpg(invalidate page),使某个条目失效,处理器通过虚拟地址来检索TLB,因此指令格式为invlpg m,主要m不是立即数,表示地址,一定要注意。如invlpg [0x1234]。
3. 加载内核
3.1 用C写内核
先简单写一个
int main()
{
while(1)
{
;
}
return 0;
}
gcc -c -o out/main.o kernel/src/main.c
这里生成的.o文件只是目标文件,也叫重定位文件,里面用到的符号(变量和函数)还没有编排地址。

使用nm指令可以查看elf文件的符号。

使用ld链接指令来链接,
ld out/main.o -Ttext 0xc0001500 -e main -o out/main.bin
-Ttext指示起始虚拟地址为0xc0001500,-e指示程序起始地址(即说明程序从哪里开始执行),可以是符号。如果程序中有_start符号,那就直接从这个符号开始。
为了方便程序的重定位,编译出的二进制文件会在开头加上一个文件头,包含程序起始地址、程序大小等信息。调用方在调用时先处理文件头,再将程序体载入,跳转到入口地址处执行。
3.2 elf格式

简单来说,elf header描述各种头的信息,包括程序头和节头,程序头描述段的信息,节头描述段下节的信息。

这张图展示了elf格式的两个主要作用,链接和运行,两个视角下处理elf略有不同。elf中的定义可以在linux系统/usr/include/elf.h中找到。
(1)elf header
elf header中数据类型

elf header结构

e_ident数组

e_type


e_machine

e_version共4字节,表示版本信息;
e_entry共4字节,指明操作系统运行此程序时,将控制权转交到的虚拟地址;
e_phoff共4字节,指明程序头表在文件中的偏移;
e_flags共4字节,指明与处理相关的标志;
e_ehsize共2字节,为elf header的大小;
e_phentsize共2字节,指节头表中每个条目的大小;
e_shnum共2字节,节头表中节头的数量;
e_shstrndx共2字节,string name table在节头表中的索引。
(2)程序头表

p_type

p_offset共4字节,本段在文件中起始偏移字节;
p_vaddr共4字节,本段在内存中起始虚拟地址;
p_paddr共4字节,仅用于与物理地址相关的系统,保留;
p_filesz共4字节,本段在文件中的大小;
p_memsz共4字节,本段在内存中的大小;
p_flags共4字节,与本段相关的标志

p_align共4字节,指明本段在文件中和内存中的对齐方式,0、1代表不对齐,否则应该是2的幂次。
(3)节头表
这里不介绍。
3.3 将内核装入内存
我们把kernel放在第9扇区,一次写入200个扇区,直接一步到位。
gcc -c -o out/main.o kernel/src/main.c && \
ld out/main.o -Ttext 0xc0001500 -e main -o out/kernel.bin && \
dd if=out/kernel.bin of=/home/zcm/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc
还要修改loader.S,需要把内核文件加载到缓冲区,放在对应的虚拟地址跳过去执行。
kernel/include/boot.inc
KERNEL_START_SECTOR equ 0x9
KERNEL_BIN_BASE_ADDR equ 0x70000
;elf属性
PT_NULL equ 00b
kernel/src/loader.S
mov eax, KERNEL_START_SECTOR
mov ebx, KERNEL_BIN_BASE_ADDR
mov ecx, 200
call rd_disk_m_32
call setup_page
rd_disk_m_32:
mov esi, eax ;备份eax
mov di, cx ;备份cx
;读写硬盘
;设置读取的扇区数
mov dx, 0x1f2
mov al, cl
out dx, al ;读取的扇区数
mov eax, esi ;恢复eax
;存入LBA地址
;写入端口0x1f3
mov dx, 0x1f3
out dx, al
;写入端口0x1f4
mov cl, 8
shr eax, cl
mov dx, 0x1f4
out dx, al
;写入端口0x1f5
shr eax, cl
mov dx, 0x1f5
out dx, al
;写入端口0x1f6
shr eax, cl
and al, 0x0f
or al, 0xe0 ;7-4位为1110
mov dx, 0x1f6
out dx, al
;写入端口0x1f7读命令
mov dx, 0x1f7
mov al, 0x20
out dx, al
;检测硬盘状态
.not_ready:
nop
in al, dx
and al, 0x88 ;第4位为1代表准备好传输
cmp al, 0x08
jnz .not_ready
;从0x1f0读取数据
mov ax, di ;待读取的扇区数
mov dx, 256
mul dx ;mul的被乘数放ax里,结果也放ax里,这里因为是按字读的,16位,所以是读256次
mov cx, ax
mov dx, 0x1f0
.go_on_read:
in ax, dx
mov [ebx], ax
add ebx, 2
loop .go_on_read ;用cx来计数
ret ;返回到函数调用处
内核加载完毕后,需要将elf格式的文件中的段展开到内核相应位置。下面是初始化内核的代码
kernel/src/loader.S
;将kernel中的段搬到对应位置
kernel_init:
xor eax, eax
xor ebx, ebx
xor ecx, ecx
xor edx, edx
mov dx, [KERNEL_BIN_BASE_ADDR + 42];42偏移处是e_phentsize,program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28];42偏移处是e_phoff,第一个program header在文件中的偏移
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44];有几个program header
.each_segment:
cmp byte [ebx + 0], PT_NULL
je .PTNULL
;为函数mem_cpy压入参数 mem_cpy(dst, src, size)
push dword [ebx + 16] ;size
mov eax, [ebx + 4]
add eax, KERNEL_BIN_BASE_ADDR
push eax ;src
push dword [ebx + 8]
call mem_cpy
add esp, 12 ;清理压入的三个参数
.PTNULL:
add ebx, edx
loop .each_segment
ret
mem_cpy:
cld
push ebp
mov ebp, esp
push ecx
mov edi, [ebp + 8] ;dst
mov esi, [ebp + 12] ;src
mov ecx, [ebp + 16] ;size
rep movsb ;主字节拷贝
;恢复环境
pop ecx ;ecx对外层循环loop .each_segment有用
pop ebp
ret
这里面关于数据复制的指令是movsb,movsw,movsd,一次分别搬运byte,word,dword,从ds:[e]si搬运到es:[e]di指向的地址。
rep则是按ecx中的次数重复执行后面的指令。cld与sld控制地址的自增和自减,这里rep每执行一次,对应esi和edi中的地址自动加上一个数据的长度,放在movs之前就行。
还有其他的一些字符串处理指令,比如ins[bwd],out[bwd],lods[bwd],stos[bwd],不是所有指令会同时自增/减两个地址的内容。
最后在开启分页后调用kernel_init
lgdt [gdt_ptr]
jmp SELECTOR_CODE:enter_kernel ;刷新流水线
enter_kernel:
call kernel_init
mov esp, 0xc009f00
jmp KERNEL_ENTRY_POINT

nasm -I kernel/include/ -o out/mbr.bin kernel/src/mbr.S
dd if=out/mbr.bin of=/home/zcm/bochs/hd60M.img bs=512 count=1 conv=notrunc
nasm -I kernel/include/ -o out/loader.bin kernel/src/loader.S
dd if=out/loader.bin of=/home/zcm/bochs/hd60M.img bs=512 count=4 seek=2 conv=notrunc
gcc -m32 -c -o out/main.o kernel/src/main.c && \
ld -m elf_i386 out/main.o -Ttext 0xc0001500 -e main -o out/kernel.bin && \
dd if=out/kernel.bin of=/home/zcm/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc
在调试中发现用readelf命令查看elf非常方便。调试发现在解析内核的时候复制了不必要的段。

我这里的修改是只复制有可执行权限的段(书中的循环会复制到不必要的段)。
;将kernel中的段搬到对应位置
kernel_init:
xor eax, eax
xor ebx, ebx
xor ecx, ecx
xor edx, edx
mov dx, [KERNEL_BIN_BASE_ADDR + 42];42偏移处是e_phentsize,program header大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28];42偏移处是e_phoff,第一个program header在文件中的偏移
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44];有几个program header
.each_segment:
cmp byte [ebx + 0], PT_NULL
je .PTNULL
;添加上下面这几行
mov eax, [ebx + 24]
and eax, 0x00000001
cmp eax, 0x00000000
je .PTNULL
mov ecx, 0x00000001
;为函数mem_cpy压入参数 mem_cpy(dst, src, size)
push dword [ebx + 16] ;size
mov eax, [ebx + 4]
add eax, KERNEL_BIN_BASE_ADDR
push eax ;src
push dword [ebx + 8]
call mem_cpy
add esp, 12 ;清理压入的三个参数lb
.PTNULL:
add ebx, edx
loop .each_segment
ret

这里编译出的kernel,gpt解释是编译器添加了一些辅助代码,不太懂,不过功能确实没问题,后面再验证一下。
4. 特权级
4.1 TSS
TSS是任务状态段,是处理器在硬件上原生支持多任务的一种实现。每一个任务都必须要有这个结构。

这里的前28个字节可以看到3组栈相关的寄存器,esp0、esp1、esp2和SS0、SS1、SS2。x86架构下特权级从ring0-3,每个特权级下有一个栈,且只在对应的栈下运行。特权级的转移分成两类,一类是中断门、调用门等实现低特权级向高特权级转移,另一类是调用返回指令从高特权级向低特权级转移。由于TSS只记录转移后特权级高的栈(特权级低的栈在调用之前就已经被保存下来了,压入了高特权级的栈),所以只记录高的三个栈结构。而且不是所有任务都有三个特权级的栈,特权级为3的任务可以有三个因为它可以往上最多转移3次,特权级为2、1、0的以此类推。TSS中的栈指针是固定的,相当于每次使用高特权级栈都是在复用这些栈指针。TSS由TR寄存器加载,执行不同任务时加载不同的TSS就行。
4.2 CPL与DPL
之前在学gdt、段选择子和段寄存器的时候有查过这个内容。段寄存器的低2位为RPL,不仅称为请求特权级,也被称为CPU当前特权级CPL(仅限于代码段寄存器)。一定要注意,CPL是指当前代码的特权级,就是存在CS里的,指令可以访问数据段、代码段,而访问的部分的段选择子的RPL是由我们设置的,即我们希望以什么特权级去访问目标数据/代码。DPL是在段描述符中的,由它为RPL赋值。当代码发生跳转时,由于代码段发生变化,特权级就有可能发生改变。当CPU检查完特权变换条件后,就可以给CS的RPL赋值了。
介绍一下几条访问规则:
(1)受访者为数据段时,只有访问者的权限不小于受访者的DPL时才可以访问;
(2)受访者为代码段时,只有访问者的权限等于受访者的DPL时才可以访问。
想要从低特权级代码转移到高特权级代码,一种方法是提权,另一种方法是一致性代码段。非一致性代码段是我们刚才所说的平权转移的情况,而一致性代码段要求目标段的特权级(DPL)一定要大于转移前的CPL。很奇怪吧,但其实还有一点没说,转移后的权限要和转移前的CPL保持一致,也就是说转移的操作并不会使CPU的权限提高。这里RPL是不参与的,毕竟CPL保持不变,不需要检查请求权限。还要注意所有数据段都是非一致的。
还有门结构,CPU只有通过门结构才能实现从低特权级转移到高特权级,我们后面也会通过门来实现系统调用。门结构是记录一段程序起始地址的描述符,与以前介绍的段描述符不同,门结构是用来描述一段程序的。门结构同样也是8个字节,共有4种门结构。

任务门可以放在gdt、ldt和idt(中断描述符表)中,调用门可以放在gdt和ldt中,中断门和陷阱门只放在idt中。任务门和调用门都可以通过call和jmp指令来调用,毕竟都放在描述符表中,而陷阱门和中断门位于idt中,只能由中断信号触发调用。下面介绍这几种门的调用方式:
(1)调用门
call和jmp后接门选择子,可用来实现系统调用,call可以向高特权级代码转移,jmp只能平级转移。
(2)中断门
int指令主动触发中断实现低到高特权级转移。linux系统调用就是用中断门来实现的。
(3)陷阱门
int3指令主动触发中断实现低到高特权级转移。一般是编译器在调试时使用。
(4)任务门
以TSS为单位,用来实现任务切换,可以有中断或指令发起。中断发生时,如果中断向量号是任务门,则会引发任务切换。也可以向调用门那样通过call和jmp实现调用。
当前特权级、门特权级和目标特权级的关系如下图所示,很形象。

当前特权级一定介于两者之间。
对于调用门,如何通过选择子索引gdt/ldt就不讲了,这里介绍一下调用门下如何为内核例程传参。由于在调用时,不同特权级使用各自的栈,调用者把参数压入自己的栈中,被调用者或者说内核例程的栈里并没有参数,怎么办呢?CPU已经提供了对应的机制,将在调用者栈中的参数赋值到被调者的栈中。通过门描述符的参数个数字段来确定几个参数,最多传31个参数。
4.3 调用门的过程保护


转移到新栈时,需要在新栈中保存旧栈的栈指针、参数和返回地址(就像正常的函数调用一样)。然后就跳转到目标地址执行。如果只是平级转移,那就只是跳转或调用而已。
返回时从新栈中取出返回地址和原来的栈指针,用来恢复旧栈的结构以及跳转回原来的代码执行。使用retf进行远返回,还需要进行特权级检查,必须是高特权级返回到低特权级。
对于数据段寄存器,如DS,ES这些,如果检查到有特权级高于返回后的CPL,就把这些数据段寄存器清零,这样的话会访问GDT索引0的项而引发异常。毕竟返回后如果这些段寄存器还保存着高特权级的栈结构,就相当于低特权级能直接访问高特权级的数据了。
4.4 使用RPL的原因
CPL只是记录了当前CPU的特权级,却没有记录调用者的特权级,而正是因为存在用户态请求内核态代理这种情况,所以需要用RPL额外记录下调用者的特权级。书中给出了一个例子,在拷贝内核时,如果用户的内存缓冲区设置在内核空间中,正常情况下由于没有RPL的检查(CPL和DPL肯定是对的),会导致在内核态时将内核空间的数据覆盖,这样会出问题。而有了RPL记录下调用前的特权级,内核态在检查的时候就会发现拷贝到的目标地址的DPL特权级高于RPL,拷贝就会失败。
所以特权级检查就是CPL<=DPL&&RPL<=DPL,发生时机是段寄存器加载选择子访问段描述符的时候。
总结一下,如果不通过调用门访问代码段,(以下都是在数值上的比较)
(1)目标代码段是非一致性代码段,则CPL=RPL=DPL;
(2)目标代码段是一致性代码段,则CPL>=DPL&&RPL>=DPL;
如果通过调用门访问代码段,则CPL<=DPL&&RPL<=DPL。
受访者是数据段时,CPL<=DPL&&RPL<=DPL;栈段由于每个特权级下都有各自的栈,所以在访问栈段数据是一定是CPL=RPL=DPL。
回到调用门的特权级检查,调用门有门描述符,其对应一段内核例程,例程本身也对应有一个段描述符,所以一共是有两个DPL,门描述符对应的DPL我们记作DPL_GATE,例程对应的段描述符的DPL我们记作DPL_CODE。前面我们说过,门只是一个跳板,它是将低特权级的代码转移到高特权级代码上去的,所以它的特权级不能高过调用者的特权级,且CPL也是不高于被调用者的特权级,因此有下图所示关系。

RPL在进入调用门是只与DPL_GATE比较一次,因为RPL特权级至少是不低于CPL的,所以CPL是短板。因此,
(1)数值上,DPL_GATE>=CPL>=DPL_CODE;
(2)DPL_GATE>=RPL。
我们再再捋一遍,因为调用门的过程涉及到几个代码段和选择子的特权级属性,实在是容易把人绕晕。首先我们明确调用门的流程,我们的调用代码需要
借助调用门转移到高特权级的代码,而高特权级的代码可能需要低特权级代码的参数用于自己的函数。所以涉及到以下代码以及选择子:
(1)调用代码,也就是低特权级代码,它对应一个自己的CPL,记作CPL_OLD,调用门时会有一个门的选择子,携带它的RPL,记作RPL_GATE;
(2)调用代码的参数,可以是一个缓冲区的地址,同样也是一块内存区域,因此它也有一个DPL,携带在段选择子中的RPL作为参数传递给高特权级代码,记作RPL_CHCHE;
(3)调用门,它有一个DPL,记作DPL_GATE,调用高特权代码时会有一个选择子,携带高特权代码段的RPL,记作RPL_CODE;
(4)调用门对应内核例程,也就是高特权级代码,它有一个DPL,记作DPL_CODE;
好了,就是以上这些了,我们再结合之前介绍的规则来一遍。首先,调用代码通过call/jmp调用调用门,检查CPL_OLD与RPL_GATE权限是否不低于DPL_GATE,且CPL_OLD权限是否不高于DPL_CODE,如果是则继续。这里不检查RPL_GATE与DPL_CODE是因为由门转移到该特权级代码用的是RPL_CODE,所以低特权级代码怎么伪造RPL_GATE都没事。转移到高特权级代码后,需要将参数复制到高特权的栈中,操作系统会识别到段选择子并将它的RPL_CHCHE强制改成CPL_OLD,这样一来伪造RPL_CACHE也没用了
4.5 IO特权级
控制IO端口特权级的有两个东西,一个是eflags的IOPL,另一个是TSS的IO位图,每个TSS都有字节的eflags和IO位图。当CPL<=IOPL时,CPU可以访问所有端口,CPL>IOPL时CPU不能访问任何端口,必须提权。但由于切换内核态花费时间,所以又有另一种方法,通过修改IO位图对应位,来打开某个端口的权限。TSS中保存着IO位图的偏移地址,最多可以有8KB,65536位即65536个端口,位上置0代表允许访问对应端口。TSS可以没有IO位图,IO位图是在TSS上方8KB空间顶格放置的,所以检查TSS中IO位图偏移就能知道TSS有没有IO位图,偏移超出这个范围那就是没有IO位图。一个IO端口只能读写一个字节,多个连续端口可以连续输出,凑成多个字节,前提是IO位图中对应为是连续的0。对于IO位图中最后一位,则需要边界检查,所以最后一字节设为0xff(最后一字节不映射端口,所以直接全设置为1也没事)。


浙公网安备 33010602011771号