【操做系统】Oranges学习笔记(四) 第五章 内核雏形


5.1 在Linux下用汇编写HelloWord

Loader须要完成从实模式向保护模式的跳转,仍是应该把GDT、LDT、8259A等内容都准备完毕?ios

在Linux下写汇编:(chapter5/a/hello.asm)web

; 编译连接方法
; (ld的'-s'选项意为"strip all", 去掉符号表等, 减小生成的可执行代码的文件大小)
; $ nasm -f elf hello.asm -o hello.o
; $ ld -s hello.o -o hello
; $ ./hello
; Hello, world!
; $

[section .data]	; 数据在此

strHello db   "Hello, world!", 0Ah
STRLEN   equ  $ - strHello

[section .text]	; 代码在此

global _start	; 咱们必须导出 _start 这个入口,以便让连接器识别
_start:
	mov	edx, STRLEN
	mov	ecx, strHello
	mov	ebx, 1
	mov	eax, 4		; sys_write
	int	0x80		; 系统调用
	mov	ebx, 0
	mov	eax, 1		; sys_exit
	int	0x80		; 系统调用

代码中定义了两个节,数据节和代码节。须要注意的是:入口点默认是 _start ,既要定义它,还要用 global_start 导出,这样连接程序才能够找到它。shell

代码自己调用了两个系统调用,不重要,书中的OS里根本用不到Linux的系统调用swift

执行步骤以下:数组

$ ls
hello.asm
$ nasm -f elf hello.asm -o hello.o
a$ ls
hello.asm  hello.o
$ ld -s hello.o -o hello
$ ls
hello  hello.asm  hello.o
$ ./hello
Hello, world!

5.2 再进一步,汇编和C同步使用

用C写程序,和汇编连接在一块儿……这是我之前从未接触的领域。网络

下面的例子中,整个程序的过程是:架构

  • 入口 _startfoo.asm 中;一开始程序从入口进入,将后面的参数先压入栈中;调用 extern 声明的 bar.c 中的函数 choose()
  • choose 比较两个传入的参数,根据不一样的结果打印出不一样的参数;
  • 打印字符串的工做倒是由 bar.c 开头声明的、 foo.asm 中用 global 导出的 myprint() 完成的。
    在这里插入图片描述

这个例子中,包含了汇编和C的相互调用:框架

  • 因为 bar.c 中用到 myprint() 函数,因此 foo.asm 中要用 global 将其导出;
  • 因为 foo.asm 中要用到外面定义的函数 choose ,所以须要用 extern 声明;
  • myprintchoose ,遵循的都是 C Calling Convention后面的参数先入栈,并由调用者来清理堆栈

代码以下:(chapter/5/b/foo.asm)ide

; 编译连接方法
; (ld的'-s'意味"strip all")
; $ nasm -f elf foo.asm -o foo.o
; $ gcc -c bar.c -o bar.o
; $ ld -s foo.o bar.o -o foobar
; $ ./foobar
; the 2nd one
; $

extern choose			;int choose(int a, int b);

[section .data]     	;数据在此
num1st dd 3
num2nd dd 4

[section .text]     	;代码在此
global _start       	;咱们必须导出_start这个入口, 以便让连接器识别
global myprint      	;导出这个函数是为了让bar.c使用

_start:
	push dword [num2nd]  
	push dword [num1st]
	call choose         ;choose(num1st, num2nd);
	add esp, 8
	
	mov ebx, 0
	mov eax, 1			;sys_exit
	int 0x80            ;系统调用

;void myprint(char *msg, int len)
myprint:
	mov edx, [esp + 8]  ;len
	mov ecx, [esp + 4]  ;msg
	mov ebx, 1
	mov eax, 4			;sys_write
	int 0x80			;系统调用
	ret

chapter5/b/bar.c 的内容以下:svg

void myprint(char* msg, int len);   //函数声明

int choose(int a, int b) { 			//函数定义
	if (a >= b) {
		myprint("the 1st one\n", 13);
	} else {
		myprint("the 2nd one\n", 13);
	}
	return 0;
}

编译连接过程以下:

$ nasm -f elf foo.asm -o foo.o
$ gcc -c bar.c -o bar.o
$ ld -s foo.o bar.o -o foobar
$ ./foobar
the 2nd one
$ ls
bar.c  bar.o  foo.asm  foobar  foo.o Makefile

总的来讲,关键点在于 externglobal 这两个关键字,有了它们就能够在汇编和C之间自由变换


5.3 ELF(Executable and Linkable Format)

ELF 文件由四部分构成:ELF头 ELF Header,程序头表 Program Header Table ,节 Sections ,节头表 Section Header Table 。、除了ELF头的位置是固定的,包含其余部分的位置、大小等重要信息外,文件中不必定要包含其余三部份内容,位置也不必定按照下面的顺序安排;大小也不固定:
在这里插入图片描述

1. ELF Header

ELF文件力求支持从8位到32位不一样架构的处理器,因此有了下面的数据类型:
在这里插入图片描述

下面的代码定义了一个 ELF Header 结构:

#define EI_NIDENT 16 
typedef struct { 
	unsigned char e_ident [EI_NIDENT];  //16字节的e_ident,包含表示ELF文件的字符及其余信息 
	Elf32_Half           e_type;        //
	Elf32_Half           e_machine;         
	Elf32_word           e_version;       
	Elf32_Addr           e_entry;        
	Elf32_Off            e_phoff;      
	Elf32_Off            e_shoff;
	Elf32_Word           e_flags;        
	Elf32_Haif           e_ehsize;        
	Elf32_Haif           e_phentsize;       
	Elf32_Haif           e_phnum;        
	Elf32_Haif           e_shentsize;       
	Elf32_Haif           e_shnum;       
	Elf32_Haif           e_shstrndx;  
} Elf32_Ehdr;

foobar 为例:

$ xxd -a -u -g 1 -c 16 -l 80 foobar
0000000: 7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00  .ELF............
0000010: 02 00 03 00 01 00 00 00 A0 80 04 08 34 00 00 00  ............4...
0000020: D0 01 00 00 00 00 00 00 34 00 20 00 03 00 28 00  ........4. ...(.
0000030: 07 00 06 00 01 00 00 00 00 00 00 00 00 80 04 08  ................
0000040: 00 80 04 08 6C 01 00 00 6C 01 00 00 05 00 00 00  ....l...l.......

关于大端小端:它们取决于CPU架构,powerpc,aix、SPARC等是大端;x86架构处理器(Intel、AMD,PC)、arm架构处理器(arm,手机)是小端 ]

  • 采用大小模式对数据进行存放的主要区别在于在存放的字节顺序,若数字的高位在内存中的低地址则是大端(即数字在内存中的二进制形式的第一字节是数字的高位),不然是小端。
  • 采用小端方式进行数据存放利于计算机处理,所以计算机的内部处理较多用小端字节序;关于字符串须要注意的是,char *ch ="12345" ,没有大端小端之分,'5' 必定是在高地址存放的;
  • 采用大端方式进行数据存放符合人类的正常思惟,除了计算机的内部处理,其余的场合几乎都是大端字节序,好比网络传输和文件储存
  • 最开始的是 16 字节的 e_ident ,其中开头 4 个字节固定不变,第一个字节为 0x7F ,后面 3 个字节为 ELF 三个字符(从低到高,顺序存放),代表这个文件是ELF文件
  • e_type 表示文件类型foobare_type0x0002 ,表示是一个可执行文件 Executable File
  • e_machinefoobar 中此项的值是 0x0003 ,表示运行该程序的体系结构要求是 Intel 80386
  • e_version 这个成员肯定文件的版本,这里是 0x00000001
  • e_entry 程序的入口地址foobar 入口地址是 0x080480A0 (高地址存放高字节,小端法);
  • e_phoffProgram header table 在文件中的偏移量,字节计数。这里是 0x00000034
  • e_shoffSection header table 在文件中的偏移量,字节计数。这里是 0x000001C0
  • e_flagsIA32 来讲,此项为零;
  • e_ehsizeELF header 大小,字节计数。这里是 0x0034
  • e_phentsizeProgram header table每一个条目(一个 Program header)的大小。这里值是 0x0020
  • e_phnumProgram header table 中的条目数量。这里有 0x0003 个;
  • e_shentsizeSection header table 中的条目大小(一个 Section header的大小)。这里是 0x0028
  • e_shnumSection header table 中的条目数量。这里有 0x0006 个;
  • e_shstrndx 包含节名称的字符串表是第几个节(从零开始)。这里值为 0x0005 ,表示第五个节包含节名称。

上面, Program header table 在文件中的偏移量 e_phoff = 0x34 ,而 ELF header 的大小 e_ehsize = 0x34 ,说明ELF头后是程序头表。

2. Program header

定义了一个 Program header 的代码以下:

typedef struct {
	ELF32_Word  p_type;
	ELF32_Off   p_offset;
	ELF32_Addr  p_vaddr;
	ELF32_Addr  p_paddr;
	ELF32_Word  p_filesz;
	ELF32_Word  p_memsz;
	ELF32_Word  p_flags;
	ELF32_Word  p_align;
} ELF32_Phdr;

Program header 描述的是系统准备运行程序时须要的一个段 Segment 的相关信息:(如在文件中的位置、大小和放入内存后的位置和大小等)

  • p_type :当前程序头描述的段的类型
  • p_offset :段的第一个字节在文件中的偏移
  • p_vaddr :段的第一个字节在内存中的虚拟地址
  • p_paddr :在物理地址定位相关的系统中,此项为物理地址保留(不太明白);
  • p_filesz :段在文件中的长度
  • p_memsz :段在内存中的长度
  • p_flags与段相关的标志
  • p_align :根据此项值肯定段在文件以及内存中如何对齐

若是咱们想把一个文件加载入内存的话,须要的就是这些信息。foobar 为例:(程序头表在文件中的偏移量是 0x34;每一个程序头的大小是 0x20 ;程序头表中的条目数量是 0x3 个):

$ xxd -a -u -g 1 -c 16 -s +0x34 -l 0x60 foobar
0000034: 01 00 00 00 00 00 00 00 00 80 04 08 00 80 04 08  ................
0000044: 6C 01 00 00 6C 01 00 00 05 00 00 00 00 10 00 00  l...l...........
0000054: 01 00 00 00 6C 01 00 00 6C 91 04 08 6C 91 04 08  ....l...l...l...
0000064: 08 00 00 00 08 00 00 00 06 00 00 00 00 10 00 00  ................
0000074: 51 E5 74 64 00 00 00 00 00 00 00 00 00 00 00 00  Q.td............
0000084: 00 00 00 00 00 00 00 00 07 00 00 00 04 00 00 00  ................

三个程序头取值以下:
在这里插入图片描述
由此,能够得出 foobar 加载入内存后的状况:
在这里插入图片描述
到这里为止,书中对 ELF 文件的探索就结束了,没有对节头表进行描述。下面直接就开始扩充Loader了。


5.4 从Loader到内核

Loader要作的工做以下:

  • 加载内核到内存;
  • 跳入保护模式;

1. 用Loader加载ELF

加载内核到内存,和引导扇区加载Loader到内存的工做很是类似,只是咱们这里须要根据 Program header table 中的值,把内核中相应的段放到正确的位置

书上的作法是:先像引导扇区处理Loader同样把内核放入内存,而后在保护模式下挪动它的位置。过程依旧是寻址文件、定位文件、读入内存。

boot.asm, loader.asm 之间共享的一些常量中、与FAT12文件有关的内容写进了一个单独文件 fat12hdr.inc
在这里插入图片描述
在这里插入图片描述
因而,使用 fat12hdr.inc 的代码以下:

jmp short LABEL_START
	nop
; 下面是FAT12 磁盘的头,包含它是由于下面用到了磁盘的一些信息
%include "fat12hdr.inc"

LABEL_START:

下面修改 loader.asm ,让它把内核先放进内存:
在这里插入图片描述
在这里插入图片描述在这里插入图片描述在这里插入图片描述
加载内核的代码已经写好了,但是咱们尚未内核,若是如今运行,就会出现下面的状况:(光盘源代码有KERNEL,不过我没有把它写入软盘)
在这里插入图片描述
先上一个最简单的内核,kernel.asm ,显示一个字符,不过显示字符时涉及到内存操做,要用到GDT,假设Loader中段寄存器 gs 就已经指向显存的开始。以后的内核就在它的基础上进行扩充:(chapter5/c/kernel.asm 还算不上内核的内核扩充)

; 编译连接方法 
; $ nasm -f elf kernel.asm -o kernel.o
; $ ld -s kernel.o -o kernel.bin

[section .text]		; 代码在此
global _start       ; 导出_start
_start:				; 跳到这里时咱们假设gs指向显存
	mov ah, 0Fh		; 0000: 黑底  1111: 白字
	mov al, 'K'
	mov [gs:((80 * 1 + 39) * 2)], ax ; 屏幕第1,39列
	jmp $

如今编译内核并写入软盘映像(以前已经写入过 loader.bin 了;这里能够直接执行 make ):

nasm -f elf -o kernel.o kernel.asm
ld -s -o kernel.bin kernel.o
sudo mount -o loop a.img /mnt/floppy/
sudo cp kernel.bin	/mnt/floppy/ -v
sudo umount /mnt/floppy/

执行后,不是 No KERNEL 而是在 Loading 后面出现一个圆点,说明Loader读了一个扇区,咱们只是加载内核到内存而没有作其余工做,因此没有其余现象出现:
在这里插入图片描述


2. 跳入保护模式

Loader是咱们本身加载的,段地址就是 BaseOfLoader ,所以 Loader 中出现的标号的物理地址能够表示为:标号的物理地址=BaseOfLoader * 10h + 标号的偏移

而后,将 BaseOfLoader 的相关声明写在同一个文件中 load.inc 中,其中 BaseOfLoaderPhyAddr 是用来代替 BaseOfLoader * 10h 的, 在 boot.asm, loader.asm 中分别以一句 %include "load.inc" 将其包含:
在这里插入图片描述
接下来,定义三个描述符,分别是一个 0~4GB 的可执行段,一个 0~4GB 的可读写段,一个指向显存开始地址的段,而后在GdtPtr中, 用 BaseOfLoader * 10h 来计算GDT的基址:
在这里插入图片描述
下面是Loader的32位代码段,打印一个字符 'P'
在这里插入图片描述

进入保护模式:
在这里插入图片描述
运行,结果以下,看到 'P' 说明进入了保护模式:
在这里插入图片描述
而后,初始化各个寄存器 ds,es,fs,ss,esp
在这里插入图片描述
其中,TopOfStack 定义以下,1KB 的堆栈:

StackSpace:	times 1024 db 0
TopOfStack	equ BaseOfLoaderPhyAddr + $	; 栈顶

接着,在Loader开头添加一些内容,先获得可用内存的信息,下面的的代码均可以从第三章复制过来,包含在32位代码段中:
在这里插入图片描述在这里插入图片描述

获得内存信息后,定义页目录和页表:(节自 chapter5/d/loader.asm )

; 页目录和页表的位置
PageDirBase	equ 100000h		; 页目录开始地址: 1M
PageTblBase	equ 101000h		; 页表开始地址: 1M + 4K

还有些字符串和变量的定义,保护模式下的地址都加上了Loader的基地址:
在这里插入图片描述
打开分页机制,这也是第三章的内容:
在这里插入图片描述在这里插入图片描述
如今,咱们来调用它们:
在这里插入图片描述
运行代码,结果以下:
在这里插入图片描述
如今,这已经成为咱们操做系统的一部分了,而再也不是一个实验。

3. 从新放置内核

接下来,咱们要整理内存中的内核并将控制权交给它。作法是:根据内核的 Program header table 的信息进行以下的内存复制:

memcpy(p_vaddr, BaseOfLoaderPhyAddr + p_offset, p_filesz);

每个Program header都描述一个段,p_offset 为段在文件中的偏移,p_filesz 为段在文件中的长度,p_vaddr 为段在内存中的虚拟地址。若是Program header有 n 个,复制就进行 n 次。

另外,有 ld 生成的可执行文件中,p_vaddr 的值彷佛太大了,相似于 0x8048XXX ,这显然已经超出了128MB的内存范围。咱们不能容许编译器来决定内核的加载地址,因此须要修改 ld 的选项来让它生成的可执行代码中 p_vaddr 的值变小。所以,将编译连接时的命令改成:

nasm -f elf -o kernel.o kernel.asm
ld -s -Ttext 0x30400 -o kernel.bin kernel.o

这样程序的入口地址变为 0x30400ELF header 等信息位于 0x30400 以前,此时的 ELF headerProgram header table 的状况以下:
在这里插入图片描述
因此,咱们应该这样放置内核:

memcpy(30000h, 90000h + 0, 40Dh);

即,咱们应该把文件开始的 40Dh 字节内容放到内存 30000h 处。因为程序入口在 30400h ,因此代码只有 0Dh + 1 个字节。

看一下 chapter5/e/kernel.bin 的内容,发现从 400h ~ 40Dh 是仅有的代码,0xEBFE 就是 jmp $ (反汇编出来就是 jmp short 0x40b) :

$ xxd -a -u -c 16 -g 1 kernel.bin
0000000: 7F 45 4C 46 01 01 01 00 00 00 00 00 00 00 00 00  .ELF............
0000010: 02 00 03 00 01 00 00 00 00 04 03 00 34 00 00 00  ............4...
0000020: 20 04 00 00 00 00 00 00 34 00 20 00 01 00 28 00   .......4. ...(.
0000030: 03 00 02 00 01 00 00 00 00 00 00 00 00 00 03 00  ................
0000040: 00 00 03 00 0D 04 00 00 0D 04 00 00 05 00 00 00  ................
0000050: 00 10 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0000060: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
*
0000400: B4 0F B0 4B 65 66 A3 EE 00 00 00 EB FE 00 2E 73  ...Kef.........s
0000410: 68 73 74 72 74 61 62 00 2E 74 65 78 74 00 00 00  hstrtab..text...
0000420: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0000430: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00  ................
0000440: 00 00 00 00 00 00 00 00 0B 00 00 00 01 00 00 00  ................
0000450: 06 00 00 00 00 04 03 00 00 04 00 00 0D 00 00 00  ................
0000460: 00 00 00 00 00 00 00 00 10 00 00 00 00 00 00 00  ................
0000470: 01 00 00 00 03 00 00 00 00 00 00 00 00 00 00 00  ................
0000480: 0D 04 00 00 11 00 00 00 00 00 00 00 00 00 00 00  ................
0000490: 01 00 00 00 00 00 00 00                          ........
$ ndisasm kernel.bin <- 反汇编; 复制时省了一段
.........
000003F9  0000              add [bx+si],al
000003FB  0000              add [bx+si],al
000003FD  0000              add [bx+si],al
000003FF  00B40FB0          add [si-0x4ff1],dh
00000403  4B                dec bx
00000404  6566A3EE00        mov [gs:0xee],eax
00000409  0000              add [bx+si],al
0000040B  EBFE              jmp short 0x40b

下面的代码实现将 kernel.bin 根据ELF文件信息转移到正确的位置,找到每一个Program header,根据其信息进行内存复制便可:
在这里插入图片描述

接下来解释为何要设置入口地址是 0x30400 。甚至于前面存放Loader.bin和Kernel.bin的位置也不是随便指定的,看一下内核被加载以后内存的使用状况,可能就明白了:

  • 09FC00h ~ 09FFFFh 不能被用做常规使用,而是做为BIOS参数区保护起来;
  • 咱们真正可使用的内存是 0500h ~ 09FBFFh 这一段;
  • 另外,将入口地址放到 0x30400 而不是直接放在 0500h 是出于调试的方便,把内核加载到这里,即便用DOS调试也不会覆盖掉DOS内存,由于DOS通常不会占用 0x30000 上面的内存地址;
  • 0x90000 开始的63KB留给了 loader.bin ,由于它本质上是一个.COM文件,不会太大;
  • kernel.bin 加载到内存方法和加载 loader.bin 同样,也是放在一个段中,因此也不可以超过 64KB
  • Boot Sector 将给咱们的空闲地址分为了两块,可是引导扇区完成使命后就没有用了,因此能够把它也做为空内存来使用。
    在这里插入图片描述

4. 向内核交出控制权

向内核跳转便可:(chapter5/3/loader.asm)

; *************************
	jmp SelectorFlatC : KernelEntryPointPhyAddr`; 正式进入内核
	; *************************

KernelEntryPointPhyAddr 定义在头文件 load.inc 中,值就是 0x30400 ,它必须和 ld 的参数 -Ttext 指定的值一致。若是咱们想把内核放在别的地方,就只需改变这两个地方。

运行效果以下,出现 'K' 就说明操做系统内核开始运行了:
在这里插入图片描述


回顾内核得到控制权之时各个寄存器的情况,内核中咱们须要这些信息:

  • cs, ds, es, fs, ss 表示的段都指向内存地址 0hgs 表示的段指向显存,这些都是进入保护模式后设置的;
  • esp, GDT 等内容都在Loader中;
  • 对内核进行扩充时,它们都会被移入内核中,方便控制;
    在这里插入图片描述

5.5 扩充内核

1. 切换堆栈和GDT

如今,咱们能够用C语言了,只要能用C,咱们就避免用汇编。下面先看一些变量和函数的定义。

type.h中定义了 u8, u16, u32 这些类型,表明8位、16位、32位的数据类型,让咱们一目了然:
在这里插入图片描述
const.h中定义了 PUBLIC, PRIVATE 等,用于区分全局和局部的符号,还有, GDT_SIZE 是GDT中描述符的个数:
在这里插入图片描述
protect.h中定义了 Descriptor 来表示描述符,相似前面 pm.inc 中的宏,不过更加清晰明了:
在这里插入图片描述
start.c中定义了全局变量 gdt_ptr (六个字节的数组)和全局函数 cstartcstart 首先把位于Loader中的原GDT所有复制给新的gdt (是一个 GDT_SIZEDescriptor 结构体数组) ,用到的函数是 string.asm 中定义的 memcpy ,而后把新的gdt的界限和基地址写入原来的 gdt_ptr 中,以便 kernel.asm 中使用:
在这里插入图片描述
这里用4个语句就完成了切换堆栈和更换GDT的任务,StackTop 是大小为2KB的堆栈段 .bss 的栈顶指针:
在这里插入图片描述

用了C,代码理解起来舒服多了(此次没有显示字符 P, K 的代码)。下面咱们编译连接:

$ nasm boot.asm -o boot.bin
$ nasm loader.asm -o loader.bin
$ nasm -f elf -o kernel.o kernel.asm
$ nasm -f elf -o string.o string.asm
$ gcc -c -o start.o start.c
$ ld -s -Ttext 0x30400 -o kernel.bin kernel.o string.o start.o
$ bximage
$ dd if=boot.bin of=a.img bs=512 count=1 conv=notrunc
$ sudo mount -o loop a.img /mnt/floppy/
$ sudo cp loader.bin /mnt/floppy/ -v
$ sudo cp kernel.bin /mnt/floppy/ -v
$ sudo umount /mnt/floppy/

gcc min.c : gcc 编译器会对源文件 min.c 进行预处理、编译、以及连接,最后生成可执行文件,默承认执行文件名为 a.out
gcc -c min.c: gcc 编译器会对源文件 min.c 进行预处理、编译、不进行连接,最后生成的是 object file (目标文件),此处为 min.o这属于编译过程的中间阶段。再通过连接,才能最终生成可执行文件;
gcc -o min.out min.c给生成的可执行文件命名,不然生成默认名称 a.out 文件;
gcc -c -o min.o min.c给生成的中间文件命名

连接的过程当中,出了错误——start.c中的 cstart 函数中存在未定义的引用 disp_str

start.o: In function `cstart':
start.c:(.text+0xe): undefined reference to `disp_str'

看了看代码,发现这是定义在 kliba.asm 中的,如今尚未用到它,所以修改 start.c ,将这句注释掉,就能够连接了:

//disp_str("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n"
	// "-----\"cstart\" begins-----\n");

运行结果以下:
在这里插入图片描述

把第三章的代码复制过来,放到 kliba.asm 中:
在这里插入图片描述
如今,取消以前start.c中注释掉的和 disp_str 相关的语句,打印字符串。而后进行编译连接:(gcc 加上 -fno-builtin 是为了防止编译器认为 memcpy 是一个 built-in function)

$ nasm -f elf -o kernel.o kernel.asm
$ nasm -f elf -o string.o string.asm
$ nasm -f elf -o kliba.o kliba.asm 
$ gcc -c -fno-builtin -o start.o start.c
$ ld -s -Ttext 0x30400 -o kernel.bin kernel.o string.o start.o kliba.o

运行:
在这里插入图片描述

2. 整理咱们的文件夹

代码文件太多了,各类类型的文件混着一块儿,看着很不舒服。咱们整理一下目录。

  • boot.asm, loader.asm 和须要的头文件单独放在目录 /boot 中;
  • const.h, protect.h, type.h 放在 /include 中,做为头文件;
  • kliba.asmstring.asm 放在 /lib 中,做为库;
  • kernel.asm, start.c 放在 /kernel 中;
    在这里插入图片描述

3. Makefile

书中这里才介绍了Makefile,由于随着源代码的增多,编译连接它们的命令也在增多,并且如今文件放入不一样的文件夹中,要编译就更麻烦了。有了Makefile,每次只须要一行命令就能够完成所有过程。

Makefile内容不少,咱们边学边用。先看一个简单的Makefile:chapter5/g/boot/Makefile

# Makefile for boot
# Programs, flags, etc.
ASM = nasm
ASMFLAGS = -I include/

# This Program
TARGET = boot.bin loader.bin

# All Phony Targets
.PHONY: everything clean all

# Default starting position
everything: $(TARGET)

clean:
	rm -f $(TARGET)

all: clean everything

boot.bin : boot.asm include/load.inc include/fat12hdr.inc
	$(ASM) $(ASMFLAGS) -o $@ $<

loader.bin : loader.asm include/load.inc include/fat12hdr.inc include/pm.inc
	$(ASM) $(ASMFLAGS) -o $@ $<

上面的程序中:

  • # 开头的是注释;
  • = 用来定义变量,ASM, ASMFLAGS 就是变量,使用它们的时候须要 $(ASM), $(ASMFLAGS)
  • Makefile最重要的语法:
    target : prerequisites
    	command
    表示:
    • 要想获得 target ,须要执行命令 command

    • target 依赖于 prerequisites ;当 prerequisites 中至少有一个文件比 target 新的时候, command 才被执行。

    • 以最后两行为例,其意义为:

      • 要获得 loader.bin ,须要执行 $(ASM) $(ASMFLAGS) -o $@ $<
      • loader.bin 依赖于 loader.asm, include/load.inc, include/pm.inc, include/fat12hdr.inc ;当这些文件中至少有一个比 loader.bin 新的时候,command 执行。
    • $@ 表示 target$< 表示 prerequisites 的第一个名字。

    • $(ASM) $(ASMFLAGS) -o $@ $< 等价于 nasm -o loader.bin loader.asm

不过,boot.bin, loader.bin 以外,everything, clean, all 后面也有目标,可是它们不是文件,而是3个动做名称:

  • 若是运行 make clean 就会执行 rm -f $(TARGET) ,即 rm -f boot.bin loader.bin
  • 若是运行 make everything 就会执行:
    nasm -I include/ -o boot.bin boot.asm
     nasm -I include/ -o loader.bin loader.asm
  • all 后面跟着 clean, everything ,表示若是执行 make allclean, everything 表示的动做会分别被执行
    $ make all
    rm -f boot.bin loader.bin
    nasm -I include/ -o boot.bin boot.asm
    nasm -I include/ -o loader.bin loader.asm
  • .PHONY 的做用就在于此,表示它后面的名字不是文件,而仅仅是一种行为的标号

若是直接输入 make整个Makefile就会从第一个名字所表明的动做开始执行,这里第一个标号是 everything ,因此 makemake everything 的结果同样:

$ make
nasm -I include/ -o boot.bin boot.asm
nasm -I include/ -o loader.bin loader.asm

运行一个 make 以后,当即运行又一次 make ,因为 make 会自动比较目标和源文件的新旧程度,而此时全部的文件都是新的,就不会作什么动做。这样,咱们每次 make 时就不会把每一个源文件都编译一遍,在大型程序中会节省不少编译时间:

$ make : 
Nothing to be done for 'everything'.

总的来讲,make程序的原则是:由果寻因,先看要生成什么,再找生成它须要的条件

接下来,对这个Makefile进行改造和扩充,就能够用于编译和连接整个操做系统工程了。先把Makefile移动到 /boot 的父目录中,而后修改一下——主要是把文件都加上了路径 boot/
在这里插入图片描述
此时运行时,因为文件名是 Makefile.boot ,因此须要用 -f 进行指定。运行 make 以下:
在这里插入图片描述


Makefile.boot 的基础上加以改进,这里咱们把GCC的选项也增长了对头文件目录的指定 -I includechapter5/g/Makefile

#########################
# Makefile for Orange'S #
#########################

# Entry point of Orange'S
# It must have the same value with 'KernelEntryPointPhyAddr' in load.inc!
ENTRYPOINT	= 0x30400

# Offset of entry point in kernel file
# It depends on ENTRYPOINT
ENTRYOFFSET	=   0x400

# Programs, flags, etc.
ASM		= nasm
DASM	= ndisasm
CC		= gcc
LD		= ld
ASMBFLAGS	= -I boot/include/
ASMKFLAGS	= -I include/ -f elf
CFLAGS		= -I include/ -c -fno-builtin
LDFLAGS		= -s -Ttext $(ENTRYPOINT)
DASMFLAGS	= -u -o $(ENTRYPOINT) -e $(ENTRYOFFSET)

# This Program
ORANGESBOOT	= boot/boot.bin boot/loader.bin
ORANGESKERNEL	= kernel.bin
OBJS		= kernel/kernel.o kernel/start.o lib/kliba.o lib/string.o
DASMOUTPUT	= kernel.bin.asm

# All Phony Targets
.PHONY : everything final image clean realclean disasm all buildimg

# Default starting position
everything : $(ORANGESBOOT) $(ORANGESKERNEL)

all : realclean everything

final : all clean

image : final buildimg

clean :
	rm -f $(OBJS)

realclean :
	rm -f $(OBJS) $(ORANGESBOOT) $(ORANGESKERNEL)

disasm :
	$(DASM) $(DASMFLAGS) $(ORANGESKERNEL) > $(DASMOUTPUT)

# We assume that "a.img" exists in current folder
buildimg :
	dd if=boot/boot.bin of=a.img bs=512 count=1 conv=notrunc
	sudo mount -o loop a.img /mnt/floppy/
	sudo cp -fv boot/loader.bin /mnt/floppy/
	sudo cp -fv kernel.bin /mnt/floppy
	sudo umount /mnt/floppy

boot/boot.bin : boot/boot.asm boot/include/load.inc boot/include/fat12hdr.inc
	$(ASM) $(ASMBFLAGS) -o $@ $<

boot/loader.bin : boot/loader.asm boot/include/load.inc \
			boot/include/fat12hdr.inc boot/include/pm.inc
	$(ASM) $(ASMBFLAGS) -o $@ $<

$(ORANGESKERNEL) : $(OBJS)
	$(LD) $(LDFLAGS) -o $(ORANGESKERNEL) $(OBJS)

kernel/kernel.o : kernel/kernel.asm
	$(ASM) $(ASMKFLAGS) -o $@ $<

kernel/start.o : kernel/start.c include/type.h include/const.h include/protect.h
	$(CC) $(CFLAGS) -o $@ $<

lib/kliba.o : lib/kliba.asm
	$(ASM) $(ASMKFLAGS) -o $@ $<

lib/string.o : lib/string.asm
	$(ASM) $(ASMKFLAGS) -o $@ $<

这个Makefile长得多,可是没有多么困难——若是可以画出一棵目标依赖树,可能容易看一些——功能上面确实强大太大了。

  • make disasm 能够反汇编内核到一个文件;
  • make buildimg 把引导扇区、loader.bin、kernel.bin 写到虚拟软盘;
  • make image :先 realclean 删除一切 .o 文件、boot.bin, loader.bin, kernel.bin ,再作 everything ,再 clean 一切 .o 文件,再 buildimg 写到虚拟软盘。

运行 make clean 试试看:
在这里插入图片描述
kernel/start.ccstart( ) 的结束处添加一行语句:
在这里插入图片描述
make image 一下,运行:
在这里插入图片描述
这说明Makefile运行正常。之后,咱们彻底能够自行定义Makefile,添加功能如复制内核文件等,用 make 来轻松完成这些任务。


4. 添加中断处理

进程是一个操做系统最基本也最重要的东西,咱们的下一个目标就是实现一个进程,再进一步,咱们应该拥有多个进程进程自己不过是一段代码,但它却涉及到进程和操做系统间执行的切换——这种控制权转换机制,就是中断。下面,咱们将中断处理添加到OS中。

要作的工做是:设置8259A和创建IDT。下面的函数 init_8259A( ) 设置8259A,它和第三章的代码彻底一致:
在这里插入图片描述

上面的代码涉及到的宏是:
在这里插入图片描述

写端口的 out_byte 位于 kliba.asm 中,此外还有读端口的操做。空操做是为了延迟,以便完成端口的读写
在这里插入图片描述
它们的原型位于 include/proto.h (用于存放函数声明) 中,start.cdisp_str 的声明也放到了里面,另外,咱们把 memcpy 放到了 string.h 中:
在这里插入图片描述

最后,修改Makefile,添加新的目标和修改原来的依赖关系:
在这里插入图片描述
确认依赖关系,更方便的作法是:gcc -M ,它会自动生成依赖关系。而后直接复制便可:

$ gcc -M kernel/start.c -I include
start.o: kernel/start.c include/type.h include/const.h include/protect.h \
 include/proto.h include/string.h include/global.h

如今咱们已经能够 make 一下了,测试本身的工做有没有错误。接下来,咱们要初始化IDT。

  • 首先修改 start.c ,和以前初始化GDT同样:
    在这里插入图片描述

  • gdt[ ], gdt_ptr[ ], idt[ ], idt_ptr[ ] 都放在了 global.h 中:
    在这里插入图片描述

  • EXTERN 定义在 const.h 中,它通常被定义为 extern ,不过在 global.h 中,若是定义了宏 GLOBAL_VARIABLES_HERE 的话,取消以前定义的 EXTERN (=extern) ,而后定义 EXTERN 为空值。

  • 经过宏 GLOBAL_VARIABLES_HERE 的使用,咱们可让 global.h 中的全部变量只出现一次,同时,预编译结束后,global.c 和其余 .c 文件的结果不一样——global.c 中变量前面没有 extern 关键字,而其余 .c 文件中变量前面都有 extern 关键字。
    在这里插入图片描述

  • GATE 定义在 protect.h 中:
    在这里插入图片描述

  • kernel.asm 中加入几句以导入 idt_ptr 这个符号并加载IDT。而后,将处理器能够处理的中断和异常列表及其处理程序都添加上:
    在这里插入图片描述在这里插入图片描述
    在这里插入图片描述

  • 上个程序中,最后栈顶被调整为指向 eip ,堆栈中从顶向下依次是:eip、cs、eflags ,这就是用 iretd 返回前的样子。

  • exception_handler( ) 在新建的 protect.c 中,原型为 void exception_handler(int vec_no, int err_code, int eip, int cs, int eflags); ,它不会破坏堆栈中的 eip, cs, eflags ,由于C调用约定让调用者恢复堆栈。这个函数的实现以下——它打印空格清空屏幕前5行,而后打印堆栈中的参数:
    在这里插入图片描述在这里插入图片描述

  • 打印字符串使用 disp_color_str( ) ,这个函数新增长了一个设置颜色的参数:
    在这里插入图片描述

  • 显示整数用到新的函数 disp_int( ) ,它被定义在新增文件 klib.c 中,用 itoa( ) 将整数(一个32位的数值)转换为(十六进制)字符串后显示:
    在这里插入图片描述
    在这里插入图片描述

  • 设置IDT的代码在函数 init_prot( ) 中,位于 protect.c 中,protect.c 几乎会调用一个函数 init_idt_desc( ) ,它用来初始化一个门描述符。其中使用的函数指针指向返回值为 void 的函数:
    在这里插入图片描述
    下面的全部异常处理程序都和上面的函数指针类型声明一致。而且, init_prot( ) 中全部描述符都初始化为中断门。其中使用的若干个宏:INT_VECTOR_ 开头的表示中断向量, DA_386IGate 表示中断门,在定义 protect.h 中定义,PRIVILEGE_KRNL, PRIVILEGE_USER 定义在 const.h 中:
    在这里插入图片描述
    在这里插入图片描述

上面就是大部分的设置IDT的代码,下面调用 init_prot( )
在这里插入图片描述
修改Makefile后,先 make 而后运行,没有任何效果。由于咱们有异常处理程序,可是没有异常发生,因而在 kernel.asm 中用 ud2 产生一个 #UD 异常:
在这里插入图片描述

再次 make ,连接时出错:
在这里插入图片描述

发如今 klib.c 中的 disp_int 报错,在 Makefile 中的 $(CFLAGS) 后面加上 -fno-stack-protector ,即不须要栈保护。以后,编译连接就能够正常完成了。

lib/klib.o : lib/klib.c
        $(CC) $(CFLAGS) -fno-stack-protector -o $@ $<

进行验证,将 char output[16]; 改成 char* output, ,编译不报错,说明问题出在定义数组的栈操做。


为了进行说明,使用代码 test.c

int main() {
    char str[16];
    return 0;
}

① 编译连接:gcc -o test test.c ;而后 gdb test

(gdb)disas main
(gdb) disas main
Dump of assembler code for function main:
   0x08048404 <+0>:	push   %ebp
   0x08048405 <+1>:	mov    %esp,%ebp
   0x08048407 <+3>:	and    $0xfffffff0,%esp
   0x0804840a <+6>:	sub    $0x20,%esp
   0x0804840d <+9>:	mov    %gs:0x14,%eax
   0x08048413 <+15>:	mov    %eax,0x1c(%esp)
   0x08048417 <+19>:	xor    %eax,%eax
   0x08048419 <+21>:	mov    $0x0,%eax
   0x0804841e <+26>:	mov    0x1c(%esp),%edx
   0x08048422 <+30>:	xor    %gs:0x14,%edx
   0x08048429 <+37>:	je     0x8048430 <main+44>
   0x0804842b <+39>:	call   0x8048320 <__stack_chk_fail@plt>
   0x08048430 <+44>:	leave  
   0x08048431 <+45>:	ret    
End of assembler dump.

② 编译连接:gcc -fno-stack-protector -o test test.c ;而后 gdb test

(gdb)disas main
(gdb) disas main
Dump of assembler code for function main:
   0x080483b4 <+0>:	push   %ebp
   0x080483b5 <+1>:	mov    %esp,%ebp
   0x080483b7 <+3>:	sub    $0x10,%esp
   0x080483ba <+6>:	mov    $0x0,%eax
   0x080483bf <+11>:	leave  
   0x080483c0 <+12>:	ret    
End of assembler dump.

发现个人电脑上,这个版本的 gcc 默认要进行栈检查的应该调用 __stack_chk_fail ,加了 -fno-stack-protector 就不用栈检查,无需调用 __stack_chk_fail


运行,效果以下。能够看到异常的助记符、名字、eflags, cs, eip 的值:
在这里插入图片描述

这是个没有错误码的异常,咱们这里产生一个有错误码的异常,将 ud2 这个指令改成 jmp 0x40:0 。运行并显示错误码:
在这里插入图片描述

初始化 8259A 和设置 IDT 这两项任务完成后,咱们就有了异常处理机制,从此,即使出了错,咱们能方便地知道错误的类型和出现的地方。与 Bochs 的调试功能结合起来,让开发更方便。


8259A 虽然已经设置完成,但咱们尚未真正开始使用它。因为两片级联的 8259A 能够挂接 15 个不一样的外部设备,应有 15中断处理程序。为简单起见,先用两个带参数的宏做为中断处理程序。下面的代码就是8259A的中断例程:
在这里插入图片描述
全部中断都会触发一个函数 spurios_irq( ) ,定义以下:
在这里插入图片描述

设置IDT:
在这里插入图片描述
在这里插入图片描述
而后,须要修改 IP 位,并在 init_8259A( ) 中打开对应中断:

在这里插入图片描述
在这里插入图片描述

make 后(这里一样有 __stack_chk_fail 的问题,用上面的方法解决便可)运行,没有特殊状况,可是敲击键盘的任意键时,就会打印 spurios_irq: 0x1 。这代表IRQ是1,即键盘中断:
在这里插入图片描述

5. 说明

咱们屡次用到 disp_str( )、disp_color_str( )、disp_int( ) 等函数。但 Minix 或者 Linux 的代码中没有与之相相似的函数。这些函数既不强大也不优美,因此在可预见的未来,当咱们的控制台等模块完善之时,会写一个漂亮的 printf( ) 。只是如今先将就着用它们吧。


5.6 小结

回过头来,发现本身竟然已经走出这么远了。 学习了保护模式,还知道了如何调试,甚至于还学习了ELF文件格式、Makefile的编写等一系列内容。还有了异常处理,设置了8259A并能够接收外部中断

更重要的是,接下来的工做是在已经搭建好的框架上完成,而且大部分将使用可读性较好的C语言编写,而不是晦涩的汇编代码。

最困难的日子已通过去,虽然眼前的路仍然很长,可是咱们再也不感受是在无边的黑暗中摸索,眼前是一条光明大道,等待咱们踏入新的征程。