上一关:S3:网关 + 多节点 这一篇:只做 S4 —— 工程化 + 长期运行。这是整条路线的最后一关。 写给谁:S3 已经做出「一网关 + 三节点」能跑的系统的人。 耗时:4–6 周。收口标志:连续跑 30 天不重启。
先说结论:这一关分界的不是「技术」,是「责任」
前三关的标准是「功能对不对」——灯亮不亮、数据准不准、断线能不能重连。
这一关的标准变成「别人用的时候会不会出问题」。这两个标准之间隔着的不是新知识,是你想到了几种死法。
业余作品和生产成品的分界线就在这:
| 业余作品 | 生产成品 |
|---|---|
| 演示时工作 | 演示之外的一万小时也工作 |
| 出错重启一下 | 出错自己恢复,并且留下线索 |
| 「应该没问题」 | 拔电 20 次,每次都起来 |
| 出问题现场去看 | 出问题远程就能查 |
这一关要做的六件事,每一件都是「为没发生的事做准备」——所以在没人催的情况下最难坚持。
一、交叉编译:为什么不能「在板子上编译」
树莓派上能直接 gcc,为什么还要交叉编译?
因为绝大多数嵌入式设备根本装不下编译器。 ESP32 有几 MB flash、几百 KB RAM,gcc 的一个 cc1 就几十 MB。
即使树莓派装得下,也有三个现实理由:
| 理由 | 说明 |
|---|---|
| 速度 | 在 Pi 上编译大型项目是几分钟到几十分钟;在 PC 上是几秒 |
| 一致性 | 团队里每个人编出来的二进制要一样;板子上装的东西各不相同 |
| 发布 | 编译产物要能打进镜像、批量刷机,而不是每台机器手动编一次 |
1.1 最小可用的交叉编译
在 Ubuntu 上给树莓派(64 位)编一个程序:
# 装工具链
sudo apt install -y gcc-aarch64-linux-gnu
# 一条命令搞定
aarch64-linux-gnu-gcc -std=c11 -Wall -Wextra -Werror -O2 \
-o gw-collector gw-collector.c -lmosquitto
# 确认架构对不对(关键一步,别跳)
file gw-collector
# 期望:ELF 64-bit LSB executable, ARM aarch64
最常见的错:编出来的还是 x86 的。 因为有 90% 的人会顺手敲 gcc 而不是 aarch64-linux-gnu-gcc。file 那一步就是防这个的。
1.2 真正麻烦的是「库」
自己写的代码好办,难的是依赖库。你在 PC 上 -lmosquitto 找的是 x86 的库,交叉编译时找不到 aarch64 的版本。
三条路,按推荐顺序:
① 在板子上装依赖,把 .so 拷回来(最省事,适合学习)
# 板子上
dpkg -L libmosquitto1 | grep '\.so'
# PC 上
scp pi:/usr/lib/aarch64-linux-gnu/libmosquitto.so.1 ./libs/
aarch64-linux-gnu-gcc -o app app.c -L./libs -lmosquitto
② 用 sysroot(正规模板)
# 把板子的 /usr/include 和 /usr/lib 整个同步到 PC
rsync -av pi:/usr/include/ /opt/rpi-sysroot/usr/include/
rsync -av pi:/usr/lib/aarch64-linux-gnu/ /opt/rpi-sysroot/usr/lib/aarch64-linux-gnu/
aarch64-linux-gnu-gcc --sysroot=/opt/rpi-sysroot -o app app.c -lmosquitto
③ 用 Buildroot / Yocto 整个重编系统(正規做法,但这一关不建议)
教学上必须说清楚:Buildroot/Yocto 是做产品的路子,一旦用上,整个系统都由你自己维护——好处是完全可控、可裁剪,坏处是你得自己负责安全更新。
学习阶段不要碰。 知道「有这个东西、什么时候该用」就够了。用在错误阶段,会把 4 周时间全花在配编译器上,一行业务代码没写。
1.3 一条必须遵守的规矩
交叉编译后,必须在真机上跑一次。
听起来是废话。但真实情况是:编译通过、架构正确、file 显示 aarch64,运行时报 Illegal instruction——因为你用的 CPU 特性(比如 NEON 的某条指令)板子不支持。
编译器不知道你的板子是哪一款。 它只知道架构。这个 gap 只能靠真机验证。
二、看门狗:三层,缺一层都不行
这是 S4 最重要的一张图。三层各自防不同的死法,不能互相替代。
2.1 第 1 层:硬件看门狗
防的是:程序卡死、中断风暴、主循环饿死。
原理很简单:一个硬件计数器,超时没被"喂"就复位整颗芯片。
/* ESP32 任务看门狗(Task WDT) */
#include "esp_task_wdt.h"
#define WDT_TIMEOUT_S 10
void app_main(void)
{
/* 初始化:超时 10 秒 */
esp_task_wdt_init(WDT_TIMEOUT_S, true); /* true = 超时触发 panic 重启 */
esp_task_wdt_add(NULL); /* 把自己这个任务加进去 */
for (;;) {
do_work();
esp_task_wdt_reset(); /* 喂狗:必须在业务真正做完之后 */
vTaskDelay(pdMS_TO_TICKS(100));
}
}
Linux 侧的硬件看门狗:
# 树莓派启用硬件看门狗
echo 'dtparam=watchdog=on' | sudo tee -a /boot/firmware/config.txt
sudo systemctl enable watchdog
# 应用层用 /dev/watchdog
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#include <linux/watchdog.h>
int wdt_open(int timeout_s)
{
int fd = open("/dev/watchdog", O_WRONLY);
if (fd < 0) return -1;
ioctl(fd, WDIOC_SETTIMEOUT, &timeout_s);
return fd;
}
/* 主循环里定期调用 */
void wdt_feed(int fd) { write(fd, "x", 1); }
⚠️
/dev/watchdog一旦 open,就进入"武装状态"。 想安全退出(比如调试)必须写一个特殊字符V再 close,否则一 close 就会复位。 我第一次调试时Ctrl+C退出程序,板子直接重启——以为是程序 bug,其实是看门狗。
2.2 最关键的坑:喂狗的位置
/* ❌ 错误:在循环顶部无脑喂 */
for (;;) {
esp_task_wdt_reset(); /* 只要循环转着就一直喂 */
do_work(); /* ← 这里卡死 10 分钟也照喂不误 */
}
/* ✅ 正确:业务真的完成之后才喂 */
for (;;) {
int rc = do_work(); /* 卡在这里 → 没人喂 → 看门狗复位 */
if (rc == 0) esp_task_wdt_reset();
}
这个区别是看门狗的全部意义所在。 无脑喂狗等于把看门狗关掉,还多烧一点电。
判据:喂狗那行代码,必须能保证"喂之前业务确实跑通了"。
2.3 第 2 层:软件看门狗(systemd)
防的是:进程还活着,但业务不动了。
比如程序卡在 recv() 等一个永远不来的数据——进程没死,硬件看门狗也没超时(因为循环还在转,还在喂),但服务已经废了。
这时候用 systemd 的 WatchdogSec:
# /etc/systemd/system/gw-collector.service
[Unit]
Description=Gateway MQTT collector
After=network-online.target mosquitto.service
Wants=network-online.target
[Service]
Type=notify
ExecStart=/opt/gw/bin/gw-collector
Restart=always
RestartSec=3
# 软件看门狗:60 秒内没有 sd_notify(WATCHDOG=1) 就判定卡死
WatchdogSec=60
[Install]
WantedBy=multi-user.target
程序里必须配合:
#include <systemd/sd-daemon.h>
int main(void)
{
/* 告诉 systemd:我起来了 */
sd_notify(0, "READY=1");
for (;;) {
int rc = poll_and_forward(); /* 真正的业务 */
/* 只有业务成功才告诉 systemd「我还健康」 */
if (rc == 0) {
sd_notify(0, "WATCHDOG=1");
}
usleep(10 * 1000);
}
}
注意 Type=notify:不加这个,sd_notify 是空操作,WatchdogSec 也不会生效。
这一层救的是「僵而不死」。 硬件看门狗救不了它——因为进程还在转、还在喂狗。只有业务层自己知道「我卡住了」。
2.4 第 3 层:外部监控(心跳)
防的是:整机失联、断电、系统起不来。
前面两层都要求系统还能跑。断电了、SD 卡坏了、内核 panic 了,看门狗也救不了。
只能靠另一台机器看着它:
# 树莓派每分钟发一次心跳
*/1 * * * * curl -s -m 5 -X POST http://gateway.local/api/hb \
-d "dev=$(hostname)&ts=$(date +%s)&up=$(uptime -s)"
网关侧判断:
# 概念示意:连续 3 次没收到 → 判定离线
if now - last_seen > 180:
alert(f"{dev} 失联超过 3 分钟")
最土但最有效的救法:智能插座。
UPS 太贵,但一个 ¥30 的智能插座可以做到「远程断电 10 秒再上电」。设备装在墙上、系统起不来的时候,这是唯一还能用的手段。
别小看这一层。 我见过太多"跑了一个月好好的",然后某天断电后起不来,人不在现场,只能等下次过去。
三、OTA:升级失败必须能退回去
「升级成功」的定义不是「固件写完了」,是「新固件跑起来了并且自报健康」。
3.1 双分区的工作方式
ESP32 的 flash 里有两块应用分区 app0 / app1,otadata 记录哪个是有效的。
#include "esp_ota_ops.h"
#include "esp_http_client.h"
esp_err_t ota_update(const char *url)
{
const esp_partition_t *running = esp_ota_get_running_partition();
const esp_partition_t *update = esp_ota_get_next_update_partition(NULL);
if (!update) return ESP_ERR_NOT_FOUND;
esp_ota_handle_t handle = 0;
esp_err_t err = esp_ota_begin(update, OTA_SIZE_UNKNOWN, &handle);
if (err != ESP_OK) return err;
/* 下载并分块写入(不要一次读进内存) */
esp_http_client_config_t cfg = { .url = url };
esp_http_client_handle_t cli = esp_http_client_init(&cfg);
esp_http_client_open(cli, 0);
char buf[1024];
int total = 0;
while (1) {
int n = esp_http_client_read(cli, buf, sizeof(buf));
if (n < 0) { esp_ota_abort(handle); return ESP_FAIL; }
if (n == 0) break;
if ((err = esp_ota_write(handle, buf, n)) != ESP_OK) {
esp_ota_abort(handle);
return err;
}
total += n;
}
esp_http_client_close(cli);
esp_http_client_cleanup(cli);
/* 这一步会校验镜像完整性 —— 不能省 */
if ((err = esp_ota_end(handle)) != ESP_OK) return err;
/* 切换启动分区,但还没确认 */
if ((err = esp_ota_set_boot_partition(update)) != ESP_OK) return err;
ESP_LOGI("OTA", "written %d bytes, rebooting", total);
esp_restart();
return ESP_OK;
}
3.2 最关键的一步:确认(这才是「回滚」的开关)
新固件启动后,必须先做自检,自检通过才调 esp_ota_mark_app_valid_cancel_rollback():
void app_main(void)
{
/* 1. 启动后先看自己是不是「未确认」的新固件 */
const esp_partition_t *running = esp_ota_get_running_partition();
esp_ota_img_states_t state;
bool pending_verify = false;
if (esp_ota_get_state_partition(running, &state) == ESP_OK) {
if (state == ESP_OTA_IMG_PENDING_VERIFY) pending_verify = true;
}
/* 2. 做真正的自检:连网、连 Broker、读一次传感器 */
bool healthy = false;
for (int i = 0; i < 5 && !healthy; i++) {
healthy = (wifi_connect() == 0) &&
(mqtt_connect() == 0) &&
(sht30_probe() == 0);
if (!healthy) vTaskDelay(pdMS_TO_TICKS(2000));
}
/* 3. 自检通过 → 确认;否则不确认,等看门狗复位后自动回滚 */
if (pending_verify) {
if (healthy) {
esp_ota_mark_app_valid_cancel_rollback();
ESP_LOGI("OTA", "new firmware confirmed");
} else {
ESP_LOGE("OTA", "self-check failed, will rollback on reset");
/* 故意不喂狗,等它复位回旧分区 */
while (1) { vTaskDelay(pdMS_TO_TICKS(1000)); }
}
}
normal_business_loop();
}
三个必须做对的细节(图里也标了):
- 确认必须发生在「业务真的跑通之后」,不能在启动第一行就调。否则固件崩溃重启后,它已经自认为健康,永远退不回去——你就得到一个无限重启的死循环。
- 掉电保护:写到一半断电 →
esp_ota_end校验失败 → bootloader 拒绝启动该分区 → 自动用旧的。校验不是可选项。 - 版本号只能升不能降(除非显式允许)。否则两台设备互相同步会来回刷——A 推 v2 给 B,B 推 v1 给 A,网络一直在传固件。
3.3 树莓派侧:没有双分区,怎么退
树莓派通常只有一个根分区,OTA 靠 A/B 镜像 + 符号链接:
/opt/app/releases/20261101-1/ ← 旧版
/opt/app/releases/20261102-1/ ← 新版
/opt/app/current -> releases/20261102-1 ← 符号链接
升级流程:解压到新目录 → 改符号链接(ln -sfn)→ 重启服务 → 自检通过才算数,否则把链接改回去。
关键点:ln -sfn 是原子操作,断电不会出现「链接指向半个目录」。而直接 cp 覆盖则不是。
四、断电恢复:哪些东西会坏
不是「断电会丢数据」这么笼统。断电的那一瞬间,具体会发生四件事,每件事有对应的防线。
4.1 防线一:根分区只读
这是最有效的一招,一次性解决「写一半」和「元数据不一致」:
# /etc/fstab
/dev/mmcblk0p1 /boot/firmware vfat ro 0 1
/dev/mmcblk0p2 / ext4 ro 0 1
tmpfs /var/log tmpfs defaults,noatime,size=64M 0 0
tmpfs /tmp tmpfs defaults,noatime,size=64M 0 0
/dev/mmcblk0p3 /data ext4 rw,noatime 0 2
根分区只读之后,断电时没有任何东西在写系统文件,就不可能损坏。
要写的东西全部挪到:
/tmp、/var/log→ tmpfs(内存,重启清空)/data→ 单独的小分区,只放真正需要持久化的(配置、数据、日志归档)
⚠️ 只读根分区后,
apt install会失败。 需要临时切回读写:sudo mount -o remount,rw /→ 装完 →sudo mount -o remount,ro /
4.2 防线二:数据用「整文件替换」而不是「原地改」
/* ❌ 原地改:写到一半断电 → 配置文件损坏 → 启动读不出来 */
FILE *f = fopen("/data/config.json", "w");
fprintf(f, ...); /* ← 此刻断电,文件可能只有一半 */
fclose(f);
/* ✅ 写临时文件 + fsync + rename(rename 是原子操作) */
FILE *f = fopen("/data/config.json.tmp", "w");
fprintf(f, ...);
fflush(f);
fsync(fileno(f)); /* ← 关键:强制落盘 */
fclose(f);
rename("/data/config.json.tmp", "/data/config.json"); /* 原子替换 */
rename() 在同一个文件系统内是原子的。 断电时要么是旧文件,要么是新文件,不会出现半个文件。
日志同理,用「追加写 + 定期切换」,不要原地改中间字节。
4.3 防线三:fsync 要放在「对外承诺之前」
/* 顺序错了,断电后「已确认」的数据其实没落盘 */
write(file, data, len);
fsync(file); /* ← 必须在回 ACK 之前 */
send_ack_to_server();
这是分布式系统里最经典的一类 bug:你以为数据存好了才告诉对方"收到",实际上数据还在页缓存里,断电后消失。
4.4 防线四:启动不能等
断电恢复的另一个死法不是数据坏,是起不来:
/* ❌ 没有超时的等网络:网络不通就永远卡在这 */
while (wifi_connect() != 0) { sleep(1); }
/* ✅ 带超时,且失败也要继续跑(降级模式) */
int has_net = 0;
for (int i = 0; i < 30; i++) {
if (wifi_connect() == 0) { has_net = 1; break; }
sleep(1);
}
if (!has_net) {
LOG_WARN("no network, enter degraded mode");
/* 继续跑:先采集并缓存,等网络恢复再补发 */
}
原则:任何一个「等」的地方都必须有超时。 长期运行的系统里,「无限等待」等于「永久宕机」。
4.5 实测口径(这一条比读文档有用)
拔电源(不是关机命令),重复 20 次,每次都检查:
- 60 秒内起来的次数 = 20/20
- 配置文件可读、内容正确
- 缓存的数据没丢(或按策略丢,但能说清丢了哪些)
- 日志能看出「上次是异常掉电」(不能静默)
用关机命令测是无效的——那走的是正常流程,断电场景根本没被触发。
五、日志与可观测性:出问题时你只有它
5.1 日志的三条硬要求
① 时间戳用 UTC + 固定格式
2026-11-02T14:31:02Z
不要用本地时间。理由:夏令时、时区改动、多设备跨区对时——你事后要按时间线对齐三个设备的日志,本地时间会毁掉这件事。
② 带上设备 ID 和复位原因
#include "esp_system.h"
void log_boot_banner(void)
{
esp_reset_reason_t r = esp_reset_reason();
const char *why =
(r == ESP_RST_POWERON) ? "power-on" :
(r == ESP_RST_WDT) ? "watchdog" :
(r == ESP_RST_PANIC) ? "panic" :
(r == ESP_RST_BROWNOUT) ? "brownout" : "other";
ESP_LOGI("BOOT", "dev=%s reset_reason=%s", NODE_ID, why);
}
这一行能省掉几小时的猜测。 看到 reset_reason=brownout 就知道是供电问题,不是代码问题。
③ 日志必须落到不丢的地方
/* ❌ 重启就没了,等于没写 */
freopen("/tmp/app.log", "a", stdout);
/* ✅ 落到 data 分区,且轮转 */
轮转很简单:
# logrotate 或自己写
if [ $(stat -c%s /data/app.log) -gt 1048576 ]; then
mv /data/app.log /data/app.log.1
fi
5.2 该看哪几个数(30 天的征兆都在趋势里)
| 指标 | 正常 | 危险信号 | 说明 |
|---|---|---|---|
| 内存 RSS | 平稳 | 单调缓涨 | 30 天最典型的死法:内存泄漏 |
| 重启次数 | 0 | > 0 | 只要不是 0,就要问「为什么」 |
| 磁盘可用 | 平稳 | 单调下降 | 写太多,会磨穿 SD 卡 |
| 数据缺口 | 0 | > 0 | 断线补发是否真的生效 |
注意「缓涨」这个词。 内存泄漏不会让程序立刻崩,它每天涨 1MB,30 天后 OOM——单次看数值完全正常,只有看趋势才能发现。
# 每天记一行,30 天后一眼看出来
echo "$(date +%F) $(ps -o rss= -p $(pgrep gw-collector))" >> /data/rss.log
六、安全:三条不能省的
长期跑在墙上的设备,安全不是加分项,是及格线。
6.1 不要明文 MQTT
/* ❌ 局域网也别裸跑,尤其经过 WiFi */
.broker.address.uri = "mqtt://192.168.1.10:1883"
/* ✅ TLS */
.broker.address.uri = "mqtts://192.168.1.10:8883",
.broker.verification.certificate = ca_pem_start,
局域网也要——WiFi 是广播介质,邻居抓得到。
6.2 每台设备的凭据必须不同
/* ❌ 所有设备一个密码,泄露一台等于全泄 */
.credentials.username = "admin",
.credentials.authentication.password = "123456",
/* ✅ 一机一密,出厂烧进去 */
.credentials.client_id = NODE_ID,
.credentials.authentication.password = device_secret_from_nvs(NODE_ID),
6.3 私钥绝不进代码仓库
/* ❌ 提交到 git = 永久泄露(历史里删不掉) */
const char *priv_key = "-----BEGIN PRIVATE KEY-----\nMIIE...";
/* ✅ 烧进 NVS / 单独的安全分区 */
esp_err_t load_key_from_nvs(char *out, size_t out_len);
一条经验:写进代码里的密钥,等于公开发布。 GitHub 上的爬虫几分钟内就会扫到。
七、排查清单
S4 的问题几乎全是「偶发」和「时间相关」的,所以按现象查比按代码查有效。
| 症状 | 最可能原因 | 怎么确认 |
|---|---|---|
| 跑几天就崩 | 内存泄漏 | 每 6 小时记 RSS,看是否单调涨 |
| 跑几天就崩 | 日志文件撑满磁盘 | df -h,检查轮转有没有生效 |
| 偶发重启 | 看门狗误触发 | 看 reset_reason=watchdog + 喂狗位置 |
| 偶发重启 | 电源电压跌落 | reset_reason=brownout,换电源 |
| 一直重启起不来 | OTA 新固件崩溃且已确认 | 检查是否在启动时就调了 confirm |
| 断电后起不来 | 配置文件损坏 | 检查是不是原地写而非 rename |
| 断电后起不来 | 启动时无限等网络 | 检查所有 while(...) 有没有超时 |
| SD 卡变只读 | 磨损到上限 | dmesg | grep mmc,会报 I/O error |
| 升级后功能异常但不重启 | 没做自检 | 自检必须包含业务级验证 |
| 服务"活着"但没数据 | 僵而不死 | systemctl status 看 watchdog 超时记录 |
| 整机失联,重插才好 | 没有外部心跳/断电手段 | 加心跳 + 智能插座 |
| 交叉编译的跑不了 | 用了板子不支持的指令集 | 真机跑一次;-mcpu 指定正确型号 |
八、6 周怎么排
| 周 | 目标 | 手上要出的东西 |
|---|---|---|
| 1 | 交叉编译跑通 + file 验证 | 板上跑的是 PC 编出来的二进制 |
| 2 | 三层看门狗 | kill -9 能自动恢复;拔电 20 次全起来 |
| 3 | OTA 双分区 + 回滚 | 故意推一个会崩的固件,验证能退回 |
| 4 | 根分区只读 + rename 写配置 | 拔电 20 次,配置不坏 |
| 5 | 日志 + 观测面板 | 能看到 RSS / 重启次数 / 数据缺口 |
| 6 | 30 天连跑开始 | 挂上墙,每天记一行 |
九、过门标准
三条硬指标,缺一条都不算过:
- 拔电 → 重插 → 60 秒内全部自动恢复(重复 20 次,20/20)
- 能远程看日志、能远程升级,且升级失败能自动退回
- 连续跑 30 天不重启;中途有一次
kill -9能自愈
答辩标准(这一条是真正的毕业线):
- 能讲清每一个设计决策的取舍:为什么用 rename 而不是直接写、为什么喂狗放在业务之后、为什么命令主题设
retain=0而状态设retain=1、为什么重连要加抖动 - 能说清这条系统最可能怎么死,以及你为那种死法准备了什么
「能答辩」的意思是:换一个人接手,看你的日志和代码,能明白你为什么这么设计。 做不到这一点,就是「碰巧能跑」,不是「工程化」。
十、整条路线的收尾
S4 做完,你手上是一个装在墙上、跑三十天、出了事自己能恢复并且留下线索的系统。
回头看一眼这五关的递进:
| 关 | 你获得的 |
|---|---|
| S0 | 敢碰硬件,懂「物理世界不讲道理」 |
| S1 | 会用 C 表达抽象(类/接口/生命周期的 C 版本) |
| S2 | 没有操作系统也能干活,理解「可观测性要自己建立」 |
| S3 | 脑和手脚接起来,理解「分布式=异常处理」 |
| S4 | 能从「能跑」推到「能交付」 |
这五关真正教的东西只有一件:
不假设一切正常。
假设网会断、电会停、卡会坏、固件会崩、内存会漏、别人会乱按。 然后为每一种都准备一条退路。
这句话在软件里也成立——你在 28 天 Linux 课里学的是「让机器听你的话」,这五关学的是「机器不听话的时候怎么办」。
参考
ESP-IDF 的 OTA / 看门狗 / 分区表 API、systemd WatchdogSec 与 sd_notify、Linux watchdog(4)、MQTT over TLS 均依据各自官方文档;文件系统原子替换与 fsync 语义依据 POSIX 与 ext4 行为。代码、四张图、排查清单、排期与过门标准均为原创整理,可直接用于教学。