【CODE.0x00】从零开始的32位操作系统开发手记
本文最后更新于:2023年10月27日 中午
请大家多多支持国产操作系统A3OS!(bushi
0x00.一切开始之前
前言
说起来,自己动手写一个操作系统一直是笔者的梦想之一,在初中的时候买了一本《30天自制操作系统》,但是一直没有时间动手去做(主要是懒),不知不觉间已经到了大二了,恰逢学校有一门课程是所谓「操作系统」,笔者认为应当是真正自己上手写一个操作系统的时候了,因此本篇博客便是用以简要记录笔者从零开始学习如何写一个操作系统内核的一个过程。如你所见,虽然显示的时间似乎是 6 月,但其实这篇文章已经断断续续地写了好些日子,且还会继续写下去(笑)。
大部分内容参考自《操作系统真象还原》,少部分内容参考自《X86汇编语言:从实模式到保护模式》,之所以没有参考此前的《30天自制操作系统》这本书是因为在现阶段看来这本书有着很多错漏的地方(),同时也“仅仅是做出一个能玩的小系统”,笔者或是更希望能够做一个 更 常 规 的内核(说到底还是做连玩具都不如的系统(恼))
下面是我们开始之前所必须的一些基础知识,若是大家已经掌握了这些粗浅的知识可以直接跳到 0x01(笑)
一、实模式下的内存布局
当我们按下主机箱上的启动键之后,CPU 通电,以实模式启动,计算机载入位于 ROM 中的 BIOS ,由 BIOS 再载入主引导记录(Master Boot Recode,简称MBR),此时控制权交由MBR,相应地, MBR 也是在计算机运行过程中我们所最早能够控制的程序
毫无疑问的是,我们的 MBR 运行在实模式下,因此在我们控制 MBR 为我们做一些其他事情之前,让我们先来了解 Intel 8086 在实模式下的 1M 内存布局
下表摘自《操作系统真象还原》:
起始地址 | 结束地址 | 大小 | 用途 |
---|---|---|---|
0x000 | 0x3FF | 1 KB | 存放中断向量表(Interrupt Vector Table) |
0x400 | 0x4FF | 256 B | BIOS 数据区 |
0x500 | 0x7BFF | 30464 B | 可用区域 |
0x7C00 | 0x7DFF | 512 B | MBR 加载地址 |
0x7E00 | 0x7FFF | 512 B | MBR 数据区(86-DOS最小所需内存到此为止,总计 32 KB) |
0x8000 | 0x9FBFF | 621568 B | 可用区域 |
0x9FC00 | 0x9FFF | 1 KB | EBDA 扩展 BIOS 数据区 |
0xA0000 | 0xAFFFF | 64 KB | 用于彩色显示适配器的显存 |
0xB0000 | 0xB7FFF | 32 KB | 用于黑白显示适配器的显存 |
0xB8000 | 0xBFFFF | 32 KB | 用于文本模式显示适配器的显存 |
0xC0000 | 0xC7FFF | 32 KB | 显示适配器 BIOS |
0xC8000 | 0xEFFFF | 160 KB | 映射硬件适配器的 ROM 或内存映射式 I/O |
0xF0000 | 0xFFFFF | 64 KB | BIOS |
我们不难看到,BIOS 被载入到内存顶部的 64 KB ,而我们的 MBR 则被载入到 0x7c00
这个地址
MBR的加载地址为什么是0x7c00?
在很多关于 x86 汇编的书上都有讲到
0x7c00
这一个地址——ROM-BIOS将硬盘上的第一个扇区(0面0道1扇区,我们存放MBR的地方)读取后加载到内存物理地址0x7c00
处,随后再通过 jmp 指令跳转到这个地方那么,为什么是
0x7c00
这样一个奇怪的地址,而不能是别的值呢?(比如0x114514?)答案是为了兼容。
在 Intel 8088 时代,当时所惯用的操作系统为 86-DOS,该操作系统最少需要 32KB 的内存空间,即
0x0~0x7FFF
而 8088 芯片本身需要
0x0~0x3FF
这段空间保存各种中断处理程序,留给操作系统的便只有0x400~0x7FFF
为了使得操作系统有着更多的连续内存空间,故 MBR 最终被放到了内存的高地址处
MBR 本身占用一个扇区 (512B),同时由于 MBR 运行时也会产生一些数据,故又预留了 512B 供 MBR 使用,最终 MBR 就被加载到
0x7FFF - 1024 + 1 = 0x7C00
这个地址上,此后也便一直沿用下来
在实模式下的 Intel 8086 CPU 仅有 20 根地址总线,其寻址范围为 1 MB
,而寄存器都是 16 位的,因此在这种模式下若是想要寻到 20 位的地址则需要采用 段基址:段内偏移
的这样一种寻址方式,在寻址时将段寄存器中的段基址左移四位,再加上段内偏移,自然就能够寻到 1 MB 的内存地址了(例如 0xFFFF 左移四位得 0xFFFF0,0xFFFF0 + F 得 0xFFFFF),相关的知识我们已经在微机原理课上学过了,这里便不再赘叙
二、与硬盘间的通信
BIOS 只会将硬盘上的第一个扇区(0面0道1扇区)帮我们载入内存,作为 MBR ,因此我们需要自己将硬盘上的其他我们需要的数据手动载入内存
我们都知道,在 x86 汇编语言当中使用 in
、out
两条指令 从端口中读出数据
、向端口中写入数据
,若我们需要访问硬盘,则通常需要用到如下端口,分别对应着硬盘内部相应的寄存器(下表摘自《操作系统真象还原》):
Primary 通道的端口 | Secondary 通道的端口 | 读操作时用途 | 写操作时用途 |
---|---|---|---|
0x1F0 | 0x170 | 读取数据 | 写入数据 |
0x1F1 | 0x171 | 失败状态信息 | 写命令时参数 |
0x1F2 | 0x172 | (操作)扇区数 | (操作)扇区数 |
0x1F3 | 0x173 | LBA low | LBA low |
0x1F4 | 0x174 | LBA mid | LBA mid |
0x1F5 | 0x175 | LBA high | LBA high |
0x1F6 | 0x176 | device寄存器 | device寄存器 |
0x1F7 | 0x177 | 获取状态 | 操作命令 |
逻辑块地址 - Logical Block Address
在我们索引硬盘时并非像访问内存一般直接按照字节操作,而是按照逻辑块地址(Logical Block Address)进行访问的,即按扇区进行访问
LBA 有着两种标准,一种是 LBA28 ,一种是 LBA48 ,我们这里主要关注前者:LBA28 使用 LBA low、LBA mid、LBA high 这三个八位的寄存器再加上 device 寄存器的低 4 位构成一个 28 位的地址
device 寄存器
前面我们讲到 device 寄存器的低四位用作 LBA28 的 24~27 位,接下来我们来看看 device 寄存器的高4位,分别对应着(0位起始):
- 第 4 位:指定通道上的主(0)/从(1)盘
- 第 5、7 位:MBS 位,固定为 1
- 第 6 位:启用 LBA 模式(1)/ CHS 模式(0)
Command / Status 寄存器
即上表中的 0x1F7/0x177
端口所对应的寄存器,当我们将我们的命令写入该寄存器时,硬盘便会开始进行相应的工作;对应地,我们可以从该寄存器中获取到硬盘此刻的状态
Coomand
以下只列举我们将会用到的三个命令:
- 0x20:读操作
- 0x30:写操作
- 0xEC:硬盘识别
Status
以下只列举我们会用到的标志位,硬盘在对应的状态会将对应标志位置 1
:
- 第 0 位:有错误发生
- 第 3 位:硬盘已准备好数据
- 第 6 位:设备就绪,等待指令
- 第 7 位:硬盘正忙
三、全局描述符表(Global Descriptor Table)
自 Intel 80286始引入了新的操作模式——「保护模式」,同时我们也正式迈入 32 位的世界,但是段寄存器仍然是 16 位的,没有随着其他寄存器一起升级
相应地,段基址也便不再储存在段寄存器当中,而是被放在了内存中,计算机使用一个叫做段描述符的 8 字节结构描述一块内存区域
包含着所有段描述符的这样一张表就叫描述符表,分为全局描述符表(GDT)和局部描述符表(LDT)两种,我们主要需要用到的是GDT
段描述符 - Segment Descriptor
段描述符是保护模式下用以描述一个内存段的全新的数据结构,在内存中占用8个字节,我们分为两部分来分析其结构:
高 32 位
31~24 | 23 | 22 | 21 | 20 | 19~16 | 15 | 14~13 | 12 | 11~8 | 7~0 |
---|---|---|---|---|---|---|---|---|---|---|
段基址的 31~24 位 | G | D/B | L | AVL | 段界限的 19 ~16 位 | P | DPL | S | TYPE | 段基址的 23~16 位 |
- G (ranularity):段粒度大小,4 KB(1) / 1B (0)
- D/B:对代码段而言为D位,对数据段而言为B位;该位为1表示有效操作数为32位,0则为16位
- L:是否为64位段描述符,1为是
- AVL:available位,暂且无用
- P:即 present,用以标识该段在内存中是否存在,1为存在
- DPL:Descriptor Priviledge Level,即特权级别,00 对应 ring 0,11 对应 ring 3
- S:是否为系统段,0表示系统段,1表示非系统段
- TYPE:段类型
其中,对于段的 TYPE 字段说明如下(下表摘自《操作系统真象还原》):
- 系统段
段类型 | 3 | 2 | 1 | 0 | 说明 |
---|---|---|---|---|---|
未定义 | 0 | 0 | 0 | 0 | 保留 |
可用的 80286 TSS | 0 | 0 | 0 | 1 | 仅限 286 的任务状态段 |
LDT | 0 | 0 | 1 | 0 | 局部描述符表 |
忙碌的 80286 TSS | 0 | 0 | 1 | 1 | 仅限 286, 其中第一位由CPU设置 |
80286 调用门 | 0 | 1 | 0 | 0 | 仅限 286 |
任务门 | 0 | 1 | 0 | 1 | 在现在操作系统中已很少用到 |
80286 中断门 | 0 | 1 | 1 | 0 | 仅限 286 |
80286 陷阱门 | 0 | 1 | 1 | 1 | 仅限 286 |
未定义 | 1 | 0 | 0 | 0 | 保留 |
可用的 80386 TSS | 1 | 0 | 0 | 1 | 386 以上 CPU 的 TSS |
未定义 | 1 | 0 | 1 | 0 | 保留 |
忙碌的 80386 TSS | 1 | 0 | 1 | 1 | 386 以上 CPU 的 TSS,第一位由CPU设置 |
80386 调用门 | 1 | 1 | 0 | 0 | 386 以上 CPU 的调用门 |
未定义 | 1 | 1 | 0 | 1 | 保留 |
中断门 | 1 | 1 | 1 | 0 | 386 以上 CPU 的中断门 |
陷阱门 | 1 | 1 | 1 | 1 | 386 以上 CPU 的陷阱门 |
- 非系统段
段类型 | X | C | R | A | 说明 |
---|---|---|---|---|---|
代码段 | 1 | 0 | 0 | * | 只执行代码段 |
1 | 0 | 1 | * | 可执行、可读代码段 | |
1 | 1 | 0 | * | 可执行、一致性代码段 | |
1 | 1 | 1 | * | 可读、可执行、一致性代码段 |
段类型 | X | E | W | A | 说明 |
---|---|---|---|---|---|
数据段 | 1 | 0 | 0 | * | 只读数据段 |
1 | 0 | 1 | * | 可读写数据段 | |
1 | 1 | 0 | * | 只读、向下扩展数据段 | |
1 | 1 | 1 | * | 可读写、向下扩展数据段 |
通常情况下数据段向高地址增长,对于标识了E(xtend)位的数据段则向低地址增长(比如说栈段就是这样一个数据段)
低 32 位
31~16 | 15~0 |
---|---|
段基址的 15~0 位 | 段界限的 15~0 位 |
段基址 32 位,段界限为 20 位,其所能够表示的地址范围为:
段基址 + (段粒度大小 x (段界限+1)) - 1
GDTR 寄存器
GDTR 寄存器为一个 48 位的寄存器,专门用以存放 GDT 的内存地址及大小,其结构如下:
47~16 | 15~0 |
---|---|
GDR 在内存中的起始地址 | GDR 界限 |
我们可以使用汇编指令 lgdt
将一个合适的 GDTR 结构加载到 GDTR 中
段选择子 - Selector
在 32 位保护模式下,16 位的段寄存器不再用以存放段基址,而是用以存放段选择子——其中包含着段描述符在描述符表中的索引值、归属全局/局部描述符表、特权级,结构如下:
15~3 | 2 | 1~0 |
---|---|---|
Index | TI | RPL |
RPL(Request Priviledge Level)表示进程对段访问的请求权限
TI 位为 0 时表示在全局描述符表中
需要注意的是第 0 个段描述符(索引0)不可用
保护模式下的段内存保护
在保护模式下我们对一个内存段的访问通过段选择子进行,而为了避免出现非法引用内存段的情况出现, CPU 会对段选择子进行合法性检查
I.索引值
前面我们讲到,0 号索引的段选择子不可用,若是计算机尝试加载一个索引值为 0 的选择子,CPU将会抛出异常
II.段类型检查
CPU 会检查段寄存器的用途与段描述符中的段类型是否匹配:
- 只有具备可执行属性的段才能加载到 CS 代码段寄存器当中
- 只具备可执行属性的段不允许加载到 CS 以外的段寄存器当中
- 只有具备有可写属性的段才能加载到 SS 栈段寄存器中
- 至少具备可读属性的段才能加载到 DS、ES、FS、GS 段寄存器中
III.段存在性检查
保护模式下 CPU 还会检查一个段描述符的 P 位,若是该段不存在则会抛出异常,由相应的异常处理程序(通常是操作系统)处理后再行判断;若存在则会由 CPU 设置段描述符的 A 位为 1 后将选择子载入段寄存器中
IV.段界限检查
前面我们讲到,一个段的段界限大小应为(段粒度大小 x (段界限+1)) - 1
,若是指令或是读写的数据长度超出了界限(例如写 2 字节有一个字节落在段外面),那么 CPU 将会抛出异常
四、8086 地址回绕
Intel 8086 仅有 20 根地址线(A0~A19),其寻址范围为 1 MB,对于大于1 MB 的地址,其超出 20 位的部分会被自动丢弃,相当于对 1MB 求模、重新绕回 0 地址
Intel 80386 虽然有 32 根地址线,但是 A20 地址线默认是关闭的(为了向前兼容 8086),因此我们需要手动打开这根地址线,将寻址空间扩展到 32 位,又称之为“打开 A20Gate”
对于这根地址线的控制通过 0x92
端口实现,将其第 1 位(0位起始)置 1 即可关闭 8086 地址回绕,打开 A20Gate
五、控制寄存器 Cr0~Cr3
在 Intel 80386 中有着这样一组寄存器,用以控制和确定处理器的操作模式以及当前执行任务的特性,这组寄存器被称之为 控制寄存器
我们主要关注 Cr0 ~Cr3 这四个控制寄存器
- Cr0:有着多个控制标志位的寄存器,其第 0 位标识着是否启用保护模式,1为启用;其第31位标识是否开启分页,1为开启,此时Cr3寄存器才会被使用
- Cr1:保留
- Cr2:存放导致页错误的线性地址
- Cr3:存放页目录表(Page Directory Table,即多级页表中的最高一层)的物理内存基地址
关于 Cr0 寄存器相关可见下表(摘自Wikipedia):
Bit | Name | Full Name | Description |
---|---|---|---|
0 | PE | Protected Mode Enable | If 1, system is in protected mode, else system is in real mode |
1 | MP | Monitor co-processor | Controls interaction of WAIT/FWAIT instructions with TS flag in CR0 |
2 | EM | Emulation | If set, no x87 floating-point unit present, if clear, x87 FPU present |
3 | TS | Task switched | Allows saving x87 task context upon a task switch only after x87 instruction used |
4 | ET | Extension type | On the 386, it allowed to specify whether the external math coprocessor was an 80287 or 80387 |
5 | NE | Numeric error | Enable internal x87 floating point error reporting when set, else enables PC style x87 error detection |
16 | WP | Write protect | When set, the CPU can’t write to read-only pages when privilege level is 0 |
18 | AM | Alignment mask | Alignment check enabled if AM set, AC flag (in EFLAGS register) set, and privilege level is 3 |
29 | NW | Not-write through | Globally enables/disable write-through caching |
30 | CD | Cache disable | Globally enables/disable the memory cache |
31 | PG | Paging | If 1, enable paging and use the § CR3 register, else disable paging. |
六、中断描述符表(Interrupt Descriptor Table)
类似于段描述符,
七、可执行 ELF 文件格式浅析
我们的内核将使用 C 语言编写,生成的文件格式笔者选择使用 ELF ,这里简单讲讲可执行(executable) ELF 文件的格式
ELF 格式提供了两种基本视图:链接视图与执行视图,区别大概就是链接后会把相同的段整合在一起,section header table 变成 program header table,这里便不再赘叙
对于一个可执行 ELF 文件(executable)而言,其结构应当如下所示:
ELF Header |
---|
Programme Header Table |
Section 1 |
Section 2 |
… |
Section N |
由于我们要写的是 32 位的操作系统,所以这里简单以 32 位 ELF 文件格式作为例子
1.ELF Header
在 Linux 内核源码 /include/uapi/linux/elf.h
中 对 32位 ELF 文件的 Header 结构体的定义如下:
1 |
|
各字段说明如下:
e_ident[16] (1 Byte * 16)
成员 | 含义 |
---|---|
e_ident[0] ~ e_ident[3] | 魔数,用以标识文件类型,对于 ELF 文件而言从 0 ~ 3应当是 0x7f 、 E 、L 、 F |
e_ident[4] | 用以标识 ELF 文件的类型,0 无效,1 为 32 位,2 为 64 位 |
e_ident[5] | 用以指定 ELF 文件的编码格式,0 无效,1 为小端序(LSB),2 为大端序(MSB) |
e_ident[6] | ELF 文件版本, 0 为非法版本, 1为当前版本 |
e_ident[7] ~ e_ident[15] | 保留 |
e_type (2 Byte)
用以指定 ELF 文件类型,如下:
类型 | 取值 | 含义 |
---|---|---|
ET_NONE | 0 | 未知目标文件格式 |
ET_REL | 1 | 可重定位文件(relocatable) |
ET_EXEC | 2 | 可执行文件(executable) |
ET_DYN | 3 | 动态共享目标文件(shared) |
ET_CORE | 4 | 核心转储文件(core) |
ET_LOPROC | 0xff00 | 特定处理器文件的扩展下边界 |
ET_HIPROC | 0xffff | 特定处理器文件的扩展上边界 |
e_machine (2 Byte)
用以指定文件所属体系结构,如下:
类型 | 取值 | 含义 |
---|---|---|
EM_NONE | 0 | 未指定 |
EM_M32 | 1 | AT & T WE 32100 |
EM_SPARC | 2 | SPARC |
EM_386 | 3 | Intel 80386 |
EM_68K | 4 | Motorola 68000 |
EM_88K | 5 | Motorola 88000 |
EM_860 | 7 | Intel 80860 |
EM_MIPS | 8 | MIPS RS3000 |
e_version (4 Bytes)
用以标注版本信息(可以随便写),一般固定为 1
e_entry (4Bytes)
用以指定 ELF 文件的程序入口点地址
e_phoff (4Bytes)
用以指定 Programe Header Table 在文件内的偏移量(字节),无则为 0
e_shoff (4Bytes)
用以指定 Section Header Table 在文件内的偏移量(字节),无则为 0
e_flags (4Bytes)
用以指定与处理器相关的标志,这里我们只需要知道对于 Intel 386 而言该值为 0
e_ehsize (2Bytes)
用以指定 ELF Header 的大小
e_phentsize (2Bytes)
用以指定 Program Header Table 中每个 entry 的大小
e_phnum (2Bytes)
用以指定 Programe Header Table 中的 entry 数量
e_shentsize (2Bytes)
用以指定 Section Header Table 中每个 entry 的大小
e_shnum (2Bytes)
用以指定 Section Header Table 中的 entry 数量
e_shstrndx (2Bytes)
用以指定 string name table 在节头表中的索引 index
以上便是 32 位 ELF 文件中 ELF Header 各字段具体含义说明,我们来简单看一个由 IDA 解析的例子:
大致如此,接下来我们来看存放各段信息的数组 Program Header Table
2.Program Heaer
前面我们讲到 ELF executable 文件中各数据段的信息定义在 Program Header Table 中,而 Program Header Table 本质上便是一个 Program Header 数组,现在我们来简单看一下其结构
在 Linux 内核源码 /include/uapi/linux/elf.h
中 对 32位 ELF 文件的 Program Header 结构体的定义如下:
1 |
|
各字段说明如下:
p_type (4 Bytes)
用以指定该段类型,取值如下:
类型 | 取值 | 含义 |
---|---|---|
PT_NULL | 0 | 忽略 |
PT_LOAD | 1 | 可加载程序段 |
PT_DYNAMIC | 2 | 动态链接信息 |
PT_INTERP | 3 | 动态加载器名称 |
PT_NOTE | 4 | 一些辅助的附加信息 |
PT_SHLIB | 5 | 保留 |
PT_PHDR | 6 | 程序头表 |
PT_LOPROC ~ PT_HIPROC | 0x70000000 ~ 0x7fffffff | 处理器保留取值 |
p_offset (4 Bytes)
指定该段在文件内的偏移值
p_vaddr (4 Bytes)
指定该段在内存中的加载基地址
p_paddr (4 Bytes)
指定该段的物理地址,通常仅用于与物理地址相关的系统中
p_filesz (4 Bytes)
说明该段在文件中的大小
p_memsz (4 Bytes)
说明该段在内存中的大小
p_flags (4 Bytes)
指定该段相关的标志位,取值见下表
类型 | 取值 | 含义 |
---|---|---|
PF_X | 1 | 可执行 |
PF_W | 2 | 可读 |
PF_R | 4 | 可写 |
PF_MASKOOS | 0x0ff00000 | 操作系统保留值 |
PF_MASKPROC | 0xf0000000 | 处理器保留值 |
例如一个段同时具有可读可写可执行权限,那么其 header 中 p_flags 的取值就应当为 PF_X | PF_W | PF_R
p_align (4 Bytes)
用以指定该段在文件与内存中的对齐方式,取值0或1表示不对齐;通常情况下该值应当是 2 的幂次数
以上便是该结构体的所有内容,这里还是随便拿一个 ELF 文件放进 IDA 里看看:
那么这里我们怎么知道一个段应该是 .data 段还是 .bss 段还是 .text 段呢?实际上对于计算机而言其只需要知道该段的加载地址、执行权限等信息即可,只有我们人类在进行逆向工程等工作时需要手动识别,由于不是本文重点故不在此赘叙
八、显卡端口控制
对于显卡的操作其实是较为复杂的,我们操作显卡主要通过两个寄存器 Address Register
和 Data Register
来指定操作的显卡寄存器对象,前者用以指定寄存器组,后者用以指定具体的寄存器,分组如下:
寄存器分组 | 寄存器子类 | 读端口 | 写端口 | 备注 |
---|---|---|---|---|
Graphics Registers | Address Register | 0x3CE | 0x3CE | |
Data Register | 0x3CF | 0x3CF | ||
Sequencer Registers | Address Register | 0x3C4 | 0x3C4 | |
Data Register | 0x3C5 | 0x3C5 | ||
Attribute Controller Registers | Address Register | 0x3C0 | 0x3C0 | |
Data Register | 0x3C1 | 0x3C0 | ||
CRT Controller Registers | Address Register | 0x3*4 | 0x3*4 | * 的值取决于 Input/Output Address Select 字段,它决定映射的端口号为 0x3B4 |
Data Register | 0x3*5 | 0x3*5 | ||
Color Registers | DAC Address Write Mode Register | 0x3C8 | 0x3C8 | |
DAC Address Read Mode Register | Unavailable | 0x3C7 | ||
DAC Data Register | 0x3C9 | 0x3C9 | ||
DAC State Register | 0x3C7 | Unavailable | ||
External (General) Registers | Miscellaneous Output Register | 0x3CC | 0x3C2 | |
Feature Control Register | 0x3CA | 0x3*A | 写端口为 0x3BA(mono模式)或 0x3DA(color 模式) | |
Input Status #0 Register | 0x3C2 | Unavailable | 只读 | |
Input Status #1 Register | 0x3*A | Unavailable | 读端口为 0x3BA(mono模式)或 |
对于 CRT Controller Registers 寄存器组而言,其操作的端口具体值取决于 Miscellaneous Output Register 寄存器的 Input/Output Address Select 字段,其中各位含义如下:
7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|
VSYNCP | HSYNCP | O/E Page | Clock Select | Clock Select | RAM En. | I/OAS |
〇、开始之前的准备工作
I.bochs 虚拟机
安装
安全起见,我们并不直接在真机上跑我们的系统,而是在虚拟机里运行,这样也更加便于调试
笔者在这里选择使用 bochs 虚拟机来运行我们的小操作系统,之所以不用大家或许更为熟悉的 qemu 是因为笔者此前使用 qemu 遇到了一些奇怪的小问题,稳定优先,所以这里先用 bochs
bochs 下载链接:https://sourceforge.net/projects/bochs/files/bochs/
解压
1 |
|
配置脚本,其中prefix
项应当为你自己的安装目录,--enable-gdb-stub
项为使用 gdb 进行调试,可以替换成 bochs 自己的调试器 --enable-debugger
,二者不可共存,笔者这里推荐不要使用gdb(虽然说你可能更熟悉他),不过还是简单讲讲如何使用 gdb 进行调试
1 |
|
一些前置的库
1 |
|
安装
1 |
|
配置文件
在我们的安装目录下的 share/doc/bochs/bochsrc-sample.txt
为 bochs 提供的配置文件样例,我们主要修改以下几点,指定对应文件的路径,以及注释掉 sound 的配置
1 |
|
我们将该文件放置于与 bochs 文件夹的同一目录下
打包脚本
使用 nasm 编译,bximage创建镜像文件,dd 打包为镜像文件
以下示例脚本打包入两个示例文件,dd 等命令的相关用法不在此赘叙
1 |
|
启动脚本
1 |
|
若是选择 gdb 作为调试器,启动后会等待我们的调试器的连接才会运行,默认是 1234
端口
在 gdb 中使用如下命令连接
1 |
|
需要注意的是 bochs 与 pwndbg 这个插件的兼容性并不好(至少在笔者的电脑上已经慢到无法令人忍耐的地步)
初始时 CS:IP
指向 0xFFFF0
,即 BIOS 入口点
当然,也可以在编译时设置选择直接使用 bochs 自带的调试器
初始时 CS:IP
指向 0xFFFF0
,即 BIOS 入口点
CS 左移四位再加上 IP 寄存器撑起了 16 位实模式下的 1MB 内存空间
II.封装宏
我们将一些操作当中所需要用到的值封装为宏放在一个特定的“头文件”中,以避免后续频繁查表:
1 |
|
后续这个“头文件”中的内容可能还会增多
0x01.从最简单的MBR入手,实模式入门
一、主引导记录 - Master Boot Recode
主引导记录位于硬盘的 0 盘 0 道 1 扇区上,是计算机启动流程中我们最早能够控制的程序,其大小仅占一个扇区
从硬盘上读取主引导记录是BIOS所做的事情,我们不需要自己手动完成
由于 MBR 被加载到内存地址 0x7c00
的位置,笔者在这里选择将该文件整个作为一个 section,并指定其 vstart 为 0x7c00,这样 nasm 在计算绝对地址时便会以 0x7c00 作为基址
1 |
|
二、段寄存器的初始化
我们还需要进行段寄存器的初始化工作,初始时我们暂且只有一个代码段,且笔者暂时不考虑再使用更多的段了,故我们选择使用 cx 寄存器的初始值来初始化其他的段寄存器
在这里我们使用 ax
寄存器进行中转,以改变段寄存器的值,这是因为 Intel 不允许将一个立即数传送到段寄存器,故我们只能借助其他寄存器或是内存单元
前面讲到,0x7E00~0x7FFF
是留给 MBR 储存数据的空间,故我们将堆栈指针寄存器 sp 初始化到这里
1 |
|
三、使用文本模式显示
前面我们讲到, 0xB800~0xBFFF
这块区域是供文本模式使用的显存,当我们在显存内的相应位置写入数据时,屏幕上就会出现对应的像素
在文本模式下显示器支持 80 x 25
16 色文本显示的窗口,一个字符占用两个字节:第一个字节为 ASCII 码,第二个字节为颜色信息
我们在这里选择使用 gs 段寄存器储存显存基址,并通过 0x10 号中断完成清屏与输出字符串的工作
在末尾我们使用 hlt
指令将计算机暂停,并填充完整 MBR 的 512 字节,需要注意的是标准 MBR 格式末尾两个字节应当固定为0x55, 0xaa
1 |
|
将以上代码进行整合,我们来简单运行一下我们的 MBR ,效果如下:
0x02.更强大的 loader,迈向保护模式
毫无疑问的是,MBR 的 512 B 大小仅允许我们做十分有限的一些事情,若是我们需要完成更加高级的任务,则需要更加复杂的程序,
比较容易想到的一种布局便是 MBR->kernel loader->kernel
这样的运行链:由mbr完成装载 kernel loader 的工作,再由 kernel loader 完成进入保护模式、装载内核的工作,最后运行内核
需要注意的是 kernel loader 别忘了指定相应的 vstart,绝对地址的计算会很让人头大的(笑)
一、从硬盘上读取数据
BIOS 只会帮我们载入硬盘第一个扇区的数据,故其他扇区上的数据都需要我们通过 MBR 手动装载入内存中,因此接下来我们来改造我们的 MBR ,完成读入 kernel loader 的任务
从硬盘上读取数据的流程很简单,只需要向对应端口写入相关信息后便能从对应端口读出数据,硬盘相关具体端口信息在前面已有,这里不再赘叙,代码如下:
1 |
|
其中有两个宏定义于我们自己的头文件中,你也可以自行修改头文件中对应宏的值
完成 kernel loader 的装载之后,我们的 MBR 就结束其使命了,接下来我们将控制权交由 kernel loader
需要注意的是我们的 kernel loader 将会越来越臃肿,后期别忘了修改 cx 寄存器的值
IDA 逆向出来的注释似乎比笔者本人写的注释更好看些233333
二、关闭 8086 地址回绕
前面我们讲到,要关闭 8086 地址回绕,只需要将 0x92
端口第 1 位(0位起始)置 1 即可打开 A20Gate,代码如下:
1 |
|
三、载入全局描述符表
我们需要在 loader 中自己定义一个 GDT 结构,之后使用 lgdt
指令载入,示例代码如下:
1 |
|
我们在这里定义了四个段描述符:0 号段描述符(空)、代码段描述符、栈段描述符、显存段描述符,并预填充了 60 个空的段描述符的位置
四、获取物理内存容量
在做好内存管理工作之前,我们首先得知道这台机子的物理容量的大小
我们可以通过 BIOS 的 0x15 中断子功能获取到物理内存大小,由于 BIOS 中断是实模式下的方法,所以我们需要在进入保护模式之前获取到物理容量的大小
0x15 号中断分别提供了三种获取物理内存大小的子功能,调用中断前应当将子功能号放入 EAX/AX 寄存器中,如下:
- EAX=0xE820:遍历主机上全部内存;返回内存布局
- AX=0xE801:分别检测低 15MB 内存与 16~4GB 内存,但最大只支持 4GB;返回内存容量大小
- AX=0x88:最多检测 64MB 内存,多余的会被自动忽略;返回内存容量大小
保险起见,我们将三种方法都写在 loader 里,若是一种方法失败了则尝试另一种,三种都失败了则只好将机器挂起,启动失败(当然,一般来说不会出现这种情况)
通过 0xE820 子功能获取内存容量
BIOS 的 0x15 号中断提供的 0xE820 子功能能够获取信息较为详细的内存布局,该功能并不会一次性返回整个可用内存的信息给我们,而是每次调用都会返回一种类型的内存信息,故我们需要迭代式多次调用该功能获取内存信息
该子功能使用一种名为地址范围描述符(Address Range Descriptor Structure)的结构来描述一段内存,从偏移 0 处起始各字段如下:
属性名称 | 描述 |
---|---|
BaseAddrLow | 基地址的低 32 位 |
BaseAddrHigh | 基地址的高 32 位 |
LengthLow | 内存长度的低 32 位 |
LengthHigh | 内存长度的高 32 位 |
Type | 该段内存的类型 |
每个字段占用 4 字节,故该结构大小应为 20 字节,其中 type 字段说明如下:
Type 值 | 名称 | 描述 |
---|---|---|
1 | AddressRangeMemory | 该段内存可以被操作系统所使用 |
2 | AddressRangeReserve | 该段内存为保留内存,操作系统不可使用 |
其他 | Undefined | 不可用的未定义内存 |
本次笔者尝试开发的操作系统暂定为 32 位,故 ARDS 中的高 32 位数据暂且不用,若是将来笔者有时间升级为 64 位系统自然会用到(笑)
毫无疑问的是,对于这样一个强大的中断子功能,我们需要给予的参数也并不少,以下是使用该子功能的方法说明(下表摘自《操作系统真象还原》):
调用/返回 | 寄存器 | 说明 |
---|---|---|
调用前输入 | EAX | 功能号:0xE820 |
EBX | 应返回的 ARDS 结构的位置(索引值?),第一次调用应置 0,后续值由 BIOS 设置,无需手动设置 | |
ES:DI | 写入 ARDS 结构的地址,BIOS 将会向该地址上写入相应的 ARDS 结构 | |
ECX | ARDS 结构的大小(字节),这里应为 20,将来也许会扩展该结构 | |
EDX | 固定为签名标记 0x534D4150 | |
返回后输出 | EFLAGS | 主要设置 CF 位:0 为成功,1 为出错 |
EAX | 固定为签名标记 0x534D4150 | |
ES:DI | 写入 ARDS 结构的地址,与输入相同 | |
ECX | 写入的字节数 | |
EBX | 下一个 ARDS 结构的位置(索引值?) |
故我们的代码应当如下:
1 |
|
简单测一下,检测到了六种类型的 ards ,最大的一个为 32 MB,刚好就是我们此前为 bochs 设置的虚拟内存大小
在这里笔者遇到了一个 bug:若是在此处打印字符串则后续无法成功启动内核,目前怀疑是流水线的问题
当然,打印字符串这种简单函数的代码没有任何贴在这里的必要(笑)
通过 0xE801 子功能获取内存容量
若是 0xE820 子功能没法获取到详细的内存信息,我们也可以考虑通过 0xE801 子功能获取内存容量
该子功能的方法说明见下表(摘自《操作系统真象还原》):
输入或输出 | 寄存器 | 用途 | 说明 |
---|---|---|---|
输入 | AX | 功能号 | 子功能号0xE801 |
输出 | EFLAGS的CF位 | 标识状态 | 0为成功,1为出错 |
AX | 内存容量 | 以 1KB 为单位,仅显示 15 MB 以下的内存容量(0x3c00) | |
BX | 内存容量 | 以 64 KB 为单位,显示 16MB~4GB 中连续单位数量 | |
CX | 内存容量 | 同 AX | |
DX | 内存容量 | 同 BX |
需要注意的是我们所获得的内存容量总会少 1MB,这是为了兼容一些老的 ISA 设备而预留的空间
那么我们通过该子功能获取内存容量大小的代码就很容易得到了:
1 |
|
通过 0x88 子功能获取内存容量
用法类似于 0xE801,只不过功能号输入从 AX 变成了 AH,同样地,由于只能获取 64MB 的内存容量,这意味着 0xE801 中的 BX、CX、ED都不会被用到,代码如下:
1 |
|
五、设置 Cr0 的 PE 位,正式进入保护模式
完成了前面的准备工作之后,我们接下来将设置 Cr0 寄存器的 PE 位,正式进入保护模式
代码如下:
1 |
|
完成这一步之后,我们就正式处于保护模式下了,撒花🌸~
六、刷新流水线
在计算机组成原理这门课程中我们初步了解到了多级流水线技术:CPU 将指令分解为多步,并让不同指令的各步操作重叠,从而实现几条指令并行处理
为了提高流水线的效率, CPU 还会进行分支预测:对于条件跳转指令而言,会根据上一次是否成功跳转来直接决定是否走这条分支,当然,若是预测失败的话载入到一半的流水线就会被清空,前功尽弃
在我们进入保护模式前所用的都是 16 位的指令,而当我们进入保护模式之后将要开始使用 32 位的指令,若是 CPU 提前以 16 位的格式进行装载,毫无疑问会出错,因此我们在这里手动使用一个远跳转来改变代码段描述符缓冲寄存器的值,清空流水线:
1 |
|
七、启动内存分页模式,装载二级页表
我们都知道 64位的 Linux 采用四级页表结构(PGD->PUD->PMD->PTE),可以为每个进程撑起 256 TB 的空间,但是对于本次笔者所要开发的 32 位操作系统而言,其内存地址空间最大为 4 GB ,那么我们只需要二级页表即可(或许未来的某一天笔者的这个小玩具会升级 64 位,不过那也不是现在要考虑的事情(笑))
按照惯例设定内存页的大小为 4KB,即每 0x1000 B 为一张内存页,对于32位的地址我们只需要使用其 31~12位
便可以完成以页为单位的的物理地址表示,剩余的 12 位的空间我们便可以用以存储页其他信息
页表的数据结构我们在操作系统课上已经有系统学习过,这里便不再赘叙,只简单讲一下二级页表,结构如下图所示(百度偷的,侵删):
对于 32 位操作系统,每个页目录中项与页表中项应为 4 字节,其结构如下:
页目录项(Page Directory Entry)
存放于页目录表中,对应页表物理地址,结构如下:
31~12 | 11~9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|
页表物理页地址31~12位 | AVL | G | 0 | D | A | PCD | PWT | US | RW | P |
页表项(Page Table Entry)
存放于页表中,对应物理页地址,结构如下:
31~12 | 11~9 | 8 | 7 | 6 | 5 | 4 | 3 | 2 | 1 | 0 |
---|---|---|---|---|---|---|---|---|---|---|
物理页地址31~12位 | AVL | G | PAT | D | A | PCD | PWT | US | RW | P |
对于页目录项与页表项中各位说明如下:
- AVL:available,该页是否可用,可忽略
- G:是否为全局页,全局页会被保存在 TLB 中方便调用;1为是
- PAT:页属性表位,用以在分级页表中设定页的属性,这里暂且先置0
- D:Dirty,当一张内存页被写入时,CPU会将该页标脏,仅针对页表项有效
- A:Accessed,该页每次被访问时该位都会被置1,定期清零,置1次数用以表示使用频率
- PCD:Page-level Cache Disable,0 为启用高速缓存,1 为禁止
- PWT:Page-level Write-Through,页级通写位,与高速缓存有关,这里暂且置 0
- US:User/Supervisor,权限位,1 时 ring0
ring3 均可访问该页,0时仅 ring0ring2 可访问(一般来说操作系统只需要用到 ring0 与 ring3) - RW:即 Read/Write,该位为 1 时该页可读写,否则只可读不可写
- P:Present,即该页是否存在,0 表示该页不存在于物理页中,此时对该页的访问会引发缺页异常
一张页表的大小与一张物理页的大小相同(4096B),其中可以存放 4096 / 4 = 1024 个页表项,那么其可以表示的内存大小便为 1024 * 0x1000 = 4194304B = 4096 KB = 4 MB
线性地址(Linear Address)
在启用分页机制之后我们所使用的地址为依据页表结构进行索引的线性地址,32 位的线性地址结构如下:
31~22 | 21~12 | 11~0 |
---|---|---|
页目录项标号 | 页表项标号 | 页内偏移 |
例如 0xc0000000 这个地址就对应着页目录表中第 768 张页表的第一个页表项、页内偏移为0,但其对应 PTE 存放的物理地址可以不是 0xc0000000,即通过页表我们完成了由线性地址 A 到 物理地址 B 的映射
线性地址?逻辑(虚拟)地址?物理地址?这么多个奇怪的名词冒出来可能有的人已经头昏脑胀了,现笔者给出一张结合分段与分页机制的地址说明示意图
需要注意的是 Linux 默认不使用分段(可以理解为整个就是一个大段),所以 Linux 下的虚拟地址/逻辑地址就是线性地址,这里我们的内核使用的是和 Linux 一样的不使用分段的思想(不然复杂度上天了),所以后面所说的虚拟地址==线性地址
启用分页机制
笼统地说,启用分页机制大概可以分为以下三个步骤:
- 准备好页目录表与页表
- 将页目录表地址写入 Cr3 寄存器
- 将 Cr0 寄存器的 PG 位置1
最后一步是真正启用分页机制的关键,因此在启用分页机制之前我们需要准备好相应的页目录表与页表,并将页表地址写入 Cr3 寄存器
I.初始化页内存位图
在现代操作系统当中,用户内存空间与内核内存空间毫无疑问是分开的,后续我们还会启用虚拟地址,按照惯例高地址的 1GB 空间留给内核,而低地址的 3GB 空间留给用户,那么相对应的,在我们的二级页表中:页目录表中的后 1/4 的空间我们留给内核存放其页表,前 3/4 的空间留给用户进程存放其页表
在这里有一个点:按照默认设置页表项中存储的页表所表示的物理地址是连续的,那么毫无疑问在一开始时第一张页表用以表示 0 ~ 0x3fffffff
的物理地址空间;但是我们的操作系统调度应当是灵活的,而我们决定在一开始时将内核先加载到物理低 1M 地址空间中,但是虚拟地址上是 0xc0000000 起始,因此这张页表会暂时同时作为第0个页目录表项与第768个页目录表项
我们在这里将第一张页目录表存放在内存 0x100000 处,将第一张页表存放在内存 0x101000 处
以及为了能够在启用分页机制之后也能够访问到页表自身,我们将页目录表的最后一个页目录表项初始化为页目录表自身
最终的一个初始的页表结构大概如下
1 |
|
II.装载页目录表,启用分页机制
我们还有一个需要注意的地方,在启用分页机制后我们所有的地址都是基于页表进行索引的线性地址,存放显存的位置应当放在内核地址空间中,由内核来控制显存,因此这里我们还需要重新修改视频段描述符的段基址;其他的段描述符同样如此,我们将要把整个段描述符表的基址初始化为内核地址空间中的地址
1 |
|
简单测试一下启用分页机制后我们的线性地址到物理地址之间的转换是否正确:
打印一个字符 3
,成功,撒花~🌸
我们可以使用 bochs 自带的 debugger 查看当前的虚拟内存到物理内存的映射,键入 info tab
指令:(gdb好像不行)
其中第一项和第二项是我们预先设置好的页目录表第 0 项与第 768 项同时映射到物理内存低 1MB 的位置;三四五项看起来好像有些奇怪,其实主要是由于我们将页目录表的最后一项设为其自身的缘故
在这里其将页目录表本身视作一张页表,那么页目录表中最后一张页表所映射的内存为高地址的 4MB :0xffc00000 ~ 0xffffffff
其中页目录表我们只填充了第 no.0、no.768~no.1022项、no.1023 项,作为页表而言也就是对应着线性地址的 0xffc00000 ~ 0xffc00fff
、0xfff00000 ~ 0xffffefff
、0xfffff000 ~ 0xffffffff
:
- 第一个对应着第一张页表的物理地址,没什么好说的
- 第二个对应着我们的 254 张内核地址空间页表,在这里映射到
0x000000101000 ~ 0x0000001fffff
,这是因为其将我们的 254 张页表视为 254 张物理页的缘故,所以我们可以通过访问线性地址0xfff00000 ~ 0xffffefff
来访问内核地址空间页表 - 第三个就是我们初始设定的自映射,也没什么好说的
通过这样的一个映射,我们在开启分页机制之后便也能通过线性地址直接访问页表本身,虽然消耗了一个页目录表项的空间,但笔者认为是值得的
八、ELF格式解析,将内核载入内存
32 位下一切的准备工作似乎都做完了,我们的 loader该开始着手考虑把内核给载入进来了——毫无疑问的是我们的内核并不会直接采用汇编来编写——那太傻了,笔者选择以 C 语言为主配合上一部分的汇编为辅,正如 Linux 内核所做的那样
那么我们的内核要使用什么样的格式呢?相较于结构奇形怪状的 PE 格式,笔者个人还是更熟悉 ELF 这种格式——Linux内核也是这个格式
至于自创一种格式…那太傻了,完全没有必要,gcc 也没法帮你弄新的格式(笑)
相应地,我们的 loader 还需要完成对 ELF 格式文件的解析的工作,在这里我们先将内核载入内存后再行解析,综合考虑在低 1MB 的地方笔者选择加载到 0x70000 这个位置,大概有 190KB 左右的可用空间,前期勉强够用了
之后我们的内核将被解析到 0x1500 这个位置,对应着的虚拟地址是 0xc0001500,故在链接内核时应当使用 -Ttext
参数指定其虚拟地址起始为 0xc0001500
将内核载入内存并解析后,我们的 “指挥棒” 将交由内核,loader 的工作正式结束
在这里有个小问题就是 ELF 文件中部分段的虚拟地址在 0x804800 附近,页表中并没有对应的物理页,因此笔者只好暂时选择只载入 type 为 load 的、文件内偏移非 0 的段,后面再来解决这个奇怪的问题,不过目前看来这些额外的奇怪的数据段似乎也没有作用()
ELF 文件的格式应当是基本功了,这里就不需要笔者再行赘叙了,反正开头也有写了
1 |
|
在进行 p_type 检测时书上用的是
cmp byte
,但是笔者看来 p_type 应当是 4 字节,故这里笔者改为cmp dword
0x03.从零开始的内核
基本内核雏形
前面讲到我们的内核将用 C 语言配合少部分汇编语言进行编写,一个操作系统内核应当在我们有操作时响应我们的操作,而在我们没有操作时等待我们的输入,与此同时处理同时所在运行的各种进程,那么一个操作系统内核的基本架构就出来了:
1 |
|
我们的操作系统内核一开始时啥都没有实现,因此在最开始时只有外面的这一层循环,里面什么都没有,但是这便是我们使用 C 语言编写的第一个内核的基本雏形
为了让我们感受到这个内核真的跑起来了,让我们用内联汇编输出点好康的(笑),这里笔者让其循环跑的第一个任务便是输出字符串 "welcome to a3os"
,由于笔者的 gcc 版本还算是比较新,可以直接使用 intel 风格的内联汇编而不需要被 AT&T 的反人类风格所折磨(笑),只需要加上参数 -masm=intel
即可
1 |
|
简单写一个 makefile(将就着看吧,瞎写的)
1 |
|
我们的 pack.sh
也应当进行修改,该把内核包进来了
1 |
|
此时除去 bochs 外的源文件组织结构应当如下:
1
2
3
4
5
6
7
8
9├── boot.inc
├── boot.sh
├── img.img
├── kernel
│ ├── main.c
│ └── Makefile
├── loader.s
├── mbr.s
└── pack.sh
简单打包运行,成功输出字符串,我们终于正式迈向内核阶段
简单的输出功能
此前我们使用过的在屏幕上打印字符的方式大概有直接读写显存、调用 BIOS 中断等,但毫无疑问的是这样的方式未免过于没有技术含量,因此我们接下来将要编写自己的输出函数直接操控显卡在屏幕上进行输出
I.单个字符输出
在操作之前我们需要保存当前上下文到栈上,同时通过 global
关键字导出 put_char
这个符号
1 |
|
首先我们需要知道光标是一个独立存在的事物,我们写入显存时不会改变光标位置,因此在文本模式下输出一个字符,我们需要手动完成的步骤如下:
- 获取光标位置
- 在光标位置处显存写入字符值
- 移动光标
输出字符之前我们需要先获得当前光标的位置,这里我们通过与显卡的 CRT Controller Registers 通信以获取当前光标位置,这里对端口等不再说明,需要时查表即可
1 |
|
C语言默认是 cdecl 调用约定,因此我们这里不考虑清栈的问题,32位下函数调用栈结构这里也不再赘叙;在一开始时我们使用 pushad
保存当前上下文总计 32 字节,加上返回地址 4 字节,因此上层调用传入的参数应当在 esp + 36
的位置,从这里获取到我们传入的参数
前面我们还讲到过,在文本模式的 80*25 的屏幕下,一个字符占用两个字节:第一个字节为字符的值,第二个字节为显示模式,因此光标位置 x 2才是字符在显存中的相对偏移,需要注意的是对于一些特殊字符(换行 \n
、退格 \b
等)我们需要进行特殊处理
需要注意的一点是对于 \b
退格的操作:若是光标已经到了显存第一个字符的位置则不需要再向前移动,这里添加一个简单的判断即可
1 |
|
还需要注意的一点是:当当前屏幕已经被填满了的时候我们需要进行滚屏,这里暂时不考虑缓存的问题,因此我们只需要将 1 ~ 24 行的位置的内容移到 0~23 行即可
考虑缓存的设计则需要对显存及显卡进行额外的操作(设置 Start Address Register 组等),这里笔者选择和原书作者一起偷懒摆烂(笑)
1 |
|
我们在一个新的头文件 print.h
中进行函数声明,以便后续链接
1 |
|
此时的内核源码架构如下:
1 |
|
stdint.h 中定义了如
uint8_t
等类型,这里就不复制过来了
其中 Makefile 文件如下(随手写的,不一定高效hhh):
lib/kernel/Makefile
1 |
|
Makefile
1 |
|
简单测试一下我们的 put_char()
函数:
1 |
|
打印效果还算理想
II.字符串输出
字符串的输出就简单的多,我们只需要循环调用 put_char()
函数一直到遇到 \0
后返回即可,这里笔者对比过用纯汇编书写的代码与用 C 实现的代码,后者 gcc 会多增添一些额外的代码,出于性能上的考虑这里还是选择纯汇编
1 |
|
简单测试一下,效果还算理想,退格也没有超出显存范围(笑)
1 |
|
0x04.内核中断实现(未完成)
中断(interrupt) 即硬件/软件向 CPU 发送的特殊信号,CPU 接收到中断后会停下当前工作转而执行中断处理程序,完成后恢复原工作流程