显示字符串和内存检测
显示字符串
显示字符串其实就是反复调用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') { __asm__ __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) { 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) { continue; }
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_message("detect finish!\r\n");
}
|
切换保护模式
实模式

x86在上电启动后自动进入实模式,即16位工作模式,这种模式是最早期的8086芯片所使用的工作模式。早期的芯片设计得较简单、工作模式也较简单,所以有诸多限制:
- 最大只能访问1MB的内存:采用段值:偏移的方式访问,内核寄存器最大为16位宽。如段寄存器CS, DS, ES, FS, GS, SS均为16位宽,AX, BX, CX DX, SI, DI, SP等也均为16位宽
- 所有的操作数最大为16位宽,出栈入栈也以16位为单位
- 没有任何保护机制,意味着应用程序可以读写内存中的任意位置
- 没有特权级支持,意味着应用程序可以随意执行任何指令,例如停机指令、关中断指令
- 没有分页机制和虚拟内存的支持
保护模式
在后续的芯片设计中,intel为处理器增加了一些新的功能,可以实现某些保护功能,即保护模式。具体的特点如下:
- 寄存器位宽扩展至==32位==,例如AX扩展至32位的EAX,最大可访问4GB内存
- 所有操作数最大为32位宽,出入栈也为32位
- 提供4种特权级。==操作系统可以运行在最高特权级,可执行任意指令;应用程序可运行于最低特权级,避免其执行某些特权指令,例如停机指令、关中断指令==
- ==支持虚拟内存,可以开启分页机制==,以隔离不同的应用程序
切换至保护模式
要切换至保护模式,需要遵循以下流程。
- 禁用中断,防止中途发生中断导致程序运行发生异常
- 打开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();
uint8_t v=inb(0x92); outb(0x92, v | 0x2);
lgdt((uint32_t)gdt_table, sizeof(gdt_table));
uint32_t cr0 = read_cr0(); write_cr0(cr0 | (1<<0));
far_jump(8, (uint32_t)protect_mode_entry);
}
|
使用lgdt进行GDT表加载时,需要注意qemu上的gdt表是否成功写入,以及gdt_table的地址与qemu上的registers地址是否一致


磁盘读取
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) { outb(0x1F6, 0xE0);
outb(0x1F2, (uint8_t)(sector_count >> 8)); outb(0x1F3, (uint8_t)(sector >> 24)); outb(0x1F4, 0); outb(0x1F5, 0);
outb(0x1F2, (uint8_t)sector_count); outb(0x1F3, (uint8_t)sector); outb(0x1F4, (uint8_t)(sector >> 8)); outb(0x1F5, (uint8_t)(sector >> 16));
outb(0x1F7, 0x24);
uint16_t * data_buf = (uint16_t *)buf;
while(sector_count--) { while((inb(0x1F7) & 0x88) != 0x8) {}
for(int i=0 ; i<SECTOR_SIZE / 2 ; i++) { *data_buf++=inw(0x1F0); } }
}
|
这里的outb、intb、inw
都是由内联汇编定义的函数,分别是按照8位写数据、读数据,还有按照16位读数据,具体就不赘述了。
完成这些步骤后磁盘的空间使用情况如下:
(实际上,上述位置的确定并不唯一,可自行选择合适的地址,只要保证loader能够正确加载即可。
可以看到,在第100扇区之前预留了比较大的空间,目的是以后loader代码量增大时,有足够的空间存放,不必再临时调整kernel的位置。)

完成以上步骤后,loader加载程序就大体实现了,后面就需要将从loader中获取的启动信息(如内存容量)传递给内核。
内核工程的创建
创建kernel文件夹与init子文件夹,创建好相应的头文件等,然后从loader跳转到kernel工程中来,将kernel代码放在第100扇区的位置,然后在loader中跳转到0x100000的位置。
1 2 3 4 5 6 7
| void load_kernel(void) { read_disk(100, 500, (uint8_t *)SYS_KERNEL_LOAD_ADDR); ((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) { push %ebp mov %esp, %ebp sub $0x10, %esp
int a = 1, b = 2;
test(a, b);
for (;;) {} }
int test(int a, int b) { push %ebp mov %esp, %ebp return a + b; pop %ebp ret
}
|
实操
将以上栈帧变化反映到项目中就是将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; 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;
for(int j = 0; j<phdr->p_filesz; j++) { *dst++ = *src++; }
dst = (uint8_t *)phdr->p_paddr + phdr->p_filesz; 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);
reload_elf_file((uint8_t *)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); for (;;) {} }
|