28 嵌入式操作系统 | 网关加 TCP 命令通道:粘包/半包怎么切

嵌入式操作系统 | 网关加 TCP 命令通道:粘包/半包怎么切

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

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

一句话概述 :上一节网关用 UDP 讲通了"中间人";这一节加上真上位机该用的 TCP 通道 ,并解决网络编程里最经典的坑------粘包 (两帧一起来)和半包 (一帧分两次来)。做法只有一句:先发 2 字节长度,再发这一帧;谁收到谁按长度切。

本节目标

  1. 会写 TCP 服务端四件套(socket → bind → listen → accept),并把监听 fd 和每个客户端 fd 都交给 uloop 管
  2. 说清 TCP 是字节流:为什么会粘包/半包,为什么必须加"长度前缀"(对比 UDP 一个数据报就是一帧)
  3. 会给每个客户端单独一个接收缓冲区,并写出 C 版"长度前缀切帧"(含半包等待、长度不合理丢弃)
  4. 会做四个实验:单命令、粘包 、半包、坏帧(都能在下课看到现象)

一、讲解内容

1.1 知识讲解

本节在上一节的基础上加什么。 上一节的网关是"串口 fd + UDP fd";这一节把 UDP 换成 TCP 命令通道 ,并且把设备状态广播给所有已连接的客户端:

复制代码
  上位机 A ──TCP──┐
  上位机 B ──TCP──┤   ┌──────────────────────────────┐
                  ├──▶│  C 网关(uloop 管 3 类 fd)    │
                  │   │  ① 串口 fd                   │
                  │   │  ② TCP 监听 fd(accept)      │
                  └───│  ③ 每个客户端 fd(各一个缓冲) │
                      └──────────────┬───────────────┘
                                     │ 串口 9600-8-N-1
                                     ▼
                          设备面板(课时 26 的程序)
课时 27(UDP) 本节课(TCP)
命令下行 UDP 数据报 TCP + 长度前缀
状态上行 UDP 发给"最近发命令的那个人" TCP 广播给所有已连接客户端
要不要切帧 不用(一个数据报就是一帧) 必须切(粘包/半包)

TCP 为什么必须切帧? 因为 TCP 提供的是字节流 ,它只保证"字节按顺序到达、不丢不重",不保证"一次 send 对应一次 recv":

现象 你 send 的 对端可能 read 到
正常 一帧(9 字节) 9 字节 ✓
粘包 两帧(9+9 字节) 18 字节一次到(两帧粘一起)
半包 一帧(9 字节) 先 5 字节,过一会儿再 4 字节

课时 13/14 你已经用过 recv 返回 0 = 对端关闭;这节课再加一条:recv 回来多少字节,和发送方怎么发没有关系。

解决办法:给帧加"长度前缀"。 我们的帧内部本来就有 LEN(PAYLOAD 长度),但那不够------因为接收方得先知道"从哪儿开始才算一帧"。所以 TCP 上再加一层外壳:

复制代码
TCP 上传输的字节:  长度(2 字节大端) | 55 AA | TYPE | LEN | PAYLOAD | CRC16 低 | CRC16 高
                    ▲ 只解决"怎么切"      ▲ 帧本身一字不改(和串口/课时 26 完全一样)

接收方的切帧逻辑(本课代码):

c 复制代码
while (rxlen >= 2) {
    flen = (rxbuf[0] << 8) | rxbuf[1];        /* ① 先看长度前缀 */
    if (flen < 6 || flen > MAX_FRAME + 16) {  /* ② 长度不合理:清空缓冲重新同步 */
        rxlen = 0; return;
    }
    if (rxlen < 2 + flen) {                   /* ③ 半包:等下一次 read 补上 */
        printf("TCP 半包:已有 %d 字节,这一帧要 %d 字节...\n", rxlen, 2 + flen);
        return;
    }
    frame = rxbuf + 2;                        /* ④ 够一帧了:校验 CRC → 写串口 */
    ...
    rxlen -= 2 + flen;                        /*    剩下的(可能是下一帧)继续 */
    memmove(rxbuf, rxbuf + 2 + flen, rxlen);
}

"每个客户端一个缓冲区"是本节的关键设计。 四个客户端各发来半帧,混在一起就没法切;所以代码里每个客户端一个 struct client:

c 复制代码
struct client {
    struct uloop_fd ufd;      /* 它自己的 fd(也交给 uloop 一起管) */
    int used;
    char addr[32];
    uint8_t rxbuf[1024];      /* ★ 它自己的接收缓冲区 */
    int rxlen;
};
static struct client clients[MAX_CLIENTS];    /* 最多 4 个,教学够用 */

回调里怎么知道"是哪个客户端"?本课用最直白的办法:遍历匹配 fd (最多 4 个,一看就懂):

for (i...) if (clients[i].used && &clients[i].ufd == f) return &clients[i];

广播 vs "最近地址" :TCP 里"谁连着"我们一清二楚,所以设备状态可以直接广播 给所有客户端(tcp_broadcast),不会再出现课时 27 那种"地址被别人顶掉"的坑。这也是"控制走 TCP、数据走 TCP/UDP"的取舍点。

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

第 0 步:环境检查 (和课时 27 一样:gcc、libubox、socat、pyserial;完整清单见课时 24 附录 F)

shell 复制代码
gcc --version | head -1
ls /usr/local/include/libubox/uloop.h
which socat
python3 -c "import serial; print('pyserial OK')"

缺什么补什么:

shell 复制代码
sudo apt update
sudo apt install -y build-essential cmake pkg-config socat
sudo apt install -y python3-pyqt5 python3-serial xvfb

第 1 步:编译

shell 复制代码
cd ~/lab/课时28-网关TCP命令通道
gcc -Wall -Wextra -o gateway_tcp gateway_tcp.c -lubox
ls -l gateway_tcp

现象(实测):[OK] gateway_tcp 编译无警告(无输出即成功)。

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

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

# 终端 B:设备面板(课时 26 的程序)
cd ~/lab/课时26-设备面板接串口
python3 device_panel_serial.py

# 终端 C:TCP 网关
cd ~/lab/课时28-网关TCP命令通道
./gateway_tcp /tmp/ttyV0 9000

网关的现象(实测启动行):

text 复制代码
网关已启动:串口 /tmp/ttyV0 @ 9600  ⇄  TCP 0.0.0.0:9000(Ctrl+C 退出)
上位机发命令:先发 2 字节长度(大端),再发那一帧
设备状态:网关会广播给所有已连接的上位机

第 3 步:客户端连上来发一条命令(点灯)

shell 复制代码
cd ~/lab/课时28-网关TCP命令通道
python3 -u tcp_client.py led on

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

text 复制代码
已连接网关 127.0.0.1:9000
发送 00 07 55 AA 01 01 01 E0 50  (长度前缀 7)
收到 55 AA 80 04 01 00 00 FE 6E 67  →  设备状态:灯 亮,风扇 0 档,温度 25.4 ℃

网关这边(实测,注意这三行):

text 复制代码
上位机连上了:127.0.0.1:43874(现在 1 个客户端)
TCP 收到 9 字节(缓冲区现在 9 字节)        ← 长度前缀 2 + 帧 7 = 9
上位机(TCP) → 串口(7 字节):55 AA 01 01 01 E0 50

设备面板:灯变绿。

第 4 步:只听模式(看状态广播)

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

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

(这次不需要"先注册"------TCP 连上了就能收广播,比课时 27 的 UDP 省心。)

第 5 步:★粘包实验------一次把两帧发出去

shell 复制代码
python3 -u tcp_client.py glue

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

text 复制代码
一次发出两帧(粘包实验):
  第一帧 55 AA 01 01 01 E0 50
  第二帧 55 AA 02 01 02 50 51

网关这边(实测,一次 read 到 18 字节,切出两帧):

text 复制代码
TCP 收到 18 字节(缓冲区现在 18 字节)
上位机(TCP) → 串口(7 字节):55 AA 01 01 01 E0 50
上位机(TCP) → 串口(7 字节):55 AA 02 01 02 50 51

两帧都执行了------这就是"用长度前缀切帧"的价值。如果代码里只处理一帧就返回,第二帧就会卡在缓冲区里直到下次数据到来(这就是很多初学者"命令丢一条"的原因)。

第 6 步:★半包实验------一帧分两次发

shell 复制代码
python3 -u tcp_client.py split

现象(实测,客户端):先把 00 07 55 AA 01 发出去,0.6 秒后补上 01 00 21 90。

网关这边(实测,半帧没有被执行,等补齐后才执行):

text 复制代码
TCP 收到 5 字节(缓冲区现在 5 字节)
TCP 半包:已有 5 字节,这一帧要 9 字节,等后面补上...
TCP 收到 4 字节(缓冲区现在 9 字节)
上位机(TCP) → 串口(7 字节):55 AA 01 01 00 21 90

第 7 步:坏帧实验

shell 复制代码
python3 -u tcp_client.py bad

现象(网关,实测):TCP 帧 CRC 不对(算出 50E0,帧里 51E0),丢弃

第 8 步:多客户端(广播给所有人)

shell 复制代码
# 开两个终端各跑一个,或像老师演示那样后台跑
python3 -u tcp_client.py status &
python3 -u tcp_client.py fan 1 &

现象(网关,实测):

text 复制代码
上位机连上了:127.0.0.1:57426(现在 1 个客户端)
上位机连上了:127.0.0.1:57428(现在 2 个客户端)
TCP 收到 8 字节(缓冲区现在 8 字节)
上位机(TCP) → 串口(6 字节):55 AA 03 00 01 40

两个客户端都收到了设备的状态广播(一方发命令,两边都能看到结果)。

第 9 步:停网关看统计

Ctrl+C 停网关,现象(实测):

text 复制代码
网关退出。统计:串口收 23 帧、TCP 广播 24 次;TCP 收 9 帧、转给串口 9 帧

第 10 步:收工。 三个终端依次 Ctrl+C。

1.3 常见错误与排查

现象 原因 解决
网关报 bind: Address already in use 9000 端口被上一次的网关占着 `ss -ltnp
客户端报 Connection refused 网关没跑 / 端口不对 先启动网关;确认端口一致
用 nc 或 `printf nc` 直接发帧,网关"没反应" 忘了长度前缀
网关日志只有"TCP 收到 xx 字节",没有转发 长度前缀不对/长度不合理 看网关有没有打印"长度前缀不合理(n)",那说明切帧同步错了
只执行了第一条命令,第二条"丢了" 切帧循环只做了一次 必须是 while (rxlen >= 2) 循环,把缓冲区里的帧全部切出来
客户端收到广播解析错 客户端没按长度前缀切 客户端也要切帧(tcp_client.py 的 recv_show 就是范例)
客户端一连就断 连接数超过 4 本课最多 2~4 个客户端(MAX_CLIENTS),改大数组即可
半包一直等不齐 对端发了一半就断了 这是正常的:缓冲区留着,对端断开时随 client_close 一起清掉
网关退出时报一堆写失败 客户端已经断开但槽位还在 本课用 client_close 在 read==0/出错时回收;提高要求时给 SIGPIPE 加 MSG_NOSIGNAL 或忽略 SIGPIPE
宿主机连不上虚拟机 网络模式/防火墙 VMware 用 NAT/桥接;ss -ltnp 确认监听在 0.0.0.0:9000

二、学生练习

本节命令速查表:

命令 作用 示例
gcc -Wall -Wextra -o gateway_tcp gateway_tcp.c -lubox 编译 TCP 网关 别忘 -lubox
./gateway_tcp /tmp/ttyV0 9000 启动(串口 + TCP 端口) 默认值就是这个
python3 -u tcp_client.py listen 连上只听状态广播 Ctrl+C 退出
python3 -u tcp_client.py led on 点灯 led off / fan 2 / status / reset
python3 -u tcp_client.py glue ★粘包实验(两帧一次发) 看网关"TCP 收到 18 字节"
python3 -u tcp_client.py split ★半包实验(一帧分两次) 看网关"TCP 半包:已有 5 字节..."
python3 -u tcp_client.py bad 坏帧(CRC 错) 看网关丢弃
`ss -ltnp grep 9000` 看谁在监听
编号 任务 提示 预期结果
L28.1 编译并三个终端起来 第 1、2 步 网关打印三行启动说明
L28.2 客户端点灯 第 3 步 面板灯亮;网关打印"TCP 收到 9 字节"+转发
L28.3 只听广播 第 4 步 每秒一行设备状态
L28.4 ★粘包实验 第 5 步 网关一次读 18 字节、切出两帧、两条都生效
L28.5 ★半包实验 第 6 步 网关先报"半包",补齐后执行
L28.6 坏帧 第 7 步 网关"CRC 不对,丢弃"
L28.7 两个客户端同时连 第 8 步 两边都收到广播;网关显示"现在 2 个客户端"
L28.8 用 nc 手工发一帧(不带长度前缀) 想一下会怎样? 网关"TCP 收到 7 字节"后卡住(切不出帧)------证明长度前缀必须有
L28.9 把 MAX_CLIENTS 改成 2,第三个客户端连 第 8 步之后 网关打印"客户端太多了,先拒绝这个连接"

三、作业

  • 必做:交 3 张截图(点灯成功 + 网关的三行日志 / 粘包 18 字节切两帧 / 半包先等待后执行 ),并回答:① 什么叫粘包、什么叫半包?为什么 UDP 不会有这个问题?② 我们的帧里本来就有 LEN 字段,为什么 TCP 上还要再加 2 字节长度前缀?③ 为什么每个客户端要单独一个接收缓冲区?④ read/recv 返回 0 表示什么,代码里怎么处理?
  • 选做:① 把长度前缀改成 4 字节 (大端),客户端与服务端同步改(想一下:这样能支持多长的帧?);② 给网关加一条 0x05 广播查询:任何客户端发它,网关让设备立刻上报一帧(需要网关自己组帧,把课时 27 注释里提到的 build_frame 写出来)
  • 提交:截图 + 文字回答,命名 课时28-作业截图.png

四、本节小结与下节预告

本节网关多了 TCP 命令通道:accept 出客户端 → 每个客户端一个缓冲 → 按长度前缀切帧(粘包/半包都能处理)→ 命令写串口 → 设备状态广播给所有客户端。你现在应该能解释"为什么 TCP 必须切帧"和"长度前缀怎么用",也见过了四个实验的真实现象。

下节课(课时 29)做状态表 + 周期上报 :网关不再"收到就转",而是维护一张设备状态表(灯/风扇/温度/在线标志),用 uloop_timeout 每 2 秒主动上报一次;设备掉线(一段时间收不到状态帧)就标记 online=0。到那时"设备掉线"在界面上就能看出来了。


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

两个文件在 代码/课时28-网关TCP命令通道/:gateway_tcp.c(本节主角,约 450 行)和 tcp_client.py(TCP 调试工具)。完整代码以仓库文件为准,下面只摘关键几段。

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

shell 复制代码
# ===== ⓿ 环境准备(缺哪个装哪个;完整清单见课时 24 附录 F)=====
sudo apt update
sudo apt install -y build-essential cmake pkg-config socat
sudo apt install -y python3-pyqt5 python3-serial xvfb
ls /usr/local/include/libubox/uloop.h          # 课时 15 装的 libubox

# ===== 终端 A:虚拟串口 =====
socat -d -d pty,raw,echo=0,link=/tmp/ttyV0 pty,raw,echo=0,link=/tmp/ttyV1

# ===== 终端 B:设备面板(课时 26 的程序)=====
cd ~/lab/课时26-设备面板接串口 && python3 device_panel_serial.py

# ===== 终端 C:TCP 网关 =====
cd ~/lab/课时28-网关TCP命令通道
gcc -Wall -Wextra -o gateway_tcp gateway_tcp.c -lubox
./gateway_tcp /tmp/ttyV0 9000

# ===== 终端 D:TCP 上位机(四个实验)=====
python3 -u tcp_client.py led on      # 单命令
python3 -u tcp_client.py glue        # 粘包
python3 -u tcp_client.py split       # 半包
python3 -u tcp_client.py bad         # 坏帧

附录 B|TCP 网关 gateway_tcp.c 关键片段

B-1 每个客户端一个槽位(含自己的接收缓冲区)

c 复制代码
struct client {
    struct uloop_fd ufd;                 /* 每个客户端一个 fd(uloop 一起管) */
    int used;
    char addr[32];                       /* 记下是谁,方便打印 */
    uint8_t rxbuf[1024];                 /* 每个客户端自己的缓冲区(关键!) */
    int rxlen;
};

static struct client clients[MAX_CLIENTS];   /* MAX_CLIENTS = 4 */

B-2 长度前缀切帧(粘包/半包都在这里处理)

c 复制代码
/* 客户端缓冲区里切帧:先读 2 字节长度前缀,再按长度取一帧 */
static void client_handle_frames(struct client *c)
{
    while (c->rxlen >= 2) {
        int flen = (c->rxbuf[0] << 8) | c->rxbuf[1];     /* 长度前缀:2 字节大端 */
        uint8_t *frame;

        if (flen < 6 || flen > MAX_FRAME + 16) {
            printf("长度前缀不合理(%d),清空这个客户端的缓冲区重新同步\n", flen);
            c->rxlen = 0;
            return;
        }
        if (c->rxlen < 2 + flen) {
            printf("TCP 半包:已有 %d 字节,这一帧要 %d 字节,等后面补上...\n", c->rxlen, 2 + flen);
            fflush(stdout);
            return;                                        /* 半包:等下一次读 */
        }

        frame = c->rxbuf + 2;
        if (frame[0] != FRAME_HEAD0 || frame[1] != FRAME_HEAD1) {
            printf("帧头不是 55 AA,丢弃这一帧\n");
            fflush(stdout);
        } else {
            uint16_t calc = crc16_modbus(frame + 2, 2 + frame[3]);
            uint16_t got = (uint16_t)(frame[4 + frame[3]] | (frame[5 + frame[3]] << 8));

            if (calc != got) {
                printf("TCP 帧 CRC 不对(算出 %04X,帧里 %04X),丢弃\n", calc, got);
                fflush(stdout);
            } else {
                tcp_rx++;
                dump("上位机(TCP) → 串口", frame, flen);
                if (write(ser_fd, frame, (size_t)flen) != flen) {
                    perror("write(serial)");
                } else {
                    ser_tx++;
                }
            }
        }
        c->rxlen -= 2 + flen;
        memmove(c->rxbuf, c->rxbuf + 2 + flen, (size_t)c->rxlen);
    }
}

B-3 accept + 槽位管理;广播给所有客户端

c 复制代码
static void on_listen_readable(struct uloop_fd *f, unsigned int events)
{
    struct sockaddr_in cli;
    socklen_t clen = sizeof(cli);
    struct client *c;
    int fd;

    (void)events;
    fd = accept(f->fd, (struct sockaddr *)&cli, &clen);
    if (fd < 0) {
        perror("accept");
        return;
    }

    c = client_alloc();
    if (c == NULL) {
        printf("客户端太多了(最多 %d 个),先拒绝这个连接\n", MAX_CLIENTS);
        fflush(stdout);
        close(fd);
        return;
    }

    memset(c, 0, sizeof(*c));
    c->used = 1;
    snprintf(c->addr, sizeof(c->addr), "%s:%d",
             inet_ntoa(cli.sin_addr), ntohs(cli.sin_port));
    c->ufd.fd = fd;
    c->ufd.cb = on_client_readable;
    uloop_fd_add(&c->ufd, ULOOP_READ);

    printf("上位机连上了:%s(现在 %d 个客户端)\n", c->addr, client_count());
    fflush(stdout);
}

/* 广播:长度前缀 + 帧,发给所有已连接的上位机 */
static void tcp_broadcast(const uint8_t *frame, int len)
{
    uint8_t out[2 + MAX_FRAME + 16];
    int i, total;

    out[0] = (uint8_t)(len >> 8);
    out[1] = (uint8_t)(len & 0xFF);
    memcpy(out + 2, frame, (size_t)len);
    total = len + 2;

    for (i = 0; i < MAX_CLIENTS; i++) {
        if (clients[i].used && write(clients[i].ufd.fd, out, (size_t)total) == total) {
            tcp_tx++;
        }
    }
}

附录 C|TCP 客户端 tcp_client.py 关键片段

python 复制代码
def send_frame(sock, frame: bytes, note=""):
    """按协议加 2 字节长度前缀再发"""
    pkt = bytes([len(frame) >> 8, len(frame) & 0xFF]) + frame
    sock.sendall(pkt)
    print("发送 %s  (长度前缀 %d)%s" % (frame_text(pkt), len(frame), note))


def recv_show(sock, seconds):
    """收 seconds 秒:按长度前缀切帧并打印(客户端也要会切帧!)"""
    buf = b""
    end = time.time() + seconds
    sock.settimeout(0.2)
    while time.time() < end:
        try:
            data = sock.recv(4096)
        except socket.timeout:
            continue
        if not data:
            print("(网关关闭了连接)")
            return
        buf += data
        while len(buf) >= 2:
            flen = (buf[0] << 8) | buf[1]
            if len(buf) < 2 + flen:
                break                       # 半包:等后面的字节
            frame, buf = buf[2:2 + flen], buf[2 + flen:]
            print("收到 %s  →  %s" % (frame_text(frame), explain(frame)))

两个实验命令(glue 粘包 / split 半包)就在 main() 里,各 5~6 行:

python 复制代码
    if args[0] == "glue":                       # ★ 粘包:两帧一次发出去
        f1 = build_frame(0x01, bytes([1]))      # 点灯
        f2 = build_frame(0x02, bytes([2]))      # 风扇 2 档
        pkt = (bytes([len(f1) >> 8, len(f1) & 0xFF]) + f1 +
               bytes([len(f2) >> 8, len(f2) & 0xFF]) + f2)
        sock.sendall(pkt)
        ...
    if args[0] == "split":                      # ★ 半包:一帧分两次发
        f = build_frame(0x01, bytes([0]))       # 关灯
        pkt = bytes([len(f) >> 8, len(f) & 0xFF]) + f
        sock.sendall(pkt[:5])
        time.sleep(0.6)
        sock.sendall(pkt[5:])
        ...
相关推荐
HY小宝F1 小时前
树莓派智能摄像头系统 30 天重学计划之 Day 03 · 网络基础:建立“包“的思维 —— 学习总结
网络·学习
ayaya_mana1 小时前
MCTier 组网联机工具:安装、配置与私有化部署
网络·windows·内网穿透·联机
故七月1 小时前
GEO 信源评估体系:如何判断一条网页信源能否被大模型采信
前端·网络·人工智能
hasty2 小时前
一次 WebSocket 握手为何能终止进程?Undici CVE-2026-19534 的异常边界
网络·websocket·网络协议
实点科技2 小时前
XB6S-EI2002如何与信捷XDH-60T4-E实现EtherNet/IP通讯?
网络
仍然.3 小时前
RabbitMQ---可靠性传输
网络·分布式·rabbitmq
砚凝霜3 小时前
【软考信息安全】第八章 防火墙类型与实现技术
网络
hasty3 小时前
前缀相同不代表同一网络:ip-address 跨地址族比较缺陷
网络·网络协议·tcp/ip
大侠归来3 小时前
cJSON 源码解析:parse_string 函数深入剖析
linux·网络·算法