操作系统内核Hack:(三)引导程序制作

时间:2021-09-06 15:45:21

操作系统内核Hack:(三)引导程序制作

关于本文涉及到的完整源码请参考MiniOS的v1_bootloader分支


1.制作方法

现在我们已经了解了关于BootLoader的一切知识,让我们开始动手做一个BootLoader吧!但真正开始之前,我们还要做出一个选择,在之前的讨论中我们曾说过,有两种学习和制作引导程序和操作系统内核的路线:1)《Orange’s:一个操作系统的实现》书中的路线;2)Linux 0.11的路线。

1.1 两种实现思路

具体来说,第一种路线就是将BootLoader和Kernel都放到预先格式化成FAT的软盘上,BootLoader通过FAT和ELF的知识定位并加载Kernel,借助FreeDOS启动或调试我们的操作系统。并非对FAT和DOS心存偏见,毕竟现在的确很多汇编语言书籍仍然以DOS作为学习平台,但我个人更喜欢第二种路线。在引导过程,我们不依赖任何文件系统,在裸扇区中加载运行第二阶段BootLoader和Kernel。进入Kernel之后,我们再挂载一个根文件系统。为何要这样做而不跟着《Orange’s》作者的思路走呢?因为我们毕竟主要学习和模仿的是*nix族的操作系统,既然如此,为何不纯粹一些?直接循着当年Linus在Linux 0.11中的思路,这才算是一次酣畅淋漓的操作系统内核的Hack之旅!

经过了本系列前两回对实验环境和底层编程基础知识的准备,加上刚才对实现思路的分析,现在我们就参考《Linux内核完全注释》中Linux 0.11的思路,借鉴《Orange’s:一个操作系统的实现》中NASM的汇编代码,“双剑合璧,威力无穷”。让我们站在前人的肩膀上,一起动手来实现一个属于我们自己的操作系统BootLoader!

1.2 Linux 0.11参考

既然要借鉴思路,我们就先看一下Linux 0.11这个成品是什么样子。因为《Linux内核完全注释》中提供的源代码链接要做很多手动修改和准备工作才能使用,而网上有很多热心的网友分享了开箱即用的0.11源码安装包。

1.2.1 编译内核

cdai@cdai ~/Source $ git clone https://github.com/yuanxinyu/Linux-0.11
cdai@cdai ~/Source $ cd Linux-0.11

cdai@cdai-Vostro-1450 ~/Source/Linux-0.11 $ make help
<<<<This is the basic help info of linux-0.11>>>

Usage:
make --generate a kernel floppy Image with a fs on hda1
make start -- start the kernel in qemu
make debug -- debug the kernel in qemu & gdb at port 1234
make disk -- generate a kernel Image & copy it to floppy
make cscope -- genereate the cscope index databases
make tags -- generate the tag file
make cg -- generate callgraph of the system architecture
make clean -- clean the object files
make distclean -- only keep the source code files

Note!:
* You need to install the following basic tools:
ubuntu|debian, qemu|bochs, ctags, cscope, calltree, graphviz
vim-full, build-essential, hex, dd, gcc 4.3.2...
* Becarefull to change the compiling options, which will heavily
influence the compiling procedure and running result.
...

<<<Be Happy To Play With It :-)>>>

cdai@cdai ~/Source/Linux-0.11 $ sudo apt-get install -y ctags cscope graphviz qemu
cdai@cdai ~/Source/Linux-0.11 $ make

1.2.2 下载硬盘IMG

从OldLinux下载硬盘镜像hdc-0.11.img,大小为127MB。将其拷贝到Linux0.11目录下后,就可以执行make start用qemu模拟Linux 0.11的运行环境了。

cdai@cdai ~/Source/Linux-0.11 $ tree

├── boot
├── fs
├── hdc-0.11.img
├── Image
├── include
├── init
├── kernel
├── lib
├── Makefile
├── Makefile.header
├── mm
├── README.md
├── readme.old
├── System.map
└── tools

8 directories, 10 files
cdai@cdai ~/Source/Linux-0.11 $ make start

1.2.3 安装运行

启动时,系统会从软盘引导,并挂载根文件系统:

SeaBIOS (version 1.7.4-20140219_122725-roseapple)

iPXE (http://ipxe.org) 00:0.3.0 C900 PCI2.10 PnP PMM+00FC1110+00F21110 C900

Booting from Floppy...

Loading system...

Partition table ok.
43012/62000 free blocks
19719/20666 free inodes
3446 buffers = 3528704 bytes buffer space
Free mem: 12574720 bytes
Ok.
[/usr/root]# ls
README hello mtools.howto shoelace.tar.Z
gcclib140 hello.c shoe

2.文件目录组织

文件目录组织是按照打包后的模块划分,一级目录分为boot1、boot2、system三个文件夹,tools中放的则是打包工具类。对应《Orange’s》代码,boot1/bootsect.s相当于boot.asm,boot2/setup.s相当于loader.asm。

cdai@cdai ~/Workspace/syspace/1-assembly $ tree -d minios/
minios/
├── boot1
│ └── bootsect.s
├── boot2
│ └── setup.s
├── Makefile
├── system
│ ├── fs
│ ├── include
│ ├── init
│ ├── kernel
│ ├── lib
│ └── mm
└── tools
└── build.c

10 directories, 4 files

3.实用工具

3.1 构建工具

3.1.1 Makefile

首先,Makefile份内的工作就是编译出bootsect、setup、system、build。之后,调用build处理前三者,最终产生拼接好的可引导Image镜像文件。在此过程中,Makefile还有一些小任务要完成。例如利用临时汇编文件tmp.s将system模块的大小拼接到bootsect.asm的头部,加载system的代码中会用到它。


#################
# Macro & Rule
#################

AS = nasm
ASFLAGS = -f elf
CC = gcc
CFLAGS = -Wall -O
LD = ld
# -Ttext org -e entry -s(omit all symbol info)
# -x(discard all local symbols) -M(print memory map)
LDFLAGS = -Ttext 0 -e main --oformat binary -s -x -M

%.o: %.c
$(CC) $(CFLAGS) -c -o $@ $<

#%.o: %.asm
# $(AS) $(ASFLAGS) -o $@ $<


#################
# Default
#################

all: Image


Image: boot1/bootsect boot2/setup system/system tools/build
tools/build boot1/bootsect boot2/setup system/system > a.bin
dd if=a.bin of=Image bs=8192 conv=notrunc
rm -f a.bin

tools/build: tools/build.c
$(CC) $(CFLAGS) -o $@ $<

# SYSSIZE= number of clicks (16 bytes) to be loaded
boot1/bootsect: boot1/bootsect.asm system/system
(echo -n "SYSSIZE equ (";ls -l system/system | grep system \
| cut -d " " -f 5 | tr '\012' ' '; echo "+ 15 ) / 16") > tmp.asm
cat $< >> tmp.asm
$(AS) -o $@ tmp.asm
rm -f tmp.asm

boot2/setup: boot2/setup.asm
$(AS) -o $@ $<

system/system: system/init/main.o system/init/myprint.o
$(LD) $(LDFLAGS) \
system/init/main.o \
system/init/myprint.o \
-o $@ > System.map

system/init/main.o: system/init/main.c

system/init/myprint.o: system/init/myprint.asm
$(AS) $(ASFLAGS) -o $@ $<


#################
# Create floppy
#################

disk:
bximage -q -fd -size=1.44 Image

.PHONY: disk


#################
# Start Vm
#################

start: Image
bochs -q -f bochsrc

qemu: Image
qemu-system-x86_64 -m 16M -boot a -fda Image

.PHONY: start


#################
# GitHub
#################

commit:
git add .
git commit -m "$(MSG)"


#################
# Clean
#################

clean:
rm -f boot1/bootsect boot2/setup system/system tools/build
rm -f system/**/*.o
rm -f a.bin tmp.asm System.map

.PHONY: clean

3.1.2 build.c

build.c主要完成一些不便于或无法在Makefile中实现的操作:

  1. 文件有效性的检查:例如bootsect末尾的0x55AA签名
  2. 文件填充:例如对不足四个扇区大小的setup进行填充
  3. 文件内容解析:例如解析出a.out或ELF格式的system,将其中二进制代码部分提取出来
  4. 拼接:将bootsect、setup、system三者拼接成Image可引导镜像
#include <stdio.h> /* fprintf */
#include <string.h>
#include <stdlib.h> /* exit */
#include <sys/types.h> /* unistd.h needs this */
#include <unistd.h> /* read/write */
#include <fcntl.h>


#define BUFFER_SIZE 1024

#define SETUP_SECTS 4 /* max number of sectors of setup */
#define STRINGIFY(x) #x /* cat string */

#define GCC_HEADER 1024 /* GCC header length */
#define SYS_SIZE 0x2000 /* max system length (SYS_SIZE*16=128KB) */


void die(const char *str)
{
fprintf(stderr, "%s\n", str);
exit(1);
}

void usage()
{
die("Usage: build bootsect setup system [> image]");
}

void copy_bootsect(char const *filename, char *buf)
{
int c, fd;

if ((fd = open(filename, O_RDONLY, 0)) < 0)
die("Unable to open 'bootsect'");

for (c = 0; c < BUFFER_SIZE; c++)
buf[c] = 0;

c = read(fd, buf, BUFFER_SIZE);

close(fd);

fprintf(stderr, "'bootsect' is %d bytes.\n", c);
if (c != 512)
die("'bootsect' must be exactly 512 bytes");

if ((*(unsigned short *)(buf + 510)) != 0xAA55)
die("'bootsect' hasn't got boot flag (0xAA55)");

c = write(1, buf, 512);
if (c != 512)
die("Write call failed");

}

void copy_setup(char const *filename, char *buf)
{
int c, i, fd;

if ((fd = open(filename, O_RDONLY, 0)) < 0)
die("Unable to open 'setup'");

for (i = 0; (c = read(fd, buf, BUFFER_SIZE)) > 0; i += c)
if (write(1, buf, c) != c)
die("Write call failed");
close(fd);

fprintf(stderr, "'setup' is %d bytes\n", i);
if (i > SETUP_SECTS * 512)
die("'setup' exceeds " STRINGIFY(SETUP_SECTS) " sectors");

// Fill '\0' if smaller than max bytes
for (c = 0; c < BUFFER_SIZE; c++)
buf[c] = 0;

while(i < SETUP_SECTS * 512) {
c = SETUP_SECTS * 512 - i;
if (c > BUFFER_SIZE)
c = BUFFER_SIZE;
if (write(1, buf, c) != c)
die("Write call failed");
i += c;
}
}

void copy_system(char const *filename, char *buf)
{
int c, i, fd;

if ((fd = open(filename, O_RDONLY, 0)) < 0)
die("Unable to open 'system'");

/*if (read(fd, buf, GCC_HEADER) != GCC_HEADER)
die("Unable to read header of 'system'");
if (((long *) buf)[5] != 0)
die("Non-GCC header of 'system'");*/


for (i = 0; (c = read(fd, buf, BUFFER_SIZE)) > 0; i += c)
if (write(1, buf, c) != c)
die("Write call failed");
close(fd);

fprintf(stderr, "'system' is %d bytes\n", i);
if (i > SYS_SIZE * 16)
die("'system' is too big");
}

int main(int argc, char const *argv[])
{
char buf[BUFFER_SIZE];

if (argc != 4)
usage();

copy_bootsect(argv[1], buf);
copy_setup(argv[2], buf);
copy_system(argv[3], buf);

return 0;
}

编写build.c中可能会碰到一些C语言的小问题:例如如何字符串拼接,sizeof(array)和sizeof(pointer)陷阱等,这里就不细说了,回头总结C语言开发问题时才一并总结吧。

3.2 调试工具

3.2.1 BIN反汇编

由于BIN文件是Raw Binary,没有文件头及其他附加信息,所以直接运行objdump是会报错的。用objdump -D -b binary -m i386,其中-D表示对整个文件反汇编,-b表示二进制,-m表示指令集架构。objdump的确可以反汇编出一些指令,但不够准确,尤其是16位操作数的处理。最好的办法就是用NASM自带的反汇编工具ndisasm,-o指定起始地址,-e指定开头跳过的字节数,-k指定不反汇编的位置,例如不用-o参数时-k 0x1FE,2会跳过0x55AA签名。与objdump对比一下,看!是不是比objdump好多了!

cdai@cdai ~/Workspace/syspace/1-assembly/minios $ objdump -D -b binary -m i386 Image 

Image: file format binary

Disassembly of section .data:

00000000 <.data>:
0: b8 c0 07 8e d8 mov $0xd88e07c0,%eax
5: b8 00 90 8e c0 mov $0xc08e9000,%eax
a: b9 00 01 31 f6 mov $0xf6310100,%ecx
f: 31 ff xor %edi,%edi
11: f3 a5 rep movsl %ds:(%esi),%es:(%edi)
13: ea 18 00 00 90 8c c8 ljmp $0xc88c,$0x90000018
1a: 8e d8 mov %eax,%ds
...

cdai@cdai ~/Workspace/syspace/1-assembly/minios $ ndisasm -o 0x7c00 Image
00007C00 B8C007 mov ax,0x7c0
00007C03 8ED8 mov ds,ax
00007C05 B80090 mov ax,0x9000
00007C08 8EC0 mov es,ax
00007C0A B90001 mov cx,0x100
00007C0D 31F6 xor si,si
00007C0F 31FF xor di,di
00007C11 F3A5 rep movsw
00007C13 EA18000090 jmp word 0x9000:0x18
00007C18 8CC8 mov ax,cs
00007C1A 8ED8 mov ds,ax

3.2.2 Bochs调试

因为之前在《C实战:强大的程序调试工具GDB》中已经学习并总结过GDB调试器的使用,所以这里就简单说一下Bochs对应的命令:

  • 执行:b加断点,c继续执行,s和p分别是step in/step next单步调试
  • 查看寄存器:r查看通用寄存器,sreg查看段寄存器
  • 查看内存:x查看线性地址,xp查看物理地址。例如xp /40bx 0x7c00表示以十六进制(x)字节(b)的形式,查看0x7c00位置长度为40字节的数据
  • 反汇编:u反汇编一段内存中的代码。例如x 0x7c00 0x7c10
<bochs:5> xp /40bx 0x7c00
[bochs]:
0x00007c00 <bogus+ 0>: 0xb8 0xc0 0x07 0x8e 0xd8 0xb8 0x00 0x90

0x00007c08 <bogus+ 8>: 0x8e 0xc0 0xb9 0x00 0x01 0x31 0xf6 0x31

0x00007c10 <bogus+ 16>: 0xff 0xf3 0xa5 0xea 0x18 0x00 0x00 0x90

0x00007c18 <bogus+ 24>: 0x8c 0xc8 0x8e 0xd8 0x8e 0xc0 0x8e 0xd0

0x00007c20 <bogus+ 32>: 0xbc 0x00 0xff 0xba 0x00 0x00 0xb9 0x02

<bochs:6> u 0x7c00 0x7c10

00007c00: ( ): mov ax, 0x07c0 ; b8c007

00007c03: ( ): mov ds, ax ; 8ed8

00007c05: ( ): mov ax, 0x9000 ; b80090

00007c08: ( ): mov es, ax ; 8ec0

00007c0a: ( ): mov cx, 0x0100 ; b90001

00007c0d: ( ): xor si, si ; 31f6

00007c0f: ( ): xor di, di ; 31ff

4.源码分析

4.1 第一阶段引导:bootsect.asm

因为受限于引导扇区的512字节,所以bootsect的工作很简单,首先就是将自己挪动到90000h高地址处,“明哲保身”避免被后面要加载的模块覆盖掉。然后打印提示信息”Loading system…”并加载setup和system后,就跳转到setup继续执行。为了简化,我们暂时没有根据make时拼接到bootsect.asm头部的SYSSIZE去加载system,而只是加载了第6个扇区

; ############################
; Constants
; ###
#########################

SETUPLEN equ 4
BOOTSEG equ 0x07c0
INITSEG equ 0x9000
SETUPSEG equ 0x9020
SYSSEG equ 0x1000


; ############################
; Booting Process
; ###
#########################

org 07c00h

; 1) Move bootsect to 0x90000
mov ax, BOOTSEG
mov ds, ax
mov ax, INITSEG
mov es, ax
mov cx, 256 ; cx = counter
xor si, si ; ds:si = source
xor di, di ; es:di = target
rep movsw ; move word by word
jmp INITSEG:go-$$ ; far jump!
go:
mov ax, cs ; re-init registers
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0xff00 ; arbitrary value >> 512

; 2) Load setup module at 0x90200
load_setup:
mov dx, 0000h ; dx = driver(dh)/head(dl)
mov cx, 0002h ; cx = track(ch)/sector(cl)
mov bx, 0200h ; es:bx = target(es=9000h,bx=90200-90000)
mov ah, 02h ; ah = service id(ah=02 means read)
mov al, SETUPLEN ; al = number of sectors to read(al)
int 13h
jnc ok_load_setup

xor dl, dl ; reset floppy and retry if failed
xor ah, ah
int 13h
jmp load_setup

; 3) Display loading message
ok_load_setup:
mov ah, 03h ; read cursur position
xor bh, bh
int 10h

mov bp, msg1-$$ ; es:bp = message start address
mov cx, 21 ; cx = message length
mov ax, 1301h
mov bx, 0007h ; bx = page no.(bh=0 page-0)
; attribute(bl=7 white fg and black bg)
mov dl, 0h
int 10h

; 4) Load system module at 0x10000
load_system:
mov ax, SYSSEG
mov es, ax
mov dx, 0000h ; dx = driver(dh)/head(dl)
mov cx, 0006h ; cx = track(ch)/sector(cl)
mov bx, 00h ; es:bx = target(es=1000h,bx=0)
mov ah, 02h ; ah = service id(ah=02 means read)
mov al, 01h ; al = number of sectors to read(al)
int 13h
jnc ok_load_system

xor dl, dl ; reset floppy and retry if failed
xor ah, ah
int 13h
jmp load_system

; 5) Jump to setup
ok_load_system:
jmp SETUPSEG:0h

; ############################
; Message
; ###
#########################

msg1:
db 13,10 ; CRLF
db "Loading system..."
db 13,10 ; CRLF

times 510-($-$$) db 0
dw 0xaa55

为什么在bootsect默认保留一些字节?因为只有bootsect的长度是确定的512字节,在末尾保留一些“全局变量”的话,其他模块很容易就能访问到。

4.2 第二阶段引导:setup.asm

setup.asm看似很长,其实都是“见怪不怪”的代码了。开头部分的段描述符的宏定义和属性的常量定义,后面就是进入保护模式的常规代码了,就是这么简单。为了简化,跳转system前直接将ss置为00000h地址处。尽管如此,这段代码却耗费了我大量时间去调错,那究竟了哪里容易出问题呢?

; ############################
; Constants & Macro
; ############################

; 描述符类型
DA_32 equ 4000h ; 32 位段
DA_LIMIT_4K equ 8000h ; 段界限粒度为 4K 字节

; 存储段描述符类型
DA_DR equ 90h ; 存在的只读数据段类型值
DA_DRW equ 92h ; 存在的可读写数据段属性值
DA_C equ 98h ; 存在的只执行代码段属性值
DA_CR equ 9Ah ; 存在的可执行可读代码段属性值

; Descriptor macro
%macro Descriptor 3
dw %2 & 0FFFFh ; Limit 1
dw %1 & 0FFFFh ; Base addr 1
db (%1 >> 16) & 0FFh ; Base addr 2
dw ((%2 >> 8) & 0F00h) | (%3 & 0F0FFh) ; Attr 1 + Limit 2 + Attr 2
db (%1 >> 24) & 0FFh ; Base addr 3
%endmacro


; ############################
; Booting Process
; ############################

[SECTION .s16]
[BITS 16]
LABEL_BEGIN:

; 1) Enter protection mode
mov ax, cs
mov ds, ax
mov es, ax
mov ss, ax
mov sp, 0100h

; 1.1) Init descriptor
xor eax, eax
mov ax, cs
shl eax, 4
add eax, LABEL_SEG_CODE32
mov word [LABEL_DESC_CODE32 + 2], ax
shr eax, 16
mov byte [LABEL_DESC_CODE32 + 4], al
mov byte [LABEL_DESC_CODE32 + 7], ah

; 1.2) Load gdt to gdtr
xor eax, eax
mov ax, ds
shl eax, 4
add eax, LABEL_GDT ; eax <- gdt base addr
mov dword [GdtPtr + 2], eax ; [GdtPtr + 2] <- gdt base addr
lgdt [GdtPtr]

; 1.3) Disable interrupt
cli

; 1.4) Enable A20 addr line
in al, 92h
or al, 00000010b
out 92h, al

; 1.5) Set PE in cr0
mov eax, cr0
or eax, 1
mov cr0, eax

; 1.6) Jump to protective mode!
jmp dword SelectorCode32:0 ; SelectorCode32 (LABEL_SEG_CODE32:0)

[SECTION .s32]
ALIGN 32
[BITS 32]
LABEL_SEG_CODE32:

; 2) Jump to system
mov ax, SelectorData
mov ds, ax
mov es, ax
mov ss, ax
mov esp, 1000h

jmp SelectorSystem:0

SegCode32Len equ $ - LABEL_SEG_CODE32

[SECTION .gdt]
; Base Addr, Limit, Attribute
LABEL_GDT: Descriptor 0, 0, 0
LABEL_DESC_CODE32: Descriptor 0, SegCode32Len, DA_CR | DA_32 | DA_LIMIT_4K
LABEL_DESC_SYSTEM: Descriptor 10000h, 0ffffh, DA_CR | DA_32 | DA_LIMIT_4K
LABEL_DESC_DATA: Descriptor 0, 0ffffh, DA_DRW | DA_32 | DA_LIMIT_4K
LABEL_DESC_VIDEO: Descriptor 0B8000h, 0ffffh, DA_DRW

GdtLen equ $ - LABEL_GDT
GdtPtr dw GdtLen - 1 ; GDT limit
dd 0 ; GDT base addr

SelectorCode32 equ LABEL_DESC_CODE32 - LABEL_GDT
SelectorSystem equ LABEL_DESC_SYSTEM - LABEL_GDT
SelectorData equ LABEL_DESC_DATA - LABEL_GDT
SelectorVideo equ LABEL_DESC_VIDEO - LABEL_GDT

此外,SegCode32Len equ $ - LABEL_SEG_CODE32一定要放到32位代码段的末尾,因为这个长度会设置到32位代码段的描述符中的Limit字段上。如果执行长度超过Limit的话,Bochs同样会没有任何提示的崩溃。

4.3 内核空壳:main.c

一路磕磕绊绊,我们终于进入了32位保护模式下的内核代码。现阶段我们在内核中还做不了什么,但这一路辛苦走下来,到这里还是应该尝点甜头儿的。那我们就在内核代码中用C程序调用汇编代码,输出一段话吧。我们先看源代码,后面会详细解释一下为什么要混合使用C和汇编,以及源代码中的细节问题。

4.3.1 源代码

从C程序调用汇编代码其实非常简单,我们并不用关注myprint()到底是什么语言实现的,只要这个函数原型使我们产生的代码能够与myprint()的实现一起工作就可以了。此外,编译myprint.asm的时候要注意不能编译成默认的BIN文件,而要编译为ELF格式产生可重定位的相关信息,这样链接器才能将其与main.o正确地链接到一起。注意参数msg的类型要声明为const char *msg,避免编译时的警告。

// main.c
void myprint(const char *msg, int len);

int main(int argc, char const *argv[])
{
myprint("Hello, MiniOS!\n", 15);
return 0;
}

我们在.loop循环外初始化循环中要用到的变量:

  • gs:显存映射空间的选择符,值32对应我们在setup.asm中建立的GDT中的SelectorVideo
  • ah:字符串的属性,我们一会儿在Bochs中打印内存时能看到它
  • ebx:循环中要递增的列值
  • ecx:循环中要递减的循环次数计数器,对应C程序传入的参数len
  • edx:循环中要递增的字符串首地址,对应C程序传入的参数msg。esp相当于一个二级指针,[esp+4]得到的只是msg,[msg]得到才是第一个字符’H’。所以说,函数原型(char *msg)是与汇编代码([esp+4])一一对应的,从此处也能一瞥C语言的灵活和强大。
; myprint.asm
[section .data]

[section .text]

global myprint

myprint:
mov ax, 32 ; SelectorVideo
mov gs, ax
mov ah, 0Ch ; attr
mov ebx, 0 ; col
mov ecx, [esp+8] ; msg len
mov edx, [esp+4] ; msg addr

.loop:
;(80 * row + col) * 2
mov edi, ebx
add edi, (80 * 20)
imul edi, 2
mov al, byte [edx]
mov [gs:edi], ax

inc ebx
dec ecx
inc edx
cmp ecx, 0h
jne .loop

jmp $

4.3.2 反汇编

Makefile中已经有了三个反汇编的target,分别利用ndisasm和objdump工具反汇编bootsect、setup和system。这里我们反汇编一下system,看一下我们的C和汇编混合代码是如何链接的,链接后又是什么样子?不要被细节干扰,抓住几个关键点就可以了,如果相关知识忘记了的话可以参考《六星经典CSAPP-笔记(3)程序的机器级表示》

  • a~e:被调用函数的惯例,保存ebp挪动esp分配栈空间
  • 11~19:保存两个函数参数msg(0x00010076指向的就是字符串的首地址)和len(0xf=15)到栈上
  • 20:调用汇编代码中的函数myprint(),call指令自动将25行的地址压入栈上作为返回地址后跳转,call的操作数0x1b加上IP指向的0x25等于0x40,恰好是myprint()在汇编代码中的起始地址。因为call压栈挪动了esp,所以两个函数参数的位置也就从(%esp)和0x4(%esp)变成了0x4(%esp)和0x8(%esp)了
[root@localhost minios]# make disasm-sys
nasm -f elf -o system/init/myprint.o system/init/myprint.asm
ld -Ttext 10000 -e main --oformat binary -s -x -M \
system/init/main.o \
system/init/myprint.o \
-o system/system > System.map
objdump -b binary -m i386 -D system/system

system/system: file format binary

Disassembly of section .data:

0000000000000000 <.data>:
0: 8d 4c 24 04 lea 0x4(%esp),%ecx
4: 83 e4 f0 and $0xfffffff0,%esp
7: ff 71 fc pushl 0xfffffffc(%ecx)
a: 55 push %ebp
b: 89 e5 mov %esp,%ebp
d: 51 push %ecx
e: 83 ec 14 sub $0x14,%esp
11: c7 44 24 04 0f 00 00 movl $0xf,0x4(%esp)
18: 00
19: c7 04 24 76 00 01 00 movl $0x10076,(%esp)
20: e8 1b 00 00 00 call 0x40
25: b8 00 00 00 00 mov $0x0,%eax
...

40: 66 b8 20 00 mov $0x20,%ax
44: 8e e8 movl %eax,%gs
46: b4 0c mov $0xc,%ah
48: bb 00 00 00 00 mov $0x0,%ebx
4d: 8b 4c 24 08 mov 0x8(%esp),%ecx
51: 8b 54 24 04 mov 0x4(%esp),%edx
55: 89 df mov %ebx,%edi
57: 81 c7 40 06 00 00 add $0x640,%edi
5d: 69 ff 02 00 00 00 imul $0x2,%edi,%edi
63: 8a 02 mov (%edx),%al
65: 65 66 89 07 mov %ax,%gs:(%edi)
69: 43 inc %ebx
6a: 49 dec %ecx
6b: 42 inc %edx
6c: 81 f9 00 00 00 00 cmp $0x0,%ecx
72: 75 e1 jne 0x55
74: eb fe jmp 0x74

76: 48
77: 65
78: 6c
79: 6c
7a: 6f
7b: 2c 20
7d: 4d
7e: 69 6e 69 4f 53 21 0a
...

4.3.3 源码解释

关于内核雏形部分的代码看起来不那么直接,直接写个C程序的main方法不就可以了吗?但这都是有原因的,解释:

  1. 为何要输出字符串?因为我们想直观地看到内核确实可以正常工作,体现出我们的劳动成果
  2. 为何要混合编程?既然要输出点什么,而这又是我们自己的操作系统内核,像printf这种系统调用我们还没有实现,所以只能靠底层的汇编代码去输出字符串。这里为了简化,我们在汇编中没有遵守正常的规则,例如保存ebp、挪动esp分配栈等
  3. **为何不直接在C中嵌入NASM代码?**C语言支持直接嵌入汇编代码,但不支持NASM语法的汇编代码,如果我们不想再学习另一种汇编语法的话,就只能单独拆分出一个asm文件
  4. 为何不直接用int 0x10中断?因为BIOS中断在保护模式下是不能用的,而我们进入保护模式后只是设置了栈的段寄存器ss,还有很多工作没做,这里为了避免出错所以用直接写显存在内存映射地址空间的方式
  5. 如何写显存映射空间?显存在内存中映射空间的起始地址是0B8000h,每行80个字符共35行。每个字符占用2个字节,第一个字节是字符的ASCII码,第二个字节是属性。例如第12行的最后一个字符在内存中的位置就是(80 * 11 + 79) * 2。为了简化我们直接将字符串输出到第21行,而没有读当前光标位置

4.3.4 测试

下面就测试一下我们的代码是否正确,说起来这还是我们写的第一个比较复杂的汇编代码,学习了如何在汇编语言中实现高级语言中的循环。一直执行到循环后的jmp $一行,通过Bochs就能看到输出。如果环境没有图形界面而安装Bochs时没有安装GUI也没有关系,打印一下0xB8B40内存位置就能跳过Bochs前面的输出,看到我们刚刚输出的内容了。每个字符后面跟着的\7和\x0C就是该字符的属性了

(0) [0x0000000000010072] 0010:00000072 (unk. ctxt): jnz .-31 (0x00010055)     ; 75e1
<bochs:161>
Next at t=13185655
(0) [0x0000000000010074] 0010:00000074 (unk. ctxt): jmp .-2 (0x00010074) ; ebfe
<bochs:162> xp /500bc 0x0B8B40
[bochs]:
0x000b8b40 <bogus+ 0>: L \7 o \7 a \7 d \7
0x000b8b48 <bogus+ 8>: i \7 n \7 g \7 \7
0x000b8b50 <bogus+ 16>: s \7 y \7 s \7 t \7
0x000b8b58 <bogus+ 24>: e \7 m \7 . \7 . \7
0x000b8b60 <bogus+ 32>: . \7 \7 \7 \7
0x000b8b68 <bogus+ 40>: \7 \7 \7 \7
...
0x000b8c80 <bogus+ 320>: H \x0C e \x0C l \x0C l \x0C
0x000b8c88 <bogus+ 328>: o \x0C , \x0C \x0C M \x0C
0x000b8c90 <bogus+ 336>: i \x0C n \x0C i \x0C O \x0C
0x000b8c98 <bogus+ 344>: S \x0C ! \x0C \x0A \x0C \7

5.总结

现在回头想想,似乎找到了当时阅读《Orange’s》一书时,进行到第五章就没有继续下去的原因:一是第三章“保护模式”过早地交代了保护模式的方方面面。尽管搭配了很多汇编代码丰富了示例,但也有些扰乱了学习进程,其实这一章是可以分成两部分,像中断、特权级、分页等一半的知识是可以在学会如何加载Kernel后再了解的;二是FAT和FreeDOS的引入又进一步干扰了进度。如果你只是把它当作未来Kernel的管理工具那还好,要是像我容易刨根问底的话可能就卡在那里了。其实像《30天自制操作系统》一书,不出几章很早就学完了Kernel的引导。在这一点上,《Orange’s》的作者比较细致,但也略显罗嗦。

5.1 引导过程小结

这里整理一下Orange’s与Linux 0.11对内存使用上的差异,以及各自比较麻烦的地方。

5.1.1 Orange’s引导过程

Orange’s的引导工作都是在两阶段引导程序boot和loader中完成的。整个引导过程还是比较清晰的,但有几个地方很麻烦:一是解析FAT文件系统格式将内核读取到内存;二是解析内核ELF文件头将内核的代码部分加载到指定位置。关于问题一之前也提到过,Orange’s作者使用FAT软盘和FreeDOS来方便调试。

  1. boot:从FAT格式软盘加载loader,打印一些信息后跳转
  2. loader:从FAT格式软盘加载内核,进入保护模式后准备GDT/IDT和PDT、栈段寄存器ss等后,跳转到内核

所以Orange’s的内存使用情况如下图所示:boot始终在7c00h,kernel和loader的位置也固定在30000h和90000h,PDT被放置在了高地址100000h上。下面就看一下Linux 0.11的引导过程是什么样?它是如何使用内存空间的?

    ; ┃ ┃
; ┃ . ┃
; ┃ . ┃
; ┃ . ┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; ┃■■■■■■Page Tables■■■■■■┃
; ┃■■■■■(大小由LOADER决定)■■■■┃
; 00101000h ┃■■■■■■■■■■■■■■■■■■┃ PageTblBase
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 00100000h ┃■■■■Page Directory Table■■■■┃ PageDirBase <- 1M
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; F0000h ┃□□□□□□□System ROM□□□□□□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; E0000h ┃□□□□Expansion of system ROM □□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; C0000h ┃□□□Reserved for ROM expansion□□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃ B8000h ← gs
; A0000h ┃□□□Display adapter reserved□□□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; 9FC00h ┃□□extended BIOS data area (EBDA)□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 90000h ┃■■■■■■■LOADER.BIN■■■■■■┃ somewhere in LOADER ← esp
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 80000h ┃■■■■■■■KERNEL.BIN■■■■■■┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 30000h ┃■■■■■■■■KERNEL■■■■■■■┃ 30400h ← KERNEL 入口 (KernelEntryPointPhyAddr)
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃ ┃
; 7E00h ┃ F R E E ┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃■■■■■■■■■■■■■■■■■■┃
; 7C00h ┃■■■■■■BOOT SECTOR■■■■■■┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃ ┃
; 500h ┃ F R E E ┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃□□□□□□□□□□□□□□□□□□┃
; 400h ┃□□□□ROM BIOS parameter area □□┃
; ┣━━━━━━━━━━━━━━━━━━┫
; ┃◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇◇┃
; 0h ┃◇◇◇◇◇◇Int Vectors◇◇◇◇◇◇┃
; ┗━━━━━━━━━━━━━━━━━━┛ ← cs, ds, es, fs, ss
;
;
; ┏━━━┓ ┏━━━┓
; ┃■■■┃ 我们使用 ┃□□□┃ 不能使用的内存
; ┗━━━┛ ┗━━━┛
; ┏━━━┓ ┏━━━┓
; ┃ ┃ 未使用空间 ┃◇◇◇┃ 可以覆盖的内存
; ┗━━━┛ ┗━━━┛

5.1.2 Linux 0.11引导过程

Linux引导程序的第一阶段与Orange’s基本一致,但第二阶段要复杂一些,主要区别在于:Linux将Orange’s的loader进入保护模式后的内核准备工作拆分到一个单独的程序head中。具体来说,Linux的system的头部其实一个汇编程序head.s编译成的,system中head后面的部分才是真正的kernel。head中负责初始化PDT、重新放置GDT/IDT等工作。为了节约内存,PDT、GDT等数据会覆盖掉head代码,所以这部分代码的技巧性很高。

5.1.3 我的MiniOS

从上面的分析能够看出,我们目前的引导过程类似Orange’s的经典两阶段引导,但本质(比如命名、放置位置、软盘上程序的加载方式等)上都是借鉴Linux的。所以前面说到的Orange’s的麻烦之处我们也巧妙地避开了:

  • 关于Orange’s的第一点因为我们的bootsect和setup是连续放置的所以省了;
  • 关于第二点可以看一下我们的Makefile,因为链接时丢掉了ELF各种符号表等信息,直接得到了纯二进制文件,并且在软盘上system也紧接着setup,所以第二点也可以省了。

接下来对引导和内核程序的完善过程中,我们还是更倾向于Linux,从setup中拆分出head,这样做的好处就是:内核程序以00000h为入口地址,最终进入内核时GDT、IDT、PDT等系统数据都位于00000h后的低地址,整体结构非常清晰。相比之下,Orange’s尽管引导过程能简单一些,但内核代码和系统数据比较零散,不够清晰。

  1. bootsect
    1.1 移动自己:将自己从7c00h拷贝到90000h。因为在2.1中bootsect区域会被用来保存BIOS信息,而2.2中system会被拷贝到00000h,所以为了避免bootsect中的信息被system覆盖掉,最好现在就挪到90000h高地址
    1.2 加载setup:加载setup到90200h
    1.3 显示信息:输出”Loading system…”
    1.4 加载system:根据SYSSIZE加载system到10000h
    1.5 跳转:跳转到setup继续执行
  2. setup
    2.1 读取内存大小:从BIOS读取内存等信息覆盖到90000h~901ffh,即bootsect的位置。因为bootsect中包含SYSSIZE,所以必须在bootsect中加载system,不能等到setup中再做
    2.2 移动system:BIOS中断已经使用完毕,可以将system拷贝到00000h覆盖掉BIOS中断表了
    2.3 进入保护模式:设置GDTR、A20、CR0进入保护模式
    2.4 跳转:跳转到system继续执行
  3. system(head)
    3.1 重新放置GDT:包括GDT/IDT,setup中的GDT是用于进入保护模式的临时GDT
    3.2 初始化PDT:根据内存大小确定PDT大小
    3.3 跳转:进入到main()函数,进入真正的内核

从上面的引导过程,也让我们清晰地了解了为什么Linux 0.11会在内存中频繁的挪动代码——bootsect拷贝自己到90000h,setup挪动system到00000h——这两次挪动的原因以及为什么system不能等到setup读取完BIOS后直接加载到00000h的原因都在上面给出了解答。