外接屏亮度这件事,标准答案是 DDC/CI。MonitorControl、Lunar、BetterDisplay 都是这个路子:通过 IOAVService 给显示器发一条 set luminance,让固件自己去改背光。干净、不损失画质、不经过合成器,我一开始也走这条。
然后撞上了最难受的一种失败。用 m1ddc 写 15,读回来还是 15:
$ m1ddc display 2 set luminance 15
Writing 15
$ m1ddc display 2 get luminance
15 # 寄存器里真的是 15,屏幕还是满亮
不是没发出去,不是权限,不是线序,也不是 IOAVService 没打开。显示器收下命令、ACK、把值写进寄存器,然后背光一动不动。README 里这段复现记录后面只跟了一句注释:# ...the panel is still at full brightness。
这个失败形态决定了后面所有选择:故障点在显示器固件里,不在主机侧。所以换一个 DDC 工具毫无意义——Lunar 和 BetterDisplay 请求的是同一个固件动作,结果不会不一样。想救这块屏,只能换一层。
二、换层:不改背光,改它收到的码值
DDC 失败之后还剩一条路:软件调光。屏幕最终发出的光,是显卡送的码值过一遍显示器 gamma 响应之后的结果,所以只要把整张 gamma 表乘一个系数 f,输出光就变成 f^gamma 倍。CGSetDisplayTransferByTable() 干的就是这件事,按显示器生效,不需要 sudo、不需要辅助功能权限、不需要签名。
关键不在于能调暗,而在于可以按尼特调。nitsync 的整个模型只有三行(来自 src/nitsyncd.c 的头注释):
mac_nits = linearBrightness * mac_nits_at_full * gain
panel_nits = panel_nits_at_full * f ^ gamma
f = clamp( (mac_nits / panel_nits) ^ (1 / gamma), 0, 1 )
这里有一步容易被忽略的反解:我要的不是"外接屏输出多少比例的光",而是"外接屏输出和内置屏一样多的尼特",所以是拿目标尼特除以面板峰值尼特,再开 gamma 次方根,得到的是码值缩放系数,不是亮度百分比。写进 gamma 表的是这个 f。这等价于把显示器固件本该做的那一步在合成器里重做一遍,顺便做得更准。
三、采样层:一次 dlsym,两条降级路径
内置屏的真实亮度只能从私有框架里拿。DisplayServices.framework 没有公开头文件,所以用 dlopen + dlsym 运行时取符号,取不到就退到滑块值再近似成线性:
void *ds = dlopen("/System/Library/PrivateFrameworks/DisplayServices.framework/DisplayServices",
RTLD_NOW);
if (!ds) {
fprintf(stderr, "nitsyncd: cannot open DisplayServices\n");
return 1;
}
GetLinearFn get_linear = (GetLinearFn)dlsym(ds, "DisplayServicesGetLinearBrightness");
GetBrightnessFn get_brightness = (GetBrightnessFn)dlsym(ds, "DisplayServicesGetBrightness");
if (!get_brightness && !get_linear) {
fprintf(stderr, "nitsyncd: brightness symbols missing\n");
return 1;
}
采样循环里两个符号的优先级是硬编码的:
| 符号 | 返回值语义 | 代码里的处理 | 拿不到时 |
|---|---|---|---|
DisplayServicesGetLinearBrightness | 已经线性化,0..1,正比于光输出 | 直接用 | 落入下一档 |
DisplayServicesGetBrightness | 滑块位置,非线性 | powf(value, 2.2f) 近似线性 | 本 tick 跳过 |
| 两个都没有 | — | 启动即失败,退出码 1 | 进程不进入主循环 |
powf(value, 2.2f) 是近似,不是标定。两条路径出来的 f 会有肉眼可见的差异,所以启动日志里专门打了一行 linear=yes/no,方便先确认自己走的是哪一档:
nitsyncd 1.0.0: mac_nits=625 panel_nits=300 gamma=2.20 gain=1.00 deadband=0.020 tau=0.060 linear=yes
四、控制层:三个变量就是一个状态机
整段亮度逻辑没有结构体,状态靠 main() 里三个 double:target(算出来的目标)、applied(当前真实写进表里的值)、have_target(是否已经有过一次有效采样)。加上 last_time 算 dt、ticks 做心跳,就是全部。
double mac_nits = (double)value * g_mac_nits * g_gain;
double ratio = mac_nits / g_panel_nits;
double raw = pow(ratio, 1.0 / g_gamma);
if (raw > 1.0) raw = 1.0;
if (!have_target) {
target = applied = raw; /* 第一帧直接落位,不做渐变 */
apply_factor(applied);
have_target = 1;
} else if (fabs(raw - target) >= g_deadband) {
target = raw; /* 认定是人为改动,不是传感器噪声 */
}
if (fabs(target - applied) > EPSILON) {
double alpha = 1.0 - exp(-dt / g_tau);
if (alpha > MAX_STEP) alpha = MAX_STEP;
applied += (target - applied) * alpha;
if (applied < 0.0) applied = 0.0;
if (applied > 1.0) applied = 1.0;
apply_factor(applied);
}
三个常量定义了这里的控制行为,取值都在文件头部:
TICK = 0.05:采样周期 50ms,名义 20Hz。EPSILON = 0.002:target与applied差不到这个数就跳过这次写表,避免每 tick 都去动合成器。MAX_STEP = 0.25:单 tick 最大步进,防止dt异常时一帧跳到底。g_deadband = 0.02/g_tau = 0.06:可从配置覆盖,deadband卡在f空间,tau是秒。
dt 还有一行钳位:if (dt > 0.5) dt = 0.5;。机器睡眠醒来时 CLOCK_MONOTONIC 会给出一个巨大的间隔,不钳的话 alpha 直接饱和到 1,屏幕从黑到全亮闪一下。
五、输出层:读改写,别把 Night Shift 洗掉
apply_factor(f) 的骨架是"读现有表 → 等比缩放 → 写回",而不是直接生成一张线性表:
CGGammaValue cur_r[256], cur_g[256], cur_b[256];
uint32_t samples = 256;
if (CGGetDisplayTransferByTable(ids[i], 256, cur_r, cur_g, cur_b, &samples) != kCGErrorSuccess)
continue;
if (samples < 2 || samples > 256) continue;
double cur_max = cur_r[samples - 1];
if (cur_max < 0.02) continue; /* 没东西可缩,跳过 */
double k = f / cur_max;
for (uint32_t j = 0; j < samples; j++) {
double r = cur_r[j] * k, g = cur_g[j] * k, b = cur_b[j] * k;
out_r[j] = (CGGammaValue)(r > 1.0 ? 1.0 : r);
out_g[j] = (CGGammaValue)(g > 1.0 ? 1.0 : g);
out_b[j] = (CGGammaValue)(b > 1.0 ? 1.0 : b);
}
CGSetDisplayTransferByTable(ids[i], samples, out_r, out_g, out_b);
为什么必须读改写:Night Shift 和 True Tone 改的也是这张表。直接写一张干净的表,暖色调会被一起抹掉,晚上用起来像换了个屏幕。等比缩放保住了颜色形状,只改整体峰值。
几个防御性分支值得单独说。samples 是输入输出参数,调用方给 256、系统可能返回别的值,所以 samples < 2 || samples > 256 都要挡;cur_max < 0.02 意味着这张表几乎全黑,再乘系数只会得到更黑的东西,直接跳过;最后每个通道单独夹到 1.0,因为 f 只在函数入口夹过一次,乘上 1/cur_max 之后仍可能溢出。目标显示器靠 CGGetOnlineDisplayList(16, ...) 拿,然后 if (ids[i] == main_id) continue; 跳过内置屏——内置屏的亮度归 macOS 管,动了就是跟系统抢。
六、踩到的坑
坑 1:把百分比当亮度。 第一版是"内置屏 50%,外接屏就乘 0.5"。结果是外接屏暗到没法看。原因在你写进表里的是码值,屏幕输出的是 码值^2.2,0.5 只发出约 22% 的光。仓库 docs/article-zh.md 里记了实测的三个点:滑块 1.00 → linear 0.80,滑块 0.50 → linear 0.267,滑块 0.30 → linear 0.038。差着一个 2.2 次方,这就是第一版偏暗的全部原因。改法是把整条链路换成尼特:625 这个默认 mac_nits 也是这么来的——MacBook Air SDR 满亮度 500 nits 时 linear = 0.8,所以 linear = 1.0 对应 625。
坑 2:外接屏会自己"呼吸"。 尼特对齐之后,什么都不做,外接屏每隔两三秒自己跳一下。抓 40 秒数据是 0.669 → 0.648 → 0.593 → 0.534 → 0.554 → 0.594 → 0.643,不是噪声,是震荡:自动亮度开着,环境光传感器一直在漂;更糟的是外接屏的光会照回传感器,于是外接屏一暗、房间变暗、内置屏调亮、外接屏跟着亮,闭环。这里我一开始选错了——把死区放在滑块空间。正确的位置是 f 空间,因为那才是我们实际写进表、也是眼睛实际看到的量。deadband 0.02 之后,作者的记录是 30 秒内 gamma 表一次都没变。
坑 3:后台进程被降频到 ~7Hz。 死区解决了抖动,又带来"按完键要三四秒才跟上"。原因有两个,第二个才致命:一是当时限制每周期最多走 2%、周期 0.1 秒,全量程要 5 秒;二是 LaunchAgent 默认以 ProcessType = Background 跑,被 App Nap 当成后台批处理降频到 ~7Hz,TICK = 0.05 根本兑现不了。安装脚本里那行 <string>Interactive</string> 就是治这个的,改成 Interactive 后实测稳定在 20Hz。再把固定步长换成时间相关的指数跟随(alpha = 1 - exp(-dt/tau),tau = 0.06,MAX_STEP 封顶 0.25),主要变化在 0.5 秒内完成,观感从"追上去"变成"快速淡出"。
坑 4:表会被别人擦掉。 唤醒、改分辨率、切色彩配置都会重置 gamma 表,而守护进程完全不知情,applied 还停在旧值上,于是外接屏回到满亮并且再也不跟着动。修法是心跳:每 100 个 tick(约 5 秒)读一次外接屏当前表的最大值,跟 applied 差超过 0.01 就重写。
if (have_target && ++ticks >= 100) {
ticks = 0;
double cur = external_ramp_max(); /* 拿不到返回 -1.0 */
if (cur >= 0.0 && fabs(cur - applied) > 0.01) apply_factor(applied);
}
external_ramp_max() 的 -1.0 是个哨兵值,区分"没有外接屏"和"外接屏表是 0"。用 0 当哨兵的话,一块全黑的屏会被误判成需要重写。
七、边界与代价
配置文件 ~/Library/Application Support/NitSync/config 里只有六个数,源码对它们各自有钳位,防止手改出离谱的值:
| key | 默认 | 含义 | load_config 里的钳位 |
|---|---|---|---|
mac_nits | 625 | 内置屏 linear=1.0 时的尼特 | 无 |
panel_nits | 300 | 外接屏峰值尼特(查规格书) | < 1.0 拉到 1.0 |
gamma | 2.2 | 面板响应曲线 | < 1.0 拉到 1.0 |
gain | 1.0 | 全局微调,1.0 = 相信数字 | 无 |
deadband | 0.02 | 小于此值的漂移忽略 | < 0.0 拉到 0.0 |
tau | 0.06 | 跟随时间常数,秒 | < 0.01 拉到 0.01 |
再往上是硬边界,参数怎么调都绕不过:
- 只能往下调。 面板 300 nits、内置屏 500 nits,内置屏过约 78% 之后外接屏已经顶满,
f被钳在 1.0,只能看着内置屏继续变亮。这是面板物理上限,不是配置问题。 - 黑位会被压。 码值缩放压下去的是整条曲线,低亮度下黑场更接近"发灰",对比度下降。
- HDR/EDR 不过这张表。 只有 SDR 合成层走 gamma 表,HDR 视频不受影响,也就不参与"同尼特"。
- 私有符号没有兼容承诺。 Apple Silicon + 内置屏是前提,README 说在 macOS 26/27 上测过;
dlsym失败时代码只是打一行brightness symbols missing然后退出 1,LaunchAgent 的KeepAlive会让它不停重启——真要长期用,这行日志得盯着。 - 心跳只看第一块外接屏。
external_ramp_max()拿到第一块非主屏就返回,多外接屏时其余几块的重置检测不到。 - 退出会还原。
main()结束前调一次apply_factor(1.0);卸载脚本也提醒了一句,如果颜色看着不对就重新登录。
八、小结
DDC 失败不是"再换个工具"能解决的,它是固件层的不作为,唯一出路是把亮度控制从显示器搬回合成器。搬回去之后要做的不是"调个百分比",而是重新建一套以尼特为单位的映射:线性亮度采样 → 目标尼特 → 反解码值系数 → 死区 + 指数跟随 → 读改写 gamma 表,外加一个 5 秒心跳防重置。整套东西是一个 src/nitsyncd.c,安装脚本只做几件事:clang 编译、复制默认配置、装 LaunchAgent。
代码在 Cooperiano/nitsync,MIT。