How Does eza Know I'm in the Terminal

eza 是命令行程序 ls 的现代替代品,提供了更多功能和更好的显示效果。我在 Fish 配置中把 ls 别名到 eza,就这样用了两年,印象里它从来没有破坏过脚本执行——这很可疑

alias ls="eza --icons auto"
alias ll="eza --icons --long --git"
alias la="eza --icons --long --git --all"
alias lt="eza --icons --tree"
alias l.="eza --icons --grid --all --no-filesize --no-user --no-time | rg '^\.'"
alias lg="eza --icons --git --git-repos --long --header"

在这篇文章里,我会尝试只靠阅读源码和注释文档,不用搜索引擎和 AI 检索答案,尽可能钻入底层实现,直到兔子洞见底。

您可以想象这是潜水,希望您没有深海恐惧症。(认真的,接下来会出现不少海洋生物照片)

为了让潜水更有趣,我决定增加一些限制:阅读源码时不使用 LSP只用字符串过滤11.指 grep 命令和编辑器查找文本功能,具体些就是 ripgrep 和 Neovim 的 Pattern定位代码位置。

grep is all you need.

eza 似乎知道我在终端盯着它

june22-2-shari.webp
图1  0 米, 海平面。夕阳被海风打碎,熟悉的事物早已消失在海天相接处,所有深潜故事的原点。 Beautiful Sunset on Okeanos Explorer (NOAA Ocean Exploration)

当执行 lsls -la 时,我的 eza 别名命令会给出这样的结果:

eza_1.png
图2  eza --icons auto src/
eza_2.png
图3  eza --icons auto --long --git --all src/ tests/

颜色高亮、文件类型图标、Git 状态一应俱全,非常好看。

但有时候我想要把 ls 的结果复制到别处,比如在博客文章中展示“文件列表”。这时如果从终端直接复制粘贴,就会带上多余的图标和样式。

所以我通常的歪招是:

ast.rs
codegen.rs
hir.rs
lexer.rs
lib.rs
main.rs
parser.rs
scope.rs
span.rs
type_checker.rs
typedef.rs
types.rs

看出哪里不对了么22.不对其实是对的,我本就期待这样的结果,只是为什么会这样期待呢?这不对。管道到 cat 之后那些文件图标没有了

所以说,eza 能检测自己接上了管道?这听起来不像是管道两端的程序能做到的事,它理应只知道自己在向 stdout 写数据而已……

shimejisim_12.webp
图4  真是少见多怪,ls 不也一样么 《蘑菇拟态日常》贴纸包

的确,ls 也一样,不带参数时逐列显示,传递到管道就变成逐行显示,输出完全换了种格式:

ast.rs  codegen.rs  hir.rs  lexer.rs  lib.rs  main.rs  parser.rs  scope.rs  span.rs  type_checker.rs  typedef.rs  types.rs
ast.rs
codegen.rs
hir.rs
lexer.rs
lib.rs
main.rs
parser.rs
scope.rs
span.rs
type_checker.rs
typedef.rs

这一切发生得太自然,“少见”的我两年之后才开始“多怪”,长久以来只觉得它理所应当。它们的内部一定有某种魔法,注定要被科学性巫术栏目收录。

eza 源码

pascal-van-de-vendel-1nYWSmesUHc-unsplash.webp
图5  0~200 米,表层带(epipelagic zone)或称阳光带(sunlight zone)。阳光穿透海水驱动光合作用,为上层水体提供营养物质,支撑起珊瑚、藻类、鱼群等常见海洋生物的生存。大多数鱼类会花大部分时间待在阳光层,少部分人类会花少部分时间待在阳光层。 A Large Group of Fish Swimming Over a Coral Reef (Photo by Pascal van de Vendel)

eza 的入口函数很早就记录了 stdout 是否为终端:

48let stdout_istty = io::stdout().is_terminal();

这个布尔值随后被传入 options.theme.to_theme

81let theme = options.theme.to_theme(stdout_istty);

to_theme 中,如果配色模式是自动(Automatic)且输出为非 tty(!isatty),就应用普通主题,同时不加载额外扩展样式。(这一点在 UseColours 的注释中也有说明)

69impl Options {
70    #[must_use]
71    pub fn to_theme(&self, isatty: bool) -> Theme {
72        if self.use_colours == UseColours::Never
73            || (self.use_colours == UseColours::Automatic && !isatty)
74        {
75            let ui = UiStyles::plain();
76            let exts = Box::new(NoFileStyle);
77            return Theme { ui, exts };
78        }

所以 eza 用 io::stdout().is_terminal() 判断是否为终端设备,从而改变它的输出主题,也就改变了内容格式

Rust 标准库

ex2206-dive03-viperfish-hires.webp
图6  200~1000 米,中层带(mesopelagic zone)或称黄昏带(twilight zone)。这里的阳光非常微弱,鱼群形成了昼夜垂直迁移的习惯,大多数生物依靠自身发光来捕食、警戒和求偶。对它们来说,人类的深潜器也只是某种进化出发光诱饵的巨大捕食者。 Dana Viperfish (NOAA Ocean Exploration)

io::stdout() 返回当前进程标准输出的一个新句柄,类型是 StdoutStdout 实现了 IsTerminal Trait,因此可以调用 is_terminal 方法。

1197#[stable(feature = "is_terminal", since = "1.70.0")]
1198pub impl(crate) trait IsTerminal {
1199    #[doc(alias = "isatty", alias = "atty")]
1200    #[stable(feature = "is_terminal", since = "1.70.0")]
1201    fn is_terminal(&self) -> bool;
1202}

Stdin Stdout Stderr 等类型都通过同一个宏实现了这个 Trait:

1251macro_rules! impl_is_terminal {
1252    ($($t:ty),*$(,)?) => {$(
1253        #[stable(feature = "is_terminal", since = "1.70.0")]
1254        impl IsTerminal for $t {
1255            #[inline]
1256            fn is_terminal(&self) -> bool {
1257                crate::sys::io::is_terminal(self)
1258            }
1259        }
1260    )*}
1261}
1262
1263impl_is_terminal!(File, Stdin, StdinLock<'_>, Stdout, StdoutLock<'_>, Stderr, StderrLock<'_>);

宏展开后,每个类型的方法都转发到 crate::sys::io::is_terminal,这个函数的内部直接调用了 libc 的 isatty 函数:

1use crate::os::fd::{AsFd, AsRawFd};
2
3pub fn is_terminal(fd: &impl AsFd) -> bool {
4    let fd = fd.as_fd();
5    unsafe { libc::isatty(fd.as_raw_fd()) != 0 }
6}

这一层比预想中简单很多。Stdout 实现 IsTerminal Trait,Trait 方法转进平台相关代码,再调用 libc 的 isattyRust 标准库到这就把事情交出去了。

我还以为要碰见超级复杂的 Rust 代码,白做那么多心理准备33.我很好奇在没有 libc 的 Windows 上 is_terminal 如何实现,之后肯定要去弄清楚的,倒是也没有白准备。 shimejisim_41.webp

glibc

ex2306-snailfish-hires.webp
图7  1000~4000 米,半深海带(bathypelagic zone)或称午夜带(midnight zone)。阳光被完全隔绝在外,食物极度稀缺,午夜带的生物大多新陈代谢缓慢,食性广泛,喜欢静静地伏击猎物。海面带来的急切,在这里会快得不合时宜。 Deep-Sea Snailfish (NOAA Ocean Exploration, Seascape Alaska)

先确认本机的版本,免得对照代码时版本不符,我的 libc 版本为 2.43

$ ldd --version
ldd (GNU libc) 2.43

只克隆 glibc 仓库的 glibc-2.43 分支:

git clone git://sourceware.org/git/glibc.git --branch glibc-2.43 --depth 1

我想用 rg isatty -t c 搜索找到 isatty 定义代码,结果却夹杂着许多调用点的噪音。好在这些函数定义都有共同的格式 isatty (int,可以用来筛掉干扰:

termios/isatty.c
23:__isatty (int fd)

sysdeps/mach/hurd/isatty.c
25:__isatty (int fd)

sysdeps/unix/sysv/linux/isatty.c
24:__isatty (int fd)

sysdeps/posix/isatty.c
23:__isatty (int fd)

干扰清除后剩下四个匹配项。

我对 glibc 的项目结构44.glibc 的 sysdeps/ 按 CPU 架构、操作系统、ABI、字长等命名分层组织目录,构建时先用 configure 生成优先目录列表,再用 make 按顺序覆盖选择对应源码。详细见 glibc 项目结构不太了解,从名字上看 sysdeps/ 目录下的文件应该对应不同平台的实现代码,这里有:

  • POSIX: sysdeps/posix/isatty.c
  • Hurd: sysdeps/mach/hurd/isatty.c
  • Linux: sysdeps/unix/sysv/linux/isatty.c

另外的 termios/isatty.c 是 fallback 占位实现,直接设置错误码并返回 -1

21/* Return 1 if FD is a terminal, 0 if not.  */
22int
23__isatty (int fd)
24{
25  __set_errno (ENOSYS);
26  return -1;
27}

我的是 Linux 系统,对应实现就是:

19#include <termios_internals.h>
20
21/* Return 1 if FD is a terminal, 0 if not. This simply does a
22   TCGETS2 ioctl into a dummy buffer without parsing the result. */
23int
24__isatty (int fd)
25{
26  struct termios2 k_termios;
27  return INLINE_SYSCALL_CALL (ioctl, fd, TCGETS2, &k_termios) == 0;
28}
29weak_alias (__isatty, isatty)

__isatty 的实现很直接:发起一次 ioctl 系统调用,只看这次调用成不成功,成功(返回 0)就判定为终端,失败则不是。

这次 ioctl 系统调用有三个参数:

  • fd:文件描述符,由外部传入,stdout 就是 1
  • TCGETS2:暂时不清楚含义
  • &k_termios:类型为 termios2 结构体指针,termios2 是内核对外暴露的终端属性结构,包含波特率等信息

据我所知,在更早版本(如 2.39)的 glibc 中,Linux 平台的 isatty 没有单独实现,用的是 POSIX 版本:调用 __tcgetattr 并检查其返回值。

21/* Return 1 if FD is a terminal, 0 if not.  */
22int
23__isatty (int fd)
24{
25  struct termios term;
26
27  return __tcgetattr (fd, &term) == 0;
28}

__tcgetattr 在 Linux 上也是同一个 ioctl(fd, TCGETS2) 调用,只是多包装了一层转换;BSD 上会换成另外的 ioctl op,这里不展开。

termios/tcgetattr.c
24:__tcgetattr (int fd, struct termios *termios_p)

sysdeps/unix/bsd/tcgetattr.c
33:__tcgetattr (int fd, struct termios *termios_p)

sysdeps/unix/sysv/linux/tcgetattr.c
22:__tcgetattr (int fd, struct termios *termios_p)
20/* Put the state of FD into *TERMIOS_P.  */
21int
22__tcgetattr (int fd, struct termios *termios_p)
23{
24  struct termios2 k_termios;
25  long int retval = INLINE_SYSCALL_CALL (ioctl, fd, TCGETS2, &k_termios);
26
27  if (__glibc_likely (retval != -1))
28    {
29      ___termios2_canonicalize_speeds (&k_termios);
30
31      memset (termios_p, 0, sizeof (*termios_p));
32      termios_p->c_iflag  = k_termios.c_iflag;
33      termios_p->c_oflag  = k_termios.c_oflag;
34      termios_p->c_cflag  = k_termios.c_cflag;
35      termios_p->c_lflag  = k_termios.c_lflag;
36      termios_p->c_line   = k_termios.c_line;
37      termios_p->c_ospeed = k_termios.c_ospeed;
38      termios_p->c_ispeed = k_termios.c_ispeed;
39
40      copy_c_cc (termios_p->c_cc, NCCS, k_termios.c_cc, _TERMIOS2_NCCS);
41    }
42
43  return retval;
44}

后面那一大段 if 是因为内核 ioctl 写入的 termios2 结构与 glibc 对外暴露的 termios2 结构不同,大概是这样吧,注释告诉我的:

29/* The the termios2 structure used in the kernel interfaces is not the
30   same as the termios structure we use in the libc.  Therefore we
31   must translate it here.  */
32
33struct termios2

所以在 Linux 上,isatty 的核心是 ioctl(fd, TCGETS2, &k_termios) 系统调用fd 来自 stdout,&k_termios 用来接收内核写回的终端属性,中间的 TCGETS2 是这次调用真正携带的信息,具体含义还需要进一步查阅。

ioctl(2) Manual

dive02-sea-pig-hires.webp
图8  4000~6000 米,深海带(Abyssopelagic Zone)。终年寒冷、压力极大,氧气和生物量都比上层水域更少。生物大多演化出巨大的眼睛,或彻底退化了视觉;身体则褪去色彩变得透明,以避免将能量浪费在发展和维持鲜艳图案上。 Sea Pig (NOAA Ocean Exploration, Seascape Alaska)

喘口气,休息一下,读会手册55.看手册是允许的,因为它类似于代码注释,属于作者给出的辅助信息。但网络搜索代码含义又是不允许的,因为这是别人从过多信息总结出的结论。我为这个规则找到了合理的解释。

ioctl (I/O control) 系统调用,用于操作特殊文件,特别是字符特殊文件(例如终端)。

The ioctl() system call manipulates the underlying device parameters of
special files.  In particular, many operating characteristics of
character special files (e.g., terminals) may be controlled with
ioctl() operations.

第一个参数是文件描述符,第二个参数是与设备相关的操作码:

The argument fd must be an open file descriptor.
The second argument is a device-dependent operation code.  The third
argument is an untyped pointer to memory.  It's traditionally char
*argp (from the days before void * was valid C), and will be so named
for this discussion.

操作码是 32-bit 常量,原则上任意取值,但人们尝试在其中构建了一些结构。

Ioctl op values are 32-bit constants.  In principle these constants are
completely arbitrary, but people have tried to build some structure
into them.

通常情况下,成功时返回 0,失败时返回 -1

Usually, on success zero is returned.  A few ioctl() operations use the
return value as an output parameter and return a nonnegative value on
success.  On error, -1 is returned, and errno is set to indicate the
error.

C 语言签名很简单:

int ioctl(int fd, unsigned long op, ...);

继续查看 op TCGETS2 的手册,发现它用来获取当前串行端口设置:

TCGETS Equivalent to tcgetattr(fd, argp).

       Get the current serial port settings.

The following four ioctls are just like TCGETS, TCSETS, TCSETSW,
TCSETSF, except that they take a struct termios2 * instead of a struct
termios *.  If the structure member c_cflag contains the flag BOTHER,
then the baud rate is stored in the structure members c_ispeed and
c_ospeed as integer values.  These ioctls are not supported on all
architectures.

       TCGETS2
       TCSETS2
       TCSETSW2
       TCSETSF2

手册写明 TCGETS 等同 tcgetattr(fd, argp),与 glibc 内部实现一致。其中 TCGETS2TCGETS 的扩展版,使用 termios2,不过 termios2termios 的区别66.termios2 主要多了显式保存输入、输出波特率的 c_ispeedc_ospeed 字段,并配合 BOTHER 支持任意整数波特率。应该对这次分析影响不大,我就简单地认为它是升级版好了。

用户态能看到的线索差不多到此为止。操作码 TCGETS2 表示这次 ioctl 想要读取终端属性,glibc 只需要根据它的返回值判断 stdout 是否像终端一样响应了这个请求。

至于请求进入内核后怎么被转发,又由哪段代码决定返回值,还得去内核源码里找。

Linux Kernel

deep-discoverer-surveys-hires.webp
图9  6000~11000 米,超深渊带(hadopelagic zone)。海洋能抵达的最后深度,只存在于海沟之中,水压超过一千个大气压。能在此处生存的鱼类极少,更多的是端足类、海参等小型生物,靠着上层沉降的有机碎屑维生;少数热液喷口和冷泉附近,还存在完全依靠化能合成细菌生存的生态群落。能从这里带回陆地的,通常只有短暂影像、少量样本和更多问题。 2016 Deepwater Exploration of the Marianas (NOAA Ocean Exploration, 2016 Deepwater Exploration of the Marianas)

获取我的系统内核版本并下载对应的内核源码:

$ uname -r
7.1.3-arch1-2
$ git clone https://github.com/torvalds/linux.git --branch v7.1 --depth=1

ioctl 系统调用

首先我想找到系统调用定义的地方,只过滤 ioctl 得到的结果太多,改换搜索 ioctlsyscall 同时出现的一行:

fs/ioctl.c
583:SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)
638:COMPAT_SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd,

security/security.c
2148: * Called when file_setattr() syscall or FS_IOC_FSSETXATTR ioctl() is called on
2163: * Called when file_getattr() syscall or FS_IOC_FSGETXATTR ioctl() is called on

tools/perf/trace/beauty/ioctl.c
175:size_t syscall_arg__scnprintf_ioctl_cmd(char *bf, size_t size, struct syscall_arg *arg)

drivers/scsi/st.c
2994:	ioctl_result = (STp->buffer)->syscall_result;

锁定了 fs/ioctl.c 里的 SYSCALL_DEFINE3COMPAT_SYSCALL_DEFINE3,后者从宏定义的代码看,应该是用来兼容的,比如在 64 位内核上运行 32 位程序。我这是原生的 64 位进程,就完全忽略它吧,只关注 SYSCALL_DEFINE3

583SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)
584{
585        CLASS(fd, f)(fd);
586        int error;
587
588        if (fd_empty(f))
589                return -EBADF;
590
591        error = security_file_ioctl(fd_file(f), cmd, arg);
592        if (error)
593                return error;
594
595        error = do_vfs_ioctl(fd_file(f), fd, cmd, arg);
596        if (error == -ENOIOCTLCMD)
597                error = vfs_ioctl(fd_file(f), cmd, arg);
598
599        return error;
600}

ioctl 系统调用执行过程中,首先会调用 security_file_ioctl 检查这次操作是否具有权限:

2497/**
2498 * security_file_ioctl() - Check if an ioctl is allowed
2499 * @file: associated file
2500 * @cmd: ioctl cmd
2501 * @arg: ioctl arguments
2502 *
2503 * Check permission for an ioctl operation on @file.  Note that @arg sometimes
2504 * represents a user space pointer; in other cases, it may be a simple integer
2505 * value.  When @arg represents a user space pointer, it should never be used
2506 * by the security module.
2507 *
2508 * Return: Returns 0 if permission is granted.
2509 */
2510int security_file_ioctl(struct file *file, unsigned int cmd, unsigned long arg)
2511{
2512        return call_int_hook(file_ioctl, file, cmd, arg);
2513}
2514EXPORT_SYMBOL_GPL(security_file_ioctl);

权限检查通过后,请求会先进 do_vfs_ioctl,处理一批 VFS 层面的通用命令,其中没有 TCGETS2。我猜测 stdout 算不上 REG 或者 ANON_FILE,所以默认分支也不会进入;就算落入默认分支,file_ioctl 函数中还是没有处理 TCGETS2 的分支。

因此 do_vfs_ioctl 最终会返回 -ENOIOCTLCMD,把请求交给 vfs_ioctl

492static int do_vfs_ioctl(struct file *filp, unsigned int fd,
493                        unsigned int cmd, unsigned long arg)
494{
495        void __user *argp = (void __user *)arg;
496        struct inode *inode = file_inode(filp);
497
498        switch (cmd) {
499        case FIOCLEX:
500                set_close_on_exec(fd, 1);
501                return 0;
502    // ...
503        default:
504                if (S_ISREG(inode->i_mode) && !IS_ANON_FILE(inode))
505                        return file_ioctl(filp, cmd, argp);
506                break;
507        }
508
509        return -ENOIOCTLCMD;
510}

vfs_ioctl 则直接把请求转发给 filp->f_op->unlocked_ioctl

44static int vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
45{
46        int error = -ENOTTY;
47
48        if (!filp->f_op->unlocked_ioctl)
49                goto out;
50
51        error = filp->f_op->unlocked_ioctl(filp, cmd, arg);
52        if (error == -ENOIOCTLCMD)
53                error = -ENOTTY;
54 out:
55        return error;
56}

filp->f_op 的类型是 file_operations,一个包含各种文件操作函数的表:

1260struct file {
1261        spinlock_t                      f_lock;
1262        fmode_t                         f_mode;
1263        const struct file_operations    *f_op;
1264    // ...
1265} __randomize_layout
1266  __attribute__((aligned(4)));  /* lest something weird decides that 2 is OK */
1926struct file_operations {
1927        struct module *owner;
1928        fop_flags_t fop_flags;
1929        loff_t (*llseek) (struct file *, loff_t, int);
1930        ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
1931        ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
1932        ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
1933        ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
1934        int (*iopoll)(struct kiocb *kiocb, struct io_comp_batch *,
1935                        unsigned int flags);
1936        int (*iterate_shared) (struct file *, struct dir_context *);
1937        __poll_t (*poll) (struct file *, struct poll_table_struct *);
1938        long (*unlocked_ioctl) (struct file *, unsigned int, unsigned long);
1939    // ...
1940} __randomize_layout;

当然了……肯定该是这样……unlocked_ioctl 是个函数指针,我跟丢了。

linux-kernel-01-ioctl-syscall.svg

TCGETS2 处理代码

那就换个思路,从底向上,先找到 TCGETS2 的处理分支:

tools/perf/trace/beauty/ioctl.c
30:	[_IOC_NR(TIOCSBRK)] = "TIOCSBRK", "TIOCCBRK", "TIOCGSID", "TCGETS2", "TCSETS2",

drivers/tty/tty_ioctl.c
394:#ifdef TCGETS2
428:#endif /* TCGETS2 */
461:#ifdef TCGETS2
804:#ifndef TCGETS2
816:	case TCGETS2:
836:#ifndef TCGETS2

drivers/tty/tty_io.c
2919:#ifdef TCGETS2
2920:	case TCGETS2:

drivers/net/wwan/wwan_core.c
996:#ifdef TCGETS2
997:	case TCGETS2:

两处 case TCGETS2 都命中在 drivers/tty/ 目录下,看来得进入 tty77.一个熟悉的词,我对它最深印象的是登录界面可以按下快捷键切换到不同“会话”。但它到底是什么,一个能输入字符显示字符的黑白界面?我好像从来没有真正理解过。 驱动程序了。

drivers/tty/tty_io.c 匹配的代码在 #ifdef CONFIG_COMPAT 中,所以又是兼容用的代码,跟之前一样跳过,只关注 drivers/tty/tty_ioctl.c

816case TCGETS2:
817        copy_termios(real_tty, &kterm);
818        if (kernel_termios_to_user_termios((struct termios2 __user *)arg, &kterm))
819                ret = -EFAULT;
820        return ret;

copy_termios 把这个 tty 当前的终端属性拷进内核内部的 kterm,随后 kernel_termios_to_user_termios 将它翻译成用户态期望的 termios2 布局,写回调用者的缓冲区。

765int tty_mode_ioctl(struct tty_struct *tty, unsigned int cmd, unsigned long arg)
766{
767        struct tty_struct *real_tty;
768        void __user *p = (void __user *)arg;
769        int ret = 0;
770        struct ktermios kterm;

只要这两步都成功,函数一路返回 0,glibc 的 isatty 就会判定为终端。

过滤发现 tty_mode_ioctl 只被 n_tty_ioctl_helper 函数调用:

936int n_tty_ioctl_helper(struct tty_struct *tty, unsigned int cmd,
937                unsigned long arg)
938{
939        int retval;
940
941        switch (cmd) {
942    //...
943        default:
944                /* Try the mode commands */
945                return tty_mode_ioctl(tty, cmd, arg);
946        }
947}

n_tty_ioctl_helper 被很多驱动使用,其中不少是 n_ 打头的文件,翻看它们的注释,发现这些都是跟线路规程(line discipline)88.事后写文章时我才知道它被翻译为“行规程”或“线路规程”,意为终端线路的规则集,与“文本行”没有直接关系。有关的程序。

net/nfc/nci/uart.c
339:		err = n_tty_ioctl_helper(tty, cmd, arg);

drivers/bluetooth/hci_ldisc.c
840:		err = n_tty_ioctl_helper(tty, cmd, arg);

drivers/tty/tty_ioctl.c
936:int n_tty_ioctl_helper(struct tty_struct *tty, unsigned int cmd,
985:EXPORT_SYMBOL(n_tty_ioctl_helper);

drivers/tty/n_tty.c
2496:		return n_tty_ioctl_helper(tty, cmd, arg);

drivers/tty/n_hdlc.c
624:		return n_tty_ioctl_helper(tty, cmd, arg);

drivers/tty/n_gsm.c
3889:		return n_tty_ioctl_helper(tty, cmd, arg);

drivers/net/ppp/ppp_synctty.c
304:		err = n_tty_ioctl_helper(tty, cmd, arg);

drivers/net/ppp/ppp_async.c
312:		err = n_tty_ioctl_helper(tty, cmd, arg);

既然是找 tty 相关代码,顺着 drivers/tty/n_tty.c 查更有价值。进入 n_tty_ioctl 函数:

2479static int n_tty_ioctl(struct tty_struct *tty, unsigned int cmd,
2480                       unsigned long arg)
2481{
2482        struct n_tty_data *ldata = tty->disc_data;
2483        unsigned int num;
2484
2485        switch (cmd) {
2486        case TIOCOUTQ:
2487                return put_user(tty_chars_in_buffer(tty), (int __user *) arg);
2488        case TIOCINQ:
2489                scoped_guard(rwsem_write, &tty->termios_rwsem)
2490                        if (L_ICANON(tty) && !L_EXTPROC(tty))
2491                                num = inq_canon(ldata);
2492                        else
2493                                num = read_cnt(ldata);
2494                return put_user(num, (unsigned int __user *) arg);
2495        default:
2496                return n_tty_ioctl_helper(tty, cmd, arg);
2497        }
2498}

n_tty_ioctl 只被 n_tty_ops 使用:

2500static struct tty_ldisc_ops n_tty_ops = {
2501        .owner           = THIS_MODULE,
2502        .num             = N_TTY,
2503        .name            = "n_tty",
2504        .open            = n_tty_open,
2505        .close           = n_tty_close,
2506        .flush_buffer    = n_tty_flush_buffer,
2507        .read            = n_tty_read,
2508        .write           = n_tty_write,
2509        .ioctl           = n_tty_ioctl,
2510        .set_termios     = n_tty_set_termios,
2511        .poll            = n_tty_poll,
2512        .receive_buf     = n_tty_receive_buf,
2513        .write_wakeup    = n_tty_write_wakeup,
2514        .receive_buf2    = n_tty_receive_buf2,
2515        .lookahead_buf   = n_tty_lookahead_flow_ctrl,
2516};
linux-kernel-02-tcgets2-handler.svg

N_TTY 线路规程注册

现在已经知道 n_tty_ops.ioctl 能处理 TCGETS2,但 n_tty_ops 本身还没解释清楚。它在哪里定义,在哪里注册,又会被谁取用,还得继续往回看。

通过搜索 n_tty_ops 的引用,我发现它在 n_tty_init 里被 tty_register_ldisc 注册进了一张全局表 tty_ldiscs。它的编号(n_tty_ops.num)是 N_TTY,因此放在 tty_ldiscs[N_TTY]99.ldisc 是 line discipline 的缩写,整理文章时我才意识到。

2532void __init n_tty_init(void)
2533{
2534        tty_register_ldisc(&n_tty_ops);
2535}
58int tty_register_ldisc(const struct tty_ldisc_ops *new_ldisc)
59{
60        unsigned long flags;
61
62        if (new_ldisc->num < N_TTY || new_ldisc->num >= NR_LDISCS)
63                return -EINVAL;
64
65        raw_spin_lock_irqsave(&tty_ldiscs_lock, flags);
66        tty_ldiscs[new_ldisc->num] = new_ldisc;
67        raw_spin_unlock_irqrestore(&tty_ldiscs_lock, flags);
68
69        return 0;
70}
71EXPORT_SYMBOL(tty_register_ldisc);

继续追查 n_tty_init 的调用者,只有一处命中,落在 console_init 里:

kernel/printk/printk.c
4396:	n_tty_init();

drivers/tty/n_tty.c
2532:void __init n_tty_init(void)
4384void __init console_init(void)
4385{
4386        int ret;
4387        initcall_t call;
4388        initcall_entry_t *ce;
4389
4390#ifdef CONFIG_NULL_TTY_DEFAULT_CONSOLE
4391        if (!console_set_on_cmdline)
4392                add_preferred_console("ttynull", 0, NULL);
4393#endif
4394
4395        /* Setup the default TTY line discipline. */
4396        n_tty_init();
4397
4398        /*
4399         * set up the console device so that later boot sequences can
4400         * inform about problems etc..
4401         */
4402        ce = __con_initcall_start;
4403        trace_initcall_level("console");
4404        while (ce < __con_initcall_end) {
4405                call = initcall_from_entry(ce);
4406                trace_initcall_start(call);
4407                ret = call();
4408                trace_initcall_finish(call, ret);
4409                ce++;
4410        }
4411}

__init 宏展开为 __attribute__((__section__(".init.text"))),这个标记会把函数塞进 .init.text 代码段。

45#define __init          __section(".init.text") __cold __latent_entropy \
46                                                __no_kstack_erase
321#define __section(section)              __attribute__((__section__(section)))

听上去只要加了 __init 就只会在内核的某个“初始化”阶段执行一次,但我不想分心去找它的具体实现,就让我偷个懒继续“听上去”吧。1010.TODO: __init 机制

linux-kernel-03-n-tty-register.svg

设置 tty 默认线路规程

drivers/tty/tty_ldisc.c 里我还找到了 tty_ldisc_init 函数,注释说它会在新分配 tty 的时候为其初始化线路规程,默认从 tty_ldiscs 表中取出 N_TTY 对应的线路规程。

794/**
795 * tty_ldisc_init       -       ldisc setup for new tty
796 * @tty: tty being allocated
797 *
798 * Set up the line discipline objects for a newly allocated tty. Note that the
799 * tty structure is not completely set up when this call is made.
800 */
801int tty_ldisc_init(struct tty_struct *tty)
802{
803        struct tty_ldisc *ld = tty_ldisc_get(tty, N_TTY);
804
805        if (IS_ERR(ld))
806                return PTR_ERR(ld);
807        tty->ldisc = ld;
808        return 0;
809}

一路跟踪 tty_ldisc_init 的调用代码,找到了这样的调用链:

  1. tty_fops.open
  2. tty_open
  3. tty_open_by_driver
  4. tty_kopen
  5. tty_init_dev
  6. alloc_tty_struct
  7. tty_ldisc_init
  8. tty->ldisc = n_tty_ops

所以 tty 设备在打开时(调用 tty_fops.open 时)会设置默认的线路规程,也就是 tty_ldiscs[N_TTY],也就是 n_tty_ops

linux-kernel-04-default-ldisc.svg

unlocked_ioctl

这样 ioctl(fd, TCGETS2) 系统调用前后还是没有连起来,我仍然不知道 .unlocked_ioctl 指针指向什么。

在所有文件名包含 tty 的文件中搜索 .unlocked_ioctl 的赋值代码:

samples/vfio-mdev/mtty.c
924:	.unlocked_ioctl = mtty_precopy_ioctl,

drivers/tty/tty_io.c
465:	.unlocked_ioctl	= tty_ioctl,
479:	.unlocked_ioctl	= tty_ioctl,
490:	.unlocked_ioctl	= hung_up_tty_ioctl,

tty 相关的 file_operations 里,只有 tty_fops 定义了 unlocked_ioctl,合理推测1111.目前还只是推测,我的直觉,稍后会向您证明的。 filp->f_op 就是 tty_fops

459static const struct file_operations tty_fops = {
460        .read_iter      = tty_read,
461        .write_iter     = tty_write,
462        .splice_read    = copy_splice_read,
463        .splice_write   = iter_file_splice_write,
464        .poll           = tty_poll,
465        .unlocked_ioctl = tty_ioctl,
466        .compat_ioctl   = tty_compat_ioctl,
467        .open           = tty_open,
468        .release        = tty_release,
469        .fasync         = tty_fasync,
470        .show_fdinfo    = tty_show_fdinfo,
471};

tty_fops.unlocked_ioctltty_ioctl。它的内部是一个大的 switch(cmd),处理几十种命令,但里面没有 TCGETS2 对应的 case,命令会落进 default 分支,交给 tty_jobctrl_ioctl 处理。

tty_jobctrl_ioctl 之后还有 tty->ops->ioctlld->ops->ioctl,这三段代码将依次尝试:

2768default:
2769        retval = tty_jobctrl_ioctl(tty, real_tty, file, cmd, arg);
2770        if (retval != -ENOIOCTLCMD)
2771                return retval;
2772}
2773if (tty->ops->ioctl) {
2774        retval = tty->ops->ioctl(tty, cmd, arg);
2775        if (retval != -ENOIOCTLCMD)
2776                return retval;
2777}
2778ld = tty_ldisc_ref_wait(tty);
2779if (!ld)
2780        return hung_up_tty_ioctl(file, cmd, arg);
2781retval = -EINVAL;
2782if (ld->ops->ioctl) {
2783        retval = ld->ops->ioctl(tty, cmd, arg);
2784        if (retval == -ENOIOCTLCMD)
2785                retval = -ENOTTY;
2786}
2787tty_ldisc_deref(ld);
2788return retval;

实际的执行情况:

  1. tty_jobctrl_ioctl:没有 TCGETS2
  2. tty->ops_ioctltty->ops 是某种 tty_operations,理论上也可能有自己的 ioctl 分支。搜索所有定义了 ioctl 字段的 tty_operations 实现,结果很多,这里暂且先跳过。

    drivers/tty/pty.c:	.ioctl = pty_bsd_ioctl,
    drivers/tty/pty.c:	.compat_ioctl = pty_bsd_compat_ioctl,
    drivers/tty/pty.c:	.ioctl = pty_unix98_ioctl,
    drivers/tty/pty.c:	.compat_ioctl = pty_unix98_compat_ioctl,
    drivers/tty/vt/vt.c:	.ioctl = vt_ioctl,
    drivers/tty/vt/vt.c:	.compat_ioctl = vt_compat_ioctl,
    drivers/tty/nozomi.c:	.ioctl = ntty_ioctl,
    drivers/tty/amiserial.c:	.ioctl = rs_ioctl,
    drivers/tty/ipwireless/tty.c:	.ioctl = ipw_ioctl,
    drivers/tty/mxser.c:	.ioctl = mxser_ioctl,
    drivers/tty/synclink_gt.c:	.ioctl = ioctl,
    drivers/tty/synclink_gt.c:	.compat_ioctl = slgt_compat_ioctl,
    drivers/tty/n_gsm.c:	.ioctl			= gsmtty_ioctl,
    drivers/tty/serial/serial_core.c:	.ioctl		= uart_ioctl,
    
  3. ld->ops->ioctltty_ldisc_ref_wait 获取 tty->ldisc,拿到上一小节说的 n_tty_ops,并使用 n_tty_ops->ioctl 执行命令
linux-kernel-05-unlocked-ioctl.svg

pts 与 Unix98 pty 驱动

上一节把 tty->ops->ioctl 这个分支放过去了,它能否拦下 TCGETS2,取决于当前 tty 的 tty->ops 到底指向哪个 tty_operations

tty_operations 的实现太多,直接从源码里枚举下去不太划算。不过别担心,还剩下最后一个线索——stdout,它到底是什么?指向哪种设备?知道之后就能缩小源码范围了。

先看看 fd 1 的文件类型:

$ file /proc/self/fd/1
/proc/self/fd/1: symbolic link to /dev/pts/9

哈……符号链接,链接到 /dev/pts/9。继续查看 /dev/pts/9 的类型:

$ file /dev/pts/9
/dev/pts/9: character special (136/9)

/dev/pts/9 是字符设备,设备号是 136/9。其中 136 是 major number,用来区分设备类型;9 是 minor number,对应这一类设备下的具体实例。

继续查当前系统注册的字符设备表:

$ rg '136' /proc/devices
17:136 pts

得知这类字符设备在内核中注册的名称为 pts,使用字符串 pts 作为新的线索,回到源码中找出谁注册了它:

 1fs/devpts/inode.c
 2118:	/* Is a devpts filesystem at "pts" in the same directory? */
 3
 4fs/namei.c
 53615:	/* Find something mounted on "pts" in the same directory as
 63620:	struct qstr this = QSTR_INIT("pts", 3);
 7
 8arch/um/drivers/pty.c
 9155:	.type		= "pts",
10
11arch/um/drivers/chan_kern.c
12458:	{ "pts", &pts_ops },
13461:	{ "pts", &not_configged_ops },
14
15drivers/tty/pty.c
16885:	pts_driver->name = "pts";

结果里 drivers/tty/pty.c:885 这行最相关,往上翻就是 unix98_pty_ini 函数,整段逻辑包在 #ifdef CONFIG_UNIX98_PTYS 里:

586/* Unix98 devices */
587#ifdef CONFIG_UNIX98_PTYS

确认我的系统内核编译配置里这个选项是开着的:

$ zcat /proc/config.gz | rg CONFIG_UNIX98_PTYS
CONFIG_UNIX98_PTYS=y

unix98_pty_init 里调用了 tty_set_operations,把 pts_driver->ops 设成 tty_driver->ops

884pts_driver->driver_name = "pty_slave";
885pts_driver->name = "pts";
886pts_driver->major = UNIX98_PTY_SLAVE_MAJOR;
887pts_driver->minor_start = 0;
888pts_driver->type = TTY_DRIVER_TYPE_PTY;
889pts_driver->subtype = PTY_TYPE_SLAVE;
890pts_driver->init_termios = tty_std_termios;
891pts_driver->init_termios.c_cflag = B38400 | CS8 | CREAD;
892pts_driver->init_termios.c_ispeed = 38400;
893pts_driver->init_termios.c_ospeed = 38400;
894pts_driver->other = ptm_driver;
895tty_set_operations(pts_driver, &pty_unix98_ops);
590static inline void tty_set_operations(struct tty_driver *driver,
591                const struct tty_operations *op)
592{
593        driver->ops = op;
594}

接着是调用 tty_register_driver 函数。

897if (tty_register_driver(ptm_driver))
898        panic("Couldn't register Unix98 ptm driver");
899if (tty_register_driver(pts_driver))
900        panic("Couldn't register Unix98 pts driver");

tty_register_driver 定义在 drivers/tty/tty_io.c,内部会调用 tty_cdev_add,分配 cdev 并将 cdev->ops 设置为 tty_fops

3158static int tty_cdev_add(struct tty_driver *driver, dev_t dev,
3159                unsigned int index, unsigned int count)
3160{
3161        int err;
3162
3163        /* init here, since reused cdevs cause crashes */
3164        driver->cdevs[index] = cdev_alloc();
3165        if (!driver->cdevs[index])
3166                return -ENOMEM;
3167        driver->cdevs[index]->ops = &tty_fops;
3168        driver->cdevs[index]->owner = driver->owner;
3169        err = cdev_add(driver->cdevs[index], dev, count);
3170        if (err)
3171                kobject_put(&driver->cdevs[index]->kobj);
3172        return err;
3173}
14struct cdev {
15        struct kobject kobj;
16        struct module *owner;
17        const struct file_operations *ops;
18        struct list_head list;
19        dev_t dev;
20        unsigned int count;
21} __randomize_layout;

现在 tty->ops 已经确定是 pty_unix98_ops,可以回头看它的 ioctl 到底处理了什么:

625static int pty_unix98_ioctl(struct tty_struct *tty,
626                            unsigned int cmd, unsigned long arg)
627{
628        switch (cmd) {
629        case TIOCSPTLCK: /* Set PT Lock (disallow slave open) */
630                return pty_set_lock(tty, (int __user *)arg);
631        case TIOCGPTLCK: /* Get PT Lock status */
632                return pty_get_lock(tty, (int __user *)arg);
633        case TIOCPKT: /* Set PT packet mode */
634                return pty_set_pktmode(tty, (int __user *)arg);
635        case TIOCGPKT: /* Get PT packet mode */
636                return pty_get_pktmode(tty, (int __user *)arg);
637        case TIOCGPTN: /* Get PT Number */
638                return put_user(tty->index, (unsigned int __user *)arg);
639        case TIOCSIG:    /* Send signal to other side of pty */
640                return pty_signal(tty, (int) arg);
641        }
642
643        return -ENOIOCTLCMD;
644}

六个 case 都与 TCGETS2 无关,返回 -ENOIOCTLCMD,请求会继续下落到 ld->ops->ioctl,也就是 n_tty_ops->ioctl

linux-kernel-06-unix98-pty.svg

汇总执行链

到这里,前面几节得到的几张局部执行链图已经可以拼起来了。

  1. 系统调用先进入 SYSCALL_DEFINE3(ioctl),拿到 fd 对应的 struct file
  2. VFS 层通过 do_vfs_ioctl 处理通用命令,发现 TCGETS2 不属于它负责的范围
  3. 请求继续进入 vfs_ioctl,准备调用当前文件的 filp->f_op->unlocked_ioctl
  4. 如果这个 fd 指向当前终端里的 /dev/pts/N,它背后就是 Unix98 pty slave driver
  5. Unix98 pty slave driver 注册出的 tty 字符设备使用 tty_fops,其中 unlocked_ioctl 指向 tty_ioctl
  6. tty_ioctl 先尝试 tty 自己的 tty->ops->ioctl,也就是 pty_unix98_ioctl
  7. pty_unix98_ioctl 不处理 TCGETS2,返回 -ENOIOCTLCMD
  8. 请求继续落到当前 tty 的线路规程 ld->ops->ioctl
  9. 这个 tty 的默认线路规程是启动阶段注册好的 N_TTY,也就是 n_tty_ops
  10. n_tty_ops.ioctl 最终进入 tty_mode_ioctl,处理 TCGETS2,把终端属性写回用户态缓冲区
linux-kernel-execution-chain-with-gap.svg

如果只按直觉看这条链已经闭合了, 但其实跳过了关键问题(图中红线):

  • vfs_ioctl 拿到的是 struct file *filp,它调用 filp->f_op->unlocked_ioctl,我只是推测它为 tty_fops->unlocked_ioctl
  • 我只找到字符设备里的 cdev->ops = &tty_fops,这说明不了什么,除非 filp->f_op = cdev->ops

所以最后还需要证明一件事:打开 /dev/pts/N 这个字符设备时,内核把 cdev->ops 塞进了当前文件的 filp->f_op

最后一块拼图

过滤 filp->f_op = cdev->ops 无果,过滤 cdev->ops 的结果看上去都不相关。倒是过滤 filp->f_op 精确匹配了变量名,逐个翻看,在 fs/char_dev.c 里找到了 chrdev_open 函数:

static int chrdev_open(struct inode *inode, struct file *filp)
{
        p = inode->i_cdev;
    // ...
        fops = fops_get(p->ops);
        if (!fops)
                goto out_cdev_put;

        replace_fops(filp, fops);
        if (filp->f_op->open) {
                ret = filp->f_op->open(inode, filp);
                if (ret)
                        goto out_cdev_put;
        }

还好没有只看匹配行就略过了,替换 f_op 的逻辑在它上方。

fopscdev->ops,传给了 replace_fops(filp, fops),这是个宏:

2353#define replace_fops(f, fops) \
2354        do {    \
2355                struct file *__file = (f); \
2356                fops_put(__file->f_op); \
2357                BUG_ON(!(__file->f_op = (fops))); \
2358        } while(0)

展开后类似(去除无关代码)1212.这种 do {} while(0) 的写法可以让一个包含多条语句的宏,在语法上表现得像一条普通单行语句,不增加任何运行时开销。

do {
  struct file *__file = (filp);
  __file->f_op = (fops)
} while(0)
449const struct file_operations def_chr_fops = {
450        .open = chrdev_open,
451        .llseek = noop_llseek,
452};
linux-kernel-07-chrdev-open.svg

小结

唉,好累,您还记得我最初要干什么吗?我已经神智不清了。

如果要回答文章标题,那么答案是:eza 通过 ioctl(fd, TCGETS2) 系统调用检测自己的 stdout 是否为终端设备,Linux 内核如果能返回终端属性,就是终端,否则就不是

linux-kernel-execution-chain.svg
图10  完整的 Linux 内核执行链

strace 验证

前面的结论完全来自源码阅读,最后用 strace 看一眼运行时行为,确认 eza 的确发起了同一次 ioctl

直接输出到终端时,fd 1 可以响应 TCGETS2 请求,返回值为 0

ioctl(1, TCGETS2, {c_iflag=ICRNL|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=39, ws_col=125, ws_xpixel=1250, ws_ypixel=741}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=39, ws_col=125, ws_xpixel=1250, ws_ypixel=741}) = 0
ioctl(0, TCGETS2, {c_iflag=ICRNL|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=39, ws_col=125, ws_xpixel=1250, ws_ypixel=741}) = 0
ioctl(1, TIOCGWINSZ, {ws_row=39, ws_col=125, ws_xpixel=1250, ws_ypixel=741}) = 0

管道到 cat 后,fd 1 变成管道,不再是 tty,内核返回 ENOTTY,无法响应 TCGETS2 请求,返回值为 -1

ioctl(1, TCGETS2, 0x7ffc232ddda0)       = -1 ENOTTY (Inappropriate ioctl for device)
ioctl(1, TIOCGWINSZ, 0x7ffc232ddb30)    = -1 ENOTTY (Inappropriate ioctl for device)
ioctl(0, TCGETS2, {c_iflag=ICRNL|IUTF8, c_oflag=NL0|CR0|TAB0|BS0|VT0|FF0|OPOST|ONLCR, c_cflag=B38400|CS8|CREAD, c_lflag=ISIG|ICANON|ECHO|ECHOE|ECHOK|IEXTEN|ECHOCTL|ECHOKE, ...}) = 0
ioctl(1, TIOCGWINSZ, 0x7ffc232e0430)    = -1 ENOTTY (Inappropriate ioctl for device)

好耶。

延伸阅读

todo!()

tty 是什么

pty 是什么

glibc 项目结构

linux kernel .init.text

is_terminal 在 Windows 上的实现

拥有同样魔法的程序

todo!()

总结

todo!()

1

指 grep 命令和编辑器查找文本功能,具体些就是 ripgrep 和 Neovim 的 Pattern

2

不对其实是对的,我本就期待这样的结果,只是为什么会这样期待呢?这不对。

3

我很好奇在没有 libc 的 Windows 上 is_terminal 如何实现,之后肯定要去弄清楚的,倒是也没有白准备。 shimejisim_41.webp

4

glibc 的 sysdeps/ 按 CPU 架构、操作系统、ABI、字长等命名分层组织目录,构建时先用 configure 生成优先目录列表,再用 make 按顺序覆盖选择对应源码。详细见 glibc 项目结构

5

看手册是允许的,因为它类似于代码注释,属于作者给出的辅助信息。但网络搜索代码含义又是不允许的,因为这是别人从过多信息总结出的结论。我为这个规则找到了合理的解释。

6

termios2 主要多了显式保存输入、输出波特率的 c_ispeedc_ospeed 字段,并配合 BOTHER 支持任意整数波特率。

7

一个熟悉的词,我对它最深印象的是登录界面可以按下快捷键切换到不同“会话”。但它到底是什么,一个能输入字符显示字符的黑白界面?我好像从来没有真正理解过。

8

事后写文章时我才知道它被翻译为“行规程”或“线路规程”,意为终端线路的规则集,与“文本行”没有直接关系。

9

ldisc 是 line discipline 的缩写,整理文章时我才意识到。

10

TODO: __init 机制

11

目前还只是推测,我的直觉,稍后会向您证明的。

12

这种 do {} while(0) 的写法可以让一个包含多条语句的宏,在语法上表现得像一条普通单行语句,不增加任何运行时开销。

Copyright © 2024 13m0n4de · CC BY-NC 4.0