2.19 终端、pty、作业控制与守护进程
Ctrl-C 为什么能杀掉前台程序,而不是被程序当成普通字符读进来?ssh 远端没有物理终端,为什么 vim/top 还能正常工作?Ctrl-Z、fg、bg 背后是谁在管?守护进程为什么要 setsid 再二次 fork?
用 termios 切换规范/非规范/原始模式并保证异常退出也能恢复;用 forkpty 在程序里跑一个交互式子进程;解释会话、进程组、控制终端与 SIGTTIN/SIGTTOU;写出符合 systemd 习惯的守护进程骨架。
2.10 把信号讲成「异步事件」,但没有回答一个日常问题:按 Ctrl-C 时,信号是谁发的?键盘并没有直接连到进程,中间隔着一个终端驱动(tty driver)与行规程(line discipline)。它负责行编辑、回显、把特殊字符翻译成信号,也负责在前后台进程组之间分配输入。
伪终端(pty)把这套机制虚拟化:ssh、tmux、docker exec -it、IDE 的集成终端,都是「一端是程序,另一端是一个假的终端」。理解了 pty,你才能解释为什么 ssh host 'ps' 不分配终端、而 ssh -t host 'top' 可以。
守护进程则是另一头:它要脱离终端与作业控制,成为会话首进程、关闭继承的 fd、把工作目录切到 /,这样终端关闭、shell 退出都不会影响它。这一章把「终端 → 会话 → 守护进程」这条线一次讲完。
2.19.1 终端驱动与行规程
终端设备(/dev/ttyS0、/dev/pts/3)在内核里对应一个 tty_struct,数据流经过两层处理:
键盘/串口 行规程(line discipline) 进程
┌──────────┐ 字节流 ┌──────────────────────────────────┐ read ┌──────────┐
│ 终端硬件 │ ────────► │ 规范模式:行编辑、回显、特殊字符 │ ─────► │ cat/vim │
│ 或 pty │ ◄──────── │ Ctrl-C → SIGINT(不进缓冲区) │ write │ │
└──────────┘ │ 非规范:VMIN/VTIME 控制读的粒度 │ ◄───── └──────────┘
│ 原始模式:几乎不做处理 │
└──────────────────────────────────┘
读图:进程看到的不是「键盘扫描码」,而是行规程处理后的字节。Ctrl-C 在规范模式下被行规程截获,变成 SIGINT 发给前台进程组,不会出现在 read 的缓冲区里;只有关掉 ISIG(原始模式)后,它才作为字节 0x03 被程序读到。
三种模式:
| 模式 | 关键标志 | read 行为 | 典型程序 |
|---|---|---|---|
| 规范(canonical) | ICANON | 按行返回,行编辑由内核做 | shell、cat |
| 非规范(noncanonical) | ~ICANON + VMIN/VTIME | 按字节数或超时返回 | 编辑器、游戏 |
| 原始(raw) | cfmakeraw | 每个字节立即返回,无回显/信号 | vim、ssh、串口工具 |
规范模式下的特殊字符(c_cc 数组下标):
| 字符 | 默认键 | 下标 | 作用 |
|---|---|---|---|
VINTR | Ctrl-C | c_cc[VINTR] | 发 SIGINT 给前台进程组 |
VQUIT | Ctrl-\ | c_cc[VQUIT] | 发 SIGQUIT(core dump) |
VERASE | Backspace | c_cc[VERASE] | 删除上一个字符 |
VKILL | Ctrl-U | c_cc[VKILL] | 删除整行 |
VEOF | Ctrl-D | c_cc[VEOF] | 行首时让 read 返回 0(EOF) |
VSUSP | Ctrl-Z | c_cc[VSUSP] | 发 SIGTSTP(停止) |
VSTOP/VSTART | Ctrl-S/Ctrl-Q | — | 流控(IXON) |
Ctrl-D 不是「发送 EOF 字符」,而是「告诉行规程:当前行为空时返回 0」。如果行里还有字符,Ctrl-D 只是让当前行立即交付,read 返回这些字符,不返回 0。
stty -a 打印当前终端的全部属性;stty raw -echo 临时切到原始模式,stty sane 恢复。把终端搞坏时(比如 cat 一个二进制文件),盲打 stty sane 再回车通常能救回来。
2.19.2 termios:读取、修改、恢复
#include <termios.h>
struct termios tio;
if (tcgetattr(STDIN_FILENO, &tio) == -1) {
perror("tcgetattr");
}
/* 修改 tio */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &tio) == -1) {
perror("tcsetattr");
}
tcsetattr 的第三个参数决定「什么时候生效」:
TCSANOW:立即生效;TCSADRAIN:等输出缓冲区写完再生效(改输出参数时用);TCSAFLUSH:丢弃未读输入后再生效(切模式时最常用,避免把旧输入当成新模式下的按键)。
cfmakeraw(&tio) 把常用标志一次清掉:
tio.c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR | IGNCR | ICRNL | IXON);
tio.c_oflag &= ~OPOST;
tio.c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
tio.c_cflag &= ~(CSIZE | PARENB);
tio.c_cflag |= CS8;
tio.c_cc[VMIN] = 1;
tio.c_cc[VTIME] = 0;
非规范模式下 VMIN/VTIME 的组合:
VMIN | VTIME | read 行为 |
|---|---|---|
| >0 | 0 | 阻塞到读满 VMIN 字节 |
| 0 | >0 | 最多等 VTIME×0.1 秒,有数据就返回 |
| >0 | >0 | 读到 VMIN 字节,或两次字节之间超时 VTIME |
| 0 | 0 | 立即返回(轮询) |
下面的程序演示原始模式:每个按键立即读出,Ctrl-C 变成字节 0x03。它用 atexit 与信号处理器双重保险恢复终端——终端程序崩溃后不回显、不回车的「终端坏了」体验,都是因为没恢复。
/* termios_raw.c:切到原始模式,逐键读取,退出时恢复终端 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>
static struct termios saved;
static int restore_needed;
static void restore_terminal(void)
{
if (restore_needed) {
restore_needed = 0;
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved) == -1) {
/* 退出路径上无法补救,只能忽略 */
}
}
}
static void on_signal(int sig)
{
restore_terminal();
_exit(128 + sig);
}
static void install_handler(int sig)
{
struct sigaction sa = {0};
sa.sa_handler = on_signal;
sigemptyset(&sa.sa_mask);
if (sigaction(sig, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
}
int main(void)
{
if (!isatty(STDIN_FILENO)) {
fprintf(stderr, "stdin is not a terminal\n");
return EXIT_FAILURE;
}
if (tcgetattr(STDIN_FILENO, &saved) == -1) {
perror("tcgetattr");
return EXIT_FAILURE;
}
restore_needed = 1;
if (atexit(restore_terminal) != 0) {
fprintf(stderr, "atexit failed\n");
return EXIT_FAILURE;
}
install_handler(SIGINT);
install_handler(SIGTERM);
install_handler(SIGHUP);
struct termios raw = saved;
cfmakeraw(&raw);
raw.c_cc[VMIN] = 1;
raw.c_cc[VTIME] = 0;
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) {
perror("tcsetattr");
return EXIT_FAILURE;
}
/* OPOST 被关掉,输出必须自己写 \r\n */
printf("raw mode; press keys, 'q' to quit\r\n");
fflush(stdout);
for (;;) {
unsigned char c;
ssize_t n = read(STDIN_FILENO, &c, 1);
if (n == -1) {
if (errno == EINTR)
continue;
perror("read");
break;
}
if (n == 0)
break;
printf("key 0x%02x", c);
if (c == 0x03)
printf(" (Ctrl-C, no SIGINT in raw mode)");
if (c == 0x1b)
printf(" (ESC, arrow keys start with this)");
printf("\r\n");
fflush(stdout);
if (c == 'q' || c == 0x04)
break;
}
return EXIT_SUCCESS; /* atexit 恢复终端 */
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g termios_raw.c -o termios_raw
./termios_raw
raw mode; press keys, 'q' to quit
key 0x68
key 0x69
key 0x03 (Ctrl-C, no SIGINT in raw mode)
key 0x71
按 Ctrl-C 不再杀进程,而是读到 0x03;按 q 退出,终端恢复。如果程序被 SIGKILL 杀死,atexit 与处理器都不会运行,终端就留在原始模式——这是 SIGKILL 无法被捕获的直接后果,只能靠 stty sane 或重新登录恢复。
- 「
Ctrl-C是程序自己读到的字符」——规范模式下由行规程转换成SIGINT;原始模式下才是字节0x03。 - 「
Ctrl-D是 EOF 字符」——它是「让当前行为空时read返回 0」的请求;行里还有内容时只交付当前行。 - 「
tcsetattr立即生效」——TCSADRAIN/TCSAFLUSH会延迟;TCSAFLUSH还会丢弃未读输入。 - 「终端属性是进程私有的」——是终端设备的属性,所有打开同一个 tty 的进程共享。一个进程改了,其他进程立刻受影响。
2.19.3 窗口大小、isatty 与 tty 名字
窗口大小不是 termios 的一部分,而是单独的结构:
#include <sys/ioctl.h>
struct winsize ws;
if (ioctl(STDOUT_FILENO, TIOCGWINSZ, &ws) == -1) {
perror("TIOCGWINSZ");
} else {
printf("rows=%u cols=%u\n", ws.ws_row, ws.ws_col);
}
SIGWINCH 在窗口大小变化时发送给前台进程组;vim、top 捕获它来重排界面。ws_xpixel/ws_ypixel 是像素尺寸,很多终端不填(为 0)。
isatty(fd) 判断 fd 是否指向终端;ttyname(fd) 返回设备路径(不可重入,用 ttyname_r)。ctermid(buf) 返回的是「控制终端的标准路径」/dev/tty,不是当前终端的实际设备名;要拿真实设备名用 ttyname。判断「是否交互式」不要用 isatty(STDIN_FILENO) 单一条件:ssh host 'cmd' 没有 tty,但 cmd 可能仍需要知道「输出是不是终端」(isatty(STDOUT_FILENO))。很多程序用「stdout 是 tty 才输出颜色」,用错 fd 会导致重定向时颜色码混进文件。
2.19.4 伪终端:一对虚拟的 master/slave
伪终端是一对设备:master 与 slave。slave 表现得像一个真实终端(有行规程、termios、窗口大小),master 是另一端,程序通过它读写 slave 收到/发出的字节。
用户进程(ssh/tmux/你的程序) 子进程(vim/top/sh)
│ │
│ read/write master read/write slave│
▼ ▼
┌─────────┐ 写入 → 行规程 → 读队列 ┌──────────────┐
│ master │ ─────────────────────────► │ slave (/dev/pts/N) │
│ (fd) │ ◄───────────────────────── │ 控制终端、termios │
└─────────┘ 读队列 ← 行规程 ← 写入 └──────────────┘
▲ ▲
└────────────── forkpty() 建立这一对 ──────────────┘
子进程:setsid + TIOCSCTTY + dup2(slave, 0/1/2)
读图:master 端不是终端,没有 termios;它只是字节通道。slave 端才是终端,vim 通过 isatty(0) 看到它,于是启用全屏模式。ssh 在远端创建一个 pty,把本地键盘字节写到 master,把 slave 的输出读回本地——远端程序完全不知道自己不在物理终端前。
手工打开 pty
#include <fcntl.h>
#include <stdlib.h>
#include <unistd.h>
int master = posix_openpt(O_RDWR | O_NOCTTY);
if (master == -1) { perror("posix_openpt"); }
if (grantpt(master) == -1) { perror("grantpt"); } /* 设置 slave 属主/权限 */
if (unlockpt(master) == -1) { perror("unlockpt"); } /* 解锁 slave */
char *name = ptsname(master); /* /dev/pts/N,不可重入 */
grantpt 通过辅助程序或 devpts 挂载选项完成权限设置;unlockpt 之前 slave 不能被打开。ptsname_r 是可重入版本。
forkpty:一步建好并连接子进程
#include <pty.h> /* Linux;macOS/BSD 用 <util.h> */
int master;
struct winsize ws = { .ws_row = 24, .ws_col = 80 };
pid_t pid = forkpty(&master, NULL, NULL, &ws);
if (pid == -1) {
perror("forkpty");
} else if (pid == 0) {
execl("/bin/sh", "sh", "-c", "tty; stty size", (char *)NULL);
_exit(127);
}
forkpty 内部做了:openpty 创建 master/slave、fork、子进程 login_tty(setsid + TIOCSCTTY + dup2 slave 到 0/1/2 + 关闭多余 fd)。父进程拿到 master,读写它就是与子进程的终端会话。
下面的程序在 pty 里跑一条 shell 命令,并把子进程的终端输出原样打印:
/* pty_run.c:在伪终端里运行命令并读回输出 */
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
#if defined(__linux__)
#include <pty.h>
#else
#include <util.h>
#endif
int main(void)
{
int master = -1;
struct winsize ws = { .ws_row = 24, .ws_col = 80 };
pid_t pid = forkpty(&master, NULL, NULL, &ws);
if (pid == -1) {
perror("forkpty");
return EXIT_FAILURE;
}
if (pid == 0) {
/* 子进程:stdin/stdout/stderr 已连到 slave,且已 setsid */
execl("/bin/sh", "sh", "-c",
"tty; stty size; echo hello-from-pty", (char *)NULL);
_exit(127);
}
char buf[4096];
for (;;) {
ssize_t n = read(master, buf, sizeof buf);
if (n > 0) {
if (write(STDOUT_FILENO, buf, (size_t)n) != n) {
perror("write stdout");
break;
}
continue;
}
if (n == -1 && errno == EINTR)
continue;
if (n == -1 && errno == EIO)
break; /* Linux:slave 全部关闭时 master 读返回 EIO */
break; /* 0:其他系统的 EOF */
}
close(master);
int status;
while (waitpid(pid, &status, 0) == -1) {
if (errno != EINTR) {
perror("waitpid");
return EXIT_FAILURE;
}
}
if (WIFEXITED(status))
printf("\nchild exited with %d\n", WEXITSTATUS(status));
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g pty_run.c -o pty_run -lutil
./pty_run
/dev/pts/5
24 80
hello-from-pty
child exited with 0
tty 输出 /dev/pts/5,stty size 输出 24 80(来自 forkpty 传入的 winsize)。这说明子进程真的认为自己在一个 24×80 的终端里。
TIOCSWINSZ 可以在运行中改窗口大小:
struct winsize ws = { .ws_row = 40, .ws_col = 120 };
ioctl(master, TIOCSWINSZ, &ws); /* 内核会向前台进程组发 SIGWINCH */
pty 的常见用途:script(1) 录制终端会话、tmux 的多窗口、docker exec -it、CI 里给需要 tty 的命令套一个 pty、测试交互式程序(Expect 类工具)。
- 「master 读返回 0 表示结束」——Linux 上 slave 全部关闭时返回
-1、errno=EIO;BSD/macOS 返回 0。两个都要处理。 - 「master 也有
termios」——master 不是终端,tcgetattr(master)返回ENOTTY;要改属性用 slave(或forkpty的termp参数)。 - 「
forkpty后子进程还会收到终端的SIGINT」——会,但前提是 slave 是它的控制终端且它在前台进程组。forkpty已经做了setsid+TIOCSCTTY。
2.19.5 会话、进程组与作业控制
三个层级:
| 概念 | 内核对象 | 作用 |
|---|---|---|
| 会话(session) | pid_t(会话首进程的 PID) | 一组进程组,最多一个控制终端 |
| 进程组(process group) | pid_t(组长 PID) | 信号可以发给整组;作业控制的单位 |
| 控制终端(controlling terminal) | tty_struct | 与一个会话绑定,产生 SIGHUP/SIGINT/SIGTTIN |
setsid() 创建新会话:调用者成为会话首进程与新进程组的组长,脱离原来的控制终端。它要求调用者不是进程组组长,否则 EPERM——这就是守护进程要先 fork 的原因。
作业控制的核心规则:
- 终端有一个前台进程组(
tpgid)。终端的输入(键盘)只给前台组;Ctrl-C/Ctrl-Z产生的信号也只发给前台组。 - 后台进程组读终端 → 内核发
SIGTTIN,默认停止该组; - 后台进程组写终端 → 若终端设了
TOSTOP,发SIGTTOU,默认停止; - shell 用
tcsetpgrp(fd, pgid)切换前台组,用waitpid(..., WUNTRACED)感知停止,用SIGCONT恢复。
./long-job & # 后台运行
jobs -l # 看作业号与 PID
fg %1 # 调到前台
# Ctrl-Z 后:
bg %1 # 后台继续
kill %1 # 给整个进程组发信号(kill 的负 PID 语义)
kill -- -1234 给进程组 1234 发信号;kill 1234 只给单个进程。shell 的 %1 是作业号,内部会转成进程组。
一个进程组是孤儿进程组(orphaned process group)当且仅当组内每个成员的父进程要么在组内、要么不在同一会话。POSIX 规定:孤儿进程组里的停止信号(SIGTSTP/SIGTTIN/SIGTTOU)会被丢弃,避免一个没人能 fg 的进程永远停住。守护进程正是靠 setsid 脱离会话,从而不受终端作业控制影响。
2.19.6 守护进程:脱离终端的标准步骤
守护进程是没有控制终端、在后台长期运行的进程。它通常由系统启动脚本或服务管理器拉起,生命周期与登录会话无关。标准做法是「两次 fork + setsid + 重置环境」。
为什么要两次 fork:
- 第一次
fork后父进程退出,子进程被 init 收养,shell 立刻返回; - 子进程
setsid()成为新会话首进程,脱离控制终端; - 第二次
fork后,孙进程不再是会话首进程。会话首进程打开一个 tty(且不带O_NOCTTY)会把它变成控制终端;不是首进程就不会。二次 fork 彻底杜绝了「重新获得控制终端」的可能。
完整骨架:
/* daemonize.c:标准守护进程骨架 */
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <syslog.h>
#include <unistd.h>
static volatile sig_atomic_t stop;
static void on_term(int sig)
{
(void)sig;
stop = 1;
}
int main(void)
{
/* 1. 第一次 fork:父进程退出,shell 拿到提示符 */
pid_t pid = fork();
if (pid == -1) {
perror("fork 1");
return EXIT_FAILURE;
}
if (pid > 0)
_exit(EXIT_SUCCESS);
/* 2. 新建会话,脱离控制终端 */
if (setsid() == -1) {
perror("setsid");
return EXIT_FAILURE;
}
/* 3. 第二次 fork:不再是会话首进程,无法再获得控制终端 */
pid = fork();
if (pid == -1) {
perror("fork 2");
return EXIT_FAILURE;
}
if (pid > 0)
_exit(EXIT_SUCCESS);
/* 4. 重置环境 */
umask(0);
if (chdir("/") == -1) {
perror("chdir");
return EXIT_FAILURE;
}
/* 5. 关闭/重定向标准 fd 到 /dev/null */
int devnull = open("/dev/null", O_RDWR);
if (devnull == -1) {
perror("open /dev/null");
return EXIT_FAILURE;
}
for (int fd = 0; fd < 3; fd++) {
if (dup2(devnull, fd) == -1) {
perror("dup2");
return EXIT_FAILURE;
}
}
if (devnull > 2)
close(devnull);
/* 6. 日志走 syslog,不再依赖 stdout/stderr */
openlog("daemonize-demo", LOG_PID, LOG_DAEMON);
syslog(LOG_INFO, "started, pid=%ld", (long)getpid());
struct sigaction sa = {0};
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask);
if (sigaction(SIGTERM, &sa, NULL) == -1 ||
sigaction(SIGINT, &sa, NULL) == -1) {
syslog(LOG_ERR, "sigaction: %s", strerror(errno));
return EXIT_FAILURE;
}
while (!stop)
pause(); /* 信号打断后检查 stop */
syslog(LOG_INFO, "stopped");
closelog();
return EXIT_SUCCESS;
}
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g daemonize.c -o daemonize
./daemonize
pgrep -a daemonize # 找到进程
sudo kill -TERM <pid>
$ ./daemonize
$ pgrep -a daemonize
52400 ./daemonize
$ sudo tail -2 /var/log/syslog
daemonize-demo[52400]: started, pid=52400
daemonize-demo[52400]: stopped
六个步骤缺一不可:
- 少了
setsid:进程仍在登录会话里,终端关闭时收到SIGHUP; - 少了二次 fork:会话首进程重新打开 tty 会获得控制终端;
- 少了
umask(0):创建的文件权限受继承的umask影响,可能过宽或过窄; - 少了
chdir("/"):工作目录可能是即将被卸载的挂载点,导致umount失败; - 少了 fd 重定向:从 shell 继承的 fd 会让管道/终端保持打开,
read端永远等不到 EOF; - 少了
syslog:日志写到已关闭的 stdout,静默丢失。
与 systemd 配合
现代发行版用 systemd 管理服务,Type= 决定它怎么判断「服务已启动」:
Type= | 服务该做什么 | 适用 |
|---|---|---|
simple | 前台运行,不要 fork | 大多数新服务(推荐) |
forking | 自己 daemonize,父进程退出 | 传统守护进程 |
notify | 就绪后调用 sd_notify(0, "READY=1") | 需要精确就绪信号 |
exec | 主进程 exec 后即视为启动 | 轻量封装 |
Type=simple 时不要再自己 daemonize:systemd 已经把服务放进独立 cgroup/会话,再 fork 会让它跟踪错 PID。这是「传统守护进程」与「容器/现代服务管理器」最大的观念差异。
- 「
daemon(3)会做二次 fork」——glibc 的daemon()只做一次 fork +setsid+chdir+ 重定向,不二次 fork。严格场景要自己写。 - 「
setsid在任何进程里都能成功」——进程组组长调用会EPERM,必须先 fork。 - 「守护进程不需要处理
SIGTERM」——systemctl stop默认发SIGTERM,不处理就会在清理到一半时被杀。 - 「
nohup等于守护进程」——nohup只是忽略SIGHUP并重定向输出,进程仍在原会话/进程组里,仍受作业控制影响。
2.19.7 termios 全表:结构体、标志位与 errno
这一节回答:struct termios 的五个标志字段各管什么?c_cc 的每个下标对应哪个键?tcsetattr 的 EINVAL/ENOTTY 分别是什么现场?
结构与函数
#include <termios.h>
struct termios {
tcflag_t c_iflag; /* 输入处理 */
tcflag_t c_oflag; /* 输出处理 */
tcflag_t c_cflag; /* 控制(波特率、字符大小、校验) */
tcflag_t c_lflag; /* 本地(回显、规范模式、信号) */
cc_t c_line; /* 行规程(Linux 上通常为 0/N_TTY) */
cc_t c_cc[NCCS]; /* 特殊字符与 VMIN/VTIME */
speed_t c_ispeed; /* 输入波特率 */
speed_t c_ospeed; /* 输出波特率 */
};
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
tcgetattr | int tcgetattr(int fd, struct termios *t) | 0 | EBADF、ENOTTY、EFAULT | fd 必须是终端 |
tcsetattr | int tcsetattr(int fd, int action, const struct termios *t) | 0 | EBADF、ENOTTY、EINVAL、EFAULT | action:TCSANOW/TCSADRAIN/TCSAFLUSH |
cfgetospeed | speed_t cfgetospeed(const struct termios *t) | 波特率常量 | 不会失败 | 返回值是 B9600 之类,不是数字 |
cfsetospeed | int cfsetospeed(struct termios *t, speed_t s) | 0 | EINVAL | 只改结构体,需 tcsetattr 生效 |
cfgetispeed | speed_t cfgetispeed(const struct termios *t) | 波特率 | 不会失败 | 输入波特率 |
cfsetispeed | int cfsetispeed(struct termios *t, speed_t s) | 0 | EINVAL | 输入波特率 |
tcflush | int tcflush(int fd, int queue) | 0 | EBADF、ENOTTY、EINVAL | TCIFLUSH/TCOFLUSH/TCIOFLUSH |
tcflow | int tcflow(int fd, int action) | 0 | EBADF、ENOTTY、EINVAL | TCOOFF/TCOON/TCIOFF/TCION |
tcdrain | int tcdrain(int fd) | 0 | EBADF、ENOTTY、EINTR | 等输出缓冲区排空 |
tcsendbreak | int tcsendbreak(int fd, int duration) | 0 | EBADF、ENOTTY、EINVAL | 发 break;duration 语义依实现 |
tcgetpgrp | pid_t tcgetpgrp(int fd) | 前台进程组 ID | EBADF、ENOTTY | 读控制终端的前台组 |
tcsetpgrp | int tcsetpgrp(int fd, pid_t pgrp) | 0 | EBADF、ENOTTY、EINVAL、EPERM、ESRCH | 设置前台组;EPERM:组不在同一会话 |
tcgetsid | pid_t tcgetsid(int fd) | 会话 ID | EBADF、ENOTTY | 控制终端的会话首进程 PID |
isatty | int isatty(int fd) | 1/0 | EBADF | 不设置 errno(除非 fd 无效) |
ttyname | char *ttyname(int fd) | 路径 | NULL | 不可重入;多线程用 ttyname_r |
tcsetattr 的 EINVAL 通常是「action 不是三个合法值之一」或「结构体里的 c_cc 越界」。ENOTTY 是「fd 不是终端」——把管道、普通文件、/dev/null 传进来都会得到它。tcsetpgrp 的 EPERM 是「目标进程组不在当前会话」,不是权限不足。
c_iflag:输入处理
| 标志 | 含义 | 常见用途 |
|---|---|---|
IGNBRK | 忽略 break | 串口 |
BRKINT | break 产生 SIGINT | 终端默认 |
IGNPAR | 忽略奇偶校验错误 | 串口 |
PARMRK | 标记奇偶错误(0xff 0x00) | 串口诊断 |
INPCK | 启用奇偶校验 | 串口 |
ISTRIP | 去掉第 8 位 | 7 位终端 |
INLCR | 输入 NL 转 CR | 老式终端 |
IGNCR | 忽略 CR | 老式终端 |
ICRNL | 输入 CR 转 NL | 默认;按 Enter 产生 \n |
IXON | 启用 Ctrl-S/Ctrl-Q 输出流控 | 默认 |
IXANY | 任意键恢复输出 | 少用 |
IXOFF | 启用输入流控 | 串口 |
IMAXBEL | 输入队列满时响铃 | 默认 |
IUTF8 | UTF-8 输入(影响退格) | 现代终端 |
ICRNL 是「为什么 read 读到的是 \n 而不是 \r」的原因;cfmakeraw 会清掉它,所以原始模式下 Enter 变成 \r(0x0d),程序要自己处理。
c_oflag:输出处理
| 标志 | 含义 | 备注 |
|---|---|---|
OPOST | 启用输出后处理 | 关掉后 \n 不会自动变 \r\n |
ONLCR | NL 转 CR-NL | 默认;终端换行必需 |
OCRNL | CR 转 NL | 少用 |
ONOCR | 第 0 列不输出 CR | 少用 |
ONLRET | NL 也执行 CR | 少用 |
OFILL | 用填充字符产生延迟 | 老式终端 |
OFDEL | 填充字符用 DEL | 老式终端 |
NLDLY/CRDLY/TABDLY/BSDLY/VTDLY/FFDLY | 各类延迟 | 现代终端基本不用 |
原始模式必须清 OPOST;否则程序输出的 \n 会被行规程改成 \r\n,与程序自己写的 \r\n 叠加成 \r\r\n。清掉 OPOST 后,程序要自己写 \r\n。
c_cflag:控制
| 标志 | 含义 |
|---|---|
CSIZE + CS5/CS6/CS7/CS8 | 字符位数;现代终端用 CS8 |
CSTOPB | 2 个停止位 |
CREAD | 启用接收 |
PARENB | 启用奇偶校验 |
PARODD | 奇校验(否则偶校验) |
HUPCL | 关闭时挂断调制解调器 |
CLOCAL | 忽略调制解调器控制线 |
CRTSCTS | 硬件流控(RTS/CTS) |
CBAUD + B9600… | 波特率;cfsetospeed 更可移植 |
c_lflag:本地模式
| 标志 | 含义 | 关掉后的效果 |
|---|---|---|
ISIG | 启用 VINTR/VQUIT/VSUSP 信号 | Ctrl-C 变成字节 0x03 |
ICANON | 规范模式(按行) | 按字节/VMIN 返回 |
ECHO | 回显输入 | 按键不显示 |
ECHOE | 回显退格(VERASE 删除字符) | 退格显示为 ^H |
ECHOK | 回显 VKILL 删除整行 | 删行不回显 |
ECHONL | 回显 NL | 不回显换行 |
ECHOCTL | 控制字符回显为 ^X | 显示原始字节 |
ECHOPRT | 回显被删除的字符 | 少用 |
ECHOKE | 回显 VKILL 的删除效果 | 少用 |
FLUSHO | 丢弃输出 | 少用 |
NOFLSH | 信号产生时不刷新队列 | 少用 |
TOSTOP | 后台写终端发 SIGTTOU | 默认关闭 |
PENDIN | 重新打印未读输入 | 少用 |
IEXTEN | 扩展处理(VDISCARD/VLNEXT/VWERASE) | 原始模式关掉 |
cfmakeraw 关掉的是 IGNBRK|BRKINT|PARMRK|ISTRIP|INLCR|IGNCR|ICRNL|IXON、OPOST、ECHO|ECHONL|ICANON|ISIG|IEXTEN,并把 CSIZE 设为 CS8、VMIN=1、VTIME=0。
c_cc:特殊字符与 VMIN/VTIME
| 下标 | 默认键 | 作用 | 仅规范模式 |
|---|---|---|---|
VINTR | Ctrl-C | SIGINT | 是 |
VQUIT | Ctrl-\ | SIGQUIT | 是 |
VERASE | Backspace | 删除一个字符 | 是 |
VKILL | Ctrl-U | 删除整行 | 是 |
VEOF | Ctrl-D | 空行时 EOF | 是 |
VEOL | 无 | 额外的行结束符 | 是 |
VEOL2 | 无 | 第二个行结束符 | 是 |
VSTART | Ctrl-Q | 恢复输出 | IXON |
VSTOP | Ctrl-S | 暂停输出 | IXON |
VSUSP | Ctrl-Z | SIGTSTP | 是 |
VREPRINT | Ctrl-R | 重打印当前行 | IEXTEN |
VWERASE | Ctrl-W | 删除一个单词 | IEXTEN |
VLNEXT | Ctrl-V | 字面量下一个字符 | IEXTEN |
VDISCARD | Ctrl-O | 丢弃输出 | IEXTEN |
VMIN | 1 | 非规范模式最少字节数 | 否 |
VTIME | 0 | 非规范模式超时(0.1 秒) | 否 |
c_cc 是 cc_t 数组,元素个数 NCCS(Linux 上 32)。不是所有下标在所有平台都存在(VEOL2/VSWTC 等是扩展);写可移植代码时用条件编译。
- 「
tcsetattr改的是进程属性」——改的是终端设备属性;同一终端上的所有进程共享。 - 「
TCSANOW会丢弃未读输入」——不会,只有TCSAFLUSH会;TCSADRAIN等输出写完。 - 「
VMIN/VTIME在规范模式下也生效」——不生效;它们只在ICANON关闭时使用。 - 「
cfmakeraw会设置CLOCAL/CREAD」——不会;它只动输入/输出/本地标志与字符大小,串口场景要自己设CLOCAL|CREAD。 - 「
ENOTTY表示权限不足」——不是,表示 fd 不是终端。权限问题是EACCES/EPERM。
2.19.8 三种终端模式:规范、非规范、原始
这一节回答:read 什么时候按行返回、什么时候按字节返回?VMIN/VTIME 的四种组合到底怎么算?原始模式与「非规范 + 关回显」差在哪?
规范模式
ICANON 置位时,行规程维护一个行缓冲区:
read只在遇到行结束符(NL、EOL、EOL2)或VEOF时返回;- 行编辑(
VERASE/VKILL/VWERASE)由内核完成; VINTR/VQUIT/VSUSP不进入缓冲区,直接产生信号;read的count小于一行时,返回部分行,剩余留在内核;下一次read继续。
规范模式适合 shell、cat、readline 之外的简单程序;复杂交互(vim)必须关掉它,自己处理每个按键。
非规范模式
ICANON 清零后,read 的行为由 VMIN/VTIME 决定:
VMIN | VTIME | read 返回条件 | 返回值 |
|---|---|---|---|
| >0 | 0 | 读满 VMIN 字节 | VMIN |
| 0 | >0 | 有数据立即返回;无数据等 VTIME×0.1s 后返回 0 | 0..n |
| >0 | >0 | 读满 VMIN,或字节间超时 VTIME | 已读字节数 |
| 0 | 0 | 立即返回(轮询) | 0 或可用字节 |
注意 VTIME 的单位是 0.1 秒,不是毫秒。VMIN=0, VTIME=0 是纯轮询,容易把 CPU 打满;事件循环里应配合 poll/select,而不是忙等。
原始模式
原始模式 = 非规范 + 关回显 + 关信号 + 关输出处理 + 8 位。cfmakeraw 一次性设置,但不同系统的实现略有差异。典型串口/全屏程序的原始模式还要:
CLOCAL | CREAD:忽略调制解调器线,启用接收;CS8:8 位字符;- 不清
PARENB时可能带校验位,二进制协议要清掉; - 自己处理
\r/\n、Ctrl-C、箭头键转义序列。
「非规范 + 保留 ISIG」是 cbreak 模式:按键立即返回,但 Ctrl-C 仍然产生 SIGINT。它适合「逐键读取但保留中断」的程序,例如分页器。
| 需求 | 模式 | 关键设置 |
|---|---|---|
| 逐行输入、行编辑 | 规范 | ICANON 加 ECHO 加 ISIG |
| 逐键输入、保留 Ctrl-C | cbreak | ~ICANON,保留 ISIG,VMIN=1 |
| 全屏/串口/二进制 | raw | cfmakeraw,CLOCAL 加 CREAD,CS8 |
| 非阻塞读 | 非规范 | VMIN=0,VTIME=0 + poll |
| 带超时的读 | 非规范 | VMIN=0,VTIME=N 或 poll 超时 |
2.19.9 pty 全表:openpty、forkpty 与内核路径
这一节回答:forkpty 到底做了哪几步?master 和 slave 各自支持哪些 ioctl?为什么 master 读不到 termios?
创建 pty 的两条路
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
posix_openpt | int posix_openpt(int flags) | master fd | EMFILE、ENFILE、EINVAL、ENOMEM | flags:O_RDWR、O_NOCTTY |
grantpt | int grantpt(int fd) | 0 | EACCES、EBADF、EINVAL | 设置 slave 属主/权限 |
unlockpt | int unlockpt(int fd) | 0 | EACCES、EBADF、EINVAL | 解锁 slave,之后才能 open |
ptsname | char *ptsname(int fd) | slave 路径 | NULL | 不可重入;用 ptsname_r |
ptsname_r | int ptsname_r(int fd, char *buf, size_t len) | 0 | EBADF、ENOTTY、ERANGE | 可重入版本 |
openpty | int openpty(int *amaster, int *aslave, char *name, const struct termios *t, const struct winsize *ws) | 0 | ENOMEM、EMFILE、EINVAL | 一步创建 master/slave |
forkpty | pid_t forkpty(int *amaster, char *name, const struct termios *t, const struct winsize *ws) | 子进程 PID(父进程)/0(子进程) | EAGAIN、ENOMEM、EMFILE | openpty + fork + login_tty |
login_tty | int login_tty(int fd) | 0 | EINVAL、EPERM | setsid + TIOCSCTTY + dup2 到 0/1/2 |
forkpty 的子进程流程:
openpty创建 master/slave;fork;- 子进程
close(master),setsid()建新会话; ioctl(slave, TIOCSCTTY, 0)把 slave 设为控制终端;dup2(slave, 0/1/2),关闭多余 fd;- 返回 0,子进程
exec目标程序。
父进程拿到 master,读写它就是与子进程的终端会话。forkpty 的 termp 参数设置 slave 的初始 termios,winsize 设置初始窗口大小;传 NULL 用默认值。
pty 的 ioctl
ioctl | 作用 | 适用 |
|---|---|---|
TIOCGWINSZ/TIOCSWINSZ | 读/写窗口大小 | master 与 slave |
TIOCGPTN | 取 slave 编号(/dev/pts/N 的 N) | master |
TIOCSPTLCK | 锁定/解锁 slave | master |
TIOCSCTTY | 把 fd 设为控制终端 | slave(会话首进程) |
TIOCNOTTY | 放弃控制终端 | 会话首进程 |
TIOCSTI | 向输入队列注入字节 | 需要权限;现代内核默认禁用 |
TCGETS/TCSETS/TCSETSW/TCSETSF | termios 读写(tcgetattr 的底层) | slave;master 返回 ENOTTY |
tcgetattr(master) 返回 ENOTTY:master 不是终端,没有行规程;它只是字节通道。要改属性,改 slave(forkpty 的 termp,或打开 /dev/pts/N)。TIOCSWINSZ 在 master 上可用,内核会向 slave 的前台进程组发 SIGWINCH。
内核路径
| 用户动作 | 内核路径 | 关键数据结构 |
|---|---|---|
posix_openpt | devpts 挂载点 → ptmx_open | struct tty_struct、struct tty_port |
grantpt/unlockpt | pts 的属主/TIOCSPTLCK | devpts inode |
open("/dev/pts/N") | pts_open → tty_init_dev | slave tty_struct |
| 写 master | pty_write → tty_ldisc_receive_buf → n_tty_receive_buf | 行规程缓冲区 |
| 读 slave | n_tty_read | read_buf、echo_buf |
forkpty | login_tty → setsid + TIOCSCTTY | signal_struct、tty_struct |
pty 由 drivers/tty/pty.c 实现:master 与 slave 是一对 tty_struct,通过 tty->link 互指。写 master 的字节进入 slave 的行规程(n_tty),经过规范模式/回显/信号处理后放进 slave 的读缓冲区;反向亦然。devpts 文件系统负责分配 /dev/pts/N 的 inode 与权限。
- 「master 也是终端,可以
tcgetattr」——不是;master 返回ENOTTY。改属性用 slave。 - 「master 读返回 0 就是结束」——Linux 上 slave 全关时返回
-1/EIO;BSD/macOS 可能返回 0。两者都要当 EOF 处理。 - 「
forkpty的子进程自动有控制终端」——是,但只在login_tty成功后;如果setsid失败(子进程是组组长),控制终端不会建立。 - 「
TIOCSTI可以随便注入输入」——现代内核默认禁用(dev.tty.legacy_tiocsti=0),且需要CAP_SYS_ADMIN;不要依赖它做测试。
2.19.10 进程组、会话与控制终端:API 与信号
这一节回答:setsid 为什么要求调用者不是进程组组长?tcsetpgrp 的 EPERM 是什么?后台读终端到底收到哪个信号?
API 表
| API | 签名 | 成功 | 失败 errno | 说明 |
|---|---|---|---|---|
getpgrp | pid_t getpgrp(void) | 当前进程组 ID | 不会失败 | 等价于 getpgid(0) |
getpgid | pid_t getpgid(pid_t pid) | 进程组 ID | ESRCH、EPERM | pid=0 表示当前进程 |
setpgid | int setpgid(pid_t pid, pid_t pgid) | 0 | EACCES、EINVAL、EPERM、ESRCH | 创建/加入进程组;pgid=0 用 pid 作组 ID |
setsid | pid_t setsid(void) | 新会话 ID | EPERM | 调用者不能是进程组组长 |
getsid | pid_t getsid(pid_t pid) | 会话 ID | ESRCH、EPERM | pid=0 当前进程 |
tcgetpgrp | pid_t tcgetpgrp(int fd) | 前台组 ID | EBADF、ENOTTY | fd 必须是控制终端 |
tcsetpgrp | int tcsetpgrp(int fd, pid_t pgrp) | 0 | EBADF、ENOTTY、EINVAL、EPERM、ESRCH | 目标组必须在同一会话 |
tcgetsid | pid_t tcgetsid(int fd) | 会话 ID | EBADF、ENOTTY | 控制终端的会话 |
kill | int kill(pid_t pid, int sig) | 0 | EINVAL、EPERM、ESRCH | pid < -1:发给进程组 -pid |
setsid 的 EPERM 是「调用者已经是进程组组长」。所以守护进程必须先 fork,让子进程继承父进程的组 ID 但不是组长,再 setsid。tcsetpgrp 的 EPERM 是「目标组不在当前会话」;shell 只能把自己会话里的作业调到前台。
作业控制信号
| 信号 | 默认动作 | 触发 | 捕获后的责任 |
|---|---|---|---|
SIGTTIN | 停止 | 后台进程组读控制终端 | 通常不捕获;让 shell 用 fg 恢复 |
SIGTTOU | 停止 | 后台进程组写控制终端(TOSTOP)或 tcsetpgrp | 同上 |
SIGTSTP | 停止 | Ctrl-Z(VSUSP) | 捕获时要自己处理停止/恢复 |
SIGCONT | 继续 | fg/bg/kill -CONT | 恢复后重新初始化终端/界面 |
SIGHUP | 终止 | 控制终端关闭、会话首进程退出 | 守护进程/长任务要决定是否忽略 |
SIGTTIN/SIGTTOU 对孤儿进程组会被丢弃(POSIX),否则一个没人能 fg 的进程会永远停住。这也是守护进程 setsid 脱离会话后不再受作业控制影响的原因。
前台/后台的切换流程
shell 的 fg 大致做四件事:
tcsetpgrp(ttyfd, job_pgid)把作业设为前台;kill(-job_pgid, SIGCONT)唤醒被停止的进程;waitpid(job_pid, &status, WUNTRACED)等它停止或退出;- 作业停止/退出后,
tcsetpgrp(ttyfd, shell_pgid)把 shell 拿回前台。
waitpid 的 WUNTRACED 让「子进程被停止」也返回;WCONTINUED 让「子进程被 SIGCONT 继续」返回。没有这两个标志,shell 无法感知 Ctrl-Z。
ps -o pid,ppid,pgid,sid,tpgid,stat,comm:一次看全五个 ID 与状态。ps -e -o pid,pgid,sid,tty,stat,comm | grep ' T ':找被停止的进程。tty/readlink /proc/self/fd/0:确认控制终端。cat /proc/$PID/stat:第 5/6/7 字段是 pgrp/session/tty。kill -TTIN <pid>/kill -CONT <pid>:手工触发/恢复。setsid sleep 100 &:观察新会话(sid等于pid)。
2.19.11 守护进程与 systemd:Type= 全表
这一节回答:Type=simple 与 Type=forking 的本质区别是什么?Type=notify 的 READY=1 什么时候发?sd_notify 的 socket 在哪?
Type= 的语义
Type= | systemd 认为「启动完成」的时机 | 服务该怎么做 | 典型场景 |
|---|---|---|---|
simple | ExecStart 的进程 fork/exec 后立即 | 前台运行,不 daemonize | 大多数新服务 |
exec | execve 成功后 | 前台;比 simple 稍晚 | 需要确认 exec 成功 |
forking | 父进程退出、子进程继续 | 自己 daemonize;建议配 PIDFile= | 传统守护进程 |
oneshot | 主进程退出后 | 跑完就退出;可配 RemainAfterExit= | 初始化脚本、迁移 |
notify | 收到 READY=1 | 调 sd_notify(0, "READY=1") | 需要精确就绪 |
notify-reload | 收到 READY=1;重载时等 RELOADING=1→READY=1 | 同 notify + 重载协议 | 支持热重载的服务 |
dbus | 在 D-Bus 上取得名字 | 调 sd_bus_request_name | D-Bus 服务 |
idle | 所有作业完成后再启动 | 前台 | 控制台输出,避免与日志混杂 |
Type=simple 时不要再 daemon()/二次 fork。systemd 已经用 cgroup 跟踪主进程;主进程 fork 后退出,systemd 会认为服务失败,可能触发 Restart= 循环。
Type=forking 要配 PIDFile=/run/foo.pid,否则 systemd 只能靠猜测;GuessMainPID= 不可靠。Type=notify 要配 NotifyAccess=main(默认)或 all,否则 sd_notify 的消息会被丢弃。
sd_notify 协议
sd_notify(unset_environment, state) 是一个用户态函数,不需要链接 libsystemd 也能实现:向 $NOTIFY_SOCKET 指向的 UNIX datagram socket 发送一行 KEY=VALUE。
| 字段 | 含义 |
|---|---|
READY=1 | 服务就绪 |
RELOADING=1 | 开始重载 |
STOPPING=1 | 开始停止 |
STATUS=... | 人类可读状态,systemctl status 显示 |
MAINPID=... | 主进程 PID(forking 服务用) |
WATCHDOG=1 | 喂看门狗 |
WATCHDOG=trigger | 主动触发看门狗失败 |
ERRNO=... | 失败错误码 |
FDSTORE=1 | 保存 fd 到 systemd |
socket 地址可以是路径(/run/systemd/notify)或抽象命名空间(以 @ 开头)。sd_notify 在 NOTIFY_SOCKET 未设置时直接返回 0,所以本地调试不会报错。
unit 文件的关键字段
[Unit]
Description=Demo notify service
After=network.target
[Service]
Type=notify
ExecStart=/usr/local/bin/sd_notify_demo
NotifyAccess=main
WatchdogSec=5
Restart=on-failure
RestartSec=2
User=demo
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
| 字段 | 作用 | 常见错误 |
|---|---|---|
Restart= | no/on-success/on-failure/on-abnormal/always | always + 立即崩溃 = 重启风暴 |
RestartSec= | 重启前等待 | 太短会打爆日志 |
WatchdogSec= | 看门狗超时 | 服务不喂 WATCHDOG=1 会被杀 |
NotifyAccess= | 谁能发 sd_notify | main 时子进程发的会被丢弃 |
User=/Group= | 运行身份 | 需要文件权限配合 |
NoNewPrivileges= | 禁止提权 | 与需要 setuid 的程序冲突 |
ProtectSystem= | 只读挂载 /usr//etc | 需要写 /etc 的服务会失败 |
PrivateTmp= | 独立 /tmp | 与其他服务共享 /tmp 会看不到文件 |
systemctl show -p Type,NotifyAccess,WatchdogUSec,Restart <unit> 可以直接核对生效值;systemd-analyze verify <unit> 做静态检查。
daemon()、nohup 与 setsid 的分工
| 手段 | 做什么 | 不做什么 |
|---|---|---|
daemon(3) | fork + setsid + chdir("/") + 重定向 fd | 不二次 fork;不处理 SIGTERM;不写 pidfile |
| 二次 fork | 彻底脱离控制终端 | 不重定向 fd;不写日志 |
nohup | 忽略 SIGHUP,重定向 stdout/stderr | 不脱离会话/进程组;仍受作业控制 |
setsid 命令 | 新建会话运行程序 | 不重定向 fd;不处理信号 |
| systemd | cgroup、日志、重启、看门狗、权限 | 需要 unit 文件与正确的 Type= |
- 「
daemon()会二次 fork」——glibc 的daemon()只 fork 一次;严格脱离控制终端要自己二次 fork。 - 「
setsid总能成功」——进程组组长调用返回EPERM;先 fork。 - 「
Type=simple的服务可以自己 fork」——不行;主进程退出会被当成失败。 - 「
sd_notify必须链接 libsystemd」——不必;协议就是发一行文本到$NOTIFY_SOCKET。 - 「
nohup就是守护进程」——不是;它只忽略SIGHUP并重定向输出,进程仍在原会话。
2.19.12 程序一:作业控制与 SIGTTIN
这个程序必须在真实终端里运行。父进程创建一个后台进程组,子进程尝试读终端,内核发 SIGTTIN 把它停止;父进程用 waitpid(WUNTRACED) 观察到停止,tcsetpgrp 把它调到前台,SIGCONT 恢复。
/* job_control.c — 后台读终端 -> SIGTTIN -> fg 恢复 */
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
int ttyfd = open("/dev/tty", O_RDWR | O_CLOEXEC);
if (ttyfd == -1) {
fprintf(stderr, "no controlling terminal: %s\n", strerror(errno));
return EXIT_FAILURE;
}
struct termios saved;
if (tcgetattr(ttyfd, &saved) == -1) die("tcgetattr");
pid_t shell_pgid = getpgrp();
pid_t pid = fork();
if (pid == -1) die("fork");
if (pid == 0) {
/* 先改终端属性(此时仍是前台),再退到后台 */
struct termios tio;
if (tcgetattr(STDIN_FILENO, &tio) == -1) _exit(1);
tio.c_lflag &= ~(tcflag_t)ICANON;
tio.c_cc[VMIN] = 0;
tio.c_cc[VTIME] = 10; /* 1 秒 */
if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &tio) == -1) _exit(1);
/* 子进程:新建进程组,成为后台组 */
if (setpgid(0, 0) == -1) die("setpgid child");
char c;
ssize_t n = read(STDIN_FILENO, &c, 1);
if (n == -1 && errno == EINTR)
n = 0; /* 被 SIGCONT 打断也算正常 */
printf("child: read returned %zd (errno=%d)\n", n, errno);
fflush(stdout);
_exit(0);
}
/* 父进程:等子进程被 SIGTTIN 停止 */
int status;
if (waitpid(pid, &status, WUNTRACED) == -1) die("waitpid");
if (WIFSTOPPED(status) && WSTOPSIG(status) == SIGTTIN) {
printf("parent: child stopped by SIGTTIN (pgid=%ld)\n", (long)pid);
if (tcsetpgrp(ttyfd, pid) == -1) die("tcsetpgrp");
if (kill(-pid, SIGCONT) == -1) die("kill SIGCONT");
printf("parent: gave terminal to child and continued it\n");
} else {
printf("parent: unexpected status=0x%x\n", status);
}
if (waitpid(pid, &status, 0) == -1) die("waitpid 2");
if (WIFEXITED(status))
printf("parent: child exited %d\n", WEXITSTATUS(status));
/* 拿回前台并恢复终端属性 */
if (tcsetpgrp(ttyfd, shell_pgid) == -1) die("tcsetpgrp back");
if (tcsetattr(ttyfd, TCSAFLUSH, &saved) == -1) die("tcsetattr restore");
close(ttyfd);
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
job_control.c -o job_control
./job_control
# parent: child stopped by SIGTTIN (pgid=...)
# parent: gave terminal to child and continued it
# child: read returned 0 (errno=0)
# parent: child exited 0
关键点:
- 子进程
setpgid(0,0)新建进程组,但父进程仍在原前台组,所以子进程是后台; - 后台组读控制终端 → 内核发
SIGTTIN→ 子进程停止; - 父进程
waitpid(WUNTRACED)才能看到「停止」这个状态;没有WUNTRACED会一直等退出; tcsetpgrp把子进程组设为前台,kill(-pid, SIGCONT)恢复;- 子进程设了
VMIN=0/VTIME=10,恢复后 1 秒超时返回 0,不会永久挂住。
如果没有真实终端(CI、重定向),open("/dev/tty") 会失败,程序直接退出。这正是「作业控制只在有控制终端时存在」的体现。
2.19.13 程序二:forkpty 双向交互
这个程序在 pty 里跑一个交互式 shell,把本地 stdin 转发给 master,把 master 的输出转发到 stdout,并处理 SIGWINCH 同步窗口大小。
/* pty_interactive.c — forkpty + poll 双向转发 */
#define _GNU_SOURCE
#include <errno.h>
#include <poll.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>
#if defined(__linux__)
#include <pty.h>
#else
#include <util.h>
#endif
static volatile sig_atomic_t winch;
static void on_winch(int sig)
{
(void)sig;
winch = 1;
}
static void die(const char *msg)
{
perror(msg);
exit(EXIT_FAILURE);
}
int main(void)
{
struct winsize ws = { .ws_row = 24, .ws_col = 80 };
int master = -1;
pid_t pid = forkpty(&master, NULL, NULL, &ws);
if (pid == -1) die("forkpty");
if (pid == 0) {
execl("/bin/sh", "sh", "-i", (char *)NULL);
_exit(127);
}
struct sigaction sa = {0};
sa.sa_handler = on_winch;
sigemptyset(&sa.sa_mask);
sigaction(SIGWINCH, &sa, NULL);
int stdin_open = 1;
for (;;) {
if (winch) {
winch = 0;
struct winsize cur;
if (ioctl(STDIN_FILENO, TIOCGWINSZ, &cur) == 0)
ioctl(master, TIOCSWINSZ, &cur);
}
struct pollfd pfd[2] = {
{ .fd = master, .events = POLLIN },
{ .fd = stdin_open ? STDIN_FILENO : -1, .events = POLLIN },
};
int n = poll(pfd, 2, -1);
if (n == -1) {
if (errno == EINTR) continue;
perror("poll");
break;
}
if (pfd[0].revents & (POLLIN | POLLHUP)) {
char buf[4096];
ssize_t r = read(master, buf, sizeof buf);
if (r > 0) {
if (write(STDOUT_FILENO, buf, (size_t)r) != r) break;
} else if (r == -1 && errno == EIO) {
break; /* slave 全关 */
} else if (r == 0) {
break;
} else if (errno != EINTR) {
perror("read master");
break;
}
}
if (stdin_open && (pfd[1].revents & (POLLIN | POLLHUP))) {
char buf[1024];
ssize_t r = read(STDIN_FILENO, buf, sizeof buf);
if (r > 0) {
if (write(master, buf, (size_t)r) != r) break;
} else {
stdin_open = 0; /* 本地 EOF:不再转发 stdin */
if (write(master, "exit\n", 5) != 5) break;
}
}
}
close(master);
int status;
while (waitpid(pid, &status, 0) == -1) {
if (errno != EINTR) die("waitpid");
}
if (WIFEXITED(status))
printf("\n[child exited %d]\n", WEXITSTATUS(status));
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
pty_interactive.c -o pty_interactive -lutil
./pty_interactive
# $ tty
# /dev/pts/7
# $ stty size
# 24 80
# $ exit
# [child exited 0]
关键点:
forkpty的子进程已经setsid并把 slave 设为控制终端,所以sh -i认为自己是交互式;poll同时等 master 和 stdin,任一可读就转发;- Linux 上 master 在 slave 全关时返回
-1/EIO,要当 EOF 处理; SIGWINCH到达时用TIOCGWINSZ读本地大小,再TIOCSWINSZ写给 master,内核会通知子进程;- 本地 stdin EOF 时写
exit\n给 shell,避免它永久等待。
这个程序就是 script/expect/tmux 的最小骨架:一个字节通道,两端都是「终端」。
2.19.14 程序三:sd_notify 与 systemd Type=notify
这个程序不链接 libsystemd,直接向 $NOTIFY_SOCKET 发 READY=1/WATCHDOG=1/STOPPING=1,用于演示 Type=notify 与 WatchdogSec=。
/* sd_notify_demo.c — 裸 sd_notify 协议 + 看门狗 */
#define _GNU_SOURCE
#include <errno.h>
#include <signal.h>
#include <stddef.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
static volatile sig_atomic_t stop;
static void on_term(int sig)
{
(void)sig;
stop = 1;
}
static int sd_notify(const char *state)
{
const char *path = getenv("NOTIFY_SOCKET");
if (path == NULL || *path == '\0')
return 0; /* 不在 systemd 下 */
int fd = socket(AF_UNIX, SOCK_DGRAM | SOCK_CLOEXEC, 0);
if (fd == -1)
return -1;
struct sockaddr_un sa;
memset(&sa, 0, sizeof sa);
sa.sun_family = AF_UNIX;
if (path[0] == '@') { /* 抽象命名空间 */
sa.sun_path[0] = '\0';
strncpy(sa.sun_path + 1, path + 1, sizeof sa.sun_path - 2);
} else {
strncpy(sa.sun_path, path, sizeof sa.sun_path - 1);
}
ssize_t n = sendto(fd, state, strlen(state), 0,
(struct sockaddr *)&sa, sizeof sa);
int saved = errno;
close(fd);
errno = saved;
return n == (ssize_t)strlen(state) ? 0 : -1;
}
int main(void)
{
struct sigaction sa = {0};
sa.sa_handler = on_term;
sigemptyset(&sa.sa_mask);
sigaction(SIGTERM, &sa, NULL);
sigaction(SIGINT, &sa, NULL);
if (sd_notify("READY=1\nSTATUS=running") == -1)
perror("sd_notify READY");
printf("sd_notify_demo pid=%ld NOTIFY_SOCKET=%s\n",
(long)getpid(),
getenv("NOTIFY_SOCKET") ? getenv("NOTIFY_SOCKET") : "(none)");
fflush(stdout);
while (!stop) {
sd_notify("WATCHDOG=1\nSTATUS=tick");
sleep(1); /* SIGTERM 会打断 sleep */
}
sd_notify("STOPPING=1\nSTATUS=stopping");
printf("sd_notify_demo stopping\n");
return EXIT_SUCCESS;
}
编译运行:
cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -g \
sd_notify_demo.c -o sd_notify_demo
./sd_notify_demo
# 不在 systemd 下:NOTIFY_SOCKET=(none),仍能运行
# 按 Ctrl-C 退出
配一个 unit:
[Unit]
Description=sd_notify demo
After=network.target
[Service]
Type=notify
ExecStart=/usr/local/bin/sd_notify_demo
NotifyAccess=main
WatchdogSec=5
Restart=on-failure
[Install]
WantedBy=multi-user.target
sudo cp sd_notify_demo /usr/local/bin/
sudo cp sd_notify_demo.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl start sd_notify-demo
systemctl status sd-notify-demo
systemctl show -p Type,NotifyAccess,WatchdogUSec sd-notify-demo
sudo systemctl stop sd-notify-demo
关键点:
NOTIFY_SOCKET未设置时,sd_notify直接返回 0,程序可以本地运行;- socket 地址可能是抽象命名空间(
@开头),要按sun_path[0]=0处理; WatchdogSec=5时,服务必须每 5 秒发一次WATCHDOG=1;本程序用alarm(1)每秒喂一次;Type=notify下不要再daemon();主进程就是被跟踪的进程;systemctl show能核对Type/NotifyAccess/WatchdogUSec是否真的生效。
2.19.15 实验:stty、ps 与 pty 观测
实验一:终端属性与作业控制
# 1. 看当前终端全部属性
stty -a
stty size
tty
readlink /proc/self/fd/0
# 2. 看进程的五个 ID
ps -o pid,ppid,pgid,sid,tpgid,stat,comm -p $$
# 预期:pgid/sid 通常等于 shell 的 PID;tpgid 是前台组
# 3. 观察 stty 的底层 ioctl
strace -e trace=ioctl stty -a 2>&1 | grep -E "TCGETS|TIOCGWINSZ" | head
# 预期:TCGETS、TIOCGWINSZ
# 4. 作业控制
sleep 100 &
ps -o pid,pgid,sid,tpgid,stat,comm -p $!
kill -STOP $! # 停止
ps -o pid,stat,comm -p $! # 状态 T
kill -CONT $! # 继续
kill $!
# 5. 新会话
setsid sleep 100 &
ps -o pid,pgid,sid,tty,stat,comm -p $!
# 预期:sid == pid,tty 为 ?(无控制终端)
怎么读:ps 的 tpgid 是「前台进程组」,stat 的 T 表示停止、S 表示可中断睡眠、R 表示运行。setsid 后 tty 变成 ?,说明没有控制终端;这正是守护进程要的效果。
实验二:pty 与 systemd
# 1. 在 pty 里跑命令
./pty_run
# 预期:/dev/pts/N、24 80、hello-from-pty
# 2. 双向交互
./pty_interactive
# 在 shell 里执行 tty、stty size、exit
# 3. strace 看 pty 的 ioctl
strace -f -e trace=ioctl,openat ./pty_run 2>&1 | grep -E "ptmx|pts|TIOC" | head -20
# 4. systemd unit 静态检查
systemd-analyze verify ./sd_notify_demo.service
systemctl show -p Type,NotifyAccess,WatchdogUSec sd-notify-demo 2>/dev/null
# 5. 看服务日志
journalctl -u sd-notify-demo -n 20 --no-pager
怎么读:strace 里会看到 openat("/dev/ptmx")、ioctl(TIOCSPTLCK)、ioctl(TIOCGPTN)、openat("/dev/pts/N")、ioctl(TIOCSCTTY)。systemd-analyze verify 会检查 unit 语法与依赖;systemctl show 显示的是合并后的生效值,不是文件里的字面值。
stty -a:看icanon/echo/isig是否被改坏。ps -o pid,pgid,sid,tpgid,stat:确认前台组与停止状态。tty/readlink /proc/PID/fd/0:确认控制终端。strace -e trace=ioctl:看TCGETS/TCSETS/TIOCSCTTY。journalctl -u 服务:看 systemd 是否因Type=/NotifyAccess判定失败。systemd-analyze verify:静态检查 unit。
2.19.16 陷阱清单:终端、pty 与守护进程的十五个现场
| # | 现象 | 原因 | 修复 |
|---|---|---|---|
| 1 | 程序退出后终端不回显 | 原始模式没恢复,且被 SIGKILL 杀死 | atexit + 信号处理器;救援用 stty sane |
| 2 | Ctrl-C 被程序读成 0x03 | ISIG 被清掉 | 想保留中断就置 ISIG;否则自己处理字节 |
| 3 | read 一直等不到整行 | 规范模式下没有换行 | 发 \n、VEOF,或关 ICANON |
| 4 | VMIN=0,VTIME=0 把 CPU 打满 | 纯轮询 | 用 poll 或 VTIME>0 |
| 5 | tcsetattr 后旧输入被当成新按键 | 用了 TCSANOW | 切模式用 TCSAFLUSH |
| 6 | tcgetattr(master) 返回 ENOTTY | master 不是终端 | 改 slave 的属性,或 forkpty 的 termp |
| 7 | pty master 读返回 EIO 被当错误 | Linux 上 slave 全关就是 EIO | 当 EOF 处理,waitpid 收子进程 |
| 8 | forkpty 子进程没有控制终端 | setsid 失败或 TIOCSCTTY 失败 | 检查子进程是否是组组长;用 login_tty 的返回值 |
| 9 | 后台 cat 停下来 | 后台组读终端收到 SIGTTIN | fg 调前台,或 kill -CONT(再读仍会停) |
| 10 | tcsetpgrp 返回 EPERM | 目标组不在同一会话 | 只能操作本会话的作业 |
| 11 | setsid 返回 EPERM | 调用者是进程组组长 | 先 fork,子进程再 setsid |
| 12 | 守护进程被终端关闭杀掉 | 没脱离控制终端/没忽略 SIGHUP | setsid + 二次 fork;或交给 systemd |
| 13 | Type=simple 服务反复重启 | 代码里 daemon(),主进程退出 | 去掉 daemonize,前台运行 |
| 14 | Type=notify 一直 activating | 没发 READY=1,或 NotifyAccess 不匹配 | 调 sd_notify;设 NotifyAccess=main/all |
| 15 | 看门狗把服务杀掉 | 没按 WatchdogSec 喂 WATCHDOG=1 | 定时喂狗,或去掉 WatchdogSec |
| 16 | nohup 后进程仍被作业控制影响 | nohup 只忽略 SIGHUP | 用 setsid/systemd 真正脱离会话 |
| 17 | 容器里 tcsetpgrp 失败 | 没有控制终端/不在同一会话 | 用 docker exec -it 分配 tty,或不要做作业控制 |
forkpty/openpty在 Linux 上属于libutil;glibc 2.34 起并入 libc,但老系统要-lutil。<pty.h>是 Linux/glibc 头;macOS/BSD 用<util.h>,函数名相同但实现不同。- Linux master 读结束返回
EIO;BSD/macOS 可能返回 0。两个都要处理。 TIOCSTI在现代内核默认禁用(dev.tty.legacy_tiocsti=0),不要用它做自动化输入。systemd的Type=notify需要NotifyAccess;默认main只接受主进程的sd_notify,子进程发的会被丢弃。daemon(3)不二次 fork;需要彻底脱离控制终端时自己写两次 fork。
2.19.17 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
解释 Ctrl-C 的路径 | 行规程 → SIGINT → 前台进程组 | 2.19.1 |
用 termios 切原始模式并恢复 | 异常退出也能恢复 | 2.19.2 |
说出 VMIN/VTIME 四种组合 | 能对应到 read 行为 | 2.19.2 |
用 forkpty 跑交互子进程 | 能读到 /dev/pts/N 与窗口大小 | 2.19.4 |
| 解释会话/进程组/控制终端 | 能说出 SIGTTIN 的触发条件 | 2.19.5 |
| 写出两次 fork 的守护进程 | 能说出每一步缺了会怎样 | 2.19.6 |
区分 Type=simple 与 forking | 知道 simple 不应再 daemonize | 2.19.6 |
Q1 在规范模式下按 Ctrl-D,read 一定返回 0 吗?什么情况下不返回 0?
Q2 一个程序在原始模式下被 SIGKILL 杀死,终端不回显、不回车的现象怎么恢复?
Q3 forkpty 之后,父进程读 master 返回 -1、errno=EIO。这意味着什么?为什么不是返回 0?
Q4 后台进程执行 cat 读终端,为什么会停下来?收到什么信号?怎么让它继续?
Q5 一个服务用 systemd 的 Type=simple 启动,但代码里仍调用 daemon()。会发生什么?
答案
A1 不一定。只有当前行为空时,Ctrl-D 才让 read 返回 0;如果行里已有字符,它只让行规程立即交付当前行,read 返回这些字符的字节数。连续按两次 Ctrl-D(第一次交付,第二次在空行上)才等价于 EOF。
A2 在新终端里执行 stty sane(或 reset)恢复;如果当前 shell 也受影响,可以 Ctrl-J 代替回车执行命令,或从另一个终端 stty -F /dev/pts/N sane。SIGKILL 不可捕获,所以程序里的恢复逻辑不会运行。
A3 表示 slave 端的所有 fd 都已关闭(子进程退出),master 没有对端了。Linux 的 pty master 在这种情况下返回 EIO 而不是 0,这是与普通管道/BSD pty 的差异;读到 EIO 应视为 EOF,关闭 master 并 waitpid。
A4 后台进程组读控制终端时,内核向它发 SIGTTIN,默认动作是停止(T 状态)。让它继续:fg 把它调到前台(shell 会 tcsetpgrp 并 SIGCONT),或 kill -CONT <pgid> 让它继续在后台跑(但再次读终端仍会被停止)。
A5 daemon() 会 fork 并让父进程退出,systemd 看到的 main PID 退出,可能误判服务失败或进入重启循环(取决于 Restart= 与 Type=)。Type=simple 要求服务前台运行、不 fork;应去掉 daemon(),日志直接写 stderr 交给 journald。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| K | 第 62 章 终端(p.1058–1087,PDF p.1103–1132) | 2.19.1–2.19.3、2.19.7–2.19.8 | 完整:62.1 概览、62.2 tcgetattr/tcsetattr、62.4 特殊字符、62.5 终端标志、62.6.1 规范模式、62.6.2 非规范模式、62.6.3 加工/cbreak/原始模式、62.7 线速、62.8 行控制、62.10 终端标识 |
| K | 第 64 章 伪终端(p.1130–1150,PDF p.1175–1200) | 2.19.4、2.19.9、2.19.13 | 完整:64.1 概览、64.2 UNIX 98 pty(posix_openpt/grantpt/unlockpt)、64.3 ptyMasterOpen、64.5 pty 的 termios、64.6 script(1)、64.8 BSD pty 对照(Linux 不采用) |
| K | 第 34 章 进程组、会话和作业控制(p.573–599,PDF p.602–628) | 2.19.5、2.19.10、2.19.12 | 完整:34.2 进程组、34.3 会话、34.4 控制终端与控制进程、34.5 前台/后台进程组、34.6 SIGHUP、34.7.1–34.7.4 作业控制实现与孤儿进程组 |
| C | 第 8 章 8.5(作业控制) | 2.19.5 | 完整(SIGTSTP/SIGCONT、waitpid) |
| G | 第 16 章 程序管理与 SELinux 初探 | 2.19.5–2.19.6 | 背景(ps、jobs、nohup) |
| G | 第 17 章 认识系统服务(daemons) | 2.19.6 | 背景(systemd、Type=) |
| O | — | — | 未用:本章不涉及调度与内存 |
下一站:第 2 篇到此结束。你已经走完「文件 → 进程 → 信号 → 网络 → 事件 → 终端」这条系统编程主线;接下来进入第 3 篇,从内核侧看这些接口背后的
task_struct、VFS、协议栈与调度器,把「会用」变成「知道为什么」。