2. loader和kernel的实现
字符串显示和内存检测
之前实现了从boot到loader的跳转,现在实现loader程序。
采用的是内联汇编的形式,在C代码中嵌入汇编程序。用的是asm关键字
,这里就不详细解释了。下面实现的是用内联汇编来显示字符串,加上__volatile__
关键字来避免内联汇编语句被编译器优化:
// 显示字符串的函数
static void show_msg(const char * msg) {
char c;
// 显示字符来进行测试,内联汇编
while ((c = *msg++) != '\0') {
__asm__ __volatile__ (
"mov $0xe, %%ah\n\t"
"mov %[ch], %%al\n\t"
"int $0x10"::[ch]"r"(c) // 输入操作数
);
}
}
对loader程序的功能扩展:检测内存容量。
一种内存检测方法: INT 0x15, EAX = 0xE820
EDX需要设置成:0x534D4150
EAX重设为0xE820
ECX重设为24
执行INT 0x15
返回结果:EAX = 0x534D4150,CF标志清0。如果EBX=0,则表明读取完毕,否则当前条目有效。
具体实现算法参考:内存检测方法。这里不作为重点说明。
切换进保护模式
首先要介绍一下实模式和保护模式。
什么是实模式
Real Mode is a simplistic 16-bit mode that is present on all x86 processors.
Real Mode was the first x86 mode design and was used by many early operating systems before the birth of Protected Mode.
For compatibility purposes, all x86 processors begin execution in Real Mode.
——出自:实模式的解释
实模式的特性是一个20位的存储器地址空间(即1MB的可寻址空间),可以直接软件访问BIOS和周边硬件,没有硬件支持的分页机制和实时多任务概念。
——出自《Linux 内核设计的艺术》
x86在上电启动后自动进入实模式,即16位工作模式,这种模式是最早期的8086芯片所使用的工作模式。早期的芯片设计得较简单、工作模式也较简单,所以有诸多限制:
- 最大只能访问1MB的内存:采用段值:偏移的方式访问,内核寄存器最大为16位宽。如段寄存器CS, DS, ES, FS, GS, SS均为16位宽,AX, BX, CX DX, SI, DI, SP等也均为16位宽;
- 所有的操作数最大为16位宽,出栈入栈也以16位为单位;
- 没有任何保护机制,意味着应用程序可以读写内存中的任意位置;
- 没有特权级支持,意味着应用程序可以随意执行任何指令,例如停机指令、关中断指令;
- 没有分页机制和虚拟内存的支持。
这种模式为一种比较原始、粗暴、简单的工作模式。虽然其有这些限制,但是这种模式下可以使用BIOS提供的服务,方便我们显示字符、读取磁盘等。
什么是保护模式
保护模式具体的特点如下:
- 寄存器位宽扩展至32位,例如AX扩展至32位的EAX,最大可访问4GB内存;
- 所有操作数最大为32位宽,出入栈也为32位;
- 提供4种特权级。操作系统可以运行在最高特权级,可执行任意指令;应用程序可运行于最低特权级,避免其执行某些特权指令,例如停机指令、关中断指令;
- 支持虚拟内存,可以开启分页机制,以隔离不同的应用程序
从实模式切换到保护模式
先简略概述一下,要从实模式切换至保护模式,需要遵循以下流程:
禁用中断
打开A20 地址线,为了访问4GB内存
加载GDT表
设置CR0的保护模式使能位
远跳转来清空原来的流水线,取消掉原16位的指令
下面将一一进行解释:
禁用中断直接用cli()
指令就行,打开A20地址线需要:
in al, 0x92
or al, 2
out 0x92, al
进入保护模式的函数如下:
// 进入保护模式
static void enter_protect_mode(void) {
// 关中断
cli();
// 开启A20地址线,使得可访问1M以上空间
// 使用的是Fast A20 Gate方式,见https://wiki.osdev.org/A20#Fast_A20_Gate
uint8_t v = inb(0x92); // 读取92这个端口
outb(0x92, v|0x2); // 回写
// 加载GDT。由于中断已经关掉,IDT不需要加载
// 用lgdt指令进行加载
lgdt((uint32_t)gdt_table, sizeof(gdt_table));
// 打开CR0的保护模式位,进入保护模式
uint32_t cr0 = read_cr0(); // 读取出CR0寄存器的值
write_cr0(cr0 | (1 << 0)); // PE位置为0后回写到CR0寄存器
// 长跳转进入到保护模式
// 使用长跳转,以便清空流水线,将里面的16位代码给清空
// 数字8跟gdt表有关
far_jump(8, (uint32_t)protect_mode_entry);
}
运行lgdt
函数前后使用qemu查看registers的状态,发现gdt表成功写入:
其中cli(),inb(),outb(),lgdt()
都使用内联汇编进行了定义,详情见代码学习,
对CR0进行设置,需要将CR0寄存器的PE位置为1,表示开启保护位:
// 改变CR0的PE位,置为1,打开保护模式
// read_cr0表示读取cr0,使用内联汇编指令将 CR0寄存器 的值移动到 cr0 变量中
static inline uint16_t read_cr0(void) {
uint32_t cr0;
__asm__ __volatile__ ("mov %%cr0, %[v]":[v]"=r"(cr0)); // "=r"表示不指定32位寄存器
return cr0;
}
// write_cr0表示回写cr0
static inline uint16_t write_cr0(uint32_t v) {
__asm__ __volatile__ ("mov %[v], %%cr0"::[v]"=r"(v)); // "=r"表示不指定32位寄存器
}
远跳转的具体实现:第一个参数是选择子,第二个参数是偏移量,这里设置成函数入口地址
static inline void far_jump(uint32_t selector, uint32_t offset) {
uint32_t addr[] = {offset, selector};
__asm__ __volatile__ ("ljmpl *(%[a])" :: [a]"r"(addr));// 从addr取出要跳转的位置进行远跳转
}
远跳转之后再查看寄存器,发现出现DS16,也就是还是16位下的配置,所以还需要把寄存器重新进行设置:
重新设置如下:
// 进入32位保护模式后的代码
.code32
.text
.global protect_mode_entry // 必须加上global
.extern load_kernel
// 保护模式的入口处
protect_mode_entry:
// 重新加载所有的数据段描述符
mov $16, %ax // 16为数据段选择子
mov %ax, %ds
mov %ax, %ss
mov %ax, %es
mov %ax, %fs
mov %ax, %gs
// 长跳转进入到32位内核加载模式中
jmp $8, $load_kernel // 这里的8参考选择子的原因
再次查看,发现是正确的了:
成功进入保护模式!
磁盘读取
之前实模式的时候是用软中断的方式读取磁盘,现在进入保护模式之后,需要重新设置磁盘的读取,用到的是LBA48模式:没有磁盘、柱面的概念,起始序号是0。使用LBA模式读取磁盘的代码如下所示:
static void read_disk(int sector, int sector_count, uint8_t *buf) {
outb(0x1F6, (uint8_t) (0xE0)); // outb是之前已经实现过的,选择硬盘
outb(0x1F2, (uint8_t) (sector_count >> 8)); // >> 8 表示左移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);
}
}
}
其中inw
类似之前的inb
,只不过按照16位的方式读取。
然后使用的时候是这样,从第100个扇区开始,大小是500KB,放到1Mb以上的内存位置,因为是保护模式,可以使用1MB以上的内存空间:
read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);
那么此时磁盘上空间的使用情况如下:
内核工程的创建
内核工程是kernel文件夹,创建相应需要的头文件这些之后,从loader中跳转到kernel中,也就是实现之前所述的二级跳转模式:boot->loader->kernel
,跳转的代码可以直接参考之前boot到loader的跳转:boot引导程序的实现
void load_kernel(void) {
// 从第100个扇区开始,大小是500KB,放到1Mb以上的内存位置,因为是保护模式,可以使用1MB以上的内存空间
read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);
// kernel的跳转代码
((void (*)(void))SYS_KERNEL_LOAD_ADDR)();
for (;;) {}
}
后面将先实现kernel程序(kernel_init),写入刚才分配的磁盘的位置(第100个扇区),然后在loader中跳到0x100000的位置进行运行,那么就实现了loader到kernel的跳转。
向内核传递启动信息
举例
x86的栈的一些前置知识简介(不详细说了):32位系统下栈单元大小是32位,指针esp +4 / -4,esp总是指向最后压入的栈单元,压栈的时候是从高地址往低地址(压栈-4,出栈+4)。
以下面这个函数kernel_init和test函数为例,这是反汇编的结果:
这是它们栈调用时候的示意图:图中“之前的ebp”表示的是在进入被调用的函数test之前需要把之前的ebp进行保存,然后把esp的值传递给ebp(反汇编中显示的 mov %esp, %ebp),那么ebp指向的就是栈顶,也就是下图中的esp2和ebp部分。反汇编语言中的%edx和%eax就是取出参数a和参数b的值:注意ebp的指向,ebp多是作为一个索引的作用(加减偏移量来取传入参数)
执行call指令的时候会将函数调用的返回地址也保存到栈中,先保存ebp,然后重新设置esp,之后的栈空间使用的是被调用的函数使用的栈空间:
实操
**将loader中读取到的相关配置信息(比如内存容量信息的结构体 boot_info)传递给内核,**采用的方式是用函数调用的方式(把boot_info作为参数进行传递)。
传入启动信息参数boot_info
,下面这是kernel的start.S
:
_start:
// 根据栈的特点
// mov 4(%esp), %eax
// push %eax
// 相当于 kernel_init(boot_info)
call kernel_init
可以认为_start
是这个函数:void start (boot_info_t * boot_info)
,这是因为load_kernel
中:((void (*)(boot_info_t *))SYS_KERNEL_LOAD_ADDR)(& boot_info)
,是把参数传递给 SYS_KERNEL_LOAD_ADDR
这个地址,而这个地址就是(1024*1024)内核加载的起始地址(_start),详细地说就是之前在kernel的cmakelist中定义了set(CMAKE_EXE_LINKER_FLAGS "-m elf_i386 -Ttext=0x100000")
和add_executable(${PROJECT_NAME} init/start.S ${C_LIST})
,保证了_start
会先被处理,也就是在0x100000的位置处:
load_kernel
的代码如下:
// 从磁盘上加载内核
void load_kernel(void) {
read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);
// kernel的跳转代码
// 用参数传递的方式将loader中读取到的相关配置信息传递给内核
((void (*)(boot_info_t *))SYS_KERNEL_LOAD_ADDR)(& boot_info);
for (;;) {}
}
由于void start (boot_info_t * boot_info)
可以视作一个函数,参考上面的test函数的例子,要想取得参数boot_info的信息,也就是类似上面的 test函数 中取得参数a和b的方式:
可以先保存ebp(push %ebp)当然也可以不用保存,因为这里不需要再返回 _start 去运行,要取出void start (boot_info_t * boot_info)
的参数boot_info
可以这么做,就是把需要的参数boot_info
放在了寄存器eax中:
push %ebp
mov %esp, %ebp
mov 0x8(%ebp), %eax
再把eax入栈,然后类似调用test函数时这里的做法,把需要的参数先入栈再调用kernel_init
:相当于 kernel_init(boot_info),成功将boot_info参数传入kernel_init函数
总体代码如下:
_start:
# 第一种方法
push %ebp
mov %esp, %ebp
mov 0x8(%ebp), %eax
# 相当于 kernel_init(boot_info)
push %eax
call kernel_init
jmp .
总体流程图如下:
验证:
这是loader
的符号表,boot_info信息在0xa060处:
取出 load kernel
(start) 传递过来的参数,再通过栈传递给kernel init
:此时eax正确指向了boot_info的位置,说明启动信息传递给eax成功:
在调试的时候可以在kernel_init的传入参数处观察到启动信息成功传入内核:
代码/数据段与链接脚本
build中的elf文件展示了内存地址范围,如下:
Section Headers:
[Nr] Name Type Addr Off Size ES Flg Lk Inf Al
[ 0] NULL 00000000 000000 000000 00 0 0 0
[ 1] .text PROGBITS 00008000 001000 000507 00 AX 0 0 1
[ 2] .rodata PROGBITS 00009000 002000 000039 00 A 0 0 1
[ 3] .data PROGBITS 0000a03c 00203c 000018 00 WA 0 0 4
[ 4] .bss NOBITS 0000a060 002054 000054 00 WA 0 0 32
[ 5] .comment PROGBITS 00000000 002054 00002b 01 MS 0 0 1
具体而言,我们知道GCC工具链默认按.text,.rodata,.data,.bss
存储代码和数据,并分析得出以下结论
- .
text
: 存储机器指令和代码; .rodata
: 存储常量以及字符串本身;.data
: 存储初始化的数据,全局的或者static静态的;.bss
: 存储未初始化的数据 (即初始化为0),全局的或者静态的stack: 存储局部变量和函数调用中返回地址等
相同类型的段会自动进行合并。
也可以自己定义链接脚本(lds文件):
SECTIONS
{
. = 0x100000; // 起始地址
.text : {
*(.text) // 将所有的目标文件的.text文件都放到一个.text文件中,下同
}
.rodata : {
*(.rodata)
}
.data : {
*(.data)
}
.bss : {
*(.bss)
}
}
注意要修改CmakeLists.txt的配置,改成自定义的链接脚本:
# 使用自定义的链接器 -Ttext=0x100000
set(CMAKE_EXE_LINKER_FLAGS "-m elf_i386 -Ttext ${PROJECT_SOURCE_DIR}/kernel.lds")
也可以把kernel.lds
中的起始地址修改成别的地址,可以用链接脚本进行精细的控制。
加载内核映像文件
elf文件的文件存储示例:通过program header 找到text这些,然后用p_offset来寻址,定位到内存中的位置;通过 program header 的 p_paddr 来找到右边内存的这个位置,然后进行文件的拷贝,拷贝的大小是p_filesz,实际写入内存中的数据会比elf文件中更大
删去之前kernel的CmakeLists.txt 中的-O binary
(二进制形式编译)之后,之前的kernel文件变成了kernel.elf文件格式,就不能像之前直接从loader跳转到1M地址处进行运行,而需要对kernel.elf
进行解析,从中提取需要的信息(有效数据和代码)。所以考虑新的存储规划如下所示:在0x100000处存放的是kernel.elf文件的信息,而解析出的信息则放到64k(0x10000)地址处:
相应的需要改变kernel.lds的信息,改成0x10000:. = 0x10000;
。为了获取kernel的入口地址,选择读取kernel.elf
文件的配置项:主要用到了elf文档定义的结构体和 elf的 program header 的表项,然后解析elf文件,提取内容到相应的内存中:
static uint32_t reload_elf_file (uint8_t * file_buffer) {
// 读取的只是ELF文件,不像BIN那样可直接运行,需要从中加载出有效数据和代码
// 简单判断是否是合法的ELF文件,用 e_ident进行检查,看最后是不是 E L F
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;
}
// 然后从中加载程序头,将内容拷贝到相应的位置,提取相应的数据
// elf_hdr->e_phnum是表项数量,相当于 program header 0 和 program header 1...
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++) { // p_filesz是要拷贝的文件的大小
*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;
}
读取到elf文件的信息中程序的入口地址后,就可以用这个入口地址,将之前loader中的配置信息(boot_info)传入kernel_entry入口地址中,而不是粗暴地使用0x10000这样的地址:
// 解析ELF文件,并通过调用的方式,进入到内核中去执行,同时传递boot参数
// 临时将elf文件先读到SYS_KERNEL_LOAD_ADDR处(这里是1M地址处),再进行解析得到kernel_entry
uint32_t kernel_entry = reload_elf_file((uint8_t *)SYS_KERNEL_LOAD_ADDR);
if (kernel_entry == 0) {
die(-1);
}
// kernel的跳转代码
// 用参数传递的方式将loader中读取到的相关配置信息传递给内核
((void (*)(boot_info_t *))kernel_entry)(& boot_info);
内联汇编代码的简单学习
内联汇编的格式
以一个典型的内联汇编代码为例进行说明:
static inline uint8_t inb(uint16_t port) {
uint8_t rv;
// inb al, dx
__asm__ __volatile__("inb %[p], %[v]" : [v]"=a" (rv) : [p]"d"(port));
return rv;
}
inline
是为了方便在头文件中进行使用,传入的是uint16_t
的端口号,它从指定的输入/输出(I/O)端口读取一个字节,并将读取的值作为无符号的 8 位整数返回。
__asm__
关键字:使用了内联汇编代码,用于执行 inb 指令,用于将指定的 I/O 端口中的一个字节读入到 al 寄存器中。
__volatile__
关键字:告诉编译器不要优化或重新排列汇编代码。
占位符 “%[p]” 和 “%[v]” 分别表示 inb 指令的端口和值操作数,并使用 :
语法指定为输入和输出操作数,而::
语法表示指定为只写操作数(output-only operands)。
port 参数作为输入操作数使用 %[p] 占位符传递给汇编代码,而 rv 变量则使用 %[v] 占位符作为输出操作数传递。为 rv 变量指定的 a 约束表示它应该存储在 al 寄存器中。为port变量指定的 "d"是一个输入操作数的约束(constraint),它告诉编译器将 port 参数传递给指令的dx寄存器。
所以上面的内联汇编代码表示的是实现汇编语言:inb al, dx
。
再来看一个例子:
::[v]"=r"(v)
这个语法可以看作是一个模板,其中包含了输入操作数、输出操作数和只读/只写操作数的约束信息。下面是这个语法的详细解释:
::
:这个语法中的双冒号表示这是一个只写操作数的指令,即不需要读取任何寄存器或内存中的值,只需要将一个值写入到指定的寄存器或内存中。- [v]:这个语法中的方括号包含了操作数的名称,这里的名称为 v。名称可以任意取,只要在汇编代码中使用相同的名称即可。
- “=r”:这个语法中的等号表示这是一个输出操作数,即将 C 语言中的变量值写入到指定的寄存器或内存中。这个语法中的 r 表示输出操作数应该使用一个通用寄存器来存储,具体使用哪个寄存器取决于编译器的分配策略。
- (v):这个语法中的括号包含了变量的名称,这里的名称也为 v(这里是函数的形参)。这个变量应该是一个 C 语言中的变量,用于存储将要写入到寄存器或内存中的值。
因此,::[v]“=r”(v) 表示这是一个只写操作数的指令,将 C 语言中的变量 v 的值写入到一个通用寄存器中,具体使用哪个寄存器由编译器决定。在汇编代码中,使用 %[v] 占位符表示这个寄存器的名称。
常见的对某一个位 置1的操作
比如cr0 | (1 << 0)
,其中(1 << 0)
表示位移操作,用于将二进制数 1 向左移动 0 位。
在位运算中,<< 符号表示左移操作,它将一个二进制数向左移动指定的位数,左边的空位用 0 填充。例如,1 << 1 的结果是二进制数 10,1 << 2 的结果是二进制数 100,以此类推。
那么cr0 | (1 << 0)
也就是 按位或(OR)操作,用于将控制寄存器 CR0 的最低位置为 1。
参考
和之前这个系列的博客的参考文档一致,后续不再列出参考链接