LV005-数据封装与以太网帧
一、 为什么要逐层封装
1. 封装解决了什么问题
上一篇说了"分层",但分层只是说了"谁管什么",真正让各层能拼起来工作的机制是封装。
封装要解决的是一个很朴素的矛盾:每一层都需要在数据前面写一点自己的信息,但又不能要求上层知道下层要写什么。
于是规矩定成了这样:
- 每一层只关心"我要在别人的数据前面加什么",加完就整体交给下一层;
- 下一层把这个"上层的东西"整体当成自己的数据,继续在前面加自己的头。
这样一来,上层不用知道以太网帧头有几个字节,下层也不用知道 IP 头里放了什么,层与层之间靠"整体当作数据"解耦。
2. 封装与解封装的对称性
发送方从下往上加头,接收方从上往下脱头,两边的顺序正好相反:
| 方向 | 顺序 |
|---|---|
| 发送(封装) | 应用数据 → 加 TCP/UDP 头 → 加 IP 头 → 加以太网头 → 发出 |
| 接收(解封装) | 收到帧 → 脱以太网头 → 脱 IP 头 → 脱 TCP/UDP 头 → 交给应用 |
每一层脱头后都会做一次判断:这个包是不是给我的? 判断依据就在该层自己的头里:
- 链路层看目的 MAC 是不是自己(或者是广播 / 组播);
- 网络层看目的 IP 是不是自己、是否允许转发;
- 传输层看目的端口有没有程序在监听。
只要有一层判断为"不是给我的",包就在这里被丢掉或者被转发出去,不会再往上走。
二、 以太网帧的字段构成
1. 帧头 14 字节
以太网(Ethernet II,也就是现在通用的 DIX 格式)帧的组成是固定的:
| 字段 | 长度 | 说明 |
|---|---|---|
| 目的 MAC | 6 字节 | 这一跳要把帧交给谁;广播为 FF:FF:FF:FF:FF:FF |
| 源 MAC | 6 字节 | 这一跳是谁发的 |
| 类型 | 2 字节 | 里面装的是什么:0x0800 = IPv4,0x0806 = ARP,0x86DD = IPv6 |
| 数据 | 46 ~ 1500 字节 | 上层的内容,最长 1500 字节(MTU) |
| 帧校验序列 FCS | 4 字节 | 32 位 CRC,由网卡硬件生成与校验 |
前面的"目的 MAC + 源 MAC + 类型"共 14 字节,就是常说的以太网头。
2. 帧尾的 FCS 与填充
两件事容易被忽略:
(1)FCS 不参与上层。它由发送网卡的硬件算出来、由接收网卡的硬件校验,协议栈里根本看不到这 4 个字节。所以抓包工具算出来的"帧长"通常不含 FCS。
(2)数据不足 46 字节要填充。以太网规定帧最短 64 字节(不含前导码),减掉 14 字节头与 4 字节 FCS,数据部分至少要有 46 字节。一个 ARP 报文只有 28 字节,剩余 18 字节就是填充出来的。
这一点在写驱动时有实际影响:收到只有 42 字节的 ARP 帧时,长度字段给出的是真实长度(42),后面的填充字节并不需要关心。
3. 类型字段是分流的依据
交换机之后的设备拿到一个帧,要做的第一件事就是看类型字段,决定交给谁处理。这正是 lwIP 里 ethernet_input() 干的事:
// Components/lwip-2.1.3/src/core/ipv4/ethernet.c
switch (type) {
case PP_HTONS(ETHTYPE_IP): /* 0x0800 → 交给 IP 层 */
case PP_HTONS(ETHTYPE_ARP): /* 0x0806 → 交给 ARP */在 LV030-ARP与MAC地址 里会看到,这个 switch 就是"ARP 与 IP 共存于同一块网卡"的实现方式。
三、 一次 ping 报文的逐层拆解
1. 从应用到链路
板子 Ping 应答时的完整报文体(不含填充与 FCS):
| 层级 | 头部长度 | 本包里的关键取值 |
|---|---|---|
| 以太网头 | 14 字节 | 目的 MAC = PC 的 MAC,源 MAC = 板子的 MAC,类型 = 0x0800 |
| IP 头 | 20 字节 | 源 IP = 192.168.16.30,目的 IP = 192.168.16.100,协议号 = 1(ICMP) |
| ICMP 头 | 8 字节 | 类型 = 0(应答)、代码 = 0、校验和、标识、序号 |
| ICMP 数据 | 可变 | ping 默认 32 或 56 字节,内容由 ping 程序自己填 |
2. 各层校验覆盖的范围
校验是分层的另一个体现——每层只校验自己该负责的部分:
| 校验 | 由谁负责 | 覆盖范围 |
|---|---|---|
| FCS | 网卡硬件 | 整个帧(不含前导码) |
| IP 头校验和 | IP 层(软件或硬件) | 只覆盖 IP 头,不含数据 |
| ICMP 校验和 | ICMP 层 | ICMP 头 + ICMP 数据 |
| TCP/UDP 校验和 | 传输层 | 头 + 数据 + 伪首部 |
本工程在 lwipopts.h 里把六个 CHECKSUM_* 全部打开,收发都校验,代价是每次收发多花一点 CPU——对 72 MHz 的 M3 来说这点开销换来的排错价值是值得的。
3. 协议号连接上下层
IP 头里的协议号字段决定了载荷交给谁,作用与以太网的类型字段完全一样:
| 协议号 | 交给谁 |
|---|---|
| 1 | ICMP |
| 6 | TCP |
| 17 | UDP |
所以一条完整的分流链是:
以太网类型 0x0800 ──▶ IP 层 ──协议号 1──▶ ICMP
以太网类型 0x0806 ──▶ ARP 层四、 MTU、最小帧长与分片
1. MTU 与 MSS
**MTU(最大传输单元)**指一层能承载的上层数据最大值。以太网的 MTU 是 1500 字节,所以一个 IP 包最长 1500 字节(含 IP 头)。
在 TCP 里还有一个相关概念 MSS(最大报文段长度):指一个 TCP 段最多能装多少数据。以太网下理论上限是 MTU - IP 头 - TCP 头 = 1500 - 20 - 20 = 1460 字节,但实际值由连接双方在握手时各自宣布、发送方按"对方宣布的 MSS"来切分。
对应到本工程,要区分两件事:
(1)上位机脚本 tools/send_iap_tcp.py 里 CHUNK = 1460,那只是脚本每次调用 sendall() 写入的字节数,不等于线上分段大小。
(2)真正决定线上分段的是板子在握手里宣布的 MSS——本工程 lwipopts.h 没定义 TCP_MSS,走 lwIP 默认值 536,所以 PC 的协议栈实际会按 ≤ 536 字节切分发送。两者的完整解释见 LV015-lwIP配置与初始化 第二节与 LV035-传输层端口UDP与TCP 第三节。
2. 超过 MTU 会怎样:分片
如果上层给下来的数据超过 MTU,IP 层有两种处理方式:
(1)分片(Fragmentation):把一个 IP 包拆成多个小包,每个都有自己的 IP 头,接收端再重组。开销是每个分片都要单独封装与校验,且任何一片丢失都要整包重传。
(2)由上层保证不超过 MTU:比如 TCP 自己按 MSS 切分。
本工程的做法是第二条,并且直接把分片功能关掉:
| 宏 | 取值 | 含义 |
|---|---|---|
IP_REASSEMBLY | 0 | 不做分片重组 |
IP_FRAG | 0 | 不主动分片 |
这样做的理由很直接:重组需要额外的缓冲与定时器,而本工程的 MEM_SIZE 只有 8 KB。关掉之后,一旦收到分片包会被直接丢弃——如果哪天真遇到"大包不通小包通",第一个要怀疑的就是这里。
3. 对驱动的约束
MTU 决定了驱动要准备多大的接收缓冲。ethernetif.c 把接口的 MTU 设成 1500 后,DM9000 驱动里给单个包预留的上限是 DM9000_PKT_MAX = 1536 字节(1500 + 14 以太网头 + 余量),见 LV005-DM9000驱动实现 第五节。
五、 lwIP 里的报文结构与收发缓冲
1. 头部结构体与协议一一对应
lwIP 把每个头部都定义成结构体,字段顺序与协议规定完全一致:
| 结构体 | 对应头部 | 声明位置 |
|---|---|---|
struct eth_hdr | 14 字节以太网头 | src/include/lwip/prot/ethernet.h |
struct ip_hdr | IP 头 | src/include/lwip/prot/ip4.h |
struct icmp_echo_hdr | ICMP 回显头 | src/include/lwip/prot/icmp.h |
struct tcp_hdr | TCP 头 | src/include/lwip/prot/tcp.h |
这组结构体必须按字节紧密排布,否则读到的字段会整体错位。这也是 port/arch/cc.h 里那组 PACK_STRUCT_* 宏不能省的原因,详见 LV010-lwIP协议栈移植 第二节。
2. pbuf:贯穿各层的缓冲结构
lwIP 用一个叫 pbuf 的结构在不同层之间传递数据。它的关键特点是支持链式:一个包可以由多个 pbuf 串起来,每个 pbuf 指向内存中一段连续区域。
这样做的好处是收包时不一定要把整包数据搬到一起:
- DM9000 驱动从 RX FIFO 读出数据后,从
PBUF_POOL里申请一个pbuf装进去; - 上层只在前面"预留"出头部空间(
pbuf_alloc的offset),要加头时直接在预留区写,不用搬动数据。
本工程把 PBUF_POOL_SIZE 定为 8、MEMP_NUM_PBUF 定为 8,也就是最多同时容纳 8 个池化 pbuf。池子用光时 pbuf_alloc 会返回 NULL,驱动里的表现就是"包被丢弃并打印 pbuf alloc failed"——这是排查丢包时最值得先看的一条计数。
六、 小结与参考资料
1. 本篇小结
- 封装让每层只需在数据前面加自己的头,层与层之间靠"整体当数据"解耦;收包时逐层脱头,每层都判断"是不是给我的"。
- 以太网帧 = 14 字节头(目的 MAC、源 MAC、类型)+ 46~1500 字节数据 + 4 字节 FCS;不足 46 字节要填充。
- 类型字段(
0x0800/0x0806)与 IP 协议号(1 / 6 / 17)是两级分流依据。 - 以太网 MTU 是 1500 字节,TCP 的 MSS 是 1460 字节,上位机按 MSS 切帧。
- 本工程关掉了
IP_REASSEMBLY与IP_FRAG,遇到分片包直接丢。 - lwIP 用
ip_hdr、eth_hdr等结构体解析头部,靠PACK_STRUCT_*宏保证紧密排布;数据用可链式的pbuf传递。
2. 参考资料
- 以太网的收发与分流:
ethernet.c - 协议栈裁剪(MTU 与分片相关宏):
lwipopts.h - 上位机切帧:
tools/send_iap_tcp.py - 手册:
STM32F1 LWIP开发手册(DM9000版)_V1.1.pdf第 1.1 节
本文档由 markdowncli 技能辅助生成