lldb工具使用
背景介绍
有时会在native程序中遇到预期外的错误,此时可以通过加log的方式一步步定位,这样逐步缩小范围,但是需要重新编译,并且无法知道当前以及之后正在执行什么指令,因此需要学会使用lldb进行native调试。由于代码一般在编译时进行了优化,去除了符号,因此需要会一些汇编指令的作用。
简单说明
关于交叉编译
首先需要说明的是,llvm本身就是支持跨平台编译的编译器,不需要使用其他的编译器(虽然它们开箱即用,但是Android目前用的是llvm,不是gcc了)。下面说明一下llvm进行交叉编译的方法。
工具链
llvm进行交叉编译的关键就是工具链,在编译的时候通过-target指定具体使用的工具链。
构建可以在手机上运行的程序:
clang++ –static -g -O0 -target aarch64-linux-gnu [源文件]
关于远程调试
由于lldb不能直接在AArch64架构上使用,所以需要借助中间工具lldb-server(从Android Studio安装NDK后获取)。
位置:/home/ovea/Android/Sdk/ndk/24.0.8215888/toolchains/llvm/prebuilt/linux-x86_64/lib64/clang/14.0.1/lib/linux/aarch64
在手机上运行lldb-server
adb root
adb push lldb-server /cache/
adb shell
\# cd cache
\# chmod +x lldb-server
\# ./lldb-server p --server --listen unix-abstract:///cache/debug.sock
使用lldb进行远程调试
lldb 查看可以远程链接的平台 (lldb) platform list
output:
Available platforms:
host: Local Linux user platform plug-in.
remote-linux: Remote Linux user platform plug-in.
remote-freebsd: Remote FreeBSD user platform plug-in.
remote-netbsd: Remote NetBSD user platform plug-in.
remote-openbsd: Remote OpenBSD user platform plug-in.
remote-ios: Remote iOS platform plug-in.
remote-macosx: Remote Mac OS X user platform plug-in.
host: Local Mac OS X user platform plug-in.
remote-windows: Remote Windows user platform plug-in.
remote-gdb-server: A platform that uses the GDB remote protocol as the communication transport.
选择平台 (lldb) platform select remote-android 查看链接状况 (lldb) platform status 建立链接 (lldb) platform connect unix-abstract-connect:///cache/debug.sock
output:
Platform: remote-android
Triple: aarch64-unknown-linux-android
OS Version: 31 (4.14.186-perf-g58ba26b0aedc)
Hostname: localhost
Connected: yes
WorkingDir: /cache
Kernel: #1 SMP PREEMPT Mon Mar 28 17:27:22 CST 2022
然后进行调试就可以了
关于调试的手段
调试的方法有两种
调试二进制文件
file [二进制文件]
调试正在运行的进程
attach [Pid]
lldb的一些使用方法(重点内容)
查找资料时看到的一些简写指令,通过help [简写指令]的方式找到全名。
💡下面内容请按照顺序阅读,不要跳过,里面包含了一些小实例(跳出死循环,对多线程操作)
指令 | 使用示例 | 说明 | 详细信息 |
file | file a.out | 绑定需要调试的二进制程序 | ![]() |
breakpoint | breakpoint set –name main | 对main函数打断点,即当程序执行到mian函数的时候停止,此时可以进行调试 | |
list | list | 查看后续代码,但是基本上用不到,优化后大部分符号就没有了,也就看不到任何后续要执行的代码了 | ![]() |
breakpoint | breakpoint list | 查看已有的断点信息 | ![]() |
breakpoint | breakpoint delete 1 | 删除编号为1的断点(不加编号是删除所有断点) | ![]() |
run | run a.out | 运行程序,遇到断点或指令执行完毕停止 | ![]() |
next | next | 执行下一行代码 | ![]() |
frame | frame variable | 查看当前调用栈所有局部变量 | ![]() |
target | target variable | 查看全局变量信息 | ![]() |
expression | expression s | 使用它查看变量信息、修改变量的值,以及直接调用函数 | ![]() |
step | step | 单步调试到函数内部 | 但是如果对应的函数没有符号,会直接跳过,无法进入(此时可以通过单步调试汇编代码的方式进入) |
continue | continue | 继续执行到下一个断点或程序终止 | |
disassemble | disassemble | 查看后续要执行的汇编指令 | ![]() |
thread | thread step-inst thread step-out |
指令级别的调试 (step-inst是遇到bl等跳转指令时跳转到对应的位置) (thread step-out会从当前栈回到上一层) |
![]() ![]() ![]() 通过指令级别的单步调试,从这里就可以看出为何无法step in printf方法了,使用static编译后没有对应函数的符号了,只剩下了汇编指令 |
breakpoint | ![]() 这个需要在构建的时候使用-g -O0生成符号和调试信息,这里line不能是29,29不是真正可用的断点位置 breakpoint set –file A.cpp –line 30 –condition ‘i==500’ |
条件断点(对循环使用) 需要有符号信息 |
![]() ![]() |
breakpoint | breakpoint set –address 0x4013d8 –condition ‘$x8 == 500’ | 条件断点(对循环使用,且没有任何符号可用) | 此时条件非常复杂了,首先对程序肯定有的main方法打断点,执行后发现只有汇编指令,其他符号都已经被优化掉了![]() ![]() 现在注意到这里很像是for循环进行+1的地方,这说明x8寄存器存储的是for循环的变量i(在0x4013d4从栈上取值之后) breakpoint set –address 0x4013d8 –condition ‘$x8 == 500’ 因此对代码段虚拟地址0x4013d8设置条件断点,当x8寄存器的值为500的时候停下 ![]() 使用continue执行后,可以确定它已经准确停在了i == 500的位置 |
frame | frame select 0 | 显示当前执行的位置信息(当前位置是0,它的调用者是1,以此类推) | ![]() |
print i | 查看当前变量i的值 | ![]() |
|
register read | register read | 查看寄存器信息 | ![]() |
register write | register write x26 1000 | 写寄存器信息 | ![]() |
expression |
print/x $lr expression/x $lr |
查看lr寄存器的16进制信息 (/x规定了输出格式,如果不指定默认10进制输出) |
![]() |
memory read | memory read –format x –count 10 $sp | 打印从栈底开始连续10个字 | ![]() |
image list | image list | 查看加载段的信息 | ![]() |
memory | memory read –size 16 –format x –count 10 $sp | 读取虚拟内存信息的值 size指定了每行输出的字节数 format指定了输出的格式 count指定了需要输出的行数 |
![]() |
attach | 借助attach指令,学习如何跳出死循环和调试正在运行的程序 (下面这个代码无论如何都会被llvm构建时优化掉sleep后面的内容) ![]() 请使用下面的代码构建,并使用-O0参数 ![]() attach 1924 |
1. 调试正在运行的进程 2. 定位循环和跳出循环 |
查看当前所有运行的进程 platform process list ![]() ![]() 调试1924这个进程 attach 1924 ![]() 检查当前所处的位置后,可以看到现在还在执行sleep方法,此时我们准备返回上一层调用栈 finsh ![]() ![]() 回到main函数后不再执行finish返回上一层调用栈(后面就是程序结束了) ![]() 回到main后发现while(true) {sleep(1);} 后面的指令都被优化掉了,及时-O0也会被优化掉… 再使用新代码构建之后,重新回到main函数所在的位置。 ![]() 红框是执行循环sleep的地方 ![]() 我们需要在cbz x8, 0x4013e0的时候,让x8寄存器的值为0,此时会跳出sleep的判断,执行后面的代码 breakpoint set –address 0x4013b8 continue ![]() 当执行指令到0x4013b8这个位置的时候,修改x8寄存器的信息 register write x8 0 ![]() 然后删除断点继续执行程序 breakpoint delete continue ![]() ![]() 后续的程序被继续执行了~ |
thread | ![]() |
最后一部分内容,对多线程的调试方法 thread需要对应的动态链接库支持,因此此处的代码是借助Recovery模块的配置信息进行编译的 为了方便大家学习,我编译了一个Android S的版本 这里需要做的事情是: 让两个子线程跳出死循环,让主线程可以继续执行 |
platform process list 查看当前正在运行的进程 ![]() attach 3101 ![]() 现在主线程在等待子线程结束,而且thread 1是主线程,thread 2、thread 3是子线程,都在执行nanosleep方法 现在选择2号线程和3号线程,分别让它们结束死循环 thread select 2 finish finish 此处不小心执行了两次finish让线程执行了,进程开始继续执行,使用以下指令打断目标进程执行 process interrupt ![]() 查看进程目前的状态 thread backtrace all ![]() 再次选择线程2 thread select 2 ![]() finish ![]() finish ![]() disassemble ![]() 这次是在0x5f77679078打断点,并且将x8寄存器的值修改为0 breakpoint set –address 0x5f77679078 continue register write x8 0 ![]() 去除断点后继续执行 breakpoint delete 2 ![]() continue 之后进程会继续执行,因此需要将它再次打断 process interrupt ![]() 原来的线程3的编号变成2了,后面就继续对这个线程进行同样的操作,就可以让主线程跳过死循环继续执行了。 ![]() ![]() |
实现原理
// TODO
zlib问题分析
对Recovery进行调试,找到在Recovery中和在Ubuntu中执行同一个压缩文件进行压缩产生不同结果的原因。
在上面这个文档中还未找到真正的原因,目前已经确认和对应平台的指令相关,但是对那么多DEFLATE块(每块3-4万次)执行过insert_string,就这一个DEFLATE块在执行了了insert_string时在IA-64和ARM64上产生了不同的结果,所以需要找到产生不同结果时CPU的状态,以及高通和MTK在相同的条件下,是否会产生相同的结果。
简要分析
Android对压缩文件进行压缩有两部分需要分析,一部分是在IA-64平台(Ubuntu)上生成的imgdiff,另一部分是在ARM64平台(MIUI)上生成的updater,它们使用的是zlib进行压缩操作。
目前的问题在于,两个不同的平台使用同样的库方法对同样的文件压缩成zip包产生的结果不一致。
最后定位它们在构造哈夫曼树的时候,构造的树从某一处数据处理开始有差异了,最后相同的地方是下面这个log显示的部分,他们可以作为条件断点的条件值。
====++++ deflate_slow_debug E left: strstart: 48 prev_length: 0 s->match_length: 2 prev_match: 41 s->match_start: 41 hash_head: 0
之后我们使用lldb进行实际应用将分为两个部分,IA-64平台(Ubuntu)和ARM64平台(MIUI)。
此处可以获取本次调试需要的一些材料:
Name:Native调试
Address:https://kpan.mioffice.cn/webfolder/ext/QI9euUd49l4%40?n=0.12483618629039439
Password:S12f
可能会遇到的挑战
- 之前for循环因为代码结构简单,因此可以快速判断变量i使用哪个寄存器进行装载,这次z_stream是个结构体,对它进行判断有一定的难度。
好在我提前针对出问题的chunk块进行了代码层面的判断,当chunk块的特征值(chunk块所有字节之和)为6034891时,会进入我特别准备好的debug函数(出问题函数名为deflate_slow_debug),大大降低了调试难度。关于特征值这部分内容,请翻阅文档:Android S 机型增量升级失败
-
在Android 平台进行调试的时候,updater是作为Recovery的子进程运行的,它的执行阶段很多,我没有像imgdiff那样大量地缩小问题范围,因此精准地控制它运行的位置将会是个挑战。(其实也可以缩小范围,把updater改造成只包含进行压缩的代码,并且脱离recovery。但是为了积累调试大型项目的经验,为以后可能的问题做准备,因此此处不打算进行缩减)
-
Recovery选择连接小米助手会中断shell,导致无法进行调试,因此需要一种方法应用OTA升级
imgdiff分析(Ubuntu平台)
确切的说,是对imgdiff再次压缩chunk分析,确保同样的原始文件在进行第二次压缩时可以得到和第一次完全一致的压缩块,模拟在updater(OTA包中的脚本解释器)进行压缩时的操作
1. 需要调试的指令
imgdiff_debug -z old.apk new.apk new.patch
2. 使用lldb运行
(lldb) file imgdiff_debug
(lldb) breakpoint set --name deflate_slow_debug
(lldb) run -z old.apk new.apk new.patch
现在停在了deflate_slow_debug这里
右边有些贴心的信息可以用于参考,通过这些信息可以再次确认我们已经位于deflate_slow_debug函数中了。
___lldb_unnamed_symbol1324所代表的方法分析
现在我们需要知道___lldb_unnamed_symbol1324是什么方法,可以用于参考定位(根据后面的代码的情况,还有当前imgdiff信息输出的情况,这个符号应该是printf,但是需要证据证明它)
💡 call的时候,会将当前调用者的地址压栈,后续通过ret返回调用点,现在再看___lldb_unnamed_symbol1324段的汇编指令,发现它使用的是jmp方法直接跳转的,jmp是强制跳转,不会记录任何信息,因此后续在ret返回的时候直接回到了deflate_slow_debug方法栈的调用点对___lldb_unnamed_symbol1324反编译后,可以发现在执行以上代码,第一行和第三行是可能执行的后续代码。
但是对第三行0x55555567d550反编译,发现超出了函数的范围
现在对第一行进行分析,jmpq *0x2382(%rip),这个就是跳转到rip+0x2382存储的位置,0x2382(%rip)的值为0x555555680088(就下一条指令的地址+0x2382,当前指令执行前rip会移动到下一条指令位置),然后我们读取这个地址的信息
要跳转的地址是0x00007ffff7cbf8f0,毕竟是小端对齐(高位的数据会放在低位),也可以直接格式化数据像下面这样
让我们看看0x00007ffff7cbf8f0地址是什么内容
现在可以确定___lldb_unnamed_symbol1324是printf函数
第三条jmp指令分析和验证上面分析的正确性
imgdiff_debug text段长度是0x00000000000a0654,起始位置是0x0000000000088ec0,装载的虚拟内存地址是0x0000555555554000(此时用这个地址加上ELF text段的开始地址就可以获取的text段在虚拟内存的地址了,后续步骤是我想从另一个角度再验证一下,确保这个想法是正确的),main函数的位置在text段的0x0000000000088ff0处,与text段起始位置偏移0x130,mian函数在虚拟内存中的存储位置是0x5555555dcff0,那么虚拟内存中text段的位置是0x5555555DCEC0 - 0x55555567D514,0x55555567d550所处的位置确实已经超出了text段的范围…
目前没有办法找到0x55555567d550地址会执行的指令,实际上它有可能在正常情况下根本就不会执行,因此现在使用step-in向后执行指令,观察程序在这块执行的逻辑。
单步调试跳转后,确认了___lldb_unnamed_symbol1324确实执行的是printf方法!上面的推断是正确的!
打印出了deflate_slow_debug方法的第一条信息,后续我们要使用它的信息去验证变量信息是否正确。
0x55555567d550处的指令可能是异常处理相关的。
验证变量信息
可以确认,目前可以正常获取变量信息,它们将作为条件断点使用。
对insert_string方法设置条件断点
由于此次编译imgdiff保留了原本符号的名称,因此没有必要去找执行insert_string真正的方法了。
是一个內联函数,让我们看看它的代码:
insert_string最后实际执行的应该是insert_string_c或者insert_string_simd,但是很不巧,它们也都是內联函数,现在我们通过确认insert_string_c和insert_string_simd符号存在的情况来看imgdiff究竟执行的是哪个代码。
imgdiff使用的是insert_string_c,由于这个方法是內联函数,因此我们只能通过对address打断点的方式观察它的执行过程了(insert_string_c在內联展开之后,由于编译器进行了优化,所以它的汇编代码散布在很多不同的区域中)。
当执行地址为0x555555673fd9、0x555555673fc2、0x555555674212、0x55555567406d,且s->strstart == 42921时,停止程序运行
#define UPDATE_HASH(s, h, c) (h = (((h) << s->hash_shift) ^ (c)) & s->hash_mask)
我们在调试的时候需要注意h (hash)的值
updater分析(Android平台)
Recovery选择连接小米助手会中断shell,导致无法进行调试,因此我们不打算通过sideload进行OTA包的安装操作,而是通过指令(Miui Recovery被设计成可以多次执行)。
adb root; adb shell;
mkdir /tmp
mount -t tmpfs -o size=2000m tmpfs /tmp
exit
adb push miui-blockota-merlin_pre_global-22.3.24-22.3.25-17008cfb07-12.0.zip /tmp/
adb root; adb shell;
recovery --update_package=/tmp/miui-blockota-merlin_pre_global-22.3.24-22.3.25-17008cfb07-12.0.zip --export_validate=ovea
ps -ef | grep recovery
这样就可以对Updater进行调试了!~
有个小技巧,在升级进度到27%的时候进行打断时,它离我们的目标代码最接近。
参考资料
官方
https://lldb.llvm.org/use/map.html
https://clang.llvm.org/docs/CrossCompilation.html
https://source.android.com/devices/tech/debug/gdb
https://lldb.llvm.org/use/tutorial.html
https://llvm.org/devmtg/2016-03/Tutorials/LLDB-tutorial.pdf
其他(建议和官方文档参考着看,以官方文档为主)
https://casatwy.com/shi-yong-lldbdiao-shi-cheng-xu-er.html
https://zhuanlan.zhihu.com/p/106415182
https://github.com/tuoxie007/play_with_llvm/blob/master/ch03.md
https://blog.csdn.net/qq_23542165/article/details/121275404
https://liangmc.com/archives/03lldb%E6%B1%87%E7%BC%96%E8%B0%83%E8%AF%95md
https://juejin.cn/post/6872764160640450574
https://www.jianshu.com/p/1005ccfe8fea
http://blog.sina.com.cn/s/blog_6af9566301013xp4.html