上一关: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:升级失败必须能退回去

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();
}

三个必须做对的细节(图里也标了):

  1. 确认必须发生在「业务真的跑通之后」,不能在启动第一行就调。否则固件崩溃重启后,它已经自认为健康,永远退不回去——你就得到一个无限重启的死循环。
  2. 掉电保护:写到一半断电 → esp_ota_end 校验失败 → bootloader 拒绝启动该分区 → 自动用旧的。校验不是可选项。
  3. 版本号只能升不能降(除非显式允许)。否则两台设备互相同步会来回刷——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 次全起来
3OTA 双分区 + 回滚故意推一个会崩的固件,验证能退回
4根分区只读 + rename 写配置拔电 20 次,配置不坏
5日志 + 观测面板能看到 RSS / 重启次数 / 数据缺口
630 天连跑开始挂上墙,每天记一行

九、过门标准

三条硬指标,缺一条都不算过:

  • 拔电 → 重插 → 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 行为。代码、四张图、排查清单、排期与过门标准均为原创整理,可直接用于教学。