因为推友问了一个问题:
@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 文件有三个主要部分:
- 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
则是反过来。原因是曾经兼容PPC
和Intel
等多种 CPU,有兴趣的同学可以阅读: macOS 内核之 OS X 系统的起源。cputype
: CPU 类型定义,CPU_TYPE_POWERPC
用于PowerPC
CPU,CPU_TYPE_I386
就是Intel
的x86
,当然还有 iPhone 的CPU_TYPE_ARM
。cpusubtype
: 属于cputype
的细分,比如i386
全部支持CPU_SUBTYPE_I386_ALL
,或者只支持armv7
的CPU_SUBTYPE_ARM_V7
。filetype
: 文件类型,决定了这个 Mach-O 文件的布局,定义从MH_OBJECT 0x1
到MH_DSYM 0xa
。MH_OBJECT
: 编译过程产生的中间文件,这个文件比较特殊,其他文件分了多个segment
和section
但是这家伙只有一个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
,不能整除就在最后一个 section
补 0
。
理论上 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_READ
和VM_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_64
和 section_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 的配置。
DWARF
是 Executable 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. 回答问题
- 如何知道一个符号在 Mach-O 文件里占用的空间?
可以读取
LC_SYMTAB
然后在最后一个segment
里找到 symbol table。LC_SYMTAB
数据是一个定长的 16 bytes 数据。然后通过
symbol table
的string 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。 -
无用
class/struct
会占用 Mach-O 空间吗?如果是
C/C++
的符号,编译链接时会知道这个class/struct
没人用,直接优化删掉,等于没有。如果是
ObjC
的符号,则还是会保留,因为有runtime
,你不知道它到底有没有被人用。所以
ObjC
无用的class/struct
在 release 下不会占用 Mach-O 的Symbol Table/String Table
空间,但是会占用 Mach-O 的(__TEXT,__text)
空间。 -
foo
的所有符号会连续吗?不连续,
link-editor
比如dyld
可以通过读取LC_SYMTAB
,LC_DYSYMTAB
等 load command,从对应的 Symbol Table 和 Dynamic Symbol Table 找到符号。比如 Just Focus 有一个 Swift enum
JFAppState
在 Symbol Table 上它的符号并不连续。 -
什么符号可以从 Mach-O 去掉?
默认情况下所有符号都会保留在 Mach-O 里,这样调试的时候就能显示全部符号,但是如前所述发布版本并不需要这些符号,完全可以去掉以节省空间。Xcode 对
Strip Style
也提供了多个选项可供设置:Build Settings -> Deployment -> Strip Style
- All Symbols 全部删掉
- Non-Global Symbols 删除全局符号以外的所有符号,保留外部符号(动态库)
- Debugging Symbols 删除 Debug 符号,保留本地符号和全局符号
单独编译静态库是无法
Strip All Symbols
的,不然你引用这个静态库链接器就不知道该怎么链接了。但是打包成一个完成 App 的时候,静态库的符号可以被去掉。理论上动态库的符号无法去掉,但是编译器可以根据你调用的方法进行优化,只保留用到的符号。但是
ObjC
有runtime
,应该无法确定哪些符号用到哪些没有。llvm
用到的链接器ld
提供了-strip-unneeded
的选项,不过我还不知道他是怎么实现的,大概要把编译原理从头学一下然后再学一遍llvm
才知道了。
3. 小结
主流操作系统 Unix-like, Windows 和 macOS 虽然各有自己可执行文件的格式,但是设计上大同小异。
Mach-O 文件格式随着系统与编译器的升级加入和删除了很多古老的 segment
或者 section
,而这些特性都需要编译器(llvm)与执行环境(xnu)的配合开发。
作为一个编译后的产物,Mach-O 里的字段有很多跟编译器的优化相关。这些字段如果要一个个理解清楚需要很多时间,并且需要熟悉编译原理以及 llvm 自家的特性(毕竟很多优化都是独有的)。所以没有必要细究每一个字段的作用,真的用到的时候再查就行了。
但是以鸟瞰的视角了解 Mach-O 文件的结构,对于理解一些古怪的问题还是很有帮助的。
参考资料
- Inside a Hello World executable on OS X — Alex Drummond
- aidansteele/osx-abi-macho-file-format-reference: Mirror of OS X ABI Mach-O File Format Reference
- Loading Bundles
- Mac OS X 10.1 Two-Level Namespace Release Notes
- MachO
- 深入理解Macho文件(二)- 消失的__OBJC段与新生的__DATA段 | SatanWoo
- Position-Independent Code
- Xcode中和symbols有关的几个设置 - 简书
- Overview of Dynamic Libraries
- Damien DeVille | Dynamic linking on iOS
- Inside a Hello World executable on OS X — Alex Drummond