如何在自制C++ Shell中实现Linux的script命令
很棒的需求!要在自制C++ Shell里实现Linux script命令的核心功能,本质上是要搭建一个伪终端(PTY)中间层,同时捕获用户输入和命令执行的输出,还要让被执行的命令以为自己在和真实终端交互。下面是具体的实现思路和关键代码片段,帮你一步步搞定:
核心原理先理清
script命令的工作逻辑其实很清晰:
它创建一个伪终端(PTY),让目标Shell/程序把PTY当作自己的标准输入输出;同时
script自身作为中间代理,一边把用户的输入转发给PTY,一边把PTY返回的输出回显给用户,并且全程把双向数据流写入日志文件。
分步实现指南
1. 创建并初始化伪终端(PTY)
首先要调用POSIX标准的PTY相关函数,创建主从设备对:
#include <fcntl.h> #include <stdlib.h> #include <unistd.h> #include <sys/ioctl.h> #include <iostream> int main() { // 打开PTY主设备 int master_fd = posix_openpt(O_RDWR); if (master_fd == -1) { perror("Failed to open PTY master"); exit(EXIT_FAILURE); } // 设置PTY从设备的权限 if (grantpt(master_fd) == -1) { perror("Failed to grant PTY access"); close(master_fd); exit(EXIT_FAILURE); } // 解锁PTY从设备,允许打开 if (unlockpt(master_fd) == -1) { perror("Failed to unlock PTY"); close(master_fd); exit(EXIT_FAILURE); } // 获取PTY从设备的路径(比如 /dev/pts/2) const char* slave_path = ptsname(master_fd); if (slave_path == nullptr) { perror("Failed to get PTY slave path"); close(master_fd); exit(EXIT_FAILURE); } // ... 后续代码 }
2. Fork子进程并绑定到PTY从设备
接下来要创建子进程,让它把标准输入/输出/错误重定向到PTY从设备,然后启动目标Shell:
pid_t pid = fork(); if (pid == -1) { perror("Fork failed"); close(master_fd); exit(EXIT_FAILURE); } if (pid == 0) { // 子进程逻辑 // 创建新会话,让子进程成为会话组长,PTY成为控制终端 setsid(); // 打开PTY从设备 int slave_fd = open(slave_path, O_RDWR); if (slave_fd == -1) { perror("Failed to open PTY slave"); exit(EXIT_FAILURE); } // 重定向标准IO到PTY从设备 dup2(slave_fd, STDIN_FILENO); dup2(slave_fd, STDOUT_FILENO); dup2(slave_fd, STDERR_FILENO); // 关闭不需要的文件描述符 close(slave_fd); close(master_fd); // 启动默认Shell(优先用环境变量SHELL,否则用bash) char* shell = getenv("SHELL"); if (shell == nullptr) shell = "/bin/bash"; execl(shell, shell, nullptr); // 如果execl失败,输出错误 perror("Failed to execute shell"); exit(EXIT_FAILURE); } // 父进程保留master_fd用于和子进程通信 // ...
3. 父进程监听双向数据流并记录日志
父进程需要同时处理两个数据源:用户的终端输入,以及PTY主设备返回的子进程输出。这里用select()实现多路复用,确保不会阻塞在单一输入上:
#include <sys/select.h> #include <fstream> #include <cstring> #include <sys/wait.h> #include <termios.h> // 保存原始终端属性,退出前恢复 struct termios original_attr; tcgetattr(STDIN_FILENO, &original_attr); atexit([&]() { tcsetattr(STDIN_FILENO, TCSANOW, &original_attr); }); // 设置终端为原始模式,捕获所有输入字符(包括特殊控制符) struct termios raw_attr = original_attr; cfmakeraw(&raw_attr); tcsetattr(STDIN_FILENO, TCSANOW, &raw_attr); // 打开日志文件(默认名为typescript,和标准script一致) std::ofstream log_file("typescript", std::ios::binary); if (!log_file.is_open()) { perror("Failed to open log file"); close(master_fd); kill(pid, SIGTERM); exit(EXIT_FAILURE); } // 写入日志开头信息(模仿标准script) time_t now = time(nullptr); log_file << "Script started on " << ctime(&now); fd_set read_fds; char buffer[1024]; ssize_t bytes_read; while (true) { FD_ZERO(&read_fds); FD_SET(STDIN_FILENO, &read_fds); FD_SET(master_fd, &read_fds); int max_fd = std::max(STDIN_FILENO, master_fd) + 1; // 等待有数据可读 int ret = select(max_fd, &read_fds, nullptr, nullptr, nullptr); if (ret == -1) { perror("Select failed"); break; } // 处理用户输入:转发给PTY,同时写入日志 if (FD_ISSET(STDIN_FILENO, &read_fds)) { bytes_read = read(STDIN_FILENO, buffer, sizeof(buffer)); if (bytes_read <= 0) break; // 用户输入结束(比如Ctrl+D) write(master_fd, buffer, bytes_read); log_file.write(buffer, bytes_read); log_file.flush(); } // 处理PTY输出:回显给用户,同时写入日志 if (FD_ISSET(master_fd, &read_fds)) { bytes_read = read(master_fd, buffer, sizeof(buffer)); if (bytes_read <= 0) break; // 子进程退出 write(STDOUT_FILENO, buffer, bytes_read); log_file.write(buffer, bytes_read); log_file.flush(); } } // 写入日志结尾信息 now = time(nullptr); log_file << "\nScript done on " << ctime(&now); // 清理资源 log_file.close(); close(master_fd); waitpid(pid, nullptr, 0); return 0; }
关键注意事项
- 信号处理:比如
SIGINT(Ctrl+C)这类信号会发给父进程,你可以设置信号处理函数,把信号转发给子进程,避免父进程意外退出。 - 终端属性恢复:一定要在退出前恢复原始终端属性,否则终端可能会出现输入不回显等异常。
- 错误处理:每个系统调用都要检查返回值,比如
read()、write()可能因为各种原因失败,要及时清理资源并退出。 - 日志格式:标准
script会包含时间戳和会话信息,你可以根据需求调整日志的开头和结尾内容。
内容的提问来源于stack exchange,提问作者Aayush Upadhyaya




