Skip to content

LV005-数据封装与以太网帧 ​

一、 为什么要逐层封装 ​

1. 封装解决了什么问题 ​

上一篇说了"分层",但分层只是说了"谁管什么",真正让各层能拼起来工作的机制是封装。

封装要解决的是一个很朴素的矛盾:每一层都需要在数据前面写一点自己的信息,但又不能要求上层知道下层要写什么。

于是规矩定成了这样:

  • 每一层只关心"我要在别人的数据前面加什么",加完就整体交给下一层;
  • 下一层把这个"上层的东西"整体当成自己的数据,继续在前面加自己的头。

这样一来,上层不用知道以太网帧头有几个字节,下层也不用知道 IP 头里放了什么,层与层之间靠"整体当作数据"解耦。

2. 封装与解封装的对称性 ​

发送方从下往上加头,接收方从上往下脱头,两边的顺序正好相反:

方向顺序
发送(封装)应用数据 → 加 TCP/UDP 头 → 加 IP 头 → 加以太网头 → 发出
接收(解封装)收到帧 → 脱以太网头 → 脱 IP 头 → 脱 TCP/UDP 头 → 交给应用

每一层脱头后都会做一次判断:这个包是不是给我的? 判断依据就在该层自己的头里:

  • 链路层看目的 MAC 是不是自己(或者是广播 / 组播);
  • 网络层看目的 IP 是不是自己、是否允许转发;
  • 传输层看目的端口有没有程序在监听。

只要有一层判断为"不是给我的",包就在这里被丢掉或者被转发出去,不会再往上走。

二、 以太网帧的字段构成 ​

1. 帧头 14 字节 ​

以太网(Ethernet II,也就是现在通用的 DIX 格式)帧的组成是固定的:

字段长度说明
目的 MAC6 字节这一跳要把帧交给谁;广播为 FF:FF:FF:FF:FF:FF
源 MAC6 字节这一跳是谁发的
类型2 字节里面装的是什么:0x0800 = IPv4,0x0806 = ARP,0x86DD = IPv6
数据46 ~ 1500 字节上层的内容,最长 1500 字节(MTU)
帧校验序列 FCS4 字节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() 干的事:

c
// 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 程序自己填
以太网帧、IP 头与 ICMP 报文的字段与长度

2. 各层校验覆盖的范围 ​

校验是分层的另一个体现——每层只校验自己该负责的部分:

校验由谁负责覆盖范围
FCS网卡硬件整个帧(不含前导码)
IP 头校验和IP 层(软件或硬件)只覆盖 IP 头,不含数据
ICMP 校验和ICMP 层ICMP 头 + ICMP 数据
TCP/UDP 校验和传输层头 + 数据 + 伪首部

本工程在 lwipopts.h 里把六个 CHECKSUM_* 全部打开,收发都校验,代价是每次收发多花一点 CPU——对 72 MHz 的 M3 来说这点开销换来的排错价值是值得的。

3. 协议号连接上下层 ​

IP 头里的协议号字段决定了载荷交给谁,作用与以太网的类型字段完全一样:

协议号交给谁
1ICMP
6TCP
17UDP

所以一条完整的分流链是:

text
以太网类型 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_REASSEMBLY0不做分片重组
IP_FRAG0不主动分片

这样做的理由很直接:重组需要额外的缓冲与定时器,而本工程的 MEM_SIZE 只有 8 KB。关掉之后,一旦收到分片包会被直接丢弃——如果哪天真遇到"大包不通小包通",第一个要怀疑的就是这里。

3. 对驱动的约束 ​

MTU 决定了驱动要准备多大的接收缓冲。ethernetif.c 把接口的 MTU 设成 1500 后,DM9000 驱动里给单个包预留的上限是 DM9000_PKT_MAX = 1536 字节(1500 + 14 以太网头 + 余量),见 LV005-DM9000驱动实现 第五节。

五、 lwIP 里的报文结构与收发缓冲 ​

1. 头部结构体与协议一一对应 ​

lwIP 把每个头部都定义成结构体,字段顺序与协议规定完全一致:

结构体对应头部声明位置
struct eth_hdr14 字节以太网头src/include/lwip/prot/ethernet.h
struct ip_hdrIP 头src/include/lwip/prot/ip4.h
struct icmp_echo_hdrICMP 回显头src/include/lwip/prot/icmp.h
struct tcp_hdrTCP 头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. 参考资料 ​


本文档由 markdowncli 技能辅助生成