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位宽,有诸多限制
- 所有的操作数最大为16位宽,出栈入栈也以16位为单位
- 没有任何保护机制,意味着应用程序可以读写内存中的任意位置
- 没有特权级支持,意味着应用程序可以随意执行任何指令,例如停机指令、关中断指令
- 没有分页机制和虚拟内存的支
2)保护模式
- 寄存器位宽扩展至32位,例如AX扩展至32位的EAX,最大可访问4GB内存
- 所有操作数最大为32位宽,出入栈也为32位
- 提供4种特权级。操作系统可以运行在最高特权级,可执行任意指令;应用程序可运行于最低特权级,避免其执行某些特权指令,例如停机指令、关中断指令
- 支持虚拟内存,可以开启分页机制,以隔离不同的应用程序
3)切换至保护模式
实模式(Real Mode)中,地址线的地址范围是 20 位,即可以寻址到 1MB 的内存空间(0x00000 到 0xFFFFF)。然而,在实模式下,CPU 只能寻址 1MB 的内存。
A20 地址线 是一种物理地址线,它在实模式下被禁止,以确保地址不超过 1MB(0xFFFFF)。
在进入保护模式后,CPU 需要开启 A20 地址线以访问 1MB 以上的内存空间。
在 实模式 中,CPU 使用段寄存器来访问内存,每个段寄存器包含一个段选择子,段选择子指向段描述符表
保护模式 引入了更复杂的内存管理机制,包括段选择子和描述符表。段描述符表包括全局描述符表(GDT)和局部描述符表(LDT)。
在保护模式中,使用 LGDT 指令加载 GDT。此指令设置 GDTR(全局描述符表寄存器),指示 CPU GDT 的基址和大小。
综上具体步骤:
- 禁用中断
- 打开A20地址线
- 加载GDT表
- 设置CR0的保护模式使能位
- 远跳转、清空流水线
4)主要代码
CPU_instr.h 主要是汇编指令的封装
- outb 向指定的 I/O 端口 port 写入一个字节的数据 data。
- cli 指令用于清除 CPU 的中断标志,从而禁止中断。
- sti 指令用于设置 CPU 的中断标志,从而允许中断
- 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 端口 0x1F0
到 0x1F7
范围内。这些寄存器用于设置硬盘的读取和写入操作。
- 配置 LBA48 地址模式:
使用 0x1F6
端口配置 LBA48 模式。
向 0x1F2
、0x1F3
、0x1F4
和 0x1F5
端口写入扇区数和 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)具体的加载过程如下:
- 初步检查elf header的合法性
- 通过elf header->e_phoff 定位到 programe header table,遍历 elf header->e_phnum次,加载各个段
- 从文件位置 p_offset 处读取filesz大小的数据,写入到内存中 paddr 的位置处
- 如果 p_filesz < p_memsz,则将部分内存清零(bss区初始化) 取elf header->e_entry,跳转到该地址运行。
从上述流程可以看出,在C代码中定义的未初始化的全局变量(分配在BSS区,初始值为0),并没有在ELF中分配相应的空间,需要自己在内存中手动清0.
2)代码实现过程
- 首先从磁盘读取内核镜像文件:read_disk() 相关代码前面已经写过
- 然后解析ELF文件、将相关数据复制到内存的正确位置、返回 ELF 文件中的入口点地址:reload_elf_file()
- 最后跳到内核的入口地址: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文件解析,将内核相关数据、代码保存到内存的指定位置,然后跳到指定地址去执行内核代码