加载:loader实现

1、利用内联汇编显示字符串

通过反复调用BIOS显示字符的方式来显示一个完整的字符串,该功能将用于loader在初始化过程中显示初始化进度、错误信息。

具体代码

// 16位代码,必须加上放在开头,以便有些io指令生成为32位
__asm__(".code16gcc");

/**
 * BIOS下显示字符串
 */
static void show_msg (const char * msg) {
//定义一个静态函数 show_msg,它接收一个字符指针 msg 作为参数。这意味着该函数只能在当前源文件中使用
    char c;

	// 使用bios写显存,持续往下写
	while ((c = *msg++) != '\0') {
		__asm__ __volatile__(   //这段代码使用了内嵌汇编(inline assembly)来直接调用 BIOS 中断服务例程来显示字符。
				"mov $0xe, %%ah\n\t"   //将寄存器 AH 设置为 0x0E,表示 BIOS 的显示字符功能。
				"mov %[ch], %%al\n\t"  //将字符变量 c 的值加载到寄存器 AL 中。
				"int $0x10"::[ch]"r"(c));   //调用 BIOS 的中断 0x10 来显示字符
}

void loader_entry(void) {
    show_msg("....loading.....\r\n");
    for(;;) {}
}

2、检测内存容量

内存检测方法: INT 0x15, EAX = 0xE820

static void  detect_memory(void) {
	uint32_t contID = 0;
	SMAP_entry_t smap_entry;
	int signature, bytes;

    show_msg("try to detect memory:");

	// 初次: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 < BOOT_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));
		if (signature != 0x534D4150) {
            show_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_cfg[boot_info.ram_region_count].start = entry->BaseL;
            boot_info.ram_region_cfg[boot_info.ram_region_count].size = entry->LengthL;
            boot_info.ram_region_count++;
        }

		if (contID == 0) {
			break;
		}
	}
    show_msg("ok.\r\n");
}
// 内存检测信息结构
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;

3、切换到保护模式

X86CPU的两种工作模式

1)实模式

x86在上电启动后自动进入实模式,即16位工作模式,这种模式是最早期的8086芯片所使用的工作模式。早期的芯片设计得较简单、工作模式也较简单,所以有诸多限制:

最大只能访问1MB的内存:采用段值:偏移的方式访问,内核寄存器最大为16位宽。如段寄存器CS, DS, ES, FS, GS, SS均为16位宽,AX, BX, CX DX, SI, DI, SP等也均为16位宽,有诸多限制

  1. 所有的操作数最大为16位宽,出栈入栈也以16位为单位
  2. 没有任何保护机制,意味着应用程序可以读写内存中的任意位置
  3. 没有特权级支持,意味着应用程序可以随意执行任何指令,例如停机指令、关中断指令
  4. 没有分页机制和虚拟内存的支

2)保护模式

  1. 寄存器位宽扩展至32位,例如AX扩展至32位的EAX,最大可访问4GB内存
  2. 所有操作数最大为32位宽,出入栈也为32位
  3. 提供4种特权级。操作系统可以运行在最高特权级,可执行任意指令;应用程序可运行于最低特权级,避免其执行某些特权指令,例如停机指令、关中断指令
  4. 支持虚拟内存,可以开启分页机制,以隔离不同的应用程序

3)切换至保护模式

实模式(Real Mode)中,地址线的地址范围是 20 位,即可以寻址到 1MB 的内存空间(0x00000 到 0xFFFFF)。然而,在实模式下,CPU 只能寻址 1MB 的内存。

A20 地址线 是一种物理地址线,它在实模式下被禁止,以确保地址不超过 1MB(0xFFFFF)。

在进入保护模式后,CPU 需要开启 A20 地址线以访问 1MB 以上的内存空间。

实模式 中,CPU 使用段寄存器来访问内存,每个段寄存器包含一个段选择子,段选择子指向段描述符表

保护模式 引入了更复杂的内存管理机制,包括段选择子和描述符表。段描述符表包括全局描述符表(GDT)和局部描述符表(LDT)。

在保护模式中,使用 LGDT 指令加载 GDT。此指令设置 GDTR(全局描述符表寄存器),指示 CPU GDT 的基址和大小。

综上具体步骤:

  1. 禁用中断
  2. 打开A20地址线
  3. 加载GDT表
  4. 设置CR0的保护模式使能位
  5. 远跳转、清空流水线

4)主要代码

CPU_instr.h   主要是汇编指令的封装

  1. outb 向指定的 I/O 端口 port 写入一个字节的数据 data。
  2. cli 指令用于清除 CPU 的中断标志,从而禁止中断。
  3. sti 指令用于设置 CPU 的中断标志,从而允许中断
  4. lgdt 指令用于加载全局描述符表(GDT)的基址和限长到 GDTR 寄存器。
#ifndef CPU_INSTR_H
#define CPU_INSTR_H

#include "types.h"

//inb 从指定的 I/O 端口 port 读取一个字节的数据。
static inline uint8_t inb(uint16_t  port) {
	uint8_t rv;
	__asm__ __volatile__("inb %[p], %[v]" : [v]"=a" (rv) : [p]"d"(port));
	return rv;
}

//outb 向指定的 I/O 端口 port 写入一个字节的数据 data。
static inline void outb(uint16_t port, uint8_t data) {
	__asm__ __volatile__("outb %[v], %[p]" : : [p]"d" (port), [v]"a" (data));
}

//cli 指令用于清除 CPU 的中断标志,从而禁止中断。
static inline void cli() {
	__asm__ __volatile__("cli");
}


//sti 指令用于设置 CPU 的中断标志,从而允许中断
static inline void sti() {
	__asm__ __volatile__("sti");
}

//lgdt 指令用于加载全局描述符表(GDT)的基址和限长到 GDTR 寄存器。
static inline void lgdt(uint32_t start, uint32_t size) {
	struct {
		uint16_t limit;
		uint16_t start15_0;    // 视频中这里写成了32位
		uint16_t start31_16;    // 视频中这里写成了32位
	} gdt;

	gdt.start31_16 = start >> 16;
	gdt.start15_0 = start & 0xFFFF;
	gdt.limit = size - 1;
 
	__asm__ __volatile__("lgdt %[g]"::[g]"m"(gdt));        //"lgdt %[g]": 执行 lgdt 指令,将 gdt 的内容加载到 GDTR 寄存器。
}

#endif

loader_16.c

主要实现了进入保护模式的函数enter_protect_mode() 和 GDT表gdt_table[][4]

// GDT表。临时用,后面内容会替换成自己的
uint16_t gdt_table[][4] = {
    {0, 0, 0, 0},
    {0xFFFF, 0x0000, 0x9A00, 0x00CF},
    {0xFFFF, 0x0000, 0x9200, 0x00CF},
};

/**
 * 进入保护模式
 */
static void  enter_protect_mode() {
    cli(); // 关中断

    // 开启A20地址线,使得可访问1M以上空间
    // 使用的是Fast A20 Gate方式,见https://wiki.osdev.org/A20#Fast_A20_Gate
    uint8_t v = inb(0x92);
    outb(0x92, v | 0x2);

    // 加载GDT。由于中断已经关掉,IDT不需要加载
    lgdt((uint32_t)gdt_table, sizeof(gdt_table));
}

void loader_entry(void) {
    show_msg("....loading.....\r\n");
	detect_memory();
    enter_protect_mode();
    for(;;) {}
}

4、使用LBA读取磁盘

由于进入保护模式后,无法使用BIOS中断的方式读取磁盘。另外,由于读取的磁盘数据会放在1MB以上的内存区域,不便于在进入保护模式前使用BIOS的磁盘读取服务来读取

LBA48模式:将硬盘上所有的扇区看成线性排列,没有磁盘、柱面等概念,因此访问起来更加简单,扇区序号从0开始

在loader_32.c代码如下

/**
* 使用LBA48位模式读取磁盘
	定义一个静态函数 read_disk,用于从磁盘读取数据。函数参数包括:
	sector:要读取的起始扇区。
	sector_count:要读取的扇区数量。
	buf:指向数据缓存的指针,将读取的数据存储到这里。
*/
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);
		}
	}
}

具体步骤

  • 设置硬盘控制寄存器

         硬盘控制器寄存器在 I/O 端口 0x1F00x1F7 范围内。这些寄存器用于设置硬盘的读取和写入操作。

  • 配置 LBA48 地址模式:

        使用 0x1F6 端口配置 LBA48 模式。

        向 0x1F20x1F30x1F40x1F5 端口写入扇区数和 LBA 地址的各个部分。

        向 0x1F7 端口发送命令以开始读取操作

  • 读取数据:

        检查数据是否准备好。

        从数据寄存器(0x1F0)读取数据,并将数据存储到缓冲区中。

5、创建内核工程并传递启动信息

创建kernel目录用来保存内核相关代码(init.c 、init.h)

init.c

#include "comm/boot_info.h"

int test (int a, int b) {
    return a + b;
}

/**
 * 内核入口
 */
void kernel_init (boot_info_t * boot_info) {
    int a = 1, b = 2;
    test(a , b);

    for (;;) {}
}

loader_32.c

/**
 * 从磁盘上加载内核
 */
void load_kernel(void) {
    // 读取的扇区数一定要大一些,保不准kernel.elf大小会变得很大

    read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);
    ((void (*)(boot_info_t *))SYS_KERNEL_LOAD_ADDR)(&boot_info);
    for (;;) {}
}

load_kernel 函数的目的是从磁盘读取内核映像到内存中,并启动内核。它通过 read_disk 函数读取指定的扇区到内存中,然后调用加载到内存中的内核入口函数,将引导信息传递给内核进行初始化。最后,它进入一个无限循环,确保在内核启动完成后不会返回

向内核传递启动信息

压栈总是先esp指针减4,再写入数据       

出栈总是先取出数据,esp加4

在C函数中,编译器会根据定义的局部变量、计算过程、函数调用按照一定的规范自动规划栈的使用。具体的使用方法如下

  • 保存局部变量和数据
  • 传递参数:从参数列表右侧往左压入栈
  • 保存返回地址
  • 通过ebp+偏移取调用者的传入的参数和自己的局部变量

主要通过内核的汇编代码进行参数传递(有三种方法)

	.text
 	.global _start
	.extern kernel_init
_start:
    # 第一种方法
    # push %ebp
    # mov %esp, %ebp
    # mov 0x8(%ebp), %eax
    # push %eax

    # 第二种方法
    # mov 4(%esp), %eax
    # push %eax

    # 第三种方法
    push 4(%esp)

    # kernel_init(boot_info)
    call kernel_init

	jmp .

6、确定指令、数据在内存中位置

通过分析工程生成的反汇编文件和elf文件,展示了GCC工具链如何安排代码、数据在内存中的位置

GCC工具链默认按照 .text  .rodata  .data    .bss  来存储代码和数据

  • text:存储机器指令和代码
  • rodata:存储常量以及字符串本身
  • data:存储初始化的数据,全局的或者static静态的
  • .bss:存储未初始化的数据(即初始化为0),全局的或者静态的
  • stack:存储局部变量和函数调用中返回地址等
  • 相同类型的段会自动进行合并

在kernel下新建kernel.lbs链接文件,具体如下:

SECTIONS
{ 
    . = 0x000100000;   //(.)为 0x000100000 , 接下来的段将从这个地址开始布局

	.text : {
		*(.text)     //*.text 表示将所有输入文件中的 .text 段的内容合并到这个段中。
	} 

	.rodata : {
		*(.rodata)
	}

	.data : {
		*(.data)
	}
	.bss : {
		*(.bss)
	}
}


/*
起始地址: . = 0x000100000; 设置程序的起始地址为 0x000100000。
段定义:
.text: 包含执行代码。
.rodata: 包含只读数据。
.data: 包含已初始化的数据。
.bss: 包含未初始化的数据
*/

最后还要修改CMakeList中的链接文件

7、加载内核映像文件

        windows的可执行文件格式通常为.exe; Liunx更多为elf文件格式

1)具体的加载过程如下:

  1. 初步检查elf header的合法性
  2. 通过elf header->e_phoff 定位到 programe header table,遍历 elf header->e_phnum次,加载各个段
  3.  从文件位置 p_offset 处读取filesz大小的数据,写入到内存中 paddr 的位置处
  4.  如果 p_filesz < p_memsz,则将部分内存清零(bss区初始化) 取elf header->e_entry,跳转到该地址运行。

从上述流程可以看出,在C代码中定义的未初始化的全局变量(分配在BSS区,初始值为0),并没有在ELF中分配相应的空间,需要自己在内存中手动清0.

2)代码实现过程

  1. 首先从磁盘读取内核镜像文件:read_disk()  相关代码前面已经写过
  2. 然后解析ELF文件、将相关数据复制到内存的正确位置、返回 ELF 文件中的入口点地址:reload_elf_file()
  3. 最后跳到内核的入口地址:load_kernel
static uint32_t reload_elf_file (uint8_t * file_buffer) {
    // 读取的只是ELF文件,不像BIN那样可直接运行,需要从中加载出有效数据和代码
    // 简单判断是否是合法的ELF文件
    Elf32_Ehdr * elf_hdr = (Elf32_Ehdr *)file_buffer;
    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_buffer + elf_hdr->e_phoff) + i;
        if (phdr->p_type != PT_LOAD) {
            continue;
        }

		// 全部使用物理地址,此时分页机制还未打开
        uint8_t * src = file_buffer + phdr->p_offset;
        uint8_t * dest = (uint8_t *)phdr->p_paddr;
        for (int j = 0; j < phdr->p_filesz; j++) {
            *dest++ = *src++;
        }

		// 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 load_kernel(void) {
    // 读取的扇区数一定要大一些,保不准kernel.elf大小会变得很大
    // 我就吃过亏,只读了100个扇区,结果运行后发现kernel的一些初始化的变量值为空,程序也会跑飞
    read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);

     // 解析ELF文件,并通过调用的方式,进入到内核中去执行,同时传递boot参数
	 // 临时将elf文件先读到SYS_KERNEL_LOAD_ADDR处,再进行解析
    uint32_t kernel_entry = reload_elf_file((uint8_t *)SYS_KERNEL_LOAD_ADDR);
	if (kernel_entry == 0) {
		die(-1);
	}

	// 转换为函数指针,然后跳进内核
    ((void (*)(boot_info_t *))kernel_entry)(&boot_info);
}

8 总结

内联汇编显示字符串:利用显示单个字符的原理
检测内容容量:利用BIOS的软中断服务
切换到保护模式:A20地址线的开启,使得可用内存不局限于1MB,采用GDT表管理内存,使用LGDT寄存器指明GDT表的基这地址和大小

进入保护模式,不方便用BIOS的软中断,因此引入LBA进行磁盘读取,在加载内核文件时,需要对ELF文件解析,将内核相关数据、代码保存到内存的指定位置,然后跳到指定地址去执行内核代码

  • 15
    点赞
  • 28
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值