1. 为什么要在 Shell 里直接操作串口很多人第一次接触串口调试习惯性打开图形化串口助手点开下拉框选端口、设波特率、敲几个字符看回显。这套流程在临时验证时确实顺手但一旦进入批量测试、长时间压测、自动化产线校验或者远程设备巡检的场景图形界面就成了累赘——你没法让一台无人值守的机器去点鼠标也没法把打开助手、选端口、发指令、截图保存这套动作写进流水线。Shell 脚本操作串口解决的正是这个断层。它把串口当成一个普通的字符设备文件来读写用最朴素的echo、cat、printf加上stty配置就能完成打开端口、设定通信参数、收发数据、解析回包的全流程。核心关键词就三个串口设备文件、stty 参数配置、重定向收发。听起来简单但真正落地时会遇到一堆图形工具帮你屏蔽掉的细节——权限、缓冲、超时、二进制数据被截断、回显干扰、端口被占用等等。这篇内容适合三类人一是做嵌入式或硬件测试、需要把串口操作脚本化的工程师二是在产线或实验室里要跑批量设备校验、想摆脱手工点界面的同学三是有一定 Shell 基础、想搞清楚字符设备到底怎么用脚本读写的开发者。我会从设备文件的基本认知讲起把stty的每个关键参数为什么这么设讲透再给出可直接抄作业的收发脚本最后重点聊那些只有真跑过才会踩到的坑。全程不依赖任何图形工具纯命令行环境即可复现。需要先说明一点不同系统上串口的设备节点命名不一样Linux 下常见/dev/ttyUSB0、/dev/ttyS0、/dev/ttyACM0macOS 下是/dev/tty.usbserial-xxxx这类。下文统一用/dev/ttyUSB0作为示例占位你替换成自己机器上实际枚举出来的节点即可。枚举方法后面会讲。2. 串口在 Shell 眼里到底是什么2.1 字符设备文件一切操作的起点在 Shell 的世界里串口不是一个程序而是一个字符设备文件。你用ls -l /dev/ttyUSB0看一眼会看到类似crw-rw----的权限位开头那个c就代表 character device。这意味着你可以像读写普通文件一样对它做open、read、write、close内核里的串口驱动会把这些系统调用翻译成真正的电平信号发到 TX 引脚上。这个认知非常关键因为它直接决定了脚本的写法。你不需要任何专用库echo AT /dev/ttyUSB0这一行就已经在往串口发数据了。反过来cat /dev/ttyUSB0就是在持续读取串口收到的字节。所有的高级串口操作本质上都是在这两个动作上加参数、加超时、加解析。但字符设备有个和普通文件不一样的地方它没有文件末尾的概念。普通文件读到结尾会返回 EOFcat就退出了串口设备只要你没关它就一直等着cat会永远挂在那里。这就是为什么直接cat串口经常卡住不动——不是坏了是它在等下一个字节。理解这一点后面所有关于超时和读取策略的设计就都顺理成章了。2.2 枚举端口先搞清楚要操作谁动手写脚本前第一步永远是确认设备节点。Linux 下最直接的办法是看内核日志和 sysfs# 插拔设备前后对比看新增了哪个 tty 节点 dmesg | grep -i tty | tail -20 # 列出所有 USB 转串口设备及其对应节点 ls -l /sys/class/tty/*/device/driver 2/dev/null # 更直观的方式按厂商信息过滤 ls /dev/serial/by-id//dev/serial/by-id/这个目录特别值得记住。它里面的名字包含了设备的厂商、型号和序列号比如usb-FTDI_FT232R_USB_UART_A12345XY-if00-port0。用这个路径而不是/dev/ttyUSB0的好处是设备号会随插拔顺序变化但 by-id 的名字是稳定的。当你的机器上同时插了好几个串口设备或者脚本要在不同机器上跑用 by-id 能避免今天 ttyUSB0 是设备 A明天变成设备 B的错乱。macOS 下对应的是ls /dev/tty.*和ls /dev/cu.*。这里有个容易混淆的点tty.*会等待载波信号DCDcu.*call-up不会。做脚本收发时优先用cu.*否则某些没有拉 DCD 的设备会让你一直阻塞在打开阶段。2.3 权限问题为什么普通用户打不开第一次跑脚本十有八九会遇到Permission denied。原因是串口设备默认属于dialout组部分发行版是uucp组普通用户不在这个组里就没有读写权限。# 查看当前用户所属组 groups # 把用户加入 dialout 组Debian/Ubuntu 系 sudo usermod -aG dialout $USER # 生效需要重新登录或者临时用 newgrp 切换 newgrp dialout这里有个实操心得usermod加组之后必须重新登录会话才生效很多人加完组发现还是没权限就是因为当前 shell 的组信息是登录时快照的。临时验证可以用newgrp dialout开个子 shell但长期还是老老实实重新登录。另外别图省事直接chmod 777 /dev/ttyUSB0设备重新插拔后权限会重置而且这属于把安全边界拆了不推荐。3. stty把串口参数配对才是真功夫3.1 波特率和帧格式一次配错全盘皆输串口通信最基础也最容易翻车的就是参数匹配。收发双方必须在波特率、数据位、校验位、停止位、流控这五项上完全一致差一个都收不到正确数据。stty就是干这个的。# 典型配置115200 波特率8 数据位无校验1 停止位无流控 stty -F /dev/ttyUSB0 115200 cs8 -cstopb -parenb -crtscts # 逐项拆解 # 115200 波特率 # cs8 8 数据位cs5/cs6/cs7/cs8 # -cstopb 1 位停止位cstopb 是 2 位前面加 - 表示关闭 # -parenb 无校验parenb 开启校验配合 parodd 选奇偶 # -crtscts 关闭硬件流控为什么这么设绝大多数 MCU、模块、传感器的出厂默认就是 115200-8-N-1这是行业里最通用的组合。但要注意-cstopb和-parenb这种负向开关的写法很容易记反——cstopb本身表示两位停止位所以关掉它才是 1 位parenb表示启用校验关掉它才是无校验。我见过不少人写成stty ... cstopb parenb结果参数完全对不上排查半天以为是线的问题。流控这块单独说一句。-crtscts关掉硬件流控在短距离、低速场景没问题但如果你的线缆较长、波特率较高比如 921600或者对方设备会突发大量数据不开流控可能导致接收缓冲溢出丢字节。这时候要评估对方是否支持 RTS/CTS 硬件流控支持的话把-crtscts改成crtscts。软件流控XON/XOFF用ixon/-ixon控制但二进制数据里如果恰好出现 0x11/0x13 会被误判为流控字符所以传二进制时务必关掉ixon。3.2 原始模式别让终端行规程动你的数据这是 Shell 串口操作里最隐蔽也最致命的一个坑。默认情况下tty 设备会经过一层行规程line discipline处理它会做几件事把收到的\r转成\n、回显你写入的字符、按行缓冲凑够一个换行才交给读端、把特殊控制字符如 CtrlC解释成信号。这些行为对交互式终端很友好但对脚本收发数据是灾难。解决办法是切到原始模式raw modestty -F /dev/ttyUSB0 raw -echo -echoe -echok -echoctl -echokeraw是一个组合开关等价于关掉一大堆行规程处理。但不同stty实现里raw覆盖的范围略有差异稳妥起见把几个关键的-echo*也显式写上。为什么要关回显因为如果不关你echo进去的每个字节都会被设备回显回来读的时候会读到自己发的东西解析逻辑直接乱套。为什么关行缓冲因为很多协议是定长帧或者以特定字节结尾不是以换行结尾行缓冲会让数据卡在缓冲里迟迟不交出来。我个人的习惯是把完整配置写成一个函数每次打开端口前调用一次避免遗漏setup_serial() { local port$1 local baud${2:-115200} stty -F $port $baud cs8 -cstopb -parenb -crtscts \ raw -echo -echoe -echok -echoctl -echoke \ -ixon -ixoff -opost }-opost是关掉输出后处理比如\n转\r\n传二进制时必须关。-ixon -ixoff关软件流控。这一套下来串口就变成了一个透明管道你写什么对面收什么对面发什么你读什么中间不做任何加工。3.3 用 stty 反查当前配置调试时经常需要确认现在端口到底是什么参数stty -F /dev/ttyUSB0 -a会把所有设置列出来。输出很长重点看这几项speed后面的数字是波特率cs8表示数据位parenb/-parenb看校验cstopb/-cstopb看停止位-echo看回显是否关闭。养成配完反查一遍的习惯能省掉大量明明配了却不通的困惑。4. 收发数据重定向、超时与二进制安全4.1 发送printf 比 echo 更可靠发数据最直接的是重定向。但echo有几个毛病默认会在末尾加换行、对反斜杠转义的处理因 shell 而异、发二进制字节很别扭。所以发指令我优先用printf# 发一条 AT 指令注意很多模块要求以 \r 结尾而不是 \n printf AT\r /dev/ttyUSB0 # 发十六进制字节序列比如某协议的帧头 printf \xAA\x55\x01\x00 /dev/ttyUSB0 # 发完立刻刷新确保数据真的出去了 # 实际上重定向关闭时内核会 flush但显式 sync 更保险这里的关键细节是行结束符。AT 指令族几乎都要求\r0x0D结尾有些要求\r\n纯\n往往没反应。这个差异坑过无数人——用图形助手时软件帮你补了\r换成脚本用echo默认补的是\n于是同样的指令脚本发就没反应。记住发指令前先查手册确认结束符不确定就\r和\r\n都试一遍。另一个细节是printf的\x转义。Bash 内置的printf支持\xHH但某些精简 shell比如某些嵌入式 busybox 的 ash可能不支持这时候要用printf %b配合八进制或者干脆用echo -e。跨平台脚本里我会加一层判断或者直接用printf的八进制写法\252\125更保险。4.2 接收超时是必须的否则脚本会挂死前面说过串口没有 EOF直接cat会永远阻塞。脚本里读串口必须带超时。最常用的工具是timeout配合dd或head# 读固定字节数最多等 2 秒 timeout 2 dd if/dev/ttyUSB0 bs1 count16 2/dev/null # 读到换行或超时适合文本协议 timeout 2 cat /dev/ttyUSB0 # 读一段时间内的所有数据适合不定长回包 timeout 1 cat /dev/ttyUSB0 /tmp/serial_rx.bintimeout的返回值有讲究正常读完返回 0超时被杀返回 124。脚本里可以据此判断是收到了完整数据还是等超时了。但要注意timeout发的是 SIGTERMcat收到后退出已经读到的数据是保留的所以超时不等于没数据很多时候是数据收了一部分但没达到退出条件。dd的bs1 countN是逐字节读精确但效率低数据量大时用bs64之类配合count换算。还有个更精细的写法是用dd的iflagnonblock做非阻塞读但可移植性差一些日常用timeout就够了。4.3 二进制数据的坑0x00 和 0x0A 会咬人文本协议随便玩一旦涉及二进制帧Shell 的字符串处理就会暴露短板。最典型的是NUL 字节0x00Bash 的变量和命令替换会把 NUL 截断$(cat file)读到 0x00 就认为字符串结束了。所以二进制数据绝对不要塞进 shell 变量要么直接落盘用xxd/od分析要么用管道直接喂给下游程序。# 错误示范二进制数据进变量会被截断 data$(timeout 1 cat /dev/ttyUSB0) # 遇到 0x00 就断了 # 正确做法落盘后用工具分析 timeout 1 cat /dev/ttyUSB0 /tmp/rx.bin xxd /tmp/rx.bin | head od -An -tx1 /tmp/rx.bin另一个坑是read命令默认以换行分隔二进制里出现 0x0A 就会被当成行尾。如果非要用read加-d 让它以 NUL 分隔或者用-n N读固定字节。但说实话二进制协议我基本不用纯 shell 解析落盘后交给 Python 或专门的解析工具更省心。Shell 的定位是搬运工不是解析器认清这个边界能少走很多弯路。4.4 一个能直接用的收发函数把上面的东西串起来给一个实测可用的收发函数#!/bin/bash PORT/dev/ttyUSB0 BAUD115200 serial_setup() { stty -F $PORT $BAUD cs8 -cstopb -parenb -crtscts \ raw -echo -echoe -echok -echoctl -echoke -ixon -ixoff -opost } serial_send() { printf %b $1 $PORT } serial_recv() { local timeout_s${1:-2} timeout $timeout_s cat $PORT } # 用法示例发 AT 收回应 serial_setup serial_send AT\r resp$(serial_recv 2) echo 收到: $resp这段脚本跑通之后你就有了一个可编程的串口通道后面所有自动化都建立在它之上。注意serial_send里用了printf %b这样调用时可以直接传AT\r这种带转义的字符串比硬编码\r灵活。5. 实战中真正会卡住你的那些坑5.1 端口被占用谁在偷偷开着它脚本报Device or resource busy八成是有别的进程占着端口。图形串口助手没关干净、上次的cat还在后台挂着、或者某个守护进程在轮询都会导致这个。排查链路是这样的# 找出谁打开了这个设备 sudo lsof /dev/ttyUSB0 sudo fuser -v /dev/ttyUSB0 # 如果确认是残留进程按 PID 干掉 sudo kill -9 PIDfuser比lsof更直接它会列出占用进程的 PID 和命令行。我遇到过最隐蔽的一次是某个后台采集脚本用nohup跑着ps里名字被截断没认出来最后靠fuser才定位到。所以脚本里打开端口前最好加一步占用检查或者用flock做互斥避免多个实例抢同一个口。5.2 数据发出去但没反应的三层排查这是最高频的问题没有之一。我的排查顺序固定为三层第一层物理层。TX/RX 是不是接反了这是新手第一大坑TX 要接对方 RX、地线有没有共、线缆是不是只供电不传数据有些劣质线只有电源针。用回环测试最快把 TX 和 RX 短接发什么应该收到什么收不到就是硬件或驱动问题。第二层参数层。波特率对不对、结束符对不对、流控开没开。用stty -a反查当前配置和对方手册逐项比对。特别注意有些设备上电默认波特率是 9600 而不是 115200或者需要先发特定指令切换波特率。第三层时序层。设备是不是需要上电延时才能响应、是不是要先拉某个控制引脚DTR/RTS才能进入通信模式、发送后是不是需要等待处理时间再读。很多模块上电后要等几百毫秒才就绪脚本发太快它根本没准备好。# 回环测试短接 TX/RX 后执行 serial_setup printf LOOPBACK\r $PORT timeout 1 cat $PORT # 应该原样收到 LOOPBACK这个三层法能覆盖 90% 的没反应问题而且是从便宜到贵、从快到慢的顺序先排除接线再怀疑参数最后查时序效率最高。5.3 回显和本地终端设置的干扰有个很迷惑的现象脚本里明明关了-echo但读到的数据里还是有自己发的内容。原因可能是stty配置没生效比如配错了端口、或者配置被后续操作覆盖也可能是你读的时机太早读到了发送缓冲里的残留。还有一种情况是设备本身有本地回显功能很多 AT 模块默认开启 echo它会把收到的指令原样回给你这跟 tty 的 echo 是两回事得用指令关掉比如ATE0。区分方法如果回显内容和发送内容完全一致且紧跟在发送之后多半是设备本地回显如果是 tty 层回显通常在stty -a里能看到echo是开着的。两个都要排查别只盯着一个。5.4 长时间运行的稳定性缓冲与重连跑几小时甚至几天的采集脚本会遇到两个稳定性问题。一是接收缓冲累积如果读的速度跟不上发的速度内核缓冲会满满了之后新数据被丢弃。解决办法是提高读取频率、加大读取块或者用dd的iflagnonblock做非阻塞轮询。二是设备掉线USB 转串口在电磁环境差或者线缆松动时会重新枚举/dev/ttyUSB0可能变成ttyUSB1。用/dev/serial/by-id/的稳定路径能缓解但更稳妥的是脚本里加设备存在性检测和重连逻辑while true; do if [ -e $PORT ]; then serial_setup serial_recv 5 /var/log/serial.log else echo $(date) 设备不在等待重连 /var/log/serial.log sleep 2 fi done这种检测-重配-读取的循环是长时间采集脚本的标准骨架比一次性配好然后死读要健壮得多。6. 把串口操作嵌进自动化流程6.1 指令-应答模式的脚本化大部分串口设备都是发一条指令、等一条应答的问答模式。把它抽象成一个函数传入指令和期望的应答模式返回成功或失败就能批量跑测试用例expect_response() { local cmd$1 local pattern$2 local timeout_s${3:-3} serial_send $cmd local resp resp$(serial_recv $timeout_s) if echo $resp | grep -q $pattern; then return 0 else echo 期望 [$pattern] 实际 [$resp] 2 return 1 fi } # 批量校验 expect_response AT\r OK echo 模块就绪 expect_response ATVER?\r V[0-9] echo 版本读取成功这里用grep -q做模式匹配而不是精确相等是因为很多设备的应答里带可变字段版本号、时间戳精确匹配太脆。但模式也别写太松否则误判。我一般会把实际收到的原始应答打到 stderr失败时能直接看到设备到底回了什么省去二次复现。6.2 产线批量校验的节奏控制产线上一个工位可能要连续校验几十上百台设备每台插上、发指令、验应答、拔下。这种场景下节奏控制比功能本身更重要。发太快设备没就绪发太慢产能上不去。我的经验是上电后固定延时 500ms 到 1s 再发第一条指令每条指令之间留 100ms 到 200ms 间隔读应答超时设 2s 到 3s。这些数字不是拍脑袋是根据常见 MCU 的启动时间和指令处理时间估的具体还要按设备手册微调。另外产线脚本一定要有明确的失败退出码和日志。哪台设备、哪条指令、收到什么、期望什么全部落盘。出了问题能直接定位而不是这批里有几台没过但不知道为啥。6.3 和上层流程的衔接Shell 串口脚本很少孤立存在通常要和其他工具串起来校验结果写进 CSV 供后续统计、失败时触发告警、通过退出码让 CI 流水线判断成败。这些衔接点用最朴素的 shell 手段就能做——追加日志、if判断退出码、exit 1传递失败。不需要引入复杂框架保持脚本轻量反而是优势因为它能在资源受限的工控机、树莓派上直接跑。我个人在实际操作中的体会是串口脚本的价值不在于写得多花哨而在于把那些靠人眼盯、靠手工点的环节变成可重复、可追溯的机器动作。一开始可能只是为了省一次点击但当你把配置、收发、校验、日志、重连都固化进脚本之后它就成了一个能 7×24 跑、出错能自证、换个人也能接手的小工具。真正难的不是stty那几个参数而是把设备可能没就绪线可能接反端口可能被占这些现实世界的意外都提前想到并在脚本里留好退路。
阅读完成 · 觉得有帮助?