《操作系统真象还原》第五章(2)
本篇对应书籍第五章5.3–5.4的内容
本篇章简单介绍了 ELF 文件格式,以及如何将 C 语言程序(内核程序)编译链接拷贝到硬盘上并通过内核加载器 loader 程序加载执行。最后简单介绍了一下特权级
本篇难点:
- ELF文件解析
第一个 C 程序
平时写程序,会用到各种库,其中的标准库一般是系统调用的封装,虽然用户程序也可以直接使用系统调用,但效果基本上还是不如直接用标准库来的好
接下来要用C语言来写内核了
C语言的设计目标是提供一种能以简易的方式编译、处理低级存储器、产生少量的机器码以及不需要任何运行环境支持便能运行的编程语言
C语言为什么能写操作系统呢?C语言是怎么控制CPU的呢?
C语言程序的生成过程是,先将源程序编译
成目标文件
(C代码编译成汇编代码,再由汇编代码生成二进制目标文件),再将目标文件
进行链接
成二进制可执行文件
这个里有两个过程:编译、链接,下面以一个简单的C语言程序为例,介绍一下这个过程
main.c:
int main(){
while(1);
return 0;
}
使用 gcc 命令进行编译:
gcc -c -o ../main.o main.c
-c:编译,汇编到目标代码,不进行链接,直接生成目标文件
-o:输出到文件
在这个阶段目标文件中的符号是未编址的(重定位),这个工作是在链接中进行
用 file 命令查看文件信息:
steven@steven ~/source/os/bochs/code/kernel$ file ../main.o
../main.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped
当前文件是ralocatable,可重定位文件,也就是未进行链接(重定位)
用 nm 命令查看符号信息:
steven@steven ~/source/os/bochs/code/kernel$ nm ../main.o
0000000000000000 T main
可以看到,程序中的符号(函数名、变量名)的地址尚未确定
使用 ld 命令进行链接,这么链接的好处是可以指定最终生成文件的起始虚拟地址:
ld ../main.o -Ttext 0xc0001500 -e main -o ../main.bin
-Ttext:指定程序的起始虚拟地址
-e:指定程序的起始地址,链接器默认把_start作为入口函数
在使用 gcc 编译的时候,如果没有使用 -c 参数,则会被编译器自动添加其他的运行库的代码,程序会大很多
在64位系统中编译32位程序:
gcc -m32 -c -o kernel/main.o kernel/main.c
ld -m elf_i386 kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin
dd if=/home/steven/source/os/bochs/code/kernel/kernel.bin of=/home/steven/source/os/bochs/hd60M.img bs=512 count=200 seek=9 conv=notrunc
注意: gcc版本需要降级到 gcc4.x, 否则elf文件会与书上有差异, 会多出段且这些段的虚拟地址并未初始化,所以bochs运行会出错
ELF 文件格式
Windows 下是 PE 文件格式,Linux下则是 ELF文件(relocatable 文件、shared object 文件、executable 文件)
程序中最重要的部分是段(segment)和节(section),段是由节组成的,多个节经过链接就合成为段。
段和节的头信息通过一种结构来存储,就是程序头表(段头表)和节头表
ELF在链接时和运行时文件形态不一样:
链接后,程序运行的代码、数据等资源都在段中,本着够用就行的原则,有关节和其他内容的学习这里就不多关注了,参考资料列出了ELF解析的参考链接,那里有较为完整的内容
#define EI_NIDENT 16
typedef struct {
unsigned char e_ident[16]; //存储字符信息
Elf32_Half e_type; //目标文件类型
Elf32_Half e_machine; //elf目标体系结构
Elf32_Word e_version; //版本信息
Elf32_Addr e_entry; //操作系统运行程序时,将控制权转交到的虚拟地址
Elf32_Off e_phoff; //程序头表的文件偏移地址
Elf32_Off e_shoff; //节头表的文件偏移地址
Elf32_Word e_flags; //处理器相关标识
Elf32_Half e_ehsize; //elf header 字节大小
Elf32_Half e_phentsize; //程序头表每个条目的字节大小,条目的数据结构是Elf32_Phdr
Elf32_Half e_phnum; //程序头表中条目数量(段数量)
Elf32_Half e_shentsize; //节头表每个条目的字节大小
Elf32_Half e_shnum; //节头表中条目的数量(节数量)
Elf32_Half e_shstrndx; //指明string name table在节头表中的索引index
} Elf32_Ehdr;
e_ident
e_ident是一个16字节大小数组:
e_type
e_machine
Program Header
Elf32_Phdr 类似 GDT 中段描述符的作用,用来描述位于磁盘上的程序的一个段
在ELF_Header中,我们可以得到Program Header的索引地址(e_phoff)、段数量(e_phnum)、表项大小(e_phentsize)。然后我们来看一下Program Header中表项的结构定义:
typedef struct {
Elf32_Word p_type; //此段的作用类型
Elf32_Off p_offset; //本段在文件内的起始偏移字节
Elf32_Addr p_vaddr; //本段在内存中的起始虚拟地址
Elf32_Addr p_paddr; //仅用于与物理地址相关的系统中
Elf32_Word p_filesz;//本段在文件中的大小
Elf32_Word p_memsz; //本段在内存中的大小
Elf32_Word p_flage; //本段相关的标识
Elf32_Word p_align; //本段在文件和内存中的对齐方式,如果是1或0,则不对齐,否则应该是2的整数次幂
} Elf32_phdr;
p_type
取值 | 代表 | 含义 |
---|---|---|
00 | PT_NULL | 此数组元素未用。结构中其他成员都是未定义的。 |
01 | PT_LOAD | 此数组元素给出一个可加载的段,段的大小由 p_filesz 和 p_memsz 描述。文件中的字节被映射到内存段开始处。如果 p_memsz 大于 p_filesz,“剩余”的字节要清零。p_filesz 不能大于 p_memsz。可加载的段在程序头部表格中根据 p_vaddr 成员按升序排列。 |
02 | PT_DYNAMIC | 数组元素给出动态链接信息。 |
03 | PT_INTERP | 数组元素给出一个 NULL 结尾的字符串的位置和长度,该字符串将被当作解释器调用。这种段类型仅对与可执行文件有意义(尽管也可能在共享目标文件上发生)。在一个文件中不能出现一次以上。如果存在这种类型的段,它必须在所有可加载段项目的前面。 |
04 | PT_NOTE | 此数组元素给出附加信息的位置和大小。 |
05 | PT_SHLIB | 此段类型被保留,不过语义未指定。包含这种类型的段的程序与 ABI不符。 |
06 | PT_PHDR | 此类型的数组元素如果存在,则给出了程序头部表自身的大小和位置,既包括在文件中也包括在内存中的信息。此类型的段在文件中不能出现一次以上。并且只有程序头部表是程序的内存映像的一部分时才起作用。如果存在此类型段,则必须在所有可加载段项目的前面。 |
0x70000000 | PT_LOPROC | 此范围的类型保留给处理器专用语义。 |
0x7fffffff | PT_HIPROC | 此范围的类型保留给处理器专用语义。 |
p_flages
将内核载入内存
这里回顾一下到目前为止的计算机开机运行的过程:
计算机启动后,运行的第一个程序是BIOS,BIOS检查、初始化硬件之后,将0盘0道1扇区(CHS方式)的主引导记录 MBR 程序加载到内存0x7C00的位置并跳转过去;
MBR(大小:512 字节) 里含有引导记录,可以选择加载指定的系统(多系统情况下),加载则是通过跳转到指定的操作系统的内核加载器程序 loader 进行,从硬盘中读取出 loader 程序到内存中并跳转过去;
内核加载器 loader 程序的工作是将内核 kernel 程序从硬盘中加载到内存中,然后将加载进来的内核程序安置到相应的虚拟内存地址,然后跳转过去执行。
就像接力棒一样,逐个交给下一棒。
到目前为止,我们的硬盘使用情况如下:
- 第 0 扇区:MBR,大小:512字节,占用1个扇区
- 第 2 扇区:loader,大小:1342字节,占用4个扇区
方便起见,kernel 程序就写入 第 9 扇区
到目前为止的使用的内存情况如下:
- 0x000~0x3FF:1K,中断向量表
- 0x400~0x4FF:256B,BIOS
- 0x500~0x7BFF:30K,可用区域
- 0x7C00~0x7DFF:512B,MBR被加载到此处
- 0x7E00~9FBFF:608K,可用区域
程序执行到 loader 后,MBR的使命已经结束了,可以被覆盖抹杀掉了,仿佛 MBR 不曾存在一样
偷个懒,按书上作者提到的使用的地址走,内核文件 kernel.bin 加载到 0x70000 地址上
修改 include/boot.inc
添加如下行
KERNEL_START_SECTOR equ 0x9
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_ENTRY_POINT equ 0xc0001500
PT_NULL equ 0
修改 loader.asm 加载内核 1
第一步,加载内核:将 kernel.bin 读取到内存中
在启用分页机制之前,将 kernel.bin 从硬盘中读取到内存中:
;------------加载 kernel------------
mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ; 从硬盘读出后写入的地址
mov ecx, 200 ; 读入的扇区数
call rd_disk_m_32 ; 从硬盘读取文件到内存, 上面eax, ebx, ecx是参数
;------------启用 分页机制------------
; 创建页目录及页表并初始化页内存位图
call setup_page
rd_disk_m_32
函数是 MBR 程序中 rd_disk_m_16
函数的 32 位版本,实现原理差不多一样,只是用到的寄存器变成32位的了
把 rd_disk_m_32
程序写好加到 loader.asm 的结尾:
;----读取文件到内存----
;参数
;eax :扇区号
;ebx :待读入的地址
;ecx :读入的扇区数
rd_disk_m_32:
mov esi ,eax ;备份eax
mov di ,cx ;备份cx
; 读写硬盘
; 1---设置要读取的扇区数
mov dx ,0x1f2 ;设置端口号,dx用来存储端口号的,要写入待读入的扇区数量
mov al ,cl
out dx ,al ;读取的扇区数
mov eax ,esi ;恢复eax
; 2---将LBA地址存入0x1f3~0x1f6
; LBA 7~0位写入端口0x1f3
mov dx ,0x1f3
out dx ,al
; LBA 15~8位写入端口0x1f4
mov cl ,8
shr eax ,cl ; 逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
mov dx ,0x1f4
out dx ,al
; LBA 24~16位写入端口0x1f5
shr eax ,cl
mov dx ,0x1f5
out dx ,al
shr eax ,cl
and al ,0x0f ; 设置lba 24~27位
or al ,0xe0 ; 设置7~4位是1110表示LBA模式
mov dx ,0x1f6
out dx ,al
; 3---向0x1f7端口写入读命令0x20
mov dx ,0x1f7
mov al ,0x20
out dx ,al
; 4---检测硬盘状态
.not_ready:
; 同写入命令端口,读取时标示硬盘状态,写入时是命令
nop
in al ,dx
and al ,0x88 ;第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al ,0x08
jnz .not_ready
; 5---0x1f0端口读取数据
mov ax ,di ; 要读取的扇区数
mov dx ,256 ; 一个扇区512字节,一次读取2字节,需要读取256次
mul dx ; 结果放在ax里
mov cx ,ax ; 要读取的次数
mov dx ,0x1f0
.go_on_read:
in ax, dx
mov [ebx], ax ; bx是要读取到的内存地址
add ebx, 0x02
loop .go_on_read ; 循环cx次
ret
修改 loader.asm 加载内核 2
第二步,初始化内核,依据ELF规范,将内核文件中的段展开到内存中相应位置
也就是说要找个地方存放内核的映像
内存要尽量往低了选,考虑到 0x900 存放的是 loader,loader 开始部分是 GDT,且 loader 大小不超过 2000 字节,为了不覆盖到 GDT,所以内存选择是 0x900 + 2000 = 0x10d0,取个整数,就是0x1500作为映像的入口地址
初始化代码如下:在开启分页重新加载 gdt 之后,初始化 kernel 并跳转到 kernel 文件的入口
; 开启分页后, 用 gdt 新的地址重新加载
lgdt [gdt_ptr]
;;;;;;;;;;;;;;;;;;;;;;;;;;;; 此时不刷新流水线也没问题 ;;;;;;;;;;;;;;;;;;;;;;;;
; 由于一直处在32位下,原则上不需要强制刷新, 经过实际测试没有以下这两句也没问题.
; 但以防万一, 还是加上啦, 免得将来出来莫句奇妙的问题.
jmp SELECTOR_CODE:enter_kernel ; 强制刷新流水线,更新 gdt
enter_kernel: ; 进入内核
call kernel_init
mov esp, 0xc009f000 ; 给栈选个高地址且不影响内存其他位置的地方
jmp KERNEL_ENTRY_POINT
kernel_init 函数:
;----------------- 将kernel.bin中的segment拷贝到编译的地址 -----------
; 此时,kernel.bin 已经被读取到内存 KERNEL_BIN_BASE_ADDR 位置上了
kernel_init:
xor eax, eax ; 清空通用寄存器
xor ebx, ebx ; ebx 记录程序头表文件内偏移地址,即e_phoff
xor ecx, ecx ; cx 记录程序头表中的 program header 数量
xor edx, edx ; dx 记录 program header 尺寸, 即 e_phentsize
mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件 42 字节处是 e_phentsize, 表示 program header 大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件 28 字节处是 e_phoff, 表示第一个程序头在文件的偏移量
add ebx, KERNEL_BIN_BASE_ADDR ; 获取程序头表第一个程序头的地址(基地址 + 偏移量)
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件 44 字节处是 e_phnum,表示程序头的数量
.each_segment:
cmp byte [ebx + 0], PT_NULL ; 若相等,则表示程序头没使用
je .PTNULL
; 为mem_cpy压入参数(从右往左)类似 memcpy(dst, src, size)
; 参数 size:待复制的大小
push dword [ebx + 16] ; 偏移程序头 16 字节处是 p_filesz, 即本段在文件内的大小
; 参数 src: 源地址
mov eax, [ebx + 4] ; 偏移程序头 4 字节处是 p_offset, 即本段在文件内的偏移大小
add eax, KERNEL_BIN_BASE_ADDR ; 加上基地址 = 物理地址
push eax
; 参数 dst: 目的地址
push dword [ebx + 8] ; 偏移程序头 8 字节处是 p_vaddr, 即本段在内存中的虚拟地址
call .mem_cpy
add esp, 12 ; 清理栈中压入的三个参数
.PTNULL:
add ebx, edx ; 程序头的地址 + 程序头的大小 = 下一个程序头的地址
loop .each_segment ; 复制下一个程序头
ret
mem_cpy 函数:
;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------
; 输入:栈中三个参数(dst,src,size)
; 输出:无
;---------------------------------------------------------
.mem_cpy:
cld ; 控制进行字符串操作时esi和edi的递增方式, cld增大, sld减小
push ebp
mov ebp, esp
push ecx ; rep指令用到了ecx,外层指令也用到了ecx,所以备份
; 分析一下为什么是 8, 因为push了ebp, 所以相对应的都需要+4
; 并且进入函数时还 push了函数返回地址, 所以再+4, 所以一共+8
mov edi, [ebp + 8] ; dst
mov esi, [ebp + 12] ; src
mov ecx, [ebp + 16] ; size
rep movsb ; 逐字节拷贝
; 恢复环境
pop ecx
pop ebp
ret
完整代码:
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; loader在保护模式下的栈指针地址,esp
; 构建GDT及其内部描述符, 每个描述符8个字节, 拆分为高低各4字节(32位)
GDT_BASE: dd 0x00000000 ; 第0个描述符,不可用
dd 0x00000000
CODE_DESC: dd 0x0000ffff ; 低32位31~16位为段基址15~0位, 15~0位为段界限15~0位
dd DESC_CODE_HIGH4
DATA_STACK_DESC: dd 0x0000ffff ; 数据段(栈段)描述符
dd DESC_DATA_HIGH4
VIDEO_DESC: dd 0x80000007 ; 0xB8000 到0xBFFFF为文字模式显示内存 段界限:limit=(0xbffff-0xb8000) / 4k=0x7
dd DESC_VIDEO_HIGH4 ; 0xB
GDT_SIZE: equ $ - GDT_BASE ; 当前位置减去GDT_BASE的地址 等于GDT的大小
GDT_LIMIT: equ GDT_SIZE - 1 ; SIZE - 1即为最大偏移量
times 60 dq 0 ; 预留60个 四字型 描述符空位, 用于后续扩展
SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0 ; 段选择子: 低3位为TI RPL状态, 其余为描述符索引值
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
; total_mem_bytes用于保存内存容量,以字节为单位,此位置比较好记。
; 当前偏移loader.bin文件头0x200字节,loader.bin的加载地址是0x900,
; 故total_mem_bytes内存中的地址是0xb00.将来在内核中咱们会引用此地址
total_mem_bytes dd 0
; gdt指针, 前2字节为gdt界限, 后4字节为gdt起始地址(共48位)
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
; 人工对齐:total_mem_bytes 4字节 + gdt_ptr 6字节 + ards_buf 244字节 + ards_nr 2字节 , 共256字节
ards_buf times 244 db 0 ; 记录内存大小的缓冲区
ards_nr dw 0 ; 记录 ARDS 结构体数量
loader_start:
; ------- int 15h eax = 0000E820h ,edx = 534D4150h ('SMAP') 获取内存布局 -------
xor ebx, ebx ; 第一次调用将eb置0
mov edx, 0x534d4150 ; edx只赋值一次,循环体中不会改变
mov di, ards_buf ; di指向ARDS缓冲区
.e820_mem_get_loop: ; 循环获取每个ARDS内存范围描述结构
mov eax, 0x0000e820 ; 执行int 0x15后,eax值变为0x534d4150, 所以每次执行int前都要更新为子功能号
mov ecx, 20 ; ARDS地址范围描述符结构大小是20字节
int 0x15
jc .e820_failed_so_try_e801 ; 若cf位为1则有错误发生,尝试0xe801子功能
add di, cx ; 使di增加20字节指向缓冲区中新的ARDS结构位置
inc word [ards_nr] ; ARDS数量加1
cmp ebx, 0 ; 若 ebx 为 0 且 cf 不为 1, 这说明 ards 全部返回,当前已是最后一个
jnz .e820_mem_get_loop ; 不为0则循环获取
; 在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量
mov cx, [ards_nr] ; 遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ; 用edx记录最大值, 在这里先清零
.find_max_mem_area: ; 无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ; base_add_low
add eax, [ebx+8] ; base_add_low + length_low = 这块ADRS容量
add ebx, 20 ; 指向下一块ARDS
cmp edx, eax ; 找出最大,edx寄存器始终是最大的内存容量
jge .next_ards ; 如果edx>=eax, 继续遍历下一块
mov edx, eax ; 如果edx<=eax, 更新edx
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok ; 获取内存容量结束
; ------ int 15h ax = E801h 获取内存大小,最大支持4G ------
; 返回后, ax cx 值一样,以KB为单位,bx dx值一样,以64KB为单位
; 在ax和cx寄存器中为低16M,在bx和dx寄存器中为16MB到4G
.e820_failed_so_try_e801:
mov ax, 0xe801
int 15
jc .e801_failed_so_try88 ; 若当前e801方法失败,就尝试0x88方法
; 1 先算出低15M的内存, ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
; 乘数在eax, 即内存数量, 积高16位在edx, 低16位在eax
mov cx, 0x400 ; 0x400 = 1024
mul cx
shl edx, 16 ; 左移16位, 将低16位放到edx高16位
and eax, 0x0000FFFF ; 0x0000FFFF = 1111 1111 1111 1111, 高16位置0
or edx, eax ; 获得完整的32位积
add edx, 0x100000 ; edx比实际少1M, 故要加1MB
mov esi, edx ; 先把低15MB的内存容量存入esi寄存器备份
; 2 再将16MB以上的内存转换为byte为单位,寄存器bx和dx中是以64KB为单位的内存数量
xor eax, eax
mov ax, bx
mov ecx, 0x10000 ; 0x10000十进制为64KB
mul ecx ; 32位乘法,默认的被乘数是eax, 积为64位, 高32位存入edx, 低32位存入eax
add esi, eax ; 由于此方法只能测出4G以内的内存, 故32位eax足够了, edx肯定为0, 只加eax便可
mov edx, esi ; edx为总内存大小
jmp .mem_get_ok
; ----------------- int 15h ah = 0x88 获取内存大小,只能获取64M之内 ----------
.e801_failed_so_try88:
; int 15后,ax存入的是以kb为单位的内存容量
mov ah, 0x88
int 0x15
jc .error_hlt
and eax, 0x0000FFFF
; 16位乘法,被乘数是ax,积为32位, 积的高16位在dx中,积的低16位在ax中
mov cx, 0x400 ; 0x400等于1024, 将ax中的内存容量换为以byte为单位
mul cx
shl edx, 16
or edx, eax ; 把积的低16位组合到edx,为32位的积
add edx, 0x100000 ; 0x88子功能只会返回1MB以上的内存, 故实际内存大小要加上1MB
.mem_get_ok:
mov [total_mem_bytes], edx ; 将内存换为byte单位后存入total_mem_bytes处
; --------------------------------- 设置进入保护模式 -----------------------------
; 1 打开A20 gate
; 2 加载gdt
; 3 将cr0 的 pe位(第0位)置1
; ----------------- 打开A20 ----------------
in al, 0x92 ; 端口号0x92 中的第1位变成 1 即可
or al, 0000_0010B
out 0x92, al
; ----------------- 加载GDT ----------------
lgdt [gdt_ptr]
; ----------------- cr0第0位置1 ----------------
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
; -------------------------------- 已经打开保护模式 ---------------------------------------
jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线
.error_hlt: ; 出错则挂起
hlt ; 处理器暂停, 直到出现中断或复位信号才继续
[bits 32]
p_mode_start:
; 初始化32位的段寄存器
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
;------------加载 kernel------------
mov eax, KERNEL_START_SECTOR ; kernel.bin所在的扇区号
mov ebx, KERNEL_BIN_BASE_ADDR ; 从硬盘读出后写入的地址
mov ecx, 200 ; 读入的扇区数
call rd_disk_m_32 ; 从硬盘读取文件到内存, 上面eax, ebx, ecx是参数
;------------启用 分页机制------------
; 创建页目录及页表并初始化页内存位图
call setup_page
; gdt需要放在内核里
; 将描述符表地址&偏移量写入内存gdt_ptr,一会用新的地址加载
sgdt [gdt_ptr] ; 取出GDT地址和偏移信息,存放在gdt_ptr这个内存位置上
; 视频段需要放在内核里与用户进程进行共享
; 将gdt描述符中视频段的段基址 + 0xc0000000
mov ebx, [gdt_ptr + 2] ; 这里gdt_ptr前2字节是偏移量,后4字节是GDT基址,先选中GDT
or dword [ebx + 0x18 + 4], 0xc0000000 ; 一个描述符8字节,0x18处是第3个段描述符也就是视频段, 修改段基址最高位为C, +4进入高4字节, 用or修改即可
; 将gdt的基址加上 0xc0000000 成为内核所在的地址
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000 ; 将栈指针同样map到内核地址
; 页目录地址赋值给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
; 打开 cr0 的 pg位 (第31位)
mov eax, cr0
or eax, 0x80000000
mov cr0, eax
; 开启分页后, 用 gdt 新的地址重新加载
lgdt [gdt_ptr]
;;;;;;;;;;;;;;;;;;;;;;;;;;;; 此时不刷新流水线也没问题 ;;;;;;;;;;;;;;;;;;;;;;;;
; 由于一直处在32位下,原则上不需要强制刷新, 经过实际测试没有以下这两句也没问题.
; 但以防万一, 还是加上啦, 免得将来出来莫句奇妙的问题.
jmp SELECTOR_CODE:enter_kernel ; 强制刷新流水线,更新 gdt
enter_kernel: ; 进入内核
call kernel_init
mov esp, 0xc009f000 ; 给栈选个高地址且不影响内存其他位置的地方
jmp KERNEL_ENTRY_POINT
;----------------- 将kernel.bin中的segment拷贝到编译的地址 -----------
; 此时,kernel.bin 已经被读取到内存 KERNEL_BIN_BASE_ADDR 位置上了
kernel_init:
xor eax, eax ; 清空通用寄存器
xor ebx, ebx ; ebx 记录程序头表文件内偏移地址,即e_phoff
xor ecx, ecx ; cx 记录程序头表中的 program header 数量
xor edx, edx ; dx 记录 program header 尺寸, 即 e_phentsize
mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; 偏移文件 42 字节处是 e_phentsize, 表示 program header 大小
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; 偏移文件 28 字节处是 e_phoff, 表示第一个程序头在文件的偏移量
add ebx, KERNEL_BIN_BASE_ADDR ; 获取程序头表第一个程序头的地址(基地址 + 偏移量)
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; 偏移文件 44 字节处是 e_phnum,表示程序头的数量
.each_segment:
cmp byte [ebx + 0], PT_NULL ; 若相等,则表示程序头没使用
je .PTNULL
; 为mem_cpy压入参数(从右往左)类似 memcpy(dst, src, size)
; 参数 size:待复制的大小
push dword [ebx + 16] ; 偏移程序头 16 字节处是 p_filesz, 即本段在文件内的大小
; 参数 src: 源地址
mov eax, [ebx + 4] ; 偏移程序头 4 字节处是 p_offset, 即本段在文件内的偏移大小
add eax, KERNEL_BIN_BASE_ADDR ; 加上基地址 = 物理地址
push eax
; 参数 dst: 目的地址
push dword [ebx + 8] ; 偏移程序头 8 字节处是 p_vaddr, 即本段在内存中的虚拟地址
call .mem_cpy
add esp, 12 ; 清理栈中压入的三个参数
.PTNULL:
add ebx, edx ; 程序头的地址 + 程序头的大小 = 下一个程序头的地址
loop .each_segment ; 复制下一个程序头
ret
;---------- 逐字节拷贝 mem_cpy(dst,src,size) ------------
; 输入:栈中三个参数(dst,src,size)
; 输出:无
;---------------------------------------------------------
.mem_cpy:
cld ; 控制进行字符串操作时esi和edi的递增方式, cld增大, sld减小
push ebp
mov ebp, esp
push ecx ; rep指令用到了ecx,外层指令也用到了ecx,所以备份
; 分析一下为什么是 8, 因为push了ebp, 所以相对应的都需要+4
; 并且进入函数时还 push了函数返回地址, 所以再+4, 所以一共+8
mov edi, [ebp + 8] ; dst
mov esi, [ebp + 12] ; src
mov ecx, [ebp + 16] ; size
rep movsb ; 逐字节拷贝
; 恢复环境
pop ecx
pop ebp
ret
; ------------- 创建页目录及页表 ---------------
setup_page:
; 先把页目录所占空间清 0
mov ecx, 4096 ; 1024 * 4 = 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir
; 开始创建页目录项(Page Directory Entry)
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; 第一个页表的位置(仅次于页目录表,页目录表大小4KB)
mov ebx, eax ; 0x00101 000, 储存到ebx为创建PTE时使用
; 下面将页目录项0和OxcOO都存为第一个页表的地址 ,每个页表表示4MB内存
; 这样Oxc03fffff(3G-3G04M)以下的地址和Ox003fffff(0-4M)以下的地址都 指向相同的页表
; 这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ; 用户特权级,可读可写,存在内存
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; 第一个目录项,0x00101 007
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; 第0xc00高10位0x300=768个页表占用的目录项,0xc00以上属于kernel空间
; 这里是把第768个目录页和第1个目录页指向同一个页表的物理地址:0x101000
; 系统实际位于0~0x100000内存地址中,将系统虚拟地址0xc00000000映射到这低1M的空间内,只需要让0xc0000000的地址指向和低1M相同的页表即可
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ; 使最后一个目录项指向页目录表自己的位置, 4092 = 1023 * 4
; 创建页表项(Page Table Entry)
mov ecx, 256 ; 1M低端内存/每页大小4K = 256
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; edx中地址为0x0,属性为7,即111b
.create_pte:
mov [ebx + esi * 4], edx ; ebx = 0x00101 000, 即第一个PTE起始地址, 每个PTE = 4 byte
add edx, 4096 ; edx + 4KB
inc esi
loop .create_pte ; 低端1M内存中,物理地址=虚拟地址,这里创建了1M空间的页表项
; 创建内核其他页表的PDE
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; eax指向第二个页表(每个页表对应一个PDE, 含有1024个页表项)
or eax, PG_US_U | PG_RW_W | PG_P
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; 769~1022的所有目录项数量, 1022 - 769 + 1 = 254
mov esi, 769
.create_kernel_pde:
mov [ebx + esi * 4], eax
inc esi
add eax, 0x1000 ; eax指向下一个页表
loop .create_kernel_pde
ret
;-------------------------------------------------------------------------------
; 功能:读取硬盘n个扇区
; eax=LBA扇区号
; ebx=将数据写入的内存地址
; ecx=读入的扇区数
;-------------------------------------------------------------------------------
rd_disk_m_32:
mov esi ,eax ; 备份eax
mov di ,cx ; 备份cx
; 读写硬盘
; 1---设置要读取的扇区数
mov dx ,0x1f2 ; 设置端口号,dx用来存储端口号的,要写入待读入的扇区数量
mov al ,cl
out dx ,al ; 读取的扇区数
mov eax ,esi ; 恢复eax
; 2---将LBA地址存入0x1f3~0x1f6
; LBA 7~0位写入端口0x1f3
mov dx ,0x1f3
out dx ,al
; LBA 15~8位写入端口0x1f4
mov cl ,8
shr eax ,cl ; 逻辑右移8位,将eax的最低8位移掉,让最低8位al的值变成接下来8位
mov dx ,0x1f4
out dx ,al
; LBA 24~16位写入端口0x1f5
shr eax ,cl
mov dx ,0x1f5
out dx ,al
shr eax ,cl
and al ,0x0f ; 设置lba 24~27位
or al ,0xe0 ; 设置7~4位是1110表示LBA模式
mov dx ,0x1f6
out dx ,al
; 3---向0x1f7端口写入读命令0x20
mov dx ,0x1f7
mov al ,0x20
out dx ,al
; 4---检测硬盘状态
.not_ready:
; 同写入命令端口,读取时标示硬盘状态,写入时是命令
nop
in al ,dx
and al ,0x88 ; 第三位为1表示已经准备好了,第7位为1表示硬盘忙
cmp al ,0x08
jnz .not_ready
; 5---0x1f0端口读取数据
mov ax ,di ; 要读取的扇区数
mov dx ,256 ; 一个扇区512字节,一次读取2字节,需要读取256次
mul dx ; 结果放在ax里
mov cx ,ax ; 要读取的次数
mov dx ,0x1f0
.go_on_read:
in ax, dx
mov [ebx], ax ; bx是要读取到的内存地址
add ebx, 0x02
loop .go_on_read ; 循环cx次
ret
运行 Bochs
运行结果如下:
下一条指令长度为 2 字节,指令内容为跳转到 2 字节之前,也就是死循环,正好与代码中的while(0)
一致,说明程序运行起来了!
特权级深入浅出
特权级分4个等级,0,1,2,3,数字越小特权越高,计算机在启动的时候,是在 0 特权级启动的,操作系统内核也处于 0 特权级,它要直接控制硬件,系统程序分别位于 1 ,2 两个特权级,一般是虚拟机、驱动程序等系统服务,用户程序一般在 3 特权级
代码特权转移的信息存在 TSS 结构中,通过 TSS 获取转移后的栈
代码特权转移的权限存在 CS.RPL(CPL)中,会根据目标段 DPL 权限来判断是否能转移
TSS 简介
TSS,Task State Segment,任务状态段,是一种数据结构,用于存储任务的环境
首先,任务是什么?任务是计算机执行的一个程序,这个程序按特权级划分可以分为用户部分和内核部分两部分,这两部分加起来才是完整的一个程序,完整的任务会经历特权级从用户级到内核级的变化。
任务是由处理器在执行,任务在特权级变换的本质是处理器当前特权级在变化,处理器处于不同特权级下,使用的是不同特权级的栈,每个特权级下都有且仅有一个栈
特权级转移分两种:
- 从低到高转移:可通过中断门、调用门等手段实现
- 从高到低转移:仅能通过调用返回实现
处理器从低特权级向高特权级转移的时候,会去查询当前任务的 TSS 数据结构,获取当前任务目标特权级的栈信息,并跳转过去,在这个过程中,跳转的时候会将当前地址记录下来存到跳转过去的栈中,从高向低转移的时候,也就直接知道如何跳转了,所以 TSS 结构只需要存储3个特权级的信息即可
TSS 是硬件支持的系统数据结构,和 GDT 一样,会被存到一个寄存器里,TSS 由 Task Register 加载
CPL、DPL简介
计算机特权级的标签体现在DPL、CPL、RPL下
CS 段选择子的 0-1 位是 RPL ,称为请求特权级,也叫当前特权级
代码段描述符中的 DPL 是 CPU 当前的特权级 CPL ,表示正在执行的代码段的特权级
当前特权级 CPL 保存在 CS 段寄存器 0-1 位的 RPL 部分,代码请求其他段的内容的时候,会检查其他段描述符中的 DPL,如果目标段的 DPL 等级比当前 CPL 高,则不能访问,如果目标段是代码段的话,则只能同级访问,
如果允许访问了,则会将目标段的 DPL 保存在当前 CS 寄存器的 RPL 里,成为当前的 CPL
之前提到的段描述符的 type 位的 C位,一致性代码段,指的是如果当前段是转移后的目标段,当前的特权级一定大于等于转移的特权级,转移后特权级不进行变化,还是转移前的特权级
代码段才有一致性非一致性区分,数据段可没有
门、调用门
门就像段描述符一样,也是一种描述符结构
四种门的结构:
门的作用:
-
调用门
call和jmp指令后接调用门选择子为参数,以调用函数例程的形式实现从低特权向高特权转移,可用来实现系统调用。call指令使用调用门可以实现向高特权代码转移,jmp指令使用调用门只能实现向平级代码转移。 -
中断门
以int指令主动发中断的形式实现从低特权向高特权转移,Linux系统调用便用此中断门实现。
-
陷阱门
以int3指令主动发中断的形式实现从低特权向高特权转移,一般是编译器在调试的时候用
-
任务门
任务以任务状态段 TSS 为单位,用来实现任务切换,它可以借助中断或指令发起。当中断发生时,如果对应的中断向量号是任务门,则会发起任务切换。也可以像调用门那样,用 call 或 jmp 指令后接任务 门的选择子或任务 TSS 的选择子。
IO 特权级
IO 读写特权是由标志寄存器 eflags 中的 IOPL 位和 TSS 中的 IO 位图决定的,它们用来指定执行 IO 操作的最小特权级。
参考资料
ELF文件格式解析(完):https://www.52pojie.cn/thread-591986-1-1.html