代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。
1.编译链接C语言
先写一个简单的c语言程序
/kernel/main.c
int main(void){
while(1);
return 0;
}kernel目录就是存放我们的内核相关代码
生成C语言程序会先将源程序编译成目标文件(由c代码变成汇编代码后,再由汇编代码生成二进制的目标文件),再将目标文件链接成二进制可执行文件。
gcc -c -o kernel/main.o kernel/main.c
-c的作用是编译、汇编到目标代码,不进行链接,也就是直接生成目标文件。
-0的作用是将输出的文件以指定文件名来存储,有同名文件存在时直接覆盖。

main.o是个目标文件(可重定位文件)。重定位是指文件中的符号还没有安排地址,这些符号的地址需要将来与其他目标文件链接成一个可执行文件时再重定位(编排地址)。这里的符号就是指所调用的函数或使用的变量,由于这些符号一般存在于其他文件中,所以此刻不能确定地址,要等所有目标文件到齐了,才能链接到一起再重新定位。哪怕可执行文件是由一个文件组成的,其目标文件中的符号也是未编址的,重定位一律在链接阶段完成。
可以用file命令来检查main.o的状态:
file kernel/main.o

nm kernel/main.o 命令通常用于显示目标文件(object file)中的符号(symbols)。当你运行 nm kernel/main.o 时,它会列出在 kernel 目录下的 main.o 目标文件中定义的符号。
我们的 main.c 过于简单,里面只有一个符号,即main,所以nm只列出了它的符号信息。main函数的地址由于未被指定,所以其值为 00000000。
nm kernel/main.o

ld是用来链接(link)目标文件 main.o 并生成一个输出文件 kernel.bin 的命令。
参数的作用:
- ld: 这是链接器(linker)命令,用于将多个目标文件链接在一起形成最终的可执行文件或镜像文件。
- kernel/main.o: 这是要链接的目标文件,即编译后生成的 main.o 文件,通常包含了代码和数据的二进制表示。
- -Ttext 0xc0001500: 这个参数指定了代码段的起始地址。在这里,0xc0001500 是代码将被加载的虚拟地址。这是设计好的。
- -e main: 这个参数指定了程序的入口点(entry point),也就是程序开始执行的地方。在这里,main 是一个符号,表示程序从这个函数开始执行。
- -o kernel/kernel.bin: 这个参数指定了生成的输出文件的名称和路径。
ld kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin
可以看到executable了

-e:指定文件的入口地址,编译器默认把名为_start的函数作为程序的入口地址。平时我们写的代码都是main函数,但链接器还是用到了_start,它不是我们提供的代码,是运行库提供的。这说明main函数不是第一个执行的代码,是被其他代码调用的,main函数在运行库代码初始化完环境后才被调用。
所以如果我们没有指定-e参数,他就会找不到_start

既然缺少_start符号,那现在把主函数main改成_start。
//int main(void){
int _start(void){
while(1);
return 0;
}重新编译,可以看到同样变成executable了

当然习惯使用main函数,所以改回来,加上-e参数指定即可
如果gcc未加-c参数指定,生成的直接就是可执行文件,同时自动添加了c语言运行库,所以会出现许多参数。

手动链接的就不会。

2.elf格式的二进制文件
用一个程序去调用另一个程序,最简单的方法就是用jmp和call指令。BIOS就是这样调用mbr的,mbr的地址是0x7c00;mbr也是这样调用loader的,loader的地址是0x900。这两个地址是固定的,因此很不灵活,调用方需要提前和被调用方约定地址。
因为每个程序是单独存在的,所以程序的入口地址需要与程序绑定,所以需要在头文件中写入程序的入口地址。主调函数在该程序文件的头文件中将该程序的入口信息读出,将其加载到相应的入口地址,然后跳转过去。
在程序中,程序头用来描述程序的布局等信息,它属于信息的信息,也就是元数据。
执行方式:由于程序文件中包含了程序头,程序的入口地址不需要写死,调用方中的调用代码可以变得通用。但不好的地方是这些元信息不是代码,不能放在CPU中执行。所以,将这种具有头文件的程序文件从外存读入内存后,从头文件中读出入口地址,需要直接跳进入口地址执行,跳过头文件才行。

比如我们需要自定义一个文件头
//自定义文件头 header.S 测试代码,无实例
header:
program_length dd program_end-program_start
start_addr dd program_start
;---------以上是文件头,以下是文件体------------
body:
program_start:
mov ax,0x1234
jmp $
program_end:header.S的被调用方式:
- 将header.bin前8字节的内容读到内存,前4字节是程序体长度,后4字节是程序的入口地址。
- 将header.bin开头偏移8字节的地方作为起始,将header.bin文件尾作为终止。
- 将起始地址和终止地址之间的程序体复制到入口地址。
- 转到入口地址处执行。
编译器gcc生成的是elf文件格式就是这样使用的
Windows下的可执行文件格式的PE(exe是扩展名,属于文件名的一部分,只是名字的后缀),Linux下的可执行文件格式是ELF。
ELF:Executable and Linkable Format,可执行链接格式。把符合ELF格式协议的文件统称为“目标文件”或“ELF文件”。

程序中最重要的部分就是段(segment)和节(section),它们是真正的程序体, 程序中有很多段,如代码段和数据段等,同样也有很多节,段是由节来组成的,多个节经过链接之后就被合并成一个段了段和节的信息也是用header来描述的,程序头是program header,节头是section header.
段(segment)和节(section):
- 关系:多个节section经过链接后被合并成一个段segment。
程序头和节头:用来描述段和节的信息的,程序头是program header,节头是section header。 - 程序头表和节头表:因为程序中的段和节的大小和数量是不固定,因此程序头表(program header table)和节头表(section header table)是专门用来描述它们的。表类似于数组,里面存放着多个程序头和节头。
- 在表中,每个成员都统称为条目entry,一个条目代表一个段/节的头描述信息。
对于程序头表,它本质上就是描述段(segment)的,所以也成为段头表。段等同于程序,可见“段”才是程序本身的组成部分。
elf header
- 因为段和节的数量不固定,所以程序头表和节头表的大小也不固定。因此,有个专门的数据结构来存放程序头表和节头表,即elf header。
- elf header是用来描述各种“头”的头,核心思想是头中嵌头,是种层次化结构的格式。
ELF 文件格式依然分为文件头和文件体两部分,ELF 格式的作用体现在两方面,一是链接阶段,另一方面是运行阶段,组织布局如图:

ELF结构中的数据结构:

elf header结构
#define EI_NIDENT 16
typedef struct{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;
如上面所述
1.e_ident[16] 是16字节大小的数组,用来表示ELF字符等信息。开头的4B是魔数0x7f以及字符串ELF的ASCII码:0x45 0x4c 0x46,如图:

file命令就可以直接查看elf 格式的可执行程序是LSB,还是MSB。

2.e_type:占用2 字节,用来指定ELF目标文件的类型。

3.e_machine:占用2 字节,用来描述ELF目标文件的体系结构类型,也就是该文件要在哪种硬件平台上运行。

4.e_version:占用4字节,表示版本信息。
5.e_entry:占用4字节,指明OS运行该程序时,将控制权交给虚拟地址。
6.e_phoff:占用4字节,指明程序头表(program header table)在文件内的字节偏移量。若没有程序头表,则为0。
7.e_shoff:占用4字节,指明节头表(section header table)在文件内的字节偏移量。若没有节头表,则为0。
8.e_flags:占用4字节,指明与处理器相关的标志。
9.e_ehsize:占用2字节,指明ELF header的字节大小。
10.e_phentsize:占用2字节,指明程序头表中每个条目的字节大小,即每个用来描述段信息的数据结构的字节大小,即struct Elf32_Phdr。
11.e_phnum:占用2字节,指明程序头表中条目的数量,即段数。
12.e_shentsize :占用2字节,指明节头表中每个条目的字节大小,即每个用来描述节信息的数据结构的字节大小。
13.e_shnum:占用2字节,指明节头表中条目的数量,即节数。
14.e_shstrndx:占用2字节,指明string name table在节头表中的索引index。
程序头表中条目数据结构
Program header描述的是一个段在文件中的位置、大小以及它被放进内存后所在的位置和大小。
typedef struct {
Elf32_Word p_type;
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Wordp_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
} Elf32_Phdr;p_type 占用 4 字节,用来指明程序中该段的类型。结构如图:

p_offset 占用 4 字节,用来指明本段在文件内的起始偏移字节。
p_vaddr 占用 4 字节,用来指明本段在内存中的起始虚拟地址。
p_paddr占用4字节,仅用于与物理地址相关的系统中,因为System V忽略用户程序中所有的物理地址,所以此项暂且保留,未设定。
p-filesz占用4字节,用来指明本段在文件中的大小。
p_memsz占用4字节,用来指明本段在内存中的大小。
p_flags占用4字节,用来指明与本段相关的标志,此标志取值范围如下:

p_align 占用 4 字节,用来指明本段在文件和内存中的对齐方式。如果值为 0 或 1,则表示不对齐。否则 p_align 应该是 2 的幂次数。
链接后,程序运行的代码、数据等资源都是在段中。
3.将内核载入内存
MBR写在了硬盘的第0扇区,第1扇区是空着的,loader写在硬盘的第2扇区,由于loader.bin 目前的大小是1342字节,占用3个扇区,所以第2-4扇区不能再用,从第5扇区起我们可以自由使用,考虑loader可能需要扩展选的第9扇区。
seek为9,目的是跨过前9个扇区(第0~8个扇区),我们在第9个扇区写入。
count为200, 目的是一次往参数of指定的文件中写入200个扇区。以后内核会越来越大,所以我们直接读入200个
sudo dd if=kernel/kernel.bin of=/bochs/bin/dreams.img bs=512 count=200 seek=9 conv=notrunc

我们的内核是由loader加载的,所以我们还要去修改下loader.asm
loader需要修改两个地方:
- 加载内核:需要把内核文件加载到内存缓冲区。
- 初始化内核:需要在分页后,将加载进来的elf内核文件安置到相应的虚拟内存地址,然后跳过去执行,从此loader的工作结束。
全部代码如下:
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR ; 程序开始的地址
LOADER_STACK_TOP equ LOADER_BASE_ADDR ; 栈顶地址
;构建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 ; 此时dpl为0
GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; 此处预留60个描述符的slot
SELECTOR_CODE equ (0x0001<<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 ; 保存内存容量,以字节为单位
; 以下是定义gdt的指针,前2字节是gdt界限,后4字节是gdt起始地址
gdt_ptr dw GDT_LIMIT
dd GDT_BASE
ards_buf times 244 db 0 ; 人工对齐:total_mem_bytes4字节+gdt_ptr6字节+ards_buf244字节+ards_nr2,共256字节
ards_nr dw 0 ; 用于记录ards结构体数量
loader_start:
; 获取内存容量,int 15, ax = E820h
xor ebx, ebx ;第一次调用时,ebx值要为0
mov edx, 0x534d4150 ;edx只赋值一次,循环体中不会改变
mov di, ards_buf ;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数量
cmp ebx, 0 ;若ebx为0且cf不为1,这说明ards全部返回,当前已是最后一个
jnz .e820_mem_get_loop
;在所有ards结构中,找出(base_add_low + length_low)的最大值,即内存的容量。
mov cx, [ards_nr] ;遍历每一个ARDS结构体,循环次数是ARDS的数量
mov ebx, ards_buf
xor edx, edx ;edx为最大的内存容量,在此先清0
.find_max_mem_area: ;无须判断type是否为1,最大的内存块一定是可被使用
mov eax, [ebx] ;base_add_low
add eax, [ebx+8] ;length_low
add ebx, 20 ;指向缓冲区中下一个ARDS结构
cmp edx, eax ;冒泡排序,找出最大,edx寄存器始终是最大的内存容量
jge .next_ards
mov edx, eax ;edx为总内存大小
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok
; 获取内存容量,int 15, ax = E801h
.e820_failed_so_try_e801:
mov ax,0xe801
int 0x15
jc .e801_failed_so_try88 ;若当前e801方法失败,就尝试0x88方法
; 先算出低15M的内存,ax和cx中是以KB为单位的内存数量,将其转换为以byte为单位
mov cx,0x400 ;cx和ax值一样,cx用做乘数
mul cx
shl edx,16
and eax,0x0000FFFF
or edx,eax
add edx, 0x100000 ;ax只是15MB,故要加1MB
mov esi,edx ;先把低15MB的内存容量存入esi寄存器备份
; 再将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 15, ah = 0x88
.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 ;把dx移到高16位
or edx, eax ;把积的低16位组合到edx,为32位的积
add edx,0x100000 ;0x88子功能只会返回1MB以上的内存,故实际内存大小要加上1MB
;将内存换为byte单位后存入total_mem_bytes处。
.mem_get_ok:
mov [total_mem_bytes], edx
; 进入保护模式三步骤
; 1.打开A20地址线
in al,0x92
or al,0000_0010B
out 0x92,al
; 2.加载gdt描述符
lgdt [gdt_ptr]
; 3.修改cr0标志寄存器的PE位
mov eax, cr0
or eax, 0x00000001
mov cr0, eax
jmp dword SELECTOR_CODE:p_mode_start ; 刷新流水线,避免分支预测的影响
; 远跳将导致之前做的预测失效,从而起到了刷新的作用。
.error_hlt: ;出错则挂起
hlt
; 下面就是保护模式下的程序了
[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
;---------------------加载kernel-------------------------
mov eax,KERNEL_START_SECTOR ;kernel.bin所在扇区号
mov ebx,KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到ebx指定的地址
mov ecx,200 ;读入的扇区数
call rd_disk_m_32
;--------------------------------------------------------
;创建页目录表及页表并初始化页内存位图
call setup_page
;要将描述符表地址及偏移量写入内存gdt_ptr中,一会儿用新地址加载
sgdt [gdt_ptr] ;存储到原来gdt所有的位置,sgdt可以获取gdt表的位置,可以在3环和0环运行,会将gdtr寄存器能容返回
;因为内核运行在3GB之上,打印功能肯定要在内核中运行,不能让用户程序控制显存
;将gdt描述符中视频段描述符中的段基址+0xc000_0000
mov ebx,[gdt_ptr + 2] ;gdt前2字节是偏移量,后4字节是基址,这里是取得gdt基址
or dword [ebx + 0x18 + 4], 0xc000_0000
;视频段是第3个段描述符,每个描述符是8字节,故0x18
;段描述符的最高4字节用来记录段基址,段基址的第31-24位
;修改完显存描述符后,来修改gdt基址,我们把gdt也移到内核空间中
;将gdt的基址加上0xc000_0000使其成为内核所在的最高位
add dword [gdt_ptr + 2], 0xc000_0000
add esp, 0xc000_0000 ;将栈指针同样映射到内核地址
;把页目录地址赋给cr3
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax
;打开cr0的pg位
mov eax,cr0
or eax,0x8000_0000
mov cr0, eax
;在开启分页后,用gdt新地址重新加载
lgdt [gdt_ptr] ;重新加载
;------------此时不刷新流水线也没关系,为了以防万一还是加上
jmp SELECTOR_CODE:enter_kernel
enter_kernel:
call kernel_init
mov esp,0xc009_f000
jmp KERNEL_ENTRY_POINT
;------------------将kernel.bin中的segment拷贝到编译的地址---------------
kernel_init: ;全部清零
xor eax,eax
xor ebx,ebx ;ebx记录程序头表地址
xor ecx,ecx ;cx记录程序头表中的program header数量
xor edx,edx ;dx记录program header尺寸,即e_phentsize
mov dx,[KERNEL_BIN_BASE_ADDR + 42] ;dx = e_phentsize:偏移文件42字节处的属性是e_phentsize,表示program header的大小
mov ebx,[KERNEL_BIN_BASE_ADDR + 28] ;ebx = e_phoff:偏移文件开始部分28字节的地方是e_phoff,表示第一个program header在文件中的偏移量。这里是将e_phoff给ebx而不是KERNEL_BIN_BASE_ADDR + 28的地址
add ebx,KERNEL_BIN_BASE_ADDR ;ebx = KERNEL_BIN_BASE_ADDR + e_phoff = 程序头表的物理地址
mov cx,[KERNEL_BIN_BASE_ADDR + 44] ;cx = e_phnum:偏移文件开始部分44字节的地方是e_phnum,表示有几个program header
.each_segment: ;分析每个段,如果不是空程序类型,则将其拷贝到编译的地址中
cmp byte [ebx + 0],PT_NULL ;程序先判断下段类型是不是PT_NULL,表示空段类型
je .PTNULL ;如果p_type = PTNULL(空程序类型),说明此program header未使用,则跳转到下一个段头
;为函数mem_cpy(dst,src,size)压入参数,参数从右往左依次压入
push dword [ebx + 16] ;push f_filesz:program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数size
mov eax,[ebx + 4] ;eax = p_offset:距程序头偏移量为4字节的位置是p_offset
add eax,KERNEL_BIN_BASE_ADDR ;eax = KERNEL_BIN_BASE_ADDR + p_offset = 该段的物理地址:加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ;push 该段的物理地址:压入memcpy的第二个参数:源地址
push dword [ebx + 8] ;push p_vaddr:压入memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr
call mem_cpy ;调用mem_cpy完成段复制
add esp,12 ;清理栈中压入的三个参数,每个4B
.PTNULL:
add ebx,edx ;edx为program header大小,即e_phentsize;每遍历一个段头,就跳转到下一个段头处
loop .each_segment ;在此ebx指向下一个program header
ret
;------------逐字节拷贝mem_cpy(dst,src,size)-------------
;输入:栈中三个参数(dst,src,size)
;输出:无
;-------------------------------------------------------
mem_cpy:
cld ;clean direction,将eflags寄存器中的方向标志位DF置0,这样rep在循环执行后面的字符串指令时,esi和edi根据使用的字符串搬运指令,自动加上所搬运数据的字节大小。
push ebp
mov ebp,esp ;esp是栈顶指针
push ecx ;rep指令用到了ecx,但ecx对于外层段的循环还有用,所以先入栈备份
mov edi,[ebp + 8] ;dst
mov esi,[ebp + 12] ;src
mov ecx,[ebp + 16] ;size
rep movsb ;逐字节拷贝:movs表示mov string,b表示byte,w表示word,d表示dword。将DS:EI/SI指向的地址处的字节搬运到ES:DI/EI指向的地址去。
;16位环境下源地址指针用SI寄存器,目的地址指针用DI寄存器;32位环境下源地址用SI寄存器,目的地址用EDI寄存器。
;恢复环境
pop ecx
pop ebp
ret ;在调用ret时,栈顶处的数据是正确的返回地址。一般情况下,我们在函数体中保持push和pop配对使用。
;------------------创建页目录表及页表-------------------
setup_page:
;先把页目录表占用的空间逐字清零
mov ecx, 4096 ;4KB,ecx = 0用于loop循环的终止条件
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi],0 ;PAGE_DIR_TABLE_POS用于定义页目录表的物理地址
inc esi ;esi++,PAGE_DIR_TABLE_POS为基址,esi为变址
loop .clear_page_dir
;开始创建页目录项(PDE)
.create_pde: ;创建Page Directory Entry
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ;此时eax为第一个页表的位置和属性,0x10_1000
mov ebx, eax ;此处为ebx赋值,是为.create_pte做准备,ebx是基址,在.create_pte中使用
;下面将页目录项0和0xc00都存为第一个页表的地址,指向同一个页表,每个页表表示4MB内存
;这样0xc03f_ffff以下的地址和0x003f_ffff以下的地址都指向相同的页表
;这是为将地址映射为内核地址做准备
or eax, PG_US_U | PG_RW_W | PG_P ;页目录项的属性RW和P位为1,US为1,表示用户属性,所有特权级别都可以访问,逻辑或后结果为0x7
mov [PAGE_DIR_TABLE_POS + 0x0],eax ;第一个目录项,在页目录表中第一个页目录项写入第一个页表的位置(0x10_1000)及属性(7),eax = 0x10_1007
mov [PAGE_DIR_TABLE_POS + 0xc00],eax
;一个页表项占4B,0xc00表示第768个页表占用的目录项,0xc00以上的目录项用于内核空间,也就是页表的0xc000_0000-0xffff_ffff共计1G属于内核
;0x0-0xbfff_ffff共计3G属于用户进程
sub eax, 0x1000 ;eax = 0x10_0000
mov [PAGE_DIR_TABLE_POS + 4092],eax ;使最后一个目录项指向页目录表自己的地址,4096-4=4092,因为一个目录项4B,为了将来能动态操作页表
;下面创建页表项PTE
;创建第一个页表0x10_1000,它用来分配物理范围0-0x3f_ffff之间的物理页,也就是虚拟地址0-0x3f_ffff和0xc000_0000-0xc03f_ffff对应的物理页。
mov ecx, 256 ;因为目前只用到了1MB内存,所以只分配这1MB。1M低端内存/每页大小4k = 256页,即256个页表项,一共要创建256个页表
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ;属性为7,US=1,RW=1,P=1
.create_pte: ;创建Page Table Entry
mov [ebx + esi*4],edx ;向页表项中写入页表地址;此时ebx已经通过上面eax赋值为0x10_1000,也就是第一个页表的地址
add edx, 4096 ;edx + 4KB指向下一个页表的起始地址(第二个页表)
inc esi
loop .create_pte
;创建内核其他页表的PDE,即内核空间中除第0个页表外的其余所有页表对应的页目录项
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ;此时eax为第二个页表的位置
or eax, PG_US_U |PG_RW_W |PG_P ;页目录项的属性US、RW和P位都是1
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ;范围为第769-1022的所有目录项数量,第255个已经指向了页目录表本身
mov esi, 769
.create_kernel_pde:
mov [ebx+esi*4], eax ;从第二个页目录项开始写
inc esi
add eax, 0x1000
loop .create_kernel_pde
ret
;读取文件到内存
;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,0x08
shr eax,cl ;逻辑右移8位,将eax的低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 ;第3位=1表示已经准备好,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready
;5.0x1f0端口读取数据
mov ax,di ;要读取的扇区数
mov dx,256 ;一个扇区512B,一次读取2B,需要读取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
加载内核
把内核文件从硬盘拷贝到内存中,并不运行内核代码。在分页前后都可以,这里在分页前加载。内核加载到内存中,要有个加载地址,即缓冲区。缓冲区buffer意味着暂时存放数据的地方。因为内核很小,在低端1MB中安放即可;
下图中打勾的是可用区域:

MBR刚刚结束使命就要被覆盖掉了。
内核被加载到内存后,loader还要通过分析其elf结构将其展开到新的位置,所以说,内核在内存中有两份拷贝,一份是elf格式的原文件kernel.bin,另一份是loader解析elf格式的kernel.bin后在内存中生成的内核映像(也就是将程序中的各种段segment复制到内存后的程序体),这个映像才是真正运行的内核。
我们在0x7e00~0x9fbff这片区域中找一个高地址来存放kernel.bin,这里选择了0x70000。因为0x9fbff – 0x70000 = 0x2fbff = 190KB,而我们的内核不会超过100KB,所以肯定够用且为整数。
;---------------------加载kernel------------------------- mov eax,KERNEL_START_SECTOR ;kernel.bin所在扇区号 mov ebx,KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到ebx指定的地址 mov ecx,200 ;读入的扇区数 call rd_disk_m_32 ;--------------------------------------------------------
逐行解释:
- mov eax, KERNEL_START_SECTOR: 将KERNEL_START_SECTOR 的值加载到 eax 寄存器中。这是 kernel.bin 存储在磁盘上的起始扇区号,用于标识从哪个扇区开始读取内核文件。
- mov ebx, KERNEL_BIN_BASE_ADDR: 将KERNEL_BIN_BASE_ADDR 的值加载到 ebx 寄存器中。这个值指定了内核加载后存放的内存地址,即 kernel.bin 被读取后要放置在内存中的位置。
- mov ecx, 200: 将数值 200 加载到 ecx 寄存器中。这个数值表示要从磁盘读取的扇区数,通常用来指定读取整个内核文件所需的扇区数量。
- 以上的eax, ebx, ecx是函数rd_disk_m_32的三个参数,为调用下面的函数做准备。
- call rd_disk_m_32: 这里调用了一个函数 rd_disk_m_32,它的作用是从磁盘上读取数据。这个函数的具体实现会包括从 eax 指定的扇区开始读取 ecx 指定的扇区数,并将读取的数据写入 ebx 指定的内存地址。
同mbr中的rd_disk函数逻辑差不多,只是版本由16位变成了32位的,函数实现原理相差无几,主要体现在里面所用的寄存器变成了32位。
;读取文件到内存
;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,0x08
shr eax,cl ;逻辑右移8位,将eax的低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 ;第3位=1表示已经准备好,第7位为1表示硬盘忙
cmp al,0x08
jnz .not_ready
;5.0x1f0端口读取数据
mov ax,di ;要读取的扇区数
mov dx,256 ;一个扇区512B,一次读取2B,需要读取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
初始化内核
因为内核肯定是越来越大,为了预留出生长空间,我们将内核文件kernel.bin加载到地址较高的空间;而内核映像放置在地址较低的空间。内核文件经过loader解析后就没用了,所以内核映像向高地址扩展时,可以覆盖掉原来的kernel.bin。
在分页模式下,程序是靠虚拟地址来运行的,交给CPU的指令/数据的地址一律被认为是虚拟地址。即向安排内核在哪片虚拟内存中,就将内核地址编译成对应的虚拟地址。
我们考虑要选择哪个虚拟地址作为内核映像的入口地址。0x900处是loader.bin加载的地址,在loader.bin的开始部分是GDT,它是不能被覆盖的。预计loader.bin不会超过2000B,所以我们可选的物理地址为0x900+2000(0x7d0) = 0x9d0,为了凑整数,选择了0x1500。
在我们的页表中,低端1MB的虚拟内存与物理地址是一一对应的,所以物理地址为0x1500,对应的虚拟地址为0xc000_1500。
对于可执行程序,我们只对其中的段(segment)感兴趣,它们才是程序运行的实质指令和数据的所在地,所以我们要找出程序中所有的段。
;------------------将kernel.bin中的segment拷贝到编译的地址---------------
kernel_init: ;全部清零
xor eax,eax
xor ebx,ebx ;ebx记录程序头表地址
xor ecx,ecx ;cx记录程序头表中的program header数量
xor edx,edx ;dx记录program header尺寸,即e_phentsize
mov dx,[KERNEL_BIN_BASE_ADDR + 42] ;dx = e_phentsize:偏移文件42字节处的属性是e_phentsize,表示program header的大小
mov ebx,[KERNEL_BIN_BASE_ADDR + 28] ;ebx = e_phoff:偏移文件开始部分28字节的地方是e_phoff,表示第一个program header在文件中的偏移量。这里是将e_phoff给ebx而不是KERNEL_BIN_BASE_ADDR + 28的地址
add ebx,KERNEL_BIN_BASE_ADDR ;ebx = KERNEL_BIN_BASE_ADDR + e_phoff = 程序头表的物理地址
mov cx,[KERNEL_BIN_BASE_ADDR + 44] ;cx = e_phnum:偏移文件开始部分44字节的地方是e_phnum,表示有几个program header函数kernel_init的作用是将kernel.bin中的段(segment)拷贝到各段自己被编译的虚拟地址处,将这些段单独提取到内存中,这就是平时所说的内存中的程序映像。
kernel_init的原理是分析程序中的每个段(segment),如果段类型不是PT_NULL (空程序类型),就将该段拷贝到编译的地址中。
现在内核已经被加载到KERNEL_BIN_BASE_ADDR 地址处,该处是文件头elf_header。在我们的程序中,遍历段的方式是指向第一个程序头后,每次增加一个段头的大小,即e_phentsize。该属性位于偏移程序开头42字节处。为了以后遍历段时方便,避免了频繁的访问内存,我们用寄存器dx来存储段头大小mov dx,[KERNEL_BIN_BASE_ADDR + 42],这样,每遍历一个段头时,就直接从dx中获取段头大小,比如add ebx,edx 。
为了找到程序中所有的段,必须要获取程序头表。在文件开头偏移28字节处是属性e_phoff,该属性表示程序头表在文件中的偏移量,程序头表是程序头program header的数组,所以e_phoff也就是第1个program header在文件中的偏移量。
在内存e_phoff处取值,将得到的程序头表偏移量存入寄存器ebx。我们需要的是程序头表的物理地址,由于此时的ebx还是程序头表文件内的偏移量,所以要将其加上内核的加载地址,这样才是程序头表的物理地址。
所以在 add ebx,KERNEL_BIN_BASE_ADDR 为ebx加上了内核文件的加载地址KERNEL_BIN_BASE_ADDR。最终ebx寄存器作为程序头表的基址,用它来遍历每一个段,此时ebx指向程序中的第1个program header。
段是由程序头(program header)来描述的,一个程序头代表一个段。在知道了第一个程序头的地址后,为了遍历所有的程序头,还需要知道程序中程序头的数量,也就是段的数量,这是由elf_header中的属性e_phnum决定的,它在elf_header中偏移为44。我们通常用cx寄存器来做循环计数器,所以使用汇编语句”mov cx, [KERNEL_BIN_BASE_ADDR +44]”将段的数量赋值给寄存器cx。现在程序头表地址在寄存器ebx中。而且又知道了程序头表中段的数量,所以现在可以遍历每一个段的信息啦,其工作在.each_segment: 中完成。
.each_segment: ;分析每个段,如果不是空程序类型,则将其拷贝到编译的地址中
cmp byte [ebx + 0],PT_NULL ;程序先判断下段类型是不是PT_NULL,表示空段类型
je .PTNULL ;如果p_type = PTNULL(空程序类型),说明此program header未使用,则跳转到下一个段头
;为函数mem_cpy(dst,src,size)压入参数,参数从右往左依次压入
push dword [ebx + 16] ;push f_filesz:program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数size
mov eax,[ebx + 4] ;eax = p_offset:距程序头偏移量为4字节的位置是p_offset
add eax,KERNEL_BIN_BASE_ADDR ;eax = KERNEL_BIN_BASE_ADDR + p_offset = 该段的物理地址:加上kernel.bin被加载到的物理地址,eax为该段的物理地址
push eax ;push 该段的物理地址:压入memcpy的第二个参数:源地址
push dword [ebx + 8] ;push p_vaddr:压入memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr
call mem_cpy ;调用mem_cpy完成段复制
add esp,12 ;清理栈中压入的三个参数,每个4Bcmp byte [ebx + 0],PT_NULL 程序先判断下段的类型是不是PT_NULL,PT_NULL是在boot.inc中定义的宏,其值为0,该意义表示空段类型。(PT_NULL也可以在Linux系统的/usr/include/elfh中找到其定义:#define PT_NULL 0.)
je .PTNULL 如果发现该段是空段类型的话,就跨过该段不处理,跳到.PTNULL处。
.PTNULL:
add ebx,edx ;edx为program header大小,即e_phentsize;每遍历一个段头,就跳转到下一个段头处
loop .each_segment ;在此ebx指向下一个program header
ret在.PTNULL处add ebx,edx 指定下一个段是通过在程序头表地址处加上一个段的大小e_phentsize来实现的,e_phentsize的值咱们已经将其存储在dx寄存器啦,所以直接将ebx,也就是当前program header地址,加上edx,ebx便指向了下一个段的program header。edx的高16位为0,所以这里用addebx, edx没有问题。
回到.each_segment:程序中的段通过mem_cpy函数复制到段自身的虚拟地址处。我们在此实现的函数是mem_cpy,不是c标准库中的memcpy函数,将来我们会在内核中实现memcpy。memcpy原型是void *memcpy(void *dest, const void *src, size_tn),功能是将src指向的地址空间处的连续n个字节拷贝到dest指向的地址空间。在汇编语言中用mem_cpy函数实现了它,此函数的原型相当于mem_cpy(void* dst, void* src, int size)。所以我们也要提供三个参数才能使用它。这三个参数都在程序头program header中,所以它们都可以基于ebx再增加适当的偏移量来得到。
在这里,我们把参数放到了栈中保存。
由于栈指针 esp 已经在 loader.S 中被加上了0xc0000000,所以其栈中地址都是内核所在的0xc0000000 以上的高地址。用 call 指令进行函数调用时,CPU 会自动在栈中压入返回地址,
由图可见:

当调用kernel_init函数时,当时的栈指针是0xc00008fc,所以kernel_init 的返回地址被存储在 0xc00008fc 处。栈中地址 0xc00008f8 处的内容是提供给函数 mem_cpy的第三个参数,即size。地址较低的0xc00008f4处是它的第二个参数,即src地址,0xc00008f0处是它的第一个参数,即dst。
;为函数mem_cpy(dst,src,size)压入参数,参数从右往左依次压入 push dword [ebx + 16] ;push f_filesz:program header中偏移16字节的地方是p_filesz,压入函数memcpy的第三个参数size mov eax,[ebx + 4] ;eax = p_offset:距程序头偏移量为4字节的位置是p_offset add eax,KERNEL_BIN_BASE_ADDR ;eax = KERNEL_BIN_BASE_ADDR + p_offset = 该段的物理地址:加上kernel.bin被加载到的物理地址,eax为该段的物理地址 push eax ;push 该段的物理地址:压入memcpy的第二个参数:源地址 push dword [ebx + 8] ;push p_vaddr:压入memcpy的第一个参数:目的地址,偏移程序头8字节的位置是p_vaddr call mem_cpy ;调用mem_cpy完成段复制 add esp,12 ;清理栈中压入的三个参数,每个4B
mem_cpy代码:
;------------逐字节拷贝mem_cpy(dst,src,size)-------------
;输入:栈中三个参数(dst,src,size)
;输出:无
;-------------------------------------------------------
mem_cpy:
cld ;clean direction,将eflags寄存器中的方向标志位DF置0,这样rep在循环执行后面的字符串指令时,esi和edi根据使用的字符串搬运指令,自动加上所搬运数据的字节大小。
push ebp
mov ebp,esp ;esp是栈顶指针,保存当前栈帧指针
push ecx ;rep指令用到了ecx,但ecx对于外层段的循环还有用,所以先入栈备份,保存 ecx 寄存器
mov edi,[ebp + 8] ;dst目的地址
mov esi,[ebp + 12] ;src源地址
mov ecx,[ebp + 16] ;size拷贝大小
rep movsb ;逐字节拷贝:movs表示mov string,b表示byte,w表示word,d表示dword。将DS:EI/SI指向的地址处的字节搬运到ES:DI/EI指向的地址去。
;16位环境下源地址指针用SI寄存器,目的地址指针用DI寄存器;32位环境下源地址用SI寄存器,目的地址用EDI寄存器。
;恢复环境
pop ecx
pop ebp
ret ;在调用ret时,栈顶处的数据是正确的返回地址。一般情况下,我们在函数体中保持push和pop配对使用。
在 mem_cpy 的实现中,我们访问栈中的参数是基于 ebp 来访问的,这通常意味着要将 esp 的值赋给 ebp。将ebp入栈备份,这样在函数结束时能够将其恢复。
mov ebp,esp 将esp赋值给了ebp。由于后来在push ecx 又将 ecx 入栈,故 esp 已经小于 ebp。栈中每个单元占用 4 字节,既然是基于ebp来获得栈中的参数,那么第1个参数 dst的地址是ebp+8,第2个参数src的地址是ebp+12,第3个参数size的地址是ebp+16,分别对这些地址用中括号取值后,便可以得到实际的参数。
cld 指令用于清除方向位,确保接下来的字符串操作是向前的。在处理字符串时,方向位的设置会影响 rep movsb 指令的行为,cld 确保每次拷贝操作都是从源地址向目的地址递增的方式进行。
cld是指clean direction,该指令是将eflags寄存器中的方向标志位DF置为0,这样rep在循环执 行后面的字符串指令时, [e]si和[e]di根据使用的字符串搬运指令,自动加上所搬运数据的字节大小,这是 由CPU自动完成的,有清除方向标志位就会有设置方向标志位,std是set direction,该指令是将方向标志位 DF置为1,每次rep循环执行后面字符串指令时, [e]si和[e]di自动减去所搬运数据的字节大小。
rep movsb 是一个重复指令,它将 ecx 寄存器中的字节数按照 esi 和 edi 指示的地址从源地址复制到目的地址。每执行一次 movsb 指令,esi 和 edi 自动增加或减少,根据方向位的设置决定是递增还是递减。
movsb、movsw、movsd。其中的movs代表move string,后面的b代表byte, w代表word,d代表dword。所以movsb的功能是搬运(复制) 1字节,movsw的功能是搬运(复制) 2字节,movsd的功能是搬运(复制)4字节。这三条指令是将DS: [E]SI指向的地址处的1、2或4个字节搬到ES: [E]DI指向的地址处, 16位环境下源地址指针用SI寄存器,目的地址指针用DI寄存器,32位环境下源地址则用ESI,目的地址则用EDI。因为字符串中的字符是按字节来存储的,任何数据在内存中都以字节存储单元来访问,字符串只是表象,本质上是复制字节,所以它更多地被通用于复制数据。
rep指令是repeat重复的意思,该指令是按照ecx寄存器中指定的次数重复执行后面的指定的指令,每执行一次,ecx自减1,直到ecx等于0时为止,所以在用rep重复执行某个指令之前,一定要将ecx寄存器提前赋值。
ret 指令用于函数返回,将控制流转移回到调用 mem_cpy 函数的位置。
还有刷新流水线。
;在开启分页后,用gdt新地址重新加载
lgdt [gdt_ptr] ;重新加载
;------------此时不刷新流水线也没关系,为了以防万一还是加上
jmp SELECTOR_CODE:enter_kernel
enter_kernel:
call kernel_init
mov esp,0xc009_f000
jmp KERNEL_ENTRY_POINT在进入内核之后,我们用的栈也要重新规划了,栈起始地址不能再用之前的0xc0000900啦。为了方便编写程序,我们在进入内核前将栈指针改成我们期待的值,我们将esp改成了0xc009f000。
boot.inc加入需要的宏
;-----------加载内核-------------------- KERNEL_START_SECTOR equ 0x9 KERNEL_BIN_BASE_ADDR equ 0x70000 KERNEL_ENTRY_POINT equ 0xc000_1500 PT_NULL equ 0
4.参考
郑钢著操作系统真象还原
田宇著一个64位操作系统的设计与实现
丁渊著ORANGE’S:一个操作系统的实现


