Linux0.11终端设备控制-哈工大操作系统实验七
本章主要讲解一下Linux0.11终端设备控制:键盘与显示器
实验要求
实验任务:修改 Linux 0.11 的终端设备处理代码,对键盘输入和字符显示进行非常规的控制。在初始状态,一切如常。用户按一次F12 后,把应用程序向终端输出所有字母都替换为“*”。用户再按一次F12,又恢复正常。第三次按 F12,再进行输出替换。依此类推。
实验本质:
IO的使用:让外设工作起来表现为CPU给外设的硬件寄存器发送指令,控制器完成真正的工作,向CPU发中断信号,然后CPU进行中断处理。操作系统要给用户提供一个简单视图-----文件视图,将CPU与外设链接起来。
简单来说就是,当按下F12后会进入特殊打印模式,此时所有的键盘按键输入后均为*,再次按下F12重新恢复正常状态,按键输入为正常输入值。按照惯例,还是先讲原理,再讲实现思路。
实验原理
1. 为什么按下键盘会有输入–显示器逻辑
https://cjdhy.blog.csdn.net/article/details/127737970
首先先看main函数:
void main(void) {
...
mem_init(main_memory_start,memory_end);
trap_init();
blk_dev_init();
chr_dev_init();
tty_init();
time_init();
sched_init();
buffer_init(buffer_memory_end);
hd_init();
floppy_init();
sti();
move_to_user_mode();
if (!fork()) {init();}
for(;;) pause();
}
其中的tty_init()就是键盘的初始化部分:
这个方法执行完成之后,我们将会具备键盘输入到显示器输出字符这个最常用的功能。
void tty_init(void)
{
rs_init();
con_init();
}
看来这个方法已经多到需要拆成两个子方法了。
打开第一个方法,还好。
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 机上已经很少用到了,所以这个直接忽略。
看第二个方法,这是重点。代码非常长,有点吓人,我先把大体框架写出。
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 等设备,你如何操作才能使得你的显示器上,显示一个字符‘a’呢?
我们先看一张图。
内存中有这样一部分区域,是和显存映射的。啥意思,就是你往上图的这些内存区域中写数据,相当于写在了显存中。而往显存中写数据,就相当于在屏幕上输出文本了。
没错,就是这么简单。
如果我们写这一行汇编语句。
mov [0xB8000],'h'
后面那个 h 相当于汇编编辑器帮我们转换成 ASCII 码的二进制数值,当然我们也可以直接写。其实就是往内存中 0xB8000 这个位置写了一个值,只要一写,屏幕上就会是这样。
简单吧,具体说来,这片内存是每两个字节表示一个显示在屏幕上的字符,第一个是字符的编码,第二个是字符的颜色,那我们先不管颜色,如果多写几个字符就像这样。
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 行处写入字符了,简单吧?(所以这里pos的值就相当于定位了光标的具体位置),接下来只要讲需要输出的内容在pos位置打印即可。
然后,当你按下键盘后,触发键盘中断,之后的程序调用链是这样的。
_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 这仨变量而已。
我们还可以做换行效果,当发现光标位置处于某一行的结尾时(这个应该很好算吧,我们都知道屏幕上一共有几行几列了),就把光标计算出一个新值,让其处于下一行的开头。
就一个小计算公式即可搞定,仍然在 con_write 源码处有体现,就是判断列号 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;
}
...
}
相似的,我们还可以实现滚屏的效果,无非就是当检测到光标已经出现在最后一行最后一列了,那就把每一行的字符,都复制到它上一行,其实就是算好哪些内存地址上的值,拷贝到哪些内存地址,就好了。
这里大家自己看源码寻找。
所以,有了这个初始化工作,我们就可以利用这些信息,弄几个小算法,实现各种我们常见控制台的操作。
或者换句话说,我们见惯不怪的控制台,回车、换行、删除、滚屏、清屏等操作,其实底层都要实现相应的代码的。
所以 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){}
2. 当键盘输入一条命令时–键盘执行逻辑
我们就从按下键盘上的 ‘c’ 键开始说起。
根据上文我们可知,首先,得益 tty_init 中讲述的一行代码。
// console.c
void con_init(void) {
...
set_trap_gate(0x21,&keyboard_interrupt);
...
}
我们成功将键盘中断绑定在了 keyboard_interrupt 这个中断处理函数上,也就是说当我们按下键盘 ‘c’ 时,CPU 的中断机制将会被触发,最终执行到这个 keyboard_interrupt 函数中。
// keyboard.s
keyboard_interrupt:
...
// 读取键盘扫描码
inb $0x60,%al
...
// 调用对应按键的处理函数
call *key_table(,%eax,4)
...
// 0 作为参数,调用 do_tty_interrupt
pushl $0
call do_tty_interrupt
...
随后,在 key_table 中寻找不同按键对应的不同处理函数,比如普通的一个字母对应的字符 ‘c’ 的处理函数为 do_self,该函数会将扫描码转换为 ASCII 字符码,并将自己放入一个队列里,我们稍后再说这部分的细节。
接下来,就是调用 do_tty_interrupt 函数,见名知意就是处理终端的中断处理函数,注意这里传递了一个参数 0。
我们接着探索,打开 do_tty_interrupt 函数。
// tty_io.c
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
...
}
这个函数几乎什么都没做,将 keyboard_interrupt 时传入的参数 0,作为 tty_table 的索引,找到 tty_table 中的第 0 项作为下一个函数的入参,仅此而已。
ty_table 是终端设备表,在 Linux 0.11 中定义了三项,分别是控制台、串行终端 1 和串行终端 2。
// tty.h
struct tty_struct tty_table[] = {
{
{...},
0, /* initial pgrp */
0, /* initial stopped */
con_write,
{0,0,0,0,""}, /* console read-queue */
{0,0,0,0,""}, /* console write-queue */
{0,0,0,0,""} /* console secondary queue */
},
{...},
{...}
};
我们用的往屏幕上输出内容的终端,就是 0 号索引位置处的控制台终端,所以我将另外两个终端定义的代码省略掉了。
tty_table 终端设备表中的每一项结构,是 tty_struct,用来描述一个终端的属性。
struct tty_struct {
struct termios termios;
int pgrp;
int stopped;
void (*write)(struct tty_struct * tty);
struct tty_queue read_q;
struct tty_queue write_q;
struct tty_queue secondary;
};
struct tty_queue {
unsigned long data;
unsigned long head;
unsigned long tail;
struct task_struct * proc_list;
char buf[TTY_BUF_SIZE];
};
说说其中较为关键的几个。
termios 是定义了终端的各种模式,包括读模式、写模式、控制模式等,这个之后再说。
void (*write)(struct tty_struct * tty) 是一个接口函数,在刚刚的 tty_table 中我们也可以看出被定义为了 con_write,也就是说今后我们调用这个 0 号终端的写操作时,将会调用的是这个 con_write 函数,这不就是接口思想么。
还有三个队列分别为读队列 read_q,写队列 write_q 以及一个辅助队列 secondary。
这些有什么用,我们通通之后再说,跟着我接着看。
// tty_io.c
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
signed char c;
while (!EMPTY(tty->read_q) && !FULL(tty->secondary)) {
// 从 read_q 中取出字符
GETCH(tty->read_q,c);
...
// 这里省略了一大坨行规则处理代码
...
// 将处理过后的字符放入 secondary
PUTCH(c,tty->secondary);
}
wake_up(&tty->secondary.proc_list);
}
展开 copy_to_cooked 函数我们发现,一个大体的框架已经有了。
在 copy_to_cooked 函数里就是个大循环,只要读队列 read_q 不为空,且辅助队列 secondary 没有满,就不断从 read_q 中取出字符,经过一大坨的处理,写入 secondary 队列里。
否则,就唤醒等待这个辅助队列 secondary 的进程,之后怎么做就由进程自己决定。
我们接着看,中间的一大坨处理过程做了什么事情呢?
这一大坨有太多太多的 if 判断,但都是围绕着同一个目的,我们举其中一个简单的例子。
#define IUCLC 0001000
#define _I_FLAG(tty,f) ((tty)->termios.c_iflag & f)
#define I_UCLC(tty) _I_FLAG((tty),IUCLC)
void copy_to_cooked(struct tty_struct * tty) {
...
// 这里省略了一大坨行规则处理代码
if (I_UCLC(tty))
c=tolower(c);
...
}
简单说,就是通过判断 tty 中的 termios,来决定对读出的字符 c 做一些处理。
在这里,就是判断 termios 中的 c_iflag 中的第 4 位是否为 1,来决定是否要将读出的字符 c 由大写变为小写。
这个 termios 就是定义了终端的模式。
struct termios {
unsigned long c_iflag; /* input mode flags */
unsigned long c_oflag; /* output mode flags */
unsigned long c_cflag; /* control mode flags */
unsigned long c_lflag; /* local mode flags */
unsigned char c_line; /* line discipline */
unsigned char c_cc[NCCS]; /* control characters */
};
比如刚刚的是否要将大写变为小写,是否将回车字符替换成换行字符,是否接受键盘控制字符信号如 ctrl + c 等。
这些模式不是 Linux 0.11 自己乱想出来的,而是实现了 POSIX.1 中规定的 termios 标准,具体可以参见:
https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap11.html#tag_11
好了,我们目前可以总结出,按下键盘后做了什么事情。
一、读队列 read_q 里的字符是什么时候放进去的?
还记不记得最开始讲的 keyboard_interrupt 函数,我们有一个方法没有展开讲。
// keyboard.s
keyboard_interrupt:
...
// 读取键盘扫描码
inb $0x60,%al
...
// 调用对应按键的处理函数
call *key_table(,%eax,4)
...
// 0 作为参数,调用 do_tty_interrupt
pushl $0
call do_tty_interrupt
...
就是这个 key_table,我们将其展开。
// keyboard.s
key_table:
.long none,do_self,do_self,do_self /* 00-03 s0 esc 1 2 */
.long do_self,do_self,do_self,do_self /* 04-07 3 4 5 6 */
...
.long do_self,do_self,do_self,do_self /* 20-23 d f g h */
...
可以看出,普通的字符 abcd 这种,对应的处理函数是 do_self,我们再继续展开。
// keyboard.s
do_self:
...
// 扫描码转换为 ASCII 码
lea key_map,%ebx
1: movb (%ebx,%eax),%al
...
// 放入队列
call put_queue
可以看到最后调用了 put_queue 函数,顾名思义放入队列,看来我们要找到答案了,继续展开。
// tty_io.c
struct tty_queue * table_list[]={
&tty_table[0].read_q, &tty_table[0].write_q,
&tty_table[1].read_q, &tty_table[1].write_q,
&tty_table[2].read_q, &tty_table[2].write_q
};
// keyboard.s
put_queue:
...
movl table_list,%edx # read-queue for console
movl head(%edx),%ecx
...
可以看出,put_queue 正是操作了我们 tty_table 数组中的零号位置,也就是控制台终端 tty 的 read_q 队列,进行入队操作。
答案揭晓了,那我们的整体流程图也可以再丰富起来。
二、放入 secondary 队列之后呢?
按下键盘后,一系列代码将我们的字符放入了 secondary 队列中,然后呢?
这就涉及到上层进程调用终端的读函数,将这个字符取走了。
上层经过库函数、文件系统函数等,最终会调用到 tty_read 函数,将字符从 secondary 队列里取走。
// tty_io.c
int tty_read(unsigned channel, char * buf, int nr) {
...
GETCH(tty->secondary,c);
...
}
取走后要干嘛,那就是上层应用程序去决定的事情了。
假如要写到控制台终端,那上层应用程序又会经过库函数、文件系统函数等层层调用,最终调用到 tty_write 函数。
// tty_io.
int tty_write(unsigned channel, char * buf, int nr) {
...
PUTCH(c,tty->write_q);
...
tty->write(tty);
...
}
这个函数首先会将字符 c 放入 write_q 这个队列,然后调用 tty 里设定的 write 函数。
终端控制台这个 tty 我们之前说了,初始化的 write 函数是 con_write,也就是 console 的写函数。
// console.c
void con_write(struct tty_struct * tty) {
...
}
这个函数之前的 tty_init 提到了,最终会配合显卡,在我们的屏幕上输出我们给出的字符。
那我们的图又可以补充了。
核心点就是三个队列 read_q,secondary 以及 write_q。
其中 read_q 是键盘按下按键后,进入到键盘中断处理程序 keyboard_interrupt 里,最终通过 put_queue 函数字符放入 read_q 这个队列。
secondary 是 read_q 队列里的未处理字符,通过 copy_to_cooked 函数,经过一定的 termios 规范处理后,将处理过后的字符放入 secondary。(处理过后的字符就是成"熟"的字符,所以叫 cooked,是不是很形象?)
然后,进程通过 tty_read 从 secondary 里读字符,通过 tty_write 将字符写入 write_q,最终 write_q 中的字符可以通过 con_write 这个控制台写函数,将字符打印在显示器上。
这就完成了从键盘输入到显示器输出的一个循环,也就是本回所讲述的内容。
整个流程也可以用PPT上的图来表示:
键盘中断初始化
键盘中断的初始化在boot/main.c文件的init()函数中,在这个函数中会调用tty_init()函数对显卡的变量等进行设置(如:video_size_row,vide_mem_start,video_port_reg,video_port_val,video_mem_end等等),并对键盘中断0x21设置,将中断过程指向keyboard_interrupt函数(这是通过set_trap_gate(0x21, &keyboard_interrupt)语句来实现的。
键盘中断发生
当键盘中断发生时,intb $0x60,%al 指令会取出键盘的扫描码,放在al寄存器中,然后查key_table表进行扫描码处理,key_table表中定义了相关所有扫描码对应键的处理函数(如do_self,func,alt,ctrl,none等),比如f1-f12键的处理则要先运行一段处理函数func,调用sched.c中定义的show_stat函数显示当前进程情况,默认情况linux-0.11中所有的功能键都进行这样的动作,然后下一步将控制键以及功能键进行转义,比如ctrl_a将会被转义为^a,f1会被转义为esc[[A,f2被转义为esc[[B,而其他键也经过类似处理,处理完毕后将跳到put_queue,将扫描码放在键盘输入队列中,当然如果没有对应的扫描码被找到,则会通过none标签直接退回,不会被放入队列。然后再调用do_tty_interrupt函数进行最后的处理。do_tty_interrupt直接调用copy_to_cooked函数对队列中的字符进行判断处理,最后调用con_write函数将其输出到显卡内存。在此同时,也会将字符放入辅助队列中,以备缓冲区读写程序使用。
字符输出流程
无论是输出字符到屏幕上还是文件中,系统最终都会调用write函数来进行,而write函数则会调用sys_write系统调用来实现字符输出,如果是输出到屏幕上,则会调用tty_write函数,而tty_write最终也会调用con_write函数将字符输出;如果是输出到文件中,则会调用file_write函数直接将字符输出到指定的文件缓冲区中。所以无论是键盘输入的回显,还是程序的字符输出,那只需要修改con_write最终写到显存中的字符就可以了。但如果要控制字符输出到文件中,则需要修改file_write函数中输出到文件缓冲区中的字符即可。
实验实现
先说实验思路,根据李老师在课程里的讲述
- 当用户按下F12时,相应的中断函数应该会跳转执行F12对应的功能处理函数 fun12
- 根据实验要求,按下F12会进行f12模式切换,这个模式可以用一个flag_12表示,当flag = 1则正常,flag = 0则全部打印*。那么模式切换函数就应当放在fun12中
- 接下来根据上述的讲解我们就只需要修改con_write函数即可,当flag表示我们处在特殊字符时,那么write_q中取出的正常字符,我们都转换成星号即可。如果是正常模式,那么就不执行星号的转换就可以了
所以就只需要实现F12函数以及修改con_write函数:
https://blog.csdn.net/weixin_43166958/article/details/104194920
/*fun f12*/
int switch_show_char_flag = 0;
void press_f12_handle(void)
{
if (switch_show_char_flag == 0)
{
switch_show_char_flag = 1;
}
else if (switch_show_char_flag == 1)
{
switch_show_char_flag = 0;
}
}
con_write函数的部分修改如下:
case 0:
if (c>31 && c<127) {
if (x>=video_num_columns) {
x -= video_num_columns;
pos -= video_size_row;
lf();
}
if (switch_show_char_flag == 1)
{
if((c>='A'&&c<='Z')||(c>='a'&&c<='z')||(c>='0'&&c<='9'))
c = '*';
}
__asm__("movb attr,%%ah\n\t"
"movw %%ax,%1\n\t"
::"a" (c),"m" (*(short *)pos)
);
pos += 2;
x++;
这样实验就完成了,整体的实验不难,需要了解键盘与显示器之间的逻辑关系。