手写操作系统(十四)-实现单个字符打印

代码、内容参考来自于包括《操作系统真象还原》、《一个64位操作系统的设计与实现》以及《ORANGE’S:一个操作系统的实现》。

1.完整代码

lib目标用来存放各种库文件。lib下建立user和kernel两个子目录,以后供内核使用的库文件就放在lib/kernel/下,lib/user/中是用户进程使用的库文件。

lib/stdint.h文件

#ifndef __LIB_STDINT_H
#define __LIB_STDINT_H
typedef signed char int8_t;
typedef signed short int int16_t;
typedef signed int int32_t;
typedef signed long long int int64_t;
typedef unsigned char uint8_t;
typedef unsigned short int uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long int uint64_t;
#endif

 

我们要实现的字符打印函数叫put_char,在print.S文件,它是用汇编语言写的。因为要和显卡打交道啦,里面涉及到端口的读写操作。

它的处理流程如下:

  • 备份寄存器现场。
  • 获取光标坐标值,光标坐标值是下一个可打印字符的位置。
  • 获取待打印的字符。
  • 判断字符是否为控制字符,若是回车符、换行符、退格符三种控制字符之一,则进入相应的处理流程。 否则,其余字符都被粗暴地认为是可见字符,进入输出流程处理。
  • 判断是否需要滚屏。
  • 更新光标坐标值,使其指向下一个打印字符的位置。
  • 恢复寄存器现场,退出。

.asm 文件通常用于纯汇编语言代码,这些代码需要经过汇编器(如NASM、MASM等)来转换成机器码。

.S 文件通常用于混合语言编程环境中,例如汇编语言和C语言的混合代码。

所以这里我们使用开始使用.S文件,将前面的asm文件都改为S文件

以下为/kernel/print.S文件

TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

[bits 32]
section .text
;-----------put_char---------------
;功能描述:把栈中的1个字符写入光标所在处
;----------------------------------
global put_char                 ;将put_char导出为全局符号
put_char:
    pushad                      ;备份32位寄存器环境,push all double,将8个32位寄存器都备份了。它们入栈的顺序为:EAX->ECX->EDX->EBX->ESP->EBP->ESI->EDI
    mov ax,SELECTOR_VIDEO       ;需要保证gs中为正确的视频段选择子,为保险起见,每次打印都为gs赋值
    mov gs,ax                   ;不能直接把立即数送入段寄存器
    ;;;;;获取当前光标的位置;;;;;
    ;先获取高8位
    mov dx,0x03d4               ;索引寄存器,03d4为Address Register,用于索引寄存器。
    mov al,0x0e                 ;用于提供光标位置的高8位
    out dx,al
    mov dx,0x03d5               ;03d5是Data Register;可以写数据和读数据。通过读写数据端口0x3d5来获取/设置光标的位置
    in al,dx                    ;得到光标位置的高8位
    mov ah,al                   ;将得到的光标高8位放入ah中

    ;再获取低8位
    mov dx,0x03d4
    mov al,0x0f                 ;用于提供光标位置的低8位
    out dx,al
    mov dx,0x03d5
    in al,dx

    ;将光标位置存入bx,bx寄存器习惯性作为基址寻址。此时bx是下一个字符的输出位置。
    mov bx,ax
    ;获取栈中压入字符的ASCII码
    mov ecx,[esp + 36]          ;pushad压入8*32b=32字节,加上主调函数4B的返回地址。故栈顶偏移36字节。
    ;判断字符是什么类型
    cmp cl,0xd                  ;CR是0x0d,回车键
    jz .is_carriage_return
    cmp cl,0xa                  ;LF是0x0a,换行符
    jz .is_line_feed

    cmp cl,0x8                  ;BS是0x08,退格键
    jz .is_backspace
    jmp .put_other

.is_backspace:                  ;理论上将光标移到该字符前即可,但怕下个字符为回车等,原字符还留着当地,所以用空格/空字符0替代原字符
    dec bx                      ;bx值-1,光标指向前一个字符
    shl bx,1                    ;左移一位等于乘2,表示光标对应显存中的偏移字节
    mov byte [gs:bx],0x20       ;0x20表示空格
    inc bx                      ;bx+1
    mov byte [gs:bx],0x07       ;0x07表示黑屏白字,这是显卡默认的前景色和背景色,不加也行。
    shr bx,1                    ;右移一位表示除以2取整,bx由显存的相对地址恢复到光标位置
    jmp .set_cursor             ;设置光标位置

.put_other:                     ;处理可见字符
    shl bx,1                    ;光标左移1位等于乘2,表示光标位置
    mov [gs:bx],cl              ;将ASCII字符放入光标位置中
    inc bx                      ;bx+1
    mov byte [gs:bx],0x07       ;字符属性,黑底白字
    shr bx,1                    ;右移一位表示除以2取整,bx由显存的相对地址恢复到光标位置
    inc bx                      ;bx+1,下一个光标值
    cmp bx,2000                 ;看是否需要滚屏
    jl .set_cursor              ;"JL"是"jump if less"(如果小于则跳转):若光标值<=2000,表示未写到。显存的最后,则去设置新的光标值,若超过屏幕字符数大小(2000),则换行(滚屏)。

.is_line_feed:                  ;是换行符LF(\n)
.is_carriage_return:            ;是回车键CR(\r),\n和\r在Linux中都是\n的意思。
    xor dx,dx                   ;dx是被除数的高16位,清零
    mov ax,bx                   ;ax是被被除数的低16位,bx是光标位置
    mov si,80                   ;si = 80为除数
    div si                      ;对80取模,(dx + ax)/si = ax(商) + dx(余数) 即bx/80=几行(ax) + 第几列(dx)
    ;如果除数是16位,被除数就是32位,位于dx和ax(高16位,低16位)中;结果的商放在ax中,余数放入dx中
    sub bx,dx                   ;bx-dx表示将bx放在行首,实现了回车的功能。

.is_carriage_return_end:        ;回车符处理结束,判断是否需要滚屏
    add bx,80
    cmp bx,2000
.is_line_feed_end:              ;若是LF,则光标移+80即可
    jl .set_cursor

.roll_screen:                   ;若超过屏幕大小,开始滚屏:屏幕范围是0~23,滚屏原理是把1~24->0~23,再将24行用空格填充
    cld
    mov ecx,960                 ;2000-80=1920个字符,共1920*2=3840字节,一次搬运4字节,一共要搬运3840/4=960次
    mov esi,0xc00b_80a0         ;第1行行首,源索引地址寄存器
    mov edi,0xc00b_8000         ;第0行行首,目的索引地址寄存器
    rep movsd                   ;repeat move string doubleword,以32b为单位进行移动,直到ecx=0
    ;将最后一行填充为空白
    mov ebx,3840                ;最后一行从3840开始
    mov ecx,80                  ;一行80字符,每次清空1字符(2B),一行要移动80次

.cls:
    mov word [gs:ebx],0x0720    ;0x0720是黑底白字的空格键,一次清空一个字符(2B)
    add ebx,2                   ;ebx移动到下一个字符处
    loop .cls                   ;循环.cls,直到ecx=0
    mov bx,1920                 ;bx存放下一个字符的光标位置,即3840/2=1920

.set_cursor:                    ;将光标设置为bx值
    ;先设置高8位
    mov dx,0x03d4               ;索引寄存器,通过0x3d4写入待操作寄存器的索引
    mov al,0x0e                 ;用于提供光标的高8位
    out dx,al
    mov dx,0x03d5               ;通过数据端口0x3d5来设置光标位置
    mov al,bh                   ;将bx的光标位置的高8位放入al中,通过al输入到dx = 0x3d5端口
    out dx,al                   ;[0x3d5端口] = bx高8位 = bh
    ;再设置低8位
    mov dx,0x03d4
    mov al,0x0f                 ;用于提供光标的低8位
    out dx,al
    mov dx,0x03d5               ;通过数据端口0x3d5来设置光标位置
    mov al,bl                   ;将bx的光标位置的低8位放入al中,通过al输入到dx = 0x3d5端口
    out dx,al                   ;[0x3d5端口] = bx低8位 = bl
    .put_char_done:
        popad                   ;将之前入栈的8个32b的寄存器出栈
        ret

 

2.代码解释

这里定义了视频段的选择子,由于只需要这三行,专门定义个配置文件有点不值当的,所以直接在这定义了。

TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

 

这里通过关键字 global 把函数 put_char 导出为全局符号,这样对外部文件便可见了,外部文件通过声明便可以调用。

global put_char                 ;将put_char导出为全局符号

 

下面就是函数 put_char了

pushad 指令用于将当前程序的所有32位通用寄存器的值依次压入栈中。

用pushad指令备份32位寄存器的环境,将8个32位全部备份了。PUSHAD是push all double,该指令压入所有双字长的寄存器,这里的“所有” 一共是8 个,它们的入栈先后顺序是: EAX->ECX->EDX->EBX->ESP->EBP->ESI->EDI, EAX 是最先入栈。

put_char:
    pushad                      ;备份32位寄存器环境,push all double,将8个32位寄存器都备份了。它们入栈的顺序为:EAX->ECX->EDX->EBX->ESP->EBP->ESI->EDI

 

这里是为gs 安装正确的选择子,为了防止将来因为GS为0导致CPU抛异常才提前加的,这和put_char本身的功能是无关的。 和硬件相关的访问都属于内核的工作,包括打印。我们要有一套机制来防止用户进程直接访问内核资源的这种越界行为。检测这种“越权”的行为是由CPU负责的,而真正起检测作用的是人给CPU设置的规则,即特权级。

这里所说的规则就是特权级,特权级分为0、1、2、3共4个等级,数字越小特权越大,在Linux中,内核工作在0级,用户进程工作在3级,特权级1、2都空着没用到。

用户进程需要用iretd返回指令上CPU运行的,CPU在执行iretd指令时会做特权检查:它检查DS、ES、FS和GS“数据”段寄存器(除了代码段CS和栈段SS寄存器之外的)。

在32位环境下,”数据”段寄存器中都是选择子,如果有任何一个段寄存器所指向的段描述符的DPL权限高于从iretd命令返回后的CPL(新的CPL,CPL就是加载到CS寄存器中选择子的RPL),CPU就会将该寄存器赋值为0。

CPU的原则:不能让高特权级的资源被低特权级的程序访问。所以要让CPU抛出异常,选择子为0表示选择子的索引位、TI位和RPL位都是0,所以会在GDT中检索到第0个段描述符。由于第0个段描述符是空的,所以CPU抛出异常。

用户进程的特权级由CS寄存器中选择子的RPL位决定,它将成为进程在CPU上运行时的CPL。
将来为用户进程初始化寄存器时,CS中的选择子RPL必须为3,进而它就是从iretd指令返回后的新CPL。而我们用于访问显存的GS寄存器,在新的CPL=3的情况下,无论为它赋予何值,其选择子所指向的段描述符中的DPL都必须等于3。

我们目前使用的显存段描述符是全局描述符表GDT中的第3个段描述符,但其DPL=0,怎么解决呢?

  • 为用户进程创建一个显存段描述符,DPL=3,专门给用户进程用。
  • 在打印函数中动手脚,将gs的值改为指向目前DPL=0的显存段描述符。

我们采用第二种方法,因为与硬件相关的必须请求内核的帮助。

所以,我们在初始化用户进程寄存器时,将gs赋值为0。用户进程在打印时,需要通过系统调用陷入内核,用户进程的CPL由3->0,执行内核代码,再将gs赋值为内核使用的现存段选择子即可。

mov ax,SELECTOR_VIDEO       ;需要保证gs中为正确的视频段选择子,为保险起见,每次打印都为gs赋值
mov gs,ax                   ;不能直接把立即数送入段寄存器

 

光标是字符的坐标,只不过是一维的线性坐标,是屏幕上所有字符以0为起始的顺序。在默认的80*25模式下,每行80个字符共25行,屏幕上可以容纳2000个字符,故该坐标值的范围是0~1999,第0行的所有字符坐标是0~24,,第1行的所有字符坐标是25~49,以此类推,最后一行的所有字符是1975~1999。因为一个字符占2B(第一个字节是ASCII码,第二个字节是格式),所以光标*2后才是字符在显存中的地址。光标的位置存放在光标坐标寄存器中。

光标并不是自动更新,光标坐标寄存器是可写的。CRT controller寄存器组中索引为0Eh和0Fh的寄存器分别为:Cursor Location High Register和Cursor Location Low Register,都是8位,分别存储光标的高8位和低8位。

访问CRT controller寄存器组的寄存器,要先往端口地址为0x3d4的Address Register中写入寄存器的索引;再向端口地址为0x3d5的Data Register读/写数据。

 

下面的代码就是用来获取光标值:

先设置待操作的寄存器索引,我们先获取的是坐标的高8位,所以要将索引0x0e写入Address Register寄存器,其端口为0x03d4.

确定了要操作的寄存器是Cursor Location High Register后,我们通过Data Register寄存器,其端口是0x3d5,将坐标读入到al寄存器,由于al中是坐标的高8位,所以将其存储在ah寄存器,对于in指令,如果源操作是8位寄存器,目的操作数必须是al,如果源操作数是16位寄存器,目的操作数必须是ax。然后同样的方法获取到坐标的低8位,至此,寄存器ax中是光标完整的16位坐标值。

;;;;;获取当前光标的位置;;;;;
;先获取高8位
mov dx,0x03d4               ;索引寄存器,03d4为Address Register,用于索引寄存器。
mov al,0x0e                 ;用于提供光标位置的高8位
out dx,al
mov dx,0x03d5               ;03d5是Data Register;可以写数据和读数据。通过读写数据端口0x3d5来获取/设置光标的位置
in al,dx                    ;得到光标位置的高8位
mov ah,al                   ;将得到的光标高8位放入ah中

;再获取低8位
mov dx,0x03d4
mov al,0x0f                 ;用于提供光标位置的低8位
out dx,al
mov dx,0x03d5
in al,dx

 

这里是将光标值从ax寄存器中复制到bx,这么做的原因是习惯用寄存器bx做基址寻址,还记得吗,在16位实模式下基址寄存器必须是bx或bp,变址必须是寄存器si或di,在32位保护模式下没必要这么做了,基址和变址寄存器可以是全部的32位的通用寄存器,就是刚才用pushad指令压入的那8个。以后的处理都要基于 bx 寄存器了,在此知道 bx现在已经是光标坐标值就行了,它是下一个可打印字符的位置。

;将光标位置存入bx,bx寄存器习惯性作为基址寻址。此时bx是下一个字符的输出位置。
mov bx,ax

 

这里是获取栈中压入的字符的ASCII 码,也就是待打印的字符,这是1 字节的数据。栈中除了调用put_char函数的返回地址占4字节外,还有最开始的pushad指令压入的8个32位的通用寄存器共32字节的数据,所以待打印的字符在栈顶偏移36字节的位置。

;获取栈中压入字符的ASCII码
mov ecx,[esp + 36]          ;pushad压入8*32b=32字节,加上主调函数4B的返回地址。故栈顶偏移36字节。

 

之后开始判断参数是什么字符,这里只把回车符CR (carriage_return)、换行符LF(line_feed)和退格键 backspace当作不可见字符,按照其实际控制意义来处理,其他字符暂时一律认为是可见字符。回车符的ASCI码是0xd,换行符的ASCII码是0xa,我们这里的处理是不管参数是回车符,还是换行符,一律按我们平时所理解的回车换行符(CRLF)处理(Linux中就把换行符处理成回车+换行),即这两个动作的合成:光标回撤到行首+换到下一行。

;判断字符是什么类型
cmp cl,0xd                  ;CR是0x0d,回车键
jz .is_carriage_return

cmp cl,0xa                  ;LF是0x0a,换行符
jz .is_line_feed

cmp cl,0x8                  ;BS是0x08,退格键
jz .is_backspace
jmp .put_other

 

接下来的重点就是这几个处理的函数了:

就是处理控制键(不可见字符)回车换行符及退格键,以及普通可见字符。 在这之前,bx 已经在mov bx,ax变成了下一个可打印字符的光标坐标值,光标值乘以 2 后便是光标在显存中的相对地址,接下来在该地址处写入字符。 同样在前面,mov ecx,[esp + 36] ,寄存器ecx已经是待打印的参数了。 由于字符的 ASCI 码只是 1 字节,所以只用寄存器 cl 就够了。

.is_backspace: 是用来处理退格键backspace的代码。 backspace的原理就是将光标向回移动1位,将该处的字符用空格覆盖。

先用dec指令先将bx减1,这样光标坐标便指向前一个字符了,再用shl指令将bx左移1位,相当于乘以2,用shl指令做乘法比用mul指令方便。 由于字符在显存中占2字节,低字节是ASCII码,高字符是属性,所以mov byte [gs:bx],0x20 也就是低字节处先把空格的ASCII码0x20写入,再通过inc指令把bx加上1,这样bx便指向了该字符的属性位置,然后mov byte [gs:bx],0x07 再将属性0x7写入到高字节(不如直接写入0x0720最简单,后面我们会这么做)。 0x7表示黑屏白字,其实这是显卡默认的前景色和背景色,所以不加也行。 此时的bx由于之前已经加1指向属性了,所以它现在已经变成了奇数,shr bx,1 通过右移指令shr将bx右移1位相当于除2取整,余数不要了,这样bx便由显存中的相对地址恢复成了光标坐标,此时的bx指向新覆盖的空格。 在不考虑余数的情况下,用右移指令做除法比div指令要省事。 由于删除了一个字符,bx中的光标坐标已经被更新为前一位,之后在jmp .set_cursor 跳到设置光标的流程.set_cursor,经过它的处理,光标才会显示在新位置。

.is_backspace:                  ;理论上将光标移到该字符前即可,但怕下个字符为回车等,原字符还留着当地,所以用空格/空字符0替代原字符
    dec bx                      ;bx值-1,光标指向前一个字符
    shl bx,1                    ;左移一位等于乘2,表示光标对应显存中的偏移字节
    mov byte [gs:bx],0x20       ;0x20表示空格
    inc bx                      ;bx+1
    mov byte [gs:bx],0x07       ;0x07表示黑屏白字,这是显卡默认的前景色和背景色,不加也行。
    shr bx,1                    ;右移一位表示除以2取整,bx由显存的相对地址恢复到光标位置
    jmp .set_cursor             ;设置光标位置

 

.put_other:是处理可见字符的代码,与上面处理backspace中的代码类似,最后通过inc指令把光标坐标bx值加1,使bx成为新的可以打印字符的坐标,之后再判断这个新坐标是否超过了屏幕显示的范围,这个新坐标值就是下次打印字符的位置。 前面说过了在80*25模式下屏幕可显示的字符数是2000,这里用cmp指令把下次打印字符的坐标和2000比较,若小于2000则表示下次打印时,字符还会在当前屏幕的范围之内,于是在第69行直接跳转到.set-cursor更新光标坐标。 如果下次打印字符的坐标不小于2000 (在咱们的应用中顶多是等于2000的情况),这意味着需要滚屏了。

.put_other:                     ;处理可见字符
    shl bx,1                    ;光标左移1位等于乘2,表示光标位置
    mov [gs:bx],cl              ;将ASCII字符放入光标位置中
    inc bx                      ;bx+1
    mov byte [gs:bx],0x07       ;字符属性,黑底白字
    shr bx,1                    ;右移一位表示除以2取整,bx由显存的相对地址恢复到光标位置
    inc bx                      ;bx+1,下一个光标值
    cmp bx,2000                 ;看是否需要滚屏
    jl .set_cursor              ;"JL"是"jump if less"(如果小于则跳转):若光标值<=2000,表示未写到。显存的最后,则去设置新的光标值,若超过屏幕字符数大小(2000),则换行(滚屏)。

 

需要滚屏的情况:

  • 新的光标值超出了屏幕右下角最后一个字符的位置。
  • 最后一行中任意位置有回车或换行符。

在80*25模式下的屏幕可显示字符数为2000。

显卡中设置屏幕上显示字符的起始地址的寄存器:Start Address High Register和Start Address Low Register。,如名字所示,它们分别设置地址的高8位和低8位。只要指定起始地址,屏幕自动从该地址开始,向后显示2000字符。

如果起始地址过大,显卡会将其在显存中回绕wrap around。

两种实现方式:

  • 通过Start Address High Register和Start Address Low Register来设置不同的起始地址,显存中可缓存16KB个字符,屏幕外的字符也可以找回。默认情况下Start Address High Register和Start Address Low Register都是0,默认情况下这两个寄存器的值是0,也就是说默认情况下屏幕上的内容是从显存的首地址(物理地址)0xb8000起,一直到以该地址向上偏移3999字节的地方。我们将他们固定为0,丢弃首行的字符。
  • 将1 ~ 24行的内容移到0 ~ 23行,将第0行数据覆盖掉。将第24行数据用空格覆盖,使它看起来像新行。将光标移动到第24行行首。

在本程序中我们使用第二种。

 

回到代码,要滚屏需要判断是不是需要滚屏,接下来是.is_carriage_return: 和.is_line_feed:

.is_line_feed:是处理回车换行符的代码,因为在滚屏操作中,除了将当前屏幕所有内容上移一行外,光标的坐标也要更新为下一行的行首,这实际上相当于在最后一行的行尾键入了回车符,在此用处理回车换行符的代码帮助我们实现上面第3步的更新光标值。回车换行本质上是两个操作,一个是回车carriage_return,,将光标回撤到当前行的行首,另一个是换行line_feed,就是切换到下一行。这两个动作合成到一起便是我们平时敲下一个回车键的效果:光标出现在下一行行首。

.is_carriage_return:是在处理回车符,也就是将光标回撤到行首。这里的方法是将光标坐标值 b× 对 80 求模,再用坐标值bx减去余数就是行首字符的位置。经过div除法操作后,dx寄存器中为余数。在sub bx,dx 用坐标值减余数,经过”sub bx,dx”后, bx便为当前行首坐标,实现了回车符的功能(不过目前还没有更新光标坐标寄存器,更新之后才算真正完成)。接下来是处理换行符line_feed,也就是将光标切换到下一行。方法是将当前光标坐标值加上每行的字符数80,这样便是下一行的坐标啦。这是在.is_carriage_return_end完成的,至此我们完成了回车、换行两个字符的处理,,我们滚屏操作中的第3步也完成了。

.is_line_feed:                  ;是换行符LF(\n)
.is_carriage_return:            ;是回车键CR(\r),\n和\r在Linux中都是\n的意思。
    xor dx,dx                   ;dx是被除数的高16位,清零
    mov ax,bx                   ;ax是被被除数的低16位,bx是光标位置
    mov si,80                   ;si = 80为除数
    div si                      ;对80取模,(dx + ax)/si = ax(商) + dx(余数) 即bx/80=几行(ax) + 第几列(dx)
    ;如果除数是16位,被除数就是32位,位于dx和ax(高16位,低16位)中;结果的商放在ax中,余数放入dx中
    sub bx,dx                   ;bx-dx表示将bx放在行首,实现了回车的功能。

 

在.is_carriage_return_end:的cmp bx,2000是回车换行符处理流程中自己的滚屏判断,它和前面所说的滚屏流程无关,不要误以为是接上面的滚屏说的。倘若没有之前的滚屏,在单独的回车换行处理流程中也要判断在将光标更新为下一行后是否超出了屏幕范围而需要滚屏。这就是我们前面所说的第2种需要滚屏的情况,即在最后一行中的任意一个位置有回车或换行符都将导致滚屏。

.is_carriage_return_end:        ;回车符处理结束,判断是否需要滚屏
    add bx,80
    cmp bx,2000
.is_line_feed_end:              ;若是LF,则光标移+80即可
    jl .set_cursor

 

.roll_screen就是滚屏的部分,先用 cld 指令清除方向位,就是把eflags寄存器中方向标志位DF (Direction Flag)清0。cld,字符串“搬运”指令movs[bwd]和rep三剑客组合完成大数据的复制。

mov ecx,960是将ecx赋值为960,它用来控制rep重复执行指令的次数,这里是要把第1~24行的字符整体往上提一行,复制到第0~23行。要复制的字符数是2000-80=1920个,每个字符是2字节,共3840 字节。我们是用 movsd 指令来复制的,它一次复制 4 字节数据,所以需要执行 3840/4=960 次。

mov esi,0xc00b_80a0是把要复制的起始地址赋给 esi寄存器,也就是屏幕第1行的起始地址,物理地址是0xb80a0。将来实现用户进程后,为方便用户进程的管理,此处会用其虚拟地址0xc00b80a0代替。

mov edi,0xc00b_8000是把目的地址赋给 edi寄存器,也就是屏幕的第 0 行的起始地址,物理地址是 0xb8000,同上,将来也会用其虚拟地址0xc00b8000代替。

rep movsd用 rep 指令配合 movsd 指令,开始循环复制,直至把第 24 行的数据复制完毕。

滚屏操作还差一步,需要将最后一行用空格填充,这是在.cls:中完成的。先准备复制的起始地址和循环次数。最后一行在显存中的偏移地址是3840,循环次数是每行的字节总数除以每次处理的字节数。

.roll_screen:                   ;若超过屏幕大小,开始滚屏:屏幕范围是0~23,滚屏原理是把1~24->0~23,再将24行用空格填充
    cld
    mov ecx,960                 ;2000-80=1920个字符,共1920*2=3840字节,一次搬运4字节,一共要搬运3840/4=960次
    mov esi,0xc00b_80a0         ;第1行行首,源索引地址寄存器
    mov edi,0xc00b_8000         ;第0行行首,目的索引地址寄存器
    rep movsd                   ;repeat move string doubleword,以32b为单位进行移动,直到ecx=0
    ;将最后一行填充为空白
    mov ebx,3840                ;最后一行从3840开始
    mov ecx,80                  ;一行80字符,每次清空1字符(2B),一行要移动80次

 

.cls:是准备清空最后一行,循环处理屏幕最后一行中的每1个字符(2 字节),一次写入2字节数据将字符置为黑屏白字的空格符。0x0720是一个空格的数据,低字节是空格的ASCII码0x20。高字节是前景色和背景色属性0x07,这里通过”mov dword[gs: ebx], 0x0720″处理1个空格,用了loop指令来实现循环执行,它也是用ecx作为循环计数器,每执行一次,ecx自动减1,直到为0时停止执行。前面ecx赋值为80了,每行80个字符共160字节,一次清空1个字符,即2字节,故循环次数是160/2=80次。第102行的mov bx, 1920是把bx设置为最后一行起始,bx作为光标坐标值,进入.set-cursor完成光标坐标的更新。

.cls:
    mov word [gs:ebx],0x0720    ;0x0720是黑底白字的空格键,一次清空一个字符(2B)
    add ebx,2                   ;ebx移动到下一个字符处
    loop .cls                   ;循环.cls,直到ecx=0
    mov bx,1920                 ;bx存放下一个字符的光标位置,即3840/2=1920

 

.set_cursor这是用来设置光标的代码,它把光标坐标寄存器设置为寄存器 bx 中的值。原理也是先通过0x3d4端口写入待操作寄存器的索引,这和之前获取坐标值的代码是一样的。只不过操作0x3d5端口不再是读取指令in,而是写入指令out,我们要把bx中的值更新到光标坐标寄存器的高8位和低 8 位。.put_char_done:完成了 put_char 的处理流程,用 popd 指令将之前入栈的 8 个 32 位寄存器恢复到各个的寄存器中,环境恢复ret指令返回。

.set_cursor:                    ;将光标设置为bx值
    ;先设置高8位
    mov dx,0x03d4               ;索引寄存器,通过0x3d4写入待操作寄存器的索引
    mov al,0x0e                 ;用于提供光标的高8位
    out dx,al
    mov dx,0x03d5               ;通过数据端口0x3d5来设置光标位置
    mov al,bh                   ;将bx的光标位置的高8位放入al中,通过al输入到dx = 0x3d5端口
    out dx,al                   ;[0x3d5端口] = bx高8位 = bh
    ;再设置低8位
    mov dx,0x03d4
    mov al,0x0f                 ;用于提供光标的低8位
    out dx,al
    mov dx,0x03d5               ;通过数据端口0x3d5来设置光标位置
    mov al,bl                   ;将bx的光标位置的低8位放入al中,通过al输入到dx = 0x3d5端口
    out dx,al                   ;[0x3d5端口] = bx低8位 = bl
    .put_char_done:
        popad                   ;将之前入栈的8个32b的寄存器出栈
        ret

 

print.S 中的函数 put_char对于其他文件来说属于外部函数,要是想在其他文件中用到的话,各文件得将其声明加进来。凡是用到此函数的文件都要加上其声明,这样比较麻烦,所以还是将其写成头文件,谁需要它就将其包含进来就行了。

为防止头文件被重复包含,避免头文件中的变量等出现重复定义的情况。可以用条件编译指令#ifndef和#endif来封闭文件的内容,把要定义的内容放在它们之中。前2行是以print.h所在的路径定义了这个宏_LIB_KERNEL_PRINT_H,以该宏来判断是否重复包含。第3行是通过include指令包含了”stdint.h”,这里是用双引号括住了stdint.h,目的是包含自己指定的文件,如果是用尖括号<>括住的,这是让编译器到系统头文件所在的目录中找所包含的文件,第4行就是一句简单的声明,给出put_char函数的原型,虽然put_char是用汇编语言写的,但它被C语言引用时,在C语言中的形式还是得符合C语言语法,之前已经讲过了cdecl调用约定,所以put_char的C语言形式是void put_char (uint8_t char_asci),这样put_char才能从栈中获取参数 char_asci。这里的 char_asci 是无符号 8 位整型变量(其实就是 unsigned char)

文件名为/kernel/print.h

#ifndef __LIB_KERNEL_PRINT_H    //防止头文件被重复包含
#define __LIB_KERNEL_PRINT_H    //以print.h所在路径定义了这个宏,以该宏来判断是否重复包含
#include "stdint.h"
void put_char(uint8_t char_asci);
#endif

 

下面是main.c,使用我们定义的put_char

#include "print.h"
void main(void){
    put_char('k');
    put_char('e');
    put_char('r');
    put_char('n');
    put_char('e');
    put_char('l');
    put_char('\n');
    put_char('1');
    put_char('2');
    put_char('\b');
    put_char('3');
    while (1);
}

 

我们需要用到用于 i386 架构编译的必要头文件和库,下载即可。

sudo apt-get install libc6-dev-i386

 

 

接着就是编译、链接、写入虚拟硬盘三步了

编译print.S

nasm -f elf -o lib/kernel/print.o lib/kernel/print.S

编译main.c,-m32这个选项会告诉编译器生成 i386 架构的目标文件

gcc -m32 -I lib/kernel -c -o kernel/main.o kernel/main.c

 

在上面的链接阶段,目标文件链接顺序是main.o在前,print.o在后, main.c文件中用到了print.o中的put_char函数,在链接顺序上,属于”调用在前,实现在后”的顺序。

使用 -m elf_i386 或 -m32 选项来指定链接器生成的目标文件是 i386 架构的 ELF 格式。

ld -m elf_i386 -Ttext=0xc0001500 -e main -o kernel/kernel.bin kernel/main.o lib/kernel/print.o

写入硬盘

sudo dd if=bin/mbr.bin of=/bochs/bin/dreams.img bs=512 count=1 conv=notrunc
sudo dd if=bin/loader.bin of=/bochs/bin/dreams.img bs=512 count=4 seek=2 conv=notrunc
sudo dd if=kernel/kernel.bin of=/bochs/bin/dreams.img bs=512 count=200 seek=9 conv=notrunc

运行结果如下:

 

 

3.参考

郑钢著操作系统真象还原

田宇著一个64位操作系统的设计与实现

丁渊著ORANGE’S:一个操作系统的实现

暂无评论

发送评论 编辑评论

|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇