什么是Mach-O文件
摘自维基百科的简要说明:
Mach-O 是Mach目标文件格式的缩写,是一种用于可执行文件、目标代码、共享库、动态加载代码和核心转储的文件格式。作为a.out格式的替代品,Mach-O提供了更强的扩展性,并提升了符号表中信息的访问速度。大多数基于Mach内核的系统都使用Mach-O。而同样使用GNU Mach作为其微内核的GNU Hurd系统则使用ELF而非Mach-O作为标准的二进制文件格式。
Mach-O格式
典型的Mach-O文件由三个区域组成:
- Mach-O header:包含有关二进制文件信息:字节顺序(magic)、cpu类型、加载命令数量等。
- Load Commands:它是一种目录,描述段(segment)的位置、符号表、动态符号表等。每一个加载命令都包含一个元信息,例如命令的类型,名称,二进制文件位置等
- Data:通常是目标文件中最大的部分,它包含代码和数据,如符号表、动态符号表等
这是一个简化的图形表示
OS X 上有两种类型的目标文件:Mach-O文件和通用二进制文件,也称为 Fat 文件。它们之间的区别:Mach-O 文件只包含一种架构(i386、x86_64、arm64等)的目标代码,而Fat二进制文件包含多个目标文件,因此包含不同的架构(i386和x86_64、arm和arm64、ETC).
Fat 文件的结构非常简单:fat 标头后跟 Mach-O 文件:
可以通过命令otool工具查看Fat_headers结构如下:
otool -fv Safari
可以通过otool -fv命令
$ otool -fv WeChat
Fat headers
fat_magic FAT_MAGIC
nfat_arch 2
architecture x86_64
cputype CPU_TYPE_X86_64
cpusubtype CPU_SUBTYPE_X86_64_ALL
capabilities 0x0
offset 16384
size 115611136
align 2^14 (16384)
architecture arm64
cputype CPU_TYPE_ARM64
cpusubtype CPU_SUBTYPE_ARM64_ALL
capabilities 0x0
offset 115638272
size 104958176
align 2^15 (32768)
Mach-O header
每一个Mach-O文件的开头都有一个header结构。用于将文件标识为Mach-O文件,Header还包含二进制文件信息.
Mach-O header是mach-header-64(或32位mach-headeer)类型的结构,定义在mach-o/loader.h文件中:
struct mach_header_64 {
uint32_t magic;/*魔数,快速定位是属于64位还是32位*/
cpu_type_t cputype; /*cpu类型,比如ARM*/
cpu_subtype_t cpusubtype;/*机器说明符*/
uint32_t filetype;/*文件类型,比如"EXECUTE(0x2)"、DYLIB、BUNDLE*/
uint32_t ncmds; /*load command数量*/
uint32_t sizeofcmds;/*所有load comands大小*/
uint32_t flags;/*标识位标识二进制文件支持的功能,主要是加载、链接有关*/
uint32_t reserved;/*预留字段*/
}
特别值得注意的是filetype成员,它描述了文件的类型,从mach-o/loader.h文件中,我们可以看到一下几个值
| 值 | 描述 |
|---|---|
| MH_OBJECT | 可重定位目标文件(.a) |
| MH_EXECUTE | 标准Mach-O可执行文件 |
| MH_DYLIB | 一个Mach-O动态链接库(.dylib) |
| MH_BUNDLE | 一个Mach-O包(.bundle) |
| MH_DSYM | 一个符号文件(.dsym) |
| MH_KEXT_BUNDLE | 一个内核扩展程序的可执行文件 |
要查看Mach-O文件的内容,可以使用/usr/bin/otool命令行,例如,要解析Mach-O的header头,使用-hv执行otool
$ otool -hv testOtool
testOtool:
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 21 2264 NOUNDEFS DYLDLINK TWOLEVEL PIE
如果你更喜欢UI,可以使用MachOView程序查看.
Load commands
在Mach-O header之后是二进制文件的加载命令,它指示(命令)动态加载器(dyld)如何在内存中加载和布局二进制文件。
加载命令可以指定:
- 文件在虚拟内存中的初始布局
- 符号表的位置(用于动态链接)
- 程序主线程的初始执行状态
- 包含主线程定义的共享库的名称
我们可以通过otool 使用-l标志查看Mach-O二进制文件的加载命令
Load command 0
cmd LC_SEGMENT_64
cmdsize 72
segname __PAGEZERO
vmaddr 0x0000000000000000
vmsize 0x0000000100000000
fileoff 0
filesize 0
maxprot 0x00000000
initprot 0x00000000
nsects 0
flags 0x0
Load command 1
cmd LC_SEGMENT_64
cmdsize 712
segname __TEXT
vmaddr 0x0000000100000000
vmsize 0x0000000000004000
fileoff 0
filesize 16384
maxprot 0x00000005
initprot 0x00000005
nsects 8
flags 0x0
Section
sectname __text
segname __TEXT
addr 0x0000000100003e94
size 0x000000000000009c
offset 16020
align 2^2 (4)
reloff 0
nreloc 0
flags 0x80000400
reserved1 0
reserved2 0
....
加载命令都以struct load_command结构开始,在mach-o/loader.h定义
struct load_command {
uint32_t cmd; /*加载命令类型*/
uint32_t cmdsize; /* 命令大小 */
};
这里,load_command.cmd 描述了加载命令的类型,而加载命令的大小在load_command.cmdsize中指定。加载命令的数据紧跟在load_command结构之后。常见的命令类型如下:
| 命令类型 | 描述 |
|---|---|
| LC_SEGMENT_64 | 将文件中(32位或64位)的段映射到内存中 |
| LC_DYLD_INFO_ONLY | 动态链接相关信息,动态链接器会根据它来进行地址重定向 |
| LC_SYMTAB | 文件所使用的符号表,包含符号表偏移量、符号数、字符串表偏移量、字符串表大小 |
| LC_DYSYMTAB | 动态链接器所使用的符号表,找到后获取间接符号表偏移量 |
| LC_LOAD_DYLINKER | 默认的加载器路径(/usr/bin/dyld) |
| LC_UUID | 文件的UUID,用于分析对应的崩溃位置,在dSYM符号文件和崩溃堆栈中都存在这个值。 |
| LC_VERSION_MIN_MACOSX | 支持最低的操作系统版本 |
| LC_SOURCE_VERSION | 构建二进制文件的源代码版本 |
| LC_MAIN | 程序的入口。动态链接器获取该地址,然后程序跳转到该处执行 |
| LC_LOAD_DYLIB | 依赖的动态库,包括动态库路径、当前版本、兼容版本等 |
| LC_RPATH | @rpath的路径,指定动态链接器搜索路径列表,以便定位框架(framework) |
| LC_FUNCTION_STARTS | 函数起始地址表 |
| LC_CODE_SIGNATURT | 代码签名 |
LC_SEGMENT/LC_SEGMENT_64 命令:它描述了一个段
Apple以下列方式定义分段:
段定义在Mach-O文件中的字节范围以及当动态链接器(dyld)加载应用程序时这些字节映射到虚拟内存的地址和内存保护属性。
如下图所示,LC_SEGMENT/LC_SEGMENT_64加载命令包含动态加载器(dyld)将段映射到内存(并设置其内存权限)的所有相关信息:
在分析Mach-O二进制文件时可能会遇到如下几个段:
__PAGEZERO: 静态链接器创建了__PAGEZERO作为可执行文件的第一个段,该段在虚拟内存中的位置及大小都为0,不能读写、不能执行,用来处理空指针。开发者尝试访问NULL指针时,会得到一个EXC_BAD_ACCESS错误__TEXT段:包含可执行代码和只读数据,静态链接器设置该段的虚拟内存权限为可读、可执行,进程被允许执行这些代码,但是不能修改__DATA段:包含可写数据,静态链接器设置该段的虚拟内存权限为可读写__LINKEDIT段:包含了动态链接库的原始数据,如符号、字符串和重定位表条目等
如果二进制文件是用Objective-C编写的,可能有一个__OBJC段,其中包含Objective-C运行时的信息,尽管此信息也可以在__DATA段中找到.
注意:段可以包含0个或者多个section,每一个section包含相同类型的代码或者数据
此加载命令是segment_command类型的结构:
/*
* The segment load command indicates that a part of this file is to be
* mapped into the task's address space. The size of this segment in memory,
* vmsize, maybe equal to or larger than the amount to map from this file,
* filesize. The file is mapped starting at fileoff to the beginning of
* the segment in memory, vmaddr. The rest of the memory of the segment,
* if any, is allocated zero fill on demand. The segment's maximum virtual
* memory protection and initial virtual memory protection are specified
* by the maxprot and initprot fields. If the segment has sections then the
* section structures directly follow the segment command and their size is
* reflected in cmdsize.
*/
struct segment_command { /* for 32-bit architectures */
uint32_t cmd; //当前加载命令的类型
uint32_t cmdsize; //表示当前加载命令的大小
char segname[16]; //段名称,占用16字节
uint32_t vmaddr; //段的虚拟内存地址
uint32_t vmsize; //段的虚拟内存大小
uint32_t fileoff; //段在文件中的偏移量
uint32_t filesize; //段在文件中的大小
vm_prot_t maxprot; //段页面的最高内存保护(r、w、x)
vm_prot_t initprot; //段页面的初始内存保护(r、w、x)
uint32_t nsects; //段中包含节的数量。一个段可以包含0个或者多个节
uint32_t flags; //段的标志信息
};
系统从fileoff处加载filesize大小的内容到虚拟内存vmaddr处,大小为vmsize,段页面的权限由initprot进行初始化,这些权限可以被修改,但是不能超过maxprot的值。
正如Apple所指出的,每个Section都包含相同的类型的代码或者数据:
一个Mach-O二进制文件被组织成段,每一个段包含一个或者多个section,如下图所示:
__TEXT段包含可执行代码和只读数据.
此部分中常见的section可能包含:
- __text: 编译的二进制代码
- __stubs、__stub_helper:用于帮助动态链接器绑定符号。
- __const: const关键字修饰的常量
- __cstring: 字符串常量
- __objc_methname:OC方法名
- __objc_classname:OC类名。
- __objc_methtype:OC方法类型(方法签名)
- __gcc_except_tab、__ustring、__unwind_info:GCC编译器自动生成,用于确定异常发生时栈所对应的信息(包括栈指针、返回地址及寄存器信息等)
__DATA段包含可写的数据,此部分中常见的section可能包含
- __got:全局非懒绑定符号指针表。
- __la_symbol_ptr:懒绑定符号指针表。
- __mod_init_func:C++类的构造函数。
- __const:未初始化过的常量。
- __cfstring:Core Foundation字符串。
- __objc_classlist:OC类列表。
- __objc_nlclslist:实现了+load方法的Objective-C类列表。
- __objc_catlist:OC分类(Category)列表。
- __objc_protolist:OC协议(Protocol)列表。
- __objc_imageinfo:镜像信息,可用它区别Objective-C 1.0与2.0。
- __objc_const:OC初始化过的常量。
- __objc_selrefs:OC选择器(SEL)引用列表。
- __objc_protorefs:OC协议引用列表。
- __objc_classrefs:OC类引用列表。
- __objc_superrefs:OC超类(即父类)引用列表。
- __objc_ivar:OC类的实例变量。
- __objc_data:OC初始化过的变量。
- __data:实际初始化数据段。
- __common:未初始化过的符号声明。
- __bss:未初始化的全局变量。
为了加快程序启动速度,iOS引入了“懒绑定”和“非懒绑定”的概念,这属于动态链接器加载Mach-O的范畴,读者大致了解一下即可。
- 非懒绑定:在动态链接器加载程序的时候就会绑定真实调用地址,之后直接使用即可。可将其理解为“主动绑定”。
- 懒绑定:只有方法被调用的时候才会去寻找对应的调用地址,然后再执行。可将其理解为“被动绑定”。
此数据结构是struct section/section_64,在mach-o/loader.h定义。
struct section_64 { /* for 64-bit architectures */
char sectname[16]; //节的名称,占用16字节
char segname[16]; //节所属的段名称,占用16字节
uint64_t addr; //映射到虚拟内存的地址
uint64_t size; //节的文件偏移地址
uint32_t offset; //节的文件偏移地址
uint32_t align; //节的字节对齐大小(2的n次幂)
uint32_t reloff; //重定位入口的文件偏移
uint32_t nreloc; //需要重定位的入口数量
uint32_t flags; //节的类型和属性
uint32_t reserved1; /* reserved (for offset or index) */
uint32_t reserved2; /* reserved (for count or sizeof) */
uint32_t reserved3; /* reserved */
};
LC_MAIN 命令:设置程序主线程入口地址和栈大小
一旦二进制文件被加载到内存中(通过动态链接器/加载器dyld),执行从二进制文件的入口点开始,那么dyld如何定位入口点呢?通过LC_MAIN命令!!!
此加载命令是entry_point_command类型的结构:
/*
* The entry_point_command is a replacement for thread_command.
* It is used for main executables to specify the location (file offset)
* of main(). If -stack_size was used at link time, the stacksize
* field will contain the stack size need for the main thread.
*/
struct entry_point_command {
uint32_t cmd; /* LC_MAIN 仅用于 MH_EXECUTE 文件 */
uint32_t cmdsize; /* 24 */
uint64_t entryoff; /* main()文件(__TEXT)的偏移量 */
uint64_t stacksize;/* 如果不为0,则初始化堆栈大小 */
};
LC_MAIN加载命令中最重要的成员是entryoff,它包含二进制入口点的偏移量。在加载时,dyld只需要将偏移量添加到二进制内存中基地址中,然后跳转到该指令以启动二进制代码的执行。
Mach-O文件可以包含一个或者多个构造函数,这些构造函数将在LC_MAIN中指定的地址之前执行,任何构造函数的偏移量都保存在__DATA_CONST段的section(__mod_init_func)中。
LC_LOAD_DYLIB 命令:加载链接动态库
LC_LOAD_DYLD加载命令描述了一个动态库依赖,它指示加载器(dyld)加载和链接所述库。Mach-O文件依赖的每一个库都有一个LC_LOAD_DYLIB加载命令。
此命令的结构是dylib_command类型的结构(其中包含struct dylib,描述实际依赖的动态库)
struct dylib_command {
uint32_t cmd; /* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB,
LC_REEXPORT_DYLIB */
uint32_t cmdsize; /* 命令大小,包含库的路径名*/
struct dylib dylib; /* 动态库的标识 */
};
struct dylib {
union lc_str name; //库的路径名
uint32_t timestamp; //依赖库构建时的时间戳
uint32_t current_version; //库的当前版本
uint32_t compatibility_version; //库的兼容性版本
};
要查看Mach_O文件依赖的库,我们可以使用otool -L查看或者MachOView程序。
$ otool -L testOtool
testOtool:
/System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 1858.112.0)
/usr/lib/libobjc.A.dylib (compatibility version 1.0.0, current version 228.0.0)
/usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1311.100.3)
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (compatibility version 150.0.0, current version 1858.112.0)
该结构对应的命令还可能是LC_LOAD_WEAK_DYLIB,它们都表示需要加载一个动态库。通过LC_LOAD_WEAK_DYLIB声明的依赖库是可选的,如果缺少这些库也没有什么影响,主程序会继续执行;而LC_LOAD_DYLIB则不同,依赖库若是没有找到,加载器会放弃并结束该进程。
Data 数据区
load Commands之后是Mach-O二进制文件的Data数据区,主要是由实际的二进制代码组成,这些数据被组织成段,由LC_SEGMENT/LC_SEGMENT_64加载命令描述,可以包含多个section.
Symbol Table符号表
符号表是将地址和符号联系起来的桥梁。符号表并不能直接存储符号,而是存储符号位于字符串表的位置。
Symbol table 由谁定义呢? 或者说,链接器是如何找到 symbol table 的呢? 链接器是通过 LC_SYMTAB 这个 load command 找到 symbol table 的,LC_SYMTAB 对应的 command 结构体如下:
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 */
};
这个命令告诉了链接器(无论是本文所述的静态链接器,还是后面博客要提到的动态链接器) symbol table 和 string table 的位置信息;symtab_command这个结构体比较简单,symoff和nsyms指示了符号表的位置和条目,stroff和strsize指示了字符串表的位置和长度。
每个 symbol entry 长度是固定的,其结构由内核定义,
struct nlist_64 {
union {
uint32_t n_strx; /* index into the string table */
} n_un;
uint8_t n_type; /* type flag, see below */
uint8_t n_sect; /* section number or NO_SECT */
uint16_t n_desc; /* see <mach-o/stab.h> */
uint64_t n_value; /* value of this symbol (or stab offset) */
};
结构体nlist_64(或nlist)描述了符号的基本信息,xnu 用 5 个字段描述了symbol信息,其中n_un、n_sect、n_value比较容易理解:
-
n_un,符号的名字(在一个 Mach-O 文件里,具有唯一性) -
n_sect,符号所在的 section index(内部符号有效值从 1 开始,最大为 255) -
n_value,符号的地址值(在链接过程中,会随着其 section 发生变化)
n_type和n_desc表达的意思稍微复杂点;都是多功能组合字段,其中,对于中间文件而言,n_desc没啥意义,此乃个人理解。 如下关于n_type的信息也是我的个人梳理,主要参考kernel/nlist_64和nlist.h。 n_type是一个 8 bit 的复合字段: -
bit[5:8]: 如果不为 0,表示这是一个与调试有关的符号,值意义类型详见mach-o/stab.h -
bit[4:5]: 若为 1,则表示该符号是私有的(外部符号) -
bit[1:4]: 符号类型- N_UNDF (0x0): 未定义
- N_ABS (0x2): 符号地址指向到绝对地址,链接器后期不会再修改
- N_SECT (0xe): 本地符号,即符号定义于当前 Mach-O
- N_PBUD (0xc): 预绑定符号
- N_INDR (0xa): 表示该符号和另一个符号是同一个,n_value指向到 string table,即该同名符号的名字
-
bit[0:1]: 表示这是外部符号,即该符号要么定义在外部,要么定义在本地但是可以被外部使用