终端的 Raw Mode

36

Raw Mode

在启动 Terminal 时, 默认是以 canonical mode 或者叫 cooked mode 启动的. 这种模式下, 用户按键产生的字符会由终端驱动存入内部缓冲区, 期间可以自动处理 Backspace , Ctrl-C 等特殊字符, 需要等待用户在按下 Enter 后才将字符从缓冲区中送到程序的标准输入 (STDIN_FINENO 0)

比如下面这个程序

#include <unistd.h>

int main() {
    char c;
    while (read(STDIN_FILENO, &c, 1) == 1) {
        write(STDOUT_FILENO, &c, 1);
    }
    return 0;
}

终端默认是 canonical mode, 所以会在 Enter 后, 从标准输入中每次读取一个字节到 c, 直到 0 个字节可读

这也是常用的一种模式, 输入命令及参数回车, 然后 shell 就会解释我们的命令. 但是你可能见过一些命令行程序比如 top, 按下 q 后并没有 Enter 就直接退出了, 这就需要 raw mode 让我们自己来处理这些输入

下面这个例子仍然是 canonical mode 的, 也是通过 q 来退出, 但是需要 Enter

#include <unistd.h>

int main() {
    char c;
    while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q') {
        write(STDOUT_FILENO, &c, 1);
    }
    return 0;
}

但是这样的问题就在于和预期稍微有些差别, 我们不仅要 Enter, 而且那之后的字符可能是 wqeqweq 这样, 而这个程序是读到一个 q char 就会退出

而在 raw mode 下, 没有回显, 不会处理特殊字符, 也没有缓冲区

关闭回显

要切换到 raw mode 首先是关闭回显, 需要用到 termios.h 提供的 tcgetattr() 函数以及 tcsetattr() 函数

这两个函数的定义是这样的:

// The tcgetattr() function copies the parameters associated with the terminal referenced by fildes in the termios structure referenced by termios_p.  
// This function is allowed from a background process; however, the terminal attributes may be subsequently changed by a foreground process.

int tcgetattr(int, struct termios *);

// The tcsetattr() function sets the parameters associated with the terminal from the termios structure referenced by termios_p.  
// The optional_actions field is created by or'ing the following values, as specified in the include file ⟨termios.h⟩.

int tcsetattr(int, int, const struct termios *);

通过下面这三个语句就可以获取到当前的状态:

struct termios raw;
tcgetattr(STDIN_FILENO, &raw);
printf("%lu", raw.c_lflag);

我们需要的就是这个 c_lflag, 将这个 flag 进行位运算后再调用 tcsetattr() 设置即可:

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    raw.c_lflag &= ~(ECHO);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

这样就关闭了回显, 运行之后输入字符就不会再有回显. 如果你发现还是有回显, 确保在终端运行, 而不是用 IDE 的 run 来运行或是其他的一些 wrap

切换到 Raw Mode

我们已经关闭了回显, 现在还需要禁用缓冲区和禁用特殊字符处理等等操作, 下面一步一步来

禁用 SIGINT 和 SIGSTP

ISIG 控制是否发送 SIGINTSIGSTP

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    raw.c_lflag &= ~(ECHO | ICANON | ISIG);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

现在你就可以发现, 不需要回车且 Ctrl-CCtrl-Z 的输入已经不起作用

禁用输出流控

什么是流控? https://en.wikipedia.org/wiki/Software_flow_control

要禁用, 需要用到另一个flag: c_iflag, 通过 IXON 控制

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    raw.c_lflag &= ~(ECHO | ICANON | ISIG);
    raw.c_iflag &= ~(IXON);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

禁用 Ctrl-V

这个操作要用到 c_lflag, 通过 IEXTEN 控制

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);
    raw.c_iflag &= ~(IXON);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

关闭换行映射

c_lflag, ICRNL

\r 的 char 值是 13, \n 的 char 值是 10

为了消除不同操作系统之间换行符的差异, 默认开启映射, 可能会将 \r 自动转换成 \n, 也就是说 Ctrl-JCtrl-M 会是一样的值, 而 Ctrl-M 原本是 13

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);
    raw.c_iflag &= ~(IXON | ICRNL);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

关闭输出处理

c_oflag, OPOST

开启时终端会将 换行符 \n 转换成 回车符后跟一个换行符: \r\n

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);
    raw.c_iflag &= ~(IXON | ICRNL);
    raw.c_oflag &= ~(OPOST);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

实际上还需要更多的操作, 比如通过结构体中的 c_cc 来设置read() 返回所需要的最小字节数以及读超时时间等:

void enable_raw_mode() {
    struct termios raw;
    tcgetattr(STDIN_FILENO, &raw);
    
    // 修改 raw.c_lflag 来禁用终端的一些本地模式
    // 禁用 ECHO: 关闭回显
    // 禁用 ICANON: 关闭规范模式 (行缓冲), 使得输入按字符处理
    // 禁用 IEXTEN: 禁用扩展输入处理
    // 禁用 ISIG: 禁用信号生成 (例如 Ctrl+C 不再生成 SIGINT 信号)
    raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);

    // 修改 raw.c_iflag 来禁用某些输入处理
    // 禁用 BRKINT, ICRNL, INPCK, ISTRIP, IXON 等标志
    raw.c_iflag &= ~(IXON | ICRNL | BRKINT | INPCK | ISTRIP);

    // 修改 raw.c_oflag 来禁用输出处理 (比如自动转换换行符)
    raw.c_oflag &= ~(OPOST);

    // 修改 raw.c_cflag" 设置字符大小为 8 位 (通常为 CS8)
    raw.c_cflag |= (CS8);

    // 设置控制字符
    // VMIN = 0: read() 至少读取 0 个字节
    // VTIME = 1000: read() 超时时间为 1000ms
    raw.c_cc[VMIN] = 0;
    raw.c_cc[VTIME] = 1000;

    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

int main() {
    char c;
    enable_raw_mode();
    while (read(STDIN_FILENO, &c, 1) == 1 && c != 'q') {
        if (c == '\r' | c == '\n') {
            write(STDOUT_FILENO, "\r\n", 2);
        }
        write(STDOUT_FILENO, &c, 1);
    }
    return 0;
}

这里就设置了 1000 秒后如果没有读取到 read() 就会直接返回, 如果 c_cc[VMIN] 为 1 则是会阻塞到读取到一个字节为止

  • BRKINT: 当检测到 break 条件时触发中断行为或产生错误, 帮助捕捉和处理输入中断
  • INPCK: 启用输入奇偶校验, 用于检测数据传输中的错误
  • ISTRIP: 对输入数据进行剥离, 将每个字节的最高位清零, 确保只保留7位数据

直接切换到 Raw Mode

实际上 termios.h 中有提供一个直接切换的 API: cfmakeraw()

这个函数只需要传入一个 struct termios, 会修改结构体中的值就像我们上面做的那样

void enable_raw_mode() {
    struct termios raw;
    cfmakeraw(&raw);
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

使用其他语言

使用其他语言就要用到 FFI 了, 比如下面使用 Rust, 好在 Rust 提供了一个 libc 库可以很容易做到:

fn enable_raw_mode() {
    unsafe {
        let mut termios = {
            std::mem::zeroed()
        };

        libc::tcgetattr(libc::STDIN_FILENO, &mut termios);
        libc::cfmakeraw(&mut termios);
        libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, &termios);
    }
}

这只是一个最简单的例子

下面是纯 FFI 的例子:

#[repr(C)]
#[derive(Copy, Clone)]
struct Termios {
    c_iflag: u32,
    c_oflag: u32,
    c_cflag: u32,
    c_lflag: u32,
    cc_c: [u8; 20],
    c_ispeed: u32,
    c_ospeed: u32,
}

extern "C" {
    fn tcgetattr(fd: i32, termios: &mut Termios) -> i32;
    fn tcsetattr(fd: i32, command: i32, termios: &Termios) -> i32;
    fn cfmakeraw(termios: &mut Termios);
}

const STDIN_FILENO: i32 = 0;
const TCSAFLUSH: i32 = 2;

fn enable_raw_mode() {
    unsafe {
        let mut termios = std::mem::zeroed();
        cfmakeraw(&mut termios);
        tcsetattr(STDIN_FILENO, TCSAFLUSH, &mut termios);
    }
}