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!
用C写程序,和汇编连接在一块儿……这是我之前从未接触的领域。网络
下面的例子中,整个程序的过程是:架构
_start
在 foo.asm
中;一开始程序从入口进入,将后面的参数先压入栈中;调用 extern
声明的 bar.c
中的函数 choose()
;choose
比较两个传入的参数,根据不一样的结果打印出不一样的参数;bar.c
开头声明的、 foo.asm
中用 global
导出的 myprint()
完成的。这个例子中,包含了汇编和C的相互调用:框架
bar.c
中用到 myprint()
函数,因此 foo.asm
中要用 global
将其导出;foo.asm
中要用到外面定义的函数 choose
,所以须要用 extern
声明;myprint
和 choose
,遵循的都是 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
总的来讲,关键点在于 extern
和 global
这两个关键字,有了它们就能够在汇编和C之间自由变换。
ELF
文件由四部分构成:ELF头 ELF Header
,程序头表 Program Header Table
,节 Sections
,节头表 Section Header Table
。、除了ELF头的位置是固定的,包含其余部分的位置、大小等重要信息外,文件中不必定要包含其余三部份内容,位置也不必定按照下面的顺序安排;大小也不固定:
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
表示文件类型,foobar
的 e_type
是 0x0002
,表示是一个可执行文件 Executable File
;e_machine
,foobar
中此项的值是 0x0003
,表示运行该程序的体系结构要求是 Intel 80386
;e_version
这个成员肯定文件的版本,这里是 0x00000001
;e_entry
程序的入口地址,foobar
入口地址是 0x080480A0
(高地址存放高字节,小端法);e_phoff
是 Program header table
在文件中的偏移量,字节计数。这里是 0x00000034
;e_shoff
是 Section header table
在文件中的偏移量,字节计数。这里是 0x000001C0
;e_flags
对 IA32
来讲,此项为零;e_ehsize
是 ELF header
大小,字节计数。这里是 0x0034
;e_phentsize
是 Program header table
中每一个条目(一个 Program header
)的大小。这里值是 0x0020
;e_phnum
是 Program header table
中的条目数量。这里有 0x0003
个;e_shentsize
是 Section header table
中的条目大小(一个 Section header
的大小)。这里是 0x0028
;e_shnum
是 Section header table
中的条目数量。这里有 0x0006
个;e_shstrndx
包含节名称的字符串表是第几个节(从零开始)。这里值为 0x0005
,表示第五个节包含节名称。上面, Program header table
在文件中的偏移量 e_phoff = 0x34
,而 ELF header
的大小 e_ehsize = 0x34
,说明ELF头后是程序头表。
定义了一个 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了。
Loader要作的工做以下:
加载内核到内存,和引导扇区加载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读了一个扇区,咱们只是加载内核到内存而没有作其余工做,因此没有其余现象出现:
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的基地址:
打开分页机制,这也是第三章的内容:
如今,咱们来调用它们:
运行代码,结果以下:
如今,这已经成为咱们操做系统的一部分了,而再也不是一个实验。
接下来,咱们要整理内存中的内核并将控制权交给它。作法是:根据内核的 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
这样程序的入口地址变为 0x30400
,ELF header
等信息位于 0x30400
以前,此时的 ELF header
和 Program 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
将给咱们的空闲地址分为了两块,可是引导扇区完成使命后就没有用了,因此能够把它也做为空内存来使用。向内核跳转便可:(chapter5/3/loader.asm
)
; ************************* jmp SelectorFlatC : KernelEntryPointPhyAddr`; 正式进入内核 ; *************************
KernelEntryPointPhyAddr
定义在头文件 load.inc
中,值就是 0x30400
,它必须和 ld
的参数 -Ttext
指定的值一致。若是咱们想把内核放在别的地方,就只需改变这两个地方。
运行效果以下,出现 'K'
就说明操做系统内核开始运行了:
回顾内核得到控制权之时各个寄存器的情况,内核中咱们须要这些信息:
cs, ds, es, fs, ss
表示的段都指向内存地址 0h
,gs
表示的段指向显存,这些都是进入保护模式后设置的;esp, GDT
等内容都在Loader中;如今,咱们能够用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
(六个字节的数组)和全局函数 cstart
。cstart
首先把位于Loader中的原GDT所有复制给新的gdt (是一个 GDT_SIZE
的 Descriptor
结构体数组) ,用到的函数是 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
运行:
代码文件太多了,各类类型的文件混着一块儿,看着很不舒服。咱们整理一下目录。
boot.asm, loader.asm
和须要的头文件单独放在目录 /boot
中;const.h, protect.h, type.h
放在 /include
中,做为头文件;kliba.asm
和 string.asm
放在 /lib
中,做为库;kernel.asm, start.c
放在 /kernel
中;书中这里才介绍了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)
;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 all
,clean, 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
,因此 make
和 make 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 include
:chapter5/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.c
的 cstart( )
的结束处添加一行语句:
make image
一下,运行:
这说明Makefile运行正常。之后,咱们彻底能够自行定义Makefile,添加功能如复制内核文件等,用 make
来轻松完成这些任务。
进程是一个操做系统最基本也最重要的东西,咱们的下一个目标就是实现一个进程,再进一步,咱们应该拥有多个进程进程自己不过是一段代码,但它却涉及到进程和操做系统间执行的切换——这种控制权转换机制,就是中断。下面,咱们将中断处理添加到OS中。
要作的工做是:设置8259A和创建IDT。下面的函数 init_8259A( )
设置8259A,它和第三章的代码彻底一致:
上面的代码涉及到的宏是:
写端口的 out_byte
位于 kliba.asm
中,此外还有读端口的操做。空操做是为了延迟,以便完成端口的读写:
它们的原型位于 include/proto.h
(用于存放函数声明) 中,start.c
中 disp_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,即键盘中断:
咱们屡次用到 disp_str( )、disp_color_str( )、disp_int( )
等函数。但 Minix
或者 Linux
的代码中没有与之相相似的函数。这些函数既不强大也不优美,因此在可预见的未来,当咱们的控制台等模块完善之时,会写一个漂亮的 printf( )
。只是如今先将就着用它们吧。
回过头来,发现本身竟然已经走出这么远了。 学习了保护模式,还知道了如何调试,甚至于还学习了ELF文件格式、Makefile的编写等一系列内容。还有了异常处理,设置了8259A并能够接收外部中断。
更重要的是,接下来的工做是在已经搭建好的框架上完成,而且大部分将使用可读性较好的C语言编写,而不是晦涩的汇编代码。
最困难的日子已通过去,虽然眼前的路仍然很长,可是咱们再也不感受是在无边的黑暗中摸索,眼前是一条光明大道,等待咱们踏入新的征程。