从零编写linux0.11 - 第七章 完善终端

编程环境: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转换为NL
        OPOST | ONLCR,  // 将输出的NL转换为CRNL
        0,              // 控制模式初始化为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 的定义如下。

// tty.h
/*  intr=^C     quit=^|     erase=del   kill=^U
    eof=^D      vtime=\0    vmin=\1     sxtc=\0
    start=^Q    stop=^S     susp=^Z     eol=\0
    reprint=^R  discard=^U  werase=^W   lnext=^V
    eol2=\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行改变了对删除的判断(其实就是换了层皮而已)。

运行看看有没有报错。

从零编写linux0.11 - 第七章 完善终端_第1张图片

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

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行)。

// tty_io.c
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 并不会发生改变。如下图所示。

从零编写linux0.11 - 第七章 完善终端_第2张图片

在 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);
                }
            } 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 的形式,其它字符就直接入队列。最后调用写函数将写队列的数据打印到屏幕上。

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

// 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);
}

我们就让这个任务进入死循环,当我们按下按键时,触发键盘中断,屏幕会显示按键对应的字符。

从零编写linux0.11 - 第七章 完善终端_第3张图片

本来我是想用感叹号的,但是 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个字符都存入读队列中,最后打印出来。

// 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 以及该任务在内核栈的空闲字节数打印出来。

运行结果如下。

从零编写linux0.11 - 第七章 完善终端_第4张图片

Insert-PageDown,方向键,小键盘数字键这些按键是一起处理的。

从零编写linux0.11 - 第七章 完善终端_第5张图片

可以看到,左边按键的扫描码比右边按键的扫描码多一个 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 需要修复。比如,在换行八十几次后,光标就跑到了屏幕首行。

从零编写linux0.11 - 第七章 完善终端_第6张图片

这应该是滚屏的时候出现了问题。具体出错位置还需要通过调试一步一步定位。

排查后发现,果然是滚屏的时候出了问题,具体是在超出显存的时候出的问题。编译器生成的汇编代码与我写的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))) {	// \0
                    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)) {	// 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)) {  // ^C
                tty_intr(tty, INTMASK);
                continue;
            }
            if (c == QUIT_CHAR(tty)) {  // ^I
                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行的循环中,由于此时辅助队列不为空,退出循环,任务可以重新运行。我们在加载文件系统的时候会用到这个函数。

终端的内容告一段落,下一章是文件系统,这部分内容真的很难,我还没把代码划分出来。

你可能感兴趣的