IOS - 符号
前言
一、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 搜索命令历史