深入理解Android NDK日志符号化
为了进行代码及产品保护,几乎所有的非开源App都会进行代码混淆,这样当收集到崩溃信息后,就需 要进行符号化来还原代码信息,以便开发者可以定位Bug。基于使用SDK和NDK的不同,Android的崩溃分为两类:Java崩溃和C/C++崩溃。Java崩溃通过mapping.txt文件进行符号化,比较简单直观,而C/C++崩溃的符号化则需要使用Google自带的一些NDK工具,比如ndk-stack、addr2line、objdump等。本文不去讨论如何使用这些工具,有兴趣的朋友可以参考同事写的另一篇文章《如何定位Android NDK开发中遇到的错误》,里面做了详细的描述。
基于NDK的Android的开发都会生成一个动态链接库(so),它是基于C/C++编译生成的。动态链接库在Linux系统下广泛使用,而Android系统底层是基于Linux的,所以NDK so库的编译生成遵循相同的规则,只不过Google NDK把相关的交叉编译工具都封装了。
Ndk-build编译时会生成的两个同名的so库,位于不同的目录/projectpath/libs/armeabi/xxx.so和/project path/obj/local/armeabi/xxx.so,比较两个so文件会发现体积相差很大。前者会跟随App一起发布,所以尽可能的小,而后者包含了很多调试信息,主要为了gdb调试的时候使用,当然NDK的日志符号化信息也包含其中。
本文主要分析这个包含调试信息的so动态库,深入分析它的组成结构。在开始之前,先来说说这样做的目的或者好处。现在的App基本都会采集上报崩溃时的日志信息,无论是采用第三方云平台(如Testin崩溃分析+),还是自己搭建云服务,都要将含调试信息的so动态库上传,实现云端日志符号化以及云端可视化管理。移动App的快速迭代,使得我们必须存储管理每一个版本的debugso库,而其包含了很多与符号化无关的信息。如果我们只提取出符号化需要的信息,那么符号化文件的体积将会呈现数量级的减少。同时可以在自定义的符号化文件中添加App的版本号等信息,实现符号化提取、上传到云端、云端解析及可视化等自动化部署。另外,从技术角度讲,你将不在害怕看到“unresolvedsymbol” linking errors,更从容地 debugging C/C++ crash或者hacking一些so文件。
首先通过readelf来看看两个不同目录下的so库有什么不同
从中可以清楚看到,包含调试信息的so库多了8个.debug_开头的条目以及.symtab和.strtab条目。符号化的本质,是通过堆栈中的地址信息,还原代码本来的语句以及相应的行号,所以这里只需解析.debug_line和.symtab,最终获取到如下的信息就可以实现符号化了。
c85c8bwillCrashjni/hello-jni.c:27-29c8bc8dwillCrashjni/hello-jni.c:32c8dc8fJNI_OnLoadjni/hello-jni.c:34c8fc93JNI_OnLoadjni/hello-jni.c:35c93c9dJNI_OnLoadjni/hello-jni.c:37
通常,目标文件分为三类:relocatable文件、executable文件和shared object文件,它们格式称为ELF(Executableand Linking Format),so动态库属于第三类shared object,它的整体组织结构如下:
optionalSection 1...Section n...
Section header table
required
ELF Header
ELFHeader文件头的结构如下,记录了文件其他内容在文件中的偏移以及大小信息。这里以32bit为例。
typedefstruct{unsignedchare_ident[EI_NIDENT];Elf32_Halfe_type;//目标文件类型,如relocatable、executable和sharedobjectElf32_Halfe_machine;//指定需要的特定架构,如Intel80386,Motorola68000Elf32_Worde_version;//目标文件版本,通e_ident中的EI_VERSIONElf32_Addre_entry;//指定入口点地址,如C可执行文件的入口是_start(),而不是main()Elf32_Offe_phoff;//programheadertable的偏移量Elf32_Offe_shoff;//sectionheadertable的偏移量Elf32_Worde_flags;//处理器相关的标志Elf32_Halfe_ehsize;//代表ELFHeader部分的大小Elf32_Halfe_phentsize;//programheadertable中每一项的大小Elf32_Halfe_phnum;//programheadertable包含多少项Elf32_Halfe_shentsize;//sectionheadertable中每一项的大小Elf32_Halfe_shnum;//sectionheadertable包含多少项Elf32_Halfe_shstrndx;//sectionheadertable中某一子项的index,该子项包含了所有section的字符串名称}Elf32_Ehdr;
其中e_ident为固定16个字节大小的数组,称为ELFIdentification,包含了处理器类型、文件编码格式、机器类型等,具体结构如下:
前四个字节称为magicnumber,分别为0x7f、’E’、’L’、’F’,表明文件类型为ELF。
EI_MAG1
1EI_MAG22EI_MAG33EI_CLASS
4表明文件是基于32-bit还是64-bit,不同的方式,对齐方式不同,读取某些内容的大小不同。
EI_DATA5表明文件数据结构的编码方式,主要分为大端和小端两种
EI_VERSION
6指定了ELF文件头的版本号
EI_OSABI
7指定使用了哪种OS-或者ABI-的ELF扩展
EI_ABIVERSION8指定该ELF目标文件的目标ABI版本
EI_PAD
9保留字段起始处,直到第16个字节
EI_NIDENT16代表了e_ident数组的大小,固定为16
Sections
该部分包含了除ELFHeader、programheader table以及sectionheader table之外的所有信息。通过sectionheader table可以找到每一个section的基本信息,如名称、类型、偏移量等。
先来看看SectionHeader的内容,仍以32-bit为例:
typedefstruct{Elf32_Wordsh_name;//指定section的名称,该值为StringTable字符串表中的索引Elf32_Wordsh_type;//指定section的分类Elf32_Wordsh_flags;//该字段的bit代表不同的section属性Elf32_Addrsh_addr;//如果section出现在内存镜像中,该字段表示section第一个字节的地址Elf32_Offsh_offset;//指定section在文件中的偏移量Elf32_Wordsh_size;//指定section占用的字节大小Elf32_Wordsh_link;//相关联的sectionheadertable的indexElf32_Wordsh_info;//附加信息,意义依赖于section的类型Elf32_Wordsh_addralign;//指定地址对其约束Elf32_Wordsh_entsize;//如果section包含一个table,该值指定table中每一个子项的大小}Elf32_Shdr;
通过SectionHeader的sh_name可以找到指定的section,比如.debug_line、.symbol、.strtab。
String Table
StringTable包含一系列以\0结束的字符序列,最后一个字节设置为\0,表明所有字符序列的结束,比如:
StringTable也属于section,只不过它的偏移量直接在ELFHeader中的e_shstrndx字段指定。StringTable的读取方法是,从指定的index开始,直到遇到休止符。比如要sectionheader中sh_name获取section的名称,比如sh_name= 7, 则从stringtable字节流的第7个index开始(注意这里从0开始),一直读到第一个休止符(index=18),读取到的名称为.debug_line
Symbol Table
该部分包含了程序符号化的定义相关信息,比如函数定义、变量定义等,每一项的定义如下:
#SymbolTableEntrytypedefstruct{Elf32_Wordst_name;//symbol字符串表的索引Elf32_Addrst_value;//symbol相关的值,依赖于symbol的类型Elf32_Wordst_size;//symbol内容的大小unsignedcharst_info;//symbol的类型及其属性unsignedcharst_other;//symbol的可见性,比如类的public等属性Elf32_Halfst_shndx;//与此symbol相关的sectionheader的索引}Elf32_Sym;
Symbol的类型包含一下几种
STT_FUNC
STT_SECTION
STT_FILE
STT_COMMO
STT_TLS
STT_LOOS
STT_LOPRO
STT_HIPROC
其中STT_FUNC就是我们要找的函数symbol。然后通过st_name从symbol字符串表中获取到相应的函数名(如JNI_OnLoad)。当symbol类型为STT_FUNC时,st_value代表该symbol的起始地址,而(st_value+st_size)代表该symbol的结束地址。
回顾之前的提到的.symtab和.strtab两个部分,对应的便是SymbolSection和SymbolString Section。
DWARF是一种调试文件格式,很多编译器和调试器都通过它进行源码调试(gdb等)。尽管它是一种独立的目标文件格式,但往往嵌入在ELF文件中。前面通过readelf看到的8个.debug_*Section全部都属于DWARF格式。本文将只讨论与符号化相关的.debug_line部分,更多的DWARF信息请查看参考文献的内容。
.debug_line部分包含了行号信息,通过它可以将代码语句和机器指令地址对应,从而进行源码调试。.debug_line有很多子项组成,每个子项都包含类似数据块头的描述,称为StatementProgramPrologue。Prologue提供了解码程序指令和跳转到其他语句的信息,它包含如下字段,这些字段是以二进制格式顺序存在的:
total_length
uword
整个子项占用的字节大小,注意并不包括该字段本身
versio
uhalf
该子项格式的版本号,其实也是整个DWARF格式的版本号,目前总共有四个版本。
prologue_length
uword
prologue的长度,不包括该字段及前面的两个字段占用的字节数,即相对于本字段,程序语句本身的第一个字节的偏移量
minimum_instruction_length
ubyte
最小的目标机器指令
default_is_stmt
ubyte
is_stmt寄存器的初始值
line_base
sbyte
不同的操作码,代表不同的含义,只影响specialopcodes
line_range
ubyte
不同的操作码,代表不同的含义,只影响specialopcodes
opcode_base
ubyte
第一个操作码的数值
standard_opcode_lengthsarray of ubyte
标准操作码的LEB128操作数的数值
include_directories
sequence
目录名字符序列
file_names
sequence
源代码所在文件名字符序列
这里用到的机器指令可以分为三类:
specialopcodes
单字节操作码,不含参数,大多数指令属于此类
standard opcodes
单字节操作码,可以包含0个或者多个LEB128参数
extended opcodes
多字节操作码
这里不做机器指令的解析说明,感兴趣的,可以查看参考文献的内容。
通过.debug_line,我们最终可以获得如下信息:文件路径、文件名、行号以及起始地址。
最后我们汇总一下整个符号化提取的过程:
1、从ELFHeader中获知32bit或者64bit,以及大端还是小端,基于此读取后面的内容
2、从ELFHeader中获得SectionHeader Table在文件中的位置
3、读取SectionHeader Table,从中获得.debug_line、.symtab以及.strtab三个section在文中的位置
4、读取.symtab和.strtab两个section,最后获得所有functionsymbol的名称、起始地址以及结束地址
5、读取.debug_line,按照DWARF格式解析获取文件名称、路径、行号以及起始地址
6、对比步骤4和5中获取的结果,进行对比合并,形成最终的结果
参考文献:
http://www.csdn.net/article/2014-12-30/2823366-Locate-Android-NDK
http://eli.thegreenplace.net/2011/02/07/how-debuggers-work-part-3-debugging-information/
http://www.sco.com/developers/gabi/latest/ch5.intro.html
http://www.dwarfstd.org/
声明:本站所有文章资源内容,如无特殊说明或标注,均为采集网络资源。如若本站内容侵犯了原著者的合法权益,可联系本站删除。