【CODE.0x00】从零开始的32位操作系统开发手记

本文最后更新于:2021年10月16日 凌晨

请大家多多支持国产操作系统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 0xFFFEF 64 KB - 16B BIOS
0xFFFF0 0xFFFFF 16 B 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 这个地址上,此后也便一直沿用下来

参见 https://www.glamenv-septzen.net/en/view/6

在实模式下的 Intel 8086 CPU 仅有 20 根地址总线,其寻址范围为 1 MB,而寄存器都是 16 位的,因此在这种模式下若是想要寻到 20 位的地址则需要采用 段基址:段内偏移 的这样一种寻址方式,在寻址时将段寄存器中的段基址左移四位,再加上段内偏移,自然就能够寻到 1 MB 的内存地址了(例如 0xFFFF 左移四位得 0xFFFF0,0xFFFF0 + F 得 0xFFFFF),相关的知识我们已经在微机原理课上学过了,这里便不再赘叙

二、与硬盘间的通信

BIOS 只会将硬盘上的第一个扇区(0面0道1扇区)帮我们载入内存,作为 MBR ,因此我们需要自己将硬盘上的其他我们需要的数据手动载入内存

我们都知道,在 x86 汇编语言当中使用 inout 两条指令 从端口中读出数据向端口中写入数据,若我们需要访问硬盘,则通常需要用到如下端口,分别对应着硬盘内部相应的寄存器(下表摘自《操作系统真象还原》):

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,这里便不再赘叙

image.png

对于一个可执行 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
/* 32-bit ELF base types. */
typedef __u32 Elf32_Addr;
typedef __u16 Elf32_Half;
typedef __u32 Elf32_Off;
typedef __s32 Elf32_Sword;
typedef __u32 Elf32_Word;

...

#define EI_NIDENT 16

typedef struct elf32_hdr{
unsigned char e_ident[EI_NIDENT];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry; /* Entry point */
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
} Elf32_Ehdr;

各字段说明如下:

e_ident[16] (1 Byte * 16)

成员 含义
e_ident[0] ~ e_ident[3] 魔数,用以标识文件类型,对于 ELF 文件而言从 0 ~ 3应当是 0x7f ELF
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 解析的例子:

image.png

大致如此,接下来我们来看存放各段信息的数组 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
2
3
4
5
6
7
8
9
10
typedef struct elf32_phdr{
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;

各字段说明如下:

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 里看看:

image.png

那么这里我们怎么知道一个段应该是 .data 段还是 .bss 段还是 .text 段呢?实际上对于计算机而言其只需要知道该段的加载地址、执行权限等信息即可,只有我们人类在进行逆向工程等工作时需要手动识别,由于不是本文重点故不在此赘叙

八、显卡端口控制

对于显卡的操作其实是较为复杂的,我们操作显卡主要通过两个寄存器 Address RegisterData 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 字段,它决定映射的端口号为 0x3B40x3B5 或 0x3D40x3D5
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
$ tar -zxvf bochs-2.6.11.tar.gz

配置脚本,其中prefix项应当为你自己的安装目录,--enable-gdb-stub项为使用 gdb 进行调试,可以替换成 bochs 自己的调试器 --enable-debugger二者不可共存,笔者这里推荐不要使用gdb(虽然说你可能更熟悉他),不过还是简单讲讲如何使用 gdb 进行调试

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
./configure \
--prefix=/home/arttnba3/Documents/bochs \
--enable-gdb-stub \
--enable-disasm \
--enable-iodebug \
--enable-x86-debugger \
--with-x \
--with-x11 \
LIBS='-lX11'

一些前置的库

1
$ sudo apt install libx11-dev libxrandr-dev

安装

1
$ make install

配置文件

在我们的安装目录下的 share/doc/bochs/bochsrc-sample.txt 为 bochs 提供的配置文件样例,我们主要修改以下几点,指定对应文件的路径,以及注释掉 sound 的配置

1
2
3
4
5
6
7
8
9
megs: 32
romimage: file=./bochs/share/bochs/BIOS-bochs-latest
vgaromimage: file=./bochs/share/bochs/VGABIOS-lgpl-latest
ata0-master: type=disk, mode=flat, path="img.img"
cpu: model=pentium, count=1, ips=50000000, reset_on_triple_fault=1, ignore_bad_msrs=1, msrs="msrs.def"
# following lines need to be added by yourself
keyboard: type=mf, serial_delay=250 keymap=./bochs/share/bochs/keymaps/x11-pc-us.map
gdbstub: enabled=1, port=1234, text_base=0, data_base=0, bss_base=0 # if you want to use bochs itself to debug, this shall be delete
#sound: driver=default, waveout=/dev/dsp. wavein=, midiout=

我们将该文件放置于与 bochs 文件夹的同一目录下

打包脚本

使用 nasm 编译,bximage创建镜像文件,dd 打包为镜像文件

以下示例脚本打包入两个示例文件,dd 等命令的相关用法不在此赘叙

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
nasm -o ./mbr.bin ./mbr.s
nasm -o ./loader.bin ./loader.s
./bochs/bin/bximage -mode=create -hd=60M -imgmode="flat" -q ./img.img
dd if=./mbr.bin \
of=./img.img \
bs=512 count=1 conv=notrunc
dd if=./loader.bin \
of=./img.img \
bs=512 count=8 seek=2 conv=notrunc

启动脚本

1
2
#!/bin/bash
./bochs/bin/bochs -f ./bochsrc.conf

若是选择 gdb 作为调试器,启动后会等待我们的调试器的连接才会运行,默认是 1234 端口

image.png

在 gdb 中使用如下命令连接

1
pwndbg> target remote localhost:1234

需要注意的是 bochs 与 pwndbg 这个插件的兼容性并不好(至少在笔者的电脑上已经慢到无法令人忍耐的地步)

初始时 CS:IP 指向 0xFFFF0,即 BIOS 入口点

image.png

当然,也可以在编译时设置选择直接使用 bochs 自带的调试器

image.png

初始时 CS:IP 指向 0xFFFF0,即 BIOS 入口点

image.png

CS 左移四位再加上 IP 寄存器撑起了 16 位实模式下的 1MB 内存空间

II.封装宏

我们将一些操作当中所需要用到的值封装为宏放在一个特定的“头文件”中,以避免后续频繁查表:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
; In this file we will set some useful macros
; Original from the book "Caozuoxitongzhenxianghuanyuan" by Zheng Gang, 2016
; Recode by arttnba3, 2021.4

;------------- load base -------------
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
PAGE_DIR_TABLE_POS equ 0x100000
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_START_SECTOR equ 9
KERNEL_ENTRY_POINT equ 0xc0001500

;------------- attributes for GDT descriptor (high 32 bit) -------------
DESC_G_4K equ 1000_0000_00000000_00000000b ; G flag, 1 for the 4k size of granularity of the sector boundaries, 0 for 1 B
DESC_D_32 equ 100_0000_00000000_00000000b ; D/B flag, D(1) for the available addr to 32 bit, B(0) for the 16 bit
DESC_L_32 equ 00_0000_00000000_00000000b ; L flag for 32 bit
DESC_L_64 equ 10_0000_00000000_00000000b ; L flag for 64 bit
DESC_AVL equ 0_0000_00000000_00000000b ; AVL flag, temporary useless

DESC_LIMIT_CODE equ 1111_00000000_00000000b ; the 19~16 bit of the segment limit
; for a larger boundary of address, it shall always be 0xFFFFF
DESC_LIMIT_DATA equ 1111_00000000_00000000b ;
DESC_LIMIT_VIDEO equ 0000_00000000_00000000b ; unknown, maybe for 16 bit?

DESC_P equ 1000_0000_00000000b ; P flag, 1 for the existance of the segment

DESC_DPL_RING_0 equ 00_0_0000_00000000b ; DPL flag, ring 0
DESC_DPL_RING_1 equ 01_0_0000_00000000b ; DPL flag, ring 1
DESC_DPL_RING_2 equ 10_0_0000_00000000b ; DPL flag, ring 2
DESC_DPL_RING_3 equ 11_0_0000_00000000b ; DPL flag, ring 3

DESC_S_CODE equ 1_0000_00000000b ; s flag, 1 for data(including code) segment
DESC_S_DATA equ 1_0000_00000000b ;
DESC_S_SYS equ 0_0000_00000000b ; s flag, 0 for system segment

DESC_TYPE_CODE_X equ 1000_00000000b ; type flag for code segment,
; eXecutable
DESC_TYPE_DATA_W equ 0010_00000000b ; type flag for data segment,
; Writable

;;------------- package macros for GDT descriptor (high 32 bit) -------------
MACRO_DESC_CODE equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L_32 + DESC_AVL + DESC_LIMIT_CODE + DESC_P + DESC_DPL_RING_0 + DESC_S_CODE + DESC_TYPE_CODE_X + 0x00

MACRO_DESC_DATA equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L_32 + DESC_AVL + DESC_LIMIT_DATA + DESC_P + DESC_DPL_RING_0 + DESC_S_DATA + DESC_TYPE_DATA_W + 0x00

MACRO_DESC_VIDEO equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L_32 + DESC_AVL + DESC_LIMIT_VIDEO + DESC_P + DESC_DPL_RING_0 + DESC_S_DATA + DESC_TYPE_DATA_W + 0x0b

;------------- attributes for segment selector -------------
RPL_RING0 equ 00b
RPL_RING1 equ 01b
RPL_RING2 equ 10b
RPL_RING3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b

;------------- attributes for page table -------------
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b

;------------- attributes for ELF -------------
PT_NULL equ 0

后续这个“头文件”中的内容可能还会增多

0x01.从最简单的MBR入手,实模式入门

一、主引导记录 - Master Boot Recode

主引导记录位于硬盘的 0 盘 0 道 1 扇区上,是计算机启动流程中我们最早能够控制的程序,其大小仅占一个扇区

从硬盘上读取主引导记录是BIOS所做的事情,我们不需要自己手动完成

由于 MBR 被加载到内存地址 0x7c00 的位置,笔者在这里选择将该文件整个作为一个 section,并指定其 vstart 为 0x7c00,这样 nasm 在计算绝对地址时便会以 0x7c00 作为基址

1
2
%include "boot.inc"
SECTION MBR vstart=0x7c00

二、段寄存器的初始化

我们还需要进行段寄存器的初始化工作,初始时我们暂且只有一个代码段,且笔者暂时不考虑再使用更多的段了,故我们选择使用 cx 寄存器的初始值来初始化其他的段寄存器

在这里我们使用 ax 寄存器进行中转,以改变段寄存器的值,这是因为 Intel 不允许将一个立即数传送到段寄存器,故我们只能借助其他寄存器或是内存单元

前面讲到,0x7E00~0x7FFF 是留给 MBR 储存数据的空间,故我们将堆栈指针寄存器 sp 初始化到这里

1
2
3
4
5
6
mov ax, cx
mov ds, ax
mov es, ax
mov fs, ax
mov ss, ax
mov sp, 0x7f00 ; stack frame pointer

三、使用文本模式显示

前面我们讲到, 0xB800~0xBFFF 这块区域是供文本模式使用的显存,当我们在显存内的相应位置写入数据时,屏幕上就会出现对应的像素

在文本模式下显示器支持 80 x 25 16 色文本显示的窗口,一个字符占用两个字节:第一个字节为 ASCII 码,第二个字节为颜色信息

我们在这里选择使用 gs 段寄存器储存显存基址,并通过 0x10 号中断完成清屏与输出字符串的工作

在末尾我们使用 hlt 指令将计算机暂停,并填充完整 MBR 的 512 字节,需要注意的是标准 MBR 格式末尾两个字节应当固定为0x55, 0xaa

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
    mov ax, 0xb800
mov gs, ax
mov ax, 0x0600 ; ah=0x06, means to volumn lines; al for nums of lines, 0 for all
mov bx, 0x0700 ; bh for the attribute of lines
mov cx, 0 ; (0, 0)
mov dx, 0x184f ; (80, 25):0~24, 0~79
int 0x10 ; clear the screen

mov di, 0
mov si, loader_msg
.a3_print: ; loop to print string 'arttnba3'
mov al, [si]
mov byte [gs:di], al
inc di
mov byte [gs:di], 0xA4
inc di
inc si
cmp di, 16
jnz .a3_print

mov bp, loader_msg ; es:bp for the addr of msg (es is 0 now)
mov cx, 14 ; length of loader_msg
mov ax, 0x1301 ; ah=0x13 for print, al for attribute(01:characters only, change the cursor(00 not change))
; other attributes in bl(00,01), or in msg(10,11)
mov bx, 0x001f ; bh:number of page, bl:attribute(1f blue bg, pink char)
mov dx, 0x1800 ; (0,24)
int 0x10

hlt

loader_msg db 'arttnba3', 39, 's mbr'

times 510 - ($ - $$) db 0 ; totally 512B, 0 for padding
db 0x55, 0xaa ; the final 2 B shall always be this

将以上代码进行整合,我们来简单运行一下我们的 MBR ,效果如下:

image.png

0x02.更强大的 loader,迈向保护模式

毫无疑问的是,MBR 的 512 B 大小仅允许我们做十分有限的一些事情,若是我们需要完成更加高级的任务,则需要更加复杂的程序,

比较容易想到的一种布局便是 MBR->kernel loader->kernel 这样的运行链:由mbr完成装载 kernel loader 的工作,再由 kernel loader 完成进入保护模式、装载内核的工作,最后运行内核

需要注意的是 kernel loader 别忘了指定相应的 vstart,绝对地址的计算会很让人头大的(笑)

一、从硬盘上读取数据

BIOS 只会帮我们载入硬盘第一个扇区的数据,故其他扇区上的数据都需要我们通过 MBR 手动装载入内存中,因此接下来我们来改造我们的 MBR ,完成读入 kernel loader 的任务

从硬盘上读取数据的流程很简单,只需要向对应端口写入相关信息后便能从对应端口读出数据,硬盘相关具体端口信息在前面已有,这里不再赘叙,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
	mov eax, LOADER_START_SECTOR	; address of the sector on the disk
mov bx, LOADER_BASE_ADDR ; destination address

mov cx, 8 ; amount of sectors to read
call read_disk

jmp LOADER_BASE_ADDR

; data to disk shall be pass by ax/al, dx for the port
read_disk:
mov esi, eax
mov di, cx
mov dx, 0x1f2 ; amount of sectors to read
mov al, cl
out dx, al

mov eax, esi ; Logical Block Address, which means that 1 for a block(usually 512B), not 1 B
; we use LBA28 there
mov dx, 0x1f3 ; LBA low
out dx, al ; LBA 0~7 bit

mov cl, 8
shr eax, cl ; LBA 8~15 bit
mov dx, 0x1f4 ; LBA mid
out dx, al

shr eax, cl ; LBA 16~23 bit
mov dx, 0x1f5 ; LBA high
out dx, al

shr eax, cl
and al, 0x0f ; LBA 24~27 bit
or al, 0xe0 ; 4(0) for main disk, 6(1) for LBA(0 for CHS), 5(1) and 7(1) are MBS bit
mov dx, 0x1f6 ; device
out dx, al

mov dx, 0x1f7 ; command for the disk
mov al, 0x20 ; 0x20: to read from the disk, 0x30: to write to the fisk
out dx, al

;; check whether the disk is ready
.not_ready:
nop
in al, dx
and al, 0x88 ; (bit) 0x80:busy, 0x08:ready
cmp al, 0x8
jnz .not_ready

;; calculate the total times of reading
mov ax, di ; amount of sectors to read
mov dx, 256 ; we can read 2 B each time, 256 times for a sector
mul dx
mov cx, ax

; data from disk shall be get from dx, port shall be set forward
mov dx, 0x1f0

.go_on_read:
in ax, dx
mov [bx], ax
add bx, 2
loop .go_on_read
ret

其中有两个宏定义于我们自己的头文件中,你也可以自行修改头文件中对应宏的值

完成 kernel loader 的装载之后,我们的 MBR 就结束其使命了,接下来我们将控制权交由 kernel loader

需要注意的是我们的 kernel loader 将会越来越臃肿,后期别忘了修改 cx 寄存器的值

IDA 逆向出来的注释似乎比笔者本人写的注释更好看些233333

image.png

二、关闭 8086 地址回绕

前面我们讲到,要关闭 8086 地址回绕,只需要将 0x92 端口第 1 位(0位起始)置 1 即可打开 A20Gate,代码如下:

1
2
3
in al, 0x92
or al, 0000_0010b
out 0x92, al

三、载入全局描述符表

我们需要在 loader 中自己定义一个 GDT 结构,之后使用 lgdt 指令载入,示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
;------------- Global Descriptor Table -------------
;; the first one shall be NULL, not available
GDT_BASE: dd 0x00000000
dd 0x00000000

CODE_DESC: dd 0x0000FFFF ; base 0x0000, limit 0xffff
dd MACRO_DESC_CODE

DATA_STACK_DESC: dd 0x0000FFFF
dd MACRO_DESC_DATA

VIDEO_DESC: dd 0x80000007 ; (0xbffff-0xb8000) / 4k = 0x7, for text mode(0xb80000~0xbffff)
dd MACRO_DESC_VIDEO

GDT_SIZE equ $ - GDT_BASE
GDT_LIMIT equ GDT_SIZE - 1
times 60 dq 0 ; reserve for 60 descriptors

;------------- something for GDT and selectors -------------
SELECTOR_CODE equ (0x0001 << 3) + TI_GDT + RPL_RING0 ; index, ti, rpl
SELECTOR_DATA equ (0x0002 << 3) + TI_GDT + RPL_RING0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL_RING0

GDT_PTR dw GDT_LIMIT
dd GDT_BASE ; little endian

ards_array times 244 db 0
ards_num dw 0x0

......

; a jmup should be at there (lol)
;;; load the GDT into GDT register(48bit)
lgdt [GDT_PTR]

我们在这里定义了四个段描述符: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
	ards_num dd 0x0
ards_array times 100 dq 0
total_mem dd 0

....

; a jump should get there
;------------- Get the info of the memory -------------
;; 0xe820 func of int 0x15
xor ebx, ebx ; set the ebx to 0
mov edx, 0x534d4150
mov di, args_array
.get_ards_loop:
mov eax, 0x0000e820
mov ecx, 20
int 0x15
jc .get_mem_e801 ; cf=1:failed, try another way
add edi, ecx
inc word [ards_num]
cmp ebx, 0
jnz .get_ards_loop

;;;find the biggest ards for our os to use
mov ecx, [ards_num]
mov ebx, ards_array
xor edx, edx ; to store the biggest one
.find_max:
mov eax, [ebx]
add eax, [ebx+8] ; base_addr_low + length_low
add ebx, 20
cmp edx, eax
jge .next_ards
mov edx, eax
.next_ards:
loop .find_max
mov [total_mem], edx
jmp .protected_mode

简单测一下,检测到了六种类型的 ards ,最大的一个为 32 MB,刚好就是我们此前为 bochs 设置的虚拟内存大小

在这里笔者遇到了一个 bug:若是在此处打印字符串则后续无法成功启动内核,目前怀疑是流水线的问题

image.png

当然,打印字符串这种简单函数的代码没有任何贴在这里的必要(笑)

通过 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
.get_mem_e801:
mov eax, 0x0000e801
int 0x15
jc .get_mem_88 ; failed, try the 0x88 func

mov ecx, 0x00000400
mul ecx
add eax, 0x100000
mov esi, eax
xor eax, eax
mov eax, ebx
mov ecx, 0x10000
mul ecx
add esi, eax
mov [total_mem], esi
jmp .protected_mode

通过 0x88 子功能获取内存容量

用法类似于 0xE801,只不过功能号输入从 AX 变成了 AH,同样地,由于只能获取 64MB 的内存容量,这意味着 0xE801 中的 BX、CX、ED都不会被用到,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.get_mem_88:
mov ah, 0x88
int 0x15
jc .get_mem_failed
and eax, 0x0000FFFF
mov cx, 0x400
mul cx
shr edx, 16
or edx, eax
add edx, 0x100000
mov dword [total_mem], edx
jmp .protected_mode

.get_mem_failed: ; we finally failed, hang it on
add edi, 2
mov esi, str_get_mem_fail
call a3_print
hlt

五、设置 Cr0 的 PE 位,正式进入保护模式

完成了前面的准备工作之后,我们接下来将设置 Cr0 寄存器的 PE 位,正式进入保护模式

代码如下:

1
2
3
4
;;; set the cr0 no.0 bit to 1
mov eax, cr0
or eax, 0x00000001
mov cr0, eax

完成这一步之后,我们就正式处于保护模式下了,撒花🌸~

六、刷新流水线

在计算机组成原理这门课程中我们初步了解到了多级流水线技术:CPU 将指令分解为多步,并让不同指令的各步操作重叠,从而实现几条指令并行处理

为了提高流水线的效率, CPU 还会进行分支预测:对于条件跳转指令而言,会根据上一次是否成功跳转来直接决定是否走这条分支,当然,若是预测失败的话载入到一半的流水线就会被清空,前功尽弃

在我们进入保护模式前所用的都是 16 位的指令,而当我们进入保护模式之后将要开始使用 32 位的指令,若是 CPU 提前以 16 位的格式进行装载,毫无疑问会出错,因此我们在这里手动使用一个远跳转来改变代码段描述符缓冲寄存器的值,清空流水线:

1
2
3
4
5
jmp dword SELECTOR_CODE:p_mode_start        ; code selector is 0 now

[bits 32]
p_mode_start:
# your other codes

七、启动内存分页模式,装载二级页表

我们都知道 64位的 Linux 采用四级页表结构(PGD->PUD->PMD->PTE),可以为每个进程撑起 256 TB 的空间,但是对于本次笔者所要开发的 32 位操作系统而言,其内存地址空间最大为 4 GB ,那么我们只需要二级页表即可(或许未来的某一天笔者的这个小玩具会升级 64 位,不过那也不是现在要考虑的事情(笑))

按照惯例设定内存页的大小为 4KB,即每 0x1000 B 为一张内存页,对于32位的地址我们只需要使用其 31~12位 便可以完成以页为单位的的物理地址表示,剩余的 12 位的空间我们便可以用以存储页其他信息

页表的数据结构我们在操作系统课上已经有系统学习过,这里便不再赘叙,只简单讲一下二级页表,结构如下图所示(百度偷的,侵删):

image.png

对于 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,1 为启用高速缓存,0 为禁止
  • PWT:Page-level Write-Through,页级通写位,与高速缓存有关,这里暂且置 0
  • US:User/Supervisor,权限位,1 时 ring0ring3 均可访问该页,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 的映射

启用分页机制

笼统地说,启用分页机制大概可以分为以下三个步骤:

  • 准备好页目录表与页表
  • 将页目录表地址写入 Cr3 寄存器
  • 将 Cr0 寄存器的 PG 位置1

最后一步是真正启用分页机制的关键,因此在启用分页机制之前我们需要准备好相应的页目录表与页表,并将页表地址写入 Cr3 寄存器

I.初始化页内存位图

在现代操作系统当中,用户内存空间与内核内存空间毫无疑问是分开的,后续我们还会启用虚拟地址,按照惯例高地址的 1GB 空间留给内核,而低地址的 3GB 空间留给用户,那么相对应的,在我们的二级页表中:页目录表中的后 1/4 的空间我们留给内核存放其页表,前 3/4 的空间留给用户进程存放其页表

在这里有一个点:按照默认设置页表项中存储的页表所表示的物理地址是连续的,那么毫无疑问在一开始时第一张页表用以表示 0 ~ 0x3fffffff 的物理地址空间;但是我们的操作系统调度应当是灵活的,而我们决定在一开始时将内核先加载到物理低 1M 地址空间中,但是虚拟地址上是 0xc0000000 起始,因此这张页表会暂时同时作为第0个页目录表项与第768个页目录表项

我们在这里将第一张页目录表存放在内存 0x100000 处,将第一张页表存放在内存 0x101000 处

以及为了能够在启用分页机制之后也能够访问到页表自身,我们将页目录表的最后一个页目录表项初始化为页目录表自身

最终的一个初始的页表结构大概如下

image.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
;;------------- turn on paging -------------
;;; clear the space for the page directory table
setup_page:
mov ecx, 4096
mov esi, 0
.clear_page_dir:
mov byte [PAGE_DIR_TABLE_POS + esi], 0
inc esi
loop .clear_page_dir

;;; generate the PDE
.create_pde:
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x1000 ; the addr of the first Page Table
mov ebx, eax

or eax, PG_US_U | PG_RW_W | PG_P ; user page, writable, present(exist)
mov [PAGE_DIR_TABLE_POS + 0x0], eax ; the first Page Table restored in the no.0 PDE and no.768 PDE
; the first PDE for 0~0x3fffff(default), including the 0 ~ 0xfffff(we're using it now)
mov [PAGE_DIR_TABLE_POS + 0xc00], eax ; at there we divided memory into two parts: user and kernel
; 0xc00 is the no.768 page directory entry, (virtual) higher are all for kernel
; our kernel will be mapping on the physical mem start from 0x100000
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS + 4092], eax ; get the last PDE pointing to the Page Directory Table itself

;;; generate PTE for the lower 1M memory
mov ecx, 256 ; at the very beginning, it's okay for us to just initialize PTE for 1MB
; each page is 4096(4K), for 1M memory, there're 256 PTE available
; each pte only needs 4B in a page table, the first one page table is enough to use
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P ; user page, writable, present(exist)
.create_pte:
mov [ebx + esi * 4], edx
add edx, 0x1000
inc esi
loop .create_pte

;;; create PDE for other page table in kernel
mov eax, PAGE_DIR_TABLE_POS
add eax, 0x2000 ; the addr of the second page table
or eax, PG_US_U | PG_RW_W | PG_P ; user page, writable, present(exist)
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254 ; no.769 ~ 1022 PDE
mov esi, 769
.create_kernel_pte:
mov [ebx + esi * 4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pte
ret

II.装载页目录表,启用分页机制

我们还有一个需要注意的地方,在启用分页机制后我们所有的地址都是基于页表进行索引的虚拟地址(线性地址),此时存放显存的位置应当放在内核地址空间中,由内核来控制显存,因此这里我们还需要重新修改视频段描述符的段基址;其他的段描述符同样如此,我们将要把整个段描述符表的基址初始化为内核地址空间中的地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
    call setup_page

sgdt [GDT_PTR] ; save the original GDT value

mov ebx, [GDT_PTR + 2] ; GDT_BASE
or dword [ebx + 0x18 + 4], 0xc0000000 ; reset the video segment descriptor to virtual addr

add esp, 0xc0000000 ; reset the stack to the kernel space(virtual addr)
add dword [GDT_PTR + 2], 0xc0000000 ; pre-reset the GDT_BASE

;; set the cr3 register
mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax

;; turning on paging
mov eax, cr0
or eax, 0x80000000
mov cr0, eax ; from now on, all addr are delt with the paging
; for example, 0xc0000000 -> Page Directory Table no.768 PDE
; no.768 PDE -> Page Table no.0 PTE -> exact page

;; reset the GDT to virtual addr
lgdt [GDT_PTR]

简单测试一下启用分页机制后我们的线性地址到物理地址之间的转换是否正确:

打印一个字符 3,成功,撒花~🌸

image.png

我们可以使用 bochs 自带的 debugger 查看当前的虚拟内存到物理内存的映射,键入 info tab 指令:(gdb好像不行)

image.png

其中第一项和第二项是我们预先设置好的页目录表第 0 项与第 768 项同时映射到物理内存低 1MB 的位置;三四五项看起来好像有些奇怪,其实主要是由于我们将页目录表的最后一项设为其自身的缘故

在这里其将页目录表本身视作一张页表,那么页目录表中最后一张页表所映射的内存为高地址的 4MB :0xffc00000 ~ 0xffffffff

其中页目录表我们只填充了第 no.0、no.768~no.1022项、no.1023 项,作为页表而言也就是对应着线性地址的 0xffc00000 ~ 0xffc00fff0xfff00000 ~ 0xffffefff0xfffff000 ~ 0xffffffff

  • 第一个对应着第一张页表的物理地址,没什么好说的
  • 第二个对应着我们的 254 张内核地址空间页表,在这里映射到 0x000000101000 ~ 0x0000001fffff ,这是因为其将我们的 254 张页表视为 254 张物理页的缘故,所以我们可以通过访问线性地址 0xfff00000 ~ 0xffffefff 来访问内核地址空间页表
  • 第三个就是我们初始设定的自映射,也没什么好说的

通过这样的一个映射,我们在开启分页机制之后便也能通过线性地址直接访问页表本身,虽然消耗了一个页目录表项的空间,但笔者认为是值得的

八、ELF格式解析,将内核载入内存

32 位下一切的准备工作似乎都做完了,我们的 loader该开始着手考虑把内核给载入进来了——毫无疑问的是我们的内核并不会直接采用汇编来编写——那太傻了,笔者选择以 C 语言为主配合上一部分的汇编为辅,正如 Linux 内核所做的那样

那么我们的内核要使用什么样的格式呢?相较于结构奇形怪状的 PE 格式,笔者个人还是更熟悉 ELF 这种格式——Linux内核也是这个格式

image.png

至于自创一种格式…那太傻了,完全没有必要,gcc 也没法帮你弄新的格式(笑)

相应地,我们的 loader 还需要完成对 ELF 格式文件的解析的工作,在这里我们先将内核载入内存后再行解析,综合考虑在低 1MB 的地方笔者选择加载到 0x70000 这个位置,大概有 190KB 左右的可用空间,前期勉强够用了

之后我们的内核将被解析到 0x1500 这个位置,对应着的虚拟地址是 0xc0001500,故在链接内核时应当使用 -Ttext 参数指定其虚拟地址起始为 0xc0001500

将内核载入内存并解析后,我们的 “指挥棒” 将交由内核,loader 的工作正式结束

在这里有个小问题就是 ELF 文件中部分段的虚拟地址在 0x804800 附近,页表中并没有对应的物理页,因此笔者只好暂时选择只载入 type 为 load 的、文件内偏移非 0 的段,后面再来解决这个奇怪的问题,不过目前看来这些额外的奇怪的数据段似乎也没有作用()

ELF 文件的格式应当是基本功了,这里就不需要笔者再行赘叙了,反正开头也有写了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
;; reset the GDT to virtual addr   
lgdt [GDT_PTR]
mov byte [gs:162], '3'

call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT

;;------------- load the kernel -------------
kernel_init:
xor eax, eax
xor ebx, ebx ; addr of each Program Header Entry
xor ecx, ecx ; nums of Program Header Entry
xor edx, edx ; size of a Program Header Entry

mov dx, [KERNEL_BIN_BASE_ADDR + 42] ; e_phentsize, size of each entry in program Header Table
mov ebx, [KERNEL_BIN_BASE_ADDR + 28] ; e_phoff, offset of Program Header Table in the ELF file
add ebx, KERNEL_BIN_BASE_ADDR
mov cx, [KERNEL_BIN_BASE_ADDR + 44] ; e_phnum, amount of entries in Program Header Table

.each_segment:
;cmp dword [ebx], PT_NULL ; check the p_type
;je .PT_IS_NULL ; PT_NULL means not used
cmp dword [ebx], 0x1
jne .PT_IS_NULL
cmp dword [ebx + 4], 0
je .PT_IS_NULL

push dword [ebx + 16] ; p_filesz, count

mov eax, [ebx + 4] ; p_offset
add eax, KERNEL_BIN_BASE_ADDR ; src
push eax

push dword [ebx + 8] ; dst, p_vaddr
call memcpy

add esp, 12 ; clear the parameters

.PT_IS_NULL:
add ebx, edx
loop .each_segment
ret

;;------------- basic memcpy -------------
;;; parameters passed by stack
memcpy:
push ebp
mov ebp, esp
push ecx

mov edi, [ebp + 8] ; dst
mov esi, [ebp + 12] ; src
mov ecx, [ebp + 16] ; count

cld
rep movsb

pop ecx
leave
ret

在进行 p_type 检测时书上用的是 cmp byte,但是笔者看来 p_type 应当是 4 字节,故这里笔者改为 cmp dword

0x03.从零开始的内核

基本内核雏形

前面讲到我们的内核将用 C 语言配合少部分汇编语言进行编写,一个操作系统内核应当在我们有操作时响应我们的操作,而在我们没有操作时等待我们的输入,与此同时处理同时所在运行的各种进程,那么一个操作系统内核的基本架构就出来了:

1
2
3
4
5
6
7
8
int main (void)
{
while (1)
{
tryToGetInputWithNoneBlocking();
dealWithTasksExisted();
}
}

我们的操作系统内核一开始时啥都没有实现,因此在最开始时只有外面的这一层循环,里面什么都没有,但是这便是我们使用 C 语言编写的第一个内核的基本雏形

为了让我们感受到这个内核真的跑起来了,让我们用内联汇编输出点好康的(笑),这里笔者让其循环跑的第一个任务便是输出字符串 "welcome to a3os",由于笔者的 gcc 版本还算是比较新,可以直接使用 intel 风格的内联汇编而不需要被 AT&T 的反人类风格所折磨(笑),只需要加上参数 -masm=intel 即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
int main (void)
{
while (1)
{
asm(
"mov byte ptr [gs:170], 'W';"
"mov byte ptr [gs:172], 'E';"
"mov byte ptr [gs:174], 'L';"
"mov byte ptr [gs:176], 'C';"
"mov byte ptr [gs:178], 'O';"
"mov byte ptr [gs:180], 'M';"
"mov byte ptr [gs:182], 'E';"
"mov byte ptr [gs:186], 'T';"
"mov byte ptr [gs:188], 'O';"
"mov byte ptr [gs:192], 'A';"
"mov byte ptr [gs:194], '3';"
"mov byte ptr [gs:196], 'O';"
"mov byte ptr [gs:198], 'S';"
"mov byte ptr [gs:200], '!';");
}
}

简单写一个 makefile(将就着看吧,瞎写的)

1
2
3
4
5
6
7
all: main.o
ld -m elf_i386 main.o -Ttext 0xc0001500 -e main -o kernel.bin
clean:
rm *.o

main.o: main.c
gcc -c -m32 -o main.o main.c -masm=intel

我们的 pack.sh 也应当进行修改,该把内核包进来了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/bin/bash
cd kernel
make clean
make
cd ..
nasm -o ./mbr.bin ./mbr.s
nasm -o ./loader.bin ./loader.s
./bochs/bin/bximage -mode=create -hd=60M -imgmode="flat" -q ./img.img
dd if=./mbr.bin \
of=./img.img \
bs=512 count=1 conv=notrunc
dd if=./loader.bin \
of=./img.img \
bs=512 count=8 seek=2 conv=notrunc
dd if=./kernel/kernel.bin \
of=./img.img \
bs=512 count=200 seek=9 conv=notrunc

此时除去 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

简单打包运行,成功输出字符串,我们终于正式迈向内核阶段

image.png

简单的输出功能

此前我们使用过的在屏幕上打印字符的方式大概有直接读写显存、调用 BIOS 中断等,但毫无疑问的是这样的方式未免过于没有技术含量,因此我们接下来将要编写自己的输出函数直接操控显卡在屏幕上进行输出

I.单个字符输出

在操作之前我们需要保存当前上下文到栈上,同时通过 global 关键字导出 put_char 这个符号

1
2
3
4
5
6
7
8
9
10
11
12
%include "../../../boot.inc"

SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL_RING0

[bits 32]
section .text

global put_char
put_char:
pushad ; save the environment
mov ax, SELECTOR_VIDEO ; set the gs value
mov gs, ax

首先我们需要知道光标是一个独立存在的事物,我们写入显存时不会改变光标位置,因此在文本模式下输出一个字符,我们需要手动完成的步骤如下:

  • 获取光标位置
  • 在光标位置处显存写入字符值
  • 移动光标

输出字符之前我们需要先获得当前光标的位置,这里我们通过与显卡的 CRT Controller Registers 通信以获取当前光标位置,这里对端口等不再说明,需要时查表即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
;------------- Get the location of the cursor -------------
;; get the high 8 bit
mov dx, 0x03d4 ; addr of the input port
mov al, 0x0e ; op code
out dx, al ; write to the port
mov dx, 0x03d5 ; addr of the output port
in al, dx ; read from the port
mov ah, al

;; get the low 8 bit
mov dx, 0x03d4 ; addr of the input port
mov al, 0x0f ; op code
out dx, al ; write to the port
mov dx, 0x03d5 ; addr of the output port
in al, dx

;; save the location in bx
mov bx, ax

C语言默认是 cdecl 调用约定,因此我们这里不考虑清栈的问题,32位下函数调用栈结构这里也不再赘叙;在一开始时我们使用 pushad 保存当前上下文总计 32 字节,加上返回地址 4 字节,因此上层调用传入的参数应当在 esp + 36 的位置,从这里获取到我们传入的参数

前面我们还讲到过,在文本模式的 80*25 的屏幕下,一个字符占用两个字节:第一个字节为字符的值,第二个字节为显示模式,因此光标位置 x 2才是字符在显存中的相对偏移,需要注意的是对于一些特殊字符(换行 \n、退格 \b 等)我们需要进行特殊处理

需要注意的一点是对于 \b 退格的操作:若是光标已经到了显存第一个字符的位置则不需要再向前移动,这里添加一个简单的判断即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
;------------- Print the char -------------
mov ecx, [esp + 36] ; pushad for 8 regs => 8*4, ret addr for 4B
cmp cl, 0xd ; \r
jz .is_carrage_return
cmp cl, 0xa ; \n
jz .is_line_feed

cmp cl, 0x8 ; \b
jz .is_backspace
jmp .put_other

;; for backspace, a blankspace to replace it
.is_backspace:
test bx, bx ; check whether it's already at the begin
jz .at_the_begin
dec bx
.at_the_begin:
shl bx, 1 ; bx *= 2
mov byte [gs:bx], 0x20
inc bx
mov byte [gs:bx], 0x7 ; mode of display
shr bx, 1 ; recover the bx
jmp .set_cursor

;; \r, set cursor back to start of current line
.is_carrage_return:
call .get_line_start_func
jmp .set_cursor

;; usual char
.put_other:
shl bx, 1
mov byte [gs:bx], cl
inc bx
mov byte [gs:bx], 0x7
shr bx, 1
inc bx ; cursor location += 2
cmp bx, 2000 ; if out of 80 * 25, scroll up a new line
jl .set_cursor

;; \n, set cursor to a new line
.is_line_feed:
call .get_line_start_func
add bx, 80
cmp bx, 2000
jl .set_cursor

还需要注意的一点是:当当前屏幕已经被填满了的时候我们需要进行滚屏,这里暂时不考虑缓存的问题,因此我们只需要将 1 ~ 24 行的位置的内容移到 0~23 行即可

考虑缓存的设计则需要对显存及显卡进行额外的操作(设置 Start Address Register 组等),这里笔者选择和原书作者一起偷懒摆烂(笑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
;; mov line 1~24 to 0~23
.roll_screen:
mov ecx, 960 ; 24 * 80 chr => *2 bytes totally => 4 bytes each mov
mov esi, 0xc00b80a0 ; addr of line 1
mov edi, 0xc00b8000 ; addr of line 0, our graphic mem start
rep movsd

;; clear line 24
mov ebx, 3840
mov ecx, 80
.clear_line_24:
mov word [gs:ebx], 0x0720
add ebx, 2 ; ' '
loop .clear_line_24
mov bx, 1920 ; start of line 24 (cursor location)

;; set the cursor
.set_cursor:
;;; high 8 bit
mov dx, 0x03d4
mov al, 0x0e
out dx, al
mov dx, 0x03d5
mov al, bh
out dx, al

;;; low 8 bit
mov dx, 0x03d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
mov al, bl
out dx, al

;------------- Return -------------
popad ; recover the regs
ret

;; function to get value of (cursor back to start of current line)
.get_line_start_func:
xor dx, dx
mov ax, bx
mov si, 80 ; a line in 80 *25 is 80 len
div si ; dx_ax / si => value of cursor / 80
sub bx, dx ; dx for the left, bx -dx to back to the start of a line
ret

我们在一个新的头文件 print.h 中进行函数声明,以便后续链接

1
2
3
4
5
#ifndef __PRINT_KERNEL_H
#define __PRINT_KERNEL_H
#include "stdint.h"
void put_char(uint8_t ascii_chr);
#endif

此时的内核源码架构如下:

1
2
3
4
5
6
7
8
9
10
11
12
$ tree
.
├── lib
│   ├── kernel
│   │   ├── Makefile
│   │   ├── print.h
│   │   └── print.s
│   └── stdint.h
├── main.c
└── Makefile

2 directories, 6 files

stdint.h 中定义了如 uint8_t 等类型,这里就不复制过来了

其中 Makefile 文件如下(随手写的,不一定高效hhh):

lib/kernel/Makefile

1
2
3
4
5
6
all:	print
mv *.o ./../../out/

print: print.s
nasm -f elf -o ./print.o ./print.s

Makefile

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
pre:
mkdir out
make all

all: main.o kernel
ld -m elf_i386 -Ttext 0xc0001500 -e main -o kernel.bin \
main.o out/*.o
clean:
rm -rf ./out/
rm -rf *.o
rm -rf kernel.bin

main.o: main.c
gcc -I lib/kernel/ -c -m32 -o main.o main.c -masm=intel

kernel: ./lib/kernel/
make -C lib/kernel/

简单测试一下我们的 put_char() 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include "print.h"

char str[] = "arttnba3";
int main (void)
{
for(int i = 0; i < 160; i++)
put_char('a');
put_char('\n');
for(int i = 0; i < 40; i++)
put_char('B');
put_char('\r');
for (int i = 0; str[i]; i++)
put_char(str[i]);
put_char('\b');
put_char('\b');
while(1)
{

}
return 0;
}

打印效果还算理想

image.png

II.字符串输出

字符串的输出就简单的多,我们只需要循环调用 put_char() 函数一直到遇到 \0 后返回即可,这里笔者对比过用纯汇编书写的代码与用 C 实现的代码,后者 gcc 会多增添一些额外的代码,出于性能上的考虑这里还是选择纯汇编

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
;------------- [FUNCTION] put_str -------------
global put_str
put_str:
push ebx
push ecx
xor ecx, ecx
mov ebx, [esp + 12] ; addr of str
.str_on:
mov cl, [ebx]
cmp cl, 0 ; judge whether it's \0 or not
jz .str_over
push ecx
call put_char
add esp, 4 ; cdecl clear the stack
inc ebx ; mov to next char
jmp .str_on
.str_over:
pop ecx
pop ebx
ret

简单测试一下,效果还算理想,退格也没有超出显存范围(笑)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "print.h"

char str[] = "arttnba3";
int main (void)
{
for (int i = 0; i < 90 * 25; i++)
put_char('\b');
put_str("Welcome to the A3OS version 0.0.1!\n");
while(1)
{

}
return 0;
}

image.png

0x04.内核中断实现(未完成)

中断interrupt) 即硬件/软件向 CPU 发送的特殊信号,CPU 接收到中断后会停下当前工作转而执行中断处理程序,完成后恢复原工作流程

0x05.内核内存管理(未完成)

0x06.内核线程,踏入多任务的世界(未完成)

0x07.初窥用户进程(未完成)

0x08.来点系统调用(未完成)

0x09.交互,CLI,shell(未完成)

0x0A.文件系统浅入浅出(未完成)