先说结论:这个标题里的 Pi,到底是指圆周率,还是指树莓派?我猜大多数人第一反应是前者——毕竟“用 Rust 重写 Pi(π)”听起来像是一个屠龙之技,用无运行时、无 GC 的系统语言去重算一个数学常数,然后再谈启动快了 10 倍。但实际上我这里说的 Pi,是那台在角落里跑了两年多的树莓派 4B。我把它上面的家庭环境监测服务从 Python 换成了 Rust,重启后服务恢复的时间从 5 秒多压到了 0.4 秒左右,折算下来确实超过了 10 倍。这篇文章不是要论证 Rust 比 Python 快,而是记录一次“无人值守设备上的常驻服务”重写过程,重点讲清楚启动时间为什么会差这么多,以及我在交叉编译、systemd 集成和传感器驱动里踩到的细节。如果你也有一台 Pi 在跑脚本、挂着某个服务,每次断电重启后都要等半天才能恢复,这篇应该对你有用。
1. 重写对象:一台树莓派上的环境监测服务
1.1 原始项目的背景与运行状态
先交代一下背景。我有一台树莓派 4B,2GB 内存,放在阳台角落,24 小时开着。它上面接了两个传感器:一个 DHT22 测温湿度,一个 BMP280 测气压。原来的逻辑很简单:用 Python 写一个常驻脚本,每 30 秒读一次传感器,把数据写进本地的 SQLite 数据库,同时通过 MQTT 推一份到家里的自建 Web 面板,方便我随时看阳台的温湿度曲线。
这个脚本本身并不复杂,最初就是用 Flask 挂了一个 /health 端点,方便我确认服务还活着;核心的采集循环则是用 Adafruit_DHT 这个库来读 DHT22,用 paho-mqtt 来做客户端。跑起来之后一切正常,传感器读数也还算稳定。真正的问题在“启动”这条链路上:平时你不会觉得有什么问题,因为服务不像桌面软件那样经常被拉起,但一旦遇到断电、内核升级重启、或者我手动调整配置后重新加载 systemd 服务,它就要经历一段漫长的恢复期。
为什么说漫长?因为我曾经多次在 Pi 重启后打开手机看温湿度曲线,发现监控断档了半小时甚至四十分钟。第一反应是模组坏了,后来查 journal 日志才发现,服务压根没有在开机后的第一时间跑起来:它在等网络,网络没就绪,MQTT 客户端就连接失败,脚本再被 systemd 拉起重试,重试前还得把整条 Python 依赖链重新 load 一遍。这个过程叠加在一起,监控就断了很久。断档半小时意味着我看不到停电后阳台的温度变化,这对一个“环境监测器”来说几乎等于失职。
1.2 真正逼我动手的几次“启动事故”
最典型的一次是小区夜间停电,Pi 跟着断电。第二天早上恢复供电后,我出门前扫了一眼数据,发现凌晨的温湿度曲线直接缺了一块。这不是硬件问题,而是服务启动流程的坑:树莓派在开机后,Wi-Fi 并不会瞬间连上,而旧脚本里我写的是“先等到网络可用,再初始化 MQTT 客户端,然后再进入采集循环”。一旦网络在等待窗口内没有准备好,程序就会抛异常退出,systemd 会按照 RestartSec 的节奏重新拉起。这个过程循环几次,十分钟就没了。
另一次印象深刻的是一台常驻快三个月的 Pi,某天 SSH 进去发现卡得不行。dmesg 一看,是 OOM killer 把 Python 进程杀了。那个进程平时 RSS 就有 110MB 左右,听着不多,但树莓派本身还有系统日志、SQLite、Wi-Fi 驱动这些在跑,几个月下来内存碎片加上文件描述符没有得到合理释放,进程越来越胖,最终把自己挤死了。Python 脚本不是不可以优化,但一个采集温湿度的小服务,你让我去调垃圾回收、调 fd 复用,总觉得投入产出比太低。
这两次事故合在一起,让我下决心重写:我需要一个启动极快、常驻内存小、断电后能立刻恢复服务的方案。注意,不是“CPU 算得快”,而是“恢复得快”。对这个场景而言,启动时间比运行性能重要得多。
2. 为什么是 Rust,而不是 Go、C 或者继续调 Python
2.1 先把旧服务启动时的时间拆开
在选型之前,我先做了一件很重要的事情:把旧 Python 服务的启动时间拆开看。很多人一说“Python 慢”,就笼统地归咎于解释器慢,实际上对于一个常驻服务来说,解释器执行指令的速度从来不是瓶颈,启动时“把环境准备好”的过程才是。
我当时的启动过程大概是这样分布的:
| 阶段 | 耗时 | 说明 |
|---|---|---|
| Python 解释器启动 | 约 30ms | 加载解释器本身 |
| 导入 Flask、paho-mqtt、Adafruit_DHT、sqlite3 等依赖 | 约 800ms | 依赖越多,import 越慢 |
| 初始化 GPIO 库并做一次预读 | 约 300ms | 树莓派上访问 /dev/gpiochip 有一定固定开销 |
| 初始化 SQLite 连接、检查数据库 schema | 约 100ms | 不算慢 |
| 等待网络就绪 | 约 3 到 4 秒 | 旧脚本里写了 sleep,这是最大的一坨 |
| Flask 初始化并监听端口 | 约 200ms | 相对可控 |
最终总启动时间 5 秒多,其中网络等待占了四分之三。也就是说,即使我把 Python 换成一个编译型语言,只要启动路径上还保留“先等网络,再干活”这个设计,启动时间依然要 4 秒左右,根本谈不上快 10 倍。这个拆解非常关键,它决定了后面的所有优化方向。
2.2 Rust 在“常驻小服务”上的优势
既然拆出了瓶颈,那选型就有了明确目标:我需要一个没有解释器、没有重型运行时、方便交叉编译到 ARM 的静态二进制。
Rust 在这条赛道上非常契合。它没有 GC,没有虚拟机,编译产物就是一个 ELF 文件,内核加载完成后直接执行 main,不需要“先把整个语言生态环境搭起来”这一步骤。内存布局由代码直接控制,一个温湿度采集服务跑起来可以稳定在个位数 MB 的 RSS。更难得的是,Rust 的安全保证不是靠运行时检查,而是在编译期完成。对 GPIO 这种需要直接读写内存映射寄存器的操作,用别的语言容易手滑,而 Rust 的rppal这类库把底层细节封装得很稳,我只需要专注业务逻辑。
当时也认真考虑过 Go。Go 的交叉编译同样方便,静态二进制也很大程度解决了部署问题,但 Go 的运行时里依然有 goroutine 调度器和 GC,在极端内存受限的树莓派上,GC 停顿并不是不可能出现。还有一个更现实的原因:树莓派 GPIO 的 Rust 生态(比如rppal)我更喜欢,API 设计干净,支持 GPIO、I2C、SPI、UART,而我这次既要 DHT22 的单总线协议,又要 BMP280 的 I2C 协议,一个库全包了。
至于 C,当然也能做,但对一个“25 小时跑着玩”的家庭小项目,用 C 去写 GPIO 时序解析和字符串格式化,开发和调试成本就太高了。Rust 等于给了我 C 级别的底层控制力,同时把内存管理、数据竞争这类最容易出幺蛾子的麻烦挡在编译期。
还有一个不容忽略的现实因素:交叉编译体验。我在 x86_64 的笔记本上一条命令就能编译出树莓派 4B 的 aarch64 二进制,20 秒内出产物,scp 到机器上直接跑,完全不需要在 Pi 上折腾编译器。这种“开发机编译、目标机运行”的流程干净利落,出问题也好定位。
3. 重写时的模块设计与核心实现
3.1 整体结构:先画清楚边界
重写之前,我先把程序按职责拆成了几个模块,而不是像旧脚本那样一个大文件从头写到尾。最终的结构是这样的:
config.rs:读取 TOML 配置文件,负责生成运行参数。sensor.rs:封装 DHT22 和 BMP280 的读取,对外只暴露一个read_all()方法。database.rs:封装 SQLite 的写入操作,提供批量插入接口。reporter.rs:封装 MQTT 上报逻辑,独立在后台运行。main.rs:负责把以上模块串起来,管理主循环和异步任务。
这个拆分不是为了显得工程化,而是为了在不同阶段做实验。比如我想单独测试传感器读取速度和稳定性,直接写一个测试入口调用sensor::read_all()就行,不用把整条数据链路跑起来。
Cargo.toml 里大概是这样:
[package] name = "env-monitor" version = "0.1.0" edition = "2021" [dependencies] rppal = "0.18" serde = { version = "1", features = ["derive"] } toml = "0.8" rusqlite = { version = "0.31", features = ["bundled"] } rumqttc = "0.24" tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] }选rumqttc是因为它原生支持异步 MQTT 客户端,运行在 tokio 上,天然适配后面的后台重试模型。SQLite 则直接用rusqlite,带 bundled 特性一并编译出 SQLite 本体,这样目标机上不用额外安装任何库。
3.2 DHT22 读取:一行初始化背后是一大串时序约束
DHT22 是一个单总线传感器,读取的时序约束非常严格。它的通信过程大致是:主机先把 GPIO 拉低至少 18ms,然后释放并切换到输入模式,传感器会自己拉低、再拉高,最后输出 40 bit 数据。每一个 bit 的 0 和 1,是靠高低电平的持续时间来区分的。这就意味着读取代码对精密延时非常敏感,稍有偏差就很容易读到 CRC 错误甚至 NaN。
我用rppal的 GPIOPin 实现读取,核心逻辑大致如下。
use rppal::gpio::Gpio; use std::{thread, time::Duration}; fn read_dht22(pin: u8) -> Result<(f32, f32), String> { let gpio = Gpio::new().map_err(|e| e.to_string())?; let mut pin = gpio.get(pin).map_err(|e| e.to_string())?.into_output(); // 拉低 18ms,向传感器发送起始信号 pin.set_low(); thread::sleep(Duration::from_millis(18)); pin.set_high(); // 切换为输入,让出总线 let mut pin = pin.into_input(); // 完整解析 40 bit 数据的时序代码比较长, // rppal 仓库的 example 里有可用的实现,我在这里不再贴全文。 // 核心步骤是“读电平 -> 计时 -> 判断位值 -> 收满 40 bit -> CRC 校验”。 read_40_bits(&mut pin) }实际开发中,我没有从零手写位解析,而是参考了rppal示例里的 approach,再结合 DHT22 数据手册里的时序图做过一版验证。这里的重点不是代码,而是约束:DHT22 的两次读取之间必须间隔至少 1 秒,这是传感器自身的物理特性决定的。如果主循环里不加任何节流,就会频繁读到超时或 CRC 错误。所以我在主循环里统一用 30 秒间隔取样,并且每次读取之后强制补一个 20ms 的稳定延时,让信号线恢复平静。
BMP280 走 I2C,比 DHT22 省心得多,但是 I2C 地址有冲突的可能,如果总线上还有其他 I2C 设备,最好显式指定地址。我配置成 0x76,这是 BMP280 的默认地址之一。
3.3 上报与落盘:把同步阻塞改成后台重试
这是这次重写里收益最大的一块设计。旧脚本的流程是“连接 MQTT 成功之后才开始采集”,导致网络等待被放到了启动路径上。新程序反了过来:主循环只做两件事——读传感器、写本地数据库。MQTT 上报被单独丢到一个异步任务里,连不上就重试,连上了就发送,一切都在后台进行。
#[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { let config = AppConfig::load("/etc/env-monitor.toml")?; let mut sensor = SensorHub::new(&config)?; let (tx, mut rx) = tokio::sync::mpsc::channel::<Sample>(32); // 上报任务独立运行,不阻塞采集主流程 tokio::spawn(async move { let mut client = build_mqtt_client(&config).await; while let Some(sample) = rx.recv().await { loop { if let Ok(()) = client.try_publish(&sample) { break; } // 连接断开或发送失败,暂时让出执行权,稍后重试 tokio::time::sleep(Duration::from_secs(3)).await; client = build_mqtt_client(&config).await; } } }); loop { match sensor.read_all() { Ok(sample) => { database::insert(&sample)?; let _ = tx.send(sample).await; } Err(err) => eprintln!("sensor read error: {err}"), } tokio::time::sleep(Duration::from_secs(30)).await; } }注意这个build_mqtt_client在启动时也会被调用一次,但它不再阻塞主流程。try_publish失败后,上报任务自己进入重连循环,主循环该采集采集、该写库写库。这样即使 Wi-Fi 一直没连上,程序也能在本地先把数据存住,面板端晚一点看到数据也没关系,至少监控不会断档。
SQLite 写入也做了一点调整。旧脚本是每条采样都INSERT,SD 卡写入慢的时候很容易撞锁。新版改成每 10 条采样批量提交一次,并且使用INSERT OR REPLACE,从根本上减少磁盘同步次数。
4. 启动时间从 5 秒降到 0.4 秒的三个关键点
4.1 关键点一:静态链接与二进制瘦身
先说明一点:Rust 默认编译出来的二进制也不是完全静态的,它也会依赖 glibc 这一层,尤其当我用aarch64-unknown-linux-gnu目标时,动态链接器、libc 这些仍然可能在运行时被加载。真正的“完全静态”在树莓派这种 ARM Linux 环境上,有两种常见路子:一是用 musl 目标,二是用 gnu 目标但确保所有依赖都是静态的。
我最终用的是aarch64-unknown-linux-gnu,配合 Rust 自带的静态链接特性,只要不链接那些纯动态库,产物基本已经不再依赖额外的系统库。为了进一步减少启动时的加载成本和文件体积,我在Cargo.toml里做了一组针对release的优化:
[profile.release] opt-level = "z" lto = true codegen-units = 1 panic = "abort" strip = trueopt-level = "z"优先优化体积,而不是追求最大化速度,因为对这个应用来说,CPU 性能完全不是瓶颈;lto = true允许跨 crate 的链接时优化,codegen-units = 1让整个 crate 在单次编译中统一优化,strip = true直接把符号表去掉,最终二进制从 8MB 左右降到 4MB 多一点。二进制越小,内核加载到内存的时间越短,对启动时间的贡献虽然不是最大,但它是第一个让我看到明显效果的操作。
4.2 关键点二:启动路径上不要等待网络
这个才是真相。旧 Python 脚本里,我写了一段类似于“等一下网络,最多 10 秒”的代码,这在很多脚本项目里很常见。在 PC 上这么做似乎问题不大,但在树莓派开机早期,Wi-Fi 网卡可能还没被驱动起来,DHCP 也可能还没拿到地址,于是这段等待经常变成 10 秒超时。即使不超时,它也人为地在启动路径上插了一段 3 到 4 秒的盲等。
新程序直接把这段等待全部移除。程序运行不需要等待任何外部资源,MQTT 连接失败就失败,主循环照常采集;数据库文件本地存在,也不需要等待网络文件系统挂载。这种设计就像“先把笔记记下来,电话打不通就设个闹铃慢慢打”,而不是“必须打通电话才开始记笔记”。
这个改动带来一个效果:systemd-analyze blame里,这个服务的启动耗时几乎等于它自己的二进制加载时间加上 GPIO 初始化时间,跟网络状态彻底解耦。
4.3 关键点三:systemd 里的依赖顺序不要写死
重写过程中,我还发现旧 systemd 服务文件里写了一句After=network-online.target。这句话的本意是“等网络就绪后再启动服务”,听着很负责任,但它让 systemd 把服务的启动时机押在了一个可能很长时间才满足的条件上。一旦 network-online.target 迟迟不满足,服务就一直卡在等待状态。
新服务文件我改成了下面这样:
[Unit] Description=Pi Environment Monitor Wants=network-online.target After=multi-user.target [Service] Type=simple ExecStart=/usr/local/bin/env-monitor --config /etc/env-monitor.toml Restart=always RestartSec=3 User=pi [Install] WantedBy=multi-user.target注意看:我保留了Wants=network-online.target,但去掉了After=network-online.target。这样 systemd 会尝试拉取网络就绪服务,但不会死等它。如果网络还没好,服务先起来,程序自己后台重试 MQTT,效果一样,启动速度却快了很多。
Type=simple也很关键。因为我们的主进程是一个常驻循环,不会主动告诉 systemd “我准备好了”,用Type=simple意味着 systemd 只要把进程拉起来就算服务启动成功,不再额外等待任何握手信号。如果你用的是带sd_notify通知机制的复杂服务,那自然要换Type=notify,但对我这个场景,simple最合适。
5. 交叉编译与部署的完整记录
5.1 开发机上的工具链准备
交叉编译听起来高大上,实际操作其实很顺手。我这里开发机是 x86_64 架构的 Linux,目标机是 aarch64 的树莓派 4B。第一步先添加 Rust 的目标支持:
rustup target add aarch64-unknown-linux-gnu sudo apt install gcc-aarch64-linux-gnu然后给 cargo 配置链接器。我习惯把这部分放在~/.cargo/config.toml里,这样对整个用户生效,省得每个项目单独配:
[target.aarch64-unknown-linux-gnu] linker = "aarch64-linux-gnu-gcc"配置好后,编译命令和普通本地编译几乎一样:
cargo build --release --target aarch64-unknown-linux-gnu产物在target/aarch64-unknown-linux-gnu/release/env-monitor。整个项目从零编译到出产物,在我笔记本上 20 秒左右。如果直接在树莓派上编,4B 要跑 6 到 8 分钟。所以我强烈建议用交叉编译,不光是快,还不用在 Pi 上装一堆 Rust 工具链,保持目标机干净。
有一个细节需要提醒:如果你后面引用了ring、openssl-sys这类带 C 代码的 crate,交叉编译时会去调用 C 编译器,gcc-aarch64-linux-gnu就必须安装好。遇到编译错误先别慌,看 Cargo 的错误信息里有没有cc或linker关键词,大概率就是工具链缺失。
5.2 部署到树莓派:scp、权限、验证
编译完成后,部署步骤很简单:
scp target/aarch64-unknown-linux-gnu/release/env-monitor pi@192.168.x.x:~/ ssh pi@192.168.x.x sudo mv ~/env-monitor /usr/local/bin/env-monitor sudo chmod +x /usr/local/bin/env-monitor这里有个权限坑我踩过:rppal访问树莓派的/dev/gpiochip0时,需要当前用户有相应权限。默认用户pi不一定在gpio组里,直接运行会报PermissionDenied或者Device or resource busy。我的解决方法是干脆让 systemd 服务跑在User=pi,然后通过 udev 规则给 GPIO 设备分配权限,或者更简单地把服务配置成User=root。对家庭项目来说,套一个User=root反而最省事,毕竟这个服务只做传感器采集和本地上报,没有复杂的安全边界。为人勤快的话,倾向User=pi+ udev 也是对的。
首次启动可以用前台模式验证:
sudo /usr/local/bin/env-monitor --config /etc/env-monitor.toml确认没有报错,能看到采样日志后,再启动 systemd 服务:
sudo systemctl daemon-reload sudo systemctl enable --now env-monitor sudo systemctl status env-monitor最后用systemd-analyze blame看一下服务到底花了多久启动:
systemd-analyze blame | grep env-monitor我实测的输出是大约 370ms 到 400ms。这个时间包括了二进制的加载、GPIO 初始化、SQLite 打开、以及第一次采样完成。对比旧服务的 5.2 秒,已经超过 10 倍了。
6. 实测数据与后续还能怎么玩
6.1 重写前后的量化对比
等整个系统稳定跑了两周多,我做了一组直观的测量。这里是我这台设备上的实测值,不同 SD 卡、不同系统版本会有差异,但量级足以说明问题:
| 指标 | Python 旧版 | Rust 新版 | 变化 |
|---|---|---|---|
| 冷启动到首条数据 | 约 5.2 秒 | 约 0.4 秒 | 约 13 倍提升 |
| 常驻内存 RSS | 约 110MB | 约 8MB | 降到约 1/14 |
| 依赖数量 | 12 个 pip 包 | 不依赖任何外部系统库 | 彻底减少 |
| 二进制大小 | 源码 + Python 环境约 80MB | 4.2MB 单文件 | 部署简单 |
| 月内异常退出次数 | 观察期 3 次 | 观察期 0 次 | 稳定性改善 |
内存差距之所以这么大,根本不是 Python 和 Rust 谁的“语法”更省内存,而是旧版本带着解释器、一堆全局对象、Flask 框架和 MQTT 客户端,天然占空间;新版本只有一个二进制的代码段和数据段,加上 tokio 的线程栈和 SQLite 的连接对象。你可以理解成:旧程序像开了一辆装满行李的房车,新程序像骑了一辆只有车架和引擎的摩托车,干同样的事,载重完全不同。
6.2 几个我实际踩过、你可能也会遇到的坑
第一个坑是 DHT22 的老问题。如果你也接 DHT22,别把采样间隔设得太短,低于 1 秒会出现间歇性 CRC 错误,程序里最好对读取失败做容错,而不是直接 panic。另外传感器线长超过 20cm 之后,读数的稳定性会明显下降,建议在信号线和电源线之间加一个 4.7kΩ 到 10kΩ 的上拉电阻,靠近传感器放置。
第二个坑是 SQLite 锁冲突。树莓派 SD 卡写入速度波动很大,如果主循环和上报任务同时写库,偶尔会报database is locked。我是通过两条规则解决:写入交给单一任务处理,并且批量提交。如果项目更复杂,可以考虑上WAL模式和连接池,但家庭环境监测这个量级,批量提交已经够稳。
第三个坑和交叉编译后的运行环境有关:不要在开发机上直接跑aarch64二进制,会报Exec format error,因为架构不匹配。验证程序行为时,可以用file /usr/local/bin/env-monitor确认架构,或者先交叉编译一个--target x86_64-unknown-linux-gnu的 debug 版在开发机测逻辑。我在开发机上用 x86_64 版本跑通了传感器模拟器,再部署 aarch64 版本到 Pi,省去了来回试错的时间。
第四个坑是 systemd 的日志时间戳。如果你像我一样用journalctl -u env-monitor -f排查问题,注意 Rust 的std::time::Instant是基于单调时钟的,它不会受系统时间调整影响,非常适合算启动耗时。但如果你想记录“传感器读到的本地时间”,应该用SystemTime或者系统时间库,别混用两种时钟做对账,否则日志里的时间会差出时区。
6.3 后续还能怎么玩
这次重写完成之后,我并没有停下,因为 Rust 生态在这方面深度很深,继续扩展的路径非常清晰。
第一,把 Web 面板也收编进同一个二进制。现在数据是通过 MQTT 推到自建面板,面板本身还是另一个服务。其实 Rust 里用axum或者actix-web挂一个轻量 HTTP 服务非常快,编译后自动带上 Web 界面,部署就是单文件,直接省略 MQTT 中转这一跳。对局域网场景来说,少一层依赖就少一个故障点。
第二,利用 systemd 的 watchdog 做进程健康管理。现在Restart=always已经能保证进程崩了立刻重启,但如果想监测“采集线程是否还活着”,可以引入sd_notify,搭配WATCHDOG=1和Type=notify,让 systemd 在程序卡死时主动杀掉并重启。我近期打算把这个加进去,毕竟无人值守设备最怕的就是“进程没退,但采集已经停了”。
第三,如果对启动速度还想继续压,可以深挖内核层面:把关键初始化提前、用Preload减少动态加载、甚至考虑用systemd的ExecStartPre预热模块。不过实测 0.4 秒已经足够好用,再往下压的边际收益很低,没必要为了数字好看而优化。
借着这三条扩展路线,这个项目已经从最初简单的“换语言”变成了一个持续迭代的基础设施。我在日常维护中最大的体会是:启动快 10 倍这件事,真正有价值的不是数字本身,而是让一台无人值守的小机器在发生意外重启后,能像什么都没发生一样继续采集数据。毕竟对一个挂在阳台角落的温湿度监控器来说,最稀缺的资源不是算力,而是“能快速回到工作岗位”的恢复能力。
如果你也想对家里的 Pi 做一次类似的升级,我的建议是先别急着选语言。打开systemd-analyze blame,看看你的服务到底在等什么,很多时候改一行依赖顺序、去掉一段无意义的 sleep,收获比换语言还大。等确认剩下的瓶颈确实来自解释器或运行时,再考虑 Rust 也不迟。