先说结论这个标题里的 Pi到底是指圆周率还是指树莓派我猜大多数人第一反应是前者——毕竟“用 Rust 重写 Piπ”听起来像是一个屠龙之技用无运行时、无 GC 的系统语言去重算一个数学常数然后再谈启动快了 10 倍。但实际上我这里说的 Pi是那台在角落里跑了两年多的树莓派 4B。我把它上面的家庭环境监测服务从 Python 换成了 Rust重启后服务恢复的时间从 5 秒多压到了 0.4 秒左右折算下来确实超过了 10 倍。这篇文章不是要论证 Rust 比 Python 快而是记录一次“无人值守设备上的常驻服务”重写过程重点讲清楚启动时间为什么会差这么多以及我在交叉编译、systemd 集成和传感器驱动里踩到的细节。如果你也有一台 Pi 在跑脚本、挂着某个服务每次断电重启后都要等半天才能恢复这篇应该对你有用。1. 重写对象一台树莓派上的环境监测服务1.1 原始项目的背景与运行状态先交代一下背景。我有一台树莓派 4B2GB 内存放在阳台角落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 或者继续调 Python2.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(), Boxdyn 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 写入也做了一点调整。旧脚本是每条采样都INSERTSD 卡写入慢的时候很容易撞锁。新版改成每 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 服务文件里写了一句Afternetwork-online.target。这句话的本意是“等网络就绪后再启动服务”听着很负责任但它让 systemd 把服务的启动时机押在了一个可能很长时间才满足的条件上。一旦 network-online.target 迟迟不满足服务就一直卡在等待状态。新服务文件我改成了下面这样[Unit] DescriptionPi Environment Monitor Wantsnetwork-online.target Aftermulti-user.target [Service] Typesimple ExecStart/usr/local/bin/env-monitor --config /etc/env-monitor.toml Restartalways RestartSec3 Userpi [Install] WantedBymulti-user.target注意看我保留了Wantsnetwork-online.target但去掉了Afternetwork-online.target。这样 systemd 会尝试拉取网络就绪服务但不会死等它。如果网络还没好服务先起来程序自己后台重试 MQTT效果一样启动速度却快了很多。Typesimple也很关键。因为我们的主进程是一个常驻循环不会主动告诉 systemd “我准备好了”用Typesimple意味着 systemd 只要把进程拉起来就算服务启动成功不再额外等待任何握手信号。如果你用的是带sd_notify通知机制的复杂服务那自然要换Typenotify但对我这个场景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 pi192.168.x.x:~/ ssh pi192.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 服务跑在Userpi然后通过 udev 规则给 GPIO 设备分配权限或者更简单地把服务配置成Userroot。对家庭项目来说套一个Userroot反而最省事毕竟这个服务只做传感器采集和本地上报没有复杂的安全边界。为人勤快的话倾向Userpi 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 环境约 80MB4.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 做进程健康管理。现在Restartalways已经能保证进程崩了立刻重启但如果想监测“采集线程是否还活着”可以引入sd_notify搭配WATCHDOG1和Typenotify让 systemd 在程序卡死时主动杀掉并重启。我近期打算把这个加进去毕竟无人值守设备最怕的就是“进程没退但采集已经停了”。第三如果对启动速度还想继续压可以深挖内核层面把关键初始化提前、用Preload减少动态加载、甚至考虑用systemd的ExecStartPre预热模块。不过实测 0.4 秒已经足够好用再往下压的边际收益很低没必要为了数字好看而优化。借着这三条扩展路线这个项目已经从最初简单的“换语言”变成了一个持续迭代的基础设施。我在日常维护中最大的体会是启动快 10 倍这件事真正有价值的不是数字本身而是让一台无人值守的小机器在发生意外重启后能像什么都没发生一样继续采集数据。毕竟对一个挂在阳台角落的温湿度监控器来说最稀缺的资源不是算力而是“能快速回到工作岗位”的恢复能力。如果你也想对家里的 Pi 做一次类似的升级我的建议是先别急着选语言。打开systemd-analyze blame看看你的服务到底在等什么很多时候改一行依赖顺序、去掉一段无意义的 sleep收获比换语言还大。等确认剩下的瓶颈确实来自解释器或运行时再考虑 Rust 也不迟。
阅读完成 · 觉得有帮助?