PAYATHON 2026

Windows 控制台 Unicode codepage 下 _kbhit() 行为异常

排障赵师傅

结论

不要用 _kbhit() 和 _getch() 组合实现 UTF-8 非阻塞输入。它们沿用传统 conio 的字节和扩展键协议,无法提供足够的信息来区分:

  • UTF-8 多字节序列
  • 以 0 或 0xE0 开头的扩展键序列
  • CRT 已完成转换但尚未返回的后续字节

即使 SetConsoleCP(CP_UTF8) 调用成功,反复执行“先 _kbhit(),再调用一次 _getch()”也可能停在一个 UTF-8 字符的中间。可靠的通用方案是用 ReadConsoleInputW() 读取 KEY_EVENT_RECORD:从 UnicodeChar 获取字符,通过 virtual-key code 和 scan code 识别功能键。

_getwch() 更适合读取 Unicode 字符,但仍沿用扩展键约定。与 _kbhit() 配合使用时,它也无法提供完整的事件语义,因此不能解决这里的问题。

为什么会出现“队列停滞”

Windows 控制台输入队列保存的是事件。通过宽字符接口读取 KEY_EVENT_RECORD 时,字符位于:

record.Event.KeyEvent.uChar.UnicodeChar

这个字段保存 UTF-16 code unit,而非 UTF-8 字节。

_getch() 需要把控制台字符转换为当前 console input code page 对应的字节。当输入页为 65001 时,一个控制台字符可能转换为多个 UTF-8 字节,例如:

à   C3 A0
€   E2 82 AC

每次 _getch() 只能返回一个字节,其余字节由 CRT 暂存。但 _kbhit() 不一定会把这些暂存字节全部报告为新的可读按键。程序读完首字节后再次等待 _kbhit(),便可能停在 UTF-8 序列中间。等到新的控制台事件到达,读取流程继续推进,看起来就像按下 Shift、Ctrl 或其他字符后“释放”了积压的数据。

具体缓存方式取决于所链接的 CRT 实现,无法根据示例判断其内部路径。无论停滞发生在 CRT 的转换缓存中,还是源于 _kbhit() 对事件的判断,conio 都没有定义可供程序依赖的 UTF-8 事件协议。

0xE0 的歧义无法通过解析算法消除

对于某些扩展键,_getch() 会先返回 0 或 0xE0,调用者需要再读取一次才能得到剩余部分。

0xE0 同时也是合法的 UTF-8 三字节序列首字节。例如,U+0800 到 U+0FFF 中有一部分字符以 0xE0 开头。只观察 _getch() 返回的字节,程序无法判断某个 0xE0 究竟是:

  • 扩展键前缀;
  • UTF-8 字符的首字节。

à 和 € 都不以 0xE0 开头,因此这一冲突并非这两个字符停滞的直接原因。不过,它说明这种方案无法处理任意 Unicode 输入。

宽字符接口没有这种字节级歧义。KEY_EVENT_RECORD 分别保存文本字符、virtual-key code、scan code 和修饰键状态。

使用 ReadConsoleInputW() 实现非阻塞读取

下面的函数先检查输入队列中的事件数量。没有事件时,它会立即返回。函数会消费非按键事件和按键释放事件,找到下一个按键按下事件后返回。

#include <windows.h>
#include <stdint.h>
#include <stdio.h>

static int poll_key_event(HANDLE input, KEY_EVENT_RECORD *key)
{
    for (;;) {
        DWORD count = 0;
        DWORD read_count = 0;
        INPUT_RECORD record;

        if (!GetNumberOfConsoleInputEvents(input, &count)) {
            return -1;
        }

        if (count == 0) {
            return 0;
        }

        if (!ReadConsoleInputW(input, &record, 1, &read_count)) {
            return -1;
        }

        if (read_count == 0) {
            return 0;
        }

        if (record.EventType != KEY_EVENT) {
            continue;
        }

        if (!record.Event.KeyEvent.bKeyDown) {
            continue;
        }

        *key = record.Event.KeyEvent;
        return 1;
    }
}

int main(void)
{
    HANDLE input = GetStdHandle(STD_INPUT_HANDLE);
    DWORD mode;

    if (input == INVALID_HANDLE_VALUE ||
        input == NULL ||
        !GetConsoleMode(input, &mode)) {
        fprintf(stderr, "standard input is not a console\n");
        return 1;
    }

    for (;;) {
        KEY_EVENT_RECORD key;
        int result;

        while ((result = poll_key_event(input, &key)) > 0) {
            WCHAR unit = key.uChar.UnicodeChar;

            if (unit != L'\0') {
                printf("UTF-16 unit: U+%04X, repeat: %u\n",
                       (unsigned int)unit,
                       (unsigned int)key.wRepeatCount);

                if (unit == L'\x001B') {
                    return 0;
                }
            } else {
                printf("special key: VK=0x%02X, scan=0x%02X, "
                       "control=0x%08lX, repeat=%u\n",
                       (unsigned int)key.wVirtualKeyCode,
                       (unsigned int)key.wVirtualScanCode,
                       (unsigned long)key.dwControlKeyState,
                       (unsigned int)key.wRepeatCount);
            }
        }

        if (result < 0) {
            fprintf(stderr, "console input error: %lu\n",
                    (unsigned long)GetLastError());
            return 1;
        }

        Sleep(10);
    }
}

该接口直接返回结构化事件:

  • UnicodeChar != 0 表示事件产生了 UTF-16 文本;
  • wVirtualKeyCode 用来识别方向键、功能键和其他虚拟键;
  • wVirtualScanCode 保存 scan code;
  • dwControlKeyState 保存 Shift、Ctrl、Alt 等状态;
  • wRepeatCount 表示按键重复次数。

程序无需再把特殊键编码为 0xFF00 | value,也不用从同一条字节流中猜测 UTF-8 和扩展键的边界。

处理基本多文种平面之外的字符

WCHAR 表示一个 UTF-16 code unit。è、à、£ 和 € 都在 BMP 内,一个 WCHAR 即可表示。U+10000 及以上的字符则需要一对 UTF-16 surrogate。

如果程序最终需要 Unicode code point,可以保存 high surrogate 状态:

typedef struct {
    WCHAR high;
} UTF16_DECODER;

static int decode_utf16_unit(
    UTF16_DECODER *decoder,
    WCHAR unit,
    uint32_t *codepoint)
{
    if (decoder->high != 0) {
        if (unit >= 0xDC00 && unit <= 0xDFFF) {
            *codepoint =
                0x10000u +
                (((uint32_t)decoder->high - 0xD800u) << 10) +
                ((uint32_t)unit - 0xDC00u);

            decoder->high = 0;
            return 1;
        }

        decoder->high = 0;
    }

    if (unit >= 0xD800 && unit <= 0xDBFF) {
        decoder->high = unit;
        return 0;
    }

    if (unit >= 0xDC00 && unit <= 0xDFFF) {
        return 0;
    }

    *codepoint = (uint32_t)unit;
    return 1;
}

如果程序需要保留格式错误信息,应为孤立 surrogate 返回专门的错误状态,不能直接忽略。

还需要修正的示例代码问题

原始循环没有初始化 ich:

int ich;
do {
    ...
} while (ich != 0x1b);

如果第一次检查时 _kbhit() 返回零,循环条件便会读取未初始化的变量,引发未定义行为。至少应改为:

int ich = 0;

这样可以消除未定义行为,但无法解决 UTF-8 多字节输入与 _kbhit() 之间的接口问题。

使用时的注意事项

  • 改用 ReadConsoleInputW() 后,字符输入不再依赖 SetConsoleCP(CP_UTF8)。code page 主要影响窄字符接口的转换。
  • 输出 Unicode 时可以使用 WriteConsoleW()。如果输出到管道或文件,需要自行明确编码。
  • 不要在同一个 console input handle 上混用 _getch()、ReadConsoleInputW()、ReadConsole() 和 stdin,否则多个读取器会互相消费或缓存数据。
  • 示例会丢弃鼠标、窗口尺寸和焦点事件。如果程序需要这些事件,应统一分发 INPUT_RECORD,不能直接跳过。
  • WaitForSingleObject(input, 0) 也可以检查控制台句柄是否已触发,但“有事件”不等于“有字符”。队列头部可能是按键释放、鼠标或窗口事件,程序仍需读取并分类。
  • PeekConsoleInputW() 只查看记录,不会将其移出队列。如果队列头部是不需要的事件,而程序始终不消费它,轮询可能变成忙循环。
  • IME、组合字符和粘贴内容未必与物理按键一一对应。程序需要分别处理“按键事件”和“文本输入”。
  • 标准输入被重定向后就不再是控制台,此时不能使用 ReadConsoleInputW()。应改用 ReadFile() 等流式输入接口,并自行处理字节编码。

_kbhit() 和 _getch() 可以用于 non-Unicode code page,但不适合作为通用的 UTF-8 非阻塞输入协议。若要可靠处理 Unicode 文本、功能键和修饰键,应保留 KEY_EVENT_RECORD 中的结构化信息,再由应用层完成 UTF-16 组合和事件分发。

备注:内容仅供参考。