这一次要证明
- 键盘输入能够到达远端 shell;
- 远端输出能够完整返回;
- Ctrl-C 和窗口大小仍有终端语义;
- 断开后 shell 能被回收,server 还能接受下一次连接。
先固定一句话
tline v0 验证的是:已有三层网络可达时,一台机器能否通过 TCP 获得另一台机器上的交互式终端。relay 负责网络可达,tline 负责终端字节与进程生命周期,两者暂时不揉成一个系统。
最小场景里,WSL 是操作端,138 和 51 都可以各自启动一个 tline server。操作端直接连接目标,不要求 138 再替 51 转发终端会话。
B 和 C 是两个可独立连接的目标,不是必须串起来的两级 server。
当前 PoC 由目标机器监听 TCP;这只是网络发起方向,不是 PTY 模型的限制。PTY master / slave 和 bash 仍然位于设备本地,master 左侧既可以接入站连接,也可以由设备 agent 主动建立出站连接。
这种 reverse-connect 形态不要求先有虚拟网络。操作端和设备都可以通过普通可出站网络连接中心,由应用层根据设备身份和 session 直接转发终端帧;只有需要为任意应用提供透明 IP 可达性时,虚拟网卡、地址分配、路由和二层转发才重新变得必要。
操作端可以复用当前 do_connect 的终端侧能力;设备端需要的是 outbound dial / register 加上当前 handle_client、forkpty() 和 bridge 的终端侧能力,不是再运行一份 do_connect。
这条路的第一层边界:设备身份、操作人授权、显式配对、传输加密、会话过期与吊销、审计和资源限制必须先进入设计。若一条长期连接同时承载控制与多个调试会话,还需要明确的 session id、多路复用和断线恢复语义。
PoC 的停止条件:主路径跑通,生命周期问题被识别并留下验证方法,然后冻结。它不是靠继续增加功能来证明价值。
“远程终端”不是把一个 shell 对象穿过网络。真正穿过网络的是字节和少量控制消息;终端语义由两端的 tty、PTY 和进程组共同恢复。
client 在逻辑上操纵 master,但真正的 master fd 由 server 进程持有,无法穿过 TCP 交给另一台机器。
网络通只解决“字节能不能过去”。交互式体验还要求控制字符、窗口尺寸、退出顺序和异常断开都能沿着同一条路径得到处理。
普通 pipe 可以连接 stdin/stdout,却不会自动给程序一个“我正在终端里运行”的环境。PTY 是一对虚拟终端设备:应用一侧操作 master,shell 一侧把 slave 当成真正终端。
| 对象 | 主要用途 | 有没有终端语义 | 在 tline 中的位置 |
|---|---|---|---|
| pipe | 进程间单向字节流 | 没有 controlling tty、窗口和前台进程组 | 不足以单独支撑完整交互式 shell |
| PTY | 在内核里模拟一端终端 | 有,slave 可以表现成 tty | 当前 PoC 的核心适配层 |
| UART / 串口 | 与真实设备交换串行字节 | 取决于远端设备协议,不自动等于 Unix shell | 未来可替换 PTY 一侧的另一种 transport |
forkpty() 可以理解为一组动作的封装:创建 PTY、fork、让子进程接管 slave,并把父进程留在 master 一侧。它方便,但没有取消 session、controlling tty 和子进程回收,只是把一部分步骤包起来了。
一次 send() 不对应对端一次 recv()。TCP 只承诺在连接存活且没有错误时,按顺序交付字节。因此 tline 需要自己的长度和类型字段。
携带键盘输入或 PTY 输出。内容可以是任意字节,不依赖换行分隔。
携带 rows / cols,server 再用 TIOCSWINSZ 更新 PTY。
表示远端会话结束。应当在残留 DATA 排空以后发送。
可靠不等于永不阻塞。对端停止读取时,TCP 发送缓冲区最终会填满,阻塞式写入会卡住当前事件循环。问题通常是双向活性和反压,不应含混地称为“TCP 丢帧”。
如果未来需要同时承受高速输出和控制消息,才需要非阻塞 I/O、独立发送队列、高水位线和明确的丢弃策略。v0 可以先记录这个边界,不必为了想象中的吞吐提前造完整流控层。
进程生命周期不能只靠信号处理器“顺手收一下”。先画出所有权,退出逻辑才有确定位置。
归属原则:server 只回收自己的连接子进程;连接处理进程只回收自己创建的 shell。谁拿到了 PID,谁就要知道何时 waitpid(),不能依赖另一个层级猜测。
父进程退出并不自动保证所有后代都按预期结束。socket 断开以后,连接处理进程还需要关闭 PTY、向正确的进程或进程组发信号,并等待 shell,避免孤儿进程、僵尸进程或 PID 复用后的误杀。
Unix 终端控制不是“一个进程对应一个终端”,而是围绕 session、process group 和 controlling terminal 组织的。
APUE 把这些知识分散在进程关系、信号、daemon、Terminal I/O 和 PTY 几章里。它们看起来像同一张图,实际回答的是三个不同问题;先分开,后面才不会把父子关系、文件描述符和作业控制混成一件事。
回答字节经过哪里,以及谁持有 PTY master。
tline client
↕ TCP frames
connection handler
↕ PTY master
kernel PTY
↕ PTY slave
bash / foreground job
回答谁创建谁,以及退出后谁负责等待和回收。
tline serve
└── connection handler
└── bash
├── sleep
└── yes | head
session
├── controlling terminal: /dev/pts/N
│ └── foreground PGID: 当前前台 job
├── process group: bash
├── foreground process group: yes | head
└── background process group: some-job &
同一个 tty 模型:/dev/ttyS0 由 UART 硬件提供输入,/dev/tty1 由虚拟控制台提供输入,/dev/pts/N 由 PTY master 提供输入;进入 line discipline 以后,shell 看到的仍是终端接口。tline 改变的是终端左侧的数据来源,没有取消右侧的 session、进程组和作业控制。
当前只把这三张图作为阅读锚点,不表示已经掌握 APUE 中的全部信号和作业控制细节。以后若启动内核中的进程、调度与终端实现研究,再沿着这组接口进入内核侧。
setsid() 具体做什么setsid() 不是“转后台”的同义词。它重建的是终端和作业控制的归属关系。调用者如果已经是 process group leader,调用会失败,因此常见模式是先 fork(),让子进程再调用 setsid()。
它想脱离启动它的终端和 session。经典 double fork 的第二次 fork 会让最终进程不再是 session leader,从而避免它日后意外重新取得 controlling terminal。
方向恰好相反:它要主动建立一个新 session,再把 PTY slave 设成自己的 controlling terminal,让 bash 的作业控制能够工作。
daemon:
fork → child setsid → optional second fork → no controlling terminal
interactive PTY child:
fork → child setsid → PTY slave becomes controlling tty → exec bash
所以同一个 setsid() 会同时出现在 daemon 和终端程序里。它们不是目的相同,而是都要重新安排“这个进程属于哪个会话、受哪个终端控制”。
子进程退出、停止或继续时,内核可以向父进程发送 SIGCHLD。退出状态会一直保留,直到父进程调用 wait() 或 waitpid();只收到信号并不等于已经完成回收。
POSIX 使用的正式名称。新代码应使用它,并明确是否关心 stop / continue 事件。
来自早期 System V 的历史名字。现代 Linux / glibc 通常把它定义成 SIGCHLD 的别名,但旧系统曾有不同语义,不能据此写可移植代码。
你记得它们“好像不完全一样”并不是错觉:历史接口确实存在差异。对今天的 Linux 程序,实践结论很简单:写 SIGCHLD,不要依赖旧式 SIGCLD 行为。
传统 Unix signal 不是可靠的事件队列。多个子进程几乎同时退出时,信号可能合并成一次通知,因此一次 handler 需要把所有已经结束的直接子进程收干净。
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
/* record or discard this direct child's exit status */
}
| 动作 | signal disposition 会怎样 | tline 中的对应处理 |
|---|---|---|
fork() |
子进程复制父进程的默认、忽略和自定义 handler 状态。 | 连接子进程会继承 server 的 SIGCHLD handler 和 SIGPIPE 忽略状态。 |
exec() |
自定义 handler 通常恢复默认;被忽略的信号仍保持忽略。 | 因此不能指望 execl("/bin/bash", ...) 自动恢复被 server 忽略的 SIGPIPE。 |
| 重新划边界 | 进入新角色前,主动恢复不应继续继承的处置。 | 连接进程恢复 SIGCHLD 默认处置;bash child 在 exec 前恢复 SIGPIPE 默认处置。 |
fork() 后,子进程会继承父进程的 signal disposition。若 server 主进程安装了一个“回收所有子进程”的 SIGCHLD handler,连接处理子进程也会继承它;它随后创建自己的 bash 时,继承来的 handler 可能先把 bash 回收,打乱连接层显式的 waitpid(shell_pid, ...)。
清晰的做法是:连接子进程在创建 PTY shell 前,把 SIGCHLD 恢复成默认处置;随后由连接层按已知 PID 显式等待。bash child 还要在 exec 前把 server 为 socket 写入而忽略的 SIGPIPE 恢复成默认处置,避免服务端策略污染用户程序。这里修复的不是两个信号技巧,而是进程角色和所有权。
本地终端进入 raw mode 后,按下 Ctrl-C 通常只是产生字节 0x03。tline 把这个字节送到远端 PTY master,真正解释它的是远端 PTY slave 的 line discipline。
这也解释了为什么 `sleep 100` 被中断后连接还能继续:终端把信号发给当前前台 job,而不是杀掉整个 session 或网络连接。
外部发送给 tline client 的 SIGINT、SIGTERM 或 SIGHUP 是另一条路径。signal handler 不应直接做复杂的 tcsetattr() 清理;更稳的模型是 handler 只设置 sig_atomic_t 标志或唤醒主循环,再由普通控制流恢复本地终端。
远程 shell 最容易展示的是“连上了”;更能决定它是否可靠的,是快速退出、半关闭、积压输出、信号竞态和 daemon 启动失败。
| 检查点 | 容易出现的误判 | 更准确的处理原则 |
|---|---|---|
POLLIN | POLLHUP |
看见 HUP 就立即退出 | HUP 只表示不会再有新数据;已有缓冲数据仍要持续读到 EOF / EIO。 |
| PTY 快速输出后退出 | 一次 4 KiB read 后发送 EXIT | 先排空 PTY,再按 DATA 后 EXIT 的顺序结束。 |
| 本地 raw mode | 先改终端,再安装清理路径 | 先保存状态并建立退出路径,最后进入 raw;所有返回都经过统一 cleanup。 |
| daemon 启动 | 父进程先退出 0,就算启动成功 | bind/listen 应先成功,或用 readiness pipe 把子进程结果回报给父进程。 |
| 阻塞式 socket 写入 | 认为大量输出会“丢 TCP 帧” | TCP 不会保留应用帧边界;真正风险是事件循环阻塞、反向控制无法推进。 |
| 强制断开客户端 | socket 关闭就等于 shell 消失 | 连接层还要关闭 PTY、终止正确的进程组并显式 wait。 |
初版报告记录了约 765 行 C、严格编译参数下零 warning、约 50 KiB 可执行文件,并跑通登录、命令执行、Ctrl-C、窗口变化、正常退出、强制断开和连续连接。后续独立复核还跑过连续两次连接、20,000 行输出以及 8 KiB 快速输出。
复核随后暴露了 signal handler 继承、PTY HUP 排空、终端恢复和 daemon bind/listen readiness 四类问题。当前单文件版本已分别加入显式子进程回收、HUP 后数据排空、flag-only signal handler、统一终端恢复和 daemon 前置 bind/listen;bash child 还会在 exec 前恢复 SIGPIPE 默认处置。构建保持零 warning,针对快速输出、外部信号、端口占用、daemon 连接和重复连接的测试均已跑通。
这证明的是一个边界清楚、可以真实使用的远程 PTY PoC,不是安全远程运维产品。并发上限、应用层流控、完整进程树回收、认证、加密、授权和审计仍明确留在边界之外。
证据分层:“命令能执行”是功能证据;“快速输出没有截断”是边界证据;“读懂谁回收谁、为什么不会竞态”才开始进入代码主权。
stty size 一致;能启动远端 bash 的服务,本质上已经暴露了远程代码执行能力。没有认证和加密时,它只能运行在明确隔离、参与者可信、生命周期很短的实验环境中。
“先加一个明文 token”通常只会制造一种已经安全的错觉。若安全设计尚未开始,诚实地限制实验环境,比增加一个无法轮换、吊销和审计的临时口令更清楚。
tline 这个名字最终可以靠近串口,但当前 PoC 的远端对象是 PTY 和 bash。真实 UART 不是把设备路径换一下就自动得到同一种语义。
TCP ↔ frame ↔ PTY master ↔ PTY slave ↔ bash
关心 session、前台进程组、窗口、控制字符和 shell 回收。
TCP ↔ frame ↔ /dev/tty* ↔ UART ↔ board
关心波特率、数据位、校验、设备锁、断线重连和二进制透明。
两条路径可以复用 framing、网络传输和一部分终端 UI,却不应假装拥有相同的会话模型。板子是否提供 shell、是否支持 resize、Ctrl-C 是普通字节还是特殊命令,都由目标协议决定。
继续的合理顺序不是立刻接板子,而是先读懂并收住当前 PTY PoC。只有真实串口痛点再次出现时,再单独定义 UART transport 的验收边界。
| 问题 | 当前答案 |
|---|---|
| tline 在证明什么? | 已有网络可达时,用 TCP + PTY 建立一个可交互、可退出的远端 bash 主路径。 |
| 为什么不能只用 pipe? | 交互式程序需要 controlling tty、窗口大小、line discipline 和前台进程组。 |
setsid() 是什么? |
创建新 session 和 process group,并脱离旧 controlling terminal;daemon 用它脱离,PTY child 用它重新建立终端归属。 |
| SIGCHLD 和 SIGCLD 呢? | 现代 POSIX 代码用 SIGCHLD;SIGCLD 是历史名字,在 Linux 通常只是别名,旧系统语义曾不同。 |
| Ctrl-C 怎样工作? | 本地传 0x03,远端 PTY line discipline 把 SIGINT 发给前台进程组。 |
| 当前最重要的代码边界? | 进程回收、PTY 数据排空、终端恢复、daemon readiness 与阻塞反压。 |
| 为什么还不是产品? | 没有身份、认证、加密、授权、审计、资源限制和完整恢复机制。 |
| 下一步是什么? | 先阅读当前实现和针对性测试;不因“以后可以接串口”立即扩项目。 |
这次真正打通的知识链是:TCP 负责传字节,协议负责分帧,PTY 恢复终端语义,session 和进程组决定信号去向,进程所有权决定谁负责回收。