用键盘输入一条命令
新建一个非常简单的 info.txt 文件:
name:flash
age:28
language:java
在命令行输入一条十分简单的命令:
$ cat info.txt | wc -l
3
这条命令的意思是读取刚刚的 info.txt 文件,输出它的行数。
我们先从最初始的状态开始说起。最初始的状态,电脑屏幕前只有这么一段话:
[root@linux] $
然后,我们按下按键 ‘c’,将会变成这样:
[root@linux] $ c
再按下’a’:
[root@linux] $ ca
再依次按下:
[root@linux] $ cat info.txt | wc -l
今天就要解释这个看起来十分"正常"的过程。凭什么我们按下键盘后,屏幕上就会出现如此的变化呢?老天爷规定的么?
我们就从按下键盘上的 ‘c’ 键开始说起
首先,得益于系列之五中控制台初始化讲述的一行代码:
// 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
...
很简单,首先通过 IO 端口操作,从键盘中读取了刚刚产生的键盘扫描码,就是刚刚按下 ‘c’ 的时候产生的键盘扫描码。随后,在 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 项作为下一个函数的入参,仅此而已。
tty_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 标准:
好了,我们目前可以总结出,按下键盘后做了什么事情:
这里我们应该产生几个疑问:
1、读队列 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
队列,进行入队操作。
答案揭晓了,那我们的整体流程图也可以再丰富起来:
2、放入 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.c
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) {
...
}
这个函数在系列之五中控制台初始化提到了,最终会配合显卡,在我们的屏幕上输出我们给出的字符。我们的图又可以补充完整了:
核心点就是三个队列 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 这个控制台写函数,将字符打印在显示器上。
这就完成了从键盘输入到显示器输出的一个循环,也就是本节所讲述的内容。好了,现在我们已经成功做到可以把这样一个字符串输入并回显在显示器上了:
[root@linux] $ cat info.txt | wc -l
shell程序读取命令
上文讲了如何用键盘将字符串输入到显示器,那 shell 程序具体是如何读入这个字符串,读入后又是怎么处理的呢?
这里我们需要知道两件事情:
-
我们键盘输入的字符,此时已经到达了控制台终端 tty 结构中的
secondary
这个队列里。 -
shell 程序将通过上层的
read
函数调用,来读取这些字符
// xv6-public sh.c
int main(void) {
static char buf[100];
// 读取命令
while(getcmd(buf, sizeof(buf)) >= 0){
// 创建新进程
if(fork() == 0)
// 执行命令
runcmd(parsecmd(buf));
// 等待进程退出
wait();
}
}
int getcmd(char *buf, int nbuf) {
...
gets(buf, nbuf);
...
}
char* gets(char *buf, int max) {
int i, cc;
char c;
for(i=0; i+1 < max; ){
cc = read(0, &c, 1);
if(cc < 1)
break;
buf[i++] = c;
if(c == '\n' || c == '\r')
break;
}
buf[i] = '\0';
return buf;
}
看,shell 程序会通过 getcmd
函数最终调用到 read
函数一个字符一个字符读入,直到读到了换行符(\n
或 \r
)的时候,才返回。
读入的字符在 buf
里,遇到换行符后,这些字符将作为一个完整的命令,传入给 runcmd
函数,真正执行这个命令。
那我们接下来的任务就是,看一下这个 read 函数是怎么把之前键盘输入并转移到 secondary 这个队列里的字符给读出来的。
read 函数是个用户态的库函数,最终会通过系统调用中断,执行 sys_read
函数:
// read_write.c
// fd = 0, count = 1
int sys_read(unsigned int fd,char * buf,int count) {
struct file * file = current->filp[fd];
// 校验 buf 区域的内存限制
verify_area(buf,count);
struct m_inode * inode = file->f_inode;
// 管道文件
if (inode->i_pipe)
return (file->f_mode&1)?read_pipe(inode,buf,count):-EIO;
// 字符设备文件
if (S_ISCHR(inode->i_mode))
return rw_char(READ,inode->i_zone[0],buf,count,&file->f_pos);
// 块设备文件
if (S_ISBLK(inode->i_mode))
return block_read(inode->i_zone[0],&file->f_pos,buf,count);
// 目录文件或普通文件
if (S_ISDIR(inode->i_mode) || S_ISREG(inode->i_mode)) {
if (count+file->f_pos > inode->i_size)
count = inode->i_size - file->f_pos;
if (count<=0)
return 0;
return file_read(inode,file,buf,count);
}
// 不是以上几种,就报错
printk("(Read)inode->i_mode=%06o\n\r",inode->i_mode);
return -EINVAL;
}
这个最上层的sys_read
,把读取管道文件、字符设备文件、块设备文件、目录文件或普通文件,都放在了同一个方法里处理,这个方法作为所有读操作的统一入口,由此也可以看出 linux 下一切皆文件的思想。
read 的第一个参数是 0,也就是 0 号文件描述符,之前我们在讲第四部分的时候说过,shell 进程是由进程 1 通过 fork 创建出来的,而进程 1 在 init
的时候打开了 /dev/tty0
作为 0 号文件描述符:
// main.c
void init(void) {
setup((void *) &drive_info);
(void) open("/dev/tty0",O_RDWR,0);
(void) dup(0);
(void) dup(0);
}
而这个 /dev/tty0
的文件类型,也就是其 inode
结构中表示文件类型与属性的 i_mode
字段,表示为字符型设备,所以最终会走到 rw_char
这个子方法下,文件系统的第一层划分就走完了。
接下来我们看 rw_char
这个方法:
// char_dev.c
static crw_ptr crw_table[]={
NULL, /* nodev */
rw_memory, /* /dev/mem etc */
NULL, /* /dev/fd */
NULL, /* /dev/hd */
rw_ttyx, /* /dev/ttyx */
rw_tty, /* /dev/tty */
NULL, /* /dev/lp */
NULL}; /* unnamed pipes */
int rw_char(int rw,int dev, char * buf, int count, off_t * pos) {
crw_ptr call_addr;
if (MAJOR(dev)>=NRDEVS)
return -ENODEV;
if (!(call_addr=crw_table[MAJOR(dev)]))
return -ENODEV;
return call_addr(rw,MINOR(dev),buf,count,pos);
}
根据 dev
这个参数,计算出主设备号为 4,次设备号为 0,所以将会走到 rw_ttyx
方法继续执行
// char_dev.c
static int rw_ttyx(int rw,unsigned minor,char * buf,int count,off_t * pos) {
return ((rw==READ)?tty_read(minor,buf,count):
tty_write(minor,buf,count));
}
根据 rw == READ
走到读操作分支 tty_read
,这就终于快和上节的内容接上了。
以下是 tty_read
函数,省略了一些关于信号和超时时间等非核心的代码:
// tty_io.c
// channel=0, nr=1
int tty_read(unsigned channel, char * buf, int nr) {
struct tty_struct * tty = &tty_table[channel];
char c, * b=buf;
while (nr>0) {
...
if (EMPTY(tty->secondary) ...) {
sleep_if_empty(&tty->secondary);
continue;
}
do {
GETCH(tty->secondary,c);
...
put_fs_byte(c,b++);
if (!--nr) break;
} while (nr>0 && !EMPTY(tty->secondary));
...
}
...
return (b-buf);
}
入参有三个参数,非常简单。channel
为 0,表示 tty_table 里的控制台终端这个具体的设备。buf
是我们要读取的数据拷贝到内存的位置指针,也就是用户缓冲区指针。nr
为 1,表示我们要读出 1 个字符。
整个方法,其实就是不断从 secondary 队列里取出字符,然后放入 buf 指所指向的内存。
如果要读取的字符数 nr 被减为 0,说明已经完成了读取任务,或者说 secondary 队列为空,说明不论你任务完没完成我都没有字符让你继续读了,那此时调用 sleep_if_empty
将线程阻塞,等待被唤醒。其中 GETCH
就是个宏,改变 secondary 队列的队头队尾指针,你自己写个队列数据结构,也是这样的操作,不再展开讲解。
#define GETCH(queue,c) \
(void)({c=(queue).buf[(queue).tail];INC((queue).tail);})
同理,判空逻辑就更为简单了,就是队列头尾指针是否相撞:
#define EMPTY(a) ((a).head == (a).tail)
理解了这些小细节之后,再明白一行关键的代码,整个 read 到 tty_read
这条线就完全可以想明白了。那就是队列为空,即不满足继续读取条件的时候,让进程阻塞的 sleep_if_empty
:
sleep_if_empty(&tty->secondary);
// tty_io.c
static void sleep_if_empty(struct tty_queue * queue) {
cli();
while (!current->signal && EMPTY(*queue))
interruptible_sleep_on(&queue->proc_list);
sti();
}
// sched.c
void interruptible_sleep_on(struct task_struct **p) {
struct task_struct *tmp;
...
tmp=*p;
*p=current;
repeat: current->state = TASK_INTERRUPTIBLE;
schedule();
if (*p && *p != current) {
(**p).state=0;
goto repeat;
}
*p=tmp;
if (tmp)
tmp->state=0;
}
我们先只看一句关键的代码,就是将当前进程的状态设置为可中断等待:
current->state = TASK_INTERRUPTIBLE;
那么执行到进程调度程序时,当前进程将不会被调度,也就相当于阻塞了,不熟悉进程调度的同学可以复习一下系列之八中如果让你来设计进程调度。
进程被调度了,什么时候被唤醒呢?当我们再次按下键盘,使得 secondary 队列中有字符时,也就打破了为空的条件,此时就应该将之前的进程唤醒了,这在上节讲过。
// tty_io.c
void do_tty_interrupt(int tty) {
copy_to_cooked(tty_table+tty);
}
void copy_to_cooked(struct tty_struct * tty) {
...
wake_up(&tty->secondary.proc_list);
}
可以看到,在 copy_to_cooked
里,在将 read_q
队列中的字符处理后放入 secondary
队列中的最后一步,就是唤醒 wake_up
这个队列里的等待进程。而 wake_up
函数更为简单,就是修改一下状态,使其变成可运行的状态:
// sched.c
void wake_up(struct task_struct **p) {
if (p && *p) {
(**p).state=0;
}
}
总体流程:
当然,进程的阻塞与唤醒是个体系,还有很多细节,我们下篇再仔细展开这部分的内容。