使用 pymobiledevice3 抓取 iOS Trace 实战

22 阅读4分钟

本文介绍如何对线上/Release 包(无源码调试、已优化)在真机上抓取实时调用栈,并借助 dSYM 把地址还原成函数与源码行号,用于定位卡顿、耗时逻辑、主线程阻塞等问题。

通过这个机制,我们可以开发用于线上包的 trace 工具,替代只能 debug 时使用的 instruments。


0. pymobiledevice3 简介

pymobiledevice3 是一个开源的跨平台(Windows / macOS / Linux)Python 库与命令行工具,用于和 iPhone / iPad 通信,可看作「命令行版的 Xcode Devices + Instruments」。既提供 CLI,也提供 Python API。直至 2026 年,本项目还在频繁发版和维护。

主要能力:

  • 设备与配对管理(usbmux、lockdown)
  • 文件访问(AFC)、App 安装 / 卸载 / 启动 / 停止
  • 系统日志、崩溃报告、截图、录屏
  • 开发者服务(DVT / Instruments):进程列表、系统监控、oslog、Core Profile 调用栈采样、debugserver
  • iOS 17+ 调试隧道(内核 / 用户态 / --native)
  • 挂载开发者镜像、模拟定位、条件模拟等

本文用到的核心能力是 DVT 的 Core Profile 会话,也就是系统 tailspin / kdebug 的采样能力,可按进程 / 线程实时抓取调用栈——无需改包、无需越狱,因此能用于线上包。

安装与资料:

# 推荐使用 pipx
pipx install pymobiledevice3
# 也可以使用 pip
pip3 install pymobiledevice3

1. 操作流程

  1. 连接设备,开启开发者调试服务,向设备订阅目标进程的调用栈采样。
  2. 采样输出的是运行时地址,默认不带符号信息。
  3. 要把地址还原成函数与代码行,需要两项信息:地址属于哪个镜像,以及该镜像本次运行的加载基址。
    • App 地址空间:加载基址每次启动都会变,需要另外抓取一份进程快照取得,再配合 App 的符号文件还原。
    • 系统代码:用系统自带符号还原即可。
  4. 目标堆栈:过滤到目标进程后,调用栈里出现 App 地址空间 的那些栈。

2. 前置条件

项要求
macOS + Xcode提供 atos、devicectl、以及 iOS DeviceSupport 系统符号
pymobiledevice3pipx install pymobiledevice3
设备iOS 17+ 需建隧道;本机用 --native(借用 remoted 建好的隧道)无需 root
目标 App已安装且能启动(线上包/Release 包,通常经企业/开发签名或已信任)
符号与线上包 UUID 完全一致 的 dSYM
网络/连线USB 或同网段,设备已 trusted

3. 操作流程

Step 0 确认设备与 UDID

pymobiledevice3 usbmux list

Step 1 确认目标进程名 / PID

# 已知 bundle id 时
pymobiledevice3 developer dvt process-id-for-bundle-id <BUNDLE_ID> \
  --native --udid <UDID>

# 不知道 bundle id 时,先列出所有进程(含进程名/pid)再找
pymobiledevice3 developer dvt proclist --native --udid <UDID> | grep -i <关键字>

Step 2 抓取实时调用栈

pymobiledevice3 developer dvt core-profile-session callstacks-live \
  --process <PROCESS_NAME> --count <N> --show-tid \
  --native --udid <UDID> | tee capture.txt

参数说明:

参数作用
--process只看该进程(强烈建议加,否则全系统栈刷屏)
--count采样够 N 条就退出;不写会一直刷
--tid只看某个线程;定位主线程时可用
--show-tid打印线程 id,便于区分主线程/子线程

Step 3 区分 App 帧与系统帧

看地址区间即可:

  • 系统帧:dyld shared cache 段,形如 0x18xxxxxxx、0x19xxxxxxx、0x23xxxxxxx、0x24xxxxxxx。
  • App 帧:主二进制单独加载的段,形如 0x100xxxxxx / 0x104xxxxxx(每次启动不同)。

一条栈里只要包含 App 帧,就是命中了目标 App 的业务代码。典型长这样(未符号化):

2026-09-13 17:07:30.067880  5767165 TraceDemo(90121)
0x0000000100bbc95c        ← App 帧
 0x0000000100bbc21c       ← App 帧
  0x000000018e90561c      ← 系统帧(Foundation)
   0x0000000191577960     ← 系统帧(CoreFoundation)
    ...
             0x0000000100bbc36c  ← App 帧(main)
              0x000000018e145c1c  ← 系统帧(dyld)

Step 4 取本次运行的 App 加载地址

每次启动都会变,抓到栈后必须重新取一次:

UDID=<UDID>

pymobiledevice3 developer dvt core-profile-session stackshot \
  --native --udid $UDID > stackshot.txt 2>/dev/null

LOAD=$(python3 -c "import json;d=json.load(open('stackshot.txt'));print(hex(next(i['imageLoadAddress'] for t in d['task_snapshots'].values() if t.get('task_snapshot',{}).get('ts_p_comm')=='<PROCESS_NAME>' for i in t['dyld_load_info'])))")
echo $LOAD

dyld_load_info 是数组,第一条就是主可执行文件,其 imageUUID 应与你的 dSYM 一致;后面还会有进程加载的系统库(如 libobjc-trampolines.dylib),忽略即可。

Step 5 符号化 App 自身帧(dSYM + atos)

DWARF=/path/to/App.app.dSYM/Contents/Resources/DWARF/<二进制名>

# 挑出 capture.txt 里的 App 段地址(用上一步的 LOAD 判断区间)
atos -arch arm64 -o "$DWARF" -l $LOAD \
  0x100bbc95c 0x100bbc21c 0x100bbc36c 0x100bbc974

输出即函数名 + 文件:行号,例如:

specialized static TraceDemoHeavyWork.run()      TraceDemoHeavyWork.swift:9
thunk for @escaping ... (NSTimer) -> ()          <compiler-generated>
main                                             AppDelegate.swift:0

可先 xcrun dwarfdump --uuid <dSYM> 与设备镜像 UUID 对比确认。

Step 6 符号化系统帧(lldb + DeviceSupport 符号)

系统库在 shared cache 里,用 lldb 让它自动加载 ~/Library/Developer/Xcode/iOS DeviceSupport/<设备>/Symbols 来符号化:

# 把要查的地址写进命令文件
cat > /tmp/lldb_cmds.txt <<'EOF'
image lookup -a 0x18e90561c
image lookup -a 0x191577960
process detach
quit
EOF

# 非交互环境必须用 script 包一层伪终端
script -q /dev/null pymobiledevice3 developer debugserver lldb <BUNDLE_ID> \
  --no-launch \
  -c "command source /tmp/lldb_cmds.txt" --native

输出 Summary: 即符号,例如:

Foundation`__NSFireTimer + 96
CoreFoundation`__CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ + 32
CoreFoundation`__CFRunLoopDoTimer + 980
UIKitCore`UIApplicationMain + 332

Step 7 还原整个堆栈

以演示的耗时逻辑为例,符号化后应看到一条从系统入口到业务函数的完整链:

业务函数(命中)                              xxx.swift:9
闭包 / 回调
Foundation     __NSFireTimer + 96
CoreFoundation __CFRUNLOOP_IS_CALLING_OUT_TO_A_TIMER_CALLBACK_FUNCTION__ + 32
CoreFoundation __CFRunLoopDoTimer + 980
CoreFoundation __CFRunLoopDoTimers + 280
CoreFoundation __CFRunLoopRun + 1816
CoreFoundation _CFRunLoopRunSpecificWithOptions + 532
GraphicsServices GSEventRunModal + 120
UIKitCore      -[UIApplication _run] + 796
UIKitCore      UIApplicationMain + 332
App            main                              AppDelegate.swift:0
dyld           start + 6928

4. 演示工程

TraceDemo 仅用于验证流程,关键点:

  • 启动后 Timer 每秒触发一次,回调里忙等 1 秒(保证主线程长时间停留在业务函数,便于采样)。
  • Release 构建保留 dSYM:DEBUG_INFORMATION_FORMAT = dwarf-with-dsym。

关键源码:

// AppDelegate.swift:起 Timer
timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
    TraceDemoHeavyWork.run()
}
RunLoop.main.add(timer!, forMode: .common)

// TraceDemoHeavyWork.swift:忙等 1 秒
static func run() {
    let deadline = CFAbsoluteTimeGetCurrent() + 1.0
    while CFAbsoluteTimeGetCurrent() < deadline {
        for i in 0..<10_000 { _ = i }
    }
}

构建 / 安装:

xcodebuild archive -project TraceDemo.xcodeproj -scheme TraceDemo \
  -configuration Release -destination 'generic/platform=iOS' \
  -archivePath build/TraceDemo.xcarchive -allowProvisioningUpdates

xcodebuild -exportArchive -archivePath build/TraceDemo.xcarchive \
  -exportPath build/export -exportOptionsPlist ExportOptions.plist -allowProvisioningUpdates

xcrun devicectl device install app --device <ID> \
  build/TraceDemo.xcarchive/Products/Applications/TraceDemo.app
xcrun devicectl device process launch --device <ID> com.demo.tracedemo

5. 命令速查

# 列设备 / 进程
pymobiledevice3 usbmux list
pymobiledevice3 developer dvt process-id-for-bundle-id <BUNDLE_ID> --native --udid <UDID>
pymobiledevice3 developer dvt proclist --native --udid <UDID> | grep -i <关键字>

# 抓栈
pymobiledevice3 developer dvt core-profile-session callstacks-live \
  --process <PROCESS_NAME> --count 5 --show-tid --native --udid <UDID> | tee capture.txt

# 取加载地址
pymobiledevice3 developer dvt core-profile-session stackshot \
  --native --udid <UDID> > stackshot.txt
LOAD=$(python3 -c "import json;d=json.load(open('stackshot.txt'));print(hex(next(i['imageLoadAddress'] for t in d['task_snapshots'].values() if t.get('task_snapshot',{}).get('ts_p_comm')=='<PROCESS_NAME>' for i in t['dyld_load_info'])))")

# App 帧符号化
atos -arch arm64 -o <App.dSYM>/Contents/Resources/DWARF/<二进制名> -l $LOAD <addr...>

# 系统帧符号化(lldb)
script -q /dev/null pymobiledevice3 developer debugserver lldb <BUNDLE_ID> \
  --no-launch -c "command source /tmp/lldb_cmds.txt" --native