《操作系统真像还原》操作系统实现——内核中的字符打印函数
昨天在看特权级相关的东西,看的云里雾里,没搞得很懂,考虑到短期之内不会弄得特别深,而且我们也用不上调用门,相关的较复杂的问题也应该不会碰到,所以准备暂时跳过。
调用约定
在说 sys_write 之前应该先说一下调用约定,我们的操作系统会使用 cdecl。由于我是打 PWN 的,对此调用约定相对还算熟悉,但是还是有学到新的东西
- cdecl 是由主调函数清理栈空间的,即调用压入的参数对栈产生的影响由主调函数消除
- cdecl 下,ecx,edx 两寄存器会被被调函数使用,需要有用户备份其值,eax 保存返回值,除此 3 个寄存器外的寄存器在被调函数返回时都会恢复原值。
以上是32位 C 程序默认使用的调用约定两个特点。关于调用约定其他的细节这里不再赘述。
在进行系统调用时,往往不遵守 cdecl 约定,Linux 下的调用约定为
32 位:eax 存储调用功能号,参数按顺序存于 ebx,ecx,edx,esi,edi,ebp 中。
64 位:eax 存储调用功能号,参数按顺序存于 rdi,rsi,rdx,r10,r8,r9 中。
sys_putchar
这是我们操作系统向屏幕输出的最基本函数,别的输出函数基本都是对这个函数的封装。
sys_putchar 是一个内核态函数,用户的特权级无法使用,也不会通过系统调用的方式提供给用户(DPL 为 0)。为了调用方便,我们考虑使用 cdecl 调用约定,即通过栈传参。
该函数需要处理的问题如下:
- 处理 LF,CR,BS 三种控制字符
- 输出其余字符,并设置好属性
- 对于输出超过当前屏幕的情况,处理好滚屏
获取光标地址
为了输出,我们需要获得当前显示器的光标位置,这需要和显示适配器进行交互,我觉得深究和什么端口交互之类的问题和学习操作系统关系不大,这里也就不再深究。只要知道由于显示器使用到的寄存器过多,将寄存器进行了分组,我们要用到的就是 CRT Controller Registers 这组寄存器,默认情况下占用的端口为 0x3D4。通过向该端口 in 数据可以选定使用该组中的特定寄存器
获取光标首先要向 0x3D4 端口写入 0x0E 和 0x0F 分别选定 Cursor Location High Register 和 Cursor Location Low Register,通过 out 把指针的地址高 8 位和低 8 位都读出来。
; get the current cursor addr (high 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0E ; Cursor Location High Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
in al,dx ; get the high 8 bits of the cursor addr
mov ah,al
; get the current cursor addr (low 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0F ; Cursor Location Low Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
in al,dx ; get the low 8 bits of the cursor addr
; save the cursor addr to bx
mov bx,ax
判断字符类型
如果是前文所述的 3 个控制字符之一,那么就进行特殊处理,否则直接输出。由于是 32 位程序,所以传入的参数在 [rsp + 4] 处,不过由于有必要保存寄存器的值,函数开头会执行 pushad
将 8 个同样寄存器入栈,所以传入的参数在 [rsp + 36] 处
; get the char wating to be put
mov ecx,[esp + 36] ; 32(backup regs) + 4(return addr) = 36
cmp cl,0x0d ; CR(Carriage Return): 0x0d
jz .sys_putchar_CarriageReturn
cmp cl,0x0a ; LF(Line Feed): 0x0a
jz .sys_putchar_LineFeed
cmp cl,0x08 ; BF(BackSpace): 0x08
jz .sys_putchar_BackSpace
jmp .sys_putchar_AnyOther ; Any other char
处理退格
退格的处理比较简单,将光标退格一位并把光标原先指向的字符替换成空格或者 ‘\0’ 就可以了,字符属性默认(0x7,黑底白字)。这里其实属性和字符一起设置,以 word 为单位会更容易,之后可能会改动。
.sys_putchar_BackSpace:
dec bx ; cursor back one step
shl bx,1 ; bx<<1 <=> bx * 2
mov byte [gs:bx],0x20 ; fill the delete char with ' '
inc bx
mov byte [gs:bx],0x07 ; 00000111b, (default status)
shr bx,1 ; bx>>1 ,=> bx // 2
jmp .sys_putchar_SetCursor
注意,我们之前把指针地址存储在了 bx 中,之后的操作都是对 bx 进行的,没有真正改变光标位置,直到子函数 .sys_putchar_SetCursor
之后才会同样进行设置。
输出字符
输出字符后需要将光标后移一位,由于光标后移了,就可能会有溢出的情况(输出到页面外),我们的处理为避免溢出,即如果光标指向第 2001 字符,代表下一次输出会溢出,此时向上滚屏一行(也就是不跳转至设置光标,执行之后对换行回车的处理)。
.sys_putchar_AnyOther:
shl bx,1 ; bx<<1
mov byte byte[gs:bx],cl ; put the char
inc bx
mov byte byte[gs:bx],0x07 ; set the statu
inc bx ; point to the next char
shr bx,1 ; bx>>1
cmp bx,2000 ; bx == 2000, don't jmp, bx < 2000, jmp
jl .sys_putchar_SetCursor ; if the cursor overflow the maximum of the
; video memory, do a Line Feed, if not, set
; the new cursor.
换行、回车
实际上回车是返回到行首,但是一般都是返回到下一行行首,所以可以和换行等同,这里也把两者等同。
.sys_putchar_LineFeed:
.sys_putchar_CarriageReturn:
xor dx,dx ; high 16 bits of the number to be div
mov bx,bx ; low 16 bits of the number to be div
mov si,80 ; diver
div si
sub bx,dx ; bx = bx - bx % 80 => make the cursor point to the front of the line
; CR done
add bx,80 ; dx = dx + 80 => point to the next line
; LF done
cmp bx,2000
jl .sys_putchar_SetCursor
此处的对光标的计算方法为 bx = bx - bx % 80 + 80
,每行有 80 个字,这么处理就是先取得当前的行首,然后跳至下一行行首。这里对末尾的处理看似有问题,也就是从输出字符那里执行过来的话,bx 就会变成 2080,但是实际上没有问题,因为这样的值会造成滚屏,滚完屏后直接置 bx 为 1920。
滚屏
说是滚屏,其实是上移一行。其实显示器中有 Start Address High/Low Register 来维护向屏幕输出的缓存开始地址,通过改变这两个寄存器就可以直接实现滚屏。但是这样做涉及硬件 I/O,在编写和时间上都未必是最优的。而且如果我们不依赖这两个寄存器,就可以完全利用 16KB 显存,实现类似 Linux 的多 TTY。如果很有必要缓存屏幕内容,也可以在内存中缓存,不一定要使用显存。
.sys_putchar_RollOneLine: ; move line 1~24 to the line 0~23 and clear the last line
; move line 1~24 to the line 0~23
mov ecx,960 ; ((2000 - 80) * 2)(byte) / 4 =960(dword)
mov esi,0xC00B80A0 ; front of line 1
mov edi,0xC00B8000 ; front of line 0
cld ; increase copy
rep movsd
; clear the last line
mov ecx,80 ; 80 words (only one word at a time)
mov ebx,3840 ; (2000 - 80) * 2 = 3840
.sys_putchar_RollOneLine_CLL:
mov word [gs:ebx],0x0720 ; blank
add ebx,2
loop .sys_putchar_RollOneLine_CLL
mov bx,1920 ; make cursor point to the last line
利用 movsd 指令可以很容易地实现上滚。然后清空最后一行(全部置为空格)。再设置光标位置为最后一行行首(1920)。
写回光标
.sys_putchar_SetCursor:
; set the current cursor addr (high 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0E ; Cursor Location High Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
mov al,bh
out dx,al ; set the high 8 bits of the cursor addr
; set the current cursor addr (low 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0F ; Cursor Location low Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
mov al,bl
out dx,al ; set the low 8 bits of the cursor addr
最后需要写回光标位置。
最后完整的 print.S
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0
[bits 32]
section .text
; -------------------- sys_putchar --------------------
; write one char in stack to the cursor
; --------------------------------------------------
global sys_putchar
sys_putchar:
pushad ; backup all regs (8 * 4 = 32bytes)
mov ax,SELECTOR_VIDEO
mov gs,ax ; make sure gs stores the right selector
; get the current cursor addr (high 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0E ; Cursor Location High Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
in al,dx ; get the high 8 bits of the cursor addr
mov ah,al
; get the current cursor addr (low 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0F ; Cursor Location Low Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
in al,dx ; get the low 8 bits of the cursor addr
; save the cursor addr to bx
mov bx,ax
; get the char wating to be put
mov ecx,[esp + 36] ; 32(backup regs) + 4(return addr) = 36
cmp cl,0x0d ; CR(Carriage Return): 0x0d
jz .sys_putchar_CarriageReturn
cmp cl,0x0a ; LF(Line Feed): 0x0a
jz .sys_putchar_LineFeed
cmp cl,0x08 ; BF(BackSpace): 0x08
jz .sys_putchar_BackSpace
jmp .sys_putchar_AnyOther ; Any other char
.sys_putchar_BackSpace:
dec bx ; cursor back one step
shl bx,1 ; bx<<1 <=> bx * 2
mov byte [gs:bx],0x20 ; fill the delete char with ' '
inc bx
mov byte [gs:bx],0x07 ; 00000111b, (default black back,withe front)
shr bx,1 ; bx>>1 ,=> bx // 2
jmp .sys_putchar_SetCursor
.sys_putchar_AnyOther:
shl bx,1 ; bx<<1
mov byte byte[gs:bx],cl ; put the char
inc bx
mov byte byte[gs:bx],0x07 ; set the statu
inc bx ; point to the next char
shr bx,1 ; bx>>1
cmp bx,2000 ; bx == 2000, don't jmp, bx < 2000, jmp
jl .sys_putchar_SetCursor ; if the cursor overflow the maximum of the
; video memory, do a Line Feed, if not, set
; the new cursor.
.sys_putchar_LineFeed:
.sys_putchar_CarriageReturn:
xor dx,dx ; high 16 bits of the number to be div
mov bx,bx ; low 16 bits of the number to be div
mov si,80 ; diver
div si
sub bx,dx ; dx = dx - dx % 80 => make the cursor point to the front of the line
; CR done
add bx,80 ; dx = dx + 80 => point to the next line
; LF done
cmp bx,2000
jl .sys_putchar_SetCursor
.sys_putchar_RollOneLine: ; move line 1~24 to the line 0~23 and clear the last line
; move line 1~24 to the line 0~23
mov ecx,960 ; ((2000 - 80) * 2)(byte) / 4 =960(dword)
mov esi,0xC00B80A0 ; front of line 1
mov edi,0xC00B8000 ; front of line 0
cld ; increase copy
rep movsd
; clear the last line
mov ecx,80 ; 80 words (only one word at a time)
mov ebx,3840 ; (2000 - 80) * 2 = 3840
.sys_putchar_RollOneLine_CLL:
mov word [gs:ebx],0x0720 ; blank
add ebx,2
loop .sys_putchar_RollOneLine_CLL
mov bx,1920 ; make cursor point to the last line
.sys_putchar_SetCursor:
; set the current cursor addr (high 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0E ; Cursor Location High Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
mov al,bh
out dx,al ; set the high 8 bits of the cursor addr
; set the current cursor addr (low 8 bits)
mov dx,0x3D4 ; Address Reg (base)
mov al,0x0F ; Cursor Location low Reg (idx)
out dx,al
mov dx,0x3D5 ; Data Reg (base)
mov al,bl
out dx,al ; set the low 8 bits of the cursor addr
popad ; reset the regs
ret
; -------------------- end of function sys_putchar --------------------
可以看到这里又设置了段选择字 gs。这样做的原因涉及用户进程,由于用户进程完全不需要也不能直接访问显存,所以没有必要在用户态下把 gs 当作一个段选择子,在许多操作系统下,gs 都被当作一个额外的寄存器存储一些额外的信息;另一方面操作系统也不需要由用户来设置 gs,所以操作系统默认 gs 的值需要重新加载。(我这里的解释和书上略有差别,多说了一些也少说了一些,不太重要,之后到用户进程的时候就可以完全解释清楚了。)
修改了一下 main.c
#include "print.h"
int _start()
{
sys_putchar('k');
sys_putchar('e');
sys_putchar('r');
sys_putchar('n');
sys_putchar('e');
sys_putchar('l');
sys_putchar('!');
sys_putchar('\n');
sys_putchar('b');
sys_putchar('a');
sys_putchar('c');
sys_putchar('k');
sys_putchar('s');
sys_putchar('p');
sys_putchar('a');
sys_putchar('c');
sys_putchar('e');
sys_putchar('\b');
sys_putchar('\n');
while(1);
return 0;
}
现在的效果为

在我补全剩下的一些输出函数前先学了一下 makefile,用脚本构建实在太逗了。
现在我写好了 Makefile,当然由于这个东西比较复杂,我写的还是比较烂的,总之现在是可以 make 一键编译了。
然后我在 print.S 中添加了 sys_putstr 函数
; -------------------- sys_putstr --------------------
; write a string (end by '\0')
; ----------------------------------------------------
global sys_putstr
sys_putstr:
push ecx
push ebx
mov ebx,[esp + 12]
xor ecx,ecx
xor eax,eax
.sys_putstr_PutNext:
mov cl,[ebx]
test cl,cl
jz .sys_putstr_EndOfStr
push ecx
call sys_putchar
add esp,4
inc ebx
inc eax
jmp .sys_putstr_PutNext
.sys_putstr_EndOfStr:
pop ebx
pop ecx
ret
; -------------------- end of function sys_putstr --------------------
输出使用 sys_putstr 完成。
修改 main.c 为
#include "print.h"
int _start()
{
sys_putstr("this is kernel!\n");
sys_putstr("Back Space\b");
while(1);
return 0;
}
现在的效果为

书上还实现了一个输出十六进制数的函数,我觉得没有必要用汇编实现这个(太折磨了),完全可以用 C 来写。所以我就不写了。