关于 macOS 伪终端(PTY)的学习

2020年4月9日 · 4 years ago

关于 macOS 伪终端(PTY)的学习

1973 年 Xerox PARC 第一次在 Xerox Alto 这款个人计算机上推出带有 GUI 界面的操作系统,自此让极大地降低个人计算机的使用门槛,也开启了更加丰富多彩的计算机发展。

不过作为一个码农,终端依然是平日不可或缺的生产力工具。在 macOS 上,系统自带的 Termianl.app 或者更加好用的开源的 iTerm2.app 是最受欢迎的终端应用(其他 X Windows 系统也有像 xterm 之类的优秀应用)。他们也都是一个 Cocoa App。那么一个 Cocoa App 是如何把自己变成一个能跟用户通过键盘交互,有标准输入输出的“伪终端”(Pseudoterminal)的呢?

〇、历史上的终端 Terminal

在带有电子显示器的终端发明以前,人们真的就是在一台带键盘的打印机上,一边打字输入,一边等待计算机在纸上打印输出。所以大家写 Hello World! 的时候都是用 print("Hello World!"),因为它是真地在打印。

第一台带有显示器,支持 ANSI escape codes 的终端是 DEC 公司生产的 VT100。在这之前他们已经生产过很多种型号的电子终端,不过这台机器是最成功的。

一、Cocoa App 如何调用系统自带的 Binaries

我们知道 ls 这个命令在 Unix 系统里就是一个 binary,一般放在 /bin 或者 /usr/bin 这样的目录里,用 whereis 可以找到它在哪里。

whereis ls

ObjectiveC 在 Foundation 里提供了 NSTask 这样的高级封装,用它的接口可以非常简单地实现类似 shell command 的效果。

但是首先一个沙盒 App 的能力是有限的,其次就算是沙盒外的 App,NSTask 也不允许直接访问 /usr/bin 目录里的 binaries,直接调用要嘛无响应要嘛直接 crash。

所以我们还得迂回一下,我们不直接运行 binaries,而是利用 bash 来运行:

NSTask *task = [[NSTask alloc] init];
[task setLaunchPath:@"/bin/bash"];
[task setArguments:@[ @"-c", @"/usr/bin/killall Dock" ]];
[task launch];

但是即便如此,想要使用 NSTask 的接口来模拟终端还是非常困难的事情。所以,Termianl Apps 们是怎么实现的呢?

二、iTerm2

iTerm2 的代码是开源的,历史原因内部实现比较复杂,而且 iTerm2 支持在 Cocoa App 里直接和 python 脚本交互,相当于他提供了一套桥接的接口,可以用 python 来实现对 iTerm2 App 的自动化,类似 Hammerspoon 这类 App 的效果。所以阅读过程中我还看到一堆 client/server 的通信,有点绕。

最后我发现真正实现终端功能的地方在这里: iTermPosixTTYReplacements.c,关键函数是:

int openpty(int *amaster, int *aslave, char *name, struct termios *termp, struct    winsize *winp);

这个函数的实现在 Libc 里,可以参考苹果开源页面

openpty() 是 BSD 函数,并不在 POSIX 标准里,不过 Linux 也有把这个函数 port 过去。从应用层的角度来看,openpty() 会跟 open("/dev/ptmx") 获取一个可用的 pseudoterminal。iTerm2 的做法就是通过该函数获得一个 pseudoterminal master 和 slave 的 fd 句柄,后续用户在 UI 界面上的输入都通过这两个句柄来交互。

iTerm 在 openpty() 之后还 fork() 了一下自己,然后父进程释放所有的句柄,这样父进程处理 UI 输入,一个窗口对应一个子进程,一个子进程对应一个 pty

为什么 Unix 要这么设计 pty 接口呢?历史原因。

早期的计算机比如 1970 年 DEC 生产的 PDP-11,他需要通过一系列的电线跟用户的终端(也就是键盘和打印机)连接到一起。这种只有键盘和打印机的终端也叫做 TTY。后来有了电子显示器之后,就得使用软件模拟一个硬件终端,也叫做"伪终端"(pseudoterminal)。

UNIX 采用的设计是加入了一个中间层,当你使用 openpty() 打开一个伪终端的时候,会给你一个 master 一个 slave 句柄。GUI 软件把键盘输入作为 master 的 input 写入,master 的 output 就会作为 slave 的 input 写入,然后再作为 output 输出。所以对于我们的 Cocoa App 应用层来说,可以简单地把 master fd 作为 writer,把 slave fd 作为 reader。

听起来好像没什么必要但是其实 slave 做了一些特殊的处理。比如 GUI 直接把键盘输入的 CTRL+C(0x03) 写入 master 句柄。这时候 slave 接收到后会把 0x03 转换成 SIGINT signal 发出。对此感兴趣的同学可以参考微软关于 ConPTY 的这篇文章

所以 iTerm2 既是一个 Cocoa App 又是一个“终端模拟器”,你可以在这个 App 里跑任意 shell 命令。

三、其他应用

openpty() 这种 master/slave fd 的设计还体现在 SSH 远程登录上。可以参考 macOS 的 OpenSSH 源码。客户端通过 SSH 协议连上服务端时,服务端的 sshd 进程开了一个 pty 用来跑客户端输入的命令。

另外 VSCode 也基于 Node.js 实现了一个编辑器内的 console,源码在这里

回到我们的 Cocoa App 来,一个 NSTask 对象在被 launch() 之前我们可以当做是一个数据存储的结构体来对待。通常我们会直接调用它的 launch() 方法,然后使用 NSPipe 来读写。

这里如果要绕过上文所述的 crash 问题,我们可以改用 openpty():

NSCAssert(openpty(&masterFD, &slaveFD, NULL, NULL, NULL) == 0,
                  @"A pseudoterminal couldn't be opened.");
*readHandle = [[NSFileHandle alloc] initWithFileDescriptor:masterFD closeOnDealloc:YES];
*writeHandle = [[NSFileHandle alloc] initWithFileDescriptor:slaveFD closeOnDealloc:YES];

有兴趣的读者朋友不妨一试。

参考资料