前言

一、Mach-O简单介绍

二、符号初识

三、通过xcode执行命令

四、脱去调试符号

五、符号的种类

六、strip

七、dead code strip


对于业务层来说,我们基本上都会去添加友盟或者是bugly等。在监测线上崩溃的时候,这些第三方的平台往往需要我们提供符号表。那么什么是符号,今天就来具体的分析一下。


一、Mach-O简单介绍

了解符合之前,先简单的介绍一下Mach-O文件。

1、简介

(1)、Mach-O(Mach Object)是macOS、ios、iPadOS存储程序和库的文件格式,包括我们的动态库、静态库、可执行文件,都是Mach-O的格式。

(2)、对应系统通过应用二进制接口(application binary interface)也就是ABI,来运行该格式的文件。也就说明了Mach-O里面内容的格式都是二进制。

(3)、Mach-O格式用来替代BDS系统的a.out格式。

(4)、Mach-O文件格式保存了在编译过程和链接过程中产生的机器代码和数据,从而为静态链接和动态链接的代码提供了单一文件格式。

2、可执行文件的调用过程

(1)、调用fork函数创建一个process

(2)、调用 execve 或其他衍生函数,在该进程上加载,执行我们的Mach-O文件。
当我们调用户execve(程序加载器),内核实际上就是在操作:
将文件加载到内存 以及 开始分析Mach-O中的mach_header,确认它是有效的Mach-O文件。

Mach-O 可以理解为 文件配置+二进制代码,但是记住Mach-O中都是二进制。


3、Mach-O 整体的格式内容


简单的来说,mach-o中主要是macho header、一堆的load command和 __text段、 __data段以及符号表组成。
可以看到load command 和 __text段、 __data段 是分开的。那么是怎么通过load command找到对应的 __text段、 __data段呢。

可以看到,每一个load command和__text段、 __data段中的内容都是对应的。
比如__text段的大小以及开始位置,都在load command中有记录,当dyld加载的时候,就会从load command中读取对应的__text段、 __data段的位置内容来进行加载。
那么load command是如何读取的呢,load command在mach-o中也是二进制的形式,但是它是以内存对齐的方式存储的,比如oc对象是以16字节对齐的。所以通过内存对齐可以快速的获取load command的信息。

4、Mach Header

通过objdump --macho --private-headers xxxx 可以查看到Mach headers,所有内容格式。
objdump命令并不是用脚本来写的,它实际上就是一个可执行文件,路径是: /usr/bin/objdump
我们随便找一个测试工程,输出一下它的mach-o headers。
通过一个machoinfo工具,过滤一下信息,只显示 cmd 和 path

可以看到,__TEXT段,__DATA段以及LC_LOAD_DYLIB(所要加载的动态库)。

再通过objdump --macho --private-header xxxx 查看Mach header的信息

也可以通过otool -h xxx 查看Mach header的原始信息

主要说下一下几个字段:
magic: 是否支持64位 (FAT:0xcafebabe ARMv7:0xfeedface ARM64:0xfeedfacf)
cputype、cpusubtype:CPU架构及子版本
filetype:文件类型
ncmds: load command的数量
sizeofcmds:load command的总大小
flags:dyld加载需要的一些标记,有28种宏定义,具体看源码,其中MH_PIE表示启用ASLR地址空间布局随机化。

5、查看__TEXT 段

objdump --macho -d xxx
创建一下新工程,只保留main函数,查看它的__TEXT

可以看到main函数的机器码和对应的汇编(这里机器码只是底层有一个类似字典的东西,将汇编一条一条的翻译成机器码,机器码是不变的)。

mach-o是可读可写的,在没有签名之前,都是可以修改mach-o的。


二、符号初识

从广义的角度来解释一下编译与链接:

编译的过程就是要把我们写的代码要去放到mach-o对应的配置里面。根据符号的特性,进行分类,放入mach-o。首先会编译成汇编,然后将符号归类,将导入符号放入间接符号表。

生成目标文件的过程:(clang-cl)编译器 -> (llvm-as)汇编器 -> .o

链接就是把多个目标文件(.o)组合成一个文件。

.o -> (llvm-ld)链接器 -> 合并成一张表 -> 生成exec

Symbol Table

先解释一下mach-o中的定义:

Symbol Table: 就是用来保存符号的地址等所有信息,也就是符号表
String Table: 就是用保存符号的名称
Indirect Symbol Table: 就是间接符号表。保存使用的外部符号。更准确的来说就是:使用外部动态库的符号。是Symbol Table的子集。

三、通过xcode执行命令

首先来到xcode执行脚本的地方:Run Script
当我们在这里去写脚本的时候,如果想要直接打印到终端上,需要绑定终端的标识。
比如:我打印一个geng

我想要把geng直接输出在终端中上面。

在终端输入tty,那么输出的/dev/ttys003就是终端的标识。
此时在xcode执行: echo "geng" > /dev/ttys003,然后 command+B,就会直接打印在终端上了。

nm -pa 命令

nm命令主要列出特性文件中的符号信息
-p 输出的信息不进行排序
-a 显示所有符号,包含调试符号

通过nm -pa命令我们可以在终端进行输出打印。那么同样我们可以在xcode里面进行处理。
我们可以在xcconfig文件中将 命令、参数等定义好,然后写一个简单的shell脚本去执行。
在xcconfig中我们可以这样配置:

MACHO_PATH = ${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/*
CMD = nm
CMD_FLAG = -pa MACHO_PATH
TTY =/dev/ttys000

这些配置都定义好之后,就可以在Run Script中去使用了。
其中BUILD_DIR、CONFIGURATION、EFFECTIVE_PLATFORM_NAME等都是xcode内置的配置:
点击xcode内置配置查看

那么在Run Script中脚本是什么时候执行的呢?答案是在编译链接之后,签名之前。


四、脱去调试符号

由于通过nm -pa 打印出来的信息非常多,其中3分之一都是调试符号(这里后面具体说明)。那么为了分析符号,需要先把调试符号先脱去,避免干扰。
那么首先可以通过strip命令来脱去符号。
strip命令是在Run Script脚本执行之后执行的。
strip的底层原理是去修改了mach-o中的内容。所以它在签名之前执行。

另外通过man ld 查看下链接器的文档

可以看到 -S Do not put debug information (STABS or DWARF) in the output file
这个参数是可以不用输出调试信息。
所以我们可以在xcconfig中设置 OTHER_LDFLAGS = -Xlinker -S
其中 -Xlinker是执行clang的命令, -Xlinker是clang要传给 ld(链接器)-S这个 命令。

减少ipa体积的排序: -O1 -OZ(编译期) 、dead code strip (死代码剥离)(链接)、strip(修改mach-o,build setting中 默认选择的是allSymbol,编译器会把内部认为的所有符号都给脱离了(除了导入符号,其他都会脱掉))


五、符号的简单分类

1、全局符号和本地符号

//全局变量
int global_uninit_value;
int global_init_value = 10;

//设置符号的可见性
double default_x __attribute__((visibility("hidden"))) ;

//静态变量 -> 本地变量
static int static_init_value = 9;
static int static_uninit_value;

可以看到以上分别定义了全局变量和静态变量。
通过 objdump --macho --syms xxx 命令来查看符号
在查看之前,我们先将调试符号给脱掉。

解释一下调试符号:当文件在编译器编译的过程中,也就是当文件被生成.o目标文件的时候,会生成一个DWARF格式的数据段 __DWARF(调试信息)。在链接的过程中,会把__DWARF给干掉,最终会变成调试符号放入macho可执行文件中。


可以看到 l(local) 开头的都是本地符号,g(global)开头的都是全局符号。那么default_x因为设置hidden,所以也变成了本地符号。

未初始化的全局符合也叫做 Common Symbol

全局符号就是针对整个项目 以及 引用这个项目的项目可见。

扩充一点:比如我重写一个NSLog函数:

//全局变量
int global_uninit_value;
int global_init_value = 10;

double default_x __attribute__((visibility("hidden"))) ;

//静态变量 -> 本地变量
static int static_init_value = 9;
static int static_uninit_value;

void NSLog(NSString *format, ...) {

}

int main(int argc, char *argv[]) {
    static_uninit_value = 10;
    NSLog(@"%d", static_init_value);
//    LGOneObject *one = [LGOneObject new];
//    [one testOneObject];
//    NSLog(@"%@", one);
  return 0;
}


可以看到新写的NSLog函数是一个全局符号,并写在__Text段。并且没有和Founddation中的NSLog有冲突。

原因是因为:有一个二级命名空间的概念,链接器默认采用二级命名空间,也就是除了会记录符号的名称,还会记录符号属于哪一个Mach-O的。

本地符号只针对当前文件可见。


2、导入符号和导出符号

比如NSLog,NSLog是从Foundation导出来了,在项目是导入了NSLog。所以导入导出是相对的。但是导出符号一定是全局符号。
通过 objdump --macho --exports-trie 命令查看导出符号

导出符号在动态库脱符号的时候是不能被脱去的。
针对OC而言,oc的类默认的都是导出符号。我添加一个MyTestObjc的类,查看导出符号:

可以看到MyTestObjc的类和元类都是导出符号。

可以通过增加链接器参数把符号转为本地符号。
OTHER_LDFLAGS= (inherited) -Xlinker -unexported_symbol -Xlinker _OBJC_CLASS__MyTestObjc

OTHER_LDFLAGS= (inherited) -Xlinker -unexported_symbol -Xlinker _OBJC_METACLASS__MyTestObjc

也可以使用 OTHER_LDFLAGS= $(inherited) -Xlinker -S -Xlinker -map xxx,指定文件可以输入所有的符号信息。

3、Weak Reference / Weak Defintion Symbol (弱引用和弱定义符号)

//弱定义符号
void weak_function(void)  __attribute__((weak));
//弱引用
void weak_import_function(void) __attribute__((weak_import));

Weak Defintion Symbol: 弱定义符号,如果静态链接器或者动态链接器为此符号找到另一个(非弱)定义,则弱定义将被忽略。只能将合并部分中的符号标记为弱定义。
那么弱定义符号的作用显然就是解决符号冲突的一种方案。

Weak Reference Symbol:弱引用符号,如果动态链接器找不到该符号的定义,则将其设置为0。链接器会将此符号设置弱链接标志。

 if (weak_import_function) {
    weak_import_function();
}
//相当于
 if (0) {
      weak_import_function();
 }

通过 OTHER_LDFLAGS= $(inherited) -Xlinker -U -Xlinker _weak_import_function 告诉链接器,_weak_import_function这个符号是动态链接的。在静态链接时,忽略找不到的情况。

如上代码,此时weak_import_function如果没有被实现的话,则会将其设置为0。此时运行代码是不会报错的。

4、重新导出符号

通过 OTHER_LDFLAGS= $(inherited) -Xlinker -alias -Xlinker _NSLog -Xlinker Geng_NSLog
给NSLog加一个别名(只能给间接符号表的符号加别名)
然后通过 nm -m ${MACH_PATH} | grep 'Geng' 命令

可以看到别名生效了。
再查看一下导出符号表:

可以看到 Geng_NSLog被重新导出了。

5、Swift符号

swift是静态语言,在编译的时候,就能确定符号的类型。当把class设置为public的时候,就是全局符号,private的时候就是本地符号。


六、strip

1、相对app可执行文件、静态库、动态库而言,strip应该脱去哪写符号

(1)app可执行文件 (All Symbols) :除了间接符号表的符号,也就是导入符号,不能脱去之外,其他的都可以用strip来脱去,这里还有一个两个特殊的符号,一个是 __mh_execute_header (获取header地址进行计算用)
一个是radr://5614542,是苹果系统的bug记录(5614542是bug id)。
(2)静态库 (Debugging Symbols):是.o文件的合集,在链接的时候,会生成重定位符号表,所以只能脱去调试符号。
(3)动态库 (Non-Global):只能脱去非全局符号。

2、strip 的原理流程

*脱去.o文件调试符号的流程:
(1)macho解析成模型Object
(2)遍历LoadCommands ,找到segname==‘__DWARF’的LoadCommand
(3)移除对应的section,再从符号表中移除Symbol
(4)将修改后的模型Object重新写入Mach-O

*脱去可执行文件或者动态库的调试符号:
(1)遍历符号表,判断符号信息。
(2)根据n_type是否包含N_STAB(0xeo),删除调试符号

#define	N_STAB	0xe0  /* if any of these bits set, a symbolic debugging entry */

*脱去Non-Global Symbols
(1)遍历符号表,判断符号信息。
(2)根据n_type是否包含N_EXT,删除非全局符号

#define	N_EXT	0x01  /* external symbol bit, set for external symbols */

*脱去All Symbols
遍历所有的符号,删除除了间接符号表中引用的符号。

七、dead code strip


可以看到定义,扫描代码,删掉没有使用的本地符号。具体的探索,后面再详细分析。


*命令总结:

如果要查看某一个命令的具体定义可以通过2种方式:
1、man xxx
2、xxx --help

* 控制台命令:

br read -f xxx 从一个文件中把断点信息加载进来
br list strip 将加载进来的断点放在一个组(strip)里面
br enable strip 将组里的断点启用
br write -f 将断点写入文件

*记录几个快捷命令:

ctrl + u 清除当前行
ctrl + l 清屏
commond + k 彻底清空
ctrl + a 到行首
ctrl + e 到行尾
ctrl + f/b 前进后退
ctrl + p 上一条命令
ctrl + r 搜索命令历史