了解Mach-O文件结构

320 阅读14分钟

什么是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:通常是目标文件中最大的部分,它包含代码和数据,如符号表、动态符号表等

这是一个简化的图形表示

4037795-4e0155c501917ae9.png

OS X 上有两种类型的目标文件:Mach-O文件和通用二进制文件,也称为 Fat 文件。它们之间的区别:Mach-O 文件只包含一种架构(i386、x86_64、arm64等)的目标代码,而Fat二进制文件包含多个目标文件,因此包含不同的架构(i386和x86_64、arm和arm64、ETC).

Fat 文件的结构非常简单:fat 标头后跟 Mach-O 文件:
image.png image.png

可以通过命令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程序查看.

image.png

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)将段映射到内存(并设置其内存权限)的所有相关信息:

image.png 在分析Mach-O二进制文件时可能会遇到如下几个段:

  • __PAGEZERO: 静态链接器创建了__PAGEZERO作为可执行文件的第一个段,该段在虚拟内存中的位置及大小都为0,不能读写、不能执行,用来处理空指针。开发者尝试访问NULL指针时,会得到一个EXC_BAD_ACCESS错误
  • __TEXT段:包含可执行代码和只读数据,静态链接器设置该段的虚拟内存权限为可读、可执行,进程被允许执行这些代码,但是不能修改
  • __DATA段:包含可写数据,静态链接器设置该段的虚拟内存权限为可读写
  • __LINKEDIT段:包含了动态链接库的原始数据,如符号、字符串和重定位表条目等

如果二进制文件是用Objective-C编写的,可能有一个__OBJC段,其中包含Objective-C运行时的信息,尽管此信息也可以在__DATA段中找到.

注意:段可以包含0个或者多个section,每一个section包含相同类型的代码或者数据

image.png

此加载命令是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,如下图所示:

image.png

__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符号表

符号表是将地址和符号联系起来的桥梁。符号表并不能直接存储符号,而是存储符号位于字符串表的位置。

image.png

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_typen_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]: 表示这是外部符号,即该符号要么定义在外部,要么定义在本地但是可以被外部使用