概述

  1. 实现 put_char() 函数,这是最基础的系统级打印函数,其他打印函数都基于此函数

  2. 实现 put_str() 函数,该函数以 put_char() 为基础,极大地方便了字符串的打印。

  3. 实现 put_int() 函数,该函数以 put_str() 为基础,支持有符号 32 位整型的打印,同时支持十进制与十六进制格式打印

  4. 后续文章将利用以上函数实现 printf() 可变参打印函数。

    注意,前三者是系统级打印函数,也就是所谓的系统调用,和普通的库函数(如printf)要区分开。

函数原型如下

enum radix{HEX=16,DEC=10};
put_char(char, unsigned char);  //参数1:字符;  参数2:字符属性
put_str(char*, unsigned char);  //参数1:字符串; 参数2:字符属性
put_int(int, unsigned char, enum radix); //参数1:数字; 参数2:字符属性; 参数3:进制

实现打印函数之前,我们还需要了解一些显存的知识。毕竟是系统调用,多多少少都会直接操作硬件,话不多说,开干。

显存的端口操作

之前咋们都是通过直接操控 0xb8000 的显存区域来实现屏幕输出,为啥现在要使用端口啦?简单来说,是为了方便。我们之前一直使用如下类似的方式进行打印:

mov ax,0xb8000  ;现在是在保护模式下,所以是0xb8000而非0xb800
mov gs,ax
mov [gs:0],'w'
mov [gs:2],'o'
mov [gs:4],'w'
mov [gs:6],'!'

这种方式的麻烦之处在于:

  1. 一行代码只能打印一个字符。显然,我们不可能用这个方法打印一整屏的内容。
  2. 我们必须手动指定打印位置。屏幕内容少时还能接受,一旦屏幕内容较多,打印时稍有不慎就会将之前的内容覆盖。

而通过操作显存端口来获得光标位置后,我们就可以放心地将字符定位任务交给光标啦。

操作端口的直接原因就是为了获取光标位置。 需要注意的是,在实模式下可以通过 BIOS 中断来获取光标位置,进入保护模式后就不能再使用 BIOS 中断了,所以必须手动操作端口。

显卡一般有 CGA、EGA、VGA 三种显示标准,功能复杂,这使得显卡具备相当多的寄存器(端口)。我们知道,计算机系统为这些端口统一编址,每个端口占用一个地址(Intel 系统的寄存器地址范围为 0~65535,注意,这个地址可不是内存地址 )。如果为显卡的每个端口都分配一个系统端口

地址,这就十分浪费硬件资源了,毕竟显卡如果这么干,那就意味着其他硬件也能这么干,那端口地址不一会就会分配光啦。所以,制造商根据功能的不同将显卡寄存器分为不同的组(并排列成数组),每个组中有两个特殊的寄存器:1)Address Register ;2)Data RegisterAddress Register 作为数组的索引,通过该寄存器来指定要访问的寄存器;Data Register 则用来输入输出,相当于所有寄存器的读写窗口

CGA :彩色图形适配器,提供两种标准文字显示模式:40×25×16 色和 80×25×16 色;以及两种常用的图形显示模式:320×200×4 色和 640×200×2 色;
EGA :增强图形适配器,在显示性能方面(颜色和分辨率)介于 CGA 和 VGA 之间;
VGA :视频图形阵列,具有分辨率高、显示速率快、颜色丰富等优点,在彩色显示器领域得到了广泛的应用,VGA最早指的是显示器 640×480 这种显示模式。

仅作了解
以上只对显卡寄存器做了个简单的讲解,因为我们待会也只需要通过端口获取光标而已,就不再做过多阐述,避免劝退。

;获取光标
   mov dx, 0x03d4  ;索引寄存器
   mov al, 0x0e	   ;用于提供光标位置的高8位
   out dx, al
   mov dx, 0x03d5  ;通过读写数据端口0x3d5来获得或设置光标位置
   in al, dx	   ;得到了光标位置的高8位
   mov ah, al

   ;再获取低8位
   mov dx, 0x03d4
   mov al, 0x0f
   out dx, al
   mov dx, 0x03d5
   in al, dx       ;此时ax中就存放着光标的位置

注意,读写 8 位端口时,只能用 al 中转;读写 16 位端口时,只能用 ax 中转。

print_char

这三个系统调用我们都使用汇编来写,实际上,这里使用汇编比 C 语言更简单。不用害怕,就 put_char 稍长一点,但其逻辑十分简单。

;------------------------   put_char   -----------------------------
;功能描述:把栈中的1个字符写入光标所在处
;-------------------------------------------------------------------
TI_GDT equ  0
RPL0  equ   0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
global put_char

section .text
put_char:
   pushad	              ;备份32位寄存器环境
   mov ax, SELECTOR_VIDEO ;需要保证gs中为正确的视频段选择子,为保险起见,每次打印时都为gs赋值
   mov gs, ax

;;;;;;;;;  获取当前光标位置 ;;;;;;;;;
   ;先获得高8位
   mov dx, 0x03d4  ;索引寄存器
   mov al, 0x0e	   ;用于提供光标位置的高8位
   out dx, al
   mov dx, 0x03d5  ;通过读写数据端口0x3d5来获得或设置光标位置
   in al, dx	   ;得到了光标位置的高8位
   mov ah, al

   ;再获取低8位
   mov dx, 0x03d4
   mov al, 0x0f
   out dx, al
   mov dx, 0x03d5
   in al, dx

   mov bx, ax                 ;将光标存入bx
   ;下面这行是在栈中获取待打印的字符
   mov ecx, [esp + 36]	      ;pushad压入4×8=32字节,加上主调函数的返回地址4字节,故esp+36字节
   mov edx, [esp + 40]        ;获取字符属性
   cmp cl, 0xd                ;CR(回车)是0x0d,LF(换行)是0x0a
   jz .is_carriage_return
   cmp cl, 0xa
   jz .is_line_feed

   cmp cl, 0x8                ;backspace的ascii码是8
   jz .is_backspace
   jmp .put_other             ;调转到可显示字符的打印
;backspace的一点说明:
;当为backspace时,本质上只要将光标移向前一个显存位置即可.后面再输入的字符自然会覆盖此处的字符
;但有可能在键入backspace后并不再键入新的字符,这时在光标已经向前移动到待删除的字符位置,但字符还在原处,
;这就显得好怪异,所以此处添加了空格或空字符0
.is_backspace:
   dec bx
   shl bx,1
   mov byte [gs:bx], 0x20     ;将待删除的字节补为0或空格皆可
   inc bx
   mov byte [gs:bx], 0x07     ;黑底白字
   shr bx,1
   jmp .set_cursor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
 .put_other:
   shl bx, 1				  ; 光标位置是用2字节表示,将光标值乘2,表示对应显存中的偏移字节
   mov [gs:bx], cl			  ; ascii字符本身
   inc bx
   mov byte [gs:bx],dl        ; 字符属性
   shr bx, 1				  ; 恢复老的光标值
   inc bx                     ; 下一个光标值
   cmp bx, 2000
   jl .set_cursor             ; 若光标值小于2000,表示未写到显存的最后,则去设置新的光标值
                              ; 若超出屏幕字符数大小(2000)则换行处理
 .is_line_feed:               ; 由于是效仿linux,linux中\n便表示下一行的行首,所以本系统中,
 .is_carriage_return:         ; 把\n和\r都处理为linux中\n的意思,也就是下一行的行首。
                              
   xor dx, dx                 ; dx是被除数的高16位,清0.
   mov ax, bx                 ; ax是被除数的低16位.
   mov si, 80                  
   div si                     
   sub bx, dx                 ; 光标值减去除80的余数便是取整
                              
 .is_CRLF_end:                ; 回车符CRLF处理结束
   add bx, 80
   cmp bx, 2000
   jl .set_cursor

;屏幕行范围是0~24,滚屏的原理是将屏幕的1~24行搬运到0~23行,再将第24行用空格填充
 .roll_screen:                ; 若超出屏幕大小,开始滚屏
   cld
   mov ecx, 960               ; 一共有2000-80=1920个字符要搬运,共1920*2=3840字节.一次搬4字节,共3840/4=960次
   mov esi, 0xb80a0	          ; 第1行行首
   mov edi, 0xb8000           ; 第0行行首
   rep movsd

;将最后一行填充为空白
   mov ebx, 3840              ; 最后一行首字符的第一个字节偏移= 1920 * 2
   mov ecx, 80                ;一行是80字符(160字节),每次清空1字符(2字节),一行需要移动80次
 .cls:
   mov word [gs:ebx], 0x0720  ;0x0720是黑底白字的空格键
   add ebx, 2
   loop .cls
   mov bx,1920                ;将光标值重置为1920,最后一行的首字符.

 .set_cursor:
;将光标设为bx值
;;;;;;; 1 先设置高8位 ;;;;;;;;
   mov dx, 0x03d4             ;索引寄存器
   mov al, 0x0e               ;用于提供光标位置的高8位
   out dx, al
   mov dx, 0x03d5             ;通过读写数据端口0x3d5来获得或设置光标位置
   mov al, bh
   out dx, al

;;;;;;; 2 再设置低8位 ;;;;;;;;;
   mov dx, 0x03d4
   mov al, 0x0f
   out dx, al
   mov dx, 0x03d5
   mov al, bl
   out dx, al
 .put_char_done:
   popad
   ret

这里的代码笔者直接扣的《操作系统:真相还原》(略作修改),代码注释已经非常清晰,下面对部分内容做说明:

  • 第 7,8 行:为了防止将来因为 GS=0 导致 CPU 抛出异常(选择子不能为0,还记得吗),这和特权级有关,后面文章会剖析。
  • 第 28,29 行:这里直接使用 esp 来定位参数,并不规范。一般我们会在函数开头 push ebpmov ebp,esp ,然后使用 ebp 来定位参数。
  • 第 77,81 行,cldrep movsd 详见汇编入门

以上就是 put_char 的内容,代码多,但逻辑简单。

put_str

put_strput_char 为基础,代码相对简单。

;--------------------------------------------
;put_str 通过put_char来打印以0字符结尾的字符串
;--------------------------------------------
;输入:参数1:字符串 参数2:字符属性
;输出:无
global put_str
put_str:
;由于本函数中只用到了ebx和ecx,只备份这两个寄存器
   push ebx
   push ecx
   push edx
   xor ecx, ecx           ; 准备用ecx存储参数,清空
   mov ebx, [esp + 16]    ; 从栈中得到待打印的字符串地址
   mov edx, [esp + 20]    ; 获取字符属性
.goon:
   mov cl, [ebx]
   cmp cl, 0              ; 如果处理到了字符串尾,跳到结束处返回
   jz .str_over
   push edx               ; 传递字符属性参数
   push ecx               ; 为put_char函数传递参数
   call put_char
   add esp, 8             ; 回收参数所占的栈空间
   inc ebx                ; 使ebx指向下一个字符
   jmp .goon
.str_over:
   pop edx
   pop ecx
   pop ebx

   ret
  • 第 13,14 行,同样不规范,请读者试试用 ebp 定位参数。不清楚的朋友可参考:函数调用过程
  • 第 22 行,外平栈,不熟悉的朋友仍请参考函数调用过程

put_int

put_str 和 put_char 笔者直接使用的《操作系统:真相还原》中的代码,而 put_int 为笔者原创,添加了有符号数打印与十六进制格式打印,代码质量不敢作保证,如有错误,请读者指出。下面内容较多,请读者打起精神继续阅读,哈哈。

;====================put_int===========================================
;参数1:数字  参数2:字符属性 ;参数3:进制
section .data
buffer times 12 db 0     ;字符串缓冲区
sign db 0                ;符号标记

section .text
global put_int
put_int:
    push ebp             ;保存原函数栈底
    mov ebp,esp          ;ebp指向本函数栈底
    pushad               ;保存所有通用寄存器

    mov eax,[ebp+8]      ;取得参数1
    mov ebx,eax          ;备份
    mov byte [sign],1    ;先默认该数为正
    mov edi,11           ;edi作为变址寄存器,指向buffer[11]
    mov esi,buffer       ;esi作为基址寄存器,指向buffer[0]
    mov cl,31            ;右移的位数如果不是1,则必须使用cl储存
    shr eax,cl           ;将数字右移31位,得到符号位
    cmp eax,1            ;如果符号位为1,则说明该数为负
    jne  .positive       ;如果不为负,则跳转至.positive处理正数

.negative:
    mov byte [sign],0    ;符号标志位设为0,表示负数
    not ebx              
    inc ebx              ;取反并加1,得到相反数,即得正数,并进入下面.positive

.positive:
    mov ax,bx
    mov cl,16
    shr ebx,cl
    mov dx,bx            ;以上四步将参数1的高16位存入dx,低16位存入ax
.loop:
    mov cx,[ebp+16]      ;取得进制
    call divdw           ;输入:ax:数字的低16位  dx:数字的高16位  cx:除数|输出:cx:余数 ax:商的低16位 dx:商的高16位
    sub edi,1            ;指定该字符的预放置位置
    cmp cl,10            ;cx存的余数,最大不超过16,故余数一定在cl中,直接使用cl
    jb  .dec             ;如果小于十就跳转到10进制处理,大于10就去16进制处理
.hex:
    add cl,'a'-10        ;将该数字转为字母(16进制)
    jmp .@2
.dec:
    add cl,'0'           ;将该数字转为数字字符
.@2:
    mov [esi+edi],cl     ;将该字符移入缓冲区
    mov cl,16            
    mov bx,dx
    shl ebx,cl
    mov bx,ax            ;以上4步将商存入ebx
    cmp ebx,0            
    jne .loop            ;如果商为0,则该数处理完毕

.@1:
    mov cx,[ebp+16]      ;如果为16进制,则在数字前还要加上0x
    cmp cx,16
    jne .sign            ;如果为10进制数,则直接处理符号
    sub edi,1
    mov byte [esi+edi],'x'
    sub edi,1
    mov byte [esi+edi],'0'

.sign:
    mov al,[sign]
    cmp al,0
    jne .@3              ;若为正数,则跳转到.@3直接打印数字
    sub edi,1
    mov byte [esi+edi],'-'
.@3:
    push dword [ebp+12]
    add  esi,edi
    push esi
    call put_str
    add esp,8

    popad
    pop ebp              ;恢复原函数栈底
    ret
;============================
;输入:ax:数字的低16位  dx:数字的高16位  cx:除数
;输出:cx:余数 ax:商的低16位 dx:商的高16位
divdw:
    push ax
    mov ax,dx
    mov dx,0
    div cx               ;div后,ax存放商,dx存放余数
    mov bx,ax
    pop ax
    div cx
    mov cx,dx
    mov dx,bx
    ret

代码注释很详细,笔者只解释以下几个地方:

  • 如何在 buffer 中定位字符?流程如下:

  • 第 10,11 行使用 ebp 来定位参数。笔者在这吃过大亏,曾想当然地省略了第 10 行,结果就是排了一天的错。在函数内使用过的寄存器一定要提前保存!

  • 为什么第 39 行除法不直接使用 div 指令,而使用 divdw 函数呢?这是因为 div 可能发生溢出,即 除法溢出 ,这将引发 CPU 异常。div 指令功能为:如果除数为 16 位,则被除数须为 32 位,高位放在 DX 中,低位放在 AX 中;将商放入 AX,余数放入 DX。而当被除数为 100000,除数为 1 时,商就无法完全存入 AX,从而发生溢出。为了避免这一问题,我们就用 divdw 函数来进行除法操作。divdw 原理剖析见文末。

  • 注意,字符串末尾必须为 0 !在 C 语言中,字符串 “abcd” 会在编译时由编译器在其末尾加 0,但是在汇编中,0 必须要我们自己加!

    \n 也是如此,在汇编中,以下数据:

    data db"wow!\n"
    

    最后的 \n 会被解析为 \n !这是因为高级语言中的 \n 是在编译阶段被识别并处理为 ASCII 码 0x8 ,这个转换是编译器的功劳。而我们自己手写汇编时,可不会还经过编译器处理。

  • 有人可能不明白为什么三个参数在栈中的位置分别是 [ebp+18],[ebp+12],[ebp+16],这意味着这三个参数的大小都是 4 字节。问题在于,我们的函数原型是 put_int(int, unsigned char, enum radix); ,第二个参数是 char 呀,不应该只压入 1 个字节吗?是这样的,C 语言不管函数参数类型是 char 还是 short 或者 int,压参时每个参数都会压入 4 字节 ,关于这点的讨论请参见C和汇编混合编程

关于上面的除法溢出,可以利用后面将学习的中断描述符表(IDT)来检验,如下:

显然,除法溢出引发 CPU 的 0 号异常。

最后,来看看效果:

大功告成!

另外,负十六进制数一般是由补码形式来显示的,这里转换就比较复杂,所以上面的 put_int 没考虑这一点,直接在十六进制数前加负号。

补更:后续学习中发现有符号整型不够用(比如显示地址,大于 2GB 就为负了),因此还需要一个无符号整型打印函数 put_uint,该函数的实现也只是在 put_int 上稍作修改,具体请参考 memory 分支。

divdw原理浅析

为避免除法溢出,我们将一次除法分解成两次除法,核心公式为:
X/n=(H<<16+L)/n=(H/n)<<16+(L/n)

其中,H=X>>16,L=X\%(2^{16})

我们一步一步来分析:

  1. 首先我们要知道,X!=(X>>16)<<16 ,这个大家一定都清楚。正确的等式(注意是等式,而非赋值)应该为:X=(X>>16)<<16+X\%2^{16} ,即得 X=(H<<16+L)
  2. 接下来的问题是,(H<<16)/n 如何得到 (H/n)<<16 ?这个简单:
    (H<<16)/n=(H*65536)/n=H*65536/n=(H/n)*65536=(H/n)>>16
    得证。

由此,我们便将 X/n 分解成了 H/n 和 L/n ,这无论如何也不可能发生溢出。

文章作者: 极简
本文链接:
版权声明: 本站所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 后端技术分享
自制操作系统
喜欢就支持一下吧