操作系统(Linux Kernel 0.11Linux Kernel 0.12)解读整理——内核初始化(main init)之控制台工作
前言
在 Linux 内核中,字符设备主要包括控制终端设备和串行终端设备,对这些设备的输入输出涉及控制台驱动程序,这包括键盘中断驱动程序 keyboard.S 和控制台显示驱动程序 console.c,还有终端驱动程序与上层程序之间的接口部分。
终端驱动程序用于控制终端设备,在终端设备和进程之间传输数据,并对所传输的数据进行一定的处理。用户在键盘上键入的原始数据(Raw data),在通过终端程序处理后,被传送给一个接收进程;而进程向终端发送的数据,在终端程序处理后,会被显示在终端屏幕上或者通过串行线路被发送到远程终端。根据终端程序对待输入或输出数据的方式,可以把终端工作模式分成两种。一种是规范(canonical)模式,此时经过终端程序的数据将被进行变换处理,然后再送出。例如把 TAB 字符扩展为8个空格字符,用键入的删除字符(backspace)控制删除前面键入的字符等。使用的处理函数一般称为行规则(linediscipline)或行规程模块。另一种是非规范(non-canonical)模式或称原始(raw)模式。在这种模式下,行规则程序仅在终端与进程之间传送数据,而不对数据进行规范模式的变换处理。
键盘中断
按下键盘后会触发中断,CPU 收到你的键盘中断后,根据中断号,寻找由操作系统写好的键盘中断处理程序。
内核的初始化如下:
void main(void) {
...
trap_init();
...
}
void trap_init(void) {
int i;
set_trap_gate(0,÷_error);
set_trap_gate(1,&debug);
set_trap_gate(2,&nmi);
set_system_gate(3,&int3); /* int3-5 can be called from all */
set_system_gate(4,&overflow);
set_system_gate(5,&bounds);
set_trap_gate(6,&invalid_op);
set_trap_gate(7,&device_not_available);
set_trap_gate(8,&double_fault);
set_trap_gate(9,&coprocessor_segment_overrun);
set_trap_gate(10,&invalid_TSS);
set_trap_gate(11,&segment_not_present);
set_trap_gate(12,&stack_segment);
set_trap_gate(13,&general_protection);
set_trap_gate(14,&page_fault);
set_trap_gate(15,&reserved);
set_trap_gate(16,&coprocessor_error);
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
set_trap_gate(45,&irq13);
set_trap_gate(39,¶llel_interrupt);
}
上面的代码主要划分为
// set 了一堆 trap_gate
set_trap_gate(0, ÷_error);
...
// 又 set 了一堆 system_gate
set_system_gate(45, &bounds);
...
// 又又批量 set 了一堆 trap_gate
for (i=17;i<48;i++)
set_trap_gate(i, &reserved);
涉及内联汇编的set_trap_gate 和 set_system_gate,其作用是在中断描述符表中插入了一个中断描述符
#define _set_gate(gate_addr,type,dpl,addr) \
__asm__ ("movw %%dx,%%ax\n\t" \
"movw %0,%%dx\n\t" \
"movl %%eax,%1\n\t" \
"movl %%edx,%2" \
: \
: "i" ((short) (0x8000+(dpl<<13)+(type<<8))), \
"o" (*((char *) (gate_addr))), \
"o" (*(4+(char *) (gate_addr))), \
"d" ((char *) (addr)),"a" (0x00080000))
#define set_trap_gate(n,addr) \
_set_gate(&idt[n],15,0,addr)
#define set_system_gate(n,addr) \
_set_gate(&idt[n],15,3,addr)
往这个 idt 表里一项一项地写东西,其对应的中断号就是第一个参数,中断处理程序就是第二个参数。之后如果来一个中断后,CPU 根据其中断号,就可以到这个中断描述符表 idt 中找到对应的中断处理程序了。
set_trap_gate(0,÷_error);
set_system_gate(5,&overflow);
设置 0 号中断,对应的中断处理程序是 divide_error。设置 5 号中断,对应的中断处理程序是 overflow,是边界出错中断。而divide_error当 CPU 执行了一条除零指令的时候,会从硬件层面发起一个 0 号异常中断,然后执行由我们操作系统定义的 divide_error 也就是除法异常处理程序,执行完之后再返回。
其中trap 与 system 的区别仅仅在于,设置的中断描述符的特权级不同,前者是 0(内核态),后者是 3(用户态),这块展开将会是非常严谨的、绕口的、复杂的特权级相关的知识,可以理解为都是设置一个中断号和中断处理程序的存在优先级的对应关系就好了。
for (i=17;i<48;i++)
set_trap_gate(i,&reserved);
17 到 48 号中断都批量设置为了 reserved 函数,这是暂时的,后面各个硬件初始化时要重新设置好这些中断,把暂时的这个给覆盖掉。
最终,内存中那个 idt 此时如下图(低端1M,虚拟地址与物理地址是相同的):
键盘产生的中断的中断号是 0x21,此时这个中断号还仅仅对应着一个临时的中断处理程序 &reserved。
void main(void) {
...
trap_init();
...
tty_init();
...
sti();
...
}
void tty_init(void) {
rs_init();
con_init();
}
void con_init(void) {
...
set_trap_gate(0x21,&keyboard_interrupt);
...
}
tty_init (终端是一种字符型设备,它有多种类型。通常使用 tty来简称各种类型的终端设备。tty是 Teletype的缩写,它是一种由 Teletype 公司生产的最早出现的终端设备,样子很象电传打字机。)最后根据调用链,会调用到一行添加 0x21 号中断处理程序的代码,set_trap_gate。
而后面的 keyboard_interrupt 就是键盘的中断处理程序!
从这一行代码开始,键盘中断生效了!
现在的中断处于禁用状态,不论是键盘中断还是其他中断;sti 最终会对应一个同名的汇编指令 sti,表示允许中断。所以这行代码之后,键盘中断才真正开始生效!
串口中断的开启,以及设置对应的中断处理程序
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);
}
控制台显示
这些 if else 是为了应对不同的显示模式,来分配不同的变量值。
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 {...}
}
...
}
当可以随意操作内存和 CPU 等设备,内存中有这样一部分区域,是和显存映射的。就是你往这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。
mov [0xB8000],'h'
//h 相当于汇编编辑器帮我们转换成 ASCII 码的二进制数值,当然我们也可以直接写。
mov [0xB8000],0x68
//往内存中 0xB8000 这个位置写了一个值,只要一写,屏幕左上角则显示一个h
具体说来,这片内存是每两个字节表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色
//当先忽略颜色,屏幕左上角会出现hello
mov [0xB8000],'h'
mov [0xB8002],'e'
mov [0xB8004],'l'
mov [0xB8006],'l'
mov [0xB8008],'o'
假设显示模式是文本模式,那条件分支就可以去掉很多,简化成如下:
#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);
}
对照内存数据表
所以,第一部分获取 0x90006 地址处的数据,就是获取显示模式等相关信息。 第二部分就是显存映射的内存地址范围,现在假设是 CGA 类型的文本模式,所以映射的内存是从 0xB8000 到 0xBA000。 第三部分是设置一些滚动屏幕时需要的参数,定义顶行和底行是哪里,这里顶行就是第一行,底行就是最后一行,很合理。 第四部分是把光标定位到之前保存的光标位置处(取内存地址 0x90000 处的数据),然后设置并开启键盘中断。
开启键盘中断后,键盘上敲击一个按键后就会触发中断,中断程序就会读键盘码转换成 ASCII 码,然后写到光标处的内存地址,也就相当于往显存写,于是这个键盘敲击的字符就显示在了屏幕上。
gotoxy函数,定位当前光标
#define ORIG_X (*(unsigned char *)0x90000)
#define ORIG_Y (*(unsigned char *)0x90001)
void con_init(void) {
...
// 第四部分 定位光标并开启键盘中断
gotoxy(ORIG_X, ORIG_Y);
...
}
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++;
...
}
con_write 函数
其中 __asm__ 内联汇编,就是把键盘输入的字符 c 写入 pos 指针指向的内存,相当于往屏幕输出了。之后两行 pos+=2 和 x++,就是调整所谓的光标。
写入一个字符,最底层,其实就是往内存的某处写个数据,然后顺便调整一下光标。
(光标的本质,其实就是 x y pos 这三变量而已。)
换行效果
当发现光标位置处于某一行的结尾时(知道屏幕上一共有几行几列了,就把光标计算出一个新值,让其处于下一行的开头)。
判断列号 x 是否大于了总列数
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;
}
...
}
滚屏的效果,无非就是当检测到光标已经出现在最后一行最后一列了,那就把每一行的字符,都复制到它上一行,本质就是算好哪些内存地址上的值,拷贝到哪些内存地址即可。
具体而言,滚屏操作是指将指定开始行和结束行的一块文本内容向上移动(向上卷动 scroll up)或向下移动(向下卷动 scroll down)。如果将屏幕看作是显示内存上对应屏幕内容的一个窗口的话,那么将屏幕内容向上移即是将窗口沿显示内存向下移动;将屏幕内容向下移动即是将窗口向上移动。在程序中就是重新设置显示控制器中显示内存的起始位置 origin 以及调整程序中相应的变量。对于这两种操作各自都有两种情况。
对于向上卷动,当屏幕对应的显示内存窗口在向下移动后仍然在显示内存范围之内的情况,也即对应当前屏幕的内存块位置始终在显示内存起始位置(video_mem_start)和末端位置 video_mem_end 之间,那么只需要调整显示控制器中起始显示内存位置即可。但是当对应屏幕的内存块位置在向下移动时超出了实际显示内存的末端(video_mem_end)这种情况,就需要移动对应显示内存中的数据,以保证所有当前屏幕数据都落在显示内存范围内。在这第二中情况,程序中是将屏幕对应的内存数据移动到实际显示内存的开始位置处(video_mem_ start)。
程序中实际的处理过程分三步进行。首先调整屏幕显示起始位置 origin;然后判断对应屏幕内存数据是否超出显示内存下界(video_mem_end),如果超出就将屏幕对应的内存数据移动到实际显示内存的开始位置处(video_mem_start);最后对移动后屏幕上出现的新行用空格字符填满。
向下卷动屏幕的操作与向上卷屏相似,也会遇到这两种类似情况。只是由于屏幕窗口上移,因此会在屏幕上方出现一空行,并且在屏幕内容所对应的内存超出显示内存范围时需要将屏幕数据内存块往下移动到显示内存的末端位置。
见惯不怪的控制台,回车、换行、删除、滚屏、清屏等操作,其实底层都有实现相应的代码的。 其中 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){}
console.c 这个文件可是整个内核中代码量最大的文件,但是功能特别单一,也都很简单,主要是处理键盘各种不同的按键,需要写好多 switch case 等语句。其中的所有子程序都是为了实现终端屏幕写函数 con_write()以及进行终端屏幕显示的控制操作。
当往一个控制台设备执行写操作时,就会调用 con_write()函数。这个函数管理着所有控制字符和换码字符序列,这些字符给应用程序提供全部的屏幕管理操作。所实现的换码序列采用 vt102 终端的规格,这意味着当你使用 telnet 连接到一台非 Linux 主机时,你的环境变量应该有TERM=vt102。然而,对于本地操作最佳的选择是设置 TERM=console,因为 Limux 控制台提供了一个 vt102 功能的超集。
函数 con_write()主要由转换语句组成,用于每次处理一个字符的有限长状态自动转义序列的解释。在正常方式下,显示字符会使用当前属性直接写到显示内存中。该函数会从终端 tty_struct数据结构的写缓冲队列 write_q 中取出字符或字符序列,然后根据字符的性质(是普通字符、控制字符、转义序列还是控制序列),把字符显示在终端屏幕上,或进行一些光标移动、字符除等屏幕控制操作。
终端屏幕初始化函数 con_init()会根据系统启动初始化时获得的系统信息,设置有关屏幕的一些基本参数值,用于 con_write()函数的操作。(有关终端设备字符缓冲队列的说明可参见 include/linux/tty.h 头文件。其中给出了字符缓冲队列的数据结构 tty_queue、终端的数据结构 tty_struct 和一些控制字符的值。另外,其中还有一些对缓冲队列进行操作的宏定义。)
在此之后,内核代码就可以用它来方便地在控制台输出字符!这在之后内核想要在启动过程中告诉用户一些信息,以及后面内核完全建立起来之后,由用户用 shell 进行操作时手动输入命令,都是可以用到这里的代码的!