termios.h-让程序立即处理按键

需求

在 Linux 下写 tui 程序为了处理按键事件可以使用 getchar()。不过呢,总要按个回车实在是不爽……细究下去发现这居然是 Linux 终端底层设计所致。为了能够制作不错的终端小程序,这一步是不可避免的。
termios.h 用于控制终端,可以操纵一些终端上的设置,进行相对底层的操作。

Linux 的终端 I/O 模式

1. 规范模式(Canonical mode)

在 Linux 下,终端默认规范模式。在规范模式下终端逐行发送数据,即先将输入收集到行缓冲,直到按下回车键才会将数据发送给程序;支持基本的行编辑功能,比如退格键删除字符等等,而且默认还会回显到终端上。

2. 原始模式(Raw mode)

与规范模式不同,在原始模式下终端立即发送数据,且不能进行行编辑(和 vi 那样差不多吧)。
也就是说,在原始模式下,所有的操作都会立即返回给程序,而不用等待回车键。这也是一些 tui 程序所需要的。

termios.h

termios.h 包含一个 struct 用于存储终端设置,使用 tcgetattr() 获取终端设置,tcsetattr() 修改终端设置。
对于 struct termios 结构内包含以下内容:

1
2
3
4
5
6
7
struct termios {
    tcflag_t c_iflag;   // 输入模式 (input modes)
    tcflag_t c_oflag;   // 输出模式 (output modes)
    tcflag_t c_cflag;   // 控制模式 (control modes)
    tcflag_t c_lflag;   // 本地模式 (local modes)
    cc_t     c_cc[NCCS]; // 控制字符 (比如 VMIN, VTIME, ERASE, INTR 等)
};

同时 termios.h 还通过typedef 定义了多种数据类型:

  • tcflag_t 用于存储终端模式
1
typedef unsigned int tcflag_t
  • cc_t 用于存储终端特殊字符
1
typedef unsigned char cc_t
  • speed_t 用于存储终端的波特率(baud rates)
1
typedef unsigned int speed_t

波特率会影响终端传输和接收速度,但是对于我们来说不重要。

具体可以去翻阅 linux 的 man 文档详细了解。

改变 Linux 终端为 Raw Mode

首先需要几个头文件:

1
2
3
#include <termios.h>
#include <unistd.h>
#include <fcntl.h>

unistd.h 有文件操作符,fcntl.h 处理非阻塞(?)
现在来开始利用 termios.h 创建 setTerminalRawMode() 函数来改变终端模式。

1
void setTerminalRawMode(bool enable, bool nonblock = true);

这里的两个函数中 enable 用于是否开启原始模式。nonblock 用于是否开启非阻塞,默认是开启。

阻塞(blocking)与否

默认 Linux 终端输入是阻塞的。

阻塞

如果是阻塞模式,用户还没按键,程序只能干等输入,不会做别的事,直到有输入了。

非阻塞(non-blocking)

程序会继续运行,并随时检查有没有输入,如果有就处理按键,即使没有程序也能运行。

在游戏上如果有一个小人在一直移动,那么阻塞模式只能在你按键才能移动,而非阻塞会一直移动刷新且实时响应按键事件。

创建 termios 结构

对终端设置的一系列数据都存储在 struct termios 之中。
创建 termios 结构以存储终端设置。

1
2
    static struct termios oldt;
    static int oldfl = -1;
  • oldt 用于保存原来终端设置,使用 static 关键词使得即使函数调用完这个变量还能存在直到整个程序生命周期结束。这样当要恢复原本终端设置时还有迹可循。
  • oldfl 保存原本的文件描述符。不过文件标识符应该是个正数,但这里用 -1 表示没设置过。
    但是当我们设置原始模式,就会改变 fd 就需要一个表明还未设置过的值在以后进行恢复。

开启原始模式

因为该函数可以达到开关,所以需要用 if 语句判断 enable 是否为真,来判断是要开启还是关闭。现在讲讲开启的具体实现。

首先需要再创建一个 termios 结构我们命名为 t,之后编辑 t 到我们想要的模式再应用即可。

1
2
3
        struct termios t;
        tcgetattr(STDIN_FILENO, &oldt);
        t = oldt;

正入上文 termios 结构内容所列举的,我们现在要操作 tc_lflag

操作 c_lflag

尽管 c_lflag 的实质是 unsigned int 不过它存储二进制位进行控制。

1
        t.c_lflag &= ~(ICANON | ECHO);

这里使用了二进制,首先来分析下 ICANONECHO 二进制:

Flag Bin Desc
ICANON 0000 0010 Canonical mode(规范模式)
ECHO 0000 1000 Echo(回显)

用按位或运算符 | 将两者合并也就是: 0000 1010 而后用按位取反运算符号 ~ 将二进制反过来即:
0000 1010 => 1111 0101
这时候这串二进制是掩码。当使用 按位与运算符 &= 赋值掩码的时候可以在保持其他位不变的时候关闭 ICANON 和 ECHO。

  • 按位与的规则:
    1 & 1 = 1 (保持原值)
    1 & 0 = 0 (清零)
    0 & 1 = 0 (保持原值)
    0 & 0 = 0 (保持原值)

此时 t 内的终端模式就是原始模式并关闭回显的了。

以及还有降低读取的延迟代码如下:

1
2
        t.c_cc[VMIN]  = 0;
        t.c_cc[VTIME] = 0;

切换成非阻塞

1
2
3
4
        if (nonblock) {
            oldfl = fcntl(STDIN_FILENO, F_GETFL, 0);
            fcntl(STDIN_FILENO, F_SETFL, oldfl | O_NONBLOCK);
        }

这里对 oldfl 的赋值真正的记录了在设置非阻塞前终端的情况。
fcntl() 来自 fcntl.h
因为是可选的操作,所以由形参中的布尔量 nonblock 来决定是否进行切换为非阻塞操作。

1
fcntl(int fd, int cmd, ...)

STDIN_FILENO 告诉现在要操作标准输入,而 F_GETFL 表明现在要获取文件状态表示。后面的 0 代表不进行其他操作。这样就备份了当前的文件操作符。

而在之后,使用 F_SETFL 开始设置文件状态表示,用 oldfl | O_NONBLOCK 来改变为非阻塞的位。
也就是说,第一行赋值只读以进行备份,第二行就是要写入改变为非阻塞。
总的来说,切换为 Raw mode 的代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
    if (enable) {
        struct termios t;
        tcgetattr(STDIN_FILENO, &oldt);
        t = oldt;

        t.c_lflag &= ~(ICANON | ECHO);

        t.c_cc[VMIN] = 0;
        t.c_cc[VTIME] = 0;

        tcsetattr(STDIN_FILENO, TCSANOW, &t);

        if (nonBlock) {
            oldfl = fcntl(STDIN_FILENO, F_GETFL, 0);
            fcntl(STDIN_FILENO, F_SETFL, oldfl | O_NONBLOCK);
        }
    }

恢复之前的模式

在上述开启 raw mode 中,我们关闭了 ICANON、ECHO,并切换到非阻塞模式。而且我们保存了操作前的终端设置和文件状态表示。
接下来就是恢复回去:
首先用 tcsetattr 恢复为 oldt 的模式:

1
        tcsetattr(STDIN_FILENO, TCSANOW, &oldt);

接着判断之前是否进入非阻塞模式,即是否修改了 oldfl, 如果被修改就说明 oldfl 保存了原本的内容,需要被还原。

1
2
3
4
        if (oldfl != -1){
            fcntl(STDIN_FILENO, F_SETFL, oldfl);
            oldfl = -1;
        }

上述函数总的如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
void setTerminalRawMode(bool enable, bool nonBlock = true) {
    static struct termios oldt;
    static int oldfl = -1;

    if (enable) {
        struct termios t;
        tcgetattr(STDIN_FILENO, &oldt);
        t = oldt;

        t.c_lflag &= ~(ICANON );

        t.c_cc[VMIN] = 0;
        t.c_cc[VTIME] = 0;

        tcsetattr(STDIN_FILENO, TCSANOW, &t);

        if (nonBlock) {
            oldfl = fcntl(STDIN_FILENO, F_GETFL, 0);
            fcntl(STDIN_FILENO, F_SETFL, oldfl | O_NONBLOCK);
        }
    } else {
        tcsetattr(STDIN_FILENO, TCSANOW, &oldt);
        if (oldfl != -1){
            fcntl(STDIN_FILENO, F_SETFL, oldfl);
            oldfl = -1;
        }

    }
}

这个可以拆分到类里面的构造函数和析构函数来更好的控制终端。
但这些过于复杂,就算是如此还是会有些问题在里面。但是对于这种需求,已经有人帮我们造好了一个相对不错的轮子: ncurses.h
使用 ncurses.h 会更方便的进入 Raw Mode,

参考资料

  1. wikibooks Serial Programming/termios
  2. linux man pages
1
man termios.h
  1. tcgetattr函数和tcsetattr函数—-终端控制介绍与使用
  2. termios结构体的详细设置
萌ICP备20241614号