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 解鎖用
TC0PTp01 CPU 及各部件溫度,本文件沒用到,協議相同

每個 key 還自帶一個四字符的類型碼(sp78flt fpe2ui16 之類),說明原始字節該怎麼換算成能看的數字。所以讀這份代碼其實一直在回答兩個問題:該取哪個 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_tIOConnectCallStructMethod 包起來

外部依賴三個文件。SMCTypes.swift 定義 SMCKeyData_t(與內核共享的 C 結構體,32 字節載荷)、SMCVal_t(key + 元數據 + 字節緩衝)、SMCDataType,以及命令序號 SMCKeysSMCExtensions.swift 提供 FourCharCode 與字符串互轉、UInt16/UInt32 從字節組初始化、Int(fromFPE2:)Float(bytes)Float.bytesModels/FanMode.swiftautomatic = 0forced = 1auto3 = 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,一一對應 sp1espf0。整族共用同一套「乘 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]Mdui8,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,不然機器一直停在測試態。另外寫模式位之後固件需要時間穩定,retryModeWritewriteWithRetry 因此都是 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 結構體,有 keykeyInfobytes[32]result 等字段,Swift 端用值類型按同樣的內存佈局近似描述。SMCKeys 枚舉給出命令序號:kernelIndex = 2readKeyInfo = 9readBytes = 5writeBytes = 6

read 要走兩次 call。先 KERNEL_INDEX + READ_KEYINFO 問內核這個 key 的類型和長度,拿到 dataSizedataType;再 KERNEL_INDEX + READ_BYTES 取回真正的字節,memcpySMCVal_t.bytes

write 只有一次 WRITE_BYTES,32 字節載荷整包拷進 input.bytes,回包檢查 output.result != 0x00,非零即失敗,返回 kIOReturnError。既然是整包提交,dataSize 和字節佈局就沒人代管,前面对 flt /fpe2 的手工編碼正是這個緣故。

FourCharCode(fromString:)(在 SMCExtensions.swift)把 "Tg " 這類 key 摺進一個 UInt32sum << 8 | char,大端打包。這才是 SMC key 在底層的樣子。