如何计算一个 Symbol 在 Mach-O 里占用了多少空间?

Nov 16, 2019 at 14:05:10

如何计算一个 symbol 在 Mach-O 里占用了多少空间?

因为推友问了一个问题:

@PofatTseng: 發問:要怎麼測量 symbol 在 MachO 裡佔據的大小,如果只看 __Text.__text 後 + 的偏移量準嗎?

@MapleShadow: @PofatTseng 看 Load Command 的 LC_SYMTAB 能满足你的场景吗?like

otool -l xxxx | grep -i LC_SYMTAB -B 10 -A 10

@PofatTseng: @MapleShadow 我想問的是單一個物件的相關 symbol ,比如我有一個 struct Foo {}

怎麼知道他在 MachO 裡佔去了多少空間?Foo 所有symbol 會在連續的位置上嗎

@MapleShadow: @PofatTseng 这个问题是个好问题,本来以为是一个简单的问题但是一点都不简单😂简单说通常情况下我们 App 的符号都被 strip 掉放进 dSYM 所以不占 Mach-O 空间,但是如果你是 debug 版或者动态库就会塞进去。至于长度,symbole table 的指针都是 8 字节(updated: 其实是 16 bytes),但是指向的符号 string 不是定长的,在 string table 里面取

我本以为是一个简单的问题,结果发现自己对 Mach-O 的很多细节都不太了解,于是学习了一下,以此文作为学习笔记。

如果只对上述问题的答案感兴趣的可以直接跳到末尾看结论。

P.S. 学习过程我参考了这篇文章但是因为年代有点久远,里面有些字段已经弃用了,当做字典参考就行。

1. Mach-O 文件的结构

我们在 macOS 系统如何启动?App 如何运行起来均有涉及 Mach-O 文件结构的讨论,但不全面。这里我们再详细介绍一下 Mach-O 的结构。下文使用的例子需要对比 Debug 和 Release 版,所以用我的 Mac 全屏休息提醒工具: Just Focus 为例。

首先我们来看最简单的 64 位单架构 Mach-O 文件(Fat Binary 后面再讨论),相关的数据结构定义在 XNU 源码的 EXTERNAL_HEADERS/mach-o/loader.h 里面。一个 Mach-O 文件有三个主要部分:

Mach-O Structure

  • Header: 定义了文件的基础信息
  • Load Commands: 包含了各种不同的 load command,用于决定文件在内存的初始布局,用于告知 dyld 动态链接的符号表,标示初始函数入口,标示动态库的地址等等。
  • Data: 这一部分被分为多个 segment,每个 segment 包含 0 个或多个 section。内核加载 Mach-O 时会根据 load commands 把相应的数据加载到内存里,根据 XNU 的注释,分 segment 是为了做数据对齐(segment alignment)以优化换页效率,下文分析 section 结构体时会讲到。

1.1 Header 部分

Header 是定长的,在 64 位 Mach-O 中表现为 mach_header_64 结构体。

struct mach_header_64 {
    uint32_t    magic;      /* mach magic number identifier */
    cpu_type_t  cputype;    /* cpu specifier */
    cpu_subtype_t   cpusubtype; /* machine specifier */
    uint32_t    filetype;   /* type of file */
    uint32_t    ncmds;      /* number of load commands */
    uint32_t    sizeofcmds; /* the size of all the load commands */
    uint32_t    flags;      /* flags */
    uint32_t    reserved;   /* reserved */
};
  • magic: 大小端兼容性之用,MH_MAGIC_64 就是编译的文件和系统是同样的 byte order,MH_CIGAM_64 则是反过来。原因是曾经兼容 PPCIntel 等多种 CPU,有兴趣的同学可以阅读: macOS 内核之 OS X 系统的起源
  • cputype: CPU 类型定义,CPU_TYPE_POWERPC 用于 PowerPC CPU,CPU_TYPE_I386 就是 Intelx86,当然还有 iPhone 的 CPU_TYPE_ARM
  • cpusubtype: 属于 cputype 的细分,比如 i386 全部支持 CPU_SUBTYPE_I386_ALL,或者只支持 armv7CPU_SUBTYPE_ARM_V7
  • filetype: 文件类型,决定了这个 Mach-O 文件的布局,定义从 MH_OBJECT 0x1MH_DSYM 0xa
    • MH_OBJECT: 编译过程产生的中间文件,这个文件比较特殊,其他文件分了多个 segmentsection 但是这家伙只有一个 segment,把所有的 section 都塞进去。这个中间文件可以在 DerivedData/JustFocus-xxxx/Build/Intermediates.noindex/JustFocus.build/Debug/JustFocus\ Helper.build/Objects-normal/x86_64/ 里面找到。
    • MH_EXECUTE: 标准可执行文件
    • MH_BUNDLE: 动态库,macOS 上跟资源文件打包为 .bundle.plugin,比如 /System/Library/Audio/Plug-Ins/HAL/AirPlay.driver/Contents/MacOS/AirPlay。本质上是动态库,Unix-like 系统叫做 .so,但是在 macOS 历史上曾经有点特殊,可以参考macOS 上 bundle (.so) 和 dylib 的区别
    • MH_DYLIB: 动态库,比如 /System/Library/Frameworks/AppKit.framework/AppKit 就是 MH_DYLIB 类型。
    • MH_PRELOAD: 不在内核运行的特殊文件格式,比如内核还没加载前就要执行的 Bootloader,参考 macOS 内核之系统如何启动?
    • MH_CORE: core 文件,程序 crash 的时候保存地址空间里的数据,服务端开发的朋友应该很熟悉。不过 macOS 默认不会把 core 信息 dump 到 /cores/ 目录,而是产生 crash log 放在 /Library/Logs/DiagnosticReports。可以参考这里打开 core dump.
    • MH_DYLINKER: 动态链接器类型,一般我们写的 App 都是用系统的 /usr/lib/dyld,这个文件就是 MH_DYLINKER 类型。
    • MH_DSYM: 编译后的 .dSYM 包里最主要的就是用 Mach-O 文件存储的 symbol 信息,比如 Alamofire.framework.dSYM/Contents/Resources/DWARF/Alamofire 就是 MH_DSYM 类型的 Mach-O 文件。
  • ncmds: load commands 个数
  • sizeofcmds: load commands 总长度
  • flags: 这里面有一堆 flags,大部分是跟编译相关的,我也没全部学明白,所以干脆不描述了,感兴趣的朋友可以看这里
  • reserved: 应该只用来做字节对齐了
    mh64->reserved = 0;     /* 8 byte alignment */
    

1.2 Segment/Section

Mach-O 文件中,读完 Header 和 Load Commands 之后,就是各种 Data 数据了,这些数据是以 segment 组织的。

一个 segment 有起始和终止的 offset,该范围内的数据就是 segment 的数据。segment 的标识是 segment name,宏以 SEG_ 开头。

但是 segment 的数据没有带上起始终止之类的信息,这些信息是在 Load Commands 中定义的。比如 LC_SEGMENT_64 会定义某个 segment 从哪里开始到哪里结束,名字是什么,虚拟内存的属性(比如 read-only),有多少个 section 等等,相当于一个索引,我们要获得有意义的数据就得先解析 Load Commands 然后再去读取对应的数据。

segment 的数据会被 dyld 根据 LC 的布局信息加载到内存里,所以 segment 都是按页对齐的。在 x86 上一页是 4096 bytes 也即 4 KB

segment 做按页对齐其实就是把它所包含的所有 section 加起来除以 4 KB,不能整除就在最后一个 section0

理论上 Mach-O 文件里的 segment 有多大,加载后就会占多少的虚拟内存。但是实际上一个 segment 有可能在加载后比它在 Mach-O 里的数据大,比如 __PGAEZERO 这个 segment。在 Mach-O 里它其实是空的,只在 Load Command 记录了一个索引信息,但是加载到内存的时候,内核会给我们的 App 的地址开始端 0x0 分配一个空的页(到 0x1000)。这个空的内存页不带内存保护(声明为 VM_PROT_NONE),不可读写不可执行,我们平时遇到的访问野指针(NULL)就会命中这个区域,然后内核就让我们的 App crash 了。

上面 header 提到过 .o 文件比较特别,他是编译过程的中间文件(intermediate object file),出于文件大小的考虑,他的所有 sections 全部放在一个 segment 里面,并且这个 segment 没有名字。

1.3 Segment 的类型

segment 用名字区分,定义了这么多种:

#define    SEG_PAGEZERO    "__PAGEZERO"    /* the pagezero segment which has no */
                                        /* protections and catches NULL */
                                        /* references for MH_EXECUTE files */
#define    SEG_TEXT    "__TEXT"    /* the tradition UNIX text segment */
#define    SEG_DATA    "__DATA"    /* the tradition UNIX data segment */
#define    SEG_OBJC    "__OBJC"    /* objective-C runtime segment */
#define    SEG_ICON     "__ICON"   /* the icon segment */
#define    SEG_LINKEDIT    "__LINKEDIT"    /* the segment containing all structs */
#define SEG_IMPORT "__IMPORT"  /* the segment for the self (dyld) */
                    /* modifing code stubs that has read, */
                    /* write and execute permissions */
#define SEG_UNIXSTACK  "__UNIXSTACK"   /* the unix stack segment */

有些是历史遗留产物,对我们来说有用的字段是这些:

  • __PAGEZERO 的作用讲过了不再赘述,这个东西是由静态链接器生成的。
  • __TEXT 包含了所有的可执行代码,内存保护设置为 VM_PROT_READVM_PROT_EXECUTE。因为这一整段都是只读的,所以内核可以在内存不够的时候把这些数据换出(page out),需要的时候再换回来(page in)。
  • __DATA 可写的数据,比如 ObjC runtime 支持的库。像这样的系统库有可能被多个进程链接,因为这一段内存可写,所以写操作会触发 copy-on-write,以此实现逻辑上每个进程有一份 copy (不一定真的要 copy)。
  • __LINKEDIT 动态链接器需要用到的数据,比如 symbol table, string table 之类的

下面这些是历史:

  • __OBJC Objective-C 的 runtime 支持,历史遗留字段,现在都放进 __DATA 里面了
  • __ICON 应该是历史遗留产物,现在图标资源已经分离出去了,我们的 App 一般打包成 .app 文件夹。
  • __IMPORT i386(IA-32) 也就是 32 位 x86 架构才会用到的一个字段,64 位改用 __DATA,__la_symbol_ptr 了。
  • __UNIXSTACK 应该也是历史产物,参考这里

1.4 Sections

__TEXT__DATA 一般会包含多个 sections,这些 sections 的命名和用途也会随着系统和编译器更新而变化,想要了解全部 section 及其作用的可以参考 LLVM 项目。这里我们看几个关键 section

Segment, Section 作用
__TEXT,__text 可执行的机器码
__TEXT,__cstring 常量定义的 C strings,以 '\0' 结尾。编译器编译时会把所有 C String 合并优化,放在这个地方。
__TEXT,__const 初始化过的常量。编译器会把所有无需重定向的以 const 声明的常量放在这类。(多数编译器都把未初始化过的常量默认赋值为 0。)
__TEXT,__objc_ 开头的 以前放在 __OBJC 里 runtime 的支持,现在都放这里了。
__TEXT,__stubs 和 __TEXT,__stub_helper 动态链接需要用到的信息

想要理解完所有 __TEXT 里的 sections,你得学习 llvm 的源码。并且这些字段也经常随着系统和编译器的更新二更新,所以我选择放弃。真的需要的时候再回过来反查就行。在这一个 segment 里最重要的就是 __TEXT,__text,可执行的机器码放在这里。

Segment, Section 作用
__DATA,__data 初始化过的变量,比如一个可变的 C string 或者一个数组
__DATA,__la_symbol_prt Imported 函数的指针表,比如 libswiftFoundation.dylib 这样的动态库的符号的指针地址
__DATA,__bss 未初始化的静态变量

1.5 Load Command

load command 的定义很简单:

struct load_command {
    uint32_t cmd;       /* type of load command */
    uint32_t cmdsize;   /* total size of command in bytes */
};

cmd 就是 LC_ 开头定义的宏,非常多,我们只看关键的,全量的请参考 loader.h 里的定义。

Command 结构体 作用
LC_UUID uuid_command 编译出来的 image/dSYM 的 UUID,用于两者互相关联
LC_SEGMENT_64 segment_command_64 定义 segment
LC_SYMTAB symtab_command 定义 symbol table
LC_DYSYMTAB dysymtab_command 定义动态链接库需要用到的 symbol table
LC_UNIXTHREAD thread_command 程序的入口。现在大部分 App 都用 dyld 调起了,内核的 Mach-O 和 dyld 则还是用 LC_UNIXTHREAD 声明入口
LC_MAIN entry_point_command 程序的入口,需要配合 LC_LOAD_LINKER 使用,把该地址交给 dyld 然后由它来调起 App 的入口函数
LC_LOAD_LINKER dylinker_command 声明用到的 dy linker, iOS/Mac 一般都是 /usr/lib/dyld
LC_LOAD_DYLIB dylib_command 该 Mach-O 需要用到的动态库

通过 Load Command 获取了 segment 的 offset 和 size 之后就可以读取为 segment_command_64section_64 结构体了。

struct segment_command_64 { /* for 64-bit architectures */
    uint32_t    cmd;        /* LC_SEGMENT_64 */
    uint32_t    cmdsize;    /* includes sizeof section_64 structs */
    char        segname[16];    /* segment name */
    uint64_t    vmaddr;     /* memory address of this segment */
    uint64_t    vmsize;     /* memory size of this segment */
    uint64_t    fileoff;    /* file offset of this segment */
    uint64_t    filesize;   /* amount to map from the file */
    vm_prot_t   maxprot;    /* maximum VM protection */
    vm_prot_t   initprot;   /* initial VM protection */
    uint32_t    nsects;     /* number of sections in segment */
    uint32_t    flags;      /* flags */
};

struct section_64 { /* for 64-bit architectures /
char sectname[16]; /
name of this section /
char segname[16]; /
segment this section goes in /
uint64_t addr; /
memory address of this section /
uint64_t size; /
size in bytes of this section /
uint32_t offset; /
file offset of this section /
uint32_t align; /
section alignment (power of 2) /
uint32_t reloff; /
file offset of relocation entries /
uint32_t nreloc; /
number of relocation entries /
uint32_t flags; /
flags (section type and attributes)/
uint32_t reserved1; /
reserved (for offset or index) /
uint32_t reserved2; /
reserved (for count or sizeof) /
uint32_t reserved3; /
reserved */
};

其中比较特殊的是,最后一个 segment 也就是 __LINKEDIT 存储 link edit information,里面有 symbole table, string table, dynamic symbol table, code signature 等信息。

但是他的 LC_SEGMENT_64 里面却没有包含里面的 sections 信息,你需要配合 LC_SYMTAB 来解析 symbol table 和 string table。

// LC_SYMTAB 对应的结构体
struct symtab_command {
    uint32_t    cmd;        /* LC_SYMTAB */
    uint32_t    cmdsize;    /* sizeof(struct symtab_command) */
    uint32_t    symoff;     /* symbol table offset */
    uint32_t    nsyms;      /* number of symbol table entries */
    uint32_t    stroff;     /* string table offset */
    uint32_t    strsize;    /* string table size in bytes */
};

1.6 符号表放在哪里

没有对 Mach-O 文件的符号进行任何处理的时候,所有符号表信息都会放在 Mach-O 文件里。

我们可以用 MachOView 直接查看 Symbol Table。

这是 Just Focus Debug 版的符号表,但是 Xcode 在编译的时候默认会对 Release 版做一个优化: 把符号从 App 的 Mach-O 去掉,写进成对的 dSYM 文件。可以在你的 Xcode Project -> Build Settings -> Build Options -> Debug Information Format 看到各个 scheme 的配置。

DWARFExecutable and Linkable Format 配套的一个 Debug 数据格式。ELF 则是 Unix 的一个标准格式,多数 Unix 系统和 Linux 都采用这种格式定义可执行文件。macOS 虽然不支持 ELF 但是用了 DWARF 作为 debug 数据格式。

  • DWARF 生成 debug 信息并塞进 Mach-O 文件
  • DWARF with dSYM File 生成 debug 信息并放到配套的 dSYM 文件,以 UUID 匹配,App 的Mach-O 里不带符号信息。

2. 回答问题

  1. 如何知道一个符号在 Mach-O 文件里占用的空间?

    可以读取 LC_SYMTAB 然后在最后一个 segment 里找到 symbol table。LC_SYMTAB 数据是一个定长的 16 bytes 数据。

    然后通过 symbol tablestring table index 获取该 symbol 对应的 string,这个就不是定长的了,读到 \0 停止。所以符号的 string 越长占 Mach-O 的 size 就越大。

    2019-11-16 updated: 上面的说法是你使用 MachOView 这样的工具时,可以肉眼 filter 已知的 string 所以可以这样查。但是系统执行文件的时候,拿到的是 (__TEXT,__text) 里的一个个指针地址,crash 发生的时候内核会保存当前进程的内存空间快照,crash 时的指令地址反查 symbol 就能得到我们人能阅读的 crash 堆栈。所以如果你想要通过 string 裸读 Mach-O 文件来反查对应指针地址的话,因为 string table 里的存储是连续的 bits,没有索引就无法读出 string,所以只能解出所有结果,然后自己去 filter。

  2. 无用 class/struct 会占用 Mach-O 空间吗?

    如果是 C/C++ 的符号,编译链接时会知道这个 class/struct 没人用,直接优化删掉,等于没有。

    如果是 ObjC 的符号,则还是会保留,因为有 runtime,你不知道它到底有没有被人用。

    所以 ObjC 无用的 class/struct 在 release 下不会占用 Mach-O 的 Symbol Table/String Table 空间,但是会占用 Mach-O 的 (__TEXT,__text) 空间。

  3. foo 的所有符号会连续吗?

    不连续,link-editor 比如 dyld 可以通过读取 LC_SYMTAB, LC_DYSYMTAB 等 load command,从对应的 Symbol Table 和 Dynamic Symbol Table 找到符号。

    比如 Just Focus 有一个 Swift enum JFAppState 在 Symbol Table 上它的符号并不连续。

  4. 什么符号可以从 Mach-O 去掉?

    默认情况下所有符号都会保留在 Mach-O 里,这样调试的时候就能显示全部符号,但是如前所述发布版本并不需要这些符号,完全可以去掉以节省空间。Xcode 对 Strip Style 也提供了多个选项可供设置: Build Settings -> Deployment -> Strip Style

    • All Symbols 全部删掉
    • Non-Global Symbols 删除全局符号以外的所有符号,保留外部符号(动态库)
    • Debugging Symbols 删除 Debug 符号,保留本地符号和全局符号

    单独编译静态库是无法 Strip All Symbols 的,不然你引用这个静态库链接器就不知道该怎么链接了。但是打包成一个完成 App 的时候,静态库的符号可以被去掉。

    理论上动态库的符号无法去掉,但是编译器可以根据你调用的方法进行优化,只保留用到的符号。但是 ObjCruntime,应该无法确定哪些符号用到哪些没有。llvm 用到的链接器 ld 提供了 -strip-unneeded 的选项,不过我还不知道他是怎么实现的,大概要把编译原理从头学一下然后再学一遍 llvm 才知道了。

3. 小结

主流操作系统 Unix-like, Windows 和 macOS 虽然各有自己可执行文件的格式,但是设计上大同小异。

Mach-O 文件格式随着系统与编译器的升级加入和删除了很多古老的 segment 或者 section,而这些特性都需要编译器(llvm)与执行环境(xnu)的配合开发。

作为一个编译后的产物,Mach-O 里的字段有很多跟编译器的优化相关。这些字段如果要一个个理解清楚需要很多时间,并且需要熟悉编译原理以及 llvm 自家的特性(毕竟很多优化都是独有的)。所以没有必要细究每一个字段的作用,真的用到的时候再查就行了。

但是以鸟瞰的视角了解 Mach-O 文件的结构,对于理解一些古怪的问题还是很有帮助的。

参考资料

Tags: