当输入`echo "Hello world"`后发生了什么

前提

首先我们要知道我们在终端执行。终端I/O有两种不同的工作方式

  • 规范方式输入处理:一行为单位进行处理
  • 非规范方式输入处理:输入字符不以行为单位进行装配

终端登入

同时,我们登入时,创建我们的终端进程,会创建一个会话,同时有shell进程组。
对于每个物理终端端口,都有一个getty监视,getty是由init程序启动的。getty对终端设备调用open含税,已读写方式将终端打开,一旦设备被打开,则文件描述符0,1,2就被设置到该设备,然后getty输出login,并等待用户输入用户名,当用户输入完用户名后,getty的使命就完成了,它会调用/usr/bin/login程序。(输入密码过程跳过)

当输入正确的密码后,login会做一下几件事

  • 将当前工作目录更改为该用户的起始目录
  • 调用chown改变该终端的所有权,使该用户成为所有者和组所有者
  • 将该终端设备的权限改为:用户读+写,组写
  • 调用setgidinitgroups设置进程的组ID
  • 使用login得到的信息初始化环境,起始目录,shell,用户名,以及系统默认路径(PATH)
  • 最后login进程改变为登陆用户名的用户ID(seruid)并调用该用户的登陆shell,类似:
1
ececl("/bin/sh", "-sh", (char *)0);
  • 登陆shell会读取点文件,进行一些环境变量的初始化设置
    • /etc/profile –> /etc/profile.d/*.sh –> ~/.bash_profile –> ~/.bashrc –> /etc/bashrck

伪终端

真正的终端是需要连接到电脑的,所以我们通过远程连接的一般都是伪终端,伪终端(pseudo terminal)这个名词暗示了与一个应用程序相比,它更加像一个终端。但事实上,伪终端并不是一个真正的终端。

  • 通常一个进程打开伪终端主设备然后调用fork。子进程建立了一个新的对话,打开一个相应的伪终端从设备,将它复制成标准输入、标准输出和标准出错,然后调用exec。伪终端从设备成为子进程的控制终端。
  • 对于伪终端从设备之上的用户进程来说,其标准输入、标准输出和标准出错都能当作终端设备使用。
  • 任何写到伪终端主设备的输入都会作为从设备端的输入

fork做了什么

fork()函数调用之后,就会为子进程创建一个新的PCB,子进程就会完全拷贝父进程的地址空间,包括堆、栈、代码段。进程被存放在一个叫做任务队列的双向循环链表当中.链表当中的每一项都是类型为task_struct成为进程描述符的结构.也就是我们写过的进程PCB。内核通过一个位置的进程标识值或PID来标识每一个进程

一个现存进程调用fork()函数是UNIX内核创建一个新进程的唯一方法

  • 为新进程分配task_struct任务结构体内存空间。
  • 把父进程task_struct任务结构体复制到子进程task_struct任务结构体。
  • 为新进程在其内存上建立内核堆栈。
  • 对子进程task_struct任务结构体中部分变量进行初始化设置。
  • 把父进程的有关信息复制给子进程,建立共享关系。
  • 把子进程加入到可运行队列中。
  • 结束fork()函数,返回子进程ID值给父进程中栈段变量id。
  • 当子进程开始运行时,操作系统返回0给子进程中栈段变量id。

子进程继承的东西

  • 实际用户ID、实际组ID、有效用户ID、有效组ID
  • 添加组ID
  • 进程组ID
  • 对话期ID
  • 控制终端
  • 设置-用户-ID标志和设置-组-ID标志
  • 当前工作目录
  • 根目录
  • 文件方式创建屏蔽字
  • 信号屏蔽和排列
  • 信号的处理方式
  • 对任一打开文件描述符的在执行时关闭标志
  • 环境
  • 连接的共享存储段
  • 资源限制

父、子进程之间的区别是:

  • fork的返回值。
  • 进程ID。
  • 不同的父进程ID。
  • 子进程的tms_utime, tms_stime, tms_cutime以及tms_ustime设置为0。
  • 父进程设置的锁,子进程不继承。
  • 子进程的未决告警被清除。
  • 子进程的未决信号集设置为空集

exec函数

fork函数创建子进程后,子进程往往要调用一种exec函数以执行另一个程序。当进程调用exec函数时,该进程完全由新程序代换,而新程序则从其main函数开始执行。因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用另一个新程序替换了当前进程的正文、数据、堆和栈段。

  • p表示该函数取filename作为参数,并且用PATH环境变量寻找可执行文件
  • l表示该函数取一个参数表,它与v互斥
  • v表示该函数取一个argv[]
  • e表示该函数取envp[]数组,而不使用当前环境

执行新程序的进程还保持了原进程的下列特征

  • 进程ID和父进程ID。
  • 实际用户ID和实际组ID。
  • 添加组ID。
  • 进程组ID。
  • 对话期ID。
  • 控制终端。
  • 闹钟尚余留的时间。
  • 当前工作目录。
  • 根目录。
  • 文件方式创建屏蔽字。
  • 文件锁。
  • 进程信号屏蔽。
  • 未决信号。
  • 资源限制。
  • tms_utime, tms_stime, tms_cutime以及tms_ustime

输入e时发生了什么

我们的电脑设备可以接非常多的输入输出设备,比如键盘、鼠标。他们通过设备控制器和CPU进行交流,比如写CPU的寄存器

  • 通过写入这些寄存器,操作系统可以命令设备发送数据、接收数据、开启或关闭,或者执行某些其他操作。
  • 通过读取这些寄存器,操作系统可以了解设备的状态,是否准备好接收一个新的命令等。

控制器有三类寄存器

  • 状态寄存器:目的是告诉 CPU ,现在已经在工作或工作已经完成
  • 命令寄存器:CPU 发送一个命令,告诉I/O设备,要进行输入/输出操作,任务完成后,会把状态寄存器里面的状态标记为完成。
  • 数据寄存器:CPU向I/O设备写入需要传输的数据

CPU通过端口I/O,每个控制寄存器被分配一个I/O端口,可以通过特殊的汇编指令操作这些寄存器
或者通过内存映射I/O,将所有控制寄存器映射到内存空间中,这样就可以像读写内存一样读写数据缓冲区

CPU通过读写设备控制器中的寄存器控制设备,进行操作和读取

别急,输入输出设备还分为两大类:

  • 块设备(Block Device):把数据存储在固定大小的块中,每个块有自己的地址,硬盘、USB 是常见的块设备
  • 字符设备(Character Device):以字符为单位发送或接收一个字符流,字符设备是不可寻址的,也没有任何寻道操作,鼠标是常见的字符设备

块设备通常传输的数据量会非常大,于是控制器设立了一个可读写的数据缓冲区

  • CPU写入数据到控制器的缓冲区时,当缓冲区的数据囤够了一部分,才会发给设备
  • CPU从控制器的缓冲区读取数据时,也需要缓冲区囤够了一部分,才拷贝到内存

I/O控制方式

  • 轮询:CPU不断询问
  • 中断:硬件的中断控制器通知CPU

但中断的方式对于频繁读写数据的磁盘,并不友好,这样 CPU 容易经常被打断,会占用 CPU 大量的时间。对于这一类设备的问题的解决方法是使用 DMA(Direct Memory Access) 功能,它可以使得设备在 CPU 不参与的情况下,能够自行完成把设备 I/O 数据放入到内存。那要实现 DMA功能要有「DMA 控制器」硬件的支持。

介于我们今天不涉及磁盘,就跳过这里

设备驱动程序

虽然设备控制器屏蔽了设备的众多细节,但每种设备的控制器的寄存器、缓冲区等使用模式都是不同的,所以为了屏蔽「设备控制器」的差异,引入了设备驱动程序。

  • 设备控制器:硬件
  • 设备驱动程序:操作系统的一部分

设备驱动程序会提供统一的接口给操作系统。通常,设备驱动程序初始化的时候,要先注册一个该设备的中断处理函数。

  • 在I/O时,设备控制器如果已经准备好数据,则会通过中断控制器向 CPU 发送中断请求;
  • 保护被中断进程的 CPU 上下文;
  • 转入相应的设备中断处理函数;
  • 进行中断处理;
  • 恢复被中断进程的上下文;

总线

CPU里面的内存接口,直接和系统总线通信,然后系统总线再接入一个I/O桥接器,这个I/O桥接器,另一边接入了内存总线,使得 CPU和内存通信。再另一边,又接入了一个I/O总线,用来连接I/O设备,比如键盘、显示器等。

系统终端

那当用户输入了键盘字符,键盘控制器就会产生扫描码数据,并将其缓冲在键盘控制器的寄存器中,紧接着键盘控制器通过总线给 CPU发送中断请求。CPU收到中断请求后,操作系统会保存被中断进程的CPU上下文,切换到核心态,然后调用键盘的中断处理程序。改变PC指针,来切换执行的程序。中断类型不同,PC指针跳转的位置也可能会不同。对中断的分类叫做中断识别码,而PC需要跳转的地址称为中断向量

键盘的中断处理程序是在键盘驱动程序初始化时注册的,那键盘中断处理函数的功能就是从键盘控制器的寄存器的缓冲区读取扫描码,再根据扫描码找到用户在键盘输入的字符,如果输入的字符是显示字符,那就会把扫描码翻译成对应显示字符的ASCII码,比如用户在键盘输入的是字母e,是显示字符,于是就会把扫描码翻译成e字符的ASCII码。

显示

得到了显示字符的ASCII码后,就会把ASCII码放到「读缓冲区队列」,接下来就是要把显示字符显示屏幕了,显示设备的驱动程序会定时从「读缓冲区队列」读取数据放到「写缓冲区队列」,最后把「写缓冲区队列」的数据一个一个写入到显示设备的控制器的寄存器中的数据缓冲区,最后将这些数据显示在屏幕里。显示出结果后,恢复被中断进程的上下文。

按下回车时

解析指令

终端实际上分2类读取命令

  • 读取命令行: Shell会逐个判断读取的每一个字符,直至遇到一个分号;、后台进程符号&、逻辑与&&、逻辑或||或换行字符等。整个命令行的读取过程才算结束。
  • 读取一个结构语句:在读取一个结构语句,比如,if语句块、for语句块、while语句块等,shell将会读入整个结构语句。直至遇到上面;&&&||或换行符。

语法分析

在读入一个完成的命令或结构语句之后,shell开始对命令语句进行语法分析,把命令语句分解为一系列单词或关键字(称为token)。通常,shell假定每个token都是以空格或制表符分隔的一系列“连续字符”组成的。

IFS是一种 set 变量,当shell处理"命令替换"和"参数替换"时,shell根据IFS的值,默认是space, tab, newline来拆解读入的变量。

shell使用Shell Grammar进行语法解析,首先输入的token必须能够成功被识别

  • 如果token是一个操作符,shell会预测这个操作符的执行行为
  • 如果token包含数字,且包含<>,则IO_NUMBER需要被返回

判断过程

  • 如果识别出输入的结尾,则应定界当前token(如果有的话)。
  • 如果之前的字符用作运算符的一部分,并且当前字符未加引号,并且可以与先前的字符一起使用以构成运算符,则应将其用作该(运算符)token的一部分。
  • 如果之前的字符用作运算符的一部分,而当前字符不能与前一个字符一起使用以构成一个运算符,则应将包含前一个字符的运算符定界。
  • 如果当前字符是<反斜杠>,单引号或双引号,并且未加引号,则它将影响到后续字符的引号。在令牌token期间,不得实际执行任何替换操作,并且结果令牌应完全包含输入中出现的字符(<newline>联接除外),且未修改,包括<引号之间的任何嵌入式或封闭的引号或替换运算符。标记>和引号末尾。令牌不应在引用字段的末尾定界。
  • 如果当前字符是未加引号的$或`,则外壳程序应从其引号未引号中标识出参数扩展(参数扩展),命令替换(命令替换)或算术扩展(算术扩展)的任何候选对象的开头。字符序列:分别为$${$(或`和$((。shell应读取足够的输入以确定要扩展的单元的末尾(如在处理字符时,如果发现替换中嵌套有扩展或引号的实例,则shell应按照为找到的结构指定的方式递归处理它们。它的末尾应允许识别嵌入式结构的任何递归操作,应不加任何改动地包含在结果令牌中,包括任何嵌入式或封闭的替换运算符或引号。
  • 如果当前字符未加引号,并且可用作新运算符的第一个字符,则应对当前令牌(如果有)进行定界。当前字符将用作下一个(操作员)token的开始。
  • 如果当前字符是未加引号的<blank>,则包含前一个字符的所有记号都会被定界,并且当前字符将被丢弃。
  • 如果前一个字符是单词的一部分,则当前字符应附加到该单词的后面。
  • 果当前字符为#,则该字符和所有后续字符(直至但不包括下一个<newline>)将作为注释丢弃。该行结尾的<newline>不被视为注释的一部分。
  • 当前字符用作新单词的开头。

命令历史替换

从命令历史中读出相关的命令,用于替换命令行中需要进行替换的命令。比如:!!命令

别名替换

如果你用过aliases命令进行过命令替换操作。在命令行中,会首先对别人命令进行替换。

Reserved Words

Reserved Words有特殊作用,下列文字会作为Reserved Words

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
!
{
}
case


do
done
elif
else


esac
fi
for
if


in
then
until
while

当以上token没有加引号,则一下情况会执行识别Reserved Words流程

  • 如果是指令的第一个字符
  • 如果第一个单词后是Reserved Word中的一个,除了caseforin
  • 当第三个字符在case指令里(只有in在这里有效)
  • 当第三个字符在for指令里(只有indo在这里有效)

花括号扩展

通过例子说明比较好

  • mkdir ~/scripts/{old,new,tools,admin}
  • cp /home/book/src/{main,list.scan.mon}.c
  • echo char-{one,two,three,four}

波浪号替换

~替换为用户主目录

shell变量

也就是替换那些定义的变量,比如HOMELANG

其他还有些操作,这里暂时不写,参见Shell Command Language

内部/外部命令

SHELL会判断用户输入的是内部命令还是外部命令。

  • 所谓的内部命令是解释器内部的指令(在启动时就调入内存的,执行效率高),会被直接的执行,如:cd
  • 而绝大部分的时候都会是外部命令(系统的软件功能,用户需要时才从硬盘调入内存的)

本次echo为内部命令,所以不会去PATH寻找

1
2
$ type echo
echo is a shell builtin

查找命令

如果是绝对路径,则会之间尝试运行,如果不是,则shell会依次去PATH下寻找这个文件名,找到后立刻执行

进程启动

内部命令

当shell执行内部指令的时候,shell不会加载外部指令然后执行,而是会直接在内部执行。

更新shell内部指令需要更新shell本体

外部指令

shell会先执行fork(),创建一个新的子进程,然后shell会通过执行execl()来初始化环境,会把上面搜索的路径结果作为pathname,指令名称作为arg0,剩下的参数也会被传入进去。

在对程序执行之前,还会检查是否拥有正确的权限。

写入日志

有些shell还会将执行的指令写入.bash_history之类的文件,一遍查询或重复使用

执行程序

echo由于是内部方法,于是会在内部执行。而外部方法则按照那个程序的逻辑执行

程序退出

程序退出,需要做一些善后工作

  • shell会收到程序终止的信号,并开始进行处理(后台作业)
  • 如果程序注册了exit_handler,则会执行
  • 程序会清理I/O,关闭所有打开的描述符,释放所使用的储存器
    • 进程退出后包括堆区在内所有占用的内存都会被操作系统回收
  • 程序会返回退出状态,shell会设置上次程序运行结果
  • 内核产生一个只是异常终止的原因的终止状态(如果异常的话)
  • shell会处理结束的子进程,程序的PCB等将会被回收

参考