Excel 复制单元格时,有时成功,有时弹出剪贴板错误。同一份工作簿、同样的操作,结果却不稳定。最初怀疑的是微信输入法的跨设备剪贴板同步:它确实会接触剪贴板,也确实在后台运行。
但关闭同步后,故障仍然存在。继续追踪后发现,失败的那一次 Excel 已经成功打开并清空剪贴板,真正失败的是后续 OLE 窗口属性设置。再把这一步从 Excel 中抽出来单独测试,Windows 的新 Atom 分配也会间歇性失败。
这篇文章整理 2026 年 10 月 30 日的一次实际排障。重点是每一步怎样缩小问题范围,以及工具究竟回答了什么问题。
先把“复制失败”变成可检验的问题
当时的环境是 Windows 10 Enterprise LTSC 2019,内核版本 10.0.17763.1935 ;Excel 为 32 位,文件版本 16.0.13929.20408 。工作簿尚有未保存内容,因此优先保留现场,从低干扰观察开始。
Excel 的提示是:
There's a problem with the clipboard, but you can still paste your content within this workbook.
后续调试又捕获到:
com\ole32\ole232\clipbrd\clipapi.cpp(3743)
80070008 Not enough memory resources are available to process this command.
“剪贴板有问题”和“内存资源不足”都只是入口。首先需要分开几个问题:
| 假设 | 有区分力的检查 |
|---|---|
| 其他程序持续占用剪贴板 | 失败时能否打开剪贴板,占用窗口对应哪个进程 |
| 输入法同步或插件干扰 | 有效关闭该功能后,原操作是否仍失败 |
| Excel 内部状态异常 | 脱离 Excel 的最小 API 调用能否复现 |
| 系统共享资源分配异常 | 新建名称与复用已有名称是否表现不同 |
| Atom 表已满 | 实际占用量、空闲链与分配返回路径是否支持 |
每次测试都应有一个明确的判断对象。否则,关闭一批后台软件后“好像好一点”,很难知道究竟改变了什么。
第一层:观察谁打开了剪贴板
排查从 PowerShell 调用 Win32 API 开始。 GetOpenClipboardWindow 返回当前打开剪贴板的窗口,再用 GetWindowThreadProcessId 映射到进程。
下面是供读者使用的精简观察示例,可在受影响用户的 Windows PowerShell 中执行。它只查看窗口与进程,不读取剪贴板内容:
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class ClipboardWindowProbe {
[DllImport("user32.dll")]
public static extern IntPtr GetOpenClipboardWindow();
[DllImport("user32.dll")]
public static extern uint GetWindowThreadProcessId(IntPtr hwnd, out uint pid);
}
'@
1..50 | ForEach-Object {
$window = [ClipboardWindowProbe]::GetOpenClipboardWindow()
if ($window -ne [IntPtr]::Zero) {
[uint32]$ownerPid = 0
[void][ClipboardWindowProbe]::GetWindowThreadProcessId($window, [ref]$ownerPid)
$process = Get-Process -Id $ownerPid -ErrorAction SilentlyContinue
[pscustomobject]@{
Time = Get-Date -Format 'HH:mm:ss.fff'
Pid = $ownerPid
Process = $process.ProcessName
}
}
Start-Sleep -Milliseconds 100
}
本次采样看到微信输入法、Excel、Explorer 短时出现。这能说明它们接触过剪贴板,却不能说明哪个程序导致了失败。正常复制、读取剪贴板,也会产生短时占用。
反过来,没有采到窗口也不能排除问题:轮询可能错过短暂占用;应用还可能用空窗口句柄打开剪贴板,此时该 API 同样返回空。这个边界见 Microsoft 的 GetOpenClipboardWindow 文档。
这里还有一个容易忽略的实验条件:停止微信输入法用户进程后,它会自动重启。因此,那一轮不能算真正完成了隔离。后来关闭跨设备剪贴板同步仍然复现,才削弱了“同步功能直接导致故障”的假设。
事件日志也做了时间对齐。最近六小时的 Application 日志没有匹配的 Excel/Office 崩溃或挂起记录;System 中虽有更早的资源耗尽事件,但时间在本次开机之前,不能拿来解释当前这次失败。
第二层:在失败瞬间抓住 API 返回值
后台进程名单不能继续缩小范围,就需要观察 Excel 自己走到了哪里。
本次使用 Windows SDK 中的 x86 CDB 调试 32 位 Excel。先做非侵入快照,只看到空闲状态,随后改为短时 API 跟踪,由用户执行一次会失败的复制。
在装有 Debugging Tools for Windows 的终端中,非侵入附加的基本形式如下;示例中的 PID 需要换成当前 Excel 的实际 PID:
$excelPid = 1234 # 替换为目标 Excel 的 PID
& "${env:ProgramFiles(x86)}\Windows Kits\10\Debuggers\x86\cdb.exe" -pv -p $excelPid
-pv 适合先看现场;要观察运行中的 API 调用,还需要另行进行有界的侵入式跟踪。两种模式用途不同,不能期待空闲快照自动捕获刚刚发生的错误。附加方式可查 CDB 用户态调试文档。
关键结果是:
OpenClipboard = 1
EmptyClipboard = 1
SetPropW = 0
OleSetClipboard -> 0x80070008
这组返回值改变了调查方向。至少在抓到的这一次失败中,Excel 已经成功打开剪贴板,不能再用“别的程序不肯释放剪贴板”解释后面的错误。
根据当前模块的反汇编继续向内定位,OLE 初始化成功,随后设置第一个窗口属性时失败,属性名是 ClipboardDataObjectInterface 。这个字符串名称的登记涉及 GlobalAddAtomW 。
路径由此收敛为:
Excel 复制
-> OleSetClipboard
-> SetPropW("ClipboardDataObjectInterface", ...)
-> Global Atom 名称登记
-> 分配失败
调试时有两个具体教训。第一,某次 !gle 因缺少 _TEB 类型符号没有给出可靠结果,不能把工具显示不完整当作精确错误码。独立 API 探针应声明 SetLastError=true ,并在失败后立即取 Marshal.GetLastWin32Error() 。
第二,调试器和控制器的架构要匹配。首次使用 64 位控制器请求停止跟踪,没有按预期完成;改用 32 位控制器后才执行排队的 bc *; qd ,清除断点并正常脱离。应在退出后确认调试器已脱离,而不是关闭窗口就假定处理完毕。
本次用到的模块内偏移只对当时检查的二进制有效。可复用的是“入口、返回值、调用路径”的定位方法,具体偏移需要重新分析。
第三层:把最小复现从 Excel 中拿出来
Atom 可以理解为 Windows 给字符串名称分配的短标识。它不是单元格数据。不同用途有不同 Atom 表,不能把注册剪贴板格式、窗口类和 Global Atom 的数量混在一起。基本机制见 Microsoft 的 Atom Tables 文档。
下一步最有价值的实验,是让独立进程申请一个新名称。如果仍然失败,问题就已经超出当前 Excel 工作簿。
下面是整理后的短探针,供复现时在受影响用户会话的 Windows PowerShell 中运行。它只做 20 次小样本申请,并释放自己成功取得的引用;这段精简示例不等同于原会话的完整诊断脚本。
Add-Type @'
using System;
using System.Runtime.InteropServices;
public static class AtomProbe {
[DllImport("kernel32.dll", CharSet = CharSet.Unicode,
ExactSpelling = true, SetLastError = true)]
public static extern ushort GlobalAddAtomW(string name);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern ushort GlobalDeleteAtom(ushort atom);
}
'@
$results = foreach ($attempt in 1..20) {
$name = 'ExcelClipboardProbe-' + [Guid]::NewGuid().ToString('N')
$atom = [AtomProbe]::GlobalAddAtomW($name)
$allocationError = 0
if ($atom -eq 0) {
$allocationError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
}
$releaseError = 0
if ($atom -ne 0) {
$releaseResult = [AtomProbe]::GlobalDeleteAtom($atom)
if ($releaseResult -ne 0) {
$releaseError = [Runtime.InteropServices.Marshal]::GetLastWin32Error()
}
}
[pscustomobject]@{
Attempt = $attempt
Atom = $atom
ErrorIfFailed = $allocationError
ReleaseError = $releaseError
}
}
$results | Format-Table -AutoSize
原会话中的独立测试确实出现了返回 0、Win32 error 8。这一步把问题从“Excel 剪贴板异常”缩小成“共享名称的新分配会失败”。
随后又做了一组对照:复用 OLE 需要的固定名称,与每次申请一个新名称,结果是否相同?
在辅助程序持有固定名称引用后,结果如下:
| 测试对象 | 次数 | 成功 | 失败 |
|---|---|---|---|
| ClipboardDataObjectInterface | 20 | 20 | 0 |
| ClipboardRootDataObjectInterface | 20 | 20 | 0 |
| ClipboardDataObjectInterfaceMTA | 20 | 20 | 0 |
| 每次生成新名称 | 20 | 11 | 9,均为 error 8 |
这张表中的“9 次失败”是新 Atom 探针的失败次数,不能写成“Excel 复制 20 次失败 9 次”。同一阶段,用户在原工作簿里连续复制 20 次成功,跨应用粘贴也正常。
先恢复工作:保留 OLE 固定名称的引用
已有名称可以复用,新名称分配却不稳定,由此得到一个范围很窄的绕行办法:在当前交互会话中,提前取得 OLE 所需的三个名称,并各持有一个引用。
名称存在期间,再添加同名字符串可以复用既有 Atom。辅助程序把这几个名称维持在可复用状态,减少 OLE 再次进入新编号分配路径的机会。
会话中的实现保存在排障仓库的 scripts/ 下:
| 文件 | 用途 |
|---|---|
| hold-excel-clipboard-atoms.ps1 | 持有三个固定引用,正常退出时释放 |
| install-excel-clipboard-atom-guard.ps1 | 安装或移除当前用户登录启动项 |
| test-excel-clipboard-atoms.ps1 | 对比固定名称与新名称的申请结果 |
| test-windows-global-atom-per-cpu.ps1 | 分逻辑 CPU 做小样本申请测试 |
这些是本次排障产生的自定义脚本,不是 Windows 自带命令。以下命令供已有该排障仓库的环境使用,在其仓库根目录执行:
# 先查看安装动作,再安装当前用户启动快捷方式
powershell.exe -NoProfile -File scripts/install-excel-clipboard-atom-guard.ps1 -WhatIf
powershell.exe -NoProfile -File scripts/install-excel-clipboard-atom-guard.ps1
# 比较固定名称与新名称
powershell.exe -NoProfile -File scripts/test-excel-clipboard-atoms.ps1 -Attempts 20 -ProbeNewAtom
# 移除启动项,并通知辅助程序正常释放自己的引用
powershell.exe -NoProfile -File scripts/install-excel-clipboard-atom-guard.ps1 -Remove
运行入口是当前用户 Startup 目录下的 Excel Clipboard Atom Guard.lnk ,状态文件位于 %LOCALAPPDATA%\ExcelClipboardAtomGuard\lease-*.json ,正常持有状态为 Holding 。它使用会话内互斥量避免重复运行,通过停止标记正常退出。
这里的运行身份会影响结果:辅助程序需要与受影响应用处于对应的交互会话,不能因为本机有定时调度器,就直接把它放进 LocalSystem 的 session 0。移动脚本仓库后,也要重新安装快捷方式以更新路径。
这项绕行恢复了工作,但没有修好全局分配状态。证据恰恰是上表中新名称仍有失败。若还没成功取得三个引用,单有后台进程也不能算安装成功;应检查 Holding 状态,再做真实 Excel 复制和跨应用粘贴。
第四层:表没满,为什么还会申请失败?
如果只看到 error 8,很容易直接归因于物理内存不足,或者断言“Atom 泄漏把表填满了”。本次记录不支持这么简单的解释:失败时,目标 Global Atom 表实际占用合计只有 198 项。
为继续追踪,使用 LiveKD 的 -ml 路径采集内核快照,再用 KD 离线分析。LiveKD 官方说明列出了这一采集模式。快照保存在本机 %LOCALAPPDATA%\ExcelClipboardDiagnostics\ ,约 2.8 GiB,没有作为博客附件公开。
这一步也暴露了取证成本。最初调试日志出现大量 Could not write to pipe, 1450 ,用户随后报告系统无响应并断电重启。采集或分析可能加剧资源压力,但不能把卡死的唯一原因定为 LiveKD。后续停止重新采集,改为复用已有快照,限制离线分析为单核、Idle 优先级、40 秒上限,并设置内存超限退出条件。
取证工具也消耗被调查系统的资源。工作恢复后,应优先在旧快照上回答小问题,避免为了追求完整解释再次影响正在使用的机器。
找对表,再解释字段
分析从 Excel 的 Win32WindowStation 句柄定位到 WinSta0 ,再沿当前版本 win32kbase!UserGlobalAtomTableCallout 的取值路径找到 Global Atom 表。它和 UserAtomTableHandle 指向的表不同。
离线分析围绕以下对象展开:
| 对象或符号 | 要回答的问题 |
|---|---|
| _RTL_ATOM_TABLE | 是否找到了正确的表结构 |
| ExHandleTable.NextHandleNeedingPool | 句柄编号空间扩展到了哪里 |
| nt!ExpFreeListCount | 存在多少条空闲链 |
| _HANDLE_TABLE_FREE_LIST | 各条链的计数与下一候选编号 |
| ExpAllocateHandleTableEntry | 当前处理器如何选择分配链 |
| RtlpInsertStringAtom | 分配的编号在哪个条件下被拒绝 |
实测 NextHandleNeedingPool 为 0x10400 。16 条按处理器选择的空闲链中,有 11 条首项会产生大于 0xFFFF 的候选 Atom,例如 0x10001 、 0x10002 、 0x1000D ;另外 5 条首项仍在范围内。
当前内核版本的反汇编显示,候选值涉及以下计算:
atom candidate = (handle >> 2) + 0xC000
RtlpInsertStringAtom 拒绝超出上界的编号;随后 RtlAddAtomToAtomTableEx 返回 STATUS_NO_MEMORY (0xC0000017) 。在该快照的 StrictFIFO 为 0 的情况下,失败后释放的条目又回到当前链表首部,下一次可能再次取到同一个不合适的编号。若线程在另一条仍有有效首项的处理器链上分配,则可能成功。
这就把几个看似矛盾的现象连了起来:表中现存名称不多,新名称却申请失败;有的请求成功,有的失败;保留已有名称的引用可以绕过新编号分配。
11/16 是这份快照中异常链表首项的比例,不是 Excel 实际复制的失败概率。调度、已有名称是否仍在、执行时刻都会影响观测结果。
缺失的证据也要记下来
实时转储没有包含所有分页内存,散列桶遍历遇到缺页,因此不能把“遍历到多少项”直接当成全表数量。这里使用完整的句柄表计数合计,并对叶页目录与候选编号做交叉核对。
某条局部链的计数为负,也不能立即判定泄漏或损坏:申请和释放可能发生在不同 CPU。判断需要结合全部链的计数和当前版本实现。
这些结论来自失败时的用户态跟踪、旧会话快照与对应版本反汇编,没有在实时内核断点上重放完整失败。它们解释了失败机制,但快照没有历史分配调用栈,仍不能回答“最早是谁让编号空间扩展到了这个状态”。
怎样验证恢复,而不是只看错误消失
本次验证分成了三个层次:
| 层次 | 绕行后、重启前 | 重启后的新会话 |
|---|---|---|
| 用户操作 | Excel 连续复制 20/20,跨应用粘贴正常 | 用户再次确认正常 |
| 固定名称 | 三个名称各 20/20,释放无失败 | 复测全部成功 |
| 新名称分配 | 11/20,仍有 error 8 | 普通新名称 20/20;16 CPU 各 3 次,共 48/48 |
逐 CPU 测试只改变测试线程的亲和性,并在 finally 中恢复,不修改 Excel 的 CPU 设置。对已有排障仓库,在根目录执行:
powershell.exe -NoProfile -File scripts/test-windows-global-atom-per-cpu.ps1 -AttemptsPerCPU 3
这给新登录会话建立了一个明确基线。重启清除了旧会话状态,辅助程序也已自动启动;48/48 证明当时小样本分配正常,不能据此承诺以后永不复发。
期间还观察到签名组件 GTJANetSignCNG.exe 私有内存一分钟增加约 138 MiB,并消耗约一个 CPU 核;临时停止相关守护服务和进程后,内存压力下降。但 Excel 在此之前已经恢复,这个发现应作为独立资源问题记录。Groupy 的卸载/卸载钩子实验也没有消除新 Atom 分配失败。二者都不能被写成已经找到的始发原因。
这次排障值得复用的顺序
最有用的动作,是在每一层都找到一个能区分假设的实验。
先观察打开剪贴板的窗口,确认“看见某个程序”与“它导致失败”之间还有距离;随后抓住失败瞬间的 API 返回值,把范围从剪贴板占用移到 OLE 属性设置;再用独立小程序复现新名称分配,让问题脱离 Excel;最后用固定名称与新名称、旧会话与新会话、不同 CPU 的结果互相对照。
工具也随问题逐步升级:PowerShell 适合进程、日志与小样本 API 探针;CDB 用来确定应用具体在哪一步失败;LiveKD 留下系统状态;KD 在离线快照中解释编号和链表行为。只有前一层证据不足以回答下一问时,才值得承担更重工具的成本。
最终得到的结论有清晰边界:复制功能已恢复,旧会话的新 Atom 分配失败有了机制解释,最初触发异常状态的程序仍未知。保留这条边界,后续若复发,就能从已有基线继续取证,而不必重新从“是不是输入法”猜起。