FanControl 源碼解析:讀懂 SMC.swift(Apple SMC 通信)
FanControl 是一個 macOS 菜單欄風扇控制工具:菜單欄顯示每把風扇的即時轉速,拖滑塊可以把它釘在某個百分比,也可以一鍵還給系統自動控制。這些功能最後都匯到同一個文件上——SMC.swift,大約 500 行,是應用與 Apple SMC 之間唯一的通道。
因為要讓同一套代碼在 Intel 和 M 系列上都跑通,這份文件我前後讀了兩遍,卡住的地方基本都在裡面,趁現在記下來。
SMC 和它的四字符 key
每台 Mac 上都有一顆 SMC(System Management Controller),溫度傳感器、風扇、電源、鍵盤背光這類瑣碎的硬件控制都歸它管。macOS 透過 IOKit 把一個叫 AppleSMC 的服務暴露給用戶態,第三方應用因此可以用 IOConnectCallStructMethod 對它做結構化的讀寫。
它的數據模型相當老派:一切用四個字符的 key 索引。
| Key | 含義 |
|---|---|
FNum |
風扇數量 |
F0Ac / F1Ac |
風扇 0/1 的實際轉速(RPM) |
F0Mn / F0Mx |
風扇 0 的最小 / 最大轉速 |
F0Tg / F1Tg |
風扇 0/1 的目標轉速(可寫) |
F0Md / F0md |
風扇手動模式標誌,大小寫依平台而異 |
FS! |
風扇模式位域,Intel 特有 |
Ftst |
風扇測試模式,Apple Silicon 解鎖用 |
TC0P、Tp01 等 |
CPU 及各部件溫度,本文件沒用到,協議相同 |
每個 key 還自帶一個四字符的類型碼(sp78、flt 、fpe2、ui16 之類),說明原始字節該怎麼換算成能看的數字。所以讀這份代碼其實一直在回答兩個問題:該取哪個 key,以及取回來的字節怎麼解釋。
代碼的三層
SMC 是公開類,實現了 SMCService 協議(定義在 SMCService.swift,測試時好塞替身)。類裡的東西大體分三層:
- 對外 API:
getValue/getStringValue/setFanMode/setFanSpeed/resetFanControl/fanModeKey/close - Apple Silicon 專屬:
unlockFanControl/writeWithRetry/retryModeWrite,被#if arch(arm64)圈住 - 底層原語:
read/write/call,把SMCKeyData_t和IOConnectCallStructMethod包起來
外部依賴三個文件。SMCTypes.swift 定義 SMCKeyData_t(與內核共享的 C 結構體,32 字節載荷)、SMCVal_t(key + 元數據 + 字節緩衝)、SMCDataType,以及命令序號 SMCKeys。SMCExtensions.swift 提供 FourCharCode 與字符串互轉、UInt16/UInt32 從字節組初始化、Int(fromFPE2:)、Float(bytes) 與 Float.bytes。Models/FanMode.swift 是 automatic = 0、forced = 1、auto3 = 3 的枚舉。
建連接
nonisolated public init() {
let matchingDictionary = IOServiceMatching("AppleSMC")
result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDictionary, &iterator)
...
device = IOIteratorNext(iterator)
IOObjectRelease(iterator)
...
result = IOServiceOpen(device, mach_task_self_, 0, &conn)
IOObjectRelease(device)
}
IOKit 的標准三步:IOServiceMatching("AppleSMC") 構造匹配字典,IOServiceGetMatchingServices 拿迭代器再用 IOIteratorNext 取出第一個設備對象,最後 IOServiceOpen 建立用戶態到內核的連接,得到句柄 conn: io_connect_t。
每一步都檢查 kern_return_t,失敗就 print 出來然後繼續往下走,conn 保持在 0。之後所有調用都會在 conn == 0 那裡撞上 kIOReturnNotOpen,等於一個很粗糙的降級。deinit 自動調 close() 做 IOServiceClose,連接週期綁在對象上。
init 上的 nonisolated 值得留意。Swift 6 的默認主隔離會把類實例鎖在主 actor,但重試邏輯裡有大批 usleep 阻塞調用,說明這套控制流程本來就打算在後台線程跑。
getValue:先防全零,再看類型
public func getValue(_ key: String) -> Double? {
var val: SMCVal_t = SMCVal_t(key)
result = read(&val) // 先查 keyInfo,再取字節
if val.dataSize > 0 {
// 全零保護
if val.bytes.first(where: { $0 != 0 }) == nil &&
val.key != "FS! " && !val.key.hasSuffix("Md") && !val.key.hasSuffix("md") {
return nil
}
switch val.dataType { ... } // 按數據類型解碼
}
}
全零不一定是壞值
對應硬件不存在時,SMC 往往回一串 0。把這種 0 當真讀出來,界面就會顯示一排詭異的「0 RPM」,所以這裡先做一次全零判斷。但兩種 key 的 0 是好端端的合法值:FS! 回 0 表示所有風扇都在自動,F*Md 回 0 表示該風扇處於自動模式。判別條件因此寫成排除式——不是 FS! 、名字不以 Md/md 結尾的 key,全零才算無效。
類型碼決定算術
val.dataType 是四字符類型碼,switch 按它解碼:
| 類型碼 | 含義 | 解碼 |
|---|---|---|
ui8 / ui16 / ui32 |
無符號整數 | 按 1/2/4 字節拼成大端整數 |
flt |
32 位 IEEE Float | Float(bytes),字節重解釋 |
fpe2 |
16 位定點,2 位小數 | (b0 << 6) + (b1 >> 2),即 ÷4 |
spXX |
16 位定點數族 | 高字節是整數部分,低字節是小數部分 |
spXX 的兩位是十六進制,各自表示整數位和小數位的個數,換算式是 (b0 * 256 + b1) / 2^(小數位數)。sp78 是 8 位整數加 8 位小數,除以 256,溫度計多數用它(TC0P);sp5a 的除數是 1024;spf0 沒有小數位,除以 1,退化成整數。
對照代碼裡的除數序列 16384、4096、2048、1024、512、256、128、64、32、16、1,正好是 2^14 到 2^0,一一對應 sp1e 到 spf0。整族共用同一套「乘 256 加低位再除以 2 的冪」的寫法,只差那個除數,所以 switch 看著長但沒有新資訊。
getStringValue 只處理 {fds:從偏移 4 起連續取 12 個 ASCII 字符,兩端去空白。這類 key 一般是固件版本或傳感器名稱。
控制風扇:兩套協議並存
Intel 和 Apple Silicon 的風扇控制根本是兩回事,代碼用 #if arch(arm64) 切開。
先確認是 Md 還是 md
if _fanModeKeyIsLower == nil {
var probe = SMCVal_t("F0md")
_fanModeKeyIsLower = read(&probe) == kIOReturnSuccess && probe.dataSize > 0
}
return _fanModeKeyIsLower! ? "F\(id)md" : "F\(id)Md"
手動模式 key 在 Intel 上是傳統的大寫 F0Md,部分 Apple Silicon 機型的固件換成了小寫 F0md。與其按架構硬編碼,不如試探:讀一次 F0md,讀得通就用小寫,結果緩存在 _fanModeKeyIsLower,不重複探測。
Intel:寫 F*Md,順帶維護 FS!
Intel 機型上每把風扇的模式由 F[id]Md(ui8,0 自動 1 手動)表達;與此同時,整機的自動/手動組合還由一個 FS! 位域管著。兩把風扇的組合湊出四個值:0 是全自動,1 是風扇 0 手動、1 自動,2 反過來,3 是兩把都手動。
setFanMode 的 Intel 分支先寫 F[id]Md = mode.rawValue,再讀當前的 FS! ,用一張 fansMode × id × mode → newMode 的八行查表算出新位域,值變了才寫回。那張表沒有訣竅可言,就是兩把風扇各兩種模式的狀態機窮舉展開。
Apple Silicon:不解鎖就寫不進去
M 系列上直接寫 F[id]Tg 多半會被 SMC 拒絕,得先把手動模式位置成 1。unlockFanControl 的順序是:
modeVal.bytes[0] = 1 // F[id]md 的第 0 字節置 1
if write(modeVal) 成功 { return true }
// 否則走 Ftst 測試模式路線
if ftstVal.bytes[0] == 1 { retryModeWrite(fanId:, maxAttempts: 20) }
ftstVal.bytes[0] = 1 // 打開風扇測試模式
writeWithRetry(ftstVal, maxAttempts: 100)
usleep(3_000_000) // 等 3 秒讓固件響應
return retryModeWrite(fanId:, maxAttempts: 300)
有兩件事容易忽略。Ftst(fan test)置 1 之後 SMC 才允許轉速被固定,事情辦完必須置回 0,不然機器一直停在測試態。另外寫模式位之後固件需要時間穩定,retryModeWrite 和 writeWithRetry 因此都是 usleep 加有限次重試:默認 10 次、間隔 50ms,解鎖路徑放到 300 次、間隔 100ms。writeWithRetry 成功即返回,失敗會把每條的 key 和錯誤碼(十六進制)印出來。第一次在真機上调這段時,這些 print 是唯一能看見內部發生了什麼的地方。
setFanSpeed:夾緊,再按類型編碼
guard let maxSpeed = self.getValue("F\(id)Mx") else { return false }
let targetSpeed = min(speed, Int(maxSpeed)) // 不允許超過硬件最大轉速
上限從 F[id]Mx 讀(SP 定點數,經 getValue 已經換算過)。接著讀 F[id]Tg 看它現在的數據類型,按類型編碼再寫回。
flt 直接把 Float(targetSpeed) 的四個字節塞進 bytes[0...3]。fpe2 是 16 位定點、實際值等於原始值 ÷ 4,所以要寫 targetSpeed * 4,再拆成兩個字節:高字節 targetSpeed >> 6,低字節 (targetSpeed << 2) & 0xFF。代碼裡低字節寫成了 (targetSpeed << 2) ^ ((targetSpeed >> 6) << 8)——<< 2 溢出到高字節的部分恰好等於 (targetSpeed >> 6) << 8,兩者異或就把高位抹掉了,結果和 & 0xFF 一樣,只是讀者得自己推一遍。
Apple Silicon 分支在寫目標轉速前會再檢查一次 F[id]md 的第 0 字節,不等 1 就補一次 unlockFanControl,防止模式位在中途被人改回去、寫入卻靜默失敗。
全部還給自動
guard let count = getValue("FNum") else { return false }
for i in 0..<Int(count) { setFanMode(i, mode: .automatic) }
讀 FNum 拿風扇數量,逐把設回 automatic。Apple Silicon 上進入前先把 Ftst 置 1、結束後置回 0(兩邊都是 writeWithRetry、各 100 次嘗試),保證恢復全程寫得動,離開時也不留在測試模式。菜單欄上那個 Reset All 按鈕進的就是這裡。
最底層:read / write / call
所有操作最後都收斂到一個函數:
private func call(_ index: UInt8, input: inout SMCKeyData_t, output: inout SMCKeyData_t) -> kern_return_t {
if conn == 0 { return kIOReturnNotOpen }
return IOConnectCallStructMethod(conn, UInt32(index), &input, inputSize, &output, &outputSize)
}
SMCKeyData_t 是與內核共享的 C 結構體,有 key、keyInfo、bytes[32]、result 等字段,Swift 端用值類型按同樣的內存佈局近似描述。SMCKeys 枚舉給出命令序號:kernelIndex = 2、readKeyInfo = 9、readBytes = 5、writeBytes = 6。
read 要走兩次 call。先 KERNEL_INDEX + READ_KEYINFO 問內核這個 key 的類型和長度,拿到 dataSize 與 dataType;再 KERNEL_INDEX + READ_BYTES 取回真正的字節,memcpy 進 SMCVal_t.bytes。
write 只有一次 WRITE_BYTES,32 字節載荷整包拷進 input.bytes,回包檢查 output.result != 0x00,非零即失敗,返回 kIOReturnError。既然是整包提交,dataSize 和字節佈局就沒人代管,前面对 flt /fpe2 的手工編碼正是這個緣故。
FourCharCode(fromString:)(在 SMCExtensions.swift)把 "Tg " 這類 key 摺進一個 UInt32:sum << 8 | char,大端打包。這才是 SMC key 在底層的樣子。