OS开发(3) - 丰富我们的OS
为OS添加基础库
正如之前所说,我们的OS最终会在另一个机器平台上运行,所以我们不能使用当前开发环境的libc库,只能使用编译器提供的库。如果想使用C语言的标准库内容,就需要自己去编写(自己造轮子)。这里依旧参考 osdev 的项目结构,分析每个路径的作用,但由于篇幅问题就不列出代码了,可以参考我的Github:https://github.com/hyt658/SteinsOS。
- kernel/: OS的内核
- kernel/arch: architecture,里面有多个文件夹代表了OS在不同架构上机器的引导和启动方式。此时我们只有i386,里面有在上一章提到的boot.S,linker.ld等来让OS可以在i386架构上正常运行
- kernel/include: 这里是所有kernel功能的header文件,其中kernel/include/kernel中的header文件是kernel的核心内容,kernel/include中其余的header文件是扩充kernel的功能。
- kernel/startup: 这里是启动kernel的函数文件,里面目前只有kmain。boot.S最终会引导从kmain开始运行kernel。
- 其余文件夹都会是kernel功能的实现。目前还没什么,之后会有类似于thread,process,lock之类的内容。
- libc/: 这里存放着OS的libc库
- libc/include: libc库的头文件的文件夹(暴露给OS用户去调用)
- 其余文件夹对应着C语言的标准库(stdio,stdlib等)
制作更舒适的输出界面
我们在libc库中完成了printf等函数,可以用C语言的风格来输出内容。但此时的输出界面多少还是有些简陋,他不会像一般终端一样有光标闪烁提示下一个输入位置在哪,也不会在输出内容很多的时候向上滚动,只会在整个界面占满后回到左上角重新开始覆盖书写。这里我们来给他升个级(具体代码都在kernel/arch/i386中)。
我们当前使用的是VGA文本模式来作为输出界面,这里先介绍一下如何与其互动。在电脑运行的过程中,CPU只能访问内存中的数据,想要对其他硬件设备控制和交换数据,就要用到I/O端口。电脑的部分物理内存会被划分成不同区来映射给不同的硬件,这些物理内存地址就是不同的I/O端口。访问这些I/O端口就可以和它们所映射的硬件设备沟通。VGA有很多I/O端口,我们这里只会用到两个:
- 0x3D4:寄存器地址端口
- 0x3D5:寄存器数据端口
VGA硬件自身有很多寄存器,更改这些寄存器的值就可以更改VGA的行为。但我们不可能把所有寄存器都映射到电脑物理内存上,所以CPU想要更改VGA的行为,需要先访问端口来选择要更改的寄存器,在更改其中的内容。这里在介绍几个我们用得到的VGA寄存器的地址:
- 0x0A:光标起始寄存器
- 0x0B:光标结束寄存器
- 0x0E:光标位置寄存器(高8位)
- 0x0F:光标位置寄存器(低8位)
最后,需要介绍一些x86汇编指令:outb
和inb
。它们是x86和I/O端口互动的汇编指令,有许多变种,如outw
和inw
。结尾的b
和w
是代表我们传输的数据是byte(1字节)还是word(2字节)。这里我们使用byte版本就行。gcc默认使用AT&T语法。
outb
的用法是:outb %al %dl
,用到了al
和dl
两个cpu寄存器,指把al
中的值输出到dl
中地址所在的端口。inb
的用法是:inb %al %dl
,类似的,从dl
中地址的端口获取输入,并把输入值存入al
中。
由于我真的不喜欢直接写汇编文件再做链接,于是用了C语言的内联汇编__asm__
来实现outb
和inb
。具体为:
/* 把val输出到port端口 */
static inline void
outb(uint16_t port, uint8_t val)
{
__asm__ volatile ("outb %0, %1\n\t"
: /* no output */
: "a"(val), "d"(port)
: /* no registers modified */);
}
/* 从port端口获取输入存在result中 */
static inline void
inb(uint16_t port, uint8_t* result)
{
__asm__ volatile ("inb %1, %0"
: "=a"(*result)
: "d"(port)
: /* no registers modified */);
}
内联汇编语法可以参考 这篇文章 ,对我帮助蛮大,内容也挺全。
首先开始实现光标,VGA寄存器的规格标准可以在 这里 查到。想要启用光标,就需要设置他的起始和结束扫描线位置。VGA文本模式默认是80x25的窗口大小,可以看成他把这个窗口划分成80x25个小格子,每个格子是一个字符,其中每个字符是由9x16的像素组成。扫描线就是每个字符格中的每一行,所以每个字符有16行扫描线。终端上的光标一般是一个下划线或一整个字符块,而不是文档中的竖线。如果我们设置起始扫描线是14结束扫描线是15,那么光标就是下划线的样子。若是0到15,那就是整个字符块。也就是说,起始和结束扫描线决定了光标的厚度。接着以0x0A寄存器举例:
第5位是cursor disable,为0启用光标,为1禁用。0-4位是起始扫描线的位置。我们想让第5位为0,0-4为0,则:
outb(0x3D4, 0x0A); // 输出0x0A到端口0x3D4来表示我们接下来要对0x0A寄存器进行操作
inb(0x3D5, &result); // 从数据端口获取0x0A寄存器原本的值
result = (result & 0xC0) | 0 // 0xC0是二进制11000000,和result做and操作可以保留前两位并把0-5位清零
// 再和0做or操作可以set0-4位为0来设置起始扫描线
outb(0x3D5, result) // 最后将result输出回去更新VGA行为
其他寄存器(结束扫描线、光标位置等)操作类似,具体可以参考我的gihub。
最后就是滚动终端。这个其实很好实现,只需要每行去copy它下一行的内容来更新自己,然后清空最后一行就可以实现滚动了。代码实现也就是两层loop,没什么难点,不再叙述。
编译代码
在平常写C/C++代码的时候,gcc默认会在本地开发环境的/usr/include或/include路径下寻找我们的#include <...>
头文件。由于我们开发OS不应该依赖于本地库,所以在之前在构建编译器gcc的时候增加了--without-headers
参数,它就不会再去本地/usr/include或/include下寻找头文件。当我们在代码中搭建好libc后,编译后的libc.a(libk.a)就是我们目标OS的库,我们就可以引用这个库去做后续开发。想要引用这个库的对应头文件,需要重新构建gcc并加上--with-sysroot
参数来允许gcc自动在${sysroot}/usr/include
下寻找头文件(注意--with-sysroot
和--without-headers
并不冲突,前者是在本地根目录下的/usr/inlcude,后者是指定${sysroot}
下的/usr/include)。或者,暂时的,在编译的时候加上--sysroot=${sysroot}
来告诉gcc系统根在哪并加上-isystem=/usr/include
来告诉gcc要在${sysroot}
的哪里寻找头文件。
${sysroot}
是一个空文件夹,当我们构建我们的OS的时候,${sysroot}
就是最终系统的根目录,我们需要把kernel,程序,头文件等依次装入其中,然后再引导启动的时候告诉BIOS我们的kernel在哪个路径中。预设是kernel在${sysroot}/boot
,二进制程序文件在${sysroot}/lib
(或者libc
),头文件在$sysroot/usr/include
中。
至于编译代码的方法,我选择了两层Makefile:最外层一个Makefile控制编译顺序,每个子文件夹各带一个Makefile来编译自己的内容。osdev的那个bash脚本编译个人感觉有点怪…很简单的东西他非要设置一堆变量然后绕好几圈,可能以后扩展方便吧。如果看不太懂osdev的编译过程在干什么,可以参考我的Github库。这里需要注意的是,由于我们编译的时候指定gcc去${sysroot}/usr/include
寻找头文件,所以在真正编译.c文件前,一定要先把对应的头文件都拷贝到${sysroot}/usr/include
中,不然找不到。最后的构建结果就在${sysroot}
中。