0.briefly speaking
点此返回上一篇博客
上一篇博客中我们简单介绍了UART和PLIC的初始化过程,并迭代式的分析了console的读写操作,这篇博客接着上一篇的话题,研究一下一个字符是怎么一步步被显示到我们的屏幕上的,经过了哪些设备和步骤。为了方便阅读,这篇文章的标号紧跟上一篇文章,从2开始。
和上一篇博客一样,这篇博客采用迭代深入的方法,我们会紧跟处理流程,并在这个过程中研究和分析每一个被调用且与主线连接紧密的子函数,直到达到被调用序列的尽头,最终我们会给出整个过程的总览,作为总结和回顾。
2.字符是如何被显示到屏幕上的
2.1.故事的开始——printf
我们在日常开发中经常会使用printf来打印一些调试信息帮助我们debug,在Xv6的内核态与用户态也各有一个名为printf.c的文件专门用来提供对printf的支持。我们这次从用户态的printf出发,看看它到底做了什么,字符是如何被显示在我们的屏幕上的。
Xv6启动时会设置第一个进程init,这个进程会打开标准输入、标准输出、标准错误这三个文件标识符(分别对应0,1,2),并且这三个文件描述符其实都指向console。代码如下(user/init.c:14):
int
main(void)
{
int pid, wpid;
// 尝试以读写方式打开console文件
// 如果不成功,使用mknod创建一个设备节点
// mknod系统调用将会把console注册为一个设备类型的文件
// 注意console虽是软件构成,却当作一个设备使用,有趣!
if(open("console", O_RDWR) < 0){
mknod("console", CONSOLE, 0);
open("console", O_RDWR);
}
// 使得标准输出和标准错误都指向console
// 自此所有输入输出操作本质上都指向
dup(0); // stdout
dup(0); // stderr
// ...以下代码从略
}
OK,从这之后就可以输出数据了,我们一般会使用printf函数来做这件事,printf的函数实现如下(user/printf.c:106),printf会调用vprintf完成一系列复杂的处理和判断,并完成字符的输出,注意默认情况下vprintf向标准输出(fd=1)输出字符。
void
printf(const char *fmt, ...)
{
// 使用va_list来处理可变参数
va_list ap;
va_start(ap, fmt);
// 调用vprintf完成结果的打印
// 注意,默认输出到文件标识符1,即标准输出
vprintf(1, fmt, ap);
}
这里不打算对vprintf解释太多,因为它涉及到了很多处理细节,它本质上在尝试解析用户传入的格式化字符串,并且按照格式化字符串约定好的格式来解析要打印的参数。注意到vprintf最终所有的打印操作都会落到一个叫做putc的函数上,我们直接去看看putc的函数实现(user/printf.c:9),可以发现这个函数就是write系统调用的简单封装。
// putc的函数实现其实就是write系统调用的简单封装
// 故事到这里就开始进入内核了...
static void
putc(int fd, char c)
{
write(fd, &c, 1);
}
2.2 陷入内核——write系统调用
从上面可知,用户态下所有字符的显示任务最终都进入到了内核下的sys_write系统调用实现,它的实现如下(kernel/sysfile.c:81),它首先调用了一些arg*函数来对用户态传入的参数进行解析,随后就调用filewrite函数进行下一步写操作。
uint64
sys_write(void)
{
struct file *f;
int n;
uint64 p;
// 解析传入的参数
// argint和argaddress分别是将传入的参数解析为整数和指针
// 这在之前的博客中已经有所提及
// argfd则稍显复杂:
// 它首先解析出了传入的文件标识符
// 然后用这个标识符去查找当前进程的打开文件表,并将文件句柄返回给传入的第三个参数
// 因此f现在持有了console的文件句柄
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
return -1;
// 调用filewrite函数进行下一步写操作
return filewrite(f, p, n);
}
2.3 转发到consolewrite——filewrite函数
filewrite函数(kernel/file.c:132)要做的事情就是根据传入的文件句柄的种类,去分门别类地对写操作进行处理和转发,它的代码和相应注释如下。因为我们传入的文件是console,而它在之前使用mknod注册成了设备,所以这里自然会落到对应的分支下。接下来,filewrite函数会调用console驱动中已经注册好的consolewrite函数,完成写操作。
// Write to file f.
// addr is a user virtual address.
int
filewrite(struct file *f, uint64 addr, int n)
{
int r, ret = 0;
if(f->writable == 0)
return -1;
// Xv6中文件有4种类型:FD_NONE, FD_PIPE, FD_INODE, FD_DEVICE
// filewrite函数对根据传入的文件类型它们进行分门别类的处理和转发
// 如果传入的是管道类型的文件
if(f->type == FD_PIPE){
ret = pipewrite(f->pipe, addr, n);
// 如果传入的是设备类型的文件,console恰好属于此类
} else if(f->type == FD_DEVICE){
// 主设备号范围非法或未注册写函数,filewrite都会调用失败
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].write)
return -1;
// 否则调用设备驱动注册的write函数,完成此次写任务
// 这个函数正是我们之前在consoleinit中注册的consolewrite函数
ret = devsw[f->major].write(1, addr, n);
// 如果传入的是索引节点类型的文件
} else if(f->type == FD_INODE){
// 从略,对索引节点的处理逻辑,我们在文件系统部分进行详细分析
} else {
panic("filewrite");
}
return ret;
}
2.4 consolewrite驱动UART完成字符输出
故事到这里已经和上一篇博客中接上了,接下来consolewrite函数会首先调用either_copyin函数将用户从printf传入的字符拷贝到内核态(字符只有一个),然后调用uartputc函数将此字符放入UART输出缓冲区中,uartputc会接着调用uartstart函数真正地将缓冲区里的字符从UART的TSR寄存器中发送出去,而这个UART输出会经由qemu的模拟直接连接到屏幕上。
3.字符是如何从键盘中被读取的
3.1 按下键盘——触发中断
UART是全双工的串口通信协议,我们在上面说它的TX(输出端)连接到了我们的显示器,那么它的RX(接收端)其实就连接到了我们的键盘。我们这里不对它们连接具体细节进行解释,因为这是qemu源码中做的事情,简单来说它模拟了一个UART 16550芯片,并将它的输出与我们的显示器相连,输入与我们的键盘相连。
当我们按下一个键时,经过键盘的扫描等操作,会确认我们按下的是哪个字符,并将这个字符的ASCII编码通过连线发送给串口芯片16550,因为我们之前在初始化16550的时候设置的trigger level是1,所以16550芯片接收到每个字符都会触发一次设备中断(即键盘导致的串口中断)。
我们之前在6.S081——补充材料——RISC-V架构中的异常与中断详解一文中详细解释了外部中断经由PLIC路由的过程,并解释了RISC-V核心的claim/complete机制。当一个核心收到了外部中断信号时,它就会从用户态经由trampoline.S进入内核态,并在usertrap函数中根据scause寄存器的数值对陷阱进行转发,这里不再详述。外部设备中断在usertrap中将会被devintr函数接管,它的代码如下:
int
devintr()
{
uint64 scause = r_scause();
// 首先根据scause中的值确定这是一个外部设备引起的中断
if((scause & 0x8000000000000000L) &&
(scause & 0xff) == 9){
// this is a supervisor external interrupt, via PLIC.
// irq indicates which device interrupted.
// 译:irq指明了是哪个设备发生的中断
// 此核心和其他核心展开竞争,如成功irq变量中就会成功收到外部中断ID
int irq = plic_claim();
// 根据irq值的不同,判断是哪种中断
// 串口中断使用uartintr()函数来处理
if(irq == UART0_IRQ){
uartintr();
// 磁盘中断使用virtio_disk_intr()函数来处理
} else if(irq == VIRTIO0_IRQ){
virtio_disk_intr();
// 否则报错,这是一个无法识别的中断类型
} else if(irq){
printf("unexpected interrupt irq=%d\n", irq);
}
// the PLIC allows each device to raise at most one
// interrupt at a time; tell the PLIC the device is
// now allowed to interrupt again.
// 译:PLIC至多只允许每个设备在同一时间发起一个中断
// 向PLIC发送complete信号表示对应中断源可以再次中断
// 这里我们在之前的博客中讲解过,complete操作可以再次使能
// 同一类型的中断
if(irq)
plic_complete(irq);
// 返回1,表示是外部设备中断且已被处理
return 1;
// 如果是时钟中断,则做下面的一些事
} else if(scause == 0x8000000000000001L){
// ...从略,我们在后面加一个专门的小话题讲解时钟中断
// 否则返回0,这个中断没有被devintr识别,表明它是一个异常
} else {
return 0;
}
}
可以看到,在devintr函数中我们会调用plic_claim函数使得核心响应这个中断,如果竞争成功这个函数就会给核心返回对应外部设备的中断ID号。之后此函数对比ID号从而将不同设备的中断ID转发到不同的处理函数中去,对于UART中断,将会被uartintr函数接管。
3.2 uartintr接管——字符放入console缓冲区并回显
3.2.1 uartintr函数总览
所以,经过devintr函数的处理,我们敲击键盘引起的UART中断最终被uartintr函数接管了(注意:uartintr实质上会接受两种类型的中断:1.输入通道RX为满则中断(键盘输入中断) 2.输出通道TX为空则中断,这里我们只讨论前者),这个函数的代码和注释如下(kernel/uart.c:179),在这个函数中调用了uartgetc、consoleintr以及uartstart函数。其中uartstart函数我们之前已经了解过,是专门调用串口来异步地发送缓冲区中字符的,接下来我们详细分析一下剩下的两个函数uartgetc和consoleintr。
// handle a uart interrupt, raised because input has
// arrived, or the uart is ready for more output, or
// both. called from trap.c.
// 译:处理一个uart中断,当有输入到来或者
// uart准备好发送更多输出时触发,或二者同时发生
// 此函数在trap.c中被调用
// 注意两种情况下会触发此函数:
// 1.输入通道RX为满(即键盘有数据输入)
// 2.输出通道TX为空
void
uartintr(void)
{
// read and process incoming characters.
// 译:读取和处理到来的字符,对应RX为满的中断
while(1){
// 使用uartgetc获取字符
// 没有获取到时跳出循环
int c = uartgetc();
if(c == -1)
break;
// 调用consoleintr函数
// 这个函数会负责将输入的字符放入console缓冲区
// 并实时回显用户输入的字符
// 如果一整行已经到达或者是EOF触发或者缓冲区满
// 则更新写指针到编辑指针的位置,详情见下
consoleintr(c);
}
// send buffered characters.
// 译:异步发送缓冲区中的字符,对应TX为空的中断
acquire(&uart_tx_lock);
uartstart();
release(&uart_tx_lock);
}
3.2.2 uartgetc函数
首先是uartgetc函数(kernel/uart.c:166),这个函数的代码和注释如下。这个函数是面向16550芯片的简单操作,它首先判断是否有输入等待读取,如果有就返回输入字符,反之则返回-1,逻辑还是非常简单的。
// read one input character from the UART.
// return -1 if none is waiting.
// 译:从UART中读取一个输入字符
// 如果没有字符输入则返回-1
int
uartgetc(void)
{
// 首先判断LSR(Line Status Register)寄存器最低位的情况
// 这一位含义是Receive Data Ready,即输入数据已就绪
if(ReadReg(LSR) & 0x01){
// 如果输入数据已经就绪,直接返回RHR(Receiving Holding Register)的值
return ReadReg(RHR);
} else {
// 输入没有就绪则返回-1
return -1;
}
}
3.2.3 consoleintr函数
然后是consoleintr(kernel/console.c:135)函数,它的代码实现颇长,因为这里面涉及到不同情况下组合键的处理,以及写指针和编辑指针的改动,逻辑稍显零碎,细节都已经注释在下方。简单来说此函数尝试将字符放入console的缓冲区cons.buf中,并适时地对一行的结束进行确认,同时它会调用consputc函数进行立即回显。
//
// the console input interrupt handler.
// uartintr() calls this for input character.
// do erase/kill processing, append to cons.buf,
// wake up consoleread() if a whole line has arrived.
// 译:console的输入中断函数
// uartintr函数调用这个函数来处理输入字符
// 完成擦除(一部分)或者删掉整个命令行、加入cons.buf等动作
// 如果一个新行到来,则唤醒consoleread函数
void
consoleintr(int c)
{
// 获取console缓冲区的锁,防止竞争
acquire(&cons.lock);
// 判断输入字符
switch(c){
// 如果是Ctrl + P组合键,则打印出所有正在运行的进程列表
// 这里不再对procdump函数展开
// 它只是以列表形式打印出当前系统中正在运行的所有进程
case C('P'): // Print process list.
procdump();
break;
// 如果是Ctrl + U组合键,删除本次之前输入的所有命令行
case C('U'): // Kill line.
// 持续回退编辑指针,直到遇见换行符或编辑指针与写指针重合
while(cons.e != cons.w &&
cons.buf[(cons.e-1) % INPUT_BUF] != '\n'){
cons.e--;
// 每成功回退一次编辑指针,就擦除之前输入的字符,回显!
consputc(BACKSPACE);
}
break;
// 如果输入的时Ctrl + H组合键,即退格键
// Ctrl + H和直接按下backspace键(ASCII值:0x7f)含义是一致的
// 所以这里将两种情况合并
case C('H'): // Backspace
case '\x7f':
// 如果之前输入过字符,那么就将编辑指针回退一个位置
// 没有过输入则退无可退
// 并擦除上一个输入字符,回显!
if(cons.e != cons.w){
cons.e--;
consputc(BACKSPACE);
}
break;
// 默认情况
default:
// 如果字符不为空,且缓冲区没有满
if(c != 0 && cons.e-cons.r < INPUT_BUF){
// 如果输入的字符是回车,即Enter键
// 则自动替换此字符为换行符'\n'
c = (c == '\r') ? '\n' : c;
// echo back to the user.
// 译:回显给用户
// 每次我们输入一个字符,如果不加回显是什么也看不到的
// 正是因为有conputc这样同步的回显我们才会实时看到我们的输入
consputc(c);
// store for consumption by consoleread().
// 译:存储下来等待consoleread读取
// 注意我们移动的是编辑指针
cons.buf[cons.e++ % INPUT_BUF] = c;
// 如果c是换行符(即之前的'\r')或者EOF组合键或者console缓冲区已满
// 此三种情况都必须确认写入了
// 所以将写入指针更新到编辑指针所在的位置,表示写入确认
// 并唤醒之前可能沉睡的consoleread函数,这个函数可能之前因为无数据可读而睡眠
if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
// wake up consoleread() if a whole line (or end-of-file)
// has arrived.
// 译:如果一整行或EOF到达,则唤醒consoleread函数
cons.w = cons.e;
wakeup(&cons.r);
}
}
break;
}
// 释放缓冲区的锁
release(&cons.lock);
}
3.2.4 consputc函数
在consoleintr函数中也大量调用了consputc函数(kernel/console.c:33),这个函数的功能就是直接将一个字符发送给UART显示,这个字符不会经过缓冲区,而是直接送到UART去发送,这是依靠uartputc_syn函数来实现的,等下我们会去对比这个函数和之前说过的uartputc函数。另外,这里还需要对回退符做一些简单的判断和处理操作,详见下面的注释。
//
// send one character to the uart.
// called by printf, and to echo input characters,
// but not from write().
// 译:发送一个字符到UART,被(内核)printf调用,以及回显输入字符
// 但不会被write()调用
void
consputc(int c)
{
// 如果当前字符是退格键
if(c == BACKSPACE){
// if the user typed backspace, overwrite with a space.
// 译:如果用户输入的是一个退格键,那么使用一个空格来覆写前一个字符
// '\b'转义字符的作用是将光标回退一格,这样下一次写入时会覆盖原本的上一个字符
// 下面连续调用三个uartputc_sync函数来将上一个字符清除掉
uartputc_sync('\b'); uartputc_sync(' '); uartputc_sync('\b');
} else {
// 如果不是退格键,那么按照原样字符输出
uartputc_sync(c);
}
}
最后说一嘴,注意区分consputc和consolewrite这两个函数,它们本质上都是向屏幕发送显示字符,但是也有很多不同,一定要注意区分:
- consputc是运行在内核态下的,consolewrite是从用户态读取字符并显示的。
- consputc输出字符是同步的,效率高,consolewrite输出字符是异步的,效率相对低。
- consputc调用uartputc_sync完成同步字符发送,consolewrite则调用uartputc完成异步字符发送。
正是因为consputc的同步发送,使得在consoleintr函数中,我们输入的字符可以第一时间回显给我们。
3.2.5 uartputc_sync v.s. uartputc
在上面consputc函数中大量调用了uartputc_sync函数,之前我们在上一篇博客中在分析consolewrite函数时简单分析了一个叫做uartputc的函数,uartputc函数负责将一个字符放入发送缓冲区中,并尝试使用uartstart函数发送它。那么这里为什么又会有一个uartputc_sync呢,它们之间的区别又在哪?
其实它们的区别主要在于动作上的同步和异步,uartputc_sync顾名思义是一个同步输出字符的函数,它不需要经过缓冲区,并且在发送时如果UART串口TX不是空闲的,它会阻塞在此直至发送成功,所以我们说这是一个同步的发送动作。而uartputc函数则是一个异步的动作,这个动作首先要经过发送缓冲区,缓冲区如果已满则要在上面睡眠等待空位出现,真正调用uartstart函数发送时,这个函数还要检查UART发送端是否空闲,如果不空闲则立即退出,等到下一次uartstart函数被调用时才会再次尝试发送。
因为uartputc_sync函数的同步性,使得它非常适合用来打印一些实时性要求很高的信息,例如回显、内核提示的信息等。
uartputc_sync函数实现如下:
// alternate version of uartputc() that doesn't
// use interrupts, for use by kernel printf() and
// to echo characters. it spins waiting for the uart's
// output register to be empty.
// 译:不使用中断的uartputc的替换版本
// 用于内核printf和回显字符
// 它会持续等待uart的输出寄存器为空(同步性、阻塞性)
void
uartputc_sync(int c)
{
// 关中断,防止串口中断再次进入造成竞争
push_off();
// 如果内核已经崩溃则陷入死循环
if(panicked){
for(;;)
;
}
// wait for Transmit Holding Empty to be set in LSR.
// 译:等待LSR中的发送寄存器为空标识被置位
while((ReadReg(LSR) & LSR_TX_IDLE) == 0)
;
// 立即通过UART发送字符
WriteReg(THR, c);
// 恢复之前的中断状态
pop_off();
}
4.输入的数据如何被消费
4.1 故事的开始——getcmd
故事到这里并没有结束,通过键盘我们将字符通过上述过程输入到了console的缓冲区cons.buf里,同时也通过回显过程实时的将我们输入的字符显示到了屏幕上。但是console里的字符必须得有事物来consume,否则缓冲区一定会爆掉,而这就是命令行解释器shell做的事情了。
命令行解释器(shell)位于user/sh.c文件中,显然,它在Xv6中是一个用户态的程序。关于它的实现细节,我可能会在整个内核核心代码分析完成之后开一个小的专题来研究一下。这里简单说一下它要完成的事情,解释器负责从内核中读取用户输入的一整行命令,然后解析它并创建(fork)一个子进程去执行刚才的这条命令。
看到这里,大概你已经想到了,所谓“从内核读取用户数输入的一整行命令”,其实就是从控制台console的缓冲区中将我们之前用键盘输入的一整行命令读出。这个过程位于getcmd函数(user/sh.c:133)中,代码如下:
int
getcmd(char *buf, int nbuf)
{
// 向屏幕输出$
// 并将承载命令的缓冲区buf初始化为空
write(2, "$ ", 2);
memset(buf, 0, nbuf);
// 调用gets函数,从内核读取最大长度为nbuf的字符串到buf中
gets(buf, nbuf);
if(buf[0] == 0) // EOF
return -1;
return 0;
}
上述函数中调用了gets来获取字符,这个函数的实现如下(user/ulib.c:55),这个函数中使用了read系统调用来从标准输入(fd=0)中一个字符一个字符地进行读取。我们在之前已经说过=在第一个线程启动时,标准输入、标准输出和标准错误都被定向到了console,所以这里的read操作其实也对应到了从console中读取一个字符。
char*
gets(char *buf, int max)
{
int i, cc;
char c;
// 从标准输入读取最多max个字符
// 每次读取一个字符
for(i=0; i+1 < max; ){
// 执行read系统调用,读取一个字符
cc = read(0, &c, 1);
// 如果没有成功读取字符,则跳出循环
if(cc < 1)
break;
// 否则将字符放入缓冲区
// 并在当前字符为回车或者换行时跳出循环
buf[i++] = c;
if(c == '\n' || c == '\r')
break;
}
// 补齐结尾符
buf[i] = '\0';
return buf;
}
4.2 陷入内核——read系统调用
接上,gets调用了read系统调用,这个系统调用经过usertrap的转发最终会陷入到sys_read这个系统内核的实现中,它的代码如下(kernel/sysfile.c:69),可以看到这个函数首先解析了用户态传入的参数,并最终调用了fileread函数来从console(设备)文件中读取字符。这个函数整体的结构和我们之前介绍的sys_read简直如出一辙,笑…
uint64
sys_read(void)
{
struct file *f;
int n;
uint64 p;
// 解析用户态传入的参数
if(argfd(0, 0, &f) < 0 || argint(2, &n) < 0 || argaddr(1, &p) < 0)
return -1;
// 调用fileread完成字符的读取
return fileread(f, p, n);
}
4.3 转发到consoleread——fileread函数
fileread函数(kernel/file.c:106)和我们之前介绍过的filewrite函数非常相似,也是相当于一个转发中转站,对于像console这样的设备文件,它会直接调用已经注册好的驱动:consoleread函数来最终完成字符的读取:
// Read from file f.
// addr is a user virtual address.
int
fileread(struct file *f, uint64 addr, int n)
{
int r = 0;
if(f->readable == 0)
return -1;
// 如果文件的类型是管道
// 则调用piperead来来进行读操作的处理
if(f->type == FD_PIPE){
r = piperead(f->pipe, addr, n);
// 如果文件的类型是设备
// 则调用设备已经注册好的驱动函数来处理
} else if(f->type == FD_DEVICE){
if(f->major < 0 || f->major >= NDEV || !devsw[f->major].read)
return -1;
r = devsw[f->major].read(1, addr, n);
// 如果文件的类型是索引节点
} else if(f->type == FD_INODE){
// ...操作从略
// 未识别的文件类型,内核陷入panic
} else {
panic("fileread");
}
return r;
}
4.4 consoleread从设备缓冲区中读取字符
故事到此为止,就回到了我们熟悉的地方,在上一篇博客中我们仔细分析了consoleread函数的实现,它主要做的事情就是对输入边界进行鉴别(例如Ctrl+D,'\n’等),并使用either_copyout函数将字符拷贝回用户态指定的地址中去。这样,console中的数据就成功被读出到了用户态,con.buf获得了释放,shell也可以解析并执行我们输入的命令了,故事到此也就彻底完整了。
5.总结——一图胜千言
真的是很复杂的流程啊,想必看到这里人都懵逼了吧,其实我也开始混乱了…console、键盘、屏幕、shell、串口,它们是怎么交互和连接的,现在是有点太过于拘泥于细节而失去了全局观了。下面我就画出整个连接关系的全貌,作为这两篇博客的总结。
在上图中,循着任何一种颜色的线条都可以将流程顺利捋顺,这就是对整个部分的总结。console的定位就是一个软件抽象出来的设备体,它专门用来缓存用户输入的字符,并对其中输入的特殊字符和组合键进行预处理,使得串口可以正常打印,shell可以正常解析。