手把手搭建Swift语言源码(最新v5.8.1)本地调试环境

笔者之前一直在探索Objective-C语言的底层原理,有天突然想到既然iOS未来的编程语言是Swift(其实国外目前已是,国内因大型app的历史包袱过重),我为什么不去研究Swift语言的底层原理,而在一个注定被抛弃的语言上继续挖掘呢。加上目前所在团队也在开始对Swift语言进行尝试,所以探索Swift语言的底层原理明显各方面的价值更大。

说干就干,开始阅读一份项目源码的第一步,是先让这个项目能够通过你自己编译通过并且顺利跑起来,这一点尤其重要。而Swift源码工程比较复杂,搭建起一个调试环境并不容易。因此能自己本地将工程编译运行起来并且可调试对于我们理解和学习Swift底层的原理可以说是不能绕过的前提。这篇的目的就是帮助读者尽快达到本地可调试的状态。

网上资料现状

目前我们在网上能搜到的这方面的资料非常少,而且几乎都存在以下几个问题

  • Swift版本非最新版本:对于我们来说继续研究老版本的Swift代码肯定没有去研究最新版的Swift代码有价值。
  • 各种编译问题:有一些资料按照其步骤会遇到各种编译问题导致无法正常运行,但是并没有全面的解决方案。
  • 无法调试:部分资料能调试SwiftRuntime,但却无法调试SwiftCore,而这两个对于研究Swift底层同样重要。
  • 官方方案跑不起来:官方文档的编译方案笔者尝试了很多次都没有办法正常的Run起来,如果有同学解决了请指导我。

明确目标

对于现在的我们,最有价值的内容应该是我们工作中经常用到的内容,比如Foundation中的Array的底层实现,比如Swift中Runtime的底层实现,而像LLVM虚拟机或者Swift编译器的底层实现,对于我们来说还不用太着急.

因此我们的目标很简单

  • Swift 目前最新的稳定版本(5.8.1)本地编译运行起来,并且可以达到SwiftRuntime和SwiftCore本地可调试

盘它

Disk Space Prepare

整个Swift工程以及其编译后的DerivedData缓存差不多需要64G的磁盘空间,建议在开始前清理本地磁盘,空出至少70G的空间以便存放工程相关文件。

Dependency Install

Swift的本地部署过程中,会依赖cmake,ninja,sccache,因此我们需要先把这些依赖安装好。

ruby 复制代码
$ brew install cmake ninja sccache

Clone&Checkout

首先,我们在我们准备存放我们Swift源码的地方创建一个文件夹,然后cd到我们的目录:

shell 复制代码
$ mkdir swift-project
$ cd swift-project

然后,我们在刚才新建的目录中执行如下命令拉取对应的 Swift 源码,并cd到源码目录:

shell 复制代码
$ git clone --branch swift-5.8.1-RELEASE [email protected]:apple/swift.git
$ cd swift

最后,拉取源码后还须拉取依赖:

shell 复制代码
$ ./utils/update-checkout --tag swift-5.8.1-RELEASE --clone

这一步会比较耗时,主要是拉取llvm-project非常耗时,它有将近4G的大小,可以耐心等待下。当我们看到下面这样的输出时就说明成功把代码盘下来了。

  • 扩展阅读

    • 如果你想跑其他的Swift版本,或者想跑和你Xcode对应版本的Swift,那么可以通过如下命令来查看
    css 复制代码
    zixun@zixundeMBP swift % xcrun swift -version
    swift-driver version: 1.75.2 Apple Swift version 5.8.1 (swiftlang-5.8.0.124.5 clang-1403.0.22.11.100)
    Target: arm64-apple-macosx13.0

Edit Code

这个时候如果我们直接去执行Swift的build-script脚本会报如下错误:

从报错来看问题出在cxxshim-OSX-ARM64的脚本里,原因就是cxxshim试图创建模块目录导致cxxshim,我们只需要将 swift/stdlib/public/Cxx/cxxshim/CMakeLists.txt 此文件中的如下命令删除即可。

Build Swift

源码修改完毕后我们开始编译,这里我们需要使用Swift的官方脚本,也就是在util目录下的build-script脚本。以下是我使用的命令:

scss 复制代码
$ utils/build-script \
--release-debuginfo --debug-swift-stdlib \
--xcode --skip-ios --skip-watchos --skip-tvos \
--skip-early-swiftsyntax --skip-build-benchmarks \
--swift-darwin-supported-archs="$(uname -m)"

这个脚本也会比较耗时,因为有这么多子工程需要编译,大家可以一边跑笔者一边给大家解释下各个参数的意思:

  • --xcode: 使用CMake的Xcode生成器,编译完成后会生成一个Swift.xcodeproj的工程。
  • --release-debuginfo: 编译出带有 RelWithDebInfo 环境变量的工程,类似于 DebugRelease 模式,RelWithDebInfo 会优化一部分,但同时保留调试信息。(相比debug编译速度快非常多)
  • --debug-swift-stdlib: 编译带有调试信息的 Swift标准库,如果想调试 Swift编译器,可以使用 --debug-swift
  • --skip-build-benchmarks: 表明在构建过程中跳过编译和构建基准测试的步骤。
  • --skip-early-swiftsyntax : 表示跳过earlyswiftsyntax, 这个不加会编译出错
  • --swift-darwin-supported-archs "$(uname -m)": 编译需要的架构,$(uname -m) 命令用于获取机器架构环境,例如笔者机器获取的结果为arm64
  • --sccache: 编译缓存工具,可以提高下一次编译的速度
  • -skip-ios --skip-tvos --skip-watchos: 跳过相应平台,这里只编译 macOS平台。
  • --bootstrapping=off: 跳过迭代构建过程,直接用现有的编译器构建目标编译器,从而节省编译时间。

可能你会遇到error: using unsupported Xcode version的错误,解决方法很简单,输入如下命令即可

ini 复制代码
export SKIP_XCODE_VERSION_CHECK=1

这样build就不会检测xcode版本了,然后我们继续执行上面build-script命令。

当我们看到如下界面的时候说明我们编译成功了:

编译成功之后,我们会在目录 swift-project下得到一个build文件夹,里面就是我们 build 的产物:

Run Swift

接下来,在上面生成的build目录中我们找到swift-macos-arm64文件夹下的Swift工程,双击打开Swift.xcodeproj来打开我们的Swift源码工程。打开后,我们可以看到如下弹框。这里网上很多文章都叫你点击Automatically Create Schemes来自动生成Schemes,笔者建议是点击Manually Manage Schemes

因为Automatically Create Schemes 会生成一堆scheme,Swift工程本身就已经很大了,再生成这么多scheme会非常卡,我们只要创建我们自己需要的就好。点击Manually Manage Schemes后会弹出如下弹框:

没关系,直接Close即可。接下来,咱们需要创建一个用来运行验证代码的Target,在 Swift.xcodeproj 工程里面,我们点击 TARGETS 下面的 + 新建一个调试的 target,我们选择 macOS 的Command Line Tool来创建

Product Name 按自己喜好取个喜欢的名字就行(笔者的名字叫Swiftabc)。接下来咱们为 target 引入依赖,如下图引入ALL_BUILD:

然后确认一下Build SettingHardened Runtime是否已经关闭:

Hardened Runtime是Apple的一种安全保护策略,具体可以查阅Hardened Runtime | Apple Developer Documentation

接下来我们如下图点击New Scheme...来创建我们需要编译的Scheme:

在如下弹框中选择我们创建的Target(Swiftabc),然后点击OK:

然后我们点击如下图的Edit Scheme...来编辑我们需要变更的内容:

在如下图的弹框中我们将 Target SchemeBuild Configuration 修改为ReWithDebInfo

完成了配置,接下去就让我们Run一下看看,不出意外的话这个时候就要出意外了,我们会遇到如下的错误:

这里当时纠结了笔者很久,苦苦寻觅得不到解法,直到发现这个Options.h的文件在llvm的工程里,抱着试一试编译llvm工程的心态编译了一把llvm解决了这个问题(而且发现Swift工程依赖很多llvm编译产物,必须先编译llvm),llvm工程在如下目录:

双击打开后和Swift工程一样,我们选择Manually Manage Schemes

然后单击弹框中的Close按钮:

接下来通过New Scheme...新建我们需要的Scheme:

这里我们选择All_BUILDScheme,这个Scheme可以把LLVM工程的库全部编译一遍

记得和Swift工程一样,把Build Configuration改成RelWithDebInfo:

选择好之后,我们开始Build LLVM的工程,LLVM的工程非常大,编译比较久,建议打一把王者后回来看看是否编译完成。当你打完王者回来后会发现不出意外的话这个时候又要出意外了:

一开始笔者想了各种办法去把这个被时代抛弃的i386架构干掉,但是发现怎么干都干不掉,如果有同学知道怎么干掉请指导我。回到这个问题本身,其实不需要解决,因为在编译i386架构之前,我发现我们需要的arm64的架构已经编译出来了,所以不解决也没关系。

因此,我们可以回到Swift工程继续编译大计,继续等待我们的编译成果,不出意外的话又又又要出意外了,我们会得到如下的报错:

这是因为工程找不到cmark,这个问题很好解决,还记得我们通过脚本生成的工程吗,有一个就是cmark:

打开cmark-gfm.xcodeproj工程,选择All_BUILD:

同样的,通过Edit Scheme...Build Configuration改成RelWithDebInfo然后Build一把

不出意外的话这次没有出意外,顺利Build Success。继续回到Swift工程继续编译大计。这一波,贼稳,不出意外的话漂亮的Build Success会弹在我们的XCode界面上。那怎么验证我们现在是否可以debug Swift的源码呢?

我们先打一波断点,搜索HeapObject.cpp文件,然后在文件的第141行打一个断点然后重新Run一把:

完美,此时我们已经具备了Swift Runtime的debug环境。接下来我们在main.swift文件中添加我们想测试的代码。

css 复制代码
import Foundation

var arr = Array(1...10)
var arr2 = arr.filter { a in
    a > 5
}
print(arr2)

然后我们在ArrayType.swift文件中找到filter函数,然后打上断点

但是当我们Run起来的时候会发现,这个断点没有进来,怎么肥四!这里笔者纠结了很久,也尝试了网上的办法将build-script脚本中的--debug-swift-stdlib参数替换成--debug-swift,依旧不行。而且--debug-swift会把包括编译器,LLVM等所有的host tool的符号全部编译链接进来,慢的一批,编译一次可以打3到4把王者荣耀。除非哪天咱们要去看编译器的源码,不然珍爱生命,远离--debug-swift

我们回到XCode,发现控制台有如下输出:

ini 复制代码
warning: Swiftabc was compiled with optimization - stepping may behave oddly; variables may not be available.

大概意思就是我们试图在使用编译器优化后的代码进行调试,这会让我们的调试变得很奇怪。有了这个线索,我们去看看Sequence.swift文件中filter的源码:

swift 复制代码
  @inlinable
  public __consuming func filter(
    _ isIncluded: (Element) throws -> Bool
  ) rethrows -> [Element] {
    return try _filter(isIncluded)
  }

方法的正上方有个注解@inlinable,学过C++的同学应该知道了,这不就是内敛函数么,Swift的@inlinable和C++类似,内敛会把我们的方法体复制在调用的时候在调用函数上展开。我们要调试源码就要把这个给关掉。

  • 在 Xcode 的 "Build Settings" -> "Swift Compiler - Code Generation" -> "Optimization Level" 设置成 "No Optimization"
  • 在 Xcode 的 "Build Settings" -> "Apple Compiler - Code Generation" -> "Optimization Level" 设置成 "None"

然后我们重新Run一把,可以发现我们完美的进入了filter函数:

这个时候,我们一般都会想看看_filter函数里的内容,我们点击step into,但是却发现进来的却是ContiguousArrayinit方法

又怎么肥四!我们先找到_filter函数

swift 复制代码
  @_transparent
  public func _filter(
    _ isIncluded: (Element) throws -> Bool
  ) rethrows -> [Element] {

    var result = ContiguousArray<Element>()

    var iterator = self.makeIterator()

    while let element = iterator.next() {
      if try isIncluded(element) {
        result.append(element)
      }
    }

    return Array(result)
  }

发现第一行var result = ContiguousArray<Element>() 好像就是初始化ContiguousArray,难道这个函数又被内敛了。看注解并没有@inlinable,而是@_transparent

我们在工程中全局搜@_transparent,会搜到如下文档内容:

Semantically, @_transparent means something like "treat this operation as

if it were a primitive operation". The name is meant to imply that both the

compiler and the compiled program will "see through" the operation to its

implementation.

This has several consequences:

  • Any calls to a function marked @_transparent MUST be inlined prior to

doing dataflow-related diagnostics, even under -Onone. This may be

necessary to catch dataflow errors.

  • Because of this, a @_transparent function is implicitly inlinable, in

that changing its implementation most likely will not affect callers in

existing compiled binaries.

  • Because of this, a public or @usableFromInline @_transparent function

MUST only reference public symbols, and MUST not be optimized based on

knowledge of the module it's in. [The former is caught by checks in Sema.]

  • Debug info SHOULD skip over the inlined operations when single-stepping

through the calling function.

This is all that @_transparent means.

大概意思就是@_transparent也是内敛,并且不会受我们前面设置的Optimization Level影响.这里我目前找到的唯一方法就是把@_transparent注释掉。

swift 复制代码
//  @_transparent
  public func _filter(
    _ isIncluded: (Element) throws -> Bool
  ) rethrows -> [Element] {

    var result = ContiguousArray<Element>()

    var iterator = self.makeIterator()

    while let element = iterator.next() {
      if try isIncluded(element) {
        result.append(element)
      }
    }

    return Array(result)
  }

注释后,我们就可以完美的进入到_filter函数啦

现在,我们完美的把当前最新的Swift版本5.8.1本地跑了起来,并且对于我们最有价值的SwiftRuntimeSwiftCore都可以调试。

One More Thing

当我要切换swift版本重新跑一遍需要怎么做?

  • 清空缓存

    • command + shift + k : 清空内存缓存

    • 删除DerivedData

  • 删除build

    • 删除swift-project/build目录下所有内容
  • 切换版本

    • 用脚本切换到你需要的tag,千万不要用git checkout,因为swift有很多子仓库需要同步切换
    sql 复制代码
    utils/update-checkout --tag swift-XXX-RELEASE
  • Build&Run

    • 按照这篇文档重新Build和Run

当我遇到问题解决不了上哪里搜/问?

juejin.cn/post/721956...

github.com/apple/swift...

相关推荐
大熊猫侯佩8 小时前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(五)
swiftui·swift·apple watch
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(三)
数据库·swiftui·swift
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(二)
数据库·swiftui·swift
大熊猫侯佩1 天前
用异步序列优雅的监听 SwiftData 2.0 中历史追踪记录(History Trace)的变化
数据库·swiftui·swift
大熊猫侯佩1 天前
由一个 SwiftData “诡异”运行时崩溃而引发的钩深索隐(一)
数据库·swiftui·swift
season_zhu2 天前
iOS开发:关于日志框架
ios·架构·swift
大熊猫侯佩2 天前
SwiftUI 中如何花样玩转 SF Symbols 符号动画和过渡特效
swiftui·swift·apple
大熊猫侯佩2 天前
SwiftData 共享数据库在 App 中的改变无法被 Widgets 感知的原因和解决
swiftui·swift·apple
大熊猫侯佩2 天前
使用令牌(Token)进一步优化 SwiftData 2.0 中历史记录追踪(History Trace)的使用
数据库·swift·apple
大熊猫侯佩2 天前
SwiftUI 在 iOS 18 中的 ForEach 点击手势逻辑发生改变的解决
swiftui·swift·apple