编程环境: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 都是直接对字符串进行操作的。这种方式并没有什么不好,只是不够灵活,想要实现更多的功能有难度,这一节,我们要使用一种数据结构替代字符串。

// tty.h
#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,形成循环。

为了方便队列的操作,我们需要定义一些操作队列的宏定义。

// tty.h
#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);})

有了这些宏定义,我们可以很方便地向队列写数据,从队列读数据,获取队列长度,判断队列是否为空等等。

// tty.h
struct tty_struct {struct termios termios;                 // 终端io属性和控制字符数据结构int pgrp;                               // 所属进程组int stopped;                            // 停止标志void (*write)(struct tty_struct *tty);  // tty写函数指针struct tty_queue read_q;                // tty读队列struct tty_queue write_q;               // tty写队列struct tty_queue secondary;             // tty辅助队列(存放规范模式字符序列)
};

struct termios 定义在 termios.h 中,termios.h 中还有许多与输入输出控制模式相关地宏定义,我们之后会用到这些宏定义,通过这些宏定义设置终端的输入输出模式。

对于键盘输入,我们会将字符放在读队列中,对于 printk 输出,我们会将字符放在写队列中。write 指向用于输出写队列字符的函数。

// tty_io.c
struct tty_struct tty_table[] = {{{ICRNL,         // 将输入的CR转换为NLOPOST | ONLCR,  // 将输出的NL转换为CRNL0,              // 控制模式初始化为0ISIG | 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,实现按下回车实现回车换行的功能。

第7行是设置本地模式标志,将终端设置为收到 INTR/QUIT/SUSP/DSUSP 会产生信号(ISIG),显示输入字符(ECHO)等,详细信息请参考这篇博客:C语言实现串口通信。在使用终端时,我们经常使用 Ctrl+C 结束一个任务,其实 Ctrl+C 代表 INTR,由于设置了 ISIG,任务会产生信号,处理信号时就会结束该任务。

第13-15行是对循环队列的初始化,暂且将它们都设置为0。

INIT_C_CC 的定义如下。

// tty.h
/*  intr=^C     quit=^|     erase=del   kill=^Ueof=^D      vtime=\0    vmin=\1     sxtc=\0start=^Q    stop=^S     susp=^Z     eol=\0reprint=^R  discard=^U  werase=^W   lnext=^Veol2=\0
*/
#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(十进制)。我们也定义一些宏定义方便辨认这些字符。

// tty.h
#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])

下面再来修改一下代码。

// printk.c
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"      // fs = ds"pushl %0\n\t"      // 字符串长度"pushl $buf\n\t""pushl $0\n\t"      // tty0"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行清空栈中多余的数据。

// tty_io.c
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); // con_write(tty);if (nr > 0)schedule();}return (b - buf);
}

确定终端号和字符长度没问题后,找到要使用的终端。如果终端缓冲区未满而且还有字符没放入缓冲区中,就一直向缓冲区中存放数据。存放完毕后,调用写函数将缓冲区的数据打印到屏幕上。如果还有字符没放入缓冲区中,说明此时缓冲区已满,先调度到其它任务去。等再次调度到这个任务后,执行上述操作,直至打印出所有的字符。

// console.c
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) // '\n',换行,使光标下移一格lf();else if (c == 13)       // '\r',回车,使光标移至行首cr();else if (c == ERASE_CHAR(tty))  // 删除del();...
}

con_write 的改动不大,只是获取字符串长度和获得字符的方式变了,第17行改变了对删除的判断(其实就是换了层皮而已)。

运行看看有没有报错。

可以看到,打印功能没什么问题。

2.键盘中断1

也是时候对键盘动手了。我们这一节的目标是按下按键,在屏幕上显示按键的内容。

说到键盘,那必定先讲键盘中断。

# keyboard.S
keyboard_interrupt:pushl %eaxpushl %ebxpushl %ecxpushl %edxpush %dspush %esmovl $0x10, %eaxmov %ax, %dsmov %ax, %esxor %al, %alinb $0x60, %al  # 保存扫描码call key_table(, %eax, 4)inb $0x61, %al   # 获得PPI(可编程外设接口)端口B状态,其位7用于允许/禁止(0/1)键盘jmp 1f
1:  jmp 1f
1:  orb $0x80, %aljmp 1f
1:  jmp 1f
1:  outb %al, $0x61 # 禁止键盘工作jmp 1f
1:  jmp 1f
1:  andb $0x7F, %aloutb %al, $0x61  # 允许键盘工作movb $0x20, %aloutb %al, $0x20 # 向8259芯片发送中断结束信号pushl $0call do_tty_interruptaddl $4, %esppop %espop %dspopl %edxpopl %ecxpopl %ebxpopl %eaxiret

一开头还是中断的老操作,寄存器入栈,修改段寄存器的值。然后将 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    = 1024key_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,0do_self:lea key_map, %ebx
1:  movb (%ebx, %eax), %alorb %al, %alje noneandl $0xff, %eaxxorl %ebx, %ebxcall 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行)。

// tty_io.c
struct tty_queue *table_list[] = {&tty_table[0].read_q, &tty_table[0].write_q,
};
# keyboard.S
put_queue:pushl %ecxpushl %edxmovl table_list, %edx        # 终端读队列的地址movl head(%edx), %ecx
1:  movb %al, buf(%edx, %ecx)incl %ecxandl $size - 1,%ecxcmpl tail(%edx), %ecxje 3fshrdl $8, %ebx, %eaxje 2f                        # 如果没有字符就跳转到2shrl $8, %ebxjmp 1b
2:  movl %ecx, head(%edx)movl proc_list(%edx), %ecxtestl %ecx, %ecxje 3fmovl $0, (%ecx)
3:  popl %edxpopl %ecxret

我们先找到终端读队列的地址,基于此得到读队列的头指针地址,将 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 的值存入读队列头指针。检查有无等待该队列的任务,有就把它的状态设置为可运行态。

总结一下键盘中断都干了什么。

  • 寄存器入栈并修改段寄存器。

  • 获得键盘扫描码

  • 执行与扫描码对应的函数:获得映射表地址,得到 ASCII 字符,将字符放入队列的缓冲区

  • 对收到的扫描码做应答

  • 整理数据,打印

  • 寄存器出栈,结束

我们还没有对第5步进行说明。

// tty_io.c
#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);}} elsePUTCH(c, tty->write_q);tty->write(tty);}}
}void do_tty_interrupt(int tty)
{copy_to_cooked(tty_table + tty);
}

我们要读取终端的读队列,如果终端被设置可以显示字符,根据不同的字符,将不同的内容放入终端的写队列。对于 ‘\r’ 就写入 ‘\n\r’,对于其它不可显示字符,如果可以显示控制字符,就显示类似 ^C 、 ^Z 的形式,其它字符就直接入队列。最后调用写函数将写队列的数据打印到屏幕上。

我们还没有注册键盘中断处理程序,找个位置加上它吧。

// console.c
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行用于复位键盘。

最后再修改一点代码,测试我们的程序可否正确执行。

// main.c
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 0lshift:orb $0x01, moderet
unlshift:andb $0xfe, moderet
rshift:orb $0x02, moderet
unrshift:andb $0xfd, moderetkey_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, %ebxtestb $0x03, modejne 1flea key_map, %ebx
1:  movb (%ebx, %eax), %alorb %al, %alje none...        # cap,ctrl,alt键的处理
4:  andl $0xff, %eaxxorl %ebx, %ebxcall 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 0keyboard_interrupt:...inb $0x60, %al  # 保存扫描码cmpb $0xe0, %alje set_e0cmpb $0xe1, %alje set_e1call key_table(, %eax, 4)movb $0, e0
e0_e1:inb $0x61, %al    # 获得PPI(可编程外设接口)端口B状态,其位7用于允许/禁止(0/1)键盘...iret
set_e0: movb $1, e0jmp e0_e1
set_e1: movb $2, e0jmp 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, %alcmpb $0,e0je 2faddb %al,%al
2:  orb %al,moderet
unctrl: movb $0x04, %alcmpb $0,e0je 2faddb %al,%al
2:  notb %alandb %al,moderetkey_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, %ebxtestb $0x03, mode       # 右altjne 1flea key_map, %ebx
1:  movb (%ebx, %eax), %alorb %al, %alje nonetestb $0x4c, mode       # ctrl或capsje 2fcmpb $'a, %aljb 2fcmpb $'}, %alja 2fsubb $32, %al
2:  testb $0x0c, mode       # ctrlje 3fcmpb $64, %aljb 3fcmpb $64 + 32, %aljae 3fsubb $64, %al
3:  ...     # alt键的处理
4:  andl $0xff, %eaxxorl %ebx, %ebxcall 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, %alcmpb $0, e0je 2faddb %al, %al
2:  orb %al, moderet
unalt:  movb $0x10, %alcmpb $0,e0je 2faddb %al, %al
2:  notb %alandb %al, moderetkey_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, %ebxtestb $0x20, mode       # 右altjne 1f...                        # shiftlea key_map, %ebx
1:  movb (%ebx, %eax), %alorb %al, %alje none...                        # ctrl或caps
2:  ...                     # ctrl
3:  testb $0x10, mode       # 左altje 4forb $0x80, %al
4:  andl $0xff, %eaxxorl %ebx, %ebxcall 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 0caps:testb $0x80, modejne 1fxorb $4, ledsxorb $0x40, modeorb $0x80, mode
set_leds:call kb_waitmovb $0xed, %al     /* set leds command */outb %al, $0x60call kb_waitmovb leds, %aloutb %al, $0x60ret
uncaps:andb $0x7f, moderet
scroll:xorb $1, ledsjmp set_leds
num:xorb $2, ledsjmp set_ledskb_wait:pushl %eax
1:  inb $0x64,%altestb $0x02,%aljne 1bpopl %eaxret

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 %eaxpushl %ecxpushl %edxcall show_statpopl %edxpopl %ecxpopl %eaxsubb $0x3B, %aljb end_funccmpb $9, %aljbe ok_funcsubb $18, %alcmpb $10, %aljb end_funccmpb $11, %alja end_func
ok_func:cmpl $4, %ecx        /* check that there is enough room */jl end_funcmovl func_table(, %eax, 4), %eaxxorl %ebx, %ebxjmp put_queue
end_func:retfunc_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个字符都存入读队列中,最后打印出来。

// sched.c
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, %aljb 1fcmpb $12, %alja 1fjne cur2            # 不是delete或小数点键则跳转testb $0x0c, mode   # 是否按下Ctrlje cur2testb $0x30, mode   # 是否按下Altjne reboot
cur2:cmpb $0x01, e0      # 扫描码中是否有e0je curtestb $0x02, leds   # 数字锁是否打开je curtestb $0x03, mode   # 是否按下Shiftjne curxorl %ebx, %ebxmovb num_table(%eax), %aljmp put_queue
1:  retcur:movb cur_table(%eax), %alcmpb $'9, %alja ok_curmovb $'~, %ah
ok_cur:shll $16, %eaxmovw $0x5b1b, %axxorl %ebx, %ebxjmp put_queuenum_table:.ascii "789 456 1230."cur_table:.ascii "HA5 DGC YB623"reboot:call kb_waitmovw $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语言代码的逻辑不一样,这种问题就很尴尬了,不好做修改。经过多次尝试,我发现更改代码顺序就好了,修改的代码如下所示。

// console.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 修复后,我们把注意力放回到终端上。终端的功能目前已经够用了,但是不够完善,不够强大。

// tty_io.c
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 就可以完成换行了。

// tty_io.c
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)) {  // ^U 删除当前行的字符while(!(EMPTY(tty->secondary) ||(c = LAST(tty->secondary)) == 10 ||c == EOF_CHAR(tty))) {  // \0if (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)) {   // del 删除一个字符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)) {  // ^S 停止终端tty->stopped = 1;continue;}if (c == START_CHAR(tty)) { // ^Q 启动终端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 队列中。

// tty_io.c
#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)) {  // ^Ctty_intr(tty, INTMASK);continue;}if (c == QUIT_CHAR(tty)) {  // ^Itty_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行的循环中,由于此时辅助队列不为空,退出循环,任务可以重新运行。我们在加载文件系统的时候会用到这个函数。

终端的内容告一段落,下一章本来准备讲文件系统,但这部分内容真的很难,我还没把代码划分出来。还是先讲讲软盘的相关内容吧。

从零编写linux0.11 - 第七章 完善终端相关推荐

  1. 从零编写linux0.11 - 第三章 printk函数

    编程环境:Ubuntu Kylin 16.04.gcc-5.4.0 代码仓库:https://gitee.com/AprilSloan/linux0.11-project linux0.11源码下载( ...

  2. 使用Vi编辑器编写Linux0.11程序并编译运行

    一.vi编辑器的使用 Linux自带的一个文本编辑器. vi编辑器有3种操作模式:命令模式.插入模式和末行模式. 命令模式:当输入vi命令后,会首先进入命令模式,此时输入的任何字符都被视为命令.命令模 ...

  3. 科技鸿蒙系统一千章,第一千六百零七章 鸿蒙紫气,成圣之机 (上)

    文学迷 > 玄幻魔法 > 天命神相 > 第一千六百零七章 鸿蒙紫气,成圣之机 (上) 第一千六百零七章 鸿蒙紫气,成圣之机 功德金身只要达到了八十一重天,大圆满的境界,实力堪混元大罗 ...

  4. 零基础学Python课后实战第七章

    零基础学Python课后实战第七章 tips 实战一:修改手机默认语言 实战二:给信用卡设置默认密码 实战三:打印每月销售明细 tips 对象:对象是事物存在的实体,如一个人. 通常将对象划分为两部分 ...

  5. 第七章:使用Netlify零成本部署组件文档

    第七章:使用Netlify无成本发布组件文档 为什么使用Netlify? 一开始一共有三个方案: 1.Github Page 2.Netlify 3.Vercel Github Page只支持一个re ...

  6. 【正点原子FPGA连载】 第七章 Verilog HDL语法 摘自【正点原子】DFZU2EG/4EV MPSoC 之FPGA开发指南V1.0

    1)实验平台:正点原子MPSoC开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=692450874670 3)全套实验源码+手册+视频下载地址: h ...

  7. 【正点原子FPGA连载】第七章 Verilog HDL语 -摘自【正点原子】领航者ZYNQ之FPGA开发指南_V2.0

    1)实验平台:正点原子领航者ZYNQ开发板 2)平台购买地址:https://item.taobao.com/item.htm?&id=606160108761 3)全套实验源码+手册+视频下 ...

  8. 【正点原子FPGA连载】第七章Verilog HDL语法 -摘自【正点原子】新起点之FPGA开发指南_V2.1

    1)实验平台:正点原子新起点V2开发板 2)平台购买地址:https://detail.tmall.com/item.htm?id=609758951113 2)全套实验源码+手册+视频下载地址:ht ...

  9. 《操作系统真象还原》第七章

    <操作系统真象还原>第七章 本篇对应书籍第七章的内容 本篇内容介绍了操作系统的中断处理机制,建立中断描述符表,填充门描述符,以及中断处理程序,初始化8259A中断控制器实现外部中断功能,控 ...

  10. 一站式linux0.11内核head.s代码段图表详解

    阅读本文章需要的基础: 计算机组成原理:针对8086,80386CPU架构的计算机硬件体系要有清楚的认知,我们都知道操作系统是用来管理硬件的,那我们就要对本版本的操作系统所依赖的硬件体系有系统的了解, ...

最新文章

  1. PHP取得json前面有乱码(去除文件头部BOM)
  2. 买不到“震楼神器”的外国小哥,用Arduino编了一个
  3. 洛谷P1265 公路修建题解
  4. mfc指示灯报警显示_消防水炮需要外置声光报警吗
  5. python测试函数
  6. C++Primer第五版——习题答案详解(九)
  7. Particle Filter Tutorial 粒子滤波:从推导到应用(四)
  8. node mysql gbk_nodejs gb2312、GBK中文乱码解决方法
  9. win10系统禁用笔记本自带键盘的方法
  10. maya中英文对比_Maya菜单中英文对照总汇
  11. 腾讯防水墙的使用(python_web)
  12. 如何应对“创可贴”式员工
  13. three相机在模型上_深入理解Three.js中透视投影照相机PerspectiveCamera
  14. 谷歌使用人工智能来预测航班延误
  15. hdf5 matlab,hdf5格式的matlab读写操作
  16. uniapp意见反馈程序
  17. Three.js进阶篇之4 - 着色器
  18. 网易云音乐无法正常运行
  19. 【淘宝SEO技巧】深度揭秘淘宝搜索排名算法模型
  20. Java - JWT的简单介绍和使用

热门文章

  1. IOS技术分享| WebRTC iOS源码下载编译
  2. 华硕主板破linux密码破解,华硕P8B75-M-LE老主板加持NVMe SSD bios(刷新软件和bios)...
  3. wow工程修理机器人图纸_wow修理机器人74a型介绍及图纸怎么得
  4. 新手教程:采用AD9软件画原理图
  5. 利用matlab使用窗函数,MATLAB中的窗函数
  6. libxml2 知:介绍
  7. 进销存excel_用Excel制作简单的进销存系统
  8. 制作微软引导盘实现装系统0失败uefi启动MediaCreationTool
  9. CS229 Machine Learning 自学与答案
  10. 将视频设置为电脑动态桌面的方法