Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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 数组下标):

字符默认键下标作用
VINTRCtrl-Cc_cc[VINTR]发 SIGINT 给前台进程组
VQUITCtrl-\c_cc[VQUIT]发 SIGQUIT(core dump)
VERASEBackspacec_cc[VERASE]删除上一个字符
VKILLCtrl-Uc_cc[VKILL]删除整行
VEOFCtrl-Dc_cc[VEOF]行首时让 read 返回 0(EOF)
VSUSPCtrl-Zc_cc[VSUSP]发 SIGTSTP(停止)
VSTOP/VSTARTCtrl-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 的组合:

VMINVTIMEread 行为
>00阻塞到读满 VMIN 字节
0>0最多等 VTIME×0.1 秒,有数据就返回
>0>0读到 VMIN 字节,或两次字节之间超时 VTIME
00立即返回(轮询)

下面的程序演示原始模式:每个按键立即读出,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 或重新登录恢复。

关于终端的四个误解
  1. 「Ctrl-C 是程序自己读到的字符」——规范模式下由行规程转换成 SIGINT;原始模式下才是字节 0x03。
  2. 「Ctrl-D 是 EOF 字符」——它是「让当前行为空时 read 返回 0」的请求;行里还有内容时只交付当前行。
  3. 「tcsetattr 立即生效」——TCSADRAIN/TCSAFLUSH 会延迟;TCSAFLUSH 还会丢弃未读输入。
  4. 「终端属性是进程私有的」——是终端设备的属性,所有打开同一个 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 类工具)。

关于 pty 的三个坑
  1. 「master 读返回 0 表示结束」——Linux 上 slave 全部关闭时返回 -1、errno=EIO;BSD/macOS 返回 0。两个都要处理。
  2. 「master 也有 termios」——master 不是终端,tcgetattr(master) 返回 ENOTTY;要改属性用 slave(或 forkpty 的 termp 参数)。
  3. 「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 守护进程:脱离终端的标准步骤

定义 2.19.1 · 守护进程(daemon)

守护进程是没有控制终端、在后台长期运行的进程。它通常由系统启动脚本或服务管理器拉起,生命周期与登录会话无关。标准做法是「两次 fork + setsid + 重置环境」。

为什么要两次 fork:

  1. 第一次 fork 后父进程退出,子进程被 init 收养,shell 立刻返回;
  2. 子进程 setsid() 成为新会话首进程,脱离控制终端;
  3. 第二次 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。这是「传统守护进程」与「容器/现代服务管理器」最大的观念差异。

关于守护进程的四个误解
  1. 「daemon(3) 会做二次 fork」——glibc 的 daemon() 只做一次 fork + setsid + chdir + 重定向,不二次 fork。严格场景要自己写。
  2. 「setsid 在任何进程里都能成功」——进程组组长调用会 EPERM,必须先 fork。
  3. 「守护进程不需要处理 SIGTERM」——systemctl stop 默认发 SIGTERM,不处理就会在清理到一半时被杀。
  4. 「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说明
tcgetattrint tcgetattr(int fd, struct termios *t)0EBADF、ENOTTY、EFAULTfd 必须是终端
tcsetattrint tcsetattr(int fd, int action, const struct termios *t)0EBADF、ENOTTY、EINVAL、EFAULTaction:TCSANOW/TCSADRAIN/TCSAFLUSH
cfgetospeedspeed_t cfgetospeed(const struct termios *t)波特率常量不会失败返回值是 B9600 之类,不是数字
cfsetospeedint cfsetospeed(struct termios *t, speed_t s)0EINVAL只改结构体,需 tcsetattr 生效
cfgetispeedspeed_t cfgetispeed(const struct termios *t)波特率不会失败输入波特率
cfsetispeedint cfsetispeed(struct termios *t, speed_t s)0EINVAL输入波特率
tcflushint tcflush(int fd, int queue)0EBADF、ENOTTY、EINVALTCIFLUSH/TCOFLUSH/TCIOFLUSH
tcflowint tcflow(int fd, int action)0EBADF、ENOTTY、EINVALTCOOFF/TCOON/TCIOFF/TCION
tcdrainint tcdrain(int fd)0EBADF、ENOTTY、EINTR等输出缓冲区排空
tcsendbreakint tcsendbreak(int fd, int duration)0EBADF、ENOTTY、EINVAL发 break;duration 语义依实现
tcgetpgrppid_t tcgetpgrp(int fd)前台进程组 IDEBADF、ENOTTY读控制终端的前台组
tcsetpgrpint tcsetpgrp(int fd, pid_t pgrp)0EBADF、ENOTTY、EINVAL、EPERM、ESRCH设置前台组;EPERM:组不在同一会话
tcgetsidpid_t tcgetsid(int fd)会话 IDEBADF、ENOTTY控制终端的会话首进程 PID
isattyint isatty(int fd)1/0EBADF不设置 errno(除非 fd 无效)
ttynamechar *ttyname(int fd)路径NULL不可重入;多线程用 ttyname_r

tcsetattr 的 EINVAL 通常是「action 不是三个合法值之一」或「结构体里的 c_cc 越界」。ENOTTY 是「fd 不是终端」——把管道、普通文件、/dev/null 传进来都会得到它。tcsetpgrp 的 EPERM 是「目标进程组不在当前会话」,不是权限不足。

c_iflag:输入处理

标志含义常见用途
IGNBRK忽略 break串口
BRKINTbreak 产生 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输入队列满时响铃默认
IUTF8UTF-8 输入(影响退格)现代终端

ICRNL 是「为什么 read 读到的是 \n 而不是 \r」的原因;cfmakeraw 会清掉它,所以原始模式下 Enter 变成 \r(0x0d),程序要自己处理。

c_oflag:输出处理

标志含义备注
OPOST启用输出后处理关掉后 \n 不会自动变 \r\n
ONLCRNL 转 CR-NL默认;终端换行必需
OCRNLCR 转 NL少用
ONOCR第 0 列不输出 CR少用
ONLRETNL 也执行 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
CSTOPB2 个停止位
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

下标默认键作用仅规范模式
VINTRCtrl-CSIGINT是
VQUITCtrl-\SIGQUIT是
VERASEBackspace删除一个字符是
VKILLCtrl-U删除整行是
VEOFCtrl-D空行时 EOF是
VEOL无额外的行结束符是
VEOL2无第二个行结束符是
VSTARTCtrl-Q恢复输出IXON
VSTOPCtrl-S暂停输出IXON
VSUSPCtrl-ZSIGTSTP是
VREPRINTCtrl-R重打印当前行IEXTEN
VWERASECtrl-W删除一个单词IEXTEN
VLNEXTCtrl-V字面量下一个字符IEXTEN
VDISCARDCtrl-O丢弃输出IEXTEN
VMIN1非规范模式最少字节数否
VTIME0非规范模式超时(0.1 秒)否

c_cc 是 cc_t 数组,元素个数 NCCS(Linux 上 32)。不是所有下标在所有平台都存在(VEOL2/VSWTC 等是扩展);写可移植代码时用条件编译。

关于 `termios` 的五个误解
  1. 「tcsetattr 改的是进程属性」——改的是终端设备属性;同一终端上的所有进程共享。
  2. 「TCSANOW 会丢弃未读输入」——不会,只有 TCSAFLUSH 会;TCSADRAIN 等输出写完。
  3. 「VMIN/VTIME 在规范模式下也生效」——不生效;它们只在 ICANON 关闭时使用。
  4. 「cfmakeraw 会设置 CLOCAL/CREAD」——不会;它只动输入/输出/本地标志与字符大小,串口场景要自己设 CLOCAL|CREAD。
  5. 「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 决定:

VMINVTIMEread 返回条件返回值
>00读满 VMIN 字节VMIN
0>0有数据立即返回;无数据等 VTIME×0.1s 后返回 00..n
>0>0读满 VMIN,或字节间超时 VTIME已读字节数
00立即返回(轮询)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_openptint posix_openpt(int flags)master fdEMFILE、ENFILE、EINVAL、ENOMEMflags:O_RDWR、O_NOCTTY
grantptint grantpt(int fd)0EACCES、EBADF、EINVAL设置 slave 属主/权限
unlockptint unlockpt(int fd)0EACCES、EBADF、EINVAL解锁 slave,之后才能 open
ptsnamechar *ptsname(int fd)slave 路径NULL不可重入;用 ptsname_r
ptsname_rint ptsname_r(int fd, char *buf, size_t len)0EBADF、ENOTTY、ERANGE可重入版本
openptyint openpty(int *amaster, int *aslave, char *name, const struct termios *t, const struct winsize *ws)0ENOMEM、EMFILE、EINVAL一步创建 master/slave
forkptypid_t forkpty(int *amaster, char *name, const struct termios *t, const struct winsize *ws)子进程 PID(父进程)/0(子进程)EAGAIN、ENOMEM、EMFILEopenpty + fork + login_tty
login_ttyint login_tty(int fd)0EINVAL、EPERMsetsid + TIOCSCTTY + dup2 到 0/1/2

forkpty 的子进程流程:

  1. openpty 创建 master/slave;
  2. fork;
  3. 子进程 close(master),setsid() 建新会话;
  4. ioctl(slave, TIOCSCTTY, 0) 把 slave 设为控制终端;
  5. dup2(slave, 0/1/2),关闭多余 fd;
  6. 返回 0,子进程 exec 目标程序。

父进程拿到 master,读写它就是与子进程的终端会话。forkpty 的 termp 参数设置 slave 的初始 termios,winsize 设置初始窗口大小;传 NULL 用默认值。

pty 的 ioctl

ioctl作用适用
TIOCGWINSZ/TIOCSWINSZ读/写窗口大小master 与 slave
TIOCGPTN取 slave 编号(/dev/pts/N 的 N)master
TIOCSPTLCK锁定/解锁 slavemaster
TIOCSCTTY把 fd 设为控制终端slave(会话首进程)
TIOCNOTTY放弃控制终端会话首进程
TIOCSTI向输入队列注入字节需要权限;现代内核默认禁用
TCGETS/TCSETS/TCSETSW/TCSETSFtermios 读写(tcgetattr 的底层)slave;master 返回 ENOTTY

tcgetattr(master) 返回 ENOTTY:master 不是终端,没有行规程;它只是字节通道。要改属性,改 slave(forkpty 的 termp,或打开 /dev/pts/N)。TIOCSWINSZ 在 master 上可用,内核会向 slave 的前台进程组发 SIGWINCH。

内核路径

用户动作内核路径关键数据结构
posix_openptdevpts 挂载点 → ptmx_openstruct tty_struct、struct tty_port
grantpt/unlockptpts 的属主/TIOCSPTLCKdevpts inode
open("/dev/pts/N")pts_open → tty_init_devslave tty_struct
写 masterpty_write → tty_ldisc_receive_buf → n_tty_receive_buf行规程缓冲区
读 slaven_tty_readread_buf、echo_buf
forkptylogin_tty → setsid + TIOCSCTTYsignal_struct、tty_struct

pty 由 drivers/tty/pty.c 实现:master 与 slave 是一对 tty_struct,通过 tty->link 互指。写 master 的字节进入 slave 的行规程(n_tty),经过规范模式/回显/信号处理后放进 slave 的读缓冲区;反向亦然。devpts 文件系统负责分配 /dev/pts/N 的 inode 与权限。

关于 pty 的四个误解
  1. 「master 也是终端,可以 tcgetattr」——不是;master 返回 ENOTTY。改属性用 slave。
  2. 「master 读返回 0 就是结束」——Linux 上 slave 全关时返回 -1/EIO;BSD/macOS 可能返回 0。两者都要当 EOF 处理。
  3. 「forkpty 的子进程自动有控制终端」——是,但只在 login_tty 成功后;如果 setsid 失败(子进程是组组长),控制终端不会建立。
  4. 「TIOCSTI 可以随便注入输入」——现代内核默认禁用(dev.tty.legacy_tiocsti=0),且需要 CAP_SYS_ADMIN;不要依赖它做测试。

2.19.10 进程组、会话与控制终端:API 与信号

这一节回答:setsid 为什么要求调用者不是进程组组长?tcsetpgrp 的 EPERM 是什么?后台读终端到底收到哪个信号?

API 表

API签名成功失败 errno说明
getpgrppid_t getpgrp(void)当前进程组 ID不会失败等价于 getpgid(0)
getpgidpid_t getpgid(pid_t pid)进程组 IDESRCH、EPERMpid=0 表示当前进程
setpgidint setpgid(pid_t pid, pid_t pgid)0EACCES、EINVAL、EPERM、ESRCH创建/加入进程组;pgid=0 用 pid 作组 ID
setsidpid_t setsid(void)新会话 IDEPERM调用者不能是进程组组长
getsidpid_t getsid(pid_t pid)会话 IDESRCH、EPERMpid=0 当前进程
tcgetpgrppid_t tcgetpgrp(int fd)前台组 IDEBADF、ENOTTYfd 必须是控制终端
tcsetpgrpint tcsetpgrp(int fd, pid_t pgrp)0EBADF、ENOTTY、EINVAL、EPERM、ESRCH目标组必须在同一会话
tcgetsidpid_t tcgetsid(int fd)会话 IDEBADF、ENOTTY控制终端的会话
killint kill(pid_t pid, int sig)0EINVAL、EPERM、ESRCHpid < -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 大致做四件事:

  1. tcsetpgrp(ttyfd, job_pgid) 把作业设为前台;
  2. kill(-job_pgid, SIGCONT) 唤醒被停止的进程;
  3. waitpid(job_pid, &status, WUNTRACED) 等它停止或退出;
  4. 作业停止/退出后,tcsetpgrp(ttyfd, shell_pgid) 把 shell 拿回前台。

waitpid 的 WUNTRACED 让「子进程被停止」也返回;WCONTINUED 让「子进程被 SIGCONT 继续」返回。没有这两个标志,shell 无法感知 Ctrl-Z。

进程组/会话的观测
  1. ps -o pid,ppid,pgid,sid,tpgid,stat,comm:一次看全五个 ID 与状态。
  2. ps -e -o pid,pgid,sid,tty,stat,comm | grep ' T ':找被停止的进程。
  3. tty / readlink /proc/self/fd/0:确认控制终端。
  4. cat /proc/$PID/stat:第 5/6/7 字段是 pgrp/session/tty。
  5. kill -TTIN <pid> / kill -CONT <pid>:手工触发/恢复。
  6. setsid sleep 100 &:观察新会话(sid 等于 pid)。

2.19.11 守护进程与 systemd:Type= 全表

这一节回答:Type=simple 与 Type=forking 的本质区别是什么?Type=notify 的 READY=1 什么时候发?sd_notify 的 socket 在哪?

Type= 的语义

Type=systemd 认为「启动完成」的时机服务该怎么做典型场景
simpleExecStart 的进程 fork/exec 后立即前台运行,不 daemonize大多数新服务
execexecve 成功后前台;比 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_nameD-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/alwaysalways + 立即崩溃 = 重启风暴
RestartSec=重启前等待太短会打爆日志
WatchdogSec=看门狗超时服务不喂 WATCHDOG=1 会被杀
NotifyAccess=谁能发 sd_notifymain 时子进程发的会被丢弃
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;不处理信号
systemdcgroup、日志、重启、看门狗、权限需要 unit 文件与正确的 Type=
关于守护进程的五个误解
  1. 「daemon() 会二次 fork」——glibc 的 daemon() 只 fork 一次;严格脱离控制终端要自己二次 fork。
  2. 「setsid 总能成功」——进程组组长调用返回 EPERM;先 fork。
  3. 「Type=simple 的服务可以自己 fork」——不行;主进程退出会被当成失败。
  4. 「sd_notify 必须链接 libsystemd」——不必;协议就是发一行文本到 $NOTIFY_SOCKET。
  5. 「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 显示的是合并后的生效值,不是文件里的字面值。

终端问题的排查顺序
  1. stty -a:看 icanon/echo/isig 是否被改坏。
  2. ps -o pid,pgid,sid,tpgid,stat:确认前台组与停止状态。
  3. tty / readlink /proc/PID/fd/0:确认控制终端。
  4. strace -e trace=ioctl:看 TCGETS/TCSETS/TIOCSCTTY。
  5. journalctl -u 服务:看 systemd 是否因 Type=/NotifyAccess 判定失败。
  6. systemd-analyze verify:静态检查 unit。

2.19.16 陷阱清单:终端、pty 与守护进程的十五个现场

#现象原因修复
1程序退出后终端不回显原始模式没恢复,且被 SIGKILL 杀死atexit + 信号处理器;救援用 stty sane
2Ctrl-C 被程序读成 0x03ISIG 被清掉想保留中断就置 ISIG;否则自己处理字节
3read 一直等不到整行规范模式下没有换行发 \n、VEOF,或关 ICANON
4VMIN=0,VTIME=0 把 CPU 打满纯轮询用 poll 或 VTIME>0
5tcsetattr 后旧输入被当成新按键用了 TCSANOW切模式用 TCSAFLUSH
6tcgetattr(master) 返回 ENOTTYmaster 不是终端改 slave 的属性,或 forkpty 的 termp
7pty master 读返回 EIO 被当错误Linux 上 slave 全关就是 EIO当 EOF 处理,waitpid 收子进程
8forkpty 子进程没有控制终端setsid 失败或 TIOCSCTTY 失败检查子进程是否是组组长;用 login_tty 的返回值
9后台 cat 停下来后台组读终端收到 SIGTTINfg 调前台,或 kill -CONT(再读仍会停)
10tcsetpgrp 返回 EPERM目标组不在同一会话只能操作本会话的作业
11setsid 返回 EPERM调用者是进程组组长先 fork,子进程再 setsid
12守护进程被终端关闭杀掉没脱离控制终端/没忽略 SIGHUPsetsid + 二次 fork;或交给 systemd
13Type=simple 服务反复重启代码里 daemon(),主进程退出去掉 daemonize,前台运行
14Type=notify 一直 activating没发 READY=1,或 NotifyAccess 不匹配调 sd_notify;设 NotifyAccess=main/all
15看门狗把服务杀掉没按 WatchdogSec 喂 WATCHDOG=1定时喂狗,或去掉 WatchdogSec
16nohup 后进程仍被作业控制影响nohup 只忽略 SIGHUP用 setsid/systemd 真正脱离会话
17容器里 tcsetpgrp 失败没有控制终端/不在同一会话用 docker exec -it 分配 tty,或不要做作业控制
版本与平台差异
  1. forkpty/openpty 在 Linux 上属于 libutil;glibc 2.34 起并入 libc,但老系统要 -lutil。
  2. <pty.h> 是 Linux/glibc 头;macOS/BSD 用 <util.h>,函数名相同但实现不同。
  3. Linux master 读结束返回 EIO;BSD/macOS 可能返回 0。两个都要处理。
  4. TIOCSTI 在现代内核默认禁用(dev.tty.legacy_tiocsti=0),不要用它做自动化输入。
  5. systemd 的 Type=notify 需要 NotifyAccess;默认 main 只接受主进程的 sd_notify,子进程发的会被丢弃。
  6. 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 不应再 daemonize2.19.6
即时自测 · 2.19

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、协议栈与调度器,把「会用」变成「知道为什么」。