近年来,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 Kit 包括 SwiftIO Micro 核心板和多种输入输出设备。随着嵌入式设备的复杂性增加,Mad Machine 选择了 Zephyr 实时操作系统和基于 Swift 构建的核心库来处理底层复杂性。这样,开发者无需担心硬件细节,同时也确保了代码对未来硬件变更的兼容性。SwiftIO Micro 配置为 600MHz 的 MCU、32MB RAM 和 16MB Flash,提供了强大的性能,这样的配置使得开发者不必担心 Swift 标准库编译后的大小(当前为 2MB)。
Swift 社区已经设立了专人以解决 Swift 语言当前在嵌入式开发领域所面临的一些困难,主要是解决 std 尺寸的问题。
开发初体验
因为官方提供了详尽 文档,因此构建开发环境的操作十分顺利,具体步骤包括:
- 安装必要的驱动程序(目前支持 macOS 和 Linux)。
- 下载 mm-sdk,包含了为嵌入式开发定制的 Swift 5.9 和相关工具。
- 在 VSCode 中安装插件并设置 SDK 路径,指向刚刚下载的 mm-sdk。
完成这些设置后,我们便可以在 VSCode 中看到 MADMACHINE 面板,并开始创建项目。
我的 VSCode 配置包括由 Swift Server Workgroup 开发的 Swift for Visual Studio Code,以及 SwiftLint 和 SwiftFormat 插件。有关在 Linux 上搭建 Swift 开发环境的详细方法,请参阅 此指南。
接着,我们引入必要的第三方库,并编写以下代码来控制蓝色 LED 灯每秒闪烁一次:
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
,其中定义了两个关键的接口协议。
注意:这些协议的声明只包含了当前代码中实际使用到的方法和属性,主要目的是为了验证概念。
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
文件进行了重要调整,以确保其与硬件的独立性。
public final class Sand<S, A> where S: ST7789Base, A: AnalogInBase {
...
// 时间获取代码也抽象出来
public init(screen: S, cursor: A, getSystemUptimeInMilliseconds: @escaping () -> Int64) {
...
}
...
}
通过这些调整,Sand
类型现在能够与任何实现了 ST7789Base
和 AnalogInBase
协议的硬件或模拟模块兼容,使其可以跨平台进行测试和调试。
构建视图组件
虽然 Sand
类已经实现了与硬件的独立性,但为了在不同的环境下使用它,我们需要适当的视图组件。
为此,我们创建了一个名为 MadBoardViewComponents
的新包,其中定义了符合 ST7789Base
和 AnalogInBase
协议的视图组件,这些组件可以在 SwiftUI 加上预览的环境中对 Sand
代码进行调试。
首先,我们利用 SwiftUI 的 Slider
来模拟电位器的行为:
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 液晶屏的组件:
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 项目,并将 MadBoardViewComponents
和 Sand
库集成进来。
与 main.swift
文件的处理方式类似,我们仅需按照标准的 SwiftUI 应用流程声明视图组件,并调用 Sand
类实例,即可使演示项目在 SwiftUI 环境中顺利运行。
编写与硬件相关的部分
完成 Sand
类型的跨平台调试后,我们可以开始将这段代码应用于实际的嵌入式项目。
在 VSCode 中,通过 MADMACHINE 面板选择 New Project
来创建名为 SandPlayground
的项目。从原始项目复制 main.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 的兴趣,开启更多的可能性。