iOS技能拓展:初识符号与链接

????????关注后回复 “进群” ,拉你进程序员交流群????????

转自:掘金 我是好宝宝

https://juejin.cn/post/6961576195332309006

写在前面

本文主要介绍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文件
2.查看__TEXT段
objdump --macho -d machO文件
3.编译链接过程
  1. 生成目标文件

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

  • 将代码尽可能的转成汇编语言

  • 将符号归类——上例使用的NSLog属于导入符号(存在别的machO文件中)它会在链接时才确定它的内存地址,因此需要暂存起来——放到重定位符号表中(其他用到的系统库API均是如此)

    • 为什么在链接时才能确定它的内存地址,是因为生成目标文件时内存没有虚拟化,本machO文件中符号可以通过地址偏移得到,而导入符号(其他machO文件)却不行

    • 同时也可以通过查看重定位符号表来查看API的使用情况

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

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

三、符号表

1.符号表
  • Symbol Table:用来保存符号

  • String Table:用来保存符号的名称

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

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

nm -pa xxx(MachO文件路径)

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

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

nm -pa ${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/* > /dev/ttys000
  • nm:在linux中列出目标文件的符号清单,常用来查看动态链接库中的函数

  • -p:不排序符号,使用该选项后的输出没有按照地址也没有按照符号名称排序

  • -a:输出全部符号表,包括调试符号

  • ${BUILD_DIR}/${CONFIGURATION}${EFFECTIVE_PLATFORM_NAME}/*:Xcode内置的参数以便于使用相对路径来执行命令

  • /dev/ttys000:终端窗口。可以在终端窗口使用tty查看当前终端

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

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

2.调试符号
  • 文件通过汇编器生成目标文件时 会生成一个DWARF格式的调试文件,它被放在machO文件中的DWARF段

  • 而在链接过程中DWARF段会被干掉并放到可执行文件的符号表中

3.剥离调试符号

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

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

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

那么具体怎么配置呢?

  • 新建Configuration文件

  • ProductConfiguration文件一一对应起来

  • 配置Configuration文件:OTHER_LDFLAGS = -Xlinker -S

    • -Xlinker表示后面的参数是传给链接器

  • 编译之后在BuildSettings中的other link flag中查看是否添加成功

四、符号表分类

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文件

从终端输出可以看出:

  • 不管是否初始化,全局变量都变成了全局符号

  • 静态变量都变成了本地符号

    • 这里需要注意的是,如果静态变量未使用的话,是会变成调试符号

1.1 全局符号与本地符号

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

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

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

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

  1. 使用`static`修饰(最为简单)
    
  2. 修改其可见性(全局符号转为本地符号,且未初始化的全局变量会被存放在未初始化的变量区中)
    
int global_num __attribute__((visibility("hidden"))) = 10;
int global_undefine_num __attribute__((visibility("hidden")));
1.2 二级命名空间
  • 动态库实现不对外声明的全局符号+主项目只做声明全局符号

    • 输出结果为动态库的代码 => 全局符号对整个项目可见

  • 动态库实现不对外声明的全局符号+主项目声明&实现全局符号

    • 输出结果为主工程的代码 => 全局符号对整个项目可见

    • 这是由于二级命名空间的缘故——链接器默认采用二级命名空间,除了记录符号名称,还会记录符号属于哪个可执行文件 => 优先使用本工程的符号

  • 动态库实现对外声明的全局符号+主项目声明&实现全局符号

    • 报错/Users/felix/Desktop/FXDemo/FXDemo/ViewController.m:18:6: Redefinition of 'global_symbol'

    • 因为动态库的全局符号对外导出了,在主工程会重新加入符号表

    • 如果不导入声明文件就不会报错

  • 主项目两个不同文件声明同一个全局符号

    • 报错1 duplicate symbol for architecture arm64

    • 因为两个符号命名空间一样

1.3 全局符号总结

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

  1. 动态库中的全局符号,仅在主项目中声明也可以使用;

  2. 动态库中的静态符号,在其他项目中都不可使用

  3. 在主项目、动态库中分别声明同一名称的符号,就牵扯到二级命名空间问题

  4. 同一项目中不能存在多个全局符号(因为二级命名空间一样)

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

2.导入符号和导出符号

继续拿刚才的NSLog举例:

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

  • 对于Foundation来说,它导出了NSLog符号(导出符号)

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

objdump --macho --exports-trie machO地址
  • 导出符号结果与全局符号结果相比较,可以看出导出符号一定是全局符号,因为它对整个项目都可见,且提供给别的项目使用

    • 由于符号表是占体积的,我们可以通过剥离符号来减少App体积

    • 而使用到的导出符号NSLog将作为间接符号保存起来,这部分符号是不能被脱去的,否则程序无法正常运行

    • 导出符号一定是全局符号这个结论可知,全局符号也是不能被脱去的

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

objdump --macho --indirect-symbols machO地址
  • 平时在定义全局符号/全局变量的时候,需要注意它在编译时会作为导出符号被别的空间/模块所使用

  • 一般情况下,全局符号导出符号,但这不是绝对的,我们可以通过链接器来控制它

    • 以动态库举例,它只需要在链接的时候提供导出符号即可,但Objective-C中所有类默认都是导出符号

    • 新建FXPerson的Objective-C对象,再去查看导出符号

    • 即便把Objective-C对象的声明从.h文件放到.m文件中,也丝毫不会改变它创建了一个导出符号的结果

可以通过在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 地址
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未定义

  • 如果别的可执行文件想重新使用这个符号的话,需要重新导出——放到本文件的导出符号表中——外界可以使用这个符号

  • 那么就需要用到链接器中的参数-alias(起别名)会把间接符号表变成导出符号

    • 仅限间接符号可以这么使用

5.Swift符号

添加一个Swift文件

import Foundation

private class SwiftPerson {
    func playGame() {
        
    }
}

public class PublicPerson {
    func playGame() {
        
    }
}

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

objdump --macho -syms machO文件 | grep 'Person'
  • Swift文件会生成很多符号

  • publicprivate对应着全局符号本地符号

  • BuildSettings中有配置项可以对Swift符号进行剥离——Strip Swift Symbols

五、剥离符号表

  • 动态库要留下导出符号供外部使用

    • 不能剥离全局符号/导出符号——Non-Global Symbols

  • 静态库是目标文件的合集+重定位符号表,只能接触到调试符号

    • 只能剥离调试符号——Debug Symbols

  • App不需要供外部使用,但是需要保留外部导入的符号

    • 不能剥离间接符号表/导入符号(NSLog)——All Symbols

写在后面

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

  • 静态库的所有符号都会放到主工程中的符号表中——可能有全局符号本地符号导出符号等(除了导入符号

    • 而App中除了导入符号,其他全部可以被剥离

  • 动态库的导出符号都会放到主工程的间接符号表

    • 动态库的导出符号不会被剥离

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

-End-

最近有一些小伙伴,让我帮忙找一些 面试题 资料,于是我翻遍了收藏的 5T 资料后,汇总整理出来,可以说是程序员面试必备!所有资料都整理到网盘了,欢迎下载!

点击????卡片,关注后回复【面试题】即可获取

在看点这里好文分享给更多人↓↓

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值