参考 Entering raw mode

这一节的内容可能比较枯燥,是一些底层的 Terminal 的属性:canonical mode,回显(echoing),键盘按键对应的 ASCII 码(可以发现一些按键不止对应一个编码,还有些按键对应的是同一个编码)和一些杂项设置…通过改变这些设置,让 Terminal 进入所谓的 “raw mode”。

Makefile

我使用 make 来构建 VIP,因为只有两个源文件,所以 Makefile 很简单:

CC = gcc
CFLAGS = -Wall -Wextra -pedantic -std=c99 -O3

all: vip

debug:
	$(CC) $(CFLAGS) vip.c -g -o vipd

re:
	make clean;make

clean:
	rm -f vip vipd

CFLAGS 里使用 c99 标准;使用 -O3进行编译优化,主要是对内联函数进行优化;-Wall -Wextra -pedantic 会打印一些额外的警告,对 debug 有帮助。

错误处理

首先介绍一下通用的错误处理函数,当遇到错误是,我们只是打印错误消息,然后直接退出程序。虽然程序没有那么健壮,但是足够简单(

void die(const char *msg) {
  perror(msg);
  exit(1);
}

Canonical mode

我们使用read() system call 来读取一个字节的输入:

#include <unistd.h>

int main(int argc, char const *argv[]) {
  char c;
  while (read(STDIN_FILENO, &c, 1) == 1) {
    write(STDOUT_FILENO, &c, 1);
    if (c == 'q') break;
  }
  return 0;
}

上面的代码,至少可以说明两点:

  • 当我们按下按键时,输入的字符马上显示在屏幕上了,即echoing(回显)。
  • 只有按下Enter键后,程序才接收到输入的字符,即 canonical mode,是按行进行输入的。

接下来,我们关闭 echoing 和 canonical mode,因为我不需要 Terminal 的回显,我们要自己来实现显示字符,也不要按行读取字符,每当按下一个按键时,程序就会接收到它,编辑器应该是按字节读取的。

关闭回显和canonical mode

使用 tcgetattr当前 terminal 的属性,使用 tcsetattr设置 terminal 的属性,这些方法都在termios.h头文件中。

typedef struct editor_config {
  struct termios origin_termios;
} Editor;

static Editor editor;

void enable_raw_mode() {
  struct termios raw;
  // get terminal attributes
  if (tcgetattr(STDIN_FILENO, &raw) == -1) die("tcgetattr");
  editor.origin_termios = raw;
  // restore at exit
  atexit(disable_raw_mode);
  // it turns out that termianl won' print what you input, and read
  // byte-by-byte instead of line-by-line
  raw.c_lflag &= ~(ECHO | ICANON);

  if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw) == -1) die("tcsetattr");
}

void disable_raw_mode() {
  if (tcsetattr(STDIN_FILENO, TCSAFLUSH, &editor.origin_termios) == -1)
    die("disable_raw_mode");
}

定义 Editor 结构体来保存全局变量,之后会添加更多变量到其中的。目前就只有一个 termios,用来保存之前的 terminal属性。在enable_raw_mode()函数里,对终端属性一通乱改之后,那么在程序退出的时候需要恢复原理的属性吧。

这里利用atexi()函数,会在程序退出之前调用传给他的函数。

这时候,如果你运行程序,似乎没有发现什么区别。我们按下按键之后,字符还是马上显示在屏幕上了。其实不然,终端已经关闭了 echoing 和 canonical mode。那么,程序就是按字节读取的,不需要按下ENTER,而字符可以显示在屏幕上是因为这句代码:write(STDOUT_FILENO, &c, 1);,这可以看做我们自己又实现了回显,注释掉这句代码,就没有任何输出了。

按键

在屏幕打印出我们按下按键的编码,这样有利于之后的编码。比如,必须知道INS键的编码,我们才能在读取的时候捕获它。运行下面的代码可以发现INS键其实对应的不是一个ASCII码,而是四个:27 [ 2 ~

int main(int argc, char const *argv[]) {
  enable_raw_mode();

  char c;
  while (read(STDIN_FILENO, &c, 1) == 1) {
    if (iscntrl(c)) {
      printf("%d\n", c);
    } else {
      printf("%d ('%c')\n", c, c);
    }
    if (c == 'q') break;
  }
  return 0;
}

iscntrl()来自ctype.h头文件,顾名思义,它是is control的缩写,判断一个字符是否是 control character。

Control characters are nonprintable characters that we don’t want to print to the screen. ASCII codes 0–31 are all control characters, and 127 is also a control character. . ASCII codes 32–126 are all printable.

可以测试一下键盘上所有按键,会发现一些有趣的事:

  • 方向键,Page UpPaage downHomeEnd这些按键,都是3到4 个字节的,以27[开头。这称为转义序列。Esc键是27。
  • Backspace是127,Enter为10。
  • CTRL-A是1,CTRL-B是2,CTRL-C是3,似乎CTRL-WORD对应的就是26个字母的顺序,这在我的另一篇博客里也有提及。
  • CTRL-S可能会让程序不能输出,可以按下CTRL-Q恢复,这是一种 stop sending you output的进制。
  • CTRL-Z也可能是CTRL-Y发送SIGTSTP信号,会使得你的程序挂起。在终端输入fg,可以唤醒程序。
  • CTRL-C发送SIGINT信号结束进程。

关闭 CTRL-C CTRL-Z CTRL-V

  raw.c_lflag &= ~(ECHO | ICANON | ISIG | IEXTEN);

关闭 CTRL-S CTRL-Q

  raw.c_iflag &= ~(IXON);

修复 CTRL-M

你会发现CTRL-WORD都是对应字母表的顺序的,除了这个CTRL-M是10,CTRL-J已经是10了,还有什么也是10吗,就是Enter键。这是终端的一个功能,会把 13('\r') 变成 10(\n)。即, translating any carriage returns (13, '\r') inputted by the user into newlines (10, '\n')。

  raw.c_iflag &= ~(IXON | ICRNL);

之后,CTRL-MEnter都会变成13。

关闭 output processing

其实我也一直不明白为啥要用\r\n来表示换行,直到看了这篇教程。先来解释一下\r\n的作用:

  • \r,carriage。移动光标到这一行的行首。

  • \n,newline。移动光标到下一行(如果需要的话会滚动屏幕),但是不改变横坐标。

相信这样的解释以后,你已经明白为什么需要两个字符,才能达到所谓的“换行”效果了。

终端会自动把\n转化成\r\n,这带来很多方便,我们使用printf函数时,只需要加上\n就可以达到换行效果。而现在是时候需要关闭这个功能了。

raw.c_oflag &= ~(OPOST);

没有 \r\n,就是下图这个样子的

without-carriage

这之后,我们需要修改printf语句为:printf("\r\n")。这显然不方便,所以我编写了一个println内联函数来代替。

#include <stdarg.h>
static inline int println(const char *fmt, ...) {
  va_list args;
  va_start(args, fmt);
  vprintf(fmt, args);
  va_end(args);
  return printf("\r\n");
}

int main(int argc, char const *argv[]) {
  enable_raw_mode();

  char c;
  while (read(STDIN_FILENO, &c, 1) == 1) {
    if (iscntrl(c)) {
      println("%d", c);
    } else {
      println("%d ('%c')", c, c);
    }
    if (c == 'q') break;
  }
  return 0;
}

Timeout for read()

现在,read()函数是阻塞的,程序阻塞在read(),等待键盘的输入。 如果 在等待的过程中我们想要做些事情该咋办呢?我们可以设置 timeout,这样如果一定时间内read()没有读到输入,就会直接返回,这样依赖read()函数,就不是阻塞的了。

// in ed_enable_raw_mode()
  raw.c_cc[VMIN] = 0;
  // read timeout at 200 ms
  // if don't set screen will not refresh until key press
  raw.c_cc[VTIME] = 2;

int main(int argc, char const *argv[]) {
  enable_raw_mode();

  while (1) {
    char c = '\0';
    if (read(STDIN_FILENO, &c, 1) == -1 && errno != EAGAIN) die("read");
    if (iscntrl(c)) {
      println("%d", c);
    } else {
      println("%d ('%c')", c, c);
    }
    if (c == 'q') break;
  }
  return 0;
}

VMIN表示最小可读的字节数。我们设置为0,读取任何可读的字节。VTIME表示read()的最长等待时间,设置为2,表示200毫秒。如果read()超时了,它会返回 0 ,进而进入下一个循环。运行程序可以看到程序一直打印0。而当按下按键时,会显示按键的编码。

小结

这一步中,我们让终端进入 raw mode,这使得编辑器可以自己渲染和打印字符在屏幕上。下一步,我们会处理低级的终端输入输出,并利用这些来渲染和移动光标。

第一步的代码可以在GitHub上查看,我用step1, step2 … 这样分支来分离每一步的代码。更加详细的步骤在steps/文件夹下给出。