Swift编译器Crash—Segmentation fault解决方案
背景
抖音上线 Swift 后,编译时偶现Segmentation fault: 11
和Illegal instruction: 4
的错误,CI/CD 和本地均有出现,且重新编译后均可恢复正常。
由于属于编译器层抛出的 Crash,加之提示的错误代码不固定且非必现,一时较为棘手。网上类似错误较多,但Segmentation fault
属于访问了错误内存的通用报错,参考意义较小。和公司内外的团队交流过,也有遇到类似错误,但原因各不相同,难以借鉴。
虽然 Swift 库二进制化后,相关代码不会参与编译,本地出现的概率大大减少,但在 CI/CD/仓库二进制化任务中依旧使用源码,出现问题需要手动重试,影响效率且繁琐,故深入编译器寻求解决方案。
Crash 堆栈
结论
简而言之,是 Swift 代码中将在 OC 中声明为类属性的NSDictionary
变量,当成 Swift 的Dictionary
使用。即一个 immutable 变量当作 mutable 变量使用了。编译器在校验SILInstruction
时出错,主动调用abort()
结束进程或出现EXC_BAD_ACCESS
的 Crash。
准备工作
编译 Swift
由于本地重现过错误,故拉取和本地一致的 swift-5.3.2-RELEASE 版本,同时推荐使用 VSCode 进行调试,Ninja 进行构建。
Ninja 是专注于速度的小型构建系统。
注意事项
- 提前预留 50G 磁盘空间
- 首次编译时长在一小时左右,CPU 基本打满
下载&编译源码
brew install cmake ninja
mkdir swift-source
cd swift-source
git clone git@github.com:apple/swift.git
cd swift/utils
./update-checkout --tag swift-5.3.2-RELEASE --clone
./build-script
主要目录
提取编译参数
笔者将相关代码抽离抖音工程, 本地复现编译报错问题后,从 Xcode 中提取编译参数:
VSCode 调试
选择合适的 LLDB 插件,以 CodeLLDB 为例配置如下的 launch.json。
其中args
内容为获取前一步提取的编译参数,批量将其中每个参数用双引号包裹,再用逗号隔开所得。
{
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug",
"program": "${workspaceFolder}/build/Ninja-DebugAssert/swift-macosx-x86_64/bin/swift",
"args": ["-frontend","-c","-primary-file"/*and other params*/],
"cwd": "${workspaceFolder}",
}
]
}
SIL
LLVM
在深入 SIL 之前,先简单介绍 LLVM,经典的 LLVM 三段式架构如下图所示,分为前端(Frontend),优化器(Optimizer)和后端(Backend)。当需要支持新语言时只需实现前端部分,需要支持新的架构只需实现后端部分,而前后端的连接枢纽就是 IR(Intermediate Representation),IR 独立于编程语言和机器架构,故 IR 阶段的优化可以做到抽象而通用。
Frontend
前端经过词法分析(Lexical Analysis),语法分析(Syntactic Analysis)生成 AST,语义分析(Semantic Analysis),中间代码生成(Intermediate Code Generation)等步骤,生成 IR。
IR
格式
IR 是 LLVM 前后端的桥接语言,其主要有三种格式:
- 可读的格式,以.ll 结尾
- Bitcode 格式,以.bc 结尾
- 运行时在内存中的格式
这三种格式完全等价。
SSA
LLVM IR 和 SIL 都是 SSA(Static Single Assignment)形式,SSA 形式中的所有变量使用前必须声明且只能被赋值一次,如此实现的好处是能够进行更高效,更深入和更具定制化的优化。
如下图所示,代码改造为 SSA 形式后,变量只能被赋值一次,就能很容易判断出 y1=1 是可被优化移除的赋值语句。
结构
基础结构由 Module 组成,每个 Module 大概相当于一个源文件。Module 包含全局变量和 Function 等。Function 对应着函数,包括方法的声实现,参数和返回值等。Function 最重要的部分就是各类 Basic Block。
Basic Block(BB) 对应着函数的控制流图,是 Instruction 的集合,且一定以 Terminator Instructions 结尾,其代表着 Basic Block 执行结束,进行分支跳转或函数返回。
Instruction 对应着指令,是程序执行的基本单元。
Optimizer
IR 经过优化器进行优化,优化器会调用执行各类 Pass。所谓 Pass,就是遍历一遍 IR,在进行针对性的处理的代码。LLVM 内置了若干 Pass,开发者也可自定义 Pass 实现特定功能,比如插桩统计函数运行耗时等。
Xcode Optimization Level
在 Xcode - Build Setting - Apple Clang - Code Generation - Optimization Level 中,可以选定优化级别,-O0 表示无优化,即不调用任何优化 Pass。其他优化级别则调用执行对应的 Pass。
Backend
后端将 IR 转成生成相应 CPU 架构的机器码。
Swiftc
不同于 OC 使用 clang 作为编译器前端,Swift 自定义了编译器前端 swiftc,如下图所示。
这里就体现出来 LLVM 三段式的好处了,支持新语言只需实现编译器前端即可。
对比 clang,Swift 新增了对 SIL(Swift Intermediate Language)的处理过程。SIL 是 Swift 引入的新的高级中间语言,用以实现更高级别的优化。
Swift 编译流程
Swift 源码经过词法分析,语法分析和语义分析生成 AST。SILGen 获取 AST 后生成 SIL,此时的 SIL 称为 Raw SIL。在经过分析和优化,生成 Canonical SIL。最后,IRGen 再将 Canonical SIL 转化为 LLVM IR 交给优化器和后端处理。
SIL 指令
SIL 假设虚拟寄存器数量无上限,以%+数字命名,如%0,%1 等一直往上递增 以下介绍几个后续会用到的指令:
alloc_stack
`: 分配栈内存apply
: 传参调用函数Load
: 从内存中加载指定地址的值function_ref
: 创建对 SIL 函数的引用
SIL 详细的指令解析可参考官方文档。
Identifier
LLVM IR 标识符有 2 种基本类型:
- 全局标识符:包含方法和全局变量等,以@开头
- 局部标识符:包含寄存器名和类型等,以%开头,其中%+数字代表未命名变量变量
在 SIL 中,标识符以@开头
- SIL function 名都以@+字母/数字命名,且通常都经过 mangle
- SIL value 同样以%+字母/数字命名,表示其引用着 instruction 或 Basic block 的参数
@convention(swift)
使用 Swift 函数的调用约定(Calling Convention),默认使用@convention(c)
和@convention(objc_method)
分别表示使用 C 和 OC 的调用约定@convention(method)
表示 Swift 实例方法的实现@convention(witness_method)
表示 Swift protocol 方法的实现
SIL 结构
SIL 实现了一整套和 IR 类似的结构,定制化实现了SILModule SILFunction SILBasicBlock SILInstruction
。
调试过程
复现 Crash
根据前文的准备工作设置好编译参数后,启动编译,复现 Crash,两种 Crash 都有复现,场景如下图所示。abort()
和EXC_BAD_ACCESS
会导致上文出现的Illegal instruction: 4
和Segmentation fault: 11
错误。由于二者的上层堆栈一致,以下以前者为例进行分析。
堆栈分析
通过堆栈溯源可看出是在生成SILFunction
后,执行postEmitFunction
校验SILFunction
的合法性时,使用SILVerifier
层层遍历并校验 BasicBlock(visitSILBasicBlock
)。对 BasicBlock 内部的SILInstruction
进行遍历校验(visitSILInstruction
)。
在获取SILInstruction
的类型时调用getKind()
返回异常,触发 Crash。
异常 SIL
- 由于此时
SILInstruction
异常,比较难定位是在校验哪段指令时异常,故在遍历SILInstruction
时打印上一段指令的内容。 - swift 源代码根目录执行以下命令,增量编译
cd build/Ninja-DebugAssert/swift-macosx-x86_64
ninja
复现后打印内容如下图所示:
调试小 tips:LLVM 中很多类都实现了 dump()函数用以打印内容,方便调试。
// function_ref Dictionary.subscript.setter
%32 = function_ref @$sSDyq_Sgxcis : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> () // user: %33
%33 = apply %32<AnyHashable, Any>(, , %24) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@in Optional<τ_0_1>, @in τ_0_0, @inout Dictionary<τ_0_0, τ_0_1>) -> ()
%34 = load [take] %24 : $*Dictionary<AnyHashable, Any> // users: %43, %37
正常 SIL
命令行使用swiftc -emit-silgen
能生成 Raw SIL,由于该类引用到了 OC 文件,故加上桥接文件的编译参数,完整命令如下:
swiftc -emit-silgen /Users/cs/code/ThirdParty/Swift_MVP/Swift_MVP/SwiftCrash.swift -o test.sil -import-objc-header /Users/cs/code/ThirdParty/Swift_MVP/Swift_MVP/Swift_MVP-Bridging-Header.h
截取部分 SIL 如下
%24 = alloc_stack $Dictionary<AnyHashable, Any> // users: %44, %34, %33, %31
%25 = metatype $@objc_metatype TestObject.Type // users: %40, %39, %27, %26
%34 = load [take] %24 : $*Dictionary<AnyHashable, Any> // users: %42, %36
%35 = function_ref @$sSD10FoundationE19_bridgeToObjectiveCSo12NSDictionaryCyF : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@guaranteed Dictionary<τ_0_0, τ_0_1>) -> @owned NSDictionary // user: %37
%36 = begin_borrow %34 : $Dictionary<AnyHashable, Any> // users: %38, %37
%37 = apply %35<AnyHashable, Any>(%36) : $@convention(method) <τ_0_0, τ_0_1 where τ_0_0 : Hashable> (@guaranteed Dictionary<τ_0_0, τ_0_1>) -> @owned NSDictionary // users: %41, %40
SIL 分析
对正常 SIL 逐条指令分析
- 在栈中分配类型为
Dictionary<AnyHashable, Any>
的内存,将其地址存到寄存器%24,该寄存器的使用者是%44, %34, %33, %31 - %25 表示类型
TestObject.Type
,即TestObject
的类型 metaType - 加载%24 寄存器的值到%34 中,同时销毁%24 的值
- 创建对函数
_bridgeToObjectiveC()-> NSDictionary
的引用,存到%35 中
- 由于函数名被 mangle,先将函数名 demangle,如下图所示,得到函数
@convention(method)
表明是 Swift 实例方法,有 2 个泛型参数,其中第一个参数τ_0_0
实现了 Hashable 协议
- 生成一个和%34 相同类型的值,存入%36,%36 结束使用之前,%34 一直存在
- 执行%35 中存储的函数,传入参数%36,返回
NSDictionary
类型,结果存在%37。其作用就是将Dictionary
转成了NSDictionary
曙光初现
对比异常 SIL,可以看出是在执行桥接方法_bridgeToObjectiveC()
时失败,遂查看源码,发现是一个 OC 的NSDictionary
不可变类型桥接到 Swift 的Dictionary
成为一个可变类型时,对其内容进行修改。虽然这种写法存在可能导致逻辑异常,但并不致编译器 Crash,属于编译器代码 bug。更有意思的是,只有在 OC 中将该属性声明为类属性(class)时,才会导致编译器 Crash。
class SwiftCrash: NSObject {
func execute() {
//compiler crash
TestObject.cachedData[""] = ""
}
}
@interface TestObject : NSObject
@property (strong, nonatomic, class) NSDictionary *cachedData;
@end
解决方案
源码修改
找到错误根源就好处理了,将问题代码中的 NSDictionary 改成 NSMutableDictionary 即可解决。
重新运行 Swift 编译器编译源码,无报错。
修改抖音源码后,也再没出现编译器 Crash 的问题,问题修复。
静态分析
潜在问题
虽然NSDictionary
正常情况下可以桥接成 Swift 的Dictionary
正常使用,但当在 Swift 中对 immutable 对象进行修改后,会重新生成新的对象,对原有对象无影响,测试代码和输出结果如下:
可以看出变量temp
内容无变化,Swift 代码修改无效。
TestObject *t = [TestObject new];
t.cachedData = [@{@"oc":@"oc"} mutableCopy];
NSDictionary *temp = t.cachedData;
NSLog(@"before execution : temp %p: %@",temp,temp);
NSLog(@"before execution : cachedData %p: %@",t.cachedData,t.cachedData);
[[[SwiftDataMgr alloc] init] executeWithT:t];
NSLog(@"after execution : temp %p: %@",temp,temp);
NSLog(@"after execution : cachedData %p: %@",t.cachedData,t.cachedData);
class SwiftDataMgr: NSObject {
@objc
func execute(t : TestObject) {
t.cachedData["swift"] = "swift"
}
}
新增规则
新增对抖音源码的静态检测规则,检测所有 OC immutable 类是否在 Swift 中被修改。防止编译器 crash 和导致潜在的逻辑错误。
所有需检测的类如下:
NSDictionary/NSSet/NSData/NSArray/NSString/NSOrderedSet/NSURLRequest/
NSIndexSet/NSCharacterSet/NSParagraphStyle/NSAttributedString
后记
行文至此,该编译器 Crash 问题已经解决。同时近期在升级 Xcode 至 12.5 版本时又遇到另一种编译器 Crash 且未提示具体报错文件,笔者如法炮制找出错误后并修复。待深入分析生成SILInstruction
异常的根本原因后,另起文章总结。