本文最后更新于: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
支持自带的debug
和gdb
(编译的时候--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"
keyboard: type=mf, serial_delay=250 keymap=./bochs/share/bochs/keymaps/x11-pc-us.map
|
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
通上电之后以实模式启动,第一个运行的程序便是BIOS
,BIOS
将MBR
载入,将控制权交给了MBR
于是便产生了玄学三问:BIOS
是什么?他从哪来?为什么他先执行?
首先先看张《操作系统真象还原》里的图,关于实模式下的内存分布
BIOS
:Basic Input & Output System
即基本输入输出系统,主要工作便是检测、初始化硬件,还建立了伟大的中断向量表
但是通过图可知,BIOS
才0xf0000-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
真正开始的地方
当初始化硬件和建立中断向量表后,BIOS
将自己最后的波纹用于检查0盘0道1扇区(实则是0盘0道0扇区,CHS表示方式中1扇区就是第一个扇区)最后两个字节是否为magic number
0x55和0xaa(你问👴为什么不是114514这种恶臭的杂修~,👴知道个der,反正书里写了)。如果是,就会把这个扇区的data
加载到0x7c00
,随后跳转执行。此处执行的,便是MBR。如果你问完magic number
又要问👴为什么是0x7c00
,👴只能说是IBM生产的PC5150的ROM BIOS 中的INT19H
的历史遗留问题,当时的BIOS
按32KB
的大小来设计,32KB
就是0x8000
,MBR
的大小是512字节,同时作为一个程序需要使用栈,姑且算1KB
好了。1KB
是多少,0x400
,所以0x8000
- 0x400
是0x7c00
。
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
|
运行后可以康康
输出的时候写了个循环,优化一下,不用算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
是显存的区域,事实上,当你直接在显存上写东西。显卡便会在屏幕上输出内容
下面是在显存文本模式区域(一个字节表示输出字符,一个字符表示其属性)输出“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
|
MBR:和我交往吧,磁盘仙贝!😋
BIOS
只能把0柱面0磁道1扇区512字节大小的MBR
载入内存,那么问题来了,MBR
执行完了,然后干什么嘞。
其实我们都知道,MBR
也不过是一个中继程序而已,然既然MBR
是从磁盘导入的,那我们能不能用MBR
从磁盘导入别的data
呢?
这就涉及到磁盘通信问题惹
先贴一张表,出自《操作系统真象还原》
先来看看一些常用的端口
- data
0x1f0/0x170
:负责管理数据,唯一一个16位的
- error/feature
0x1f1/0x171
:读取硬盘失败时记录失败信息/写入硬盘时存储命令需要的额外参数
- Sector count
0x1f2/0x172
:指定待写入或待读取的扇区
- device
0x1f6/0x176
:杂项寄存器,什么功能都带点。图来自《操作系统真象还原》
- status (读硬盘时)
0x17f/0x177
:状态寄存器。图来自《操作系统真象还原》
- 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
| 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了
但是这个loader并没有任何意义,因为他是在实模式下运行的,只是为了测试MBR的功能性。
0x2:保护模式
段描述符(Segment Descriptor)
随着时间的推移,8086进化成了80386,地址总线也从16位变成32位,but,段寄存器却还是16位从未变过
这是因为关于段的信息被存放在了内存中一块叫做段描述符的地方
段描述符长8字节,64位,里面的各个位的具体含义如下
- 0-15:段界限低16位
- 16-31:段基址低16位
- 32-39:段基址中8位
- 40-43:type 指定本描述符的类型,用于表示内存段和门的子类型。图出自《操作系统真象还原》
- 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的内存地址和大小,下图出自《操作系统真象还原》
前16位是界限值,2^16 = 65536,所以前16位的max value
= 65536 -1 = 0xffff
and
一个段描述符长8字节,所以一个GDT
最多存储8192个段描述符
lgdt:对GDTR特攻指令,具体操作为
selector
选择子是16位,下图出自《操作系统真象还原》
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
,和GD
T类似,就不再赘述
打开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
下两图出自《操作系统真象还原》
第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寄存器。
展示个结果
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),格式如下图。此表来自《操作系统真象还原》
type字段的意义如下。此表来自《操作系统真象还原》
同时,0xE820在中断前,还需要几个寄存器布置参数,返回后的值也储存在几个寄存器中。此表来自《操作系统真象还原》
动手写一下
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
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 $
|
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 字节,页表项的作用是存储内存物理地址。当访问一个线性地址时,实际上就是在访问页表项中所记录的物理内存地址。
下图为一级页表示意图。来自《操作系统真象还原》
页表项0-11位为页内寻址
页表项12-31位为该页表项在页表中的index
页表的地址被存放在CR3寄存器中,可以通过CR3寄存器中页表项的物理地址(此时还未开启分页)+ index*4找到目标页表项对应的物理地址,最后加上低12位的偏移,就能访问对应的物理地址
But,想一下,4GB总内存,以4kB的粒度分,光页表项就有0x100000
之多,存储这些页表项就要花费4MB大小的内存。并且每个进程都有独立的页表,光是这些内存就是一笔很大的开销,那怎么办捏?
一级页表不行,👴再套一层不行🐎
所以二级页表他来了
二级页表
先看一下二级页表示意图。下图来自《操作系统真象还原》
这次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,反正固定不变的东西,存放点信息不是美滋滋,于是,PTE
和PDE
就被改造调教开发成了下面的模样🥵。下图来自《操作系统真象还原》
- 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
可以查看页表映射
这边解释一下为什么会有这种奇怪的映射,因为页目录表的最后一项页目录项是页表的物理地址,对应虚拟地址的高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)为单位。接视图就是在链接时用到的视图,而执行视图则是在执行时用到的视图。
先来看看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字节,含义如下图
e_type:2字节,文件类型,类型有以下几个
e_machine:2字节,文件架构,有以下几个架构
用readelf -h
即可查看elf header详细信息
位于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:指明该段的类型
p_flags:指明该段的标志
下图画红线的0x20字节便是一个程序头表
当然,也可以使用readelf -l
得到详细信息
以上,便是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
|
最后的效果的就是这样
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
|
运行发现结果还是可以的