《操作系统真像还原》操作系统实现——内核中的字符打印函数

Posted on May 20, 2021

昨天在看特权级相关的东西,看的云里雾里,没搞得很懂,考虑到短期之内不会弄得特别深,而且我们也用不上调用门,相关的较复杂的问题也应该不会碰到,所以准备暂时跳过。

调用约定

在说 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 来写。所以我就不写了。