外接显示器黑屏,第一反应通常是:线坏了、杂牌屏不行、刷新率不兼容、重启试试。这次四条路我都走了一遍,全是死胡同。
真正的原因是我自己点的一下鼠标。macOS 26 的菜单栏 Screen Mirroring 面板里,这块显示器显示为 "Extending",旁边有个 Stop Extending。我点了。显示器瞬间黑屏,之后系统设置、system_profiler、BetterDisplay 全部看不见它,重启也不恢复。
这篇文章讲清楚三件事:Stop Extending 到底对系统做了什么、怎么用一条证据链把它从一堆看似合理的假设里挖出来、以及在不重装、不重置 NVRAM 的前提下如何精确撤销它。
TL;DR
- 环境:MacBook Pro(Apple M4 Pro),macOS 26.6.2 (25G83),合盖使用。两块外接屏:DELL U2723QX(3840×2160,主屏)和一台杂牌 USB-C 显示器(EDID 名称就叫 "Monitor",2560×1600)。BetterDisplay 5.1.1。
- 触发动作:菜单栏 Screen Mirroring → 看到 Monitor 处于 "Extending" → 点击 Stop Extending。
- 现象:杂牌屏黑屏(有背光但无内容);系统设置 → 显示器里消失;
system_profiler、BetterDisplay 均不列出它;重启无效。 - 排除项:USB-C 链路、EDID、DisplayPort modeset、刷新率(260Hz → 60Hz 无效)、BetterDisplay 的
-reconfigure/-connectAllDisplays。 - 根因:WindowServer 持久化配置
com.apple.windowserver.displays.plist里,该显示器的条目被写入了IndependentOutput = True。这个标志把它从桌面合成中剔除,CoreGraphics 把它标记为 offline。 - 修复:在
CGBeginDisplayConfiguration/CGCompleteDisplayConfiguration(.permanently)事务里调用 SkyLight 私有符号SLSConfigureDisplayIndependentOutput(cfg, displayID, false)。几秒内恢复,重启后依然有效。
问题现场
合盖模式下,两块外接屏一直工作正常。10 月 15 日 23:45 我打开菜单栏右上角的 Screen Mirroring,想看看投屏选项,注意到 Monitor 旁边写着 "Extending",下方有一个 Stop Extending 按钮。
我没多想,点了。
然后:
- 杂牌屏立刻黑屏。背光还在(屏幕是深灰而不是关机的纯黑),但没有任何内容,鼠标也移不过去。
- 系统设置 → 显示器里只剩 DELL。
system_profiler SPDisplaysDataType只列出 DELL。- BetterDisplay 的
get -identifiers也只有 DELL 和 Default Group。 - 重启。还是黑的。
此时 BetterDisplay 对这块屏完全无感——它的 CLI 连 tagID 都分配不出来。这一点很关键:BetterDisplay 看不见,说明问题在 BetterDisplay 之下。
排查:三层证据
显示管线从下到上大致是:物理链路(USB-C / DisplayPort) → 内核 DCP 驱动(modeset、HPD) → WindowServer / SkyLight(桌面合成、显示配置) → 应用层(系统设置、BetterDisplay)。我从底往上一层一层拿证据。
第一层:内核与链路——正常
先确认显示器是不是真的"连上了"。
IORegistry 里 EDID 完整,DisplayAttributes 读出 ProductName "Monitor"、Year 2025、MaximumRefreshRate 240,PortID 32 挂在 dispext1 下,IOMobileFramebufferShim 存在。硬件层面系统认得它。
再看内核 DCP 日志(DCPEXT1 对应这块屏):
mode_set_gated 2560x1600@259Hz ... switch to normal mode succeeded
HPDMonitor off -> on
链路训练成功、modeset 成功、热插拔检测在线,没有任何 unplug / link-loss。几条伴随出现的 vblank<300us、EDT ERROR getClockFrequency(0)、pmu ram read error 在每次 modeset 都有,DELL 那路也一样,判定为噪音。
结论:不是线、不是杂牌屏、不是 DisplayPort 协商。 显示器是点亮且在线的,只是没人往它上面画东西。
第二层:CoreGraphics——offline
既然内核说在线,就看 WindowServer 怎么认。写一个几行的 Swift 脚本查 CoreGraphics:
swiftimport CoreGraphicsfor d: CGDirectDisplayID in [1, 2, 3, 4] {print("display \(d): online=\(CGDisplayIsOnline(d)) active=\(CGDisplayIsActive(d)) bounds=\(CGDisplayBounds(d)) vendor=\(CGDisplayVendorNumber(d))")}var cnt: UInt32 = 0; var ids = [CGDirectDisplayID](repeating: 0, count: 16)CGGetOnlineDisplayList(16, &ids, &cnt); print("online list:", ids[0..<Int(cnt)])
输出:
display 2: online=0 active=0 bounds=(-2560.0, 345.0, 2560.0, 1600.0) vendor=19083
display 3: online=1 active=1 bounds=(0.0, 0.0, 3840.0, 2160.0) vendor=4268
online list: [3]
有意思的地方:display 2(杂牌屏)online=0,但 CG 仍然记得它的 bounds、vendor,甚至当前 mode(mode 115,2560×1600@260Hz)。WindowServer 知道这块屏的一切,就是不让它上线。
顺手排除刷新率假设:把 display 2 切到 mode 120(2560×1600@60Hz),CGCompleteDisplayConfiguration(.permanently) 返回成功,DCPEXT1 日志也完成了新的 modeset——然后它还是 offline。刷新率不是问题。
第三层:WindowServer 日志与持久化配置——找到了
log show --predicate 'process == "WindowServer"' 过滤 Display:Config,几条关键记录:
2026-10-15 23:45:43.296 client api - independent output change for display 2
...
2026-10-16 02:40:11.050 client api - Enable display: 0x2
2026-10-16 02:40:11.050 config_via_client_api - no independent output work
23:45:43 正是我点 Stop Extending 的时刻——Control Center 通过 "client api" 对 display 2 做了一次 independent output change。之后 BetterDisplay 启动时尝试 Enable display: 0x2,WindowServer 回了一句 "No change between prior and requested configuration":对它来说这块屏已经是 enabled 的,只是处于"独立输出"状态,enable/disable 根本不是同一个维度。
再看持久化配置。WindowServer 把显示布局存在两处:
~/Library/Preferences/ByHost/com.apple.windowserver.displays.<UUID>.plist/Library/Preferences/com.apple.windowserver.displays.plist
键路径 DisplaySets/Configs[0]/DisplayConfig[n]。两块屏的条目并排放着,只有杂牌屏多了一个键:
IndependentOutput = True
DELL 没有这个键。这就是全部差异。
把三层证据串起来:内核在线 → CG offline → 唯一差异是 IndependentOutput。根因确定。重启不恢复也解释通了——这个标志是持久化的。
Stop Extending 到底做了什么
从行为推断,macOS 26 Screen Mirroring 面板里的 "Stop Extending" 并不是"断开显示器"或"禁用显示器",而是把它切换成 Independent Output(独立输出):显示器依然处于 enabled 状态、依然有 modeset,但不再属于桌面扩展空间,WindowServer 不往上面合成任何窗口。这个状态是给独立投放内容(类似演示输出、专用输出)准备的,普通用户在菜单里看到一个 "Stop" 按钮根本意识不到它的语义。
更要命的是:
- 这个状态进了持久化 plist,重启不会清掉。
- 系统设置 → 显示器里不展示独立输出的屏幕,没有 UI 给你把它改回来(至少我没找到)。
- BetterDisplay 的
perform -reconfigure、perform -connectAllDisplays都无效,因为它们操作的是 enable/disable 和连接层面,不碰 IndependentOutput。
修复:用 SkyLight 把标志关掉
CoreGraphics 公开 API 里没有 IndependentOutput 的接口,但 SkyLight(WindowServer 的客户端框架)有。用 dlsym 在 /System/Library/PrivateFrameworks/SkyLight.framework/SkyLight 里能找到 SLSConfigureDisplayIndependentOutput,签名是 (CGDisplayConfigRef, UInt32 displayID, Bool enabled) -> Int32。
把它放进标准的 CG 配置事务里,以 .permanently 提交,让 plist 一起更新:
swiftimport Foundationimport CoreGraphicslet h = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)typealias ConfIndep = @convention(c) (CGDisplayConfigRef?, UInt32, Bool) -> Int32guard let sym = dlsym(h, "SLSConfigureDisplayIndependentOutput") else { print("no symbol"); exit(1) }let f = unsafeBitCast(sym, to: ConfIndep.self)let displayID: UInt32 = 2 // the display stuck in Independent Outputvar cfg: CGDisplayConfigRef?var e = CGBeginDisplayConfiguration(&cfg); print("begin:", e.rawValue)let r = f(cfg, displayID, false); print("SLSConfigureDisplayIndependentOutput:", r)e = CGCompleteDisplayConfiguration(cfg, .permanently); print("complete:", e.rawValue)sleep(2)print("online=\(CGDisplayIsOnline(displayID)) active=\(CGDisplayIsActive(displayID))")
shellswift indep.swift
输出 begin: 0、SLSConfigureDisplayIndependentOutput: 0、complete: 0,两秒后 online=1 active=1。WindowServer 日志同步出现一条 client api - independent output change for display 2——和 Stop Extending 那一下走的是同一条路径,只是方向相反。
验证:
CGGetOnlineDisplayList→[3, 2]。system_profiler SPDisplaysDataType→ Monitor 2560×1600@60Hz,Online: Yes。- BetterDisplay
get -identifiers→ 出现 "Monitor",分配了 tagID;get -displayInformation读出 RGB 10bit full、SDR、完整的 connectionModeList。 - 两份 plist 里
IndependentOutput键消失。 - 显示器有画面。
复盘
这不是 Bug,是语义陷阱。 Stop Extending 做的事和它的字面意思是一致的——"停止把这块屏当作桌面扩展"。问题在于它把屏幕扔进了一个用户不可见、系统设置不可逆、还会持久化的状态。一个破坏性操作出现在菜单栏快捷面板里,没有确认,没有撤销入口。
排查时的几个教训:
- 先确认故障在哪一层,再提假设。 内核日志十分钟就能把"线、屏、链路"三个假设全部排除,比换线重启省得多。
- "看不见"和"坏了"是两回事。 CG 保留着 display 2 的全部属性,只是 offline。这个细节直接把方向从硬件转到了配置。
- 工具看不见的东西,去它下面找。 BetterDisplay 很强,但它工作在 WindowServer 之上;WindowServer 自己的持久化配置和日志才是最终裁判。
- 对比法比阅读法快。 两块屏的 plist 条目并排一比,差异只有一个键。不需要先理解每个键的含义。
如果你也中招了:先试菜单栏 Screen Mirroring 里点回那块屏(理论上应该重新 Extend,我修好后没有再去试这条路)。如果找不到入口,上面的 Swift 脚本几秒解决,把 displayID 换成你的即可——用 CGGetOnlineDisplayList 查不到的话,直接遍历 1 到 10 看哪个 online=0 但 vendor 非零。
附带代价:排查过程中我把这块屏从默认的 260Hz 永久切到了 60Hz 用于排除变量。修完后记得去系统设置里把刷新率改回来。
最后说一句:以后菜单栏里任何带 "Stop" 字样的按钮,点之前先想想它停的到底是什么。
Comments