Mach-O
Mach-O
(Mach Objec
t)是macOS
、iOS
、iPadOS
存储程序和库的文件格式。对应系统通过应用二进制接口(application binary interfac
e,缩写为 ABI
)来运行该格式的文件。
Mach-O
格式用来替代BSD
系统的a.out
格式。Mach-O
文件格式保存了在编译过程和链接过程中产生的机器代码和数据,从而为静态链接和动态链接的代码提供了单一文件格式。
可执行文件的调用过程:
- 调用
fork
函数,创建一个process
(进程); - 调用
execve
或其衍生函数,在该进程上加载,执行Mach-O
文件。
当调用execve
(程序加载器) 时,内核实际上在执行以下操作:
i.将文件加载到内存;
ii.开始分析Mach-O
中的mach_header
,以确认它是有效的Mach-O
文件。
Mach-o File Format
一个Mach-o
文件有两部分组成:header
和data
。
一个简单的
main
函数例子:
int main(int argc, const char * argv[]) {
return 0;
}
编译好后查看下__TEXT
段:
objdump --macho -d ${MACHO_PATH}
main
函数对应的机器码如下,后面的汇编是给我们阅读的,机器并不需要,可以理解为注释。
'int main() { }' compiled for x86_64-apple-macosx with clang
0x55, // offset 0 -- pushq %rbp
0x48, 0x89, 0xe5, // offset 1 -- movq %rsp, %rbp
0x31, 0xc0, // offset 4 -- xorl %eax, %eax
0x5d, // offset 6 -- popq %rbp
0xc3 // offset 7 -- retq
这里的机器码是固定的,也就是所谓的ABI
稳定。
Big endian or little endian
iOS
和macOS
开发都是小端模式。
当读取一个地址的时候
- 小端:从右往左读
- 大端:从左往右读
header
代表了文件的映射,描述了文件的内容以及文件所有内容所在的位置。header
内的section
描述了对应的二进制信息。
header
包含三种类型:
Mach header
segment
sections
Mach header
⚠️:Mach header
属于header
的一部分,它包含了整个文件的信息和segment
信息。
直接新建一个工程看下
Mach header
:
objdump --macho -private-header ${MACHO_PATH}
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
MH_MAGIC_64 X86_64 ALL 0x00 EXECUTE 19 1944 NOUNDEFS DYLDLINK TWOLEVEL WEAK_DEFINES BINDS_TO_WEAK PIE
也可以通过otool
查看:
otool -h ${MACHO_PATH}
magic cputype cpusubtype caps filetype ncmds sizeofcmds flags
0xfeedfacf 16777223 3 0x00 2 20 1960 0x00218085
查看mach header
objdump --macho -private-header ${MACHO_PATH}
otool -h ${MACHO_PATH}
相对来说objdump
更容易阅读,otool
更接近原始数据。
data
紧跟header
之后,由多个二进制组成,one by one。
Load Commands
二进制文件加载进内存要执行的一些指令。
这里的指令主要在负责我们 APP
对应进程的创建和基本设置(分配虚拟内存,创建主线程,处理代码签名/加密的工作),然后对动态链接库(.dylib
系统库和我们自己创建的动态库)进行库加载和符号解析的工作。
直接在.app
目录下dump
(objdump --macho --private-headers
)一下可执行文件:
➜ TestMutableConfig.app objdump --macho --private-headers TestMutableConfig
可以看到load command
信息如下:
都知道程序主入口是
main
函数,看下对应的main
信息:
objdump --macho --private-headers TestMutableConfig | grep 'LC_MAIN' -A 3
cmd LC_MAIN
cmdsize 24
entryoff 7856
stacksize 0
dyld
通过LC_MAIN
去找程序的主入口。当然可以自己指定不为main
。
可以理解为Mach-O
就是:文件配置+二进制代码
Mach-O
是可读可写的
上面通过objdump
输出的信息有点多,如果只想看自己关注的信息,可以精简下写一个自定义读取程序machoInfo
:
原理是根据Mach-O
格式读取数据
1:直接终端命令查看:
2:直接
main
函数传参进去:machoInfo
地址:machoInfo
目标文件生成过程:
- 链接器(
llvm-ld
)并没有被执行。 - 目标文件不会包含
Unix
程序在被装载和执行时所必须包含的信息。
修改main.m
文件:
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;
int main(int argc, const char * argv[]) {
static_uninit_value = 10;
NSLog(@"%d", static_init_value);
return 0;
}
对应的代码段:
(__TEXT,__text) section
_main:
100003f50: 55 pushq %rbp
100003f51: 48 89 e5 movq %rsp, %rbp
100003f54: 48 83 ec 10 subq $16, %rsp
100003f58: 48 8d 05 a9 00 00 00 leaq 169(%rip), %rax ## Objc cfstring ref: @"%d"
100003f5f: c7 45 fc 00 00 00 00 movl $0, -4(%rbp)
100003f66: 89 7d f8 movl %edi, -8(%rbp)
100003f69: 48 89 75 f0 movq %rsi, -16(%rbp)
100003f6d: c7 05 a1 40 00 00 0a 00 00 00 movl $10, _static_init_value(%rip)
100003f77: 8b 35 97 40 00 00 movl _static_init_value(%rip), %esi
100003f7d: 48 89 c7 movq %rax, %rdi
100003f80: b0 00 movb $0, %al
100003f82: e8 09 00 00 00 callq 0x100003f90 ## symbol stub for: _NSLog
100003f87: 31 c0 xorl %eax, %eax
100003f89: 48 83 c4 10 addq $16, %rsp
100003f8d: 5d popq %rbp
100003f8e: c3 retq
在生成.o
的过程中:
- 能变成汇编的代码尽量变成汇编。
- 符号归类 -> 重定位符号表(放的是
.m/.o
文件用到的API
)。也就是保存了当前文件用到的符号。 -
.o
-> 链接 -> 合并到一张表 -> exec(可执行文件)
查看重定位符号表
objdump --macho -reloc test.o
(目标文件)
链接
链接的本质就是把多个目标文件组合成一个文件
将多个.o
文件合并成一个可执行文件,在链接的过程中可以进行操作。
可以通过man ld
查看都有哪些功能,比如:why-live
、why-load
等。
Symbol
-
Symbol Table
: 用来保存符号。 -
String Table
: 用来保存符号的名称。 -
Indirect Symbol Table
: 间接符号表。保存使用的外部符号,更准确一点就是使用外部动态库的符号。是Symbol Table
的子集。
符号的种类
为了方便操作,直接在Xcode
Run script
中添加命令编译后直接将结果输出到终端:
Run Script
:
#输出 "HotpotCat" 重定位到 终端 /dev/ttys003
echo "HotpotCat" > /dev/ttys003
其中/dev/ttys003
为终端,可以在终端中输入tty
查看获取。
Run Script
也可以读取XCConfig
中的内容。可以配合起来完成。所以我们可以分为3步:配置读取符号脚本,XCConfig
传递参数,Run sctipt
执行脚本
1.xcode_run_cmd.sh
定义一个xcode_run_cmd
脚本,将符号输出到终端,这个脚本需要2个参数,参数从XCConfig
配置。
#!/bin/sh
RunCommand() {
#判断全局字符串VERBOSE_SCRIPT_LOGGING是否为空。-n string判断字符串是否非空
#[[是 bash 程序语言的关键字。用于判断
if [[ -n "$VERBOSE_SCRIPT_LOGGING" ]]; then
#作为一个字符串输出所有参数。使用时加引号"$*" 会将所有的参数作为一个整体,以"$1 $2 … $n"的形式输出所有参数
if [[ -n "$TTY" ]]; then
echo "♦ $@" 1>$TTY
else
echo "♦ $*"
fi
echo "------------------------------------------------------------------------------" 1>$TTY
fi
#与$*相同。但是使用时加引号,并在引号中返回每个参数。"$@" 会将各个参数分开,以"$1" "$2" … "$n" 的形式输出所有参数
if [[ -n "$TTY" ]]; then
eval "$@" &>$TTY
else
"$@"
fi
#显示最后命令的退出状态。0表示没有错误,其他任何值表明有错误。
return $?
}
EchoError() {
#在shell脚本中,默认情况下,总是有三个文件处于打开状态,标准输入(键盘输入)、标准输出(输出到屏幕)、标准错误(也是输出到屏幕),它们分别对应的文件描述符是0,1,2
# > 默认为标准输出重定向,与 1> 相同
# 2>&1 意思是把 标准错误输出 重定向到 标准输出.
# &>file 意思是把标准输出 和 标准错误输出 都重定向到文件file中
# 1>&2 将标准输出重定向到标准错误输出。实际上就是打印所有参数已标准错误格式
if [[ -n "$TTY" ]]; then
echo "$@" 1>&2>$TTY
else
echo "$@" 1>&2
fi
}
RunCMDToTTY() {
# CMD = 运行到命令
# TTY = 终端
if [[ ! -e "$TTY" ]]; then
EchoError "=========================================="
EchoError "ERROR: Not Config tty to output."
exit -1
fi
if [[ -n "$CMD" ]]; then
RunCommand $CMD
else
EchoError "=========================================="
EchoError "ERROR:Failed to run CMD. THE CMD must not null"
fi
}
RunCMDToTTY
2.XCConfig
配置:
由于脚本中有3个参数,可以直接在XCConfig
中配置,脚本中就可以读取到了。因为脚本是在Run strip
执行的,并且和工程在同一目录。
// BUILD_DIR build路径 *为可执行文件名称,这里为了适配写为*通配符
MACHO_PATH=${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/*
//# CMD = 运行的命令
//# CMD_FLAG = 运行的命令参数
//# TTY = 终端
CMD = nm
// -p: 不排序
// -a: 显示所有符号,包含调试符号
CMD_FLAG = -pa ${MACHO_PATH}
TTY = /dev/ttys000
BUILD_DIR
:
由于不需要调试符号,可以脱去(
strip
)调试符号我们知道
build setting
中有个strip
设置:Deployment Postprocessing
和Strip Style
:可以看到
Strip
是在Run Script
之后执行的。所以在build setting
中设置行不通。那么直接
man ld
查看下有没有可用的命令:可以看到有个
S
比较符合。XCConfig
修改完后配置如下:
// BUILD_DIR build路径 *为可执行文件名称,这里为了适配写为*通配符
MACHO_PATH=${BUILD_DIR}/$(CONFIGURATION)$(EFFECTIVE_PLATFORM_NAME)/*
//# CMD = 运行的命令
//# CMD_FLAG = 运行的命令参数
//# TTY = 终端
CMD = nm
// -p: 不排序
// -a: 显示所有符号,包含调试符号
CMD_FLAG = -pa ${MACHO_PATH}
TTY = /dev/ttys000
//-Xlinker 虽然执行的ld,但是命令传给连接器
OTHER_LDFLAGS=-Xlinker -S
3.Run script
执行脚本
# SRCROOT 当前代码路径
/bin/sh "$SRCROOT/xcode_run_cmd.sh"
至此,环境已经配置好了。
接下来配置一些符号看下:
//全局变量
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;
int main(int argc, const char * argv[]) {
static_uninit_value = 10;
NSLog(@"%d", static_init_value);
return 0;
}
objdump --macho --syms ${MACHO_PATH}
查看下:
可以看到有好多
Debug
符号,在生成文件的时候会生成DWARF
调试文件,放在.o
专门的DWARF
段,当连接的时候会放入符号表中。也就是说调试符号在目标文件中是放在DWARF
段中的,当链接后放在符号表中。去掉这些干扰信息,有两种方式:
-
xcconfig
中配置OTHER_LDFLAGS=$(inherited) -Xlinker -S
-
xcode
中配置。
- 从上面的分析可以看出,不论初始化的还是未初始化的全局变量都是全局符号。
- 静态变量都变成了本地符号。
全局和本地本质上就是可见性。
全局变量和静态变量区别
当将一个全局变量定义为静态变量后就变成了本地变量,从全局可见变成了文件可见。 (静态变量 -> 本地变量
)。
按功能划分:
Type | 说明 | 备注 |
---|---|---|
f | File | 文件 |
F | Function | 方法 |
O | Data | 数据 |
d | Debug | 调试符号 |
ABS | Absolute | |
COM | Common | |
UND | ? | 未定义符号 |
符号可见性
符号可见性在clang
下有两种hidden
和default
(默认)。protected
在clang
下是没有的。
-
default
:用它定义的符号将被导出。 -
hidden
:用它定义的符号将不被导出。
__attribute__
可以把编译器支持的参数传递给编译器。我们经常遇到就是__attribute__((deprecated))
。
visibility
控制文件导出符号,限制符号可见性。
int hidden_y __attribute__((visibility("hidden"))) = 99;
double default_y __attribute__((visibility("default"))) = 100;
在上面例子中default_x
通过hidden
变成了l
本地符号,放在__DATA__common
未初始化区。
所以可以通过:
static
__attribute__((visibility("hidden")))
控制符号可见性。
two_levelnamespace & flat_namespace
二级命名空间与一级命名空间。链接器默认采用二级命名空间,也就是除了会记录符号 名称,还会记录符号属于哪个Mach-O
的,比如会记录下来_NSLog
来自Foundation
。
所以如果我们在自己主工程中有一个函数,另外一个framework
中也有同名函数:
void global_object() {
NSLog(@"global_object");
}
在调用过程中由于存在二级命名空间并不会产生冲突。如果修改为一级命名空间就会冲突。
导入导出符号
export symbol
:导出符号意味着,告诉别的模块,我有一个这样的符号,你可以将 其导入(Import
)。
导出导入是相对的。比如NSLog
对于Foundation
来说是导出符号,对于调用方而言是导入符号。导出符号一定是全局符号。
查看导出符号:
objdump --macho --exports-trie ${MACHO_PATH}
Exports trie:
0x100000000 __mh_execute_header
0x100003F50 _main
0x100008010 _global_init_value
0x100008020 _global_uninit_value
可以看到就是上面例子中的全局符号。全局变量默认会被导出。
对于动态库而言是在运行的过程中被加载,在编译连接阶段只要提供符号就可以了。这里就涉及到间接符号表
(保存着可执行文件使用的其它动态库的符号)了。
查看间接符号表:
objdump --macho --indirect-symbols ${MACHO_PATH}
- 全局符号可以变成导出符号。
由于符号影响macho
的大小,而间接符号不能删除。也就意味着使用到的动态库的全局符号不能删除。所以在strip
动态库的时候只能脱不是全局符号的符号。
间接符号->动态库的导出符号->全局符号->strip
只能拖不是全局符号的符号
定义一个OC
类:
@interface OCObject ()
- (void)testOCObject;
@end
@implementation OCObject
- (void)testOCObject {
NSLog(@"testOCObject");
}
@end
查看一下导出符号:
Exports trie:
0x100000000 __mh_execute_header
0x100003F20 _main
0x1000080B8 _OBJC_METACLASS_$_OCObject
0x1000080E0 _OBJC_CLASS_$_OCObject
0x100008110 _global_init_value
0x100008120 _global_uninit_value
可以看到OC
的类,默认就是导出符号。所以对于OC
的动态库要控制体积,对于不想暴露的符号可以借助链接器设置-unexported_symbol
后面跟不想导出的符号:
OTHER_LDFLAGS=$(inherited) -Xlinker -unexported_symbol -Xlinker _OBJC_CLASS_$_OCObject
可以看到已经没有了
_OBJC_CLASS_$_OCObject
导出符号了。这个时候就可以strip
掉了。对于外界也不可见了。
weak symbol
Weak Reference Symbol
: 表示此未定义符号是弱引用。如果动态链接器找不到该符号的定义,则将其设置为0。链接器会将此符号设置弱链接标志。
Weak defintion Symbol
: 表示此符号为弱定义符号。如果静态链接器或动态链接器为此符号找到另一个(非弱)定义,则弱定义将被忽略。只能将合并部分中的符号标记为弱定义。
定义WeakSymbol.h
:
// 弱定义
void weak_function(void) __attribute__((weak));
//弱引用
void weak_import_function(void) __attribute__((weak_import));
实现WeakSymbol.m
:
void weak_function(void) {
NSLog(@"weak_function");
}
void weak_import_function(void) {
NSLog(@"weak_import_function");
}
如果在
main
文件中再实现一个weak_function
void weak_function(void) {
NSLog(@"main weak_function");
}
这个时候正常情况下应该报符号冲突,弱定义后并不会冲突。
- 弱定义符号并不影响符号的全局和导出。
- 声明成弱定义的符号找到一个符号后,其它的会被忽略。
若把一个弱定义的全局符号隐藏掉,则会变成弱定义的本地符号。
void weak_hidden_function(void) __attribute__((weak, visibility("hidden")));
弱引用
如果没有实现则不会报错,调用的地方做判断即可。
if (weak_import_function) {
weak_import_function();
}
在编译的时候需要告诉编译器不要管-U
,这个符号是动态实现的。
-U symbol_name
Specified that it is ok for symbol_name to have no definition. With -two_levelnamespace,the resulting symbol will be marked dynamic_lookup which means dyld will search all loaded images.
OTHER_LDFLAGS=$(inherited) -Xlinker -U -Xlinker _weak_import_function
这个时候即使weak_import_function
不实现,编译和运行也都不会报错。
在这里如果我们将动态库全部声明为弱引用,则就不会报找不到符号的错误了。
重新导出符号
比如NSLog
对于我们来说是导入符号,对于可执行文件是一个未定义符号(存在间接符号表中)。
这里的
NSLog
只能在当前文件中使用,如果想给其它文件使用就需要重新导出。重新导出后就放在导出符号表中,外部就可以使用了。可以通过给一个符号起别名(只能给间接符号表中的符号起别名,起别名后会自动把间接符号表中的符号变成导出符号)的方式重新导出符号。
OTHER_LDFLAGS=$(inherited) -Xlinker -alias -Xlinker _NSLog -Xlinker HOTPOT_NSLog
通过易于阅读的
nm
方式输出下:
nm -m ${MACHO_PATH} | grep 'HOTPOT_NSLog'
可以看到是间接的外部的
_NSLog
别名的符号。接着看下导出符号表的情况
objdump --macho --exports-trie ${MACHO_PATH}
可以看到
HOTPOT_NSLog
是一个re-export
的导出符号。所以我们已通过将自己库依赖的动态库重新导出让动态库可见。一般用在自己的库依赖其它动态库,让其它动态库可见(可以是库也可以是符号)。
-reexported_symbols_list file
The specified filename contains a list of symbol names that are implemented in a dependent dylib and should be re-exported through the dylib being created.
-unexported_symbol symbol
The specified symbol is added to the list of global symbols names that will not remain as global symbols in the output file. This option can be used multiple times. For short lists, this can be more convenient than creating a file and using -unexported_symbols_list.
reexported_symbols_list
后面跟文件,可以将需要重新导出的符号写入到文件中。也可以通过unexported_symbol
隐藏起来。
swift symbol
internal enum SwiftEnumSymbol {
case maxHeap
case minHeap
internal func testSwiftEnumSymbol<T: Comparable>(type: T.Type) -> (T, T) -> Bool {
switch self {
case .maxHeap:
return (>)
case .minHeap:
return (<)
}
}
}
struct SwiftStructSymbol {
func testSwiftStructSymbol(o: Int) {
}
}
public protocol SwiftProtocolSymbol: class {
func testSwiftProtocolSymbol()
}
public class SwiftClassSymbol {
func testSwiftClassSymbol() {
}
}
查看全部符号
objdump --macho --syms ${MACHO_PATH}
可以看到添加swift
文件后多了很多符号:
只查看
SwiftClassSymbol
的符号:
objdump --macho --syms ${MACHO_PATH} | grep 'SwiftClassSymbol'
私有化SwiftClassSymbol
private class SwiftClassSymbol {
func testSwiftClassSymbol() {
}
}
可以看到都变成了local
。所以swift
是编译型语言,编译过程中就已经确定了符号的类型。
strip
- 动态库不能脱全局符号。
- App导出符号一般不提供给别人使用,对于间接符号(也就是导入符号)不能脱, 其它的都可以脱(本地+全局+弱定义都可以脱,只留下间接符号表中的符号)。
- 静态库是
.o
文件的合集 + 重定位符号表。所以重定位符号不能脱,唯一能脱的就是调试符号。
strip style
- Debugging Symbols (.o 静态库 / 可执行文件 动态库)
- All Symbols(App)
- Non-Global Symbols(动态库)
就只符号表来说
App使用静态库比使用动态库文件大小要小
。由于静态库最终会合并到macho
,对于App
来说会脱去所有符号,只留下间接符号。单纯只说静态库和动态库,那么静态库相对较小。引入App
后静态库小。(仅仅从符号的角度考虑)。
对于动态库可以考虑从导出符号优化体积,对于OC
默认就是导出符号。
strip
命令:
-x: non_global
无参数: 代表全部符号
-S
: 调试符号
dead code strip
在构建完成之后如果是 C、C++ 等静态的语言的代码、一些常量定义,如果发现没有被使用到将会被标记为 Dead code。开启 DEAD_CODE_STRIP = YES 这些 Dead code 将不会被打包到安装包中。在 LinkMap 这些符号也会被标记为 <<dead>>。
dead code strip
(死代码剥离)是一个连接器参数,可以扫描哪些代码没有用到并且删掉的不是导出符号(本地符号)。
-dead_strip
Remove functions and data that are unreachable by the entry point or exported symbols.
可以通过终端man ld
然后输入/ + 关键字
搜索命令,n
往下查找,N
往上查找。
打开code dead strip
前:
打开
code dead strip
后可以查看下符号表:可以看到没有使用的
weak_hidden_function
方法被脱去了。
strip原理
.o/静态库
.o/静态库
符号放在__DWARF
段,在脱符号的时候:
1.遍历LoadCommands
找到Segname
为__DWARF
的LoadCommand
然后移除下面所有的Section
;
2.再移除符号表中的Symbol
;
3.将重新改写后的macho
文件重新写入;
动态库/可执行文件
调试符号
遍历符号表判断符号表中的符号
n_type
是否包含N_STAB(0xe0)
,是调试符号就删除。
All Symbols
遍历间接符号表中所有的符号,只要不是间接符号表中的符号都可以删除。
Non-Global Symbols
遍历符号表,通过
n_type != N_EXT
判断。
dead code strip
和strip
主要是操作符号。
包大小优化方向
-
-O1
-Oz
生成目标文件 -
dead code strip
死代码剥离 链接的时候 -
strip
剥离符号 修改mach-o
LLVM调试
对于想要调试的命令直接添加scheme
,然后在Compile Sources
中查看文件:
2.找到对应文件在main
函数中打断点:
3.添加-pa
并将要调试的可执行程序拖进去
4.run without building
strip调试
1.在编译好的LLVM
源码工程中在TARGETS
中搜索strip
都时候,只有一个llvm-strip
:
可以看到是一个脚本:
这个脚本将
llvm-objcopy
可执行文件链接成了llvm-strip
可执行文件。类似快捷方式。2.添加
llvm-strip
脚本对应的scheme
编译。3.编译完成后在源码对应的
llvm-project/build/Debug/bin
目录下可以看到llvm-strip
对应的快捷方式。4.在
targets
中复制一个可执行文件命名为strip
。由于
llvm-objcopy
的代码中作乐判断,如果名称为llvm-objcopy
句当作llvm-objcopy
运行,如果是strip
就当作strip
运行。搜
strip
Show In Finder
这个时候是找不到对应的文件的。5.优化
复制一份
llvm-strip
:改名为
strip
:这个时候再重复4中的
Show In Finder
,就自动关联到strip
了。6.添加
strip
scheme并且在llvm-objcopy.cpp
文件的main
函数打上断点,然后run without building
就可以调试了-
**br read -f 文件路径**
可以将一个文件的断点信息导入。导入后默认没有启用。 -
br list strip
将所有断点放入strip
组中。 -
br enable strip
启用组里面的所有断点。
Xcode
默认断点式根据绝对路径来断的,不通用。通过lldb
终端下的断点式真正的符号断点,可以通用。
我们当然可以将自己的断点写入到文件中:
br write -f 文件
写入文件。
7.拖入可执行文件就可以开始调试了