Linux/UNIX系统编程手册笔记:终端

发布于:2025-09-10 ⋅ 阅读:(18) ⋅ 点赞:(0)

终端深度解析:掌控 Linux 交互基石

在 Linux 系统中,终端是用户与操作系统交互的核心界面,从命令行操作到程序输入输出,都依赖终端的底层机制。理解终端的属性、模式与控制方式,是高效使用命令行、开发终端应用的关键。本文将逐层拆解终端的核心知识点,带你掌握终端交互的底层逻辑。

一、整体概览

(一)终端的本质

终端是用户与操作系统的交互接口,分为物理终端(如串口终端 )和虚拟终端(如 gnome-terminaltty )。它负责:

  • 接收用户输入(键盘按键 )。
  • 显示系统输出(文本、颜色、光标控制 )。

在 Linux 中,终端设备文件通常位于 /dev/tty*(如 /dev/tty1 对应虚拟控制台 )。

二、获取和修改终端属性

(一)tcgetattrtcsetattr 函数

通过这两个函数获取、设置终端属性,控制输入输出行为:

#include <termios.h>
int tcgetattr(int fd, struct termios *termios_p);
int tcsetattr(int fd, int actions, const struct termios *termios_p);
  • fd:终端设备描述符(如 STDIN_FILENO )。
  • termios_p:存储终端属性的结构体,包含输入、输出、控制模式等。

(二)示例:禁用回显

struct termios orig_termios;
tcgetattr(STDIN_FILENO, &orig_termios); // 保存原属性

struct termios new_termios = orig_termios;
new_termios.c_lflag &= ~ECHO; // 禁用回显
tcsetattr(STDIN_FILENO, TCSANOW, &new_termios); // 立即生效

// 输入不会回显
char buf[100];
read(STDIN_FILENO, buf, sizeof(buf));

tcsetattr(STDIN_FILENO, TCSANOW, &orig_termios); // 恢复原属性

三、stty 命令

(一)功能与用法

stty 是终端属性的命令行工具,用于查看、修改终端设置:

  • 查看当前设置:stty -a
  • 修改设置:stty echo(启用回显 )、stty -echo(禁用回显 )

示例:模拟密码输入(禁用回显 )

stty -echo
read -p "Password: " pass
stty echo
echo "\nYou entered: $pass"

四、终端特殊字符

(一)特殊字符的作用

终端定义了特殊控制字符,触发特定行为:

  • Ctrl+CVINTR ):发送中断信号(终止前台进程 )。
  • Ctrl+DVEOF ):文件结束符(关闭输入 )。
  • Ctrl+ZVSUSP ):挂起进程。

这些字符可通过 termios 结构体或 stty 命令修改:

stty intr ^B # 将中断字符改为 Ctrl+B

五、终端标志

(一)标志的分类与作用

termios 结构体中的标志控制终端行为,分为:

  • 输入标志c_iflag ):处理输入数据(如奇偶校验、换行转换 )。
  • 输出标志c_oflag ):处理输出数据(如回车换行转换 )。
  • 控制标志c_cflag ):设置波特率、数据位等(主要用于串口终端 )。
  • 本地标志c_lflag ):控制本地模式(如回显、 canonical 模式 )。

(二)示例:启用原始模式

new_termios.c_lflag &= ~(ICANON | ECHO); // 禁用规范模式和回显
new_termios.c_cc[VMIN] = 1; // 至少读取 1 字节
new_termios.c_cc[VTIME] = 0; // 无超时
tcsetattr(STDIN_FILENO, TCSANOW, &new_termios);

原始模式下,终端立即传递输入,适合游戏、串口通信等场景。

六、终端的 I/O 模式

(一)规范模式(Canonical Mode)

  • 特点:输入以行(换行符 )为单位缓冲,支持编辑(退格、删除 )。
  • 应用:默认的命令行交互模式,适合逐行输入场景。

(二)非规范模式(Noncanonical Mode)

  • 特点:输入立即传递,无行缓冲,需手动处理输入事件。
  • 应用:实时交互应用(如文本编辑器、游戏 )。

(三)加工模式、cbreak 模式以及原始模式

  • cbreak 模式:禁用规范模式,但保留回显、信号处理。
  • 原始模式:禁用所有规范处理(回显、信号、输入处理 ),完全原始输入。

七、终端线速(比特率)

(一)线速的设置

对于串口终端,通过 c_cflag 设置波特率(如 B9600 ):

new_termios.c_cflag &= ~CBAUD; // 清除原有波特率
new_termios.c_cflag |= B115200; // 设置为 115200 波特率
tcsetattr(fd, TCSANOW, &new_termios);

虚拟终端通常忽略线速设置,主要用于物理串口通信。

八、终端的行控制

(一)行控制的实现

通过终端控制序列(Escape Sequence ),可控制光标、清屏、颜色等:

  • 清屏:printf("\033[2J");
  • 移动光标:printf("\033[10;20H");(移动到第 10 行第 20 列 )

这些序列遵循 ANSI 转义码标准,是终端 UI 开发的基础(如 vimhtop )。

九、终端窗口大小

(一)获取与监听窗口变化

通过 ioctl 获取终端窗口大小:

struct winsize ws;
ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws);
printf("Rows: %d, Columns: %d\n", ws.ws_row, ws.ws_col);

监听窗口变化可通过 SIGWINCH 信号实现,动态调整界面布局。

十、终端标识

(一)终端类型与环境变量

TERM 环境变量标识终端类型(如 xterm-256color ),程序据此调整输出(如颜色支持 )。

echo $TERM # 查看终端类型

不同终端类型支持的控制序列不同,需确保程序与终端兼容。

十一、总结

在早期的UNIX系统上,终端是通过串行线连接到计算机上的真正的硬件设备。早期的终端并没有得到标准化,这意味着对于不同的硬件厂商,对终端进行编程时的转义序列是不同的。在现代工作站上,这样的终端已经被运行着X Window系统的位图监视器所取代了。但是,当处理虚拟设备比如虚拟控制台和终端模拟器(使用了伪终端),以及通过串行线连接的真实设备时,仍然需要能够对终端进行编程。

有关终端的设置(除了终端窗口大小外)都维护在termios结构体中,它包含了4个位掩码字段用来控制有关终端的各种设置,以及一个定义了各种特殊字符的数组,这些特殊字符由终端驱动程序负责解释。函数tcgetattr()和tcsetattr()允许程序获取并修改终端的设置。

当执行输入时,终端驱动程序可以操作于两种不同的模式下。在规范模式下,输入会装配成行(由其中一种行终止符结束),且打开了行编辑功能。与之相反,非规范模式下允许应用程序一次只读取一个输入字符,而不需要等到用户输入一个行终止符。非规范模式下禁止了行编辑功能。非规范模式下的读操作什么时候完成,是由termios结构体中的MIN和TIME字段来控制的,它们决定了最少被读取的字符数以及施加于读操作上的超时时间。我们对非规范模式下的读操作的4种不同情况作了描述。

历史上第7版UNIX以及BSD终端驱动程序提供了3种输入模式——加工模式、cbreak模式和原始模式——它们对终端的输入和输出处理提供了不同程度的支持。cbreak和原始模式可以通过修改termios结构体中的各个字段来模拟。

还有一系列函数可以执行各种其他的终端操作。这些函数包括修改终端线速以及执行行控制操作(生成一个BREAK状态,暂停进程直到输出已经完成传递,刷新终端的输入和输出队列,暂停或恢复终端和计算机之间的双向数据传输)。其他的函数允许我们检查给定的文件描述符是否指向一个中断,并获取该终端的名称。系统调用ioctl()可用来获取并修改由内核记录的终端窗口大小,并执行一系列其他的与终端相关的操作。

终端是 Linux 交互的基石,涉及属性配置、输入输出模式、特殊字符、控制序列等多个层面:

  • 属性控制:通过 termiosstty 灵活调整终端行为,适配不同应用场景。
  • I/O 模式:规范模式适合命令行,非规范模式适合实时交互,原始模式适合底层通信。
  • 高级控制:利用转义序列实现复杂 UI,监听窗口变化优化布局。

掌握终端的底层机制,不仅能提升命令行操作效率,还能开发出如 vim 般强大的终端应用。无论是调试串口设备、编写交互脚本,还是打造终端工具,这些知识都是不可或缺的支撑。

深入解析其他备选 I/O 模型:提升 Linux 程序 I/O 效率的关键

在 Linux 系统编程中,I/O 模型的选择直接影响程序的性能与并发能力。除了基础的阻塞 I/O,还有多种高效的 I/O 模型,如 I/O 多路复用、信号驱动 I/O、epoll 等。这些模型各有特点,适用于不同的应用场景。本文将全面剖析这些备选 I/O 模型,帮助你精准选择并优化程序的 I/O 处理。

一、整体概览

(一)I/O 模型的核心价值

I/O 模型决定了程序如何处理输入输出操作,影响着程序的并发能力、资源利用率响应速度。在高并发场景(如 Web 服务器、实时通信系统 )中,选择合适的 I/O 模型可显著提升程序性能,避免阻塞导致的资源浪费。

(二)水平触发和边缘触发

  • 水平触发(Level - Triggered):只要文件描述符处于就绪状态(如可读、可写 ),就会持续触发通知。select、poll、epoll 默认采用水平触发。
  • 边缘触发(Edge - Triggered):仅在文件描述符的就绪状态发生变化时(如从不可读变为可读 )触发通知。epoll 支持边缘触发,可减少不必要的系统调用,但需注意处理完所有数据,否则可能导致遗漏。

(三)在备选的 I/O 模型中采用非阻塞 I/O

非阻塞 I/O 让程序在 I/O 操作无法立即完成时,不会陷入阻塞,而是快速返回错误(如 EAGAINEWOULDBLOCK )。结合 I/O 多路复用,可实现高效的并发 I/O 处理,避免线程或进程因阻塞而挂起。

二、I/O 多路复用

(一)select() 系统调用

select 允许程序监控多个文件描述符的就绪状态,函数原型:

#include <sys/select.h>
int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
  • nfds:需监控的最大文件描述符 + 1。
  • readfds/writefds/exceptfds:分别监控读、写、异常事件的文件描述符集合。
  • timeout:超时时间,NULL 表示无限等待。

示例:监控标准输入和套接字的可读事件

fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(STDIN_FILENO, &read_fds);
FD_SET(sock_fd, &read_fds);

struct timeval timeout = {.tv_sec = 5, .tv_usec = 0};
int ready = select(MAX_FD + 1, &read_fds, NULL, NULL, &timeout);
if (ready > 0) {
    if (FD_ISSET(STDIN_FILENO, &read_fds)) {
        // 标准输入可读
    }
    if (FD_ISSET(sock_fd, &read_fds)) {
        // 套接字可读
    }
}

select 的缺点是文件描述符数量有限(受 FD_SETSIZE 限制 ),且每次调用需重新初始化集合,效率随监控数量增加而下降。

(二)poll() 系统调用

pollselect 的改进版,通过结构体数组监控文件描述符,突破了 select 的文件描述符数量限制,函数原型:

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
    int fd;         // 要监控的文件描述符
    short events;   // 期望的事件(如 POLLIN、POLLOUT )
    short revents;  // 实际发生的事件
};

示例:监控多个文件描述符

struct pollfd fds[2] = {
    {.fd = STDIN_FILENO, .events = POLLIN},
    {.fd = sock_fd, .events = POLLIN}
};

int ready = poll(fds, 2, 5000); // 超时 5 秒
if (ready > 0) {
    for (int i = 0; i < 2; i++) {
        if (fds[i].revents & POLLIN) {
            // 处理就绪的文件描述符
        }
    }
}

poll 无需重新初始化集合,但仍需遍历所有文件描述符判断事件,高并发时效率仍受限。

(三)文件描述符何时就绪?

文件描述符就绪的判断条件:

  • 可读:接收缓冲区有数据,或收到关闭连接通知(如 FIN 包 )。
  • 可写:发送缓冲区有空闲空间,可写入数据。
  • 异常:发生带外数据(OOB )或其他错误。

需注意,不同文件描述符(如套接字、管道、终端 )的就绪条件可能存在差异。

(四)比较 select() 和 poll()

特性 select() poll()
文件描述符数量 FD_SETSIZE 限制 无显式限制
集合操作 需手动初始化、重置集合 结构体数组自动记录事件
效率 随监控数量增加下降明显 相对稳定,但高并发仍有瓶颈

(五)select() 和 poll() 存在的问题

  • 效率问题:每次调用需遍历所有监控的文件描述符,判断就绪状态,高并发时开销大。
  • 无法高效处理大量文件描述符:监控数千个文件描述符时,性能显著下降。

三、信号驱动 I/O

(一)何时发送 “I/O 就绪” 信号?

信号驱动 I/O 通过注册信号(如 SIGIO ),当文件描述符就绪时,内核发送信号通知程序。需先为文件描述符启用信号驱动模式:

// 绑定进程 ID 到文件描述符,接收 SIGIO 信号
fcntl(fd, F_SETOWN, getpid()); 
// 启用信号驱动 I/O
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_ASYNC); 

当文件描述符可读、可写或发生异常时,内核发送 SIGIO 信号,程序在信号处理函数中处理 I/O 操作。

(二)优化信号驱动 I/O 的使用

  • 信号处理函数:需简洁高效,避免复杂操作(信号处理函数中不能调用非可重入函数 )。
  • 结合非阻塞 I/O:在信号处理函数中,使用非阻塞 I/O 读取或写入数据,避免再次阻塞。

四、epoll 编程接口

(一)创建 epoll 实例:epoll_create()

#include <sys/epoll.h>
int epoll_create(int size);

epoll_create 创建一个 epoll 实例,返回用于操作的文件描述符。size 参数已被弃用,通常传 1 即可。

(二)修改 epoll 的兴趣列表:epoll_ctl()

int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);

struct epoll_event {
    uint32_t events;  // 关注的事件(如 EPOLLIN、EPOLLOUT )
    epoll_data_t data; // 关联的数据(如文件描述符、自定义指针 )
};
  • opEPOLL_CTL_ADD(添加监控 )、EPOLL_CTL_MOD(修改监控 )、EPOLL_CTL_DEL(删除监控 )。

示例:添加套接字监控可读事件

struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = sock_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ev);

(三)事件等待:epoll_wait()

int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);

epoll_wait 等待 epoll 实例中监控的文件描述符就绪事件,返回就绪的事件数量,填充 events 数组。

(四)深入探究 epoll 的语义

  • 水平触发与边缘触发:通过 epoll_event.eventsEPOLLET 标志设置边缘触发模式。
  • 高效性:epoll 基于红黑树和就绪队列实现,无需遍历所有文件描述符,适合高并发场景(如 Nginx 使用 epoll 处理万级连接 )。

(五)epoll 同 I/O 多路复用的性能对比

epoll 在高并发、大量文件描述符场景下,性能远超 select 和 poll,原因:

  • 无需每次调用重新初始化监控集合。
  • 内核维护就绪队列,直接返回就绪事件,避免无效遍历。

(六)边缘触发通知

边缘触发模式下,epoll_wait 仅在文件描述符状态变化时通知。需注意:

  • 必须一次性读取或写入所有数据(直到返回 EAGAIN ),否则可能遗漏事件。
  • 结合非阻塞 I/O,确保数据处理完毕。

五、在信号和文件描述符上等待

(一)pselect() 系统调用

pselectselect 的增强版,支持信号掩码的临时替换,函数原型:

int pselect(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, const struct timespec *timeout, const sigset_t *sigmask);

可用于在等待 I/O 事件时,临时阻塞特定信号,避免信号中断 select 调用。

(二)self - pipe 技巧

当程序需在信号处理函数中唤醒阻塞的 select/poll/epoll_wait 时,可使用 self - pipe 技巧:

  1. 创建一个管道(self_pipe )。
  2. 信号处理函数向管道写入一个字节。
  3. 监控管道的可读事件,触发后处理信号逻辑。

示例:唤醒 epoll_wait

// 信号处理函数
void sig_handler(int signum) {
    write(self_pipe[1], "a", 1);
}

// 监控管道可读事件
struct epoll_event ev;
ev.events = EPOLLIN;
ev.data.fd = self_pipe[0];
epoll_ctl(epfd, EPOLL_CTL_ADD, self_pipe[0], &ev);

六、总结

本章我们探究了针对标准I/O模型之外的其他几种可选的I/O模型。它们是:I/O多路复用(select()和poll())、信号驱动I/O以及Linux专有的epoll API。所有这些机制都允许我们监视多个文件描述符,以查看哪个文件描述符上可执行I/O操作。需要注意的是,所有这些机制并不实际执行I/O操作。相反,一旦发现某个文件描述符处于就绪态,我们仍然采用传统的I/O系统调用来完成实际的I/O操作。

I/O多路复用机制中的select()和poll()能够同时监视多个文件描述符,以查看哪个文件描述符上可执行I/O操作。在这两个系统调用中,我们传递一个待监视的文件描述符列表给内核,之后内核返回一个修改过的列表以表明哪些文件描述符处于就绪态了。在每一次调用中都要传递完整的文件描述符列表,并且在调用返回后还要检查它们,这个事实表明当需要监视大量的文件描述符时,select()和poll()的性能表现将变得很差。

信号驱动I/O允许一个进程在文件描述符处于I/O就绪态时接收到一个信号。要使用信号驱动I/O,我们必须为SIGIO信号安装一个信号处理例程,设定接收信号的属主进程,并在打开文件时设定O_ASYNC标志使得信号可以生成。相比I/O多路复用,当监视大量的文件描述符时信号驱动I/O有着显著的性能优势。Linux允许我们修改用来通知的信号,而如果我们采用实时信号的话,那么多个信号通知就可以排队处理。信号处理例程可以使用siginfo_t参数来确定产生信号的文件描述符以及发生事件的类型。

同信号驱动I/O一样,当监视大量的文件描述符时epoll也能提供高效的性能。epoll(以及信号驱动I/O)的性能优势源自内核能够“记住”进程正在监视的文件描述符列表这一事实(与之相反的是,select()和poll()都必须反复告诉内核哪些文件描述符需要监视)。相比于信号驱动I/O,epoll API还有些值得一提的优点:我们可以避免处理信号时的复杂流程,而且可以指定需要监视的I/O事件类型(例如输入或输出事件)。

本章中我们在水平触发通知和边缘触发通知之间做了严格区分。在水平触发通知模型下,只要当前文件描述符上可以进行I/O操作,我们就能得到通知。与之相反,在边缘触发通知模型下,只有自上一次监视以来,文件描述符上有发生I/O事件时才会通知我们。I/O多路复用采用的是水平触发通知模型;信号驱动I/O基本上是边缘触发通知模型;而epoll能够以任意一种方式工作(默认情况下是水平触发)。边缘触发通知通常都和非阻塞式I/O结合起来使用。

本章结尾部分我们探讨了一个经常会遇到的问题。那就是如何在监视多个文件描述符的同时等待信号的发送?对于这个问题,通常的解决方案是采用一种称为self-pipe的技巧,即信号处理例程写一个字节数据到管道中,代表管道读端的文件描述符包含在被监视的文件描述符集合中。SUSv3中定义了pselect(),这是select()的变种,它提供了解决这个问题的另一种方法。但是pselect()并没有包含在所有的UNIX实现中。Linux也提供了类似(但非标准)的ppoll()和epoll_pwait()接口。

不同的 I/O 模型适用于不同的场景:

  • select/poll:适合小规模文件描述符监控,实现简单,但高并发性能不足。
  • 信号驱动 I/O:适合对响应及时性要求高的场景,需注意信号处理的复杂性。
  • epoll:高并发场景的首选,支持水平触发和边缘触发,性能卓越,是高性能网络服务器(如 Nginx )的核心技术。

在实际开发中,需根据程序的并发需求、资源限制选择合适的 I/O 模型。结合非阻塞 I/O、信号处理、自我管道等技巧,可进一步优化程序的 I/O 处理效率,打造高性能、高可靠的 Linux 应用。

伪终端深度解析:模拟终端交互的底层逻辑

在 Linux 系统中,伪终端(Pseudoterminal,PTY )是一种模拟终端设备的机制,广泛用于终端复用、自动化脚本(如 script 命令 )、容器终端交互等场景。它让程序能像操作真实终端一样,与其他进程进行交互。本文将深入拆解伪终端的实现与应用,带你掌握模拟终端交互的核心技术。

一、整体概览

(一)伪终端的作用

伪终端在控制进程(如终端模拟器、脚本程序 )和被控制进程(如 Shell、命令行工具 )之间搭建桥梁:

  • 控制进程通过伪终端的“主设备”(Master )发送输入、接收输出。
  • 被控制进程将伪终端的“从设备”(Slave )视为真实终端,执行命令并输出结果。

典型场景:ssh 远程登录、screen/tmux 终端复用、Docker 容器的 attach 功能。

二、UNIX98 伪终端

(一)核心函数与流程

UNIX98 规范定义了现代伪终端的接口,通过以下步骤创建和使用:

1. 打开未使用的主设备:posix_openpt()
#include <stdlib.h>
#include <fcntl.h>
int posix_openpt(int flags);
  • flags:通常传 O_RDWR | O_NOCTTY(读写模式,不将主设备设为控制终端 )。
  • 返回主设备文件描述符(Master FD ),用于后续操作。

示例:

int master_fd = posix_openpt(O_RDWR | O_NOCTTY);
if (master_fd == -1) {
    perror("posix_openpt");
    return 1;
}
2. 修改从设备属主和权限:grantpt()
int grantpt(int fd);
  • 将伪终端从设备的属主设为当前用户,确保权限正确,让被控制进程能访问从设备。

示例:

if (grantpt(master_fd) == -1) {
    perror("grantpt");
    close(master_fd);
    return 1;
}
3. 解锁从设备:unlockpt()
int unlockpt(int fd);
  • 解锁从设备,允许被控制进程打开它。

示例:

if (unlockpt(master_fd) == -1) {
    perror("unlockpt");
    close(master_fd);
    return 1;
}
4. 获取从设备名称:ptsname()
char *ptsname(int fd);
  • 返回从设备的路径(如 /dev/pts/2 ),供被控制进程打开。

示例:

char *slave_path = ptsname(master_fd);
if (slave_path == NULL) {
    perror("ptsname");
    close(master_fd);
    return 1;
}
printf("Slave device: %s\n", slave_path);

三、打开主设备:ptyMasterOpen()

(一)封装与实现

ptyMasterOpen 是对 UNIX98 伪终端主设备打开流程的封装,通常包含 posix_openptgrantptunlockpt 等操作:

int ptyMasterOpen() {
    int master_fd = posix_openpt(O_RDWR | O_NOCTTY);
    if (master_fd == -1) return -1;
    if (grantpt(master_fd) == -1) {
        close(master_fd);
        return -1;
    }
    if (unlockpt(master_fd) == -1) {
        close(master_fd);
        return -1;
    }
    return master_fd;
}

通过封装,简化伪终端主设备的创建流程,方便复用。

四、将进程连接到伪终端:ptyFork()

(一)功能与实现

ptyFork 负责:

  1. 创建伪终端主设备和从设备。
  2. fork 子进程,将子进程的标准输入、输出、错误重定向到伪终端从设备。
  3. 父进程通过主设备与子进程交互。

核心代码:

#include <sys/wait.h>
#include <unistd.h>

int ptyFork(int *master_fd, char *slave_name, size_t slave_name_size) {
    int mfd = ptyMasterOpen();
    if (mfd == -1) return -1;

    char *slave_path = ptsname(mfd);
    if (slave_path == NULL) {
        close(mfd);
        return -1;
    }
    if (slave_name != NULL) {
        strncpy(slave_name, slave_path, slave_name_size);
    }

    pid_t pid = fork();
    if (pid == -1) {
        close(mfd);
        return -1;
    } else if (pid == 0) { // 子进程
        close(mfd); // 关闭主设备

        // 打开从设备
        int sfd = open(slave_path, O_RDWR | O_NOCTTY);
        if (sfd == -1) _exit(1);

        // 重定向标准输入、输出、错误到从设备
        dup2(sfd, STDIN_FILENO);
        dup2(sfd, STDOUT_FILENO);
        dup2(sfd, STDERR_FILENO);
        if (sfd > STDERR_FILENO) close(sfd);

        // 执行登录 Shell(或其他程序)
        execlp("/bin/bash", "bash", (char *)NULL);
        _exit(1);
    } else { // 父进程
        *master_fd = mfd;
        return pid;
    }
}

子进程通过伪终端从设备与父进程通信,实现类似终端的交互环境。

五、伪终端 I/O

(一)数据交互流程

父进程(控制进程 )通过主设备文件描述符,与子进程(被控制进程 )进行 I/O:

  • 父进程发送输入write(master_fd, "ls\n", 3),子进程的 Shell 接收并执行 ls 命令。
  • 父进程接收输出read(master_fd, buf, sizeof(buf)),读取子进程的命令输出。

需注意伪终端的行规范(如回显、换行转换 ),可通过 termios 配置从设备的终端属性(见终端相关章节 )。

六、实现 script(1) 程序

(一)script 功能与原理

script 命令记录终端会话的输入输出,核心逻辑:

  1. 创建伪终端,启动 Shell 作为被控制进程。
  2. 同时将用户输入转发给伪终端,并写入日志文件。
  3. 将伪终端的输出同时显示到屏幕和写入日志文件。

简化实现:

#include <fcntl.h>

int main() {
    int master_fd;
    char slave_path[256];
    pid_t child_pid = ptyFork(&master_fd, slave_path, sizeof(slave_path));
    if (child_pid == -1) {
        perror("ptyFork");
        return 1;
    }

    // 打开日志文件
    int log_fd = open("session.log", O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (log_fd == -1) {
        perror("open log");
        close(master_fd);
        return 1;
    }

    char buf[1024];
    ssize_t n;
    fd_set read_fds;

    while (true) {
        FD_ZERO(&read_fds);
        FD_SET(STDIN_FILENO, &read_fds);
        FD_SET(master_fd, &read_fds);

        int ready = select(FD_SETSIZE, &read_fds, NULL, NULL, NULL);
        if (ready == -1) {
            perror("select");
            break;
        }

        // 处理用户输入
        if (FD_ISSET(STDIN_FILENO, &read_fds)) {
            n = read(STDIN_FILENO, buf, sizeof(buf));
            if (n <= 0) break;
            // 转发到伪终端
            write(master_fd, buf, n); 
            // 写入日志
            write(log_fd, buf, n); 
        }

        // 处理伪终端输出
        if (FD_ISSET(master_fd, &read_fds)) {
            n = read(master_fd, buf, sizeof(buf));
            if (n <= 0) break;
            // 显示到屏幕
            write(STDOUT_FILENO, buf, n); 
            // 写入日志
            write(log_fd, buf, n); 
        }
    }

    close(log_fd);
    close(master_fd);
    waitpid(child_pid, NULL, 0);
    return 0;
}

通过 select 同时监控用户输入和伪终端输出,实现输入转发和会话记录。

七、终端属性和窗口大小

(一)属性同步

伪终端的从设备需同步终端属性(如回显、行规范 ),可通过 tcgetattr/tcsetattr 配置:

struct termios term_attr;
// 获取主设备对应的终端属性(实际作用于从设备)
tcgetattr(master_fd, &term_attr); 
term_attr.c_lflag &= ~ECHO; // 禁用回显
tcsetattr(master_fd, TCSANOW, &term_attr);

(二)窗口大小通知

当控制进程的窗口大小变化时,需通过 TIOCSWINSZ ioctl 通知伪终端从设备:

struct winsize ws = {.ws_row = 24, .ws_col = 80};
ioctl(master_fd, TIOCSWINSZ, &ws);

确保被控制进程(如 Shell )能正确调整输出格式。

八、BSD 风格的伪终端

(一)差异与兼容

BSD 风格的伪终端(如 /dev/ptmx/dev/ttyp* )与 UNIX98 伪终端的接口不同

  • BSD 伪终端通过 open("/dev/ptmx", O_RDWR) 创建主设备,无需 posix_openpt 等函数。
  • 需手动创建从设备节点(较复杂 ),而 UNIX98 伪终端自动管理 /dev/pts/ 下的从设备。

现代 Linux 系统优先支持 UNIX98 伪终端,但 BSD 风格的接口仍可用于兼容旧系统。

九、总结

伪终端对是由一对互联的伪终端主设备和从设备组成的。连接在一起后,这两个设备提供了一个双向的IPC通道。伪终端的好处在于,我们可以将一个面向终端的程序连接到从设备端,它可以通过打开了主设备的程序来驱动。伪终端从设备表现得就像一个常规的终端一样。所有可以施加于常规终端上的操作都可以施加于从设备上,而且从主设备到从设备传递的输入,其解释的方式同键盘输入到常规终端的方式一样。

伪终端的一种常见用途是提供网络登录服务的应用。但是,伪终端也可以用在许多其他的程序中,比如终端模拟器以及script(1)程序。

System V和BSD系统提供了不同的伪终端API。Linux对这两种API都提供支持,但是System V的伪终端API成为了SUSv3规范中的标准。

伪终端是模拟终端交互的核心机制,通过主从设备分离,实现控制进程与被控制进程的灵活通信:

  • UNIX98 伪终端:接口简洁,自动管理从设备,是现代 Linux 开发的首选。
  • 应用场景:终端复用、自动化脚本、容器交互等,让程序能像真实终端一样运行命令。
  • 关键技术:掌握 posix_openptgrantptunlockptptsname 等函数,结合 forkexec 实现进程重定向,处理终端属性和 I/O 交互。

理解伪终端的底层逻辑,能帮助你开发终端模拟器、自动化运维工具、容器交互组件等,精准控制进程的输入输出,打造灵活高效的交互环境。


网站公告

今日签到

点亮在社区的每一天
去签到