30天自制操作系统

第0天 着手开发之前

电脑的 CPU 如果接到无视 OS 保护的指令或不可能执行的指令时,首先会保存当前状态,中断正在执行的指令,然后调用事先设定的函数,这种机制称为 异常保护机制 .

不能归类到任何异常类型中去的异常事态被称为一般保护异常.

一般的 C 编译器都是用于开发应用程序, 所以根本没有任何操作寄存器的指令.

在 Linux 上, 二进制文件编辑, 可使用 bvighex, 感觉 vim 的二进制编辑有点问题.

第1天 从计算机结构到汇编程序入门

软盘的原理是把二进制的0和1转换为磁极的N极和S极. 所以可以只用 0 和 1 就可以写出映像文件.

不能仅以0和1来表达的内容,都不能以电信号的形式传递给CPU.

将汇编程序编译为映像文件, 如:

1
$ nask helloos.nas helloos.img

DB 指令是”defined byte” 的缩写,也就是往文件里直接写入1个字节的指令. (也可以用 db), 在汇编语言的世界里,只要有了 DB 指令,就可以做出任何数据.

DW 指令, “defined word”, 两字节,16 位.

DD 指令, “defined double-word”, 四字节,32 位.

RESB指令是”reserve byte”的缩写,用于开辟字节内存. 作者编写的 nask 程序不仅会把指定的地址空出来,还会在空出来的地址上自动填入 0x00.

$这里指一个变量,告诉我们这一行现在的字节数.

; 用于注释.
提问
软盘的格式化是在干嘛.

启动区

计算机读写软盘的时候是以512字节为一个单位进行读写,而不是一个字节一个字节地读写.

第一个扇区为启动区,会检查其最后两个字节的内容是否为0x55AA来确定这个扇区的开头是否为启动程序。

计算机首先从最初一个扇区开始读软盘, 然后去检查这个扇区最后两个字节的内容. 如果这最后 2 个字节不是 0x55 AA, 计算机会认为这张盘上没有所需的启动程序.

IPL

Initial Program Loader的缩写。启动程序加载器。用于读入程序.

启动区只有 512 字节,实际的操作系统装不下。因此,几乎所有的操作系统, 都是把加载操作系统本身的程序放在启动区里.

启动

boot,是bootstrap的缩写,有“自力更生完成任务”的含义。

矛盾的操作系统自动启动机制被称为bootstrap方式.

第2天 汇编语言学习与Makefile入门

ORG指令,源于”origin”, 指明把这些机器语言指令装载到内存中的哪个地址. 这里指定的地址是 0x7c00.

MOV AX,0 相当于 AX=0
8个代表性的寄存器的名称:

  • AX accumulator, 累加器
  • CX counter, 计数寄存器
  • DX data, 数据寄存器
  • BX base, 基址寄存器
  • SP stack pointer, 栈指针寄存器
  • BP base pointer, 基址指针寄存器
  • SI source index, 源变址寄存器
  • DI destination index, 目的变址寄存器

都是16位寄存器. 名称中的X表示扩展(extend)的意思,因为以前的寄存器是8位, 现在变成了 16 位,扩展了一倍.

8个8位寄存器:

  • AL accumulator low, 累加寄存器低位
  • CL counter low, 计数寄存器低位
  • DL data low, 数据寄存器低位
  • BL base low, 基质寄存器低位
  • AH accumulator high, 累加寄存器高位
  • CH count high, 计数寄存器低位
  • DH data high, 数据寄存器低位
  • BH base high, 基址寄存器低位

电脑是32位的使用32位寄存器:

EAX, ECX, EDX, EBX, ESP, EXP, ESI, EDI

在16位寄存器的名字前面加上一个E, 字母E来源于”Extend”.

EAX的低16位就是AX但是高16位没有名称. 要单独使用高 16 位的话, 就需要使用移位指令, 把高 16 位移到低 16 位后才能用.

段寄存器,16位寄存器:

  • ES extra segment, 附加段寄存器
  • CS code segment, 代码段寄存器
  • SS stack segment, 栈段寄存器
  • DS data segment, 数据段寄存器
  • FS segment part 2, 没有名称
  • ES segment part 3, 没有名称

在汇编语言中,所有标号都仅仅是单纯的数字. 每个标号对应的数字,是由汇编语言编译器根据ORG指令计算出来的。编译器计算出的”标号的地方对应的内存地址”就是那个标号的值.

1
MOV SI,msg

这里的msg是一个标号, 实际上SI的值为标号代表的地址.

1
MOV SI,[msg]

[]表示内存. 这里就表示取 [msg] 地址的值到 SI 寄存器中.

内存 Memory

内存是CPU的外部存储器.

使用内存的速度慢很多.

严格来说, CPU 和内存之间还有称为 芯片 (chipset) 的控制单元.

从根本上讲, 程序本身也是保存在内存里的, 程序一般都大于 44 字节, 不可能保存在寄存器中, 所以规定程序必须放在内存里.

每一个内存地址都有 8 位内存空间.

BYTE, WORD, DWORD 都是汇编语言保留字. 相邻指的是地址增加方向的相邻.

只有BX, BP, SI, DI 可用于指定内存地址.

1
2
MOV BX, DX
MOV AL, BYTE[BX]

MOX 指令的一个规则, 注意位数要相同. 违反这一规则的话,汇编语言就找不到相对应的机器语言.

如果 SI 中保存的是 987 的话, BYTE [SI] 就会被解释为 BYTE [987], 即指定地址为 987 的内存.

但是 AX, CX, DX, SP 不能用来指定内存地址. 只有 BX, BP, SI, DI 这几个可以指定内存地址. (记忆,base + index) 这是因为 CPU 没有处理这种指令的电路.

CMP指令和JE指令:

1
2
CMP AL,0
JE fin

INT指令是软件中断指令,源自”interrupt”.

BIOS程序,”Basic Input Output System”,其中写入了操作系统开发人员常用的一些程序,位于电脑主板上的ROM单元里。

电脑的设定画面也在BIOS中.

BIOS 是为操作系统开发人员准备的各种函数的集合. 而 INT 就是用来调用这些函数的指令, INT 的后面是个数字, 使用不同的数字可以调用不同的函数.

要显示文字, 就应该看跟显卡有关的函数.

HLT指令,让CPU停止动作,但不彻底,即没断电,使其进入待机状态。只要外部状态变化,就会继续执行程序。其源自英文”halt”. 待机时使用 HLT 是好习惯.

内存的 0 号地址, 也就是最开始的部分, 是 BIOS 程序用来实现各种不同功能的地方. 在内存的0xf0000号附近,还存放这BIOS程序本身.

内存里还有其他不少地方也是不能使用的.

0x00007c00-0x00007dff (恰好是 512 个字节): 启动区内容的装载地址. 程序中ORG指令的值就是这个数字. 这个数字没有什么特别之处,只是最初的规定.

Makefile 入门

使用 # 来注释.

第3天 进入32位模式并导入C语言

调用BIOS中的程序是,其会根据寄存器中的值来作出相应的反应.

从外到内,柱面号增大。

C0-H0-S1 柱面0, 磁头1, 扇区1.

JC 指令, Jump if Carry, 如果进位标志 (carry flag) 是 1 的话,就跳转.

进位标志是一个只能从存储 1 为信息的寄存器, 除此之外, CPU 还有其他几个只有 1 位的寄存器. 这种 1 位寄存及称为标志 flag. (标志之所以叫 flag 是因为它的开和关就像升旗降旗的状态一样)

以下几个寄存器及其表示:

  • CH, 柱面号
  • CL. 扇区号
  • DH, 磁头号
  • DL, 驱动器号

就是指定从哪一个柱面, 哪一个扇区, 哪一个磁头, 哪一个驱动器上读取数据.

在有多个软盘驱动器的时候,用磁盘驱动器号来指定从哪个驱动器的软盘上读取数据.

可以看到最外围的柱面为柱面0. 柱面中有扇区.

与光盘不同, 软盘和磁盘是两面都能记录数据的, 因此有正面和反面两个磁头, 分别是磁头 0 号和磁头 1 号.

1 张软盘有 80 个柱面, 2 个磁头, 18 个扇区, 且一个扇区有 512 字节. 所以, 一张软盘的容量是:
$$
\displaylines{80 \times 2 \times 18 \times 512 = 1474560\ Byte = 1440\ KB}
$$

含有 IPL 的启动区, 位于 C0-H0-S1 (柱面 Cylinder 0, 磁头 Head 0, 扇区 Sector 1)

使用段寄存器时, 以 ES:BX 这种方式来表示地址, 写成 MOV AL, [ES:BX]. 表示 ES x 16 + BX 的内存地址.

0x7c00 ~ 0x7dff 用于启动区, 0x7e00 以后直到 0x9fbff 为止的区域都没有特别的用途.

事实上, 不管我们要指定内存的什么地址, 都必须同时指定段寄存器 , 这是规定, 一般如果省略的话就会把 DS 作为默认的段寄存器. 也就是说:

1
MOV CX, [1234]

其实是:

1
MOV CX, [DS:1234] 

的意思.

试错

1
MOV AH=0x02

表示读入磁盘.

JNC 是 “Jump if Not Carry” 的缩写, 也就是如果进位标志是 0 的话就跳转.

JAE 是 “Jump if above or equal” 的缩写, 意思是大于或等于时跳转.

读到 18 扇区

没有 ADD ES, 0x020 指令.

JBE 指令是 “Jump if Below or Equal” 的缩写, 意思是小于或等于就跳转.

要读下一个扇区, 只需给 CL 加 1, 给 ES 加上 0x20 (相当于加上了 $16 \times 2 \times 16 = 512$, 第一个 16 是 ES 这个段寄存器用在 [ES:] 中时要乘以的倍数). CL 是扇区号, ES 指定读入地址.

写入软盘的时候是一个扇区一个扇区的写入.

读入 10 个柱面

JB 指令, 是 “Jump if Below” 的缩写

EQU 指令 是 “equal” 的缩写. 相当于 C 语言的 #define 命令, 用来声明常数. 如 CYLS EQU 10 意思是 CYLS = 10 (CYLS 这里表示 Cylinders)

着手开发操作系统

一般向一个空软盘保存文件时:

  1. 文件名会写在 0x002600 以后的地方.
  2. 文件的内容会写在 0x004200 以后的地方

从启动区执行操作系统

确认操作系统的执行情况

设定 AH=0x00, 调用显卡 BIOS 的函数, 就可以切换显示模式.

设置成显卡模式的步骤:

32位模式

指CPU的模式,CPU有16位和32位两种模式。16位模式使用AX和CX等寄存器。32位使用EAX和ECX等寄存器。

16位和32位模式产生的机器语言的指令代码不一样。

CPU的自我保护功能在16位下不能使用.

如果用 32 位模式就不能调用 BIOS 功能, 因为 BIOS 是用 16 位机器语言.

VRAM指显卡内存(vedio RAM). 也就是用来显示画面的内存.

开始导入 C 语言

goto指令实际上会被编译成JMP指令.

C语言中不能使用HLT, 也没有相当与DB的指令.

gcc 是以 gas 汇编语言为基础.

“A to B” 中的 “to” 常被写作 “2”.

目标文件是一种特殊的机器语言文件,必须与其他文件链接(link)后才能变成真正可以执行的机器语言.

C语言有一些局限性,不可能只用C语言来编写所有的程序,所以其中有一部分必须用汇编来写,然后链接到C语言写的程序上.

实现 HLT

用汇编写的函数, 之后还要与 bootpack.obj 链接, 所以也需要编译成目标文件. 注意要在函数名的前面加上 _, 否则就不能很好地与 C 语言函数链接, 需要链接的函数名, 都要用 GLOBAL 指令声明.

RET 指令, 相当于 C 语言的 return, 表示 “函数的处理到此结束, 返回吧”.

映像文件
大概是,不是文件本来的状态,而是一种替代形式. 简单来说就是软盘的备份数据.

第4天 C语言与画面显示练习

CPU(英特尔系列):

1
8086 -> 80186 -> 286 -> 386 -> 486 -> Pentium -> Per

到286为止CPU是16位,386以后CPU是32位.

挑战指针

1
MOV [0x1234], 0x56

这种写法会报错, 在向内存写入时, 需要指定内存的大小:

1
MOV BYTE [0x1234], 0x56

这也是变量声明需要声明类型, 也就是获知其大小.

1
2
3
char *p;  //用于BYTE类地址
short *p; //用于WORD类地址
int *p; //用于DWORD类地址

但是,以上变量p都是4字节.

类型转换的 cast 在英文中的原意有压入模具,让材料成为某种特定的形状.

在 C 语言中, 普通数值和表示内存地址的数值被认为是两种不同的东西.

p[i]*(p+i) 意思完全相同.

[i]p*(i+p) 也表示同样的含义.

8 位彩色模式, 是由程序员随意指定 0 ~ 255 的数字所对应的颜色.

1
char a[3];

相当于汇编中的:

1
2
a:
RESB 3

CPU 的管脚不仅与内存相连, 和其他设备也相连.

FLAGS 这个标志寄存器各位的作用:

根据 C 语言的规定, 执行 RET 语句时, EAX 中的值就被看作是函数的返回值.

第5天 结构体, 文字显示与 GDT/IDT 初始化

独立的功能做成独立的函数, 这样的程序读起来要容易一些.

试用结构体

结构体的好处, 是可以将各种东西一起传递:

1
2
3
4
5
6
7
struct TEST {
int a1, a2, a3;
char b1, b2, b3;
};

struct TEST test;
foo(test);

两种使用结构体内变量的方法:

  • . 点标记, 如:

    1
    (*binfo).scrnx = 10;
  • -> 箭头标记

    1
    binfo->scrnx = 10;

    也就是说, 使用箭头形式可以不用将指针解引用.

显示字符

C 语言无法用二进制数记录数据, 只能写成十六进制或八进制.

增加字体

1
extern char hankaku[4096];

像这种在源程序以外准备的数据, 都需要加上 extern 属性. 这样, C 编译器就能知道它是外部数据, 并在编译时做出相应调整.

C 语言中, 字符串都是以 0x00 结尾的.

每一个字符都是通过改变每一个像素点形成.

显示变量值

sprintf() 函数, 其不是按指定格式输出, 只是将输出内容作为字符串写在内存中.

显示鼠标指针

GDT 与 IDT 的初始化

GDT 和 IDT 都是与 CPU 有关的设定.

为了让操作系统能够使用 32 位模式, 需要对 CPU 做各种设定.

分段 Segmentation

汇编语言编程时, 如果不用 ORG 指令明确声明程序要读入的内存地址, 就不能写出正确的程序.

分段用于解决, 每一个程序都运行时的内存重叠问题 (比如都从 0x7c00 加载, 那都使用这一片内存吗)

所谓分段, 几个比方说, 就是按照自己喜欢的方式, 将合计 4GB 的内存分成很多块 (block), 每一块的起始地址都看作 0 来处理. 这很方便, 有了这个功能, 任何程序都先可以写上一句 ORG 0, 像这样分割出来的块, 就被称为段 (segment).

32 位中, 写成 MOV AL,[DS:EBX], 这里 [] 中的地址不再是 DX x 16 + EBX, 而是 DS 所表示的段的起始地址加上 EBX. 即使省略了 DS, 也会自动认为是指定了 DS.

为了表示一个段, 需要有以下信息:

  • 段的大小是多少
  • 段的起始位置在哪里
  • 段的管理属性 (禁止写入, 禁止执行, 系统专用等)

CPU 用 8 个字节 (=64位) 的数据来表示这些信息. 但是, 用于指定段的寄存器只有 16 位.

因为段寄存器是 16 位, 所以本来应该能够处理 0 ~ 65535 范围的数, 但由于 CPU 设计上的原因, 段寄存器的低 3 位不能使用. 因此能够使用的段号只有 13 位, 能够处理的就只有位于 0~8191 的区域了. 也就是说可以定义 8192 个段, 需要 $8192 \times 8 = 65536$ 字节, 这 64KB 的数据就称为 GDT “global (segment) descripter table”, 全局段号记录表.

将数据整齐地排列在内存的某个地方, 然后将内存的起始地址和有效设定个数放在 CPU 内被称作 GDTR (global segment descripter table rergister) 的特殊寄存器中,设定就完成了.

也就是说这 64 KB 是用来存放段的相关信息, 而不是段的内容.

IDT 是 “Interrupt descriptor table” 的缩写, 即 “中断记录表”. 其记录了 0~255 的中断号码与调用函数的对应关系.

各个设备有变化时就产生中断, 中断发生后, CPU 暂时停止正在处理的任务, 并做好接下来能继续处理的准备, 转而执行中断程序. 中断程序执行完以后, 在调用实现设定好的函数, 返回处理中的任务.


30天自制操作系统
http://example.com/2022/07/13/30天自制操作系统/
作者
Jie
发布于
2022年7月13日
许可协议