UNIX环境高级编程 学习笔记 第十九章 伪终端

2023-10-29

终端登录是经由终端设备进行的(终端设备天然提供终端语义),在终端和运行程序之间有一个终端行规程,通过该规程我们能设置终端特殊字符(如退格、行删除、中断等)。但当一个登录请求到达网络连接时,终端行规程并不是自动被加载到网络连接和登录shell之间。下图显示了一个伪终端设备驱动程序,用于提供终端语义:
在这里插入图片描述
伪终端术语指,对一个应用程序而言,它看上去像一个终端,但事实上它并不是一个真正的终端。下图显示了使用伪终端时,相关进程的典型安排:
在这里插入图片描述
上图关键点如下:
1.通常,一个进程打开伪终端主设备,然后调用fork,子进程建立一个新的会话,打开一个相应的伪终端从设备,将其文件描述符复制到标准输入、标准输入和标准错误,然后调用exec。伪终端从设备称为子进程的控制终端。

2.对于伪终端从设备上的用户来说,其标准输入、标准输出、标准错误都是终端设备,用户进程能使用这些描述符调用第18章中的所有终端IO函数。但因为伪终端从设备不是真正的终端设备,所以无意义的函数调用(如改变波特率、发送中断符、设置奇偶校验)将被忽略。

3.任何写到伪终端主设备的都会作为从设备的输入,反之亦然。所有从设备端的输入都来自伪终端主设备上的用户进程。

上图是FreeBSD、Mac OSX、Linux系统中的伪终端结构,下图是Solaris的伪终端结构:
在这里插入图片描述
Solaris中,伪终端是使用STREAMS子系统构建的,如上图,虚线框中的两个STREAMS模块是可选的。pckt和ptem提供伪终端特有的语义。ldterm和ttcompat提供行规程处理。

现在简化以上图示,不再显示19-1中的读函数和写函数、19-2中的流首,同时使用PTY表示伪终端,并将所有19-2伪终端从设备之上的STREAMS模块合并在一起表示为终端行规程模块。

伪终端的典型用途:
1.网络登录服务器:伪终端可用于构造提供网络登录的服务器,如telnetd和rlogind服务器,以下是rlogind服务器的进程安排:
在这里插入图片描述
在rlogind服务器和登录shell之间有两个exec调用,这是因为login程序通常在两个exec函数之间检验用户是否合法。驱动PTY主设备的进程通常同时在读写另一个IO流,上图中另一个IO流是TCP/IP框,这表示该进程必然使用了某种形式的如select或poll函数这样的IO多路转接,或被分成两个进程或线程。

2.窗口系统终端模拟:窗口系统通常提供一个终端模拟器,这样我们就能在熟悉的命令行环境中通过shell来运行程序。终端模拟器作为shell和窗口管理器之间的媒介,每个shell在自己的窗口中执行,如下图:
在这里插入图片描述
shell将自己的标准输入、标准输出、标准错误连接到PTY的从设备端,终端模拟程序打开PTY的主设备。终端模拟器除了作为窗口子系统的接口,还要负责模拟一种特定的终端,这意味着它需要根据它所模拟的设备类型来响应返回码,这些码列在termcap和terminfo数据库中。

当用户改变终端模拟器窗口的大小时,窗口管理器会通知终端模拟器,终端模拟器在PTY的主设备端发出TIOCSWINSZ为参数的ioctl命令来设置从设备的窗口大小,如果新的窗口大小和当前的不同,内核会发送一个SIGWINCH信号给前台PTY从设备的进程组,需要重绘屏幕的应用就可以捕捉这个SIGWINCH信号,然后发出以TIOCSWINSZ为参数的ioctl命令获得新屏幕尺寸并重绘屏幕。

3.script程序:script程序是随大多UNIX系统提供的,它将终端会话期间的所有输入和输出信息复制到一个文件中。为完成此工作,该程序将自己置于终端和一个新调用的登录shell之间,如下图:
在这里插入图片描述
script程序通常是从登录shell启动的,该shell之后等待script程序的终止。

script程序运行时,位于PTY从设备上的终端行规程的所有输出都将复制到脚本文件中(通常称为typescript)。因为击键通常由行规程模块回显,所以该脚本文件也包括了输入的内容,但因为键入的口令不会回显,所以该脚本不会包含口令。

4.expect程序:伪终端可以用来在非交互模式中驱动交互式程序的运行。很多程序天生就需要一个终端运行,如passwd命令,它要求用户在系统提示后输入口令。

为支持批处理操作模式而修改所有交互式程序是非常麻烦的,更好的解决方法是通过一个脚本来驱动交互式程序,expect程序提供了这样的方法。expect程序使用伪终端来运行其他程序。exepct提供了一种编程语言用于检查运行程序的输出,以确定用什么作为后续输入发送给该程序。

5.运行协同进程:当通过管道与协同进程进行通信时,协同进程中我们不能调用标准IO库进行输入、输出,因为对于管道,标准IO库会完全缓冲标准输入和标准输出,因此当协同进程写后,只有完全填满协同进程的IO缓冲区后协同进程才会冲洗输出,但如果协同进程写之后没有调用fflush就直接等待调用进程的返回,这样会造成死锁。如果协同进程是一个已经编译的程序,我们没有办法在源码中加入fflush函数,则无法解决这个问题。我们可以将一个伪终端放到两个进程之间,使协同进程认为它是由终端驱动的,而非另一个进程:
在这里插入图片描述
现在协同进程的标准输入和标准输入就像终端设备一样,所以标准IO库会将这两个流设置成行缓冲。

父进程有两种方法在自身和协同进程之间获得伪终端,一种是父进程直接调用我们编写的pty_fork而非调用fork;另一种是exec我们编写的pty程序,将协同进程作为参数。后面会给出pty_fork函数和pty程序。

6.观看长时间运行程序的输出:标准shell可以将一个需要长时间运行的程序放到后台运行,但如果将该程序的标准输出重定向到一个文件,且它产生的输出不多(标准IO库将完全缓冲它的标准输出),则不能方便地监控程序的进展。

如果有源程序,可以加入fflush函数强制标准IO缓冲区在某些节点冲洗或把缓冲模式改成行缓冲,如果没有源程序,可以在pty程序下运行该程序,让标准IO库认为标准输出是终端:
在这里插入图片描述
上图中,我们将输出缓慢的程序称为slowout,从登录shell到pty进程的fork/exec调用箭头是虚线表示的,为的是强调pty进程是作为后台任务运行的。

PTY表现地像物理终端设备一样,在打开PTY设备文件时,应用程序不需要设置O_TTY_INIT标识,SUS要求PTY的从设备第一次被打开时要初始化,这样为了设备能如期望运行所需的非标准的termios结构里的标志都会被设置,这个要求是为了遵循POSIX标准的应用对PTY调用tcgetattr和tcsetattr时能正常工作。

各种平台打开伪终端设备的方法有所不同,SUS的XSI扩展中包含了很多函数试图统一这些方法,这些函数的基础是SVR 4用于管理基于STREAMS的伪终端的一组函数。posix_openpt函数提供了一种可移植的方法打开下一个可用的伪终端主设备:
在这里插入图片描述
参数oflag是一个位屏蔽字,指定如何打开主设备,可以指定O_RDWR打开主设备进行读、写,指定O_NOCTTY来防止主设备成为调用者的控制终端。其他打开标志会导致未定义行为。

伪终端从设备可用前,必须设置权限,以便应用可以访问它,grantpt函数可把从设备节点的用户ID设置为调用者的实际用户ID;把从设备的组ID设置为一非指定值,通常是可以访问该终端设备的组。权限被设置为0620。

实现通常将PTY从设备的组所有者设为tty组。把那些要对系统中所有活动终端具有写权限的程序(如wall(向所有终端发送消息)、write(向特定终端发送消息))的设置组ID设置为tty组,因为在PTY从设备上tty组的写权限是被允许的,这些程序可以向活动终端写入。
在这里插入图片描述
为了更改从设备节点的权限,grantpt函数可能需要fork并exec一个设置用户ID程序(如Solaris中的/usr/lib/pt_chmod),用于更改从设备节点权限,因此调用者可能收到SIGCHLD信号。

unlockpt函数用于准予对从设备的访问,从而允许应用打开该设备。通过阻止对从设备的访问,建立该设备的应用有机会在使用主、从设备前正确初始化这些设备。

以上两个函数的fd参数是与伪终端主设备关联的文件描述符。

如果给定了伪终端主设备的文件描述符,可用ptsname函数找到伪终端从设备的路径名。这使应用程序可以独立于给定平台的某种特定约定而标识从设备。该函数返回的名字可能存储在静态存储中,后续的调用可能会覆盖它:
在这里插入图片描述
在这里插入图片描述
FreeBSD中,grantpt和unlockpt函数除了参数验证外不执行任何操作,PTY是通过正确的权限动态创建出来的。FreeBSD定义O_NOCTTY标志只是为了兼容调用posix_openpt的应用,在FreeBSD中打开终端设备不会引起分配控制终端的副作用,所以O_NOCTTY标志无作用。

SUS已经改善了以上函数的可移植性,但不同系统间仍有差异,我们提供两个处理所有这些细节的自己编写的函数:ptym_open和ptys_open函数。ptym_open函数打开下一个可用的PTY主设备,ptys_open函数打开相应的从设备:
在这里插入图片描述
通常不直接调用这两个函数,而是由我们编写的pty_fork函数调用它们。

ptym_open函数的调用者必须分配一个数组来存放从设备的名字,如果调用成功,相应的从设备名会通过pts_name参数返回,然后这个名字传给用来打开该从设备的ptys_open函数。ptym_open函数的pts_name参数提供的缓冲区的字节长度由pts_namesz参数传送,ptym_open函数不会复制比该缓冲区长的字符串。

一个进程调用ptym_open打开一个主设备并得到从设备名,然后该进程fork子进程,子进程在调用setsid建立新的会话后调用ptys_open打开从设备,这就是从设备成为子进程控制终端的过程。

ptym_open和ptys_open函数:

// 在调用stdlib前需要定义_XOPEN_SOURCE宏才可以看到伪终端相关函数
#define _XOPEN_SOURCE
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <fcntl.h>
#if defined(SOLARIS)
#include <stropts.h>
#endif

// posix_openpt函数是刚加入POSIX的
// 因此有些支持System V(又叫Unix 98)伪终端的实现没有这个函数
// 但实现它很容易
int posix_openpt(int flags) {
    return open("/dev/ptmx", flags);
}

int ptym_open(char *pts_name, int pts_namesz) {
    char *ptr;
    int fdm, err;

    if ((fdm = posix_openpt(O_RDWR)) < 0) {
        return -1;
    }
    if (grantpt(fdm) < 0) {    /* grant access to slave */
        goto errout;
    }
    if (unlockpt(fdm) < 0) {    /* clear slave's lock flag */
        goto errout;
    }
    if ((ptr = ptsname(fdm)) == NULL) {    /* get slave's name */
        goto errout;
    }

    /*
     * Return name of slave. Null terminate to handle
     * case where strlen(ptr) >= pts_namesz.
     */
    strncpy(pts_name, ptr, pts_namesz);
    pts_name[pts_namesz - 1] = '\0';
    return fdm;    /* return fd of master */
errout:
    err = errno;
    close(fdm);
    errno = err;
    return -1;
}

int ptys_open(char *pts_name) {
    int fds;
#if defined(SOLARIS)
    int err, setup;
#endif

    if ((fds = open(pts_name, O_RDWR)) < 0) {
        return -1;
    }

#if defined(SOLARIS)
    /*
     * Check if stream is already set up by autopush facility.
     */
    if ((setup = ioctl(fds, I_FIND, "ldterm")) < 0) {    // 是否有提供行规程处理的STREAMS ldterm模块
        goto errout;
    }

    // I_PUSH将ioctl函数的第三个参数指定的模块推入(push)到文件描述符所关联的设备的输入输出流队列
    if (setup == 0) {
        if (ioctl(fds, I_PUSH, "ptem") < 0) {
		    goto errout;
		}
		if (ioctl(fds, I_PUSH, "ldterm") < 0) {
		    goto errout;
		}
		if (ioctl(fds, I_PUSH, "ttcompat") < 0) {
errout:
            err = errno;
		    close(fds);
		    errno = err;
		    return -1;
		}
    }
#endif
    return fds;
}

ptym_open函数用XSI PTY函数找到并打开一个未被使用的PTY主设备,并初始化对应的PTY从设备。ptys_open函数打开PTY从设备。在Solaris系统中,在PTY从设备表现得像个终端前,可能需要多做几步工作,Solaris中,打开从设备后,我们需要将3个STREAMS模块压入从设备的流中,伪终端仿真模块(ptem)和终端行规程模块(ldterm)合在一起像一个真正的终端一样工作。ttcompat模块提供了对早期系统(如V7、4BSD、Xenix)的ioctl调用的兼容性,这是一个可选模块,但对于网络登录,它是自动压入的,所以我们将它压入到从设备的流中。也可能不需要压入这3个模块,因为它们可能已经位于流中。STREAMS系统支持一种称为autopush的工具,它允许系统管理员配置一张模块列表,只要打开一个特定设备,就将这些模块压入流中。可用I_FIND为参数调用ioctl观察ldterm是否已在流中,如果是,则认为该流已用autopush机制配置,这样就无需再压入相应模块。

Linux、Mac OS X、Solaris都遵循历史上System V的行为:如果调用者是一个还没有控制终端的会话首进程,ptys_open函数中open调用打开从设备时会分配该PTY从设备作为控制终端,如果不想让这种情况发生,可以用O_NOCTTY标志调用open;而在FreeBSD中,打开PTY从设备不会产生分配其作为控制终端的副作用。

编写函数pty_fork,它调用fork后打开主设备和从设备,然后创建作为会话首进程的子进程并使其具有控制终端:
在这里插入图片描述
PTY主设备的文件描述符通过ptrfdm指针参数返回。

如果参数slave_name不为空,从设备名被存储在该指针指向的存储区中,该存储区需要由调用者分配。

如果指针参数slave_termios不为空,则使用该指针所引用的结构初始化从设备的终端行规程。如果该指针参数为空,则会把从设备的termios结构设置成实现定义的初始状态。类似地,如果slave_winsize指针参数不为空,那么按该指针所引用的结构初始化从设备的窗口大小,如果该指针为空,winsize结构通常被初始化为0。

pty_fork函数:

#include <termios.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/ioctl.h>
#include <stdio.h>

pid_t pty_fork(int *ptrfdm, char *slave_name, int slave_namesz,
              const struct termios *slave_termios,
	          const struct winsize *slave_winsize) {
    int fdm, fds;
    pid_t pid;
    char pts_name[20];

    if ((fdm = ptym_open(pts_name, sizeof(pts_name))) < 0) {
        printf("can't open master pty: %s, error %d", pts_name, fdm);
		exit(1);
    }

    if (slave_name != NULL) {
        /*
		 * Return name of slave. Null terminate to handle case
		 * where strlen(pts_name) >= slave_namesz.
		 */
		strncpy(slave_name, pts_name, slave_namesz);
		slave_name[slave_namesz - 1] = '\0';
	}
	
	if ((pid = fork()) < 0) {
	    return -1;
    } else if (pid == 0) {    /* child */
	    if (setsid() < 0) {
		    printf("setsid error\n");
		    exit(1);
		}
	
		/*
		 * System V acquires controlling terminal on open().
		 */
		if ((fds = ptys_open(pts_name)) < 0) {
		    printf("can't open slave pty\n");
		    exit(1);
		}
		close(fdm);    /* all done with master in child */

#if defined(BSD)
        /*
         * TIOCSCTTY is the BSD way to acquire a controlling terminal.
         */
        if (ioctl(fds, TIOCSCTTY, (char *)0) < 0) {
            printf("TIOCSCTTY error\n");
	    	exit(1);
        }
#endif
        /*
		 * Set slave's termios and window size.
		 */
		if (slave_termios != NULL) {
		    if (tcsetattr(fds, TCSANOW, slave_termios) < 0) {
		        printf("tcsetattr error on slave pty\n");
				exit(1);
		    }
		}
		if (slave_winsize != NULL) {
		    if (ioctl(fds, TIOCSWINSZ, slave_winsize) < 0) {
		        printf("TIOCSWINSZ error on slave pty\n");
				exit(1);
		    }
		}

		/*
		 * Slave becomes stdin/stdout/stderr of child.
		 */
		if (dup2(fds, STDIN_FILENO) != STDIN_FILENO) {
		    printf("dup2 error to stdin\n");
		    exit(1);
		}
		if (dup2(fds, STDOUT_FILENO) != STDOUT_FILENO) {
		    printf("dup2 error to stdout\n");
		    exit(1);
		}
		if (dup2(fds, STDERR_FILENO) != STDERR_FILENO) {
		    printf("dup2 error to stderr\n");
		    exit(1);
		}
		if (fds != STDIN_FILENO && fds != STDOUT_FILENO && fds != STDERR_FILENO) {
		    close(fds);
		}
		return 0;    /* child returns 0 just like fork() */
    } else {    /* parent */
        *ptrfdm = fdm;    /* return fd of master */
		return pid;    /* parent returns pid of child */
    }
}

以上函数在打开PTY主设备后,调用fork。子进程先调用setsid建立新会话(fork后,子进程已不是进程组组长,之后调用setsid才会成功建立新会话),发生以下操作:(a)子进程创建一个新会话,它是该会话的首进程;(b)子进程创建一个新的进程组,并成为新进程组的组长进程;(c)子进程断开与之前可能有的控制终端的关联,失去控制终端。在Linux、Mac OS X、Solaris系统中,调用ptys_open时,从设备会成为新会话的控制终端;在FreeBSD中,必须以TIOCSCTTY为参数调用ioctl来分配一个控制终端。

pty_fork函数的子进程中,pty_fork函数返回后,标准输入、标准输出、标准错误都以读写模式打开,不能将标准输入改为只读,另两个改为只写,因为posix_openpt函数只能以读写打开伪终端主设备,而对于一个已打开的描述符,是无法更改其读写权限的。

termios和winsz两个结构在子进程中初始化,然后从设备的文件描述符被复制到子进程的标准输入、标准输出、标准错误,子进程以后调用exec执行程序时,它都具有与PTY从设备联系起来的上述3个描述符。

调用fork后,父进程返回PTY主设备的描述符和子进程的进程ID。

编写pty程序,它的目的是在一个独立的会话中执行程序,且同一个伪终端相连接,使用方法是用:

pty prog arg1 arg2

代替:

prog arg1 arg2

pty程序:

#include <termios.h>
#include <sys/ioctl.h>
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>

#ifdef LINUX
#define OPTSTR "+d:einv"    // 为了在Linux中强制POSIX行为,需要将选项字符串的第一个字符设为加号
#else
#define OPTSTR "d:einv"
#endif

static void set_noecho(int);    /* at the end of this file */
void do_driver(char *);    /* in the file driver.c */
void loop(int, int);    /* in the file loop.c */

int main(int argc, char *argv[]) {
    int fdm, c, ignoreeof, interactive, noecho, verbose;
    pid_t pid;
    char *driver;
    char slave_name[20];
    struct termios orig_termios;
    struct winsize size;

    interactive = isatty(STDIN_FILENO);
    ignoreeof = 0;
    noecho = 0;
    verbose = 0;
    driver = NULL;

    opterr = 0;    /* don't want getopt() writing to stderr */
    while ((c = getopt(argc, argv, OPTSTR)) != EOF) {
        switch (c) {
		case 'd':    /* driver for stdin/stdout */
		    driver = optarg;
		    break;
	
		case 'e':    /* noecho for slave pty's line discipline */
		    noecho = 1;
		    break;
		
		case 'i':    /* ignore EOF on standard input */
		    ignoreeof = 1;
		    break;
	
		case 'n':    /* not interactive */
		    interactive = 0;
		    break;
	
		case 'v':    /* verbose */
		    verbose = 1;
		    break;
	
	    case '?':
		    printf("unrecognized option: -%c", optopt);
		    exit(1);
		}
    }
    // optind是argv中下一个要处理的元素的索引
    if (optind >= argc) {
        printf("usage: pty [ -d driver -einv ] program [ arg ... ]");
		exit(1);
    }

    if (interactive) {    /* fetch current termios and window size */
        if (tcgetattr(STDIN_FILENO, &orig_termios) < 0) {
		    printf("tcgetattr error on stdin\n");
		    exit(1);
		}
		if (ioctl(STDIN_FILENO, TIOCGWINSZ, (char *)&size) < 0) {
		    printf("TIOCGWINSZ error\n");
		    exit(1);
		}
		pid = pty_fork(&fdm, slave_name, sizeof(slave_name),
		    &orig_termios, &size);
    } else {
        pid = pty_fork(&fdm, slave_name, sizeof(slave_name),
			NULL, NULL);
    }

    if (pid < 0) {
        printf("fork error\n");
		exit(1);
    } else if (pid == 0) {    /* child */
        if (noecho) {
		    set_noecho(STDIN_FILENO);    /* stdin is slave pty */
		}
		// ANSI标准要求argv[argc]是空指针
		if (execvp(argv[optind], &argv[optind]) < 0) {
		    printf("can't execte: %s", argv[optind]);
		    exit(1);
		}
    }

    if (verbose) {
        fprintf(stderr, "slave name = %s\n", slave_name);
		if (driver != NULL) {
		    fprintf(stderr, "driver = %s\n", driver);
		}
    }

    if (interactive && driver == NULL) {
        if (tty_raw(STDIN_FILENO) < 0) {    /* user's tty to raw mode */
		    printf("tty_raw error\n");
		    exit(1);
		}
		if (atexit(tty_atexit) < 0) {    /* reset user's tty on exit */
		    printf("atexit error\n");
		}
    }

    if (driver) {
        do_driver(driver);    /* changes our stdin/stdout */
    }

    loop(fdm, ignoreeof);    /* copies stdin -> ptym, ptym -> stdout */

    exit(0);
}

static void set_noecho(int fd) {    /* turn off echo (for slave pty) */
    struct termios stermios;

    if (tcgetattr(fd, &stermios) < 0) {
        printf("tcgetattr error\n");
		exit(1);
    }

    stermios.c_lflag &= ~(ECHO | ECHOE | ECHOK | ECHONL);

    /*
     * Also turn off NL to CR/NL mapping on output.
     */
    stermios.c_oflag &= ~ONLCR;

    if (tcsetattr(fd, TCSANOW, &stermios) < 0) {
        printf("tcsetattr error\n");
		exit(1);
    }
}

调用pty_fork前,获取termios和winsize结构当前值,将其作为参数传给pty_fork函数,这样PTY从设备具有和当前终端相同的初始状态。

子进程从pty_fork函数返回后,可选地关闭了PTY从设备的回显(关闭后,从设备输入队列中的内容不会再复制到其输出队列),然后调用execvp执行命令行指定的程序,所有余下的命令行参数成为该程序的参数。

loop函数作用是将从标准输入接收到的所有内容复制到PTY主设备,并将PTY主设备接收到的所有内容复制到标准输出,尽管使用select或poll函数的单进程或多线程是可行的,但为了有所变化,loop函数使用了两个进程:

#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <stdlib.h>

#define BUFFSIZE 512

static void sig_term(int);
static volatile sig_atomic_t sigcaught;    /* set by signal handler */

void loop(int ptym, int ignoreeof) {
    pid_t child;
    int nread;
    char buf[BUFFSIZE];

    if ((child = fork()) < 0) {
        printf("fork error\n");
		exit(1);
    } else if (child == 0) {    /* child copies stdin to ptym */
        for (; ; ) {
		    if ((nread = read(STDIN_FILENO, buf, BUFFSIZE)) < 0) {
		        printf("read error from stdin\n");
				exit(1);
		    } else if (nread == 0) {
		        break;    /* EOF on stdin means we're done */
		    }
		    if (writen(ptym, buf, nread) != nread) {
		        printf("writen error to master pty\n");
				exit(1);
		    }
		}

		/*
		 * We always terminate when we encounter an EOF on stdin,
		 * but we notify the parent only if ignoreeof is 0.
		 */
		if (ignoreeof == 0) {
		    kill(getppid(), SIGTERM);    /* notify parent */
		}
		exit(0);    /* and terminate; child can't return */
    }

    /*
     * Parent copies ptym to stdout.
     */
    if (signal_intr(SIGTERM, sig_term) == SIG_ERR) {
        printf("signal_intr error for SIGTERM\n");
		exit(1);
    }

    for (; ; ) {
        if ((nread = read(ptym, buf, BUFFSIZE)) <= 0) {
		    break;    /* signal caught, error, or EOF */
		}
		if (written(STDOUT_FILENO, buf, nread) != nread) {
		    printf("written error to stdout\n");
		    exit(1);
		}
    }

    /*
     * There are three ways to get here: sigterm() below caught the 
     * SIGTERM from the child, we read an EOF on the pty master (which
     * means we have to signal the child to stop), or an error.
     */
    if (sigcaught == 0) {    /* tell child if it didn't send us the signal */
        kill(child, SIGTERM);
    }

    /*
     * Parent returns to caller.
     */
}

/*  
 * The child sends us SIGTERM when it gets EOF on the pty slave or
 * when read() fails. We probably interrupted the read of ptym.
 */
static void sig_term(int signo) {
    sigcaught = 1;    /* just set flag and return */
}

以上程序因为使用了两个进程,所以一个终止时,必须通知另一个,我们用SIGTERM信号进行这种通知。

使用pty程序,如果执行命令:

pty ksh

我们会得到一个运行在伪终端下的全新Korn shell。

文件ttyname包含了18-16中打印标准输入、标准输出、标准错误关联的设备名的程序,如果用pty程序运行它:
在这里插入图片描述
如果是用telnetd或rlogind远程登录,在伪终端上登录的用户应该在utmp文件中有相应登录项,但通过窗口系统或script类程序在伪终端上运行shell的用户是否在utmp文件中有记录取决于系统实现,如果utmp文件中没有记录,who程序一般不会显示相应伪终端正被使用。一般伪终端程序不能对utmp文件进行写操作,除非utmp文件允许其他用户写(这被认为是一个安全漏洞)。

在pty下运行作业控制shell时,它能正常运行,如:

pty ksh

这将会在pty下运行Korn shell,我们能在这个新shell下运行程序并使用作业控制,如同在登录shell中一样。但如果在pty下运行一个交互式程序,如:

pty cat

那么在键入作业控制挂起字符之前该程序的运行一切正常,而在键入作业控制挂起字符时,作业控制挂起字符会被显示为^Z,并且被忽略。而在早期基于BSD的系统中,输入作业控制挂起字符后cat进程会终止,pty进程也终止,回到登录shell。以下是运行pty cat时的安排:
在这里插入图片描述
键入挂起字符时,它被上图中cat进程下面的行规程模块所识别,这是因为pty将上图中pty父进程下面的终端行规程终端设为原始模式。cat的父进程是pty父进程,它属于另一个会话。cat现在属于一个孤儿进程组。

在早期基于BSD的系统中,键入挂起字符后,cat会首先接收到文件结束符从而终止运行,导致PTY从设备终止,进而导致PTY主设备终止。接着,向正从PTY主设备读取的pty父进程发送一个文件结束符,之后pty父进程将SIGTERM信号发送给pty子进程,pty子进程不捕捉此信号,于是默认pty子进程终止。最后pty父进程调用main函数尾端的exit(0)。

上图中有三个进程组:
(1)登录shell。

(2)pty父进程和子进程。

(3)cat进程。

前两个进程组组成了一个会话,其中登录shell是会话首进程。第二个会话仅包括cat进程。登录shell是后台进程组,其他两个是前台进程组。

对于发送给孤儿进程组里进程(上例中是cat)的SIGTSTP信号,历史上不同系统的处理方式也不同,POSIX.1只是说明SIGTSTP信号不能被发送给该进程。4.3 BSD派生的系统向进程发送一个不能忽略或捕捉的SIGKILL信号。4.4 BSD不发送SIGKILL,而是采用符合POSIX.1的处理方法,即如果接收SIGTSTP信号的进程对SIGTSTP信号是默认配置,且SIGTSTP信号是传给孤儿进程组中的一个进程时,4.4 BSD内核会丢弃SIGTSTP信号,当前大多时间都采用这种处理模式。做以上处理的原因是没有shell进程会注意到该进程停止了,然后继续运行此进程。

当我们使用pty来运行作业控制shell时,被这个新shell调用的作业不会是任何孤儿进程组的成员,因为shell调用作业时会fork一个子进程运行作业,大多shell在fork后都会为子进程创建一个新进程组,而shell与这个子进程都属于一个会话。此时Ctrl+Z被发送到由shell调用的进程而非shell本身。

让pty调用的进程能处理作业控制信号的唯一方法是增加一个pty命令行选项,使pty子进程能识别作业挂起字符,而不是让该字符穿越所有路程到cat进程下面的终端行规程。

如果运行一个缓慢产生输出的程序:

pty slowout > file.out &

当子进程试图从标准输入(终端)读数据时,pty进程将立刻停止运行,因为该作业是一个后台作业,当它试图访问终端时会向进程发送SIGTTIN信号,此信号默认使进程停止运行。如果将标准输入重定向使得pty不从终端读取数据:

pty slowout < /dev/null > file.out &

那么pty程序也立即停止,因为它从标准输入读取到一个文件结束符,可使用-i选项忽略来自标准输入的文件结束符:

pty -i slowout < /dev/null > file.out &

这个标志使子进程遇到文件结束符退出时,不会告诉父进程终止,父进程会一直将PTY从设备的输出复制到标准输出。

可以用pty程序实现script程序,以下是实现过程的shell脚本:

#!/bin/sh
# ${SHELL:~/bin/sh}代表变量替换
# 它的含义是如果环境变量SHELL的值存在且非空,则使用该值,否则使用~/bin/sh。
pty "${SHELL:~/bin/sh}" | tee typescript

执行以上脚本后,可执行ps命令观察进程间关系,以下是进程间关系:
在这里插入图片描述
上例中,假设SHELL变量是Korn shell,script程序会将新shell和它调用的所有子进程的输出复制出来,因为PTY从设备上的行规程模块通常允许回显,所以键入也被写到typescript文件中。

一般script程序会在输出文件头增加一行说明它的开始时间,在输出文件末尾增加一行说明它的结束时间,将此特性增加到shell脚本:

#!/bin/sh
(echo "Script started on " `date`;
pty "${SHELL:~/bin/sh}";
echo "Script done on " `date`) | tee typescript

协同进程不能使用标准IO函数,原因是标准输入和标准输出不是终端,所以标准IO函数会将IO内容放到缓冲区,如果运行协同进程时把:

if (execl("./add2", "add2", (char *)0) < 0) 

替换成:

if (execl("./pty", "pty", "-e", "add2", (char *)0) < 0)

在pty下运行协同进程,即使协同进程使用了标准IO仍然可以正确运行。以下是在pty下运行协同进程的安排:
在这里插入图片描述
上图中的驱动程序是第十五章中(15-18)调用上图的上面的C语句的程序。如果不使用pty程序的-e选项(不回显),则interactive变量的值是isatty函数的返回值,由于pty程序的标准输入没有连到终端(而是连到驱动程序),因此interactie变量为false,所以真正终端上的终端行规程保持规范模式,并允许回显,如果不使用-e选项关掉PTY从设备上的行规程模块的回显,则每个键入的字符都将被两个行规程模块各回显一次。

上图中的驱动程序在读pty父进程时使用的是read函数,由于读的是管道,可能会出现只读了一部分的情况,之后驱动程序将读到的那部分输出到终端,再继续读下一条交给协同进程的输入。解决方法是使用标准IO库进行读写,并设为行缓冲的(默认管道是全缓冲的)。如果驱动程序在读协同进程时使用的是fgets函数,这假设了协同进程只会返回一行结果。

pty不能非交互式地运行交互式程序,举个例子,如果我们在pty下运行telnet命令与远程主机交互:

pty telnet 192.168.1.3

这与直接运行telnet程序相比没什么区别,我们希望在一个脚本中运行telnet程序,如果telnet.cmd文件包含以下内容:

sar
passwd
uptime
exit

第一行是登录到远程主机时使用的用户名;第二行是口令;第三行是希望运行的命令;第四行终止此会话,如果按下列方式运行脚本:

pty -i < talnet.cmd telnet 192.168.1.3

它不会像我们期望的那样工作,telnet.cmd文件的内容在还没有得到机会提示我们输入用户名和口令前,就被发送到了远程主机。在login程序调用tcsetattr函数关闭回显读口令时,会丢弃输入队列中的所有数据,这样我们发送的数据就被丢掉了。

如果我们像15-18程序那样运行pty也不可以,因为15-18中的程序即使使用了标准IO库,如果用的是fgets函数,那么它就假设在一个管道写入的每一行都在另一个管道产生一行输出。更进一步,如果想在发送给协同进程数据前从协同进程读,这就行不通了。

有一些从shell脚本驱动交互式程序的方法:在pty程序里增加一种命令语言(用于作业控制的语言)和一个解释器(直接执行写在脚本语言或编程语言中的指令,不需要先将其编译成机器码),但一个适当的命令语言可能十倍于pty程序的大小;另一种选择是使用命令语言并用pty_fork函数来调用交互式程序,这正是expect程序所做的。

pty程序的-d选项使驱动程序的标准输入是pty的标准输入,使驱动程序的标准输出是pty的标准输出,这有点像协同进程,只不过协同进程是驱动程序。此时,由pty通过以下do_driver函数完成驱动进程的fork和exec,且我们在pty和驱动进程两者之间使用一个双向流管道,而不是两个半双工管道。使用-d选项时,以下do_driver函数由pty的main函数调用:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

void do_driver(char *driver) {
    pid_t child;
    int pipe[2];

    /*
     * Create a full-duplex pipe to communicate with the driver.
     */
    if (fd_pipe(pipe) < 0) {
        printf("can't create stream pipe\n");
		exit(1);
    }

    if ((child = fork()) < 0) {
        printf("fork error\n");
		exit(1);
    } else if (child == 0) {    /* child */
        close(pipe[1]);

		/* stdin for driver */
		if (dup2(pipe[0], STDIN_FILENO) != STDIN_FILENO) {
		    printf("dup2 error to stdin\n");
		    exit(1);
		}
		/* stdout for driver */
		if (dup2(pipe[0], STDOUT_FILENO) != STDOUT_FILENO) {
		    printf("dup2 error to stdout\n");
		    exit(1);
		}
		// 将标准输入和标准输出都关联到全双工管道后,就可以把原来关联到全双工管道的pipe[0]关闭
		if (pipe[0] != STDIN_FILENO && pipe[0] != STDOUT_FILENO) {
		    close(pipe[0]);
		}
	
		/* leave stderr for driver alone */
		execlp(driver, driver, (char *)0);
		printf("execlp error for: %s", driver);
		exit(1);
    }

    close(pipe[0]);    /* parent */
    if (dup2(pipe[1], STDIN_FILENO) != STDIN_FILENO) {
        printf("dup2 error to stdin\n");
    }
    if (dup2(pipe[1], STDOUT_FILENO) != STDOUT_FILENO) {
        printf("dup2 error to stdout\n");
    }
    // 将标准输入和标准输出都关联到全双工管道后,就可以把原来关联到全双工管道的pipe[1]关闭
    if (pipe[1] != STDIN_FILENO && pipe[1] != STDOUT_FILENO) {
        close(pipe[1]);
    }

    /*
     * Parent returns, but with stdin and stdout connected
     * to the driver.
     */
}

虽然以上驱动程序有和pty连接在一起的标准输入和标准输出,但驱动进程仍然可以通过读、写/dev/tty同用户交互。虽然这个方法不如expect程序通用,但它用不到50行代码提供了pty的一种实用的选项。

伪终端高级特性:
1.打包模式:能够使PTY主设备了解到PTY从设备的状态变化。在Solaris系统中,可以通过将STREAMS模块pckt压入PTY主设备端来设置这种模式。在FreeBSD、Linux、Mac OS X中,可用TIOCPKT参数调用ioctl函数来设置这种模式。

Solaris和其他平台相比,具体的打包模式有所不同,在Solaris中,读取PTY主设备的进程必须调用getmsg从流首取得消息(而其他平台每次对PTY主设备的读操作都会返回带有可选数据的状态字节),这是因为pckt模块将一些时间转化成了无数据的STREAMS消息(指在STREAMS模块中传递的消息,它没有实际的数据内容,一些特定的事件(如从设备关闭)被转化为无数据的STREAMS消息)。

无论实现细节如何,打包模式的目的是,当PTY从设备上的行规程模块出现以下事件时,通知PTY主设备读取数据:读队列被冲洗、写队列被冲洗、输出被停止(如Ctrl+S)、输出重新开始、IXON/IXOFF流控制被禁用后重新启动、IXON/IXOFF流控制被启用后重新禁用。rlogin客户进程和rlogind服务器进程会使用到以上事件。

2.远程模式:PTY主设备可以用TIOCREMOTE参数调用ioctl函数将PTY从设备设置成远程模式。Mac OS X 10.6.8、Solaris 10使用同样的参数调用ioctl函数来启用或禁用这个特性,但在Solaris中,ioctl函数的第三个参数是一个整型数,而Mac OS X中则是一个指向整型数的指针。FreeBSD 8.0和Linux 3.2.0不支持这一命令。

当PTY主设备将PTY从设备设置成这种模式时,它通知PTY从设备上的行规程模块不对从设备上接收到的数据进行任何处理,不管从设备的termios结构中的规范或非规范标志是否设置。远程模式适用于窗口管理器这种进行自己的行编辑的应用。

3.窗口大小变化:PTY主设备上的进程可以用TIOCSWINSZ为参数调用ioctl来设置从设备的窗口大小。如果新的大小和当前的大小不同,SIGWINCH信号将被发送到PTY从设备的前台进程组。

4.信号发生:读、写PTY主设备的进程可以向PTY从设备的进程组发送信号。在Solaris 10中,可以用TIOCSIGNAL为参数调用ioctl函数做到这一点。FreeBSD 8.0、Linux 3.2.0、Mac OS X 10.6.8中,用TIOCSIG为参数调用ioctl函数来做到这一点。这两种情况下,第三个参数都是信号编号值。

telnet和rlogin远程登录到一个BSD系统上时,PTY从设备的所有权和权限被设置,此过程能进行是由于这两个远程登录程序都以超级用户权限运行,它们都可以成功调用chown和chmod。

解释以下例子中,ttyname程序(输出标准输入、标准输出、标准错误的设备名)不读入只输出的情况下,文件data的内容还被输出到终端上:
在这里插入图片描述
因为PTY从设备上的行规程能够回显,所以pty从其标准输入所读取的以及写向PTY主设备的内容默认都回显,尽管ttyname程序从不读取数据,但是该回显也可通过从设备上的行规程模块实现。

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

UNIX环境高级编程 学习笔记 第十九章 伪终端 的相关文章

随机推荐

  • chatgpt赋能python:Python画散点图:用代码展现数据分布

    Python画散点图 用代码展现数据分布 Python是一种动态语言 易于学习和使用 并且在各种领域都有广泛的应用 其中一种常见的用途是数据可视化 特别是散点图 本篇文章将向您展示如何使用Python在Jupyter Notebook中绘制
  • c# 远程关机 重启 注销 小练习

    亲测小练习 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41
  • 我的HTC G16 CHACHA A810e版手机如何解锁和一键root的

    之前用了很多方法 都无法将该手机进行解锁 unlock 后来 应用刷机精灵就解决了 里面对HTC的解锁进行了集成处理 无需登录HTC官网 使用该软件 刷了原厂系统 且把预装软件进行了卸载 非常方便 我的手机配置如下 刷机精灵为
  • 3D Web轻量化引擎HOOPS:轻松解决OSGB模型复杂性与性能挑战!

    在当今的数字时代 三维模型的创建和展示对于众多行业都至关重要 无论是用于游戏开发 虚拟现实体验 建筑设计还是工程仿真 高质量的3D模型都能够提供更真实的视觉效果和更精确的数据表示 然而 随着模型的复杂性增加 其数据量也迅速膨胀 这可能会导致
  • Kafka的生成者、消费者、broker的基本概念

    kafka是一款基于发布与订阅的消息系统 它一般被称为 分布式提交日志 或者 分布式流平台 文件系统或者数据库提交日志用来提供所有事物的持久化记录 通过重建这些日志可以重建系统的状态 同样地 kafka的数据是按照一定顺序持久化保存的 可以
  • sql总结喔

    一 数据库基础 如何开服务 2个 TCP IP协议 SQL Server MSSQLSERVER 系统数据库 master 核心 模板 model 模型 msdb 警告日志 tempdb 临时 数据库文件分为数据文件 mdf ndf 和日志
  • SPI通信原理和协议

    1 SPI原理超详细讲解 值得一看 https blog csdn net as480133937 article details 105764119 2 STM32 HAL库 STM32CubeMX教程十四 SPI https blog
  • 2021-11-11密码学

    对称密码体系中发送者和接收者使用同一个密钥加密和解密 对称 可逆 非对称密码体系中发送者使用接收者的公钥进行加密 接收者使用自己的私钥进行解密 非对称 五种对称加密算法总结 1 DES 已破解 不再安全 基本没有企业在用了 是对称加密算法的
  • idea 配置文件乱码修改

    Settings gt Editor gt File Encodings 设置统一编码
  • As 启动项目出现unsupported modules detected

    首先是出现如下情况 unsupported modules detected Compilation is not supported for following modules app Unfortunately you can t ha
  • python yield关键字全面解析

    你是否曾因处理的数据集过大而内存溢出 你是否曾因为处理各种复杂的函数状态而烦恼 It does help 本文聚焦yield generator 帮助你解锁python进阶技法 写出更优雅的程序 先导概念 为了更好的理解本篇推文的内容 读者
  • 计算机网络笔记七(数据链路层:帧、MTU、ARP协议)

    1 数据链路层 1 1主要功能 用于两个设备 同一种数据链路节点 之间进行信息传递 网络层和数据链路层对比 网络层是进行地址管理和路由选择的 它是为数据报的转发找出一条路来 而数据链路层解决的是两个结点直接的数据交换 接近于物理层的概念 1
  • 线性代数学习笔记——矩阵(二)(针对期末与考研)

    伴随矩阵 设 A a i j A a ij
  • wazuh初探系列一 : wazuh环境配置

    目录 方法一 一体化部署 安装先决条件 第一步 安装所有必需的软件包 第二步 安装Elasticsearch 1 添加 Elastic Stack 存储库 安装 GPG 密钥 添加存储库 更新源 2 Elasticsearch安装和配置 安
  • 建表时出现10654错误the right syntax to use near‘NUL COMMENT‘

    创建表时 出现 10654 You have an err in your SQl sytaxy check the manual that corresponds to your MySQL sever version for the r
  • 从零开始搭建创业公司后台技术栈

    转自 http ju outofmemory cn entry 351897 编辑 公众号程序员面试 前言 说到后台技术栈 脑海中是不是浮现的是这样一幅图 图 1 有点眼晕 以下只是我们会用到的一些语言的合集 而且只是语言层面的一部分 就整
  • 服务器虚拟化方需求分析报告,服务器虚拟化解决方案报告书.doc

    WORD格式可编辑 专业知识整理分享 服务器虚拟化解决方案 Citrix XenServer服务器虚拟化解决方案 1 1 方案综述 1 1 1服务器虚拟化的业务及应用需求 随着企业业务的飞速发展 越来越多的业务系统依赖于数据中心的支撑 其中
  • 泛微移动表单校验手机号跟邮箱

    泛微移动表单校验手机号跟邮箱 var iphoneVal f phone val 获取表单字段 var checkIphone d 1 345789 d
  • h5上下滑动动画效果(vue)

    1 详情介绍 图片可以使用网络图片 根据请求过来的图片来获取高度要控制滑动的位置 可以换成视频 要实现滑动播放视频的效果 并且可以在上面添加一些其他的功能 白色背景区域可以展示对应的数据 具体效果看文章末尾 2 编码介绍 template部
  • UNIX环境高级编程 学习笔记 第十九章 伪终端

    终端登录是经由终端设备进行的 终端设备天然提供终端语义 在终端和运行程序之间有一个终端行规程 通过该规程我们能设置终端特殊字符 如退格 行删除 中断等 但当一个登录请求到达网络连接时 终端行规程并不是自动被加载到网络连接和登录shell之间