TTY 行规范:让 shell 真正能交互
上一章(059)让内核跑起了用真 musl 编译的静态程序,
hello能打出Hello from musl干净退出了。可一旦你想跟它交互——比如scanf读你敲的输入——就读不到。因为 stdin(fd==0)的实现是键盘 PS/2 环形缓冲上的忙等轮询:没键就空转一百万次,然后返回 0;而 musl 把「read 返回 0」当成 EOF,于是交互式程序一上来就以为输入结束了。stdout 那边也没好到哪去:fd==1直接走kprintf,没有行缓冲;ioctl不管问什么都返回-ENOTTY,musl 探窗口大小(TIOCGWINSZ)失败就退回全缓冲,printf的输出不及时。键盘敲一个字符就交给程序一个字符,没有退格编辑、没有回车成行提交、没有 Ctrl+C——离一个真终端差远了。这一章补一个真的 TTY 子系统,把「键盘 → 行规范 → 进程」这条链接通。行规范(line discipline)是终端的灵魂:它在原始按键和程序看到的输入之间加一层加工——攒一行、处理退格、回车才提交、把 Ctrl+C 翻译成信号。然后接上阻塞读(让 shell 等 stdin 时 CPU 不空转)、接上真 ioctl(答上 musl/glibc 的探针)、把 Ctrl+C 接成真的 SIGINT 投给前台进程组。做完这些,shell 就是个能用的交互终端了:本章的 punchline 就是这个——敲退格能编辑、回车提交整行、Ctrl+C 打断前台、Ctrl+D 结束输入。
一条诚实的边界先说在前头:这一章做的是 console TTY(系统唯一一个终端,单例,键盘当输入、串口/Console 当回显),不是 PTY。PTY(master/slave 对、
/dev/ptmx、/dev/pts/N)要建设备 inode,而 CinuxOS 这会儿还没有 DevFS,建了也是空中楼阁——PTY 留到后面 DevFS 落了再做。console TTY 单例绕开设备节点这一层,功能(行规范 + 阻塞读 + EOF + 信号)是完整的,不欠债。
这章咱们要点亮什么
- 行规范是原始按键和程序输入之间的一层加工:ICANON 模式下攒一行、退格编辑、回车或
^D才提交;ISIG 模式下把^C/^\/^Z翻译成信号,这些字符根本不进输入缓冲。 - 怎么让一块内核逻辑能在 host 上单测:行规范核心写成纯逻辑,回显走注入的 callback、信号走枚举——不直接碰
kprintf、不直接碰signal_send,于是 host 能链真码跑单测,而不是手搓 mock。 - 阻塞读的两件难事:一是别让 CPU 空转(用
prepare_to_wait+schedule_blocked替忙等);二是「read 返回 0 = EOF」和「read 暂时没数据 = 该阻塞」这两个语义得分开,不然^D会让 read 一直返 0 假 EOF。 - ioctl 不再是摆设:
TCGETS/TCSETS读写 termios、TIOCGWINSZ答窗口尺寸,解锁 musl/glibc 的行缓冲;而且这些经 061 那套 accessor 走,坏用户指针返回-EFAULT而不是 panic。 - Ctrl+C 接成真信号:
^C在行规范里被认出来,映射成 SIGINT,经killpg投给前台进程组——终端能打断程序了。
先看清现状:stdin/stdout 有多简陋
动手之前,先 grep 坐实一下之前的家底有多薄。
stdin(fd==0)之前是键盘 PS/2 环形缓冲上的忙等轮询——没键就空转,转完返 0,被 musl 当 EOF。所以一个想读你输入的程序,一上来就 EOF 结束了。stdout(fd==1)直接 kprintf,一个字节吐一个字节,没有「攒一行再 flush」这回事。ioctl 更干脆,不管什么 request 都返 -ENOTTY——可 musl/glibc 启动时会拿 stdout 探 TIOCGWINSZ(终端窗口多大),探失败就退回全缓冲,你 printf 的东西要等缓冲区满才看得见。
地基倒是大多现成:信号、进程组、killpg 已经做完;进程结构里还预埋了一个 Task::controlling_tty 字段(没终端时是 -1);Console 已经能当 echo 的 sink。差的就差一个真 TTY 子系统,把键盘 → 行规范 → 进程这条链接起来。