编程环境:Ubuntu Kylin 16.04、gcc-7.3.0
代码仓库:https://gitee.com/AprilSloan/linux0.11-project
linux0.11源码下载(不能直接编译,需进行修改)
本章目标
本章将会完善终端,实现输入功能,完善输出功能。知识点涉及到键盘和终端控制。
1.数据结构介绍
之前我们在用 printk 函数打印字符串时,printk 函数调用 tty_write,tty_write 调用 con_write 都是直接对字符串进行操作的。这种方式并没有什么不好,只是不够灵活,想要实现更多的功能有难度,这一节,我们要使用一种数据结构替代字符串。
#define TTY_BUF_SIZE 1024
struct tty_queue {
unsigned long data;
unsigned long head;
unsigned long tail;
struct task_struct *proc_list;
char buf[TTY_BUF_SIZE];
};
采用的数据结构是循环队列,当缓冲区头尾指针超过缓冲区大小时,它们就会变成0,形成循环。
为了方便队列的操作,我们需要定义一些操作队列的宏定义。
#define INC(a) ((a) = ((a)+1) & (TTY_BUF_SIZE-1))
#define DEC(a) ((a) = ((a)-1) & (TTY_BUF_SIZE-1))
#define EMPTY(a) ((a).head == (a).tail)
#define LEFT(a) (((a).tail-(a).head-1)&(TTY_BUF_SIZE-1))
#define LAST(a) ((a).buf[(TTY_BUF_SIZE-1)&((a).head-1)])
#define FULL(a) (!LEFT(a))
#define CHARS(a) (((a).head-(a).tail)&(TTY_BUF_SIZE-1))
#define GETCH(queue,c) \
(void)({c=(queue).buf[(queue).tail];INC((queue).tail);})
#define PUTCH(c,queue) \
(void)({(queue).buf[(queue).head]=(c);INC((queue).head);})
有了这些宏定义,我们可以很方便地向队列写数据,从队列读数据,获取队列长度,判断队列是否为空等等。
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 termios 定义在 termios.h 中,termios.h 中还有许多与输入输出控制模式相关地宏定义,我们之后会用到这些宏定义,通过这些宏定义设置终端的输入输出模式。
对于键盘输入,我们会将字符放在读队列中,对于 printk 输出,我们会将字符放在写队列中。write 指向用于输出写队列字符的函数。
struct tty_struct tty_table[] = {
{
{ICRNL,
OPOST | ONLCR,
0,
ISIG | ICANON | ECHO | ECHOCTL | ECHOKE,
0,
INIT_C_CC},
0,
0,
con_write,
{0, 0, 0, 0, ""},
{0, 0, 0, 0, ""},
{0, 0, 0, 0, ""}
}
};
第4-9行是关于 termios 结构体的初始化。第4,5行的这种转换有什么用呢?我们按下回车键,系统会收到 CR,CR 在存入读队列时会转换为 NL,当我们把键盘读入的字符打印到屏幕上时,NL 会转换为 CRNL,实现按下回车实现回车换行的功能。同时,使用 printk 打印时,\n 就可以实现回车换行。
第7行是设置本地模式标志,将终端设置为收到 INTR/QUIT/SUSP/DSUSP 会产生信号(ISIG),显示输入字符(ECHO)等,详细信息请参考这篇博客:C语言实现串口通信。在使用终端时,我们经常使用 Ctrl+C 结束一个任务,其实 Ctrl+C 代表 INTR,由于设置了 ISIG,任务会产生信号,处理信号时就会结束该任务。
第13-15行是对循环队列的初始化,暂且将它们都设置为0。
INIT_C_CC 的定义如下。
#define INIT_C_CC "\003\034\177\025\004\0\1\0\021\023\032\0\022\017\027\026\0"
^C 代表 Ctrl+C,^Z 代表 Ctrl+Z,以此类推。这里的数字都是8进制数,177(八进制)= 127(十进制)。我们也定义一些宏定义方便辨认这些字符。
#define INTR_CHAR(tty) ((tty)->termios.c_cc[VINTR])
#define QUIT_CHAR(tty) ((tty)->termios.c_cc[VQUIT])
#define ERASE_CHAR(tty) ((tty)->termios.c_cc[VERASE])
#define KILL_CHAR(tty) ((tty)->termios.c_cc[VKILL])
#define EOF_CHAR(tty) ((tty)->termios.c_cc[VEOF])
#define START_CHAR(tty) ((tty)->termios.c_cc[VSTART])
#define STOP_CHAR(tty) ((tty)->termios.c_cc[VSTOP])
#define SUSPEND_CHAR(tty) ((tty)->termios.c_cc[VSUSP])
下面再来修改一下代码。
int printk(const char *fmt, ...)
{
va_list args;
int i;
va_start(args, fmt);
i = vsprintf(buf, fmt, args);
va_end(args);
__asm__("push %%fs\n\t"
"push %%ds\n\t"
"pop %%fs\n\t"
"pushl %0\n\t"
"pushl $buf\n\t"
"pushl $0\n\t"
"call tty_write\n\t"
"addl $8, %%esp\n\t"
"popl %0\n\t"
"pop %%fs"
::"r"(i));
return i;
}
第11-12行相当于将 ds 的值放入 fs 中。第13-15行将参数入栈,第16行调用 tty_write 打印字符串。第17-19行清空栈中多余的数据。
int tty_write(unsigned channel, char *buf, int nr)
{
struct tty_struct *tty;
char c, *b = buf;
if (channel > 0 || nr < 0)
return -1;
tty = channel + tty_table;
while (nr > 0) {
if (current->signal)
break;
while (nr > 0 && !FULL(tty->write_q)) {
c = *b;
b++; nr--;
PUTCH(c, tty->write_q);
}
tty->write(tty);
if (nr > 0)
schedule();
}
return (b - buf);
}
确定终端号和字符长度没问题后,找到要使用的终端。如果终端缓冲区未满而且还有字符没放入缓冲区中,就一直向缓冲区中存放数据。存放完毕后,调用写函数将缓冲区的数据打印到屏幕上。如果还有字符没放入缓冲区中,说明此时缓冲区已满,先调度到其它任务去。等再次调度到这个任务后,执行上述操作,直至打印出所有的字符。
void con_write(struct tty_struct *tty)
{
int nr;
char c;
nr = CHARS(tty->write_q);
while (nr--) {
GETCH(tty->write_q, c);
if (c > 31 && c < 127) {
...
}
else if (c == 10 || c == 11 || c == 12)
lf();
else if (c == 13)
cr();
else if (c == ERASE_CHAR(tty))
del();
...
}
con_write 的改动不大,只是获取字符串长度和获得字符的方式变了,第17行改变了对删除的判断(其实就是换了层皮而已)。
运行看看有没有报错。
可以看到,打印功能没什么问题。
2.键盘中断1
也是时候对键盘动手了。我们这一节的目标是按下按键,在屏幕上显示按键的内容。
说到键盘,那必定先讲键盘中断。
# keyboard.S
keyboard_interrupt:
pushl %eax
pushl %ebx
pushl %ecx
pushl %edx
push %ds
push %es
movl $0x10, %eax
mov %ax, %ds
mov %ax, %es
xor %al, %al
inb $0x60, %al # 保存扫描码
call key_table(, %eax, 4)
inb $0x61, %al # 获得PPI(可编程外设接口)端口B状态,其位7用于允许/禁止(0/1)键盘
jmp 1f
1: jmp 1f
1: orb $0x80, %al
jmp 1f
1: jmp 1f
1: outb %al, $0x61 # 禁止键盘工作
jmp 1f
1: jmp 1f
1: andb $0x7F, %al
outb %al, $0x61 # 允许键盘工作
movb $0x20, %al
outb %al, $0x20 # 向8259芯片发送中断结束信号
pushl $0
call do_tty_interrupt
addl $4, %esp
pop %es
pop %ds
popl %edx
popl %ecx
popl %ebx
popl %eax
iret
一开头还是中断的老操作,寄存器入栈,修改段寄存器的值。然后将 0x60 端口的数据存入 al 中。0x60 端口是干什么用的?0x60 端口属于 8042芯片(键盘控制器),无论键盘的按键被按下还是松开,都会发送数据到 0x60 端口的寄存器,这个数据我们称之为扫描码。比如,按下 A 键,扫描码为 0x1E,松开 A 键,扫描码为 0x9E,我们可以通过扫描码知道按下或松开了哪个键。更多的扫描码,可以看这篇博客:键盘扫描码集(共三版)。请勿把扫描码与 ASCII 码混淆。
根据不同的按键,我们执行不同的函数,函数列表如下。
这一节,我们只处理普通的按键,如数字、字符、符号等,Shift、Ctrl、Alt、方向键等会在之后的内容添加。
如果普通的按键按下,我们统一执行 do_self,对于松开按键,我们执行 none,也就是什么也不做。
do_self 函数会将按下的字符保存到终端的缓冲区中。
将按下的字符保存到终端的缓冲区之后,我们需要对收到的扫描码做出应答,具体做法就是先禁止键盘,然后立刻重新允许键盘工作,对应第15-25行代码。接着,我们需要向8259芯片发送中断结束信号,表示我们已经响应中断了。
将0作为 do_tty_interrupt 的参数入栈,调用 do_tty_interrupt 函数打印字符,之后将寄存器出栈,iret 结束中断处理函数。
# keyboard.S
key_table:
.long none,do_self,do_self,do_self /* 00-03 br 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 /* 08-0B 7 8 9 0 */
.long do_self,do_self,do_self,do_self /* 0C-0F + ' bs tab */
.long do_self,do_self,do_self,do_self /* 10-13 q w e r */
.long do_self,do_self,do_self,do_self /* 14-17 t y u i */
.long do_self,do_self,do_self,do_self /* 18-1B o p } ^ */
.long do_self,none,do_self,do_self /* 1C-1F enter br a s */
.long do_self,do_self,do_self,do_self /* 20-23 d f g h */
.long do_self,do_self,do_self,do_self /* 24-27 j k l | */
.long do_self,do_self,none,do_self /* 28-2B { para br , */
.long do_self,do_self,do_self,do_self /* 2C-2F z x c v */
.long do_self,do_self,do_self,do_self /* 30-33 b n m , */
.long do_self,do_self,none,do_self /* 34-37 . / br * */
.long none,do_self,none,none /* 38-3B br sp br br */
.long none,none,none,none /* 3C-3F br br br br */
.long none,none,none,none /* 40-43 br br br br */
.long none,none,none,none /* 44-47 br br br br */
.long none,none,do_self,none /* 48-4B br br - br */
.long none,none,do_self,none /* 4C-4F br br + br */
.long none,none,none,none /* 50-53 br br br br */
.long none,none,do_self,none /* 54-57 br br < br */
...
我们先讲 do_self 函数,再讲 do_tty_interrupt 吧。
# keyboard.S
size = 1024
key_map:
.byte 0,27
.ascii "1234567890-="
.byte 127,9
.ascii "qwertyuiop[]"
.byte 13,0
.ascii "asdfghjkl;'"
.byte '`,0
.ascii "\\zxcvbnm,./"
.byte 0,'*,0,32 /* 36-39 */
.fill 16,1,0 /* 3A-49 */
.byte '-,0,0,0,'+ /* 4A-4E */
.byte 0,0,0,0,0,0,0 /* 4F-55 */
.byte '<
.fill 10,1,0
do_self:
lea key_map, %ebx
1: movb (%ebx, %eax), %al
orb %al, %al
je none
andl $0xff, %eax
xorl %ebx, %ebx
call put_queue
none: ret
key_map 是扫描码-ASCII 字符映射表,这是美国键盘的映射表,我们日常使用的键盘也是这个映射表。映射表怎么使用呢?还是以 A 为例,按下 A 键,扫描码为 0x1E,‘a’ 字符相对于映射表起始地址的偏移就是 0x1E,我们这就通过扫描码找到了 ASCII 字符。
将映射表地址存入 ebx 中(第21行),通过映射表和扫描码找到 ASCII 字符保存到 al 中(第22行),如果 al 为0,就跳转到 none。只保存 eax 的低8位(第25行),将 ebx 清零(第26行),将 ASCII 字符保存到终端的缓冲区中(第27行)。
struct tty_queue *table_list[] = {
&tty_table[0].read_q, &tty_table[0].write_q,
};
# keyboard.S
put_queue:
pushl %ecx
pushl %edx
movl table_list, %edx # 终端读队列的地址
movl head(%edx), %ecx
1: movb %al, buf(%edx, %ecx)
incl %ecx
andl $size - 1,%ecx
cmpl tail(%edx), %ecx
je 3f
shrdl $8, %ebx, %eax
je 2f # 如果没有字符就跳转到2
shrl $8, %ebx
jmp 1b
2: movl %ecx, head(%edx)
movl proc_list(%edx), %ecx
testl %ecx, %ecx
je 3f
movl $0, (%ecx)
3: popl %edx
popl %ecx
ret
我们先找到终端读队列的地址,基于此得到读队列的头指针地址,将 ASCII 字符存入缓冲区中,第7-9行代码与 C 语言下PUTCH(al, tty_table[0].read_q) 作用相同。检查缓冲区是否还有空间存放数据(第10行,与FULL(tty_table[0].read_q) 相同),如果这个操作会导致缓冲区填满,就舍弃数据,结束。
shrdl 会将 ebx 的低8位移动到 eax 的高8位上,而 ebx 并不会发生改变。如下图所示。
在 put_queue 之前,我们会把要存入队列的字符放在 eax 中,eax 最多可以存放4个字符,ebx 一般为0。如果 eax 不为0,说明还有字符需要存入队列中,就把 ebx 右移8位,然后继续执行1标签。如果没有就跳转到2标签,将 ecx 的值存入读队列头指针。检查有无等待该队列的任务,有就把它的状态设置为可运行态。
总结一下键盘中断都干了什么。
我们还没有对第5步进行说明。
#define _L_FLAG(tty,f) ((tty)->termios.c_lflag & f)
#define L_ECHO(tty) _L_FLAG((tty),ECHO)
#define L_ECHOCTL(tty) _L_FLAG((tty),ECHOCTL)
void copy_to_cooked(struct tty_struct *tty)
{
signed char c;
while (!EMPTY(tty->read_q)) {
GETCH(tty->read_q, c);
if (L_ECHO(tty)) {
if (c == 13) {
PUTCH(10, tty->write_q);
PUTCH(13, tty->write_q);
} else if (c < 32) {
if (L_ECHOCTL(tty)) {
PUTCH('^', tty->write_q);
PUTCH(c + 64, tty->write_q);
}
} else
PUTCH(c, tty->write_q);
tty->write(tty);
}
}
}
void do_tty_interrupt(int tty)
{
copy_to_cooked(tty_table + tty);
}
我们要读取终端的读队列,如果终端被设置可以显示字符,根据不同的字符,将不同的内容放入终端的写队列。对于 ‘\r’ 就写入 ‘\n\r’,对于其它不可显示字符,如果可以显示控制字符,就显示类似 ^C 、 ^Z 的形式,其它字符就直接入队列。最后调用写函数将写队列的数据打印到屏幕上。
我们还没有注册键盘中断处理程序,找个位置加上它吧。
void con_init(void)
{
unsigned char a;
...
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);
}
第8-11行用于复位键盘。
最后再修改一点代码,测试我们的程序可否正确执行。
void init(void)
{
while (1);
}
我们就让这个任务进入死循环,当我们按下按键时,触发键盘中断,屏幕会显示按键对应的字符。
本来我是想用感叹号的,但是 Shift 键还不能用,所以就用了句号。现在 Ctrl、Shift、Alt、数字小键盘、方向键、Home键等,都没有相应的代码,我们会在之后的小节中逐步完善。
3.键盘中断2
这次,我们要处理一些特殊的按键:Shift,Ctrl,Alt,Cap(大小写),num(键盘锁),scroll。
键盘左右两边都有 Shift,Ctrl,Alt 键,两边的键盘扫描码并不相同,按下左侧的 Ctrl 会产生扫描码 0x1d,按下右侧的 Ctrl 会产生扫描码 0xe0 和 0x1d。
我们会使用一个变量记录 Shift,Ctrl,Alt,Cap 键的状态,如果按下了这些按键,使用或运算在变量的不同位置1,当松开按键时,将变量的相应位置0。如果按键产生了两个扫描码,我们也需要单独做一些处理。
按键 | 位号 |
---|
左Shift | 0 | 右Shift | 1 | 左Ctrl | 2 | 右Ctrl | 3 | 左Alt | 4 | 右Alt | 5 | Cap | 6,7 |
1.Shift 键
# keyboard.S
mode: .byte 0
lshift:
orb $0x01, mode
ret
unlshift:
andb $0xfe, mode
ret
rshift:
orb $0x02, mode
ret
unrshift:
andb $0xfd, mode
ret
key_table:
...
.long do_self,do_self,lshift,do_self /* 28-2B { para lshift , */
...
.long do_self,do_self,rshift,do_self /* 34-37 . / rshift * */
...
.long none,none,unlshift,none /* A8-AB br br unlshift br */
...
.long none,none,unrshift,none /* B4-B7 br br unrshift br */
...
使用 mode 记录 Shift,Ctrl,Alt,Cap 键的状态。如上面的表格所示,我们使用第0位和第1位记录 Shift 的状态。
按下 Shift 键产生的扫描码为 0x2a 或 0x36,我们会跳转的相应的函数中,将 mode 的第0位或第1位置1。松开 Shift 将 mode 的第0位或第1位置0。
我们以按下 Shift+A 为例,这时应该将字符 ‘A’ 送入终端队列中,而不是字符 ‘a’。之前的扫描码-ASCII 字符映射表不能满足需求,我们需要创建一张 Shift 的扫描码-ASCII 字符映射表。其映射表如下所示。
# keyboard.S
shift_map:
.byte 0,27
.ascii "!@#$%^&*()_+"
.byte 127,9
.ascii "QWERTYUIOP{}"
.byte 13,0
.ascii "ASDFGHJKL:\""
.byte '~,0
.ascii "|ZXCVBNM<>?"
.byte 0,'*,0,32 /* 36-39 */
.fill 16,1,0 /* 3A-49 */
.byte '-,0,0,0,'+ /* 4A-4E */
.byte 0,0,0,0,0,0,0 /* 4F-55 */
.byte '>
.fill 10,1,0
处理 Shift 键之后,我们还需要处理 a 键,原本的 do_self 函数检查 Shift 键的状态,需要进行修改。
# keyboard.S
do_self:
... # alt键的处理
lea shift_map, %ebx
testb $0x03, mode
jne 1f
lea key_map, %ebx
1: movb (%ebx, %eax), %al
orb %al, %al
je none
... # cap,ctrl,alt键的处理
4: andl $0xff, %eax
xorl %ebx, %ebx
call put_queue
none: ret
如果没有按下 Cap,Ctrl,Alt键,我们可以认为处理这些按键的代码不存在。
将 Shift 映射表的地址存入 ebx 中,如果按下了 Shift 键,则 testb 指令的结果不为0,跳转到第8行。此时,ebx 中是 Shift 映射表地址,eax 中是 a 的扫描码,通过它们可以得到字符 A,然后存入 al 中。
最后会在屏幕上显示字符 A,这个过程并不难理解吧。
2.Ctrl 键
左侧 Ctrl 键的扫描码为 0x1d,右侧的 Ctrl 键有2个扫描码:0xe0、0x1d。
0xe0 代表按下该按键会产生2个扫描码,0xe1 代表按下该按键会产生3个扫描码。(按下 Pause 键会产生3个扫描码:0xe1,0x1d,0x45)
# keyboard.S
e0: .byte 0
keyboard_interrupt:
...
inb $0x60, %al # 保存扫描码
cmpb $0xe0, %al
je set_e0
cmpb $0xe1, %al
je set_e1
call key_table(, %eax, 4)
movb $0, e0
e0_e1:
inb $0x61, %al # 获得PPI(可编程外设接口)端口B状态,其位7用于允许/禁止(0/1)键盘
...
iret
set_e0: movb $1, e0
jmp e0_e1
set_e1: movb $2, e0
jmp e0_e1
我们使用一个变量 e0 来标记扫描码中是否有 0xe0 或 0xe1。e0 = 1 表示扫描码中有 0xe0,e0 = 2 表示扫描码中有 0xe1。
以按下 Ctrl+C 为例。如果是右侧的 Ctrl 键,此时会先产生扫描码 0xe0,将变量 e0 设置为1。由于没有字符入终端队列的操作,所以什么也不会打印。之后又会触发键盘中断,读入扫描码 0x1d,然后调用 ctrl 函数。如果是左侧的 Ctrl 键,则会直接读入扫描码 0x1d,然后调用 ctrl 函数。
# keyboard.S
ctrl: movb $0x04, %al
cmpb $0,e0
je 2f
addb %al,%al
2: orb %al,mode
ret
unctrl: movb $0x04, %al
cmpb $0,e0
je 2f
addb %al,%al
2: notb %al
andb %al,mode
ret
key_table:
...
.long do_self,ctrl,do_self,do_self /* 1C-1F enter ctrl a s */
...
.long none,unctrl,none,none /* 9C-9F br unctrl br br */
...
第2-7行表示按下 Ctrl 会将 mode 的第2位或第3位置1。第8-14行表示松开 Ctrl 会将 mode 的第2位或第3位置0。
之后,我们都会同时检查 mode 的第2位或第3位,所以无论按下的是左侧的 Ctrl 键还是右侧的 Ctrl 键,最终得到的结果都是相同的。
因为按下了 Ctrl 键,所以 mode 的第2位或第3位会置1。之后一直到结束中断也不会做特别的操作。然后再次触发中断,处理 c 键。
# keyboard.S
do_self:
... # alt键的处理
lea shift_map, %ebx
testb $0x03, mode # 右alt
jne 1f
lea key_map, %ebx
1: movb (%ebx, %eax), %al
orb %al, %al
je none
testb $0x4c, mode # ctrl或caps
je 2f
cmpb $'a, %al
jb 2f
cmpb $'}, %al
ja 2f
subb $32, %al
2: testb $0x0c, mode # ctrl
je 3f
cmpb $64, %al
jb 3f
cmpb $64 + 32, %al
jae 3f
subb $64, %al
3: ... # alt键的处理
4: andl $0xff, %eax
xorl %ebx, %ebx
call put_queue
none: ret
c 键按下的扫描码是 0x2e,所以在执行 do_self 之前,al 的值为 0x2e。Ctrl 使用普通的键盘映射表,c 键对映的 ASCII 码是0x63,al 的值变为 0x63。如果按键的 ASCII 码大于等于 ‘a’ ,小于等于 ‘}’,则将 al 的值减去32(第13-17行),al 的值变为 0x43(67)。如果 al 的值大于等于64,小于96,则将 al 的值减去64(第20-24行),al 的值变为3。之后还是将 al 的值保存到读队列中。
在 copy_to_cooked 函数中,会将3解析为 ^C 这两个字符并保存在写队列中。最后打印在屏幕上。
alt 键的处理代码并不会影响 ctrl 键的处理,当做没有就行了。
3.Alt 键
左侧 Alt 键的扫描码为 0x38,右侧的 Alt 键有2个扫描码:0xe0、0x38。
Alt 键的处理函数如下。Alt 键的处理函数仅修改 mode 的值。
# keyboard.S
alt: movb $0x10, %al
cmpb $0, e0
je 2f
addb %al, %al
2: orb %al, mode
ret
unalt: movb $0x10, %al
cmpb $0,e0
je 2f
addb %al, %al
2: notb %al
andb %al, mode
ret
key_table:
...
.long alt,do_self,caps,none /* 38-3B alt sp caps br */
...
.long unalt,none,uncaps,none /* B8-BB unalt br uncaps br */
...
按下左 Alt 键,将 mode 的第4位置1,松开左 Alt 键,将 mode 的第4位置0。
按下右 Alt 键,将 mode 的第5位置1,松开右 Alt 键,将 mode 的第5位置0。
Alt 键有专门的键盘映射表,可以看到,它的映射表大多数的值为0。
# keyboard.S
alt_map:
.byte 0,0
.ascii "\0@\0$\0\0{[]}\\\0"
.byte 0,0
.byte 0,0,0,0,0,0,0,0,0,0,0
.byte '~,13,0
.byte 0,0,0,0,0,0,0,0,0,0,0
.byte 0,0
.byte 0,0,0,0,0,0,0,0,0,0,0
.byte 0,0,0,0 /* 36-39 */
.fill 16,1,0 /* 3A-49 */
.byte 0,0,0,0,0 /* 4A-4E */
.byte 0,0,0,0,0,0,0 /* 4F-55 */
.byte '|
.fill 10,1,0
我们以 Alt+2 为例,讲解 Alt 键的处理流程。
# keyboard.S
do_self:
lea alt_map, %ebx
testb $0x20, mode # 右alt
jne 1f
... # shift
lea key_map, %ebx
1: movb (%ebx, %eax), %al
orb %al, %al
je none
... # ctrl或caps
2: ... # ctrl
3: testb $0x10, mode # 左alt
je 4f
orb $0x80, %al
4: andl $0xff, %eax
xorl %ebx, %ebx
call put_queue
none: ret
可以看到,对于左右两边的 Alt 键的处理并不相同。右 Alt 键才会使用 Alt 的键盘映射表,会将64送入读队列,最后打印出 @。左 Alt 键不使用映射表,al 的值为50(第7行),之后会变为178(第14行),最后打印出 ^。
bochs 模拟器对于 Alt 键的支持并不是很好,建议大家用 vmware 虚拟机进行测试。新建虚拟机的步骤如这篇博客所示:【操作系统】30天自制操作系统–(1)虚拟机加载最小操作系统。
4.Caps、num、scroll 键
一般来说,键盘的右上角有三个灯,它们分别表示 Caps、num 和 scroll 键的状态。第一次按下这些按键时,相应的灯会亮起来,再次按下则会熄灭。我们的处理函数需要达到这种效果。
# keyboard.S
leds: .byte 0
caps:
testb $0x80, mode
jne 1f
xorb $4, leds
xorb $0x40, mode
orb $0x80, mode
set_leds:
call kb_wait
movb $0xed, %al /* set leds command */
outb %al, $0x60
call kb_wait
movb leds, %al
outb %al, $0x60
ret
uncaps:
andb $0x7f, mode
ret
scroll:
xorb $1, leds
jmp set_leds
num:xorb $2, leds
jmp set_leds
kb_wait:
pushl %eax
1: inb $0x64,%al
testb $0x02,%al
jne 1b
popl %eax
ret
leds 的第0位代表 scroll 的状态,第1位代表 num 的状态,第2位代表 caps 的状态。
mode 的第6位代表 Caps 键是否工作,第7位代表 Caps 键是否按下。
caps、num、scroll 的处理函数都要设置 leds 的位,caps 还需要修改 mode 的值,之后就需要控制灯的亮灭。松开 caps 需要将相应位置0。
kb_wait 用于检查是否可以向 8042 芯片写入数据,它会一直循环直至可以写入数据。
当 0x60 端口收到 0xed 命令后,一个 led 设置会话开始,它会等待一个 led 设置字节。通过 leds 的值设置不同 led 灯的亮灭。
另外,按下 Caps 会把小写字母转换为大写字母。(小写字母的 ASCII 码值减去32就得到了对应的大写字符)
这些按键处理函数的分布如下。
# keyboard.S
key_table:
...
.long alt,do_self,caps,none /* 38-3B alt sp caps br */
...
.long none,num,scroll,none /* 44-47 br num scr br */
...
.long unalt,none,uncaps,none /* B8-BB unalt br uncaps br */
...
4.键盘中断3
我们还剩一些按键没有处理:F1-F12,Insert-PageDown,方向键,小键盘数字键。这一节会全部解决掉。
首先解决 F1-F12 这12个按键。
# keyboard.S
func:
pushl %eax
pushl %ecx
pushl %edx
call show_stat
popl %edx
popl %ecx
popl %eax
subb $0x3B, %al
jb end_func
cmpb $9, %al
jbe ok_func
subb $18, %al
cmpb $10, %al
jb end_func
cmpb $11, %al
ja end_func
ok_func:
cmpl $4, %ecx /* check that there is enough room */
jl end_func
movl func_table(, %eax, 4), %eax
xorl %ebx, %ebx
jmp put_queue
end_func:
ret
func_table:
.long 0x415b5b1b, 0x425b5b1b, 0x435b5b1b, 0x445b5b1b
.long 0x455b5b1b, 0x465b5b1b, 0x475b5b1b, 0x485b5b1b
.long 0x495b5b1b, 0x4a5b5b1b, 0x4b5b5b1b, 0x4c5b5b1b
第3-9行调用 show_stat 函数,打印任务的信息,这个函数的解释在下面。
第10-18行判断 al 的取值是否为 0x3B-0x44,0x57,0x58,如果不是就直接结束。(F1-F12 按键的扫描码为 0x3B-0x44,0x57,0x58,所以在进入 func 时,al 的取值应该是 0x3B-0x44,0x57,0x58)
func_table 中的12个数字代表 F1-F12 映射的 ASCII 码字符。F1 对应于 ESC [[A,F2 对应于 ESC [[B,以此类推。put_queue 会把 eax 中的4个字符都存入读队列中,最后打印出来。
void show_task(int nr,struct task_struct *p)
{
int i, j = 4096 - sizeof(struct task_struct);
printk("%d: pid=%d, state=%d, ", nr, p->pid, p->state);
i = 0;
while (i < j && !((char *)(p + 1))[i])
i++;
printk("%d (of %d) chars free in kernel stack\r\n", i, j);
}
void show_stat(void)
{
int i;
for (i = 0; i < NR_TASKS; i++)
if (task[i])
show_task(i, task[i]);
}
show_stat 会把系统中存在的所有任务的 pid, state 以及该任务在内核栈的空闲字节数打印出来。
运行结果如下。
Insert-PageDown,方向键,小键盘数字键这些按键是一起处理的。
可以看到,左边按键的扫描码比右边按键的扫描码多一个 0xE0,所以我们可以一个处理函数处理这些按键,用 e0 变量区别左右两边的按键。
# keyboard.S
cursor:
subb $0x47, %al
jb 1f
cmpb $12, %al
ja 1f
jne cur2 # 不是delete或小数点键则跳转
testb $0x0c, mode # 是否按下Ctrl
je cur2
testb $0x30, mode # 是否按下Alt
jne reboot
cur2:
cmpb $0x01, e0 # 扫描码中是否有e0
je cur
testb $0x02, leds # 数字锁是否打开
je cur
testb $0x03, mode # 是否按下Shift
jne cur
xorl %ebx, %ebx
movb num_table(%eax), %al
jmp put_queue
1: ret
cur:movb cur_table(%eax), %al
cmpb $'9, %al
ja ok_cur
movb $'~, %ah
ok_cur:
shll $16, %eax
movw $0x5b1b, %ax
xorl %ebx, %ebx
jmp put_queue
num_table:
.ascii "789 456 1230."
cur_table:
.ascii "HA5 DGC YB623"
reboot:
call kb_wait
movw $0x1234,0x472 /* don't do memory check */
movb $0xfc,%al /* pulse reset and A20 low */
outb %al,$0x64
die:jmp die
第3-6行判断扫描码是否在合理的范围内,如果不是就返回。如果同时按下 Ctrl、Alt 以及 delete 或 小数点键,会跳转到 reboot,该子程序通过设置键盘控制器,向复位线输出负脉冲,使系统复位重启。但是,无论在 bochs 模拟器还是在 vmware 虚拟机都无法测试该功能,只能在实体机上测试了。
第13-18行,没有 e0 或没打开数字锁或没按下 Shift 就直接跳转到 cur。这就表示按下的是数字小键盘的按键,将数字映射表的 ASCII 码存入读队列中。
第24-32行会将3或4个字符存入读队列中。以 Home 和 Insert 为例,按下 Home 会向读队列中放入3个字符,打印 ^[[H,按下 Insert 会向读队列中放入4个字符,打印 ^[[2~。
下面是这些按键的分布。
key_table:
...
.long alt,do_self,caps,func /* 38-3B br sp caps f1 */
.long func,func,func,func /* 3C-3F f2 f3 f4 f5 */
.long func,func,func,func /* 40-43 f6 f7 f8 f9 */
.long func,num,scroll,cursor /* 44-47 f10 num scr home */
.long cursor,cursor,do_self,cursor /* 48-4B up pgup - left */
.long cursor,cursor,do_self,cursor /* 4C-4F n5 right + end */
.long cursor,cursor,cursor,cursor /* 50-53 dn pgdn ins del */
.long none,none,do_self,func /* 54-57 br br < f11 */
.long func,none,none,none /* 58-5B f12 br br br */
...
键盘中断的内容终于结束了,我也觉得这内容有点多而且繁琐。感觉这些内容了解就好,不必深究,毕竟学习操作系统,任务管理、文件系统这些才是精华。
5.完善终端
我们的终端还有一些小 bug 需要修复。比如,在换行八十几次后,光标就跑到了屏幕首行。
这应该是滚屏的时候出现了问题。具体出错位置还需要通过调试一步一步定位。
排查后发现,果然是滚屏的时候出了问题,具体是在超出显存的时候出的问题。编译器生成的汇编代码与我写的C语言代码的逻辑不一样,这种问题就很尴尬了,不好做修改。经过多次尝试,我发现更改代码顺序就好了,修改的代码如下所示。
static void scrup(void)
{
if (video_type == VIDEO_TYPE_EGAC || video_type == VIDEO_TYPE_EGAM)
{
if (!top && bottom == video_num_lines) {
...
if (scr_end <= video_mem_end) {
...
}
else {
int tmp = origin;
origin = video_mem_start;
pos = video_mem_start + (video_num_lines - 1) * video_size_row;
scr_end = pos + video_size_row;
__asm__("cld\n\t"
"rep\n\t"
"movsd\n\t"
"movl %2, %%ecx\n\t"
"rep\n\t"
"stosw"
::"a"(video_erase_char),
"c"((video_num_lines - 1) * video_num_columns >> 1),
"m"(video_num_columns),
"D"(video_mem_start),
"S"(tmp)
);
y = bottom - 1;
x = 0;
}
set_origin();
}
...
}
...
}
bug 修复后,我们把注意力放回到终端上。终端的功能目前已经够用了,但是不够完善,不够强大。
int tty_write(unsigned channel, char *buf, int nr)
{
static int cr_flag = 0;
struct tty_struct *tty;
char c, *b = buf;
if (channel > 0 || nr < 0)
return -1;
tty = channel + tty_table;
while (nr > 0) {
if (current->signal)
break;
while (nr > 0 && !FULL(tty->write_q)) {
c = get_fs_byte(b);
if (O_POST(tty)) {
if (c == '\r' && O_CRNL(tty))
c = '\n';
else if (c=='\n' && O_NLRET(tty))
c = '\r';
if (c == '\n' && !cr_flag && O_NLCR(tty)) {
cr_flag = 1;
PUTCH(13, tty->write_q);
continue;
}
if (O_LCUC(tty))
c = toupper(c);
}
b++; nr--;
PUTCH(c, tty->write_q);
}
tty->write(tty);
if (nr > 0)
schedule();
}
return (b - buf);
}
tty_write 函数中添加的内容主要是对换行符的处理。我们之前将终端的输出模式设置为 OPOST | ONLCR。这代表需要对字符处理后才放入写队列,将换行转换成回车换行(即将 \n 转换为 \r\n)。修改 tty_write后,printk 的字符串中只需加 \n 就可以完成换行了。
void copy_to_cooked(struct tty_struct *tty)
{
signed char c;
while (!EMPTY(tty->read_q) && !FULL(tty->secondary)) {
...
if (I_UCLC(tty))
c = tolower(c);
if (L_CANON(tty)) {
if (c == KILL_CHAR(tty)) {
while(!(EMPTY(tty->secondary) ||
(c = LAST(tty->secondary)) == 10 ||
c == EOF_CHAR(tty))) {
if (L_ECHO(tty)) {
if (c < 32)
PUTCH(127, tty->write_q);
PUTCH(127, tty->write_q);
tty->write(tty);
}
DEC(tty->secondary.head);
}
continue;
}
if (c == ERASE_CHAR(tty)) {
if (EMPTY(tty->secondary) ||
(c = LAST(tty->secondary)) == 10 ||
c == EOF_CHAR(tty))
continue;
if (L_ECHO(tty)) {
if (c < 32)
PUTCH(127, tty->write_q);
PUTCH(127, tty->write_q);
tty->write(tty);
}
DEC(tty->secondary.head);
continue;
}
if (c == STOP_CHAR(tty)) {
tty->stopped = 1;
continue;
}
if (c == START_CHAR(tty)) {
tty->stopped = 0;
continue;
}
}
...
if (L_ECHO(tty)) {
...
tty->write(tty);
}
PUTCH(c, tty->secondary);
}
}
第10-45行是对一些特殊字符的处理。
^U会删除当前行的所有字符,secondary 队列用于保存之前输出的字符,如果之前没有输出字符(即 secondary 队列为空),或最后输出的是换行符或 \0,就什么也不做,重新获取字符。否则向写队列中添加 del 字符(ASCII 码 127),对于 ASCII 码值小于32的字符,我们会以 ^ + 字母的形式输出,所以需要删除2个字符,更新 secondary 队列,循环直至退出。删除一个字符的操作与 ^U 的操作差不多,不多赘述。
^S 和 ^Q 会设置 stopped 成员,控制终端的运行。它们需要结合 sh 可执行文件才有用。
第52行将当前字符存入 secondary 队列中。
#define INTMASK (1<<(SIGINT-1))
#define QUITMASK (1<<(SIGQUIT-1))
void tty_intr(struct tty_struct *tty, int mask)
{
int i;
if (tty->pgrp <= 0)
return;
for (i = 0; i < NR_TASKS; i++)
if (task[i] && task[i]->pgrp == tty->pgrp)
task[i]->signal |= mask;
}
static void sleep_if_empty(struct tty_queue *queue)
{
cli();
while (!current->signal && EMPTY(*queue))
interruptible_sleep_on(&queue->proc_list);
sti();
}
void wait_for_keypress(void)
{
sleep_if_empty(&tty_table[0].secondary);
}
void copy_to_cooked(struct tty_struct *tty)
{
signed char c;
while (!EMPTY(tty->read_q) && !FULL(tty->secondary)) {
...
if (L_ISIG(tty)) {
if (c == INTR_CHAR(tty)) {
tty_intr(tty, INTMASK);
continue;
}
if (c == QUIT_CHAR(tty)) {
tty_intr(tty, QUITMASK);
continue;
}
}
if (c == 10 || c == EOF_CHAR(tty))
tty->secondary.data++;
if (L_ECHO(tty)) {
...
}
PUTCH(c, tty->secondary);
}
wake_up(&tty->secondary.proc_list);
}
copy_to_cooked 的内容过多,所以我将它分为两部分进行讲解。我们对终端设置了 ISIG 标志位,当输入 INTR、QUIT、SUSP 或 DSUSP 时,会产生相应的信号。当按下 ^C 或 ^I 时,会向终端所属的进程组的每一个任务发送信号。
wait_for_keypress 会阻塞当前任务,当按下按键才会使该任务重新运行。按下按键,程序会将按键 ASCII 码保存到辅助队列中(第48行),之后会唤醒任务(第50行)。任务被唤醒后,会回到第18行的循环中,由于此时辅助队列不为空,退出循环,任务可以重新运行。我们在加载文件系统的时候会用到这个函数。
终端的内容告一段落,下一章是文件系统,这部分内容真的很难,我还没把代码划分出来。
|