利用指纹传感器构建一个便携式设备:只需将食指放在上面,就会自动输入验证码
CyberKey:从网页开发转行嵌入式项目时学到的经验
我是一名拥有6年经验的全栈Web开发人员,擅长Python、Rust、JavaScript、数据库和API。这就是我的日常工作。我从未接触过电子设备。
几周前,我决定开发CyberKey。这个想法源于工作中一件烦人的事:当我锁定电脑时,VPN会断开连接,而且我每天都要多次输入TOTP验证码。每次解锁手机、打开验证器应用、读取验证码并及时输入,直到它过期为止。CyberKey是一款小巧的设备,能彻底解决这一麻烦。只需将食指放在上面,蓝牙就会自动输入验证码。无需任何应用,无需手机,也无需复制粘贴。仅凭手指一动,验证码就会出现在输入框中。
该项目运行在一块M5StickC Plus 2上,这是一款大小如打火机的ESP32微控制器。它由M5Stack公司生产,该公司专门制造基于ESP32的模块,配备屏幕、电池和内置接口,同时还提供一系列兼容的传感器和外设。CyberKey中的指纹识别传感器就是其中之一。对于不想使用面包板和焊接的软件开发者来说,这是一个相当合适的入门选择。固件(直接运行在芯片上的程序,无需底层操作系统)采用Rust语言编写。在整个开发过程中,Claude给予了我大量帮助,大部分工作都发生在宝宝午睡的时间段,每天大约两小时。如果没有他的协助,我根本无法在合理的时间内完成这个项目。
这不是教程,而是我在六年网络开发中从未接触过的概念的总结,以及对固件架构的详细讲解,面向那些好奇网络开发背后技术细节的开发者。
硬件:当你的代码与物理世界交互时
最让我感到意外的是,微控制器与我之前接触过的任何东西都截然不同。服务器在其底层有操作系统:调度器、文件系统、网络栈和内存分配器。你可以在这些基础上编写代码。而微控制器则没有这些功能。它只有一块芯片、一些闪存和几百KB的RAM。无论你的程序需要做什么,都必须自己完成初始化设置。
最具体的体现就是**GPIO**引脚(通用输入/输出)。这些是芯片的物理引脚,每个引脚都连接到电路板上的导线,你的代码可以将其设置为高电平(3.3V)或低电平(0V),也可以读取其当前状态。这是一种布尔值,但由电流构成。点亮LED实际上就是将某个引脚设为“真”;而读取按钮按下事件,则是在循环中读取该引脚的电平值。
为了让芯片之间能够相互通信,嵌入式系统使用了一组小型的标准协议。我在CyberKey中使用的三种是**UART**、**I2C**和**SPI**。让我印象深刻的类比是,它们在功能上大致相当于网络开发中的不同网络协议:每种协议都在简单性、速度以及可连接设备数量之间做出了权衡。
UART 最简单:两根线,两个设备,无共享时钟。双方事先约定传输速率(波特率),仅发送比特。这正是你用来烧录和调试电路板的 USB 串口所基于的协议。I2C 只用两根线,但支持同一总线上多个设备,每个设备都有独立地址,类似于 IP 地址。速度较慢,但非常适合传感器和时钟等不需要高吞吐量的应用。SPI 是最快的:四根线,带专用时钟,可实现高达 80 MHz 的数据传输速率,因此常用于显示屏。
最让我感到惊讶的是SPI背后的层次结构。它有四根线:CLK(时钟)、MOSI(数据输出)、MISO(数据输入)和CS(片选)。前三个构成一个**总线**,物理上由所有组件共享,就像一根电缆同时连接多个芯片一样。当ESP32发送时钟信号时,所有连接的芯片都会同时接收到该信号。第四根线CS的作用是决定哪一块芯片响应,而其他芯片不响应:当特定芯片的CS被拉低时,该芯片才会响应;其余芯片则忽略该信号。
在代码中,这对应于一个清晰的层次结构:一个**驱动器**管理三条共享的总线,而一个**设备**将该驱动器与一个特定的CS引脚结合,以表示单个组件。如果你的显示设备和SD卡共用同一根总线,那么你就会有一个驱动器和两个设备,每个CS引脚对应一个设备。这里的“总线”并非偶然,它与城市公交车的比喻相同——一条共享路线,多个乘客可以乘坐,每个人在自己的站点下车。
固件架构
Web服务器入口在开始处理请求之前,会执行许多操作:连接数据库、注册中间件、设置路由、启动后台作业队列。但在这些功能的背后,还存在操作系统对内存和调度的管理、运行时对输入输出的处理,以及提供事件循环的框架。你是在建立已经存在的多个层次之上进行开发的。
在嵌入式系统中,这些层并不存在。`main()` 并不是程序的起点,而是整个程序。在 CyberKey 的固件中,`main()` 是一个长而连续的初始化函数:
电源引脚:必须立即保持高电平,否则在松开按钮时电路板将关闭。
**电池ADC**:用于读取电池电压的模数转换器;该芯片只能识别数字,无法直接处理电压,因此需要硬件进行转换。
**命令行界面的UART**:用于连接笔记本电脑的串行接口,通过USB进行指纹录入和时钟同步
I2C 用于实时时钟:这样设备就能知道当前时间,从而生成有效的 TOTP 代码
**显示的SPI**:用于显示当前状态、TOTP验证码以及BLE配对PIN码的屏幕
**BLE**:蓝牙低能耗,一种无线协议,使设备在计算机上表现为键盘
指纹传感器:通过UART连接至Grove接口(M5Stack标准接口,单个插头内集成了电源和通信协议,无需焊接)
每个组件都需要独立的协议配置、独立的引脚和独立的驱动程序。只有在所有内容初始化完成后,控制权才会传递给主循环。
顺序至关重要。在完成硬件复位序列之前初始化显示控制器会导致黑屏。在SPI总线准备好之前启动BLE会引发崩溃。没有任何框架能捕获你的错误。如果操作顺序有误,设备将无法正常工作,而且通常不会显示任何错误信息。
初始化后,固件会运行一个永不退出的循环。它检查按钮、处理BLE事件、监听指纹匹配、更新显示并读取电池电量。这就是你自己编写的事件循环。
在网页开发中,没有能与之相媲美的东西就是电源管理。运行在200mAh电池上的设备会快速耗电,而你让任何组件持续运行都会产生运行成本。ESP32具备睡眠模式,本应能在使用之间大幅降低功耗。我花了不少时间试图让这些模式生效,却始终未能成功。由于一些我至今仍无法完全确定的原因,芯片实际上从未真正进入睡眠状态。最简单的有效解决方案是:按下按钮C,通过将GPIO拉低来完全切断电源。此时电路板立即关闭,不再消耗电力,并在下次启动后几秒内自动重新连接到已绑定的主机。
嵌入式中的 Rust:介于 C++ 与空之间
嵌入式系统中,Rust 并非主流语言。C 和 C++ 是主流。Espressif 的官方 ESP32 SDK(称为 ESP-IDF)是用 C 编写的。M5Stack 的官方驱动程序则使用 C++ 编写。大多数社区成员、教程和示例也都采用 C 和 C++ 语言。
使 Rust 在这里可用的原因在于,有一层 crates 将 ESP-IDF 的 C 代码封装起来,并通过 Rust API 提供接口。当我调用函数来读取电池电压时,表面上是 Rust,但底层仍是 C 语言实现的。你获得了 Rust 提供的安全保障,同时仍然建立在用 C 编写的底层基础之上。
实际的结果是,阅读C++代码已成为工作流程的一部分。要了解某个组件的运行方式,最可靠的来源通常是官方的C++驱动程序:需要切换哪个引脚、按什么顺序以及使用什么样的时序。对于指纹传感器,M5Stack发布了用C++编写的Arduino驱动程序。我读了它,理解了其实现的UART通信协议,并从零开始用Rust重新编写了这个驱动程序。这并非因为Rust本身要求这样做,而是因为当时还没有适用于该传感器的Rust驱动程序。
这给了我一个名为 `fingerprint2-rs` 的 crate,使用 `no_std` 编译。`no_std` 注解告诉编译器,这段代码不能依赖标准库,因为标准库假设底层有一个操作系统。没有操作系统,就没有标准库,也就没有运行时开销。
至于Rust具体带来了什么:编译器在每个阶段都强制执行错误处理,这一点很重要,因为一次失败的I2C读取可能会悄无声息地破坏你的状态。内存由开发者显式管理,没有垃圾回收机制。当可用内存仅有几百KB时,这一点就显得尤为关键。我不会夸大其词:Rust并没有让硬件更易于理解,但它确实让代码在编译后更值得信赖。
网页开发没有为你准备好的东西
**无需热重载。** 固件的每次修改都遵循相同的流程:编辑代码、编译、通过USB将二进制文件刷入设备、等待设备启动,然后观察。一个完整的迭代大约需要一分钟。你很快就会学会在敲代码前先思考。
**崩溃可能导致设备无法使用。** 在网页开发中,未处理的异常会打印堆栈跟踪并导致进程重启;而在嵌入式系统中,劣质的固件可能会使设备陷入无限重启循环,无输出或完全无响应。所谓“砖机”(bricking)意味着设备变得如同砖块一般:无法启动,且若要恢复它,必须执行特定的刷写程序,甚至可能根本无法恢复。在本项目中,我从未遇到过这种情况,但这种可能性决定了你对每次更改进行测试时的严谨程度。
内存不可弹性扩展。ESP32 内部有 520 KB 的 RAM,没有堆内存的扩展、无交换空间,也没有“简单增加更多”的选项。每一次内存分配都是一个决策。这正是 `no_std` 能发挥作用的地方:通过设计,内存使用量变得明确且可预测。
电池始终萦绕在你心头。在网页开发中,能耗是隐形的,这是云服务提供商的问题。对于一个使用200毫安时电池的设备而言,任何被开启的组件都会消耗你的续航能力。这迫使我们以不同的方式思考每一项架构决策,而这种思维方式完全出乎我意料,原本并非来自网络领域。
与AI共同协作开发
我在引言中提过,但有必要具体说明:在整个项目过程中,我大量依赖了Claude。并不是盲目地信任它作为代码生成器,而是作为一种保持思路畅通的方式。当你在宝宝醒来前还有两个小时时,绝不能花四十五分钟去弄清楚为什么你的I2C总线卡住了。有了能解释概念、指出相关部分并勾勒出方向的工具,整个进度就会完全改变。
它所不能替代的,是那些需要判断的决策。我负责确定方向、功能设计、架构以及质量标准。当某件事出现问题时,我会在设备上进行测试,查看终端输出,并查阅M5Stack官方文档、他们的GitHub仓库,以及一些基于相同硬件开发的开源项目,寻找解决方案。很多时候,正是这些方法最终帮我们解决了问题,而不是AI。有时,直觉也恰恰证明了正确。
我遇到的风险是:动作太快。在多个阶段,我实现了某个功能、上线后便继续前进,却并未完全理解自己刚刚写下的代码。后来当系统出现问题时,我才不得不重新阅读自己的代码,仿佛它属于别人一般。
结果与要点
设备运行正常。将已注册的手指放在传感器上,系统会识别并显示TOTP验证码,通过蓝牙在不到一秒内输入完成配对。BLE配对、显示屏、实时时钟、USB命令行接口:所有功能均按预期运行。对于首个嵌入式项目来说,目前的成果让我感到满意。
唯一令人失望的是电池续航。200mAh的电池,设备能使用大约3到4小时。我尝试了各种方法来改善这一点——睡眠模式、传感器待机、不同的组合设置,但都没有带来显著的提升。我一直没能完全理解其中的原因。目前最务实的解决方案是强制关机:按一下按钮C即可在硬件层面切断所有电源,下次开机时设备会自动重新连接到已绑定的主机。虽然不够优雅,但诚实可靠。如果你有不需要硬件改动的改进想法,我非常乐意看到你的拉取请求。
如果我再做一次,我会在编写代码之前花更多时间去理解核心概念。我遇到最困难的时刻,总是那些跳过了基础内容的时候。此外,我还会从一开始就添加日志,没有它们调试嵌入式硬件就只能盯着一个静止的设备猜测。
更广泛的启示是:嵌入式开发对网页开发者来说是可接触的。这些概念虽然陌生,但易于学习,而清晰架构和可读代码的直觉也容易迁移。它只是比学习一个新框架更令人困惑的一种形式而已。
本文编译自hackster.io





