macOS 菜单栏风扇自适应温控
按温度自动调速,装上就不用管。
macOS 不让你控制风扇转速。机器凉的时候风扇完全停转,热起来才由固件接管——而固件的启动阈值很高(本机实测 68.8 °C 时风扇仍是 0 转)。
FanPilot 接管这件事:读 CPU 核心温度,按一条曲线连续调节两个风扇,并保证一个转速下限(默认 2000 RPM),让机器始终有气流。
它的设计目标不是"散热最强",而是恰到好处且永不失控:
| 自适应 | 23 个核心温度传感器取最大值 → 五层平滑 → 连续调速,无阶跃、无抖动 |
| 可控 | 菜单栏里点一下改转速下限,曲线自动跟着重新铺开 |
| 省资源 | 守护常驻 CPU 0.10% · RSS 5.7 MB · 亚 2ms 定时器 0/s |
| 失效安全 | 任何异常都回到出厂的「固件自动控制」,绝不把风扇留在低速 |
⚠️ 本工具直接写 SMC 风扇寄存器。所有目标值都被夹在硬件自己报告的F*Mn~F*Mx范围内, 且 90 °C 以上强制打满且忽略一切用户配置。但你仍应了解自己在做什么。
要求:Apple Silicon Mac · macOS 13+ · 与其它风扇控制软件互斥(两者会抢写 SMC)
FanPilot 依赖 SMC 的 F*Tg 键可写、FNum 可读、Tp* 传感器存在。这些在不同机型上不一定成立,
所以先确认你的机器支不支持,再装东西——不要先装一个 root 守护、然后才发现不兼容。
tar -xzf FanPilot-*.tar.gz && cd FanPilot-*/
bash install/precheck.sh输出示例(本机):
── SMC 接口(决定能不能用)
✅ SMC 可读(IOServiceMatching("AppleSMC") 成功)
✅ 检测到 2 个风扇(FNum)
✅ 风扇0: 范围 1350~5349 RPM · 当前 2014 RPM · 模式 0(0=固件 1=手动)
✅ 风扇1: 范围 1350~5777 RPM · 当前 2018 RPM · 模式 0
✅ 温度传感器可读(当前最热 53.7 °C)
── 冲突检查
✅ 无其它风扇控制软件
✅ 完全兼容,可以安装
有 ❌ 阻断项就不要装。预检完全只读,不会改动任何东西。
从 Releases 下载 FanPilot-<版本>.tar.gz:
tar -xzf FanPilot-*.tar.gz
cd FanPilot-*/
bash install.shinstall.sh 会依次做 5 件事,每步都会打印结果:
| 步骤 | 做什么 | 要 sudo? |
|---|---|---|
| 1 | 解除 Gatekeeper 隔离(见下方说明) | ❌ |
| 2 | 跑兼容性预检 + 冲突检查,有阻断项就中止 | ❌ |
| 3 | 装控制守护 fanpilotd(root LaunchDaemon) |
✅ |
| 4 | 装菜单栏 App 到 /Applications + 开机自启 |
❌ |
| 5 | 跑 25 项验收,全绿才算成功 | ✅ |
为什么要 sudo:写 SMC 风扇寄存器需要 root(读不需要)。守护是全机唯一能写 SMC 的进程; 菜单栏 App 完全无特权,只读状态文件。这个权限边界是刻意划的,见架构。
关于 Gatekeeper:包内二进制是 ad-hoc 签名(自用工具,没有 Apple Developer ID)。 从网上下载的文件带
com.apple.quarantine属性,Gatekeeper 会拒绝执行。install.sh第一步执行xattr -dr com.apple.quarantine解除—— 这不是绕过安全机制,而是你对自己下载的东西做显式授信。 介意的话请走方式 B 从源码编译。
不想信任预编译二进制就自己编。需要 Xcode Command Line Tools(xcode-select --install):
git clone https://github.com/neosun100/FanPilot.git
cd FanPilot
make # 编译守护 fanpilotd 与命令行工具 fanctl(C,无第三方依赖)
make precheck # 兼容性预检(只读)
make install # 装 root LaunchDaemon(会要 sudo)
make verify # 25 项验收,含反向断言
make app # 编译菜单栏 App(需要 Swift 6+)
bash install/install-app.sh # 装 App + 开机自启(不需要 sudo)跑一遍全部测试(可选,182 项):make test
装 root 守护这种事应该完全透明:
| 路径 | 内容 | 谁的 |
|---|---|---|
/usr/local/sbin/fanpilotd |
控制守护(约 80 KB,纯 C) | root |
/Library/LaunchDaemons/com.newmac.fanpilotd.plist |
守护的 launchd 配置(RunAtLoad + KeepAlive) |
root |
/usr/local/etc/fanpilot/fanpilot.conf |
配置(key = value,带注释) |
你(App 要能改) |
/var/run/fanpilot.status.json |
运行状态(守护写,0644) |
root 写 / 所有人可读 |
/var/log/fanpilotd.log |
守护日志 | root |
/Applications/FanPilot.app |
菜单栏 App(约 300 KB) | 你 |
~/Library/LaunchAgents/com.newmac.fanpilot.menu.plist |
App 的开机自启 | 你 |
不装任何内核扩展、不改 SIP、不装第三方依赖、不联网。
cat /var/run/fanpilot.status.json # 应看到实时温度与两个风扇的目标/实际转速
sudo launchctl print system/com.newmac.fanpilotd | grep -E 'state|runs'state = running 且 runs = 1 就对了。runs 短时间暴涨说明守护在崩溃重启循环(KeepAlive 在反复救它),
这时看 /var/log/fanpilotd.log。
菜单栏右侧应出现两行小字:上行温度、下行转速。
sudo bash install/uninstall.sh # 守护
# App:
launchctl bootout gui/$(id -u)/com.newmac.fanpilot.menu
rm -f ~/Library/LaunchAgents/com.newmac.fanpilot.menu.plist
rm -rf /Applications/FanPilot.app卸载脚本先让守护把风扇交还固件,再删文件。顺序反了会把风扇永久留在手动模式 ——实测 SMC 不会自动回退(停止写入 60 秒后仍保持手动)。脚本里还有一道无条件兜底, 即使守护的信号处理没跑成也会强制写回
F*md=0。
菜单栏没出现图标?
App 是 LSUIElement(不进 Dock),只在菜单栏。若菜单栏项太多可能被折叠——
先确认进程在跑:pgrep -x FanPilot。日志在 /tmp/fanpilot-menu.log。
显示「守护未运行」?
sudo launchctl print system/com.newmac.fanpilotd 看状态,/var/log/fanpilotd.log 看原因。
最常见是有其它风扇软件在抢写 SMC——安装时会检测并拒绝,但如果是装完之后才装的其它软件就不会被拦。
改转速下限报 Permission denied?
配置属主被改成了 root。修:sudo chown $(whoami) /usr/local/etc/fanpilot/fanpilot.conf
(原子写需要目录也可写,所以配置放在专属目录 /usr/local/etc/fanpilot/ 下)。
风扇一直不转 / 转速是 0?
先跑预检确认 F*Tg 可写。若守护 mode 显示 stopped_firmware_auto,
说明它已交还固件——固件在凉的时候本来就让风扇停转(本机实测 68.8 °C 时仍是 0 转)。
菜单栏两行:上行最热核心温度、下行两个风扇的平均转速。
固定宽度 23pt,四种状态宽度一致,不会横跳挤占其他图标。状态标记是贴底细条而不是 emoji:
- 正常 —— 无标记
- 紧急全速 —— 贴底实心条(温度 ≥ 90 °C,忽略限幅直接打满)
- 风扇故障 —— 贴底虚线条(实际转速持续低于目标 55%)
- 固件接管 —— 整体变淡(守护已退出,风扇回到出厂控制)
为什么不用 emoji:模板着色模式下 AppKit 只用 alpha 通道,🔥 会变成一坨纯黑块。 为什么标记贴底而不放左侧:4 位转速(如
5349)占满全宽时,左侧标记会压在数字上。
点开下拉面板可以看到:当前模式 + 哪个温度在驱动调速、三档温度各自的作用标注、 平滑后温度、每个风扇的实际/目标/硬件范围/已用多少百分比散热能力/故障标记、 生效中的全部参数、累计 SMC 写入次数,以及三个开关——转速下限(6 档)、 温度口径(3 档)和开机自动启动。
改完下限即刻生效:守护监视配置文件 mtime 自动重载,不需要编辑配置文件、不需要输密码、 也不需要点什么"重新加载"。
下限只决定空闲时的地板转速,不改变高温时的散热能力——曲线永远铺到硬件上限。 真实的取舍是「空闲噪音 ↔ 温度基线」。
| 写 SMC 频率 | 散热 | |
|---|---|---|
| 自适应曲线(默认) | 约 16 次/分 ≈ 2.3 万次/天 | 随温度连续调 |
| 定死转速 | 首次爬升后稳态 0 次 | 恒定,不随温度变 |
单元测试实测 300 个周期(温度大幅波动):定死 4 次 vs 自适应 291 次——写入少 73 倍。
🩸 为什么加这一档。 本机 2026-09-13~14 两天内发生 5 次 PMU 硬件看门狗复位 (
SOCD report detected: (iBoot panic)·wdog,reset_in_1)。排查后确认:不是过热、 不是 kernel panic、复位前也无任何软件卡顿(2 秒粒度采样,间隔恒定 2010 ms)。 而唯一有长期无崩溃记录的配置,是「把转速钉死、之后基本不再写 SMC」那种用法。
⚠️ 相关不等于因果,根因仍未确定。 这一档是降低暴露面,不是已证明的修复。 如果你的机器也出现无故重启、且 panic 文件里有SOCD,可以试这一档。
定死模式下 min_rpm / max_rpm / ema_seconds / 曲线全部不参与,菜单里那两项会变灰。
emergency_temp 打满仍然保留(emergency_override = 1)——它只在真的超阈值时才写 SMC,
所以「减少写入」和「保留过热保护」并不冲突,没有理由为前者放弃后者。
23 个核心传感器要归成一个数喂给曲线。用哪个数是可选的:
| 档 | 相对最高核 | 手感 |
|---|---|---|
| 最高核心温度 | — | 最保守,风扇最早升速 |
| 全核平均温度 | 低约 3~13 °C | 默认,性能核与能效核折中 |
| 最低核心温度 | 低约 20~25 °C | 最安静,风扇最晚升速 |
温差之所以这么大,是因为两个核簇差距悬殊:实测性能核 7284 °C,能效核只有 5862 °C。
面板里三个温度同时列出,并在各自旁边写清它在起什么作用:
自适应控制中 · 按「最低核心」调速
────────────────────────────────
最高核心 91.6 °C ⚠️ 偏高(固件设定点约 89°C)
全核平均 79.8 °C ← 紧急判据 ≥90°C(还差 10.2)
最低核心 67.0 °C ← 曲线输入
平滑后 68.3 °C · EMA 15s · 这个数才进曲线
🩸 早期版本用
★曲线/!紧急两个符号。!紧急是个坏设计—— 读起来像告警("79.8°C!紧急"),实际只是「紧急阈值盯的是这一档」, 把状态说成了事件。现在写完整词组并附上阈值与当前余量。
🔴 紧急判据用独立设置
emergency_source,不跟随温度口径。 若它也用「最低核」,实测意味着最高核 110 °C、平均 95 °C 时仍不触发 —— 那不是保护被削弱,是等于关掉。所以两个口径分开,并在菜单里显式显示,不做隐藏行为。 想让它跟随,改emergency_source即可。⚠️ 无论怎么设,macOS/SoC 自身的硬件过热保护始终生效。
验过。SMC 类型是 flt(IEEE-754),手工核对过字节级解码;与第三方 App 独立实现交叉验证;
并用物理反证排除了华氏度(若空闲期的 34 是华氏 = 1.1 °C,比室温低 35 度,不可能)。
完整四项验证见 docs/SMC-RESEARCH.md §3.1。
顺带一个会让人困惑的现象:其它温度 App 可能显示比我们低约 10 °C。 那不是单位错,而是它们把多个键(含风扇区等偏凉的传感器)混合并平滑后显示。 两个数都对,口径不同;混合平滑值低于真实结温,不适合当控制输入。
两层按权限隔离,数据单向流动:
fanpilotd(root LaunchDaemon,C)—— 全机唯一能写 SMC 的进程。flock单实例锁,KeepAlive兜底。FanPilot.app(菜单栏,Swift/AppKit)—— 无任何权限,只读状态 JSON,绝不碰 SMC。
配置文件对用户可写(否则无特权的 App 改不了设置),因此配置被当作不可信输入:所有安全阈值在守护代码里硬夹,不相信文件里写的值。
23×Tp* → max() → EMA(15s) → 分段曲线 → 非对称限幅 → 死区(50 RPM) → 写 SMC
局部热点 瞬时尖峰 温度→转速 升200/降60 抑制无谓写入
每层解决一个具体的抖动来源。非对称限幅是"丝滑"的关键:散热要快(升 200 RPM/s)、安静要稳(降 60 RPM/s)。
配置里的曲线是形状模板。抬高下限时整条曲线按 [下限, 硬件上限] 重新铺开,而不是被夹平:
| 下限 | 45 °C | 55 °C | 65 °C | 75 °C | 85 °C |
|---|---|---|---|---|---|
| 2000 | 2000 | 2609 | 3421 | 4334 | 5349 |
| 3000 | 3000 | 3427 | 3997 | 4637 | 5349 |
| 4000 | 4000 | 4245 | 4572 | 4940 | 5349 |
两个风扇的硬件上限不同(实测 5349 / 5777 RPM),曲线按各自上限分别重铺。
| 场景 | 机制 | 暴露窗口 |
|---|---|---|
正常退出 / SIGTERM / 卸载 |
信号处理器写 F*md=0 交还固件 |
0 |
SIGKILL / 崩溃 / 内核 panic |
launchd KeepAlive 重启,新实例接管 |
~1 s |
| 传感器读取失败 | 立即交还固件,不用陈旧值继续控制 | 0 |
| 系统睡眠 / 合盖 | kIOMessageSystemWillSleep → 写 F*md=0 交还固件;唤醒后重新接管 |
0 |
实测确认 SMC 不会自动回退(停止写入 60 秒后仍保持手动模式),所以 KeepAlive 不是可选项而是安全机制本身。
⭐ 转速下限本身也是失效安全:守护死掉时风扇保持最后转速,最坏情况是卡在 ≥2000 RPM——比出厂固件空闲时的 0 转风量还大,失效方向偏安全。
🩸 睡眠那一条曾经完全不存在,代价是真实发生过的 bug:2026-09-14 合盖睡眠 44 分钟(电池),
风扇按睡前的 3822 RPM 一直转。根因是设计时把「失效」等同于进程死掉,
漏了「进程活着但被冻结」——而冻结比死掉更危险:死掉有 KeepAlive 重启接管,
冻结让一条陈旧指令在硬件寄存器里无限期生效,且正好发生在最不该吹风的时候。
睡眠路径最难测(要真睡一次,且睡着时没有任何进程能记录),所以做了可离线触发的判据:
sudo fanpilotd --selftest-pm # 投喂一条 WillSleep,回读 F*md 是否真变 0make test # 单元 112 + 验收 25 + E2E/回归 45 = 182 项| 层 | 内容 |
|---|---|
tests/unit_logic.c |
93 项 —— fanlogic.h 全部纯逻辑:正常路径 + 边界 + 退化输入(0 / 负 / 除零 / NaN) |
install/verify.sh |
25 项 —— 运行状态、失效安全断言、资源预算、反向断言 |
tests/e2e.sh |
27 项 —— 完整用户路径 + 10 条按真实 bug 编号的回归(R1~R10) |
纯决策逻辑全部剥离到 src/fanlogic.h(零 IOKit 依赖),否则它与 IOKit 缠在一起时只能靠跑真机观察,无法单元测试。
反向断言和正向断言一样重要——只验"能用"会漏掉"不该能用的也能用"。例如:越界转速必须被拒、第二个实例必须启动失败、恶意配置的安全阈值必须被夹住。
docs/SMC-RESEARCH.md |
SMC 逆向调研:键位、可写性判定、传感器筛选方法、全部实测数据 |
docs/PLAN.md |
设计与开发计划:控制算法、安全红线、资源预算、被推翻的设计 |
调研工具(全部只读,不写任何 SMC 键):
make research
./research/smcprobe # 枚举全部 3669 个 SMC 键 + 风扇现状
./research/sensors # 传感器普查 + 可写性属性
./research/sample 70 2 # 时序采样(判传感器响应性用)
./research/bench # 读取开销基准🩸 传感器必须按「时序方差」筛,不能按瞬时值筛。 按数值排最热的两个键是
Tf06=88.81 °C 与Tf16=85.97 °C,看着像热点告警——实测 70 秒 35 个样本标准差 0.000,它们是常量 (很可能是固件跳闸点),不是实时温度。判据是方差,不是瞬时值。
在 MacBook Pro M5 Max (Mac17,6) / macOS 26.6.2 / SIP 启用上开发与实测。
不硬编码任何机型参数:风扇数量取自 FNum,上下限取自 F*Mn/F*Mx,传感器在启动时枚举。
理论上适用于有可写 F*Tg 键的 Apple Silicon Mac,但只在上述机型验证过。
- 下拉面板真截图 ——
assets/menubar-states.png是 App 渲染代码导出的真实输出 (不是模拟图),但下拉面板需要人工截图(Cmd+Shift+4)。不用模拟图冒充截图。 - 曲线拖点编辑 UI —— 现在改曲线要编辑配置文件; 转速下限(6 档)与温度口径(3 档)已可在菜单栏直接点
- 真机合盖复验睡眠交接 —— 代码路径已用
fanpilotd --selftest-pm验证 (投喂kIOMessageSystemWillSleep后回读F*md确认变 0),但真实合盖一次 的端到端确认还没做。判据:合盖 10 分钟后掀开,pmset -g log | grep "Entering Sleep"有条目 + 全程听不到风扇 - 其他机型验证 —— 只在 Mac17,6 (M5 Max) 实测过。
install/precheck.sh可在安装前只读自检,但没有其它机型的真机数据 -
Tf?6是否真是固件跳闸点 ——Tf06=88.81 /Tf16=85.97 恒定不变, 一致性很强但无直接证据(见docs/SMC-RESEARCH.md§6) -
fanctl kv补齐三档温度 —— 现在只报temp_hottest,缺 average / min / smoothed,且它自己只读 5 个键(守护读 23 个)⇒ 两边的temp_hottest口径不同。 机器可读面应改成直接透传守护的状态 JSON,而不是自己再读一遍 SMC
MIT
