#!/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 || true
    exit 0
}
trap cleanup INT TERM EXIT

############################
# 3. UART 初始化
############################
init_uart() {
    for i in {0..3}; do
        stty -F "${UART_DEV[$i]}" "${UART_BAUD[$i]}" raw -echo \
             ignbrk -icrnl -ixon -opost -isig -icanon
    done
}

############################
# 4. 后台:接收
############################
# UART 接收
for i in {0..3}; do
    {
        while :; do
            read -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_uart
    while :; do
        # UART 循环发送
        for i in {0..3}; do
            printf '%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}; do
            cansend "${CAN_BUS[$i]}" "${CAN_FRAME[$i]}"
            echo "[${CAN_BUS[$i]}] TX ${CAN_FRAME[$i]}"
        done
        sleep 2
    done
}

############################
# 6. 唯一交互:等待 exit
############################
echo ">>> UART/CAN 已启动,输入 exit 退出 <<<"
main_loop &
read -p "" cmd
[[ "$cmd" == "exit" ]] && cleanup



  1. 背景说明
    最近写了一个全功能硬件自检脚本,需要 同时 做三件事:
  2. 周期性 发送 数据到 UART + CAN + GPIO;
  3. 实时 接收 UART/CAN 的回环数据并打印;
  4. 用户敲 exit 时瞬间 干净退出。

后台接收部分虽然只有几行,却最容易踩坑。本文把 UART 和 CAN 的接收逻辑逐行拆透,保证你下次拷代码时知道“为什么非得这么写”。


  1. 先给完整上下文(省流版)
# 1) 串口设备
UART_DEV=(/dev/ttyAS{1..4})
# 2) CAN 接口
CAN_BUS=(can0 can1)

  1. UART 接收——以 /dev/ttyAS1 为例
{
    while :; do
        if read -r -t1 line < "${UART_DEV[0]}"; then
            echo "[tty1] RX $line"
        fi
    done
} &

行号 代码 作用 坑点提示
{ ... } & 整段逻辑丢进 子 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'} 去掉。


  1. 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 一起后台化。 如果不包,管道左右可能分到不同进程组,杀不干净。


  1. 为什么“子 shell + &”是最佳实践?
  • 并发:主脚本初始化/主循环不会被阻塞。
  • 隔离:子 shell 的变量、重定向、工作目录与父脚本完全隔离。
  • 易杀:
  jobs -p | xargs -r kill -9

一条命令即可杀掉 所有 子 shell,避免残留 candump 进程。


  1. 生命周期时序图
启动脚本
   │
   ├─ 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

  1. FAQ 速查

问题 解决
串口读出来末尾带 ^M line=${line%$'\r'}
串口偶尔丢字节 在 stty 里加 raw -echo 关闭回显/规范模式
candump 日志太长刷屏 加 grep -v " 0000000" 过滤空帧
退出时 candump 僵死 确认整段逻辑用 {} 包起来,再 &


  1. 一行总结
    把 接收逻辑 写成 { while read ... } & 的三明治结构,

就能在 shell 里低成本实现 多总线并发监听 + 一键退出,

UART、CAN、SPI、I2C 统统适用。


更多推荐