目录
内核(kernel)
内核是操作系统的核心。内核是操作系统执行的第一道程序,被率先加载到内存中开始系统行为。内核始终保持在主内存中直到系统被关闭。内核将用户输入的命令转换成计算机硬件能理解的机器语言。在这个内核从实模式转向保护模式,内核运行在保护模式下。
第一部分:kernel.cpp
1. printf函数的实现
现在,我想在操作系统中实现简单的输出:
printf("Hello MyOS world!");
然而,正常C++会include<iostream>等,但是现在写操作系统将没办法使用库函数。所以pirntf函数要自己实现。
我们现在要做的工作是在屏幕上输出字符。内存的固定的一块会对应屏幕的输出,就是说,我们如果在对应屏幕的内存上写一个字符,这个字符就会输出在屏幕上。
代码中的0xB8000就是VGA文本模式的起始地址,指向屏幕的字符缓冲区。
static short * VideoMemory = (short*)0xB8000;
屏幕中,一行是80个字符,一共25行,y表示行数,x表示列数。
80 * y + x 就是在表示这个字符要写在哪个位置(即字符要显示的位置)。
在这段显示内存中,表示屏幕上的一个字符,需要两个字节:第一个字节表示这个字符的前景、背景、颜色(属性);第二个字节就是这个字节的内容。
& 0xFF00:这个部分保留当前字符位置的前景和背景颜色(即高字节),用位于操作(&)清除低字节(字符部分)。FF表示黑底白字的属性设置。
|str[i]使用位或操作(|)将新字符str[i]加入到当前字符位置,即低地址(表示要输出的字符)。
for(int i = 0; str[i] != '\0'; ++i)
{
VideoMemory[80 * y + x] = (VideoMemory[80 * y + x] & 0xFF00) | str[i];
++x;
}
完整的printf函数的实现如下:
void printf(const char * str)
{
static short * VideoMemory = (short*)0xB8000;
static char x = 0, y = 0;
for(int i = 0; str[i] != '\0'; ++i)
{
VideoMemory[80 * y + x] = (VideoMemory[80 * y + x] & 0xFF00) | str[i];
++x;
}
}
2. callConstructors函数
constructor 是一个函数指针类型,指向没有参数和返回值的函数。
start_ctors 和 end_ctors 是外部变量,其实是在汇编定义的,所以用extern。(在链接文件linker中写的)功能是指向构造函数的起始和结束位置。在编译时,链接器会将构造函数放在这两个标记之间,所以说其实是在构造函数的范围。读下面callConstructors函数就会更加清楚。
typedef void(*constructor)();
extern constructor start_ctors;
extern constructor end_ctors;
如果不加extern “C”,系统会自动给他改名(如_Z61callConstructors)。因为要确保跟loader.o里面的函数的名字是对应上的,才可以正常调用,所以要加个extern “C”,不让他改名。
callConstructors函数遍历构造函数并调用它们,通常用于初始化全局变量。这里需要调用一些函数,这些函数要用一些函数指针。
extern "C" void callConstructors()
{
for(constructor * i = &start_ctors; i != &end_ctors; ++i)
(*i)();
}
3. kernelMain函数
这里不做太多的工作,就输出一段话。传递参数multiboo_structure 和 magicnumber,通常用于多重引导协议。(这两个参数我不是很懂)
这里不能return,因为内核程序会一直运行,关机的时候才会结束。所以要无限循环,使内核保持运行,防止程序结束。
extern "C" void kernelMain(void * multiboo_structure, int magicnumber)
{
printf("Hello MyOS world!");
while(1);
}
4. 完整代码
void printf(const char * str)
{
static short * VideoMemory = (short*)0xB8000;
static char x = 0, y = 0;
for(int i = 0; str[i] != '\0'; ++i)
{
VideoMemory[80 * y + x] = (VideoMemory[80 * y + x] & 0xFF00) | str[i];
++x;
}
}
typedef void(*constructor)();
extern constructor start_ctors;
extern constructor end_ctors;
extern "C" void callConstructors()
{
for(constructor * i = &start_ctors; i != &end_ctors; ++i)
(*i)();
}
extern "C" void kernelMain(void * multiboo_structure, int magicnumber)
{
printf("Hello MyOS world!");
while(1);
}
第二部分:loader.s
1. 设置变量
首先,设置MAGIC变量,这是grub定义的,grub要认识这几个字节才能引导程序。MAGIC的数据是固定的,用于指示内核是以多重引导的方式加载。
.set MAGIC, 0x1badb002
FLAGS代表多重引导标志,这些标志制定了内核的加载选项,如是否使用内存信息、是否支持模块等。
1<<0表示将第0位设置成1,1<<1表示将第1位设置成1,其他位保持不变
.set FLAGS, (1 << 0 | 1 << 1)
CHECKNUM用作检查数字,用于验证加载程序的有效性或完整性。grub会检查CHECKNUM、MAGIC、FLAGS加起来等于0,所以把CHECKNUM设为MAGIC和FLAGS加起来的负数。
.set CHECKNUM, -(MAGIC + FLAGS)
定义了一个新的段,名为.multiboot,这是多重引导协议所需的一个特定段。multiboot是指多重引导。
.section .multiboot
.long MAGIC
.long FLAGS
.long CHECKNUM
text section表示代码段。
.section .text
extern是外部符号,意味着kernelMain和callConstructors函数在其他模块中定义,引导程序会调用这些函数。要在callConstructors进行全局变量的初始化。下面是对这两个函数进行声明:
.extern kernelMain
.extern callConstructors
说明指示链接器loader符号是全局的,意味着可以被其他模块访问。程序运行的时候是从loader开始运行的。
.global loader
完整代码如下:
.set MAGIC, 0x1badb002
.set FLAGS, (1 << 0 | 1 << 1)
.set CHECKNUM, -(MAGIC + FLAGS)
.section .multiboot
.long MAGIC
.long FLAGS
.long CHECKNUM
.section .text
.extern kernelMain
.extern callConstructors
.global loader
2. loader
首先,初始化。
将内核栈的地址传递到esp栈指针寄存器,即初始化栈。
mov $kernel_stack, %esp
调用callConstructors函数,对全局变量进行初始化,这是在第一部分的kernel.cpp的函数。
call callConstructors
将%eax和%ebx寄存器的值压入栈,以便在调用kernelMain之后可以恢复这些值。
push %eax
push %ebx
之后,需要调用内核的开始函数,这也是在第一部分的kernel.cpp的函数。
call kernelMain
3. _stop
这是停止程序,定义了一个无限循环。
cli表示禁用中断。
执行hlt指令使CPU进入休眠状态,直到接收到中断。
jmp _stop 表示继续循环,确保程序不会意外地继续执行。
_stop:
cli
hlt
jmp _stop
4. 数据段和栈
表示定义一个数据段(这里没有初始化,通常用于分配位),并且将大小定为2M(两兆)。
.section .bss
.space 2*1024*1024
栈是在这里,使用栈时,从高地址向低地址使用,所以栈空间在上面,栈的开始地址在下面。栈是从高地址向低地址增长,通常在高地址部分分配占空间。
kernel_stack:
5. 完整代码
.set MAGIC, 0x1badb002
.set FLAGS, (1 << 0 | 1 << 1)
.set CHECKNUM, -(MAGIC + FLAGS)
.section .multiboot
.long MAGIC
.long FLAGS
.long CHECKNUM
.section .text
.extern kernelMain
.extern callConstructors
.global loader
loader:
mov $kernel_stack, %esp
call callConstructors
push %eax
push %ebx
call kernelMain
_stop:
cli
hlt
jmp _stop
.section .bss
.space 2*1024*1024
kernel_stack:
第三部分:linker.ld
编译出来是obj文件,kernel.cpp文件编译出来也是obj文件,我们要将这两个文件连接起来,所以需要linker.ld。
1. ENTRY
首先,指定程序的开始的地方是loader。
ENTRY(loader)
2. OUTPUT
指定输出文件的格式为 ELF(Executable and Linkable Format),并且是 32 位 x86 格式。
OUTPUT_FORMAT(elf32-i386)
第一个 i386 表示生成的代码将用于 x86 32 位架构。第二个 i386 表示输出文件的目标架构同样是 x86 32 位。
OUTPUT_ARCH(i386:i386)
3. 段布局(SECTIONS)
下面是段定义SECTIONS,定义一个段的集合。
3.1 加载地址
首先,将当前位置设置在内存的1M的位置,这是内核加载的起始地址。
. = 0x100000;
3.2 .text段
*(.multiboot) 表示在loader.s中定义的multiboot。
*(.text*)表示.text开头的段合并到这里。
*(.rodata)表示其他的只读数据段合并到这里。
.text :
{
*(.multiboot)
*(.text*)
*(.rodata)
}
3.3 .data段
start_ctros的值为当前地址(此时指向 .data段的起始位置),用于标记构造函数的开始。(kernel.cpp里出现过)
start_ctors = .;
下面代码表示保持所有输入文件中的 .init_array 段。这个段通常包含在程序启动时需要执行的初始化函数指针。(所有的目标文件中的有关全局变量的数据)
KEEP(*(.init_array))
保持所有以 .init_array. 开头的段,并按初始化优先级排序。(全局变量需要默认初始化的方式)
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*)))
end_ctros的值为当前地址(此时指向 .data 段的结束位置),用于标记构造函数的结束。(同样,kernel.cpp里出现过)
end_ctors = .;
将所有输入文件中的 .data 段合并到此处。(其他的数据段)
*(.data)
.data数据段的完整代码:
.data :
{
start_ctors = .;
KEEP(*(.init_array))
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*)))
end_ctors = .;
*(.data)
}
3.4 .bss段
下面定义一个未初始化的数据段,包含未初始化的全局和静态变量。*(.bss) 表示将所有输入文件中的 .bss 段合并到此处。
.bss :
{
*(.bss)
}
3.5 /DISCARD/段
最后定义一个丢弃段,这里的内容在链接时会被丢弃(抛弃一些段)。
所有以 .fini_array 开头的段将被丢弃。这个段通常包含在程序退出时需要执行的清理函数指针。
所有输入文件中的 .comment 段也会被丢弃。通常用于存储版本信息等非必要的注释。
/DISCARD/ :
{
*(.fini_array*)
*(.comment)
}
4. 完整代码
ENTRY(loader)
OUTPUT_FORMAT(elf32-i386)
OUTPUT_ARCH(i386:i386)
SECTIONS
{
. = 0x100000;
.text :
{
*(.multiboot)
*(.text*)
*(.rodata)
}
.data :
{
start_ctors = .;
KEEP(*(.init_array))
KEEP(*(SORT_BY_INIT_PRIORITY(.init_array.*)))
end_ctors = .;
*(.data)
}
.bss :
{
*(.bss)
}
/DISCARD/ :
{
*(.fini_array*)
*(.comment)
}
}
第四部分:makefile
ubuntu编译时需要用到makefile文件。
1. 变量定义
以下是传递给 g++ 编译器的参数,主要用于确保生成 32 位代码并禁用一些 C++ 特性。
-m32: 指定生成 32 位代码
-fno-use-cxa-atexit: 禁用 C++ 的 atexit 功能。
-nostdlib: 不链接标准库。
-fno-builtin: 禁用内置函数。
-fno-rtti: 禁用运行时类型识别。
-fno-exceptions: 禁用异常处理。
-fno-leading-underscore: 禁用在符号前添加下划线的行为。
GPPPARAMS = -m32 -fno-use-cxa-atexit -nostdlib -fno-builtin -fno-rtti -fno-exceptions -fno-leading-underscore
传递给汇编器的参数,用于确保生成 32 位代码。
ASMPARAMS = --32
这是传递给链接器的参数,用于指定输出格式。
-melf_i386: 指定生成 ELF 格式,适用于 32 位 x86 架构。
LDPARAMS = -melf_i386
2. 编译规则
这行定义了一个目标文件列表,包含kernel.o 和 loader.o,这两个文件是编译后生成的目标文件。
objects = kernel.o loader.o
%是来替换输入,就是说因为我们有kernel.cpp,所以当编译kernel.cpp的时候%会变替换成kernel
使用g++编译源文件$< 生成目标文件$@, -c参数表示只编译而不链接
$(GPPPARAMS): 使用之前定义的编译参数。
$<表示输入文件,是依赖于上面写的%.cpp。
%.o: %.cpp
g++ $(GPPPARAMS) -o $@ -c $<
这条规则表示如何从.s文件生成.o文件。
使用汇编器as将汇编源文件$< 编译为目标文件$@。
因为我的ubuntu是64位机,所以默认编译成64位,编译的时候需要参数ASMPARAMS。
%.o: %.s
as $(ASMPARAMS) -o $@ $<
3. 链接并生成内核二进制文件
mykernel.bin是目标文件(可执行文件)。
linker.ld是链接脚本文件,定义了内存布局和如何将目标文件链接在一起。
$(objects)之前定义的目标文件列表。
ld: 使用链接器。
-T $<: 指定链接脚本文件($< 代表依赖文件)。
-o $@: 指定输出文件为 mykernel.bin($@ 代表目标文件)。
mykernel.bin需要依赖于linker.ld和objects。
mykernel.bin: linker.ld $(objects)
ld $(LDPARAMS) -T $< -o $@ $(objects)
4. 生成 ISO 镜像
创建光盘镜像文件。
定义一个目标,表示要生成的iso文件。这会依赖于mykernel.bin,意味着在生成iso文件之前,必须先生成mykernel.bin文件。
mykernel.iso: mykernel.bin
创建一个名为iso的目录,用于存放iso文件结构。
mkdir iso
在iso目录下创建一个boot子目录,通常这个目录包含启动加载器和内核文件。
mkdir iso/boot
在 boot 目录下创建一个 grub 子目录,用于存放 GRUB 启动加载器的配置文件。
mkdir iso/boot/grub
将 mykernel.bin 复制到 iso/boot 目录中。$< 表示依赖文件,这里是 mykernel.bin。
cp $< iso/boot
向 GRUB 配置文件中写入一行,设置启动菜单的超时时间为 0 秒。意味着如果没有选择,系统会立即启动默认项。
iso/boot/grub/grub.cfg: 将这一行(timeout=0)追加到 grub.cfg 文件中。如果文件不存在,则会创建它。
echo 'set timeout = 0' >> iso/boot/grub/grub.cfg
设置 GRUB 启动菜单的默认项为第一个条目(索引为 0),这通常是内核。(因为我们只有一个系统)
echo 'set default = 0' >> iso/boot/grub/grub.cfg
向 grub.cfg 文件中写入一个空行,增加可读性。
echo '' >> iso/boot/grub/grub.cfg
定义一个启动菜单项,菜单项的名称为 "My Operating System ..."。
{: 表示这个菜单项的内容将从这里开始。
echo 'menuentry "My Operating System ..." {' >> iso/boot/grub/grub.cfg
指示 GRUB 启动操作系统。
同样前面有制表符: 继续属于上面定义的菜单项。
echo ' boot' >> iso/boot/grub/grub.cfg
echo '}': 结束之前定义的菜单项内容。
echo '}' >> iso/boot/grub/grub.cfg
grub-mkrescue: 使用 GRUB 工具创建 ISO 映像。
--output=$@: 指定输出文件为目标文件(这里是 mykernel.iso)。
iso: 指定输入的目录结构,即包含内核和 GRUB 配置的目录。
grub-mkrescue --output=$@ iso
清理临时创建的 iso 目录,删除它及其所有内容,以避免后续编译时的混淆。
rm -rf iso
5. 运行虚拟机
run: 目标,用于运行生成的 ISO 文件。
依赖于: mykernel.iso,确保在运行前生成该文件。
virtualboxvm: 启动 VirtualBox 虚拟机。这里的Tuitorial是我创建的虚拟机的名字。
这段命令只不过是为了方便更快速启动虚拟机(毕竟运行一行代码就能启动虚拟机了)
run: mykernel.iso
virtualboxvm --startvm "Tuitorial" &
6. 清理规则
.phony声明clean是一个虚拟目标,make clean的时候会执行下面的代码。
因为如果没有及时的删除已经导出过的.bin或者.iso,会显示不用更新,还要手动删除。为了防止这个,还是通过make clean命令来直接删除会比较方便。
.phony: clean
clean:
rm -rf $(objects) mykernel.bin mykernel.iso
7. 完整代码
GPPPARAMS = -m32 -fno-use-cxa-atexit -nostdlib -fno-builtin -fno-rtti -fno-exceptions -fno-leading-underscore
ASMPARAMS = --32
LDPARAMS = -melf_i386
objects = kernel.o loader.o
%.o: %.cpp
g++ $(GPPPARAMS) -o $@ -c $<
%.o: %.s
as $(ASMPARAMS) -o $@ $<
mykernel.bin: linker.ld $(objects)
ld $(LDPARAMS) -T $< -o $@ $(objects)
mykernel.iso: mykernel.bin
echo 'set timeout = 0' >> iso/boot/grub/grub.cfg
echo 'set default = 0' >> iso/boot/grub/grub.cfg
echo '' >> iso/boot/grub/grub.cfg
echo 'menuentry "My Operating System ..." {' >> iso/boot/grub/grub.cfg
echo ' multiboot /boot/mykernel.bin' >> iso/boot/grub/grub.cfg
echo ' boot' >> iso/boot/grub/grub.cfg
echo '}' >> iso/boot/grub/grub.cfg
grub-mkrescue --output=$@ iso
rm -rf iso
run: mykernel.iso
virtualboxvm --startvm "Tuitorial" &
.phony: clean
clean:
rm -rf $(objects) mykernel.bin mykernel.iso