上一篇文章完成了将控制权交给内核,使我们的操作系统内核开始运行了。这篇文章开始记录内核扩充,我们目前的 esp、GDT 等工作还是在Loader中完成的,为了方便控制,我们要把它们放进内核中才好,现在就来做这项工作。
切换堆栈和 GDT
有一点需要先说明,我们现在可以使用C语言了,只要可以用C,我们就尽量避免使用汇编,毕竟C语言比汇编用起来要方便很多。
首先,我们先来切换堆栈和GDT,内核 kernel.asm 代码进行重新编写,具体代码如下所示。
; 编译链接方法
; nasm -f elf kernel.asm -o kernel.o
; nasm -f elf string.asm -o string.o
; gcc -m32 -c start.c -o start.o
; ld -m elf_i386 -Ttext 0x30400 -s kernel.o string.o start.o -o kernel.bin
SELECTOR_KERNEL_CS equ 8
; 导入函数
extern cstart
; 导入全局变量
extern gdt_ptr
[SECTION .bss]
StackSpace resb 2 * 1024
StackTop: ; 栈顶
[section .text] ; 代码在此
global _start ; 导出 _start
_start:
; 把 esp 从 LOADER 挪到 KERNEL
mov esp, StackTop ; 堆栈在 bss 段中
sgdt [gdt_ptr] ; cstart() 中将会用到 gdt_ptr
call cstart ; 在此函数中改变了gdt_ptr,让它指向新的GDT
lgdt [gdt_ptr] ; 使用新的GDT
jmp SELECTOR_KERNEL_CS:csinit
csinit: ; 这个跳转指令强制使用刚刚初始化的结构
push 0
popfd ; Pop top of stack into EFLAGS
hlt
在这段代码中,用4条语句就完成了切换堆栈和更换GDT的任务。其中,StockTop定义在 .bss 段中,堆栈大小为2KB。操作GDT时用到的gdt_ptr是一个全局变量,cstart是一个全局函数,它们定义在start.c中。start.c 代码如下所示。
#include "type.h"
#include "const.h"
#include "protect.h"
PUBLIC void* memcpy(void* pDst, void* pSrc, int iSize);
PUBLIC u8 gdt_ptr[6]; /* 0~15:Limit 16~47:Base */
PUBLIC DESCRIPTOR gdt[GDT_SIZE];
PUBLIC void cstart()
{
/* 将 LOADER 中的 GDT 复制到新的 GDT 中 */
memcpy(&gdt, /* New GDT */
(void*)(*((u32*)(&gdt_ptr[2]))), /* Base of Old GDT */
*((u16*)(&gdt_ptr[0])) + 1 /* Limit of Old GDT */
);
/* gdt_ptr[6] 共 6 个字节:0~15:Limit 16~47:Base。用作 sgdt/lgdt 的参数。*/
u16* p_gdt_limit = (u16*)(&gdt_ptr[0]);
u32* p_gdt_base = (u32*)(&gdt_ptr[2]);
*p_gdt_limit = GDT_SIZE * sizeof(DESCRIPTOR) - 1;
*p_gdt_base = (u32)&gdt;
}
函数cstart()首先把位于 Loader 中的原GDT复制到新的GDT,然后把gdt_ptr中的内容换成新的GDT的基地址和界限。复制GDT使用的是函数memcpy,这个函数就是我们在 Loader 中使用的MemCpy,只不过在这里将函数名做了更改,这次我们把它的函数体放进string.asm中,这样也便于以后进行工具函数的扩展。string.asm代码如下所示。
[SECTION .text]
; 导出函数
global memcpy
; void* memcpy(void* es:pDest, void* ds:pSrc, int iSize);
memcpy:
push ebp
mov ebp, esp
push esi
push edi
push ecx
mov edi, [ebp + 8] ; Destination
mov esi, [ebp + 12] ; Source
mov ecx, [ebp + 16] ; Counter
.1:
cmp ecx, 0 ; 判断计数器
jz .2 ; 计数器为零时跳出
mov al, [ds:esi] ; ┓
inc esi ; ┃
; ┣ 逐字节移动
mov byte [es:edi], al ; ┃
inc edi ; ┛
dec ecx ; 计数器减一
jmp .1 ; 循环
.2:
mov eax, [ebp + 8] ; 返回值
pop ecx
pop edi
pop esi
mov esp, ebp
pop ebp
ret ; 函数结束,返回
; memcpy 结束
函数cstart()中除了用到memcpy定义在其它文件之外,还用到了一些新定义的类型、结构体和宏,这些内容分别定义在了type.h、const.h以及protect.h。这三个头文件代码如下所示。
代码 const.h。
#ifndef _ORANGES_CONST_H_
#define _ORANGES_CONST_H_
/* 函数类型 */
#define PUBLIC /* PUBLIC is the opposite of PRIVATE */
#define PRIVATE static /* PRIVATE x limits the scope of x */
/* GDT 和 IDT 中描述符的个数 */
#define GDT_SIZE 128
#endif
代码 type.h。
#ifndef _ORANGES_TYPE_H_
#define _ORANGES_TYPE_H_
typedef unsigned int u32;
typedef unsigned short u16;
typedef unsigned char u8;
#endif
u8、u16、u32分别代表8位、16位、32位的数据类型。定义它们可以增加我们代码的可读性。
代码 protect.h。
#ifndef _ORANGES_PROTECT_H_
#define _ORANGES_PROTECT_H_
/* 存储段描述符/系统段描述符 */
typedef struct s_descriptor /* 共 8 个字节 */
{
u16 limit_low; /* Limit */
u16 base_low; /* Base */
u8 base_mid; /* Base */
u8 attr1; /* P(1) DPL(2) DT(1) TYPE(4) */
u8 limit_high_attr2; /* G(1) D(1) 0(1) AVL(1) LimitHigh(4) */
u8 base_high; /* Base */
}DESCRIPTOR;
#endif
记录到这里,可能大家对代码已经有了一个大概的了解,毕竟C语言多少是要好理解一点的。因为书上这里也没有写得特别清楚,说的是参考Minix来做,代码有时仅仅是奉行了拿来主义,那作为读者的我来说,更是拿来主义了,只要能写功能就可以了,其它固定的代码模式怎么写就直接拿来用了,如果以后对知识理解的更加深入了,可能就理解了。
说明一点的是,在kernel.asm内核代码中,将显示字符“K”的代码去掉了。同时,loader.asm中显示“P”字符的代码也删除了。毕竟我们当时显示字符的目的是看运行是否正确,是否运行到了那里,是作为调试信息使用的。现在我们的代码运行没有问题,它们也就没有存在的必要了。
好了,我们现在开始进行编译链接:
# 因为删除了显示字符“P”的代码,所以Loader需要重新编译
nasm loader.asm -o loader.bin
# 内核编译链接
nasm -f elf kernel.asm -o kernel.o
nasm -f elf string.asm -o string.o
gcc -m32 -c start.c -o start.o
ld -m elf_i386 -Ttext 0x30400 -s kernel.o string.o start.o -o kernel.bin
编译链接完成后,将loader.bin和kernel.bin拷贝到软件文件fd.img中,运行,查看运行效果。
运行成功,不过屏幕上没有提示信息,看起来有些别扭,总感觉好像少了点什么,是不是程序出问题了?为了证明程序没有问题,我们现在给它添加一个字符串信息的展示,为了以后使用方便,我们将展示字符串的功能编写成一个函数,这样以后需要的时候只用调用就可以了。我们把显示字符串的函数放在一个新的文件kliba.asm中,代码如下所示。
[SECTION .data]
disp_pos dd 0
[SECTION .text]
; 导出函数
global disp_str
; void disp_str(char * info);
disp_str:
push ebp
mov ebp, esp
mov esi, [ebp + 8] ; pszInfo
mov edi, [disp_pos]
mov ah, 0Fh
.1:
lodsb
test al, al
jz .2
cmp al, 0Ah ; 换行
jnz .3
push eax
push ebx
mov eax, edi
mov bl, 160
div bl
and eax, 0FFh
inc eax
mov bl, 160
mul bl
mov edi, eax
pop ebx
pop eax
jmp .1
.3:
mov [gs:edi], ax
add edi, 2
jmp .1
.2:
mov [disp_pos], edi
pop ebp
ret
使用方法和mempy是一样的,需要在C语言代码中声明一下才可以使用。现在我们就可以在cstart()函数中添加打印字符串的代码了。需要注意的是,由于变量disp_pos被初始化为0,所以如果直接打印字符的话,字符会出现在屏幕的左上角,会覆盖掉现有的显示内容,于是在输出时添加了许多回车(\n),以便让字符串越过已经打印的区域。
......
PUBLIC void disp_str(char * pszInfo);
......
PUBLIC void cstart()
{
disp_str("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n-----\"cstart\" begins-----\n");
......
}
现在再来进行编译,这次只用编译kliba.asm和start.c就可以了,不过链接时需要添加kliba.asm编译的文件。
nasm -f elf kliba.asm -o kliba.o
gcc -m32 -c start.c -o start.o
ld -m elf_i386 -Ttext 0x30400 -s kernel.o string.o kliba.o start.o -o kernel.bin
编译链接完成后,将kernel.bin拷贝到软件文件fd.img中,运行,查看运行效果。
可以看到,我们打印的字符串出现在了屏幕上。
整理我们的文件夹
做到这里,相信你和我一样,感觉还不错,文件越来越多,系统也开始像那么回事了。但是出现了一个问题,所有的文件都放在了一个文件夹下,包括我们编译生成的文件和虚拟机用到的文件,看起来很乱,找到某个文件都需要花点时间。看样子,我们是时候将目录整理一下了。
目录整理就比较简单了,可以按照个人的习惯进行整理。在这里,我使用书上给出的目录结构。
- boot.asm和loader.asm放在单独的目录/boot中,包括它们所需要的头文件也放在该目录下;
- klib.asm和string.asm放在/lib中,以库的形象出现;
- type.h、const.h和protect.h放在/include目录中;
- kernel.asm和start.c放在/kernel里面。
这样一来,结构就比较清晰了,我们来看一下现在的目录结构。
Makefile
随着源代码的增多,编译链接它们的命令也在逐渐增多,就像我们刚刚的编译链接命令就有5行。如果每一次都是这样编译链接的话,那真是太麻烦了,而且,现在文件已经分开放置在不同的文件夹下,要编译它们无疑变得更加困难。所以,我们现在准备使用Makefile。
Makefile的内容是很多的,在Linux环境下使用GNU make来构建和管理自己的工程,是很常见的。所以在这里并不记录关于Makefile的具体用法,只记录一些常用的内容。首先先来看一个简单的Makefile文件,我们把它放在目录 /boot 下,用来编译boot.bin和loader.bin。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 include/lib.inc
$(ASM) $(ASMFLAGS) -o $@ $<
在Makefile中,以字符“#”开头的行是注释,“=”用来定义变量。这里,ASM和ASMFLAGS就是两个变量,要注意的是,使用它们的时候要用$(ASM)和$(ASMFLAGS)。其实,看明白了这两点,Makefile整体上也就明白了一半。.PHONY这个关键字我们暂时不管,先来看一下Makefile的最重要的语法:
target : prerequisites
command
上面这样的形式代表两层意思:
- 要想得到 target,需要执行命令 command。
- target 依赖 prerequisites,当 prerequisites 中至少有一个文件比 target 文件新时,command才被执行。
比如这个Makefile文件的最后两行,含义就是:
- 要想得到loader.bin,需要执行“$(ASM) $(ASMFLAGS) -o $@ $<”。
- loader.bin依赖一下文件:
- loader.asm
- include/load.inc
- include/pm.inc
- include/fat12hdr.inc
- include/lib.inc
当它们中至少有一个比loader.bin新时,command才被执行。
接下来我们来看“$(ASM) $(ASMFLAGS) -o $@ $<”的含义:
- $@代表 target;
- $<代表 prerequisites 的第一个名字。
联系前面我们说过的$(ASM)和$(ASMFLAGS),这个命令行等价于:
nasm -o loader.bin loader.asm
在Makefile中我们注意到,除了boot.bin和loader.bin两个文件后面有冒号,everything、clean、all后面也有冒号,可是它们3个并不是文件,而是动作名称。如果运行“make clean”,将会执行“rm -f $(TARGET)”,即“rm -f boot.bin loader.bin”。
all后面跟着的是clean和everything,这表明如果执行“make all”,clean和everything所表示的动作将分别执行。下图就是make all的执行结果。
刚才我们忽略的关键字 .PHONY,其实是表示它后面的名字并不是文件,而是一种行为的标号。
我们刚才已经运行了make all了,其实直接运行make也是可以的,这时make程序会从第一个名字所代表的动作开始执行。在本例中,第一个标号是everything,所以make和make everything是一样的,下面的过程清楚的表示了这一点:
由于make会自动比较目标和源文件的新旧程度,所以如果运行一个make之后立即运行另一个的话,make程序不会做任何事情,因为所有文件都是新的,不需要生成什么。
至此,这个Makefile文件就已经没有什么地方需要进行说明的了。其实,make程序的原则就是由果寻因,先看要生成什么,再找生成它需要的条件。
好了,我们现在只需要对这个Makefile文件进行改造和扩充,就可以用它来编译链接整个操作系统的工程了。我们首先把Makefile挪到/boot的父目录中,然后对其进行修改,修改后的内容如下所示。
# 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/ -m32 -c -fno-builtin
LDFLAGS = -m elf_i386 -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 "fd.img" exists in current folder
buildimg :
dd if=boot/boot.bin of=fd.img bs=512 count=1 conv=notrunc
sudo mount -o loop fd.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 boot/include/lib.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 $@ $<
可以看到,因为目录层次的原因,我们把GCC的选项也增加了对头文件目录的指定“-I include”。这个Makefile虽然比原来长出不少,但是并不算难。不过,它的功能却十分强大,通过make disasm我们可以反汇编内核到一个文件。甚至于,通过make buildimg或者make image,我们可以直接把引导扇区、loader.bin和kernel.bin写入虚拟软盘。既然功能这么强大,那我们就来试一下它的效果,输入make image,执行情况如下图所示。
这可真是太省事了,只需要一个命令,make程序就按照预先设定的步骤将所有工作搞定。
接下来,我们修改一下start.c,在cstart()的最后再添加一行打印字符串的程序,再运行make,查看一下运行效果。代码start.c如下所示。
PUBLIC void cstart()
{
disp_str("\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n-----\"cstart\" begins-----\n");
......
disp_str("-----\"cstart\" ends-----\n");
}
运行成功,我们新添加的字符串输出了。这样表明我们的Makefile运行正常,这真是太好了,今后我们重新编译链接的速度就会大大加快,而且像引导扇区、复制内核文件这样的工作也可以交给make来完成,甚至我们也可以根据自己的需要自由添加功能,这感觉真是不错。
公众号