- 一个iOS程序员的自我修养(一)编译和链接
- 一个iOS程序员的自我修养(二)Mach-O里面有什么
- 一个iOS程序员的自我修养(三)Mach-O文件静态链接
- 一个iOS程序员的自我修养(四)可执行文件的装载
- 一个iOS程序员的自我修养(五)Mach-O文件动态链接
- 一个iOS程序员的自我修养(六)动态链接应用:fishhook原理
- 一个iOS程序员的自我修养(七)静态链接应用:静态库插桩原理
- 一个iOS程序员的自我修养(八)内存
Mach-O
Mach-O 文件网上介绍的比较多,但是大多数都只是介绍了文件内的结构,并没有说明为什么会以这样的结构排布。通过阅读《程序员的自我修养》一书,结合 MachOView 工具,重新梳理一下 Mach-O 文件。
除了 iOS 系统的 Mach-O,与之对应的还有 Windows 下的 PE 和 Linux 下的 ELF。它们都是基于一种叫做 COFF 文件的变种,它的主要贡献是引入了“段”的机制,我们编写的应用程序正是被以这种段的形式存储在 Mach-O 中。Mach-O 中除了包含机器代码指令和数据,还包括符号表、调试信息、字符串表等等,它们都被以段的形式存储。苹果官方描述 Mach-O 的结构如下:
下面会通过这个图逐步展开分析 Mach-O 的内部细节,Mach-O 整体分为三部分:
- Header:最前面的部分是 Mach-O 文件头,用来描述文件版本、目标机器型号、程序入口等信息。
- Load commands: 多个 Segment 组成,每个 Segment 又包含了多个相同类型的 Section。为何叫加载命令,因为它是用来被系统加载使用的。
- Data: 被 Load commands 描述的各个 Section,包括编写的指令代码,定义的常量变量等,还包括符号表,字符串表等等其他我们比较熟悉的段,也就是说我们所写的应用程序会被拆分成一个个的 Section 存储在 Mach-O 文件中。
那么 Mach-O 为什么要以这种“段”的形式存储呢?其实这种分段的好处有很多:
- 每个段可以根据它们的读写权限被映射到不同的内存区域,例如程序的指令是可读的,所以会被映射到可读区域,这样可以防止程序的指令被有意或无意的改写。
- 对于现代的 cpu 来说,它们有着强大的缓存体系,按段的形式存放对缓存命中提高有好处。
- 当系统中运行着多个该程序的副本时,它们的指令都是一样的,所以内存中的只读数据只保存一份,可以节省大量的内存。
Header
下面以蚂蚁财富的可执行文件为例,通过 MachOView 来看下内部具体细节:
由于 Data 中段比较多这里只截取了部分。
Mach-O 文件头结构及相关常数被定义在 “/usr/include/mach-o/loader.h” 文件中,因为 5s 之后的版本 cpu 架构都是 64 位,这里以 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:一个叫做 “魔数” 的字段,标识了 Mach-O 文件的格式。
- cputype:cpu 类型。
- cpusubtype:机器标识符。
- filetype:文件类型。
- ncmds:Load Commands 的数量。
- sizeofcmds:Load Commands 大小。
- flags:动态链接器标识。
- reserved:保留字段。
单纯的看这个结构体不太好理解,下面再通过 MachOView 看一下蚂蚁财富 App 的 Header 构成:
Header 部分主要是对 Mach-O 文件的描述,从上图中可以看出,Header 的最后一个字段相对于文件开始的偏移量为 0x1c,reserved 字段虽然没有值,但它仍然占用了 4 个字节,这样整个 Header 一共占用了 32 个字节。Mach-O 文件被系统装载的时候,会先读出 Header 部分,通过 Header 就可以找到 Load Commands 加载指令部分,读取到加载指令就可以加载到我们编写的代码了。在 Header 中有个重要的字段 sizeofcmds 用来表示 Load Commands 的大小,通过它就可以找到 Load Commands 的位置。
Load Command
Load Command 是 Mach-O 文件中除了文件头以外最重要的结构,它描述了 Data 中的各个段信息,比如每个段的段名、段的长度、在文件中的偏移、读写权限以及段的其他属性,它的位置要由 Header 的大小决定,下面是 Load Command 的起始位置和结束位置:
Load Command 的起始偏移和结束偏移分别为 0x20 和 0x2380。0x20 换算成 10 进制是 32,正好是 Header 的大小,0x2380 和 0x20 差值正好是 Header 中 Size of Load Commands 的值 0x2368,由此也验证了 Load Command 的位置是紧随 Header 后面的。
在 MachOView 中 Load Command 的主要结构如下:
Load Command 由多个 Segment 构成,一个 Segment 包含一个或多个属性类似的 Section。关于 Segment 结构的定义也可以在 “/usr/include/mach-o/loader.h” 中找到:
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 */
};
- cmd:Segment 的类型,和下面的 flags 标记位决定着这个段如何被装载。
- segname:段名。
- vmaddr:当前段在虚拟内存起始地址。
- vmsize:当前段在虚拟内存地址占用的长度。
- fileoff:在文件中的偏移。
- filesize:在文件中的长度。
- nsects:包含section 的个数。
- flags:标记位,表示在进程虚拟地址空间中的属性,比如是否可写、是否可执行等。
vmaddr 和 vmsize 是在应用程序被加载进虚拟内存用到的,在将 Mach-O 加载到虚拟内存的时候,会在虚拟内存上的 vmaddr 位置开始,取出 vmsize 大小的空间来存放这个段。然而在 Section 内就不存在这两个字段,因为这两个字段是给装载用的,这也是 Segment 和 Section 最主要的区别。
Data
Data 中存放的所有的 Section,例如机器指令,全局变量和局部静态变量,符号表,调试信息等都会被存储到对应的 Section 中:
Mach-O 被装载的时候会通过 Segment 寻找对应的 Section,在 Load Commands 中 通过 Segment 可以直接找到每个 Section 的位置和大小,例如上图 _text 段在文件中的偏移为 0x40E0,这与它在 Segment 中的 offset 是一致的,如下图红框内所示:
在 loader.h 中也能找到 Section 的结构体的定义:
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 */
};
关于 sectname 特别说明一下,它表示了这个 Section 存放了哪些信息,下面列举一些:
- __text:可执行的机器码。
- __cstring:一些C字符串。
- __const:常量。
- __data:存储初始化的可变数据。
- __bss:存储未初始化的全局变量和局部静态变量。
- __objc_clasname:存储 OC 类名。
- __objc_classlist:方法列表。
- __objc_protocollist:协议列表。
Section 和 Segument
上面关于 Section 和 Segment 的主要区别没有细说,既然有了 Section,为什么还要有 Segment ?
这块涉及到内存分页加载的概念,之前很火的抖音的二进制重排也是利用这种分页加载的机制,Mach-O 被虚拟内存加载的时候是以页为单位,在 iOS 上,一页的大小被划定为 16kb,每个 Section 在映射时都是系统页长度的整数倍,不足一个页的部分也会占用一个页,这样在 Section 增多后会带来大量内存碎片。Segment 是在装载的角度重新划分了 Mach-O 的各个段,对于相同权限的 Section,把它们合并到一起作为一个 Segment 进行映射。从目标文件链接的角度看,Mach-O 文件是按照 Section 存储的,但从装载的角度看,它是按照 Segment 划分的,到这里就应该很容易的理解上面苹果官方给出的 Mach-O 结构图了。
引用
《程序员的自我修养》