概述

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

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

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

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

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

函数原型如下

1
2
3
4
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 的显存区域来实现屏幕输出,为啥现在要使用端口啦?简单来说,是为了方便。我们之前一直使用如下类似的方式进行打印:

1
2
3
4
5
6
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 这种显示模式。

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
;获取光标
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 稍长一点,但其逻辑十分简单。

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
;------------------------   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 为基础,代码相对简单。

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
;--------------------------------------------
;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 为笔者原创,添加了有符号数打印与十六进制格式打印,代码质量不敢作保证(本人菜比),如有错误,请读者指出。下面内容较多,请读者打起精神继续阅读,哈哈。

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
;====================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 也是如此,在汇编中,以下数据:

    1
    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)X/n=(H<<16+L)/n=(H/n)<<16+(L/n)

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

我们一步一步来分析:

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

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