当 Swift 编译器删除了标准库中的代码 - 记修复 Swift 6 中的冗余 Load 指令消除优化器

WeZZard计算艺术的构造与解释2025-3-9 24:00
最新更新:苹果已接受该问题的修复。最终解决方案在相关代码所有者 review 后进行了调整。
蛇年春节假期前,一位同事向我展示了一个由 use-after-free(释放后使用)错误导致的神秘崩溃。最近,我有时间深入研究这个问题,并发现崩溃是由 Swift 编译器的错误编译引起的。下面是最小复现代码,必须使用 -Osize 优化级别编译。我们可以通过在编译过程中启用地址检查器(address sanitizer)来检测 use-after-free 问题。
swift
let storage = ValueStorage()
storage.append(1)
// 运行时崩溃!
storage.append(2)
public class ValueStorage {
private class Data {
var values: [Int] = []
}
private var data = Data()
private func withAutoreleasingUnsafeMutableData<R>(_ body: (_ dataPtr: AutoreleasingUnsafeMutablePointer<Data>) throws -> R) rethrows -> R {
try withUnsafeMutablePointer(to: &data) { pointer in
try body(AutoreleasingUnsafeMutablePointer(pointer))
}
}
public func append(_ value: Int) {
withAutoreleasingUnsafeMutableData { dataPtr in
// 立即崩溃的行
dataPtr.pointee.values.append(value)
}
}
}
在 Xcode 中打开地址检查器
在 Xcode 中打开地址检查器
UAF 问题出现
UAF 问题出现
有趣的是,将 AutoreleasingUnsafeMutablePointer 替换为 UnsafeMutablePointer 可以解决这个问题。
没有 UAF 问题
没有 UAF 问题

1
调查崩溃现场

反汇编有问题的程序后,我们可以发现 Array 的追加函数内联到了 ValueStorage.append 函数中。关键问题是程序在 Array 重新分配后没有重新获取 Array 的缓冲区对象。这导致使用寄存器 r12 计算的地址指向旧缓冲区(如果确实发生了重新分配)。这段反汇编代码可以被简化为:
asm
; 将 `self.values: [Int]` 加载到 r12
mov r15, qword [r13 + 0x10]
; r14 现在是 `self.values: [Int]`
mov r14, r15
; 将 Array 的缓冲区对象加载到 r12
mov r12, qword [r14 + 0x10]
; 重新分配,可能释放旧缓冲区对象
call Swift.Array._reserveCapacityAssumingUniqueBuffer
; 将新计数设置到旧缓冲区对象
; use-after-free(释放后使用)发生
mov qword [r12 + 0x10] rax
以下是 ValueStorage.append 的完整反汇编代码:
ValueStorage.append 的反汇编码
ValueStorage.append 的反汇编码
检查 Swift 标准库代码发现,其实际上是通过访问 self 上的 _buffer 属性来更新元素计数并插入新元素,而不是使用现有的旧缓冲区变量。Swift 编译器错误地删除了目标代码中重新获取 _buffer 对象的操作。
swift
// 标准库中的实现
@inlinable
@_semantics("array.mutate_unknown")
@_effects(notEscaping self.**)
internal mutating func _appendElementAssumeUniqueAndCapacity(
_ oldCount: Int,
newElement: __owned Element
) {
// 使用 `self` 上的 `_buffer`
_buffer.mutableCount = oldCount &+ 1
// 使用 `self` 上的 `_buffer`
(_buffer.mutableFirstElementAddress + oldCount).initialize(to: newElement)
}
// 可能产生生成目标代码的假想实现
@inlinable
@_semantics("array.mutate_unknown")
@_effects(notEscaping self.**)
internal mutating func _appendElementAssumeUniqueAndCapacity(
_ oldCount: Int,
newElement: __owned Element
// 显式重用旧缓冲区
oldBufer: _Buffer
) {
// 使用 `oldBuffer`
oldBufer.mutableCount = oldCount &+ 1
(oldBufer.mutableFirstElementAddress + oldCount).initialize(to: newElement)
}

2
为什么 Swift 编译器删除了代码?

通过检查中间编译产物,我们可以在"优化 SIL"输出中找到初始错误编译,这可以通过在调用 swiftc 时添加 -emit-sil 参数获得。比较使用 AutoreleasingUnsafeMutablePointer(左侧)和 UnsafeMutablePointer(右侧)程序的优化 SIL,我们发现使用 AutoreleasingUnsafeMutablePointer 时,数组存储的关键 load 指令缺失。
崩溃与不崩溃的 SIL 对比
崩溃与不崩溃的 SIL 对比
为了确定哪个编译器过程移除了这个 load 指令,我们可以使用 -Xllvm 参数启用编译器中的调试打印。具体来说,我们可以使用 --sil-print-function 让编译器在每次有修改时打印指定函数的 SIL:
shell
swiftc YOUR_SWIFT_SOURCE.swift -Osize \
-Xllvm '--sil-print-function=$MangledSwiftFunctionName'
此分析的关键日志可总结为:
sil
*** SIL function after #10338, stage MidLevel,Function, pass 37: CSE (cse)
// closure #1 in ValueStorage.append(_:)
...
sil private @$s8Crashing12ValueStorageC6appendyySiFySAyAA4Data33_A856358C389441A2F6EA224BB743344FLLCGXEfU_ : $@convention(thin) @substituted <τ_0_0> (AutoreleasingUnsafeMutablePointer<Data>, Int) -> (@out τ_0_0, @error any Error) for <()> {
...
bb3(%17 : $Optional<AnyObject>):
...
%26 = load %25 : $*Builtin.BridgeObject
...
%35 = function_ref @$sSa36_reserveCapacityAssumingUniqueBuffer8oldCountySi_tFSi_Tg5 : $@convention(method) (Int, @inout Array<Int>) -> () // user: %36
%36 = apply %35(%34, %20) : $@convention(method) (Int, @inout Array<Int>) -> ()
...
// load 指令仍然存在
%42 = load %25 : $*Builtin.BridgeObject
%43 = unchecked_ref_cast %42 : $Builtin.BridgeObject to $__ContiguousArrayStorageBase
%44 = ref_element_addr %43 : $__ContiguousArrayStorageBase, #__ContiguousArrayStorageBase.countAndCapacity
%45 = struct_element_addr %44 : $*_ArrayBody, #_ArrayBody._storage
%46 = struct_element_addr %45 : $*_SwiftArrayBodyStorage, #_SwiftArrayBodyStorage.count
store %41 to %46 : $*Int
*** SIL function after #10339, stage MidLevel,Function, pass 38: RedundantLoadElimination (redundant-load-elimination)
// closure #1 in ValueStorage.append(_:)
...
sil private @$s8Crashing12ValueStorageC6appendyySiFySAyAA4Data33_A856358C389441A2F6EA224BB743344FLLCGXEfU_ : $@convention(thin) @substituted <τ_0_0> (AutoreleasingUnsafeMutablePointer<Data>, Int) -> (@out τ_0_0, @error any Error) for <()> {
...
bb3(%17 : $Optional<AnyObject>):
...
%26 = load %25 : $*Builtin.BridgeObject
...
%35 = function_ref @$sSa36_reserveCapacityAssumingUniqueBuffer8oldCountySi_tFSi_Tg5 : $@convention(method) (Int, @inout Array<Int>) -> ()
%36 = apply %35(%34, %20) : $@convention(method) (Int, @inout Array<Int>) -> ()
...
// load 指令被消除了
%42 = unchecked_ref_cast %26 : $Builtin.BridgeObject to $__ContiguousArrayStorageBase
%43 = ref_element_addr %42 : $__ContiguousArrayStorageBase, #__ContiguousArrayStorageBase.countAndCapacity
%44 = struct_element_addr %43 : $*_ArrayBody, #_ArrayBody._storage
%45 = struct_element_addr %44 : $*_SwiftArrayBodyStorage, #_SwiftArrayBodyStorage.count
store %41 to %45 : $*Int
从这些日志中,我们可以清楚地看到 redundant load elimination (冗余 load 指令消除,下称 RLE) 过程删除了以下 load 指令:
sil
%42 = load %25 : $*Builtin.BridgeObject

3
思考修复方案

要完善修复方案,我们首先需要了解 RLE。这个优化过程通过消除虚拟寄存器和实际寄存器的冗余 "get 和 set" 操作来优化代码。考虑下面这个有关虚拟寄存器的例子:
sil
%1 = load %x
%2 = store %1
%3 = load %2
return %3
一个更优的等效版本会立即返回 %1,因为 %2 只是一个中间结果。这是 RLE 应该正确处理的情况。这里,我们称之为情况 1。
sil
%1 = load %x
return %1
然而,考虑这个更复杂的情况:
sil
%1 = load %x
call print(%1)
call Foo(%x)
%3 = load %x // 我们能消除这一行吗?
return %3
在这里,消除 %3 = load %x 取决于 Foo 是否修改了 %x 的内容。如果修改了,我们就不能直接返回 %1,因为 %3 = load %x 加载了更新后的内容。这里,我们称之为情况 2。
现在我们可以开始看看 Swift 中 RLE 的有关实现了。其入口位于 RedundantLoadElimination.swift 文件:
swift
let redundantLoadElimination = FunctionPass(name: "redundant-load-elimination") {
(function: Function, context: FunctionPassContext) in
eliminateRedundantLoads(in: function, ignoreArrays: false, context)
}
从这个入口点阅读代码,我们发现其算法与经典的 RLE 方法不同:
  1. 它在每个逆序的基本块中反向迭代所有指令,以查找所有 load 指令
  2. 对于每个 load,它检查其先前的指令以查找:
    • 可用的 store 指令进行优化(对应第一种情况)
    • 可用的 load 指令进行优化(对应第二种情况),如果当两个 load 之间没有对地址有副作用的函数调用的话
  3. 指令扫描有复杂度预算限制
比较 RLE 与 AutoreleasingUnsafeMutablePointerUnsafeMutablePointer 的详细行为,我们可以获得如下日志:
text
// AutoreleasingUnsafeMutablePointer
eliminating redundant loads in function: $s8Crashing12ValueStorageC6appendyySiFySAyAA4Data33_A856358C389441A2F6EA224BB743344FLLCGXEfU_
...
scanning prior instructions for the load: %42 = load %25 : $*Builtin.BridgeObject // users: %52, %43
...
visiting instruction: %36 = apply %35(%34, %20) : $@convention(method) (Int, @inout Array<Int>) -> ()
transparent
// %35 = function_ref @$sSa36_reserveCapacityAssumingUniqueBuffer8oldCountySi_tFSi_Tg5 : $@convention(method) (Int, @inout Array<Int>) -> () // user: %36
text
// UnsafeMutablePointer
eliminating redundant loads in function: $s11NonCrashing12ValueStorageC6appendyySiF
...
scanning prior instructions for the load: %35 = load %18 : $*Builtin.BridgeObject // users: %45, %36
...
visiting instruction: %29 = apply %28(%27, %12) : $@convention(method) (Int, @inout Array<Int>) -> ()
overwritten
// %28 = function_ref @$sSa36_reserveCapacityAssumingUniqueBuffer8oldCountySi_tFSi_Tg5 : $@convention(method) (Int, @inout Array<Int>) -> () // user: %36
这揭示了:
  • 使用 AutoreleasingUnsafeMutablePointer 时,RLE 认为数组重新分配函数对 load 指令操作数的地址是"透明的",从而启用了 load 指令消除
  • 使用 UnsafeMutablePointer 时,RLE 正确地认识到该函数会覆写地址,从而阻止了 load 指令消除
对于情况 2 所属场景,Swift 6 算法检查 load 的所有先前指令,以确定 load 操作数的源和 load 指令本身之间的函数调用的副作用。
副作用分析
副作用分析
在这里,关键的发现是 Swift 编译器仅在 load 操作数的最终源头有未知逃逸结果时才会考虑函数的副作用。通过在 AliasAnalysis.swift 中的函数设置断点,我发现了两种指针类型之间的关键差异:
getApplyEffect 函数
getApplyEffect 函数
  • 使用 AutoreleasingUnsafeMutablePointer 时,编译器检查 load 指令的操作数的定义源是否逃逸。当确定不逃逸时,编译器将错误地假设函数没有副作用。(line 376)
  • 使用 UnsafeMutablePointer 时,编译器将获取数组重新分配函数的全局副作用(可能来自 @_effects 属性。只有标记为 readOnlyreadNone 的函数会被视为无副作用。)(line 381)
于是我们需要对第 371 行的 visit 函数进行进一步调查。该行会对 load 指令的操作数执行逃逸分析。下图说明了这个过程:
逃逸分析 1
逃逸分析 1
以下是逃逸分析的过程:
  1. 沿着 use-def chain (使用-定义链) 向上走,分析逃逸行为
  2. 每一步都将构建一个路径,表示如何从该点推导出 load 指令的操作数
但当我们遇到 AutoreleasingUnsafeMutablePointerpointee getter 实现时情况会变得复杂。它在上述逃逸分析过程中是一个非平凡的案例:
swift
@frozen
public struct AutoreleasingUnsafeMutablePointer<Pointee /* TODO : class */>
: _Pointer {
@inlinable
public var pointee: Pointee {
@_transparent get {
// The memory addressed by this pointer contains a non-owning reference,
// therefore we *must not* point an `UnsafePointer<AnyObject>` to
// it---otherwise we would allow the compiler to assume it has a +1
// refcount, enabling some optimizations that wouldn't be valid.
//
// Instead, we need to load the pointee as a +0 unmanaged reference. For
// an extra twist, `Pointee` is allowed (but not required) to be an
// optional type, so we actually need to load it as an optional, and
// explicitly handle the nil case.
let unmanaged =
UnsafePointer<Optional<Unmanaged<AnyObject>>>(_rawValue).pointee
return _unsafeReferenceCast(
unmanaged?.takeUnretainedValue(),
to: Pointee.self)
}
...
}
...
}
实现细节揭示,AutoreleasingUnsafeMutablePointer 必须将引用从 Optional<Unmanaged<AnyObject>> 转换为 Pointee 类型以维持 +0 引用计数。这种转换通过 _unsafeReferenceCast 函数执行:
swift
@_transparent
@unsafe
public func _unsafeReferenceCast<T, U>(_ x: T, to: U.Type) -> U {
return Builtin.castReference(x)
}
编译器将其转换为 Builtin.castReference 函数,最终在 SIL 中表示为 unchecked_ref_cast 指令:
sil
%y = unchecked_ref_cast %x : $SourceSILType to $DesintationSILType
因为 AutoreleasingUnsafeMutablePointer 的引入,使得 $SourceSILType$DesintationSILType 可能是 Optional 类型,最终导致了问题:
sil
%y = unchecked_ref_cast %x : $Optional<AnyObject> to $Data
此指令可以在 Optional 和非 Optional 类型之间进行转换,有效地包装或解包值。由于逃逸分析沿着 use-def chain (使用-定义链) 走,路径必须严格反映如何从定义点推导出 load 操作数,这些隐式的 Optional 转换会创建的路径不匹配,如图所示:
逃逸分析 2
逃逸分析 2
通过检查 WalkUtils.swift 中的 walkUpDefault 函数我们可以确认这一假设:该函数在向上走期间处理各种指令类型,但缺乏对 unchecked_ref_castOptional 转换的适当处理:
swift
public mutating func walkUpDefault(value def: Value, path: Path) -> WalkResult {
switch def {
...
case let urc as UncheckedRefCastInst:
if urc.type.isClassExistential || urc.fromInstance.type.isClassExistential {
// Sometimes `unchecked_ref_cast` is misused to cast between AnyObject and a class (instead of
// init_existential_ref and open_existential_ref).
// We need to ignore this because otherwise the path wouldn't contain the right `existential` field kind.
return rootDef(value: urc, path: path)
}
return walkUp(value: urc.fromInstance, path: path)
...
}
}

4
修复方案

解决方案是在 use-def chain (使用-定义链) 走向上游时考虑 Optional 和非 Optional 类型转换:
swift
public mutating func walkUpDefault(value def: Value, path: Path) -> WalkResult {
switch def {
...
case let urc as UncheckedRefCastInst:
if urc.type.isClassExistential || urc.fromInstance.type.isClassExistential {
// Sometimes `unchecked_ref_cast` is misused to cast between AnyObject and a class (instead of
// init_existential_ref and open_existential_ref).
// We need to ignore this because otherwise the path wouldn't contain the right `existential` field kind.
return rootDef(value: urc, path: path)
}
switch (urc.type.isOptional, urc.fromInstance.type.isOptional) {
case (true, false):
if let path = path.popIfMatches(.enumCase, index: 1) {
return walkUp(value: urc.fromInstance, path: path)
} else {
return unmatchedPath(value: urc.fromInstance, path: path)
}
case (false, true):
return walkUp(value: urc.fromInstance, path: path.push(.enumCase, index: 1))
default:
return walkUp(value: urc.fromInstance, path: path)
}
...
}
}
下图说明了这个修复如何适应逃逸分析过程中的 Optional 和非 Optional 类型转换。当逃逸分析过程遇到 Optional 和非 Optional 类型之间的 unchecked_ref_cast 时,该修复通过调整路径以考虑枚举 case 差异,确保了正确的路径转换。
逃逸分析 3
逃逸分析 3
在 def-use chain (定义-使用链) 分析中,walkDownDefault 函数也需要类似的更改:
swift
public mutating func walkDownDefault(value operand: Operand, path: Path) -> WalkResult {
let instruction = operand.instruction
switch instruction {
...
case let urc as UncheckedRefCastInst:
if urc.type.isClassExistential || urc.fromInstance.type.isClassExistential {
// Sometimes `unchecked_ref_cast` is misused to cast between AnyObject and a class (instead of
// init_existential_ref and open_existential_ref).
// We need to ignore this because otherwise the path wouldn't contain the right `existential` field kind.
return leafUse(value: operand, path: path)
}
switch (urc.type.isOptional, urc.fromInstance.type.isOptional) {
case (true, false):
return walkDownUses(ofValue: operand, path: path.push(.enumCase, index: 1))
case (false, true):
if let path = path.popIfMatches(.enumCase, index: 1) {
return walkDownUses(ofValue: operand, path: path)
} else {
return unmatchedPath(value: operand, path: path)
}
default:
return walkDownUses(ofValue: operand, path: path)
}
...
}
}
实施此修复后,编译使用 AutoreleasingUnsafeMutablePointer 的代码产生的日志显示 RLE 正确识别潜在的副作用:
text
eliminating redundant loads in function: $s8Crashing12ValueStorageC6appendyySiF
scanning prior instructions for the load: %45 = load %28 : $*Builtin.BridgeObject // users: %55, %46
...
visiting instruction: %39 = apply %38(%37, %23) : $@convention(method) (Int, @inout Array<Int>) -> ()
overwritten
优化后的 SIL 现在保留了数组重新分配后的关键 load 指令:
sil
// ValueStorage.append(_:)
// Isolation: unspecified
sil [noinline] @$s8Crashing12ValueStorageC6appendyySiF : $@convention(method) (Int, @guaranteed ValueStorage) -> () {
[%1: noescape, read c*.v**, write c*.v**, copy c*.v**, destroy c*.v**]
[global: read,write,copy,destroy,allocate,deinit_barrier]
...
bb3(%20 : $Optional<AnyObject>):
...
// function_ref specialized Array._reserveCapacityAssumingUniqueBuffer(oldCount:)
%38 = function_ref @$sSa36_reserveCapacityAssumingUniqueBuffer8oldCountySi_tFSi_Tg5 : $@convention(method) (Int, @inout Array<Int>) -> ()
%39 = apply %38(%37, %23) : $@convention(method) (Int, @inout Array<Int>) -> ()
%45 = load %28 : $*Builtin.BridgeObject
...
} // end sil function '$s8Crashing12ValueStorageC6appendyySiF'

5
调试 Swift 编译器的技巧

5.1
获取 Swift 编译器的中间产物

要检查 Swift 编译器在每个编译阶段的中间表示:
shell
swiftc YourSwiftSource.swift -Osize -emit-silgen > YourSwiftSource.silgen.sil # 生成原始 SIL
swiftc YourSwiftSource.swift -Osize -emit-sil > YourSwiftSource.sil.sil # 生成优化 SIL
swiftc YourSwiftSource.swift -Osize -emit-irgen > YourSwiftSource.irgen.ll # 生成原始 LLVM IR
swiftc YourSwiftSource.swift -Osize -emit-ir > YourSwiftSource.ir.ll # 生成优化 LLVM IR

5.2
利用 LLVM 传递参数

LLVM 提供了许多可与 Swift 一起使用的传递参数:
shell
# 打印指定函数的 SIL 更改
swiftc -Xllvm '--sil-print-function=$MangledSwiftFunctionName'
# 在运行每个 SIL 过程前打印其名称
swiftc -Xllvm '--sil-print-pass-name=pass-name'
# 打印内联到其他函数中的函数
swiftc -Xllvm '--sil-print-inlining-callee=true'

5.3
构建 Swift 编译器

需要注意的是,在这篇文章中,我们是在调试编译器的行为细节,但 Swift 编程语言和标准库捆绑在一起。由于问题与 Array.append 函数的内联相关,我们应该构建一个 debug 版本的编译器和一个 release 版本的标准库,以确保 Array.append 的内联成本尽可能低。你可以使用以下命令实现:
shell
utils/build-script --no-swift-stdlib-assertions \
--skip-ios --skip-tvos --skip-watchos --skip-build-benchmarks

5.4
SIL 和 LLVM IR 的语法高亮

你可以在 VS Code(或 Cursor)扩展市场中搜索"WeZZard"以获取相关 IDE 中的 SIL 语法高亮。
你可以在 VS Code(或 Cursor)扩展市场中找到 Ingvar Stepanyan 的"LLVM"扩展,以获取相关 IDE 中的 LLVM IR 语法高亮。