探秘 Mach-O 文件
之前负责项目的包体积优化学习了 Mach-O 文件的格式,那么 Mach-O 究竟是怎么样的文件,知道它的组成之后我们又能做点什么?本文会从 Mach-O 文件的介绍讲起,再看看认识它后的一些实际应用。
Mach-O 文件格式
先让我们看看 Mach-O 的大致构成
再使用 MachOView 一窥究竟
结合可知 Mach-O 文件包含了三部分内容:
- Header(头部),指明了 cpu 架构、大小端序、文件类型、Load Commands 个数等一些基本信息
- Load Commands(加载命令),正如官方的图所示,描述了怎样加载每个 Segment 的信息。在 Mach-O 文件中可以有多个 Segment,每个 Segment 可能包含一个或多个 Section。
- Data(数据区),Segment 的具体数据,包含了代码和数据等。
Headers
Mach-O 文件的头部定义如下:
- magic 标志符 0xfeedface 是 32 位, 0xfeedfacf 是 64 位。
- cputype 和 cpusubtype 确定 cpu 类型、平台
- filetype 文件类型,可执行文件、符号文件(DSYM)、内核扩展等
- ncmds 加载 Load Commands 的数量
- flags dyld 加载的标志
MH_NOUNDEFS目标文件没有未定义的符号,MH_DYLDLINK目标文件是动态链接输入文件,不能被再次静态链接,MH_SPLIT_SEGS只读 segments 和 可读写 segments 分离,MH_NO_HEAP_EXECUTION堆内存不可执行…
filetype 的定义有:
flags 的定义有:
简单总结一下就是 Headers 能帮助校验 Mach-O 合法性和定位文件的运行环境。
Load Commands
Headers 之后就是 Load Commands,其占用的内存和加载命令的总数在 Headers 中已经指出。
Load Commands 的定义比较简单:
- cmd 字段,如上图它指出了 command 类型
LC_SEGMENT、LC_SEGMENT_64将 segment 映射到进程的内存空间,LC_UUID二进制文件 id,与符号表 uuid 对应,可用作符号表匹配,LC_LOAD_DYLINKER启动动态加载器,LC_SYMTAB描述在__LINKEDIT段的哪找字符串表、符号表,LC_CODE_SIGNATURE代码签名等
- cmdsize 字段,主要用以计算出到下一个 command 的偏移量。
Segment & Section
这里先来看看 segment 的定义:
- cmd 就是上面分析的 command 类型
- segname 在源码中定义的宏
#define SEG_PAGEZERO "__PAGEZERO" // 可执行文件捕获空指针的段#define SEG_TEXT "__TEXT" // 代码段,只读数据段#define SEG_DATA "__DATA" // 数据段#define SEG_LINKEDIT "__LINKEDIT" // 包含动态链接器所需的符号、字符串表等数据
- vmaddr 段的虚存地址(未偏移),由于 ALSR,程序会在进程加上一段偏移量(slide),真实的地址 = vm address + slide
- vmsize 段的虚存大小
- fileoff 段在文件的偏移
- filesize 段在文件的大小
- nsects 段中有多少个 section
接着看看 section 的定义:
__Text 和 __Data 都有自己的 section
- segname 就是所在段的名称
- sectname section名称,部分列举:
Text.__text主程序代码Text.__cstringc 字符串Text.__stubs桩代码Text.__stub_helperData.__data初始化可变的数据Data.__objc_imageinfo镜像信息 ,在运行时初始化时objc_init,调用load_images加载新的镜像到 infolist 中
#include
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 文件的基本概念,接着聊聊可以怎样减少项目的体积。
减少包大小
iOS 的包主要由可执行文件、资源文件(图片)等文件组成,所以可以从这两大头文件入手优化。
可执行文件瘦身
我们的项目中难免会存在一些没使用的类或方法,由于 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类。 - 取两者差集,得到没被引用的类的段地址
- otool -o 二进制文件,获取段信息
- 通过脚本使用没被引用的类的段地址去段信息中匹配出具体类名
压缩图片资源
这点就跟本文的主题没什么关系,不感兴趣可以略过。 压缩 app 中的图片是我做的另一个努力,虽然 Xcode 会压一遍,但是经我压缩后打包发现包还是会少个将近 1m,这里用到的工具是 ImageOptim,贴出我的三脚猫 python:
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 架构会有所不同。
/** 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;
之后,递归获取函数栈帧
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 的指针
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的偏移量
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 文件关联类的方法名
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_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 文件打交道,但是对它有个基本概念,无论是做崩溃分析、逆向等都是有帮助的。
面试资料:
面试题持续整理更新中,如果你想一起进阶去大厂,不妨添加一下交流群1012951431
面试题资料或者相关学习资料都在群文件中 进群即可下载!