27 嵌入式操作系统 | C 网关:串口 ⇄ UDP 转发(先用 UDP 当上位机替身)

嵌入式操作系统 | C 网关:串口 ⇄ UDP 转发(先用 UDP 当上位机替身)

本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git

课件、示例代码与验收脚本都在该仓库,可直接 git clone 或下载 ZIP 使用。
模块七 · 2 学时 · 环境:Ubuntu(gcc + 课时 15 装的 libubox)+ socat 虚拟串口

一句话概述 :写项目里的"中间人"------C 网关 。它一头打开串口 /tmp/ttyV0(设备那边),一头开一个 UDP 口(上位机那边),把上位机的命令原样写进串口 、把设备的状态帧转回上位机 。下课时你会看到:一条 UDP 命令 → 网关照转 → 设备面板的灯亮了 → 状态又回到上位机。

本节目标

  1. 说清网关的职责(协议转发 + 校验 + 统计),会画"上位机 ⇄ 网关 ⇄ 设备"的数据流
  2. 会在一个 uloop 里同时管两个 fd(串口 + UDP)------课时 16/17/23 的知识在这里合体
  3. 会写 UDP 的收发(socket/bind/recvfrom/sendto),知道为什么先用 UDP 当上位机替身
  4. 会 C 版"切帧 + CRC 校验"(和课时 26 的 Python 版对照着看,算法是同一套)

一、讲解内容

1.1 知识讲解

网关在项目里的位置。 前面两节课:设备(面板)会执行命令了,但只有串口能控制它;现在我们加一层"中间人",让网络上的程序也能控制它:

复制代码
  上位机(本节先用 UDP 命令行工具)
        │  UDP 数据报(同一套帧:55 AA | TYPE | LEN | 数据 | CRC16)
        ▼
   ┌─────────────────────────────┐
   │   C 网关(uloop 事件循环)    │   ← 本节课写这个
   │   · 串口 fd:读设备 / 写命令  │
   │   · UDP fd:收命令 / 发状态   │
   └─────────────────────────────┘
        │  9600-8-N-1 串口(socat 虚拟串口)
        ▼
  设备面板(课时 26 的 device_panel_serial.py)

为什么先用 UDP,而不是直接上 TCP?(下节课再上 TCP)

UDP TCP
要不要"连接" 不要,直接 sendto 要 listen/accept,还要管多客户端
数据边界 一个数据报 = 一帧,天然不粘连 是字节流:可能粘包、可能半包,要自己切
代码量 30 行够 要多 60~80 行

所以顺序是:先用 UDP 把"网关在中间转发"这件事讲通 (本课),下节课再把 TCP 命令通道加上(真上位机用 TCP 更合适,因为可靠)。

本节的核心:一个 uloop 同时管两个 fd。 课时 16 管过 UDP 的 fd、课时 23 管过串口的 fd,这次是两个一起管------这就是事件驱动的价值:串口有数据就读串口,UDP 来了就读 UDP,谁也不用等谁:

c 复制代码
ufd_ser.fd = ser_fd;  ufd_ser.cb = on_serial_readable;  uloop_fd_add(&ufd_ser, ULOOP_READ);
ufd_udp.fd = udp_fd;  ufd_udp.cb = on_udp_readable;     uloop_fd_add(&ufd_udp, ULOOP_READ);
uloop_run();          /* 之后就是"谁有事谁回调" */

UDP 的四个函数(本课只用到这四个):

函数 作用 本课怎么用
socket(AF_INET, SOCK_DGRAM, 0) 建一个 UDP 套接字 网关启动时建一次
bind(fd, addr, len) 绑定 0.0.0.0:9200 这样本机和 Windows 宿主机都能发命令进来
recvfrom(fd, buf, ..., &src, &slen) 收一个数据报,顺带得到对方地址 收到命令,并记下"上位机在哪"
sendto(fd, buf, len, 0, &peer, len) 发给指定地址 把设备状态转回上位机

"记住最近的上位机地址"------本节的一个简化设计(也有坑) :设备每秒都在上报状态,但 UDP 里网关没法"主动知道"上位机在哪 ,所以本课的简化做法是:谁给我发过命令,我就记住他的地址,之后设备状态都发给他 (代码里的 peer / has_peer)。

坑(下节课就修):任何发过包的人都会顶掉这个地址------比如你用一个临时端口的脚本发了一帧坏数据,网关就会把状态发到那个"已经退出"的地址,真正的上位机反而收不到了。课时 29 会改成"上报目标固定/从 uci 读",更靠谱。

C 版的"切帧"和 Python 版是同一套算法,只是写法更"手工":

步骤 课时 26(Python) 本课(C)
找帧头 buf.find(b"\x55\xaa") 不是 55 AA 就把缓冲区左移一个字节 (memmove)继续找
判长度 total = 2 + 2 + buf[3] + 2 一样;另外加一条"长度 > 64 就丢掉重找"
半帧 留着,下次轮询再拼 一样:留着,等下次 read 补上
校验 crc16_modbus(frame[2:4+len]) crc16_modbus(rxbuf + 2, 2 + len)

本课的"三个数字"(打印在退出统计里,是排查的利器):串口收了多少帧、转给上位机多少帧、上位机收了多少帧、转给串口多少帧。对不上就说明某一环丢了数据。

1.2 操作步骤(一条命令一步,看现象)

第 0 步:环境检查(本节要 gcc、libubox、socat、pyserial)

shell 复制代码
gcc --version | head -1                                  # 要能打印版本
ls /usr/local/include/libubox/uloop.h                    # 课时 15 装的 libubox
which socat                                              # 造虚拟串口用
python3 -c "import serial; print('pyserial OK')"         # 设备面板要用

现象:四行都正常。缺东西就补(照抄版清单见课时 24 附录 F):

shell 复制代码
sudo apt update
sudo apt install -y build-essential cmake pkg-config socat      # C 侧
sudo apt install -y python3-pyqt5 python3-serial xvfb           # 设备面板要用

第 1 步:编译网关

shell 复制代码
cd ~/lab/课时27-网关骨架串口与UDP
gcc -Wall -Wextra -o gateway gateway.c -lubox
ls -l gateway

现象:编译无输出(无警告无错误)即成功,ls 能看到 gateway。

报 cannot find -lubox → 课时 15 的库没装好或忘了 sudo ldconfig;报 uloop_fd_handler 之类参数不匹配 → 回调签名要写成 (struct uloop_fd *f, unsigned int events)。

第 2 步:三个终端准备(A 串口 / B 设备面板 / C 网关)

shell 复制代码
# 终端 A:造虚拟串口(保持运行)
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1

# 终端 B:设备面板(课时 26 的程序;要在桌面里,或用 xvfb 代替)
cd ~/lab/课时26-设备面板接串口
python3 device_panel_serial.py

# 终端 C:网关
cd ~/lab/课时27-网关骨架串口与UDP
./gateway /tmp/ttyV0 9200

网关的现象(实测):

text 复制代码
网关已启动:串口 /tmp/ttyV0 @ 9600  ⇄  UDP 0.0.0.0:9200(Ctrl+C 退出)
方向说明:上位机 UDP 发命令 → 网关 → 串口 → 设备;设备的状态帧 → 网关 → 上位机

串口 → 上位机(10 字节):55 AA 80 04 00 00 00 FD 2F 9A
(还没有上位机发来过命令,这一帧先不转发)
串口 → 上位机(10 字节):55 AA 80 04 00 00 00 FE 6F 9B
(还没有上位机发来过命令,这一帧先不转发)

这两行"先不转发"很重要:设备已经在每秒上报了,但网关还不知道"上位机在哪"------等你发过一次命令,它就记住了。

第 3 步:上位机(UDP)发命令------点灯

shell 复制代码
cd ~/lab/课时27-网关骨架串口与UDP
python3 -u udp_client.py led on

现象(实测,客户端这边):

text 复制代码
发送到 127.0.0.1:9200:55 AA 01 01 01 E0 50
收到 55 AA 80 04 01 00 00 FE 6E 67  →  设备状态:灯 亮,风扇 0 档,温度 25.4 ℃
收到 55 AA 80 04 01 00 00 FE 6E 67  →  设备状态:灯 亮,风扇 0 档,温度 25.4 ℃
收到 55 AA 80 04 01 00 00 FF AF A7  →  设备状态:灯 亮,风扇 0 档,温度 25.5 ℃

网关这边(实测):

text 复制代码
上位机地址 = 127.0.0.1:9100
上位机 → 串口(7 字节):55 AA 01 01 01 E0 50
串口 → 上位机(10 字节):55 AA 80 04 01 00 00 FE 6E 67
已转给上位机 127.0.0.1:9100

设备面板那边:灯变绿(和课时 26 命令行点灯一样的效果,只是这次命令从网络来)。

第 4 步:只听模式(看设备每秒上报被转成 UDP)

shell 复制代码
python3 -u udp_client.py listen        # Ctrl+C 退出

现象(实测):每 1 秒一行 收到 55 AA 80 04 ... → 设备状态:灯 亮,风扇 2 档,温度 xx.x ℃。

listen 只监听、不"注册"地址;如果网关还没记住任何地址,先发一条命令(比如 status)。

第 5 步:其余命令都试一遍

shell 复制代码
python3 -u udp_client.py fan 2         # 风扇 2 档
python3 -u udp_client.py status        # 查状态
python3 -u udp_client.py reset         # 复位(灯灭、风扇停)

第 6 步:坏帧实验(网关会把它挡住)

shell 复制代码
python3 -u -c "
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(bytes.fromhex('55aa010101e051'), ('127.0.0.1', 9200))   # CRC 故意写错(本应 50)
print('已发出 CRC 错的帧:55 AA 01 01 01 E0 51')
s.close()
"

现象(网关日志,实测):上位机帧 CRC 不对(算出 50E0,帧里 51E0),丢弃

→ 坏数据没有进串口,设备不受影响。这就是"网关要校验"的意义。

第 7 步:看清"地址被顶掉"这个简化设计的坑

上一步那个临时脚本用的是随机高位端口 ,它同样会把网关的 peer 顶掉。现象(实测):网关把下一帧状态发给了那个端口------

text 复制代码
串口 → 上位机(10 字节):55 AA 80 04 01 02 00 FB 0F A4
已转给上位机 127.0.0.1:33199        ← 临时脚本的端口,不是我们的 9100

我们的 listen 会"莫名其妙收不到"了;再发一条命令(比如 status)就把地址抢回来。

这个坑下节课(29)正式修:把上报目标改成固定地址/从 uci 读,而不是"谁说话听谁的"。

第 8 步:停网关,看统计

在终端 C 按 Ctrl+C,现象(实测):

text 复制代码
网关退出。统计:串口收 12 帧、转给上位机 10 帧;上位机收 3 帧、转给串口 3 帧

数字对不上是正常的(有"还没记住地址就上报的"、有坏帧被丢的)------但要能解释每一处差异,这就是"会用统计排查"。

第 9 步:收工。 三个终端依次 Ctrl+C:网关 → 设备面板 → socat(虚拟串口消失)。

1.3 常见错误与排查

现象 原因 解决
gateway: cannot find -lubox libubox 没装好 / 没 ldconfig 回课时 15 装 libubox,装完 sudo ldconfig
编译报 uloop_fd_handler 参数不匹配 回调签名写错 必须是 void cb(struct uloop_fd *f, unsigned int events)
网关启动报 No such file or directory 串口不在(socat 没跑) 先做第 2 步的终端 A;ls -l /tmp/ttyV0 确认
网关报 bind: Address already in use 端口 9200 被上一次的网关占着 `ss -lunp
上位机发命令,网关没反应 网关没跑 / 发到了错的端口 看网关有没有打印"上位机地址 = ...";确认端口一致
网关日志一直在刷"(还没有上位机发过来命令...)" 还没发过命令 随便发一条:python3 -u udp_client.py status
udp_client.py listen 收不到状态 地址被别的程序顶掉了(第 7 步) 再发一条命令抢回地址;或直接用 status/led on 这类命令(它们自己会收 1.2 秒)
Windows 宿主机上发命令收不到回复 客户端绑的是 0.0.0.0:9100,网关回给它的虚拟机侧地址 用 --gw <虚拟机IP> 发命令;回复会回到宿主机的 9100(防火墙可能拦,先在本机演示)
设备面板没反应但网关日志正常 串口方向反了 设备面板必须用 /tmp/ttyV1,网关必须用 /tmp/ttyV0
收到的帧数字对不上统计 半帧、坏帧、地址未注册 都属于正常损耗,看日志逐条解释

二、学生练习

本节命令速查表:

命令 作用 示例
gcc -Wall -Wextra -o gateway gateway.c -lubox 编译网关 必须加 -lubox
./gateway /tmp/ttyV0 9200 启动网关(串口 + UDP 端口) 参数可省略,默认就是这个值
python3 -u udp_client.py listen 只听设备状态(UDP) Ctrl+C 退出
python3 -u udp_client.py led on 通过网络点灯 led off / fan 2 / status / reset
python3 -u udp_client.py status --gw 192.168.31.129 指定网关地址(宿主机上发命令) 默认 127.0.0.1
`ss -lunp grep 9200` 看谁占着 UDP 9200
python3 ../../课时22-串口命令行调试/crc_check.py --crc 01 01 01 算一帧的 CRC 造测试帧用
编号 任务 提示 预期结果
L27.1 编译 + 三个终端跑起来 第 1、2 步 网关打印"网关已启动..."和"(还没有上位机发来过命令...)"
L27.2 用 UDP 点灯 第 3 步 设备面板灯变绿;网关打印"上位机 → 串口"和"已转给上位机"
L27.3 只听模式看上报 第 4 步 每秒一行"设备状态:..."
L27.4 四条命令都试 第 5 步 灯/风扇/查状态/复位都生效
L27.5 发一帧 CRC 错的命令 第 6 步 网关打印"上位机帧 CRC 不对...丢弃",设备无反应
L27.6 复现"地址被顶掉" 第 7 步 网关把状态转给了一个高位端口,listen 收不到
L27.7 停网关看统计 第 8 步 打印"串口收 x 帧、转给上位机 y 帧..."并解释差异
L27.8 换个端口启动两个网关? 想一下:两个网关能同时用一个串口吗? 第二个会打开失败/抢数据------串口是独占总线

三、作业

  • 必做:交 3 张截图(网关启动+设备每秒上报被"暂不转发" / 一条 UDP 命令点亮设备 + 网关的三行转发日志 / 停网关时的统计行),并回答:① 网关为什么要同时管两个 fd?如果只用"一个 while 循环先读串口再读 UDP"会有什么问题?② 为什么本节先用 UDP 而不是 TCP?③ 网关为什么要校验 CRC 后再转发?④ "记住最近的上位机地址"这个设计有什么坑?你会怎么改?
  • 选做:① 给网关加一条"日志时间戳"(每行前面打印 HH:MM:SS,方便对照时序);② 试着把网关的上报目标改成固定地址 (比如写死 127.0.0.1:9100,用 sendto 发过去),体会"谁说话听谁的"和"固定目标"的区别
  • 提交:截图 + 文字回答,命名 课时27-作业截图.png

四、本节小结与下节预告

本节写出了项目的"中间人":一个 uloop 同时管串口 fd 和 UDP fd ,两边原样转发、切帧、校验 CRC、打印统计。你现在应该能说清这条链路的每一段(网络 → 网关 → 串口 → 设备,以及反向)。同时我们也看到了简化设计的代价:"谁说话听谁"的上报目标很容易被顶掉。

下节课(课时 28)把 UDP 换成/加上 TCP 命令通道 :你会遇到网络编程里最经典的坑------粘包和半包 (TCP 是字节流,两帧可能一起到、一帧可能分两次到),并用"2 字节长度前缀"把它解决掉;顺便复习课时 14/17 的 accept 与多客户端。


附录:怎么运行 + 完整代码

两个文件都在 代码/课时27-网关骨架串口与UDP/:gateway.c(C 网关,本节主角)和 udp_client.py(上位机替身/调试工具)。行尾请用 LF。

附录 A|环境准备 + 怎么运行

shell 复制代码
# ===== ⓿ 环境准备(缺哪个装哪个;完整清单见课时 24 附录 F)=====
sudo apt update
sudo apt install -y build-essential cmake pkg-config socat     # gcc + 虚拟串口
sudo apt install -y python3-pyqt5 python3-serial xvfb          # 设备面板(课时 26 的程序)
ls /usr/local/include/libubox/uloop.h                          # 课时 15 装的 libubox
ldconfig -p | grep libubox                                     # 找不到就回课时 15 重装 + sudo ldconfig

# ===== 终端 A:造虚拟串口(保持运行)=====
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1
ls -l /tmp/ttyV0 /tmp/ttyV1

# ===== 终端 B:设备面板(课时 26 的程序;桌面里跑,或用 xvfb)=====
cd ~/lab/课时26-设备面板接串口
python3 device_panel_serial.py
xvfb-run -a python3 device_panel_serial.py --shot /tmp/panel.png --shot-after 20   # 没有桌面时

# ===== 终端 C:网关 =====
cd ~/lab/课时27-网关骨架串口与UDP
gcc -Wall -Wextra -o gateway gateway.c -lubox
./gateway /tmp/ttyV0 9200

# ===== 终端 D:上位机(UDP)=====
cd ~/lab/课时27-网关骨架串口与UDP
python3 -u udp_client.py listen            # 只听
python3 -u udp_client.py led on            # 点灯
python3 -u udp_client.py fan 2             # 风扇 2 档
python3 -u udp_client.py status            # 查状态
python3 -u udp_client.py reset             # 复位

附录 B|C 网关 gateway.c(完整文件在代码目录,这里讲关键几段)

B-1 全局状态:两个 fd、串口缓冲、上位机地址、统计

c 复制代码
static struct uloop_fd ufd_ser;  /* 串口"有数据可读"事件 */
static struct uloop_fd ufd_udp;  /* UDP "有数据报可读"事件 */

static uint8_t rxbuf[512];       /* 串口缓冲区(字节流,要自己拼帧) */
static int rxlen;

static struct sockaddr_in peer;  /* 最近给网关发过命令的"上位机"地址 */
static int has_peer;

static int ser_tx, ser_rx, udp_tx, udp_rx;   /* 统计,方便看现象 */

B-2 原样转发:把设备状态发给"最近发过命令的上位机"

c 复制代码
/* 把一帧发给"最近给网关发过命令的那个上位机" */
static void forward_to_peer(const uint8_t *frame, int len)
{
    if (!has_peer) {
        printf("(还没有上位机发来过命令,这一帧先不转发)\n");
        fflush(stdout);
        return;
    }
    if (sendto(udp_fd, frame, (size_t)len, 0,
               (struct sockaddr *)&peer, sizeof(peer)) != len) {
        perror("sendto");
        return;
    }
    udp_tx++;
    printf("已转给上位机 %s:%d\n", inet_ntoa(peer.sin_addr), ntohs(peer.sin_port));
    fflush(stdout);
}

B-3 串口侧:切帧 + CRC 校验 + 转发

c 复制代码
/* 串口缓冲区里切帧:找帧头 → 判长度 → 取一帧 → 校验 CRC → 转给上位机 */
static void serial_handle_frames(void)
{
    while (rxlen >= 4) {
        int total;

        if (!(rxbuf[0] == FRAME_HEAD0 && rxbuf[1] == FRAME_HEAD1)) {
            memmove(rxbuf, rxbuf + 1, (size_t)(--rxlen));   /* 丢一个字节,重新找帧头 */
            continue;
        }
        if (rxbuf[3] > MAX_FRAME) {
            memmove(rxbuf, rxbuf + 1, (size_t)(--rxlen));   /* 长度不合理,也丢掉重找 */
            continue;
        }
        total = 2 + 2 + rxbuf[3] + 2;
        if (rxlen < total) {
            printf("收到半帧(%d/%d 字节),等后面补上...\n", rxlen, total);
            fflush(stdout);
            return;                                         /* 半帧:等下一次读 */
        }
        {
            uint16_t calc = crc16_modbus(rxbuf + 2, 2 + rxbuf[3]);
            uint16_t got = (uint16_t)(rxbuf[4 + rxbuf[3]] | (rxbuf[5 + rxbuf[3]] << 8));

            if (calc != got) {
                printf("CRC 校验失败(算出 %04X,帧里 %04X),丢弃\n", calc, got);
                fflush(stdout);
            } else {
                ser_rx++;
                dump("串口 → 上位机", rxbuf, total);
                forward_to_peer(rxbuf, total);
            }
        }
        rxlen -= total;
        memmove(rxbuf, rxbuf + total, (size_t)rxlen);
    }
}

/* 串口有数据可读:uloop 回调 */
static void on_serial_readable(struct uloop_fd *f, unsigned int events)
{
    ssize_t n;

    (void)events;
    n = read(f->fd, rxbuf + rxlen, sizeof(rxbuf) - (size_t)rxlen);
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR) {
            return;
        }
        perror("read(serial)");
        uloop_end();
        return;
    }
    if (n == 0) {
        return;
    }
    rxlen += (int)n;
    serial_handle_frames();
}

B-4 UDP 侧:收命令 → 校验 → 写串口;以及 main 里怎么把两个 fd 都加进 uloop

c 复制代码
/* UDP 有数据报可读:uloop 回调(上位机的命令从这里进来) */
static void on_udp_readable(struct uloop_fd *f, unsigned int events)
{
    uint8_t buf[MAX_FRAME + 16];
    struct sockaddr_in src;
    socklen_t slen = sizeof(src);
    ssize_t n;
    int len;
    uint16_t calc, got;

    (void)events;
    n = recvfrom(f->fd, buf, sizeof(buf), 0, (struct sockaddr *)&src, &slen);
    if (n <= 0) {
        return;
    }

    peer = src;                       /* 记住它:设备上报就发回这里 */
    has_peer = 1;

    if (n < 6 || buf[0] != FRAME_HEAD0 || buf[1] != FRAME_HEAD1) {
        printf("上位机来的不是合法帧(%d 字节),丢弃\n", (int)n);
        fflush(stdout);
        return;
    }
    len = buf[3];
    if (n != 6 + len) {
        printf("上位机帧长度不对(收到 %d,帧里写着 %d),丢弃\n", (int)n, 6 + len);
        fflush(stdout);
        return;
    }
    calc = crc16_modbus(buf + 2, 2 + len);
    got = (uint16_t)(buf[4 + len] | (buf[5 + len] << 8));
    if (calc != got) {
        printf("上位机帧 CRC 不对(算出 %04X,帧里 %04X),丢弃\n", calc, got);
        fflush(stdout);
        return;
    }

    udp_rx++;
    printf("上位机地址 = %s:%d\n", inet_ntoa(src.sin_addr), ntohs(src.sin_port));
    dump("上位机 → 串口", buf, (int)n);

    if (write(ser_fd, buf, (size_t)n) != n) {
        perror("write(serial)");
        return;
    }
    ser_tx++;
}

int main(int argc, char *argv[])
{
    const char *dev = (argc >= 2) ? argv[1] : DEV_DEFAULT;
    int port = (argc >= 3) ? atoi(argv[2]) : UDP_PORT;

    uloop_init();

    ser_fd = serial_open(dev);
    if (ser_fd < 0) { uloop_done(); return 1; }
    udp_fd = udp_open(port);
    if (udp_fd < 0) { close(ser_fd); uloop_done(); return 1; }

    ufd_ser.fd = ser_fd;
    ufd_ser.cb = on_serial_readable;
    uloop_fd_add(&ufd_ser, ULOOP_READ);

    ufd_udp.fd = udp_fd;
    ufd_udp.cb = on_udp_readable;
    uloop_fd_add(&ufd_udp, ULOOP_READ);

    printf("网关已启动:串口 %s @ 9600  ⇄  UDP 0.0.0.0:%d(Ctrl+C 退出)\n", dev, port);
    fflush(stdout);

    uloop_run();                 /* 之后就是"谁有事谁回调" */
    uloop_done();

    printf("\n网关退出。统计:串口收 %d 帧、转给上位机 %d 帧;上位机收 %d 帧、转给串口 %d 帧\n",
           ser_rx, udp_tx, udp_rx, ser_tx);
    close(ser_fd);
    close(udp_fd);
    return 0;
}

附录 C|上位机替身 udp_client.py(完整文件在代码目录)

它只做三件事:组帧 (build_frame)、发出去 (sendto)、把收到的帧翻译成人话 (explain)。

关键片段(和课时 26 的 serial_monitor.py 是同一套 CRC 与翻译逻辑,只是把串口换成了 UDP):

python 复制代码
def build_frame(type_: int, payload: bytes = b"") -> bytes:
    body = bytes([type_, len(payload)]) + payload
    crc = crc16_modbus(body)
    return b"\x55\xaa" + body + bytes([crc & 0xFF, crc >> 8])


def send_command(host, port, frame):
    s = make_socket()                       # 绑定本机 9100,便于网关把状态发回来
    s.sendto(frame, (host, port))
    print("发送到 %s:%d:%s" % (host, port, frame_text(frame)))

    end = time.time() + 1.2                 # 发完等 1.2 秒,把回复也打印出来
    while time.time() < end:
        try:
            data, addr = s.recvfrom(2048)
        except socket.timeout:
            continue
        print("收到 %s  →  %s" % (frame_text(data), explain(data)))
    s.close()

用法回顾:listen(只听)/ led on|off / fan 0~3 / status / reset,

还可以加 --gw 192.168.31.129 --port 9200 指定网关(上位机跑在 Windows 上时用)。

相关推荐
沐欣工作室_lvyiyi1 小时前
基于STM32的便捷式智能气象仪设计(论文+源码)
stm32·单片机·嵌入式硬件·单片机毕业设计·电子信息工程毕业设计·河北工程技术学院·廊坊师范学院
iCxhust1 小时前
STM32F103C8T6 CAN 通讯
stm32·单片机·嵌入式硬件
老王爱玩车2 小时前
深入理解指针6
c语言·开发语言·学习
苏辰澈2 小时前
C++自己的族谱规则:继承详解上
c语言·c++·visualstudio
Logic1014 小时前
C语言/数据结构字符串题解:按字符类型重排(数字→字母→符号)——三趟扫描稳定分区
c语言·数据结构·字符串·时间复杂度·计数排序·算法题·ascii
Logic10111 小时前
C语言/数据结构位运算题解:异或XOR找出学习计划中的“独特学习时间“——只出现一次的数字
c语言·数据结构·数组·位运算·时间复杂度·算法题·异或性质
wuminyu11 小时前
C++协程实现接收端的零拷贝Buffer管理原理剖析
java·linux·c语言·jvm·c++
周洲083011 小时前
STM32 串口底层深度解析|USART 中断标志、硬件 FIFO,乱码根源底层定位
stm32·单片机·嵌入式硬件
思麟呀14 小时前
位运算:从一个整数到一个集合
c语言·数据结构·c++·算法