一个操作系统的设计与实现——第6章 显卡驱动

发布时间 2023-11-12 09:51:25作者: 樱雨楼

进入内核以后,应该做些什么呢?本章将实现一个最容易看到效果的模块:显卡驱动。

6.1 什么是驱动

驱动这个词听起来很高大上,但实际上很简单,就是硬件的接口函数。在软件工程中,可以使用接口封装和简化设计,硬件也是一样。例如:想要读硬盘,需要很多指令设定好几个端口,然后等待硬盘就绪,最后才能读硬盘。这一套流程可以封装成一个接口函数,其接受三个参数:

  1. 起始扇区号
  2. 读取的扇区数
  3. 数据存储的地址

这个函数就称为硬盘驱动。硬盘驱动的实现非常简单,但不是本章要讨论的内容。

6.2 显卡驱动的实现原理

本章要实现的是显卡驱动,说的再直白一些,就是printf函数。从0xb8000开始的4000字节决定了屏幕上显示的内容,所以,只需要往这段内存写入ASCII码,就能最终实现printf函数。不过,目前还缺少一样重要技术,那就是对光标的读写。如果没有光标,命令行的使用体验就会很差,无论是内核还是用户,都难以确定下一个要显示的字符应当出现在哪里。所以,对光标的读写是显卡驱动中的一个重要部分。

光标的读写需要使用一对端口:0x3d40x3d5。其中,0x3d4是索引端口,0x3d5是数据端口。这一对端口就像一个数组,想要读写数据,需要依次进行两步操作:

  1. 0x3d4端口写入一个索引值
  2. 0x3d5端口读出或写入数据

光标是一个16位的数字,其表示的是:从屏幕左上角开始偏移的字符数(注意不是字节数)。然而,0x3d40x3d5端口都是8位端口,所以,对光标的读写需要分成高8位与低8位分别进行,具体操作步骤如下:

  1. 0x3d4端口写入0xe
  2. 0x3d5端口读取或写入光标的高8位
  3. 0x3d4端口写入0xf
  4. 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行,分三种情况打印字符,分别为:

  1. 第61~69行,如果待打印的字符是\b,则将光标回退一格。这里需要小心:只有大于0的光标才能回退。光标回退后,还需要构造"删除字符"的假象,可以通过在光标处打印一个空格实现
  2. 第71~74行,如果待打印的字符是\n\r,则将光标修改为下一行的行首。可以通过先对光标做除法,再做乘法实现
  3. 第76~79行,对于其他字符,直接打印即可。打印后,需要将光标加1。请注意:光标是屏幕上字符的偏移量,不是显存地址的偏移量,二者之间是两倍的关系

第82~97行,用于实现滚屏。一屏幕有2000个字符,所以,当光标大于等于2000时就溢出了。此时需要将整个屏幕的内容向上抬一行,这包括以下三个步骤:

  1. 第84~88行,将第2~25行的显存复制到第1~24行。注意:AT&T语法的movsd指令应写为movsl
  2. 第90~94行,将第25行清空。类似的,AT&T语法的stosd指令应写为stosl。此外,清空显存不能使用"a"(0),如果这样做,颜色信息就丢失了
  3. 第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函数的各项功能。