在基于 .NET (C#) 开发 Windows 上的跨平台终端模拟器(例如接入 cmd.exePowerShellSSH)时,微软在 Windows 10 引入的 ConPTY (Pseudo Console) 是目前唯一的完美解。它能让你轻松实现类似 VS Code 集成终端或是 Windows Terminal 的原生交互体验。

然而,在 C# 中通过 P/Invoke 调用底层 Win32 API 极其容易踩坑,尤其是极其隐蔽的 0xc0000142 (初始化失败) 崩溃。本文总结了在实现 ConPTY 过程中遇到的所有深水重灾区,并给出了终极解决方案。


坑位一:最致命的 0xc0000142 —— UpdateProcThreadAttribute 传参玄学

现象
一切 API 调用看似正常,但在执行 CreateProcess 启动 cmd.exe 时,进程秒退,抛出错误码 0xc0000142(STATUS_DLL_INIT_FAILED)。

原因
为了让子进程挂载到虚拟终端,必须使用扩展属性表,并设置 PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE
在常规的 C# P/Invoke 习惯中,由于大部分系统属性要求传入的是内存地址 (指针),开发者往往会将参数声明为 ref IntPtr lpValue,然后传入句柄变量(ref hPC)。

但这是一个巨大的陷阱!微软在这里打破了常规
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE 标志要求 lpValue 必须是句柄自身的值,而不是指向句柄的指针!
如果使用 ref,C# 将本地栈变量的内存地址传给了内核,内核拿着这个栈地址当成 ConPTY 句柄去初始化,必然引发严重的越界崩溃,直接导致子进程加载失败。

解决方案
完全舍弃 ref,将 P/Invoke 签名的 lpValue 声明为 IntPtr 按值传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 【错误写法】:
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool UpdateProcThreadAttribute(..., ref IntPtr lpValue, ...);

// ❌ 错误调用:把 hPC 在栈上的地址传进去了
var hPCValue = hPC;
UpdateProcThreadAttribute(..., ref hPCValue, ...);


// 【正确写法】:
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool UpdateProcThreadAttribute(..., IntPtr lpValue, ...);

// ✅ 正确调用:直接把句柄的值丢进去
UpdateProcThreadAttribute(..., hPC, ...);

坑位二:匿名管道的“继承权”与生命周期

现象
还是 0xc0000142 崩溃,或者调用成功但没有任何输出流返回。

原因

  1. 未授权继承:在调用 CreatePipe 创建管道时,往往图方便不传安全属性。Windows 默认创建的句柄是不可被子进程继承的。当你把这根管子的读/写端传给 CreatePseudoConsole 时,底层的 conhost.exe 根本无权使用它。
  2. 过河拆桥(提前释放句柄):很多开发者以为把句柄传给 CreatePseudoConsole 后就没用了,马上调用 CloseHandle。但其实该管道要维系整个终端进程的生命周期,提前关闭会导致管道破裂,cmd.exe 找不到标准输入输出设备而直接崩溃。

解决方案
必须严格设置 bInheritHandle = true 结构体,且只能在最终类释放 (Dispose/Close) 时才销毁管道句柄。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ✅ 正确初始化安全属性,允许被子进程继承
var sa = new SECURITY_ATTRIBUTES {
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
bInheritHandle = true // !!! 极其关键 !!!
};

// 创建管道
CreatePipe(out _hPipeInRead, out _hPipeIn, ref sa, 0);
CreatePipe(out _hPipeOut, out _hPipeOutWrite, ref sa, 0);

// 创建伪控制台 (ConPTY)
CreatePseudoConsole(size, _hPipeInRead, _hPipeOutWrite, 0, out _hPC);

// ❌ 千万不能在这里调用 CloseHandle(_hPipeInRead)!
// 请把这两个句柄存为全局字段,等整个终端关闭时才销毁。

坑位三:STARTUPINFOEX 内部结构不对齐

现象
传给 CreateProcess 的参数结构总是报无效或内存越界。

原因
.NET 在封送 (Marshalling) 结构体时默认使用 Ansi 编码。如果 CreateProcess 声明了 CharSet = CharSet.Unicode (即实际调用了 CreateProcessW),但基础结构体 STARTUPINFO 却没声明,内部的 string 字段(如 lpDesktop, lpTitle)就会发生编码灾难与内存指针彻底错位。

解决方案
STARTUPINFO 的 P/Invoke 结构强制标记 CharSet = CharSet.Unicode

1
2
3
4
5
6
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct STARTUPINFO {
public int cb;
public string lpReserved, lpDesktop, lpTitle;
/* ... 略 ... */
}

坑位四:隐秘的属性链表内存泄漏

现象
程序运行久了,或者频繁打开/关闭终端时,句柄和非托管内存暴涨。

原因
在使用扩展属性表 lpAttributeList 时,我们习惯使用 Marshal.AllocHGlobal 向非托管内存申请了一块空间,并在 CreateProcess 执行后调用 Marshal.FreeHGlobal 清理它。
但这还远远不够! 因为 InitializeProcThreadAttributeList 这类底层 API 可能在系统内部又做了一层额外的链表挂载和分配。仅仅释放外部指针会导致内核层产生碎片泄漏。

解决方案
必须在 Marshal.FreeHGlobal 之前,强制调用 Win32 原生的 DeleteProcThreadAttributeList 来拆除内部挂载。

1
2
3
4
5
6
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool DeleteProcThreadAttributeList(IntPtr lpAttributeList);

/* ... 在 CreateProcess 返回后 ... */
DeleteProcThreadAttributeList(siEx.lpAttributeList); // ✅ 必须加在这个位置
Marshal.FreeHGlobal(siEx.lpAttributeList);

附加 UI 避坑指南:中文等宽排版与控制符乱码

当成功捕获 ConPTY 的缓冲流并在基于 WPF / WinForms / Avalonia 自定义 UI 绘制流时,常常会遇到以下两个渲染死角:

  1. 废弃字符乱码(\u0200)
    大部分终端状态机(如 XtermSharp 原理)在初始化内存网格(Cell)时,由于 0x00 通常被保留为了不可见控制符,引擎会将无内容的空格设为 Unicode 保留替换位 \u0200(或 \u0000)。如果 UI 绘图层直接用 DrawStringFormattedText 进行绘制,遇到字体回退时会在屏幕上印满像 À 或者 Ȁ 的乱码斑点。
    修复:在 StringBuilder 剥离时,强制做一刀切:if (c == 0 || c == '\u0200') append(' ')

  2. 中文宽字符产生的排版“多米诺效应”
    在 GUI 使用单一的长字符串 Measure 去直接画包含中文的一整行终端时,一旦中文字库(如微软雅黑替换了 Consolas fallback)的绝对物理宽度不是恰好精准等于英文字符的 2.000 倍,这 0.1px 甚至更小的误差就会随字符增多不断放大累积,使得你的文字再也对不上黑窗口本该在的光标格子,造成各种缝隙和错位!
    修复:不要对带有宽字符的一整行进行粗暴组合渲染!当底层内存检测到某字符的终端跨度为 2(Width == 2)时,立刻切断当前的长句绘制 Chunk,并严格以 X * CharWidth 强制给这个汉字重定位。通过强硬的绝对网格锁定,能彻底杜绝前端字体的弹性形变干扰。


复制就可用的ConPtyConnection类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
using Microsoft.Terminal.Wpf;
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

public class ConPtyConnection : ITerminalConnection, IDisposable
{
private readonly string _commandLine;
private uint _cols;
private uint _rows;

// 构造时传入参数
public ConPtyConnection(string commandLine = "cmd.exe", uint cols = 120, uint rows = 30)
{
_commandLine = commandLine;
_cols = cols;
_rows = rows;
}

// ── Win32 P/Invoke ──────────────────────────────────────────
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CreatePipe(out IntPtr hRead, out IntPtr hWrite,
ref SECURITY_ATTRIBUTES lpPipeAttributes, int nSize);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool CloseHandle(IntPtr hObject);

[DllImport("kernel32.dll", SetLastError = true)]
static extern int CreatePseudoConsole(COORD size, IntPtr hInput,
IntPtr hOutput, uint dwFlags, out IntPtr phPC);

[DllImport("kernel32.dll", SetLastError = true)]
static extern int ResizePseudoConsole(IntPtr hPC, COORD size);

[DllImport("kernel32.dll", SetLastError = true)]
static extern void ClosePseudoConsole(IntPtr hPC);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool InitializeProcThreadAttributeList(
IntPtr lpAttributeList, int dwAttributeCount,
int dwFlags, ref IntPtr lpSize);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool UpdateProcThreadAttribute(
IntPtr lpAttributeList, uint dwFlags, IntPtr Attribute,
IntPtr lpValue, IntPtr cbSize,
IntPtr lpPreviousValue, IntPtr lpReturnSize);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool DeleteProcThreadAttributeList(IntPtr lpAttributeList);

[DllImport("kernel32.dll", SetLastError = true, CharSet = CharSet.Unicode)]
static extern bool CreateProcess(
string lpApplicationName, string lpCommandLine,
ref SECURITY_ATTRIBUTES lpProcessAttributes,
ref SECURITY_ATTRIBUTES lpThreadAttributes,
bool bInheritHandles, uint dwCreationFlags,
IntPtr lpEnvironment, string lpCurrentDirectory,
ref STARTUPINFOEX lpStartupInfo,
out PROCESS_INFORMATION lpProcessInformation);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool WriteFile(IntPtr hFile, byte[] lpBuffer,
int nNumberOfBytesToWrite, out int lpNumberOfBytesWritten,
IntPtr lpOverlapped);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool ReadFile(IntPtr hFile, byte[] lpBuffer,
int nNumberOfBytesToRead, out int lpNumberOfBytesRead,
IntPtr lpOverlapped);

[StructLayout(LayoutKind.Sequential)]
struct COORD { public short X, Y; }

[StructLayout(LayoutKind.Sequential)]
struct SECURITY_ATTRIBUTES
{
public int nLength;
public IntPtr lpSecurityDescriptor;
public bool bInheritHandle;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct STARTUPINFO
{
public int cb;
public string lpReserved, lpDesktop, lpTitle;
public int dwX, dwY, dwXSize, dwYSize;
public int dwXCountChars, dwYCountChars, dwFillAttribute, dwFlags;
public short wShowWindow, cbReserved2;
public IntPtr lpReserved2, hStdInput, hStdOutput, hStdError;
}

[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
struct STARTUPINFOEX
{
public STARTUPINFO StartupInfo;
public IntPtr lpAttributeList;
}

[StructLayout(LayoutKind.Sequential)]
struct PROCESS_INFORMATION
{
public IntPtr hProcess, hThread;
public int dwProcessId, dwThreadId;
}

const uint EXTENDED_STARTUPINFO_PRESENT = 0x00080000;
static readonly IntPtr PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE = new IntPtr(0x00020016);

// ── 字段 ────────────────────────────────────────────────────
public event EventHandler<TerminalOutputEventArgs> TerminalOutput;

IntPtr _hPC = IntPtr.Zero;
IntPtr _hPipeIn = IntPtr.Zero; // 写给子进程
IntPtr _hPipeOut = IntPtr.Zero; // 从子进程读
IntPtr _hPipeInRead = IntPtr.Zero;
IntPtr _hPipeOutWrite = IntPtr.Zero;
PROCESS_INFORMATION _pi;
CancellationTokenSource _cts = new();



// ── ITerminalConnection 实现 ────────────────────────────────
public void Start()
{
var sa = new SECURITY_ATTRIBUTES {
nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>(),
bInheritHandle = true
};

// 创建两对管道
CreatePipe(out _hPipeInRead, out _hPipeIn, ref sa, 0);
CreatePipe(out _hPipeOut, out _hPipeOutWrite, ref sa, 0);

// 创建 ConPTY
var size = new COORD { X = (short)_cols, Y = (short)_rows };
CreatePseudoConsole(size, _hPipeInRead, _hPipeOutWrite, 0, out _hPC);

// 设置扩展启动信息(把 ConPTY 塞进去)
var siEx = new STARTUPINFOEX();
siEx.StartupInfo.cb = Marshal.SizeOf<STARTUPINFOEX>();
IntPtr size2 = IntPtr.Zero;
InitializeProcThreadAttributeList(IntPtr.Zero, 1, 0, ref size2);
siEx.lpAttributeList = Marshal.AllocHGlobal(size2);
InitializeProcThreadAttributeList(siEx.lpAttributeList, 1, 0, ref size2);
UpdateProcThreadAttribute(siEx.lpAttributeList, 0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
_hPC, new IntPtr(IntPtr.Size), IntPtr.Zero, IntPtr.Zero);

var psa = new SECURITY_ATTRIBUTES { nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>() };
var tsa = new SECURITY_ATTRIBUTES { nLength = Marshal.SizeOf<SECURITY_ATTRIBUTES>() };

CreateProcess(null, _commandLine, ref psa, ref tsa, false,
EXTENDED_STARTUPINFO_PRESENT, IntPtr.Zero, null,
ref siEx, out _pi);

DeleteProcThreadAttributeList(siEx.lpAttributeList);
Marshal.FreeHGlobal(siEx.lpAttributeList);

// 启动读取线程,把子进程输出源源不断发给控件
Task.Run(() => ReadLoop(_cts.Token));
}

void ReadLoop(CancellationToken ct)
{
var buf = new byte[4096];
while (!ct.IsCancellationRequested)
{
if (!ReadFile(_hPipeOut, buf, buf.Length, out int bytesRead, IntPtr.Zero) || bytesRead == 0)
break;
var text = System.Text.Encoding.UTF8.GetString(buf, 0, bytesRead);
TerminalOutput?.Invoke(this, new TerminalOutputEventArgs(text));
}
}

public void WriteInput(string data)
{
if (_hPipeIn == IntPtr.Zero) return;
var bytes = System.Text.Encoding.UTF8.GetBytes(data);
WriteFile(_hPipeIn, bytes, bytes.Length, out _, IntPtr.Zero);
}

public void Resize(uint rows, uint columns)
{
_cols = columns;
_rows = rows;
if (_hPC != IntPtr.Zero)
ResizePseudoConsole(_hPC, new COORD { X = (short)columns, Y = (short)rows });
}

public void Close()
{
_cts.Cancel();
if (_hPC != IntPtr.Zero) { ClosePseudoConsole(_hPC); _hPC = IntPtr.Zero; }
if (_hPipeIn != IntPtr.Zero) { CloseHandle(_hPipeIn); _hPipeIn = IntPtr.Zero; }
if (_hPipeOut != IntPtr.Zero) { CloseHandle(_hPipeOut); _hPipeOut = IntPtr.Zero; }
if (_hPipeInRead != IntPtr.Zero) { CloseHandle(_hPipeInRead); _hPipeInRead = IntPtr.Zero; }
if (_hPipeOutWrite != IntPtr.Zero) { CloseHandle(_hPipeOutWrite); _hPipeOutWrite = IntPtr.Zero; }
}

public void Dispose() => Close();
}

这么来使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
public partial class CmdWindow : Window
{
private ConPtyConnection _connection;

public CmdWindow()
{
InitializeComponent();
Terminal.Loaded += Terminal_Loaded;
}

private void Terminal_Loaded(object sender, RoutedEventArgs e)
{
var theme = new TerminalTheme
{
DefaultBackground = 0x0c0c0c,
DefaultForeground = 0xcccccc,
DefaultSelectionBackground = 0xcccccc,
CursorStyle = CursorStyle.BlinkingBar,
ColorTable = new uint[]
{
0x0C0C0C, 0x1F0FC5, 0x0EA113, 0x009CC1,
0xDA3700, 0x981788, 0xDD963A, 0xCCCCCC,
0x767676, 0x5648E7, 0x0CC616, 0xA5F1F9,
0xFF783B, 0x9E00B4, 0xD6D661, 0xF2F2F2
},
};

_connection = new ConPtyConnection();

// 启动 cmd.exe(也可以换成 "powershell.exe" 或 "ssh user@host")
var conn = new ConPtyConnection("cmd.exe", 120, 30);
Terminal.Connection = conn;

Terminal.Connection = _connection;
Terminal.SetTheme(theme, "Cascadia Code", 12);
Terminal.Focus();
}

protected override void OnClosed(EventArgs e)
{
_connection?.Close();
base.OnClosed(e);
}
}