COOKED_READ_DATA 深度解析:conhost 中 Windows 控制台 cooked read(readline)实现的完整行为与源码剖析
COOKED_READ_DATA 深度解析conhost 中 Windows 控制台 cooked readreadline实现的完整行为与源码剖析【免费下载链接】terminalThe new Windows Terminal and the original Windows console host, all in the same place!项目地址: https://gitcode.com/GitHub_Trending/term/terminal本文以 doc/COOKED_READ_DATA.md 这份人工测试清单为骨架系统梳理 Windows 控制台宿主conhost中“cooked read”即控制台的单行输入编辑俗称 readline的完整行为从ReadConsole的输入模式到COOKED_READ_DATA的状态机覆盖按键处理、F 键弹层、历史导航、宽字符渲染与无障碍播报等全部交互细节并结合 src/host/readDataCooked.cpp 与 src/host/history.h 的源码实现逐项佐证。读完本文你可以完整理解 cmd.exe 提示符下每一处输入编辑行为的底层来源并能对照源码清单进行验证与手工回归测试。一、什么是 cooked readCOOKED_READ_DATA 在架构中的位置当控制台应用如 cmd.exe以默认输入模式ENABLE_LINE_INPUT | ENABLE_PROCESSED_INPUT | ENABLE_ECHO_INPUT默认值见 src/host/inputBuffer.cpp 中的INPUT_BUFFER_DEFAULT_INPUT_MODE调用ReadConsole读取 stdin 时conhost 并不是“按一个字符读一个字符”而是接管整行输入由终端负责回显、光标移动、退格删词、历史回溯等全部编辑操作直到用户按下回车才把整行交还给应用。这个“编辑行”的状态机就是本文主题COOKED_READ_DATA。从源码结构看其继承关系与调度链如下基类 ReadDatareadData.hpp实现IWaitRoutine接口用于在“一次读取需要阻塞等待更多输入”时把读取状态挂入等待队列、跨调用持久化工厂入口在 src/host/stream.cpp 的_ReadLineInput()通过std::make_uniqueCOOKED_READ_DATA(...)构造实例第 346 行先注册到全局控制台信息gci.SetCookedReadData()再调用Read()若返回 false还需要更多按键则把整个对象移交给等待队列ConsoleWaitQueue::s_CreateWait后续每次有新按键写入输入缓冲时由Notify()唤醒继续编辑输入测试基线可用功能测试印证src/host/ft_host/API_InputTests.cpp 中即以ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT | ENABLE_MOUSE_INPUT模式发起行读取。Notify()readDataCooked.cpp 第 201-249 行还定义了读取的终止条件收到 CtrlC/CtrlBreak 时以STATUS_ALERTED结束读取等待线程退出ThreadDying或句柄关闭HandleClosing时同样终止——这些正是 cooked read 与原始读取共享的底层语义。二、类结构状态机、缓冲区与四种弹层COOKED_READ_DATA的完整声明位于 src/host/readDataCooked.hpp核心成员可归为四组1. 状态机第 46-51 行enum class State : uint8_t { Accumulating 0, // 正在逐键编辑 DoneWithWakeupMask, // 按到了“唤醒掩码”控制字符如 Tab读取提前结束 DoneWithCarriageReturn, // 按下回车读取结束 };2. 弹层类型PopupKind第 53-79 行共四种与测试清单一一对应枚举值触发键作用头部注释原文语义CopyToCharF2从上一条命令中复制“当前光标位置到目标字符首次出现处不含该字符”的文本到当前行行为同 F3 但自动搜索终止字符CopyFromCharF4删除“当前光标位置到目标字符首次出现处不含该字符”之间的文本CommandNumberF9按序号1-5 位数字替换当前提示行为历史中第 N 条命令CommandListF7可视化的历史命令选择对话框是“所有弹层中使用最广泛的”3. 行缓冲与光标第 162-179 行_buffer整行文本、_bufferCursor光标的码元偏移、_bufferDirtyBeg脏区起点用于增量重绘、_insertMode覆盖模式、以及_originInViewport、_pagerPromptEnd、_pagerContentTop、_pagerHeight等“pager 坐标”——当输入行占满整个视口后cooked read 会退化为类似分页器的滚动行为。4. 关联对象第 153-160 行_screenInfo输出缓冲引用、_history按客户进程查到的CommandHistory*见 CommandHistory::s_Find、_ctrlWakeupMaskCONSOLE_READCONSOLE_CONTROL::dwCtrlWakeupMask控制哪些控制字符会提前结束读取、_userBuffer客户端读缓冲区 span。一个值得注意的工程细节构造函数中会为屏幕缓冲额外申请一个共享输出句柄第 52-62 行既保证读取期间缓冲存活也顺带完成“该缓冲是否允许被本读取访问”的权限检查注释还特别说明了 GH#16158——即使读取目标是备用缓冲也必须持有主缓冲的句柄。三、输入主循环与按键分发3.1 _readCharInputLoop一次唤醒处理尽可能多的按键Read()第 270-287 行先跑_readCharInputLoop()再_redisplay()若仍处于Accumulating则返回 false 继续等待。_readCharInputLoop()第 429-466 行是一个 while 循环只要状态仍是Accumulating就从InputBuffer取一个字符GetChar并按当前是否处于弹层分派到三条路径有弹层时 →_popupHandleInput(wch, vkey, modifiers)再压一层弹层时键事件路由到栈顶弹层例如 F7 之上再按 F9无弹层且是“命令行编辑虚拟键”方向键、F 键等→_handleVkey()无弹层且是普通字符 →_handleChar()。3.2 Tab 与 dwCtrlWakeupMaskcmd.exe 补全的协作机制_handleChar()第 469-532 行开头有一段专门处理唤醒掩码第 473-496 行若按下的控制字符wch L 命中_ctrlWakeupMask的某一位(1 wch)conhost 会把该字符插入缓冲但不回显记录modifiers到_controlKeyState然后转入DoneWithWakeupMask结束本次读取。cmd.exe 正是利用这一点发起 Tab 补全它调用ReadConsole时把\t放入唤醒掩码Tab 一按下conhost 立即返回并把 Tab 交给 cmd补全文本由 cmd 自己WriteConsoleW打印随后 cmd 再次发起ReadConsole并用nInitialChars告知“我刚写了这么多字符”——这段协作流程在构造函数第 64-76 行有非常详尽的注释作者称之为“trust me bro”式 API新实现通过暴力搜索起始列来还原initialData的布局位置以正确支持制表符与宽字符场景。这解释了清单中“cmd.exe 空目录空提示符下按 Tab 只发出哔声、不打印任何文本”的行为conhost 的职责仅是把\t交还应用哔声与是否打印补全完全由 cmd.exe 决定。3.3 逐字符编辑与插入/覆盖模式_handleChar()中其余分支回车UNICODE_CARRIAGERETURN第 500-506 行光标移到行尾并转入DoneWithCarriageReturn注意此刻不往_buffer追加换行换行由后置循环处理原因见第七节退格 / CtrlBackspace第 507-517 行仅当ENABLE_PROCESSED_INPUT开启时才执行编辑——退格删除“光标前的一个字形”TextBuffer::GraphemePrevCtrlBackspace 删除“光标前的一个词”_wordPrev否则退格被当作普通字符处理普通字符第 522-531 行非插入模式即覆盖/overstrike 模式下先计算光标后一个字形的长度作为删除量实现“输入覆盖下一个字符”插入模式则纯插入。Insert键正是切换_insertMode见 4.1 表。CtrlV之类控制字符之所以最终显示为^V发生在绘制阶段的_layoutLine()见第八节所有 0x20的字符被渲染为^wch 制表符则按 8 列对齐展开为空格第 1349-1385 行。这正好对应清单中的 C 程序实验清单原文为printf( ); gets(buffer);gets已被现代 C 标准废弃可用fgets等价复现先输出 4 个空格作为提示再输入 Tab、A、CtrlV、Tab、A 后提示行显示为A^V ATab 被展开为若干空格、CtrlV 被可视化为^V且“最初的 4 个空格永不被删除”——从源码结构看这是重绘区域的语义_originInViewport懒初始化于首次需要重绘时的光标位置第 852-859 行_redisplay()只管理“提示符之后”的行内容提示符本身含那 4 个空格不属于 cooked read 的绘制区。四、_handleVkey全部编辑键与 F 键的源码对照表_handleVkey()第 535-730 行是清单中“光标/编辑类”所有条目最直接的证据。下面按清单原意逐项给出实现位置与机制说明。4.1 基础编辑与光标移动清单条目doc/COOKED_READ_DATA.md源码行为位置Backspace 删除前一个字形GraphemePrev后退 _replace删除L507-L517CtrlBackspace 删除前一个词_wordPrev()经典 Windows 跳词算法L507、L391-L408Escape 清空输入_replace(0, npos, ...)整行置空不结束读取L542-L547Home 到行首CtrlHome 删除光标到行首先_replace(0, cursor)可选再_setCursorPosition(0)L548-L557End 到行尾CtrlEnd 删除光标到行尾对称实现_replace(cursor, npos)L558-L567Left 上一个码元CtrlLeft 上一个词首GraphemePrev/_wordPrevL568-L580Right 与 F1 下一个码元VK_F1与VK_RIGHT共用分支L581-L593在输入末尾按 Right从上一条命令粘贴字符取_history-GetLastCommand()按字形逐段追加到行尾L594-L618CtrlRight 下一个词尾_wordNext()L585-L588Insert 切换覆盖模式翻转_insertMode并同步光标形态SetCursorDBModeL620-L623Delete 删除下一个码元GraphemeNext定位后_replace删除L624-L631其中“词”的边界由 第 386-426 行 的注释解释得非常清楚_wordPrev/_wordNext刻意沿用 conhost、Notepad、Visual Studio 等“老应用”的经典跳词算法“跳 1 字符、跳 x、跳非 x”两者因 x 不同向前跳分隔符、向后跳词符而手感略有不对称源码中以TODO: GH#15787标记了这一遗留问题。而“按码元/字形”移动Left/Right/Delete/Backspace使用TextBuffer::GraphemePrev/GraphemeNext这保证了中文中文维基百科输入、代理对如 等清单前列出的多码元输入在光标移动与删除时始终按一个可见单元处理不会出现半个代理对的乱码。4.2 历史导航键清单条目源码行为位置Up 与 F5 遍历历史无历史不崩溃停在第一条VK_UP与VK_F5共用!AtFirstCommand()时Retrieve(Previous)整行替换L632-L638Down 反向遍历停在最后一条!AtLastCommand()时Retrieve(Next)L639-L644PageUp 取最旧命令RetrieveNth(0)L645-L650PageDown 取最新命令RetrieveNth(INT_MAX)L651-L656F8 循环匹配“与当前缓冲至光标处同前缀”的历史命令以substr(0, cursor)为前缀调用FindMatchingCommand(prefix, LastDisplayed, ...)命中后整行替换且光标保持原位L700-L712历史容器即 CommandHistory每个客户进程一条s_Find(processHandle)在COOKED_READ_DATA构造时取得readDataCooked.cpp 第 48 行容量由 conhost 的HistoryBufferSize设置决定history.cpp 第 324 行 中_maxCommands gci.GetHistoryBufferSize()该值经 Settings::GetHistoryBufferSize() 读取。类上公开的Add/Retrieve/RetrieveNth/FindMatchingCommand/Empty/Remove/Swap/AtFirstCommand/AtLastCommand等接口history.h 第 37-63 行恰好一一对应上文所有按键行为LastDisplayed字段第 88 行则记录“上次显示到第几条”是 F8 循环起点与 F7 弹层初始选中项的来源。4.3 清单中“历史管理”类快捷键清单条目源码行为位置AltF7 清空命令历史_history-Empty()并置CLE_ALLOCATED标志L683-L698AltF10 清除 doskey 别名仅当 Alt 按下时调用Alias::s_ClearCmdExeAliases()注释明确“专用于 cmd.exe”L719-L725F6 插入 CtrlZ_handleChar(0x1a, modifiers)——“不知道为什么F6 就是 ^Z 的别名”源码注释原话L679-L682F3 复制上一条命令不整行截断、光标落到已复制文本末尾仅当上一条命令比光标位置长时_replace(cursor, npos, last.data()cursor, count)从光标处接管行尾L663-L673cmd.exe 中 Tab 补全双 emoji 文件名ab.txt/ab.txt连续补全conhost 侧即 3.2 节的唤醒掩码机制补全候选由 cmd 完成第二次 Tab 由 cmd 换到下一个候选L473-L496五、四个 F 键弹层实现细节逐条对照清单弹层输入统一由_popupHandleInput()第 1441-1467 行按栈顶弹层分派_popupPush()第 1405-1430 行负责初始化CommandNumber弹层把 5 字符输入缓冲全部置空格CommandNumberMaxInputLength 5readDataCooked.hpp 第 43 行CommandList弹层初始height 10、选中项取_history-LastDisplayed。5.1 F2 CopyToChar 与 F4 CopyFromCharF2_popupHandleCopyToCharInput等待单个字符输入后在上一条命令中从光标偏移处find(wch)把[cursor, idx)这段文本复制进当前缓冲光标处随即关闭弹层——与清单“F3 的自动搜索版”一致F4_popupHandleCopyFromCharInput在当前缓冲中从光标处find(wch)找不到则取到行尾删除[cursor, idx)且不删除目标字符本身源码注释还指出该弹层历史上被错误地依赖了_history现在已放开——它根本不依赖历史。两者的提示行“按某键后的字符提示文案”通过_popupDrawPrompt()第 1625-1639 行从资源字符串加载ID_CONSOLE_MSGCMDLINEF2/ID_CONSOLE_MSGCMDLINEF4并用XTPUSHSGRCSI # {/XTPOPSGRCSI # }切换弹层配色。按 Escape 均走_popupsDone()关闭。5.2 F9 CommandNumber_popupHandleCommandNumberInput 精确实现了清单的三个约束忽略非十进制字符只接受0-9其余字符直接丢弃允许 1-5 位数字bufferSize CommandNumberMaxInputLength(5)才追加Backspace 把末位改回空格回车取回命令std::stoi(buffer)后_replace(_history-RetrieveNth(n))整行替换并关闭弹层。5.3 F7 CommandList功能最丰富的弹层输入处理在 _popupHandleCommandListInput清单条目源码行为Left/Right 以选中命令替换当前缓冲且光标落到行尾_replace(RetrieveNth(selected)); _popupsDone();_replace的wstring_view重载会把光标置到size()见 L879-L885回车 替换并立即执行连按 Enter替换后追加_handleChar(UNICODE_CARRIAGERETURN, modifiers)Up/Down 移动选择10 条内停在首尾、超 20 条同样越界钳制、条目过多时滚动selected--/越界由绘制函数的std::clamp统一钳制ShiftUp/Down 交换历史条目顺序_history-Swap(selected, selected±1)Home/End 到首/末条selected 0/INT_MAX再钳制PageUp/PageDown 按当前弹层高度 $height$ 逐页移动selected ± cl.heightDelete 删除选中历史条目删空后关闭弹层_history-Remove(selected)F9 叠加“命令序号”输入框_popupPush(CommandNumber)——_popups是栈此时深度为 2绘制逻辑 _popupDrawCommandList 对应清单的视觉条目高度上限 20 行height min(historySize, min(viewportHeight/2 - 1, 20))第 1652 行并额外预留一行给可能叠加的 F9 提示超长条目截断每条历史经_layoutLine(line, str, 0, indexWidth 4, size.width)限制在“序号宽度 4 列”到视口宽度之间放不下自然截断滚动条条目多于可见行时绘制▴/▾端帽与█/▒轨道第 1695-1710 行轨道位置按选中项线性插值选中项标记▸并在序号列右对齐fmt::format_to(... L{:{}}: )。六、回车之后换行、历史入库与 doskey 别名展开读取在回车或唤醒掩码后进入_handlePostCharInputLoop()第 734-827 行这是清单多项行为的收口处换行后缀ENABLE_PROCESSED_INPUT开启时补\r\n否则补\r第 745 行并用WriteCharsLegacy而非_flushBuffer写出——第 748-763 行的大段注释解释了原因回车会让“提示行尾坐标”产生跨行跳变走常规重绘会误判脏区、留下旧提示行残字注释给出了echo hello→foobar foo bar→ F7 回选的复现步骤历史入库仅当ENABLE_ECHO_INPUT时调用_history-Add(input, CONSOLE_HISTORY_NODUP)——去重开关来自 conhost 全局标志doskey 别名展开Alias::s_MatchAndCopyAlias(input, _exeName, lineCount)按应用名匹配别名命中则整行替换为展开结果可能多行如doskey testecho foo$Techo bar未命中则原样追加换行多行别名分片返回lineCount 1时先返回第一行其余部分经SaveMultilinePendingInput存入输入读句柄状态后续读取逐行吐出第 803-812 行确保每次读取都以换行结束收尾动作置CONSOLE_IGNORE_NEXT_KEYUP忽略配对的 keyup、恢复光标形态SetCursorDBMode(false)、按_userBuffer剩余量回填numBytes与controlKeyState。七、渲染引擎脏区重绘、分页滚动与 VT 序列_redisplay()第 907-1295 行把_buffer布局成std::vectorLine再以最小化 VT 序列写回屏幕其设计直接服务于清单中“宽字符、超长行、弹层”等全部视觉验收点脏区增量重绘以_bufferDirtyBeg与_bufferCursor把缓冲切成三段布局只把脏列之后的内容真正写出头部注释第 900-905 行指出这把O(n²)的逐键全量重绘优化成了O(n)避免粘贴长文本时的性能悬崖行尾擦除策略提示行变短且剩余 ≤16 列时用空格填充16 列用CSI K第 993-1003 行宽字符换行“欺骗”技巧_layoutLine()第 1297-1393 行在宽字符放不下最后一列时不补空格而直接报告“行已满”让终端在打印该宽字符时自己插入填充空格保证 CtrlA/CtrlC 复制提示行时不带上尾部填充空白还特判了“视口仅 1 列”时跳过无法放入的字形以防死锁GH#19922第 1316-1323 行分页pager滚动当行内容超过视口高度origin 归零为{0,0}并重排第 1085-1092 行此后滚动优先用CSI S/T指令滚动行数超过 pager 高度时退化为整行重写第 1163-1206 行注释里给出了 10 行视口从 2 行到 11/12 行的推演弹层开/关的终端状态保护打开弹层输出ESC 7DECSCCSI ?25l隐藏光标DECTCEMCSI # {XTPUSHSGR关闭时输出ESC 8DECRCCSI ?25hCSI # }第 1127-1155 行注释详细说明了为何关闭路径要刻意省略 XTPUSHSGR/XTPOPSGR 以免属性被二次重置。另外窗口缩放场景由EraseBeforeResize()/RedrawAfterResize()第 292-347 行成对处理缩放前把光标锚回提示行起点直接操作 TextBuffer避免向 ConPTY 输出多余 VT重排后读取新坐标、整行重绘。八、无障碍Narrator 播报验收清单末尾两条Narrator 开启时逐字符输入只播报该字符行尾退格只播报被删字符针对屏幕阅读器/旁白模式的播报精度。从源码结构看conhost 的播报由 host 侧的可访问性通知组件承载见 src/host/AccessibilityNotifier.cpp cooked read 的每类编辑动作插入、删字符、删词、整行替换、历史回填都会经由屏幕缓冲变更触发相应的文本变更通知——清单要求验证的正是“播报内容与实际操作一一对应、不多报不漏报”属于人工测试项可在启用 Narrator 后按清单逐条执行。九、完整手工测试清单原文档继承 验证建议以下为 doc/COOKED_READ_DATA.md 的完整测试矩阵原文标注全部 ✅ 为必须复验项供手工回归时直接勾选使用输入内容类ASCII 输入中文输入中文维基百科代理对输入cmd.exe 中创建ab.txt与ab.txt按 Tab 补全到ab.txt光标右移越过a后再连按两次 Tab补全到ab.txtC 程序printf( ); gets(buffer);gets可用fgets等价替代按 Tab、A、CtrlV、Tab、A 后提示为A^V A光标移动正常对任意片段退格/删除正常最初的 4 个空格永远不被删除编辑类Backspace 删前一字形CtrlBackspace 删前一词Escape 清空输入Home 到行首 / CtrlHome 删光标至行首End 到行尾 / CtrlEnd 删光标至行尾Left 上一个码元 / CtrlLeft 上一词首Right 与 F1 下一码元行尾按 Right 从上一条命令粘贴字符CtrlRight 下一词尾Insert 切换覆盖模式Delete 删下一码元历史类Up 与 F5 遍历历史无历史不崩溃、停在第一条Down 反向无历史不崩溃、停在最后一条PageUp 最旧命令PageDown 最新命令F8 按当前缓冲前缀循环匹配历史命令无历史不崩溃AltF7 清空历史AltF10 清除 doskey 别名弹层类F2启动“copy to char”提示Escape 关闭输入字符后从上一条命令复制至该字符处同 F3 但自动搜索F3从当前光标处复制上一条命令尽量多的字符当前缓冲更长时不擦除尾部文本光标停在已复制文本末尾F4启动“copy from char”提示Escape 关闭删除光标到目标字符首现处不含之间的文本F6插入 CtrlZF7无修饰键启动命令列表提示Escape 关闭超宽条目截断历史变长时高度最多扩展到 20 行F9 叠加序号提示Left/Right 替换缓冲且光标在行尾Up/Down 选择10 条与 20 条时均停在首尾、过多时滚动ShiftUp/Down 重排条目Home/End 首/末条PageUp/PageDown 按 $height$ 逐页F9启动“命令序号”提示Escape 关闭忽略非十进制字符允许 1-5 位数字回车取回对应历史命令其他cmd.exe 空目录空提示符下按 Tab仅哔声、无输出Narrator 开启时cmd.exe逐字符输入只播报正在输入的字符行尾退格只播报被删字符十、延伸阅读路径主题文件cooked read 状态机与渲染src/host/readDataCooked.hpp、src/host/readDataCooked.cpp读取数据基类与等待唤醒src/host/readData.hpp行读取入口构造/挂起/唤醒src/host/stream.cpp历史容器与 APIsrc/host/history.h、src/host/history.cpp历史容量设置src/host/settings.cpp默认输入模式src/host/inputBuffer.cpp可访问性通知src/host/AccessibilityNotifier.cpp输入功能测试src/host/ft_host/API_InputTests.cpp原始测试清单doc/COOKED_READ_DATA.md综合来看COOKED_READ_DATA是 conhost 中“把 ReadConsole 从一次性数据搬运升级为完整行编辑器”的核心状态机决定读取何时结束_handleChar/_handleVkey覆盖全部编辑语义四个弹层复用了同一套布局与脏区渲染管线而CommandHistory则把 cmd 时代的历史行为按进程隔离地延续下来。原文档的测试清单恰好按“内容—编辑—历史—弹层—无障碍”五个维度划界与源码结构高度同构是理解乃至验证这套机制的最佳地图。【免费下载链接】terminalThe new Windows Terminal and the original Windows console host, all in the same place!项目地址: https://gitcode.com/GitHub_Trending/term/terminal创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
上一篇/下一篇内容由系统自动关联
返回资讯列表 →