DJI Assistant 2 在新版 macOS 上一直轉圈:兩個睡了好幾年的 bug

我手上有一台 Phantom 4 Pro,是拿來考無人機專業操作證的考照機。二手 P4P 是很多人的考照機首選,原因我在 drone.sean.cat 的機型頁整理過。

二手機到手,總有幾件事要用電腦做:視覺系統校正、韌體更新、Fly Safe 禁航區資料庫更新。DJI 給的工具是 DJI Assistant 2 For Phantom。這支程式的最後一版是 2020 年的 2.0.10.4,之後 DJI 就沒再更新了。

在 macOS 27 上,它看起來能用。可以登入、認得飛機,畫面也正常,可是只要按下需要跟飛機溝通的按鈕,就一直轉圈。

徵兆

符合以下幾點,大概就是這篇要講的問題:

  • 用 USB 接上飛機後,Calibrate Now 一直 loading,韌體頁或 Fly Safe 資料庫更新也沒有反應。
  • ~/Library/Logs/DiagnosticReports/ 裡有 DJIService-<日期>.ips 這種 crash 報告,時間點就在飛機接上後幾秒。
  • /Applications/DJI Assistant 2 For Phantom.app/Contents/DJIApp/ui_ass2.log 一直重複 ws://localhost:19870/general2connection error。
  • macOS 是 26 以後的版本,Intel 跟 Apple Silicon 都一樣。

畫面上的 Assistant 其實是兩支程式:DJIBrowser 負責 UI,DJIService 負責跟飛機溝通,兩邊透過 localhost:19870 的 WebSocket 講話。DJIService 一死,UI 就只能一直等下去。

先修好再說

只想修好的人,看這一段就夠了。以下只適用 DJI Assistant 2 For Phantom 2.0.10.4,其他版本腳本會拒絕執行。

  1. 把 DJI Assistant 完全關掉。
  2. 下載修復腳本,執行前建議先打開看過內容。
  3. 在終端機執行:
bash ~/Downloads/dji-assistant2-mac-fix.sh status
bash ~/Downloads/dji-assistant2-mac-fix.sh apply
  1. 如果飛機開著,先關機再開機,再打開 DJI Assistant。

腳本只用 macOS 內建的 shasum、od、dd,做的事是把 Assistant 內附的 QtCore 改 2 個 byte。改之前會先比對 sha256,跟已知的原版不同就不動;原檔會備份到 ~/Library/Application Support/dji-assistant2-mac-fix/。想還原就執行:

bash ~/Downloads/dji-assistant2-mac-fix.sh restore

我沒有附上改好的 DJI 檔案。腳本只會在你自己的電腦上,修改你自己安裝的那一份。

視覺校正還有兩個條件

服務不再崩潰之後,Calibrate Now 還有另外兩關,跟上面的 bug 無關:

  • 只接一台螢幕。 接兩台時,它會把兩台的解析度和物理尺寸混在一起算,算出來的校正圖案大小是 0,校正視窗一片空白。
  • 解析度要設成螢幕原生、而且不縮放。 校正程式要求「目前解析度」等於「最佳解析度」,不然 log 會出現 current resolution and best resolution doesn't match,畫面就一直跳出輸入螢幕尺寸的框。以 15 吋 MacBook Air 為例,要切到 2880 × 1800(1x),系統設定預設不會列出這個選項,要這樣叫出來(以英文介面為例):
  1. 系統設定 → 顯示器,按右下角的 Advanced…,打開第一個選項 Show resolutions as list。
  2. 回到解析度列表,打開 Show all resolutions。
  3. 選 2880 × 1800。字會變得很小,校正完再切回來。

另外,每次重開 DJI Assistant 都要順便把飛機關機再開機,不然校正程式打不開飛機的視覺鏡頭,log 會一直重試 open camera fail:311。

它為什麼會崩潰

第一個 bug:四個同名的類別

DJIService 本身不認得任何機種,它會載入 Contents/MacOS/DJIServices/ 底下的四個 plugin:飛機(Uav)、遙控器(Rc、Rc2),還有一個推測是基地台的 Bs。四個 plugin 各自寫了一個叫 DJIUpgradeView 的類別,處理韌體升級,名字一樣,內容完全不同。DJIActivateView 跟 DJIFtpDataExportView 也是這樣。

C++ 有一條「同一個名字只能有一種定義」的規則(One Definition Rule),這已經違反了。後果是:每個 plugin 都有一個 CreateWebSocketHandler<DJIUpgradeView>,符號名稱一模一樣,而且是對外公開的 weak 符號。

macOS 的動態連結器 dyld 遇到同名的 weak 符號,會合併成一份,先載入的那份勝出。DJIService 是照檔名排序載入 plugin 的,順序是 Bs → Rc2 → Rc → Uav,Bs 排第一。

結果就是,飛機的升級通道建出來的,是 Bs 版本的 DJIUpgradeView。它拿飛機的連線去問「這是哪台基地台」,得到 null,接著從 null 讀資料:

DJIUpgradeView::DJIUpgradeView(QWebSocket*, QObject*)   // libDJIBsService.dylib
  DJIDeviceDll::GetDevice(websocket)   → 回傳 null
  movq 0x28(%rax), %rbx                → rax = 0,讀 0x28,SIGSEGV

crash 報告的暫存器剛好對上:rax = 0、出錯位址 0x28,而 r12 指著 SingletonEx<DJIBsDeviceDll>::_instance。

第二個 bug:Qt 5.6 看不懂新的版本號

照理說這個雷不會炸。用 RTLD_LOCAL 載入的動態函式庫,dyld 不會拿它去做 weak 合併。舊版 dyld 的原始碼寫得很直接:

if image is loaded with RTLD_LOCAL, then its symbols’ visibility is reduced and it can’t coalesce with other images

(dyld-852.2 ImageLoaderMachO.cpp)新版 dyld4 也是 weak coalescing ignores hidden images。

DJIService 用 Qt 的 QLibrary 載入 plugin,而 Qt 預設就會帶 RTLD_LOCAL。只是 Qt 5.6.0 多加了一個條件(qlibrary_unix.cpp):

#if defined(Q_OS_MAC)
        if (QSysInfo::MacintoshVersion >= QSysInfo::MV_10_4)
#endif
        dlFlags |= RTLD_LOCAL;

而 MacintoshVersion 是這樣換算的(qsysinfo.h):

#define Q_MV_OSX(major, minor) (major == 10 ? minor + 2 : (major == 9 ? 1 : 0))

2016 年的 Qt 沒想到 macOS 會有不是 10 開頭的一天。主版本號只要不是 10,就算成 0,0 比 10.4 小,RTLD_LOCAL 就不加了。

以前為什麼沒事

macOS 11 改成 11 開頭的時候,Apple 留了一手:用舊 SDK 編出來的 app 問系統版本,會拿到 10.16。Apple 開發者論壇上有人回報,在 macOS 12.3.1 用 10.14 SDK 編的 app 拿到的是 10.16,採納的答案說這是刻意的相容機制(thread 713444)。所以 Qt 5.6 一直以為自己跑在 10.16 上,RTLD_LOCAL 照加,DJI 的雷也就一直沒炸。

到了 macOS 27,這個相容機制不見了。我編了一支小程式,標成用 10.11 SDK 編譯,在 macOS 27 上問 NSProcessInfo 系統版本,拿到的是 27.0.0,設定 SYSTEM_VERSION_COMPAT=1 也一樣。

用 dyld 的除錯環境變數可以直接看到差別:

$ DYLD_PRINT_APIS=1 ./DJIService
dlopen("DJIServices/libDJIBsService.dylib", 0x00000001)    # 只有 RTLD_LAZY
...
$ DYLD_PRINT_BINDINGS=1 ./DJIService
<libDJIUavService.dylib/obind#13> -> ... (libDJIBsService.dylib/...)

四個 plugin 都以 global 的方式載入,後面三個的 weak 符號全被綁到 Bs 身上。光是 Uav 跟 Bs 之間,被合併的 weak 符號就有 678 個。大多數是內容完全相同的 template,合併了也沒事,真正會崩的是那三個同名的類別。

修法

既然問題出在 Qt 少加了 RTLD_LOCAL,最根本的修法就是讓它加回去。QtCore 裡那段判斷編譯出來是:

cmpl $0x5, (QSysInfo::MacintoshVersion)
jle  <跳過 RTLD_LOCAL>
orl  $0x4, %r12d                 # RTLD_LOCAL

把 jle(7e 0a)換成兩個 nop(90 90),Qt 就一律帶上 RTLD_LOCAL。改完再看一次,dlopen 變成 0x00000005,四個 plugin 都綁回自己的那一份,這也是 Qt 對 10.4 以後的 macOS 本來就該有的行為。接上飛機再試,升級、啟用、資料匯出三條通道都不再崩潰。修復腳本做的就是這件事。

誰會遇到

  • macOS 10.15 以前:不會。 主版本號是 10,Qt 會加上 RTLD_LOCAL(從原始碼推論)。
  • macOS 11–12:不會。 舊 SDK 的 app 拿到的是 10.16(Apple 論壇回報)。
  • macOS 13–15:不確定。 沒有找到證據。
  • macOS 26:很可能會。 我的 Mac 在 26 時就出現過同樣的症狀,但沒留下 crash 報告。
  • macOS 27:會。 實測。
  • Windows:不會。 Windows 的 DLL 匯入是綁定到指定的 DLL,沒有跨 DLL 的 weak 合併(推論,沒有實測)。

跟 CPU 無關,Intel Mac 一樣會遇到。DJI 的 Windows 版應該是同一套程式碼,違規還是在,只是 Windows 的機制不會讓它出事。

修好之後

服務不再崩潰後,我順利做完了視覺校正,Fly Safe 資料庫也從 Mac 更新成功。在這之前,帶螢幕的遙控器每次開機都跳出「Precise Fly Safe Database Update」,照指示用 OTG 線接上飛機,還是更新不成功;用手機接無螢幕的遙控器也失敗過。

只剩模擬器還是打不開。DJISimulatorUI 是 DJI 程式裡唯一加了殼的,一啟動,解殼的程式就自己呼叫 abort,這次我沒有再追下去。想在家練習的話,drone.sean.cat 整理了其他可以練術科的模擬器。

給 AI agent 的 prompt

如果你的版本跟我不同,腳本會拒絕執行。這時可以把下面這段交給 Claude Code 之類的 AI agent,讓它照同樣的思路處理。它會需要 Xcode Command Line Tools(otool、nm)。

我的 Mac 上的 DJI Assistant 2 一接上飛機就卡在 loading,
懷疑是後台的 DJIService 崩潰了。請依照下面的步驟診斷與修復。
任何修改檔案的動作,都先跟我確認,並先備份、記錄 sha256。

1. 用 pgrep -x DJIService 確認它還在不在,
   再讀 ~/Library/Logs/DiagnosticReports/ 裡最新的 DJIService-*.ips。
   看崩潰的 thread 是不是「某個 libDJI*Service.dylib 的 std::function
   → 另一個 libDJI*Service.dylib 的 CreateWebSocketHandler<...>」。
   frame 0 沒有名字時,用 threadState.rip 減掉 usedImages 裡的 base 來定位。
2. 先關掉 DJI Assistant,在 App 的 Contents/MacOS 目錄下
   用 DYLD_PRINT_APIS=1 執行 ./DJIService 大約 10 秒,
   看 dlopen DJIServices/*.dylib 帶的 mode。
   如果沒有 RTLD_LOCAL(0x4),就是 Qt 5.x 的
   QSysInfo::MacintoshVersion 判斷失誤。
3. 在 Contents/Frameworks/QtCore.framework 裡的 QtCore,
   找到 QLibraryPrivate::load_sys 裡比較 QSysInfo::MacintoshVersion 的指令
   (cmpl 之後接一個 jle,跳過 orl $0x4),
   只把那個 jle 改成等長的 nop,其他檔案都不要動。
4. 再跑一次步驟 2,確認 mode 帶有 0x4。
   接著把飛機關機再開機、開 DJI Assistant,
   從 Contents/DJIApp/ui_ass2.log 找出飛機的 FILE 序號,
   用 node 的 WebSocket 直接連
   ws://localhost:19870/controller/upgrade/<序號>,
   確認連線不會斷、DJIService 也沒有崩潰。
5. 告訴我怎麼還原。

不要下載任何 DJI 以外的二進位檔,要安裝工具前先問我。

DJI 停止更新一支程式,不代表它從此不會壞。它可能只是在等某一版 macOS,把當年的版本號假設拿走。