更好的阅读体验,请点击 YinKai 's Blog | 实现一个最简单的内核。
? 这篇文章带大家实现一个最简单的操作系统内核—— Hello OS。
? 我们这里将借助 Ubuntu Linux 操纵系统上的 GRUB 引导程序来引导我们的 Hello OS。
? 首先我们得了解一下,Hello OS 的引导流程:
? 简单解释一下,PC 机 BIOS 固件是固化在 PC 机主板上的 ROM 芯片中的,掉电也能保存,PC 机上电后的第一条指令就是 BIOS 固件中的,它负责检测和初始化 CPU、内存及主板平台,然后加载引导设备(大概率是硬盘)中的第一个扇区数据,到 0x7c00 地址开始的内存空间,再接着跳转到 0x7c00 处执行指令,在我们这里的情况下就是 GRUB 引导程序。
? 我们的 Hello OS 总有 6 个文件,下面一一讲解。
#include "vgastr.h"
void main()
{
printf("Hello OS! I am YinKai");
return;
}
; 多引导协议头(GRUB)
MBT_HDR_FLAGS EQU 0x00010003
MBT_HDR_MAGIC EQU 0x1BADB002 ; 多引导协议头魔数
MBT_HDR2_MAGIC EQU 0xe85250d6 ; 第二版多引导协议头魔数
global _start ; 导出 _start 符号
extern main ; 导入外部的 main 函数符号
[section .start.text] ; 定义 .start.text 代码节
[bits 32] ; 汇编成32位代码
_start:
jmp _entry
ALIGN 8
; GRUB 所需的多引导协议头
mbt_hdr:
dd MBT_HDR_MAGIC
dd MBT_HDR_FLAGS
dd -(MBT_HDR_MAGIC+MBT_HDR_FLAGS)
dd mbt_hdr
dd _start
dd 0
dd 0
dd _entry
ALIGN 8
; GRUB2 所需的多引导协议头
mbt2_hdr:
DD MBT_HDR2_MAGIC
DD 0
DD mbt2_hdr_end - mbt2_hdr
DD -(MBT_HDR2_MAGIC + 0 + (mbt2_hdr_end - mbt2_hdr))
DW 2, 0
DD 24
DD mbt2_hdr
DD _start
DD 0
DD 0
DW 3, 0
DD 12
DD _entry
DD 0
DW 0, 0
DD 8
mbt2_hdr_end:
ALIGN 8
_entry:
; 关中断
cli
; 关不可屏蔽中断
in al, 0x70
or al, 0x80
out 0x70, al
; 重新加载GDT
lgdt [GDT_PTR]
jmp dword 0x8 :_32bits_mode
_32bits_mode:
; 初始化C语言可能会用到的寄存器
mov ax, 0x10
mov ds, ax
mov ss, ax
mov es, ax
mov fs, ax
mov gs, ax
xor eax,eax
xor ebx,ebx
xor ecx,ecx
xor edx,edx
xor edi,edi
xor esi,esi
xor ebp,ebp
xor esp,esp
; 初始化栈,C语言需要栈才能工作
mov esp,0x9000
; 调用C语言函数main
call main
; 让CPU停止执行指令
halt_step:
halt
jmp halt_step
; GDT 全局描述符表
GDT_START:
knull_dsc: dq 0
kcode_dsc: dq 0x00cf9e000000ffff
kdata_dsc: dq 0x00cf92000000ffff
k16cd_dsc: dq 0x00009e000000ffff
k16da_dsc: dq 0x000092000000ffff
GDT_END:
GDT_PTR:
GDTLEN dw GDT_END-GDT_START-1
GDTBASE dd GDT_START
? 这是一个引导加载程序,它是计算机启动过程中的第一个软件,它的主要任务是在计算机启动时,通过 GRUB 或 GRUB2 多引导协议头,初始化系统环境,设置 GDT,然后调用 C 语言的 main
函数。
? 首先我们得知道显卡的字符模式的工作细节。
? 它把屏幕分成 24 行,每行 80 个字符,把这(24*80)个位置映射到以 0xb8000 地址开始的内存中,每两个字节对应一个字符,其中一个字节是字符的 ASCII 码,另一个字节为字符的颜色值。如下图所示:
? 了解原理之后,我们来自己实现 printf 函数:
void _strwrite(char* string)
{
char* p_strdst = (char*)(0xb8000);
while (*string)
{
*p_strdst = *string++;
p_strdst += 2;
}
return;
}
void printf(char* fmt, ...)
{
_strwrite(fmt);
return;
}
? 代码很简单,我们在 printf 把传入的字符串作为参数,传给 _strwrite 函数,然后把字符串中的每个字符依次写入 0xb8000 地址开始的显存中。p_strdst 每次加 2 ,是为了跳过表示颜色值的字符,直接指向下一个字符的 ASCII 值。
? 为了编译器能够正确识别我们的函数,我们还需要另写一个文件,保证函数调用的正确性。
void _strwrite(char* string);
void printf(char* fmt, ...);
ENTRY(_start)
OUTPUT_ARCH(i386)
OUTPUT_FORMAT(elf32-i386)
SECTIONS
{
. = 0x200000;
__begin_start_text = .;
.start.text : ALIGN(4) { *(.start.text) }
__end_start_text = .;
__begin_text = .;
.text : ALIGN(4) { *(.text) }
__end_text = .;
__begin_data = .;
.data : ALIGN(4) { *(.data) }
__end_data = .;
__begin_rodata = .;
.rodata : ALIGN(4) { *(.rodata) *(.rodata.*) }
__end_rodata = .;
__begin_kstrtab = .;
.kstrtab : ALIGN(4) { *(.kstrtab) }
__end_kstrtab = .;
__begin_bss = .;
.bss : ALIGN(4) { *(.bss) }
__end_bss = .;
}
? 这段代码是一个链接脚本,用于告诉链接器如何将各个目标文件组合成最终的可执行文件。
? 我们这里使用 make 工具进行系统编译,将每个代码模块编译最后链接成可执行的二进制文件。
MAKEFLAGS = -sR
MKDIR = mkdir
RMDIR = rmdir
CP = cp
CD = cd
DD = dd
RM = rm
ASM = nasm
CC = gcc
LD = ld
OBJCOPY = objcopy
ASMBFLAGS = -f elf -w-orphan-labels
CFLAGS = -c -Os -std=c99 -m32 -Wall -Wshadow -W -Wconversion -Wno-sign-conversion -fno-stack-protector -fomit-frame-pointer -fno-builtin -fno-common -ffreestanding -Wno-unused-parameter -Wunused-variable
LDFLAGS = -s -static -T hello.lds -n -Map HelloOS.map
OJCYFLAGS = -S -O binary
HELLOOS_OBJS :=
HELLOOS_OBJS += entry.o main.o vgastr.o
HELLOOS_ELF = HelloOS.elf
HELLOOS_BIN = HelloOS.bin
.PHONY : build clean all link bin
all: clean build link bin
clean:
$(RM) -f *.o *.bin *.elf
build: $(HELLOOS_OBJS)
link: $(HELLOOS_ELF)
$(HELLOOS_ELF): $(HELLOOS_OBJS)
$(LD) $(LDFLAGS) -o $@ $(HELLOOS_OBJS)
bin: $(HELLOOS_BIN)
$(HELLOOS_BIN): $(HELLOOS_ELF)
$(OBJCOPY) $(OJCYFLAGS) $< $@
%.o : %.asm
$(ASM) $(ASMBFLAGS) -o $@ $<
%.o : %.c
$(CC) $(CFLAGS) -o $@ $<
? 不同的系统,可能操作不同,我这里用的是 ubuntu。
sudo apt-get install nasm
sudo apt install build-essential
? 修改启动项等待时间,以供我们选择启动项文件
? sudo vim /etc/default/grub
,打开文件,修改为 10 s
? 使用 sudo update-grub
更新我们的修改。
:::warning
? 每次使用这个命令之后,我们追加的启动项(后面会说到)就会被清除,需要重新添加。
:::
? 在自己的家目录下创建一个 HelloOS 文件夹,放入我们依赖的 6 个文件,代码及文件命名见上。
? 在 HelloOS 目录下,使用 make 命令,即可获得 HelloOS.bin 文件,并将该文件移动到 /boot/
目录下。(如果原本就有要将其删除,再放入。)
? 使用 df /boot
获取文件系统名,以及文件系统的挂载点,我的如下:
文件系统 1K-块 已用 可用 已用% 挂载点
/dev/sda5 19947120 9921616 8986912 53% /
? 写 grub 的引导文件,将下面的启动项代码插入到 /boot/grub/grub.cfg
文件末尾
menuentry 'HelloOS' {
insmod part_msdos #GRUB加载分区模块识别分区
insmod ext2 #GRUB加载ext文件系统模块识别ext文件系统
set root='hd0,msdos5' #注意boot目录挂载的分区,这是我机器上的情况
multiboot2 /boot/HelloOS.bin #GRUB以multiboot2协议加载HelloOS.bin
boot #GRUB启动HelloOS.bin
}
:::warning
① 这里的 hd0,msdos?
需要根据 (4)中的 /dev/sda?
对应起来;
② 如果挂载点是 / 就需要在文件中写 /boot/HelloOS.bin
;如果挂载点是 /boot
,则直接写 /HelloOS.bin
即可
③ 如果该文件不可修改,可以用 root 权限修改该文件为可写文件。
:::
? 最后使用 reboot
命令,即可重启系统,看到我们的 Hello OS 选项:
? 选择后,即可看到我们在主函数 main.c 中写的字符串啦~
Hello OS 启动的流程主要包括以下步骤:
entry.asm
中,负责初始化系统环境。_start
调用 _32bits_mode
将处理器切换到 32 位保护模式。main.c
包含操作系统的主要逻辑,调用了输出字符串的函数。vgastr.c
中的 _strwrite
和 printf
负责向屏幕输出字符串。halt
指令让 CPU 停止执行,操作系统启动过程结束。