服务端技术栈与踩坑记录
记录 hearth(服务端 GPS 追踪系统)开发和部署过程中的技术选型与踩坑。
数据流
轮椅 [ATGM336H GPS → ESP32S3转发 → LoRa TX]
→ (433MHz 无线) →
LoRa直连串口模块
→ (/dev/ttyUSB0, 9600 baud) →
Wyse 5070 [hearth: 串口 → NMEA 解析 → SQLite → SSE → Web 地图]技术栈选型
| 层 | 选型 | 理由 |
|---|---|---|
| 语言 | Rust | 异步 + 安全 + musl 静态编译尝试(后弃用,见踩坑 #8) |
| HTTP | axum | tokio 生态原生,轻量,SSE 支持好 |
| 异步 | tokio | 串口读取用 spawn_blocking,HTTP/SSE 用异步 |
| 串口 | tokio-serial | 直接操作 /dev/ttyUSB0(LoRa TTL 适配器) |
| 数据库 | SQLite (rusqlite) | 单文件,无需额外服务,适合嵌入式场景 |
| 前端地图 | Leaflet + 高德瓦片 | 国内访问快,免费额度够用 |
| 实时推送 | SSE (Server-Sent Events) | 比 WebSocket 简单,单向推送够用 |
| 部署系统 | Arch Linux (pacstrap) | 滚动更新,包管理方便,比 LFS 省时间 |
| Bootloader | systemd-boot | 比 GRUB 简单,UEFI 原生 |
| Init | systemd | 服务管理、网络、日志一体化 |
| LoRa 接收 | DX-LR22-433T22D + CH340 TTL | 透明模式,插上即 /dev/ttyUSB0,无需 Arduino 中转 |
编译
最终采用 gnu 目标动态链接。Arch 自带 glibc,兼容性最好:
cargo build --release
# 产出 target/release/hearth,动态链接 glibc部署方式:构建 2GB 磁盘镜像 → dd 到 SD 卡 → Wyse 5070 从 SD 卡启动 → 运行 install-to-emmc 安装到内置 eMMC。
更新部署(服务运行中无法直接 scp,用 ssh + cat 绕过文件锁):
ssh root@wyse 'killall hearth; sleep 1'
ssh root@wyse 'cat > /tmp/h && mv /tmp/h /usr/local/bin/hearth && chmod +x /usr/local/bin/hearth' < target/release/hearth踩坑记录
1. LFS 构建失败 → 转 Arch pacstrap
最初计划从零 LFS 构建整个系统(学习目的),花了几天编译工具链和基础包。最终产出的 ISO 在 Wyse 5070 上完全没有画面输出。
原因不明(可能是内核配置缺少显示驱动,或 initramfs 有问题),排查成本太高。
解决: 放弃 LFS,改用 pacstrap 直接安装 Arch 包到磁盘镜像。10 分钟搞定一个能启动的系统。
2. initramfs autodetect 导致目标机无法启动
mkinitcpio 默认的 autodetect hook 只打包当前宿主机的内核模块。构建机是 NVMe 硬盘,打出来的 initramfs 里没有 SD/MMC 驱动。
Wyse 5070 从 SD 卡启动时报 Timed out waiting for device /dev/disk/by-uuid/...,掉进 emergency mode。
解决: 从 HOOKS 中移除 autodetect,让 initramfs 包含所有模块:
# /etc/mkinitcpio.conf
HOOKS=(base systemd microcode modconf kms keyboard sd-vconsole block filesystems fsck)代价是 initramfs 从 ~30MB 膨胀到 ~193MB,但对于安装介质来说无所谓。
3. pacstrap -K 极慢
pacstrap -K 会在目标系统中从零初始化 pacman GPG keyring,需要大量熵,在 chroot 环境中极慢(10+ 分钟卡住不动)。
解决: 去掉 -K,直接使用宿主机的 keyring。构建环境可信,不需要重新验证。
4. cleanup trap 误删挂载点
构建脚本的 cleanup 函数用了 rm -rf "$MNT",如果 umount 失败(比如进程占用),会在挂载状态下递归删除整个文件系统。
解决: 改为 rmdir "$MNT",只在目录为空(已成功 umount)时才删除。
5. Arch 没有 dialout 组
Debian 系用 dialout 组管理串口权限,Arch 用 uucp。直接 usermod -aG dialout hearth 会报错。
解决: 改为 usermod -aG uucp hearth。
6. 镜像太大 dd 慢
最初做了 4GB 镜像(为了放进 3.6G SD 卡还缩到 3.5GB),但实际系统只用了 1.2GB。通过 USB 2.0 读卡器 dd 速度只有 ~5MB/s,3.5GB 要刷 11 分钟。
解决: 重新规划分区 —— 512M EFI + 1400M root + 剩余做 swap。镜像缩到 2GB,刷写时间减半。install-to-emmc 安装到 eMMC 时会重新分区,root 占满全部剩余空间。
7. dd 写入看似瞬间完成
用 dd conv=fsync 或 oflag=direct 写 SD 卡,显示速度 3.8GB/s —— 明显不对。
原因:SD 卡设备号变了(从 /dev/sda 变成 /dev/sdb),dd 写到了一个已不存在的设备节点,内核直接返回成功。
解决: 每次写入前用 lsblk 确认设备名。用 pv 管道可以看到真实写入速度。
8. musl 静态二进制在 Celeron J4105 上 SIGSEGV
最初用 x86_64-unknown-linux-musl 目标编译,在构建机(Ryzen 7940HX)上运行正常,但部署到 Wyse 5070(Celeron J4105)后直接 core dump:
hearth.service: Failed with result 'core-dump'.
Process: 1717 ExecStart=/usr/local/bin/hearth (code=dumped, signal=SEGV)file 命令显示二进制确实有 musl 解释器 (/lib/ld-musl-x86_64.so.1),安装 musl 包后解释器问题解决了,但仍然 SIGSEGV。怀疑是编译器在 Ryzen 上启用了某些 Gemini Lake 不支持的指令。
解决: 放弃 musl 静态编译,改用默认 gnu 目标(cargo build --release),动态链接 glibc。Arch 自带 glibc 2.43,完全兼容。musl 的"无依赖部署"优势在 Arch 环境下意义不大。
9. systemd Restart=on-failure 不够用
hearth 服务使用 Restart=on-failure,但如果进程以 exit code 0 退出(某些错误路径可能不返回非零),systemd 不会重启。Arduino 未插入时 Rust 代码内部虽有重试循环,但 service unit 层面缺少兜底。
解决: 改为 Restart=always,无论退出码如何都重启。Rust 代码的串口重连循环 + systemd 的 always restart 形成双重保障。
10. 镜像没有预装 SSH
首次系统构建时忘了装 openssh,Wyse 5070 只能接键盘显示器操作。后续发现还需要允许 root 密码登录。
解决: pacstrap 加入 openssh,启用 sshd 服务,并设置:
sed -i 's/^#PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_configsystemd 服务配置
[Unit]
Description=Hearth — Wheelchair GPS Tracker
After=network.target systemd-udevd.service
Wants=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/hearth
WorkingDirectory=/usr/local/share/hearth
Environment=SERIAL_PORT=/dev/ttyUSB0
Environment=BAUD=9600
Environment=PORT=3000
Environment=DB_PATH=/var/lib/hearth/hearth.db
Environment=STATIC_DIR=/usr/local/share/hearth/static
Environment=RUST_LOG=info
Restart=always
RestartSec=3
User=hearth
Group=hearth
[Install]
WantedBy=multi-user.targethearth 用户属于 uucp 组以获得串口访问权限。
11. 串口设备名硬编码 /dev/ttyACM0
最初串口路径通过环境变量 SERIAL_PORT 写死为 /dev/ttyACM0。如果 Leonardo 被分配为 ttyACM1(多设备、重新插拔等原因),服务就找不到串口了。
解决: 在 Rust 代码中加入自动探测逻辑:优先找 udev 符号链接 /dev/ttyLeonardo,没有则扫描 /dev/ttyACM* 和 /dev/ttyUSB*,都找不到才回退到默认值。每 3 秒重新探测,Arduino 热插拔自动恢复。
12. 尝试 LFS 极简 initramfs 方案 → 放弃
在 Arch pacstrap 方案稳定运行后,出于学习目的尝试了一个更极端的方案:完全抛弃 systemd 和包管理器,手工构建一个只有 kernel + busybox + hearth 二进制的最小系统,整个 rootfs 打包为 initramfs(cpio.gz),内核启动后直接解压到 tmpfs 运行。
目标是做一个 Ventoy 兼容的安装 ISO:启动 → 进 shell → 运行 install-to-emmc → 安装到内置存储。
遇到的问题链:
busybox init 作为 PID 1 无法打开 /dev/console:initramfs 环境下 devtmpfs 不会自动挂载(
CONFIG_DEVTMPFS_MOUNT=y只对真实 rootfs 生效),busybox init 尝试打开/dev/console失败后静默挂起,表现为内核启动到clocksource: Switched to clocksource tsc后无任何输出。用 GDB attach 到 QEMU 发现所有 CPU 都在 idle。改用rdinit=/bin/sh能进 shell,证明不是内核问题。最终用自写的/initshell 脚本替代 busybox init,手动挂载 devtmpfs。fdisk 写分区表后设备节点不出现:initramfs 环境没有 udevd,fdisk 写完 GPT 后内核不会自动创建
/dev/vda1等分区设备。尝试了blockdev --rereadpt+mdev -s组合,但在 QEMU virtio 磁盘上仍然不稳定,分区设备时有时无。工具链不完整的无底洞:busybox 的
mkfs.vfat、mke2fs、blkid都是精简实现,行为与完整版有微妙差异。每解决一个问题就冒出下一个缺失的工具或不兼容的行为。调试成本极高:没有包管理器意味着每次修改都要重新打 cpio → 重建 ISO → 重启 QEMU,一个循环 30 秒以上。而且 initramfs 里没有 strace、没有完整的 /proc 信息,排错全靠猜。
结论: 学习价值已经获得(理解了 initramfs 的工作原理、PID 1 的职责、devtmpfs 的挂载时机、分区表重读机制),但作为生产方案投入产出比太低。Arch pacstrap 方案虽然镜像大一些(~2GB vs 理论上 <50MB),但 10 分钟就能构建一个完全可用的系统,且有包管理器兜底。
对于 128MB 内存的路由器场景,如果未来真的需要极小镜像,Alpine Linux 是更务实的选择 —— 有包管理器、有 OpenRC、有 musl,但不需要从零手搓每一层。
13. Arduino 中转方案被放弃 → LoRa 直连串口
最初方案:LoRa 模块接 Arduino Leonardo,Leonardo 通过 USB CDC ACM 转发给 Wyse 5070。
LoRa 模块(DX-LR22-433T22D)到手后发现随模块附赠了 CH340 TTL 串口适配器。该模块默认透明传输模式(MODE0),本质就是无线串口延长线——直接把 TTL 适配器插上,/dev/ttyUSB0 就出现了,收发数据完全透明,不需要任何 AT 配置。
Arduino 的唯一作用是做串口桥,现在 TTL 适配器直接承担这个角色,少了一个环节,也少了一个故障点。
改动: SERIAL_PORT 默认值从 /dev/ttyACM0 改为 /dev/ttyUSB0,find_port() 本来就同时扫描 ttyACM* 和 ttyUSB*,代码几乎不用动。
14. ServeDir 相对路径导致前端 404
tower-http 的 ServeDir::new("static") 使用相对路径,实际解析为进程 cwd + static/。hearth 服务由 init 脚本启动,cwd 是 /,而静态文件在 /usr/local/share/hearth/static/,导致所有前端请求返回 404。
解决: 改为通过环境变量 STATIC_DIR 传入绝对路径,默认值 /usr/local/share/hearth/static:
let static_dir = std::env::var("STATIC_DIR")
.unwrap_or("/usr/local/share/hearth/static".into());
// ...
.nest_service("/", ServeDir::new(&static_dir))本地开发时不设置 STATIC_DIR,cargo run 从项目根目录启动,static/ 目录就在旁边,也能正常工作。
