关于作死试图搓一个32位操作系统不得不说的事

本文最后更新于:2023年12月30日 晚上

0x0:写在一切之前

本来在笔者的计划中,手搓一个简易的操作系统是在暑假就该完成的事情,结果笔者是只懒🐶,而且当时一直在搞fuzz和iot(但好像也没搞出什么东西来,只是单纯的在摸鱼罢了)

然后呢笔者最近想重开kernel,静下心来好好学点内核。但掐指一算好像还有两个星期就到期末周了,学个🐕8

👴当🐓立断不如看点书,参考一下《操作系统真象还原》,基础能打一点是一点。

环境配置

bochs安装

Bochs x86 PC emulator - Browse /bochs at SourceForge.net

找个版本下载并解压

1
2
3
$ ./configure --prefix=/your/path/yo/bochs --enable-debugger --enable-disasm --enable-iodebug --enable-x86-debugger --with-x --with-x11
$ make
$ make install

bochs支持自带的debuggdb(编译的时候--enable-debugger变成--enable-gdb-stub),但gdb这适配做的是一坨shit,建议直接用bochs自带的

配置文件,抄的A3👴的呜呜,把share/doc/bochs/bochsrc-sample.txt里的改一改

1
2
3
4
5
6
7
8
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
#sound: driver=default, waveout=/dev/dsp. wavein=, midiout=

bochs 调试

大部分调试命令和gdb一样,有几个特殊的

1
2
3
4
5
6
7
8
9
10
11
show mode:每次 CPU 变换模式时就提示,模式是指保护模式、实模式,比如从实模式进入到保护模式时会有提示

show int:每次有中断时就提示 p 同时显示三种中断类型,这 三 种中断类型包括“ softint ”、“ extint ”和“ iret ”。可以单独显示某类中断,如执行 show softint 只显示软件主动触发的中断, show extint 则只显示来自外部设备的中断, show iret 只显示 iretd 指令有关的信息

reg:常用寄存器的值

info gdt/ldt/CPU/idt/ivt:全局符号描述表/局部符号描述表/所有CPU寄存器的值/显示中断向量表IDT/显示中断向量表IVT

sreg:查看段寄存器的值
dreg:查看调试寄存器的值
creg:查看控制寄存器的值

pack & start

pack.sh

1
2
3
4
!/bin/bash
nasm -o ./mbr.bin ./mbr.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

start.sh

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

0x1:开始调教MBR(bushi)🥵

about BIOS

众所周知,当你按下神圣的开机键,CPU通上电之后以实模式启动,第一个运行的程序便是BIOSBIOSMBR载入,将控制权交给了MBR

于是便产生了玄学三问:BIOS是什么?他从哪来?为什么他先执行?

首先先看张《操作系统真象还原》里的图,关于实模式下的内存分布

img

BIOSBasic Input & Output System即基本输入输出系统,主要工作便是检测、初始化硬件,还建立了伟大的中断向量表

但是通过图可知,BIOS0xf0000-0xfffff共计64KB大小,不可能兼顾到所有的硬件设备,而且此时运行在实模式下,也没有这个必要,所以只要挑一些重要的,能保证计算机运行的基本硬件IO操作就行了。此为BIOS名称由来

至于BIOS在哪里,这玩意一直在主板上的ROM里,通电的时候ROM就被映射在0xf0000-0xfffff,只要访问了这里就算访问了BIOS,这个映射完全是由硬件完成的。

But BIOS也算是个程序,所以也是有入口的,此处便是0xffff0。电脑开机的一瞬间,CPU的cs:ip寄存器被赋值为0xf000:0xfff0。然后开机时处于实模式,段基址cs要左移四位,所以此时cs:ip等效地址为0xffff0,为BIOS入口。

但是0xffff0-0xfffff这短短的16字节显然并不能干什么,通过调试发现,0xffff0处的指令实为跳转指令,跳到0xfe05b处。这才是BIOS真正开始的地方

img

当初始化硬件和建立中断向量表后,BIOS将自己最后的波纹用于检查0盘0道1扇区(实则是0盘0道0扇区,CHS表示方式中1扇区就是第一个扇区)最后两个字节是否为magic number 0x550xaa(你问👴为什么不是114514这种恶臭的杂修~,👴知道个der,反正书里写了)。如果是,就会把这个扇区的data加载到0x7c00,随后跳转执行。此处执行的,便是MBR。如果你问完magic number又要问👴为什么是0x7c00,👴只能说是IBM生产的PC5150的ROM BIOS 中的INT19H的历史遗留问题,当时的BIOS32KB的大小来设计,32KB就是0x8000MBR的大小是512字节,同时作为一个程序需要使用栈,姑且算1KB好了。1KB是多少,0x400,所以0x8000 - 0x4000x7c00

MBRの初次调教

写了这么多终于到MBR了,妈妈生的

MBR(Master Boot Recode)——主引导记录,是我们能最早支配的程序,因为BIOS这byd是写死的。

需要注意的是,MBR的大小必须是512字节,因为只有这样才能保证0道0盘1扇区的最后两个字节为magic

先写一个在屏幕上输出字符的MBR

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
SECTION MBR vstart=0x7c00    ;因为MBR被加载到0x7c00,所以将整个code作为section,并将vstart赋值0x7c00,这样计算绝对地址时就会以0x7c00为base
mov ax,cs ;段寄存器不能用立即数,只能通过寄存器或者内存进行赋值
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00

mov ax,0x600 ;上卷全部行,清屏
mov bx,0x700
mov cx,0 ;左上角(0,0)
mov dx,0x184f ;右下角(80,25)
int 0x10 ;BIOS提供的0x10中断

mov ah,3 ;获取光标位置
mov bh,0
int 0x10

mov ax,message ;es:bp是字符串地址,因为前面es已经被初始化,所以cs=es
mov bp,ax

mov cx,0x20 ;length
mov ax,0x1301 ;ah:01 显示字符串,光标跟随移动
mov bx,0x2 ;bl:02 黑底绿字
int 0x10

jmp $ ;$表示当前行,程序在这边卡住

message db "The First MBR of Korey0sh1"
times 510-($-$$) db 0 ;$$表示section的位置,填充510的length,补上magic number
db 0x55,0xaa

运行后可以康康

img

输出的时候写了个循环,优化一下,不用算length了

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
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00

mov ax,0x600
mov bx,0x700
mov cx,0
mov dx,0x184f
int 0x10

mov ax,message
mov bp,ax

.korey_print:

mov ah,3
mov bh,0
int 0x10

mov cx,1
mov ax,0x1301
mov bx,0x2
int 0x10

inc bp
mov ax,[bp]
test ax,ax
jnz .korey_print

jmp $

message db "The First MBR of Korey0sh1"
times 510-($-$$) db 0
db 0x55,0xaa

变成显存形状的MBR

前面的MBR,使用BIOS提供的0x10中断完成了字符输出,说实话还是依赖于中断向量表。

But,中断向量表只存在于实模式,以后还是要进入保护模式的捏,但保护模式就莫得中断向量表了,也就无法使用int 0x10来完成输出了。

那该怎么办捏

答案就是直接对显卡上手。

显卡显卡,就是用来图像输出的卡。而显卡中的显存,是显卡提供给我们的接口。关于实模式的内存分布中提到0xA0000-0xC7FFF是显存的区域,事实上,当你直接在显存上写东西。显卡便会在屏幕上输出内容

img

下面是在显存文本模式区域(一个字节表示输出字符,一个字符表示其属性)输出“korey"

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
SECTION MBR vstart=0x7c00
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

mov ax,0x600
mov bx,0x700
mov cx,0
mov dx,0x184f
int 0x10

mov byte [gs:0x00],'k'
mov byte [gs:0x01],0xa4 ;A:背景色绿色 4:前景色红色

mov byte [gs:0x02],'o'
mov byte [gs:0x03],0xa4

mov byte [gs:0x04],'r'
mov byte [gs:0x05],0xa4

mov byte [gs:0x06],'e'
mov byte [gs:0x07],0xa4

mov byte [gs:0x08],'y'
mov byte [gs:0x09],0xa4

jmp $

times 510-($-$$) db 0
db 0x55,0xaa

img

MBR:和我交往吧,磁盘仙贝!😋

BIOS只能把0柱面0磁道1扇区512字节大小的MBR载入内存,那么问题来了,MBR执行完了,然后干什么嘞。

其实我们都知道,MBR也不过是一个中继程序而已,然既然MBR是从磁盘导入的,那我们能不能用MBR从磁盘导入别的data呢?

这就涉及到磁盘通信问题惹

先贴一张表,出自《操作系统真象还原》

img

先来看看一些常用的端口

  • data 0x1f0/0x170:负责管理数据,唯一一个16位的
  • error/feature 0x1f1/0x171:读取硬盘失败时记录失败信息/写入硬盘时存储命令需要的额外参数
  • Sector count 0x1f2/0x172:指定待写入或待读取的扇区
  • device 0x1f6/0x176:杂项寄存器,什么功能都带点。图来自《操作系统真象还原》
  • img
  • status (读硬盘时)0x17f/0x177:状态寄存器。图来自《操作系统真象还原》
  • img
  • command(写硬盘时)0x17f/0x177:储存让硬盘执行的命令。常用的就三个
  • identify: 0xEC 硬盘识别
    read sector: 0x20 读取扇区
    write sector: 0x30 写入扇区

在物理层面上,硬盘内寻址是通过”柱面.磁头.扇区”来定位的Cylinder Head Sector,简称为 CHS ,这对读写的磁头很形象,但对于可爱的MBR小姐就太抽象了。于是出现了LBA,逻辑块地址(logic block address),不考虑扇区所在的物理结构。

LBA又分LBA28(最大支持128G)和LBA48(最大支持128PB),我们这边就讨论LBA28

所以三个8位的寄存器存放LBA28的低24位,高4位存放在device寄存器的低4位

x86架构中,与IO设备通信时一般会用in/out这两个指令,从端口读出数据或者向端口写入数据。并且默认dx寄存器存储端口号。

1
2
mov dx,0x1f0
in dx,al ;这样就是向0x1f0端口写入al的值了

至于如何从磁盘读入数据写入内存的大致步骤笔者就不赘述了,可以参考书籍

下面就是代码罢了

boot.inc

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
LOADER_BASE_ADDR equ 0x900            ;0x500-0x7bff都是可用区域
LOADER_START_SECTOR equ 0x2 ;扇区2
%include "boot.inc"
SECTION MBR vstart=0x7c00
mov ax,cs ;initial
mov ds,ax
mov es,ax
mov ss,ax
mov fs,ax
mov sp,0x7c00
mov ax,0xb800
mov gs,ax

mov ax,0x600 ;通过上卷清空屏幕
mov bx,0x700
mov cx,0
mov dx,0x184f
int 0x10

mov eax,LOADER_START_SECTOR ;eax = 2
mov bx,LOADER_BASE_ADDR ;bx = 0x900
mov cx,1 ;待读取扇区数 = 1
call rd_disk_m_16

jmp LOADER_BASE_ADDR

rd_disk_m_16:
mov esi,eax ;保存参数
mov di,cx

mov dx,0x1f2
mov al,cl
out dx,al ;传个sector count 待读取扇区数为1

mov eax,esi

mov dx,0x1f3
out dx,al ;LAB low

mov cl,8
shr eax,cl
mov dx,0x1f4
out dx,al ;LAB mid

shr eax,cl
mov dx,0x1f5
out dx,al ;LAB high

shr eax,cl
and al,0x0f
or al,0xe0
mov dx,0x1f6
out dx,al ;lba 第 24-27位。设置 7-4 位为 1110 ,表示 lba 模式

mov dx,0x1f7
mov al,0x20
out dx,al ;写入读取命令

.not_ready: ;还是0x1f7端口
nop
in al,dx
and al,0x88 ;第 4 位为 1 表示硬盘控制器已准备好数据传输 第 7 位为 1 表示硬盘忙
cmp al,0x08
jnz .not_ready ;直到准备好为止

mov ax,di
mov dx,256
mul dx
mov cx,ax ;di 为要读取的扇区数,一个扇区有512字节每次读入一个字116共需 di*512/2次,所以 di*
mov dx,0x1f0

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

times 510-($-$$) db 0
db 0x55,0xaa

当然此时扇区2里面一点数据都没有,我们可以随便写一点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
%include "boot.inc"
SECTION LOADER vstart=LOADER_BASE_ADDR
mov ax,cs
mov ds,ax
mov es,ax
mov ss,ax
mov sp,LOADER_BASE_ADDR
mov ax,0xb800
mov gs,ax

mov ax,0x600
mov bx,0x700
mov cx,0
mov dx,0x184f
int 0x10

mov byte [gs:0x00],'L'
mov byte [gs:0x01],0xa4

mov byte [gs:0x02],'O'
mov byte [gs:0x03],0xa4

mov byte [gs:0x04],'A'
mov byte [gs:0x05],0xa4

mov byte [gs:0x06],'D'
mov byte [gs:0x07],0xa4

mov byte [gs:0x08],'E'
mov byte [gs:0x09],0xa4

mov byte [gs:0x0a],'R'
mov byte [gs:0x0b],0xa4

jmp $

编译后dd进磁盘的扇区2,所以pack.h也得改一改

1
2
3
4
5
6
7
8
9
10
#!/bin/bash
nasm -o ./mbr.bin ./mbr.S
nasm -o ./loader.bin ./load.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=1 seek=2 conv=notrunc

然后就可以愉快的跳到0x900执行loader了

img

但是这个loader并没有任何意义,因为他是在实模式下运行的,只是为了测试MBR的功能性。

0x2:保护模式

段描述符(Segment Descriptor)

随着时间的推移,8086进化成了80386,地址总线也从16位变成32位,but,段寄存器却还是16位从未变过

这是因为关于段的信息被存放在了内存中一块叫做段描述符的地方

img

段描述符长8字节,64位,里面的各个位的具体含义如下

  • 0-15:段界限低16位
  • 16-31:段基址低16位
  • 32-39:段基址中8位
  • 40-43:type 指定本描述符的类型,用于表示内存段和门的子类型。图出自《操作系统真象还原》
  • img
  • 44:S 0/1 —> 系统段/数据段
  • 45-46:DPL ,即Descriptor Privilege Level,描述符特权级,分0、1、2、3,数字越小,特权级越大
  • 47:P Present,即段是否存在 0/1 —> 不存在/存在
  • 48-51:段界限高4位
  • 52:AVL,即AVaiLable,可用的
  • 53:L,是否为64位代码段 0/1 —> 否/是
  • 54:D/B,表示有效地址(段内偏移地址)和操作数大小 0/1 —> 16位/32位
  • 55:G,粒度 0/1 —> 1B/ 4KB
  • 56-63:段基址高8位

你要问👴为什么段界限和段基址会被拆成这个JB样子,👴只能说兼容、兼容、还是牛魔的兼容

全局描述符表GDT(Global Descriptor Table)

一个段描述符只能描述一段内存,but内存被分成许多段是无法避免的,所以这时候全局描述符表就出现了,一个内存中专门用来存放段描述符的地方。

程序都可以在GDT中定义自己的段描述符,CPU通过一个专门指向GDT的寄存器GDTR和一个“下标”,也就是selector选择子,在GDT中精准的找到自己需要的段描述符

GDTR

48位寄存器,专门用来存储GDT的内存地址和大小,下图出自《操作系统真象还原》

img

前16位是界限值,2^16 = 65536,所以前16位的max value = 65536 -1 = 0xffff

and一个段描述符长8字节,所以一个GDT最多存储8192个段描述符

lgdt:对GDTR特攻指令,具体操作为

1
lgdt 48位内存数据

selector

选择子是16位,下图出自《操作系统真象还原》

img

0-1位:RPL,请求特权级,00为0,11为3

2位:TI,即Table Indicator,0为GDT中索引描述符,1为LDT(有全局当然也会有局部,Local Descriptor Table,局部描述符表)中索引描述表

高13位是索引值,2^13 = 8192,即最多能有8192个段描述符,和上文相符

PS:GDT中第0个索引值不可用,因为selector未初始化时为0

至于LDT,和GDT类似,就不再赘述

打开A20地址线

先来说说8086的地址回绕

8086地址回绕

8086的地址总线是20位的,所以有A0-A19这20根地址线

在实模式下,采用段基址*16+偏移的寻址方式,不难发现,当0xffff:0xffff时,此时的逻辑地址是0x10ffef,但是,20位地址总线的最大值是0xfffff,在逻辑上这是正确的,但在物理内存中没有相应的地址。为了避免这个bug,所以8086采取的策略便是将逻辑地址对0x100000取模,这就是8086的地址回绕

A20GATE

到了80286这个拥有24位地址总线的CPU,但是intel为了兼容考虑,在实模式下,仍然只开启A0-A19这低20位地址线,并采用8086地址回绕。

所以我们需要突破A20地址线,这就是说的打开A20GATE

其实打开A20很简单,只要把0x92端口的第1位(最低位是第0位)置1,代码如下

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

CR0寄存器的PE位

先了解一下CR0-CR3寄存器

控制寄存器

控制寄存器用于控制和确定处理器的操作模式以及当前执行任务的特性,在80386中有4个,分别为CR0、CR1、CR2、CR3,其中CR1被保留,为后续开发做准备。

CR0包括指示处理器工作方式的控制位,包含启用和禁止分页管理机制的控制位,包含控制浮点协处理器操作的控制位。CR2及CR3由分页管理机制使用。CR0中的位5—位30及CR3中的位0至位11是保留位,这些位不能是随意值,必须为0。

详细介绍一下CR0寄存器

CR0

下两图出自《操作系统真象还原》

img

img

第0位:PE,即Protection Enable,为0时是实模式,为1时是保护模式,所以PE便是我们的目标,将他置1也很简单

1
2
3
mov eax,cr0
or eax,0x00000001
mov cr0,eax

顺带提一嘴,只有CR0的最高位PG为1,开启分页时,CR3才会被启用

about CPU

流水线

虽然执行单元EU是CPU执行指令的唯一部件,但是CPU 的指令执行过程分为取指令、译码、执行三个步骤。每个步骤都是独立执行的, CPU 可以一边执行指令,一边取指令,一边译码。CPU是只能一次处理一个指令,但是也妹说不能干的别的事啊😋。这样就使效率得到极大的提升。

jmp清空

CPU 是按照程序中指令顺序来填充流水线的,大多是情况下当前指令和下一条指令在空间上是挨着的。但如果当前执行的指令是jmp ,下一条指令已经被送上流水线译码了,第三条指令已经被送上流水线取指。but因为cs:ip已经跳到不知道哪去了,所以 CPU 在遇到无条件转移指令 jmp 时,会清空流水线。

分支预测

随着流水线而来的一个问题便是,如果CPU遇到一个条件跳转语句,假设两条路分别时A、B,那么CPU在还未执行判断语句的时候,是如何选择A或B进入流水线的?

最简单的方法是 2 位预测法。用 2 位 bit 的计数器来记录跳转状态,每跳转一次加 1,如果未跳转就减 1。当遇到跳转指令时,如果计数器的值大于 1 则跳转,如果小于等于 1 则不跳。这基于的原理是当一件事情发生时,有很大概率下一次还会发生。

同时,intel架构的CPU中存在分支目标缓冲器(Branch Target Buffer,BTB),会将分支指令的地址和跳转信息存放在其中,CPU在下一次遇到分支指令时,会在BTB寻找相同地址的指令,参照其中的统计信息,选择将哪一个分支载入流水线。如果未找到相同地址,会使用Static Predictor,即静态预测,这是基于大量代码的共同特征总结的。比如循环结构体一般都在结束跳转指令的上方。

当然加载错误的分支指令也没关系,只要在CPU执行前制止就行了,虽然清空流水线装载正确分支的花销挺大的😓

乱序执行

就是 CPU 中运行的指令并不按照代码中的顺序执行,而是按照一定的策略打乱顺序执行,也许后面的指令先执行,当然,得保证指令之间不具备相关性 。

比如下面的例子,第一个指令需要去内存中寻找值,而add ebx只需要简单的加法操作就行了,所以CPU就会在指令1访问内存的等待中执行指令2.

1
2
mov rax, [0xfff00]
add ebx, 1

缓存

缓存是 20 世纪最大的发明,其原理是用一些存取速度较快的存储设备作为数据缓冲区,避免频繁访问速度较慢的低速存储设备,归根结底的原因是低速存储设备是整个系统的瓶颈,缓存用来缓解“瓶颈设备”的压力(摘自《操作系统真象还原》)。都知道木桶原理,和硬盘相比,内存DRAM的速度已经够快了,却还是连CPU的尾气都吃不到。所以,内存8行,就出现了三级缓存L1、L2、L3(SRAM,静态随机访问储存器,期待能在amd的三缓里在装系统的那天😚)。寄存器和SRAM在速度上是同一级别的东西,都是用相同的存储电路实现的,用的都是触发器,速度快的飞起 。

比如当循环执行一段code时,短时间内这块内存将被高频率访问,如果将这块code放到三缓里,就能极大的提高程序运行效率。

PE模式下的内存保护措施

都叫保护模式了保护措施能少🐎

段描述符&选择子

对使用selector的检查:check索引值,check是否使用了gdt中索引【0】的段描述表

针对段描述符中的type字段,有下列几个原则

  • 只有具备可执行属性的段才能加载到CS寄存器中
  • 只具有可执行属性的段不允许加载到除CS外的段寄存器中
  • 只有具备可写属性的段才能加载到SS寄存器中
  • 至少具备可读属性的段才能加载到DS、ES、FS、GS段寄存器中

check type后,还会chekc P位确认内存段是否存在,访问过相应段后,会将其段描述符中的A位置1(这算什么,标记?bushi)

Data & System Segment

段界限check

段界限:
$$
(段界限+1)*粒度-1
$$
这要注意这个就差不多了

ok,前置知识基本讲完了,可以进入保护模式了!!ヾ(≧▽≦*)o

Let’s go!

boot.inc

多了好多配置信息

nasm还挺人性化的,可以在数字中加”_”使数位更清楚,且不影响值的表示

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
;-----loader & kernel msg-----
LOADER_BASE_ADDR equ 0x900
LOADER_START_SECTOR equ 0x2
;-----the value of GDT-----
DESC_G_4K equ 1000_0000_00000000_00000000b ;G: 4kb
DESC_D_32 equ 100_0000_00000000_00000000b ;D: 32位
DESC_L equ 00_0000_00000000_00000000b ;L: 32位代码段
DESC_AVL equ 0_0000_00000000_00000000b ;无意义
DESC_LIMIT_CODE2 equ 1111_00000000_00000000b ;平坦模式,就是0xf
DESC_LIMIT_DATA2 equ DESC_LIMIT_CODE2
DESC_LIMIT_VIDEO2 equ 0000_00000000_00000000b ;这边设置video是为了表示显存(0xb8000),所以limit设置0
DESC_P equ 10000000_00000000b ;P: 存在
DESC_DPL_0 equ 0000000_00000000b
DESC_DPL_1 equ 0100000_00000000b
DESC_DPL_2 equ 1000000_00000000b
DESC_DPL_3 equ 1100000_00000000b
DESC_S_CODE equ 10000_00000000b ;S: 非系统段,代码段
DESC_S_DATA equ DESC_S_CODE ;S: 非系统段,数据段
DESC_S_sys equ 00000_00000000b ;S: 系统段
DESC_TYPE_CODE equ 1000_00000000b ;只执行代码段
DESC_TYPE_DATA equ 0010_00000000b ;只读,向下扩展的数据段
;-----code gdt高位4字节-----
DESC_CODE_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_CODE2 + DESC_P + DESC_DPL_0 + DESC_S_CODE + DESC_TYPE_CODE + 0x00
;-----data gdt高四位字节-----
DESC_DATA_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_DATA2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x00
;-----video gdt高四位字节-----
DESC_VIDEO_HIGH4 equ (0x00 << 24) + DESC_G_4K + DESC_D_32 + DESC_L + DESC_AVL + DESC_LIMIT_VIDEO2 + DESC_P + DESC_DPL_0 + DESC_S_DATA + DESC_TYPE_DATA + 0x0b
;-----the value of selector-----
RPL0 equ 00b
RPL1 equ 01b
RPL2 equ 10b
RPL3 equ 11b
TI_GDT equ 000b
TI_LDT equ 100b

MBR

mbr和前面的还是一样的。只是load.s经过编译后得到的load.bin的size大于512,所以读取扇区的数量改变,这边直接改成4了

1
2
3
4
mov eax,LOADER_START_SECTOR
mov bx,LOADER_BASE_ADDR
mov cx,4 ;sector count
call rd_disk_m_16

pack的时候脚本也有变化

count改为4

1
2
3
dd if=./loader.bin \
of=./img.img \
bs=512 count=4 seek=2 conv=notrunc

Load.S

load.s要改的就比较多了

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
66
67
68
69
%include "boot.inc"
SECTION LOADER vstart=LOADER_BASE_ADDR
LOADER_STACK_TOP equ LOADER_BASE_ADDR

jmp loader_start

GDT_BASE: ;gdt[0]为空
dd 0x00000000
dd 0x00000000

CODE_DESC: ;gdt[1]
dd 0x0000ffff
dd DESC_CODE_HIGH4

DATA_STACK_DESC: ;gdt[2]
dd 0x0000ffff
dd DESC_DATA_HIGH4

VIDEO_DESC: ;gdt[3]
dd 0x80000007 ;(0xbffff-0xb8000)/4k = 7
dd DESC_VIDEO_HIGH4

GDT_SIZE equ $ - GDT_BASE ; 0x8*4
GDT_LIMIT equ GDT_SIZE - 1 ; 0x20 - 1
times 60 dq 0

SELECTOR_CODE equ (0x0001<<3) + TI_GDT + RPL0
SELECTOR_DATA equ (0x0002<<3) + TI_GDT + RPL0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0

gdt_ptr dw GDT_LIMIT
dd GDT_BASE

loadermsg db 'korey is ready'

loader_start:
mov sp, LOADER_BASE_ADDR ;print "korey is ready"
mov bp, loadermsg
mov cx, 14
mov ax, 0x1301
mov bx, 0x001f
mov dx, 0x1800
int 0x10

in al, 0x92 ;open A20 gate
or al, 0000_0010b
out 0x92, al

lgdt [gdt_ptr] ;load gdt

mov eax, cr0 ;real mode to protection mode
or eax, 0x00000001
mov cr0, eax

jmp dword SELECTOR_CODE:p_mode_start ;刷新流水线

[bits 32]
p_mode_start:
mov ax,SELECTOR_DATA
mov ds,ax
mov es,ax
mov ss,ax
mov esp,LOADER_STACK_TOP
mov ax,SELECTOR_VIDEO
mov gs,ax

mov byte [gs:160], 'P'

jmp $

可以看到jmp dword SELECTOR_CODE:p_mode_start这句代码感觉有点脱裤子放屁,毕竟程序流只要顺序执行也能滑到p_mode_start ,那为什么用jmp呢?因为CPU采用流水线作业(上面提到过),会将几条指令放在一起重叠执行(感觉mips架构这个特点就很显著),所以p_mode_start是32位的,和16位的一起执行直接能把CPU的CPU干烧了,这是其一。其二是虽然进入了32位保护模式,ds,cs,ss这些段寄存器里还是16位的段基址。其他位默认0,导致D位=0,也即是进入32位模式了段寄存器还是被认为是16位,那就麻烦大了。并且mov cs, xxx这类指令是被禁止的,只有用远调用指令call,远转移指令jmp,远返回指令retf可以间接改变CS的值。所以用jmp清空流水线并刷新CS寄存器。

展示个结果

img

0x3:PE to Kernel !!

获取内存容量

BIOS的0x15中断号中,提供了3个子功能来获取内存容量

  • EAX = 0xE820,遍历所有内存
  • EAX = 0xE801,检测低15MB和16MB-4GB的内存,最大支持4GB
  • AH = 0x88,最多检测64MB,超过64MB也返回64MB

这三种功能返回的信息详细程度一词5递减,但是操作复杂程度反之

0xE820

此功能每次会返回一个不同属性的内存布局信息,因此需要不停迭代来获取全部内容

因为返回的信息较为丰富,寄存器无法存放,所以需要结构体来存放返回值,此结构体为地址范围描述符(Address Range Descripter Structure, ARDS),格式如下图。此表来自《操作系统真象还原》

img

type字段的意义如下。此表来自《操作系统真象还原》

img

同时,0xE820在中断前,还需要几个寄存器布置参数,返回后的值也储存在几个寄存器中。此表来自《操作系统真象还原》

img

动手写一下

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
;前半部分和load.S一样
times 60 dq 0

total_mem_bytes dd 0
gdt_ptr dw GDT_LIMIT
dd GDT_BASE

ards_buf times 244 db 0
ards_nr dw 0

loader_start:
xor ebx,ebx
mov esi, ards_buf
mov es,esi
xor esi,esi
xor edi,edi
.e820:
mov eax,0xe820
mov ecx,20
mov edx,0x534d4150
int 0x15
add edi,20
cmp ebx, 0
jnz .e820

jmp $

看看ARDS

img

0xE801

只需eax寄存器=0xe801即可执行int 0x15调用

返回时,eax = ecx,粒度为1kB,只显示15MB及以下的内存容量;ebx = edx,粒度为64KB,显示16MB-4GB的内存

but我们获得的内存总量总是比实际大小小1MB,这是为了兼容老ISA设备,最后输出的时候加上去就行了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
loader_start:
.e801:
mov ax, 0xe801
int 0x15
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_bytes],esi

jmp $

img

1
2
3
4
5
6
#megs: 256
#megs: 128
megs: 64
#megs: 64
#megs: 16
#megs: 8

和配置文件相符

0x88

只需ah = 0x88,返回值也只有一个,存储在eax中,最后再加上一个1MB就行

1
2
3
4
5
6
7
8
9
10
11
loader_start:
.88:
mov ah, 0x88
int 0x15
mov ecx,0x400
mul ecx
add eax,0x100000

mov [total_mem_bytes],eax

jmp $

内存分页

内存分页解除了线性内存与物理内存一一对应的关系,通过映射关系,将线性地址映射到任意物理地址

内存分页由CPU提供硬件(页部件)支持,通过建立页表,以及页表查询来实现映射关系,这也是由CPU完成的,毕竟在CPU看来,一切都是慢速设备,不如自己来。当然还得感谢CPU设计师与OS设计师的合作。

至于什么是映射关系,这个应该不用笔者赘述了

一级页表

32位架构的CPU的地址总线的max是4GB

CPU以4kB为粒度,将内存分为一页页,此为一级页表

此时我们需要一个地方来存储每页内存的信息,这个地方就是页表(Page Table)。页表中的项称为页表项( Page Table Entry,PTE ),其大小是 4 字节,页表项的作用是存储内存物理地址。当访问一个线性地址时,实际上就是在访问页表项中所记录的物理内存地址。

下图为一级页表示意图。来自《操作系统真象还原》

img

页表项0-11位为页内寻址

页表项12-31位为该页表项在页表中的index

页表的地址被存放在CR3寄存器中,可以通过CR3寄存器中页表项的物理地址(此时还未开启分页)+ index*4找到目标页表项对应的物理地址,最后加上低12位的偏移,就能访问对应的物理地址

But,想一下,4GB总内存,以4kB的粒度分,光页表项就有0x100000之多,存储这些页表项就要花费4MB大小的内存。并且每个进程都有独立的页表,光是这些内存就是一笔很大的开销,那怎么办捏?

一级页表不行,👴再套一层不行🐎

所以二级页表他来了

二级页表

先看一下二级页表示意图。下图来自《操作系统真象还原》

img

这次CPU学聪明了,先创建一个4KB大小的页目录表,其中存放着1024个页目录项(Page Directory Entry, PDE)。何为页目录项?就是页表的物理地址,页目录项大小同页表项一样,都用来描述一个物理页的物理地址,其大小都是 4 字节。

页目录表中共 1024 个页表,也就是有 1024 个页目录项 。一个页目录项中记录一个页表物理页地址,物理页地址是指页的物理地址,在页目录项及页表项中记录的都是页的物理地址。

中间层果然是万能的(bushi)

二级页表虽然原理与一级页表相同,但是寻址方式发生了一点小变化

对于一个32位虚拟地址:

  • 0-11位:页内偏移
  • 12-21位:PTE索引
  • 22-31位:PDE索引

计算公式和一级页表类似

反正这些公式的计算是CPU帮👴干的

突然,受尽压榨的CPU发现,这些页表项和页目录项都是以4KB为粒度,也就是说最后12位都是0,反正固定不变的东西,存放点信息不是美滋滋,于是,PTEPDE就被改造调教开发成了下面的模样🥵。下图来自《操作系统真象还原》

img

  • P:present 1:存在内存中/0:不存在内存中
  • RW:read/wirte 1:可读可写/0:可读不可写
  • US:user/super 1:非特权/0:特权
  • PWT:Page-level Write-Through,页级通写位,置1表示此内存页是高速缓存,此处置0即可
  • PCD:Page-level Cache Disable,页级高速缓存禁止位,置1表示此内存页启用高速缓存,此处置0即可
  • A:Accessed,访问位 1:被CPU访问过,用来统计访问频率
  • D:Dirty,脏页,CPU对一个内存页执行写操作后,会对此内存页对应的页表项D位置1
  • PAT:Page Attribute Table,太复杂了,👴不写了
  • G:Global,全局位,与下文的TLB有关
  • AVL:Available,可用位,但是可不可用跟CPU👴有什么关系

开启页表

boot.inc添加

1
2
3
4
5
6
7
8
;-----页表物理地址-----
PAGE_DIR_TABLE_POS equ 0x100000
;-----页表属性-----
PG_P equ 1b
PG_RW_R equ 00b
PG_RW_W equ 10b
PG_US_S equ 000b
PG_US_U equ 100b

load.S

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
66
;-----前面就是进入PE模式-----
call setup_page

sgdt [gdt_ptr] ;保存当前gdt的值
mov ebx, [gdt_ptr + 2] ;使gdt_base和selector3的base加0xc0000000
or dword [ebx + 0x18 + 4], 0xc0000000
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000

mov eax, PAGE_DIR_TABLE_POS ;将页目录表物理地址存进CR3
mov cr3, eax

mov eax, cr0 ;开启分页
or eax, 0x80000000
mov cr0, eax

lgdt [gdt_ptr]
mov byte [gs:160], 'V'

jmp $


setup_page:
[bits 32]
mov ecx,0x1000
mov esi,0
.clear_page_dir: ;将页目录表清零
mov byte[PAGE_DIR_TABLE_POS + esi],0
inc esi
loop .clear_page_dir

.create_pde:
mov eax, PAGE_DIR_TABLE_POS ;第一个页表创建在物理地址0x101000
add eax, 0x1000
mov ebx, eax

or eax, PG_US_U | PG_RW_W | PG_P ;用户权限、可写、存在
mov [PAGE_DIR_TABLE_POS + 0x0], eax ;将index=0 & 768的页目录项赋值为0x101007,c00以上的用于内核空间
mov [PAGE_DIR_TABLE_POS+0xc00], eax
sub eax, 0x1000
mov [PAGE_DIR_TABLE_POS+4092],eax ;将页目录表的物理地址作为最后一个页目录项

mov ecx, 256 ;将0x100000以下的内存,作为第一个页表中的PTE
mov esi, 0
mov edx, PG_US_U | PG_RW_W | PG_P

.create_pte:
mov [ebx+esi*4], edx
add edx, 0x1000
inc esi
loop .create_pte

mov eax, PAGE_DIR_TABLE_POS ;将1MB-1GB的内存全部映射到高处,使得内核和操作系统共享同一片物理地址
add eax, 0x2000
or eax, PG_US_U | PG_RW_W | PG_P
mov ebx, PAGE_DIR_TABLE_POS
mov ecx, 254
mov esi, 769

.create_kernel_pde:
mov [ebx+esi*4], eax
inc esi
add eax, 0x1000
loop .create_kernel_pde

ret

虚拟地址访问页表

info tab可以查看页表映射

img

这边解释一下为什么会有这种奇怪的映射,因为页目录表的最后一项页目录项是页表的物理地址,对应虚拟地址的高10位为0x3ff

此时页目录项为0x100000,最后一个页表项还是原来的页目录项,就是这玩意被用了两次,第一次当页目录项,第二次当页表项,无限套娃。其余两个也是这个思路,所以访问0xfffffXXX的虚拟地址,就能访问页表

1
0xfffff000-0xffffffff -> 0x000000100000-0x000000100fff

TLB

在二级页表中,从虚拟地址转换到物理地址需要访问三次内存,那三级、四级页表怎么办?对于CPU来说,访问内存无疑是一种降低效率的行为,所以TLB(Translation Lookaside Buffer,快表)就出现了。

TLB 中的条目是虚拟地址的高 20 位到物理地址高 20 位的映射结果。TLB将近期访问的虚拟地址转换成物理地址后,一一对应储存起来,当CPU下一次需要转换时,会先来TLB中查询,查到就直接拿去用,如果没查到对应的物理地址,会在转换后更新TLB

可以使用invlpg指令更新TLB

load kernel!

对于linux kernel,并不采用纯汇编的方式来编写(虽然这8是8行😅),将C和汇编结合起来会更便于理解

因为我们是在linux操作系统中,kernel的文件格式是elf,所以在loader kernel文件前,我们还需要对elf文件有足够了解

elf 文件

ELF,Executable and Linkable Format,可执行链接格式。

ELF文件提供了两种文件视图,链接格式视图和执行格式视图。链接视图是以节(section)为单位,执行视图是以段(segment)为单位。接视图就是在链接时用到的视图,而执行视图则是在执行时用到的视图。

img

先来看看elf header

ELF header

ELF header位于文件的开始位置,它的主要目的是定位文件的其他部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
typedef struct elf32_hdr
{
unsigned char e_ident[EI_NIDENT]; /* Magic number and other info */
Elf32_Half e_type; /* Object file type */
Elf32_Half e_machine; /* Architecture */
Elf32_Word e_version; /* Object file version */
Elf32_Addr e_entry; /* Entry point virtual address */
Elf32_Off e_phoff; /* Program header table file offset */
Elf32_Off e_shoff; /* Section header table file offset */
Elf32_Word e_flags; /* Processor-specific flags */
Elf32_Half e_ehsize; /* ELF header size in bytes */
Elf32_Half e_phentsize; /* Program header table entry size */
Elf32_Half e_phnum; /* Program header table entry count */
Elf32_Half e_shentsize; /* Section header table entry size */
Elf32_Half e_shnum; /* Section header table entry count */
Elf32_Half e_shstrndx; /* Section header string table index */
} Elf32_Ehdr;

e_ident:16字节,含义如下图

img

e_type:2字节,文件类型,类型有以下几个

img

e_machine:2字节,文件架构,有以下几个架构

img

readelf -h即可查看elf header详细信息

img

Program header table

位于elf header之后,程序头表(Program header table)列举了有效的段(segments)和他们的属性(执行视图)

程序头是一个结构的数组,每一个结构都表示一个段(segments)。在可执行文件或者共享链接库中所有的节(sections)都被分为不同的几个段(segments)。

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;

程序头的索引地址(e_phoff)、段数量(e_phnum)、表项大小(e_phentsize)都是通过 ELF头部信息获取的。

p_type:指明该段的类型

img

p_flags:指明该段的标志

img

下图画红线的0x20字节便是一个程序头表

img

当然,也可以使用readelf -l得到详细信息

img

以上,便是elf文件浅析

now lets’s go!

先随便写个kernel,内联汇编用起来还是爽的

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
#include <stdio.h>

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:184], ' ';"
"mov byte ptr [gs:186], 'k';"
"mov byte ptr [gs:188], 'o';"
"mov byte ptr [gs:190], 'r';"
"mov byte ptr [gs:192], 'e';"
"mov byte ptr [gs:194], 'y';"
"mov byte ptr [gs:196], ''';"
"mov byte ptr [gs:198], 'O';"
"mov byte ptr [gs:200], 'S';"
);
}
return 0;
}

我们需要将kernel载入到内存中,完成解析,书中是将kernel载入到0x70000处,解析程序入口到0x1500处,笔者也就照着来了。

用脚本完成编译链接,0x1500对应的虚拟地址为0xc0001500

1
2
3
#!/bin/sh
gcc -m32 -c ./kernel/kernel.c -o ./kernel/kernel.o -masm=intel
ld -melf_i386 ./kernel/kernel.o -Ttext 0xc0001500 -e main -o ./kernel/kernel.bin

完成载入和解析后,便可以跳到kernel处,load的任务就结束了(我的任务完成啦🤪)

以下便是代码的变化

boot.inc

1
2
3
4
5
;-----add-----
KERNEL_START_SECTOR equ 0x9
KERNEL_BIN_BASE_ADDR equ 0x70000
KERNEL_ENTRY_POINT equ 0xc0001500
PT_NULL equ 0

pack.sh

1
2
3
dd if=./kernel/kernel.bin \
of=./img.img \
bs=512 count=200 seek=9 conv=notrunc

load.s

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
;开启保护模式......
p_mode_start:
mov ax,SELECTOR_DATA
mov ds,ax
mov es,ax
mov ss,ax
mov esp,LOADER_STACK_TOP
mov ax,SELECTOR_VIDEO
mov gs,ax

mov eax, KERNEL_START_SECTOR
mov ebx, KERNEL_BIN_BASE_ADDR
mov ecx, 200
call rd_disk_m_32 ;和rd_dsik_m_16一样,寄存器换成32位就行

call setup_page

sgdt [gdt_ptr]
mov ebx, [gdt_ptr + 2]
or dword [ebx + 0x18 + 4], 0xc0000000
add dword [gdt_ptr + 2], 0xc0000000
add esp, 0xc0000000

mov eax, PAGE_DIR_TABLE_POS
mov cr3, eax

mov eax, cr0
or eax, 0x80000000
mov cr0, eax

lgdt [gdt_ptr] ;开启分页

jmp SELECTOR_CODE:enter_kernel ;刷新流水线,虽然不刷也莫得关系

enter_kernel:
call kernel_init
mov esp, 0xc009f000
jmp KERNEL_ENTRY_POINT


;......
kernel_init:
[bits 32]
xor eax, eax
xor ebx, ebx
xor ecx, ecx
xor edx, edx

mov dx, [KERNEL_BIN_BASE_ADDR + 42] ;length of program header
mov ebx, [KERNEL_BIN_BASE_ADDR + 28]
add ebx, KERNEL_BIN_BASE_ADDR ;program header table start address
mov cx, [KERNEL_BIN_BASE_ADDR+44] ;count of program header

.each_segment:
cmp byte[ebx + 0],PT_NULL ;if [ebx+0] == 0,the program header is empty
je .PTNULL

push dword [ebx + 16] ;segment size
mov eax, [ebx+4]
add eax, KERNEL_BIN_BASE_ADDR
push eax ;src
push dword [ebx + 8] ;dst

call mem_cpy
add esp, 12 ;recover

.PTNULL:
add ebx, edx ;next program header
loop .each_segment
ret

mem_cpy: ;模仿memcpy函数,待我去看看memcpy源码
cld
push ebp
mov ebp, esp
push ecx

mov edi, [ebp+8]
mov esi, [ebp+12]
mov ecx, [ebp+16]
rep movsb ;将ds:esi处size为ecx的data,复制到es:edi处,逐字节拷贝

pop ecx
leave ;恢复栈帧
ret

最后的效果的就是这样

img

0x4:实现自己的输出函数

在此之前,我们在屏幕上输出,要么是依靠BIOS中断,要么是直接对显存进行操作

但我们是什么,是尊贵的kernel!输出这件事,应该只要轻轻的call一个function,就能完成

所以,是时候写一个输出函数了(虽然只是对显存操作测封装罢了🤔)

单个字符输出

print.S

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
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
TI_GDT	equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0

[bits 32]
section .text

global put_char

put_char:
[bits 32]
pushad ;保存寄存器的值
mov ax, SELECTOR_VIDEO
mov gs, ax

mov dx, 0x3d4 ;获取光标的值
mov al, 0x0e
out dx, al
mov dx, 0x03d5
in al, dx
mov ah, al

mov dx, 0x3d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
in al, dx

mov bx, ax
mov ecx, [esp+36] ;将要输出字符的ascii码值给ecx

cmp cl, 0x0d
jz .is_carriage_return
cmp cl, 0xa
jz .is_line_feed

cmp cl, 0x8
jz .is_backspace
jmp .put_other

;-----退格-----
.is_backspace:
[bits 32]
dec bx
shl bx, 1

mov byte [gs:bx], 0x20
inc bx
mov byte [gs:bx], 0x7
shr bx, 1
jmp .set_cursor


.put_other:
[bits 32]
shl bx, 1
mov [gs:bx], cl
inc bx
mov byte [gs:bx], 0x7
shr bx, 1
inc bx
cmp bx, 2000
jl .set_cursor

;-----换行-----
.is_line_feed:
[bits 32]
add bx, 80
cmp bx, 2000
jl .set_cursor
;-----回车-----
.is_carriage_return:
[bits 32]
xor dx, dx
mov ax, bx
mov si, 80
div si
sub bx, dx

jmp .set_cursor

;-----滚屏-----
.roll_screen: ;简单来说,就是把1-24的data移动到0-23行,因为我们无需考虑缓存问题
[bits 32]
cld
mov eax, 960 ;960*4 = 3840

mov esi, 0xc00b80a0 ;第1行
mov edi, 0xc00b8000 ;第0行
rep movsd ;4字节4字节移动

mov ebx, 3840 ;第24行第一个光标的值
mov ecx, 80

.cls: ;使最后一行为空白行
[bits 32]
mov word [gs:ebx], 0x720
add ebx, 2
loop .cls
mov bx, 1920
;-----存储当前光标的值-----
.set_cursor:
[bits 32]
mov dx, 0x3d4
mov al,0x0e
out dx, al
mov dx, 0x03d5
mov al, bh
out dx, al

mov dx, 0x3d4
mov al, 0xf
out dx,al
mov dx, 0x03d5
mov al, bl
out dx, al
;-----恢复寄存器状态-----
.put_char_done:
[bits 32]
popad
ret

在显存的文本显示模式中,两个字节显示一个字符——一字节为字符的值,一字节为字符的属性。所以获取光标的值*2才是对应字符在显存中的偏移。

bochs的屏幕,可以容纳80*25共2000个字符,占据4000字节,所以换行就是简单粗暴的光标值+80,回车是当前光标值 - (当前光标值对80取模)。至于滚行的原理,就是简单粗暴的把1-24行移动到0-23行,突出一个不考虑缓存🤪

字符串输出

好吧其实就是对单个字符输出的封装呜呜

print.h

1
2
3
4
5
6
#ifndef _LIB_KERNEL_PRINT_H
#define _LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_csci);
void put_str(char *msg);
#endif

print.S

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
global put_str

put_str:
push ebx
push ecx
xor ecx, ecx
mov ebx, [esp + 12]
.go_on:
mov cl, [ebx]
cmp cl, 0
jz .str_over
push ecx
call put_char
add esp, 4
inc ebx
jmp .go_on

.str_over:
pop ecx
pop ebx
ret

main.c

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

int main(void)
{
put_str("begin\r\n");
put_str("aaaaaaaaaa");
put_str("\b\b\b\b\b\b\b\b");
put_str("bbbbbbbbbb\r\n");
put_str("end\r\n");
}

ld.sh

1
2
3
4
#!/bin/sh
nasm -f elf -o ./lib/kernel/print.o ./lib/kernel/print.S
gcc -m32 -I lib/kernel/ -c -o ./kernel/main.o ./kernel/main.c -masm=intel
ld -melf_i386 ./kernel/main.o ./lib/kernel/print.o -Ttext 0xc0001500 -e main -o ./kernel/kernel.bin

运行发现结果还是可以的


关于作死试图搓一个32位操作系统不得不说的事
http://example.com/2023/11/20/OS/
作者
korey0sh1
发布于
2023年11月20日
许可协议