首页
/ Windows Terminal 仓库中的 GUIConsole 示例:用 ConPTY 伪控制台从零搭建 WPF 自定义终端

Windows Terminal 仓库中的 GUIConsole 示例:用 ConPTY 伪控制台从零搭建 WPF 自定义终端

2026-09-06 14:45:14作者:柯茵沙

本文以 Windows Terminal 仓库中的 samples/ConPTY/GUIConsole 官方示例为蓝本,完整拆解"如何用 ConPTY 伪控制台 + WPF 打造一套自有终端界面"的骨架实现:涵盖两条匿名管道的建立、CreatePseudoConsole/CreateProcess 扩展启动属性的 PInvoke 调用链、VT100 数据的读写通道,以及 WPF 侧的输出渲染循环与输入转发逻辑。读完之后,你将能够独立复现一个可读写伪控制台的 WPF 终端骨架,并理解 Windows Terminal 这类终端宿主与 conhost 之间通信的底层原理。

示例定位:一套"自定义 WPF 控制台"的骨架

仓库中的 README 对该示例的定位非常明确:这是一个展示"自定义 WPF 控制台骨架长什么样"的示例工程,由两个项目组成:

项目 目标框架 职责
GUIConsole.WPF .NET 4.6.1 WPF 应用。创建一个 WPF Window 充当控制台窗口,同时保留底层控制台的可见性
GUIConsole.ConPTY .NET Standard 2.0 类库。负责创建控制台并启用伪控制台(pseudoconsole)行为,核心公共 API 集中在 Terminal.cs

其中 Terminal.cs 对外暴露两样东西,分别承担控制台的输入与输出通道:

  • ConsoleOutStream:一个连接到伪控制台输出管道的 FileStream,其内容是以 VT100 转义序列编码的终端输出流;
  • WriteToPseudoConsole(string input):把给定的字符串经伪控制台的输入管道写入,接受 VT100 编码的输入。

整个解决方案由 GUIConsole.sln 组织,与同目录下的 MiniTerm(控制台版)、EchoCon(C++ 版)示例互为参照,GUIConsole 则是其中唯一面向图形界面的参考实现。

总体架构:两条管道 + 一个伪控制台 + 一个子进程

从源码结构看,一次完整的启动流程(Terminal.Start)的调用链如下:

Terminal.Start(command, width, height)
 ├─ new PseudoConsolePipe()            # 输入管道:宿主写 → 伪控制台读
 ├─ new PseudoConsolePipe()            # 输出管道:伪控制台写 → 宿主读
 ├─ PseudoConsole.Create(...)          # P/Invoke: CreatePseudoConsole
 ├─ ProcessFactory.Start(...)          # P/Invoke: CreateProcess(EXTENDED_STARTUPINFO_PRESENT)
 ├─ ConsoleOutStream = new FileStream(outputPipe.ReadSide)
 ├─ OutputReady 事件触发              # 通知 UI 层可以开始消费输出
 └─ WaitForExit(process).WaitOne()    # 阻塞直到子进程退出

这套流程完全对应微软"创建伪控制台会话"官方文档描述的标准步骤:先建两条匿名管道,再用管道端点创建伪控制台,最后把伪控制台句柄作为进程创建属性(PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE)传给子进程。下面按数据流顺序逐层剖析。

GUIConsole.ConPTY:伪控制台类库逐层解析

1. PseudoConsolePipe:用匿名管道承载终端 I/O

PseudoConsolePipe.cs 是最底层的封装,它利用 Win32 CreatePipe 创建一条匿名管道,并把读、写两端分别保存为 SafeFileHandle

internal sealed class PseudoConsolePipe : IDisposable
{
    public readonly SafeFileHandle ReadSide;
    public readonly SafeFileHandle WriteSide;

    public PseudoConsolePipe()
    {
        if (!CreatePipe(out ReadSide, out WriteSide, IntPtr.Zero, 0))
        {
            throw new Win32Exception(Marshal.GetLastWin32Error(), "failed to create pipe");
        }
    }
    ...
}

CreatePipe 的 PInvoke 声明位于 PseudoConsoleApi.cs。整个示例会创建 两个 PseudoConsolePipe 实例:一个用于输入、一个用于输出,方向相反:

  • 输入管道:宿主进程持有写端,伪控制台持有读端;
  • 输出管道:伪控制台持有写端,宿主进程持有读端(也就是暴露给上层的 ConsoleOutStream)。

2. PseudoConsole:CreatePseudoConsole 的托管封装

PseudoConsole.cs 封装了 CreatePseudoConsole 的调用,并暴露一个关键静态属性:

internal sealed class PseudoConsole : IDisposable
{
    public static readonly IntPtr PseudoConsoleThreadAttribute = (IntPtr)PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE;

    internal static PseudoConsole Create(SafeFileHandle inputReadSide,
                                         SafeFileHandle outputWriteSide,
                                         int width, int height)
    {
        var createResult = CreatePseudoConsole(
            new COORD { X = (short)width, Y = (short)height },
            inputReadSide, outputWriteSide,
            0, out IntPtr hPC);
        if (createResult != 0)
        {
            throw new Win32Exception(createResult, "Could not create pseudo console.");
        }
        return new PseudoConsole(hPC);
    }

    public void Dispose() => ClosePseudoConsole(Handle);
}

几个值得注意的细节:

  • 尺寸以字符为单位COORD 结构(short X / short Y)传入的是列数与行数,而不是像素。这决定了后续 VT100 流中的换行、光标定位等行为。
  • 线程属性常量PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 的取值 0x00020016 定义在 PseudoConsoleApi.cs,它是下一步"把伪控制台绑定到子进程"时的属性键。
  • 即创建即接管CreatePseudoConsolehInput/hOutput 参数直接消费管道端点,伪控制台内部会把所有控制台 I/O 重定向到这两条管道上,子进程此后对标准输入/输出的读写都发生在伪控制台"屏幕"上。

3. ProcessFactory:把伪控制台"注入"子进程

真正把伪控制台与一个真实进程关联起来的是 ProcessFactory.cs。它实现了"准备子进程创建"阶段的全部扩展启动逻辑:

internal static Process Start(string command, IntPtr attributes, IntPtr hPC)
{
    var startupInfo = ConfigureProcessThread(hPC, attributes);
    var processInfo = RunProcess(ref startupInfo, command);
    return new Process(startupInfo, processInfo);
}

ConfigureProcessThread 里是一段经典的"两遍调用"模式:

  1. 第一遍调用 InitializeProcThreadAttributeList(传 IntPtr.Zero:失败是预期行为,目的是让系统算出属性列表所需的字节数 lpSize
  2. Marshal.AllocHGlobal(lpSize) 分配内存后第二遍初始化属性列表;
  3. UpdateProcThreadAttribute:以 PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 为属性键、伪控制台句柄 hPC 为值,把伪控制台写进属性列表(属性值长度为 IntPtr.Size,即一个句柄大小)。

随后 RunProcessdwCreationFlags: EXTENDED_STARTUPINFO_PRESENT(常量 0x00080000,定义于 ProcessApi.cs)调用 CreateProcess,并传入 STARTUPINFOEX 结构体(在标准 STARTUPINFO 基础上多出 lpAttributeList 字段,见 ProcessApi.cs)。子进程由此启动,其控制台被替换为伪控制台,所有屏幕更新都转为 VT100 序列写入输出管道。

CreateProcessbInheritHandlesfalse——管道端点已通过 CreatePseudoConsole 交给内核侧接管,无需句柄继承。

4. Terminal:面向宿主应用的公共门面

Terminal.csGUIConsole.ConPTY 唯一面向 WPF 应用暴露的门面类。其 Start 方法(Terminal.cs)把前文三层串成一个 using 块:

public void Start(string command, int consoleWidth = 80, int consoleHeight = 30)
{
    using (var inputPipe = new PseudoConsolePipe())
    using (var outputPipe = new PseudoConsolePipe())
    using (var pseudoConsole = PseudoConsole.Create(inputPipe.ReadSide, outputPipe.WriteSide, consoleWidth, consoleHeight))
    using (var process = ProcessFactory.Start(command, PseudoConsole.PseudoConsoleThreadAttribute, pseudoConsole.Handle))
    {
        // 把所有伪控制台输出复制到一个 FileStream 上,暴露给应用其余部分
        ConsoleOutStream = new FileStream(outputPipe.ReadSide, FileAccess.Read);
        OutputReady.Invoke(this, EventArgs.Empty);

        // 保存输入管道句柄,并包装一个 writer 供后续复用
        _consoleInputPipeWriteHandle = inputPipe.WriteSide;
        _consoleInputWriter = new StreamWriter(new FileStream(_consoleInputPipeWriteHandle, FileAccess.Write))
        {
            AutoFlush = true
        };

        // 若控制台被非正常关闭(例如窗口标题栏的 'X'),释放资源
        OnClose(() => DisposeResources(process, pseudoConsole, outputPipe, inputPipe, _consoleInputWriter));

        WaitForExit(process).WaitOne(Timeout.Infinite);
    }
}

参数语义(含默认值)为:

参数 默认值 说明
command 必填 要运行的命令,例如 cmd.exe;示例 WPF 应用中实际传入的是 powershell.exe
consoleWidth 80 伪控制台初始宽度(字符列数)
consoleHeight 30 伪控制台初始高度(字符行数)

关键设计点:

  • OutputReady 事件:管道接通、可以安全开始读输出时触发一次。UI 层用它作为"启动消费线程"的信号,避免与 Start 的初始化竞争。
  • AutoFlush = trueStreamWriterWriteToPseudoConsole 直接写入该 writer(Terminal.cs),若尚未 Start 则抛出 InvalidOperationException
  • WaitForExit:用子进程句柄包装一个 AutoResetEvent(注意 ownsHandle: false,句柄所有权归 ProcessDispose),保证 Start 阻塞至子进程退出后,using 块自动回收管道与伪控制台。

5. 进程资源清理:Process 与 Ctrl-C 处理

Process.csDispose 中按顺序释放三类资源:DeleteProcThreadAttributeList + Marshal.FreeHGlobal 归还属性列表内存,CloseHandle 关闭 hProcess/hThread,并带终结器兜底(Process.cs)。

另一处容易忽略的健壮性设计在 Terminal.cs:通过 SetConsoleCtrlHandler(PInvoke 声明于 ConsoleApi.cs)注册控制台控制事件处理器,专门捕获 CTRL_CLOSE_EVENT。其用途在注释中写得很直白——"free resources in case the console is ungracefully closed (e.g. by the 'x' in the window titlebar)",即宿主进程自己可能被外力关掉时,仍能保证管道、伪控制台等句柄被显式 Dispose,而不是泄漏到进程终止。

GUIConsole.WPF:窗口即终端的界面层

WPF 侧由 MainWindow.xamlMainWindow.xaml.cs 构成,展示了"没有系统标题栏的终端窗口"最小骨架。

无边框窗口外壳

XAML 中 WindowStyle="None"AllowsTransparency="True",去掉系统窗框后自己绘制标题栏:一个 TextBlock 显示标题,三个按钮使用 Segoe MDL2 Assets 图标字体实现最小化/最大化/关闭(关闭按钮悬停变红)。鼠标左键按下时调用 DragMove() 支持拖拽移动窗口——这正是自定义终端"外壳"的常见做法。

启动与输出消费循环

MainWindow.xaml.csWindow_Loaded 中启动终端:

_terminal = new Terminal();
Task.Run(() => _terminal.Start("powershell.exe"));
_terminal.OutputReady += Terminal_OutputReady;

OutputReady 触发后,用 TaskCreationOptions.LongRunning 启动一个长驻线程专职读输出(CopyConsoleToWindow),注释解释了原因:"so that we don't use a standard thread pool thread"——控制台输出是永不退出的长生命周期任务,不应占用线程池线程。读取循环本身刻意写得极其朴素:

while ((bytesRead = reader.ReadBlock(buf, 0, 1)) != 0)
{
    // This is where you'd parse and tokenize the incoming VT100 text, most likely.
    Dispatcher.Invoke(() =>
    {
        // For now, just emit raw VT100 to the primary TextBlock.
        TerminalHistoryBlock.Text += new string(buf.Take(bytesRead).ToArray());
    });
}

源码注释点出了这个示例的本质边界:逐字符读取、原样追加到 TextBlock,并不解析 VT100。真正的"parse and tokenize the incoming VT100 text"是留给读者实现的——这也解释了 README 所说的"骨架(skeleton)"含义:数据通路已经打通(VT100 流确实从管道流出),但渲染器尚未实现。

输入转发与自动滚动

键盘事件处理(MainWindow.xaml.cs)同样是最小化实现:

private void Window_KeyDown(object sender, KeyEventArgs e)
{
    if (!e.Handled)
    {
        // This is where you'd take the pressed key, and convert it to a
        // VT100 code before sending it along. For now, we'll just send _something_.
        _terminal.WriteToPseudoConsole(e.Key.ToString());
    }
}

注释同样坦承这只是"send something":把按键名(如 DEnter)的字符串发出去,而不是映射为对应的 VT 控制序列(如 \x04\r)。而滚动区域则实现了一个实用的自动滚动策略(ScrollViewer_ScrollChanged):滚到底部时重新挂接自动滚动,向上翻阅历史时暂停,新内容到来时若处于自动滚动状态则 ScrollToEnd()

运行方式与适用前提

该示例为纯 Windows 桌面工程,使用方式即"查看 + 构建 + 运行":

  • 用 Visual Studio 打开 GUIConsole.sln,分别构建 GUIConsole.WPF(.NET 4.6.1 目标)与 GUIConsole.ConPTY(.NET Standard 2.0 类库)后运行 WPF 应用即可;
  • 由于调用 CreatePseudoConsole/ClosePseudoConsole运行时必须位于提供 ConPTY API 的 Windows 10 系统上,且需以较新的 Windows SDK 编译 kernel32.dll 的 PInvoke 签名;
  • 需要特别说明 README 中的一句关键描述:"keeps the underlying console visible"——该骨架并未隐藏子进程关联的默认控制台窗口,WPF 窗口只是叠加在真实 conhost 窗口之上的演示层。若要打造"只有自己的窗口"的终端,还需处理控制台分配策略(如通过进程启动属性控制 CREATE_NO_WINDOW、或在 CREATE_NEW_CONSOLE 场景下接管),这是本示例有意留白、交由后续读者补齐的部分。

小结:从骨架到真实终端还差什么

GUIConsole 示例以约两百行核心代码展示了 ConPTY 终端宿主的完整数据通路:匿名管道 → CreatePseudoConsole → 扩展启动属性 CreateProcess → VT100 读写通道 → 控制事件清理。它刻意保留了三处"占位符":不解析 VT100 的渲染器、按键到控制序列的映射表、以及对底层控制台的隐藏。对照本仓库 src/cascadia 下 Windows Terminal 的完整实现(含解析器、渲染器与设置系统),可以清晰看到一套生产级终端在这些占位符之上要补全的纵深;而对希望自建终端外壳的开发者而言,这份示例恰恰是最低成本、最贴近 Win32 原语的教学基线——读懂它,就掌握了 conhost 与图形宿主之间通信的全部关键调用。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.74 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.81 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
531
595
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.84 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.63 K
1.02 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
518
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
389