• Linux 系统编程学习笔记


    终端

    终端的基本概念

    每个进程都可以通过一个特殊设备文件/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)模式:终端可配置成回显模式,输入队列中每个字符既送给用户程序,也送给输出队列,因此在命令行键入字符时,字符可以程序读取,同时也可以在屏幕上看到该字符的回显。

    终端登录的过程

    1. 系统启动时,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目录下的配置文件

    1. getty根据命令行参数打开终端设备作为它的控制终端,把文件描述符0、1、2都指向控制终端,然后提示用户输入账号。用户输入账户后,getty任务就完成了,它再执行login程序:
    execle("/bin/login", "login", "-p", username, NULL, envp);
    
    1. 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为例,说明网络终端登录和使用过程

    步骤:

    1. 用户通过telnet客户端连接服务器。
      如果服务器配置为独立(Standalone)模式,则在服务器监听连接请求是一个telnetd进程,它fork一个telnetd子进程服务客户端,父进程负责监听其他连接请求。
      另外一种情况:服务器端由系统服务程序inetd或xinetd监听连接请求,inetd称为internet Super-Server,它监听系统中多个网络服务端口,如果连接请求的端口号和telnet服务端口号一致,则fork/exec一个telnetd子进程来服务客户端。xinetd是inetd升级版本,配置更灵活。

    2. fork、exec /bin/login、/exec /bin/bash
      telnetd子进程打开一个伪终端设备,然后再经过fork一分为二:父进程操作伪终端主设备,子进程将伪终端从设备作为它的控制终端,并且将文件描述符0、1、2指向控制终端,二者通过伪终端通信,父进程还负责和telnet客户端通信,子进程负责用户的登录过程,提示输入账户,然后调用exec变成login进程,然后调用exec变成Shell进程。这个Shell进程认为自己的控制终端是伪终端设备。
      伪终端主设备可以看作键盘、显示等硬件,操作伪终端的“用户”就是父进程telnetd。

    3. 当用户输入命令时,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和进程组的角度来看登录和执行命令的过程:

    1. setsid()创建Session Leader,同时创建进程组
      getty/telnetd进程在打开终端设备之前,调用setsid函数创建一个新的Session,该进程称为Session Leader,该进程id也可以看做Session的id,然后该进程打开终端设备作为这个Session中所有进程的控制终端。在创建新Session的同时,也创建了一个新的进程组,该进程是这个进程组的Process Group Leader该进程的id也是进程组的id。

    2. 登录过程Session Leader不随进程改变而改变
      登录过程中,getty或telnetd进程变成login,然后变成Shell,但仍然是同一个进程,仍然是Session Leader。

    3. 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
    
    1. stty命令设置终端选项,禁止后台进程写;
    2. 启动一个后台进程准备往终端写;
    3. 进程收到一个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编程一站式学习》

  • 相关阅读:
    [转] EJB 3和Spring技术体系比较
    JBOSS只能本机localhost和127.0.0.1能访问的解决
    JBOSS EAP 6.0+ Standalone模式安装成Windows服务
    IBM WebSphere MQ 7.5基本用法
    maven学习(上)- 基本入门用法
    mac下环境变量、maven3.1.1 及 jdk1.7.0.45配置
    java:读/写配置文件
    java:使用匿名类直接new接口
    java与c#的反射性能比较
    XmlSpy / XSD 以及 验证
  • 原文地址:https://www.cnblogs.com/fortunely/p/14607205.html
Copyright © 2020-2023  润新知