嵌入式操作系统 | 串口命令行调试(socat / stty / printf / hexdump)
本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git
课件、示例代码与验收脚本都在该仓库,可直接
git clone或下载 ZIP 使用。
模块六 · 2 学时 · 环境:VMware + Ubuntu 20.04
一句话概述:一行 C 代码都不写,全部用命令行把串口调通:用 socat 造一对虚拟串口,用 stty 查看/设置串口参数,用 printf 发数据、hexdump 收数据,再用 exec 3<> 双工打开串口,手工完成一次 Modbus RTU 读写并用 crc_check.py 核对 CRC 与温度;最后故意制造故障,练排查能力。
本节目标
- 能说出串口参数(波特率 / 数据位 / 校验位 / 停止位),会用
stty查看和设置它们,并知道每条对应程序里的哪个选项 - 会用
socat+printf+hexdump完成"串口能发能收"的验证,会用exec 3<>在同一个 fd 上收发 - 会手工发一帧 Modbus RTU 请求、看懂 7 字节响应,并用
crc_check.py核对 CRC、换算温度;出现异常能按现象查原因
本节课的意义 :串口出问题(收到乱码、收不到数据、数据被抢走)时,第一反应应该是拿几条命令去验证物理链路和参数,而不是先改程序。这些命令在现场调试、工控设备对接时天天用。
一、讲解内容
1.1 知识讲解
串口是什么。 设备之间最常用的一种连接方式:两边按同样的"约定"收发字节。这个约定就是四个参数,常写成 9600-8-N-1:
| 参数 | 取值 | 含义 |
|---|---|---|
| 波特率 | 9600 / 115200 ... | 每秒传多少位,两边必须一致,不一致就是乱码 |
| 数据位 | 8 | 一个字符 8 位 |
| 校验位 | N(None) | 不做奇偶校验(Modbus RTU 就是 8-N-1 或 8-E-1) |
| 停止位 | 1 | 一帧结束的标志 |
串口在 Linux 里就是一个"设备文件"。 真串口叫 /dev/ttyS0(主板串口)、/dev/ttyUSB0(USB 转串口),打开、读写就是普通文件操作。虚拟机里没有真串口怎么办------用 socat 造一对虚拟串口:
text
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
▲ 一端给我们调 ▲ 另一端给对端设备
raw = 不做任何字符加工(二进制协议必备),echo=0 = 关掉内核回显。对我们来说 /tmp/ttyV0 就是串口:写进去的数据会从 /tmp/ttyV1 出来,反之亦然。
stty 是 termios 的命令行前端------这是本课最重要的一张对照表(下节课写 C 程序时,代码里那几行就是这张表):
| 命令行(本课用) | 程序里(下节课用) | 作用 |
|---|---|---|
stty -F /dev/xxx -a |
tcgetattr() |
看当前所有参数 |
stty -F /dev/xxx 9600 |
cfsetispeed/cfsetospeed |
设波特率 |
stty -F /dev/xxx cs8 |
`c_cflag | = CS8` |
stty -F /dev/xxx -cstopb |
c_cflag &= ~CSTOPB |
1 位停止位 |
stty -F /dev/xxx -parenb |
c_cflag &= ~PARENB |
无校验 |
stty -F /dev/xxx raw -echo |
cfmakeraw() |
原始模式 + 关回显 |
-F后面跟设备名,意思就是"对这个设备操作",不是对当前终端。不加-F改的是你自己的终端,一定要小心。
为什么要 raw + -echo。 串口默认是"熟模式(cooked)":内核会做行编辑(按回车才交数据)、把 \r 变成 \n、把 Ctrl+C 当信号、并且把你写出去的数据回显回来。这些对二进制协议全是灾难。所以:
- 上课用的 socat 已经带了
raw,echo=0,所以你stty -a看到的已经是-icanon -echo; - 真串口默认是
icanon echo,所以程序里必须写cfmakeraw();命令行里必须写stty ... raw -echo; - 反过来,你也可以故意
stty -F /tmp/ttyV1 echo把回显打开------马上就能看到"自己发出去的数据又回到了自己手里"(下面第 9 步有实验,这是排查时最常见的坑)。
命令行收发的三件套。
| 目的 | 命令 | 说明 |
|---|---|---|
| 发数据 | printf 'abc\r\n' > /tmp/ttyV0 |
用 printf,不要用 echo :echo 对 \x、\r 的处理各系统不一致,printf 可靠 |
| 收数据 | `timeout 5 head -c 7 /tmp/ttyV1 | hexdump -C` |
| 兜底 | timeout 5 ... |
收不到数据时不会永远卡住,5 秒自动结束------排查时永远加上 timeout |
| 双工 | exec 3<>/tmp/ttyV0 之后 >&3 发、<&3 收 |
同一个 fd 才能"我发完你回我",两个重定向会打开两次设备。注意 :bash 打开设备不带 O_NOCTTY ,在没有控制终端的环境(脚本 / setsid / 服务)里会把串口抢成你的控制终端,读回来是 0 字节------所以这几条命令要在真实终端里敲(见 1.3 最后两行) |
Modbus RTU 帧结构(工业上最常见的串口协议,我们读一个保持寄存器):
| 位置 | 请求帧 01 03 00 00 00 01 84 0A |
响应帧 01 03 02 00 FA 38 07 |
|---|---|---|
| 1 | 01 从机地址 |
01 从机地址 |
| 2 | 03 功能码(读保持寄存器) |
03 功能码 |
| 3 | 00 起始地址高字节 |
02 后面的数据有几个字节 |
| 4 | 00 起始地址低字节 |
00 数据高字节 |
| 5 | 00 要读几个(高字节) |
FA 数据低字节(0x00FA = 250) |
| 6 | 01 要读几个(低字节) |
38 CRC 低字节 |
| 7 | 84 CRC 低字节 |
07 CRC 高字节 |
| 8 | 0A CRC 高字节 |
--- |
CRC 怎么算:用现成的小工具 (本课提供 crc_check.py,CRC16/MODBUS:初值 0xFFFF、反射多项式 0xA001,低字节在前):
shell
python3 crc_check.py --crc 01 03 00 00 00 01 # 算请求帧的 CRC → 84 0A
python3 crc_check.py 01 03 02 00 FA 38 07 # 校验响应帧 → 通过,温度 25.0 C
手工发帧时先用它把请求帧的 CRC 算出来,就不用背
84 0A了;收到响应也用它核对。
读上来的数据怎么变温度。 寄存器里是整数,本例约定"放大 10 倍":读到 250 就是 25.0 C。真实设备的换算规则看它的说明书。
1.2 操作步骤(一条命令一步,看现象)
第 1 步:造一对虚拟串口(终端 A,保持运行)
shell
sudo apt install -y socat # 课时 15 装过就不用再装
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
现象(打印两个 PTY 编号并建好软链接,然后停在这里不退出,这是正常的):
text
socat[3553] N PTY is /dev/pts/4
socat[3553] N PTY is /dev/pts/5
socat[3553] N starting data transfer loop with FDs [5,5] and [7,7]
第 2 步:确认"串口"真的在(终端 B)
shell
ls -l /tmp/ttyV0 /tmp/ttyV1
现象(软链接指向 socat 造出来的 pts 设备;这一步是排查的第一条命令:文件不在,就是 socat 没开):
text
lrwxrwxrwx 1 root root 10 /tmp/ttyV0 -> /dev/pts/4
lrwxrwxrwx 1 root root 10 /tmp/ttyV1 -> /dev/pts/5
第 3 步:用 stty 看当前参数(不改任何东西,只看)
shell
stty -F /tmp/ttyV0 -a
现象(关键几行,本环境实测):
text
speed 38400 baud; rows 0; columns 0; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D; eol = <undef>;
...
-parenb -parodd -cmspar cs8 -hupcl -cstopb cread -clocal -crtscts
...
-isig -icanon iexten -echo echoe echok -echonl -noflsh -xcase -tostop -echoprt
怎么读这份输出:
speed 38400 baud:当前"波特率"(socat 默认给的,说明默认值不一定是我们想要的)cs8 -cstopb -parenb:8 位数据、1 位停止位、无校验 ------ 已经对了-icanon -echo:不是熟模式、不回显 ------ 这是 socat 的raw,echo=0帮我们设好的
第 4 步:用 stty 把参数设成 9600-8-N-1
shell
stty -F /tmp/ttyV0 9600 cs8 -cstopb -parenb raw -echo
stty -F /tmp/ttyV0 -a | head -1
现象(第一行变成 9600,其余不变):
text
speed 9600 baud; rows 0; columns 0; line = 0;
第 5 步:第一次收发验证------字符串能不能过去
终端 B 先"听"(收 7 个字节就结束,最多等 5 秒):
shell
timeout 5 head -c 7 /tmp/ttyV1 | hexdump -C
终端 C 再"说":
shell
printf 'hello\r\n' > /tmp/ttyV0
现象(终端 B 打出 7 个字节,右边还能看出 hello 和回车换行 0d 0a)------能到这里就说明串口通了:
text
00000000 68 65 6c 6c 6f 0d 0a |hello..|
00000007
第 6 步:把参数改回去/改成别的,看会不会乱码(重要结论)
shell
stty -F /tmp/ttyV1 115200 # 故意让两端的"波特率"不一样
timeout 5 head -c 7 /tmp/ttyV1 | hexdump -C # 终端 B 继续听
printf 'hello\r\n' > /tmp/ttyV0 # 终端 C 再发
现象与结论(本课必须讲清楚这一点 ):在 socat 造的虚拟串口上数据照样能收到,不会乱码------因为虚拟串口的背后是内存,根本不关心波特率。真串口上两边波特率不一致才会出现乱码/丢字节。所以:
- 用虚拟串口练的是命令和排查手法;
- 真设备的波特率不一致,只能靠"两边参数对一遍"来解决 (这就是第 3 步那条
stty -a的用途)。
第 7 步:命令行发二进制帧(Modbus 请求帧)
shell
printf '\x01\x03\x00\x00\x00\x01\x84\x0a' > /tmp/ttyV0
终端 B 用 timeout 3 head -c 8 /tmp/ttyV1 | hexdump -C 听,现象:
text
00000000 01 03 00 00 00 01 84 0a |........|
00000008
说明命令行完全可以收二进制,\x01 这种写法由 printf 直接送出原始字节(hexdump -C 会把不可打印字节显示成点)。
第 8 步:启动 Modbus 从机(终端 B,必须先开)
从机脚本用课程资料里的 modbus_slave.py(Python 标准库实现,模拟一个温度传感器,每答一次温度升 0.5):
shell
python3 -u modbus_slave.py /tmp/ttyV1
现象:
text
Modbus 从机已打开 /tmp/ttyV1,等待主机请求...(Ctrl+C 退出)
为什么加
-u:不加的话 Python 输出会攒在缓冲区里,重定向到文件或在后台跑时看不到打印,容易误以为程序没反应。
第 9 步:在同一 fd 上"发一帧、收一帧"(本课的高潮)
shell
exec 3<>/tmp/ttyV0 # 读写方式打开串口,挂到 fd 3
printf '\x01\x03\x00\x00\x00\x01\x84\x0a' >&3 # 从 fd 3 发出请求帧
timeout 2 head -c 7 <&3 | hexdump -C # 从同一个 fd 3 读 7 字节响应
exec 3<&- # 用完关掉
现象(实测:从机回的正是温度 250):
text
00000000 01 03 02 00 fa 38 07 |.....8.|
00000007
终端 B 的从机也同步打印了一来一回:
text
收到请求:01 03 00 00 00 01 84 0A
发送响应:01 03 02 00 FA 38 07
这一步有两个坑(老师现场实测出来的,记下来):
- 这几条命令要在真实终端里敲。 bash 的
exec 3<>打开设备不带O_NOCTTY:如果当前 shell 没有控制终端(脚本、setsid/nohup起的脚本、systemd 服务里),虚拟串口会被它"认领"成控制终端,于是head -c 7 <&3立刻读到 0 字节(EOF) ------而同一时刻从机日志明明写着发送响应。这也正是下一课 C 程序里一定写open(dev, O_RDWR | O_NOCTTY)的原因。 - 如果确实要在没有控制终端的场合做这件事,用带
O_NOCTTY的 Python 一行命令代替(printf/head/cat/stty这些命令自己打开设备时都带O_NOCTTY,所以只有 shell 的exec 3<>有这个坑):
shell
python3 -u -c "
import os, time
fd = os.open('/tmp/ttyV0', os.O_RDWR | os.O_NOCTTY)
os.write(fd, bytes.fromhex('010300000001840a'))
time.sleep(0.5)
print(' '.join('%02X' % b for b in os.read(fd, 7)))
os.close(fd)
"
现象(实测):输出 01 03 02 00 FA 38 07。
第 10 步:用 crc_check.py 核对 CRC、把寄存器值换算成温度
shell
python3 crc_check.py 01 03 02 00 FA 38 07
现象(实测):
text
输入字节:01 03 02 00 FA 38 07
CRC 校验通过(0x0738)
功能码 03、从机 01:寄存器值 = 250,温度 = 25.0 C
故意改坏一个字节,看工具能不能发现(排查时用它判断"帧是不是被改过/错位"):
shell
python3 crc_check.py 01 03 02 00 FA 38 08
text
输入字节:01 03 02 00 FA 38 08
CRC 校验失败:算出 0x0738,帧里是 0x0838(字节错位或数据被改动)
第 11 步:故意制造 4 种故障,练排查(每个都做一遍,记现象)
| 故障 | 怎么做 | 现象 | 结论 |
|---|---|---|---|
| 对端不在 | 终端 B 按 Ctrl+C 停掉从机,再执行第 9 步 |
timeout 等满 2 秒,什么都没输出 |
"收不到"先查对端是否在跑 |
| 回显没关 | stty -F /tmp/ttyV1 echo,再从 fd 3 发 printf 'abc\r' >&3,然后 `timeout 2 head -c 5 <&3 |
hexdump -C` | 收到 61 62 63 5e 4d(即 abc^M)------自己的数据被弹回来了 |
| 残留数据污染 | 先发 4 个字节(printf 'abc\r' > /tmp/ttyV0)再启动从机、跑第 9 步 |
从机打印 收到请求:61 62 63 0D 01 03 00 00 / CRC 不对,丢弃,主机一直收不到响应 |
帧错位多半是上一次实验的残留字节混进来了:重开 socat,或先把缓冲读空 |
| 一端两个人用 | 两个终端同时 cat /tmp/ttyV0,再发数据 |
两个 cat 都能打开(虚拟串口不报 busy),但数据被其中一个抢走,另一个永远等不到 |
串口是独占资源,约定一端只给一个程序用 |
第 12 步:收工。 终端 A 按 Ctrl+C 停掉 socat,然后 ls -l /tmp/ttyV0 会报"没有那个文件"------虚拟串口随 socat 一起消失。
1.3 常见错误与排查
排查顺序(背下来,现场调试就按这个顺序走):
text
① 设备在不在 ls -l /tmp/ttyV0 → 不在:socat 没开
② 参数对不对 stty -F /tmp/ttyV0 -a → 两端对一遍(真设备关键)
③ 能不能发 printf 'hello\r\n' > /dev/xxx
④ 能不能收 timeout 5 head -c 7 /dev/yyy | hexdump -C
⑤ 字节对不对 hexdump -C 看原始字节,别只看"有没有数据"
⑥ 帧对不对 python3 crc_check.py <收到的字节>
| 现象 | 原因 | 解决 |
|---|---|---|
No such file or directory: /tmp/ttyV0 |
socat 没启动 / 已退出 | 先做第 1 步,ls -l /tmp/ttyV0 确认软链接在 |
| 命令一直卡着不回 | 用 cat 收数据、对端又没发 |
永远加 timeout :`timeout 5 head -c N /dev/xxx |
收到 5E 4D(^M)或收到自己发的原样数据 |
对端回显 开着(echo 没关) |
stty -F /dev/xxx raw -echo;程序里 cfmakeraw() |
| 收到乱码 / 字节数不对 | 两端参数不一致(尤其真串口波特率) | 两边都执行 stty -a 对一遍;确认 9600-8-N-1 |
从机一直说 CRC 不对,主机收不到回复 |
帧错位:上次实验的残留字节混进来了 | 重开 socat;或先把缓冲读空(timeout 1 cat /dev/xxx > /dev/null) |
| 数据"时有时无" | 同一端被两个程序同时打开,数据被抢走 | 一端只给一个程序用;fuser -v /tmp/ttyV0(或 lsof /tmp/ttyV0)看谁占着 |
printf 发出去对方收到一堆怪东西 |
用 echo 发的,转义没生效 |
改用 printf,\x0A 这种十六进制转义只有 printf 可靠 |
| 后台跑的 Python 从机"没反应" | print 被缓冲,输出没刷出来 | 用 python3 -u modbus_slave.py ...(-u 关缓冲) |
stty 一改,自己的终端乱了 |
忘了 -F,改的是当前终端 |
一定写 -F 设备名;真乱了敲 stty sane 恢复 |
第 9 步 head -c 7 <&3 读到 0 字节,但从机日志说"已发送响应" |
当前 shell 没有控制终端 ,bash 的 exec 3<>(不带 O_NOCTTY)把串口抢成了控制终端 |
在真实终端里执行;或改用第 9 步第 2 条那个带 O_NOCTTY 的 Python 命令 |
程序报 tcsetattr: Interrupted system call |
系统调用被信号打断(errno == EINTR) |
标准做法:errno == EINTR 时重试 (课时 23 的 serial_open()、write_frame() 就是这么写的) |
二、学生练习
本节命令速查表:
| 命令 | 作用 | 示例 |
|---|---|---|
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1 |
造一对虚拟串口(终端 A) | 见第 1 步 |
ls -l /tmp/ttyV0 /tmp/ttyV1 |
确认"串口"在 | --- |
stty -F 设备 -a |
看串口参数 | stty -F /tmp/ttyV0 -a |
stty -F 设备 9600 cs8 -cstopb -parenb raw -echo |
设成 9600-8-N-1 | --- |
printf '...' > 设备 |
发数据(支持 \x01 写二进制) |
printf 'hello\r\n' > /tmp/ttyV0 |
| `timeout 5 head -c N 设备 | hexdump -C` | 收 N 字节并按十六进制看 |
exec 3<>设备 / >&3 / <&3 |
同一 fd 双工收发 | 见第 9 步 |
python3 crc_check.py 01 03 02 00 FA 38 07 |
校验 CRC、换算温度 | --- |
python3 -u modbus_slave.py /tmp/ttyV1 |
启动 Modbus 从机(对端) | --- |
| 编号 | 任务 | 提示 | 预期结果 |
|---|---|---|---|
| L22.1 | 造虚拟串口并确认 | 第 1、2 步 | ls -l 看到两个软链接 |
| L22.2 | 看参数、把两端都设成 9600-8-N-1 | 第 3、4 步 | stty -a 里 speed 9600、cs8 -cstopb -parenb |
| L22.3 | 用命令发一句 hello 并另一端收 |
第 5 步 | hexdump 出现 68 65 6c 6c 6f 0d 0a |
| L22.4 | 手工发 Modbus 请求帧并收 7 字节响应 | 第 9 步(别忘了 exec 3<>) |
收到 01 03 02 00 FA 38 07 |
| L22.5 | 用工具核对 CRC 和温度 | 第 10 步 | "CRC 校验通过,温度 25.0 C" |
| L22.6 | 故意改坏一个字节再校验 | 第 10 步 | "CRC 校验失败:算出 0x0738,帧里是..." |
| L22.7 | 复现"回显没关"的现象 | 第 11 步第 2 行 | 收到 61 62 63 5e 4d(abc^M) |
| L22.8 | 复现"残留数据导致帧错位" | 先发 abc 再从机启动 |
从机打印 CRC 不对,丢弃 |
三、作业
- 必做:完成 L22.3、L22.4、L22.5,提交三张截图(
stty -a的输出 / 手工收发的hexdump/crc_check.py的校验结果),并回答:①raw和-echo分别解决什么问题?② 请求帧01 03 00 00 00 01 84 0A里每个字节是什么意思?③ Modbus 的 CRC 低字节在前还是高字节在前?④ 为什么虚拟串口两边"波特率不一致"也不乱码?那真串口会怎样?⑤ 第 9 步为什么必须"在真实终端里敲"?O_NOCTTY起什么作用? - 选做:① 不查工具,自己用
printf手工把"读 2 个寄存器"的请求帧组出来(提示:功能码 03、起始地址 0x0000、数量 0x0002,用crc_check.py --crc算 CRC,然后看从机会回什么);② 把"排查顺序"那张六步写成一个小抄贴在自己笔记本上,下节课用 - 提交:截图 + 文字回答,命名
课时22-作业截图.png
四、本节小结与下节预告
本节一行 C 代码都没写,却把串口这套东西摸清了:socat 造虚拟串口、stty 看/设参数(和 termios 一一对应)、printf/hexdump/timeout 收发数据、exec 3<> 双工打开、手工完成一次 Modbus 读写并用工具核对 CRC。更重要的是那条六步排查顺序------以后串口调不通,先按它走一遍。
下节课把这些命令写成一个真正的程序:用 uloop 打开串口,定时 发 Modbus 请求,事件驱动 收响应和做超时保护------顺便把"一问一答、sleep 干等"的阻塞写法改成不阻塞的写法。
附录:怎么运行 + 完整代码
本课不用写代码,附录 B、C 的两个 Python 脚本是"对端工具+校验工具",直接用课程的现成文件即可。行尾请用 LF。
附录 A|怎么运行(三个终端,注意先后顺序)
shell
# ===== 终端 A:造一对虚拟串口(保持运行,别关)=====
sudo apt install -y socat
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
# ===== 终端 B:对端(先开!)=====
ls -l /tmp/ttyV0 /tmp/ttyV1 # ① 设备在不在
stty -F /tmp/ttyV0 -a # ② 参数对不对(看 speed/cs8/-parenb)
cd ~/lab/课时22-串口命令行调试
python3 -u modbus_slave.py /tmp/ttyV1 # ④ 启动从机,停在这里等请求
# ===== 终端 C:主机侧,全部用命令 =====
stty -F /tmp/ttyV0 9600 cs8 -cstopb -parenb raw -echo # ③ 设成 9600-8-N-1
printf 'hello\r\n' > /tmp/ttyV0 # ③ 能不能发(终端 B 可加一条命令收)
python3 crc_check.py --crc 01 03 00 00 00 01 # ⑥ 先算出请求帧的 CRC
exec 3<>/tmp/ttyV0 # 读写打开串口,挂到 fd 3
printf '\x01\x03\x00\x00\x00\x01\x84\x0a' >&3 # 发请求帧(8 字节)
timeout 2 head -c 7 <&3 | hexdump -C # 收响应帧(7 字节)
exec 3<&- # 关掉 fd 3
python3 crc_check.py 01 03 02 00 FA 38 07 # ⑥ 核对 CRC + 换算温度
为什么"对端先开":串口不像 TCP 有连接握手,收不到数据时无法区分"对端没开"还是"帧不对"。先把对端跑起来,出错就只剩"帧不对"一种可能------这就是排查的思路。
附录 B|文件 crc_check.py(命令行算 CRC / 校验帧的小工具)
python
#!/usr/bin/env python3
"""课时 22 的"命令行小工具":算 Modbus CRC、校验响应帧、换算温度
用法一(算请求帧的 CRC):
python3 crc_check.py --crc 01 03 00 00 00 01
-> CRC = 0x0A84,帧 = 01 03 00 00 00 01 84 0A
用法二(校验收到的 7 字节响应帧,并算温度):
python3 crc_check.py 01 03 02 00 FA 38 07
-> CRC 校验通过,温度 = 25.0 C
也支持从管道读(十六进制,空格分隔):
echo "01 03 02 00 FA 38 07" | python3 crc_check.py
"""
import sys
def crc16_modbus(data: bytes) -> int:
"""CRC16/MODBUS:初值 0xFFFF、反射多项式 0xA001"""
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
def parse_bytes(args) -> bytes:
"""把命令行参数或标准输入里的十六进制字节攒成 bytes"""
if not args:
args = sys.stdin.read().replace(",", " ").split()
out = bytearray()
for a in args:
a = a.strip()
if a.lower().startswith("0x"):
a = a[2:]
if not a:
continue
if len(a) > 2: # 允许 "01030200FA3807" 这种连写
for i in range(0, len(a) - 1, 2):
out.append(int(a[i:i + 2], 16))
else:
out.append(int(a, 16))
return bytes(out)
def main() -> int:
args = sys.argv[1:]
only_crc = False
if args and args[0] in ("--crc", "-c"):
only_crc = True
args = args[1:]
data = parse_bytes(args)
if not data:
print(__doc__)
return 1
print("输入字节:" + " ".join(f"{b:02X}" for b in data))
if only_crc:
c = crc16_modbus(data)
frame = data + bytes([c & 0xFF, c >> 8]) # Modbus:低字节在前
print(f"CRC = 0x{c:04X}(低字节在前:{c & 0xFF:02X} {c >> 8:02X})")
print("完整帧:" + " ".join(f"{b:02X}" for b in frame))
return 0
# 校验整个帧:前 n-2 字节算 CRC,和最后两字节比
if len(data) < 4:
print("帧太短(至少 4 字节),没法校验")
return 1
body = data[:-2]
got = data[-2] | (data[-1] << 8)
c = crc16_modbus(body)
if c != got:
print(f"CRC 校验失败:算出 0x{c:04X},帧里是 0x{got:04X}(字节错位或数据被改动)")
return 2
print(f"CRC 校验通过(0x{got:04X})")
if data[1] == 0x03 and data[2] == 2: # 读保持寄存器、2 个字节数据
raw = (data[3] << 8) | data[4]
print(f"功能码 03、从机 {data[0]:02X}:寄存器值 = {raw},温度 = {raw / 10.0:.1f} C")
else:
print(f"从机 {data[0]:02X}、功能码 {data[1]:02X}、数据 " +
" ".join(f"{b:02X}" for b in data[2:-2]))
return 0
if __name__ == "__main__":
sys.exit(main())
附录 C|文件 modbus_slave.py(Modbus RTU 从机,模拟温度传感器)
python
#!/usr/bin/env python3
"""课时 22 / 23 的"测试工具":Modbus RTU 从机(模拟一个温度传感器)
用法:python3 -u modbus_slave.py /tmp/ttyV1 [起始温度]
(另一端给主机:课时 22 用命令行发帧,课时 23 用 C 程序发帧)
"""
import os
import struct
import sys
def crc16_modbus(data: bytes) -> int:
crc = 0xFFFF
for b in data:
crc ^= b
for _ in range(8):
crc = (crc >> 1) ^ 0xA001 if crc & 1 else crc >> 1
return crc
dev = sys.argv[1] if len(sys.argv) > 1 else "/tmp/ttyV1"
temp = float(sys.argv[2]) if len(sys.argv) > 2 else 25.0
fd = os.open(dev, os.O_RDWR | os.O_NOCTTY)
print(f"Modbus 从机已打开 {dev},等待主机请求...(Ctrl+C 退出)")
buf = b""
try:
while True:
data = os.read(fd, 256)
if not data:
continue
buf += data
while len(buf) >= 8:
frame, buf = buf[:8], buf[8:]
print("收到请求:" + " ".join(f"{b:02X}" for b in frame))
if crc16_modbus(frame[:6]) != (frame[6] | (frame[7] << 8)):
print(" CRC 不对,丢弃")
continue
if frame[1] != 0x03:
print(f" 功能码 {frame[1]:#04x} 不支持,丢弃")
continue
value = int(round(temp * 10)) # 25.0 C -> 250
resp = bytes([frame[0], 0x03, 2, (value >> 8) & 0xFF, value & 0xFF])
resp += struct.pack("<H", crc16_modbus(resp))
os.write(fd, resp)
print("发送响应:" + " ".join(f"{b:02X}" for b in resp))
temp += 0.5 # 每答一次温度升 0.5,方便观察
except KeyboardInterrupt:
print("\n从机退出")
finally:
os.close(fd)
进阶(选做):把从机的
os.read换成select.select([fd], [], [], 1),就变成"有数据才处理"------这正是下节课 C 程序里 uloop 帮我们做的事。