本文介绍如何对线上/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. 操作流程
- 连接设备,开启开发者调试服务,向设备订阅目标进程的调用栈采样。
- 采样输出的是运行时地址,默认不带符号信息。
- 要把地址还原成函数与代码行,需要两项信息:地址属于哪个镜像,以及该镜像本次运行的加载基址。
- App 地址空间:加载基址每次启动都会变,需要另外抓取一份进程快照取得,再配合 App 的符号文件还原。
- 系统代码:用系统自带符号还原即可。
- 目标堆栈:过滤到目标进程后,调用栈里出现 App 地址空间 的那些栈。
2. 前置条件
| 项 | 要求 |
|---|---|
| macOS + Xcode | 提供 atos、devicectl、以及 iOS DeviceSupport 系统符号 |
| pymobiledevice3 | pipx 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