重读 xv6(VII)

$ echo hello | cat > hello.txt

#

我们已经讨论了 xv6 所有的模块设计,是时候出一个总集篇了

以下是我们要回答的终极问题:

当用户输入并运行 echo hello | cat > hello.txt 时,xv6 内部发生了什么?

敲下键盘之前 #

源码文件:sh.cutil.c

……shell 正在运行,打印出 $,调用 util/gets 等待用户输入命令

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

Trap 到内核,调用 sys_read

嵌套调用 fileread,发现文件描述符 0 指向 device,调用驱动程序 consoleread

等待用户输入……

人机交互 #

用户按下键盘,输入字符 e

此时,uart 检测到串行接口的寄存器发生改变,认为用户发起了一次写入操作,向 PLIC 提出一次设备中断

硬件检测到中断,sys_read 线程通过 kernelvec 陷入到驱动程序

kerneltrap 检测到 %scause 类型为 uart,调用对应的驱动函数 uartintr

嵌套调用 consoleintr ,从寄存器得知,输入字符为 e

console 更新其缓冲区 buffer 并调用 uartputc 将字符 e 回显到屏幕

uart 驱动返回到 consoleread 线程,从 buffer 读入字符 e

用户继续输入剩余的 cho hello | cat > hello.txt

此时 consoleread 一直处于循环,等待 \n 结束本行输入

最后用户敲击 Enter 键,consoleread 检测到 \n,结束输入

consoleread 结束后,从中断返回到 sys_read 线程

sys_read 结束读入,返回到 gets

gets 结束输入,getcmd 结束,shell 完成 command 字符串输入

解析命令 #

shell 读取到字符串 echo hello | cat > hello.txt

shelltoken 解析命令:

  1. 读入 echo hello
  2. 检查到 | ,认为有管道,分离出 cat > hello.txt
  3. 读入 cat
  4. 检查到 > ,认为有重定向,输出设置为 hello.txt

随后 shell 调用 runcmd 执行命令:

  1. 先检测到 PIPE,建立管道
  2. fork 出两个进程,重定向 IO 至管道,递归执行两个 cmd
  3. 检测到 cat > hello.txtREDIR 命令
  4. 调用 sys_open 打开 hello.txt,重定向输出流
  5. 分别执行 echocat

以上是 shell 的运行路径,下面我们回到 runcmd 执行前

管道 #

管道的实现与 console 类似,采用循环 buffer、双指针读写阻塞实现

调用 sys_pipe 时,会用 kalloc 动态申请空间

使用管道时,通过 dup/close 做文件描述符上的变换,将管道的一端指向 0/1 来重定向 IO

shell 创建 pipe,将 echocat 连接到管道上

文件 #

shell 调用 sys_open,创建 hello.txt 文件

open 扫描当前 path 下的目录,发现没有 hello.txt 文件,于是调用 create 创建

create 移动到对应的 path,调用 ialloc 获取 inode 资源,再将索引 link 加入到目录文件下,完成空文件创建

完成 command 环境的创建,到这里我们才开始正式运行命令

稍等一下 #

shell 成功 fork 出两个进程,等待执行 exec

我们忽略了一些东西:

  1. Trap 到系统调用的过程
  2. fork 管理的进程资源
  3. schedule 调度到新进程

让我们把时间稍稍前调,来到 fork 执行前……

sys_fork #

现在,fork 刚刚要执行,这是一个系统调用

将系统调用号放到指定的寄存器,执行 ECALL 汇编指令,发出中断

硬件检测到中断,跳转到 TRAPPOLINE 中的 uservec

uservectrapframe 中更换上下文,调用 sys_fork,复制进程资源

Fork 首先向 proctable 请求 UNUSED 进程资源,初始化页表、trapframe 随后,按 pagetable 逐个复制内存到新进程,其他变量也一并复制

fork 结束,从 userret 返回用户空间

调度 #

现在我们有三个 shell 进程了,准备好调度进程

时钟发出中断信号,硬件中断,调用 yield 让出 CPU

切换到 scheduler 线程,调用swtch,瞬间替换 context

此时切换到新进程的 forkret,调用 userret 回到用户态,顺利完成进程调度

exec #

调用 exec 读入用户代码 echocat,创建 user stack,重定向程序流

执行完成后,我们现在拥有三个进程 shellechocat

exec 并不改变 proc 已经打开的描述符,因此重定向 IO 不受影响

运行命令 #

echocat 调用 read/write 进行服务

不过,我们已经将 0, 1 进行重定向,因此数据都通过文件或管道流动

hello #

运行结束后,我们将输出结果 hello 写入 hello.txt

这里就直接讨论 write 的流程:

  1. 找到 hello.txtinode 文件
  2. 查找 inode 的映射表,找到要写入的物理地址
  3. 调用 bwrite,获取磁盘的 buffer block 映射
  4. hello 写入 log
  5. 事务结束后,commitlog 真正写入到磁盘

写入结束,shell 准备读取下一条命令

虚拟内存 #

有关虚拟内存的流程比较碎,没有提到,主要是以下部分:

  1. kalloc 动态分配空间
  2. 取地址时,硬件总是通过页表翻译出真实地址
  3. 读写时,需要用到 copyin / copyout 加上页表,在不同线程之间进行内存通信

后言 #

最后这个问题模仿了计网自顶向下的某一章,但我的笔力驾驭不了整个运行过程,花了太多篇幅在 shell 上,安排比较随意

如果能真正在头脑中运行这一串程序流,不需要弄清每个细节,只需要拨开层层抽象的洋葱,我想对 xv6 架构就认识的足够了,这也是我做源码回顾的动机