myOs

参考这篇文章在mac系统中进行一个简单的基于Intel x86的32位操作系统内核实现

预备知识梳理

X86架构:

CPU里面是定义了一些指令的,CPU根据指令然后控制计算机进行各种各样的操作。那么CPU所有指令的集合,简称为指令集,也叫指令集架构。

  • X86架构(The X86 architecture)是微处理器执行的计算机语言指令集,指一个intel通用计算机系列的标准编号缩写,也标识一套通用的计算机指令集合。 ——百度
  • x86泛指一系列基于Intel8086且向后兼容的中央处理器指令集架构。最早的8086处理器于1978年由Intel推出,为16位微处理器。——维基

CPU两大架构:ARM和X86

  • Intel和ARM处理器的第一个区别是,前者使用复杂指令集(CISC,Complex Instruction Set Computer),而后者使用精简指令集(RISC,Reduced Instruction Set Computer)。属于这两种类中的各种架构之间最大的区别,在于它们的设计者考虑问题方式的不同。可以这么说,X86指令集中的指令是复杂的,一条很长指令就可以很多功能,而ARM指令集的指令是很精简的,需要几条精简的短指令完成很多功能。
  • X86的方向是高性能方向,因为它追求一条指令完成很多功能,而ARM的方向是面向低功耗,要求指令尽可能精简。
  • X86的市场主要是PC和服务器,因为需要高性能。ARM的市场主要是手机和平板,因为需要低功耗。

GDB的使用

使用指南

使用GDB
一般来说GDB主要调试的是C/C++的程序。要调试C/C++的程序,首先在编译时,我们必须要把调试信息加到可执行文件中。使用编译器(cc/gcc/g++)的 -g 参数可以做到这一点。如:

1
2
3
gcc -g hello.c -o hello

g++ -g hello.cpp -o hello

如果没有-g,你将看不见程序的函数名、变量名,所代替的全是运行时的内存地址。

启动GDB的方法有以下几种:

1、gdb program
program 也就是你的执行文件,一般在当前目录下。

2、gdb program core
用gdb同时调试一个运行程序和core文件,core是程序非法执行后core dump后产生的文件。

3、gdb program 1234
如果你的程序是一个服务程序,那么你可以指定这个服务程序运行时的进程ID。gdb会自动attach上去,并调试他。program应该在PATH环境变量中搜索得到。
GDB启动时,可以加上一些GDB的启动开关,详细的开关可以用gdb -help查看。下面只列举一些比较常用的参数:

  • --symbols=SYMFILE 从指定文件中读取符号表。
  • --se=FILE 从指定文件中读取符号表信息,并把他用在可执行文件中。
  • --core=COREFILE 调试时core dump的core文件。
  • --directory=DIR 加入一个源文件的搜索路径。默认搜索路径是环境变量中PATH所定义的路径。

GDB命令

GDB中的命令很多,但我们只需掌握其中十个左右的命令,就大致可以完成日常的基本的程序调试工作。

在这里插入图片描述
gdb命令拥有较多内部命令。在gdb命令提示符“(gdb)”下输入“help”可以查看所有内部命令及使用说明。

GDB 中运行UNIX的shell程序

在gdb环境中,你可以执行UNIX的shell的命令,使用gdb的shell命令来完成:

  • shell
    调用UNIX的shell来执行,环境变量SHELL中定义的UNIX的shell将会被用来执行,如果SHELL没有定义,那就使用UNIX的标准shell:/bin/sh。
    退出用exit命令,回到gdb提示符

  • make
    可以在gdb中执行make命令来重新build自己的程序。这个命令等价于“shell make ”。

环境配置

安装qemu虚拟机

mac下:

1
$ brew install qemu

执行该指令报错,需要 brew update -v更新一下

安装过程很慢,需要耐心等待……..

安装过程实在太慢,开启VPN也没有用,因此尝试替换brew源,切换为国内的阿里源:

Homebrew 主要有四个部分组成: brewhomebrew-corehomebrew-bottleshomebrew-cask

名称 说明
brew Homebrew 源代码仓库
homebrew-core Homebrew 核心软件仓库
homebrew-bottles Homebrew 预编译二进制软件包
homebrew-cask 提供 macOS 应用和大型二进制文件
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
# 查看 brew.git 当前源
$ cd "$(brew --repo)" && git remote -v
origin https://github.com/Homebrew/brew.git (fetch)
origin https://github.com/Homebrew/brew.git (push)

# 查看 homebrew-core.git 当前源
$ cd "$(brew --repo homebrew/core)" && git remote -v
origin https://github.com/Homebrew/homebrew-core.git (fetch)
origin https://github.com/Homebrew/homebrew-core.git (push)

# 修改 brew.git 为阿里源
$ git -C "$(brew --repo)" remote set-url origin https://mirrors.aliyun.com/homebrew/brew.git

# 修改 homebrew-core.git 为阿里源
$ git -C "$(brew --repo homebrew/core)" remote set-url origin https://mirrors.aliyun.com/homebrew/homebrew-core.git

# zsh 替换 brew bintray 镜像
$ echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.aliyun.com/homebrew/homebrew-bottles' >> ~/.zshrc
$ source ~/.zshrc

# bash 替换 brew bintray 镜像
$ echo 'export HOMEBREW_BOTTLE_DOMAIN=https://mirrors.aliyun.com/homebrew/homebrew-bottles' >> ~/.bash_profile
$ source ~/.bash_profile

# 刷新源
$ brew update

linux下:(我用的是CentOs7)

sudo yum install qemu -y

另外要安装汇编编译器nasm

sudo yum install qemu -y

开发过程中用到的脚本文件

MakeFile

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
#!Makefile

C_SOURCES = $(shell find . -name "*.c")
C_OBJECTS = $(patsubst %.c, %.o, $(C_SOURCES))
S_SOURCES = $(shell find . -name "*.s")
S_OBJECTS = $(patsubst %.s, %.o, $(S_SOURCES))

CC = gcc
LD = ld
ASM = nasm

C_FLAGS = -c -Wall -m32 -ggdb -gstabs+ -nostdinc -fno-pic -fno-builtin -fno-stack-protector -I include
LD_FLAGS = -T scripts/kernel.ld -m elf_i386 -nostdlib
ASM_FLAGS = -f elf -g -F stabs

all: $(S_OBJECTS) $(C_OBJECTS) link update_image

.c.o:
@echo 编译代码文件 $< ...
$(CC) $(C_FLAGS) $< -o $@

.s.o:
@echo 编译汇编文件 $< ...
$(ASM) $(ASM_FLAGS) $<

link:
@echo 链接内核文件...
$(LD) $(LD_FLAGS) $(S_OBJECTS) $(C_OBJECTS) -o hx_kernel

.PHONY:clean
clean:
$(RM) $(S_OBJECTS) $(C_OBJECTS) hx_kernel

.PHONY:update_image
update_image:
sudo mount floppy.img /mnt/kernel
sudo cp hx_kernel /mnt/kernel/hx_kernel
sleep 1
sudo umount /mnt/kernel

.PHONY:mount_image
mount_image:
sudo mount floppy.img /mnt/kernel

.PHONY:umount_image
umount_image:
sudo umount /mnt/kernel

.PHONY:qemu
qemu:
qemu-system-x86_64 -fda floppy.img -boot a

.PHONY:bochs
bochs:
bochs -f tools/bochsrc.txt

.PHONY:debug
debug:
qemu-system-x86_64 -S -s -fda floppy.img -boot a &
sleep 1
cgdb -x tools/gdbinit

注意Makefile中关于gcc编译参数的这一行:

1
C_FLAGS = -c -Wall -m32 -ggdb -gstabs+ -nostdinc -fno-builtin -fno-stack-protector -I include

重要的几个参数:

  • -m32 是生成32位代码,这样的话我们的开发环境也可以使用64位的Linux系统。
  • -ggdb 和-gstabs+ 是添加相关的调试信息,调试对后期的排错很重要。
  • -nostdinc 是不包含C语言的标准库里的头文件。1
  • -fno-builtin 是要求gcc不主动使用自己的内建函数,除非显式声明。
  • -fno-stack-protector 是不使用栈保护等检测。

另外注意这里qemu指令是需要带上后缀的 qemu-system-x86_64而不是只有qemu(不知道原因,centOs下没有直接的qemu命令)

kernel.ld

链接器脚本。

我们知道一个可执行的文件大致由代码段和数据段组成,但是操作系统怎么正确加载它呢?事实上可执行文件有义务向操作系统提供代码段和数据段位置等信息,以便操作系统正确找到它的代码段和数据段并加载执行。通常这个信息被统一组织放置在可执行文件的头部区域。不同的操作系统中自然就设计了不同的组织方式,比如Linux常见的ELF(Executable and Linkable Format,可执行链接格式)格式,windows下常见的PE(Portable Executable,可移植的可执行文件)格式都是。而ld支持很多种链接格式,我们可以在参数里指定。那为何选择ELF格式呢?原因很简单,因为GRUB可以检测识别出ELF格式的可执行文件,并且能找到相关的代码段和数据段的位置信息,从而正确的把我们的内核加载到正确的位置上去。

看懂了这个Makefile和链接器脚本,我们就向成功迈出了一大步了。大家还记得上一章谈过的GRUB Multiboot标准吗?只要按照标准生成规范的Multiboot引导信息,同时使用标准的ELF格式,GRUB就能把我们的内核正确的加载和执行了。

链接脚本结构:

  1. 链接配置(可有可无)

如一些符号变量的定义、入口地址、输出格式等

1
2
3
4
STACK_SIZE = 0X200;
OUTPUT_FORMAT(elf32-littlearm)
OUTPUT_ARCH(arm)
ENTRY(_start)
  1. 内存布局定义

脚本中以MEMORY命令定义了存储空间,其中以ORIGIN定义地址空间的起始地址,LENGTH定义地址空间的长度。

1
2
3
4
MEMORY
{
FLASH (rx) : ORIGIN = 0, LENGTH = 64K
}
  1. 段链接定义

脚本中以SECTIONS命令定义一些段(text、data、bss等段)链接分布。

1
2
3
4
5
6
7
SECTIONS
{
.text :
{
*(.text*)
} > FLASH
}
  1. .text段即代码段,* (.text*)指示将工程中所有目标文件的.text段链接到FLASH中

接下来是项目初步采用的链接器脚本的定义。

kernal.ld:

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
/*
* kernel.ld -- 针对 kernel 格式所写的链接脚本
*/

ENTRY(start)
SECTIONS
{
/* 段起始位置 */

. = 0x100000;
.text :
{
*(.text)
. = ALIGN(4096);
}

.data :
{
*(.data)
*(.rodata)
. = ALIGN(4096);
}

.bss :
{
*(.bss)
. = ALIGN(4096);
}

.stab :
{
*(.stab)
. = ALIGN(4096);
}

.stabstr :
{
*(.stabstr)
. = ALIGN(4096);
}

/DISCARD/ : { *(.comment) *(.eh_frame) }
}

ld链接命令的参数:

1
LD_FLAGS = -T scripts/kernel.ld -m elf_i386 -nostdlib
  • -T scripts/kernel.ld 是使用我们自己的链接器脚本。
  • -m elf_i386 是生成i386平台下的ELF格式的可执行文件,这是Linux下的可执行文件格式。
  • -nostdlib 是不链接C语言的标准库。

这个脚本告诉ld程序如何构造我们所需的内核映像文件。

首先,脚本声明了内核程序的入口地址是符号 “start” 。然后声明了段起始位置0x100000(1MB),接着是第一个段.text段(代码段)、已初始化数据段.data、未初始化数据段.bss以及它们采用的4096的页对齐方式。Linux GCC 增加了额外的数据段.rodata,这是一个只读的已初始化数据段,放置常量什么的。另外为了简单起见,我们把.rodata段和.data段放在了一起。最后的stab和stabstr段暂时无需关注,等到后面讲到调试信息的时候就会明白。

如果你对这里的ld链接器的脚本不是很理解也不是很重要,只要理解了脚本表示的意思就好

我们所用到的脚本暂时就是这两个,随着项目的逐渐展开,还会有陆续的代码加进来。

目前的目录结构是这样的:

1
2
3
4
5
6
.
|-- Makefile
`-- scripts
`-- kernel.ld

1 directory, 2 files

你也可以按照这个目录来放置代码,这样会比较清晰

计算机启动过程、GRUB 以及 multiboot 标准

操作系统的启动过程

一、第一阶段:BIOS(Basic Input/Output System)

上个世纪70年代初,”只读内存”(read-only memory,缩写为ROM)发明,开机程序被刷入ROM芯片,计算机通电后,第一件事就是读取它。

1.1 硬件自检

BIOS程序首先检查,计算机硬件能否满足运行的基本条件,这叫做”硬件自检”(Power-On Self-Test),缩写为POST

如果硬件出现问题,主板会发出不同含义的蜂鸣,启动中止。如果没有问题,屏幕就会显示出CPU、内存、硬盘等信息。

1.2 启动顺序

硬件自检完成后,BIOS把控制权转交给下一阶段的启动程序。

这时,BIOS需要知道,”下一阶段的启动程序”具体存放在哪一个设备。也就是说,BIOS需要有一个外部储存设备的排序,排在前面的设备就是优先转交控制权的设备。这种排序叫做”启动顺序”(Boot Sequence)。

打开BIOS的操作界面,里面有一项就是”设定启动顺序”。

二、第二阶段:主引导记录

BIOS按照”启动顺序”,把控制权转交给排在第一位的储存设备。

这时,计算机读取该设备的第一个扇区,也就是读取最前面的512个字节。如果这512个字节的最后两个字节是0x55和0xAA,表明这个设备可以用于启动;如果不是,表明设备不能用于启动,控制权于是被转交给”启动顺序”中的下一个设备。

这最前面的512个字节,就叫做“主引导记录”(Master boot record,缩写为MBR)。

2.1 主引导记录的结构

“主引导记录”只有512个字节,放不了太多东西。它的主要作用是,告诉计算机到硬盘的哪一个位置去找操作系统。

主引导记录由三个部分组成:

  (1) 第1-446字节:调用操作系统的机器码。

  (2) 第447-510字节:分区表(Partition table)。

  (3) 第511-512字节:主引导记录签名(0x55和0xAA)。

其中,第二部分”分区表”的作用,是将硬盘分成若干个区。

2.2 分区表

硬盘分区有很多好处。考虑到每个区可以安装不同的操作系统,”主引导记录”因此必须知道将控制权转交给哪个区。

分区表的长度只有64个字节,里面又分成四项,每项16个字节。所以,一个硬盘最多只能分四个一级分区,又叫做”主分区”。

每个主分区的16个字节,由6个部分组成:

  (1) 第1个字节:如果为0x80,就表示该主分区是激活分区,控制权要转交给这个分区。四个主分区里面只能有一个是激活的。

  (2) 第2-4个字节:主分区第一个扇区的物理位置(柱面、磁头、扇区号等等)。

  (3) 第5个字节:主分区类型

  (4) 第6-8个字节:主分区最后一个扇区的物理位置。

  (5) 第9-12字节:该主分区第一个扇区的逻辑地址。

  (6) 第13-16字节:主分区的扇区总数。

最后的四个字节(”主分区的扇区总数”),决定了这个主分区的长度。也就是说,一个主分区的扇区总数最多不超过2的32次方。

如果每个扇区为512个字节,就意味着单个分区最大不超过2TB。再考虑到扇区的逻辑地址也是32位,所以单个硬盘可利用的空间最大也不超过2TB。如果想使用更大的硬盘,只有2个方法:一是提高每个扇区的字节数,二是增加扇区总数

三、第三阶段:硬盘启动

这时,计算机的控制权就要转交给硬盘的某个分区了,这里又分成三种情况。

3.1 情况A:卷引导记录

上一节提到,四个主分区里面,只有一个是激活的。计算机会读取激活分区的第一个扇区,叫做“卷引导记录“(Volume boot record,缩写为VBR)。

“卷引导记录”的主要作用是,告诉计算机,操作系统在这个分区里的位置。然后,计算机就会加载操作系统了。

3.2 情况B:扩展分区和逻辑分区

随着硬盘越来越大,四个主分区已经不够了,需要更多的分区。但是,分区表只有四项,因此规定有且仅有一个区可以被定义成”扩展分区”(Extended partition)。

所谓”扩展分区”,就是指这个区里面又分成多个区。这种分区里面的分区,就叫做”逻辑分区”(logical partition)。

计算机先读取扩展分区的第一个扇区,叫做“扩展引导记录”(Extended boot record,缩写为EBR)。它里面也包含一张64字节的分区表,但是最多只有两项(也就是两个逻辑分区)。

计算机接着读取第二个逻辑分区的第一个扇区,再从里面的分区表中找到第三个逻辑分区的位置,以此类推,直到某个逻辑分区的分区表只包含它自身为止(即只有一个分区项)。因此,扩展分区可以包含无数个逻辑分区。

但是,似乎很少通过这种方式启动操作系统。如果操作系统确实安装在扩展分区,一般采用下一种方式启动。

3.3 情况C:启动管理器

在这种情况下,计算机读取”主引导记录”前面446字节的机器码之后,不再把控制权转交给某一个分区,而是运行事先安装的“启动管理器”(boot loader),由用户选择启动哪一个操作系统。

Linux环境中,目前最流行的启动管理器是Grub

四、第四阶段:操作系统

控制权转交给操作系统后,操作系统的内核首先被载入内存。

以Linux系统为例,先载入/boot目录下面的kernel。内核加载成功后,第一个运行的程序是/sbin/init。它根据配置文件(Debian系统是/etc/initab)产生init进程。这是Linux启动后的第一个进程,pid进程编号为1,其他进程都是它的后代。

然后,init线程加载系统的各个模块,比如窗口程序和网络程序,直至执行/bin/login程序,跳出登录界面,等待用户输入用户名和密码。

至此,全部启动过程完成。

一些概念

CPU寻址

首先,我们的内核使用32位的地址总线来寻址,所以能编址出2的32次方,也就是4G的地址空间。那么这4G的空间指向哪里?大多数人的第一反应都是内存。然而我们知道在主板上除了内存还有BIOS、显卡、声卡、网卡等外部设备,CPU需要和这些外设进行通信。那么实现通信自然就得有地址,不然怎么表示数据的去向呢?比如显卡内部就有自己的一些存储单元。在x86下,当需要访问这些存储单元的时候,就需要给予不同的访问地址来区分每一个读写单元。

引出两个专业名词:端口统一编址和端口独立编址:

  • 端口统一编址就是把所有和外设存储单元对应的端口直接编址在这4G的地址空间里,当我们对某一个地址进行访问的时候实际上是在访问某个外设的存储单元。
  • 端口独立编址就是说这些端口没有编址在地址空间里,而是另行独立编址。

x86架构部分的采用了端口独立编址,又部分的采用了端口统一编址。部分外设的部分存储单元直接可以通过某个内存地址访问,而其他部分在一个独立的端口地址空间中,需要使用in/out指令去访问,我们用到的时候再来细说。

CPU在加电后的启动过程

从按下电源开始:

  • 首先是CPU重置。主板加电之后在电压尚未稳定之前,主板上的北桥控制芯片会向CPU发出重置信号(Reset),此时CPU进行初始化。
  • 当电压稳定后,控制芯片会撤销Reset信号,CPU便开始了模式化的工作。此时形成的第一条指令的地址是0xFFFFFFF0,从这里开始,CPU就进入了一个“取指令-翻译指令-执行”的循环了。
  • 我们需要做的就是在各个阶段提供给CPU相关的数据,以完成这个“接力赛”。这个接力过程中任何一个环节如果出现致命问题,其导致的直接后果就是宕机。死机是最好的结果,最坏的结果是程序在“默默的”破坏我们的数据,所以一定要谨慎对待。

这个0xFFFFFFF0地址指向的是BIOS芯片里。刚刚提到,在4G的地址空间里,有一些地址是分给外设的,这个地址便是映射到BIOS的。由于计算机刚加电的时候内存等芯片尚未初始化,所以也只能是指向BIOS芯片里已经被“固化”的指令了。

紧接着就是BIOS的POST(Power On Self Test,上电自检)过程了,BIOS对计算机各个部件开始初始化,如果有错误会给出报警音。当BIOS完成这些工作之后,它的任务就是在外部存储设备中寻找操作系统,而我们最常用的外存自然就是硬盘了。

BIOS里面就有一张启动设备表,BIOS会按照这个表里面列出的顺序查找可启动设备。那么怎么知道该设备是否可以启动呢?规则其实很简单:如果这个存储设备的第一个扇区中512个字节的最后两个字节是0x55和0xAA,那么该存储设备就是可启动的。这是一个约定,所以BIOS会对这个列表中的设备逐一检测,只要有一个设备满足要求,后续的设备将不再测试。

当BIOS找到可启动的设备后,便将该设备的第一个扇区加载到内存的0x7C00地址处,并且跳转过去执行。而我们要做的事情,便是从构造这个可启动的扇区开始。

GRUB和multiboot规范

因为一个扇区只有512字节,放不下太多的代码,所以常规的做法便是在这里写下载入操作系统内核的代码,这段代码就是所谓的bootloader程序。一般意义上的bootloader负责将软硬件的环境设置到一个合适的状态,然后加载操作系统内核并且移交执行权限。

而GRUB是一个来自GNU项目的多操作系统启动程序。它是多启动规范的实现,允许用户可以在计算机内同时拥有多个操作系统,并在计算机启动时选择希望运行的操作系统。

引出GRUB的原因很简单:我不准备自己实现bootloader程序。

  • 其一,实现bootloader牵扯太多在后期才要讲述的知识。与其前期简陋的实现这个bootloader,还不如就用现成的优秀实现,以后有机会自己再学着改进;
  • 第二,我想在后面把这个小内核安装到物理机器上去,而读者们想必在自己的机器上已经有了多个操作系统了。这样的话如果非得实现自己的bootloader的话,势必会造成和已有操作系统的不兼容。

所以,我干脆决定直接使用GRUB来加载内核。以后就能让它很简单的安装在物理机器上,这样的话我们能拥有一个Linux系统和自己的小内核共存的计算机了。如果你愿意的话,也可以再加上一个Windows系统。

那么问题是怎么能让GRUB加载这个小内核呢?答案是GRUB提供的multiboot规范。这份规范是描述如何构造出一个能够被GRUB识别,并且按照我们定义的规则去加载的操作系统内核。

P.S. 古老的8086处理器是0xFFFF0这个地址。

裸机上运行Hello Os Kernel!

启动镜像的制作

解决了理论上的问题之后,我们来解决一个现实的问题。这个问题是,这个小内核放在哪里?虚拟机是它运行的场所,那么虚拟磁盘自然就是安放内核的选择了。不过我们这次不选择硬盘,而是选择已经几乎消失殆尽的1.44MB的软盘。为什么?说实话是因为比较简单。

在Linux下制作一个1.44MB软盘的技术太简单了,甚至dd命令也可以做一个出来。不过在这个软盘镜像上安装一个GRUB稍有难度,所以大家直接可以使用我提供的已经安装好了GRUB的软盘镜像。

再必须引入一个新的术语名词——文件系统。什么是文件系统呢?其实简单的说,这里所谓的文件系统指的是操作系统用于明确磁盘或分区上的文件存储的方法和数据结构,即在磁盘上组织文件的方法。如果把这个1.44MB的软盘可以看做一个巨大的数组,那么每个数组成员都是一个扇区,拥有512字节的存储单元。现在需要往这个软盘上写入文件,那么自然需要一套合适的管理规则,否则的话岂不是全乱套了?就和这篇文档一样,不同的章节被划分在不同的位置,同时在最前面有所有章节的目录,这就是一种对信息的组织方式,文件系统也不过是这么一套组织策略罢了。

考虑到兼容因素,自然是选择目前使用的比较广泛的文件系统了,这样的话也便于我们对软盘里面存储的文件进行管理操作。那么使用什么文件系统呢?软盘一般使用的是FAT12文件系统,所以我们也就不标新立异了。这个文件系统也很简单,后期大家有兴趣的话甚至可以在我们的内核里实现能处理这个文件系统的代码。至于FAT12文件系统的资料也很多,我就不详细说了。

GRUB支持从很多常见的文件系统中去读取和载入内核文件,当然也包括我们使用的FAT12格式。我们在自己开发使用的Linux系统上很容易就可以挂载和读写这个虚拟软盘里的文件,这给开发带来了极大的便利。我们经常做的事就是拷贝编译好的内核进去,然后在虚拟机运行。另外这个虚拟软盘文件里还有GRUB和它的配置文件,我提供的软盘镜像里安装的是一个很早的GRUB版本,它的配置文件简单到一看就懂。所以大家完全可以自己看着修改。

内核的入口和初始化

前期的铺垫到这里就告一段落了,下面我们开始编码工作吧。

代码从什么函数开始执行呢?如果你记忆力足够好的话你会记得ld链接脚本里面的一行文字:

1
ENTRY(start)

这就意味着我们告诉了ld链接器入口函数是start,所以代码从start函数开始。

大家先来一起围观下入口部分的代码全貌:

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
; ----------------------------------------------------------------
;
; boot.s -- 内核从这里开始
;
; ----------------------------------------------------------------

; Multiboot 魔数,由规范决定的,这个十六进制数是固定的
MBOOT_HEADER_MAGIC equ 0x1BADB002

; 0 号位表示所有的引导模块将按页(4KB)边界对齐,4KB与后面谈到的分页有关
MBOOT_PAGE_ALIGN equ 1 << 0

; 1 号位通过 Multiboot 信息结构的 mem_* 域包括可用内存的信息
; (告诉GRUB把内存空间的信息包含在Multiboot信息结构中)
MBOOT_MEM_INFO equ 1 << 1

; 定义我们使用的 Multiboot 的标记
MBOOT_HEADER_FLAGS equ MBOOT_PAGE_ALIGN | MBOOT_MEM_INFO

; 域checksum是一个32位的无符号值,当与其他的magic域(也就是magic和flags)
; 相加时,要求其结果必须是32位的无符号值 0 (即magic+flags+checksum = 0)
MBOOT_CHECKSUM equ -(MBOOT_HEADER_MAGIC+MBOOT_HEADER_FLAGS)

; 符合Multiboot规范的 OS 映象需要这样一个 magic Multiboot 头
; Multiboot 头的分布必须如下表所示:
; ----------------------------------------------------------
; 偏移量 类型 域名 备注
;
; 0 u32 magic 必需
; 4 u32 flags 必需
; 8 u32 checksum 必需
;
; 我们只使用到这些就够了,更多的详细说明请参阅 GNU 相关文档
;-----------------------------------------------------------

;-----------------------------------------------------------------------------

[BITS 32] ; 所有代码以 32-bit 的方式编译
section .text ; 代码段从这里开始

; 在代码段的起始位置设置符合 Multiboot 规范的标记
; dd的意思是四字节符号

dd MBOOT_HEADER_MAGIC ; GRUB 会通过这个魔数判断该映像是否支持
dd MBOOT_HEADER_FLAGS ; GRUB 的一些加载时选项,其详细注释在定义处
dd MBOOT_CHECKSUM ; 检测数值,其含义在定义处

[GLOBAL start] ; 向外部声明内核代码入口,此处提供该声明给链接器
[GLOBAL glb_mboot_ptr] ; 向外部声明 struct multiboot * 变量
[EXTERN kern_entry] ; 声明内核 C 代码的入口函数

start:
cli ; 此时还没有设置好保护模式的中断处理,要关闭中断
; 所以必须关闭中断。这个命令会将IF寄存器置为0
mov esp, STACK_TOP ; 设置内核栈地址
mov ebp, 0 ; 栈顶指针修改为 0
and esp, 0FFFFFFF0H ; 栈地址按照16字节对齐
mov [glb_mboot_ptr], ebx ; 将 ebx 中存储的指针存入前面声明的全局变量
call kern_entry ; 调用内核入口函数,进入kernel的init函数
stop:
hlt ; 停机指令,可以降低 CPU 功耗
jmp stop ; 进入死循环,到这里结束,关机什么的后面再说

;-----------------------------------------------------------------------------

section .bss ; 未初始化的数据段从这里开始
stack:
resb 32768 ; 0x8000 这里作为内核栈
glb_mboot_ptr: ; 全局的 multiboot 结构体指针
resb 4

STACK_TOP equ $-stack-1 ; 内核栈顶,$ 符指代是当前地址

;-----------------------------------------------------------------------------

我们简单的介绍下这段代码做的事情。

首先是一些宏定义,定义了Multiboot标准的魔数和几个标识,GRUB会读取这些信息以判断我们的意图。真正的代码是从39行以后开始的,首先我们在代码段的入口位置之前定义了几个Multiboot规范要求的配置信息,其实也就是3个4字节变量。我们通过这几个变量告诉了GRUB我们要求它提供可用内存的信息,而且要求内核中所有的段在内存里按照4KB进行对齐。

紧接着就是我们内核的入口函数start了,入口代码很短,主要做的是关闭中断,传参数(按照协议,GRUB把一些计算机硬件和我们内核文件相关的信息放在了一个结构体中,并且将这个结构体指针放在了ebx寄存器中)并且调用内核的入口函数。

等到这个函数返回之后,内核就进入了一个死循环了。

这段代码我加上了很多注释,希望这能给你阅读代码带来便利。如果你之前按照我的要求认真的去翻阅了GRUB的Multiboot标准的话,因该很容易就能理解这里代码的含义。如果你理解起来感到很困难,那就结合Multiboot标准的相关规范仔细阅读,这个过程是必须的。

OK,理解了上面这两段代码,我们暂时的告别汇编,用C语言来实现内核的入口函数。

1
2
3
4
int kern_entry()
{
return 0;
}

还有一个在后面需要用到的头文件,主要是几个宏和一些类型的重定义。代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef INCLUDE_TYPES_H_
#define INCLUDE_TYPES_H_

#ifndef NULL
#define NULL 0
#endif

#ifndef TRUE
#define TRUE 1
#define FALSE 0
#endif

typedef unsigned int uint32_t;
typedef int int32_t;
typedef unsigned short uint16_t;
typedef short int16_t;
typedef unsigned char uint8_t;
typedef char int8_t;

#endif // INCLUDE_TYPES_H_

最后我再列出当GRUB载入我们的内核时,CPU的一些状态信息:

  1. CS 指向基地址为 0x00000000,限长为4G – 1的代码段描述符。
  2. DS,SS,ES,FS 和 GS 指向基地址为0x00000000,限长为4G–1的数据段描述符。
  3. A20 地址线已经打开。
  4. 页机制被禁止。
  5. 中断被禁止。
  6. EAX = 0x2BADB002
  7. 系统信息和启动信息块的线性地址保存在 EBX中(相当于一个指针)。

准备好了这一切之后,再把之前完成的软盘镜像放在Makefile文件的同级目录下,现在目录结构是这样的:

1
2
3
4
5
6
7
8
9
10
11
12
13
.
|-- boot
| `-- boot.s
|-- floppy.img
|-- include
| `-- types.h
|-- init
| |-- entry.c
|-- Makefile
`-- scripts
`-- kernel.ld

4 directories, 6 files

我们直接执行 make 命令编译代码,没有意外的话会生成一个叫做hx_kernel的文件,并且自动挂载软盘镜像把这个文件复制进去。18

最后我们来运行它,使用下面的命令即可运行:

1
make qemu

很简单吧,这样我们的内核就在qemu虚拟机里执行了。首先显示的是GRUB菜单,但是我们的内核载入之后就在也没有动静了,因为我们什么代码也没有写。

有点失望是不是?别急,下一章过后,我们就可以自如的在屏幕上显示字符了。有点迫不及待了?那我就先透露一点吧,按照如下代码修改kern_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
#include "types.h"

int kern_entry()
{
uint8_t *input = (uint8_t *)0xB8000;
uint8_t color = (0 << 4) | (15 & 0x0F);

*input++ = 'H'; *input++ = color;
*input++ = 'e'; *input++ = color;
*input++ = 'l'; *input++ = color;
*input++ = 'l'; *input++ = color;
*input++ = 'o'; *input++ = color;
*input++ = ','; *input++ = color;
*input++ = ' '; *input++ = color;
*input++ = 'O'; *input++ = color;
*input++ = 'S'; *input++ = color;
*input++ = ' '; *input++ = color;
*input++ = 'K'; *input++ = color;
*input++ = 'e'; *input++ = color;
*input++ = 'r'; *input++ = color;
*input++ = 'n'; *input++ = color;
*input++ = 'e'; *input++ = color;
*input++ = 'l'; *input++ = color;
*input++ = '!'; *input++ = color;

return 0;
}

现在我们编译并且启动虚拟机,就能看到了第一阶段的成果,屏幕上华丽丽的输出了“Hello, OS World!”的字样

注意点:

  • 如果在挂载这一步出错,请在自己系统的/mnt目录下建立一个叫做kernel的目录,当然你也可以修改Makefile文件

运行结果:

字符模式下的显卡驱动

1MB以下的地址空间分布

在第二章我们简单的谈过地址空间的概念,并提到4G的地址空间并非全部指向主存储器,而是有部分的地址分给了其他外设。特别地,在地址空间的最低1MB处,有很多地址是属于外部设备的,下图描绘了该处地址映射的分布情况:

低端地址空间的映射关系

在PC上要显示文字,通常需要显示器和显卡这两个硬件设备。一般来说显卡负责提供显示内容,并控制具体的显示模块和状态。显示器的职责是负责将显卡呈递的内容可视化的显示出来。既然显卡需要控制显示的数据,自然就需要存储这些待显示的内容,所以显卡就有自己的存储区域。这个存储区域叫做显示存储器(Video RAM,VRAM),简称显存。

当然,访问显存就需要地址。CGA/EGA+ Chroma text video buffer 这个区域映射的就是工作在文本模式的显存。同时显卡还有另外一个工作模式叫做图形模式,这个模式是目前最最常用的模式。

显卡在文本模式下的显示规则

我们知道,对于一个字符的编码通常有输入码、内码和字模码三种。其中字模码定义了一个字符在屏幕上显示的点阵坐标。通常显卡内置一套关于基本英文字符的显示是很容易做到的,而内置汉字的显示就较为麻烦。

在这篇文档中我们只使用显卡的文本模式,不会涉及到图形模式的内容。因为一旦使用了图形模式的内容,我们就需要自行定义字符的字模码了,这很繁琐而且对我们理解操作系统原理的意义不是很大。

所以我们只使用显卡的文本模式进行屏幕显示控制。所有在PC上工作的显卡,在加电初始化之后都会自动初始化到80*25的文本模式。

在这个模式下,屏幕被划分为25行,每行可以显示80个字符,所以一屏可以显示2000个字符。上图中的0xB8000~0xBFFFF这个地址段便是映射到文本模式的显存的。当访问这些地址的时候,实际上读写的是显存区域,而显卡会周期性的读取这里的数据,并且把它们按顺序显示在屏幕上。

那么,按照什么规则显示呢?这就要谈到内码了。内码定义了字符在内存中存储的形式,而英文编码就是大家所熟知的ASCII(American Standard Code for Information Interchange,美国信息交换标准代码)码了。对应的关系很简单,从0xB8000这个地址开始,每2个字节表示屏幕上显示的一个字符。从屏幕的第一行开始对应,一行接着一行的对应下去。而这两个字节的前一个是显示字符的ASCII码,后一个是控制这个字符颜色和属性的控制信息,这个字节的8个bit位表示不同的含义。每一位的含义如图所示:

字符属性示意图

这些位的组合效果如下图所示:

显卡文本模式的颜色表

这两张图可以帮助我们在显卡的字符模式显示彩色的文本了,懂得这些原理对于探索性质的显示也就足够了。

理解了显卡文本模式的原理之后接下来就是对屏幕显示控制编码了。不过显卡除了显示内容的存储单元之外,还有部分的显示控制单元需要了解。这些显示控制单元被编制在了独立的I/O空间里,需要用特殊的in/out指令去读写。这里相关的控制寄存器多达300多个,显然无法一一映射到I/O端口的地址空间。对此工程师们解决方案是,将一个端口作为内部寄存器的索引:0x3D4,再通过0x3D5端口来设置相应寄存器的值。

端口读写函数的实现

在具体的设置之前,我们首先需要几个端口读写函数的实现。因为C语言并没有直接操作端口的方法,而且频繁的内联汇编麻烦又容易出错。所以好的做法就是定义几个端口读写函数。代码如下:

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
#include "common.h"

// 端口写一个字节
inline void outb(uint16_t port, uint8_t value)
{
asm volatile ("outb %1, %0" : : "dN" (port), "a" (value));
}

// 端口读一个字节
inline uint8_t inb(uint16_t port)
{
uint8_t ret;

asm volatile("inb %1, %0" : "=a" (ret) : "dN" (port));

return ret;
}

// 端口读一个字
inline uint16_t inw(uint16_t port)
{
uint16_t ret;

asm volatile ("inw %1, %0" : "=a" (ret) : "dN" (port));

return ret;
}

对应的头文件如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#ifndef INCLUDE_COMMON_H_
#define INCLUDE_COMMON_H_

#include "types.h"

// 端口写一个字节
void outb(uint16_t port, uint8_t value);

// 端口读一个字节
uint8_t inb(uint16_t port);

// 端口读一个字
uint16_t inw(uint16_t port);

#endif // INCLUDE_COMMON_H_

细心的读者想必已经发现了函数定义之前的inline关键字了吧?这是GNU对ANSI C的扩展,它和C++语言里的inline的作用是一样的。函数前面加上inline之后,编译器会尝试在该函数的调用点进行直接进行代码展开,而不是传统的函数调用。这么做的既有传统函数的好处,即避免了重复性的编码,减少了出错的几率。又减少了函数的调用,提高了代码的执行效率。另外,你可能见过宏函数这种用法,但是宏函数是没有参数类型的检查的,相比inline还是逊了一筹。

颜色的枚举定义和屏幕操作函数的实现

接下来是颜色定义的枚举和一些屏幕控制函数的声明。代码如下:

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
#ifndef INCLUDE_CONSOLE_H_
#define INCLUDE_CONSOLE_H_

#include "types.h"

typedef
enum real_color {
rc_black = 0,
rc_blue = 1,
rc_green = 2,
rc_cyan = 3,
rc_red = 4,
rc_magenta = 5,
rc_brown = 6,
rc_light_grey = 7,
rc_dark_grey = 8,
rc_light_blue = 9,
rc_light_green = 10,
rc_light_cyan = 11,
rc_light_red = 12,
rc_light_magenta = 13,
rc_light_brown = 14, // yellow
rc_white = 15
} real_color_t;

// 清屏操作
void console_clear();

// 屏幕输出一个字符 带颜色
void console_putc_color(char c, real_color_t back, real_color_t fore);

// 屏幕打印一个以 \0 结尾的字符串 默认黑底白字
void console_write(char *cstr);

// 屏幕打印一个以 \0 结尾的字符串 带颜色
void console_write_color(char *cstr, real_color_t back, real_color_t fore);

// 屏幕输出一个十六进制的整型数
void console_write_hex(uint32_t n, real_color_t back, real_color_t fore);

// 屏幕输出一个十进制的整型数
void console_write_dec(uint32_t n, real_color_t back, real_color_t fore);

#endif // INCLUDE_CONSOLE_H_

参照着前面的表格,理解颜色的枚举类型并不困难。接下来是显存起始位置和当前输出的屏幕位置的变量定义。同时,我们将屏幕抽象为一个80*25的二维数组,每个数组成员都是2个字节,表示屏幕上显示的一个字符。

1
2
3
4
5
6
// VGA 的显示缓冲的起点是 0xB8000
static uint16_t *video_memory = (uint16_t *)0xB8000;

// 屏幕"光标"的坐标
static uint8_t cursor_x = 0;
static uint8_t cursor_y = 0;

请大家留意这里变量定义时候的 static 限定符,当一个全局变量或者函数只在本模块文件内被使用时,最好限定其作用域。每个模块应当尽可能的向外部暴露较少的接口。

屏幕输入光标的移动

在本模块内,cursor_x 和 cursor_y 这两个变量指明了逻辑上的当前输出位置,但是并没有实际上移动硬件的显示“光标”,下面的函数实现了根据这两个变量的值移动光标的功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
static void move_cursor()
{
// 屏幕是 80 字节宽
uint16_t cursorLocation = cursor_y * 80 + cursor_x;

// 在这里用到的两个内部寄存器的编号为14与15,分别表示光标位置
// 的高8位与低8位。

outb(0x3D4, 14); // 告诉 VGA 我们要设置光标的高字节
outb(0x3D5, cursorLocation >> 8); // 发送高 8 位
outb(0x3D4, 15); // 告诉 VGA 我们要设置光标的低字节
outb(0x3D5, cursorLocation); // 发送低 8 位
}

这里的端口和设置值都是固定的,也没有什么道理可讲。虽然显卡的各项技术发展的很快,但是这个原始的VGA标准被所有显卡完整的保存了下来。

清屏操作

然后是清屏操作,其实这里的“清屏”很简单,其实就是用白底黑字的“空格符”覆盖整个屏幕的显示区域罢了,然后移动光标到0位置。这么做自然就实现了我们想要的“清屏”操作了。代码很简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void console_clear()
{
uint8_t attribute_byte = (0 << 4) | (15 & 0x0F);
uint16_t blank = 0x20 | (attribute_byte << 8);

int i;
for (i = 0; i < 80 * 25; i++) {
video_memory[i] = blank;
}

cursor_x = 0;
cursor_y = 0;
move_cursor();
}

屏幕滚动显示

那么屏幕滚动呢?用C语言来描述实际上就是将后24行的数据全部向上挪动一行,最后一行清空罢了,就是这么简单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
static void scroll()
{
// attribute_byte 被构造出一个黑底白字的描述格式
uint8_t attribute_byte = (0 << 4) | (15 & 0x0F);
uint16_t blank = 0x20 | (attribute_byte << 8); // space 是 0x20

// cursor_y 到 25 的时候,就该换行了
if (cursor_y >= 25) {
// 将所有行的显示数据复制到上一行,第一行永远消失了...
int i;

for (i = 0 * 80; i < 24 * 80; i++) {
video_memory[i] = video_memory[i+80];
}

// 最后的一行数据现在填充空格,不显示任何字符
for (i = 24 * 80; i < 25 * 80; i++) {
video_memory[i] = blank;
}

// 向上移动了一行,所以 cursor_y 现在是 24
cursor_y = 24;
}
}

显示字符串

那么屏幕显示字符串呢?我们可以先实现屏幕显示一个字符的函数,那么屏幕显示一个字符串不就可以了么。这几个函数的实现如下:

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
void console_putc_color(char c, real_color_t back, real_color_t fore)
{
uint8_t back_color = (uint8_t)back;
uint8_t fore_color = (uint8_t)fore;

uint8_t attribute_byte = (back_color << 4) | (fore_color & 0x0F);
uint16_t attribute = attribute_byte << 8;

// 0x08 是退格键的 ASCII 码
// 0x09 是tab 键的 ASCII 码
if (c == 0x08 && cursor_x) {
cursor_x--;
} else if (c == 0x09) {
cursor_x = (cursor_x+8) & ~(8-1);
} else if (c == '\r') {
cursor_x = 0;
} else if (c == '\n') {
cursor_x = 0;
cursor_y++;
} else if (c >= ' ') {
video_memory[cursor_y*80 + cursor_x] = c | attribute;
cursor_x++;
}

// 每 80 个字符一行,满80就必须换行了
if (cursor_x >= 80) {
cursor_x = 0;
cursor_y ++;
}

// 如果需要的话滚动屏幕显示
scroll();

// 移动硬件的输入光标
move_cursor();
}

void console_write(char *cstr)
{
while (*cstr) {
console_putc_color(*cstr++, rc_black, rc_white);
}
}

void console_write_color(char *cstr, real_color_t back, real_color_t fore)
{
while (*cstr) {
console_putc_color(*cstr++, back, fore);
}
}

代码里唯一需要注意的便是输出后要检查当前的位置和判断一些特殊的符号表示的操作,例如换行之类的实现。同时一定要注意修改存储当前位置的两个变量和移动屏幕上的光标,而且屏幕输出满了以后要上滚。我们暂时不考虑诸如屏幕翻页之类的功能。至于屏幕输出十六进制数字和十进制数字的函数请大家自己实现,相信这并不困难。

测试屏幕操作函数

屏幕的操作到这里就告一段落了,我们修改下初始化函数,感受一下今天的成果吧。

1
2
3
4
5
6
7
8
9
10
#include "console.h"

int kern_entry(multiboot_t *mboot_ptr)
{
console_clear();

console_write_color("Hello, OS kernel!\n", rc_black, rc_green);

return 0;
}

编译运行,干净的屏幕上出现了我们绿色的文字,还有下一行闪烁着的输入光标。

相关库函数和调试打印函数

我们已经实现了一个能在屏幕上任意输出字符的小内核了。但是在开始新的探索之前,需要完成一些在内核开发中至关重要的模块。

C语言的字符串处理函数

我们之前多次提到现有的用户态的C语言标准库无法使用在内核中,但是内核开发中难免要用到诸如字符串操作的函数,所以我们需要自己实现这些字符串相关的函数。

首先给出函数的声明:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#ifndef INCLUDE_STRING_H_
#define INCLUDE_STRING_H_

#include "types.h"

void memcpy(uint8_t *dest, const uint8_t *src, uint32_t len);

void memset(void *dest, uint8_t val, uint32_t len);

void bzero(void *dest, uint32_t len);

int strcmp(const char *str1, const char *str2);

char *strcpy(char *dest, const char *src);

char *strcat(char *dest, const char *src);

int strlen(const char *src);

#endif // INCLUDE_STRING_H_

至于函数的实现我只给出其中几个函数的参考实现,剩下的请大家自己实现吧,考验大家C语言指针基本功的时候到了。注意这里的函数最好在用户态下进行编码和测试,确认正确无误了再放入内核中使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "string.h"

inline void memcpy(uint8_t *dest, const uint8_t *src, uint32_t len)
{
for (; len != 0; len--) {
*dest++ = *src++;
}
}

inline void memset(void *dest, uint8_t val, uint32_t len)
{
uint8_t *dst = (uint8_t *)dest;

for ( ; len != 0; len--) {
*dst++ = val;
}
}

inline void bzero(void *dest, uint32_t len)
{
memset(dest, 0, len);
}

内核级的屏幕打印函数

初学C语言时使用的printf函数想必大家都很熟悉吧?可是在这里是没有办法使用现有的库的。不过完成了屏幕的控制输出之后,我们就可以基于它同时根据printf函数的实现原理,写出一个内核态下可以进行屏幕打印的函数printk了。但是这里恐怕不敢展开来讲,这涉及到C语言的可变形参表和函数调用栈等繁多细节。原本我只想给出具体的实现以供大家参考,但是又觉得带给大家“夹生饭”的做法不太好。所以我简单的结合代码给大家阐述下基本的实现原理,同时希望没理解的读者自行检索相关资料,争取理解这个函数的实现。

我们之前已经实现过了屏幕打印字符串和数字等内容的函数了,那么此时想实现printf函数,难点就在于构造这个最终打印的字符串。现在摆在我们面前的问题其实只有两个,那就是如何知道有多少个参数传进来和如何知道每一个参数的类型。其实我们完全可以照搬printf的做法,提供同样的接口。printf的用法大家很清楚,首先是一个待显示的字符串,里面分别用%加相关字母的方式一一指明了后面的参数数量和类型。只要我们传递正确的带有格式描述的字符串和相关参数,printf函数就能正确的打印出来结果。

我们的printk函数的实现完全模仿printf函数的接口,首先是函数声明:

1
2
3
4
5
6
7
8
#include "console.h"
#include "vargs.h"

// 内核的打印函数
void printk(const char *format, ...);

// 内核的打印函数 带颜色
void printk_color(real_color_t back, real_color_t fore, const char *format, ...);

后面一个printk_color对应之前的带颜色的屏幕输出,因为C语言没有C++那样的函数重载或者默认参数的特性,所以我们只能定义两个函数了。printk函数的声明的参数列表首先是一个字符串,然后是三个小数点,这样的话编译器会允许我们在调用printk函数的时候带有任意多个实参了。剩下的问题就是在printk的实现里,如何在没有形参名的情况下找到取到每一个参数。解决了这个问题之后,我想剩下的问题就很简单了。

我们先贴出另一个所需要的头文件 vargs.h 的内容:

1
2
3
4
5
6
7
8
9
10
#ifndef INCLUDE_VARGS_H_
#define INCLUDE_VARGS_H_

typedef __builtin_va_list va_list;

#define va_start(ap, last) (__builtin_va_start(ap, last))
#define va_arg(ap, type) (__builtin_va_arg(ap, type))
#define va_end(ap)

#endif // INCLUDE_VARGS_H_

我们定义了几个宏,这几个宏用于取得每一个调用printk函数时传入的参数值。可能你会很诧异va_list、__builtin_va_start和__builtin_va_arg这几个类似于函数东西在何处定义,其实它们是gcc内置的变量和函数之类的存在了。GNU C提供了很多扩展,这只是其中的一个。而其他平台上通常把它们定义为宏,下面是一个简化版的定义:(事实上出于x86压栈元素长度的限制和优化的考虑,小于等于4字节的类型统一扩展到4字节压栈。大于4字节小于等于8字节的类型统一以8字节压栈(另外32位压栈指令的操作数只能是16位或者32位的))

1
2
3
4
5
#define  va_list              char *

#define va_start(p, first) (p = (va_list)&first + sizeof(first))
#define va_arg(p, next) (*(next*)((p += sizeof(next) ) - sizeof(next)))
#define va_end(p) (p = (va_list)NULL)

我们可以看到,这几个宏的作用是根据第一个参数的地址和类型,通过逐渐计算出以后每一个参数的起始地址的方法取出每一个参数。也就是说这是建立在“函数调用的参数在内存里是连续的”这一简单假设之上的。

我们知道函数调用是通过栈来传递参数的,那参数按照什么顺序入栈?入栈后使用完参数后何处的代码清理之前栈里的参数呢?

事实上传递参数的工作必须由函数调用者和函数本身来协调,即就是所谓的“调用约定”。现行的调用约定有很多中,而C语言默认的调用约定就是cdecl了,cdecl约定规定由调用者从右向左向栈里连续的压入参数,在函数返回之后,再清理掉压入的参数以保证堆栈平衡。对于类似于 func(1, 2, 3, 4); 这样的函数调用编译后生成的汇编代码类似下面这样:

1
2
3
4
5
6
push 4
push 3
push 2
push 1
call func
sub esp, 16

大家看明白没有?默认情况下按照cdecl约定,参数被从右向左连续压栈了,而且调用完后根据参数长度自行清理了参数。5明白了这些,我们就为以后的汇编和C语言函数的相互调用打好了基础。而且也明白了参数在栈里面是连续的存储的,只要知道了第一个参数在栈里的地址和每个参数的类型,就能计算出每一个参数的地址访问到它们了。

printk涉及的代码比较多,没有办法在这里一一细说了。还是那句话,需要大家主动的去探索学习。这个项目使用的printk甚至直接参考和复制了Linux早期内核里的一些思想和子函数的实现,希望大家自己去研究一下。至于使用的方法就很简单了,它和大家熟悉的printk函数没有什么太大差异。

代码级调试的实现

不知道大家之前的编码过程是否顺利?是否遇到了运行后无法得出结果的问题?我们平时构建用户级程序的时候,有很长一段都是在调试。那这个小内核能否像平时那样轻松的调试查错?如果不能或者只能进行汇编级别的调试,恐怕会对我们的后期开发造成很大的影响。毕竟在客观上bug一就避免不了,那我们能否使用平日里习惯的调试工具进行轻松的排错?答案是肯定的。我们给出的解决方案就是使用qemu联合gdb进行C语言源代码级别的调试。具体怎么做呢?

首先是通讯问题,因为qemu和gdb运行的时候毕竟是两个进程,数据交换必然涉及到进程间通信机制。所幸它们都支持一个标准的调试协议,而且开启的方法都很简单。qemu使用以下命令启动即可:

1
qemu -S -s -fda floppy.img -boot a 

这几个参数中 -fda floppy.img 和 -boot a 是指定启动的镜像,-s 这个参数指的是启动时开启1234端口等待gdb连接(这个参数从字面上看比较隐晦),-S 是指是启动时不自动开始运行,等待调试器的执行命令。以调试模式启动了虚拟机之后,再启动gdb。需要注意的是,此时的gdb没有内核程序的符号文件,没有办法进行代码级调试。解决的办法很简单,我们使用命令加载待调试内核对应的可执行文件即可。启动了gdb之后,我们依次执行以下指令即可。

1
2
3
4
file hx_kernel
target remote :1234
break kern_entry
c

这几个命令的意思分别是加载待调试文件的符号信息;连接本地的1234端口;在 kern_entry 函数处下断点;执行到断点处。如果每次调试都需要这样做的话也未免太麻烦了,所以我们可以把上面几条命令写在scripts目录里的gdbinit文件里,在启动gdb的时候自动加载执行。甚至在Makefile里也有我写的一个专门用于调试的伪目标debug 。在开始测试前,先给出我此时的目录结构以便大家核对。

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
.
|-- boot
| `-- boot.s
|-- drivers
| `-- console.c
|-- floppy.img
|-- include
| |-- common.h
| |-- console.h
| |-- debug.h
| |-- string.h
| |-- types.h
| `-- vargs.h
|-- init
| `-- entry.c
|-- kernel
| `-- debug
| |-- debug.c
| `-- printk.c
|-- libs
| |-- common.c
| `-- string.c
|-- Makefile
`-- scripts
|-- gdbinit
`-- kernel.ld

8 directories, 17 files

现在开始调试测试,执行以下命令开始调试。8

1
2
make
make debug

源码级的调试效果如图:

源码级别调试内核

剩下的调试操作和平时使用gdb的方法别无二致,所以大家应该都不陌生。有的读者可能需要学习一些查看寄存器值之类的命令,请查阅手册吧。

打印函数调用栈

解决了代码级调试的功能,我们来完成一些稍微复杂的函数,那就是当内核遇到致命错误时,如何自动打印当前的函数调用栈?这涉及到GRUB Multiboot规范的很多细节和函数调用栈的结构。我们先从Multiboot的细节说起。

在boot/boot.s里的start函数调用kern_entry函数之前,我们把ebx寄存器的值赋给了一个全局变量glb_mboot_ptr。这是一个指向了multiboot_t类型结构体的指针,这个结构体存储了GRUB在调用内核前获取的硬件信息和内核文件本身的一些信息。我们先给出具体的结构体的定义如下:

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
#ifndef INCLUDE_MULTIBOOT_H_
#define INCLUDE_MULTIBOOT_H_

#include "types.h"

typedef
struct multiboot_t {
uint32_t flags; // Multiboot 的版本信息
/**
* 从 BIOS 获知的可用内存
*
* mem_lower 和 mem_upper 分别指出了低端和高端内存的大小,单位是K。
* 低端内存的首地址是 0 ,高端内存的首地址是 1M 。
* 低端内存的最大可能值是 640K
* 高端内存的最大可能值是最大值减去 1M 。但并不保证是这个值。
*/
uint32_t mem_lower;
uint32_t mem_upper;

uint32_t boot_device; // 指出引导程序从哪个BIOS磁盘设备载入的OS映像
uint32_t cmdline; // 内核命令行
uint32_t mods_count; // boot 模块列表
uint32_t mods_addr;

/**
* ELF 格式内核映像的 section 头表。包括每项的大小、一共有几项以及作为名字索引
* 的字符串。
*/
uint32_t num;
uint32_t size;
uint32_t addr;
uint32_t shndx;

/**
* 以下两项指出保存由 BIOS 提供的内存分布的缓冲区的地址和长度
* mmap_addr 是缓冲区的地址, mmap_length 是缓冲区的总大小
* 缓冲区由一个或者多个下面的 mmap_entry_t 组成
*/
uint32_t mmap_length;
uint32_t mmap_addr;

uint32_t drives_length; // 指出第一个驱动器这个结构的大小
uint32_t drives_addr; // 指出第一个驱动器结构的物理地址
uint32_t config_table; // ROM 配置表
uint32_t boot_loader_name; // boot loader 的名字
uint32_t apm_table; // APM 表
uint32_t vbe_control_info;
uint32_t vbe_mode_info;
uint32_t vbe_mode;
uint32_t vbe_interface_seg;
uint32_t vbe_interface_off;
uint32_t vbe_interface_len;
} __attribute__((packed)) multiboot_t;

/**
* size 是相关结构的大小,单位是字节,它可能大于最小值 20
* base_addr_low 是启动地址的低32位,base_addr_high 是高 32 位,启动地址总共有 64 位
* length_low 是内存区域大小的低32位,length_high 是内存区域大小的高 32 位,总共是 64 位
* type 是相应地址区间的类型,1 代表可用 RAM,所有其它的值代表保留区域
*/
typedef
struct mmap_entry_t {
uint32_t size; // size 是不含 size 自身变量的大小
uint32_t base_addr_low;
uint32_t base_addr_high;
uint32_t length_low;
uint32_t length_high;
uint32_t type;
} __attribute__((packed)) mmap_entry_t;

// 声明全局的 multiboot_t * 指针
extern multiboot_t *glb_mboot_ptr;

#endif // INCLUDE_MULTIBOOT_H_

结构体中有很多注释,大家结合具体的协议文档很容易就可以看懂。我们暂时需要关心的主要是符号表,其它的信息我们之后用到的时候再讨论。也就是说,我们暂时只关注结构体中以下几个字段的内容即可:

1
2
3
4
5
6
7
8
9
10
......
/**
* ELF 格式内核映像的 section 头表。
* 包括每项的大小、一共有几项以及作为名字索引的字符串表。
*/
uint32_t num;
uint32_t size;
uint32_t addr;
uint32_t shndx;
......

要理解下面的内容还真有些困难,因为它涉及的面太广了。我们先以ELF的文件格式做为切入点。我们先添加elf.h这个头文件:

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
#ifndef INCLUDE_ELF_H_
#define INCLUDE_ELF_H_

#include "types.h"
#include "multiboot.h"

#define ELF32_ST_TYPE(i) ((i)&0xf)

// ELF 格式区段头
typedef
struct elf_section_header_t {
uint32_t name;
uint32_t type;
uint32_t flags;
uint32_t addr;
uint32_t offset;
uint32_t size;
uint32_t link;
uint32_t info;
uint32_t addralign;
uint32_t entsize;
} __attribute__((packed)) elf_section_header_t;

// ELF 格式符号
typedef
struct elf_symbol_t {
uint32_t name;
uint32_t value;
uint32_t size;
uint8_t info;
uint8_t other;
uint16_t shndx;
} __attribute__((packed)) elf_symbol_t;

// ELF 信息
typedef
struct elf_t {
elf_symbol_t *symtab;
uint32_t symtabsz;
const char *strtab;
uint32_t strtabsz;
} elf_t;

// 从 multiboot_t 结构获取ELF信息
elf_t elf_from_multiboot(multiboot_t *mb);

// 查看ELF的符号信息
const char *elf_lookup_symbol(uint32_t addr, elf_t *elf);

#endif // INCLUDE_ELF_H_

这段结构体定义里包含了ELF文件的区段头、符号表等信息。我们给出从multiboot_t结构中提取出ELF相关信息的代码:

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
#include "common.h"
#include "string.h"
#include "elf.h"

// 从 multiboot_t 结构获取ELF信息
elf_t elf_from_multiboot(multiboot_t *mb)
{
int i;
elf_t elf;
elf_section_header_t *sh = (elf_section_header_t *)mb->addr;

uint32_t shstrtab = sh[mb->shndx].addr;
for (i = 0; i < mb->num; i++) {
const char *name = (const char *)(shstrtab + sh[i].name);
// 在 GRUB 提供的 multiboot 信息中寻找
// 内核 ELF 格式所提取的字符串表和符号表
if (strcmp(name, ".strtab") == 0) {
elf.strtab = (const char *)sh[i].addr;
elf.strtabsz = sh[i].size;
}
if (strcmp(name, ".symtab") == 0) {
elf.symtab = (elf_symbol_t*)sh[i].addr;
elf.symtabsz = sh[i].size;
}
}

return elf;
}

// 查看ELF的符号信息
const char *elf_lookup_symbol(uint32_t addr, elf_t *elf)
{
int i;

for (i = 0; i < (elf->symtabsz / sizeof(elf_symbol_t)); i++) {
if (ELF32_ST_TYPE(elf->symtab[i].info) != 0x2) {
continue;
}
// 通过函数调用地址查到函数的名字
if ( (addr >= elf->symtab[i].value) && (addr < (elf->symtab[i].value + elf->symtab[i].size)) ) {
return (const char *)((uint32_t)elf->strtab + elf->symtab[i].name);
}
}

return NULL;
}

我们之前多次提过GRUB在载入内核之后,会读取ELF并把相关的信息组织成结构体放在multiboot_t结构,并把结构体指针放在ebx寄存器里传递给内核。其multiboot_t结构的addr成员便指向的是elf_section_header_t类型的结构体数组,num成员是这个结构体数组的成员个数。

这里的代码可能让大家一下子有点蒙,如果你觉得无从下手的话不妨在纸上画一画这几个结构体的关系图,这能帮助你理解。对于这里的代码大家不必过于深究,毕竟ELF格式只是Linux平台下的一种可执行格式,而本文档的目的是想让大家建立对项目的整体把握,细节问题就留给大家自己去理解吧。

通过以上的努力,我们获取了ELF文件中关于每个函数的名称和它们代码的区域,那么此时如何使用这些信息寻找函数名称呢?其实大家从elf_lookup_symbol函数的实现里就能看出来。我们提供了一个地址,然后查询这个地址在哪个函数的代码区间里,然后返回了这个函数名的字符串指针。

终于到了最后的函数调用栈问题了,这也是最终的打印调用栈函数panic的实现原理。

我们利用objdump文件反汇编生成的hx_kernel文件,找到入口函数的代码结合start函数的实现一起分析。反汇编的指令如下:

1
objdump -M intel -d hx_kernel

-M intel 参数是生成Intel风格的汇编,想必大家对Intel风格的汇编更熟悉吧。这个命令会反汇编所有的函数,我们找到start函数和kern_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
0010000c <start>:
10000c: fa cli
10000d: 89 1d 00 b0 10 00 mov DWORD PTR ds:0x10b000,ebx
100013: bc 03 80 00 00 mov esp,0x8003
100018: 83 e4 f0 and esp,0xfffffff0
10001b: bd 00 00 00 00 mov ebp,0x0
100020: e8 af 0a 00 00 call 100ad4 <kern_entry>
00100025 <stop>:
100025: f4 hlt
100026: eb fd jmp 100025 <stop>

00100ad4 <kern_entry>:
100ad4: 55 push ebp
100ad5: 89 e5 mov ebp,esp
100ad7: 83 ec 18 sub esp,0x16
100ada: e8 39 01 00 00 call 100c18 <console_clear>
100adf: c7 44 24 08 9b 21 10 mov DWORD PTR [esp+0x8],0x10219b
100ae6: 00
100ae7: c7 44 24 04 02 00 00 mov DWORD PTR [esp+0x4],0x2
100aee: 00
100aef: c7 04 24 00 00 00 00 mov DWORD PTR [esp],0x0
100af6: e8 33 f7 ff ff call 10022e <printk_color>
100afb: b8 00 00 00 00 mov eax,0x0
100b00: c9 leave
100b01: c3 ret

我们从start函数开始分析。首先第2行是关闭中断,因为此时尚未设置中断相关的一些数据结构,如果发生了中断的话就会崩溃。接下来第3行是我们把ebx寄存器中存储的multiboot_t结构的指针传给了全局变量glb_mboot_ptr,接着4、5行分别是初始化内核栈的栈顶指针,第5行与运算的目的是使得栈地址按照16字节对齐,这样的效率比较好。随后start函数调用了内核入口kern_entry函数。大家注意这里的call指令实际上做了两件事情,第一件事情是将call指令随后的地址压入栈,然后跳转到kern_entry函数的起始地址那里去。也就是说这里的call 100ad4 等价于以下两条指令:

1
2
push 100022
jmp 100ad4

我们这里碰巧有个stop的标号在这里,所以nasm处理成stop函数了。其实所有的函数在代码段里都是连续的,无论跳转到哪里,都会从该处开始执行的。现在大家思考这样一个问题,为什么要保存call指令的下一条指令的地址到栈里呢?其实很简单,因为子函数调用完会返回,不保存返回的地址的话怎么知道该往哪里返回呢。

我们继续往下看,kern_entry函数一开始就把ebp寄存器压栈,然后将esp赋给ebp寄存器。为什么要先压栈呢?因为在一个CPU核里所有的寄存器都只有一份,当执行流程从一个函数跳转到另外一个函数的时候,之前的寄存器可能保存着重要的信息。如果我们不保护之前的执行现场,当子函数执行完返回的时候就会出问题。那这么多寄存器全都要保存吗?当然不是,x86的规则是这样的:寄存器分为调用者保存寄存器和被调用者保存寄存器。按照惯例,eax,edx,ecx寄存器是调用者保存,ebx,esi,edi,ebp等寄存器是被调用者负责保存。举个例子,一个函数想使用ebp寄存器那么必须在返回前恢复ebp原先的值,而使用edx寄存器就无需暂存和恢复。如果我们只用C语言编程的话自然无需关注这些,因为编译器会替我们打点这一切。但是如果要实现汇编和C语言的混合编程的话,就要留心这些了。

我们回到正题。第16行的汇编指令实际上是开辟函数局部变量的操作,不过这个函数没有用到。接着又是一个函数调用,同理,压入当前指令之后一条指令的地址,然后跳转过去执行,而且之后所有的函数调用基本上都是按照这个套路进行的。当函数执行完之后,函数清理开辟的局部变量的空间,恢复在栈里保存的ebp寄存器,弹出返回地址跳转回去。这就是函数执行和返回的一个大致的流程。所以当一个函数遇到了错误的时候,我们就可以调用一个打印当前栈里的函数调用链的函数来帮助我们调试。原理很简单,所有函数的返回地址都保存在栈里,我们结合之前获取到的所有函数的名称和它们的地址区间,只要查找到这个返回地址在哪一个函数的地址区间里,就能知道之前调用的函数了。而这个查找函数我们已经实现了。

不知道我刚刚的描述大家理解了没有?如果还有点迷糊的话来看下面的这张图片,这是按照上文的描述给出的函数调用函数栈的示意图。不过需要注意的是,所有的地址是根据我自己机器上生成的汇编地址绘制的。大家可能会有不一样的地址,但是原理是一致的。

内核函数调用栈

在示意图中我们假设从start函数->kern_entry函数->console_clear函数的调用过程,最终暂停在console_clear函数里面。我们可以清楚的看到,只要拿到此时的ebp寄存器的值,就可以沿着这个调用链找到每一个调用的函数的返回地址,之前的问题就这样解决了。需要注意的是C语言里对指针做算数运算时,改变的地址长度是和当前指针变量的类型相关的。

我们分别给出最终的打印函数调用信息的panic函数的声明和实现。顺带还有几个调试使用的宏,都很简单:

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
#ifndef INCLUDE_DEBUG_H_
#define INCLUDE_DEBUG_H_

#include "console.h"
#include "vargs.h"
#include "elf.h"

#define assert(x, info) \
do { \
if (!(x)) { \
panic(info); \
} \
} while (0)

// 编译期间静态检测
#define static_assert(x) \
switch (x) { case 0: case (x): ; }

// 初始化 Debug 信息
void init_debug();

// 打印当前的函数调用栈信息
void panic(const char *msg);

// 打印当前的段存器值
void print_cur_status();

// 内核的打印函数
void printk(const char *format, ...);

// 内核的打印函数 带颜色
void printk_color(real_color_t back, real_color_t fore, const char *format, ...);

#endif // INCLUDE_DEBUG_H_

这里已经是debug.h头文件的完整的内容了,具体的几个实现函数一并在下面列出。

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
#include "debug.h"

static void print_stack_trace();
static elf_t kernel_elf;

void init_debug()
{
// 从 GRUB 提供的信息中获取到内核符号表和代码地址信息
kernel_elf = elf_from_multiboot(glb_mboot_ptr);
}

void print_cur_status()
{
static int round = 0;
uint16_t reg1, reg2, reg3, reg4;

asm volatile ( "mov %%cs, %0;"
"mov %%ds, %1;"
"mov %%es, %2;"
"mov %%ss, %3;"
: "=m"(reg1), "=m"(reg2), "=m"(reg3), "=m"(reg4));

// 打印当前的运行级别
printk("%d: @ring %d\n", round, reg1 & 0x3);
printk("%d: cs = %x\n", round, reg1);
printk("%d: ds = %x\n", round, reg2);
printk("%d: es = %x\n", round, reg3);
printk("%d: ss = %x\n", round, reg4);
++round;
}

void panic(const char *msg)
{
printk("*** System panic: %s\n", msg);
print_stack_trace();
printk("***\n");

// 致命错误发生后打印栈信息后停止在这里
while(1);
}

void print_stack_trace()
{
uint32_t *ebp, *eip;

asm volatile ("mov %%ebp, %0" : "=r" (ebp));
while (ebp) {
eip = ebp + 1;
printk(" [0x%x] %s\n", *eip, elf_lookup_symbol(*eip, &kernel_elf));
ebp = (uint32_t*)*ebp;
}
}

至此,本章要阐述的内容到此结束。我们整合所有代码,如下修改entry函数并编译运行测试一下这个打印函数调用栈的函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "console.h"
#include "debug.h"

int kern_entry()
{
init_debug();

console_clear();

printk_color(rc_black, rc_green, "Hello, OS kernel!\n");

panic("test");

return 0;
}

运行结果如图:

panic 函数测试图

添加全局段描述符表

保护模式的引入

从本章开始,我们就要开始涉及到x86保护模式编程的一些细节问题了。

我们从80386处理器入手。首先,到了80386时代,CPU有了四种运行模式,即实模式、保护模式、虚拟8086模式和SMM模式。

模式指的是8086CPU的运行模式,不过这是后来提出的概念,在8086时代只有当时的运行模式,自然也就没有“实模式”这么个提法。8086的汇编中,我们对于实模式的各种机制应该算是比较了解了,其大致包括实模式1MB的线性地址空间、内存寻址方法、寄存器、端口读写以及中断处理方法等内容。

不过到了80386时代,引进了一种沿用至今的CPU运行机制——保护模式(Protected Mode)。保护模式有一些新的特色,用来增强多工和系统稳定度,比如内存保护,分页系统,以及硬件支持的虚拟内存等。大部分现今基于x86架构的操作系统都在保护模式下运行,包括Linux、FreeBSD、以及微软Windows 2.0和之后版本(都指32位操作系统) 。

虚拟8086模式用于在保护模式下运行原来实模式下的16位程序,我们不关心。SMM模式是不对程序员开放的,所以我们也不关心。

我们先来研究保护模式,在保护模式里80386首先扩展了8086的处理器,原先的AX,BX,CX,DX,SI,DI,SP,BP从16位扩展(Extend)到了32位,并改名EAX,EBX,ECX,EDX,ESI,EDI,ESP,EBP,E就是Extend的意思。当然,保留了原先的16位寄存器的使用习惯,就像在8086下能用AH和AL访问AX的高低部分一样,不过EAX的低位部分能使用AX直接访问,高位却没有直接的方法,只能通过数据右移16位之后再访问。另外,CS,DS,ES,SS这几个16位段寄存器保留,再增加FS,GS两个段寄存器。另外还有其它很多新增加的寄存器。本着实用原则,到时候用到了我们再说。

保护模式下的内存分段

我们知道,对CPU来讲,系统中的所有储存器中的储存单元都处于一个统一的逻辑储存器中,它的容量受CPU寻址能力的限制。这个逻辑储存器就是我们所说的线性地址空间。8086有20位地址线,拥有1MB的线性地址空间。而80386有32位地址线,拥有4GB的线性地址空间。但是80386依旧保留了8086采用的地址分段的方式,只是增加了一个折中的方案,即只分一个段,段基址0×00000000,段长0xFFFFFFFF(4GB),这样的话整个线性空间可以看作就一个段,这就是所谓的平坦模型(Flat Mode)。

我们先来看保护模式下的内存是如何分段管理的。为了便于理解,我们从一个设计者的角度来研究这个问题,顺便试图按我的理解对一些机制的设计原因做一些阐释。

首先是对内存分段中每一个段的描述,实模式对于内存段并没有访问控制,任意的程序可以修改任意地址的变量,而保护模式需要对内存段的性质和允许的操作给出定义,以实现对特定内存段的访问检测和数据保护。考虑到各种属性和需要设置的操作,32位保护模式下对一个内存段的描述需要8个字节,其称之为段描述符(Segment Descriptor)。段描述符分为数据段描述符、指令段描述符和系统段描述符三种。

我们现在看一张段描述符的8个字节的分解图吧,至于每一个细节的含义请大家自行查阅Intel文档。

显然,寄存器不足以存放N多个内存段的描述符集合,所以这些描述符的集合(称之为描述符表)被放置在内存里了。在很多描述符表中,最重要的就是所谓的全局描述符表(Global Descriptor Table,GDT),它为整个软硬件系统服务。

一个问题解决了,但是又引出了的其他问题:

问题一:这些描述符表放置在内存哪里?答案是没有固定的位置,可以任由程序员安排在任意合适的位置。

问题二:既然没有指定固定位置,CPU如何知道全局描述符表在哪?答案是Intel干脆设置了一个48位的专用的全局描述符表寄存器(GDTR)来保存全局描述符表的信息

那这48位怎么分配呢?如图所示,0-15位表示GDT的边界位置(数值为表的长度-1,因为从0计算),16-47位这32位存放的就是GDT的基地址(恰似数组的首地址)。

既然用16位来表示表的长度,那么2的16次方就是65536字节,除以每一个描述符的8字节,那么最多能创建8192个描述符。

貌似说了这么多,我们一直还没提CPU的默认工作方式。80386CPU加电的时候自动进入实模式3既然CPU加电后就一直工作在实模式下了。那怎么进入保护模式呢?说来也简单,80386CPU内部有5个32位的控制寄存器(Control Register,CR),分别是CR0到CR3,以及CR8。用来表示CPU的一些状态,其中的CR0寄存器的PE位(Protection Enable,保护模式允许位),0号位,就表示了CPU的运行状态,0为实模式,1为保护模式。通过修改这个位就可以立即改变CPU的工作模式。

不过需要注意的是,一旦CR0寄存器的PE位被修改,CPU就立即按照保护模式去寻址了,所以这就要求我们必须在进入保护模式之前就在内存里放置好GDT,然后设置好GDTR寄存器。我们知道实模式下只有1MB的寻址空间,所以GDT就等于被限制在了这里。即便是再不乐意我们也没有办法,只得委屈求全的先安排在这里。不过进入保护模式之后我们就可以在4G的空间里设置并修改原来的GDTR了。

OK,现在有了描述符的数组了,也有了“数组指针”(GDTR)了,怎么表示我们要访问哪个段呢?还记得8086时代的段寄存器吧?不过此时它们改名字了,叫段选择器(段选择子)。此时的CS等寄存器不再保存段基址了,而是保存其指向段的索引信息,CPU会根据这些信息在内存中获取到段信息。

地址合成的过程如下图所示:

以上就是对x86保护模式的阐述了,细节问题希望大家能结合之后的代码自行参考Intel文档学习。

具体采用的分段策略

下面开始针对我们的内核给出具体的设计方案了。我们之前简单的阐述了分段,事实上现代操作系统几乎不再使用分段而是绕过分段技术直接使用了分页。其实分段和分页没什么必然联系。只不过Intel从8086开始,其制造的CPU就以段地址+偏移地址的方式来访问内存。后来要兼容以前的CPU,Intel不得不一直保留着这个传统。分段可以说是Intel的CPU一直保持着的一种机制,而分页只是保护模式下的一种内存管理策略。不过想开启分页机制,CPU就必须工作在保护模式,而工作在保护模式时候可以不开启分页。所以事实上分段是必须的,而分页是可选的。

那我们如何“绕过”这个分段机制呢?不知道大家是否还记得之前我们所说的平坦模式(Flat Mode)?这就是我们“绕过”的方法。当整个虚拟地址空间是一个起始地址为0,限长为4G的“段”时,我们给出的偏移地址就在数值上等于是段机制处理之后的地址了。 不过我们不是简单的对所有的段使用同样的描述符,而是给代码段和数据段分配不同的描述符。下面的示意图描述了这个抽象:

在第二章中我们谈到了GRUB在载入内核时候的一些状态,其中就有以下两条:

  1. CS 指向基地址为 0x00000000,限长为4G – 1的代码段描述符。
  2. DS,SS,ES,FS 和 GS 指向基地址为0x00000000,限长为4G–1的数据段描述符。

大家现在应该理解这两项了吧?既然GRUB已经替我们做过了,那我们还有必要再来一次吗?当然有必要了,一来是学习的必要,二来在后期实现TSS任务切换的时候还要用到全局描述符表。

下面我们给出具体的实现代码,首先是头文件和一些定义:

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
#ifndef INCLUDE_GDT_H_
#define INCLUDE_GDT_H_

#include "types.h"

// 全局描述符类型
typedef
struct gdt_entry_t {
uint16_t limit_low; // 段界限 15 ~ 0
uint16_t base_low; // 段基地址 15 ~ 0
uint8_t base_middle; // 段基地址 23 ~ 16
uint8_t access; // 段存在位、描述符特权级、描述符类型、描述符子类别
uint8_t granularity; // 其他标志、段界限 19 ~ 16
uint8_t base_high; // 段基地址 31 ~ 24
} __attribute__((packed)) gdt_entry_t;

// GDTR
typedef
struct gdt_ptr_t {
uint16_t limit; // 全局描述符表限长
uint32_t base; // 全局描述符表 32 位基地址
} __attribute__((packed)) gdt_ptr_t;

// 初始化全局描述符表
void init_gdt();

// GDT 加载到 GDTR 的函数[汇编实现]
extern void gdt_flush(uint32_t);

#endif // INCLUDE_GDT_H_

结合上面关于全局段描述符表的格式说明,这两个结构体的定义应该很容易看懂。具体的函数实现如下:

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
#include "gdt.h"
#include "string.h"

// 全局描述符表长度
#define GDT_LENGTH 5

// 全局描述符表定义
gdt_entry_t gdt_entries[GDT_LENGTH];

// GDTR
gdt_ptr_t gdt_ptr;

static void gdt_set_gate(int32_t num, uint32_t base,
uint32_t limit, uint8_t access, uint8_t gran);

// 声明内核栈地址
extern uint32_t stack;

// 初始化全局描述符表
void init_gdt()
{
// 全局描述符表界限 e.g. 从 0 开始,所以总长要 - 1
gdt_ptr.limit = sizeof(gdt_entry_t) * GDT_LENGTH - 1;
gdt_ptr.base = (uint32_t)&gdt_entries;

// 采用 Intel 平坦模型
gdt_set_gate(0, 0, 0, 0, 0); // 按照 Intel 文档要求,第一个描述符必须全 0
gdt_set_gate(1, 0, 0xFFFFFFFF, 0x9A, 0xCF); // 指令段
gdt_set_gate(2, 0, 0xFFFFFFFF, 0x92, 0xCF); // 数据段
gdt_set_gate(3, 0, 0xFFFFFFFF, 0xFA, 0xCF); // 用户模式代码段
gdt_set_gate(4, 0, 0xFFFFFFFF, 0xF2, 0xCF); // 用户模式数据段

// 加载全局描述符表地址到 GPTR 寄存器
gdt_flush((uint32_t)&gdt_ptr);
}

// 全局描述符表构造函数,根据下标构造
// 参数分别是 数组下标、基地址、限长、访问标志,其它访问标志
static void gdt_set_gate(int32_t num, uint32_t base, uint32_t limit, uint8_t access, uint8_t gran)
{
gdt_entries[num].base_low = (base & 0xFFFF);
gdt_entries[num].base_middle = (base >> 16) & 0xFF;
gdt_entries[num].base_high = (base >> 24) & 0xFF;

gdt_entries[num].limit_low = (limit & 0xFFFF);
gdt_entries[num].granularity = (limit >> 16) & 0x0F;

gdt_entries[num].granularity |= gran & 0xF0;
gdt_entries[num].access = access;
}

这里唯一麻烦的就是需要对照着Intel文档的说明,去为每一个段描述符计算权限位的数值了。至于加载全局描述符表的操作,为了方便起见我们直接用汇编语言实现了,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
gdt_flush:
mov eax, [esp+4] ; 参数存入 eax 寄存器
lgdt [eax] ; 加载到 GDTR [修改原先GRUB设置]

mov ax, 0x10 ; 加载我们的数据段描述符
mov ds, ax ; 更新所有可以更新的段寄存器
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax
jmp 0x08:.flush ; 远跳转, 0x08 是我们的代码段描述符
; 远跳目的是清空流水线并串行化处理器
.flush:
ret

我想这个汇编函数中唯一需要解释的就是jmp跳转那一句了,首先0×08是我们跳转目标段的段选择子(这个不陌生吧?),其对应段描述符第2项。后面的跳转目标标号可能会让你诧异,因为它就是下一行代码。这是为何?当然有深意了,第一,Intel不允许直接修改段寄存器cs的值,我们只好这样通过这种方式更新cs段寄存器;第二,x86以后CPU所增加的指令流水线和高速缓存可能会在新的全局描述符表加载之后依旧保持之前的缓存,那么修改GDTR之后最安全的做法就是立即清空流水线和更新高速缓存。说的这么牛逼的样子,其实只要一句jmp跳转就能强迫CPU自动更新了,很简单吧?

到这里段描述符表的创建就告一段落了,其实我们完全可以直接计算出这些段具体的表示数值然后硬编码进去,但是出于学习的目的,我们还是写了这些函数进行处理。当然了,我们没有谈及一些具体的描述符细节问题,因为Intel文档的描述都很详细。

创建好所有的文件以后,再次修改入口函数如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "gdt.h"
#include "console.h"
#include "debug.h"

int kern_entry()
{
init_debug();
init_gdt();

console_clear();
printk_color(rc_black, rc_green, "Hello, OS kernel!\n");

return 0;
}

编译运行后如果输出了正常的信息,就说明我们成功了。

添加中断描述符表

中断的引入

中断是用以提高计算机工作效率、增强计算机功能的一项重要技术。其实简单说中断就是一种通知机制罢了。我们知道操作系统的一个核心任务就是和连接在主板上的所有的硬件设备进行通信,但是CPU和这些外设的速率根本就不在一个数量级上,倘若CPU向某一个设备发出一个请求并且一直等待反馈结果的话,这样带来的性能损失是不可接受的。而且CPU在运行期间需要得知外设所发生的事件,轮询显然是不可取的,那么就迫切需要一种机制来帮助我们解决这个问题。

肩负着这一伟大使命,中断应运而生。当某个中断发生时,典型的处理方式就是CPU会打断当前的任务,保留当前的执行现场后再转移到该中断事先安排好的中断处理函数去执行。在中断处理函数执行结束之后再恢复中断之前的执行现场,去执行之前的任务。

从物理学的角度看,中断其实就是一种电信号,一般由硬件设备生成并送入中断控制器统一协调。中断控制器就是个简单的电子芯片,其作用就是将汇集的多路中断管线,采用复用技术只通过一条中断线和CPU相连接。既然中断控制器这里只有一条线和CPU相链接,那么为了区分各个设备,中断自然就有编号了。

补充一下,其实CPU的中断管脚并非只有一根,其实是有NMI和INTR两个管脚,因为从严重性上来看,中断是分为两类的,首先NMI管脚触发的中断是需要无条件立即处理的,这种类型的中断是不会被阻塞和屏蔽的,所以叫做非屏蔽中断(Non Maskable Interrupt,NMI)。事实上一旦产生了NMI中断,就意味着CPU遇到了不可挽回的错误,一般不会进行处理,只是给出一个错误信息。而我们之前所说的中断控制器连接的管脚叫做INTR,这类中断有两个特点,分别是数量多和可屏蔽。而我们主要关注的正是INTR中断。

举一个通俗的例子,假设你就是CPU,你正在看书(执行任务),突然间你的鼻涕流下来了(一个NMI中断),这个自然是不可以屏蔽的,不然会流到嘴里的…(好恶心),你现在把书反着扣在桌子上避免找不到页码(保留当前执行现场),取出纸巾…(此处省略几十个字),OK,你处理完后把书拿起来继续看(恢复之前的执行现场)。这就是一个中断的处理过程,其实很简单是不是?这是不可屏蔽中断,那么可屏蔽的呢?还是刚刚那个场景,你在看书,手机响了(一个INTR中断),但是你在学习期间不想被打扰,就无视它了…这就是可屏蔽中断了。

通俗的例子举完了,我们还是专业一点好了。在x86PC中,我们熟知的中断控制芯片就是8259A PIC了,它就是我们说的中断控制器了。Intel的处理器允许256个中断,中断号范围是0~255。8259A PIC芯片负责15个,但是并不固定中断号,允许通过IO端口设置以避免冲突。它的全称是可编程中断控制器(Programmable Interrupt Controller,PIC)。关于8259A PIC的资料网上铺天盖地的,至于8259A PIC的结构,如何屏蔽中断什么的我就不多说了,请大家自行去了解。

其实从上面的描述中我们基本上能理解中断的概念了。再简单说就是硬件发生了某个事件后告诉中断控制器,中断控制器汇报给CPU,CPU从中断控制器处得知了中断号,根据这个中断号找到对应的中断处理程序并转移过去执行,完成后重新回到之前的执行流程去。

我们之前一直说的都是硬件中断,其实除了硬件中断之外还有软件中断,也就是软件系统也可以利用中断机制来完成一些任务,比如有些OS的系统调用的实现就采用了中断的方式。

中断的实现

我们的重点是保护模式下的中断处理。中断处理程序是运行在ring0层的,这就意味着中断处理程序拥有着系统的全部权限,仿照内存段描述符表的思路,Intel设置了一个叫做中断描述符表(IDT, Interrupt Descriptor Table)的东西,和段描述符表一样放置在主存中,类似地,也有一个中断描述符表寄存器(IDTR)记录这个表的起始地址。IDT 表中可以存放三种类型的门描述符:中断门描述符、陷阱门描述符、任务门描述符。那么下文的重点就是这些门描述符的结构和设置方法了,其实这里很类似GDT的那一套过程,我们先给出中断门描述符的结构:3

中断描述符的格式

根据这个描述信息,我们给出相关的C语言结构体定义:

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
#ifndef INCLUDE_IDT_H_
#define INCLUDE_IDT_H_

#include "types.h"

// 初始化中断描述符表
void init_idt();

// 中断描述符
typedef
struct idt_entry_t {
uint16_t base_lo; // 中断处理函数地址 15 ~ 0 位
uint16_t sel; // 目标代码段描述符选择子
uint8_t always0; // 置 0 段
uint8_t flags; // 一些标志,文档有解释
uint16_t base_hi; // 中断处理函数地址 31 ~ 16 位
}__attribute__((packed)) idt_entry_t;

// IDTR
typedef
struct idt_ptr_t {
uint16_t limit; // 限长
uint32_t base; // 基址
} __attribute__((packed)) idt_ptr_t;

#endif // INCLUDE_IDT_H_

之前我们建立了中断的概念并且介绍了描述符表的结构,接下来我们细化CPU处理中断的过程,首先是起始过程,也就是从CPU发现中断事件后,打断当前程序或任务的执行,根据某种机制跳转到中断处理函数去执行的过程:

  1. CPU在执行完当前程序的每一条指令后,都会去确认在执行刚才的指令过程中是否发送中断请求过来,如果有那么CPU就会在相应的时钟脉冲到来时从总线上读取中断请求对应的中断向量。然后根据得到的中断向量为索引到IDT中找到该向量对应的中断描述符,中断描述符里保存着中断处理函数的段选择子;
  2. CPU使用IDT查到的中断处理函数段选择子从GDT中取得相应的段描述符,段描述符里保存了中断处理函数的段基址和属性信息。此时CPU要进行一个很关键的特权检验的过程,这个涉及到CPL、RPL和DPL的数值检验以及判断是否发生用户态到内核态的切换。如果发生了切换,还要涉及到TSS段和用户栈和内核栈的切换;4
  3. 确认无误后CPU开始保存当前被打断的程序的现场(即一些寄存器的值),以便于将来恢复被打断的程序继续执行。这需要利用内核栈来保存相关现场信息,即依次压入当前被打断程序使用的eflags、cs、eip、以及错误代码号(如果当前中断有错误代码);
  4. 最后CPU会从中断描述符中取出中断处理函数的起始地址并跳转过去执行。

以上是起始过程,中断处理函数执行完成之后需要通过iret或iretd指令恢复被打断的程序的执行。这时候比较简单,首先CPU会从内核栈里弹出先前保存的被打断的程序的现场信息,即之前的eflags,cs,eip重新开始被打断前的任务。5

需要注意的是:如果此次处理的是带有错误码的中断,CPU在恢复先前程序的现场时,并不会弹出错误代码。这一步需要通过软件完成,即要求相关的中断处理函数在使用iret指令返回之前添加出栈代码主动弹出错误代码。

下图描述了CPU自动保护和恢复的寄存器的栈结构:

中断发生时CPU保存的现场

CPU在发生中断的时候是按照上问所描述的过程执行的,那操作系统需要做什么呢?

首先是实现中断处理函数。按照Intel的规定,0~19号中断属于CPU所有6,而且第20-31号中断也被Intel保留,所以从32~255号才属于用户自定义中断。虽说是“用户自定义”,其实在x86上有些中断按照习惯还是给予了固定的设备。比如32号是timer中断,33号是键盘中断等等。

中断处理函数怎么实现呢?直接可以进行相关的逻辑处理吗?当然不行了。CPU在中断产生时自动保存了部分的执行现场,但是依旧有很多寄存器需要我们自己去保护和恢复,下面是CPU保护的寄存器和剩余需要保护的寄存器一起定义的结构体。

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
// 寄存器类型
typedef
struct pt_regs_t {
uint32_t ds; // 用于保存用户的数据段描述符
uint32_t edi; // 从 edi 到 eax 由 pusha 指令压入
uint32_t esi;
uint32_t ebp;
uint32_t esp;
uint32_t ebx;
uint32_t edx;
uint32_t ecx;
uint32_t eax;
uint32_t int_no; // 中断号
uint32_t err_code; // 错误代码(有中断错误代码的中断会由CPU压入)
uint32_t eip; // 以下由处理器自动压入
uint32_t cs;
uint32_t eflags;
uint32_t useresp;
uint32_t ss;
} pt_regs;

// 定义中断处理函数指针
typedef void (*interrupt_handler_t)(pt_regs *);

// 注册一个中断处理函数
void register_interrupt_handler(uint8_t n, interrupt_handler_t h);

// 调用中断处理函数
void isr_handler(pt_regs *regs);

// 声明中断处理函数 0 ~ 19 属于 CPU 的异常中断
// ISR:中断服务程序(interrupt service routine)
void isr0(); // 0 #DE 除 0 异常
void isr1(); // 1 #DB 调试异常
void isr2(); // 2 NMI
void isr3(); // 3 BP 断点异常
void isr4(); // 4 #OF 溢出
void isr5(); // 5 #BR 对数组的引用超出边界
void isr6(); // 6 #UD 无效或未定义的操作码
void isr7(); // 7 #NM 设备不可用(无数学协处理器)
void isr8(); // 8 #DF 双重故障(有错误代码)
void isr9(); // 9 协处理器跨段操作
void isr10(); // 10 #TS 无效TSS(有错误代码)
void isr11(); // 11 #NP 段不存在(有错误代码)
void isr12(); // 12 #SS 栈错误(有错误代码)
void isr13(); // 13 #GP 常规保护(有错误代码)
void isr14(); // 14 #PF 页故障(有错误代码)
void isr15(); // 15 CPU 保留
void isr16(); // 16 #MF 浮点处理单元错误
void isr17(); // 17 #AC 对齐检查
void isr18(); // 18 #MC 机器检查
void isr19(); // 19 #XM SIMD(单指令多数据)浮点异常

// 20 ~ 31 Intel 保留
void isr20();
void isr21();
void isr22();
void isr23();
void isr24();
void isr25();
void isr26();
void isr27();
void isr28();
void isr29();
void isr30();
void isr31();

// 32 ~ 255 用户自定义异常
void isr255();

一个很现实的问题是:所有的中断处理函数中,除了CPU本身保护的现场外,其它寄存器的保存和恢复过程都是一样的。所以,如果在每个中断处理函数中都实现一次显然冗余而且易错。所以我们很自然把原本的中断处理函数逻辑上拆解为三部分,第一部分是一致的现场保护操作;第二部分是每个中断特有的处理逻辑;第三部分又是一致的现场恢复。

实际上我们把每个中断处理函数拆解为四段,在四个函数里实现。具体的实现如下:

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
; 定义两个构造中断处理函数的宏(有的中断有错误代码,有的没有)
; 用于没有错误代码的中断
%macro ISR_NOERRCODE 1
[GLOBAL isr%1]
isr%1:
cli ; 首先关闭中断
push 0 ; push 无效的中断错误代码
push %1 ; push 中断号
jmp isr_common_stub
%endmacro

; 用于有错误代码的中断
%macro ISR_ERRCODE 1
[GLOBAL isr%1]
isr%1:
cli ; 关闭中断
push %1 ; push 中断号
jmp isr_common_stub
%endmacro

; 定义中断处理函数
ISR_NOERRCODE 0 ; 0 #DE 除 0 异常
ISR_NOERRCODE 1 ; 1 #DB 调试异常
ISR_NOERRCODE 2 ; 2 NMI
ISR_NOERRCODE 3 ; 3 BP 断点异常
ISR_NOERRCODE 4 ; 4 #OF 溢出
ISR_NOERRCODE 5 ; 5 #BR 对数组的引用超出边界
ISR_NOERRCODE 6 ; 6 #UD 无效或未定义的操作码
ISR_NOERRCODE 7 ; 7 #NM 设备不可用(无数学协处理器)
ISR_ERRCODE 8 ; 8 #DF 双重故障(有错误代码)
ISR_NOERRCODE 9 ; 9 协处理器跨段操作
ISR_ERRCODE 10 ; 10 #TS 无效TSS(有错误代码)
ISR_ERRCODE 11 ; 11 #NP 段不存在(有错误代码)
ISR_ERRCODE 12 ; 12 #SS 栈错误(有错误代码)
ISR_ERRCODE 13 ; 13 #GP 常规保护(有错误代码)
ISR_ERRCODE 14 ; 14 #PF 页故障(有错误代码)
ISR_NOERRCODE 15 ; 15 CPU 保留
ISR_NOERRCODE 16 ; 16 #MF 浮点处理单元错误
ISR_ERRCODE 17 ; 17 #AC 对齐检查
ISR_NOERRCODE 18 ; 18 #MC 机器检查
ISR_NOERRCODE 19 ; 19 #XM SIMD(单指令多数据)浮点异常

; 20 ~ 31 Intel 保留
ISR_NOERRCODE 20
ISR_NOERRCODE 21
ISR_NOERRCODE 22
ISR_NOERRCODE 23
ISR_NOERRCODE 24
ISR_NOERRCODE 25
ISR_NOERRCODE 26
ISR_NOERRCODE 27
ISR_NOERRCODE 28
ISR_NOERRCODE 29
ISR_NOERRCODE 30
ISR_NOERRCODE 31
; 32 ~ 255 用户自定义
ISR_NOERRCODE 255

函数执行的开始位置我们自行向栈里压入中断的号码,这是为了识别出中断号码。上面的代码用到了NASM的宏汇编,因为指令结构是相同的,所以我们可以用宏汇编来生成代码。如果大家对这里有疑问的话,可以参考NASM手册或者直接对生成的内核文件反汇编查看相关函数的实现。另外还有一点需要说明的是,因为有的中断处理函数会自动压入错误号,而有的不会,这样给我们的清栈造成了麻烦。所以我们在不会压入错误号的中断的处理函数里手动压入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
; 中断服务程序
isr_common_stub:
pusha ; Pushes edi, esi, ebp, esp, ebx, edx, ecx, eax
mov ax, ds
push eax ; 保存数据段描述符

mov ax, 0x10 ; 加载内核数据段描述符表
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax

push esp ; 此时的 esp 寄存器的值等价于 pt_regs 结构体的指针
call isr_handler ; 在 C 语言代码里
add esp, 4 ; 清除压入的参数

pop ebx ; 恢复原来的数据段描述符
mov ds, bx
mov es, bx
mov fs, bx
mov gs, bx
mov ss, bx

popa ; Pops edi, esi, ebp, esp, ebx, edx, ecx, eax
add esp, 8 ; 清理栈里的 error code 和 ISR
iret
.end:

这里声明了一个外部函数isr_handler,它的实现如下:

1
2
3
4
5
6
7
8
9
// 调用中断处理函数
void isr_handler(pt_regs *regs)
{
if (interrupt_handlers[regs->int_no]) {
interrupt_handlers[regs->int_no](regs);
} else {
printk_color(rc_black, rc_blue, "Unhandled interrupt: %d\n", regs->int_no);
}
}

从上面的代码中我们看到,事实上具体的中断处理函数的原型都是 void (pt_regs *),它们被统一组织放置在了全局的函数指针数组interrupt_handers里面。idt_hanlder函数如判断这个中断函数是否注册,如果注册了会执行该函数,否则打印出Unhandled interrupt和中断号码。

最后是中断描述符表的创建和和加载函数:

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
77
78
79
80
81
82
83
84
#include "common.h"
#include "string.h"
#include "debug.h"
#include "idt.h"

// 中断描述符表
idt_entry_t idt_entries[256];

// IDTR
idt_ptr_t idt_ptr;

// 中断处理函数的指针数组
interrupt_handler_t interrupt_handlers[256];

// 设置中断描述符
static void idt_set_gate(uint8_t num, uint32_t base, uint16_t sel, uint8_t flags);

// 声明加载 IDTR 的函数
extern void idt_flush(uint32_t);

// 初始化中断描述符表
void init_idt()
{
bzero((uint8_t *)&interrupt_handlers, sizeof(interrupt_handler_t) * 256);

idt_ptr.limit = sizeof(idt_entry_t) * 256 - 1;
idt_ptr.base = (uint32_t)&idt_entries;

bzero((uint8_t *)&idt_entries, sizeof(idt_entry_t) * 256);

// 0-32: 用于 CPU 的中断处理
idt_set_gate( 0, (uint32_t)isr0, 0x08, 0x8E);
idt_set_gate( 1, (uint32_t)isr1, 0x08, 0x8E);
idt_set_gate( 2, (uint32_t)isr2, 0x08, 0x8E);
idt_set_gate( 3, (uint32_t)isr3, 0x08, 0x8E);
idt_set_gate( 4, (uint32_t)isr4, 0x08, 0x8E);
idt_set_gate( 5, (uint32_t)isr5, 0x08, 0x8E);
idt_set_gate( 6, (uint32_t)isr6, 0x08, 0x8E);
idt_set_gate( 7, (uint32_t)isr7, 0x08, 0x8E);
idt_set_gate( 8, (uint32_t)isr8, 0x08, 0x8E);
idt_set_gate( 9, (uint32_t)isr9, 0x08, 0x8E);
idt_set_gate(10, (uint32_t)isr10, 0x08, 0x8E);
idt_set_gate(11, (uint32_t)isr11, 0x08, 0x8E);
idt_set_gate(12, (uint32_t)isr12, 0x08, 0x8E);
idt_set_gate(13, (uint32_t)isr13, 0x08, 0x8E);
idt_set_gate(14, (uint32_t)isr14, 0x08, 0x8E);
idt_set_gate(15, (uint32_t)isr15, 0x08, 0x8E);
idt_set_gate(16, (uint32_t)isr16, 0x08, 0x8E);
idt_set_gate(17, (uint32_t)isr17, 0x08, 0x8E);
idt_set_gate(18, (uint32_t)isr18, 0x08, 0x8E);
idt_set_gate(19, (uint32_t)isr19, 0x08, 0x8E);
idt_set_gate(20, (uint32_t)isr20, 0x08, 0x8E);
idt_set_gate(21, (uint32_t)isr21, 0x08, 0x8E);
idt_set_gate(22, (uint32_t)isr22, 0x08, 0x8E);
idt_set_gate(23, (uint32_t)isr23, 0x08, 0x8E);
idt_set_gate(24, (uint32_t)isr24, 0x08, 0x8E);
idt_set_gate(25, (uint32_t)isr25, 0x08, 0x8E);
idt_set_gate(26, (uint32_t)isr26, 0x08, 0x8E);
idt_set_gate(27, (uint32_t)isr27, 0x08, 0x8E);
idt_set_gate(28, (uint32_t)isr28, 0x08, 0x8E);
idt_set_gate(29, (uint32_t)isr29, 0x08, 0x8E);
idt_set_gate(30, (uint32_t)isr30, 0x08, 0x8E);
idt_set_gate(31, (uint32_t)isr31, 0x08, 0x8E);

// 255 将来用于实现系统调用
idt_set_gate(255, (uint32_t)isr255, 0x08, 0x8E);

// 更新设置中断描述符表
idt_flush((uint32_t)&idt_ptr);
}

// 设置中断描述符
static void idt_set_gate(uint8_t num, uint32_t base, uint16_t sel, uint8_t flags)
{
idt_entries[num].base_lo = base & 0xFFFF;
idt_entries[num].base_hi = (base >> 16) & 0xFFFF;

idt_entries[num].sel = sel;
idt_entries[num].always0 = 0;

// 先留下 0x60 这个魔数,以后实现用户态时候
// 这个与运算可以设置中断门的特权级别为 3
idt_entries[num].flags = flags; // | 0x60
}

加载中断描述符表的函数很简单:

1
2
3
4
5
idt_flush:
mov eax, [esp+4] ; 参数存入 eax 寄存器
lidt [eax] ; 加载到 IDTR
ret
.end:

整理好上面的代码,修改入口函数测试一下中断吧。入口代码修改如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "console.h"
#include "debug.h"
#include "gdt.h"
#include "idt.h"

int kern_entry()
{
init_debug();
init_gdt();
init_idt();

console_clear();
printk_color(rc_black, rc_green, "Hello, OS kernel!\n");

asm volatile ("int $0x3");
asm volatile ("int $0x4");

return 0;
}

编译运行,我们得到了正确的结果

完成中断请求和定时器中断

本章在其基础上讨论中断请求的实现。

我们在上一章中提到,外设的所有中断由中断控制芯片8259A统一汇集之后连接到CPU的INTR引脚。这章我们就来探究8259APIC的初始化和实现定时器的中断处理。

8259A PIC每一片可以管理8个中断源,显然一般情况下设备数量会超过这个值。这里就要提到IBM PC/AT 8259A PIC架构了,IBM的设计方案是使用8259APIC的级联功能,使用两片级联(分为主、从片)的方式来管理硬件中断。其中主片的INT端连接到CPU的INTR引脚,从片的INT连接到主片的IR2引脚。结构如下图所示:

8259A PIC级联

图中时钟中断连接在主片的IRQ0引脚,键盘中断连接在了主片的IRQ1引脚。其它的引脚暂时用不到就不说了。在上一张描述中断描述符表时我们知道了0~31号中断是CPU使用和保留的,用户可以使用的中断从32号开始。所以这里的IRQ0对应的中断号就是32号,IRQ1就是33号,然后以此类推。

理论就暂时阐述到这里,接下来是实现代码。首先是对8259A PIC的初始化,在设置中断描述符表的函数init_idt最前面加入如下代码:

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
// 初始化中断描述符表
void init_idt()
{
// 重新映射 IRQ 表
// 两片级联的 Intel 8259A 芯片
// 主片端口 0x20 0x21
// 从片端口 0xA0 0xA1

// 初始化主片、从片
// 0001 0001
outb(0x20, 0x11);
outb(0xA0, 0x11);

// 设置主片 IRQ 从 0x20(32) 号中断开始
outb(0x21, 0x20);

// 设置从片 IRQ 从 0x28(40) 号中断开始
outb(0xA1, 0x28);

// 设置主片 IR2 引脚连接从片
outb(0x21, 0x04);

// 告诉从片输出引脚和主片 IR2 号相连
outb(0xA1, 0x02);

// 设置主片和从片按照 8086 的方式工作
outb(0x21, 0x01);
outb(0xA1, 0x01);

// 设置主从片允许中断
outb(0x21, 0x0);
outb(0xA1, 0x0);

... ...
}

对8259A PIC具体的设置我们不再阐述,这种资料网上铺天盖地的都是。相信结合注释很容易理解这个简单的初始化过程。

完成了初始化之后,我们继续添加对IRQ处理函数的添加。首先是在idt.h头文件末尾添加如下内容:

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
// IRQ 处理函数
void irq_handler(pt_regs *regs);

// 定义IRQ
#define IRQ0 32 // 电脑系统计时器
#define IRQ1 33 // 键盘
#define IRQ2 34 // 与 IRQ9 相接,MPU-401 MD 使用
#define IRQ3 35 // 串口设备
#define IRQ4 36 // 串口设备
#define IRQ5 37 // 建议声卡使用
#define IRQ6 38 // 软驱传输控制使用
#define IRQ7 39 // 打印机传输控制使用
#define IRQ8 40 // 即时时钟
#define IRQ9 41 // 与 IRQ2 相接,可设定给其他硬件
#define IRQ10 42 // 建议网卡使用
#define IRQ11 43 // 建议 AGP 显卡使用
#define IRQ12 44 // 接 PS/2 鼠标,也可设定给其他硬件
#define IRQ13 45 // 协处理器使用
#define IRQ14 46 // IDE0 传输控制使用
#define IRQ15 47 // IDE1 传输控制使用

// 声明 IRQ 函数
// IRQ:中断请求(Interrupt Request)
void irq0(); // 电脑系统计时器
void irq1(); // 键盘
void irq2(); // 与 IRQ9 相接,MPU-401 MD 使用
void irq3(); // 串口设备
void irq4(); // 串口设备
void irq5(); // 建议声卡使用
void irq6(); // 软驱传输控制使用
void irq7(); // 打印机传输控制使用
void irq8(); // 即时时钟
void irq9(); // 与 IRQ2 相接,可设定给其他硬件
void irq10(); // 建议网卡使用
void irq11(); // 建议 AGP 显卡使用
void irq12(); // 接 PS/2 鼠标,也可设定给其他硬件
void irq13(); // 协处理器使用
void irq14(); // IDE0 传输控制使用
void irq15(); // IDE1 传输控制使用

然后是idt_s.s中添加相应的处理过程:

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
; 构造中断请求的宏
%macro IRQ 2
[GLOBAL irq%1]
irq%1:
cli
push byte 0
push byte %2
jmp irq_common_stub
%endmacro

IRQ 0, 32 ; 电脑系统计时器
IRQ 1, 33 ; 键盘
IRQ 2, 34 ; 与 IRQ9 相接,MPU-401 MD 使用
IRQ 3, 35 ; 串口设备
IRQ 4, 36 ; 串口设备
IRQ 5, 37 ; 建议声卡使用
IRQ 6, 38 ; 软驱传输控制使用
IRQ 7, 39 ; 打印机传输控制使用
IRQ 8, 40 ; 即时时钟
IRQ 9, 41 ; 与 IRQ2 相接,可设定给其他硬件
IRQ 10, 42 ; 建议网卡使用
IRQ 11, 43 ; 建议 AGP 显卡使用
IRQ 12, 44 ; 接 PS/2 鼠标,也可设定给其他硬件
IRQ 13, 45 ; 协处理器使用
IRQ 14, 46 ; IDE0 传输控制使用
IRQ 15, 47 ; IDE1 传输控制使用

[GLOBAL irq_common_stub]
[EXTERN irq_handler]
irq_common_stub:
pusha ; pushes edi, esi, ebp, esp, ebx, edx, ecx, eax

mov ax, ds
push eax ; 保存数据段描述符

mov ax, 0x10 ; 加载内核数据段描述符
mov ds, ax
mov es, ax
mov fs, ax
mov gs, ax
mov ss, ax

push esp
call irq_handler
add esp, 4

pop ebx ; 恢复原来的数据段描述符
mov ds, bx
mov es, bx
mov fs, bx
mov gs, bx
mov ss, bx

popa ; Pops edi,esi,ebp...
add esp, 8 ; 清理压栈的 错误代码 和 ISR 编号
iret ; 出栈 CS, EIP, EFLAGS, SS, ESP
.end:

最后是init_idt函数构造IRQ的相关描述符和具体的IRQ处理函数了。

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
// 初始化中断描述符表
void init_idt()
{
... ...
idt_set_gate(31, (uint32_t)isr31, 0x08, 0x8E);

idt_set_gate(32, (uint32_t)irq0, 0x08, 0x8E);
idt_set_gate(33, (uint32_t)irq1, 0x08, 0x8E);
idt_set_gate(34, (uint32_t)irq2, 0x08, 0x8E);
idt_set_gate(35, (uint32_t)irq3, 0x08, 0x8E);
idt_set_gate(36, (uint32_t)irq4, 0x08, 0x8E);
idt_set_gate(37, (uint32_t)irq5, 0x08, 0x8E);
idt_set_gate(38, (uint32_t)irq6, 0x08, 0x8E);
idt_set_gate(39, (uint32_t)irq7, 0x08, 0x8E);
idt_set_gate(40, (uint32_t)irq8, 0x08, 0x8E);
idt_set_gate(41, (uint32_t)irq9, 0x08, 0x8E);
idt_set_gate(42, (uint32_t)irq10, 0x08, 0x8E);
idt_set_gate(43, (uint32_t)irq11, 0x08, 0x8E);
idt_set_gate(44, (uint32_t)irq12, 0x08, 0x8E);
idt_set_gate(45, (uint32_t)irq13, 0x08, 0x8E);
idt_set_gate(46, (uint32_t)irq14, 0x08, 0x8E);
idt_set_gate(47, (uint32_t)irq15, 0x08, 0x8E);

// 255 将来用于实现系统调用
idt_set_gate(255, (uint32_t)isr255, 0x08, 0x8E);

... ...
}

// IRQ 处理函数
void irq_handler(pt_regs *regs)
{
// 发送中断结束信号给 PICs
// 按照我们的设置,从 32 号中断起为用户自定义中断
// 因为单片的 Intel 8259A 芯片只能处理 8 级中断
// 故大于等于 40 的中断号是由从片处理的
if (regs->int_no >= 40) {
// 发送重设信号给从片
outb(0xA0, 0x20);
}
// 发送重设信号给主片
outb(0x20, 0x20);

if (interrupt_handlers[regs->int_no]) {
interrupt_handlers[regs->int_no](regs);
}
}

结合代码中详细的注释和本章开始的8259A PIC的结构图,详细很容易理解这个处理过程。其实IRQ和ISR的处理过程很类似:

  • ISR的处理过程是 (isr0 - isr31) -> isr_common_stub -> isr_handler -> 具体的ISR处理函数。
  • IRQ的处理过程是 (irq0 - irq15) -> irq_common_stub -> irq_hanlder -> 具体的IRQ处理函数。

写到这里具体的IRQ处理过程就完成了,以后只需要设置好相应的处理函数就好了,接下来我们实现时钟中断的产生和处理。

时钟中断对于操作系统内核来说很重要的一种中断,它使得CPU无论在执行任何用户或者内核的程序时,都能定义的将执行权利交还到CPU手中来。2除了记录时间之外,时钟中断的处理函数里通常都是对进程的调度处理。

具体的时钟中断源是8253/8254 Timer产成的,要按照需要的频率产生中断,需要先配置8253/8254 Timer芯片。代码如下:

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
#include "timer.h"
#include "debug.h"
#include "common.h"
#include "idt.h"

void timer_callback(pt_regs *regs)
{
static uint32_t tick = 0;
printk_color(rc_black, rc_red, "Tick: %d\n", tick++);
}

void init_timer(uint32_t frequency)
{
// 注册时间相关的处理函数
register_interrupt_handler(IRQ0, timer_callback);

// Intel 8253/8254 PIT芯片 I/O端口地址范围是40h~43h
// 输入频率为 1193180,frequency 即每秒中断次数
uint32_t divisor = 1193180 / frequency;

// D7 D6 D5 D4 D3 D2 D1 D0
// 0 0 1 1 0 1 1 0
// 即就是 36 H
// 设置 8253/8254 芯片工作在模式 3 下
outb(0x43, 0x36);

// 拆分低字节和高字节
uint8_t low = (uint8_t)(divisor & 0xFF);
uint8_t hign = (uint8_t)((divisor >> 8) & 0xFF);

// 分别写入低字节和高字节
outb(0x40, low);
outb(0x40, hign);
}

对应的头文件如下:

1
2
3
4
5
6
7
8
#ifndef INCLUDE_TIMER_H_
#define INCLUDE_TIMER_H_

#include "types.h"

void init_timer(uint32_t frequency);

#endif // INCLUDE_TIMER_H_

8253/8254 Timer有三种工作模式,我们使用第三种。init_timer函数的参数是所需的时钟中断的频率,具体的设置原理不再赘述。最后,修改入口函数进行测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "console.h"
#include "debug.h"
#include "gdt.h"
#include "idt.h"
#include "timer.h"

int kern_entry()
{
init_debug();
init_gdt();
init_idt();

console_clear();
printk_color(rc_black, rc_green, "Hello, OS kernel!\n");

init_timer(200);

// 开启中断
asm volatile ("sti");

return 0;
}

最后编译执行,我们看到了如下的输出:

物理内存管理的实现

之前的章节中简单介绍了段式的内存管理,这章我们来讨论页式的内存管理。

首先是CPU在保护模式下分页未开启和分页开启的不同状态时,MMU组件处理地址的流程。

  • 如果没有开启分页:
    逻辑地址 -> 段机制处理 -> 线性地址 = 物理地址
  • 如果开启分页:
    逻辑地址 -> 段机制处理 -> 线性地址 -> 页机制处理 -> 物理地址

因为采用了平坦模式,所以给出的访问地址实际上已经等于线性地址了(段基址为0),那么剩下的问题就是所谓的页机制处理了。

长时间以来,随着计算机技术的发展,存储器的容量在不断的高速增加着。但是说起内存(这里指RAM,下同)这个东西,它有一个很奇葩的特性,就是无论它有多大,都总是不够用(P.S.厨房的垃圾桶也一样)。现在我们看似拥有着以前的程序员想都不敢想的“天文数字”的内存,动辄就是几G十几G的。但是相信我,历史总是嘲弄人的。就像当年程序员们质疑32位地址线带来的4GB空间太大没有意义似的,我们也会有一天抱怨现在的内存太小的。

那么,既然内存总是不够用的,那内存不够用了怎么办?还有,使用过程中出现的内存碎片怎么办?假设我们有4GB的物理内存,现在有1、2、3、4一共4个程序分别各占据连续的1G内存,然后2、4退出,此时虽然有空闲的两段内存,却连一个稍大于1GB的程序都无法载入了。

当然了,这只是一个例子。不过按照一般的思路,在内存释放之后,如何回收呢?做碎片整理吗?即便我们不在乎整理过程带来的效率损失,光是程序加载时候的地址逐一重定位就是及其麻烦的。那怎么办?当然了,解决的办法是有的,聪明的计算机工程师们想到了采用分页的方式来管理物理内存。他们在逻辑上把内存划分为定长的物理页,同时将一个程序执行时候的线性地址地址空间划分为逻辑页,在分页机制工作的前提下,给硬件提供一组数据结构来保存这种映射关系。也就是说,线性地址是连续的,但是其实际指向的物理地址就不见得是连续的了。别忘了,RAM是随机存储器,读取任意一个地址的理论时间都是一样的(暂时让我们忘了cache吧)。我们让CPU在寻址的时候,自动去查找线性地址到物理地址的映射关系,从而找到实际的数据就好。严格说地址翻译是由MMU组件来进行的,但是现在MMU一般都是CPU的一个组成部分了,所以也就不严格区分了。

下面的图片描述了这个映射关系:

分页映射

一图胜千言,我们看到了固定大小的物理页、虚拟页、甚至还有磁盘页。我觉得这张图片很能说明问题了,相信聪明的你从这里都悟出来了虚拟内存的实现原理了。没错,虚拟内存实质上就是把物理内存中暂时用不到的内容暂时换出到外存里,空出内存放置现阶段需要的数据。至于替换的策略当然有相应的算法了,比如最先换入原则,最少使用原则等等方法可以使用。

相信通过上文的描述,我们对分页已经建立了初步的理解了。那么接下来的问题是,怎么表示和存储这个映射关系。这里描述起来简单,但是代码就不是那么直观了,原因很简单,因为需要一组数据结构来管理内存,但是这组数据结构本身也得放在内存里。所以牵扯到一个自己管理自己的问题。而且,开启分页模式之后,CPU立即就会按照分页模式的规则去解释线性地址了。所以,这意味着必须先建立好地址映射的数据结构,才能开启分页,而且必须保证之前的代码地址和数据地址都能映射正确。

下面我们来说说x86下的一种简单的做法吧。

在32位操作系统下使用32位地址总线(暂时原谅我在这里错误的描述吧,其实还有PAE这个东西),所以寻址空间有2的32次方,也就是4GB。一定要注意,我们强调了很多次了,这个空间里,有一些断断续续的地址实际上是指向了其它的外设,不过大部分还是指向RAM的。具体采取的分页大小可以有多种选择,但是过于小的分页会造成管理结构太大,过于大的分页又浪费内存。现在较为常见的分页是4KB一个页,也就是4096字节一个页。简单计算下,4GB的内存分成4KB一个的页,那就是1MB个页,没错吧?每个虚拟页到物理页的映射需要4个字节来存储的话(别忘了前提是32位环境下),整个4GB空间的映射需要4MB的数据结构来存储。

目前看起来一切都很好,4MB似乎也不是很大。但是,这只是一个虚拟地址空间的映射啊,别忘了每个进程都有自己的映射,而且操作系统中通常有N个进程在运行。这样的话,假如有100个进程在运行,就需要400MB的内存来存储管理信息!这就太浪费了。

怎么办?聪明的工程师们提出了分级页表的实现策略,他们提出了页目录,页表的概念。以32位的地址来说,分为3段来寻址,分别是地址的低12位,中间10位和高10位。高10位表示当前地址项在页目录中的偏移,最终偏移处指向对应的页表,中间10位是当前地址在该页表中的偏移,我们按照这个偏移就能查出来最终指向的物理页了,最低的12位表示当前地址在该物理页中的偏移。就这样,我们就实现了分级页表。

我们来张图看看:

多级页表

也许你已经算出来了,这样做的话映射4GB地址空间需要4MB+4KB的内存。我们这是搬起石头砸了自己的脚吗?当然不是,因为在一个进程中,实际使用到的内存大都远没有4GB这么大,所以通过两级页表的映射,我们就可以只映射需要的地址就可以了,是不是节省了内存呢?概念我们暂时就说到这里,更专业的描述和规范请参阅Intel文档,也就是上面那张图片的出处。

说完了理论,接下来就是具体的实现了。我们把内存管理分为物理内存管理和虚拟内存管理两个部分来进行。本章讨论物理内存的管理。要实现内存的管理,首先要解决以下三个问题:

  1. 如何获取可用物理内存的大小和地址?
  2. 采用什么样的数据结构来描述物理内存?
  3. 申请和释放物理内存的算法如何实现?

我们先来解决第一个问题。获取物理内存的方法一般有BIOS调用和直接探测等方法,但是GRUB的Mutliboot协议提供了更简单的方法。还记得那个multiboot_t结构体吗?GRUB已经获取了物理内存的分布并且把它们放置在了这个结构体里的以下两个成员里。

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
typedef
struct multiboot_t {

... ...

/**
* 以下两项指出保存由 BIOS 提供的内存分布的缓冲区的地址和长度
* mmap_addr 是缓冲区的地址, mmap_length 是缓冲区的总大小
* 缓冲区由一个或者多个下面的 mmap_entry_t 组成
*/
uint32_t mmap_length;
uint32_t mmap_addr;

... ...

} __attribute__((packed)) multiboot_t;

/**
* size 是相关结构的大小,单位是字节,它可能大于最小值 20
* base_addr_low 是启动地址的低32位,base_addr_high 是高 32 位,启动地址总共有 64 位
* length_low 是内存区域大小的低32位,length_high 是内存区域大小的高 32 位,总共是 64 位
* type 是相应地址区间的类型,1 代表可用 RAM,所有其它的值代表保留区域
*/
typedef
struct mmap_entry_t {
uint32_t size; // size 是不含 size 自身变量的大小
uint32_t base_addr_low;
uint32_t base_addr_high;
uint32_t length_low;
uint32_t length_high;
uint32_t type;
} __attribute__((packed)) mmap_entry_t;

GRUB将内存探测的结果按每个分段整理为mmap_entry结构体的数组。mmap_addr是这个结构体数组的首地址,mmap_length是整个数组的长度。

这里需要留意的是mmap_entry结构体的size成员指的是除了size之外的成员的大小。至于base和length拆为了两段是因为物理地址可能用32位表示不下,不过我们只考虑32位操作系统,而且暂时只支持512MB的内存即可。type成员用来描述这个内存段的属性,因为物理不一定指向RAM里,也可能是其它外设。

下面的代码实现了遍历这个结构,打印所有物理内存段的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include "multiboot.h"
#include "common.h"
#include "debug.h"
#include "pmm.h"

void show_memory_map()
{
uint32_t mmap_addr = glb_mboot_ptr->mmap_addr;
uint32_t mmap_length = glb_mboot_ptr->mmap_length;

printk("Memory map:\n");

mmap_entry_t *mmap = (mmap_entry_t *)mmap_addr;
for (mmap = (mmap_entry_t *)mmap_addr; (uint32_t)mmap < mmap_addr + mmap_length; mmap++) {
printk("base_addr = 0x%X%08X, length = 0x%X%08X, type = 0x%X\n",
(uint32_t)mmap->base_addr_high, (uint32_t)mmap->base_addr_low,
(uint32_t)mmap->length_high, (uint32_t)mmap->length_low,
(uint32_t)mmap->type);
}
}

现在第一个问题解决了。等等,我们还需要知道内核本身加载到物理内存的位置信息,这块内存必须是物理内存管理所保留的。那怎么获取呢?看起来很困难,其实解决起来特别容易。大家想想,链接器负责了整个内核文件的链接和重定位工作,肯定知道内核文件加载到内存中的位置。我们修改链接器脚本,定义两个变量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 ... ...

. = 0x100000;
PROVIDE( kern_start = . );
.text :
{
*(.text)
. = ALIGN(4096);
}

... ....

.stabstr :
{
*(.stabstr)
. = ALIGN(4096);
}
PROVIDE( kern_end = . );

... ...

从上面的代码里可以看到最先放置的段.text的开始位置和最后一个段.stabstr的结尾分别定义了两个变量kern_start和kern_end,这两个变量在C代码里声明后就可以使用了。

我们添加如下的头文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifndef INCLUDE_PMM_H
#define INCLUDE_PMM_H

#include "multiboot.h"

// 内核文件在内存中的起始和结束位置
// 在链接器脚本中要求链接器定义
extern uint8_t kern_start[];
extern uint8_t kern_end[];

// 输出 BIOS 提供的物理内存布局
void show_memory_map();

#endif // INCLUDE_PMM_H

接着修改入口函数进行测试,代码如下:

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
#include "console.h"
#include "debug.h"
#include "gdt.h"
#include "idt.h"
#include "timer.h"
#include "pmm.h"

int kern_entry()
{
init_debug();
init_gdt();
init_idt();

console_clear();
printk_color(rc_black, rc_green, "Hello, OS kernel!\n\n");

init_timer(200);

// 开启中断
// asm volatile ("sti");

printk("kernel in memory start: 0x%08X\n", kern_start);
printk("kernel in memory end: 0x%08X\n", kern_end);
printk("kernel in memory used: %d KB\n\n", (kern_end - kern_start + 1023) / 1024);

show_memory_map();

return 0;
}

编译运行之后结果如下:

物理内存布局

我们采用的是qemu虚拟机默认的配置,所以物理内存大约是32MB。从这个输出结果中可以看到,可用的内存段是1MB以下的0x0-0x9FC00和1M以上的0x100000-0x7EFE000两段。需要注意的是这个区域随着我们的虚拟机内存设置不同而不同,所以这个值只是我自己虚拟机跑出来的一种结果。另外我们也成功的输出了内核的起始位置0x100000(1MB处)和结束位置0x114000以及占用的内存大小是80KB。1

第一个问题算是完全解决了,下面是第二个问题:采用什么样的数据结构来描述物理内存?

为了支持后续的虚拟内存管理,物理内存也必须按照4KB的页框来管理内存。1MB以下的物理地址区域有太多的外设的存储区域映射在这里,为了大家实验的时候避免一些不必要的麻烦,我们不使用1MB以下的零碎物理内存,而是直接在1MB以上支持最多512MB的物理内存管理。

物理内存管理算法最著名的就是Linux内核所采用的伙伴算法了,这方面的资料也很容易获取到。伙伴算法在申请和释放物理页框的时候会对物理页框进行合并操作,尽可能的保证可用物理内存的连续性。这里需要引入内存碎片这个概念,内存碎片分为内部碎片和外部碎片两种。内部碎片就是已经被分配出去却不能被利用的内存空间,比如我们为了管理方便,按照4KB内存块进行管理。此时任何申请内存的操作都会以4KB的倍数返回内存块。即使申请1个字节也返回指向4KB大的内存块的指针,这样的话造成了分配出去的内存没有被有效利用,而且剩余空间无法分配给其它进程(因为最小的管理单位是4KB)。外部碎片是指内存频繁请求和释放大小不同的连续页框后,导致在已分配页框块周围分散了许多小块空闲的页框,尽管这些空闲页框的总数可以满足接下来的请求,但却无法满足一个大块的连续页框。

不过在本章并不会去实现伙伴算法。理由很简单,如果在大家对物理内存管理都没有概念的时候就贸然引入伙伴算法后带来的机制和策略的双重复杂性就难以学习和接受了。我们会采用一个很简单的策略来对内存进行管理操作:将物理页面的管理地址设定在1MB以上内核加载的结束位置之后,从这个起始位置到512MB的地址处将所有的物理内存按页划分,将每页的地址放入栈里存储。这样在需要的时候就可以按页获取到物理内存了,具体的实现如下:

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
#include "multiboot.h"
#include "common.h"
#include "debug.h"
#include "pmm.h"

// 物理内存页面管理的栈
static uint32_t pmm_stack[PAGE_MAX_SIZE+1];

// 物理内存管理的栈指针
static uint32_t pmm_stack_top;

// 物理内存页的数量
uint32_t phy_page_count;

void show_memory_map()
{
uint32_t mmap_addr = glb_mboot_ptr->mmap_addr;
uint32_t mmap_length = glb_mboot_ptr->mmap_length;

printk("Memory map:\n");

mmap_entry_t *mmap = (mmap_entry_t *)mmap_addr;
for (mmap = (mmap_entry_t *)mmap_addr; (uint32_t)mmap < mmap_addr + mmap_length; mmap++) {
printk("base_addr = 0x%X%08X, length = 0x%X%08X, type = 0x%X\n",
(uint32_t)mmap->base_addr_high, (uint32_t)mmap->base_addr_low,
(uint32_t)mmap->length_high, (uint32_t)mmap->length_low,
(uint32_t)mmap->type);
}
}

void init_pmm()
{
mmap_entry_t *mmap_start_addr = (mmap_entry_t *)glb_mboot_ptr->mmap_addr;
mmap_entry_t *mmap_end_addr = (mmap_entry_t *)glb_mboot_ptr->mmap_addr + glb_mboot_ptr->mmap_length;

mmap_entry_t *map_entry;

for (map_entry = mmap_start_addr; map_entry < mmap_end_addr; map_entry++) {

// 如果是可用内存 ( 按照协议,1 表示可用内存,其它数字指保留区域 )
if (map_entry->type == 1 && map_entry->base_addr_low == 0x100000) {

// 把内核结束位置到结束位置的内存段,按页存储到页管理栈里
// 最多支持512MB的物理内存
uint32_t page_addr = map_entry->base_addr_low + (uint32_t)(kern_end - kern_start);
uint32_t length = map_entry->base_addr_low + map_entry->length_low;

while (page_addr < length && page_addr <= PMM_MAX_SIZE) {
pmm_free_page(page_addr);
page_addr += PMM_PAGE_SIZE;
phy_page_count++;
}
}
}
}

uint32_t pmm_alloc_page()
{
assert(pmm_stack_top != 0, "out of memory");

uint32_t page = pmm_stack[pmm_stack_top--];

return page;
}

void pmm_free_page(uint32_t p)
{
assert(pmm_stack_top != PAGE_MAX_SIZE, "out of pmm_stack stack");

pmm_stack[++pmm_stack_top] = p;
}

其对应的头文件如下:

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
#ifndef INCLUDE_PMM_H
#define INCLUDE_PMM_H

#include "multiboot.h"

// 线程栈的大小
#define STACK_SIZE 8192

// 支持的最大物理内存(512MB)
#define PMM_MAX_SIZE 0x20000000

// 物理内存页框大小
#define PMM_PAGE_SIZE 0x1000

// 最多支持的物理页面个数
#define PAGE_MAX_SIZE (PMM_MAX_SIZE/PMM_PAGE_SIZE)

// 页掩码 按照 4096 对齐地址
#define PHY_PAGE_MASK 0xFFFFF000

// 内核文件在内存中的起始和结束位置
// 在链接器脚本中要求链接器定义
extern uint8_t kern_start[];
extern uint8_t kern_end[];

// 动态分配物理内存页的总数
extern uint32_t phy_page_count;

// 输出 BIOS 提供的物理内存布局
void show_memory_map();

// 初始化物理内存管理
void init_pmm();

// 返回一个内存页的物理地址
uint32_t pmm_alloc_page();

// 释放申请的内存
void pmm_free_page(uint32_t p);

#endif // INCLUDE_PMM_H

最后修改入口函数为以下内容:

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
#include "console.h"
#include "debug.h"
#include "gdt.h"
#include "idt.h"
#include "timer.h"
#include "pmm.h"

int kern_entry()
{
init_debug();
init_gdt();
init_idt();

console_clear();
printk_color(rc_black, rc_green, "Hello, OS kernel!\n");

init_timer(200);

// 开启中断
// asm volatile ("sti");

printk("kernel in memory start: 0x%08X\n", kern_start);
printk("kernel in memory end: 0x%08X\n", kern_end);
printk("kernel in memory used: %d KB\n\n", (kern_end - kern_start) / 1024);

show_memory_map();
init_pmm();

printk_color(rc_black, rc_red, "\nThe Count of Physical Memory Page is: %u\n\n", phy_page_count);

uint32_t allc_addr = NULL;
printk_color(rc_black, rc_light_brown, "Test Physical Memory Alloc :\n");
allc_addr = pmm_alloc_page();
printk_color(rc_black, rc_light_brown, "Alloc Physical Addr: 0x%08X\n", allc_addr);
allc_addr = pmm_alloc_page();
printk_color(rc_black, rc_light_brown, "Alloc Physical Addr: 0x%08X\n", allc_addr);
allc_addr = pmm_alloc_page();
printk_color(rc_black, rc_light_brown, "Alloc Physical Addr: 0x%08X\n", allc_addr);
allc_addr = pmm_alloc_page();
printk_color(rc_black, rc_light_brown, "Alloc Physical Addr: 0x%08X\n", allc_addr);

return 0;
}

最终的测试结果如图所示:

虚拟内存管理的实现

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022 ZHU
  • 访问人数: | 浏览次数:

请我喝杯咖啡吧~

支付宝
微信