显示字符串和内存检测

显示字符串

显示字符串其实就是反复调用BIOS显示字符风方式来显示一个完整的字符串,用于loader加载程序在初始化过程中显示进度、错误信息等。

具体代码采用内联汇编的形式,在C代码中嵌入汇编语句,使用__asm__关键字,加上__volatile__关键字避免编译器优化内联汇编语句(和C++非常像)

1
2
3
4
5
6
7
8
9
10
11
static void show_message(const char* message) {
char c;
while((c=*message++)!='\0') { //在没有遇到结束符'\0'前循环显示指定字符
//在c中使用汇编指令需要采取内联汇编的形式
__asm__ __volatile__( //使用volatile关键字避免编译器对汇编语句的优化
"mov $0xe, %%ah\n\t"
"mov %[ch], %%al\n\t"
"int $0x10"::[ch]"r"(c)
);
}
}

内存容量检测

使用的方法为INT 0x15, EAX = 0xE820

具体原理不是关注重点,因此直接忽略,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
static void detect_memory(void) {  //检测内存容量,应该有128M
uint32_t contID=0;
uint32_t signature, bytes;
SMAP_entry_t smap_entry;
show_message("try to detect memory:");

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_message("failed\r\n");
return;
}

if (bytes > 20 && (entry->ACPI & 0x0001) == 0) {
// ignore this entry
continue;
}

if(entry->Type == 1) { //ram中有一部分内存被硬件或其他设备占用,因此需要判断类型,type为1可以被操作系统使用
boot_info.ram_region_cfg[boot_info.ram_region_count].start=entry->BaseL; //操作系统为32位操作系统,只需要读取低32位
boot_info.ram_region_cfg[boot_info.ram_region_count].size=entry->LengthL; //同样只读低32位
boot_info.ram_region_count++;
}

if(contID == 0) { //如果contID为0,说明整个读取已经结束
break;
}
}
show_message("detect finish!\r\n");

}

切换保护模式

实模式

1

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

  1. 最大只能访问1MB的内存:采用段值:偏移的方式访问,内核寄存器最大为16位宽。如段寄存器CS, DS, ES, FS, GS, SS均为16位宽,AX, BX, CX DX, SI, DI, SP等也均为16位宽
  2. 所有的操作数最大为16位宽,出栈入栈也以16位为单位
  3. 没有任何保护机制,意味着应用程序可以读写内存中的任意位置
  4. 没有特权级支持,意味着应用程序可以随意执行任何指令,例如停机指令、关中断指令
  5. 没有分页机制和虚拟内存的支持

保护模式

2在后续的芯片设计中,intel为处理器增加了一些新的功能,可以实现某些保护功能,即保护模式。具体的特点如下:

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

切换至保护模式

3要切换至保护模式,需要遵循以下流程。

  • 禁用中断,防止中途发生中断导致程序运行发生异常
  • 打开A20 地址线(为了保证后续的cpu可以兼容以前的程序运行:在早期的 IBM PC 中,地址线 A20 被硬连线到逻辑 0,导致 CPU 无法访问超过 1MB 的内存(2^20 = 1MB)。这是为了与旧版软件兼容。当切换到保护模式时,我们需要启用 A20 地址线,以便访问完整的 4GB 地址空间。)
  • 加载GDT表(GDT 是保护模式下的核心数据结构,定义了内存段的访问权限和基址。)
  • 设置CR0,开启保护模式使能位(也就是PE位)
  • 远跳转,清空流水线(当我们将 CR0 寄存器的 PE 位置 1 后,CPU 立即进入保护模式,但此时的指令流水线中仍然是按照实模式解释的指令。如果不清除这些指令,会导致严重的错误。)

函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static void enter_protect_mode(void) {  //进入保护模式
cli(); //关中断

//启用A20地址线
uint8_t v=inb(0x92);
outb(0x92, v | 0x2); //将读取的值与 0x2 进行按位或操作,设置第 2 位

lgdt((uint32_t)gdt_table, sizeof(gdt_table)); //加载GDT表

uint32_t cr0 = read_cr0(); //将CR0寄存器的PE位置为1,开启保护模式使能位
write_cr0(cr0 | (1<<0)); //1<<0:生成值 0x00000001,与CR0或,将第0位设置为1

//清空流水线
far_jump(8, (uint32_t)protect_mode_entry);

}

使用lgdt进行GDT表加载时,需要注意qemu上的gdt表是否成功写入,以及gdt_table的地址与qemu上的registers地址是否一致

3

3

磁盘读取

loader还需要从硬盘中读取数据(如解析文件系统,加载操作系统内核等)加载到内存中执行

在实模式是,是采用软中断的方式读取磁盘,进入保护模式之后就需要重新设置磁盘读取的方式。这里采用的是LBA48模式

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
static void read_disk(uint32_t sector, int sector_count, uint8_t * buf) {
//设置设备选择寄存器0x1F6
outb(0x1F6, 0xE0); //选择硬盘:主盘或从盘
//0x1F6寄存器每位设置,1110 0000
// - 位7: 1(固定为1,表示启用LBA模式)
// - 位6: 1(LBA模式标志位)
// - 位5: 1(固定为1,可能用于兼容性)
// - 位4: 0(选择主盘Master,若为1则选择从盘Slave)
// - 位3-0: 0(LBA地址的高4位,此处为0)


//发送扇区数量的高八位
outb(0x1F2, (uint8_t)(sector_count >> 8));
//发送LBA地址的高4字节(分四次发送,每次一字节)
outb(0x1F3, (uint8_t)(sector >> 24));
outb(0x1F4, 0);
outb(0x1F5, 0);

// 发送扇区数量的低8位和完整LBA地址(覆盖之前的部分设置)
outb(0x1F2, (uint8_t)sector_count);
outb(0x1F3, (uint8_t)sector);
outb(0x1F4, (uint8_t)(sector >> 8));
outb(0x1F5, (uint8_t)(sector >> 16));

// 发送读取命令(0x24 = READ SECTORS EXT,扩展读取命令)
outb(0x1F7, 0x24);

uint16_t * data_buf = (uint16_t *)buf;

while(sector_count--) {
// 等待硬盘就绪(轮询状态寄存器0x1F7)
// 状态寄存器的位7=1表示硬盘忙,位3=1表示数据就绪
// 当状态为0x08(即0000 1000)时,表示数据就绪且不忙
while((inb(0x1F7) & 0x88) != 0x8) {}

// 读取当前扇区数据(每扇区通常为512字节 = 256个16位字)
for(int i=0 ; i<SECTOR_SIZE / 2 ; i++) {
*data_buf++=inw(0x1F0);
}
}

}

这里的outb、intb、inw都是由内联汇编定义的函数,分别是按照8位写数据、读数据,还有按照16位读数据,具体就不赘述了。

完成这些步骤后磁盘的空间使用情况如下:

(实际上,上述位置的确定并不唯一,可自行选择合适的地址,只要保证loader能够正确加载即可。
可以看到,在第100扇区之前预留了比较大的空间,目的是以后loader代码量增大时,有足够的空间存放,不必再临时调整kernel的位置。)

image0330

完成以上步骤后,loader加载程序就大体实现了,后面就需要将从loader中获取的启动信息(如内存容量)传递给内核。

内核工程的创建

创建kernel文件夹与init子文件夹,创建好相应的头文件等,然后从loader跳转到kernel工程中来,将kernel代码放在第100扇区的位置,然后在loader中跳转到0x100000的位置。

1
2
3
4
5
6
7
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_init函数,并且传入启动信息,这里涉及到了一些调用函数的函数栈的变化。

举一个例子:

1
2
3
4
5
6
7
8
void kernel_init(boot_info_t * boot_info) {
int a = 1, b = 2;
test(a, b);
for(;;) {}
}
int test (int a, int b) {
return a + b;
}

在32位系统下,调用上述函数时,就会是这样的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
void kernel_init(boot_info_t *boot_info) {
// 1. 函数开头:建立栈帧
push %ebp // 保存调用者(父函数)的 ebp
mov %esp, %ebp // 让 kernel_init 的 ebp 指向当前栈顶(刚压入的旧 ebp 位置)。
sub $0x10, %esp // 分配 16 字节空间给 kernel_init 的局部变量(a、b 等)。

/*栈帧状态:
栈底(高地址) → 旧ebp(父函数的ebp)
返回地址(父函数调用kernel_init的返回地址,图里未画全)
局部变量空间(16字节,未初始化)
栈顶(低地址) ← kernel_init的esp(sub $0x10后,esp指向这里)
*/

// 2. 定义局部变量
int a = 1, b = 2;
// 对应汇编:
// movl $0x1, -0x4(%ebp) → a = 1(存在 ebp-4)
// movl $0x2, -0x8(%ebp) → b = 2(存在 ebp-8)
/*栈帧状态:
栈底 → 旧ebp(父函数)
返回地址(父函数)
局部变量a=1(ebp-4)
局部变量b=2(ebp-8)
剩余局部变量空间(8字节,图里简化了)
栈顶 ← kernel_init的esp
*/

// 3. 调用 test(a, b)
test(a, b);
// 对应汇编:
// push -0x8(%ebp) → 压入 b(从右往左传参)
// push -0x4(%ebp) → 压入 a
// call 1000c <test> → 压入返回地址
/*栈帧状态:
栈底 → 旧ebp(父函数)
返回地址(父函数)
局部变量a=1
局部变量b=2
参数b=2(刚压入的)
参数a=1(刚压入的)
返回地址(kernel_init → test的返回地址)
栈顶 ← kernel_init的esp(call后,esp指向这里)
*/

// 4. 死循环(省略细节)
for (;;) {}
}

int test(int a, int b) {
// 1. 函数开头:建立栈帧
push %ebp // 保存旧ebp(kernel_init的ebp)
mov %esp, %ebp // 新ebp = 旧esp

// 2. 计算返回值 a + b
return a + b;
// 对应汇编:
// mov 0x8(%ebp), %edx → a = ebp+8
// mov 0xc(%ebp), %eax → b = ebp+c
// add %edx, %eax → 返回值 = a + b

// 3. 函数结尾:销毁栈帧
pop %ebp // 恢复旧ebp(kernel_init的ebp)
ret // 弹出返回地址,回到kernel_init

/*test栈帧状态:
栈底 → 旧ebp(父函数)
返回地址(父函数)
局部变量a=1
局部变量b=2
参数b=2
参数a=1
返回地址(kernel_init → test)
旧ebp(kernel_init的ebp) → test的ebp指向这里!
栈顶 ← test的esp(push %ebp后,esp指向这里)
*/
}

实操

将以上栈帧变化反映到项目中就是将loader中读取到的相关配置信息(比如存储内存容量信息的结构体boot_info)传递给内核,采取的就是调用函数的方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
_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


/*
流程相当于:
从start的栈中取出参数boot_info并放入eax寄存器中 --> eax寄存器入栈 --> 执行call指令(调用函数)
*/

代码/数据段与链接脚本

GCC工具链默认以.text, .rodata, .data, .bss存储代码和数据。具体来说是:

.text: 存储编译后可执行代码(机器指令),如函数体、条件判断、循环逻辑等

.rodata: 存储程序中声明的只读数据

.data: 存储程序中已初始化的全局变量和静态变量

.bss: 存储程序中未初始化的全局变量和静态变量(初始化为0)

也可以自定义链接脚本(lds文件),此时的Cmakelist.txt的配置需要修改成自定义的链接脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
SECTIONS
{
. = 0x100000; // 起始地址,这里可以修改为别的地址
.text : {
*(.text) // 将所有的目标文件的.text文件都放到一个.text文件中,下同
}
.rodata : {
*(.rodata)
}
.data : {
*(.data)
}
.bss : {
*(.bss)
}
}

加载内核映像文件

linux的可执行文件通常为.elf结尾的文件格式,该文件中包含了上面提到的.text, .rodata, .data, .bss隔断的信息,通常解析该文件,找到program header table,并从该表中读取出相应的代码、数据段等相关信息,并将代码和数据加载到对应的内存中,完成整个加载过程。

文件具体的格式就不需要很细致的了解了,具体的加载过程如下:

  • 初步检查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,跳转到该地址运行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static uint32_t reload_elf_file (uint8_t * file_buffer) {
Elf32_Ehdr * elf_hdr = (Elf32_Ehdr *)file_buffer;
/*检查传入的文件是否是elf文件*/
if ((elf_hdr->e_ident[0] != 0x7F) || (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 * dst = file_buffer + phdr->p_paddr; //目的地址

// 复制操作,传输text和rodata段是没问题的,但是在elf文件中data和bss段是存放在一起的,没办法通过这种方式读取
// 并且bss段存放未初始化的数据,也就是全零,只需要知道全零的区域有多大就够了
for(int j = 0; j<phdr->p_filesz; j++) {
*dst++ = *src++;
}

/*获取结束地址*/
dst = (uint8_t *)phdr->p_paddr + phdr->p_filesz;

// memsz和filesz不相等时,后续要填0
for(int j = 0; j < phdr->p_memsz - phdr->p_filesz; j++) {
*dst++ = 0;
}

return elf_hdr->e_entry;
}
}

/*最终从磁盘上加载内核的代码如下*/
void load_kernel(void) {
read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR);

/*用于解析在1Mb内存处存放的elf文件,提取代码和数据,放在64Kb(0x10000)开始的位置*/
reload_elf_file((uint8_t *)SYS_KERNEL_LOAD_ADDR);
/*跳转到kernel入口处的地址,与1Mb地址0x100000注意区分,那里是放置kernel.lef文件的地址*/
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);
for (;;) {}
}