做者:陈浩 贝聊科技移动开发部 iOS 工程师python
本文已发表在我的博客git
以前负责项目的包体积优化学习了 Mach-O 文件的格式,那么 Mach-O 到底是怎么样的文件,知道它的组成以后咱们又能作点什么?本文会从 Mach-O 文件的介绍讲起,再看看认识它后的一些实际应用。github
先让咱们看看 Mach-O 的大体构成shell
再使用 MachOView 一窥究竟swift
结合可知 Mach-O 文件包含了三部份内容:bash
Mach-O 文件的头部定义以下:架构
MH_NOUNDEFS
目标文件没有未定义的符号,MH_DYLDLINK
目标文件是动态连接输入文件,不能被再次静态连接,MH_SPLIT_SEGS
只读 segments 和 可读写 segments 分离,MH_NO_HEAP_EXECUTION
堆内存不可执行…filetype 的定义有:app
flags 的定义有:ide
简单总结一下就是 Headers 能帮助校验 Mach-O 合法性和定位文件的运行环境。函数
Headers 以后就是 Load Commands,其占用的内存和加载命令的总数在 Headers 中已经指出。
Load Commands 的定义比较简单:
LC_SEGMENT、LC_SEGMENT_64
将 segment 映射到进程的内存空间,LC_UUID
二进制文件 id,与符号表 uuid 对应,可用做符号表匹配,LC_LOAD_DYLINKER
启动动态加载器,LC_SYMTAB
描述在 __LINKEDIT
段的哪找字符串表、符号表,LC_CODE_SIGNATURE
代码签名等这里先来看看 segment 的定义:
#define SEG_PAGEZERO "__PAGEZERO" // 可执行文件捕获空指针的段
#define SEG_TEXT "__TEXT" // 代码段,只读数据段
#define SEG_DATA "__DATA" // 数据段
#define SEG_LINKEDIT "__LINKEDIT" // 包含动态连接器所需的符号、字符串表等数据
接着看看 section 的定义:
__Text
和 __Data
都有本身的 section
Text.__text
主程序代码Text.__cstring
c 字符串Text.__stubs
桩代码Text.__stub_helper
Data.__data
初始化可变的数据Data.__objc_imageinfo
镜像信息 ,在运行时初始化时 objc_init
,调用 load_images
加载新的镜像到 infolist 中
Data.__la_symbol_ptr
Data.__nl_symbol_ptr
Data.__objc_classlist
类列表Data.__objc_classrefs
引用的类这节最后探究下 stubs,在 Xcode 中新建 C 项目,代码以下:
#include <stdio.h>
int main(int argc, const char * argv[]) {
printf("Hello, coder\n");
return 0;
}
复制代码
使用 gcc -c main.c
将其编译成 a.out 文件,调用 nm 命令查看 .o 文件的符号
看到 _printf
是未定义的,也就是说并无该函数的内存地址。nm 打印出的信息代表dyld_stub_binder
也是未定义的。 打开 Hopper 查看 .o 文件
能够看出 printf 会跳入 __stubs
中,地址也与 MachOView 看到的相对应
双击刚才 __stubs
中的地址,会跳转到 __la_symbol_ptr
在 MachOView 中查看 0x100001010 对应的数据为 0x10000f9c
用 Hopper 搜索 0x10000f9c,跳转到 stub_helper
,可知 __la_symbol_ptr
里的数据被 bind 成了 stub_helper
由此可知,__la_symbol_ptr
中的数据被第一次调用时会经过 dyld_stub_binder
进行相关绑定,而 __nl_symbol_ptr
中的数据就是在动态库绑定时进行加载。
因此 __la_symbol_ptr
中的数据在初始状态都被 bind 成 stub_helper
,接着 dyld_stub_binder
会加载相应的动态连接库,执行具体的函数实现,此时 __la_symbol_ptr
也获取到了函数的真实地址,完成了一次近似懒加载的过程。
写到这里,算是快速过了一遍 Mach-O 文件的基本概念,接着聊聊能够怎样减小项目的体积。
咱们的项目中不免会存在一些没使用的类或方法,因为 OC 的动态特性,编译器会对全部的源文件进行编译,找出并删除没用到的类或方法能够减小可执行文件大小。 上文中提到了 __objc_classlist
和 __objc_classrefs
,它们分别表示项目中所有类列表和项目中被引用的类列表,那么取二者之差,就能删除一些项目中没使用的类文件。可是在删除过程当中记住要在项目中全局搜索确认下,看看有没有经过字符串调用无引用的类的方法,缘由仍是 OC 是动态语言。 在看具体作法以前,顺带提一下我公司的项目组成。咱们维护着俩客户端,共用着一个基础库(lib 库),可能有时因为产品的需求变动或者为了产品功能的预留致使 lib 库中只有着某个端使用的代码,我在上述的作法中对脚本作了稍微改进,以防删除了 lib 库的代码,致使另外一个端跑不起来,下面介绍通用的作法:
otool -v -s __objc_classlist
和 otool -v -s __objc_classrefs
命令,逆向 __DATA. __objc_classlist
段和 __DATA. __objc_classrefs
段获取当前全部oc类和被引用的oc类。这点就跟本文的主题没什么关系,不感兴趣能够略过。 压缩 app 中的图片是我作的另外一个努力,虽然 Xcode 会压一遍,可是经我压缩后打包发现包仍是会少个将近 1m,这里用到的工具是 ImageOptim,贴出个人三脚猫 python:
all_file_size = 0
all_file_count = 0
def fileDriector(filePath):
global all_file_size, all_file_count
for file in os.listdir(filePath):
if os.path.isdir(filePath + '/' + file):
if file != 'Pods' and not file.startswith('.') and not file.endswith('.framework') \
and not file.endswith('.bundle') and not file.endswith('.a') and file != 'libs' \
or file.endswith('.xcassets') or file.endswith('.imageset'):
the_path = filePath + '/' + file
fileDriector(the_path)
elif file.endswith('.png') or file.endswith('.jpg'):
fileName = filePath + '/' + file
comand_line = "echo %s | imageoptim" % fileName
test = subprocess.Popen(comand_line, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
output = test.communicate()[0]
numberList = re.findall('\.?\d+\.?\d*kb', output)
lastSize = numberList[-1]
lastSizeList = re.findall('\.?\d+\.?\d*', lastSize)
saveSize = lastSizeList[0]
if saveSize.startswith('.'):
saveSize = '0' + saveSize
finalSize = float(saveSize)
all_file_size += finalSize
all_file_count += 1
print output
复制代码
其余的一些减包方案就不展开了,接下来我试着分析一下 bestswifter 大神的 BSBacktraceLogger
能够看到 Debug 模式下,符号表文件会存入可执行文件中,而 Release 模式则会生成出 DSYM 文件,咱们日常使用 Bugly 等工具上传的就是这份 DSYM 文件,DSYM 也是种 Mach-O 文件。在 Debug 模式,因为符号表在内存中,这为咱们符号化堆栈提供了可能性。
bool bs_fillThreadStateIntoMachineContext(thread_t thread, _STRUCT_MCONTEXT *machineContext) {
mach_msg_type_number_t state_count = BS_THREAD_STATE_COUNT;
kern_return_t kr = thread_get_state(thread, BS_THREAD_STATE, (thread_state_t)&machineContext->__ss, &state_count);
return (kr == KERN_SUCCESS);
}
复制代码
thread_get_state
函数获取线程执行状态(例如寄存器),传入 _STRUCT_MCONTEXT
结构体,_STRUCT_MCONTEXT
在不一样的 cpu 架构会有所不一样。
uintptr_t bs_mach_instructionAddress(mcontext_t const machineContext){
return machineContext->__ss.BS_INSTRUCTION_ADDRESS;
}
const uintptr_t instructionAddress = bs_mach_instructionAddress(&machineContext);
复制代码
获取当前指令的地址,也就是当前的栈帧,即当前被调用的函数。下面先讲下关于栈帧的概念。
如上图,一个函数调用栈是由若干个栈帧组成,每一个栈帧经过 FP 和 SP 划分界线,fun1 函数 SP 和 FP 的指向就是 main 函数的栈帧。因此说只要知道当前函数的栈帧就能获取上一个函数的栈帧,从而回溯出函数调用栈。
程序计数器(PC)做用是给出将要执行的下一条指令在内存中的地址,上面代码的 BS_INSTRUCTION_ADDRESS
。其中 16 位为 %ip,32 位为 %eip,64 位为 %rip,arm 是 pc。
SP 是栈指针寄存器,指向栈顶。
FP 是栈基址寄存器,指向栈起始位置。
LR 寄存器在子程序调用时会存储 PC 的值,即返回值。
为了方便获取栈帧,干脆构造一个栈帧的结构体,如下代码来自 KSCrash,它的注释已经很好的讲明告终构体的起因,BSBacktraceLogger 与之相似。
/** Represents an entry in a frame list.
* This is modeled after the various i386/x64 frame walkers in the xnu source,
* and seems to work fine in ARM as well. I haven't included the args pointer * since it's not needed in this context.
*/
typedef struct FrameEntry
{
/** The previous frame in the list. */
struct FrameEntry* previous;
/** The instruction address. */
uintptr_t return_address;
} FrameEntry;
复制代码
以后,递归获取函数栈帧
for(; i < 50; i++) {
backtraceBuffer[i] = frame.return_address;
if(backtraceBuffer[i] == 0 ||
frame.previous == 0 ||
bs_mach_copyMem(frame.previous, &frame, sizeof(frame)) != KERN_SUCCESS) {
break;
}
}
复制代码
符号化地址的大体思路分三步:1. 获取地址所在的内存镜像;2. 定位到内存镜像的符号表;3. 再从符号表中找到目标地址的符号。
uint32_t bs_imageIndexContainingAddress(const uintptr_t address) {
const uint32_t imageCount = _dyld_image_count();
const struct mach_header* header = 0;
for(uint32_t iImg = 0; iImg < imageCount; iImg++) {
header = _dyld_get_image_header(iImg);
复制代码
遍历 image,获得指向 image header 的指针
uintptr_t addressWSlide = address - (uintptr_t)_dyld_get_image_vmaddr_slide(iImg);
uintptr_t cmdPtr = bs_firstCmdAfterHeader(header);
复制代码
对指针 +1 操做,返回指向 load command 的指针
for(uint32_t iCmd = 0; iCmd < header->ncmds; iCmd++) {
const struct load_command* loadCmd = (struct load_command*)cmdPtr;
if(loadCmd->cmd == LC_SEGMENT) {
const struct segment_command* segCmd = (struct segment_command*)cmdPtr;
if(addressWSlide >= segCmd->vmaddr &&
addressWSlide < segCmd->vmaddr + segCmd->vmsize) {
return iImg;
}
}
复制代码
若是某个 segment 包含这个地址,那么该地址应大于 segment 的起始地址,小于 segment 的起始地址 + segment 的大小。
__LINKEDIT
段包含了符号表(symbol),字符串表(string),重定位表(relocation)。LC_SYMTAB
指明了 __LINKEDIT
段查找字符串和符号表的位置。咱们能够结合 SEG_LINKEDIT
和 LC_SYMTAB
来找到 image 的符号表。 接下来看看段基址的获取: 虚拟地址偏移量 = 虚拟地址(vmaddr) - 文件偏移量(fileoff) 段基址 = 虚拟地址偏移量 + ASLR的偏移量
const uintptr_t imageVMAddrSlide = (uintptr_t)_dyld_get_image_vmaddr_slide(idx);
// ALSR
const uintptr_t addressWithSlide = address - imageVMAddrSlide;
const uintptr_t segmentBase = bs_segmentBaseOfImageIndex(idx) + imageVMAddrSlide;
有了段基址,获取符号表和字符串表就只是计算下 symoff 和 stroff 偏移量了:
const BS_NLIST* symbolTable = (BS_NLIST*)(segmentBase + symtabCmd->symoff);
const uintptr_t stringTable = segmentBase + symtabCmd->stroff;
复制代码
递归查找离 addressWithSlide 更近的函数入口地址,由于 addressWithSlide 确定大于某个函数的入口。
for(uint32_t iSym = 0; iSym < symtabCmd->nsyms; iSym++) {
// If n_value is 0, the symbol refers to an external object.
if(symbolTable[iSym].n_value != 0) {
uintptr_t symbolBase = symbolTable[iSym].n_value;
uintptr_t currentDistance = addressWithSlide - symbolBase;
if((addressWithSlide >= symbolBase) &&
(currentDistance <= bestDistance)) {
bestMatch = symbolTable + iSym;
bestDistance = currentDistance;
}
}
}
复制代码
MachO 文件的 __Text
段有 __objc_classname
和 __objc_methname
来表示类名和方法名,可是这二者之间是如何作到关联的呢?下面我以系统的计算器作例子,试着进一步研究下 MachO 文件。 使用 MachOView 打开系统计算机,先来看看 __objc_classname
和 __objc_methname
在 load commands 里的定义:
咱们顺着 __objc_classname
的偏移offset 109518 即 0x1ABCE 来到:
同理 __objc_methname
的偏移为 0x165E8:
那么,怎样像 class-dump 那样将类和自个的方法名对应起来呢? 因为每一个类的虚拟地址都在Data 段 __objc_classlist
中:
咱们看到起始地址对应的是 0x1000298A8 这个地址,为了获得实际的地址须要用虚拟地址 - 段起始地址 + 文件偏移,通过一番计算,结果是0x298A8,来到文件偏移处,已经在DATA 段的 __objc_data
在这里会对应着类的结构体,代码拷自 class-dump
struct cd_objc2_class {
uint64_t isa;
uint64_t superclass;
uint64_t cache;
uint64_t vtable;
uint64_t data; // points to class_ro_t
uint64_t reserved1;
uint64_t reserved2;
uint64_t reserved3;
};
复制代码
data 是咱们感兴趣的,它指向 class_ro_t
,熟悉 runtime 的话应该知道 class_ro_t
存储了类在编译器就肯定的属性、方法、协议等。 因此上图 isa 的数据是 0x1000298D0,继续顺着找下去 0x100020A68 就是 data 的内存地址,再用上面的公式计算获得 0x20A68,咱们在 __objc_const
找到那里:
这里就是对应着 class_ro_t
,来看看它在 class-dump 里的定义:
struct cd_objc2_class_ro_t {
uint32_t flags;
uint32_t instanceStart;
uint32_t instanceSize;
uint32_t reserved; // *** this field does not exist in the 32-bit version ***
uint64_t ivarLayout;
uint64_t name;
uint64_t baseMethods;
uint64_t baseProtocols;
uint64_t ivars;
uint64_t weakIvarLayout;
uint64_t baseProperties;
};
复制代码
最终 0x20A80 就是name,0x20A88 就是 baseMethods。name 对应的正好是 0x1ABCE,类名是 BitFieldBox。baseMethods 指向内存 0x100020A00,该地址对应的数据是 18 00 00 00 04 00 00 00 表示 entsize 和 count 方法数,在这8个字节以后就是 name 方法名,types 方法类型, imp 函数指针了,因此方法名处的数据为 0x1000165e8 恰好对应 initWithFrame: 将结论用 class-dump 验证可得 BitFieldBox 的第一个方法是 initWithFrame
最初学习 MachO 文件格式以为挺抽象的,后来通过各类源码的阅读和融合,终于在一次次地探索中比较直观地认识了 MachO 文件,特别是在 MachO 文件关联类的方法名时对类在内存中的布局有了更进一步的认识。虽然咱们日常开发基本不和 MachO 文件打交道,可是对它有个基本概念,不管是作崩溃分析、逆向等都是有帮助的。