-
使用 Swift 编写安全敏感代码
了解如何使用 Swift 编写安全关键代码,从而构建内存安全的软件,并消除各类漏洞。探索 Swift 如何在边界、生命周期、类型、初始化和并发等方面保障安全。探究 Span 和不可拷贝类型等高性能原语,了解如何通过严格内存安全功能来审核不安全构造,并逐步完成现有 C 模块的迁移。
这个讲座最初作为“与 Apple 会面交流”活动“加固你的 App:关键策略助你增强安全性”的一部分讲授。请观看完整视频,以了解更多见解和相关讲座。资源
-
搜索此视频…
我是 Swift 语言团队的 Doug, 今天我和我的同事 Felix 一起, 将为大家讲解内存安全与 Swift。
内存安全是一项重大的安全挑战。 此前,其他演讲已经探讨了有助于 在 C 和 C++ 代码库中 发现内存安全漏洞, 或防止这些漏洞演变为安全问题的缓解措施。 但这些方法都没有像 在编程语言本身中定义内存 安全机制那样全面和有效。 因此,我们将首先回顾 Swift 的设计是 如何解决内存安全问题的。
随后,我们将探讨性能问题, 以及如何利用 Swift 近期推出的新特性, 在不牺牲安全性的前提下, 从底层代码中获得最佳性能。
向内存安全语言的过渡将是 一个漫长的过程。 因此,我们将详细探讨 封装不安全代码的最佳实践, 以及如何从 C 家族语言逐步 迁移到 Swift。
首先,内存安全并不是一个旨 在防止所有错误的笼统概念。 优秀的语言设计可以定义一种机制, 来处理某些类别的错误, 或者让这些错误更难出现在你的代码中。 但现实是,编程错误确实会发生。 因此,内存安全旨在确保程序的行为 与其编写内容一致。 乍听之下,这似乎有些荒谬。 当然,程序的行为本应与其编写内容一致, 但正如我们之前所讨论的, 当攻击者利用内存安全漏洞时, 他们可以迫使程序执行代码中未编写的行为。 这就是为什么内存安全 在安全领域如此重要的原因。 因为无论在何处— —比如你的 C 或 C++ 代码中—— 只要存在内存安全漏洞, 就可能导致程序做出几乎任何事情。
而一种内存安全的语言可以防范此类荒谬的情况。 它可以通过两种方式 实现这一点:要么拒绝编译可能包含内存 安全问题的任何代码, 要么在运行时插入检查机制, 以检测是否出现异常。 你可以将这些策略结合使用,事实上, Swift 就采用了编译 时和运行时检查相结合的方式, 我稍后会详细说明。 这里只有一条规则: 如果出现潜在的内存安全问题, 程序绝不能继续执行。 试图在内存状态已损坏的情况下继续运行, 正是编程错误演变为内存安全漏洞的根源。
我们之前讨论过这些 内容,通常会将内存 安全分解为编程语言需要解决的五个不同维度。 它们分别是边界安全、 生命周期安全、类型安全、 初始化安全和线程安全。 C 语言家族并未针对这些维度提供安全保障, 尽管存在一些缓解措施可以解决其中部分问题。 现在让我们深入探讨 Swift 及其如何处理这些不同领域。
边界安全是最容易理解的。 它旨在检查并确保访问操作仅限于某个内存块内。 不要超出该内存块的范围。 正如我们在 C 语言中所见, 你可以将指针调整到已分配内存块的末尾之后, 然后读取或修改其他无关的值, 从而引发内存安全问题。
这里的 Swift 代码试图重现相同的漏洞。 这里有一个包含 12 个元素的数组。 我们尝试访问第 15 个元素。 Swift 会进行运行时边界检查, 以确保该操作立即被拦截, 这与我们之前讨论的边界安全类似。 这是最简单的一种。接下来的内容会更有趣。 生命周期安全则确保当你访问内存时, 该内存仍然有效。 而在 C 语言中,编写违反生命 周期安全性的代码相当容易, 例如释放后使用(use after free)的情况, 代码先释放了已分配的内存, 随后又试图通过这个过期的指针
访问该内存,而此时该指针 可能已指向完全不同的对象。
Swift 通过移除释放 操作来消除释放后使用的问题。 因此,如果你有如下代码, 可以分配一个我的类的实例。 将其赋值给一个局部变量。 你可以用它来调用该实例的方法, 请注意这里没有任何free操作。 取而代之的是,当该实例不再被使用时, 编译器会自动插入销毁操作, 而且编译器总会以 正确的方式执行这一操作,对吧?
Swift 的集合类也采用了 同样的自动生命周期管理机制。 例如数组和字典等。 这里,数组在第一行被创建, 一旦不再被使用,它就会被自动释放。
Swift 使用了一种称 为自动引用计数的技术。 我们之所以选择这种技术而非 其他更传统的垃圾回收技术, 是因为它在工程设计上具有非常理想的权衡。 采用自动引用计数后, 你基本上可以 在 Swift 中编程而无需考虑内存管理。 所有编程模式似乎都能正常工作, 而且大多数时候你无需考虑对象的生命周期。 另一方面,自动引用计数(ARC)速度非常快。 无论是性能还是内存占用, 其开销都非常低。 而且它不会出现传统垃圾 回收方案中常见的运行暂停现象。
不过,有时你 需要确保在维持生命周期安全的同时, 运行时开销为零。 因此, Swift 还提供了不可复制类型。 这些类型描述了对某个唯一拥有的资源的所有权。 这里存在一个权衡 :不可复制类型的编程模型更为严格。 你必须更多地考虑所有权问题。 但作为回报,你将获得零开销。 我们将在本讲座的后半 部分再次讨论不可复制类型。
类型安全能够防止使用错误的类型访问内存。 同样, C 语言允许你通过几种不同的方式 轻松引发类型安全问题。 假设我们将一个整数写入内存中的这个单元格。 随后,我们可能会通过 联合体的成员,或者使用显式 类型转换将其读回为文件,从而导致类型混淆。
Swift 提供了与 C 语言联合体 和类型转换功能等效的特性, 但这两者在设计上都确保了安全性。 Swift 的枚举是带区分符的。 带区分符的联合 体意味着,它们会编码 当前哪个选项处于活动状态。 这里我们有一个 resource 枚举。 它可以存储一个文件, 也可以存储一个用于访问资源的整数标识符。 你可以使用 switch 语句进行模式匹配, 这能确保你绝不会访问 错误的成员,因为这些是…… 这是成对访问。
Swift 的类型转换与此类似。 因此,这里的 Swift 代码 定义了一个类和一个子类。 该子类重写了一个方法, 并引入了它自己的另一个方法。 这是一种相当经典的面向对象编程。 在这里,我们将尝试进行向下转换。 现在,这里的 `as` 语句会将给定的对象 向下转换为我的子类。 它会生成一个可选值, 该值要么包含作为我的子类的实例, 要么在向下转换失败时为空。 因此,如果它是该子类的实例, 代码将在 `if` 语句的代码块内执行。 如果是这样,我们就可以安全地调用另一个方法。 如果向下转换失败,该可选值将为空。 则 `if` 语句的主体不会被执行。 这样既消除了类型转换可能带来的内存安全隐患, 也有助于避免因错误转换导致的编程错误。
初始化安全是另一个相当直观的示例。 这正是防止在内存未初始化前被读取的机制。 C 语言并不要求这一点。 因此,你可以声明一个局部变量, 然后在它被初始化之前就直接使用它。 如果你尝试在 Swift 中做 同样的事情, Swift 编译器会拒绝这种尝试。 它会指出你尚未为该变量赋值或初始化, 并在编译时将其拒绝。 因此,不会出现内存安全问题。
最后一个也是最棘手的问题是线程安全。 违反线程安全意味着,程序中的任何地方 出现数据竞争都可能引发内存安全问题。 即使你的编程语言在其他所有 方面都是安全的,这种情况仍可能发生。
在这个示例中,这里有一个共享的可变资源。 函数 `replaceResource` 将更改该资源的值, 包括将其类型设置为该标识符。
假设这一操作发生在某个线程中。 现在,在另一个线程中, 有一个 `useResource` 函数正 在操作同一个共享资源。 如果这里发生数据竞争, 就可能导致一个线程将其视为文件时, 另一个线程正在将该整数覆盖到该文件实例上。 用整数覆盖实际的文件实例。 Swift 6 的并发模型 通过确保不会对共享的可变 状态进行并发访问,从而防止了此类数据竞争。
此外, Swift 还提供了安全处理 并发的方法,这些方法在高 级别上不会引入数据竞争。 其中之一就是 Actor, Actor 是一种 封装其状态并防止其被并发修改的类型。 这里的 resource 变量现在已成 为 Actor 状态的一部分, 因此 Swift 确保使用 resource 以及可能触及该状态的 replace resource 操作绝不会并发执行。
这一机制通过 Swift 的 async-await 模型得到一致的强制执行。 因此,对 Actor 的方法调用总是异步的, 因为调用方可能需要等待 Actor 在其他线程上完成代码执行, 才能安全地进行该调用。
Swift 还提供了用于 防止数据竞争的低级原语。 此处的 mutex 类型描述了一种每次只能 由单个线程访问的资源。
对存储在 mutex 中的数据的每次 访问,都通过 widthlock 函数进行, 该函数提供对该数据的临时访问权限。 在闭包内部,互斥锁确保同一时间只有 单个线程能够运行其闭包。
因此,在 Swift 中, 内存安全已融入语言的设计之中。 这其中有一项优势, 除非亲身体验,否则很难真正体会到。 因为当你使用一种内存安全的语言时, 这不仅仅意味着不必为此担心。 我是否引入了内存安全漏洞, 或者需要去寻找这些漏洞? 你根本就不再去思考这个问题了。 因此,你可以专注地关注代码的正确性, 以及其他与内存安全无关的问题。
现在,如果你来自 C 语言家族, 你可能在想,这种内存安全是否是 以性能为代价换来的。
从宏观层面来看, Swift 是一种原生编译语言。 它为其内存安全模型提供了高效的实现, 对于许多程序来说, 这已经足够了;但对于必须达到
不安全 C性能水平的底层 代码, Swift 6.2 引入了 几个安全的抽象机制来提供帮助。
我们来看一个 C 语言的示例。 这是一个使用跑长编码的图像解码器。 在循环中,它每次从输入 缓冲区读取 4 个字节。 这里是计数和像素数据, 随后将结果写入输出缓冲区。 在此过程中,你可以看到 它正在手动执行边界检查。 此外,这段 C 代码还做出了许多 实际上并未经过验证的假设。 例如,我们假设计数参数 正确描述了相应缓冲区的大小。 我们还假设输入和输出缓冲区 不会以某种奇怪的方式产生别名, 并且在程序运行期间不会被 其他线程修改或释放。
以下是将该代码转换为 Swift 的版本。 输入缓冲区是一个同时 封装了数据和计数值的数组。 这样你就能清楚地知道。 这同时也能确保,即使在多线程程序中, 该函数运行期间数据也不会消失或被修改。
现在数组访问会进行边界检查, 因此错误会 在运行时被捕获,而不会演变成内存安全漏洞。 不过我之前说过要谈谈性能。 那么我们就来看看这个。 如果在循环中进行了正确的边界检查, 当证明这样做是安全的时,编译 器通常可以完全优化掉这些边界检查。
Swift 的写时复制数组 在实现中使用了引用计数。 同样,编译器通常可以优化 掉所有的引用计数操作, 本例中就是如此。 然而,这个追加操作是 要向数组中添加一个新元素。 如果数组中没有足够的空间, 就必须分配更多存储空间的新内存, 然后将所有现有元素复制到新内存中。 这会比我们之前的 C 语言实现更慢。 之前的实现只是通过指针写出像素。
这里还有第二个问题, 即这种设计迫使客户端必须自己拥有数组。 我们可以要求它们自行引入副本。 下面是一个示例。我们在数组中存有一些数据, 但该数组有一个简单的头部, 包含实际输出图像的宽度和高度, 后面紧跟着我们真正想要解码的图像数据。 现在,这里的主要问题在于, 我们必须复制所有这些运行长度编码的像素数据, 仅仅是为了调用我们一直在开发的解码函数。 由于我们的解码函数需要读取整个像素数组, 这意味着需要额外的堆内存 分配以及对所有图像数据的复制, 而这是我们绝对不想看到的。
这里还有一个相关 问题,即我们的解码函数每次都会 在堆上分配结果数组。 对于这次特定的图像解码操作, 这样或许尚可接受。 但可能会有其他调用方 要求将像素放置在特定位置, 例如放入一个已经 分配好的固定大小的帧缓冲区中。 要实现这一点,你将不得不再次复制结果。 因此,在低级且对性能要求极高的代码库中, 这类问题经常会出现。虽然可以使用擦除片段 (erase slices)或通用集合 (generic collections 来部分解决这个问题, 但这些方法操作起来可能比较棘手。
因此,新的 span 类型家族应运而生。 span 提供了对连续 内存的安全且开销低的访问方式。 如果你使用过 Swift 中的不安全缓冲区指针类型, 可以将 span 视为这些 类型的安全对应版本。
span 背后的核心思想是:它引用的是 自己并不拥有的连续内存。 你可以将其视为指针加长度, 因为这正是它在内存中的具体表示形式。 span 提供了全面的内存安全性。 它通过编译器检查来确保生命周期安全, 且完全不产生运行时开销。 它通过边界检查来确保边界安全, 对所有访问操作进行验证。 但最重要的是,它并不拥有其引用的存储空间。 相反,它与那些真正拥有 存储空间的各种类型进行交互。 你可以获取一个引用写时复制 数组或固定长度内联数组的 span。 它还与不安全的指针类型进行交互, 这在与不安全的语言或早于 span 出现的代码交互时尤为重要。
span 类型家族于 Swift 6.2 中引入。 不过,我们认为 span 至关重要, 因此将这些类型向后 兼容至更早版本的 Apple 操作系统。 这样,你现在就可以采用它们, 而无需提高部署目标版本。
好,现在是时候将 span 应用 于运行长度解码器的输入了。
这并不需要太多改动。 我们只需将参数类型从数组改 为 `span` 即可。 之所以可行,是因为 `span` 提供了与数组相同的元素访问 API。 而且由于这段代码只是读取数据, 并未尝试修改数据。 它也不会返回副本。 实际上,该函数中无需进行其他任何更改。 现在发生变化的是 API 契约:调用方现 在需要提供 `span` 而不是数组。 那么,让我们回到调用方的代码中, 看看该如何实现。
首先,每个数组都有一个 `span` 属性, 它会返回一个 `span` 对象。 因此,我们可以像这样修改代码使其能够 编译通过。只需在末尾添加 `span` 即可。 这确实可行。但是,它并不会提升性能, 因为我们仍然在创建一个新的数组。 如果先将所有数据复制到 `span` 中,我们就能做得更好。 因此,我们将从原始数组中 获取一个 `span`, 然后仅将其中我们关心的切片传递 给 `decode` 函数。 `read` 代码只接收 与其相关的 `span`, 既没有额外的内存分配,也没有数据复制, 同时在整个过程中仍能保持内存安全。
这个示例确实让人觉得 span 只是 array 的直接替代品。 但事实并非如此。它实际上 是对他人拥有的存储空间的引用。 因此,为了确保安全且不产生任何运行时开销, span 类型本身有一些必要的限制。
所以, span 属于我们所 说的不可逃逸类型。 它采用上文展示的波浪号(~)语法来定义。 你可以使用相同的语法来定义 自己的非可逃逸类型, 使其行为与 `span` 一致。 现在,对于非可逃逸类型,你可以像之前 那样将其作为参数传递给函数。
但是,你无法从函数中返回任何 `span`, 因为只有当非可逃逸 类型的生命周期与某个参数绑定时, 才能返回该类型的值。
让我们深入分析第一个 `run` 函数的本体,看看这意味着什么。 这里的代码正在查找与第一个元素 匹配的连续元素序列。 而内部的循环会在发现第一个不匹配项时终止。 这里的关键部分不是那个逻辑, 而是最后的返回语句。 那么,我们在这里返回什么呢? 我们直接返回从接收到的 data 参数中提取的 span。 这只是其中相关的一部分。 这没有问题。这意味着提供该 span 的调用 方会确保该结果 span 保持足够长的生命周期。
然而,试想如果代码改为将 data 复制到一个新数组中。 该数组存储在一个局部变量中, 然后试图从该副本中返回一个 span。 编译器会在此处报错。
原因在于,这个新创建的数组存储在局部变量中。 正是这个局部变量使其保持存活。 我们不能将一个由调用方控制的局部 变量中的 span 返回给调用方, 因为一旦我们退出函数,该局部变量就会消失。 因此,如果你从 C 语言的角度来思考, 我所说的这些限制可能听起来很熟悉。 试想你获取了一个局部变量的地址。 你必须非常小心处理这个指针, 以确保它不会被保存在某处, 从而在函数返回且局部 变量已消失后仍被使用。 Swift 将这些限制纳入了语言规范, 因此你无法因操作失误而危及内存安全。
Span 本身提供对内存的只读访问。 还有另一种类型 mutableSpan, 它提供可变访问, 因此你可以既读又写。
你可以使用我之前提到的任何连续集合 中的 `mutable span` 属性, 将其存储为 `immutable span`, 然后像预期那样通过下标操作修改其中的元素。
现在,这里安全模型的一个关键部分在于: `mutable span` 需要对底层 内存拥有独占访问权限。 这意味着,如果你有一个 `immutable span` 指向某块内存。 则其他人无法访问该内存区域。 无论是写入还是读取。
因此,在我们的代码中, 如果创建第二个 span 来访问同一数组内部, 然后尝试进行变异操作, 编译器会在此处报错,以防止在变异操作 仍处于活动状态时对存储区域进行任何访问。
这种排他性模型是 Swift 的基础。 实际上,它自语言诞生之初就已存在。 正是它确保了在使用可变 方法和 inout 参数 时内存的安全性。 大多数 Swift 程序员甚至不 知道它的存在, 因为实际触发内存安全违规的情况非常罕见, 但它作为一道安全防线, 始终在保障内存安全。
好,现在是时候在运行长度 解码函数中采用不可变的 span 类型, 而不是返回一个堆分配的数组了。 这部分工作量会比 span 那部分稍大一些。 不过,我们还是要从函数签名开始。 这里的关键在于,不再创建 新的存储空间并将其返回给调用方。 调用方将通过这个输出 参数告诉我们把结果放在哪里。 之所以采用inout参数, 是因为当你修改可变 span 时, 结果会写入该参数中。 请注意,现在不再将像素追加到输出数组中, 而是直接写入最终位置, 也就是通过这个 out 参数。 这意味着该函数中不再进行任何内存分配。 读写缓冲区的设置完全由调用方负责, 就像我们在 C 语言中做的那样。说到调用方。 实际上,之前它依赖 于 earlydecode 返回一个数组这一事实。 那么让我们回过头来看看这一点。 现在,该函数需要做的是 分配自己的像素数组。 然后,它会将一个可变范围(mutable span)传递给该数组, 以便读取代码来填充生成的像素。
当然,这个调用者选择进行堆内存分配, 但另一个调用者可能会做出完全不同的选择。
下面是一个示例。这里的结构体表示一个 320 × 240的像素帧缓冲区。 它被表示为一个内联数组,以避免堆内存分配。
图像解码操作通过 `Inout` 再次获取了帧缓冲区的可变引用。 然后,它将 `immutableSpan` 传递到这些像素中, 并将其传递给我们的解码函数。
看明白这里发生了什么吗? 解码操作直接作用于帧本身。 没有。没有额外的复制。没有内存分配。
采用 span 不仅能显著提升性能, 还能增强内存安全性。 我们鼓励你在 Swift 代码库中采用它。 如果你想找出可能需要采用 span 的地方, 从性能角度来看,有两个切入点。 寻找那些使用数组或特定 数据类型且对性能敏感的代码。 如果你发现存在额外的复制或堆内存分配, 请改用 `span` —— 就像我们在解码操作中出于 内存安全考虑所做的那样。 首先,替换代码中所有 `unsafebuffer` 指针类型的使用。 顾名思义, 这些不安全的指针类型无法保证内存安全, 应尽量避免使用。 对于大多数使用不安全指针的场景, span 是一种安全的替代方案, 尽管实现起来可能需要进行一些重构。 现在,我将把话筒交给我的同事 Felix, 请他进一步讲解如何 在不牺牲内存安全性的前提下处理 Swift 中的不安全构造。
谢谢, Doug。
大家好,我叫 Felix, 来自安全工程与架构团队。 议程上的下一个主题是安全地使用不安全代码。
如今,内存安全的语言代表着编程的未来。
正如 Doug 所解释的, 通过使用像 `span` 这样开销很低的原语, 安全代码不仅运行速度极快, 而且不会引入内存安全漏洞。
与此同时,不安全代码如今无处不在。 字面意义
上的无处不在。即使在以前 难以使用安全语言的领域, 安全语言如今也正逐渐普及, 但仍有数十亿行不安全代码需要处理。 这一点早已为人所知。
遗憾的是,这已成为根深蒂固的工程常识。 重写存在风险。新的实现 可能会引入或重新引入错误, 而且当现有实现与不安全实现同时演进时, 两者会相互竞争。
正因如此,制定计划, 通过分阶段重写的方式逐步 从不安全代码迁移出来至关重要。 这样风险就更容易管理, 成功几率也会大幅提高。
而这在今天之所以重要, 是因为 Swift 具备独特的能力, 其设计初衷正是为了支持从 C、 C + + 和 Objective-C 的渐进 式迁移。
其中第一项工具就是严格的内存安全机制。 要处理不安全代码, 没有比启用严格的内存安全机制更安全的方法了。 这一点非常重要。
严格的内存安全机制会揭示 所有不安全代码的使用情况。 这非常有用,因为… 虽然 Swift 通常会在不安全 元素的名称中明确标注unsafe, 但某些操作本身是隐式不安全的。
例如,在这段代码中, 数组复制函数声明了两个数组变量 x 和 y, 并使用 memcpy 将其中 一个复制到另一个。
代码中并未说明 memcpy 会执行任何不安全操作, 但 memcpy 是一个 C 函数, 因此它接受不安全指针。 x 和 y会被隐式地转换为不安全指针。 这可能会导致内存安全漏洞, 却没有任何明确的提示。
一旦启用严格的内存安全检查, 编译器就会发出警告。 对于这种情况以及所有其他 使用不安全代码的场景—— 即表达式中使用了不安全结构但未 标记为unsafe的情况——只需 在调用前添加unsafe关键字即可解决。
我将花一点时间详细说明
`unsafe` 关键字。 除了处理编译器警告外,它还有两个主要功能。
首先,它是在安全审查人员 审核代码库时的一种警示。 `unsafe` 关键字表明可能正 在发生某种潜在危险的情况。
其次,而且更为重要的是, 它提醒你必须验证一些编译器无法 验证的内容。
让我们回到 Memcpy 的示例。 这段代码是正确的, 因为两个数组都包含四个字节。 然而,编译器并不知道这 是调用该函数的先决条件。
unsafe 关键字就是为了提醒你, 需要验证 Memcpy 是否被正确使用。 要在 Xcode 项目中启用它, 请在 Swift 语言选项下 查找严格内存安全设置。
在 Swift 包中,通过在包描述 中添加严格内存安全设置即可启用该功能。
现在,尽管已启用严格内存安全 且能确保unsafe代码可见, 但最好还是完全避免使用unsafe功能。
只有两种情况真正需要使用 unsafe代码。 第一,与不安全的库进行互操作;
第二,实现安全的原始类型。 在许多情况下,这实际上是同 一颗不安全小宝石的两个侧面。
使用安全语言编写新代码虽有诸多好处, 但对于某些任务而言, 不安全的实现可能仍是最佳工具。
这是因为不安全的库很常见,且抛开安全性不谈, 它们可能仍然是最成熟的。
当有可能将不安全的库重写为安全语言时。 这总是更可取的, 但在任意时间范围内并不总是可行。 安全的重写需要时间和专业知识, 而这些资源并非总是立即可得。 Swift 可以帮助确保该库被安全地使用。
Doug 之前介绍了 decode 函数。 假设它是在一个外部 C 库中实现的,而该库无法轻易重写。
当 Swift 识别到该头文件时,会将其作 为 Swift 风格的不安全接口暴露出来。 该接口接受相同的参数:源指针、 源大小、目标指针以及目标计数。 虽然这并非理想方案— —因为它仍然使用不安全值—— 但仍可从 Swift 中调用该函数。
然而,如果启用了严格的内存安全检查, 编译器会生成相同的不安全代码结构。 警告。
最好像 Doug 演示的那样, 为不安全的实现编写一个安全的封装类。 该封装类将 span 作为输入, 将可变 span 作为输出。
该封装类中会包含大量 unsafe 的声明。 这是因为每次从 span 中取出 指针或使用这些指针的操作, 都需要被标记为 unsafe。 不过,这样便于进行安全审计。
该代码从每个 `span` 中 取出一个不安全的指针, 并将其与相应的计数一起传递给 C 实现。
无需审核 Swift 接口的用户, 因为它传递的是 `span`, 而 `span` 是安全的,这里不会出错。 但如果真有问题,那肯定出在这个实现中。 C 实现中也可能存在内存安全漏洞。 不过,一旦红色代码获得了安全的实现, 调用它的 Swift 模块 就不再存在任何问题。 这种风险被完全消除了。
现在,在实现安全原语方面, 封装不安全代码 时还可能发生另一种情况:外部库 可能会提供某种必须手动创建和销毁的资源。
这里再次展示真正的解码函数。 我对其进行了修改:不再 是单一的无状态解码函数, 现在你需要创建一个包含RL 的解码器对象, 然后使用RL的 destroy 方法将其销毁。
RL 的解码函数与之前基本相同, 但现在它将解码器作为第一个参数。
这种模式需要显式声明 Dianette, 因为 Copyable 结构体 无法拥有 Dianette。 这通常通过类来实现。 如今,许多封装不安全 资源的代码都呈现这种形式。
初始化器会调用RL的 init 函数, 随后再调用RL的 destroy 函数。 而解码函数将与之前的版本基本相同。
这种方法虽然可行,但效率仍有提升空间。
首先,类实例都是动态分配的。 对于生命周期较长的对象,这通常不是问题, 但反复创建和销毁实例 会导致本可避免的 malloc 和 free 调用。 而且,使用动态分配来封装另 一个动态分配的操作开销更大。
第二类实例采用引用计数机制。 这虽然能确保复杂的内存管理场景保持安全, 但即使对象的生命周期 非常简单,最终仍可能因 引用计数操作而付出额外代价。
最后,类实例的所有字段在运行时都会进行 细粒度的排他性检查,这使得别名操作比 在结构体上更为灵活。 但同样,操作简单的类型可能完全无法从中受益, 却仍需承担相应成本。
类实例虽然用途广泛, 但可能比实际需要的更臃肿。 这些开销虽小, 但与完全不进行任何检查的不安全实现相比, 它们会迅速累积。
从 Swift 6 开始, 你可以使用不可复制的结构体来处理这些用例。
卡车并不需要那种动态分配。 因此,这消除了一个开销来源。 而且它们具有粗粒度的排他性检查, 这些检查大多可在编译时验证。 因此,程序从一开始就包含较少的此类检查, 也更容易通过优化将其消除。
然而,由于 decodeOne 函数中引用类型的灵活性, 共享不可复制
结构体比共享类或可复制 结构体受到的限制要多得多。
对同一个解码器拥有多个引用,并通过其中 任意一个调用 decode 方法, 这完全没有问题。
然而,如果对不可复制的结构体 进行同样的操作,则会引发错误。 编译器会立即在对象a处诊断出错误— —a被移动到 B 中,随后又被再次使用。
简而言之,对于简单的对象管理, 不可复制的结构体相较于类具有诸多性能优势。 但你未必总能使用它们,因为它们的灵活性较低。 它们虽然更具体,但作为交换, 你能获得更可预测的性能。
最后我想谈谈 从 C 语言调用 Swift。
目前所有主流平台都拥有一个不 安全的 C 或 C++ 核心, 而所有安全语言都必须以某种方式调用该核心。 与 C 语言混合使用是安全 语言的一种正常且预期的能力。 尽管如此,这通常还是很困难的。
其中一个原因在于,
不同语言的预期各不相同, 当一种语言调用另一种语言时, 两种语言的预期都必须得到满足。 例如,在使用垃圾回收机制的语言中, 将对象指针传递给 C 可能需要与垃 圾回收器进行特殊协作, 以防止在对象仍存在时被移动。 来自C的引用。另一个原因是, 大多数语言彼此之间理解得并不透彻。 例如,在许多能够调用C的语言中, 互操作需要语法糖。 这指的是 C 头文件, 因为目标语言的编译器 无法直接读取 C 头文件。 编写这样的代码非常繁琐。
但值得庆幸的是, Swift 专为 与不安全语言进行互操作而设计。
通过内置完整的 C 编译器, Swift 为开发者消除了大部分复杂性。
它可以通过解析桥接头文件,来解释并使头 文件中的任意声明(包括内联 函数)对 C 编译器可用。 项目可以从其桥接头文件中包含任意头文件,
而 Swift 可以通过生成 自己的头文件,将声明提供给 C 编译器。
我现在将 Doug 早先的解码 示例重新展示在屏幕上。 Doug 展示了使用 span 的实现 方式是最灵活的选择。 该实现被用作另外两个函数的独特后端, 这两个函数以完全不同的方式返回像素。 第一个函数返回数组, 第二个函数则通过引用将输出 结果写入模式 X 帧结构体中。
从 Swift 6.3 开始, span 实现也 可以作为 C 函数的后端, 例如 Doug 最初演示的那种。
这是 Doug 此前介绍过的一种实现方式。 请大家最后仔细看一眼, 因为我即将删除它, 并用 Swift 实现来替换。
要实现这一点, 首先需要一个 Swift 函数, 其原型与目标 C 函数兼容。
这里有一个现成的代码, 它接受一个输入指针、一个账户、一个输出指针, 在账户中,然后它 在输入指针上创建一个 span, 在输出指针上创建一个 span。 并调用通用的 Swift 后端代码。
接下来,需要将该函数暴露出来, 有两种实现方式。 第一种是向函数添加 `@C` 属性。
使用 `@C` 时, Swift 会将其类型转换为合理的对应 C 类型。 例如, Swift 的 `Int32` 类型会转换为 `int32t`, C 基础整数类型 `char`、 `short`、 `end` 等会转换为相应的 C 类型。 请注意, `int` 会转换为 `pointer div t`, 并且。 这就是我们在实现时,代码接受并 返回指针类型而非具体类型的原因。 当待实现的函数已在桥接头文件中可见时, 还有第二种选择:使用`@C`组合, 就像在使用带有at 实现的 Objective-C 方法时那样, 而不是在生成的头文件中生成声明。 这会要求编译器从你的桥接头 文件中查找 decode 函数的现有声明。
使用此方法时, 编译器会验证 Swift 原型 是否与 C 原型匹配, 若两者不兼容则会报错。
仅使用 `@C` 时, Swift 必须指定参数类型; 而使用 `@implementation` `@C` 时,它能够适应其他一些转换。 例如,如果 C 原型使用 size T, Swift 也会接受一个接收 int 而不是 Uint 的实现。
现在,这是一个回顾刚才发生情况的好机会。 在我右侧有一个名 为 cats and dogs 的 C 函数,用于识别图片中的宠物。
它接受一个指向图像结构 体的指针和一个指向宠物记录缓冲区的指针。 它管理用于解压图像的像素内存, 并调用 decode 函数来解压图像。 最后,它会调用 `identifyCatsAndDogs` 来 定位图片中猫和狗的位置。
此前,该函数会调用右侧(即 Dog 之前 展示的)`decode` 的 C 语言实现;但通过 使用 C 语言实现(且无需在 Swift 中进行任何修改), `catsAndDogs` 函数现在 使用的是安全的 `decode` 实现, 并且在 Swift 中运行得非常顺畅。这是 因为 Swift 编译器能够将 `decode` 的实现与 clang 识别的 `decode` 的 C 函数原型进行匹配。 C 和 Swift 实现中的这些功能,正是将 C 代码库迁移到 Swift 并使用 安全语言编写新功能(当这些功能需要被 C 调用方使用时)的绝佳工具。 现在你已经了解了 `span`、
`only` 类型以及 C 和 Swift 之间的互 操作。
接下来你需要做的是:首先, 在代码中采用严格的内存安全机制。 这将使你能够 处理代码库中的不安全操作。
接下来,开始使用 `span` 安全地访问连续内存。 首先,替换所有不安全的缓冲区指针用法。 然后,尽可能通过 仅移动类型封装不安全的资源, 否则创建安全的接口。
最后,利用 Swift 的互操作功能, 逐步将不安全的代码迁移到 Swift 中。 感谢大家的关注。 内存不安全性是当今安全漏洞的最大来源, 我期待与大家携手共建一个更安全的世界。
-