进入内核以后,应该做些什么呢?本章将实现一个最容易看到效果的模块:显卡驱动。
6.1 什么是驱动
驱动这个词听起来很高大上,但实际上很简单,就是硬件的接口函数。在软件工程中,可以使用接口封装和简化设计,硬件也是一样。例如:想要读硬盘,需要很多指令设定好几个端口,然后等待硬盘就绪,最后才能读硬盘。这一套流程可以封装成一个接口函数,其接受三个参数:
- 起始扇区号
- 读取的扇区数
- 数据存储的地址
这个函数就称为硬盘驱动。硬盘驱动的实现非常简单,但不是本章要讨论的内容。
6.2 显卡驱动的实现原理
本章要实现的是显卡驱动,说的再直白一些,就是printf
函数。从0xb8000
开始的4000字节决定了屏幕上显示的内容,所以,只需要往这段内存写入ASCII码,就能最终实现printf
函数。不过,目前还缺少一样重要技术,那就是对光标的读写。如果没有光标,命令行的使用体验就会很差,无论是内核还是用户,都难以确定下一个要显示的字符应当出现在哪里。所以,对光标的读写是显卡驱动中的一个重要部分。
光标的读写需要使用一对端口:0x3d4
与0x3d5
。其中,0x3d4
是索引端口,0x3d5
是数据端口。这一对端口就像一个数组,想要读写数据,需要依次进行两步操作:
- 向
0x3d4
端口写入一个索引值 - 从
0x3d5
端口读出或写入数据
光标是一个16位的数字,其表示的是:从屏幕左上角开始偏移的字符数(注意不是字节数)。然而,0x3d4
与0x3d5
端口都是8位端口,所以,对光标的读写需要分成高8位与低8位分别进行,具体操作步骤如下:
- 向
0x3d4
端口写入0xe
- 从
0x3d5
端口读取或写入光标的高8位 - 向
0x3d4
端口写入0xf
- 从
0x3d5
端口读取或写入光标的低8位
综上,基于光标读写和显存,就能实现出显卡驱动了。
6.3 内联汇编
光标的读写函数可以使用汇编语言实现,也可以使用内联汇编。内联汇编适用于在C语言代码中插入一段短小的汇编代码,在我们的操作系统中比较常用。本节将介绍GCC提供的拓展内联汇编语法。
拓展内联汇编的框架如下:
__asm__ __volatile__(
汇编代码...
: 输出约束
: 输入约束
: 寄存器或内存修改指示
);
__asm__
是内联汇编的关键词,__volatile__
用于阻止编译器对内联汇编代码进行任何优化,这对于操作系统的代码来说是必须的。
这四个部分不需要每次都全部写出,具体来说,如果只有汇编代码和输出约束,就可以这样写:
__asm__ __volatile__(
汇编代码...
: 输出约束
);
然而,如果只有输入约束而没有输出约束,则输出约束前面的冒号不可省略,否则就会引起歧义:
__asm__ __volatile__(
汇编代码...
:
: 输入约束
);
下面分别对这四个部分进行讨论。
6.3.1 汇编代码
内联汇编使用的是AT&T汇编语言,汇编代码以字符串的形式给出,各指令之间以需要以分号或换行符隔开。例如:
__asm__ __volatile__("pushf; popf");
或:
__asm__ __volatile__(
"pushf\n\t"
"popf\n\t"
);
此外,寄存器需要额外前置一个百分号,例如:
__asm__ __volatile__(
"mov %%eax, %%ebx\n\t"
"inc %%ebx\n\t"
);
这样做的原因将在下文中讨论。
6.3.2 输出约束
内联汇编是插入到C语言代码中的一段汇编代码。所以,其需要通过输出约束和输入约束与外面的C语言代码进行衔接。输出约束的语法如下:
: "..."(变量名), "..."(变量名), ...
"..."
中填写的是一类特殊的字符,列举如下:
字符串 | 含义 |
---|---|
a | 使用EAX |
b | 使用EBX |
c | 使用ECX |
d | 使用EDX |
S | 使用ESI |
D | 使用EDI |
r | 使用任意通用寄存器 |
m | 使用内存寻址(即[...] ) |
g | 使用任意通用寄存器或内存寻址 |
此外,字符前面还需要添加以下字符中的一个:
字符串 | 含义 |
---|---|
= | 该变量仅用于输出 |
+ | 该变量先用于输入,再用于输出 |
内联汇编保证:当这段内联汇编结束后,括号中的这个变量的值,就是其选用的寄存器或内存中的值。例如:
unsigned N = 0;
__asm__ __volatile__("mov $6, %%eax": "=a"(N));
// 此时,N == 6
对于"=r"
或"=g"
这种比较模糊的约束,内联汇编提供了占位符语法。具体来说,从输出约束的第一个约束开始,到输入约束的最后一个约束结束,依次对约束从0开始编号,然后,第N个约束就可以使用%N
代替。例如:
unsigned N = 0;
__asm__ __volatile__("mov $6, %0": "=r"(N));
// 此时,N == 6
内联汇编认为,占位符比具体的寄存器更加常用,所以,指代具体寄存器时需要额外前置一个百分号。
占位符引出了一个问题:对于EAX来说,其还可以是AX,AL,AH,所以,占位符也需要与之对应的语法以区分寄存器的宽度。这三种情况在占位符中分别写作:%wN
,%bN
,%hN
。例如:
unsigned N = 0;
__asm__ __volatile__(
"mov $6, %b0\n\t"
"mov $6, %h0\n\t"
: "=r"(N)
);
// 此时,N == 0x606
6.3.3 输入约束
输入约束使用的字符串与输出约束一致,且不需要前置等号或加号。内联汇编保证:在执行这段内联汇编之前,会将变量的值传送到指定的寄存器或内存中。例如:
unsigned N = 0;
__asm__ __volatile__(
"mov %%eax, %%ebx\n\t"
: "=b"(N)
: "a"(6)
);
// 此时,N == 6
也可以使用占位符:
unsigned N = 0;
__asm__ __volatile__(
"mov %1, %0\n\t"
: "=r"(N)
: "r"(6)
);
// 此时,N == 6
如果在输出约束中使用了+
,则这个变量先作为输入,后作为输出,例如:
unsigned N = 0, M = 0x1000;
__asm__ __volatile__(
// %0作为输入
"add %0, %1\n\t"
"mov %1, %0\n\t"
// %0作为输出
: "+r"(N)
: "r"(M)
);
// 此时,N == 0x1000
又如:
unsigned N = 0;
__asm__ __volatile__(
"inc %0\n\t"
: "+r"(N)
);
// 此时,N == 1
6.3.4 寄存器或内存修改指示
内联汇编可能会修改一些寄存器或内存的值。对于已经出现在输入约束或输出约束中的那些,编译器是知道的,然而,如果汇编代码还修改了其他寄存器或内存,编译器就无从得知了。因此,这部分信息需要声明在修改指示中。
如果修改了其他寄存器,需要将其全名写在一个字符串中,如"ax"
,"edx"
等。寄存器的宽度在这里并不重要,编译器会一律当作最宽的寄存器处理。例如,就算只写了"al"
,编译器也会将其视为"eax"
。如果修改了内存,就需要写"memory"
。多个声明之间以逗号隔开。例如:
__asm__ __volatile__(
"mov %%eax, %%ebx\n\t"
"mov %%eax, %%ecx\n\t"
:
: "a"(6)
: "ebx", "ecx"
);
上例中,由于EAX已经在输入约束中声明过了,所以不需要在最后重复声明。
又如:
__asm__ __volatile__(
"movl $6, (%%eax)\n\t"
:
:
: "memory"
);
6.3.5 独占约束
最后,还要讨论一种非常特殊的情况:
unsigned CR3;
__asm__ __volatile__(
"mov %%cr3, %0\n\t"
"mov %1, %%cr3\n\t"
: "=r"(CR3)
: "r"(0x6)
);
这段代码看似没什么问题,但编译器实际生成的代码可能是这样的:
...
mov %cr3, %eax
mov %eax, %cr3
...
可以看到,%0
和%1
使用了相同的寄存器。这是因为:内联汇编只保证,当内联汇编结束以后,会将寄存器中的值传送到变量中,而在此之前,就没有任何保证了。所以,编译器可以将一个寄存器先用在别处,最后再用于输出。一般情况下,对输出约束中的寄存器的写入都发生在内联汇编的最后,所以不会产生问题,但在这个例子中不是这样,在对输出约束寄存器%0
写入后,又执行了其他代码,此时,一旦共用寄存器,就会产生错误。
想要避免这个问题,需要在输出约束中附加"独占约束",写作=&...
,这样一来,编译器就会为这个约束单独安排一个寄存器,保证不会共享。所以,正确的写法如下:
unsigned CR3;
__asm__ __volatile__(
"mov %%cr3, %0\n\t"
"mov %1, %%cr3\n\t"
: "=&r"(CR3) // 使用独占约束
: "r"(0x6)
);
6.4 显卡驱动的实现
请看本章代码6/Util.h
。
这个头文件中声明了一些杂项,包括bool
(相当于C语言标准库的stdbool.h
),定宽整数类型(相当于C语言标准库的stdint.h
的一部分),以及不定长参数的一种简化实现(相当于C语言标准库的stdarg.h
)。真正的stdarg.h
无法基于C语言本身实现,必须由编译器提供支持,这是因为不定长参数的位置与编译器的优化直接相关。这里的实现仅考虑cdecl调用约定,且不能在打开优化的编译模式下使用。
接下来,请看本章代码6/Print.h
。
这个头文件中声明了显卡驱动的各种函数。
接下来,请看本章代码6/Print.hpp
。
第6行,定义了供printHex
函数使用的16进制转换表。
__getSector
函数与__setSector
函数用于获取光标和设置光标,其使用内联汇编实现。
printChar
函数用于打印一个字符,它是显卡驱动中最重要的函数。无论要打印什么,最终都是通过调用这个函数实现的。
第57行,获取光标位置。
第59~80行,分三种情况打印字符,分别为:
- 第61~69行,如果待打印的字符是
\b
,则将光标回退一格。这里需要小心:只有大于0的光标才能回退。光标回退后,还需要构造"删除字符"的假象,可以通过在光标处打印一个空格实现 - 第71~74行,如果待打印的字符是
\n
或\r
,则将光标修改为下一行的行首。可以通过先对光标做除法,再做乘法实现 - 第76~79行,对于其他字符,直接打印即可。打印后,需要将光标加1。请注意:光标是屏幕上字符的偏移量,不是显存地址的偏移量,二者之间是两倍的关系
第82~97行,用于实现滚屏。一屏幕有2000个字符,所以,当光标大于等于2000时就溢出了。此时需要将整个屏幕的内容向上抬一行,这包括以下三个步骤:
- 第84~88行,将第2~25行的显存复制到第1~24行。注意:AT&T语法的
movsd
指令应写为movsl
- 第90~94行,将第25行清空。类似的,AT&T语法的
stosd
指令应写为stosl
。此外,清空显存不能使用"a"(0)
,如果这样做,颜色信息就丢失了 - 第96行,将光标设置为1920,这是最后一行的开头
第99行,将新的光标值写入显卡。一个字符就打印完成了。
printStr
函数,printInt
函数,printHex
函数都是对printChar
函数的封装。
printf
函数的实现使用了只有两个状态的自动机写法。这两个状态分别为:
- 没有看到百分号
- 上一个字符是百分号
这个printf
函数只支持%%
、%c
、%s
、%d
、%x
这几种格式,分别对应于上面实现的几个函数。
__cleanScreen
函数用于清屏,并设置光标为0。
printInit
函数是__cleanScreen
函数的封装,其用于初始化显卡驱动。
接下来,请看本章代码6/Kernel.c
。
第5行,调用printInit
函数,完成显卡驱动的初始化。
6.5 测试
本章代码6/Kernel.c
测试了printf
函数的各项功能。