引言
- 前面讲了那么多章节,都只能说是做铺垫,从本章开始,我们正式开始实现操作系统
整体设计
- 在前面的章节当中,我们做过很多实验,这些实验全部都放在了 loader.asm 文件中,这么做显然是不合适的。loader 的功能就应该只有加载内核并跳转到内核执行
- 操作系统整体规划设计
- 新建 “KOS” 文件夹,从今以后实现的代码都放在这个文件夹中
- 新建 “bootloader” 文件夹,将我们前面实现过的代码 boot.asm 和 loader.asm 放入其中, make 一下再运行,一切 OK。
用 python 替代 Makefile
- 从今天起,将抛弃 Makefile 文件,改用 python 实现编译脚本,python 用起来简单快速
- 先提供个基础版本:Build.py ,仅单纯的将 Makefile 翻译成 python 脚本
- 其中关键点:os.system 函数可以将字符串转化成命令执行,比如
importos
os.system("ls -a") # 显示当前目录下的所有文件
- 运行 python 文件
python Build.py
- 得到最终的 a.img ,bochs 运行一下
优化遗留问题
- boot.asm 中读取硬盘中数据到内存中时,读取的扇区数是我们写死的 20 个扇区
; 将硬盘扇区 2 中的数据读入到内存 0x900 处 mov eax, 0x02 mov bx, 0x900 mov cx, 20 call rd_disk_to_mem
- 现在我们来解决这个遗留问题
- 回想一下,硬盘的第二个扇区我们是不是并没有使用,现在正好把它用上,我们把 loader.bin 文件所占的山区数算出来,填充到第二个扇区的前两个字节中,然后在 boot.asm 中读取第二个扇区,以获得 loader.bin 的扇区数。这样子就能实现自适应 loader.bin 大小了
- 优化后的 python 脚本:Build.py
- boot.asm 中改动处:
; 将硬盘扇区 1 中的数据读入到内存 0x700 处 mov eax, 0x01 mov bx, 0x700 mov cx, 1 call rd_disk_to_mem ; 将硬盘扇区 2 中的数据读入到内存 0x900 处 mov eax, 0x02 mov bx, 0x900 mov cx, [0x700] call rd_disk_to_mem
- 重新编译
python Build.py
- 接下来需要反汇编调试了,这次是对 boot.asm 进行反汇编
ndisasm -o 0x7c00 boot.bin > boot.txt
- 打开 boot.txt 文件,在 mov cx, [0x700] 执行完成后地址(0x7c4a)处打断点
<bochs:1> b 0x7c4a <bochs:2> c ... <bochs:3> xp 0x700 [bochs]: 0x00000700 <bogus+ 0>: 0x00000009 <bochs:4> reg eax: 0x00000002 2 ecx: 0x00000009 9 edx: 0x000001f0 496 ebx: 0x00000900 2304 esp: 0x00007c00 31744 ebp: 0x00000000 0 esi: 0x00000001 1 edi: 0x00000001 1 eip: 0x00007c4a eflags 0x00000016: id vip vif ac vm rf nt IOPL=0 of df if tf sf zf AF PF cf
- 通过 xp 查看内存和 reg 查看 cx 寄存器的值都为 9,即 loader.bin 所占的扇区数。说明我们优化成功了
bootloader 代码优化
- bootloader 分为 boot 和 loader 两个部分,我们把它们需要重复使用相同函数拆分出来,放到 common.asm 中
- 相同函数功能有 print 和 rd_disk_to_mem。两者都需要打印函数,boot 需要加载 loader,而 loader 需要加载内核。所以 rd_disk_to_mem 函数也是需要重复的
- 按照 C 语言的思想,我们把属性定义等单独放到头文件中 inc.asm
- 使用时需要包含文件
%include "./bootloader/inc.asm"
%include "./bootloader/common.asm"
- 优化后的 boot.asm
- loader.asm 优化,删除不需要的东西,仅保留开启保护模式并跳转到 kernel 的功能
- 开启保护模式后再由保护模式跳转到 kernel,利用平坦模式跳转到实际指定的物理地址处
- 由于以后我们以后在低特权级情况下也可能进行 I/O 操作,所以要改一下 IOPL=3
pushf pop eax or eax, 0x3000 ; bit12-bit13:11b push eax popf
- 最终改动后的 loader.asm
- 由于还没实现 kernel,所以暂时先不跳转到 kernel
; jmp dword FLAT_MODE_SELECTOR:KERNEL_START_ADDR
kernel
- bootloader 的代码优化工作暂时就到这里吧,接下来我们来实现 kernel 部分,等 kernel 实现后再实现 loader 加载并跳转到 kernel
- 创建 "init" 文件夹并在其中创建 "kentry.asm" 和 "main.c" 两个文件夹
- 有了前面讲的 C 与汇编混合编程知识,这里就不做详细介绍了
- kentry.asm 内容如下:
[section .text] global _start extern main _start: mov ax, 8 ; 没啥实际意义,仅用于断点调试,便于查看 mov ax, 9 ; 没啥实际意义,仅用于断点调试,便于查看 call main jmp $
- main.c 内容如下:
void main(void) { while(1); }
- 编译 kentry.asm
nasm -f elf32 kentry.asm -o kentry.o
- 编译 main.c【注意必须加 '-nostdinc', 不需要使用系统自带的库及头文件】
gcc -m32 -nostdinc -c main.c -o main.o
- 链接,-Ttext是链接时将初始地址重定向为 0xB000【注意链接时 kentry.o 必须在最前面】
ld -Ttext 0xB000 -s -m elf_i386 -o kernel.out kentry.o main.o
- 我们最终链接生成可执行程序 kernel.out,但是这个程序是 linux 使用的 elf 格式的,不能直接加载进内存执行,CPU 只认识代码和数据,无法正确执行 elf 可执行程序。于是我们需要提取 elf 中的代码段和数据段(删除 elf 文件格式信息)
objcopy -O binary kernel.out kernel.bin
- 接下来使用 dd 命令将 kernel.bin 写入 a.img 中
- 为了自动化,我们把 kernel.bin 所占的扇区数存入 a.img 扇区 1 的第 3-4 个字节中(共 2 个字节)。kernel.bin 数据接着写到 loader.bin 数据的下一个扇区即可
- 原先创建虚拟硬盘固定大小 60M,现在也改成自动计算
- 改动后的 Build.py
- 有了 kernel.bin 程序, loader 就可以加载并跳转到 kernel 了
; 将硬盘扇区 1 中的数据读入到内存 0x700 处 mov eax, 0x01 mov bx, 0x700 mov cx, 1 call rd_disk_to_mem ; 将硬盘扇中 kernel 数据读入到内存 0xB000 处 mov eax, 0 mov ax, [0x700] ; 0x700 处存的是 loader.bin 所占的扇区数,再 +2 找到 kernel 起始扇区 add ax, 2 mov bx, KERNEL_START_ADDR mov cx, [0x702] call rd_disk_to_mem
- 编译运行一下,发现程序崩溃了,查找了半天,原来是平坦模式段描述符要具有可执行属性
- 改完以后以为可以了,结果还是崩溃,原来是读扇区 1 到内存 0x700 与栈使用的内存冲突了,我们也可以使用 boot 时使用的栈空间
; mov sp, 0x900 ; 设置栈 mov sp, 0x7c00 ; 设置栈
- loader 最终程序:loader.asm
- 断点调试,验证一下
<bochs:1> b 0xb000 <bochs:2> c ... (0) [0x0000b000] 0010:0000b000 (unk. ctxt): mov ax, 0x0008 ; 66b80800 <bochs:3> s Next at t=16770437 (0) [0x0000b004] 0010:0000b004 (unk. ctxt): mov ax, 0x0009 ; 66b80900
- 在 kernel 入口地址 0xb000 处打断点,单步执行,从调试信息看其执行的汇编指令就是我们在 kentry.asm 中实现的汇编代码
- 辛苦了那么久,程序终于走到了 main
- 最后让我们欣赏一下当前程序运行效果
工程管理
- 目前工程中一个 .h 文件都没有,我们创建一个名为 “include” 的文件夹,头文件都放在该文件夹中,再创建一个头文件 “common.h” 放到 “include” 目录下,这样,整个工程所需的基本文件类型就都有了, “common.h” 内容如下
#ifndef __COMMON_H_ #define __COMMON_H_ typedef unsigned char U08; typedef unsigned short U16; typedef unsigned int U32; typedef char S08; typedef short S16; typedef int S32; #endif • 在 main.c 中包含 common.h 文件 #include <common.h> S32 main(void) { while(1); return 0; }
- 因为新增了头文件 common.h,所以编译肯定是报错的。解决办法是 gcc 编译时加 "-I" 指定头文件路径,如果有多个头文件路径,可以多次使用 "-I",示例如下:
gcc -Iinclude_dir1 -Iinclude_dir2 -c main.c -o main.o
- 目前我们的编译脚本 Build.py 直接包含了所有的工程文件,当文件总数较少的时候,这种方式比较简单快捷,但当后期工程文件逐渐增加时,且我们想对其中某一部分功能实现选择性编译时,这种方式就不太科学了
- 现在提倡模块化设计思想:我们设计一种配置文件,每个目录下都包含一个这样的配置文件,该配置文件只负责三件事,一是包含当前目录下的文件夹,二是包含当前目录下的源文件,三是包含当前目录下源文件所包含的头文件路径
- 我们给这个配置文件取名 “BUILD.json”,文件内容使用 json 格式,因为 json 是一种通用格式,所以可以使用别人已实现的库对 json 文件进行解析。当然,你也可以自定义格式,只不过文件解析就必须自己实现了
- 整个工程组织结构如下:
KOS |--- BUILD.json |--- include | |--- common.h |--- bootloader | |--- BUILD.json | |--- boot.asm | |--- common.asm | |--- inc.asm | |--- loader.asm |--- init | |--- BUILD.json | |--- kentry.asm | |--- main.c
- 最外层 BUILD.json 内容如下:
{ "dir" : [ "bootloader", "init" ], "src" : [ ], "inc" : [ ] } • "dir" 中包含的是当前目录下的文件夹 • bootloader 目录下的 BUILD.json 内容如下: { "dir" : [ ], "src" : [ "boot.asm", "loader.asm" ], "inc" : [ ] }
- "src" 中包含的是当前目录下的源文件
- init 目录下的 BUILD.json 内容如下:
{ "dir" : [ ], "src" : [ "kentry.asm", "main.c" ], "inc" : [ "include" ] }
- "inc" 中包含的是当前目录下源文件所包含的头文件路径,注意这个路径是相对于编译脚本 “Build.py” 的路径
- 实现解析 BUILD.json 文件的函数:Parse_BUILD_CFG,该函数通过递归遍历路径 path 下的所有的 BUILD.json 文件,并将遍历后的结果以 '源文件文件名':['源文件所在路径', ['源文件所包含的头文件路径1', '源文件所包含的头文件路径1']] 这种形式的数据添加到字典 project 中,函数内容具体如下:
defParse_BUILD_CFG(path):
JsonPathName = os.path.join(path, BUILD_CFG) # 以只读方式打开文件 JsonPathName with open(JsonPathName, 'r') as f: # 读取文件内容到 json_text json_text = f.read() # 将 json_text 内容转为 python 字典 json_dict json_dict = json.loads(json_text) # 遍历字典 json_dict for key in json_dict: if key == 'src': # 如果是源文件,则将 '源文件文件名':['源文件所在路径', ['源文件所包含的头文件路径1', '源文件所包含的头文件路径1']] # 这种形式的数据添加到字典 project 中 if json_dict[key]: for item in json_dict[key]: project[item] = [path, json_dict['inc']] elif key == 'dir': # 如果是文件夹,则进入该文件夹内,递归调用 Parse_BUILD_CFG if json_dict[key]: for item in json_dict[key]: new_path = os.path.join(path, item) Parse_BUILD_CFG(new_path) elif key == 'inc': # 如果是头文件路径,则不处理 pass else: print("Invalid key") return project • 定义一个全局 project 字典,调用 Parse_BUILD_CFG 函数后,我们把 project 中的内容打印出来 root_path = '' project = Parse_BUILD_CFG(root_path) # 打印 project for item in project: str_print = item + ': ' + str(project[item]) print(str_print)
- 打印内容如下(从左到右依次是源文件名,源文件所在路径,源文件包含的头文件所在路径,其中路径都是为 Build.py 所在路径的相对路径):
boot.asm: ['bootloader', []] loader.asm: ['bootloader', []] kentry.asm: ['init', ['include']] main.c: ['init', ['include']]
- 好了,我们想要的整个工程信息都在 project 字典中了,接下来就是读取 project 字典,获取想要的信息,编译,链接等
- Build.py 修改过程就不过多介绍了,我也是利用 print 打印一点一点的调试出来的,整个过程只是繁琐一点而已。改动后的代码如下 Build.py
- 由于 Build.py 做了一定的调整,boot.asm 和 loader.asm 中包含文件处也要改动一下
; %include "./bootloader/inc.asm" ; %include "./bootloader/common.asm" %include "inc.asm" %include "common.asm"
- 至此,整个工程的基本雏形已经展现出来了
补充
- 后续的开发过程中遇到一个 BUG,我觉得放到这里提前说一下比较合适,什么 BUG 呢?
- 我们找到 rd_disk_to_mem 函数,看一下下面的语句,其中 bx 为函数参数之一,表示将硬盘中的数据读取到 bx 内存地址处,mov [bx], ax 这条指令中我们能操作的内存访问是 [0, 0xFFFF],一旦内存操作超过这个范围,那就有问题了
... .go_on_read: in ax, dx mov [bx], ax add bx, 2 loop .go_on_read ...
- 由于 rd_disk_to_mem 是在 实模式下调用,bx 为 16 位,其最大值为 0xFFFF,我们把 kernel 程序读取到 0xB000 处,从代码上看即把 bx 赋值 0xB000,这时候就产生了一个问题,那就只能将 kernel 程序读取到 [0xB000, 0xFFFF] 这个内存范围,大概 19K 多一点,一旦 kernel 程序过大,那么肯定无法把 kernel 程序全部正确的读到内存中
mov bx, KERNEL_START_ADDR ; 0xB000 mov cx, [0x702] call rd_disk_to_mem
- 临时解决方案如下,改变段基址 ds 的值为 0xb00, bx 为 0,mov [bx], ax 这条指令本质其实是 mov [ds:bx], ax,ds:bx = (ds<<4) + bx,这么做仅仅只是把 19K 的大小稍微扩大到 64K,不过对于目前的 kernel 程序代码量来说已经够用了,当然,也可以每当 bx 累加到超过 0xFFFF 的时候,使 ds += 0x1000,然后把 bx 清 0,这样子最多的内存操作空间就变成了 0xFFFF:0xFFFF = 1M,不过比较麻烦,64K 将就用吧
; 将硬盘扇中 kernel 数据读入到内存 0xB000 处 mov eax, 0 mov ax, [0x700] ; 0x700 处存的是 loader.bin 所占的扇区数,再 +2 找打 kernel 起始扇区 add ax, 2 mov cx, [0x702] mov dx, 0xb00 mov ds, dx mov bx, 0x0 call rd_disk_to_mem ; 需恢复 ds=0, 下面的程序需要 ds 为 0 mov dx, 0x0 mov ds, dx
- 当然了,不管是简单的 64K 或者复杂改动增加到 1M(更精确的说是 1M 减去 0xB000),都不够大,不能很好的解决这个问题,真正解决问题要等到我们学习并实现了硬盘驱动代码,那时就是 4G 内存空间任意使用了,这里先有个印象如果以后内核莫名其妙的出问题了,可以查看一下是否是内核程序过大了