stdin-stdout
Linux 标准输入输出与文件描述符
本文整理自一次关于 stdin / stdout / stderr、文件描述符、终端设备和 ANSI 转义序列的讨论。
示例以 Linux / macOS 为准,两者的差异会单独标注。
目录
1. 三个标准流
2. 文件描述符 fd
4. fd 的指向由谁控制
5. 重定向与管道
7. 如何查看 fd
10. 用 ANSI 转义序列控制终端
11. 常见问题速查
1. 三个标准流
每个进程启动时,通常已经打开了三条通道:
| fd | 名称 | 默认去向 | 典型用途 |
|---|---|---|---|
| 0 | stdin(标准输入) | 终端键盘 / 管道读端 | 读用户输入、读管道数据 |
| 1 | stdout(标准输出) | 终端屏幕 / 文件 / 管道写端 | 正常输出、console.log |
| 2 | stderr(标准错误) | 同 stdout,但独立一条 | 错误信息、console.error |
fd 2 是存在的,它就是标准错误。错误信息和普通输出走两条不同的通道,所以:
ls /nonexistent > out.txt # 错误仍然显示在屏幕上(走 fd 2)
ls /nonexistent 2> err.txt # 这次错误被收进文件
三个流只是 POSIX 约定,不是内核强制。进程可以关掉它们,也可以把它们指向任何地方。
缓冲差异
- 输出到终端:通常行缓冲(遇到换行刷新)
- 输出到管道 / 文件:通常全缓冲(缓冲区满才刷新)
- stderr:通常无缓冲,保证错误立即出现
这也是为什么程序崩溃时,普通输出可能丢了一部分,而错误信息还在。
2. 文件描述符 fd
fd(file descriptor)是进程内一张“已打开文件表”的下标编号。
进程不直接持有文件本身,只持有一个小整数,内核通过这个整数找到真正的对象:
进程 A 的 fd 表 内核中的打开文件对象(open file description)
┌────┬──────────────────┐
│ 0 │ ─────────────────┼──► 终端 pty(读)
│ 1 │ ─────────────────┼──► 终端 pty(写)
│ 2 │ ─────────────────┼──► 终端 pty(写)
│ 3 │ ─────────────────┼──► /tmp/a.txt
│ 11 │ ─────────────────┼──► socket 连接到 1.2.3.4:80
└────┴──────────────────┘
三个层次要分清
| 层次 | 是否每进程独立 | 说明 |
|---|---|---|
| fd 编号(0、1、2…) | 是 | 只是各自进程表里的下标,两个进程都有 1 并不冲突 |
| fd 表项 / open file description | 继承时共享,独立 open 时各自独立 | 保存文件偏移、读写模式等 |
| 目标(tty / 文件 / 管道 / socket) | 由 fd 指向决定 | 多个 fd 可以指向同一个目标 |
所以 Node 的 fd 1 和 Java 的 fd 1 是两个不同的表项,但通常指向同一个终端设备。关掉自己的 fd 1 不影响对方,改自己的重定向也不影响对方;但两边都往同一个终端写时,输出会交错。
编号分配规则
- fd 号由内核分配,规则是取当前可用的最小号
- 关闭一个 fd 后,这个号可以被后续
open复用 - 上限由
ulimit -n决定(Linux 常见 1024 / 1048576,macOS 默认常常较小)
一个实际例子(Node):
const a = fs.openSync("/tmp/fd-demo.txt", "w"); // 可能是 11
const b = fs.openSync("/tmp/fd-demo.txt", "a"); // 可能是 12
fs.closeSync(a);
const c = fs.openSync("/tmp/fd-demo.txt", "a"); // 又拿到 11
为什么不从 3 开始?因为 Node 运行时自己(libuv、事件循环等)已经占用了若干 fd。fd 复用也是很多“关闭后继续使用旧 fd”类 bug 的来源。
fork / exec 时的继承
fork会复制一份 fd 表,表项仍指向同一个 open file descriptionexec会保留 fd 表(除非该 fd 设置了 close-on-exec)- 所以 shell 启动的
node、java、ls,默认都会继承 shell 的 0/1/2
3. stdout 与命令行的关系
“命令行”和 stdout 是两个层面的东西:
| 角色 | 是什么 | 负责什么 |
|---|---|---|
| 终端模拟器 | iTerm / Terminal / Kitty 等 GUI 程序 | 接收字节并绘制到屏幕 |
| shell | zsh / bash,一个普通进程 | 读你输入的命令、启动子进程、设置管道与重定向 |
| 你的进程 | node app.js、ls、java | 只往 fd 1 写字节,不关心终端长什么样 |
“命令行”是 shell 提供的交互界面:你打的字走 stdin,程序的输出走 stdout。stdout 不是屏幕,屏幕是终端模拟器画的。
默认靠“继承”连在一起
键盘 ──► 终端模拟器 ──► pty master ──► pty slave
│
┌───────────────────────┴──────────────┐
│ zsh fd 0/1/2 ──► pty slave │
│ │ fork + exec(继承 fd 表) │
│ ▼ │
│ node fd 0/1/2 ──► 同一个 pty │
└───────────────────────────────────────┘
│
屏幕 ◄── 终端模拟器 ◄── pty master ◄┘
echo hi(shell 内建)和 node app.js(子进程)的输出都出现在同一个窗口,不是因为它们“认识命令行”,而是因为它们的 fd 1 指向同一个终端设备。
推论
- stdout 重定向到文件后,终端上就什么都看不到
- 每个进程都有自己的 stdout;
ls | wc里,ls 的 stdout 是管道写端,wc 的 stdout 才是终端 console.log写 fd 1,console.error写 fd 2
4. fd 的指向由谁控制
内核只维护表和执行系统调用,不决定 fd 指向哪里。 指向由两方决定:
1. 父进程在 exec 之前(通常是 shell)布好线
2. 进程自己在运行时通过 open / dup2 / close / freopen 修改
启动时:shell 先布好线,再 exec
以 node app.js > app.log 为例:
iTerm 打开 pty 对,把 slave 端接到 shell 的 fd 0/1/2
│
▼
zsh(fd 1 → /dev/ttys003)
│ fork:子进程复制 fd 表
│
│ 子进程里(exec 之前):
│ fd = open("app.log", O_WRONLY | O_CREAT | O_TRUNC)
│ dup2(fd, 1) ← 让 fd 1 指向 app.log
│ close(fd)
▼
exec("node") ← fd 表原样保留
│
▼
node 进程的 fd 1 → app.log
>、|、2>&1 本质上都是 dup2 的语法糖:
2>&1= 让 fd 2 指向 fd 1 当前指向的位置cmd > f 2>&1:先重定向 stdout,再把 stderr 接到 stdout,两者都进文件cmd 2>&1 > f:先把 stderr 接到“原来的终端”,再重定向 stdout,stderr 仍然打屏
运行时:只有进程自己能改
int fd = open("log.txt", O_WRONLY | O_CREAT | O_APPEND, 0644);
dup2(fd, 1); // 从此 printf 都写到 log.txt
close(fd);
- 父进程在 exec 之后不能再改子进程的 fd 表(除非调试器
ptrace之类) - 其他无关进程也不能跨进程修改你的 fd
- Node 核心没有暴露
dup2;JS 层替换process.stdout.write只影响走这个对象的代码,不改真正的 OS fd
Node 中 fd 1 的去向
process.stdout 只是 Node 启动时对 fd 1 的包装:
process.stdout.fd // 1
process.stdout.isTTY // true / undefined
fs.fstatSync(1) // dev / ino / rdev / isCharacterDevice()
不同启动方式下 fd 1 的指向:
| 启动方式 | fd 1 指向 | isTTY |
|---|---|---|
| 在终端直接 node app.js | pty slave | true |
| node app.js > app.log | 普通文件 | undefined |
| node app.js \| less | 管道写端 | undefined |
| IDE 运行面板 / CI | 通常是管道 | undefined |
| Docker(不带 -t) | 容器运行时接的管道 | undefined |
| nohup node app.js & | nohup.out 或 /dev/null | undefined |
| GUI 程序(从 Finder 启动) | /dev/null 或系统日志 | undefined |
Java 的 System.out 同理,是 fd 1 的包装;System.setOut(...) 只改 Java 层的输出对象,不改 OS fd。
5. 重定向与管道
| 语法 | 含义 |
|---|---|
| cmd > file | stdout 覆盖写入文件(O_TRUNC) |
| cmd >> file | stdout 追加写入(O_APPEND) |
| cmd 2> file | stderr 写入文件 |
| cmd 2>&1 | 让 stderr 指向 stdout 当前的目标 |
| cmd < file | stdin 从文件读 |
| cmd1 \| cmd2 | cmd1 的 stdout 接到 cmd2 的 stdin |
| cmd > /dev/null | 丢弃 stdout |
| cmd > /dev/tty | 强制写回当前控制终端,无视重定向 |
| cmd > out 2>&1 | stdout 和 stderr 都进 out |
共享与不共享
- fork 继承:两个进程共享同一个 open file description,文件偏移也共享
- 各自独立
open():即使打开同一个文件,也是两个 open file description,偏移各自独立 >>(O_APPEND):每次写入前定位到文件末尾,多次追加不会互相覆盖;但内容仍可能按块交错
对终端来说文件偏移无所谓,所以平时感知不到;对日志文件就会表现为“互相覆盖”或“各写各的”。
6. 多个进程写同一个 fd 1 会乱吗
会乱,而且这是常态。 平时看起来不乱,只是因为同一时刻通常只有一个前台进程在写。
前提是多个进程的 fd 1 指向同一个终端 / 管道 / 文件;指向不同目标时互不影响。
内核的保证边界
内核只保证到“单次 write() 调用”这一层:
| 目标 | 保证 |
|---|---|
| 管道 / FIFO | 单次 write() ≤ PIPE_BUF 是原子的(Linux 4096,macOS 实测 512);超过可能被拆开插入 |
| 普通文件 | 共享打开文件描述时,内核串行更新写位置;O_APPEND 的追加是原子的 |
| 终端 / pty | 有 tty 锁,小写入通常整块进入缓冲;但 POSIX 不保证终端写入的原子性 |
注意:这是“一次 write 不被打断”,不是“一条逻辑消息不被打断”。不同 write 之间的顺序完全由调度器决定。
交错示例
两个进程各写 4 行,每行一次 os.write:
os.write(1, b"A" * 40 + b"\n")
实际输出可能交替为:
AAAAAAAA...
BBBBBBBB...
AAAAAAAA...
BBBBBBBB...
每行完整,但 A/B 的先后顺序不确定。
什么情况下会花屏
1. ANSI 序列被拦腰截断:\x1b[31m 是多字节、有状态的,若在 \x1b 与 [ 之间插入其它字节,终端解析器状态会错位
2. 两个全屏程序抢同一个 tty:一个进备用屏、另一个移动光标,互相踩
3. 跨多次 write 的进度条 / \r 重绘:必然被插入
4. 独立 open 同一文件且不带 O_APPEND:偏移各自独立,会互相覆盖
如何避免
- 物理隔离:tmux / screen 每个 pane 一个独立 pty
- 重定向:后台进程
cmd > a.log 2>&1 &,别往终端写 - 单写者:多线程 / 多协程输出时加锁,或汇总到一个日志队列,整行一次 write
- TUI 独占终端:全屏程序运行时,任何
console.log、后台任务、shell 的 job 通知都会破坏画面;日志应写文件 - 合成写入:把一帧的控制序列拼成一个字符串、一次
write,缩小被插入的窗口
7. 如何查看 fd
查看当前 shell / 当前进程
ls -l /dev/fd # 列出 0..N
lsof -p $$ -a -d 0-2 # 只看 0/1/2($$ = 当前 shell PID)
macOS 的 /dev/fd/1 会直接显示解析后的类型:
crw-rw-rw- ... /dev/fd/0 # 字符设备 → 终端
-rw-r--r-- ... /dev/fd/1 # 普通文件 → 被重定向
prw-r--r-- ... /dev/fd/2 # FIFO → 管道
Linux 上 /dev/fd 是 /proc/self/fd 的软链,会显示 1 -> /dev/pts/3。
注意:
lsof的多个筛选条件默认是 OR。要“某进程的某个 fd”,必须加-a(AND):
lsof -p。漏掉-a -d 1 -a会变成“该进程的所有文件 + 所有进程的 fd 1”。
查看任意进程
lsof -p <PID> # 该进程打开的所有对象
lsof -p <PID> -a -d 0-2 # 只看标准流
lsof -p <PID> -a -d 1 # 只看 fd 1
sudo lsof -p <PID> # root 进程需要 sudo
lsof -p <PID> -F pftn # 机器可读:pid / fd / type / name
Linux 专属、更直接:
ls -l /proc/<PID>/fd
readlink /proc/<PID>/fd/1
macOS 没有 /proc,且 /dev/fd/N 不是可 readlink 的软链,查别的进程基本就用 lsof。
反查:谁占着某个资源
lsof $(tty) # 谁打开了当前终端
lsof -t /dev/ttys003 # 只输出 PID(Linux 对应 /dev/pts/3)
lsof /path/to/file # 谁打开了这个文件
lsof -i :3000 # 谁占着 3000 端口
lsof -U # 所有 Unix socket
lsof +D /some/dir # 目录下所有被打开的文件
lsof +L1 # 链接数为 0 的已删除文件(仍被进程持有)
读 lsof 输出
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NODE NAME
node 48211 jiayaooli 1u CHR 16,3 0t0 1234 /dev/ttys003
node 48211 jiayaooli 3w REG 1,16 4096 567 /tmp/app.log
bash 48002 jiayaooli 1u PIPE 0x... 0t0 ->0x7f...
- FD 列:
1u= fd 1,u表示可读写,r/w表示只读 / 只写;cwd、txt、mem是伪条目,不是真正的 fd - TYPE 列:
CHR字符设备(终端)、REG普通文件、DIR目录、PIPE管道、IPv4/IPv6网络、unixUnix socket - NAME 列:终端是
/dev/ttys00x,文件是路径,管道是->0x...,socket 是本地->远端 - 管道两端可以通过
lsof | grep 0x7f...找到持有相同 id 的进程
在程序里查
Node:
process.stdout.fd // 1
process.stdin.fd // 0
process.stdout.isTTY // 是否终端
fs.fstatSync(1) // dev / ino / rdev / isCharacterDevice()
fs.readdirSync('/dev/fd') // 自己的 fd 号列表
Python:
import os
os.listdir('/dev/fd') # ['0', '1', '2', ...]
os.fstat(1) # 目标元信息
查不到的常见原因
- 权限不够:加
sudo - fd 已经关闭:表里没有这一项
- 进程已退出:fd 表随进程消失
- macOS SIP:受保护进程即使 root 也可能看不全
8. 终端设备:tty、ttys、pts
ttys001、ttys007 是 macOS 的伪终端(pty)从设备节点,路径是 /dev/ttys001。一个终端会话(终端 App 的一个窗口 / 标签页、一个 tmux pane、一次 ssh 登录)对应一个这样的设备。
名字拆解
ttys001
│ │└── 实例编号:第几个 pty,内核按需分配
│ └── slave(从端)
└── teletype,终端设备的传统叫法
ttys001 和 ttys007 没有功能差异,只是两个不同的 pty 实例。
pty 是一对设备
iTerm2 / Terminal.app
│ 持有主端(通过 /dev/ptmx 分配)
▼
┌─────────────┐
│ pty 设备对 │
└─────────────┘
│
▼
/dev/ttys007 ←── shell 和它启动的所有进程,fd 0/1/2 指向这里
- 键盘输入:终端模拟器写主端 → 内核转给从端 → shell 从 fd 0 读到
- 程序输出:写从端 → 主端 → 终端模拟器收到字节并绘制
编号与生命周期
- 内核分配时取当前可用的最小号
- 会话关闭后号码可被回收复用
- 看到
ttys007不代表现在开着 7 个终端,只说明至少创建过这个实例 - 关闭窗口 → pty 从端销毁;会话首进程会收到
SIGHUP,这是“关终端导致程序被杀”的根源 - 节点属于登录用户,权限通常是
crw--w----,其他普通用户不能读写 - 后台进程读控制终端会收到
SIGTTIN;开启TOSTOP时后台写会收到SIGTTOU
容易混淆的设备名
| 名字 | 是什么 |
|---|---|
| /dev/ttys007(macOS) | pty 从端,一个终端会话 |
| /dev/pts/0(Linux) | Linux 的 pty 从端 |
| /dev/tty | 特殊别名,指向当前进程的控制终端,不固定是哪个 |
| /dev/console | 系统控制台(图形登录界面) |
| /dev/tty1(Linux) | 虚拟控制台(VT),不是 pty |
| /dev/ttyS0(Linux) | 串口,大写 S,和 ttys 无关 |
| /dev/tty.usbserial-xxx(macOS) | 真正的串口设备,名字里带点 |
查终端相关信息的命令
tty # 当前 pty → /dev/ttys007
echo $TTY # zsh 中也可以
ls -l /dev/ttys* # 所有 pty 节点(Linux 用 /dev/pts/*)
lsof /dev/ttys007 # 谁打开了这个 pty
ps -t ttys007 # 挂在这个终端上的进程(Linux 用 ps -t pts/7)
who # 哪些用户登录在哪些 tty 上
w # 更详细的登录会话信息
“当前终端的 PID”到底指什么
终端窗口本身和 pty 设备都没有 PID;有 PID 的是下面三者:
# 1. 当前 shell
echo $$
ps -o pid=,ppid=,comm= -p $$
# 2. 终端模拟器(大多数情况下是 shell 的父进程)
ps -o pid=,comm= -p $PPID
pgrep -x iTerm2
pgrep -x Terminal
pgrep -x WezTerm # 其他:kitty / Alacritty / ghostty / Warp
# 3. 挂在这个 tty 上的所有进程
lsof -t $(tty) | xargs ps -o pid=,ppid=,comm=
# 4. 当前前台命令
pgrep -P $$
如果父进程不是终端 App(VS Code 集成终端、tmux、ssh),需要沿进程树往上找:
p=$$
while (( p > 1 )); do
ps -o pid=,ppid=,comm= -p $p
p=$(ps -o ppid= -p $p | tr -d ' ')
done
在 tmux 中:
echo $TMUX # 有值说明在 tmux 里
tmux display-message -p '#{pid} #{client_pid}' # server PID 和客户端 PID
9. isTTY:判断是否在真终端里
Node 的 process.stdin.isTTY / process.stdout.isTTY:
- 流指向真正的终端设备(tty / pty)时是
true - 被重定向到文件、管道、
/dev/null时是undefined(注意不是false)
常见守卫
if (!stdin.isTTY || !stdout.isTTY) {
console.error('这个程序需要在真正的终端里运行(不能重定向 / 管道)。');
process.exit(1);
}
等价于 !(stdin.isTTY && stdout.isTTY):两个都是 TTY 才放行。
拆解:
!stdin.isTTY= stdin 不是终端!stdout.isTTY= stdout 不是终端||= 任意一个不是终端,就成立
为什么必须两个都查
TUI 依赖两件只有真终端支持的事:
1. stdin.setRawMode(true):逐键读取;管道输入上调用会直接抛错
2. ANSI 转义序列:备用屏、隐藏光标、鼠标事件;重定向到文件时写进去全是乱码
只查 stdout 不够:echo hi | node app.js 时 stdout 仍是终端,但 stdin 是管道,raw mode 会挂。
其他语言 / 工具里的等价写法
isatty(1) // C
[ -t 1 ] # shell:stdout 是否终端
tty -s # 静默判断
sys.stdout.isatty()
常见陷阱
- 不要写
=== false:非 TTY 时是undefined,undefined === false为假,守卫会失效 - 正向写法:
const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY)
10. 用 ANSI 转义序列控制终端
ANSI 控制终端靠的是“往终端设备写入特殊字节”,终端模拟器收到后自己执行动作。 shell 不“执行”这些指令,只是把字节写到 stdout。
例如 \x1b[?1049h 是 6 个字节:
1b 5b 3f 31 30 34 39 68
ESC [ ? 1 0 4 9 h
终端模拟器看到后切换到备用屏。
为什么裸写会 bad pattern
在 zsh 里直接输入:
\x1b[?1049h
会报 zsh: bad pattern: x1b[?1049h。这不是终端的问题,而是 shell 在解析阶段就失败了:
1. \x1b 的反斜杠被 shell 的词法分析吃掉,得到词 x1b[?1049h
2. zsh 把裸词当 glob 模式,[...] 是字符类,而 [ 没有闭合 → bad pattern
3. 即使加引号,它仍是一个命令名:zsh: command not found: ...
要在命令行“输出”,必须有程序把字节写到 stdout。
正确写法
printf '\x1b[?1049h' # 最推荐,zsh / bash 通用,不加换行
printf '\033[?1049h' # 八进制写法,更 POSIX
echo -e '\x1b[?1049h' # bash 需要 -e
echo '\x1b[?1049h' # zsh 内置 echo 默认就解释(会多一个换行)
print -n -- $'\x1b[?1049h' # zsh 惯用法
printf '%s' $'\x1b[?1049h' # 通用:$'...' 先产生真实 ESC 字节
实测差异:
| 命令 | 实际写出的字节 |
|---|---|
| zsh -c 'echo "\x1b[?1049h"' | 1b 5b 3f 31 30 34 39 68 0a(有 ESC,多了换行) |
| zsh -c 'printf "\x1b[?1049h"' | 1b 5b 3f 31 30 34 39 68(干净) |
| bash -c 'echo "\x1b[?1049h"' | 字面量 \x1b[?1049h(bash echo 不解释) |
echo 会补 \n;进备用屏后多这一个换行,光标位置就和预期差一行。TUI 必须无换行,所以用 printf 或 -n。
必须写到终端
printf '\x1b[?1049h' > /dev/tty # 强制写回当前终端,无视重定向
如果 stdout 被重定向到文件或管道,字节只是进了文件,终端不会理你。
检查实际字节
printf '\x1b[?1049h' | xxd # 1b5b3f3130343968
printf '\x1b[?1049h' | cat -v # ^[[?1049h
常用序列
| 序列 | 作用 |
|---|---|
| \x1b[?1049h / \x1b[?1049l | 进入 / 离开备用屏 |
| \x1b[?25l / \x1b[?25h | 隐藏 / 显示光标 |
| \x1b[2J + \x1b[H | 清屏 + 光标回左上 |
| \x1b[ | 光标定位到第 r 行第 c 列 |
| \x1b[?1000h / \x1b[?1006h | 开启鼠标事件 / SGR 鼠标编码 |
| \x1b[?2004h / \x1b[?2004l | 开启 / 关闭括号粘贴 |
用 tput 抽象
不想记转义码,可以用 terminfo:
tput smcup # 进入备用屏
tput rmcup # 离开备用屏
tput civis # 隐藏光标
tput cnorm # 显示光标
clear、less、vim 内部都是同一套写法。
在 TUI 中使用
stdout.write(T.altOn + T.bracketedOn + T.mouseOn + T.hide);
把一串控制序列拼成一个字符串、一次 write,可以减少被其他进程插入的机会。退出时按相反顺序恢复:
stdout.write(T.bracketedOff + T.mouseOff + T.show + T.altOff);
顺序很重要:先关粘贴 / 鼠标 / 光标,最后才离开备用屏,否则恢复过程会显示在错误的屏幕上。
11. 常见问题速查
| 问题 | 答案 |
|---|---|
| stdout 是什么? | 标准输出,文件描述符 1 |
| 有 fd 2 吗? | 有,stderr,标准错误 |
| fd 1 和 fd 2 会互相覆盖吗? | 不会,两条独立通道,除非显式 2>&1 |
| Node 的 fd 1 和 Java 的 fd 1 冲突吗? | fd 表互相独立;但目标可能都是同一个终端,所以输出会交错 |
| 谁决定 fd 指向哪里? | 父进程在 exec 前布好线,进程自己在运行时用 open / dup2 / close 修改 |
| process.stdout.fd 是多少? | 1 |
| 为什么 isTTY 是 undefined? | fd 指向的不是终端(重定向、管道、IDE、CI、Docker 等) |
| 多个进程写同一个终端会乱吗? | 会;内核只保证单次 write() 的原子性,不保证跨进程顺序 |
| PIPE_BUF 是多少? | Linux 4096,macOS 512(本机实测) |
| 怎么查 fd 指向? | lsof -p ;Linux 也可以 readlink /proc/ |
| 怎么查当前终端是哪个设备? | tty |
| ttys001 是什么? | macOS 的 pty 从端设备节点;Linux 对应 /dev/pts/N |
| 终端窗口有 PID 吗? | 窗口和 pty 没有;终端 App、shell、进程各有 PID |
| 怎么查终端 App 的 PID? | ps -o pid=,comm= -p $PPID,或 pgrep -x iTerm2 |
| echo "\x1b[?1049h" 为什么执行了指令? | echo 把 ESC 字节写给了终端,终端模拟器解析并执行 |
| 裸写 \x1b[?1049h 为什么报 bad pattern? | shell 先做词法分析和 glob,[ 未闭合,根本没把字节写给终端 |
| 怎么可靠输出 ANSI 序列? | printf '\x1b[...';必要时 > /dev/tty |
| TUI 为什么要独占终端? | 多个进程写同一个 tty 会破坏光标、备用屏和转义序列状态 |
平台差异小结
| 主题 | Linux | macOS |
|---|---|---|
| 查看其他进程的 fd | /proc/ + readlink | lsof -p |
| /dev/fd | 指向 /proc/self/fd 的软链 | 特殊节点,ls -l 直接显示目标类型 |
| pty 从端命名 | /dev/pts/N | /dev/ttysNNN |
| 虚拟控制台 | /dev/tty1 等 | 通常没有 Linux 风格的 VT |
| 串口 | /dev/ttyS0(大写 S) | /dev/tty.usbserial-* |
| PIPE_BUF | 4096 | 512 |
| ps -t 参数 | pts/7 | ttys007 |
| echo 的转义行为 | bash 默认不解释,需要 -e | zsh 内置 echo 默认解释 |