更好的阅读体验,请点击 YinKai 's Blog | 实现一个最简单的内核。
这篇文章带大家实现一个最简单的操作系统内核—— Hello OS。
PC 机的引导流程
我们这里将借助 Ubuntu Linux 操纵系统上的 GRUB 引导程序来引导我们的 Hello OS。
首先我们得了解一下,Hello OS 的引导流程:
简单解释一下,PC 机 BIOS 固件是固化在 PC 机主板上的 ROM 芯片中的,掉电也能保存,PC 机上电后的第一条指令就是 BIOS 固件中的,它负责检测和初始化 CPU、内存及主板平台,然后加载引导设备(大概率是硬盘)中的第一个扇区数据,到 0x7c00 地址开始的内存空间,再接着跳转到 0x7c00 处执行指令,在我们这里的情况下就是 GRUB 引导程序。
Hello OS 引导汇编代码
我们的 Hello OS 总有 6 个文件,下面一一讲解。
Hello OS 的主函数
main.c
#include "vgastr.h"
void main()
{
printf("Hello OS! I am YinKai");
return;
}
entry.asm
; 多引导协议头(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 函数:
vgastr.c
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 值。
为了编译器能够正确识别我们的函数,我们还需要另写一个文件,保证函数调用的正确性。
vgastr.h
void _strwrite(char* string);
void printf(char* fmt, ...);
链接
hello.lds
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 工具进行系统编译,将每个代码模块编译最后链接成可执行的二进制文件。
Makefile
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 $@ $<
安装 Hello OS
不同的系统,可能操作不同,我这里用的是 ubuntu。
安装编译环境
- 安装汇编编译器
sudo apt-get install nasm
- 安装gcc(该命令会安装包括gcc在内的所有软件)
sudo apt install build-essential
修改启动项等待时间
修改启动项等待时间,以供我们选择启动项文件
sudo vim /etc/default/grub
,打开文件,修改为 10 s
使用 sudo update-grub
更新我们的修改。
:::warning
每次使用这个命令之后,我们追加的启动项(后面会说到)就会被清除,需要重新添加。
:::
构建 HelloOS.bin 文件
在自己的家目录下创建一个 HelloOS 文件夹,放入我们依赖的 6 个文件,代码及文件命名见上。
使用 make 构建
在 HelloOS 目录下,使用 make 命令,即可获得 HelloOS.bin 文件,并将该文件移动到 /boot/
目录下。(如果原本就有要将其删除,再放入。)
追加 GRUB 启动项
使用 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 启动的流程主要包括以下步骤:
- 计算机上电: 当计算机上电时,主板上的 BIOS 固件开始执行。
- BIOS 初始化: BIOS 负责检测和初始化计算机硬件,包括处理器、内存等。
- 加载引导扇区: BIOS 根据启动设备配置加载引导扇区,通常是硬盘上的 MBR。
- 引导加载程序执行: 引导加载程序(如 GRUB)被加载,负责加载操作系统内核。
- GRUB 加载 Hello OS: GRUB 通过配置文件加载 Hello OS 的二进制文件(HelloOS.bin)。
- Hello OS 入口点: Hello OS 的入口点在
entry.asm
中,负责初始化系统环境。 - 切换到 32 位保护模式:
_start
调用_32bits_mode
将处理器切换到 32 位保护模式。 - C 语言的 main 函数:
main.c
包含操作系统的主要逻辑,调用了输出字符串的函数。 - 屏幕输出:
vgastr.c
中的_strwrite
和printf
负责向屏幕输出字符串。 - 系统初始化完成: Hello OS 在初始化完成后,等待主要逻辑执行完毕。
- CPU 停止执行指令: 使用
halt
指令让 CPU 停止执行,操作系统启动过程结束。 - 系统运行或重新启动: 如果需要,可以继续执行其他操作系统功能或重新启动计算机。