终端
终端的基本概念
每个进程都可以通过一个特殊设备文件/dev/tty访问它的控制终端,每个终端设备都对应一个不同的设备文件,/dev/tty 提供了一个通用的接口,一个进程要访问它的控制终端即可以通过/dev/tty,也可以通过该终端设备所对应的设备文件来访问。
ttyname函数可以由文件描述符查出对应的文件名,该文件描述符必须指向一个终端设备而不能是任意文件。
例,查看终端对应的设备文件名
#include <unistd.h>
#include <stdio.h>
int main() {
printf("fd 0: %s
", ttyname(0));
printf("fd 1: %s
", ttyname(1));
printf("fd 2: %s
", ttyname(2));
return 0;
}
图形终端窗口下运行这个程序,可能得到
$ ./a.out
fd 0: /dev/pts/0
fd 1: /dev/pts/0
fd 2: /dev/pts/0
再打开一个终端串口运行程序,
$ ./a.out
fd 0: /dev/pts/1
fd 1: /dev/pts/1
fd 2: /dev/pts/1
用Ctrl-Alt-F1切换到字符终端运行程序,
$ ./a.out
fd 0: /dev/tty1
fd 1: /dev/tty1
fd 2: /dev/tty1
终端登录过程
一台PC只有一套键盘和显示器(一套终端设备),但可以通过Ctrl-Alt-F1~F6 切换到6个字符终端,相当于6套虚拟的终端设备,共用一套物理终端设备,对应设备文件分别是/dev/tty1~6,称为虚拟终端(Virtual Terminal)。
/dev/tty0 表示当前虚拟终端,Ctrl-Alt-F2切换到字符终端时,/dev/tty0就代表/dev/tty2。/dev/tty0是一个通用接口,但不能表示图形终端窗口所对应的终端。
嵌入式Linux通过串口连接目标板,串口对应终端设备/dev/ttyS0, /dev/tty1等。可通过Linux minicom或Windows终端工具登录到目标板系统,Windows推荐使用Tera Term。
终端设备模块示意图
硬件驱动程序:负责读写实际的硬件设备,如从键盘读入字符,把字符输出到显示器;
line discipline 线路规程:像一个过滤器,对于某些特殊字符不是直接让其通过,而是做特殊处理,如按键Ctrl-Z,对应字符不会被用户程序read,而是被线路规程截获,解释成SIGTSTP信号发给前台进程,默认使其停止。不过,线路规程应该过滤哪些字符,做哪些特殊处理是可以配置的。
终端缓冲示意图
终端设备都有输入输出队列缓冲区。
以输入队列为例,从键盘输入的字符经线路规程过滤后进入输入队列,用户程序以先进先出的顺序从队列中读取字符。当输入队列满时,再输入字符会丢失,同时系统会响铃警报。
回显(Echo)模式:终端可配置成回显模式,输入队列中每个字符既送给用户程序,也送给输出队列,因此在命令行键入字符时,字符可以程序读取,同时也可以在屏幕上看到该字符的回显。
终端登录的过程
- 系统启动时,init进程根据配置文件/etc/inittab确定需要打开哪些终端
如配置文件中这样一行:
1:2345:respawn:/sbin/getty 9600 tty1
和/etc/passwd类似,每个字段用 : (冒号)隔开。
1 - 这一行的配置id,通常和tty的后缀一致,如果是配置tty2那一行id应该是2;
2345 - 表示运行级别2~5都执行该配置;
respawn - 表示init进程会监视getty进程的运行状态,一旦该进程终止,init会再次fork/exec该命令,因此退出终端登录后会再次提示输入账号;
/sbin/getty 9600 tty1 - init进程要fork/exec的命令,打开终端/dev/tty1,波特率9600,然后提示用户输入账户; (问题:提示用户输入账户是改行的作用,还是下一行?)
注:有些新版Linux已经不用/etc/inittab,如Ubuntu用/etc/event.d目录下的配置文件
- getty根据命令行参数打开终端设备作为它的控制终端,把文件描述符0、1、2都指向控制终端,然后提示用户输入账号。用户输入账户后,getty任务就完成了,它再执行login程序:
execle("/bin/login", "login", "-p", username, NULL, envp);
- login 程序提示用户输入密码(输入密码期间关闭终端回显),然后验证账号密码的正确性。如果密码不正确,init重新fork/exec一个getty进程。如果密码正确,login程序设置一些环境变量,设置当前工作目录为该用户的主目录,然后执行shell
execl("/bin/bash", "-bash", NULL);
/bin/bash - 要启动的程序路径;
-bash - 告诉bash自己是作为登录Shell启动的,执行Shell的启动脚本/etc/profile,然后是用户主目录下面的~/.bash_profile, /.bash_login和/.profile。
小结
启动过程: 从/etc/inittab启动init进程 -> getty 打开终端设备,提示输入账户 -> login 提示用户输入密码,登录系统
网络登录过程
虚拟终端或串口终端的数目有限,虚拟终端一般是/dev/tty1~6个。
串口终端的数目 <= 串口数目(硬件)。网络终端、图形终端窗口数目不受限制,因为是通过伪终端(Pseudo TTY)实现。
一套伪终端由一个主设备(PTY Master)和一个从设备(PTY Slave)组成。主设备概念上相当于键盘和显示器,不过并不是真正的硬件而是一个内核模块,操作者不是用户而是另外一个进程。底层驱动程序访问的不是硬件,而是主设备。网络终端或图形终端窗口的Shell进程以及它启动的其他进程,都会认为自己的控制终端是伪终端从设备,如/dev/pts/0, /dev/pts/1等。
以telnet为例,说明网络终端登录和使用过程
步骤:
-
用户通过telnet客户端连接服务器。
如果服务器配置为独立(Standalone)模式,则在服务器监听连接请求是一个telnetd进程,它fork一个telnetd子进程服务客户端,父进程负责监听其他连接请求。
另外一种情况:服务器端由系统服务程序inetd或xinetd监听连接请求,inetd称为internet Super-Server,它监听系统中多个网络服务端口,如果连接请求的端口号和telnet服务端口号一致,则fork/exec一个telnetd子进程来服务客户端。xinetd是inetd升级版本,配置更灵活。 -
fork、exec /bin/login、/exec /bin/bash
telnetd子进程打开一个伪终端设备,然后再经过fork一分为二:父进程操作伪终端主设备,子进程将伪终端从设备作为它的控制终端,并且将文件描述符0、1、2指向控制终端,二者通过伪终端通信,父进程还负责和telnet客户端通信,子进程负责用户的登录过程,提示输入账户,然后调用exec变成login进程,然后调用exec变成Shell进程。这个Shell进程认为自己的控制终端是伪终端设备。
伪终端主设备可以看作键盘、显示等硬件,操作伪终端的“用户”就是父进程telnetd。 -
当用户输入命令时,telnet客户端将用户输入的字符通过网络发给telnetd服务器,由telnetd服务器代表用户将这些字符输入伪终端。
Shell进程不知道自己连接的是伪终端,而是不是键盘、显示器等硬件,也不知道操作终端的“用户”是telnetd服务器,而不是真正的用户。Shell解释执行命令,将标准输出和标准错误输出写到终端设备,这些数据是最终由telnetd服务器发回给telnet客户端,然后显示给用户看。
设备文件:
BSD系列UNIX在/dev目录下创建很多ptyXXX和ttyXX设备文件,XX由字母和数字组成,ptyXX是主设备,ttyXX是从设备,伪终端的数目取决于内核的配置。
SYS V系列的UNIX上,伪终端主设备是/dev/ptmx,"mx"表示Multiplex,多个主设备复用同一个设备文件,每打开一次/dev/ptmx,内核就分配一个主设备,同时在/dev/pts目录下创建一个从设备文件,当终端关闭时,就从/dev/pts目录下删除相应的从设备文件。
Linux支持上面2种伪终端,目前标准倾向于SYS V的。
作业控制
Session与进程组
Shell分前后台来控制的不是进程而是作业(Job),或者进程组(Process Group)。一个前台作业可以由多个进程组成,一个后台作业也可以由多个进程组成,Shell可以同时运行一个前台作业和任意多个后台作业,这称为作业控制(Job Control)
例,命令启动5个进程
$ proc1 | proc2 &
$ proc3 | proc4 | proc5
proc1, proc2属于同一个后台进程组;proc3, proc4, proc5属于同一个前台进程组。这些进程组的控制终端相同,属于同一个Session。当用户键入Ctrl-C时,内核发送SIGINT给前台进程组的所有进程。
各进程、进程组、Session的关系:
从Session和进程组的角度来看登录和执行命令的过程:
-
setsid()创建Session Leader,同时创建进程组
getty/telnetd进程在打开终端设备之前,调用setsid函数创建一个新的Session,该进程称为Session Leader,该进程id也可以看做Session的id,然后该进程打开终端设备作为这个Session中所有进程的控制终端。在创建新Session的同时,也创建了一个新的进程组,该进程是这个进程组的Process Group Leader该进程的id也是进程组的id。 -
登录过程Session Leader不随进程改变而改变
登录过程中,getty或telnetd进程变成login,然后变成Shell,但仍然是同一个进程,仍然是Session Leader。 -
setpgid()设置新进程组Leader,并加入组员。tcsetpgrp设置前台进程组。
Shell进程fork出的子进程本来具有和Shell相同的Session、进程组和控制终端,但Shell调用setpgid()将作业中的某个子进程指定为一个新进程组Leader,然后调用setpgid将该作业中其他的子进程也转移到这个进程组中。如果这个进程组需要在前台运行,就调用tcsetpgrp函数将它设置为前台进程组,由于一个Session只能有一个前台进程组,所以Shell所在的进程组就自动变成后台进程组。
作业和进程组的区别
上面例子中,proc3、4、5被Shell放到同一个前台进程组,其中一个是该进程组的Leader,Shell调用wait等待它们运行结束。一旦运行结束,Shell就调用tcsetpgrp将自己提到前台继续接受命令。
如果pro3、4、5某个进程又fork子进程,子进程属于同一进程组,但Shell不知道子进程存在,因此不会调用wait等待其结束。也就是说,proc3、4、5是Shell的作业,而子进程不是。
简而言之,由Shell fork出来的进程组分为前后台进程组,属于作业;而进程创建的子进程属于进程组,但不属于作业。判断依据是Shell是否知道该进程的存在。
如何查看进程、进程组的关系?
使用ps命令查看前台进程
$ ps -o pid,ppid,pgrp,sesion,tpgid,comm | cat
PID PPID PGRP SESS TPGID COMMAND
6994 6989 6994 6994 8762 bash
8762 6994 8762 6994 8762 ps
8763 6994 8762 6994 8762 cat
该作业由ps、cat 2个进程组成,前台运行。
PPID列 => ps、cat父进程是bash
PGRP => bash在id为6994的进程组 = bash进程id => bash是进程组Leader;2个子进程ps、cat在id为8762的进程组 = ps进程id => ps是进程组Leader
SESS列 => 三个进程都在同一Session,id=bash进程id => bash为Session Leader
TPGID列 => 前台进程组id=8762 = 2个进程所在进程组
使用ps命令查看后台进程
$ ps -o pid,ppid,pgrp,session,tpgid,comm | cat &
[1] 8835
$ PID PPID PGRP SESS TPGID COMMAND
6994 6989 6994 6994 6994 bash
8834 6994 8834 6994 6994 ps
8835 6994 8834 6994 6994 cat
该作业由ps、cat 2个进程组成,在后台运行,bash不等作业结束就打印"[1] 8835",然后打印 "$" 命令提示符,"[1]"是作业编号,如果同时运行多个作业,可以用编号区分,8835是该作业中某个进程id。
与作业控制有关的信号
$ cat &
[1] 9386
$ 回车
[1]+ Stopped cat
将cat放到后台运行,由于cat需要读标准输入(终端),后台进程不能读终端输入,因此内核发SIGTTIN信号给进程,信号默认处理动作是停止进程。
$ jobs # 查看当前作业
[1]+ Stopped cat
$ fg %1 # 将第1个作业提至前台,向停止作业发送SIGCONT信号,继续运行改作业
cat
hello 回车
hello
^Z # Ctrl-Z
[1]+ Stopped cat
jobs命令查看当前有哪些作业。
fg命令可以将某个作业提至前台运行,如果该作业的进程组正在后台运行,则提至前台运行;如果该作业处于停止状态,则给进程组的每个进程发送SIGCONT信号,使它继续运行。参数%1表示将第1个作业提至前台运行。cat提至前台运行后,挂起等待终端输入,当输入hello并回车后,cat打印出同样的一行,然后继续挂起等待输入。
如果输入Ctrl-Z(上面的^Z),则向所有前台进程发SIGTSTP信号,该信号的默认动作是使进程停止。
$ bg %1
[1]+ cat &
[1]+ Stopped cat
bg命令可以让某个停止的作业在后台继续运行,也需要给该作业的进程组的每个进程发SIGCONT信号。cat进程继续在后台运行,但是无法从终端输入,所以又收到SIGTTIN信号停止。
kill前台进程组已停止进程
$ ps
PID TTY TIME CMD
6994 pts/0 00:00:05 bash
11022 pts/0 00:00:00 cat
11023 pts/0 00:00:00 ps
$ kill 11022 # 给一个停止的进程发SIGTERM信号不立即处理,等进程准备继续运行之前处理,默认动作是终止进程
$ ps PID TTY TIME CMD
6994 pts/0 00:00:05 bash
11022 pts/0 00:00:00 cat
11024 pts/0 00:00:00 ps
$ fg %1
cat
Terminated
kill后台进程组已停止进程
$ cat &
[1] 11121
$ ps
PID TTY TIME CMD
6994 pts/0 00:00:05 bash
11121 pts/0 00:00:00 cat
11122 pts/0 00:00:00 ps
[1]+ Stopped cat
$ kill -KILL 11121 # 向一个已停止进程发送SIGKILL信号
[1]+ Killed cat
SIGKILL信号不能呗阻塞也不能被忽略,也不能用自定义含义捕捉,只能按系统默认动作立刻处理。类似信号还有SIGSTOP,会使得进程停止,而且默认处理动作不能改变。 ----- 这样的设计,能确保不管什么样的进程出现异常,系统管理员总是一部分可以利用SIGKILL终止或SIGSTOP停止可能有问题的进程。
后台进程不能从终端读,否则会收到SIGTTIN停止;不过,允许后台进程向终端写。可以设置一个终端选项禁止后台进程写:
$ cat testfile &
[1] 11426
$ hello
[1]+ Done cat testfile
$ stty tostop
$ cat testfile &
[1] 11428
[1]+ Stopped cat testfile
$ fg %1
cat testfile
hello
- stty命令设置终端选项,禁止后台进程写;
- 启动一个后台进程准备往终端写;
- 进程收到一个SIGTTOUU信号,默认处理当作是停止进程;
守护进程
Linux在系统启动会时启动很多系统服务进程,这些系统服务没有控制终端,不与用户交互,仅在系统关闭时才终止,这样的进程就叫守护进程(daemon)。
UNIX系统有很多守护进程,名称通常以字母“d”结尾(Daemon。例如,syslogd 就是指管理系统日志的守护进程。通过ps命令 ps -efj
的输出实例,查看守护进程,内核守护进程的名字出现在方括号中。
$ ps axj
PPID PID PGID SID TTY TPGID STAT UID TIME COMMAND
0 1 1 1 ? -1 Ss 0 0:01 /sbin/init
0 2 0 0 ? -1 S< 0 0:00 [kthreadd]
2 3 0 0 ? -1 S< 0 0:00 [migration/0]
2 4 0 0 ? -1 S< 0 0:00 [ksoftirqd/0]
......
1 2373 2373 2373 ? -1 S<s 0 0:00 /sbin/udevd --daemon
......
1 4680 4680 4680 ? -1 Ss 0 0:00 /usr/sbin/acpid -c /etc
......
1 4808 4808 4808 ? -1 Ss 102 0:00 /sbin/syslogd
......
TPGID=-1 => 进程没有控制终端,也就是守护进程。
Command列 - 用[]括起来的名字表示内核线程,在内核里创建,没有用户空间代码,因此没有程序文件名和命令行,通常以k开头的名字,表示Kernel。
init进程 - Linux第一个用户级进程,有许多重要任务,如启动getty(用户登录)、实现运行级别、处理僵尸进程。
udevd进程 - 负责维护/dev目录下设备文件。
apcid进程 - 负责电源管理。
syslogd - 负责维护/var/log下日志文件
如何创建守护进程?
调用setsid函数创建新Session,并成为Session Leader。
核心点:调用setsid前,确保当前进程不是进程组Leader。
#include <unistd.h>
// 创建Session
// 成功返回新Session id,出错返回-1
// 调用该函数前,当前进程不允许是进程组的Leader,否则出错
pid_t setsid(void);
如何保证当前进程不是进程组的Leader?
只要先fork再调用setsid。fork创建的子进程和父进程在同一个进程组,进程组的Leader必然是该组的第一个进程,因此子进程不可能是该组的第一个进程,而在子进程中调用setsid也就没有问题。
调用setsid成功的结果:
- 创建一个新Session,当前进程成为Session Leader,当前进程的id就是Session的id;
- 创建一个新的进程组,当前进程成为进程的Leader,当前进程的id就是进程组的id;
- 如果当前进程原本有一个控制终端,则失去该终端,成为一个没有控制终端的进程。所谓失去控制终端是指,原来的控制终端仍然是打开的,也可以读写,但是只是一个普通的打开文件而不是控制终端了;
例,创建守护进程
#include <stdlib.h>
#include <stdio.h>
#inlcude <fcntl.h>
void daemonize(void) {
pid_t pid;
if ((pid = fork()) < 0) {
perror("fork");
exit(1);
}
else if (pid != 0) { // parent
exit(0);
}
else { // child
setsid(); // fork之后再调用setsid,能确保当前进程不是Leader
// 改变当前工作目录到root
if (chdir("/") < 0) {
perror("chdir");
exit(1);
}
// 下面语句的作用是将文件描述符(fd)0/1/2重定向到/dev/null
close(0); // 0 标准输入;1 标准输出; 2 标准报错。关闭fd=0文件描述符,下次open默认分配fd=0
open("/dev/null", O_RDWR); // 特殊文件,读出来的所有值都是0,写进去的所有值都会被丢弃。默认分配fd=0
dup2(0, 1);
dup2(0, 2);
}
}
int main() {
daemonize();
while(1);
}
要点:
- 为确保调用setsid的进程不是进程组leader,先fork子进程,父进程退出,子进程再调用setsid创建新Session,成为守护进程;
- 按守护进程惯例,通常将当前工作目录切换到根目录;
- 将文件描述符0/1/2重定向到/dev/null;
Linux提供库函数daemon(3)实现上面的daemon函数功能,,不过带2个参数指示要不要切换工作目录到根目录,以及要不要把文件描述符0/1/2重定向到/dev/null。
$ ./a.out
$ ps
PID TTY TIME CMD
11494 pts/0 00:00:00 bash
13271 pts/0 00:00:00 ps
$ ps xj | grep a.out
1 13270 13270 13270 ? -1 Rs 1000 0:05 ./a.out
11494 13273 13272 11494 pts/0 13272 S+ 1000 0:00 grep
a.out
(关闭终端窗口重新打开,或者注销重新登录)
$ ps xj | grep a.out
1 13270 13270 13270 ? -1 Rs 1000 0:21 ./a.out
13282 13338 13337 13282 pts/1 13337 S+ 1000 0:00 grep
a.out
$ kill 13270
运行程序a.out(上面的daemonize 编译得到),它将变成一个守护进程,不再和当前终端关联,用户关闭终端窗口或注销也不会影响守护进程的运行。
查看守护进程:使用ps命令,必须带x参数,如ps axj。
参考
《linuxC编程一站式学习》