用 Swift 开发嵌入式应用

发表于

为您每周带来有关 Swift 和 SwiftUI 的精选资讯!

近年来,Swift 逐渐展现出其跨平台开发的潜能。在本文中,我将分享我使用 Swift 语言在 SwiftIO 开发板上进行嵌入式开发的一些尝试和体会。

特别说明:本文讨论的嵌入式开发专指在不具备内存管理单元(MMU)的 MCU(微控制器单元)硬件上的开发,不涉及像树莓派(Raspberry Pi)这类具备完整通用计算能力的设备。

Swift 并非专属于苹果生态

虽然大部分 Swift 开发者主要在苹果生态中使用这一语言,Swift 自诞生起便被设计为一种跨平台的现代系统级编程语言。这表明 Swift 的开发团队希望这种语言能在更广泛的平台和系统上运行,满足从底层到用户界面的多样化开发需求。

肘子的 Swift 周报第 26 期 中,我们介绍了 Swift 在 Linux、Windows 以及嵌入式平台上的发展,以及一些关键的框架和项目的开源情况和跨平台开发进展。这些动向显示,Swift 正在加速其在非苹果平台生态中的扩展,以实现其最初的全平台适用愿景。

为什么要用高级语言进行嵌入式开发

我们的日常生活中充斥着对嵌入式设备的使用,涉及家电、门禁、监控以及 POS 机等众多设备。传统上,许多人认为嵌入式系统的硬件性能有限,仅适用于功能简单且对稳定性要求高的应用场景。

然而,随着技术的进步和需求的增长,低性能的嵌入式设备已经无法满足各种复杂场景的需求。以高速普及中的智能汽车为例,为了确保稳定性,方向盘后的仪表板不会直接通过车载系统控制并显示,而是依赖于一款具备足够的性能来支持复杂的显示效果的微控制器(MCU)。如此即便主车机系统发生故障,仪表板仍需保持功能。

随着嵌入式应用的复杂度成倍增加,如果不采用高级编程语言进行开发,将严重影响开发效率和应用的整体表现。

SwiftIO Playground Kit

数年前,开发者们就开始尝试将 Swift 应用于嵌入式领域,并取得了初步进展。我在两年前关注到了 Mad Machine 这个团队。由于疫情影响,他们的初代产品在芯片供应上遇到了困难。今年三月,他们推出了新一代产品:SwiftIO Playground Kit。在收到了开发套件后,我立即进行了测试,并体验了使用 Swift 进行嵌入式开发的乐趣和挑战。

SwiftIO Playground

SwiftIO Playground Kit 包括 SwiftIO Micro 核心板和多种输入输出设备。随着嵌入式设备的复杂性增加,Mad Machine 选择了 Zephyr 实时操作系统和基于 Swift 构建的核心库来处理底层复杂性。这样,开发者无需担心硬件细节,同时也确保了代码对未来硬件变更的兼容性。SwiftIO Micro 配置为 600MHz 的 MCU、32MB RAM 和 16MB Flash,提供了强大的性能,这样的配置使得开发者不必担心 Swift 标准库编译后的大小(当前为 2MB)。

SwiftIO Software Structure

Swift 社区已经设立了专人以解决 Swift 语言当前在嵌入式开发领域所面临的一些困难,主要是解决 std 尺寸的问题。

开发初体验

因为官方提供了详尽 文档,因此构建开发环境的操作十分顺利,具体步骤包括:

  • 安装必要的驱动程序(目前支持 macOS 和 Linux)。
  • 下载 mm-sdk,包含了为嵌入式开发定制的 Swift 5.9 和相关工具。
  • 在 VSCode 中安装插件并设置 SDK 路径,指向刚刚下载的 mm-sdk。

完成这些设置后,我们便可以在 VSCode 中看到 MADMACHINE 面板,并开始创建项目。

image-20240429135727625

我的 VSCode 配置包括由 Swift Server Workgroup 开发的 Swift for Visual Studio Code,以及 SwiftLint 和 SwiftFormat 插件。有关在 Linux 上搭建 Swift 开发环境的详细方法,请参阅 此指南

接着,我们引入必要的第三方库,并编写以下代码来控制蓝色 LED 灯每秒闪烁一次:

Swift
import SwiftIO
import MadBoard

let led = DigitalOut(Id.BLUE)

while true {
    led.write(true)
    sleep(ms: 1000)

    led.write(false)
    sleep(ms: 1000)
}

官方还提供了许多有趣的 Demo,如下面的沙子坠落模拟,详细的 算法说明 可以在官网找到。用户可以通过调整电位器改变沙子的喷射位置,点击按钮来释放新的沙粒。

尽管开发过程总体顺利,但在最初的兴奋过后,我开始注意到与我平时使用 Swift 进行的开发相比,当前方式还存在一些不足。首先是在 VSCode 中,尽管安装了插件,但在代码声明跳转和第三方库深入方面的表现仍有局限。另外由于开发板的数据传输速率限制,每次编译后都需要等待一段时间才能传输数据到开发板并调试(例如沙子项目需要大约 14 秒)。对于习惯于 Xcode + SwiftUI + 预览的开发流程的我来说,这种开发体验存在明显的差距。特别是在开发较为复杂且包含 UI 的应用时,编译和数据传输的耗时可能会显著影响开发效率。

那么,我们是否能在现有条件下,通过熟悉的工具和流程来改进这些问题呢?

用熟悉的方式来开发嵌入式应用

在本章中,我将以“沙子”项目为例,展示如何构建我理想中的开发流程。您可以在 此处 查看修改后的项目。

分析

“沙子”项目主要由两个 Swift 文件构成。Sand.swift 包含了项目的核心逻辑,定义了一个 Sand 类型,负责根据电位器的输入调整喷射口的位置,并处理沙粒的坠落、碰撞和动画逻辑。而 main.swift 主要负责硬件的初始化和循环调用 sand.update() 方法以刷新显示内容。

深入分析 Sand 类型,我们可以识别出以下几个与硬件直接交互的部分:

  • 利用 ST7789 控制器绘制图像
  • 通过 AnalogIn 读取电位器的数据
  • 使用 getSystemUptimeInMilliseconds 函数获取时间间隔以改变沙子的颜色

若能将这些与硬件交互的逻辑抽象化,我们就可以使 Sand 类型(即应用的核心逻辑)独立于具体硬件,使其能够在其他平台上运行。

幸运的是,Mad Machine 使用纯 Swift 代码来控制这些硬件,我们可以通过将其实现抽象成协议的方式,满足我们的开发需求。

声明协议

为了实现硬件抽象化,我创建了一个新的包 MadBoardBase,其中定义了两个关键的接口协议。

image-20240429152145843

注意:这些协议的声明只包含了当前代码中实际使用到的方法和属性,主要目的是为了验证概念。

Swift
public protocol ST7789Base {
  func writePixel(x: Int, y: Int, color: UInt16)
  func writeBitmap(x: Int, y: Int, width w: Int, height h: Int, data: UnsafeRawBufferPointer)
  var width: Int { get }
  var height: Int { get }
}

public protocol AnalogInBase {
  func readPercentage() -> Float
}

这些协议允许我们在不直接依赖具体硬件实现的情况下,模拟和控制硬件行为,从而使 Sand 类型的应用逻辑可以跨平台运行。

剥离硬件依赖

为进一步抽象化,我创建了一个新的包:Sand,专门用于封装与 Sand 类型相关的声明。通过整合 MadBoardBase 包,我们对 Sand.swift 文件进行了重要调整,以确保其与硬件的独立性。

Swift
public final class Sand<S, A> where S: ST7789Base, A: AnalogInBase { 
  ...
  // 时间获取代码也抽象出来
  public init(screen: S, cursor: A, getSystemUptimeInMilliseconds: @escaping () -> Int64) {
    ...
  }
  ...
}

通过这些调整,Sand 类型现在能够与任何实现了 ST7789BaseAnalogInBase 协议的硬件或模拟模块兼容,使其可以跨平台进行测试和调试。

查看代码的变更:调整前调整后

构建视图组件

虽然 Sand 类已经实现了与硬件的独立性,但为了在不同的环境下使用它,我们需要适当的视图组件。

为此,我们创建了一个名为 MadBoardViewComponents 的新包,其中定义了符合 ST7789BaseAnalogInBase 协议的视图组件,这些组件可以在 SwiftUI 加上预览的环境中对 Sand 代码进行调试。

首先,我们利用 SwiftUI 的 Slider 来模拟电位器的行为:

Swift
public final class AnalogInModel: AnalogInBase,ObservableObject {
  @Published public var value:Float = .zero
  public init(){}
  public func readPercentage() -> Float {
    value
  }
}

public struct AnalogInComponent: View {
  @ObservedObject private var model:AnalogInModel
  public init(model: AnalogInModel) {
    self.model = model
  }
  public var body: some View {
    Slider(value: $model.value, in: 0...1)
  }
}

接着,我们创建一个模拟 ST7789 液晶屏的组件:

Swift
public final class ST7789Model: ST7789Base, ObservableObject {
  public var width: Int
  public var height: Int
  var pixels: [UInt16] // 用于存储像素数据的数组

  public init(width: Int = 240, height: Int = 240) {
    self.width = width
    self.height = height
    pixels = Array(repeating: 0x0000, count: width * height)
  }

  public func writePixel(x: Int, y: Int, color: UInt16) {
    guard x >= 0, y >= 0, x < width, y < height else { return }
    pixels[y * width + x] = color
  }

  public func writeBitmap(x: Int, y: Int, width w: Int, height h: Int, data: UnsafeRawBufferPointer) {
    guard x >= 0, y >= 0, x + w <= width, y + h <= height else {
      return
    }

    let srcData = data.bindMemory(to: UInt16.self)

    for row in 0 ..< h {
      let srcRowStart = row * w
      let dstRowStart = (y + row) * width + x
      for col in 0 ..< w {
        pixels[dstRowStart + col] = srcData[srcRowStart + col]
      }
    }
  }
}

public class ST7789UIView: UIView {
  ....
}

public struct ST7789UIComponent: UIViewRepresentable {
  var model: ST7789Model
  var scale: CGFloat

  public init(model: ST7789Model, scale: CGFloat = 1.0) {
    self.model = model
    self.scale = scale
  }

  public func makeUIView(context _: Context) -> ST7789UIView {
    let view = ST7789UIView(frame: CGRect(x: 0, y: 0, width: CGFloat(model.width), height: CGFloat(model.height)), model: model)
    return view
  }

  public func updateUIView(_: ST7789UIView, context _: Context) {}

  public func sizeThatFits(_: ProposedViewSize, uiView _: ST7789UIView, context _: Context) -> CGSize? {
    .init(width: CGFloat(model.width), height: CGFloat(model.height))
  }
}

完整的 ST7789UIView 代码可在此链接查看。我最初尝试使用 SwiftUI 的 Canvas 构建组件,但由于性能不足,目前转而采用基于 UIKit 的实现方式。为了进一步优化性能,应考虑在未来使用 Metal。此外,为了减少对主线程的影响,可以考虑采用其他的输入方法,如利用游戏手柄进行输入。

创建 SwiftUI 项目

在完成所有准备工作后,我们现在可以开始以熟悉的方式开发“沙子”演示项目了。首先,在根目录下创建一个名为 DropSand 的 SwiftUI 项目,并将 MadBoardViewComponentsSand 库集成进来。

image-20240429164147976

main.swift 文件的处理方式类似,我们仅需按照标准的 SwiftUI 应用流程声明视图组件,并调用 Sand 类实例,即可使演示项目在 SwiftUI 环境中顺利运行。

编写与硬件相关的部分

完成 Sand 类型的跨平台调试后,我们可以开始将这段代码应用于实际的嵌入式项目。

在 VSCode 中,通过 MADMACHINE 面板选择 New Project 来创建名为 SandPlayground 的项目。从原始项目复制 main.swift 的代码,并进行以下必要的调整,使其适应嵌入式环境:

Swift
import Sand

extension ST7789: ST7789Base {}
extension AnalogIn: AnalogInBase {}
// 增加用于获取时间的函数
var sand = Sand(screen: screen, cursor: cursor, getSystemUptimeInMilliseconds: getSystemUptimeInMilliseconds)

这样,经过调整的 Sand 代码便可以无缝运行在苹果设备和嵌入式平台上,实现真正的代码复用。

梳理

有些人可能会认为我的方法把简单的事情复杂化了。然而,如果我们仔细梳理一下理想的开发流程,便能清晰看到这些调整带来的优势。开发 SwiftIO 应用通常遵循以下步骤:

  • 创建 SwiftUI 项目:开始一个新项目,并引入由 Mad Machine 或社区开发的模拟组件。
  • 开发和测试核心包:在一个单独的包中编写基于硬件协议的核心代码,并对其进行单元测试。
  • 集成和交互测试:将核心代码集成到 SwiftUI 项目中,完成交互性测试。
  • 在嵌入式环境中实施:将核心代码集成到嵌入式项目中,进行硬件调试,最终完成应用的开发。

通过这种方式,开发者可以在大部分时间里,在熟悉的环境中使用熟悉的工具进行开发,这大大提高了 SwiftIO 项目的开发效率。

展望

对许多 Swift 开发者而言,嵌入式开发是一片陌生的领域。然而,随着像 SwiftIO 这样的硬件和 SDK 的出现,我们现在有机会探索这个领域。即使不是出于职业需要,使用这些工具来实现个人的创意点子,为自己、家人和朋友创造乐趣,也是一件非常有意义的事情。

展望未来,如果 Mad Machine 能够进一步抽象化硬件并构建更多模拟组件,那么孩子和学生就能在 iPad 上完成大部分嵌入式开发工作。这不仅可以降低入门的技术门槛,还能极大激发他们学习和使用 Swift 的兴趣,开启更多的可能性。

与全球开发者一同,每周探索 Swift 世界的精彩内容

如果文章对你有所帮助,可以请我喝杯