getchar()和stdin

8

有一相关问题在这里,但我的问题不同。

我想更多地了解getchar()和stdin的内部工作原理。我知道getchar()最终只是调用fgetc(stdin)。

我的问题是关于缓冲、stdin和getchar()的行为。给定经典的K&R示例:

#include <stdio.h>

main()
{
    int c;

    c = getchar();
    while (c != EOF) {
        putchar(c);
        c = getchar();
    }
}

我认为getchar()的行为可以描述如下:

如果stdin缓冲区中没有内容,则让操作系统接受用户输入,直到按下[enter]键。然后返回缓冲区中的第一个字符。

假设程序正在运行,用户输入"anchovies"。

因此,在上面的代码列表中,第一次调用getchar()等待用户输入并将缓冲区中的第一个字符分配给变量c。在循环内部,第一次迭代调用getchar()会说“嘿,缓冲区中有东西,请返回缓冲区中的下一个字符。”但是while循环的第N次迭代导致getchar()说“嘿,缓冲区中没有任何内容,所以让stdin收集用户输入。

我花了一些时间研究了c源代码,但似乎这更像是stdin的行为特征,而不是fgetc()的特征。

我错了吗?感谢您的见解。


3
我非常怀疑"经典的K&R示例"中的fflush(stdin)存在未定义行为!我现在无法立即检查... - pmg
1
@pmg:你是对的,我已经更正了例子。 - schot
谢谢,我可能粘贴了一个脏的例子,其中包含了我实验时留下的一些垃圾。 - ybakos
3个回答

24

你观察到的行为与 C 和 getchar() 无关,而与操作系统内核中的电传打字机 (TTY) 子系统有关。

为此,您需要了解进程如何从键盘获取输入,以及它们如何将输出写入终端窗口(我假设您使用UNIX,并且以下解释特别适用于UNIX,即Linux、macOS等):

enter image description here

上图中标题为“Terminal”的方框是您的终端窗口,例如 xterm、iTerm 或 Terminal.app。在旧时代,终端是单独的硬件设备,由键盘和屏幕组成,它们通过串行线 (RS-232) 连接到一台(可能是远程的)计算机上。在终端键盘上键入的每个字符都会通过该行发送到计算机并被连接到终端的应用程序消费掉。应用程序生成的每个字符作为输出发送到相同的行上,然后由终端显示在屏幕上。

现在,终端不再是硬件设备,而是移至计算机内部,成为被称为终端仿真器的进程。xterm、iTerm2、Terminal.app 等都是终端仿真器。

然而,应用程序和终端仿真器之间的通信机制与硬件终端相同。终端仿真器模拟硬件终端。这意味着,从应用程序的角度来看,今天与终端仿真器(例如iTerm2)交谈的工作方式与 1979 年与真正的终端设备(例如DEC VT100)交谈的方式相同。保留了这种机制,以便为硬件终端开发的应用程序仍可与软件终端仿真器配合使用。

那么,这个通信机制是如何工作的?UNIX 内核中有一个名为TTY的子系统(TTY 代表电传打字机,这是最早的计算机终端形式,甚至没有屏幕,只有键盘和打印机)。您可以将 TTY 视为终端的通用驱动程序。TTY 从连接到终端的端口读取字节(来自终端的键盘),并向此端口写入字节(被发送到该终端的显示器)。

计算机连接到的每个终端(或运行在计算机上的每个终端仿真器进程)都有一个 TTY 实例。因此,从应用程序的角度来看,TTY 实例也被称为TTY 设备。按照 UNIX 的方式,将驱动程序接口作为文件提供,这些 TTY 设备以某种形式显示为/dev/tty*,例如,在 macOS 上它们是/dev/ttys001/dev/ttys002等。

一个应用程序可以将其标准流(stdin,stdout,stderr)定向到TTY设备(实际上,这是默认设置,您可以使用命令找出您的shell连接到哪个TTY设备)。这意味着无论用户在键盘上输入什么都成为应用程序的标准输入,并且应用程序写入其标准输出的任何内容都被发送到终端屏幕(或终端仿真器的终端窗口)。所有这些都通过TTY设备进行,也就是说,应用程序只与TTY设备(内核中的此类型驱动程序)通信。

现在,关键点:TTY设备不仅仅是将每个输入字符传递到应用程序的标准输入。默认情况下,TTY设备对接收到的字符应用所谓的行规则。这意味着它会本地缓冲它们并解释删除、退格和其他行编辑字符,并且只有在接收到回车符换行符时才将它们传递给应用程序的标准输入,这意味着用户已经完成了输入和编辑整行。

也就是说,在用户按下返回键之前,getchar()在stdin中看不到任何东西。就好像到目前为止什么也没有输入一样。只有当用户按下返回键时,TTY设备才将这些字符发送到应用程序的标准输入,getchar()随即读取。

从这个意义上讲, getchar() 的行为没有什么特别之处。它只是在stdin中立即读取可用的字符。您观察到的行缓冲发生在内核中的TTY设备中。

现在进入有趣的地方:可以配置此TTY设备。例如,您可以使用 stty 命令从shell中进行配置。这使您可以配置TTY设备应用于传入字符的行规则的几乎所有方面。或者,通过将TTY设备设置为原始模式,可以禁用任何处理。在这种情况下,TTY设备会将每个接收到的字符立即转发到应用程序的标准输入而不进行任何形式的编辑。

如果您在TTY设备中启用原始模式,则会发现 getchar () 立即 接收到您在键盘上输入的每个字符。以下C程序演示了这一点:

#include <stdio.h>
#include <unistd.h>   // STDIN_FILENO, isatty(), ttyname()
#include <stdlib.h>   // exit()
#include <termios.h>

int main() {
    struct termios tty_opts_backup, tty_opts_raw;

    if (!isatty(STDIN_FILENO)) {
      printf("Error: stdin is not a TTY\n");
      exit(1);
    }
    printf("stdin is %s\n", ttyname(STDIN_FILENO));

    // Back up current TTY settings
    tcgetattr(STDIN_FILENO, &tty_opts_backup);

    // Change TTY settings to raw mode
    cfmakeraw(&tty_opts_raw);
    tcsetattr(STDIN_FILENO, TCSANOW, &tty_opts_raw);

    // Read and print characters from stdin
    int c, i = 1;
    for (c = getchar(); c != 3; c = getchar()) {
        printf("%d. 0x%02x (0%02o)\r\n", i++, c, c);
    }
    printf("You typed 0x03 (003). Exiting.\r\n");

    // Restore previous TTY settings
    tcsetattr(STDIN_FILENO, TCSANOW, &tty_opts_backup);
}

该程序将当前进程的TTY设备设置为原始模式,然后使用getchar()循环读取和打印来自stdin的字符。这些字符以十六进制和八进制表示法打印为ASCII代码。该程序特别将ETX字符(ASCII代码0x03)解释为终止触发器。您可以通过键入Ctrl-C在键盘上生成此字符。


这是一个很好的答案,非常全面。如果我理解正确,终端会在我输入时立即显示我输入的所有内容。因此,它可能以类似于原始模式的方式设置TTY。在终端中运行上述程序时,该终端停止打印我输入的任何内容,而仅将输出打印到stdout。因此,我猜测在此程序中设置TTY也会影响它如何向父进程(在本例中为终端)传递字节到stdin。 - yijiem
我认为 'cfmakeraw' 调用并没有完全初始化结构体,它只是修补了进入原始模式的部分。因此,在调用之前,您需要插入 "tty_opts_raw = tty_opts_backup;"。 - reddish
1
这是一个很好的答案!顺便说一下,man:cfmakeraw显示了“原始”模式下设置的各种termios标志。我用手册页面上的标志设置替换了cfmakeraw调用,结果程序进入了一个无限循环。我不得不从另一个屏幕杀掉它,然后运行 stty sane 进行恢复。所以我会继续使用 cfmakeraw 调用.... - dturvene

8

getchar()的输入是行缓冲的,并且输入缓冲区是有限的,通常为4 kB。你看到的第一件事是你输入的每个字符的回显。当你按下ENTER键时,getchar()开始返回字符直到LF(它被转换为CR-LF)。当你连续按键但没有LF的时间超过4096个字符时,它会停止回显,你必须按ENTER键才能继续。


5
我知道getchar()最终只是调用了fgetc(stdin)
不一定。 getchargetc同样可以扩展为从文件中读取的实际过程,其中fgetc是如下实现的。
int fgetc(FILE *fp)
{
    return getc(fp);
}

嘿,缓冲区里没有东西了,所以让 stdin 收集用户输入。

[...] 看起来这更像是 stdin 的行为特征,而不是 fgetc()

我只能告诉你我的所知道的,也就是 Unix/Linux 是如何工作的。在该平台上,一个 FILE(包括 stdin 指向的对象)持有一个文件描述符(一个 int),该文件描述符被传递给操作系统,表示 FILE 从哪个输入源获取数据,还有一些其他的管理信息。

然后 "收集" 部分意味着 "调用 read 系统调用来再次填充缓冲区"。虽然这因 C 的不同实现而异。


谢谢您的见解。那么,从高层次来看,我的一般行为描述是否正确?就像“嘿,缓冲区里没有任何东西,所以从标准输入中收集一些新的输入?” - ybakos
1
@ybakos:是的,就是这样。顺便说一下,fgetc实现技巧并不是虚构的:如果我没记错的话,我在4.4BSD UNIX C库中看到过这个技巧,而Mac OS X中的C库最终也是从那里派生出来的。 - Fred Foo

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接