《操作系统真象还原》第五章(3)——载入内核
文章目录
- 前言
- 一个报错的解决
- 用 C 语言写内核
- GCC版本问题
- 编译连接,得到可执行文件
- 将内核载入内存
- 写两个脚本
- 修改loader.S
- 加载内核
- 初始化内核
- 跳转到内核区
- 运行Bochs
- 目前的完整源码
- loader.S
- boot.int
- 结语
前言
这是第五章第三部分,主要完成内核相关的内容。
前两部分链接:
《操作系统真象还原》第五章(1)——获取内存容量-CSDN博客
《操作系统真象还原》第五章(2)——启用内存分页机制-CSDN博客
这篇博客也是第五章最后一篇博客,最后会对第五章进行一个总结。
全文25000字+,请耐心阅读。
一个报错的解决
第五章第二部分结尾出现了一个报错,昨天时间不够暂且搁置,今天把它解决。对照书上的截图和代码后发现源代码有问题,下面贴出修改后的代码。
;将页目录0和0x300项都指向第一个页表的地址,都对应0-4mb的物理地址。
mov [PAGE_DIR_TABLE_POS+0x0],eax ;0项
mov [PAGE_DIR_TABLE_POS+0xc00],eax ;0x300项转化为10进制是3*256=768
;错误代码是mov [PAGE_DIR_TABLE_POS+0x300],eax,错误原因是一个项4字节忘了×4
sub eax,0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax ;在页目录最后一项1023项写入页目录本身的地址,所以是4096-4
编译源代码、写入硬盘、运行bochs
nasm -I include/ -o loader.bin loader.S
dd if=/home/hongbai/bochs/loader.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=4 seek=2 conv=notrunc
cd bin
./bochs -f bochsrc.disk
6,c,ctrl+c,然后输入info gdt
查看gdtr寄存器情况,结果如下

可以看到原来的报错已经消失,成功显示了四个段提示符(0,代码段,数据段,显存段)
输入info tab
查看虚拟地址和物理地址的映射情况,结果如下

和理想中的情况一致。
用 C 语言写内核
这部分需要注意的是我们写操作系统必须避开依赖,所以只支持c原生语法结构,不能用c标准库。
在对应路径下(对我而言就是~/bochs)新建kernel文件夹,在kernel文件夹下新建文本文件,命名为main.c,代码如下:
int main(void){
while(1);
return 0;
}
这个简单粗暴的死循环仅仅是为了演示 elf 文件解析以及加载内核的作用。
GCC版本问题
参考博客链接:关于Ubutun20及以上安装gcc-4.4 gcc-4.4-multilib的方法_gcc-multilib安装-CSDN博客
输入gcc -v
查看gcc版本,结果如下,我的版本是13.3.0,需要把它降级为4.4,便于兼容。

我的ubuntu版本是24.4,下面添加添加14.4的更新源
sudo add-apt-repository 'deb http://archive.ubuntu.com/ubuntu/ trusty main'
sudo add-apt-repository 'deb http://archive.ubuntu.com/ubuntu/ trusty universe'
sudo apt update
报错
W: GPG 错误:http://archive.ubuntu.com/ubuntu trusty Release: 由于没有公钥,无法验证下列签名: NO_PUBKEY 40976EAF437D05B5 NO_PUBKEY 3B4FE6ACC0B21F32
E: 仓库 “http://archive.ubuntu.com/ubuntu trusty Release” 没有数字签名。
N: 无法安全地用该源进行更新,所以默认禁用该源。
N: 参见 apt-secure(8) 手册以了解仓库创建和用户配置方面的细节。
因为 Ubuntu 的软件仓库使用 GPG 密钥进行签名验证,而较旧版本的密钥可能已从新系统中移除。所以我们需要先导入密钥。下面三行代码是下载密钥,导入密钥到文件,再次更新更新源的代码。
gpg --keyserver hkp://keyserver.ubuntu.com:80 --recv-keys 40976EAF437D05B5 3B4FE6ACC0B21F32
gpg --export 40976EAF437D05B5 | sudo tee /etc/apt/trusted.gpg.d/ubuntu-trusty-main.gpg > /dev/null
gpg --export 3B4FE6ACC0B21F32 | sudo tee /etc/apt/trusted.gpg.d/ubuntu-trusty-universe.gpg > /dev/null
sudo apt update
结果如下图

输入sudo apt-get install -y gcc-4.4 g++-4.4
安装4.4版本的gcc和g++,然后输入dpkg -l | grep gcc
查看gcc版本,结果如下

目前已经安装了4.4gcc,输入下面两行代码,把4.4设置为高优先级并查看。
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-4.4 50
sudo update-alternatives --install /usr/bin/gcc gcc /usr/bin/gcc-13 40
sudo update-alternatives --config gcc
结果如下,可以看到4.4已经是默认版本。

编译连接,得到可执行文件
输入gcc -c -o kernel/main.o kernel/main.c
,编译main.c,得到main.o,这是一个待重定位文件,还是个半成品。
输入ld kernel/main.o -Ttext 0xc0001500 -e main -o kernel/kernel.bin
-Ttext是指定起始虚拟地址,我们设定为0xc0001500,这个数字后面有解释。-e是指定程序从哪个起始地址开始运行,可以用标号,如果不指定,默认是从_start符号处开始运行。
最后得到kernel.bin,这是个可执行文件,最终结果如下

我们输入以下两行代码,查看main.c最终转化为的汇编代码是什么样的。
gcc -S -o /tmp/main.S main.c
cat -n /tmp/main.S
结果如下,和设想的一样,转化为类似jmp $
的代码

输入readelf -e kernel.bin
,查看elf重点信息,结果如下
ELF 头:
Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00
类别: ELF64
数据: 2 补码,小端序 (little endian)
Version: 1 (current)
OS/ABI: UNIX - System V
ABI 版本: 0
类型: EXEC (可执行文件)
系统架构: Advanced Micro Devices X86-64
版本: 0x1
入口点地址: 0xc0001500
程序头起点: 64 (bytes into file)
Start of section headers: 4432 (bytes into file)
标志: 0x0
Size of this header: 64 (bytes)
Size of program headers: 56 (bytes)
Number of program headers: 4
Size of section headers: 64 (bytes)
Number of section headers: 7
Section header string table index: 6
节头:
[号] 名称 类型 地址 偏移量
大小 全体大小 旗标 链接 信息 对齐
[ 0] NULL 0000000000000000 00000000
0000000000000000 0000000000000000 0 0 0
[ 1] .text PROGBITS 00000000c0001500 00000500
0000000000000006 0000000000000000 AX 0 0 1
[ 2] .eh_frame PROGBITS 00000000c0002000 00001000
0000000000000034 0000000000000000 A 0 0 8
[ 3] .comment PROGBITS 0000000000000000 00001034
000000000000002a 0000000000000001 MS 0 0 1
[ 4] .symtab SYMTAB 0000000000000000 00001060
0000000000000090 0000000000000018 5 2 8
[ 5] .strtab STRTAB 0000000000000000 000010f0
0000000000000025 0000000000000000 0 0 1
[ 6] .shstrtab STRTAB 0000000000000000 00001115
0000000000000034 0000000000000000 0 0 1
Key to Flags:
W (write), A (alloc), X (execute), M (merge), S (strings), I (info),
L (link order), O (extra OS processing required), G (group), T (TLS),
C (compressed), x (unknown), o (OS specific), E (exclude),
D (mbind), l (large), p (processor specific)
程序头:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x00000000c0001000 0x00000000c0000000
0x0000000000000120 0x0000000000000120 R 0x1000
LOAD 0x0000000000000500 0x00000000c0001500 0x00000000c0001500
0x0000000000000006 0x0000000000000006 R E 0x1000
LOAD 0x0000000000001000 0x00000000c0002000 0x00000000c0002000
0x0000000000000034 0x0000000000000034 R 0x1000
GNU_STACK 0x0000000000000000 0x0000000000000000 0x0000000000000000
0x0000000000000000 0x0000000000000000 RW 0x10
Section to Segment mapping:
段节...
00
01 .text
02 .eh_frame
03
将内核载入内存
内核文件是kernel.bin,loader把它从硬盘载入内存,完成接力棒最后一棒。先把kernel写入硬盘,代码如下
dd if=/home/hongbai/bochs/kernel/kernel.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=200 seek=10 conv=notrunc
写入虚拟硬盘的第10扇区,设置为200个扇区大小,200*512B=100KB,写大一点,方便我们后续扩展。
gcc -c -o main.o main.c
ldmain.o -Ttext 0xc0001500 -e main -o kernel.bin
dd if=/home/hongbai/bochs/kernel/kernel.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=200 seek=10 conv=notrunc
写两个脚本
考虑到后续我们会多次修改kernel,干脆把编译、连接、写入硬盘三部分工作集成起来,写一个脚本。
在kernel文件夹下新建build_kernel.sh,代码如下
#!/bin/bash
# 编译链接main.c并将可执行文件写入硬盘的脚本
# 1. 编译main.c生成main.o
echo "正在编译main.c..."
gcc -c -o main.o main.c
if [ $? -ne 0 ]; then
echo "编译失败!"
exit 1
fi
# 2. 链接main.o生成kernel.bin
echo "正在链接目标文件..."
ld main.o -Ttext 0xc0001500 -e main -o kernel.bin
if [ $? -ne 0 ]; then
echo "链接失败!"
exit 1
fi
# 3. 将kernel.bin写入磁盘镜像
echo "正在写入磁盘镜像..."
dd if=/home/hongbai/bochs/kernel/kernel.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=200 seek=10 conv=notrunc
if [ $? -ne 0 ]; then
echo "写入磁盘失败!"
exit 1
fi
echo "操作成功完成!"
保存,然后输入chmod +x build_kernel.sh
给予执行权限。
同理我们再写一个用于构建loader的脚本
#!/bin/bash
# 编译链接写入loader的脚本
# 定义路径变量(方便修改)
LOADER_SOURCE="loader.S"
LOADER_BIN="loader.bin"
BOCHS_DIR="/home/hongbai/bochs"
OUTPUT_IMG="$BOCHS_DIR/bin/c.img"
INCLUDE_DIR="include/"
# 1. 使用nasm编译loader.S
echo "正在编译loader.S..."
nasm -I $INCLUDE_DIR -o $LOADER_BIN $LOADER_SOURCE
if [ $? -ne 0 ]; then
echo "编译失败!"
exit 1
fi
# 2. 将loader.bin写入磁盘镜像
echo "正在写入磁盘镜像..."
dd if=$BOCHS_DIR/$LOADER_BIN of=$OUTPUT_IMG bs=512 count=4 seek=2 conv=notrunc
if [ $? -ne 0 ]; then
echo "写入磁盘失败!"
exit 1
fi
echo "loader编译并写入成功完成!"
放在bochs文件夹。给权限chmod +x build_loader.sh
。
运行指令是./build_loader.sh
(加载器)和./build_kernel.sh
(内核)
修改loader.S
总共要修改两个部分:1.加载内核,即把内核从硬盘加载到内存。2.初始化内核,作用是在分页后,把加载到内存的elf文件放到相应的虚拟内存地址,并jmp到那里,去执行内核。
加载内核
这部分我们放到分页前。
内核要写到内存的哪里?低1MB中,0-4ff存了中断向量表和bios数据,9fc00-9ffff也存了bios数据,除了这些地方都可用,范围是0x500-0x9fbff。我们要先把kernel.bin加载到内存,再把这个bin解析后展开成内核映像,这个内核映像才是实际运行的操作系统。我们把bin放到高地址,内核映像放到低地址,bin加载完成后可以被覆盖,最终选择了0x70000,这个地址到高地址边界还有200k左右空间,足够我们载入bin了,后续被覆盖也无所谓。
这部分代码类似mbr加载loader的代码。
先修改boot.inc,加入以下代码
;-------------------- 内核kernel --------------------
KERNEL_START_SECTOR equ 0xa ;硬盘扇区号
KERNEL_BIN_BASE_ADDR equ 0x70000 ;内存地址
KERNEL_ENTRY_POINT equ 0xc0001500 ;虚拟地址入口
然后就是加载kernel部分
mov eax,KERNEL_START_SECTOR ;kernel.bin所在的扇区号
mov ebx,KERNEL_BIN_BASE_ADDR ;从磁盘读出后,写入到 ebx指定的地址
mov ecx,200 ;读入的扇区数
call rd_disk_m_32 ;写入内存
关于rd_disk_m_32这部分代码,整体和mbr.S中的rd_disk_m_16一致,只需要把最后循环读入字节的地址寄存器bx改为ebx(我们是从0x70000开始读,bx显然不够大)。这里我就不再贴出来了。
初始化内核
初始化的含义是,根据 elf 规范将内核文件中的段(segment)展开到(复制到)内存中的相应位置。接着给出在哪段物理内存存放内存映像,以及内存映像的虚拟地址入口。
先看物理地址。loader.bin加载到内存0x900处,最终大小在2000字节以内,因为这里面定义了gdt,我们不要覆盖它,所以地址要在0x900+2000后,同时地址要尽可能的低一点,干脆选择0x1500这个地址作为内存映像的起始地址,那么对应的虚拟地址就是0xc0001500,这也是我们之前连接时指定的虚拟地址位置。
整体思路是从elf头中获取程序头数量、每个程序头尺寸、第一个程序头的文件偏移地址,循环程序头数量次,每次先检验这个程序头是否被使用,如果被使用就跳过,否则通过程序头获取段文件偏移地址、段要放到的虚拟地址位置、段字节数,然后利用一个memset函数进行复制,这样最终就展开了所有的segment。
代码如下:
;----------将kernel.bin中的segment展开 -----------
kernel_init:
xor eax,eax
xor ebx,ebx ;记录程序头表的地址,即e_phoff,也就是第一个程序头的偏移地址
xor ecx,ecx ;记录程序头表中,程序头的数量e_phnum,也就是segment数量
xor edx,edx ;记录每个程序头的尺寸,即e_phentsize
mov dx,[KERNEL_BIN_BASE_ADDR+42] ;程序头大小
mov ebx,[KERNEL_BIN_BASE_ADDR+28]
add ebx,KERNEL_BIN_BASE_ADDR ;程序头地址
mov cx,[KERNEL_BIN_BASE_ADDR+44] ;程序头数量
.each_segment:
cmp byte [ebx+0],PT_NULL;若相等,说明这个程序头未使用
je .PTNULL ;未使用则跳转
;模拟c语言里的mem_cpy,输入三个参数:源地址,目标地址,拷贝内容字节数
push dword [ebx+16] ;获取段字节数p_filesz
mov eax,[ebx+4] ;距程序头偏移量为4字节的位置是p_offset
add eax,KERNEL_BIN_BASE_ADDR
push eax ;段偏移地址,也就是源地址
push dword [ebx+8] ;目标地址p_vaddr
call mem_cpy
add esp,12 ;一次是向栈段压入3*4=12字节的数据
.PTNULL:
add ebx,edx ;指向下一个程序头
loop .each_segment
ret
mem_cpy:
cld ;将DF标志位设置为0,确保指针递增
push ebp ;我们用ebp指针配合sp,而不是esp,干脆在这里存进栈备份一下
mov ebp,esp
push ecx ;ecx保存着程序头数,后续会改为段字节数,这里备份一下
mov edi,[ebp+8] ;源地址
mov esi,[ebp+12] ;目标地址
mov ecx,[ebp+16] ;我们是逐字节复制,字节数也就是要复制的次数
rep movsb ;rep是重复,次数是ecx,movsb是按字节从ds:ei复制内容到es:di处,和cld配合使用,让ei和di自动自增
pop ecx
pop ebp ;恢复这两个参数,不影响上面循环的进行
ret
跳转到内核区
这部分代码非常短,一是让cpu跳转到内核区,二是给出虚拟地址入口,起到一个衔接作用。
;-------------------- 跳转到内核区 --------------------------------
jmp SELECTOR_CODE:enter_kernel ;刷新流水线
enter_kernel:
call kernel_init
mov esp,0xc009f000 ;设立栈段的起始地址,这个数字是最接近高地址的和4KB对齐的数
jmp KERNEL_ENTRY_POINT ;写在宏里面,0xc0001500
运行Bochs
编译写入loader,编译连接写入kernel,启动bochs并调试
gcc -m32 -c -o main.o main.c
ld -m elf_i386 main.o -Ttext 0xc0001500 -e main -o kernel.bin
dd if=/home/hongbai/bochs/kernel/kernel.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=200 seek=10 conv=notrunc
nasm -I include/ -o loader.bin loader.S
dd if=/home/hongbai/bochs/loader.bin of=/home/hongbai/bochs/bin/c.img bs=512 count=4 seek=2 conv=notrunc
cd bin
./bochs -f bochsrc.disk
这部分有个要点:我们在切换gcc版本为4.4后,默认编译方式仍然是64位,要在编译main时加上参数(所以之前的脚本还需要修改),指定以32位编码,cpu架构是80386,编译连接后输入readelf -e kernel.bin
,看是否如下图所示

最终结果如下

关于这个9是怎么来的,可以看下面代码中“跳转到内核区”部分的注释。
目前的完整源码
贴一下目前完整的源码,包括loader.S和boot.inc,mbr和kernel比较简短简单就不贴了。
loader.S
%include "boot.inc"
section loader vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR
jmp loader_start
;-------------------- 数据段 --------------------------------
;这部分完成GDT和4个段描述符的构建
;编译后程序的地址是越来越高的,所以代码要先开辟低地址再开辟高地址。8字节64位是一个段描述符。dd命令开辟出一个4字节32位空间。
GDT_BASE:
dd 0x00000000
dd 0x00000000 ;第0个段描述符无法使用,开辟出来空着
CODE_DESC:
dd 0x0000ffff ;前四位是16位段基址,设置为0,后四位是16位段界限,设置为1。高位在前低位在后
dd DESC_CODE_HIGH4 ;提前在配置文件中写好的高32位
DATA_STACK_DESC:
dd 0x0000ffff
dd DESC_DATA_HIGH4 ;数据和栈用相同的段描述符
VIDEO_DESC:
dd 0x80000007 ;这部分请看博客里的说明
dd DESC_VIDEO_HIGH4
;计算gdt大小,同时预留出60个段描述符的空间
;这部分为加载gdt做准备。dq定义4字8字节64位。times是nasm提供的伪指令,作用是循环。以后我们还要向gdt里添加别的表符,这里提前留出位置。
GDT_SIZE equ $-GDT_BASE
GDT_LIMIT equ GDT_SIZE-1
times 59 dq 0
times 5 db 0
;构建选择子
;选择子放在段寄存器里,16位大小,高13位是gdt的索引,第2位是ti位,指示索引是gdt的还是ldt的,0、1两位是特权级位。
SELECTOR_CODE equ (0x0001<<3)+TI_GDT+RPL0 ;0001就是下标1,左移3位相当于*8,因为一个表项是8字节
SELECTOR_DATA equ (0x0002<<3)+TI_GDT+RPL0
SELECTOR_VIDEO equ (0x0003<<3)+TI_GDT+RPL0
;设置保存内存容量的标号
;关于这个标号的地址,loader.S的起始地址是0x900,这行前面有64个8字节的段描述符,所以这里是0x900+0x200=0xb00
total_mem_bytes dd 0 ;初始为0,最终变成总内存容量
;定义GDT指针
gdt_ptr:
dw GDT_LIMIT ;前2字节是gdt的界限
dd GDT_BASE ;后4字节是gdt的起始位置
;ards缓冲区地址和数量
ards_buf times 244 db 0 ;ards缓冲区,存放ards
ards_nr dw 0 ;用于记录ards结构体的数量
;-------------------- 实现三种获取内存容量的方法 --------------------------------
loader_start:
mov dword [GDT_BASE],0
mov dword [GDT_BASE+4],0;显式清零
mov sp,LOADER_BASE_ADDR ;先初始化了栈指针
;方法1:利用0xe820获取内存
;以下部分,通过0xe820获取所有的ards
xor ebx,ebx ;用异或置零,初始ebx设置为0,后续我们不需要再处理
mov edx,0x534d4150 ;固定值
mov di,ards_buf ;es:di指向缓冲区,es在mbr设置,这里修改di即可
.e820_mem_get_loop:
mov eax,0x0000e820 ;因为执行完int 0x15后eax,ebx,ecx会变化,所以每次循环都要重新设置
mov ecx,20 ;返回的字节数,固定为20
int 0x15
jc .e820_failed_so_try_e801 ;如果e820失败,尝试e801
add di,cx ;+20字节指向下一个ards
inc word [ards_nr] ;记录ards数量
cmp ebx,0 ;如果ebx=0且cf=0,所有ards全部返回
jnz .e820_mem_get_loop ;如果ebx!=0,继续循环
;遍历ards,找到最大的32位基地址+内存长度,即为最大内存容量
mov cx,[ards_nr]
mov ebx,ards_buf
xor edx,edx ;edx保存最大内存容量,初始置0
.find_max_mem_area:
mov eax,[ebx] ;32位基地址
add eax,[ebx+8] ;内存长度
add ebx,20 ;指向下一个ards
cmp edx,eax
jge .next_ards ;如果edx>=eax,跳转到下一个ards,否则让edx=eax,最终效果是找到最大的ards
mov edx,eax
.next_ards:
loop .find_max_mem_area
jmp .mem_get_ok
;方法2:利用0xe801获取内存
;返回后,ax=cx单位是1kb,里面是小于16mb的单位数,bx=dx单位是64kb,里面是大于16mb的单位数。最终需要转化为字节数。
.e820_failed_so_try_e801:
mov ax,0x0000e801
int 0x15
jc .e801_failed_so_try88 ;如果e801失败,尝试88方法
;以下计算出低于16mb的内存容量大小
mov cx,0x400 ;1024
mul cx ;16位乘法,结果是32位,低16在ax,高16在dx
shl edx,16 ;左移16位
and eax,0x0000ffff ;保留低16位
or edx,eax ;拼接edx的高16位和eax低16位,放到edx中
add edx,0x100000 ;+1mb,原因是获取的内存比实际内存少1mb
mov esi,edx ;备份edx
;以下计算16mb以上内存容量大小
xor eax,eax
mov ax,bx ;大于16mb的单位数存在bx、dx里
mov ecx,0x10000 ;单位是64kb
mul ecx ;32位乘法,结果是64位,低32位在eax,高32位在edx
add esi,eax ;这种方法的上限就是4gb,所以不必理会高32位,只需要把低32位加进结果即可
mov edx,esi
jmp .mem_get_ok
;方法3:利用0x88获取内存
;这部分是方法2的简化版,代码参考2,不再写注释
.e801_failed_so_try88:
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
;-------------------- 记录内存容量 --------------------------------
;如果三种方法都失败,跳转到这里,进行一个死循环
.error_hlt:
jmp $
;不管使用了哪种方法,只要成功,都要跳转到这里记录,单位是1字节
.mem_get_ok:
mov [total_mem_bytes],edx ;在total_mem_bytes地址记录总内存容量
;-------------------- 完成进入保护模式的三个步骤 --------------------------------
in al,0x92
or al,0000_0010B
out 0x92,al ;打开 A20
lgdt [gdt_ptr] ;加载 GDT
mov eax, cr0
or eax, 0x00000001
mov cr0, eax ;cr0 第 0 位置 1
;-------------------- 进入保护模式 --------------------------------
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' ;通过显卡打印一个字符,验证是否进入保护模式,后续被覆盖
;-------------------- 加载内核到缓冲区 --------------------------------
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 ;创建页目录和页表
sgdt [gdt_ptr] ;要将描述符表地址及偏移量写入内存 gdt_ptr,一会儿用新地址重新加载
mov ebx,[gdt_ptr+2] ;先获取gdt基址,基址存在后四字节
add dword [gdt_ptr+2],0xc0000000 ;将 gdt 的基址加上 0xc0000000 使其成为内核所在的高地址
or dword [ebx+0x18+4],0xc0000000 ;将 gdt描述符中视频段描述符中的段基址+0xc0000000
;视频段是第 3 个段描述符,每个描述符是 8 字节,24=0x18
;段描述符的高 4 字节的最高位是段基址的第 31~24 位
add esp,0xc0000000 ;将栈指针同样映射到内核地址
mov eax,PAGE_DIR_TABLE_POS
mov cr3,eax ;把页目录地址赋给 cr3
mov eax,cr0
or eax,0x80000000
mov cr0,eax ;打开 cr0的 pg位(第 31 位)
lgdt [gdt_ptr] ;在开启分页后,用 gdt 新的地址重新加载
mov eax,SELECTOR_VIDEO
mov gs,eax
mov byte [gs:160],'V' ;通过显卡打印一个字符,验证是否开启分页,后续被覆盖
jmp SELECTOR_CODE:enter_kernel
;-------------------- 跳转到内核区 --------------------------------
enter_kernel:
call kernel_init
mov esp,0xc009f000 ;设立栈段的起始地址,这个数字是最接近高地址的和4KB对齐的数
mov eax,SELECTOR_VIDEO
mov gs,eax
mov [gs:160], byte '9' ;验证内核是否加载成功,这也是最后屏幕打印出的字符
jmp KERNEL_ENTRY_POINT ;写在宏里面,0xc0001500
;-------------------- 创建页目录和页表 --------------------------------
;首先先明确一点,那就是我们这里只处理低1mb的页表,目标是让0~1mb和3gb~3gb+1mb都映射低1mb
setup_page:
;先逐字节清0
mov ecx,4096 ;循环4096次
mov esi,0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS+esi],0
inc esi
loop .clear_page_dir ;清理出4KB的空间
;创建两个页目录项
.create_pde:
mov eax,PAGE_DIR_TABLE_POS
add eax,0x1000 ;起始位置+4KB,指向第一个页表,为后面的创建页表项作准备
mov ebx,eax
or eax,PG_US_U|PG_RW_W|PG_P ;将后12位和前20位地址拼接在一起,组成一个表项
;后面三个或后=0x7,代表这个目录项允许任意特权级访问,可写,后续我们会用到init用户级进程。
;将页目录0和0x300项都指向第一个页表的地址,都对应0-4mb的物理地址。
mov [PAGE_DIR_TABLE_POS+0x0],eax ;0项
mov [PAGE_DIR_TABLE_POS+0xc00],eax ;0xc00项转化为10进制是4*768,对应768项,每个项4字节
;错误代码是mov [PAGE_DIR_TABLE_POS+0x300],eax,错误原因是一个项4字节忘了×4
sub eax,0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax ;在页目录最后一项1023项写入页目录本身的地址,所以是4096-4
;创建页表项
mov ecx,256 ;目前我们先安排低1mb内存,1M/4k=256项
mov esi,0
mov edx,PG_US_U|PG_RW_W|PG_P ;edx存每个表项的相对地址,初始为0+0x7
.create_pte:
mov [ebx+esi*4],edx ;每过4字节有一项,每项存着一页,一页是4k
add edx,4096
inc esi
loop .create_pte ;循环256次
;创建内核其他页表的PDE
;这部分是3gb后的部分,共享给所有用户线程的,所以只关注页目录768项后的项,1023项用来存页目录本身的地址,不需要处理
;这里创建剩下的769~1022=254项
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 ;范围为第 769~1022 的所有目录项数量
mov esi,769
.create_kernel_pde:
mov [ebx+esi*4],eax
inc esi
add eax,0x1000
loop .create_kernel_pde
ret
;-------------------- 初始化内核 --------------------------------
kernel_init:
xor eax,eax
xor ebx,ebx ;记录程序头表的地址,即e_phoff,也就是第一个程序头的偏移地址
xor ecx,ecx ;记录程序头表中,程序头的数量e_phnum,也就是segment数量
xor edx,edx ;记录每个程序头的尺寸,即e_phentsize
mov dx,[KERNEL_BIN_BASE_ADDR+42] ;程序头大小
mov ebx,[KERNEL_BIN_BASE_ADDR+28]
add ebx,KERNEL_BIN_BASE_ADDR ;程序头地址
mov cx,[KERNEL_BIN_BASE_ADDR+44] ;程序头数量
.each_segment:
cmp byte [ebx+0],PT_NULL;若相等,说明这个程序头未使用
je .PTNULL ;未使用则跳转
mov eax,[ebx+8]
cmp eax,0xc0001500
jb .PTNULL
;模拟c语言里的mem_cpy,输入三个参数:源地址,目标地址,拷贝内容字节数
push dword [ebx+16] ;获取段字节数p_filesz
mov eax,[ebx+4] ;距程序头偏移量为4字节的位置是p_offset
add eax,KERNEL_BIN_BASE_ADDR
push eax ;段偏移地址,也就是源地址
push dword [ebx+8] ;目标地址p_vaddr
call mem_cpy
add esp,12 ;一次是向栈段压入3*4=12字节的数据
.PTNULL:
add ebx,edx ;指向下一个程序头
loop .each_segment
ret
mem_cpy:
cld ;将DF标志位设置为0,确保指针递增
push ebp ;我们用ebp指针配合sp,而不是esp,干脆在这里存进栈备份一下
mov ebp,esp
push ecx ;ecx保存着程序头数,后续会改为段字节数,这里备份一下
mov edi,[ebp+8] ;源地址
mov esi,[ebp+12] ;目标地址
mov ecx,[ebp+16] ;我们是逐字节复制,字节数也就是要复制的次数
rep movsb ;rep是重复,次数是ecx,movsb是按字节从ds:ei复制内容到es:di处,和cld配合使用,让ei和di自动自增
pop ecx
pop ebp ;恢复这两个参数,不影响上面循环的进行
ret
;-------------------- 从硬盘读取内核的函数 --------------------------------
rd_disk_m_32:
;以下部分备份关键数据
mov esi,eax ;备份eax
mov di,cx ;备份cx
;以下部分写入要读取的扇区数
mov dx,0x1f2
mov al,cl
out dx,al
mov eax,esi ;写入扇区数
;以下部分写入LBA编号和参数
mov dx,0x1f3
out dx,al
mov cl,0x8
shr eax,cl
mov dx,0x1f4
out dx,al
shr eax,cl
mov dx,0x1f5
out dx,al ;写入LBA低24位的地址
shr eax,cl
and al,0x0f ;00001111,作用是保留0-3位,4-7位变为0
or al,0xe0 ;01110000,作用是把al的7-4位设置为0111,即设置lba模式
mov dx,0x1f6
out dx,al ;向0x1F6,device寄存器写LBA最高4位数字和LBA参数
;以下部分写入要进行的硬盘操作的操作码
mov dx,0x1f7
mov ax,0x20
out dx,al ;向0x1F7,command寄存器写读入命令0x20
;以下部分检验硬盘状态
.not_ready:
nop ;在这里设置了一定的时间
in al,dx
and al,0x88 ;10001000,第 4 位为 1 表示硬盘控制器已准备好数据传输,第 7 位为 1 表示硬盘忙
cmp al,0x08 ;比较,改变标志寄存器状态
jne .not_ready ;jump_not_equal,检验硬盘状态,如果失败就继续检验
;以下部分从硬盘读入loader程序
mov ax,di
mov dx,256
mul dx ;计算读取的字数,mul的结果默认存在ax里,相当于ax=ax*256,即扇区数*512(字节数)/2(1字=2字节)
mov cx,ax ;cx寄存器存循环次数
mov dx,0x1f0
.go_on_ready:
in ax,dx
mov [ebx],ax
add ebx,2
loop .go_on_ready ;从0x1F0,data寄存器读取数据
ret
boot.int
;-------------------- 加载器loader --------------------
LOADER_START_SECTOR equ 0x2 ;硬盘扇区号
LOADER_BASE_ADDR equ 0x900 ;内存地址
;-------------------- 内核kernel --------------------
KERNEL_START_SECTOR equ 0xa ;硬盘扇区号
KERNEL_BIN_BASE_ADDR equ 0x70000 ;内存地址
KERNEL_ENTRY_POINT equ 0xc0001500 ;虚拟地址入口
;-------------------- 页表 --------------------
PAGE_DIR_TABLE_POS equ 0x100000 ;内存地址,在低1MB之外
;-------------------- gdt段描述符属性 --------------------------------
;上面的第多少位都是针对的高32位而言的 参照博客的图
DESC_G_4K equ 1_00000000000000000000000b ;第23位G段,设置粒度,表示4K或者1MB,段界限的单位值,此时为1则为4k
DESC_D_32 equ 1_0000000000000000000000b ;第22位D/B位 表示地址值用32位EIP寄存器 操作数与指令码32位
DESC_L equ 0_000000000000000000000b ;第21位 设置成0表示不设置成64位代码段 忽略
DESC_AVL equ 0_00000000000000000000b ;第20位 是软件可用的 操作系统额外提供的 可不设置
DESC_LIMIT_CODE2 equ 1111_0000000000000000b ;第16-19位 段界限的最后四位 全部初始化为1 因为最大段界限*粒度必须等于0xffffffff
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2 ;相同的值 数据段与代码段段界限相同
DESC_LIMIT_VIDEO2 equ 0000_0000000000000000b ;第16-19位 显存区描述符VIDEO2 书上后面的0少打了一位
DESC_P equ 1_000000000000000b ;第15位 P段,判断段是否存在于内存
DESC_DPL_0 equ 00_0000000000000b ;第13-14位,设置特权级
DESC_DPL_1 equ 01_0000000000000b ;0为操作系统 权力最高 3为用户段 用于保护
DESC_DPL_2 equ 10_0000000000000b
DESC_DPL_3 equ 11_0000000000000b
DESC_S_sys equ 0_000000000000b ;第12位为0 则表示系统段 为1则表示数据段
DESC_S_CODE equ 1_000000000000b ;第12位与type字段结合 判断是否为系统段还是数据段
DESC_S_DATA equ DESC_S_CODE
DESC_TYPE_CODE equ 1000_00000000b ;第9-11位type段 1000 可执行 不允许可读 已访问位0
;x=1 e=0 w=0 a=0
DESC_TYPE_DATA equ 0010_00000000b ;第9-11位type段 0010 可写
;x=0 e=0 w=1 a=0
;-------------------- 段描述符初始化 --------------------------------
;代码段描述符高位4字节初始化 (0x00共8位 <<24 共32位初始化0)
;4KB为单位 Data段32位操作数 初始化的部分段界限 最高权限操作系统代码段 P存在表示 状态
DESC_CODE_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_D_32 + \
DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + \
DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
;数据段描述符高位4字节初始化
DESC_DATA_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_D_32 + \
DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + \
DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
;显存段描述符高位4字节初始化
DESC_VIDEO_HIGH4 equ (0x00<<24) + DESC_G_4K + DESC_D_32 + \
DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + \
DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0B ;这里末尾是B
;-------------------- 段描述符子属性 --------------------------------
;第0-1位 RPL特权级,决定是否允许访问 第2位TI,0表示GDT,1表示LDT
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b
;-------------------- 页表子属性 --------------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_U equ 000b
PG_US_S equ 100b
;-------------------- elf段属性 --------------------
PT_NULL equ 0x0
结语
三天多的时间,20多个小时的学习实践,几万字博客,几百行源码,终于完成了第五大章的实现,我们的操作系统算是有了个完整的框架。一路走来颇为不易,不过轻舟已过万重山,高楼的地基已经打好,后续就是添砖加瓦了,让我们共同期待大厦的建成吧!