15|控制台初始化tty_init:字符是怎么显示在屏幕上的?
你好,我是闪客。
上一讲我们说到,继内存管理结构 mem_map 和中断描述符表 idt 建立好之后,我们又通过 blk_dev_init 在内存中倒腾出了一个新的数据结构 request,并且把它们都放在了一个 request[32] 数组中。
这是块设备驱动程序与内存缓冲区的桥梁,通过它我们就可以完整地表示出一个块设备读写操作要做的事。
控制台初始化 tty_init
我们继续往下来看tty_init。
// init/main.c
void main(void) {
...
tty_init();
...
}
这个方法执行完成之后,我们将会具备从键盘输入字符、到显示器输出字符这个最常用的功能,这时我们能直观感受到操作系统有了点交互性!
打开这个函数后我有点慌。
// kernel/chr_drv/tty_io.c
void tty_init(void)
{
rs_init();
con_init();
}
看来这个方法已经多到需要拆成两个子方法了,不好对付!
打开第一个方法,发现还好。
// kernel/chr_drv/serial.c
void rs_init(void)
{
set_intr_gate(0x24,rs1_interrupt);
set_intr_gate(0x23,rs2_interrupt);
init(tty_table[1].read_q.data);
init(tty_table[2].read_q.data);
outb(inb_p(0x21)&0xE7,0x21);
}
这个方法开启了串口中断,设置了对应的中断处理程序。串口在我们现在的 PC 机上已经很少用到了,所以这个可以直接忽略。
看第二个方法,这是重点。代码非常长,有点吓人,我先把大体框架写出来。
// kernel/chr_drv/console.c
void con_init(void) {
...
if (ORIG_VIDEO_MODE == 7) {
...
if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
else {...}
} else {
...
if ((ORIG_VIDEO_EGA_BX & 0xff) != 0x10) {...}
else {...}
}
...
}
可以看出,这里有非常多的 if else。这是为了应对不同的显示模式,方便分配不同的变量值。如果我们只找出了一个显示模式,这些分支就可以只看一个了。
直写显存
啥是显示模式呢?那我们得先说说显示的问题,一个字符是如何显示在屏幕上的呢?换句话说,如果你可以随意操作内存和 CPU 等设备,你该怎么操作才能让你的显示器上显示一个字符“h”呢?
我们先看一张图。

内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于写在了屏幕上。
没错,就是这么简单。
如果我们写出这一行汇编语句,就是下面的样子。
mov [0xB8000],'h'
后面那个 h 相当于汇编编辑器帮我们转换成了 ASCII 码的二进制数值,当然我们也可以直接写成数值形式。
mov [0xB8000],0x68
这其实就是往内存中 0xB8000 这个位置写了一个值,只要写入一个值,屏幕上就会是下面这样。

简单吧?
具体说来,这片内存每两个字节可以表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色。我们先看编码,先不管颜色,如果多写几个字符就像下面这样。
mov [0xB8000],'h'
mov [0xB8002],'e'
mov [0xB8004],'l'
mov [0xB8006],'l'
mov [0xB8008],'o'
此时屏幕上就会是这样。

是不是相当简单?那我们回过头看刚刚的代码,我们就假设显示模式是我们现在的这种文本模式,那条件分支就可以去掉好多。
代码可以简化成下面这个样子。
// kernel/chr_drv/console.c
#define ORIG_X (*(unsigned char *)0x90000)
#define ORIG_Y (*(unsigned char *)0x90001)
void con_init(void) {
register unsigned char a;
// 第一部分 获取显示模式相关信息
video_num_columns = (((*(unsigned short *)0x90006) & 0xff00) >> 8);
video_size_row = video_num_columns * 2;
video_num_lines = 25;
video_page = (*(unsigned short *)0x90004);
video_erase_char = 0x0720;
// 第二部分 显存映射的内存区域
video_mem_start = 0xb8000;
video_port_reg = 0x3d4;
video_port_val = 0x3d5;
video_mem_end = 0xba000;
// 第三部分 滚动屏幕操作时的信息
origin = video_mem_start;
scr_end = video_mem_start + video_num_lines * video_size_row;
top = 0;
bottom = video_num_lines;
// 第四部分 定位光标并开启键盘中断
gotoxy(ORIG_X, ORIG_Y);
set_trap_gate(0x21,&keyboard_interrupt);
outb_p(inb_p(0x21)&0xfd,0x21);
a=inb_p(0x61);
outb_p(a|0x80,0x61);
outb(a,0x61);
}
别看这么多,实际上一点都不难。
还记不记得第一部分的时候我们做的工作,我们在内存中存了好多以后要用的数据。就在第 5 讲,你可以翻回去看看。

所以,第一部分获取 0x90006 地址处的数据,就是获取显示模式等相关信息。
第二部分就是显存映射的内存地址范围,我们现在假设它是 CGA 类型的文本模式,所以映射的内存就是从 0xB8000 到 0xBA000。
第三部分是设置一些滚动屏幕时需要的参数,定义顶行和底行是哪里。这里顶行就是第一行,底行就是最后一行,很合理。
第四部分是把光标定位到之前保存的光标位置处(取内存地址 0x90000 处的数据),然后设置并开启键盘中断。
开启键盘中断后,在键盘上敲击一个按键后就会触发中断,中断程序会读键盘码转换成 ASCII 码,然后写到光标处的内存地址,其实也就相当于往显存里写,于是这个键盘敲击的字符就显示在了屏幕上。
这一切具体是怎么做到的呢?我们先看看我们干了什么。
- 我们现在根据已有信息已经可以往屏幕上的任意位置写字符了,而且还能指定颜色。
- 我们也能接受键盘中断,根据键盘码中断处理程序就可以得知按下了哪个键。
有了这俩功能做铺垫,那我们想干嘛还不是为所欲为?
为x、y、pos参数赋值
好,接下来我们看看 con_init 里具体是怎么处理的,很简单。一切的起点,就是第四步的 gotoxy 函数,定位当前光标。
// kernel/chr_drv/console.c
#define ORIG_X (*(unsigned char *)0x90000)
#define ORIG_Y (*(unsigned char *)0x90001)
void con_init(void) {
...
// 第四部分 定位光标并开启键盘中断
gotoxy(ORIG_X, ORIG_Y);
...
}
这里面干嘛了呢?
// kernel/chr_drv/console.c
static inline void gotoxy(unsigned int new_x,unsigned int new_y) {
...
x = new_x;
y = new_y;
pos = origin + y*video_size_row + (x<<1);
}
就是给 x、y、pos 这三个参数附上了值。
其中 x 表示光标在哪一列,y 表示光标在哪一行,pos 表示根据列号和行号计算出来的内存指针。也就是说,往这个 pos 指向的地址写数据,就相当于往控制台的 x 列 y 行处写入字符了,简单吧?
然后,当你按下键盘后,触发键盘中断,之后的程序调用链是下面这样的。
_keyboard_interrupt:
...
call _do_tty_interrupt
...
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
...
tty->write(tty);
...
}
// 控制台时 tty 的 write 为 con_write 函数
void con_write(struct tty_struct * tty) {
...
__asm__("movb _attr,%%ah\n\t"
"movw %%ax,%1\n\t"
::"a" (c),"m" (*(short *)pos)
:"ax");
pos += 2;
x++;
...
}
通过中断调用到中断处理函数 _keyboard_interrupt,然后一路调用到 con_write 中的关键代码。
这段由 asm 包裹的内联汇编代码,就是把键盘输入的字符 c 写入 pos 指针指向的内存,相当于往屏幕输出了。而之后两行 pos+=2 和 x++,就是调整所谓的光标。
你看,写入一个字符,最底层其实就是往内存的某处写个数据,然后顺便调整一下光标位置。
由此我们也可以看出,光标的本质,其实就是这里的 x、y、pos 仨变量而已。
其他常见的控制台操作
除了数据的显示,我们还可以做换行效果,当发现光标位于某一行的结尾时(这个应该很好算吧,我们都知道屏幕上一共有几行几列了),就给光标计算出一个新值,让其处于下一行的开头。
要做到这点一个小计算公式即可搞定,它仍然在 con_write 源码处有体现,那就是判断列号 x 是否大于了总列数。
// kernel/chr_drv/console.c
void con_write(struct tty_struct * tty) {
...
if (x>=video_num_columns) {
x -= video_num_columns;
pos -= video_size_row;
lf();
}
...
}
static void lf(void) {
if (y+1<bottom) {
y++;
pos += video_size_row;
return;
}
...
}
相似地,我们还可以实现滚屏的效果,无非就是当检测到光标已经出现在最后一行最后一列时,就把每一行的字符都复制到它上一行。本质上就是算好将哪些内存地址上的值拷贝到哪些内存地址就可以了。
这里我还是鼓励你自己看看源码。
有了这个初始化工作,我们就可以利用这些信息弄几个小算法,实现各种我们常见的控制台操作了。或者换句话说,我们见惯不怪的控制台:回车、换行、删除、滚屏、清屏等操作,其实底层都要实现相应的代码。
console.c 中的其他方法其实就是做这个事的。我们简单来看看有哪些方法,就不展开每一个功能的方法体了。
// 定位光标的
static inline void gotoxy(unsigned int new_x, unsigned int new_y){}
// 滚屏,即内容向上滚动一行
static void scrup(void){}
// 光标同列位置下移一行
static void lf(int currcons){}
// 光标回到第一列
static void cr(void){}
...
// 删除一行
static void delete_line(void){}
这部分内容繁多,但没什么难度,你只要理解了基本原理即可了。
OK,整个 console.c 就讲完了。要知道,这个文件可是整个内核中代码量最大的文件。不过它的功能特别单一,也都很简单,主要是处理键盘各种不同的按键,需要我们写好多 switch case 等语句,我们这里就完全没必要去展开了,其实就是个苦力活。
总结时刻
到这里, tty_init 的作用也正式讲完了。
在此之后,内核代码就可以用它来方便地在控制台输出字符啦!如果内核想要在启动过程中告诉用户一些信息,或者等到内核完全建立起来之后,用户用 Shell 进行操作时手动输入命令,都是可以用到这里的代码的!
下节课让我们继续向前进发,看下一个被初始化的倒霉鬼是什么东东。
我是闪客,欲知后事如何,且听下回分解。