《从零手写操作系统 (17):终端子系统——TTY、行规程与作业控制》
前言:从“裸设备I/O”到“交互式终端体验”
在前面的章节中,我们让进程能够接收信号、Shell能够通过fork/exec运行子程序。但此时的输入输出仍是割裂的:键盘驱动直接往环形缓冲区塞扫描码,串口/显存直接吐字节,没有“行”的概念,没有回显,没有退格编辑,更没有Ctrl+Z挂起和fg/bg切换。用户面对的不是一个“终端”,而是一个裸露的硬件管道。
本章我们将构建完整的TTY子系统,这是Unix人机交互的灵魂所在。它将原始按键流转化为结构化的行输入,将程序输出流转化为带控制的显示序列,并引入作业控制(Job Control)让Shell真正具备多任务管理能力。
本章里程碑:
- ✅ 实现TTY抽象层:统一键盘输入与屏幕/串口输出
- ✅ 实现行规程(Line Discipline):回显、退格、行缓冲、特殊字符处理
- ✅ 实现作业控制原语:SIGTSTP(Ctrl+Z)、SIGCONT、前台进程组切换
- ✅ 重构Shell:支持
&后台执行、jobs、fg、bg命令 - ✅ 将tty_read/tty_write作为系统调用暴露给用户态
- ✅ 验证完整终端体验:行编辑、Ctrl+C中断、Ctrl+Z挂起、fg恢复
核心概念:三层架构与作业控制语义
TTY ≠ 键盘 + 屏幕
Unix终端的经典三层模型:
| 层级 | 职责 | 本章实现 |
|---|---|---|
| 硬件驱动层 | 读写物理设备(PS/2、VGA、UART) | 已有keyboard.c / console.c |
| 行规程层 (LDISC) | 行编辑、回显、信号生成、流量控制 | ★ 本章核心新增 |
| TTY接口层 | 向用户态提供read/write/ioctl统一API | sys_tty_read / sys_tty_write |
行规程是中间那层“智能胶水”。它拦截原始按键,决定哪些字符回显、哪些存入行缓冲、哪些立即触发信号。没有行规程的终端就像没有语法解析器的编译器——能接收输入,但无法理解意图。
作业控制的核心:进程组与前台/后台
Unix作业控制依赖两个关键抽象:
- 进程组(Process Group):一组相关进程的集合,由PGID标识。同一管道或同一Shell启动的命令属于同一进程组。
- 前台进程组:当前拥有终端控制权的进程组。只有前台进程组才能读取tty输入;后台进程组尝试read时收到SIGTTIN被暂停。
⚠️ 本章简化:不实现完整的session/ctty机制。使用全局变量
foreground_pgid追踪前台进程组,Shell自身作为PGID=0的特殊组始终存在。这足以支撑教学级作业控制。
Ctrl+Z vs Ctrl+C的本质区别
- Ctrl+C → SIGINT:请求终止。默认动作是退出。
- Ctrl+Z → SIGTSTP:请求暂停。默认动作是将进程置为STOPPED状态,并将终端控制权交还Shell。
SIGTSTP的处理必须在内核侧完成状态转换,因为用户态Handler无法安全地将自己挂起并通知父进程。这与SIGINT的纯信号投递有本质不同。
实战代码
TTY结构与行规程
// tty.h
#define TTY_BUF_SIZE 256
#define LDISC_BUF_SIZE 256
typedef struct tty {
// 原始输入缓冲(来自键盘驱动)
uint8_t raw_buf[TTY_BUF_SIZE];
volatile int raw_head, raw_tail;
// ★ 行规程缓冲(编辑后的完整行)
char ldisc_buf[LDISC_BUF_SIZE];
int ldisc_len; // 当前行已积累字符数
int ldisc_ready; // 1=遇到\n,整行可读
// 终端属性
int echo_enabled; // 是否回显
int canonical_mode; // 1=行模式(默认), 0=raw模式
// 作业控制
int foreground_pgid; // 当前前台进程组ID
} tty_t;
extern tty_t system_tty;
// tty.c - 行规程核心
#include "tty.h"
#include "console.h"
#include "process.h"
#include "signal.h"
tty_t system_tty = {
.echo_enabled = 1,
.canonical_mode = 1,
.foreground_pgid = 0,
};
// ★ 由键盘驱动在IRQ中调用
void tty_input_char(char c) {
tty_t *t = &system_tty;
if (!t->canonical_mode) {
// Raw模式:直接入队,不做任何处理
t->raw_buf[t->raw_head++ % TTY_BUF_SIZE] = c;
return;
}
// === Canonical模式:行规程处理 ===
// 特殊字符优先处理
switch (c) {
case '\x03': // Ctrl+C
send_signal_to_pgid(t->foreground_pgid, SIGINT);
return;
case '\x1A': // Ctrl+Z
send_signal_to_pgid(t->foreground_pgid, SIGTSTP);
return;
case '\b': // Backspace
case 0x7F: // DEL
if (t->ldisc_len > 0) {
t->ldisc_len--;
if (t->echo_enabled) {
console_putc('\b');
console_putc(' ');
console_putc('\b');
}
}
return;
case '\n':
case '\r':
// 行结束:标记就绪,回显换行
if (t->echo_enabled) console_putc('\n');
t->ldisc_buf[t->ldisc_len++] = '\n';
t->ldisc_ready = 1;
// 唤醒等待读取此tty的进程
tty_wakeup_readers();
return;
}
// 普通可打印字符
if (c >= 0x20 && t->ldisc_len < LDISC_BUF_SIZE - 1) {
t->ldisc_buf[t->ldisc_len++] = c;
if (t->echo_enabled) console_putc(c);
}
}
// sys_tty_read: 阻塞直到一行就绪
int sys_tty_read(interrupt_frame_t *frame) {
char *user_buf = (char *)frame->ebx;
int count = frame->ecx;
tty_t *t = &system_tty;
// ★ 仅前台进程组可读
if (current_process->pgid != t->foreground_pgid) {
// 后台进程尝试读tty → SIGTTIN
current_process->sig_pending |= (1 << SIGTTIN);
current_process->state = PROC_STOPPED;
schedule();
// 被SIGCONT唤醒后重新检查
return -1; // EINTR
}
// 阻塞等待整行
while (!t->ldisc_ready) {
current_process->state = PROC_WAITING;
current_process->wait_channel = (uint32_t)&t->ldisc_ready;
schedule();
}
// 拷贝整行到用户缓冲
int copy_len = t->ldisc_len < count ? t->ldisc_len : count;
memcpy(user_buf, t->ldisc_buf, copy_len);
// 重置行缓冲
t->ldisc_len = 0;
t->ldisc_ready = 0;
return copy_len;
}
SIGTSTP内核侧处理与作业切换
// signal.c 补充
void handle_sigtstp(process_t *proc) {
// ★ SIGTSTP的默认动作在内核侧完成,不走用户态handler
proc->state = PROC_STOPPED;
// 如果该进程属于前台进程组,将控制权交还Shell(PGID=0)
tty_t *t = &system_tty;
if (proc->pgid == t->foreground_pgid) {
t->foreground_pgid = 0; // Shell收回前台
// 通知Shell有新作业停止(通过SIGCHLD)
process_t *shell = find_process_by_pgid(0);
if (shell) {
shell->sig_pending |= (1 << SIGCHLD);
if (shell->state == PROC_WAITING ||
shell->state == PROC_SLEEPING) {
shell->state = PROC_READY;
}
}
}
kprintf("[TTY] PID %d (PGID %d) stopped by SIGTSTP\n",
proc->pid, proc->pgid);
}
// sys_sigreturn中对SIGTSTP的特殊处理已在check_and_deliver_signal中分流
// SIGTSTP永远不进用户态handler,直接在deliver阶段调用handle_sigtstp
Shell作业控制命令
// user/shell.c - 作业控制扩展
#define SYS_GETPGID 12
#define SYS_SETPGID 13
#define SYS_TCSETPGRP 14 // 设置前台进程组
static int jobs_count = 0;
static struct { int pgid; int pid; int stopped; char cmd[64]; } jobs[16];
static void cmd_jobs(void) {
for (int i = 0; i < jobs_count; i++) {
const char *state = jobs[i].stopped ? "Stopped" : "Running";
printf("[%d] %s\t%s (pgid=%d)\n",
i+1, state, jobs[i].cmd, jobs[i].pgid);
}
}
static void cmd_fg(const char *arg) {
int job_idx = atoi(arg) - 1;
if (job_idx < 0 || job_idx >= jobs_count) {
printf("fg: invalid job\n"); return;
}
// 将指定作业设为前台并发送SIGCONT
asm volatile("int $0x80" :: "a"(SYS_TCSETPGRP), "b"(jobs[job_idx].pgid));
// 等待前台作业完成或再次停止
int status;
sys_wait(&status);
// 更新jobs表...
}
static void cmd_bg(const char *arg) {
int job_idx = atoi(arg) - 1;
if (job_idx < 0 || job_idx >= jobs_count) {
printf("bg: invalid job\n"); return;
}
// 仅发送SIGCONT,不设前台
asm volatile("int $0x80" : : "a"(9/*SYS_KILL*/),
"b"(jobs[job_idx].pgid), "c"(SIGCONT));
jobs[job_idx].stopped = 0;
}
// exec前设置子进程PGID
static int run_command(char *cmd, int background) {
int pid = sys_fork();
if (pid == 0) {
if (!background) {
// 前台子进程:PGID = 自己的PID
asm volatile("int $0x80" :: "a"(SYS_SETPGID), "b"(0), "c"(0));
} else {
// 后台子进程:PGID = 自己的PID(独立作业)
asm volatile("int $0x80" :: "a"(SYS_SETPGID), "b"(0), "c"(0));
}
sys_exec(cmd);
printf("exec failed: %s\n", cmd);
sys_exit(1);
}
if (!background) {
// 前台:设其为前台进程组并等待
asm volatile("int $0x80" :: "a"(SYS_TCSETPGRP), "b"(pid));
int status;
sys_wait(&status);
// Shell自动收回前台(由SIGTSTP/SIGCHLD处理保证)
} else {
// 后台:记录到jobs表,不等待
jobs[jobs_count].pgid = pid;
jobs[jobs_count].pid = pid;
jobs[jobs_count].stopped = 0;
strncpy(jobs[jobs_count].cmd, cmd, 63);
jobs_count++;
printf("[%d] %d\n", jobs_count, pid);
}
return pid;
}
关键细节解析
1. 为什么行缓冲放在内核而非用户态?
如果行编辑在用户态完成(如readline库),那么每个程序都要自带一套编辑逻辑,且Ctrl+C等信号只能在read返回后才能被检测。内核侧行规程保证了所有程序共享一致的编辑体验,且特殊字符能在任意时刻被即时响应。这也是为什么Unix的stty配置是per-tty而非per-process的。
2. 为什么SIGTSTP必须内核处理而不能交给用户Handler?
假设用户注册了SIGTSTP handler并在其中调用pause()等待SIGCONT。问题在于:在handler执行期间进程仍处于RUNNING状态,终端仍认为它是前台进程。其他进程无法获得终端输入权。只有内核原子地完成“STOPPED状态+前台切换”两步操作,才能保证作业控制的正确性。POSIX允许捕获SIGTSTP,但要求handler返回后若未显式忽略则仍执行默认停止动作——本章简化为完全禁止用户捕获SIGTSTP。
3. 后台进程读tty为什么要发SIGTTIN而非直接返回错误?
如果后台进程read返回EAGAIN,它可能会busy-loop重试,耗尽CPU。SIGTTIN将其暂停是一种背压机制:告诉进程“现在不该你读,等你被fg回来再说”。当用户执行fg %1时,Shell先发SIGCONT再设前台,进程恢复后重新执行read,此时已是前台,正常返回数据。这是Unix作业控制优雅性的体现:用信号协调而非轮询竞争。
调试Checklist:终端与作业控制排查
| 症状 | 可能原因 | 排查方法 |
|---|---|---|
| 按键无回显但程序能读到 | echo_enabled未初始化/tty_input_char未走canonical路径 | dump system_tty.echo_enabled;确认键盘IRQ调用了tty_input_char而非直接写raw_buf |
| Ctrl+C/Ctrl+Z无效 | 特殊字符码值错误/send_signal_to_pgid未找到目标进程 | kprintf在switch分支入口dump收到的字符值;确认foreground_pgid与实际子进程pgid匹配 |
| fg后进程不恢复 | SIGCONT未发送/tcsetpgrp未更新foreground_pgid | 在cmd_fg中kprintf确认syscall参数;dump system_tty.foreground_pgid变化 |
| 后台进程抢占输入 | SIGTTIN未实现/后台判断条件错误 | 确认sys_tty_read中pgid比较逻辑;测试后台cat是否被正确暂停 |
| 退格删除多个字符 | \b处理未考虑多字节/回显序列不完整 | 测试单字符退格是否正常;确认console_putc('\b')在你的显示驱动中正确左移光标 |
| jobs列表状态不更新 | SIGCHLD未处理/wait未区分WIFSTOPPED | 在Shell的SIGCHLD handler中dump wait返回的status;确认WIFSTOPPED宏正确提取 |
🔧 黄金法则:终端调试的最佳工具是双窗口对照。一个窗口跑你的OS,另一个窗口开着Linux终端运行相同命令序列,逐键对比行为差异。不要凭记忆判断“应该怎样”——Unix终端的行为规范藏在四十年的兼容性包袱里,很多“反直觉”行为其实是正确的。当你不确定时,Linux就是标准答案。
本章小结与下一步
今天我们构建了操作系统的人机交互灵魂:
- ✅ 实现了完整的TTY三层架构:驱动→行规程→系统调用
- ✅ 行规程支持回显、退格、行缓冲、Ctrl+C/Z即时响应
- ✅ 作业控制实现了进程组、前台切换、SIGTSTP内核处理、SIGTTIN背压
- ✅ Shell获得了
&、jobs、fg、bg完整能力 - ✅ 终端行为首次达到“类Unix可用”水准
从此,你的操作系统拥有了真正的交互式用户体验。当你第一次按下Ctrl+Z看到程序暂停、输入fg看到它无缝恢复、后台任务安静运行不打断输入时,你感受到的是Unix设计哲学中最人性化的部分:计算机应该适应人的工作流,而非相反。
下一章预告:《文件系统进阶:inode、目录树与持久化存储》
当前的initrd是只读的扁平文件列表。下一章将实现真正的分层文件系统:inode结构、目录项、路径解析、文件创建/删除,以及基于RAMDisk的读写持久化,让你的OS告别“重启即失忆”。
参考资料
- Linux Kernel:
drivers/tty/tty_io.c,drivers/tty/n_tty.c - The Design of the UNIX Operating System (Bach), Chapter 10-11
- xv6 Source:
kernel/console.c,kernel/proc.c(job control) - Stevens APUE Chapter 9 (Process Relationships) & Chapter 19 (Pseudo Terminals)
- 本系列完整代码:[你的GitHub仓库链接](Commit:
t1t2y3j)
📝 作者注:这是《从零手写操作系统》系列的第17篇。终端子系统是第一个让你同时面对“硬件时序”、“内核状态机”和“用户交互预期”三重约束的章节。如果你的Ctrl+Z有时有效有时无效,大概率是键盘IRQ与信号投递之间的竞态——试着在tty_input_char入口处加cli/sti保护临界区。建议先实现纯canonical模式(无作业控制),确认行编辑完美后再叠加SIGTSTP和fg/bg。终端的正确性是用无数次手动按键测出来的,自动化测试在这里几乎无用。 下一章,我们让文件“活”起来!


openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐

所有评论(0)