终端的 Raw Mode
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
控制是否发送 SIGINT
和 SIGSTP
void enable_raw_mode() {
struct termios raw;
tcgetattr(STDIN_FILENO, &raw);
raw.c_lflag &= ~(ECHO | ICANON | ISIG);
tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}
现在你就可以发现, 不需要回车且 Ctrl-C
和 Ctrl-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-J
和 Ctrl-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);
}
}