把 shell 脚本里的「后台接收」-- 以 UART/CAN 双总线监听为例
#!/bin/bash
set -euo pipefail
shopt -s lastpipe############################
# 1. 固定配置
############################
UART_DEV=(/dev/ttyAS{1..4})
UART_BAUD=(9600 115200 57600 921600)
UART_TX_LINE=("UART1_LOOP_9600""UART2_LOOP_115200""UART3_LOOP_57600""UART4_LOOP_921600"
)
CAN_BUS=(can0 can1)
CAN_FRAME=("12345678#AABBCCDDEEFF0011""87654321#FFEEDDCCBBAA9988"
)############################
# 2. 清理函数
############################
cleanup() {echo; echo ">>> 收到 exit,清理后台任务 ..."jobs -p | xargs -r kill -9 2>/dev/null || trueexit 0
}
trap cleanup INT TERM EXIT############################
# 3. UART 初始化
############################
init_uart() {for i in {0..3}; dostty -F "${UART_DEV[$i]}" "${UART_BAUD[$i]}" raw -echo \ignbrk -icrnl -ixon -opost -isig -icanondone
}############################
# 4. 后台:接收
############################
# UART 接收
for i in {0..3}; do{while :; doread -r -t1 line < "${UART_DEV[$i]}" && echo "[tty$((i+1))] RX $line"done} &
done
# CAN 接收
for bus in "${CAN_BUS[@]}"; do{ candump -L "$bus" | while read -r f; do echo "[$bus] RX $f"; done; } &
done############################
# 5. 主循环:定时发送
############################
main_loop() {init_uartwhile :; do# UART 循环发送for i in {0..3}; doprintf '%s\r\n' "${UART_TX_LINE[$i]}" > "${UART_DEV[$i]}"echo "[tty$((i+1))] TX ${UART_TX_LINE[$i]}"done# CAN 扩展帧循环发送for i in {0..1}; docansend "${CAN_BUS[$i]}" "${CAN_FRAME[$i]}"echo "[${CAN_BUS[$i]}] TX ${CAN_FRAME[$i]}"donesleep 2done
}############################
# 6. 唯一交互:等待 exit
############################
echo ">>> UART/CAN 已启动,输入 exit 退出 <<<"
main_loop &
read -p "" cmd
[[ "$cmd" == "exit" ]] && cleanup
- 背景说明
最近写了一个全功能硬件自检脚本,需要 同时 做三件事: - 周期性 发送 数据到 UART + CAN + GPIO;
- 实时 接收 UART/CAN 的回环数据并打印;
- 用户敲
exit
时瞬间 干净退出。
后台接收部分虽然只有几行,却最容易踩坑。本文把 UART 和 CAN 的接收逻辑逐行拆透,保证你下次拷代码时知道“为什么非得这么写”。
- 先给完整上下文(省流版)
# 1) 串口设备
UART_DEV=(/dev/ttyAS{1..4})
# 2) CAN 接口
CAN_BUS=(can0 can1)
- UART 接收——以
/dev/ttyAS1
为例
{while :; doif read -r -t1 line < "${UART_DEV[0]}"; thenecho "[tty1] RX $line"fidone
} &
行号 代码 作用 坑点提示
① { ... } &
整段逻辑丢进 子 shell 后台任务,主脚本继续往下跑。 不加 {}
的话 &
只会把最后一条命令后台化。
② while :
死循环,保持“永远在线”。 如果写 while read
,一旦串口没数据就直接退出,监听就断了。
③ read -r -t1 line < "${UART_DEV[0]}"
-t1
:最多等 1 秒,无数据立刻返回非 0;-r
:禁用反斜杠转义;<
:重定向文件描述符。 去掉 -t1
会导致 read
永远阻塞,脚本退出时杀不掉。
④ if ... then echo
只有真正读到内容才打印,避免空行刷屏。 不加判断会把空行也打出来。
⑤ echo "[tty1] RX $line"
统一前缀,方便肉眼 grep。 如果设备发 \r\n
,$line
末尾会带 \r
,可 line=${line%$'\r'}
去掉。
- CAN 接收——以
can0
为例
{ candump -L can0 | while read -r frame; do echo "[can0] RX $frame"; done; } &
行号 代码 作用 坑点提示
① candump -L can0
-L
单行日志格式:timestamp id#data
。 不加 -L
会多行输出,不好 read
。
② while read -r frame
从管道里逐行读。 在子 shell 里,while
循环结束后 candump
也会被杀,干净。
③ echo "[can0] RX $frame"
加前缀,和 UART 日志对齐。 —
④ 整体再包一次 { ... } &
整个 candump+while 一起后台化。 如果不包,管道左右可能分到不同进程组,杀不干净。
- 为什么“子 shell +
&
”是最佳实践?
- 并发:主脚本初始化/主循环不会被阻塞。
- 隔离:子 shell 的变量、重定向、工作目录与父脚本完全隔离。
- 易杀:
jobs -p | xargs -r kill -9
一条命令即可杀掉 所有 子 shell,避免残留 candump 进程。
- 生命周期时序图
启动脚本│├─ fork 子 shell 1 → UART 接收 (ttyAS1)├─ fork 子 shell 2 → UART 接收 (ttyAS2)├─ fork 子 shell 3 → UART 接收 (ttyAS3)├─ fork 子 shell 4 → UART 接收 (ttyAS4)├─ fork 子 shell 5 → candump + while (can0)├─ fork 子 shell 6 → candump + while (can1)│└─ 父进程进入 main_loop,做 UART/CAN/GPIO 发送↑
用户敲 exit → cleanup() → kill -9 所有子 shell
- FAQ 速查
问题 解决
串口读出来末尾带 ^M
line=${line%$'\r'}
串口偶尔丢字节 在 stty
里加 raw -echo
关闭回显/规范模式
candump 日志太长刷屏 加 grep -v " 0000000"
过滤空帧
退出时 candump 僵死 确认整段逻辑用 {}
包起来,再 &
- 一行总结
把 接收逻辑 写成{ while read ... } &
的三明治结构,
就能在 shell 里低成本实现 多总线并发监听 + 一键退出,
UART、CAN、SPI、I2C 统统适用。