探秘 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.__cstring c 字符串
    • Text.__stubs 桩代码
    • Text.__stub_helper
    • Data.__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

面试题资料或者相关学习资料都在群文件中 进群即可下载!

 
iOS