我们产品需要临时增加一个后门紧急升级功能,包含 uboot 升级、内核 bootargs 环境变量设置,再加上常规 OTA 升级。整个流程由 USB 热插拔自动触发,U 盘插上后自动挂载并执行脚本完成升级。
昨天已经搞定了临时 OTA 升级程序,今天继续调试 USB 脚本触发升级的完整链路。这个功能看着简单,拆下来需求其实不少:要检查版本号做升级过滤,升级时要有特殊标志灯闪烁,升级成功后指示灯常亮提醒产线人员,还要防止反复升级,周末还得拷机压测。
工作环境
- 硬件平台:Ingenic SoC(MIPS 架构)
- 操作系统:嵌入式 Linux
- Shell 环境:/bin/sh(dash)
- 升级程序:usbUpgrade
- 触发方式:USB 热插拔自动挂载执行脚本
- 可用命令:基本 Busybox 工具集,
script、screen 这些高级命令都被裁剪了
开发的大致工作流程如下:
USB插入 → 自动挂载 → 触发脚本A → 调用脚本B → 检测升级文件 → 升级uboot(并设置bootargs) → 执行ota升级程序做常规ota升级
调试过程中遇到了几个问题:
- uboot 的环境变量会概率恢复默认值;
- USB 热插拔识别太快,应用程序还没运行起来,版本号文件没生成,导致脚本不能继续执行;
- 指示灯亮了又被应用程序关掉,没达到预期效果;
- 手动运行脚本一切正常,但 USB 自动挂载启动脚本运行后,升级进程进入"假死"状态。
前三个问题排查完,手动运行脚本也确实顺利完成了预期功能。结果实际测试时,出现了让人抓狂的现象 4。以前也遇到过类似的情况,可能进入进程所在路径再运行就能解决,但这次没这么简单:
在串口终端手动敲 ./advancedsettings.sh&,升级一切正常;但走热插拔脚本自动调用 advancedsettings.sh& 后,usbUpgrade 进程刚起来就退出,一个升级动作都不执行。
排查:从"程序没问题"到"环境不对"
第一步,先确认程序本身。串口手动执行,升级成功。结论:advancedsettings.sh 和 usbUpgrade 本身没问题。
第二步,给脚本 B 加调试。在 ota_upgrade() 里加判断,2 秒后检查进程还在不在:
ota_upgrade()
{
cd "$USBMOUNTPOUNT"
echo "Starting usbUpgrade..."
./usbUpgrade > /tmp/usbUpgrade.log 2>&1 &
UPGRADE_PID=$!
sleep 2
if kill -0 $UPGRADE_PID 2>/dev/null; then
echo "usbUpgrade is running"
else
echo "usbUpgrade exited immediately"
fi
}
结果:
Starting usbUpgrade...
usbUpgrade exited immediately
再看进程和串口打印,是空的,连个报错都没留下——进程起来了,但什么都没干就退了。
=== Process check after 2s ===
1242 root 3380 S grep -E (usbUpgrade|PID1|PID2)
WARNING: nohup process 1207 exited
=== Last 30 lines of ===
=== End of log ===
第三步,手动运行与自动运行对比。最诡异的是,串口手动跑同一个脚本 advancedsettings.sh 是成功的,只有热插拔自动触发时才出问题:
| 执行方式 |
命令 |
结果 |
| 串口手动 |
/tmp/mnt/usb/advancedsettings.sh |
成功 |
| 热插拔自动 |
USB 插入自动触发 |
失败 |
程序和脚本都没问题,那剩下的变量只有一样——执行环境。
环境对比差异性
把两种方式下的环境变量都打出来看了看。
手动执行时(串口终端登录)继承的是一套完整的 shell 环境:
$ env
TERM=xterm
HOME=/
USER=root
SHELL=/bin/sh
PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/app/bin
LD_LIBRARY_PATH=/lib:/usr/lib:/usr/local/lib:/app/lib
PS1='[\u@\h:\w]# '
HUSHLOGIN=FALSE
# ... 后面还有一堆
而热插拔脚本里的环境,就寒酸多了:
$ env
HOME=/
USER=root
SHELL=/bin/sh
PATH=/bin:/sbin:/usr/bin
# TERM、PS1、LD_LIBRARY_PATH……全都没有
差异一目了然。
折腾过的那些方案
问题定位到环境后,我先试了各种"把进程搞成后台守护"的常规操作,结果全部阵亡:
| 方案 |
命令 |
结果 |
| nohup + 重定向 |
nohup ./usbUpgrade >> /dev/console & |
启动即退出,日志空 |
| setsid |
setsid ./usbUpgrade >> /dev/console & |
同样失败(部分嵌入式系统里 setsid 不可用或行为不一样) |
| disown |
./usbUpgrade >> /dev/console & + disown |
disown: not found——dash 没有这个 bash 内置命令 |
| 双重 fork |
( ( ./usbUpgrade >> /dev/console& |
进程依然立即退出 |
| exec |
exec ./usbUpgrade |
脚本进程被替换,程序照样退 |
| script |
script -q -c "./usbUpgrade" /dev/null & |
script: not found——嵌入式系统为了省空间没带 |
| screen |
screen -dmS upgrade ./usbUpgrade |
screen: not found |
一句话总结:把程序往"后台"折腾没用,它需要的不是后台,是一套像样的环境。
最终方案:把环境补齐再启动
思路转变之后,方案反而很简单:启动前把交互式终端的环境变量手动补全,顺便处理掉 SIGHUP 的问题。
ota_upgrade()
{
echo "===== Starting OTA upgrade at $(date) ====="
# 升级各分区
echo "Upgrading mtd0 (boot)..."
flashcp ${USBMOUNTPOINT}/a1n_boot* /dev/mtd0
if [ $? -ne 0 ]; then
echo "ERROR: flashcp failed"
return 1
fi
# 设置内核启动参数
fw_setenv bootargs ......
# 准备升级文件
cp ${USBMOUNTPOINT}/*app*.bin ${USBMOUNTPOINT}/tmp_upgrade.bin
chmod 777 ${USBMOUNTPOINT}/tmp_upgrade.bin
# 进入USB目录
cd "$USBMOUNTPOINT"
echo "Current directory: $(pwd), start to ota upgrade......"
# ===== 关键步骤:设置完整的交互式环境 =====
export TERM=xterm
export HOME=/
export USER=root
export SHELL=/bin/sh
export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/app/bin
export LD_LIBRARY_PATH=/lib:/usr/lib:/usr/local/lib:/app/lib
export PS1='[\u@\h:\w]# '
# 忽略SIGHUP信号,防止父进程退出时被杀死
trap '' HUP
# 启动升级程序(重定向到控制台以便查看输出)
./usbUpgrade > /dev/console &
echo "===== ota upgrade completed ====="
}
三个关键点:
- 环境变量一个不能少。 程序启动时可能会查这些,具体哪个是"压死骆驼的最后一根稻草"没挨个验证,反正全补齐就对了:
| 环境变量 |
作用 |
为什么需要 |
TERM=xterm |
终端类型 |
程序检测终端类型,决定是否用交互特性 |
HOME=/ |
用户家目录 |
程序可能要读配置文件 |
USER=root |
用户名 |
程序可能要校验用户身份 |
PATH |
命令搜索路径 |
程序可能要调用外部命令 |
LD_LIBRARY_PATH |
动态库搜索路径 |
程序可能依赖特定位置的库 |
PS1 |
Shell 提示符 |
程序可能检测 shell 交互模式 |
-
trap '' HUP 比 nohup 靠谱: 忽略 SIGHUP,防止父进程退出时把子进程一起带走,效果上相当于 nohup,但不用依赖那个"被裁掉"的命令。
-
输出打印保留到串口: ./usbUpgrade > /dev/console & 把输出打到系统控制台,方便看程序到底在干嘛;直接 > /dev/null 的话,下次还得抓瞎。
根因分析:手动和自动,用的根本不是同一套环境
手动执行成功,是因为串口终端登录时继承了完整的 shell 环境——TERM、PATH、LD_LIBRARY_PATH 齐全,还挂着 tty:
串口终端登录 → 继承完整shell环境 → TERM/PATH/LD_LIBRARY_PATH/tty 都在
→ usbUpgrade 各项检测通过 → 正常运行
而热插拔脚本是在一个"纯净"环境里起来的——PATH 不完整、LD_LIBRARY_PATH 缺失、没 TERM、没 tty。usbUpgrade 要么在检测终端,要么在加载动态库,要么在找外部命令的时候,直接判断"这里不对劲",然后默默退场:
热插拔脚本 → 简单shell环境 → TERM/PATH/LD_LIBRARY_PATH/tty 全缺
→ usbUpgrade 某项检测失败 → 立即退出
从行为上看,usbUpgrade 大概率是踩了下面某颗雷:
- 检测有没有 tty,没有就退出;
- 检查环境变量,缺 TERM 之类的就拒绝运行;
- 需要动态库路径,LD_LIBRARY_PATH 不对导致库加载失败;
- 调用外部命令,PATH 不完整导致依赖命令找不到。
反正环境补齐之后,问题没了。
经验总结
调试思路:先确认程序本身没问题(手动跑一遍),再给脚本加日志、打印关键变量,然后用 env 对比手动/自动的环境差异,最后逐步排除。嵌入式系统工具集有限,script、screen 都没有,别在这上面浪费时间。
这套坑踩下来,几个要点:
| 嵌入式系统特点 |
影响 |
应对 |
| 空间有限 |
缺高级命令 |
用基础 shell 命令 |
| 环境简陋 |
环境变量缺失 |
启动前手动补齐完整环境 |
| /bin/sh 是 dash |
不支持 bash 特性 |
避免 disown 等 bash 内置命令 |
| 热插拔后台运行 |
父进程退出牵连子进程 |
trap '' HUP + 重定向 |
最后给一个通用模板,遇到类似的"手动能行、自动不行",直接套:
# 1. 设置完整环境变量
setup_environment() {
export TERM=xterm
export HOME=/
export USER=root
export SHELL=/bin/sh
export PATH=/bin:/sbin:/usr/bin:/usr/sbin:/usr/local/bin:/app/bin
export LD_LIBRARY_PATH=/lib:/usr/lib:/usr/local/lib:/app/lib
export PS1='[\u@\h:\w]# '
}
# 2. 忽略SIGHUP信号
trap '' HUP
# 3. 切换到工作目录
cd /path/to/workdir
# 4. 启动程序
./program > /dev/console 2>&1 &
# 5. 验证启动
sleep 2
if pgrep -f "program" > /dev/null 2>&1; then
echo "Program started successfully"
else
echo "Program failed to start"
fi
文末小结
这次的问题根本不是程序 bug,也不是脚本逻辑 bug,而是手动环境和自动环境不一致。usbUpgrade 依赖特定的环境变量和 tty,热插拔脚本给不了,它就直接"摆烂"退出。
调试过程中 AI 贡献了不少思路,但生成的代码通常很臃肿,需要自己做裁剪。一方面要搞清楚脚本中每一行的用途,另一方面也不至于让代码变成超级屎山。
如果你也遇到过嵌入式 Linux 下"手动正常、自动异常"的诡异问题,不妨先在 云栈社区 上搜索一下类似案例,环境变量对比这个方法,在排查热插拔、定时任务、开机自启等场景下的程序异常退出问题时,往往能一击命中。