嵌入式产品最冤的一种坏:所有零件都是好的,软件也都在,就是开机那两秒的顺序不对,整机看起来像砖。这次的现象是烧完新固件"没有显示",最后定位到一个 tmpfs 目录偶发缺失,顺手把 init 脚本改成了三层自愈。这篇一半是排查实录,一半是设计。

一、现象

  • 烧录新固件上电,面板能连上下位机,但歌名、状态全是占位符——面板活、主机"在",但什么都不说;
  • 手动把 /etc/init.d/ 下几个服务 restart 一遍,全部恢复正常;
  • 用户视角是"烧录失败没有显示",真相是主机业务栈全灭。

“手动重启就好、开机必坏”——这个组合几乎直接剧透了答案:开机时序问题。

二、先踩 busybox ps 的坑

串口上去 ps 想看业务进程——空的!第一反应是业务二进制没进固件。翻 rootfs 镜像,二进制好端端都在。三轮误判之后才恍然:这版 busybox 的 ps 只显示挂在 ttyS0 上的进程,daemon 一个都看不见。

教训:查进程一律 /proc 直读——

1
2
3
4
for p in /proc/[0-9]*; do
    c=$(tr '\0' ' ' < $p/cmdline 2>/dev/null)
    [ -n "$c" ] && echo "${p#/proc/} $c"
done | grep -E 'mpd|player'

这台设备的精简 busybox 连 head/tail 都没有,读文件得 dd | hexdump。工具不全的板子上,/proc 是唯一不会骗你的接口。

三、真相链:五张多米诺

  1. /run/media/userdata 不是块设备挂载,是 prepare-runtime(S15)在 /run 这个 tmpfs 里 mkdir 出来的目录;
  2. 开机偶发:业务服务(S94 之后)起来的时候,这个目录还没就绪;
  3. MPD 启动第一件事是打开 /run/media/userdata/mpd.pid.db——打不开,秒崩;
  4. procd 配的是 respawn 3600 5 5:重试 5 次、每次隔 5 秒,然后弃管(procd 不再拉起这个服务);
  5. MPD 死透 → 上层的业务服务或连坐、或等锁,整栈全灭,且永不自愈。

每一环单看都"合理",合起来就是:一个目录晚到两秒,整机假死一辈子。

四、设计:三层自愈

设计目标一句话:服务可以晚启动,不允许死透。

第 1 层:启动前等待——把"崩溃"变"晚启动"

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
USERDATA=/run/media/userdata

wait_userdata() {
    local n=0
    while [ ! -w "$USERDATA" ]; do
        n=$((n + 1))
        [ $((n % 10)) -eq 1 ] && echo "mpd: waiting for $USERDATA (${n}s)" >&2
        [ "$n" -ge 120 ] && {
            echo "mpd: $USERDATA not ready after 120s, start anyway" >&2
            return 0
        }
        sleep 1
    done
}

三个细节:

  • 用 -w(可写)不用 -e(存在):只读 rootfs 上如果有同名假目录,-e 会放行然后照样崩;-w 把这种假目录也拦住。
  • 120 秒超时后放行:等待不能变成永远卡死启动,超时照常启动,让 respawn 层兜底。每层都要有出口。
  • 每 10 秒打一行心跳到 stderr(logread 可见),现场定位一眼看出"卡在等谁"。

第 2 层:无限重生——把"5 次弃管"变"永远再试"

1
procd_set_param respawn 3600 5 0

procd respawn 三个参数是 threshold timeout retry:3600 秒内崩溃视为异常重启、隔 5 秒再拉起、retry=0 表示无限次。OpenWrt 模板里常写 5——对桌面软件合理(防止疯狂刷日志),对无头嵌入式产品,“放弃服务"没有任何好处:反正没有人在现场敲命令重启它。

监控类服务(player-service-monitor)不做等待直接上——缺席检测本身就是它的职责,它不该有前置依赖。

第 3 层:依赖降级不连坐 + 末尾再断言

prepare-runtime 原来是串行 setup,private 分区(持久化增强件)挂载失败会中断整个引导。改成:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
setup() {
    setup_run_tmpfs || {
        echo "prepare-runtime: setup_run_tmpfs FAILED" >&2
        return 1                      # 基础件:失败才拦
    }
    setup_private_partition || {
        echo "prepare-runtime: private partition unavailable (warn, continue)" >&2
    }                                  # 增强件:降级警告
    mkdir -p /run/media/userdata       # 末尾再断言一次
}

原则:基础件与增强件分级,增强件失败绝不连坐基础件。末尾那句看似多余的 mkdir -p 是防"中途被二次挂载遮蔽"的再断言——init 脚本里的幂等语句永远不嫌多。

五、复盘

坏味道 改法
启动即崩 启动前等待(带超时出口)
重试 5 次弃管 respawn retry=0 无限重生
依赖串行连坐 分级 + 降级警告
单点创建无兜底 末尾幂等再断言

还有一条隐藏收获:wait_userdata 的心跳日志成了最便宜的探针——以后任何"这次开机有没有发生等待"的疑问,logread | grep waiting 一行定案。

最后:修完的固件上板,开机两分钟内四个业务服务全部自起,/proc 枚举齐活——一个 init 脚本的改动,让这类"假死"从产品上永久退场。