macOS 26 点了 Stop Extending 后外接显示器黑屏:一次 WindowServer IndependentOutput 排障

外接显示器黑屏,第一反应通常是:线坏了、杂牌屏不行、刷新率不兼容、重启试试。这次四条路我都走了一遍,全是死胡同。

真正的原因是我自己点的一下鼠标。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:

swift
import CoreGraphics
for 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" 按钮根本意识不到它的语义。

更要命的是:

  1. 这个状态进了持久化 plist,重启不会清掉。
  2. 系统设置 → 显示器里不展示独立输出的屏幕,没有 UI 给你把它改回来(至少我没找到)。
  3. 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 一起更新:

swift
import Foundation
import CoreGraphics
let h = dlopen("/System/Library/PrivateFrameworks/SkyLight.framework/SkyLight", RTLD_NOW)
typealias ConfIndep = @convention(c) (CGDisplayConfigRef?, UInt32, Bool) -> Int32
guard 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 Output
var 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))")
shell
swift 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 做的事和它的字面意思是一致的——"停止把这块屏当作桌面扩展"。问题在于它把屏幕扔进了一个用户不可见、系统设置不可逆、还会持久化的状态。一个破坏性操作出现在菜单栏快捷面板里,没有确认,没有撤销入口。

排查时的几个教训:

  1. 先确认故障在哪一层,再提假设。 内核日志十分钟就能把"线、屏、链路"三个假设全部排除,比换线重启省得多。
  2. "看不见"和"坏了"是两回事。 CG 保留着 display 2 的全部属性,只是 offline。这个细节直接把方向从硬件转到了配置。
  3. 工具看不见的东西,去它下面找。 BetterDisplay 很强,但它工作在 WindowServer 之上;WindowServer 自己的持久化配置和日志才是最终裁判。
  4. 对比法比阅读法快。 两块屏的 plist 条目并排一比,差异只有一个键。不需要先理解每个键的含义。

如果你也中招了:先试菜单栏 Screen Mirroring 里点回那块屏(理论上应该重新 Extend,我修好后没有再去试这条路)。如果找不到入口,上面的 Swift 脚本几秒解决,把 displayID 换成你的即可——用 CGGetOnlineDisplayList 查不到的话,直接遍历 1 到 10 看哪个 online=0 但 vendor 非零。

附带代价:排查过程中我把这块屏从默认的 260Hz 永久切到了 60Hz 用于排除变量。修完后记得去系统设置里把刷新率改回来。

最后说一句:以后菜单栏里任何带 "Stop" 字样的按钮,点之前先想想它停的到底是什么。


Comments