嵌入式操作系统 | 网关加 TCP 命令通道:粘包/半包怎么切
本课程开源地址(Gitee) :https://gitee.com/fujianxinxi/qianrushixitongyingyongkaifa.git
课件、示例代码与验收脚本都在该仓库,可直接
git clone或下载 ZIP 使用。
模块七 · 2 学时 · 环境:Ubuntu(gcc + libubox)+ socat 虚拟串口
一句话概述 :上一节网关用 UDP 讲通了"中间人";这一节加上真上位机该用的 TCP 通道 ,并解决网络编程里最经典的坑------粘包 (两帧一起来)和半包 (一帧分两次来)。做法只有一句:先发 2 字节长度,再发这一帧;谁收到谁按长度切。
本节目标
- 会写 TCP 服务端四件套(
socket → bind → listen → accept),并把监听 fd 和每个客户端 fd 都交给 uloop 管 - 说清 TCP 是字节流:为什么会粘包/半包,为什么必须加"长度前缀"(对比 UDP 一个数据报就是一帧)
- 会给每个客户端单独一个接收缓冲区,并写出 C 版"长度前缀切帧"(含半包等待、长度不合理丢弃)
- 会做四个实验:单命令、粘包 、半包、坏帧(都能在下课看到现象)
一、讲解内容
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:])
...