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 组合和事件分发。
备注:内容仅供参考。
