返回

Linux 终端:正确处理 Ctrl+C 输入避免 stdin 阻塞

Linux

Linux:处理 Ctrl+C 中断 stdin 问题

在 Linux 系统上开发涉及终端输入输出的应用时,捕获 Ctrl+C 信号中断程序的运行是一种常见需求。 同样,对于一些特定的应用场景, 我们希望应用程序可以接收和处理包括 Ctrl+C 在内的各种键盘输入组合,而不是直接被系统中断。 本文将探讨一种在 Linux 中处理 Ctrl+C 输入时,可能导致标准输入(stdin)无法正常工作的状况, 并提供可行的解决方案。

问题分析

问题通常出现在以下情景:程序为了读取原始的、非规范的键盘输入而禁用了终端的规范模式 (canonical mode) 以及一些信号处理功能。 常见的代码片段如下所示,其中通过 tcsetattr 系统调用修改了终端的属性。 核心操作是禁用 ICANON (启用规范模式),ISIG (启用信号生成),IEXTEN(扩展输入处理)以及回显。 IXONIXOFF负责软件流控,在此也被禁用。

termios tOld, tNew;
tcgetattr(STDIN_FILENO, &tOld);
tNew = tOld;
tNew.c_lflag &= ~(ICANON | ISIG | IEXTEN | IXON | ECHO);
tNew.c_iflag &= ~(IXON | IXOFF);
tcsetattr(STDIN_FILENO, TCSANOW, &tNew);

//  读取字符...
// 还原之前的终端设置
tcsetattr(STDIN_FILENO, TCSANOW, &tOld);

在这个模式下,程序不再按行读取输入,而是可以立即读取每个按键事件。 这使得读取 Ctrl 组合键成为可能。但是,禁用 ISIG 标记虽然允许程序捕获Ctrl+C 等组合键的输入,但 Ctrl+C 也会导致终端驱动程序发送 SIGINT 信号到前台进程组(我们的应用)。 这会导致当前输入通道阻塞,之后的输入行为都依赖于 SIGINT 的后续处理,而我们的程序恰好没有设置。 于是产生了 “Ctrl+C 导致 stdin 输入停止工作” 现象。
这表明应用程序既需要处理 Ctrl+C 输入,又要防止因发送信号而终止或者挂起输入。

另外,使用 select 检查文件符是否可读的方法,其在某些场景也可能与 SIGINT 信号存在竞争关系,可能出现无法捕获的状况。

std::function<bool(unsigned)> fnWaitForNextKey =
    [](unsigned iMillis) -> bool
    {
        struct timeval tv;
        fd_set fds;
        tv.tv_sec = 0;
        tv.tv_usec = iMillis * 1000;
        FD_ZERO(&fds);
        FD_SET(STDIN_FILENO, &fds);
        return select(STDIN_FILENO + 1, &fds, NULL, NULL, &tv);
    };

解决方案

为了解决这个问题,需在禁用信号生成的同时,注册一个自定义的 SIGINT 信号处理函数。当接收到 SIGINT 信号, 既不结束程序, 也不让它影响 stdin 的正常运作。 步骤如下:

1. 安装信号处理函数

使用 signal() 函数, 安装一个自定义的信号处理器,当程序接收到 SIGINT 信号(即按下 Ctrl+C)时, 运行该函数。此函数什么都不做, 可以是一个空函数。

#include <signal.h>

void sigint_handler(int signum){
    // 空函数
}

int main(){
 signal(SIGINT, sigint_handler);
 //...
}

2. 修改终端属性

按照之前的代码禁用终端的规范模式以及信号产生。 这能使 Ctrl+C 变成一个正常的字符输入,而不再触发进程中断。

termios tOld, tNew;
tcgetattr(STDIN_FILENO, &tOld);
tNew = tOld;
tNew.c_lflag &= ~(ICANON | ISIG | IEXTEN | ECHO);
tNew.c_iflag &= ~(IXON | IXOFF);
tcsetattr(STDIN_FILENO, TCSANOW, &tNew);

3. 读取字符

程序可以使用 read 函数来读取按键, 或者使用 getchar 。 此时 Ctrl+C 将作为一个普通字符读取, 而不再会中断程序。

char buffer[1];
while (true) {
       ssize_t bytesRead = read(STDIN_FILENO, buffer, 1);
       if (bytesRead > 0) {
             // process the input
             if (buffer[0] == '\x03') {  // '\x03' 是 Ctrl+C 的字符代码
                  std::cout << "Ctrl+C pressed.\n";
                  break; // 程序退出,也可做其他的逻辑
               }else{
                    printf("Character: 0x%X \n", (unsigned char) buffer[0]);
                  }
       }
}

4. 恢复终端设置

退出读取循环后,不要忘记恢复终端原来的属性,这样避免对终端的后续操作产生副作用。

 tcsetattr(STDIN_FILENO, TCSANOW, &tOld);

安全建议

在禁用终端的 ISIG 功能时需要格外注意。如果信号处理逻辑没有得到正确实施,程序可能无法响应系统信号,从而导致进程的终止不可控。建议在程序退出时及时恢复终端属性,或是在必要时使用默认的信号处理行为, 这能有效地避免在一些复杂程序环境下因未处理 SIGINT 而导致的退出错误。

以下为完整的代码示例

#include <iostream>
#include <termios.h>
#include <unistd.h>
#include <signal.h>

void sigint_handler(int signum){
   //do nothing
}

int main() {
  signal(SIGINT, sigint_handler);

  termios tOld, tNew;
  tcgetattr(STDIN_FILENO, &tOld);
  tNew = tOld;
  tNew.c_lflag &= ~(ICANON | ISIG | IEXTEN | ECHO);
  tNew.c_iflag &= ~(IXON | IXOFF);
  tcsetattr(STDIN_FILENO, TCSANOW, &tNew);
  
  char buffer[1];
    while (true) {
            ssize_t bytesRead = read(STDIN_FILENO, buffer, 1);
             if (bytesRead > 0) {
                  if (buffer[0] == '\x03') {  
                      std::cout << "Ctrl+C pressed.\n";
                      break; 
                      }else{
                       printf("Character: 0x%X \n", (unsigned char) buffer[0]);
                    }
            }
    }
    tcsetattr(STDIN_FILENO, TCSANOW, &tOld);

    return 0;
}

通过结合信号处理函数,终端属性控制和正确的读取操作,应用可以既处理 Ctrl+C 输入, 又能避免其造成的输入流中断。