项目简介
本项目将从零实现一个基于 x86 32位的操作系统,系统支持多进程的运行,虚拟内存,进程与进程之间隔离,文件系统。
开发语言:汇编语言与C语言,主要是C语言。
开发工具: gcc编译器,gdb调试器,qemu虚拟机,dd工具 ,CMake .........
开发环境:windows11操作系统,VScode编辑器。
计算机启动流程
要开发一个操作系统,首先我们要知道操作系统的加载过程。
当系统上电后,CPU首先会初始化然后执行的BIOS的代码。BIOS会检查系统硬件,确保一切正常。这一阶段也称为 POST,即开机自检。
完成自检后BIOS会加载硬盘上的第一个物理扇区(扇区0)到内存的 0x7c00 处,然后CPU会跳转到 0x7c00 处执行。
我们一般称第一个物理扇区为主引导扇区,主引导扇区上的程序称为主引导程序。主引导程序负责从硬盘加载操作系统,然后跳转至操作系统执行。因为一个扇区是512字节,所以主引导程序的大小必须在512字节以内。同时主引导扇区的最后两个字节必须 0x55与0xAA,用于告诉BIOS这是一个有效的主引导扇区。
主引导程序
因为主引导扇区的程序只能有512字节,所以我们无法在主引导扇区完成太多工作,我们这里采用二级引导模式。由主引导扇区加载二级引导程序,然后在二级引导程序中完成加载内核前的准备工作并加载内核。
在主引导程序中,我们首先完成段寄存器的初始化。我们需要将段寄存器全部初始化为 0 ,以平坦模式访问内存。
这里简单介绍一下在x86实模式下的内存访问模式,在实模式下CPU有 20 根地址线能够标识 2^20 个地址即 1MB,而CPU中的寄存器只有 16 位,一个寄存器只能存储 0x0000~0xFFFF 内的数,那么如何访问20位的地址?
实模式下的内存访问依赖于段寄存器和偏移量。地址由一个16位的段寄存器值和一个16位的偏移量组成。实际的物理地址是通过将段寄存器的值左移4位(乘以16)然后加上偏移量得到的。这意味着每个段的最大大小为64KB。
在主引导程序中我们将段寄存器的值都设为 0 ,只访问 64 KB内的内存,在二级引导程序设置了GDT表并开启A20地址线后,即可访问全部的 4GB 内存。
.code16
.text
.global _start
.extern boot_entry
_start:
//初始化寄存器
mov $0 , %ax
mov %ax, %ds
mov %ax, %ss
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
mov $_start , %sp
.code16 伪指令生成 16 位模式下的机器码。
.code16指示汇编器当前代码段应使用16位的指令集和寄存器,这里我们处在实模式下即16位表示环境,在之后进入保护模式后就不用再指定,汇编器默认生成32位机器码。
之后的代码初始化了 ds ss es fs gs 段寄存器。同时将_start的值为栈指针寄存器sp的值。接下来我们使用 13 号中断加载磁盘中的二级引导程序。
read:
//加载二级引导程序进入内存
//使用BIOS中断
//AH=02H,AL=扇区数,CH=柱面,CL=扇区
//DH=磁头,DL=驱动器,ES:BX 加载到的内存地址
mov $0x8000, %bx
mov $0x2, %ah
mov $0x2, %cx
mov $64, %al
mov $0x80, %dx
int $0x13
//CF=0 操作成功
jc read
此处的代码表明,从硬盘的第二个扇区开始读取,读取64个扇区,数据加载到0x8000处。下面是 使用 13 号中断的寄存器设置。
寄存器使用:
AH:操作代码,定义要执行的操作类型。
AL:其他参数,如扇区号、驱动器号等,取决于AH的值。
CH:柱面号的高8位。
CL:柱面号的低6位和扇区号的高2位。
DH:磁头号。
DL:驱动器号和设备类型。
ES:BX:指向内存缓冲区的指针,用于存储读取的数据。
常见的功能代码:
0x02:读取磁盘扇区。
0x03:写入磁盘扇区。
完成了上面的工作我们跳转至C语言环境中,通过函数调用跳转至二级引导程序。
//进入C语言环境
jmp boot_entry
我们在程序的开头声明了外部符号 boot_entry ( .extern boot entry),在编译时链接器会将这个寻找这个函数,并将jmp后的符号替换为boot_entry的内存地址。这样就实现了汇编语言跳转至C语言函数。
在另一个文件中我们定义了函数 boot_entry 。
__asm__(".code16gcc");
#include "boot.h" //boot.h为空
#define LOADER_START 0x8000
//Boot的C入口函数
void boot_entry()
{
((void (*)(void))LOADER_START)(); //跳转至loader执行
}
我们知道:函数指针直接指向函数的代码在内存中的起始地址。当调用这个指针时,程序会跳转到这个地址执行函数的代码。
所以我们也可以用内存地址调用函数,在上面我们通过将 0x8000 强转为函数指针然后通过调用这个函数指针使程序跳转至二级引导程序运行。
现在我们已经基本完成主引导程序的工作。最后我们需要设置主引导扇区的标志位 0x55 与 0xAA。
//引导结束段
.section boot_end, "ax"
boot_sig: .byte 0x55, 0xaa
.section boot_end, "ax"这行代码指示汇编器创建一个名为 boot_end 的段。这个段的属性是 "ax",意味着它既可以被访问(a),也可以被执行(x)。在这个段后我们使用 .byte 伪指令写入了 0x55 与 0xAA。那么如何使得这个段在扇区的最后两个字节呢?
在CMake中,我们设置了链接器选项。
set(CMAKE_EXE_LINKER_FLAGS "-m elf_i386 -Ttext=0x7c00 --section-start boot_end=0x7dfe")
- -m elf_i386:这个选项告诉链接器生成一个适用于i386架构的ELF格式的可执行文件。这是32位x86架构的标准格式。
- -Ttext=0x7c00:这个选项指定了文本段(.text section)的起始地址为0x7c00。这是创建传统启动扇区的关键,因为BIOS会在启动时加载启动设备的首个扇区(512字节)到内存地址0x7c00处,并从这个地址开始执行。
- --section-start boot_end=0x7dfe:这个选项为boot_end段指定了一个起始地址0x7dfe。这通常用于将引导扇区的结束标志(通常是两个字节的0x55AA)放置在引导扇区的最后两个字节位置。boot_end段紧接在引导程序代码之后,确保引导扇区的总大小不超过512字节。
由此我们完成了主引导扇区程序的编写。
代码汇总
//start.S
//主引导程序,启动时由硬件加载运行,然后完成对二级引导程序loader的加载
//该部分程序存储于磁盘的第1个扇区,在计算机启动时将会由BIOS加载到0x7c00处
//之后,将由BIOS跳转至0x7c00处开始运行
//16位代码
.code16
.text
.global _start
.extern boot_entry
_start:
//初始化寄存器
mov $0 , %ax
mov %ax, %ds
mov %ax, %ss
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
mov $_start , %sp
read:
//加载二级引导程序进入内存
//使用BIOS中断
//AH=02H,AL=扇区数,CH=柱面,CL=扇区
//DH=磁头,DL=驱动器,ES:BX 加载到的内存地址
mov $0x8000, %bx
mov $0x2, %ah
mov $0x2, %cx
mov $64, %al
mov $0x80, %dx
int $0x13
//CF=0 操作成功
jc read
//进入C语言环境
jmp boot_entry
//引导结束段
.section boot_end, "ax"
boot_sig: .byte 0x55, 0xaa
//boot.c
__asm__(".code16gcc");
#include "boot.h"
#define LOADER_START 0x8000
//Boot的C入口函数
void boot_entry()
{
((void (*)(void))LOADER_START)(); //跳转至loader执行
}