Bootstrap

手写操作系统:二级引导程序

项目简介

在上篇博客,我们完成了主引导扇区的编写,在主引导扇区我们初始化了寄存器,加载了二级引导程序到内存地址 0x8000处,并跳转至0x8000处执行,在本文我们将继续编写二级引导程序。

在二级引导程序将完成以下任务

检测内存容量

打开A20地址线

设置全局段描述符表

进入保护模式

加载并跳转至内核执行

目前项目流程如下:

在工程下我们新建一个loader文件夹,存储二级引导程序的代码,文件夹下有五个文件,分别为start.S ,loader_16.c ,loader_32.c ,loader.h 与 CMakeLists.txt。start.S是二级引导程序的入口,loader_16.c存储16位操作尺寸即实模式下的代码,loader_32.c 存储32位操作尺寸保护模式下的代码。

这里简单介绍一下编译二进制文件,与写入虚拟硬盘。项目使用CMake构建编译生成二进制文件通过 vscode 配置文件在启动调试后,自动执行脚本将生成的二进制文件写入虚拟硬盘,同时打开 qemu 虚拟机进行调试。

显示字符

在start.S里我们跳转到C语言环境

//二级引导程序loader

	//16位代码
  	.code16
 	.text
	.global _start
	.extern loader_entry
	.extern loader_kernel

	.global protect
_start:
	//跳转至C语言环境
	jmp loader_entry

我们使用jmp指令跳转至C语言环境,loader_entry是一个C函数,链接器会将 loader_entry 替换为loader_entry函数的其实地址,执行命令后变回跳转至此处执行。这个函数我们在 loader_16.c 定义。

在loader_16.c中我们先完成一个简单的功能——在屏幕上显示字符。在计算机启动时显卡默认处于文本模式,在文本模式下,显卡将每个字符表示为一个图形符号,在屏幕上显示为一个8x8或8x16像素的块,在文本模式下显示器可以显示 80x25 个字符。在文本模式下我们可以通过访问内存中的显示缓冲区显示字符。

在标准的80x25文本模式下,B800:0000 地址开始的内存区域包含了2000个字节,正好对应于80列乘以25行的字符网格。 每个字符占用两个字节:第一个字节是字符本身的ASCII码,第二个字节是属性字节,定义了字符的颜色和亮度等。所以我们可以直接使用mov指令操作内存显示字符。

这一段代码就是一个主引导扇区程序,它会在屏幕打印 "hello word" 。

//这里使用的是x86汇编,项目中使用的汇编语法和伪指令是特定于GAS的
start:
	mov ax , 0xb800     
	mov ds , ax          ;设置数据段基址

	mov  byte [0x00] , 'h'  
	mov  byte [0x01] , 0x07  

	mov  byte [0x02] , 'e'  
	mov  byte [0x03] , 0x07 

	mov  byte [0x04] , 'l'  
	mov  byte [0x05] , 0x07 

	mov  byte [0x06] , 'l'  
	mov  byte [0x07] , 0x07 

	mov  byte [0x08] , 'o'  
	mov  byte [0x09] , 0x07 

	mov  byte [0x0C] , 'w'  
	mov  byte [0x0D] , 0x07 

	mov  byte [0x0E] , 'o'  
	mov  byte [0x0F] , 0x07 

	mov  byte [0x10] , 'r'  
	mov  byte [0x11] , 0x07 

	mov  byte [0x12] , 'l'  
	mov  byte [0x13] , 0x07 

	mov  byte [0x14] , 'd'  
	mov  byte [0x15] , 0x07

jump:	
	
	jmp  jump
	
cur:
	times 510-(cur-start)  db 0

	db 0x55 , 0xAA 

不难看出这种方法十分繁琐,我们可以使用一种更简单的方法使用软件中断输出字符。cpu会根据我们提供的中断号,在中断向量表中找到中断处理程序的地址跳转至该地址执行。

实模式下的中断向量表(IVT)是计算机在实模式下用于处理中断和异常的一张表。中断向量表通常位于物理内存的最开始部分,从地址0x00000开始。每个中断向量表条目占用4个字节,其中前两个字节是段地址,后两个字节是偏移地址)。中断号范围从0到255,每个中断号对应IVT中的一个条目。系统中断和异常都有固定的中断号,并且通常由BIOS或操作系统设置它们的处理程序。我们也可以设置自定义的中断处理程序,通过修改IVT中的相应条目来实现。当中断发生时,CPU会自动从IVT中找到对应的条目,并跳转到指定的段地址和偏移地址执行中断服务例程。

当系统从实模式切换到保护模式时,IVT会被中断描述符表(IDT)替代,提供更高级的中断和异常处理功能,在进入保护模式后我们需要手动设置 IDT 表。

这里我们使用 16(0x10) 号中断在屏幕光标处输出字符。通过在 AH 寄存器中设置功能号,可以指定 int 10h 要执行的具体操作。以下是一些常用的功能号:

AH = 0x00:设置光标位置。

AH = 0x01:读取当前光标位置。

AH = 0x02:在当前光标位置显示字符。

AH = 0x03:读取光标状态(包括光标位置和光标可见性)。

AH = 0x06:定义字符的属性。

AH = 0x09:写入字符串到屏幕。

AH 寄存器设置为 0xE 时,这个中断用于在当前光标位置显示一个字符,并更新光标位置。此时 AL 寄存器需要设置为想要显示的字符的ASCII码。

通过这个中断我们就可以实现显示字符的功能,代码如下:

//使用内联汇编显示文字
static void print_msg(const char* msg)
{
    char c;
    while((c=*msg++) != '\0')
    {
        //内联汇编
        __asm__ __volatile__(             //__asm__ __volatile__ 避免编译器优化
            "mov $0xe, %%ah\n\t"
            "mov %[ch] ,%%al\n\t"
            "int $0x10" ::[ch]"r"(c)      //r 表示随机寄存器        
        );
    }
}

在代码中我们使用内联汇编显示字符,关于内联汇编,可以参考这篇博客—— 内联汇编-CSDN博客

检测可用内存 

在计算机中并不是所有内存都可以被我们自由使用,系统会保留一部分内存用于特定的硬件操作和系统功能。例如,BIOS和其他固件可能会占用一部分内存,某些内存地址可能被映射到硬件设备,如图形卡、网络卡等,这些地址不可用于常规的内存访问。为了在后续中合理地分配内存给运行中的进程,我们需要检测可用的内存空间。

这一部分工作我们仍然使用中断完成,在检测前我们首先在头文件定义一个结构体,用于存储可用内存。

#pragma once
#include"types.h"        
//在types.h中我们定义了uint8_t ~ uint32_t

#define RAM_REGION_MAX 10
#define SYS_KERNEL_LOAD_ADDR (1024*1024)

typedef struct ram_region_struct
{
    uint32_t start;  //起始地址
    
    uint32_t size;   //内存地址

}ram_region_struct;

typedef struct boot_info_t   
{
    ram_region_struct ram_region[RAM_REGION_MAX];    //可用内存
    
    int ram_region_count;        //可用内存数量

}boot_info_t;

 然后我们在函数中调用 0x15 号中断来检测内存,关于 0x15中断检测内存可以参考这篇博客——操作系统内核管理模块的实现1-检测可用内存_系统管理模块系统监测存储空间-CSDN博客

 我们这里不再赘述,代码如下:

/*
内存检测信息结构
typedef struct SMAP_entry 
{
    uint32_t BaseL; // base address uint64_t
    uint32_t BaseH;
    uint32_t LengthL; // length uint64_t
    uint32_t LengthH;
    uint32_t Type; // entry Type
    uint32_t ACPI; // extended
}__attribute__((packed)) SMAP_entry_t;
/*

//检测内存容量
static void  detect_memory(void) 
{
	uint32_t contID = 0;
	SMAP_entry_t smap_entry;
	int signature, bytes;

    print_msg("detect memory......\n\r");

	// 初次:EDX=0x534D4150,EAX=0xE820,ECX=24,INT 0x15, EBX=0(初次)
	// 后续:EAX=0xE820,ECX=24,
	// 结束判断:EBX=0
	boot_info.ram_region_count = 0;
	for (int i = 0; i < RAM_REGION_MAX; i++) 
    {
		SMAP_entry_t * entry = &smap_entry;
        //内联汇编

		__asm__ __volatile__(
            "int  $0x15"
			: "=a"(signature), "=c"(bytes), "=b"(contID)     
			: "a"(0xE820), "b"(contID), "c"(24), "d"(0x534D4150), "D"(entry));         
		//"=a"(signature):EAX->signature。"=c"(bytes):ECX->bytes。"=b"(contID):EBX->contID。
        //"a"(0xE820):0xE820->EAX。调用E820内存映射函数 "b"(contID):contID->EBX。"c"(24):24->ECX。"d"(0x534D4150):0x534D4150->EDX  "D"(entry) :entry_>EDI

        if (signature != 0x534D4150) 
        {
            print_msg("failed.\r\n");
			return;
		}

		// todo: 20字节
		if (bytes > 20 && (entry->ACPI & 0x0001) == 0)
        {
			continue;
		}

        // 保存RAM信息,只取32位
        if (entry->Type == 1) 
        {
            boot_info.ram_region[boot_info.ram_region_count].start = entry->BaseL;
            boot_info.ram_region[boot_info.ram_region_count].size =  entry->LengthL;
            boot_info.ram_region_count++;
        }

		if (contID == 0) 
        {
			break;
		}
	}
    //打印可用内存
    print_msg("usable memory:\r\n");
    for(int i=0;i<boot_info.ram_region_count;i++)
    {
        char str[8]={0};
        uintToHex(boot_info.ram_region[i].start,str,8);
        print_msg("start: 0x");
        print_msg(str);
        print_msg("  ");
        uintToHex(boot_info.ram_region[i].size,str,8);
        print_msg("size: 0x");
        print_msg(str);
        print_msg("\r\n");
    }
    print_msg("detection completed.\r\n");
}

打开A20地址线

我们知道在早期的英特尔处理器中,CPU有20根地址线可以访问 1MB 的内存。在32位环境下CPU有32位地址线,在实模式下为了兼容早期程序,第21根地址线是默认关闭的,如果想要访问完整的4GB内存我们需要手动打开A20地址线(地址线从零开始计算,A20即第21根地址线)。

打开A20地址线有许多方法:

  • 通过快速辅助寄存器: 在某些系统上,可以通过写入端口0x92来快速启用A20地址线。这种方法比较快,因为它直接操作硬件。
outb(0x92, 0x02); // outb是汇编指令,用于输出一个字节到指定的端口
  • 通过BIOS中断: 使用BIOS中断0x15,子功能0x84,可以请求BIOS来启用A20地址线。 
mov ax, 0x2401  ; 设置AH为0x24,AL为0x01,表示启用A20地址线
int 0x15        ; 调用BIOS中断
  • 通过键盘控制器: 另一个启用A20地址线的常见方法是通过键盘控制器的控制寄存器。这种方法较慢,因为它需要对键盘控制器进行多次操作。
mov al, 0xAD   ; 控制寄存器的命令字
out 0x64, al   ; 发送命令到键盘控制器的控制寄存器
mov al, 0xD0   ; 读取状态寄存器
in 0x60, al    ; 从键盘控制器的状态寄存器读取数据
or al, 0x02    ; 将A20位设置为1
out 0x60, al   ; 写回状态寄存器

这里我们使用了第一种方法打开地址线。为了方便我们访问端口,我们在comm文件夹定义一个头文件,cpu_instr .h在头文件中,我们会定义内联函数,在内联函数中我们又使用内联汇编,模拟汇编指令。如outb指令:

//向端口写入一个字节
static inline void outb(uint16_t port,uint8_t data)
{

    //outb al, dx
    __asm__ __volatile("outb %[val] ,%[p]" :: [p] "d"(port), [val]"a"(data));           
}

打开A20地址线

//打开A20地址线以访问4GB内存
uint8_t port_92 = inb(0x92);      //读取92号端口
outb(0x92,port_92 | 0x2);       //第二位置一

设置全局段描述符表

在进入保护模式之前,还有一项重要的工作——设置全局段描述符表(GDT)。

在32位系统上大多数的寄存器都从16位拓展为32位,但段寄存器仍为16位,那么16位的段寄存器如何访问32位的内存地址呢?

在32位的保护模式中,段寄存器用于选择段描述符,而不是直接参与地址的计算。段寄存器中存储的不再是内存地址,而是16位的段选择子,选择子用于索引GDT或LDT,以获取相应的段描述符。

Index一共13位,正好可以索引到GDT或者LDT的最大限长。由于描述符表都是8字节对齐的,所以index放在高13位,这样把低3位屏蔽后就可以得到表内偏移地址。 

TI(Table Indicator)位是引用描述符表指示位,TI=0指示从全局描述符表GDT中读取描述符;TI=1指示从局部描述符表LDT中读取描述符。

选择子的最低两位是请求特权级RPL(Requested Privilege Level),用于特权检查。CS和SS寄存器中的RPL就是CPL(当前特权级)在Linux下只使用两个特权级0、3分别是内核态、用户态。

用段寄存器选择段的基本流程:

  1. 加载段寄存器:首先,需要将一个段选择器加载到段寄存器中。段选择器是一个16位的值,它包含了一个索引(用于在全局描述符表GDT或局部描述符表LDT中查找段描述符)和一个请求特权级(RPL)。

  2. 索引查找:段寄存器中的段选择器的索引部分用于在GDT或LDT中查找对应的段描述符。如果索引超出了描述符表的大小,或者描述符表中相应位置的描述符不存在,将会导致一个通用保护故障(#GP)。

  3. 检查类型和权限:一旦找到段描述符,CPU会检查描述符的类型和权限。段描述符中的类型字段定义了段的类型(如代码段、数据段等),而DPL(描述符特权级)字段定义了可以访问该段的最低特权级别。同时为了快速访问,CPU会将段的基地址,偏移量等保存在影子寄存器中。

  4. 检查RPL和CPL:段选择器中的RPL与当前进程的CPL(当前特权级)一起,确定是否可以访问该段。如果RPL大于CPL,或者段描述符中的DPL大于CPL,访问将被拒绝。

  5. 获取段基地址:如果权限检查通过,CPU将从段描述符中获取段的基地址。这个基地址与段内偏移量组合,形成完整的物理地址或线性地址。

  6. 计算物理/线性地址:在实模式下,段基地址直接与段内偏移量相加得到物理地址。在保护模式下,段基地址和偏移量组合形成线性地址,然后可能需要通过分页机制转换为物理地址。

  7. 访问内存:使用计算得到的地址,CPU可以访问内存中的段。如果访问类型(如读、写或执行)与段描述符中的类型不匹配,或者访问违反了其他权限设置,将会导致一个保护故障。

  8. 异常和中断处理:在访问内存时,如果发生任何异常或中断,CPU将保存当前的上下文,包括段寄存器的值,到一个安全的地方(如堆栈或任务状态段TSS),以便之后可以恢复执行。

  9. 任务切换:在任务切换时,操作系统会更新所有相关的段寄存器,以确保新任务使用正确的段描述符和内存段。

段描述符存储在GDT或者LDT,段描述符(Segment Descriptor)是全局描述符表(GDT)或局部描述符表( LDT)中的一个数据结构,用于定义段的属性和行为。GDT 和LDT 段描述符表实际上是段描述符的一个长度不定的数据阵列。描述符表在长度上是可变的,最多容纳213 个描述符,最少包含一个描述符。每个项有8 个字节长,称为一个段描述符。段描述符设定了使用该段时的内存访问权限、段的界限和基地址。 段描述符为8个字节64位结构如下:

我们通过设置GDTR寄存器指向GDT表,GDT、LDT、GDTR、LDTR与段寄存器关系如下:

在项目中我们采用的是平坦模式访问内存,所以我们这里将数据段与代码段基地址都设为0x00000000,段界限设为4GB。

//设置全局段描述符表
uint16_t gdt_table[][4] = {
    {0, 0, 0, 0},                           //空描述符
    {0xFFFF, 0x0000, 0x9A00, 0x00CF},       
    {0xFFFF, 0x0000, 0x9200, 0x00CF},       
};

x86架构中,全局描述符表的第一个段描述符需要被设置为全零。第二个描述符表是代码段,特权级为0,可读写,在从内存中存在,基地址为0x00000000,段界限为4GB。第三个描述符表是数据段,特权级为0,可读写,在从内存中存在,基地址为0x00000000,段界限为4GB。

这只是一个简单的实现,在进入内核后,我们会重新设置GDT。定义了GDT表后,我们需要设定gdtr寄存器。我们可以使用lgdt指令设置GDTR寄存器。我们仍然使用内联函数完成lgdt功能。

//设置全局段描述符表
lgdt((uint32_t)gdt_table,sizeof(gdt_table));


//设置GDTR
static inline void lgdt(uint32_t start,uint16_t size)
{
    struct
    {
        uint16_t limit;             //描述符表大小-1
        uint16_t startl;            //低16位
        uint16_t starth;            //高16位
    }gdt;

    gdt.limit=size-1;
    gdt.starth=  start >> 16;
    gdt.startl=  start & 0xFFFF;

    __asm__ __volatile("lgdt %[g]" ::[g]"m"(gdt));
}

进入保护模式

进入保护模式的基本流程:
 

初始化全局描述符表(GDT)

创建一个GDT结构,其中包含必要的段描述符,例如代码段、数据段等。

设置GDT的界限和基地址

计算GDT的大小,并创建一个GDTR(全局描述符表寄存器)结构,包含GDT的界限和基地址。

加载GDTR

使用lgdt指令将GDTR结构加载到CPU的GDTR寄存器中。

设置段寄存器

将CS(代码段寄存器)、DS(数据段寄存器)、ES、FS、GS和SS(堆栈段寄存器)等设置为合适的值,通常指向GDT中的有效段描述符。

开启A20地址线

如果系统需要访问超过1MB的内存,需要确保A20地址线被启用,允许访问高端内存。

修改CR0寄存器

将CR0寄存器的PE位(保护使能位)设置为1,启用保护模式。

远跳转至新的代码段

执行一个远跳转指令,跳转到一个新的代码段,这个跳转操作会刷新内部的段寄存器,并开始在保护模式下执行代码。

我们已经完成大部分工作,现在只要将CR

初始化全局描述符表(GDT)

创建一个GDT结构,其中包含必要的段描述符,例如代码段、数据段等。

设置GDT的界限和基地址

计算GDT的大小,并创建一个GDTR(全局描述符表寄存器)结构,包含GDT的界限和基地址。

加载GDTR

使用lgdt指令将GDTR结构加载到CPU的GDTR寄存器中。

设置段寄存器

将CS(代码段寄存器)、DS(数据段寄存器)、ES、FS、GS和SS(堆栈段寄存器)等设置为合适的值,通常指向GDT中的有效段描述符。

开启A20地址线

如果系统需要访问超过1MB的内存,需要确保A20地址线被启用,允许访问高端内存。

修改CR0寄存器

将CR0寄存器的PE位(保护使能位)设置为1,启用保护模式。

远跳转至新的代码段

执行一个远跳转指令,跳转到一个新的代码段,这个跳转操作会刷新内部的段寄存器,并开始在保护模式下执行代码。

我们已经完成了大部分工作,现在只需设置CR0寄存器即可进入保护模式,我们使用内联汇编完成这个功能:

//设置CR0,PE位
static inline void CR0_PE()
{

    uint32_t cr0;

    //读取CR0
    __asm__("mov %%cr0, %0" :"=r"(cr0));

    //修改CR0
    cr0 |= 1; 

    //写回
    __asm__("mov %0, %%cr0" :: "r"(cr0));    
}

完成这部分工作我们就进入了保护模式,也从16位环境跳转至32位环境了,接下来我们需要远跳转清空CPU流水线更新CS段寄存器(CS段寄存器无法手动更改),我们跳转到start.s中执行:

//远跳转
static inline void far_jump(uint32_t selector , uint32_t offset)
{

    uint32_t addr[]={offset,selector};  //offset->偏移量,selector->段选择子
    __asm__("ljmpl *(%[a])"::[a]"r"(addr));    
}

//进入保护模式
static void protect_entry()
{
    cli();   //关闭中断
    
    //打开A20地址线以访问4GB内存
    uint8_t port_92 = inb(0x92);      //读取92号端口
    outb(0x92,port_92 | 0x2);       //第二位置一

    //设置全局段描述符表
    lgdt((uint32_t)gdt_table,sizeof(gdt_table));
    print_msg("GDT settings\n\r");
    //设置CR0 PE位
    print_msg("protect mode enter\n\r");
    CR0_PE();
    //清空cpu流水线
    far_jump(8,(uint32_t)protect);   
}

 在start.s中我们更新其他段寄存器执行数据段然后跳转到C语言环境中

//二级引导程序loader

	//16位代码
  	.code16
 	.text
	.global _start
	.extern loader_entry
	.extern loader_kernel

	.global protect
_start:
	//跳转至C语言环境
	jmp loader_entry
	

	//32位代码
	.code32
	.text
protect:
	//将段寄存器设置为32位
	mov $16, %ax 	//16为段选择子
	mov %ax, %ds
	mov %ax, %ss
	mov %ax, %es
	mov %ax, %fs
	mov %ax, %gs
	jmp $8 , $loader_kernel

loader_kernel在loader_32.c中定义,在这里我们会加载操作系统内核到内存然后跳转至内核执行。

加载内核 

在进入保护模式后,我们未设置中断描述符表不能使用软件中断,所以我们通过读写端口使用LBA模式读写磁盘,我们将内核加载至内存地址0x10000处。

//使用LBA48位模式读取磁盘
static void read_disk(int sector, int sector_count, uint8_t * buf) 
{
    outb(0x1F6, (uint8_t) (0xE0));

	outb(0x1F2, (uint8_t) (sector_count >> 8));
    outb(0x1F3, (uint8_t) (sector >> 24));		// LBA参数的24~31位
    outb(0x1F4, (uint8_t) (0));					// LBA参数的32~39位
    outb(0x1F5, (uint8_t) (0));					// LBA参数的40~47位

    outb(0x1F2, (uint8_t) (sector_count));
	outb(0x1F3, (uint8_t) (sector));			// LBA参数的0~7位
	outb(0x1F4, (uint8_t) (sector >> 8));		// LBA参数的8~15位
	outb(0x1F5, (uint8_t) (sector >> 16));		// LBA参数的16~23位

	outb(0x1F7, (uint8_t) 0x24);

	// 读取数据
	uint16_t *data_buf = (uint16_t*) buf;
	while (sector_count-- > 0) 
    {
		// 每次扇区读之前都要检查,等待数据就绪
		while ((inb(0x1F7) & 0x88) != 0x8) 
        {}

		// 读取并将数据写入到缓存中
		for (int i = 0; i < SECTOR_SIZE / 2; i++) 
        {
			*data_buf++ = inw(0x1F0);
		}
	}
}

outb(0x1F6, (uint8_t) (0xE0)); - 向硬盘的备用寄存器(Alternate Status Register)端口0x1F6写入0xE0,这是LBA模式的特定命令。

outb(0x1F2, (uint8_t) (sector_count >> 8)); - 发送要读取的扇区数量的高8位到端口0x1F2。

outb(0x1F3, (uint8_t) (sector >> 24)); - 发送LBA地址的高8位到端口0x1F3。 接下来的几条outb指令发送剩余的LBA地址和扇区数量到相应的端口。

outb(0x1F7, (uint8_t) 0x24); - 向命令寄存器(Command Register)端口0x1F7写入0x24,这是读取扇区的命令。

然后,代码进入一个循环,读取每个扇区的数据。 在读取每个扇区之前,代码使用一个循环来检查硬盘状态,等待数据就绪。

inb(0x1F7) - 读取硬盘的状态寄存器,检查数据是否就绪。

一旦数据就绪,代码使用inw(0x1F0)指令从数据端口0x1F0读取数据。这里假设每次读取是16位宽,因此使用uint16_t *data_buf作为缓冲区的指针。 SECTOR_SIZE是定义好的一个宏,表示每个扇区的大小,为512字节。循环读取每个扇区的数据到提供的缓冲区buf。 

在读取完内核文件后我们需要跳转至内核执行,在以前我们会直接跳转,但在内核编译时,我们编译生成的是elf文件,而不是之前的bin文件,我们需要解析elf文件,得到程序入口地址,然后跳转至入口执行。

ELF 头部位于文件的开始位置,提供了整个文件的总体信息。它包括程序入口点地址、程序头表和节区头表的位置等信息,我们需要读取ELF 头部中的elf_entry 成员,它存储了程序的入口点地址。这是程序加载到内存后,CPU 开始执行的起始点。


// ELF Header
#define EI_NIDENT       16
#define ELF_MAGIC       0x7F

typedef struct {
    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;

#define PT_LOAD         1

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_flags;
    Elf32_Word p_align;
} Elf32_Phdr;


//解析ELF文件格式
static uint32_t load_elf(uint8_t* file)
{
	Elf32_Ehdr* elf_hdr= (Elf32_Ehdr*)file;
	 if ((elf_hdr->e_ident[0] != ELF_MAGIC) || (elf_hdr->e_ident[1] != 'E')
        || (elf_hdr->e_ident[2] != 'L') || (elf_hdr->e_ident[3] != 'F')) 
	{
        return 0;
    }

	for(int i=0;i<elf_hdr->e_phnum;i++)
	{
		Elf32_Phdr* phdr=(Elf32_Phdr*)(file+ elf_hdr->e_phoff) + i;  //e_phoff为偏移量
		//检查是否可加载
		if(phdr->p_type != PT_LOAD)
		{
			continue;
		}

		//使用物理地址,此时分页机制还未打开
        uint8_t * src = file + phdr->p_offset;	//源文件地址

        uint8_t * dest = (uint8_t *)phdr->p_paddr;		//目的地址
        for (int j = 0; j < phdr->p_filesz; j++) 
		{
            *dest++ = *src++;							//按地址复制
        }
		//bss区域全0
		//memsz和filesz不同时,后续要填0
		dest= (uint8_t *)phdr->p_paddr + phdr->p_filesz;
		for (int j = 0; j < phdr->p_memsz - phdr->p_filesz; j++) 
		{
			*dest++ = 0;
		}
	}	
	return elf_hdr->e_entry; //返回程序入口地址
}	

获得入口地址后我们强转为函数指针后调用即可,代码如下:

void loader_kernel()
{
    //加载内核程序
    read_disk(100,500,(uint8_t *)SYS_KERNEL_LOAD_ADDR);  
    //跳转至内核程序
	uint32_t kernel_entry = load_elf((uint8_t *)SYS_KERNEL_LOAD_ADDR);
	if(kernel_entry==0)
	{
		die(1);
	}
	((void (*)(boot_info_t*))kernel_entry)(&boot_info);
}

到此二级引导程序就完成了,在下一节我们会开始编写内核程序。因为篇幅问题,很多代码,细节 ,目录结构等没有解释,有兴趣的读者可以在gitee上查看完整代码。

代码仓库

x86_os: 从零手写32位操作系统 (gitee.com)

;