用 Swift 开发嵌入式应用
用 Swift 开发嵌入式应用
近年来,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 {
}
官方还提供了许多有趣的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)
}
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) {
}
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) {
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)
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) {
}
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的兴趣,开启更多的可能性。