Tenloy's Blog

(二) Mach-O 文件结构

Word count: 7.9kReading time: 30 min
2021/10/06 Share

上一篇说到源码经过预处理、编译、汇编之后生成目标文件,这一章介绍一下iOS、Mac OS中目标文件的格式Mach-O的结构,方便了解之后的链接生成可执行文件的过程。

先附上相关源码地址:与Mach-O 文件格式有关的结构体定义都可以从 /usr/include/mach-o/loader.h 中找到(直接在xcode项目中找到loader.h,然后Show In Finder即可)。

一、进程与二进制格式

进程在众多操作系统中都有提及,它是作为一个正在执行的程序的实例,这是 UNIX 的一个基本概念。而进程的出现是特殊文件在内从中加载得到的结果,这种文件必须使用操作系统可以认知的格式,这样才对该文件引入依赖库,初始化运行环境以及顺利地执行创造条件。

Mach-O(Mach Object File Format)是 macOS 上的可执行文件格式,类似于 Linux 和大部分 UNIX 的原生格式 ELF(Extensible Firmware Interface)。macOS 支持三种可执行格式:解释器脚本格式、通用二进制格式和 Mach-O 格式(关于三者区别,在下面说到Mach-O Header的时候介绍)。

二、相关工具

命令行工具

  • file 命令,查看Mach-O文件的基本信息:file 文件路径
  • otool 命令,查看Mach-O特定部分和段的内容
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    #查看Mach-O文件的header信息
    otool -h 文件路径

    #查看Mach-O文件的load commands信息
    otool -l 文件路径

    #查看Mach-O文件所使用到的动态库
    otool -L 文件路径

    # 更多使用方法,终端输入otool -help查看
  • lipo 命令,来处理多架构Mach-O文件,常用命令如下
    1
    2
    3
    4
    5
    6
    7
    8
    #查看架构信息
    lipo -info 文件路径

    #导出某种类型的架构
    lipo 文件路径 -thin 架构类型 -output 输出文件路径

    #合并多种架构类型
    lipo 文件路径1 文件路径2 -output 输出文件路径
  • nm命令,llvm提供,可以查看目标文件符号表里的内容:符号、对应的地址、符号的一些修饰符。
    1
    2
    OVERVIEW: llvm symbol table dumper
    USAGE: nm [options] <input files>

GUI工具

  • MachOView:文件浏览。MachOView官网
  • Hopper Disassembler、IDA Pro:反汇编工具

三、Mach-O 文件格式

Mach-O 文件格式在官方文档中有一个描述图,很多教程中都引用到。官网文档

12

可以看的出 Mach-O 主要由 3 部分组成,下面一一讲述。

3.1 示例

用 helloworld 来做个试验:

1
2
3
4
5
6
7
/// main.cpp
#import <stdio.h>

int main() {
printf("hello");
return 0;
}

使用 clang -g main.cpp -o main 生成执行文件。然后拖入到 MachOView 中来查看一下加载 Segment 的结构(当然使用 Synalyze It! 也能捕捉到这些信息的,但是 MachOView 更对结构的分层更加一目了然):

12

3.2 Mach-O 头

Mach-O 头(Mach Header)描述了 Mach-O 的 CPU 架构、大小端、文件类型以及加载命令等信息。它的作用是让内核在读取该文件创建虚拟进程空间的时候,检查文件的合法性以及当前硬件的特性是否能支持程序的运行。

以下只给出 64 位定义的代码,因为 32 位的区别是缺少了一个预留字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
#define MH_MAGIC    0xfeedface    /* the mach magic number */
#define MH_CIGAM 0xcefaedfe /* NXSwapInt(MH_MAGIC) */

struct mach_header_64 {
uint32_t magic; /* magic(魔数):用来确认文件的格式,操作系统在加载可执行文件的时候会确认魔数是否正确,如果不正确会拒绝加载。 */
cpu_type_t cputype; /* CPU架构 */
cpu_subtype_t cpusubtype; /* CPU子版本 */
uint32_t filetype; /* 文件类型,常见的Mach-O文件有:MH_OBJECT(目标文件)、MH_EXECUTABLE(可执行二进制文件)、MH_DYLIB(动态库)等等。这些文件类型定义在 loader.h 文件中同样可以找到 */
uint32_t ncmds; /* 加载器中加载命令的数量 */
uint32_t sizeofcmds; /* 加载器中所有加载命令的总大小 */
uint32_t flags; /* dyld 加载需要的一些标志,其中MH_PIE表示启用地址空间布局随机化(ASLR)。其他的值在loader.h文件中同样可以找到 */
uint32_t reserved; /* 64位的保留字段 */
};

魔数会表明文件的格式。filetype会表明具体是什么文件类型(都是猫,也分黑猫、白猫)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// magic:常见的魔数(Mac是小端模式)

// Mach-O文件。用途:macOS 的原生二进制格式
#define MH_MAGIC 0xfeedface /* 32位设备上的魔数,大端模式(符合人类阅读习惯,高位数据在前) */
#define MH_CIGAM 0xcefaedfe /* 32位、小端(高位地址在后),CIGAM就是MAGIC反过来写,从命名上也可以看出端倪 */
#define MH_MAGIC_64 0xfeedfacf /* 64位、大端 */
#define MH_CIGAM_64 0xcffaedfe /* 64位、小端 */

//通用二进制格式FAT。用途:包含多种架构支持的二进制格式,只在 macOS 上支持。(在文章末尾简单介绍一下,有兴趣可以瞜一眼)
#define FAT_MAGIC 0xcafebabe
#define FAT_CIGAM 0xbebafeca /* NXSwapLong(FAT_MAGIC) */
#define FAT_MAGIC_64 0xcafebabf
#define FAT_CIGAM_64 0xbfbafeca /* NXSwapLong(FAT_MAGIC_64) */

//脚本格式。用途:主要用于 shell 脚本,但是也常用语其他解释器,如 Perl, AWK 等。也就是我们常见的脚本文件中在 `#!` 标记后的字符串,即为执行命令的指令方式,以文件的 stdin 来传递命令。
// 魔数为 \x7FELF

// filetype:常见的Mach-O格式的文件类型
#define MH_OBJECT 0x1 /* 可重定位的目标文件 */
#define MH_EXECUTE 0x2 /* 可执行二进制文件 */
#define MH_DYLIB 0x6 /* 动态绑定共享库 */
#define MH_DYLINKER 0x7 /* 动态链接编辑器,如dyld */
#define MH_BUNDLE 0x8 /* 动态绑定bundle(包)文件 */
#define MH_DSYM 0xa /* 调试所用的符号文件 */

举例:利用otool工具查看Mach-o文件的头部

1
2
3
4
5
6
7
8
$ otool -hv bibi.decrypted 
Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC ARM V7 0x00 EXECUTE 59 6016 NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE

Mach header
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 ARM64 ALL 0x00 EXECUTE 59 6744 NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE

3.3 Load Command

概述

Mach-O文件头中包含了非常详细的指令,这些指令被称为“加载指令”,在被调用时清晰地指导了如何设置并加载二进制数据。

  • Load Command的最终目标只有一个,就是:指导内核加载器、动态链接器怎么将可执行文件装载到内存进行执行
  • 每条命令都会描述其指向(或者是结构体中直接包含的)的数据是什么类型、数据的大小、用途等,内核加载器/动态链接器拿到这些数据之后,需要去做相应的处理。详细可见下面图片中命令 - 用途的说明。

这些指令,或称为“加载命令”,紧跟在基本的mach_header之后。

每一条命令,在load.c文件中,都有对应的结构体,来记录信息。共同点是都采用“类型-长度-值”的格式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
struct xxx_command {
uint32_t cmd; /* 32位的cmd值(表示类型) ,下面列举了部分 */
uint32_t cmdsize; /* 32位的cmdsize值(32位二进制为4的倍数,64位二进制为8的倍数) */
... /* 记录命令本身的一些信息 */
}

// 下面列举一些load command的类型(对应的cmd值),这里只列举了部分,全面的可以看源码,总共50多种load command。按照加载命令是由内核加载器、动态链接器处理分开记录。
// 内核加载器处理的加载命令:
#define LC_SEGMENT 0x1 /* 定义一个段(Segment),加载后被映射到内存中,包括里面的节(Section) */
#define LC_LOAD_DYLINKER 0xe /* 默认的加载器路径。通常路径是“/usr/lib/dyld” */
#define LC_UUID 0x1b /* 用于标识Mach-0文件的ID,匹配二进制文件与符号表。在分析崩溃堆栈信息能用到,通过地址在符号表中找到符号 */
#define LC_CODE_SIGNATURE 0x1d /* 代码签名信息 */
#define LC_ENCRYPTION_INFO_64 0x2C /* 文件是否加密的标志,加密内容的偏移和大小 */

// 动态链接器处理的加载命令:
#define LC_SYMTAB 0x2 /* 为文件定义符号表和字符串表,在链接文件时被链接器使用,同时也用于调试器映射符号到源文件。符号表定义的本地符号仅用于调试,而已定义和未定义的 external 符号被链接器使用 */
#define LC_DYSYMTAB 0xb /* 将符号表中给出符号的额外符号信息提供给动态链接器。 */
#define LC_LOAD_DYLIB 0xc /* 加载动态链接库 */
#define LC_ID_DYLIB 0xd /* 动态库的标识,包括动态库名称、当前版本号、兼容版本号。可以使用“otool-L xxx”命令查看 */
#define LC_RPATH (0x1c | LC_REQ_DYLD) /* RunpathSearchPaths,@rpath搜索的路径 */
#define LC_DYLD_INFO 0x22 /* compressed dyld information */
#define LC_DYLD_INFO_ONLY (0x22 | LC_REQ_DYLD) /* 记录了有关链接的重要信息,包括在__LINKEDIT中动态链接相关信息的具体偏移和大小。ONLY表示这个加载指令是程序运行所必需的,如果旧的链接器无法识别它,程序就会出错 */
#define LC_VERSION_MIN_IPHONEOS 0x25 /* 系统要求的最低版本 */
#define LC_FUNCTION_STARTS 0x26 /* 函数起始地址表,使调试器和其他程序能很容易地看到一个地址是否在函数内 */
#define LC_MAIN (0x28 | LC_REQ_DYLD) /* 程序的入口。dyld获取该地址,然后跳转到该处执行。replacement for LC_UNIXTHREAD */
#define LC_DATA_IN_CODE 0x29 /* 定义在代码段内的非指令的表 */
#define LC_SOURCE_VERSION 0x2A /* 构建二进制文件的源代码版本号 */

有一些命令是由内核加载器(定义在bsd/kern/mach_loader.c文件中) 直接使用的, 其他命令是由动态链接器处理的。

12 12

在Mach-O文件加载解析时,多个Load Command会告诉操作系统应当如何加载文件中每个Segment的数据,对系统内核加载器和动态链接器起引导作用。(不同的数据对应不同的加载命令,可以看到segment_command_64symtab_commanddylib_command等,下面我们会讲解Segment的加载命令,下一节讲静态链接时,会涉及符号表symtab的加载命令)。

下面,以几个内核加载器负责解析处理的load command,来简单看下:

3.3.1 LC_SEGMENT(进程虚拟内存设置)

LC_SEGMENT(或LC_SEGMENT_64) 命令是最主要的加载命令,这条命令指导内核如何设置新运行的进程的内存空间。这些“段”直接从Mach-O二进制文件加载到内存中。

每一条LC_SEGMENT[64] 命令都提供了段布局的所有必要细节信息。关于常见的LC_SEGMENT命令,可以见下面3.4.1 节的讲述,介绍了几个重要的段、其数据结构成员变量。

有了LC_SEGMENT命令,设置进程虚拟内存的过程就变成遵循LC_SEGMENT命令的简单操作。

  • 对于每一个段,将文件中相应的内容加载到内存中:从偏移量为 fileoff 处加载 filesize 字节到虚拟内存地址 vmaddr 处的 vmsize 字节
  • 每一个段的页面都根据 initprot 进行初始化,initprot 指定了如何通过读/写/执行位初始化页面的保护级别。段的保护设置可以动态改变,但是不能超过 maxprot 中指定的值(在iOS中,+x和+w是互斥的)。

3.3.2 LC_LOAD_DYLIB与LC_ID_DYLIB

LC_LOAD_DYLIB:

  • 可执行文件(MH_EXECUTE类型)的Mach-O都会存在LC_LOAD_DYLIB类型的Load Command,该Load Command指定了当前Mach-O需要依赖的动态库(可以是系统的动态库也可以是开发者创建的动态库)。
  • LC_LOAD_DYLIB类型的Load Command在内存中对应struct dylib_command结构,dylib_command结构包含struct dylib结构,struct dylib结构中name字段标记了动态库的路径。那么这个路径是从哪里来的呢?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/*
动态链接库由两个东西标识。
路径名(为执行而找到的库的名称)和兼容性版本号。 路径名必须匹配,并且库的用户中的兼容性号必须大于或等于正在使用的库。
时间戳用于记录库的构建和复制到用户的时间,因此它可以用来确定运行时使用的库是否与构建程序时使用的完全相同。
*/
struct dylib {
union lc_str name; /* library's path name */
uint32_t timestamp; /* library's build time stamp */
uint32_t current_version; /* library's current version number */
uint32_t compatibility_version; /* library's compatibility vers number*/
};

/*
动态链接库(mach header中的filetype == MH_DYLIB)包含一个 dylib_command (cmd == LC_ID_DYLIB) 来标识库。
使用动态链接库的模块包含它使用到的每个动态库的 dylib_command(cmd == LC_LOAD_DYLIB、LC_LOAD_WEAK_DYLIB 或 LC_REEXPORT_DYLIB)。
*/
struct dylib_command {
uint32_t cmd; /* LC_ID_DYLIB, LC_LOAD_{,WEAK_}DYLIB, LC_REEXPORT_DYLIB */
uint32_t cmdsize; /* includes pathname string */
struct dylib dylib; /* the library identification */
};

LC_ID_DYLIB:

  • 动态库的Mach-O是MH_DYLIB类型的,一个动态库中必须包含一个LC_ID_DYLIB类型的Load Command(一般位于__LINKEDIT之后),它在内存中也是一个struct dylib_command结构,会有name字段。
  • 这里的dylib_command信息会在LINK时作为LC_LOAD_DYLIB类型的Load Command插入进可执行文件(MH_EXECUTE 类型的Mach-O)中。

打开APP时,dyld会先递归遍历所有类型为LC_LOAD_DYLIB的Load Command从而查找依赖库,查找的路径即是由对应的dylib_command结构的name指定,一般为@rpath/DYNAME.framework/DYNAME。如果在指定各种查找路径都找不到,就会出现”dyld: Library not loaded”错误。

3.3.3 LC_CODE_SIGNATURE(数字签名)

Mach-O二进制文件有一个重要特性就是可以进行数字签名。尽管在 OS X 中仍然没怎么使用数字签名,不过由于代码签名和新改进的沙盒机制绑定在一起,所以签名的使用率也越来越高。在 iOS 中,代码签名是强制要求的,这也是苹果尽可能对系统封锁的另一种尝试:在 iOS 中只有苹果自己的签名才会被认可。在 OS X 中,code sign(1) 工具可以用于操纵和显示代码签名。man手册页,以及 Apple’s code signing guide 和 Mac OS X Code Signing In Depth文档都从系统管理员的角度详细解释了代码签名机制。

LC_CODE_SIGNATURE 包含了 Mach-O 二进制文件的代码签名,如果这个签名和代码本身不匹配(或者如果在iOS上这条命令不存在),那么内核会立即给进程发送一个SIGKILL信号将进程杀掉,没有商量的余地,毫不留情。

在iOS 4之前,还可以通过两条sysctl(8)命令覆盖负责强制执行(利用内核的MAC,即Mandatory AccessControl)的内核变量,从而实现禁用代码签名检查:

1
2
sysctl -w security.mac.proc_enforce = 0 //禁用进程的MAC
sysctl -w security.mac.vnode_enforce=0 //禁用VNode的MAC

而在之后版本的iOS中,苹果意识到只要能够获得root权限,越狱者就可以覆盖内核变量。因此这些变量变成了只读变量。untethered越狱(即完美越狱)因为利用了一个内核漏洞所以可以修改这些变量。由于这些变量的默认值都是启用签名检查,所以不完美越狱会导致非苹果签名的应用程序崩溃——除非i设备以完美越狱的方式引导。

此外,通过 Saurik 的 ldid 这类工具可以在 Mach-O 中嵌入伪代码签名。这个工具可以替代OS X的code sign(1),允许生成自我签署认证的伪签名。这在iOS中尤为重要,因为签名和沙盒模型的应用程序“entitlement”绑定在一起, 而后者在iOS中是强制要求的。entitlement 是声明式的许可(以plist的形式保存),必须内嵌在Mach-O中并且通过签名盖章,从而允许执行安全敏感的操作时具有运行时权限。

OS X 和 iOS 都有一个特殊的系统调用csops(#169)用于代码签名的操作

3.3.4 LC_MAIN(设置主线程入口地址)

从Mountain Lion开始,一条新的加载命令LC_MAIN替代了LC_UNIX_THREAD命令。

  • 后者的作用是:开启一个unix线程,初始化栈和寄存器,通常情况下,除了指令指针(Intel的IP)或程序计数器(ARM的r15)之外,所有的寄存器值都为0。
  • 前者作用是设置程序主线程的入口点地址和栈大小

这条命令比LC_UNIXTHREAD命令更实用一些, 因为无论如何除了程序计数器之外所有的寄存器都设置为0了。由于没有LC_UNIXTHREAD命令, 所以不可以在之前版本的 OS X 上运行使用了LC_MAIN的二进制文件(在加载时会导致dyld(1)崩溃)。

12

LC_Main对应的加载命令如下,记录了可执行文件的入口函数int main(int argc, char * argv[])的信息:

1
2
3
4
5
6
struct entry_point_command {
uint32_t cmd; /* LC_MAIN only used in MH_EXECUTE filetypes */
uint32_t cmdsize; /* 24 */
uint64_t entryoff; /* file (__TEXT) offset of main() */
uint64_t stacksize; /* if not zero, initial stack size */
};

从定义上可以看到入口函数的地址计算:Entry Point = vm_addr(__TEXT) + entryOff + Slide

dyld的源码里能看到对Entry Point的获取和调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
dyld
▼ __dyld_start // 源码在dyldStartup.s这个文件,用汇编实现
▼ dyldbootstrap::start() // dyldInitialization.cpp
▼ dyld::_main()
//函数的最后,调用 getEntryFromLC_MAIN,从 Load Command 读取LC_MAIN入口,如果没有LC_MAIN入口,就读取LC_UNIXTHREAD,然后跳到主程序的入口处执行

namespace dyldbootstrap {

uintptr_t start(const dyld3::MachOLoaded* appsMachHeader, int argc, const char* argv[],
const dyld3::MachOLoaded* dyldsMachHeader, uintptr_t* startGlue) {
//
// Entry point for dyld. The kernel loads dyld and jumps to __dyld_start which
// sets up some registers and call this function.
//
// Returns address of main() in target program which __dyld_start jumps to
//
uintptr_t
_main(const macho_header* mainExecutableMH, uintptr_t mainExecutableSlide,
int argc, const char* argv[], const char* envp[], const char* apple[],
uintptr_t* startGlue) {
// find entry point for main executable
result = (uintptr_t)sMainExecutable->getEntryFromLC_MAIN();
return result;
}
}

}

这里简单看一下这几种load command所表示的信息。关于进程地址空间分布、线程入口在第四节 —— 装载会从进程启动到运行详细梳理一下流程。

3.4 Data

数据区(Data):Data 中每一个段(Segment)的数据都保存在此,段的概念和 ELF 文件中段的概念类似,都拥有一个或多个 Section ,用来存放数据和代码。

Raw segment data存放了所有的原始数据,而Load commands相当于Raw segment data的索引目录

3.4.1 Segment(段)

其中,LC_SEGMENT_64定义了一个64位的段,当文件加载后映射到地址空间(包括段里面节的定义)。64位段的定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct segment_command_64 { /* for 64-bit architectures */
uint32_t cmd; /* Load Command类型,这里LC_SEGMENT_64代表将文件中64位的段映射到进程的地址空间。LC_SEGMENT_64和LC_SEGMENT的结构差别不大 */
uint32_t cmdsize; /* 代表Load commands的大小 */
char segname[16]; /* 16字节的段名称 */
uint64_t vmaddr; /* 段映射到虚拟地址中的内存起始地址 */
uint64_t vmsize; /* 段映射到虚拟地址中的内存大小 */
uint64_t fileoff; /* 段在当前架构(MachO)文件中的偏移量,如果是胖二进制文件,也指的是相对于当前MachO文件的偏移 */
uint64_t filesize; /* 段在文件中的大小 */
vm_prot_t maxprot; /* 段页面的最高内存保护,用八进制表示(4=r(read),2=w(write),1=x(execute执行权限)) */
vm_prot_t initprot; /* 段页面最初始的内存保护 */
uint32_t nsects; /* 段(segment)包含的区(section)的个数(如果存在的话) */
uint32_t flags; /* 段页面标志 */
};

系统将 fileoff 偏移处 filesize 大小的内容加载到虚拟内存的 vmaddr 处,大小为vmsize,段页面的权限由initprot进行初始化。它的权限可以动态改变,但是不能超过maxprot的值,例如 _TEXT 初始化和最大权限都是可读/可执行/不可写。

常见的LC_SEGMENT Segment (cmd为LC_SEGMET),其segname[16]有以下几种值:

  • __PAGEZERO:空指针陷阱段,映射到虚拟内存空间的第1页,用于捕捉对 NULL 指针的引用。
    • LC_SEGMENT_64(__PAGEZERO) 中:File Offset、File Size都是0、VM Address=0x0、VM Size=0x100000000,可以看出__PageZero在macho文件中不存在,只存在于虚拟内存中。
    • 在 32 位的系统中,这是内存中单独的一个页面(4KB),而且这个页面所有的访问权限都被撤消了。
    • 在 64 位系统上,这个段对应了一个完整的32位地址空间(即前4GB)。
    • 这个段有助于捕捉空指针引用(因为空指针实际上就是 0),或捕捉将整数当做指针引用(因为32位平台下的 4095 以下的值,以及64位平台下4GB以下的值都在这个范围内)。
    • 由于这个范围内所有访问权限(读、写和执行)都被撤消了,所以在这个范围内的任何解引用操作都会引发来自 MMU 的硬件页错误, 进而产生一个内核可以捕捉的陷阱。内核将这个陷阱转换为C++异常或表示总线错误的POSIX信号(SIGBUS) 。
    • PAGEZERO不是设计给进程使用的,但是多少成为了恶意代码的温床。想要通过“额外”代码感染Mach-O的攻击者往往发现可以很方便地通过PAGEZERO实现这个目的。PAGEZERO通常不属于目标文件的一部分(其对应的加载指令LC_SEGMENT将filesize指定为0),但是对此并没有严格的要求。
  • __TEXT:代码段/只读数据段。
    • LC_SEGMENT_64(__TEXT ) 中:File Offser = 0,可以看出,mach header、load commands都是属于代码段的地址范围,都是只读的,放一起也可以。紧接着就是数据段。
    • vm size = file size,数据是直接拷贝过去。
    • 和其他所有操作系统一样,文本段被设置为r-x,即只读且可执行。这不仅可以防止二进制代码在内存中被修改,还可以通过共享这个只读段优化内存的使用。通过这种方式,同一个程序的多个实例可以仅使用一份TEXT副本。
    • 文本段通常包含多个区,实际的代码在_text区中。
    • 文本段还可以包含其他只读数据,例如常量和硬编码的字符串。
  • __DATA:可读取和写入数据的段。
    • vm size略大于file size。
  • __LINKEDIT:动态链接器dyld需要使用的信息,包括符号表、重定位表、绑定信息、懒加载信息等。
  • __OBJC:包含会被Objective Runtime使用到的一些数据。(从Macho文档上看,他包含了一些编译器私有的节。没有任何公开的资料描述)
  • __IMPORT:用于 i386 的二进制文件的导入表。
  • __MALLOC_TINY:用于小于一个页面大小的内存分配。
  • __MALLOC_SMALL:用于几个页面大小的内存分配。

下面是使用vmmap(1)输出的一个实例程序a32位硬件设备上运行的进程地址空间,显示了区域的名称、地址范围、权限(当前权限和最高权限)以及映射的名称(通常对应的是Mach-O目标文件,如果有的话)。

32位进程的虚拟地址空间布局:

26

3.4.2 Section(节)

从示例图中可以看到,部分的 Segment (__TEXT__DATA) 可以进一步分解为 Section。

之所以按照 Segment(段) -> Section(节) 的结构组织方式,是因为在同一个 Segment 下的 Section,在内存中的权限相同(编译时,编译器把相同权限的section放在一起,成为segment),可以不完全按照 Page 的大小进行内存对齐,节省内存的空间。而 Segment 对外整体暴露,在装载程序时,完整映射成一个vma(Virtual Memory Address),更好的做到内存对齐,减少内存碎片(可以参考《OS X & iOS Kernel Programming》第一章内容)。

Section 具体的数据结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct section_64 { 
char sectname[16]; /* Section 的名字 */
char segname[16]; /* Section 所在的 Segment 名称 */
uint64_t addr; /* Section 映射到虚拟地址的偏移(所在的内存地址) */
uint64_t size; /* Section 的大小 */
uint32_t offset; /* Section 在当前架构文件中的偏移 */
uint32_t align; /* Section 的内存对齐边界 (2 的次幂) */
uint32_t reloff; /* 重定位入口的文件偏移 */
uint32_t nreloc; /* 重定位入口的数目 */
uint32_t flags; /* Section标志属性 */
uint32_t reserved1; /* 保留字段1 (for offset or index) */
uint32_t reserved2; /* 保留字段2 (for count or sizeof) */
uint32_t reserved3; /* 保留字段3 */
};

结合示例图,下面列举一些常见(并非全部)的 Section:(是按照在MachO文件的顺序排序的(也就是加载到虚拟地址空间中的排序),Swift相关的Section没有列出)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// __TEXT Segment(段)下面的节:
__text // 程序可执行的代码区域
__stubs // 间接符号存根。本质上是一小段代码,跳转到懒加载/延迟绑定(lazybinding)指针表(即__DATA.la_symbol_ptr)。找到对应项指针指向的地址。
__sub_helper // 辅助函数。帮助解决懒加载符号加载,上述提到的lazybinding的表(__DATA.la_symbol_ptr)中对应项的指针在没有找到真正的符号地址的时候,都指向这。
__const // 初始化过的常量
__cstring // 去重后的只读的C风格字符串,包含OC的部分字符串和属性名 (不含中文字符)
__ustring // utf-8编码后的中文字符串
__objc_classname // 类名
__objc_methname // 方法名
__objc_methtype // 方法签名
__unwind_info // 用户存储处理异常情况信息
__eh_frame // 调试辅助信息

// __DATA Segment(段)下面的节:
__got // 非懒加载全局指针表
__la_symbol_ptr // 延迟导入/懒加载(lazy-binding)符号指针表,每个表项中的指针一开始指向stub_helper。在第 1 次调用时才会绑定值。
__nl_symbol_ptr // 非延迟导入/非懒加载(lazy-binding)符号指针表,每个表项中的指针都指向一个在dyld加载过程中,搜索完成的符号。即在dyld加载时会立即绑定值。
__mod_init_func // 初始化/constructor(构造)函数
__mod_term_func // destructor(析构)函数
__const // 没有初始化过的常量
__cfstring // OC字符串
__objc_classlist // 程序中的类列表
__objc_nlclslist // 程序中自己实现了+load方法的类
__objc_protolist // 协议的列表
__objc_classrefs // 被引用的类列表
__objc_ivar // 成员变量
__data // 初始化过的可变的数据
__bss // 没有初始化的静态变量
__common // 没有初始化过的符号声明

3.5 __stubs__stub_helper

用来实现 LazyBind 的两个section:__TEXT.__stubs__TEXT.__stub_helper

在 wikipedia 有一个关于 Method stub 的词条,大意就是:Stub 是指用来替换一部分功能的程序段。桩程序可以用来模拟已有程序的行为(比如一个远端机器的过程)或是对将要开发的代码的一种临时替代。

总结来说:

  • __stub 就是一段代码,功能为:跳转到 __DATA.__la_symbol_ptr( __DATA Segment 中的 __la_symbol_ptr Section) 对应表项的数据,所指向的地址。
  • __la_symbol_ptr 里面的所有表项的数据在初始时都会被 binding 成 __stub_helper
  • 当懒加载符号第一次使用到的时候,按照上面的结构,会跳转到__stub_helper这个section的代码,然后代码中会调用dyld_stub_binder来执行真正的bind。 bind结束后,就将__la_symbol_ptr中该懒加载符号 原本对应的指向__stub_helper的地址 修改为 符号的真实地址。
  • 之后的调用中,虽然依旧会跳到 __stub 区域,但是 __la_symbol_ptr表由于在之前的调用中获取到了符号的真实地址而已经修正完成,所以无需在进入 dyld_stub_binder 阶段,可以直接使用符号。

这样就完成了LazyBind的过程。Stub 机制 其实和 wikipedia 上的说法一致,设置一个桩函数(模拟、占位函数)并采用 lazy 思想做成延迟 binding 的流程。

在《深入解析 Mac OS X & iOS操作系统》中有详细的验证,也可以参考深入剖析Macho (1) 自己动手验证一下。

四、通用二进制格式(Universal Binary)

通常也被称为胖二进制格式(Fat Binary),Apple 提出这个概念是为了解决一些历史原因,macOS(更确切的应该说是 OS X)最早是构建于 PPC 架构智商,后来才移植到 Intel 架构(从 Mac OS X Tiger 10.4.7 开始),通用二进制格式的二进制文件可以在 PPC 和 x86 两种处理器上执行。

说到底,通用二进制格式只不过是对多架构的二进制文件的打包集合文件,而 macOS 中的多架构二进制文件也就是适配不同架构的 Mach-O 文件。即一个通用二进制格式包含了很多个 Mach-O 格式文件。它有以下特点:

  • 因为需要存储多种架构的代码,所以通用二进制文件要比单架构二进制文件要大
  • 因为两种种架构之间可以共用一些资源,所以两种架构的通用二进制文件大小不会达到单一架构版本的两倍。
  • 运行过程中只会调用其中的部分代码,所以运行起来不会占用额外的内存

Fat Header 的数据结构在 <mach-o/fat.h> 头文件中有定义,可以参看 /usr/include/mach-o/fat.h 找到定义头:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#define FAT_MAGIC    0xcafebabe
#define FAT_CIGAM 0xbebafeca /* NXSwapLong(FAT_MAGIC) */

struct fat_header {
uint32_t magic; /* FAT_MAGIC 或 FAT_MAGIC_64 */
uint32_t nfat_arch; /* 结构体实例的个数 */
};

struct fat_arch {
cpu_type_t cputype; /* cpu 说明符 (int) */
cpu_subtype_t cpusubtype; /* 指定 cpu 确切型号的整数 (int) */
uint32_t offset; /* CPU 架构数据相对于当前文件开头的偏移值 */
uint32_t size; /* 数据大小 */
uint32_t align; /* 数据内润对其边界,取值为 2 的幂 */
};

对于 cputypecpusubtype 两个字段这里不讲述,可以参看 /usr/include/mach/machine.h 头中对其的定义,另外 Apple 官方文档中也有简单的描述。

fat_header 中,magic 也就是我们之前在表中罗列的 magic 标识符,也可以类比成 UNIX 中 ELF 文件的 magic 标识。加载器会通过这个符号来判断这是什么文件,通用二进制的 magic0xcafebabenfat_arch 字段指明当前的通用二进制文件中包含了多少个不同架构的 Mach-O 文件。fat_header 后会跟着多个 fat_arch,并与多个 Mach-O 文件及其描述信息(文件大小、CPU 架构、CPU 型号、内存对齐方式)相关联。

这里可以通过 file 命令来查看简要的架构信息,这里以 iOS 平台 WeChat 4.5.1 版本为例:

1
2
3
4
~ file Desktop/WeChat.app/WeChat
Desktop/WeChat.app/WeChat: Mach-O universal binary with 2 architectures: [arm_v7: Mach-O executable arm_v7] [arm64]
Desktop/WeChat.app/WeChat (for architecture armv7): Mach-O executable arm_v7
Desktop/WeChat.app/WeChat (for architecture arm64): Mach-O 64-bit executable arm64

进一步,也可以使用 otool 工具来打印其 fat_header 详细信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
~ otool -f -V Desktop/WeChat.app/WeChat
Fat headers
fat_magic FAT_MAGIC
nfat_arch 2
architecture armv7
cputype CPU_TYPE_ARM
cpusubtype CPU_SUBTYPE_ARM_V7
capabilities 0x0
offset 16384
size 56450224
align 2^14 (16384)
architecture arm64
cputype CPU_TYPE_ARM64
cpusubtype CPU_SUBTYPE_ARM64_ALL
capabilities 0x0
offset 56475648
size 64571648
align 2^14 (16384)

之后我们用 Synalyze It! 来查看 WeChat 的 Mach64 Header 的效果:

12
  • 从第一个段中得到 magic = 0xcafebabe ,说明是 FAT_MAGIC
  • 第二段中所存储的字段为 nfat_arch = 0x00000002,说明该 App 中包含了两种 CPU 架构。
  • 后续的则是 fat_arch 结构体中的内容,cputype(0x0000000c)cpusubtype(0x00000009)offset(0x00004000)size(0x03505C00) 等等。如果只含有一种 CPU 架构,是没有 fat 头定义的,这部分则可跳过,从而直接过去 arch 数据。

注意,在mach-o中,数据结构中的地址表示:如果是value、address等,那一般是绝对地址;如果是偏移量offset等,一般都是相对于目标文件/可执行文件(注意,尤其是后者,起始地址不会是0,寻址时要加上起始地址)。

五、参考链接

Author:Tenloy

原文链接:https://tenloy.github.io/2021/10/06/compile-macho.html

发表日期:2021.10.06 , 4:25 AM

更新日期:2024.04.24 , 4:50 PM

版权声明:本文采用Crative Commons 4.0 许可协议进行许可

CATALOG
  1. 一、进程与二进制格式
  2. 二、相关工具
  3. 三、Mach-O 文件格式
    1. 3.1 示例
    2. 3.2 Mach-O 头
    3. 3.3 Load Command
      1. 概述
      2. 3.3.1 LC_SEGMENT(进程虚拟内存设置)
      3. 3.3.2 LC_LOAD_DYLIB与LC_ID_DYLIB
      4. 3.3.3 LC_CODE_SIGNATURE(数字签名)
      5. 3.3.4 LC_MAIN(设置主线程入口地址)
    4. 3.4 Data
      1. 3.4.1 Segment(段)
      2. 3.4.2 Section(节)
    5. 3.5 __stubs 与 __stub_helper
  4. 四、通用二进制格式(Universal Binary)
  5. 五、参考链接