编程环境:Ubuntu Kylin 16.04
代码仓库:https://gitee.com/AprilSloan/linux0.11-project
工程结构:每一个目录对应于一章的内容,如chapter_01对应于第一章。每一章包含多个小节,如chapter_01/1st代表第一章第一节(简单的启动盘),所有的shell命令都默认当前目录为小节的目录。
linux0.11源码下载(不能直接编译,需进行修改)
原本linux0.11是用gas汇编编写bootloader,我认为大家对gas汇编不太熟悉,所以用Intel汇编编写bootloader。部分汇编知识和计算机知识并不会详细讲述,毕竟这是博客不是写书,还请大家见谅。
1.简单的启动盘
来写一个简单的启动盘吧。启动盘有两个最基本的要求,一是大小必须为512字节,二是最后两个字节必须是0x55和0xaa,不然这不会被识别为启动盘。
以下为bootsect.s的内容。
start:
jmp start ; 死循环
times 0x1fe - ($ - $$) db 0 ; 填写0,直到0x1fe
dw 0xaa55 ; 启动盘标识
第1、2行是死循环,第4行是让jmp指令后到0x1fe的空间全部填充为0。第五行是让地址0x1fe和0x1ff分别为0x55和0xaa。
利用如下指令编译汇编文件。(没有安装nasm的话,用sudo apt install nasm
安装nasm)
nasm boot/bootsect.s -o boot/bootsect.bin
此时,bootsect.bin就可以用来仿真调试了,但为了规范,我们还是将bootsect.bin写入软盘中,再用软盘启动。所以,我们要创建一个软盘。这里使用bximage创建软盘,安装bochs仿真器的过程中就会安装bximage,大家可以看看我的另一篇博客安装bochs:Linux下bochs的安装与使用,里面也有使用bochs的方法。
接下来要将bootsect.bin写入kernel.img中,使用如下命令:
dd if=boot/bootsect.bin of=kernel.img bs=512 count=1 conv=notrunc
现在就可以用kernel.img进行仿真调试了。但还别急,要使用bochs仿真还需要配置文件固定仿真使用的cpu、内存大小、软盘启动设置等等,这个文件在仓库中有,我命名为bochsrc,内容如下。
plugin_ctrl: unmapped=1, biosdev=1, speaker=1, extfpuirq=1, parallel=1, serial=1, iodebug=1
config_interface: textconfig
#使用GUI调试
display_library: x, options="gui_debug"
cpu: model=core2_penryn_t9600, count=1, ips=50000000, reset_on_triple_fault=1, ignore_bad_msrs=1, msrs="msrs.def"
cpu: cpuid_limit_winnt=0
cpuid: x86_64=1, mmx=1, sep=1, simd=sse4_2, apic=xapic, aes=1, movbe=1, xsave=1
cpuid: family=6, model=0x1a, stepping=5
romimage: file=$BXSHARE/BIOS-bochs-latest
vgaromimage: file=$BXSHARE/VGABIOS-lgpl-latest
vga: extension=vbe, update_freq=5
keyboard: type=mf, serial_delay=250, paste_delay=100000, user_shortcut=none
mouse: type=ps2, enabled=0, toggle=ctrl+mbutton
pci: enabled=1, chipset=i440fx
clock: sync=none, time0=local, rtc_sync=0
private_colormap: enabled=0
#软盘启动的配置,kernel.img为软盘名
floppya: type=1_44, 1_44=kernel.img, status=inserted, write_protected=0
ata0: enabled=1, ioaddr1=0x1f0, ioaddr2=0x3f0, irq=14
ata1: enabled=0, ioaddr1=0x170, ioaddr2=0x370, irq=15
ata2: enabled=0, ioaddr1=0x1e8, ioaddr2=0x3e0, irq=11
ata3: enabled=0, ioaddr1=0x168, ioaddr2=0x360, irq=9
# 硬盘启动配置,kernel.img为硬盘名,根据硬盘大小不同需改变后三个参数的值
#ata0-master: type=disk, mode=flat, path=kernel.img, cylinders=130, heads=16, spt=63
#ata0-slave: type=none
#ata0-master: type=none
#ata1-slave: type=none
# 软盘启动/硬盘启动
boot: floppy
# boot: disk
floppy_bootsig_check: disabled=0
log: -
logprefix: %t%e%d
panic: action=ask
error: action=report
info: action=report
debug: action=ignore
debugger_log: -
com1: enabled=1, mode=null
com2: enabled=0
com3: enabled=0
com4: enabled=0
parport1: enabled=1, file=none
parport2: enabled=0
speaker: enabled=1, mode=system
magic_break: enabled=0
print_timestamps: enabled=0
port_e9_hack: enabled=0
megs: 2048
好了,开始仿真调试吧!
bochs -qf bochsrc
输入以上命令会出现两个界面,如下所示:
第一个界面会显示操作系统的汇编代码,当前的寄存器数值,可以查看内存内容,输入指令控制程序运行。第二个程序是操作系统运行时用于显示的界面。
在第一个界面中分别输入以下两个指令:
b 0x7c00
c
第一个指令用于在0x7c00地址打断点,第二个指令让程序继续运行,程序会在断点处停止。界面会变成如下所示:
这是我们编写的代码。我们可以看到,程序被加载到了0x7c00地址处,那为什么是在这个地址呢?这是约定俗成的,电脑开机,会将磁盘的前512字节内容拷贝到0x7c00地址,如果这512字节不是以0x55和0xaa结束,会报错说找不到启动盘。
这一节的内容就到此为止了,下一节让我来打印Hello World吧。
2.打印Hello World
学习编程必不可少的是什么?是语法或数据结构吗?不,是Hello World!
我们对bootsect.s进行修改。
start:
mov ax, 0x07c0
mov es, ax
mov dx, 0 ; 光标位置为(0,0)
mov cx, 16 ; 写16个字符
mov bx, 0x0007 ; 页面0,颜色模式7
mov bp, msg ; 字符串地址
mov ax, 0x1301 ; 写字符串,光标随之移动
int 0x10 ; 进入BIOS中断
jmp $ ; 死循环
msg: ; 要打印的字符串
db 13, 10
db "Hello World!"
db 13, 10
times 0x1fe - ($ - $$) db 0 ; 填写0,直到0x1fe
dw 0xaa55 ; 启动盘标识
这段程序打印Hello World!后进入死循环。虽然我把程序执行结果告诉你了,但你还是想知道第2-8行是什么意思对不对?推荐下载BIOS接口技术参考手册,这是我的资源,不收C币,免费下载。查阅手册,找到int 0x10下的如下内容:
虽然是英文的,但也不难。汇编程序中改变了ax,bx,cx,dx,es,bp等寄存器的值,改变这些寄存器的目的都在上图中指出。
最后得到的结果如图所示。
有没有觉得每次在终端里敲命令很繁琐?就不能一条命令就编译内核然后启动仿真器吗?当然可以!现在轮到脚本和Makefile出场了。
使用bximage制作软盘虽然只需要敲几下键盘就可以了,但本着能偷懒就偷懒的精神,我用脚本(在Makefile中出错了)实现了制作软盘的步骤,命名为mkimg.sh。
#!/bin/bash
RED_COLOR='\E[41m'
BLUE_COLOR='\E[44m'
RESET='\E[0m'
echo -e "${BLUE_COLOR}=== env check ===${RESET}"
if [ ! -e bochsrc ];then
echo -e "${RED_COLOR}=== no bochsrc ===${RESET}"
exit 1
fi
if [ ! -e /usr/local/bin/bochs ];then
echo "${RED_COLOR}=== no bochs ===${RESET}"
exit 1
fi
if [ ! -e /usr/local/bin/bximage ];then
echo "${RED_COLOR}=== no bximage ===${RESET}"
exit 1
fi
if [ -e kernel.img ]; then
rm kernel.img
fi
echo -e "1\nfd\n\nkernel.img\n" | bximage
我在这里面添加了不少提示性的信息,最后一句才是精华,好好品味吧。
Makefile要完成所有的工作,之后,一条make
指令就可以开始仿真调试了。
default: all
all: Image
Image: mkimg boot/bootsect.bin
dd if=boot/bootsect.bin of=kernel.img bs=512 count=1 conv=notrunc
bochs -qf bochsrc
boot/bootsect.bin: boot/bootsect.s
nasm boot/bootsect.s -o boot/bootsect.bin
mkimg:
./mkimg.sh
clean:
rm -rf boot/*.bin kernel.img
这个Makefile并不难理解(每次有人说**不难理解,我都想打人,我终究活成了自己讨厌的摸样:-),不做过多赘述。
这节内容到此结束,下节会详细讲讲启动盘的任务。
3.boot和loader
bootloader的作用是什么?它主要完成加载内核以及系统初始化,把加载内核的工作称为boot,把系统初始化称为loader。bootsect.s完成boot部分,setup.s完成loader部分。
系统一开始只会将bootsect.s的内容,而setup.s的内容就需要让bootsect.s加载到内存中。我们首先将bootsect.s的内容从0x7c00移动到0x90000,再将setup.s的内容加载到0x90200。前面0x00000~0x90000的空间之后会用来存放内核。这里我们假设setup.s的内容会占用4个扇区。
下面会小小地改动bootsect.s的内容。
SETUPLEN equ 4
BOOTSEG equ 0x07c0
INITSEG equ 0x9000
SETUPSEG equ 0x9020
start:
mov ax, BOOTSEG
mov ds, ax
mov ax, INITSEG
mov es, ax
mov cx, 256
sub si, si
sub di, di
rep
movsw ; 将bootsect.s从0x7c00移动到0x90000
jmp INITSEG:go
go: mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xff00
load_setup:
mov dx, 0x00
mov cx, 0x02
mov bx, 0x0200
mov ax, 0x0200 + SETUPLEN
int 0x13 ; 加载setup.s到0x90200
jnc ok_load_setup
mov dx, 0x00
mov ax, 0x00
int 0x13
jmp load_setup ; 加载失败则复位软盘,重新加载
ok_load_setup:
mov ah, 0x03
xor bh, bh
int 0x10 ; 获取光标位置
mov cx, 24
mov bx, 0x0007
mov bp, msg
mov ax, 0x1301
int 0x10 ; 打印字符串
jmp SETUPSEG:0 ; 跳转到setup.s的内容
msg:
db 13, 10
db "Loading system ..."
db 13, 10, 13, 10
times 0x1fe - ($ - $$) db 0 ; 填写0,直到0x1fe
dw 0xaa55 ; 启动盘标识
这个内容不算多吧。。。前4行定义了4个宏定义,方便以后修改或维护。第7~15行将移动bootsect.s的内容,rep movsw
会将[ds:si]地址的两个字节移动到[es:di]处,每次移动si和di都加1,一共移动cx次。然后是跳转到移动后的地址。
跳转后要重新设置段寄存器和sp,这些寄存器在之后都会用到,sp的值只要远大于512即可。
做完这些就开始加载setup.s的内容,setup.s的内容保存在第2~5扇区内容。查BIOS接口技术参考手册的int 0x13可以知道:
dh=0,dl=0,ch=0,cl=2代表第2扇区,al=4代表读取4扇区,将内容读到es:bx(0x9000:0x200)地址处。读取成功,CF=0,跳转到ok_load_setup;读取失败,CF=1,复位软盘,重新读取扇区。
下面是读取光标位置到dx寄存器中。在第2节中,打印Hello World的地方原本有字符,不大好看。读取光标位置后打印字符串会打印在空白地区,更好看一点。
打印字符串的程序之前已经说过,就不多说了。打印Loading system …之后就会跳转到setup.s的内容中。
这次把setup.s写简单一点,还是一个死循环。
start:
jmp start
弄完bootsect.s和setup.s后,就开始更改Makefile。
default: all
all: Image
Image: mkimg boot/bootsect.bin boot/setup.bin
dd if=boot/bootsect.bin of=kernel.img bs=512 count=1 conv=notrunc
dd if=boot/setup.bin of=kernel.img bs=512 count=4 seek=1 conv=notrunc
bochs -qf bochsrc
boot/bootsect.bin: boot/bootsect.s
nasm boot/bootsect.s -o boot/bootsect.bin
boot/setup.bin: boot/setup.s
nasm boot/setup.s -o boot/setup.bin
mkimg:
./mkimg.sh
clean:
rm -rf boot/*.bin kernel.img
dd指令的使用可以在linux中使用dd --help
进行查看,其余就没什么好讲的了。
那就开始仿真调试吧。
make完运行了jmp INITSEG:go
指令之后,地址发生了变化。
我们可以一直使用n
命令一步一步运行。
这次打印的字符串明显要更好一些。
咦?说好的setup.s的内容呢?别慌,点击上面的Refresh键。
这次可以看到死循环了。
这一节的内容结束了,下一节会开始完善setup.s,bootsect.s的内容会在开始写内核的时候再完善。
4.完善setup.s
setup.s要本要完成移动内核到指定位置的任务,但现在还没有写内核,就只完成初始化的功能就可以了。
目前,CPU还处于16位模式,能够使用的寄存器也都是16位的,而我们接下来要进入32位保护模式,setup.s会为进入32位做准备。(我们都知道32位比16位好,那这是为什么呢?)
要进入保护模式需要做什么呢?
- 初始化GDT描述符,加载gdtr
- 打开A20地址线
- 设置cr0寄存器的PE位为1,使之运行于保护模式
最后还需要跳转到保护模式的地址,但由于没写内核代码,这步将由死循环代替(老惯例了)。
以下是修改后的setup.s的代码:
INITSEG equ 0x9000
SYSSEG equ 0x1000
SETUPSEG equ 0x9020
cli ; 保护模式下中断机制尚未建立,应禁止中断
start:
mov ax, SETUPSEG
mov ds, ax
lgdt [gdt_48]
mov al, 2
out 0x92, al
mov ax, 0x0001
lmsw ax
jmp $
gdt:
dw 0, 0, 0, 0
dw 0x07ff, 0x0000, 0x9a00, 0x00c0
dw 0x07ff, 0x0000, 0x9200, 0x00c0
gdt_48:
dw 0x800
dw 512 + gdt, 0x9
gdt包含操作系统内存分段管理的相关知识,在setup.s中只是临时设置gdt,在内核中会重新设置gdt。gdt的结构如下所示。如果对gdt不感兴趣就跳过下面的图表吧:-)
gdt结构图
gdt各字段解释
gdt各字段 | 意义 |
---|---|
基地址 | 段在内存中的起始地址 |
段界限 | 段长度=(段界限+1)*段界限单位(段界限单位与下方的G字段有关) |
G | 粒度位,用于解释段界限的含义。G=0,段界限以字节为单位,段的扩展范围为1B到1MB(描述符的界限值为20位);G=1,段界限以4KB为单位,段的扩展范围为4KB到4GB |
D/B | 默认的操作数大小。主要是为了能够在32位处理器上兼容运行16位保护模式的程序。D/B=0,表示指令中的偏移地址或者操作数是16位的;D/B=1,表示指令中的偏移地址或者操作数是32位的。 |
L | 64位代码段标志,保留此位给64位处理器使用,目前将此位置0即可。 |
AVL | 软件可利用位。80386对该位未作规定,且与80386兼容的处理器都不会对该位的使用做任何规定。 |
P | 段存在位,用于表示描述符所对应的段是否存在。P=0,表示段不在内存中。P=1,表示段在内存中。 |
DPL | 表示描述符的特权级。处理器支持的特权级别有4种,分别是0,1,2,3,其中0是最高特权级别。 |
S | 用于指定描述符的类型。S=0,表示这是一个系统段;S=1,表示这是一个代码段或数据段(堆栈段也是特殊的数据段)。 |
TYPE | 由于介绍过长,放在下面解释 |
gdt中TYPE字段的介绍
数据段 | 代码段 | ||||||||
---|---|---|---|---|---|---|---|---|---|
X | E | W | A | 含义 | X | C | R | A | 含义 |
0 | 0 | 0 | X | 只读 | 1 | 0 | 0 | X | 只执行 |
0 | 0 | 1 | X | 读和写 | 1 | 0 | 1 | X | 读和可执行 |
0 | 1 | 0 | X | 只读,向下扩展 | 1 | 1 | 0 | X | 只执行,依从的代码段 |
0 | 1 | 1 | X | 读写,向下扩展 | 1 | 1 | 1 | X | 可执行,读,依从的代码段 |
上表中第2行各字母的含义
字母 | 含义 |
---|---|
X | 表示是否可执行。X=0,表示不可执行,数据段总是不可执行的;X=1,表示可执行,代码段总是可执行的。 |
E | 段的扩展方向。E=0,向上扩展,即向高地址方向扩展,是普通的数据段;E=1,向下扩展,即向低地址方向扩展,通常是堆栈段。 |
W | 段的写属性。W=0,不允许写入,此时写入的话会引发异常中断;W=1,允许写入。 |
A | 已访问位。用于表示它指向的段最近是否被访问过。在描述符创建的时候应该清0。之后每当该段被访问时,处理器将该位置1。 |
C | 段特权级依从。C=0,表示非依从的代码段,可以与特权级相同的代码段调用,或者通过门调用;C=1,表示运行从低特权级的程序转移到该段执行。 |
R | 段是否允许读出。R=0,表示代码段不能读出,此时读出会引发处理器异常中断;R=1,表示代码段可以读出。 |
通过上面的图表,我们可以知道代码第18-20行都干了什么。每个gdt占8个字节,所以每一行就是一个gdt。第一个gdt内容必须是0,对应于第18行代码。第二个gdt代表系统代码段,这8个字符的意思是:该段在内存的起始地址是0,段界限为0x7FF,段界限以4KB为单位,段长度为8MB,该段指令中的偏移地址或者操作数是32位的,段在内存中,特权级为0,这是一个代码段或数据段,该段可读可执行。这么介绍确实有些繁琐,不过应该挺易懂的。第三个gdt代表系统数据段,相关内容还请各位自行观看。
第8行代码是加载gdt的地址及长度到gdtr中。LGDT指令是将源操作数中的值加载到全局描述符表格寄存器(GDTR)。源操作数指定6字节内存位置,它包括了GDT的基址和界限。如果操作数大小属性是32位,则将16位限制(操作数的2个低位字节)与32位基址(操作数的4个高位字节)加载到寄存器。第24行的基址也可表示为0x90200+gdt或0x9020:gdt。
第10-11行是为了开启A20,这样就可以访问1MB以上的内容地址了。关于A20的更多知识请自行百度。开启A20的方法有几种,这里使用的是最简单的方法,与linux0.11的代码并不相同。
第13-14行是为了设置cr0寄存器的PE位为1(PE位位于cr0寄存器的bit0)。这段代码与如下代码的意思相同。
mov ax, cr0
or ax, 1
mov cr0, ax
下面来运行一下代码吧。
这好像没什么可以展示的啊。在运行了lgdt和lmsw指令之后可以看看gdtr和cr0寄存器的变化。
进入保护模式的准备已经做好了,下面就可以写内核,但是写完内核就需要更新bootsect.s和setup.s的内容,好复杂啊。。。
这章内容有点多,感觉应该写得更细一点,但写多了又感觉会变得很臃肿,就这样了,下章的内容也不少啊。