写在前面

本文主要介绍Mach-O编译链接符号分类

符号可能平时开发的时候接触不多,本文会从新手视角介绍一下这个在编译链接阶段默默付出的家伙

一、MachO

1.MachO

  • Mach-O(MachO Object)是macOS、iOS、iPadOS存储程序和库的文件格式。对应系统通过应用二进制接口(application binary interface,缩写为ABI)来运行该格式的文件
  • Mach-O格式用来替代BSD系统a.out格式。Mach-O文件格式保存了在编译过程和链接过程中产生的机器代码和数据,从而为静态链接和动态链接的代码提供了单一文件格式
  • Mach-O文件中全部由二进制组成,可以理解成文件配置+二进制代码

2.MachO调用过程

  1. 调用fork函数,创建一个process
  2. 调用execve或其衍生函数,在该进程上加载,执行我们的Mach-O文件。当我们调用execve(程序加载器)内核实际上在执行以下操作:
  • 将文件加载到内存中
  • 开始分析Mach-O中的mach_header,以确认它是有效的Mach-O文件

二、查看MachO信息

1.查看mach-header

为了方便就新建了一个MacOS的项目代码如下,编译生成可执行文件

#import <Foundation/Foundation.h>

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"Hello, World!");
    }
    return 0;
}

使用如下命令查看mach-header

/// objdump查看
objdump --macho --private-header machO文件

/// otool查看
otool -h machO文件



ios 符号 ios 符号解析_ios 符号

2.查看__TEXT段

objdump --macho -d machO文件



ios 符号 ios 符号解析_java_02

3.编译链接过程

  1. 生成目标文件

ios 符号 ios 符号解析_编程语言_03

在编译时编译器干了两件事情:

  • 将代码尽可能的转成汇编语言
  • 将符号归类——上例使用的NSLog属于导入符号(存在别的machO文件中)它会在链接时才确定它的内存地址,因此需要暂存起来——放到重定位符号表中(其他用到的系统库API均是如此)
  • 为什么在链接时才能确定它的内存地址,是因为生成目标文件时内存没有虚拟化,本machO文件中符号可以通过地址偏移得到,而导入符号(其他machO文件)却不行
  • 同时也可以通过查看重定位符号表来查看API的使用情况

ios 符号 ios 符号解析_嵌入式_04

/// 查看目标文件的重定向符号表
objdump --macho --reloc 目标文件
  1. 生成可执行文件

粗略的讲,链接过程是将多个目标文件的符号表汇总到一张表中(处理目标文件的符号表),最后去生成可执行文件exec



ios 符号 ios 符号解析_ios 符号_05

三、符号表

1.符号表

  • Symbol Table:用来保存符号
  • String Table:用来保存符号的名称
  • Indirect Symbol Table:叫做间接符号表,用来保存使用的外部符号。更准确一点就是使用的外部动态库的符号,是Symbol Table的子集,例如使用Foundation库中的NSLog就是间接符号

使用如下命令就可以查看可执行文件中符号表,其中-p表示不排序,-a表示输出全部符号表,包括调试符号

nm -pa xxx(MachO文件路径)



ios 符号 ios 符号解析_python_06

迷迷糊糊能看到mainNSlogobjc_autoreleasePoolPopobjc_autoreleasePoolPush等输出,这不正就是我们代码中的main函数执行嘛!

但是每次使用nm \-pa xxx(MachO文件路径)总归有点麻烦,好在我们可以使用脚本(脚本是真的香)



ios 符号 ios 符号解析_java_07

nm -pa ${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/* > /dev/ttys000
  • nm:在linux中列出目标文件的符号清单,常用来查看动态链接库中的函数
  • -p:不排序符号,使用该选项后的输出没有按照地址也没有按照符号名称排序
  • -a:输出全部符号表,包括调试符号
  • ${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/*:Xcode内置的参数以便于使用相对路径来执行命令
  • /dev/ttys000:终端窗口。可以在终端窗口使用tty查看当前终端

ios 符号 ios 符号解析_python_08

也可以在项目根目录下新建一个build.sh,在文件中添加需要执行的脚本命令,同时在Run Script中进行配置脚本(有可能需要赋予执行权限)



ios 符号 ios 符号解析_ios 符号_09

ios 符号 ios 符号解析_嵌入式_10

从这个图可以看出链接主程序->脚本运行->签名应用

2.调试符号

  • 文件通过汇编器生成目标文件时 会生成一个DWARF格式的调试文件,它被放在machO文件中的DWARF段
  • 而在链接过程中DWARF段会被干掉并放到可执行文件的符号表中

3.剥离调试符号

方案一:Xcode中给我们提供了Strip Symbols选项



ios 符号 ios 符号解析_编程语言_11

但是编译之后终端输出没有任何变化,这是因为剥离符号是在执行脚本之后的



ios 符号 ios 符号解析_嵌入式_12

方案二:我们可以通过设置链接器参数来修改链接时的配置,具体可以通过man ld在终端中查看,从而会发现-S参数可以剥离调试符号



ios 符号 ios 符号解析_ios 符号_13

那么具体怎么配置呢?

  • 新建Configuration文件
  • ProductConfiguration文件一一对应起来
  • 配置Configuration文件:OTHER_LDFLAGS = -Xlinker -S
  • -Xlinker表示后面的参数是传给链接器
  • 编译之后在BuildSettings中的other link flag中查看是否添加成功

ios 符号 ios 符号解析_java_14

四、符号表分类

1.全局符号和静态符号

将代码改写——添加全局变量和静态变量

#import <Foundation/Foundation.h>

// 全局变量
int global_num = 10;
int global_undefine_num;

// 静态变量
static int static_num = 10;
static int static_undefine_num;

int main(int argc, const char * argv[]) {
    @autoreleasepool {
        NSLog(@"%d-%d", static_num, static_undefine_num);
    }
    return 0;
}

使用如下命令行查看可执行文件(剥去调试符号更容易查看)

objdump --macho -syms machO文件



ios 符号 ios 符号解析_python_15

从终端输出可以看出:

  • 不管是否初始化,全局变量都变成了全局符号
  • 静态变量都变成了本地符号
  • 这里需要注意的是,如果静态变量未使用的话,是会变成调试符号
1.1 全局符号与本地符号

全局符号本地符号的本质区别是其可见性(visibility)可见性分为两种:

  • default:用它定义的符号将被导出
  • hidden:用它定义的符号将不被导出

隐藏全局符号有两种方法:

使用`static`修饰(最为简单)
修改其可见性(全局符号转为本地符号,且未初始化的全局变量会被存放在未初始化的变量区中)
int global_num __attribute__((visibility("hidden"))) = 10;
int global_undefine_num __attribute__((visibility("hidden")));



ios 符号 ios 符号解析_嵌入式_16

1.2 二级命名空间
  • 动态库实现不对外声明的全局符号+主项目只做声明全局符号
  • 输出结果为动态库的代码 => 全局符号对整个项目可见

ios 符号 ios 符号解析_ios 符号_17

  • 动态库实现不对外声明的全局符号+主项目声明&实现全局符号
  • 输出结果为主工程的代码 => 全局符号对整个项目可见
  • 这是由于二级命名空间的缘故——链接器默认采用二级命名空间,除了记录符号名称,还会记录符号属于哪个可执行文件 => 优先使用本工程的符号

ios 符号 ios 符号解析_python_18

  • 动态库实现对外声明的全局符号+主项目声明&实现全局符号
  • 报错/Users/felix/Desktop/FXDemo/FXDemo/ViewController.m:18:6: Redefinition of 'global_symbol'
  • 因为动态库的全局符号对外导出了,在主工程会重新加入符号表
  • 如果不导入声明文件就不会报错
  • 主项目两个不同文件声明同一个全局符号
  • 报错1 duplicate symbol for architecture arm64
  • 因为两个符号命名空间一样

ios 符号 ios 符号解析_编程语言_19

1.3 全局符号总结

全局符号对整个项目可见;本地符号对当前文件可见

  1. 动态库中的全局符号,仅在主项目中声明也可以使用;
  2. 动态库中的静态符号,在其他项目中都不可使用
  3. 在主项目、动态库中分别声明同一名称的符号,就牵扯到二级命名空间问题
  4. 同一项目中不能存在多个全局符号(因为二级命名空间一样)

二级命名空间&一级命名空间,链接器默认会采用二级命名空间,也就是除了记录符号之外,还会记录符号属于哪个machO的,比如记录NSLog属于Foundation

2.导入符号和导出符号

继续拿刚才的NSLog举例:

  • 对于本machO文件来说,导入了NSLog符号(导入符号)
  • 对于Foundation来说,它导出了NSLog符号(导出符号)

可以使用命令行查看本文件中的导出符号

objdump --macho --exports-trie machO地址



ios 符号 ios 符号解析_嵌入式_20

  • 导出符号结果与全局符号结果相比较,可以看出导出符号一定是全局符号,因为它对整个项目都可见,且提供给别的项目使用
  • 由于符号表是占体积的,我们可以通过剥离符号来减少App体积
  • 而使用到的导出符号NSLog将作为间接符号保存起来,这部分符号是不能被脱去的,否则程序无法正常运行
  • 导出符号一定是全局符号这个结论可知,全局符号也是不能被脱去的

间接符号表用来保存外部符号,即导出符号,可以使用命令行查看本文件中使用到的间接符号表

objdump --macho --indirect-symbols machO地址



ios 符号 ios 符号解析_python_21

  • 平时在定义全局符号/全局变量的时候,需要注意它在编译时会作为导出符号被别的空间/模块所使用
  • 一般情况下,全局符号导出符号,但这不是绝对的,我们可以通过链接器来控制它
  • 以动态库举例,它只需要在链接的时候提供导出符号即可,但Objective-C中所有类默认都是导出符号
  • 新建FXPerson的Objective-C对象,再去查看导出符号
  • 即便把Objective-C对象的声明从.h文件放到.m文件中,也丝毫不会改变它创建了一个导出符号的结果

ios 符号 ios 符号解析_ios 符号_22

可以通过在Xcconfig文件中这么定义,就能指定对应的“导出符号”不导出——不但可以减少App体积,同时无法通过符号访问对应类会更加安全

// 剥离调试符号
OTHER_LDFLAGS = ${inherited} -Xlinker -S
// 剥离FXPerson元类导出符号
OTHER_LDFLAGS = ${inherited} -Xlinker -unexported_symbol -Xlinker _OBJC_METACLASS_$_FXPerson
// 剥离FXPerson类导出符号
OTHER_LDFLAGS = ${inherited} -Xlinker -unexported_symbol -Xlinker _OBJC_CLASS_$_FXPerson
// -unexported_symbol_list可以指定一个需要剥离文件的符号
// -map导出当前machO文件的符号信息以及链接其他库的信息
OTHER_LDFLAGS = ${inherited} -Xlinker -map -Xlinker 地址



ios 符号 ios 符号解析_java_23

3.弱引用符号和弱定义符号

  • 弱引用符号(Weak Reference Symbol)如果链接器找不到该符号的定义,则将其设置为0。链接器会将此符号设置为弱链接标志
  • 关键字为weak import
  • 可以只做声明不做实现——需要判空使用
  • 不配置链接器参数会报错——Undefined symbol: _weak_import_function
  • 配置链接器参数为-U(告诉链接器这个符号是动态链接的,在编译时不需要理会)
  • OTHER_LDFLAGS = ${inherited} -Xlinker -U -Xlinker _weak_import_function
  • 作用:避免找不到符号实现而崩溃
  • 弱定义符号(Weak Defintion Symbol)如果链接器为此符号找到了另一个非弱定义,则弱定义将被忽略
  • 关键字为weak
  • 本身是一个全局符号/导出符号
  • 只做声明不做实现会报错
  • 声明+多个实现不会报错——动态运行会使用最先找到的弱定义符号,其他都将被忽略
  • 作用:避免多个全局符号的实现冲突

4.重新导出符号

NSLog这种导入符号在machO文件中是UND未定义



ios 符号 ios 符号解析_java_24

  • 如果别的可执行文件想重新使用这个符号的话,需要重新导出——放到本文件的导出符号表中——外界可以使用这个符号
  • 那么就需要用到链接器中的参数-alias(起别名)会把间接符号表变成导出符号
  • 仅限间接符号可以这么使用

5.Swift符号

添加一个Swift文件

import Foundation

private class SwiftPerson {
    func playGame() {
        
    }
}

public class PublicPerson {
    func playGame() {
        
    }
}

使用命令行查看符号表并过滤

objdump --macho -syms machO文件 | grep 'Person'



ios 符号 ios 符号解析_java_25

  • Swift文件会生成很多符号
  • publicprivate对应着全局符号本地符号
  • BuildSettings中有配置项可以对Swift符号进行剥离——Strip Swift Symbols

ios 符号 ios 符号解析_嵌入式_26

五、剥离符号表

  • 动态库要留下导出符号供外部使用
  • 不能剥离全局符号/导出符号——Non-Global Symbols
  • 静态库是目标文件的合集+重定位符号表,只能接触到调试符号
  • 只能剥离调试符号——Debug Symbols
  • App不需要供外部使用,但是需要保留外部导入的符号
  • 不能剥离间接符号表/导入符号(NSLog)——All Symbols

写在后面

就符号而言,App链接同等代码量的静态库和动态库,哪个包体积更小?

  • 静态库的所有符号都会放到主工程中的符号表中——可能有全局符号本地符号导出符号等(除了导入符号
  • 而App中除了导入符号,其他全部可以被剥离
  • 动态库的导出符号都会放到主工程的间接符号表
  • 动态库的导出符号不会被剥离

所以App链接静态库的体积会小于动态库