目录
- ARP 协议
- ARP 协议的简介
- ARP 协议的工作流程(获取目标设备 MAC 地址步骤)
- ARP 缓存表的超时处理
- APR 报文的报文结构
- ARP 协议层的接收与发送原理解析
- 发送ARP 请求数据包
- 接收ARP 应答数据包
- IP 协议
- IP 协议的简介
- IP 数据报
- IP 数据报结构
- IP 数据报的分片解析
- IP 数据报的分片重装(后包先置情况处理)
- IP 数据报的输出
- IP 数据报的输入
- ICMP 协议(控制型报文)
- ICMP 协议简介
- ICMP 报文类型
- ICMP 报文结构
- ICMP 的实现
- ICMP 数据结构体
- 发送ICMP 差错报文
- ICMP 报文处理
- RAW 编程接口UDP 实验
- UDP 协议简介
- UDP 报文的数据结构
- UDP 报文接收
- RAW 的UDP 接口简介
- RAW 的UDP 实验
- 硬件设计
- 软件设计
- 下载验证
- RAW 接口编程 TCP 客户端实验
- TCP 协议
- TCP 协议简介
- TCP 的建立连接
- TCP 终止连接
- TCP 报文结构
- lwIP 的 TCP 报文首部数据结构
- lwIP 的TCP 连接状态图
- lwIP 的TCP 控制块
- lwIP 的TCP 编程
- lwIP 的TCP 建立与关闭连接原理
- lwIP 中 RAW API 编程接口中与 TCP 相关的函数
- RAW 接口的 TCP 实验
- 硬件设计
- 软件设计
- 下载验证
- RAW 编程接口TCP 服务器实验
- RAW 编程接口TCP 简介
- RAW 接口的TCP 实验
- 硬件设计
- 软件设计
- 下载验证
- RAW 编程接口Web Server 实验
- Web Server 文件以及相关技术简介
- Web Server 实验
- 硬件设计
- 软件设计
- 下载验证
ARP 协议
ARP 协议的简介
ARP 全称为 Address Resolution Protocol(地址解析协议),是根据 IP 地址获取物理地址的一个TCP/IP 协议。
主机发送信息时将包含目标 IP 地址的 ARP 请求广播到局域网络上的所有主机,并接收返回消息,以此确定目标的物理地址;收到返回消息后将该 IP 地址和物理地址存入本机ARP 缓存中并保留一定时间,下次请求时直接查询 ARP 缓存以节约资源。地址解析协议是建立在网络中各个主机互相信任的基础上的,局域网络上的主机可以自主发送 ARP 应答消息,其他主机收到应答报文时不会检测该报文的真实性就会将其记入本机ARP 缓存;
总的来说,ARP 协议是透过目标设备的 IP 地址,查询目标设备的MAC 地址,保证通信的顺利进行。
ARP 协议的工作流程(获取目标设备 MAC 地址步骤)
假设两台主机,分别为主机A(192.168.0.10)与主机B(192.168.0.11),它们两个都是同一网段的,如果主机 A 向主机 B 发送信息或者数据,ARP 的地址解析过程有以下几个步骤:
-
①主机A 首先查自己的 ARP 表是否有包含主机 B 的信息,例如主机 B 的 MAC 地址,如果主机 A 的 ARP 表包含主机 B 的 MAC 地址,则主机A 直接利用 ARP 表的主机 B 的 MAC 地址对 IP 数据包进行封装并把数据包发给主机B。
-
②如果主机A 的ARP 表不包含主机B 的MAC 地址或者没有找到主机B 的MAC 地址,则主机A 就把数据包缓存起来,然后以广播的方式发送一个ARP 包的请求报文,该ARP 包的内容包含主机A 的IP 地址、MAC 地址、主机B 的IP 地址和主机B 的全0 的MAC 地址,由于主机A 发送ARP 包是使用广播形式,那么同一网段的主机都可以收到该ARP 包,主机B接收到这个ARP 包会进行处理。
-
③主机B 接收到主机A 的ARP 包之后,主机B 会对这个ARP 解析并比较自己的IP 地址和ARP 包的目的IP 地址是否相同,如果相同,则主机B 将ARP 请求报文中的发送端(即主机A)的IP 地址和MAC 地址存入自己的ARP 表中。之后以单播方式发送ARP 响应报文给主机A,其中包含了自己的MAC 地址。
-
④当主机A 收到了主机B 的ARP 包也是同样的处理,首先比较ARP 包的IP 地址是否和自己的IP 地址相同,如果IP 地址相同,则把ARP 包的信息存入自己的ARP 表中,最后对IP数据包进行封装并把数据包发给主机B。
从上述步骤的内容,可得到ARP 包的流程图,如下图所示:
可以看到,主机A 发送数据之前先判断主机A 的ARP 缓存表是否包含主机B 的MAC 地址,若主机A 的ARP 缓存表没有主机B 的MAC 地址,则主机A 把要发送的数据挂起并发送一个ARP 请求包,发送完成之后等待主机B 应答,直到收到主机B 的应答包之后才把挂起的数据包添加以太网首部发送至主机B 当中。
lwIP 描述ARP 缓存表和ARP 相关处理函数由etharp.c/h 文件定义,下面笔者重点讲解ARP 缓存表的表项信息和挂起流程。ARP 缓存表结构如下所示:
struct etharp_entry
{
#if ARP_QUEUEING
/* 数据包缓存队列指针*/
struct etharp_q_entry *q;
#else /* ARP_QUEUEING */
/* 指向此ARP表项上的单个挂起数据包的指针*/
struct pbuf *q;
#endif /* ARP_QUEUEING */
ip4_addr_t ipaddr; /* 目标IP 地址*/
struct netif *netif; /* 对应网卡信息*/
struct eth_addr ethaddr; /* 对应的MAC 地址*/
u16_t ctime; /* 生存时间信息*/
u8_t state; /* 表项的状态*/
};
static struct etharp_entry arp_table[ARP_TABLE_SIZE];
可以看出,ARP 缓存表(arp_table)最大存放10 个表项,每一个表项描述符了IP 地址映射MAC 地址的信息和表项生存时间与状态。这个ARP 缓存表很小,lwIP 根据传入的目标IP地址对ARP 缓存表直接采用遍历方式查找对应的MAC 地址。
注:每一个表项都有一个生存时间,若超出了自身生存时间,则lwIP 内核会把这个表项删除,这里用到了超时处理机制。
每一个表项从创建、请求等都设置了一个状态,不同状态的表项都需要特殊的处理,这些状态如下所示:
enum etharp_state {
ETHARP_STATE_EMPTY = 0,
ETHARP_STATE_PENDING,
ETHARP_STATE_STABLE,
ETHARP_STATE_STABLE_REREQUESTING_1,
ETHARP_STATE_STABLE_REREQUESTING_2
};
下面笔者讲解一下每一个表项的作用及任务。
-
(1) ETHARP_STATE_EMPTY 状态
这个状态表示ARP 缓存表处于初始化的状态,所有表项初始化之后才可以被使用,如果需要添加表项,lwIP 内核就会遍历ARP 缓存表并找到合适的表项进行添加。 -
(2) ETHARP_STATE_PENDING 状态
该状态表示该表项处于不稳定状态,此时该表项只记录到了IP 地址,但是还未记录到对应的MAC 地址。很可能的情况是:lwIP 内核已经发出一个关于该IP 地址的ARP 请求到数据链路上且lwIP 内核还未收到ARP 应答,此时ETHARP_STATE_PENDING 状态下会设定超时时间(5 秒),当计数超时后,对应的表项将被删除,超时时间需要宏定义ARP_MAXPENDING 来指定,默认为5 秒,如果在5 秒之前收到应答数据包,那么系统会更新缓存表的信息,记录目标IP 地址与目标MAC 地址的映射关系并且开始记录表项的生存时间,同时该表项的状态会变成ETHARP_STATE_STABLE 状态。 -
(3) ETHARP_STATE_STABLE 状态
当收到应答之前,这些数据包会暂时挂载到表项的数据包缓冲队列上,收到应答之后,系统已经更新ARP 缓存表,那么系统发送数据就会进入该状态 -
(4)ETHARP_STATE_STABLE_REREQUESTING_1&ÐARP_STATE_STABLE_REREQUESTING_2 状态如果系统再一次发送ARP 请求数据包,则表项状态会暂时被设置为 ETHARP_STATE_ST
ABLE_REREQUESTING_1,之后设置为ETHARP_STATE_STABLE_REREQUESTING_2 状态,其实这两个状态为过渡状态,如果5 秒之前收到ARP 应答后,表项又会被设置为ETHARP_S
TATE_STABLE 状态,这样子能保持表项的有效性。
这些状态也是和超时处理相关,在ARP 超时事件中,需要定时遍历ARP 缓存表各个表项的状态和检测各个表项的生存时间。稍后笔者也会讲解ARP 超时事件的作用。
表项挂起数据包之前讲解过,lwIP 发送数据包时需要检测ARP 缓存表是否包含对方主机的MAC 地址,若ARP 缓存表没有包含对方主机的MAC 地址,则lwIP 内核在ARP 缓存表上创建一个表项并且构建一个ARP 请求包,发送完成之后lwIP 内核把要发送的数据包挂载到新创建的表项当中。在表项中包含了etharp_q_entry 结构体和pbuf 结构体指针,这两个都是用来挂载数据包的,一般来说,lwIP 内核不使用etharp_q_entry 结构体挂载数据包,而是直接使用指针指向pbuf 数据包,下面笔者使用一张图来描述上面的内容。
ARP 缓存表的超时处理
上一个小节写了这么多,无非就是为了ARP 表项的ctime(生存时间)这个参数准备的,其实这个参数笔者在上面也有所涉及,因为系统以周期的形式调用函数etharp_trm。例如,5秒之前收到ARP 的应答包就会更新ARP 缓存表,这个函数的作用就是使每个ARP 缓存表项ctime 字段加1 处理,如果某个表项的生存时间计数值大于系统规定的某个值,系统就会删除该表项。etharp_trm 函数如下所示:
void etharp_tmr(void) {
u8_t i;
/* 第一步:ARP缓存表遍历,ARP_TABLE_SIZE = 10 */
for (i = 0; i < ARP_TABLE_SIZE; ++i) {
/* 获取表项的状态*/
u8_t state = arp_table[i].state;
/* 第二步:判断该状态不等于空(初始化的状态)*/
if (state != ETHARP_STATE_EMPTY) {
/* ARP缓存表项的生存时间+1 */
arp_table[i].ctime++;
/* 第三步:发送ARP请求数据包并判断ctime是否大于5秒*/
if ((arp_table[i].ctime >= ARP_MAXAGE) ||
((arp_table[i].state == ETHARP_STATE_PENDING) &&
(arp_table[i].ctime >= ARP_MAXPENDING))) {
/* 从ARP缓存表中删除该表项*/
etharp_free_entry(i);
} else if (arp_table[i].state == ETHARP_STATE_STABLE_REREQUESTING_1) {
/* 这是一个过度形式*/
arp_table[i].state = ETHARP_STATE_STABLE_REREQUESTING_2;
} else if (arp_table[i].state == ETHARP_STATE_STABLE_REREQUESTING_2) {
/* 将状态重置为稳定状态,使下一个传输的数据包将
重新发送一个ARP请求*/
arp_table[i].state = ETHARP_STATE_STABLE;
} else if (arp_table[i].state == ETHARP_STATE_PENDING) {
/* 仍然挂起,重新发送一个ARP查询*/
etharp_request(arp_table[i].netif, & arp_table[i].ipaddr);
}
}
}
}
此函数非常简单,这里笔者使用一个流程图来讲解这个函数的实现流程,如下图所示:
从上图可以看出,这些ARP 缓存表的表项都会定期检测,如果这些表项超时最大生存时间,那么lwIP 内核会把这些表项统一删除。
APR 报文的报文结构
典型的ARP 报文结构,该结构如下图所示:
左边的是以太网首部,数据发送时必须添加以太网首部,添加完成之后才能把数据发往到网络当中(这里解答了为什么需要对方主机的MAC 地址),而右边是ARP 报文结构,它一共定义了5 个字段,它们分别为:
- 硬件类型:如果这个类型设置为1 表示以太网MAC 地址。
- 协议类型:表示要映射的协议地址类型,0x0800–映射为IP 地址。
- 硬件地址长度和协议地址长度:以太网ARP 请求和应答分别设置为6 和4,它们代表MAC 地址长度和IP 地址长度。在ARP 协议包中留出硬件地址长度字段和协议地址长度字段可以使得ARP 协议在任何网络中被使用,而不仅仅只在以太网中。
- op:ARP 数据包的类型,ARP 请求设置为1,ARP 应答设置为2。
- 剩下的字段就是填入本地IP 地址与本地MAC 地址和目标IP 地址与目标MAC 地址。
关于ARP 报文结构可在ethernet.h 找到一些数据结构和宏描述,如下所示:
/**********************************ethernet.h********************************/ #
define ETH_HWADDR_LEN 6 /* 以太网地址长度*/
struct eth_addr { /* 一个以太网MAC地址*/
PACK_STRUCT_FLD_8(u8_t addr[ETH_HWADDR_LEN]);
}
PACK_STRUCT_STRUCT;
struct eth_hdr { /* 以太网首部*/ #
if ETH_PAD_SIZE
PACK_STRUCT_FLD_8(u8_t padding[ETH_PAD_SIZE]);#
endif
PACK_STRUCT_FLD_S(struct eth_addr dest); /* 以太网目标地址(6字节) */
PACK_STRUCT_FLD_S(struct eth_addr src); /* 以太网源MAC 地址(6字节) */
PACK_STRUCT_FIELD(u16_t type); /* 帧类型(2字节) */
}
PACK_STRUCT_STRUCT;
/***********************************etharp.h**********************************/
struct etharp_hdr { /* ARP 报文*/
/* ARP 报文首部*/
PACK_STRUCT_FIELD(u16_t hwtype); /* 硬件类型(2字节) */
PACK_STRUCT_FIELD(u16_t proto); /* 协议类型(2字节) */
PACK_STRUCT_FLD_8(u8_t hwlen); /* 硬件地址长度(1字节) */
PACK_STRUCT_FLD_8(u8_t protolen); /* 协议地址长度(2字节) */
PACK_STRUCT_FIELD(u16_t opcode); /* op 字段(2字节) */
PACK_STRUCT_FLD_S(struct eth_addr shwaddr); /* 源MAC 地址(6字节) */
PACK_STRUCT_FLD_S(struct ip4_addr2 sipaddr); /* 源ip 地址(4字节) */
PACK_STRUCT_FLD_S(struct eth_addr dhwaddr); /* 目标MAC 地址(6字节) */
PACK_STRUCT_FLD_S(struct ip4_addr2 dipaddr); /* 目标ip 地址(4字节) */
}
PACK_STRUCT_STRUCT;
/* op 字段操作*/
enum etharp_opcode {
ARP_REQUEST = 1, /* 请求包*/
ARP_REPLY = 2 /* 应答包*/
};
前面的eth_hdr 结构体就是定义了以太网首部字段,而etharp_hdr 结构体定义了ARP 首部的字段信息。下面笔者使用wireshark 网络抓包工具形象地讲解报文格式和内容,如下图所示:
从这两张图可以看出,图一的ARP 数据包是以广播的方式发送,它的OP 字段类型为1表示ARP 数据包为ARP 请求包。图二的ARP 数据包为ARP 应答包,因为它的OP 字段为2,所以该包不是以广播的方式发送。
ARP 协议层的接收与发送原理解析
发送ARP 请求数据包
构建ARP 请求包函数是在etharp_raw 函数下实现,该函数如下所示:
static err_t
etharp_raw(struct netif * netif, /* 发送ARP 数据包的lwip 网络接口*/
const struct eth_addr * ethsrc_addr, /* 以太网源MAC 地址*/
const struct eth_addr * ethdst_addr, /* 以太网目标MAC 地址*/
const struct eth_addr * hwsrc_addr, /* ARP 协议源MAC 地址*/
const ip4_addr_t * ipsrc_addr, /* ARP 协议源IP 地址*/
const struct eth_addr * hwdst_addr, /* ARP 协议目标MAC 地址*/
const ip4_addr_t * ipdst_addr, /* ARP 协议目标IP 地址*/
const u16_t opcode) /* ARP 数据包的类型:1为请求包类型、2为应答包类型*/ {
struct pbuf * p;
err_t result = ERR_OK;
struct etharp_hdr * hdr;
/* 申请ARP 报文的内存池空间*/
p = pbuf_alloc(PBUF_LINK, SIZEOF_ETHARP_HDR, PBUF_RAM);
/* 申请内存池是否成功*/
if (p == NULL) {
ETHARP_STATS_INC(etharp.memerr);
return ERR_MEM;
}
/* ARP 报文的数据区域,并且强制将起始地址转化成ARP 报文首部*/
hdr = (struct etharp_hdr * ) p - > payload;
/* ARP 数据包的op 字段*/
hdr - > opcode = lwip_htons(opcode);
/* 源MAC地址*/
SMEMCPY( & hdr - > shwaddr, hwsrc_addr, ETH_HWADDR_LEN);
/* 目的MAC地址*/
SMEMCPY( & hdr - > dhwaddr, hwdst_addr, ETH_HWADDR_LEN);
/* 源IP地址*/
IPADDR_WORDALIGNED_COPY_FROM_IP4_ADDR_T( & hdr - > sipaddr, ipsrc_addr);
/* 目的IP地址*/
IPADDR_WORDALIGNED_COPY_FROM_IP4_ADDR_T( & hdr - > dipaddr, ipdst_addr);
/* 硬件类型*/
hdr - > hwtype = PP_HTONS(HWTYPE_ETHERNET);
/* 协议类型*/
hdr - > proto = PP_HTONS(ETHTYPE_IP);
/* 硬件地址长度*/
hdr - > hwlen = ETH_HWADDR_LEN;
/* 协议地址长度*/
hdr - > protolen = sizeof(ip4_addr_t);#
if LWIP_AUTOIP
if (ip4_addr_islinklocal(ipsrc_addr)) {
ethernet_output(netif, p, ethsrc_addr, & ethbroadcast, ETHTYPE_ARP);
} else# endif /* LWIP_AUTOIP */ {
/* 调用底层发送函数将以太网数据帧发送出去*/
ethernet_output(netif, p, ethsrc_addr, ethdst_addr, ETHTYPE_ARP);
}
ETHARP_STATS_INC(etharp.xmit);
/* 发送完成释放内存*/
pbuf_free(p);
p = NULL;
/* 发送完成返回结果*/
return result;
}
/* 定义以太网广播地址*/
const struct eth_addr ethbroadcast = {
{
0xff, 0xff, 0xff, 0xff, 0xff, 0xff
}
};
/* 填写ARP请求包的接收方MAC字段*/
const struct eth_addr ethzero = {
{
0, 0, 0, 0, 0, 0
}
};
static err_t
etharp_request_dst(struct netif * netif,
const ip4_addr_t * ipaddr,
const struct eth_addr * hw_dst_addr) {
return etharp_raw(netif, (struct eth_addr * ) netif - > hwaddr, hw_dst_addr, (struct eth_addr * ) netif - > hwaddr,
netif_ip4_addr(netif), & ethzero,
ipaddr, ARP_REQUEST);
}
/* 发送一个要求ipaddr的ARP请求包*/
err_t
etharp_request(struct netif * netif,
const ip4_addr_t * ipaddr) {
return etharp_request_dst(netif, ipaddr, & ethbroadcast);
}
发送 ARP 请求报文之前先申请pbuf 内存,接着由pbuf 的payload 指针指向的地址添加ARP 首部,添加完成之后设置ARP 首部字段的信息,最后由ethernet_output 函数为pbuf 添加以太网首部和发送,发送完成之后把要发送的数据挂载到ARP 缓存表项当中。
接收ARP 应答数据包
虽然ARP 和IP 协议同属于网络层的协议,但是从分层的结构来看,ARP 处于网络层的最底层,而IP 处于网络层的顶层。总的来说,ARP 最接近网卡驱动文件,发送的数据经过ARP检测和操作发送至网卡驱动文件处理,由网卡驱动文件调用ETH 外设把数据发送至PHY 设备当中。
下面笔者来讲解网卡驱动文件的函数如何把接收的数据发送至ARP 或者IP 处理,这个函数为ethernet_input,如下所示:
err_t
ethernet_input(struct pbuf * p, struct netif * netif) {
struct eth_hdr * ethhdr;
u16_t type;#
if LWIP_ARP || ETHARP_SUPPORT_VLAN || LWIP_IPV6
s16_t ip_hdr_offset = SIZEOF_ETH_HDR;#
endif /* LWIP_ARP || ETHARP_SUPPORT_VLAN */
/* 第一步:判断数据包是否小于等于以太网头部的大小
如果是,则释放内存,直接返回*/
if (p - > len <= SIZEOF_ETH_HDR) {
ETHARP_STATS_INC(etharp.proterr);
ETHARP_STATS_INC(etharp.drop);
MIB2_STATS_NETIF_INC(netif, ifinerrors);
goto free_and_return;
}
if (p - > if_idx == NETIF_NO_INDEX) {
p - > if_idx = netif_get_index(netif);
}
/* 第二步:p->payload表示指向缓冲区中实际数据的指针
相当于指向以太网的头部*/
ethhdr = (struct eth_hdr * ) p - > payload;
/* 第三步:获取数据包的类型*/
type = ethhdr - > type;#
if LWIP_ARP_FILTER_NETIF
netif = LWIP_ARP_FILTER_NETIF_FN(p, netif, lwip_htons(type));#
endif /* LWIP_ARP_FILTER_NETIF*/
/* 第四步:判断数据包是以怎么样的类型发来的*/
if (ethhdr - > dest.addr[0] & 1) {
/* 这可能是一个多播或广播包*/
if (ethhdr - > dest.addr[0] == LL_IP4_MULTICAST_ADDR_0) {#
if LWIP_IPV4
if ((ethhdr - > dest.addr[1] == LL_IP4_MULTICAST_ADDR_1) &&
(ethhdr - > dest.addr[2] == LL_IP4_MULTICAST_ADDR_2)) {
/* 将pbuf标记为链路层多播*/
p - > flags |= PBUF_FLAG_LLMCAST;
}#
endif /* LWIP_IPV4 */
} else if (eth_addr_cmp( & ethhdr - > dest, & ethbroadcast)) {
/* 将pbuf标记为链路层广播*/
p - > flags |= PBUF_FLAG_LLBCAST;
}
}
/* 第五步:判断数据包的类型*/
switch (type) {#
if LWIP_IPV4 && LWIP_ARP
/* IP数据包*/
case PP_HTONS(ETHTYPE_IP):
if (!(netif - > flags & NETIF_FLAG_ETHARP)) {
goto free_and_return;
}
/* 去除以太网报头*/
if ((p - > len < ip_hdr_offset) ||
pbuf_header(p, (s16_t) - ip_hdr_offset)) { /* 去除以太网首部失败,则直接返回*/
goto free_and_return;
} else {
/* 传递到IP 协议去处理*/
ip4_input(p, netif);
}
break;
/* 对于是ARP 包*/
case PP_HTONS(ETHTYPE_ARP):
if (!(netif - > flags & NETIF_FLAG_ETHARP)) {
goto free_and_return;
}
/* 去除以太网首部*/
if ((p - > len < ip_hdr_offset) ||
pbuf_header(p, (s16_t) - ip_hdr_offset)) { /* 去除以太网首部失败,则直接返回*/
ETHARP_STATS_INC(etharp.lenerr);
ETHARP_STATS_INC(etharp.drop);
goto free_and_return;
} else {
/* 传递到ARP 协议处理*/
etharp_input(p, netif);
}
break;#
endif /* LWIP_IPV4 && LWIP_ARP */
default:
#ifdef LWIP_HOOK_UNKNOWN_ETH_PROTOCOL
if (LWIP_HOOK_UNKNOWN_ETH_PROTOCOL(p, netif) == ERR_OK) {
break;
}#
endif
ETHARP_STATS_INC(etharp.proterr);
ETHARP_STATS_INC(etharp.drop);
MIB2_STATS_NETIF_INC(netif, ifinunknownprotos);
goto free_and_return;
}
return ERR_OK;
free_and_return:
pbuf_free(p);
return ERR_OK;
}
为了理解整个以太网的数据帧在ARP 层处理,笔者就以图形展示整个数据包递交流程,
如下图所示
可以看出,数据包在ethernet_input 中需要判断该数据包的类型,若该数据包的类型为IP数据包,则lwIP 内核把该数据包递交给ip4_input 函数处理。若该数据包的类型为ARP 数据包,则lwIP 内核把该数据包递交给etharp_input 函数处理,递交完成之后该函数需要判断ARP 数据包的类型,如果它是ARP 请求包,则lwIP 内核调用etharp_raw 函数构建ARP 应答包并且更新ARP 缓存表;如果它是ARP 应答包,则lwip 内核更新ARP 缓存表并且把表项挂载的数据包以ethernet_output 函数发送。
IP 协议
IP 指网际互连协议,Internet Protocol 的缩写,是TCP/IP 体系中的网络层协议。设计 IP 的目的是提高网络的可扩展性:
-
一是解决互联网问题,实现大规模、异构网络的互联互通;
-
二是分割顶层网络应用和底层网络技术之间的耦合关系,以利于两者的独立发展。
-
根据端到端的设计原则,IP 只为主机提供一种无连接、不可靠的、尽力而为的数据包传输服务。
IP 协议的简介
IP 协议是整个TCP/IP 协议族的核心,也是构成互联网的基础。IP 位于TCP/IP 模型的网络层(相当于OSI 模型的网络层),它可以向传输层提供各种协议的信息,例如TCP、UDP 等;
对下可将IP 信息包放到链路层,通过以太网、令牌环网络等各种技术来传送。为了能适应异构网络,IP 强调适应性、简洁性和可操作性,并在可靠性做了一定的牺牲。这里我们不过多深入了解IP 协议了,本章笔者重点讲解IP 数据报的分片与重组原理。
IP 数据报
IP 层数据报也叫做 IP 数据报或者 IP 分组,IP 数据报组装在以太网帧中发送的,它通常由两个部分组成,即 IP 首部与数据区域,其中IP 的首部是20 字节大小,数据区域理论上可以多达 65535 个字节,由于以太网网络接口的最大传输单元为1500,所以一个完整的数据包不能超出 1500 字节。IP 数据报结构如以下图所示:
-
(1) 版本:占4 位指IP 协议的版本。通信双方使用的IP 协议版本必须一致。广泛使用的IP 协议版本号为4(即IPv4)。
-
(2) 首部长度:占4 位可表示的最大十进制数值是15。请注意,这个字段所表示数的单位是32 位字长(1 个32 位字长是4 字节),因此,当IP 的首部长度为1111 时(即十进制的15),首部长度就达到60 字节。当IP 分组的首部长度不是4 字节的整数倍时,必须利用最后的填充字段加以填充。因此数据部分永远在4 字节的整数倍开始,这样在实现IP 协议时较为方便。
首部长度限制为60 字节的缺点是有时可能不够用。但这样做是希望用户尽量减少开销。最常用的首部长度就是20 字节(即首部长度为0101),这时不使用任何选项。 -
(3) 区分服务:占8 位,用来获得更好的服务。这个字段在旧标准中叫做服务类型,但实际上一直没有被使用过。
-
(4) 总长度:总长度指首部和数据之和的长度,单位为字节。总长度字段为16 位,因此数据报的最大长度为2^16-1=65534 字节。
在IP 层下面的每一种数据链路层都有自己的帧格式,其中包括帧格式中的数据字段的最大长度,这称为最大传送单元MTU。当一个数据报封装成链路层的帧时,此数据报的总长度(即首部加上数据部分)一定不能超过下面的数据链路层的MTU 值。 -
(5) 标识(identification):占16 位IP 软件在存储器中维持一个计数器,每产生一个数据报,计数器就加1,并将此值赋给标识字段。但这个“标识”并不是序号,因为IP 是无连接服务,数据报不存在按序接收的问题。当数据报由于长度超过网络的MTU 而必须分片时,这个标识字段的值就被复制到所有的数据报的标识字段中。相同的标识字段的值使分片后的各数据报片最后能正确地重装成为原来的数据报。
-
(6) 标志(flag):占3 位但只有2 位有意义的。
- 标志字段中的最低位记为MF(More Fragment)。MF=1 即表示后面“还有分片”的数据报。MF=0 表示这已是若干数据报片中的最后一个。
- 标志字段中间的一位记为DF(Don’t Fragment),意思是“不能分片”。只有当DF=0时才允许分片。
- (7) 片偏移:占13 位片偏移指出:较长的分组在分片后,某片在原分组中的相对位置。也就是说,相对用户数据字段的起点,该片从何处开始。片偏移以8 个字节为偏移单位。这就是说,除了最后一个分片,每个分片的长度一定是8 字节(64 位)的整数倍。
- (8) 生存时间:占8 位生存时间字段常用的的英文缩写是TTL(Time To Live),表明是数据报在网络中的寿命。由发出数据报的源点设置这个字段。其目的是防止无法交付的数据报无限制地在因特网中兜圈子,因而白白消耗网络资源。最初的设计是以秒作为TTL 的单位。每经过一个路由器时,就把TTL 减去数据报在路由器消耗掉的一段时间。若数据报在路由器消耗的时间小于1 秒,就把TTL 值减1。当TTL 值为0 时,就丢弃这个数据报。后来把TTL 字段的功能改为“跳数限制”(但名称不变)。路由器在转发数据报之前就把TTL 值减1.若TTL 值减少到零,就丢弃这个数据报,不再转发。因此,TTL 的单位不再是秒,而是跳数。TTL 的意义是指明数据报在网络中至多可经过多少个路由器。显然,数据报在网络上经过的路由器的最大数值是255。若把TTL 的初始值设为1,就表示这个数据报只能在本局域网中传送。
- (9) 协议:占8 位协议字段指出此数据报携带的数据是使用何种协议,以便使目的主机的IP 层知道应将数据部分上交给哪个处理过程。
- (10) 首部检验和:占16 位这个字段只检验数据报的首部,但不包括数据部分。这是因为数据报每经过一个路由器,路由器都要重新计算一下首部检验和(一些字段,如生存时间、标志、片偏移等都可能发生变化)。不检验数据部分可减少计算的工作量。
- (11) 源地址:占32 位。
- (12) 目的地址:占32 位。
- (13) 数据区域:这是IP 数据报的最后的一个字段,也是最重要的内容,lwIP 发送数据报是把该层的首部封装到数据包里面,在IP 层也是把IP 首部封装在其中,因为有数据区域才会有数据报首部的存在,在大多数情况下,IP 数据报中的数据字段包含要交付给目标IP 地址的运输层(TCP 协议或UDP 协议),当然数据区域也可承载其他类型的报文,如ICMP 报文等。
IP 数据报结构
在lwIP 中,为了描述 IP 报文结构,它在ip4.h 文件中定义了一个ip_hdr 结构体来描述IP
数据报的内容,该结构体如下所示:
struct ip_hdr {
/* 版本号+首部长度+服务类型*/
PACK_STRUCT_FLD_8(u8_t _v_hl);
/* 服务类型*/
PACK_STRUCT_FLD_8(u8_t _tos);
/* 总长度(IP首部+数据区) */
PACK_STRUCT_FIELD(u16_t _len);
/* 数据包标识(编号) */
PACK_STRUCT_FIELD(u16_t _id);
/* 标志+片偏移*/
PACK_STRUCT_FIELD(u16_t _offset);
/* IP首部标志定义*/
#define IP_RF 0x8000 U /* 保留*/ # define IP_DF 0x4000 U /* 是否允许分片*/ # define IP_MF 0x2000 U /* 后续是否还有更多分片*/ # define IP_OFFMASK 0x1fff U /* 片偏移域掩码*/
/* 生存时间(最大转发次数)+协议类型(IGMP:1、UDP:17、TCP:6) */
PACK_STRUCT_FLD_8(u8_t _ttl);
/* 协议*/
PACK_STRUCT_FLD_8(u8_t _proto);
/* 校验和(IP首部) */
PACK_STRUCT_FIELD(u16_t _chksum);
/* 源IP地址/目的IP地址*/
PACK_STRUCT_FLD_S(ip4_addr_p_t src);
PACK_STRUCT_FLD_S(ip4_addr_p_t dest);
}
PACK_STRUCT_STRUCT;
PACK_STRUCT_END
可以看出,此结构体的成员变量和上图9.2.1 的字段一一对应。
IP 数据报的分片解析
TCP/IP 协议栈为什么具备分片的概念,因为应用程序处理的数据是不确定的,可能超出网络接口最大传输单元,为此TCP/IP 协议栈引入了分片概念,它是以MTU 为界限对这个大型的数据切割成多个小型的数据包。这些小型的数据叫做IP 的分组和分片,它们在接收方进行重组处理,这样,接收方的应用程序接收到这个大型的数据了。总的来讲,IP 数据报的分片概念是为了解决IP 数据报数据过大的问题而诞生。注:以太网最大传输单元MTU 为1500。
现在笔者举个示例,让大家更好的理解IP 分片的原理:
假设IP 数据报整体的大小为4000 字节,IP 首部默认为20 字节,而数据区域为3980。由于以太网最大传输单元为1500,所以lwIP 内核会把这个数据报进行分片处理。
- 第一个IP 分片:
分片数据大小:20(IP 首部)+ 1480(数据区域)。
标识:888。
标志:IP_MF = 1 后续还有分片。
片偏移量:片偏移量是0,单位是8 字节,本片偏移量相当于0 字节。 - 第二片IP 数据报:
分片数据大小:20(IP 首部)+ 1480(数据区域)。
标识:888。
标志:IP_MF = 1 后续还有分片。
片偏移量:片偏移量是185(1480/8),单位是8 字节,本片偏移量相当于1480 字节。 - 第三片IP 数据报:
分片数据大小:20(IP 首部)+ 1020(数据区域)。
标识:888。
标志:IP_MF = 0,后续没有分片。
片偏移量:片偏移量是370(185+185),单位是8 字节,本片偏移量相当于2960 字节。
注:这些分片的标识都是一致的,而IP_MF 表示后续有没有分片,若IP_MF 为0,则这个分片为最后一个分片。
从上图可以看出,一个大型的IP 数据包经过网络层处理,它会被分成两个或者两个以上的IP 分片,这些分片的数据组合起来就是应用程序发送的数据与传输层的首部。
至此,我们已经明白了IP 分片的原理,下面笔者讲解lwIP 内核如何实现这个原理,它的实现函数为ip4_frag,该函数如下所示:
/**
* 如果IP数据报对netif来说太大,则将其分片,
将数据报切成MTU大小的块,然后按顺序发送通过将pbuf_ref指向p
* @param p:要发送的IP数据包
* @param netif:发送的netif
* @param dest:目的IP地址
* @return ERR_OK:发送成功, err_t:其他
*/
err_t
ip4_frag(struct pbuf * p, struct netif * netif,
const ip4_addr_t * dest) {
struct pbuf * rambuf;#
if !LWIP_NETIF_TX_SINGLE_PBUF
struct pbuf * newpbuf;
u16_t newpbuflen = 0;
u16_t left_to_copy;#
endif
struct ip_hdr * original_iphdr;
struct ip_hdr * iphdr;
/* (1500 - 20)/8 = 偏移185 */
const u16_t nfb = (u16_t)((netif - > mtu - IP_HLEN) / 8);
u16_t left, fragsize;
u16_t ofo;
int last;
u16_t poff = IP_HLEN; /* IP头部长度*/
u16_t tmp;
int mf_set;
original_iphdr = (struct ip_hdr * ) p - > payload; /* 指向数据报*/
iphdr = original_iphdr;
/* 判断IP头部是否为20 */
if (IPH_HL_BYTES(iphdr) != IP_HLEN) {
return ERR_VAL;
}
/* tmp变量获取标志和片偏移数值*/
tmp = lwip_ntohs(IPH_OFFSET(iphdr));
/* ofo = 片偏移*/
ofo = tmp & IP_OFFMASK;
/* mf_set = 分片标志*/
mf_set = tmp & IP_MF;
/* left = 总长度减去IP头部等于有效数据长度,4000 - 20 = 3980 */
left = (u16_t)(p - > tot_len - IP_HLEN);
/* 判断left是否为有效数据*/
while (left) {
/* 判断有效数据和偏移数据大小,fragsize = 1480 (3980 < 1480 ? 3980 : 1480) */
fragsize = LWIP_MIN(left, (u16_t)(nfb * 8));
/* rambuf申请20字节大小的内存块*/
rambuf = pbuf_alloc(PBUF_LINK, IP_HLEN, PBUF_RAM);
if (rambuf == NULL) {
goto memerr;
}
/* 这个rambuf有效数据指针指向original_iphdr数据报*/
SMEMCPY(rambuf - > payload, original_iphdr, IP_HLEN);
/* iphdr指向有效区域地址rambuf->payload */
iphdr = (struct ip_hdr * ) rambuf - > payload;
/* left_to_copy = 偏移数据大小(1480) */
left_to_copy = fragsize;
while (left_to_copy) {
struct pbuf_custom_ref * pcr;
/* 当前pbuf中数据的长度,plen = 3980 - 20 = 3960 */
u16_t plen = (u16_t)(p - > len - poff);
/* newpbuflen = 1480 (1480 < 3960 ? 1480 : 3960) */
newpbuflen = LWIP_MIN(left_to_copy, plen);
if (!newpbuflen) {
poff = 0;
p = p - > next;
continue;
}
/* pcr申请内存*/
pcr = ip_frag_alloc_pbuf_custom_ref();
if (pcr == NULL) {
pbuf_free(rambuf);
goto memerr;
}
/* newpbuf申请内存1480字节,
保存了这个数据区域偏移poff字节的数据(p->payload + poff) */
newpbuf = pbuf_alloced_custom(PBUF_RAW, newpbuflen, PBUF_REF, & pcr - > pc, (u8_t * ) p - > payload + poff, newpbuflen);
if (newpbuf == NULL) {
/* 释放内存*/
ip_frag_free_pbuf_custom_ref(pcr);
pbuf_free(rambuf);
goto memerr;
}
/* 增加pbuf的引用计数*/
pbuf_ref(p);
pcr - > original = p;
pcr - > pc.custom_free_function = ipfrag_free_pbuf_custom;
/* 将它添加到rambuf的链的末尾*/
pbuf_cat(rambuf, newpbuf);
/* left_to_copy = 0 (1480 - 1480) */
left_to_copy = (u16_t)(left_to_copy - newpbuflen);
if (left_to_copy) {
poff = 0;
p = p - > next;
}
}
/* poff = 1500 (20 + 1480) */
poff = (u16_t)(poff + newpbuflen);
/* last = 0 (3980 <= (1500 - 20)) */
last = (left <= netif - > mtu - IP_HLEN);
/* 设置新的偏移量和MF标志*/
tmp = (IP_OFFMASK & (ofo));
/* 判断是否是最后一个分片*/
if (!last || mf_set) {
/* 最后一个片段设置了MF为0 */
tmp = tmp | IP_MF;
}
/* 分段偏移与标志字段*/
IPH_OFFSET_SET(iphdr, lwip_htons(tmp));
/* 设置数据报总长度= 1500 (1480 + 20) */
IPH_LEN_SET(iphdr, lwip_htons((u16_t)(fragsize + IP_HLEN)));
/* 校验为0 */
IPH_CHKSUM_SET(iphdr, 0);
/* 发送IP数据报*/
netif - > output(netif, rambuf, dest);
IPFRAG_STATS_INC(ip_frag.xmit);
/* rambuf释放内存*/
pbuf_free(rambuf);
/* left = 2500 (3980 - 1480) */
left = (u16_t)(left - fragsize);
/* 片偏移ofo = 185(0 + 185) */
ofo = (u16_t)(ofo + nfb);
}
MIB2_STATS_INC(mib2.ipfragoks);
return ERR_OK;
memerr:
MIB2_STATS_INC(mib2.ipfragfails);
return ERR_MEM;
}
MIB2_STATS_INC(mib2.ipfragoks);
return ERR_OK;
memerr:
MIB2_STATS_INC(mib2.ipfragfails);
return ERR_MEM;
}
此函数非常简单,首先判断这个大型数据包的有效区域总长度,系统根据这个总长度划分数据区域,接着申请20+sizeof(struct pbuf)字节的rampbuf 来存储IP 首部,然后根据poff 数值让被分片数据包的payload 指针偏移poff 大小,它所指向的地址由newpbuf 数据包的payload指针指向,最后调用netif->output 函数发送该分片,其他分片一样操作。
上图中,newpbuf 的payload 指针指向的地址由左边的payload 指针经过偏移得来的。
IP 数据报的分片重装(后包先置情况处理)
由于 IP 分组在网络传输过程中到达目的地点的时间是不确定的,所以后面的分组可能比前面的分组先达到目的地点。为此,lwIP 内核需要将接收到的分组暂存起来,等所有的分组都接收完成之后,再将数据传递给上层。
在lwIP 中,有专门的结构体负责缓存这些分组,这个结构体为ip_reassdata 重装数据链表,该结构体在ip4_frag.h 文件中定义,如下所示:
/* 重装数据结构体*/
struct ip_reassdata {
struct ip_reassdata *next; /* 指向下一个重装节点*/
struct pbuf *p; /* 指向分组的pbuf */
struct ip_hdr iphdr; /* IP数据报的首部*/
u16_t datagram_len; /* 已收到数据的长度*/
u8_t flags; /* 标志是否最后一个分组*/
u8_t timer; /* 超时间隔*/
};
这个结构体描述了同类型的IP 分组信息,同类型的IP 分组会挂载到该重装节点上,如下图所示:
可以看到,这些分片挂载到同一个重装节点上,它们挂载之前,是把IP 首部的前8 字节强制转换成三个字段,其中next_pbuf 指针用来链接这些IP 分组,形成了单向链表,而start和end 字段用来描述分组的顺序,lwIP 系统根据这些数值对分组进行排序。
lwIP 内核的IP 重组功能由ip4_reass 函数实现,该函数的代码量比较长,这里笔者不深入讲解了,我们会在视频当中讲解IP 重装流程。
IP 数据报的输出
无论是UDP 还是TCP,它们的数据段递交至网络层的接口是一致的,这个接口函数如下所示:
err_t
ip4_output_if_src(struct pbuf * p,
const ip4_addr_t * src,
const ip4_addr_t * dest,
u8_t ttl, u8_t tos,
u8_t proto, struct netif * netif) {
struct ip_hdr * iphdr;
ip4_addr_t dest_addr;
if (dest != LWIP_IP_HDRINCL) {
u16_t ip_hlen = IP_HLEN;
/* 第一步:生成IP报头*/
if (pbuf_header(p, IP_HLEN)) {
return ERR_BUF;
}
/* 第二步:iphdr 指向IP头部指针*/
iphdr = (struct ip_hdr * ) p - > payload;
/* 设置生存时间(最大转发次数) */
IPH_TTL_SET(iphdr, ttl);
/* 设置协议类型(IGMP:1、UDP:17、TCP:6) */
IPH_PROTO_SET(iphdr, proto);
/* 设置目的IP地址*/
ip4_addr_copy(iphdr - > dest, * dest);
/* 设置版本号+设置首部长度*/
IPH_VHL_SET(iphdr, 4, ip_hlen / 4);
/* 服务类型*/
IPH_TOS_SET(iphdr, tos);
/* 设置总长度(IP首部+数据区) */
IPH_LEN_SET(iphdr, lwip_htons(p - > tot_len));
/* 设置标志+片偏移*/
IPH_OFFSET_SET(iphdr, 0);
/* 设置数据包标识(编号) */
IPH_ID_SET(iphdr, lwip_htons(ip_id));
/* 每发送一个数据包,编号加一*/
++ip_id;
/* 没有指定源IP地址*/
if (src == NULL) {
/* 将当前网络接口IP地址设置为源IP地址*/
ip4_addr_copy(iphdr - > src, * IP4_ADDR_ANY4);
} else {
/* 复制源IP地址*/
ip4_addr_copy(iphdr - > src, * src);
}
} else {
/* IP头部已经包含在pbuf中*/
iphdr = (struct ip_hdr * ) p - > payload;
ip4_addr_copy(dest_addr, iphdr - > dest);
dest = & dest_addr;
}
IP_STATS_INC(ip.xmit);
ip4_debug_print(p);
/* 如果数据包总长度大于MTU,则分片发送*/
if (netif - > mtu && (p - > tot_len > netif - > mtu)) {
return ip4_frag(p, netif, dest);
}
/* 如果数据包总长度不大于MTU,则直接发送*/
return netif - > output(netif, p, dest);
}
此函数非常简单,这里笔者使用一个流程图来描述该函数的实现原理,如下图所示:
此函数首先判断目标IP 地址是否为NULL,若目标IP 地址不为空,则偏移payload 指针添加IP 首部,偏移完成之后设置IP 首部字段信息,接着判断该数据包的总长度是否大于以太网传输单元,若大于,则调用ip4_frag 函数对这个数据包分组并且逐一发送,否则直接调用ethrap_output 函数把数据包递交给ARP 层处理。
IP 数据报的输入
数据包提交给网络层之前,系统需要判断接收到的数据包是 IP 数据包还是 ARP 数据包,若接收到的是IP 数据包,则lwIP 内核调用ip4_input 函数处理这个数据包,该函数如下所示:
err_t ip4_input(struct pbuf *p, struct netif *inp)
{
struct ip_hdr *iphdr;
struct netif *netif;
u16_t iphdr_hlen;
u16_t iphdr_len;
#if IP_ACCEPT_LINK_LAYER_ADDRESSING || LWIP_IGMP int check_ip_src = 1;
#endif /* IP_ACCEPT_LINK_LAYER_ADDRESSING || LWIP_IGMP */
IP_STATS_INC(ip.recv);
MIB2_STATS_INC(mib2.ipinreceives);
/* 识别IP报头*/
iphdr = (struct ip_hdr *)p - > payload;
/* 第一步:判断版本是否为IPv4 */
if (IPH_V(iphdr) != 4)
{
ip4_debug_print(p);
pbuf_free(p); /* 释放空间*/
IP_STATS_INC(ip.err);
IP_STATS_INC(ip.drop);
MIB2_STATS_INC(mib2.ipinhdrerrors);
return ERR_OK;
}
/* 以4字节(32位)字段获得IP头的长度*/
iphdr_hlen = IPH_HL(iphdr);
/* 以字节计算IP报头长度*/
iphdr_hlen *= 4;
/* 以字节为单位获取ip长度*/
iphdr_len = lwip_ntohs(IPH_LEN(iphdr));
/* 修剪pbuf。这对于< 60字节的数据包尤其需要。*/
if (iphdr_len<p -> tot_len)
{
pbuf_realloc(p, iphdr_len);
}
/* 第二步:标头长度超过第一个pbuf 长度,或者ip 长度超过总pbuf 长度*/
if ((iphdr_hlen > p - > len) || (iphdr_len > p - > tot_len) || (iphdr_hlen < IP_HLEN))
{
if (iphdr_hlen < IP_HLEN)
{
}
if (iphdr_hlen > p - > len)
{
}
if (iphdr_len > p - > tot_len)
{
}
/* 释放空间*/
pbuf_free(p);
IP_STATS_INC(ip.lenerr);
IP_STATS_INC(ip.drop);
MIB2_STATS_INC(mib2.ipindiscards);
return ERR_OK;
}
/* 第三步:验证校验和*/
#if CHECKSUM_CHECK_IP
/* 省略代码*/
#endif
/* 将源IP 地址与目标IP 地址复制到对齐的ip_data.current_iphdr_src和
ip_data.current_iphdr_dest */
ip_addr_copy_from_ip4(ip_data.current_iphdr_dest, iphdr - > dest);
ip_addr_copy_from_ip4(ip_data.current_iphdr_src, iphdr - > src);
/* 第四步:匹配数据包和接口,即这个数据包是否发给本地*/
if (ip4_addr_ismulticast(ip4_current_dest_addr()))
{
#if LWIP_IGMP
/* 省略代码*/
#else /* LWIP_IGMP */
/* 如果网卡已经挂载了和IP 地址有效*/
if ((netif_is_up(inp)) && (!ip4_addr_isany_val(*netif_ip4_addr(inp))))
{
netif = inp;
}
else
{
netif = NULL;
}
#endif /* LWIP_IGMP */
}
/* 如果数据报不是发给本地*/
else
{
int first = 1;
netif = inp;
do
{
/* 接口已启动并配置? */
if ((netif_is_up(netif)) &&
(!ip4_addr_isany_val(*netif_ip4_addr(netif))))
{
/* 单播到此接口地址? */
if (ip4_addr_cmp(ip4_current_dest_addr(),
netif_ip4_addr(netif)) ||
/* 或广播在此接口网络地址? */
ip4_addr_isbroadcast(ip4_current_dest_addr(), netif) #if LWIP_NETIF_LOOPBACK && !LWIP_HAVE_LOOPIF || (ip4_addr_get_u32(ip4_current_dest_addr()) == PP_HTONL(IPADDR_LOOPBACK)) #endif /* LWIP_NETIF_LOOPBACK && !LWIP_HAVE_LOOPIF */
)
{
break;
}
#if LWIP_AUTOIP
if (autoip_accept_packet(netif, ip4_current_dest_addr()))
{
/* 跳出if循环*/
break;
}
#endif /* LWIP_AUTOIP */
}
if (first)
{
#if !LWIP_NETIF_LOOPBACK || LWIP_HAVE_LOOPIF
/* 检查一下目标IP 地址是否是环回地址*/
if (ip4_addr_isloopback(ip4_current_dest_addr()))
{
netif = NULL;
break;
}
#endif /* !LWIP_NETIF_LOOPBACK || LWIP_HAVE_LOOPIF */
first = 0;
netif = netif_list;
}
else
{
netif = netif - > next;
}
if (netif == inp)
{
netif = netif - > next;
}
} while (netif != NULL);
}
#if IP_ACCEPT_LINK_LAYER_ADDRESSING
if (netif == NULL)
{
/* 远程端口是DHCP服务器? */
if (IPH_PROTO(iphdr) == IP_PROTO_UDP)
{
struct udp_hdr *udphdr = (struct udp_hdr *)((u8_t *)iphdr + iphdr_hlen);
if (IP_ACCEPT_LINK_LAYER_ADDRESSED_PORT(udphdr - > dest))
{
netif = inp;
check_ip_src = 0;
}
}
}
#endif /* IP_ACCEPT_LINK_LAYER_ADDRESSING */
#if LWIP_IGMP || IP_ACCEPT_LINK_LAYER_ADDRESSING
if (check_ip_src #if IP_ACCEPT_LINK_LAYER_ADDRESSING && !ip4_addr_isany_val(*ip4_current_src_addr()) #endif /* IP_ACCEPT_LINK_LAYER_ADDRESSING */
)
#endif /* LWIP_IGMP || IP_ACCEPT_LINK_LAYER_ADDRESSING */ {
/* 第五步:IP 地址,源IP 地址不能是多播或者广播地址*/
if ((ip4_addr_isbroadcast(ip4_current_src_addr(), inp)) ||
(ip4_addr_ismulticast(ip4_current_src_addr())))
{
/* 释放空间*/
pbuf_free(p);
IP_STATS_INC(ip.drop);
MIB2_STATS_INC(mib2.ipinaddrerrors);
MIB2_STATS_INC(mib2.ipindiscards);
return ERR_OK;
}
}
/* 第六步:如果还没找到对应的网卡,数据包不是给我们的*/
if (netif == NULL)
{
/* 路由转发或者丢弃。如果IP_FORWARD 宏定义被使能,则进行转发*/
#if IP_FORWARD
/* 非广播包?*/
if (!ip4_addr_isbroadcast(ip4_current_dest_addr(), inp))
{
/* 尝试在(其他)网卡上转发IP 数据包*/
ip4_forward(p, iphdr, inp);
}
else
#endif /* IP_FORWARD */
{
IP_STATS_INC(ip.drop);
MIB2_STATS_INC(mib2.ipinaddrerrors);
MIB2_STATS_INC(mib2.ipindiscards);
}
/* 释放空间*/
pbuf_free(p);
return ERR_OK;
}
/* 第七步:如果数据报由多个片段组成(分片处理)?*/
if ((IPH_OFFSET(iphdr) & PP_HTONS(IP_OFFMASK | IP_MF)) != 0)
{
/* 重装数据报*/
p = ip4_reass(p);
/* 如果重装没有完成*/
if (p == NULL)
{
return ERR_OK;
}
/* 分片重装完成,将数据报首部强制转换为ip_hdr 类型*/
iphdr = (struct ip_hdr *)p - > payload;
}
#if IP_OPTIONS_ALLOWED == 0
#if LWIP_IGMP
if ((iphdr_hlen > IP_HLEN) && (IPH_PROTO(iphdr) != IP_PROTO_IGMP))
{
#else
/* 第八步:如果IP 数据报首部长度大于20 字节,就表示错误*/
if (iphdr_hlen > IP_HLEN)
{
#endif /* LWIP_IGMP */
/* 释放空间*/
pbuf_free(p);
IP_STATS_INC(ip.opterr);
IP_STATS_INC(ip.drop);
/* u不受支持的协议特性*/
MIB2_STATS_INC(mib2.ipinunknownprotos);
return ERR_OK;
}
#endif /* IP_OPTIONS_ALLOWED == 0 */
/* 第九步:发送到上层协议*/
ip4_debug_print(p);
ip_data.current_netif = netif;
ip_data.current_input_netif = inp;
ip_data.current_ip4_header = iphdr;
ip_data.current_ip_header_tot_len = IPH_HL(iphdr) * 4;
#if LWIP_RAW
/* RAW API 输入*/
if (raw_input(p, inp) == 0) #endif /* LWIP_RAW */
{
/* 转移到有效载荷(数据区域),不需要检查*/
pbuf_header(p, -(s16_t)iphdr_hlen);
/* 根据IP 数据报首部的协议的类型处理*/
switch (IPH_PROTO(iphdr))
{
#if LWIP_UDP
/* UDP协议*/
case IP_PROTO_UDP:
#if LWIP_UDPLITE
case IP_PROTO_UDPLITE:
#endif /* LWIP_UDPLITE */
MIB2_STATS_INC(mib2.ipindelivers);
/* IP层递交给网络层的函数*/
udp_input(p, inp);
break;
#endif /* LWIP_UDP */
#if LWIP_TCP
/* TCP协议*/
case IP_PROTO_TCP:
MIB2_STATS_INC(mib2.ipindelivers);
/* IP层递交给网络层的函数*/
tcp_input(p, inp);
break;
#endif /* LWIP_TCP */
pbuf_free(p); /* 释放空间*/
IP_STATS_INC(ip.proterr);
IP_STATS_INC(ip.drop);
MIB2_STATS_INC(mib2.ipinunknownprotos);
}
}
/* 全局变量清零*/
ip_data.current_netif = NULL;
ip_data.current_input_netif = NULL;
ip_data.current_ip4_header = NULL;
ip_data.current_ip_header_tot_len = 0;
ip4_addr_set_any(ip4_current_src_addr());
ip4_addr_set_any(ip4_current_dest_addr());
return ERR_OK;
}
上述的源码篇幅很长,也不容易理解,下面笔者把上述的源码分成十步来讲解:
第一步:判断IP 数据报的版本是否是IPv4,如果不是,那么lwIP 会掉弃该数据报。
第二步:判断标头长度超过第一个pbuf 长度,或者ip 长度超过总pbuf 长度,如果是,那么lwIP 会丢弃该数据报。
第三步:验证校验和,如果不正确,那么lwIP 会掉弃该数据报。
第四步:匹配数据包和接口,这个数据包是否发给本地。
第五步:判断IP 数据报是否是广播或者多播,如果是,那么lwIP 会丢弃该数据报。
第六步:如果到了这一步,没有发现网络接口,那么lwIP 会丢弃该数据报。
第七步:如果如IP 数据报不能分片处理,那么lwIP 会丢弃该数据报。
第八步:如果IP 数据报的IP 首部大于20 字节,那么lwIP 会丢弃该数据报。
第九步:把数据包递交给上层。
第十步:判断该数据报的协议为TCP/UDP/ICMP/IGMP,如果不是这四个协议,则丢弃该数据报。
ICMP 协议(控制型报文)
ICMP(Internet Control Message Protocol)Internet 控制报文协议。它是TCP/IP 协议簇的一个子协议,用于在 IP 主机、路由器之间传递控制消息。控制消息是指网络通不通、主机是否可达、路由是否可用等网络本身的消息,这些控制消息虽然并不传输到用户数据,但是对于用户数据的传递起着重要的作用。
ICMP 协议简介
IP 协议虽然是TCP/IP 协议中的核心部分,但是它是一种无连接的不可靠数据报交付,这个协议本身没有任何错误检验和恢复机制,为了弥补 IP 协议中的缺陷,ICMP 协议登场了,ICMP 协议是一种面向无连接的协议,用于传输出错报告控制信息。它是一个非常重要的协议,它对于网络安全具有极其重要的意义。
它属于网络层协议,主要用于在主机与路由器之间传递控制信息,包括报告错误、交换受限控制和状态信息等。当遇到IP 数据无法访问目标、IP 路由器无法按当前的传输速率转发数据包等情况时,会自动发送 ICMP 消息。
ICMP 协议用于IP 主机、路由器之间递交控制消息,在网络中,控制消息分为很多种,例如数据报错信息、网络状况信息和主句状况信息等,虽然这些信息不会递交给用户数据,但对于用户来说数据报有效性得到提高。
ICMP 应用场景
IP 协议本身不提供差错报告和差错控制机制来保证数据报递交的有效性,如果在路由器无法递交一个数据报或者数据报生存时间为0 时,那么路由器会直接掉弃这个数据报,虽然IP 层这样处理是合理的,但是对于源主机来说,比较希望得到数据报递交过程中出现异常相关信息,以便重新递交数据报或者其他处理。
IP 协议不能进行主机管理与查询机制,简单来说:不知道对方主机或者路由器的活跃,对于不活跃的主机和路由器就没有必要发送数据报,所以对于主机管理员来说:更希望得到对方主机和路由器的信息,这样可以根据相关的信息对自身配置、数据报发送控制。
为了解决上述的两个问题,TCP/IP 设计人员在协议上引入了特殊用途报文,这个报文为网际报文控制协议简称ICMP,从TCP/IP 的协议结构来看,它是和IP’协议一样,都是处于网络层,但是ICMP 协议有自己一套报文结构,这样数据报就变成了 IP 首部+ICMP 首部+数据区域,ICMP 协议不为任何的应用程序服务,它的目的是目的主机的网络层处理软件。
ICMP 报文类型
在没有引入ICMP 报文之前,IP 数据报一般分为IP 首部+IP 数据区域,现在添加了ICMP协议,则IP 数据报分为IP 首部+ICMP 首部+数据区域。ICMP 报文分为两类:一类是ICMP 差错报告报文,另一类是ICMP 查询报文,这两类报文分别解决上小节的两个问题。
①ICMP 差错报告报文主要用来向IP 数据报源主机返回一个差错报告信息,这个信息就是判断路由器和主机对当前的数据报进行正常处理,例如无法将数据报递交给上层处理,或者数据报因为生存时间而被删除。
②ICMP 查询报文用于一台主机向另一台主机查询特定的信息,这个类型的报文是成对出现的,例如源主机发送查询报文,当目标主机收到该报文之后,它会根据查询报文的约定的格式为源主机放回应答报文。
ICMP 差错报告报文和ICMP 查询报文常见类型如下表所示:
注:lwIP 只实现差错报文的类型3 和11,而查询报文只处理回显请求。
ICMP 报文结构
ICMP 报文有8 字节首部和可变长度的数据部分组成,因为ICMP 有两种类型的报文,其中不同的报文其首部的格式也会有点差异,当然也有通用的地方,例如首部的前4 个字节是通用的,ICMP 报文结构如下图所示:
类型字段:表示使用ICMP 的两类类型中的哪一个。
代码字段:产生ICMP 报文的具体原因。
校验和字段:用于记录包括ICMP 报文数据部分在内的整个ICMP 数据报的校验和。
首部剩余的4 字节在每种类型的报文有特殊的定义,总的看来说:不同类型的报文,数据部分长度和含义存在差异,例如差错报文会引起差错的据报的信息,而查询报文携带查询请求和查询结果数据。
- ICMP 差错报文
(1) 目的站不可到达
当路由器发送的数据报不能发送到指定目的地时,或者说当路由器不能够给数据报找到路由或主机不能够交付数据报时,就丢弃这个数据报,然后向发送数据报的源主机设备发回一个终点不可达数据报文。如下图所示:
举个例子:主机A 给主机B 发送一个数据报,在网络中传输时中间可能要经过很多台路由器,主机A 先把这个数据报发送给路由器,路由器收到这个数据报后,此时路由R1 发生了故障,它不知道这个数据报下一步该发给哪个路由设备或者那台主机设备,也就是说这个数据报不能发送到目的地主机B,这时路由器会把这个数据报丢弃并向主机A 发回一个终点不可达的数据报文。
ICMP 目的不可达差错报告报文产生差错的原因有很多,如网络不可达、主机不可达、协议不可达、端口不可达等,引起差错的原因会在ICMP 报文中的代码字段(Code)记录。对于不同的差错代码字段的值是不一样的,但是lwIP 实现的只有前6 种,如下图所示:
当然ICMP 目的不可达报文首部剩下的4 字节是未使用,而ICMP 报文数据区装载了IP数据报首部及IP 数据报的数据区域前8 字节,为什么需要装载IP 数据报的数据区域中前8 个字节的数据呢?因为IP 数据报的数据区域前8 个字节刚好覆盖了传输层协议中的端口号字段,而IP 数据报首部就拥有目标IP 地址与源IP 地址,当源主机收到这样子的ICMP 报文后,它能根据ICMP 报文的数据区域判断出是哪个数据包出现问题,并且IP 层能够根据端口号将报文传递给对应的上层协议处理,差错报文结构如下图所示:
可以看出:首部剩下的4 个字节是未使用的,而数据区域保存的是引起差错IP 首部和引起差错数据包的数据区域前8 字节数据。准确来说,就是把引起差错IP 数据包的IP 部和数据区域的前8 字节数据拷贝到差错报文的数据区域。
(2) 源站抑制
由于IP 协议是面向无连接的,没有流量控制机制,数据在传输过程中是非常容易造成拥塞的现象。而ICMP 源点抑制报文就是给IP 协议提供一种流量监控的机制,因为ICMP 源点抑制机制并不能控制流量的大小,但是能根据流量的使用情况,给源主机提供一些建议。这个报文的作用就是通知数据报在拥塞时被丢弃了,另外还会警告源主机流量出现了拥塞的情况,然后源主机根据反馈的ICMP 源点抑制报文信息作出处理,至于源主机怎么就不关它的事了。
如下图所示:
(3) 端口不可达
当目标系统收到一个IP 数据报的某个服务请求时,如果本地没有此服务,则本地会向源头返回 ICMP 端口不可达信息。常见的端口不可达有:主机A 向主机B 发起一个ftp 的传输请求,从主机B 传输一个文件到主机A,由于主机B 设备没有开启ftp 服务的 69 端口,因此主机A 在请求主机B 时,会收到主机B 回复的一个ICMP 端口不可达的差错报文。
(4) 超时
ICMP 差错报告报文主要在以下几种情况中,会发送ICMP 超时报文:
- 当路由器接收到的数据报的TTL 生命周期字段值为0 时,路由器会把该数据报丢弃掉,并向源主机发回一个ICMP 超时报文。
- 另外,当目标主机在规定时间内没有收到所有的数据分片时,会把已经收到的所有数据分片丢弃,并向源主机发回一个ICMP 超时报文。在超时报文中,代码0 只能给路由器使用,表示生存周期字段值为0,代码1 只能给目的主机使用,它表示在规定的时间内,目的主机没有收到所有的数据分片。
(5) 参数错误
当数据报在因特网上传送时,在其首部中出现的任何二义性或者首部字段值被修改都可能会产生非常严重的问题。如果路由器或目的主机发现了这种二义性,或在数据报的某个字段中缺少某个值,就丢弃这个数据报,并回送参数问题报文。
- ICMP 查询报文
ping 程序利用ICMP 回显请求报文和回显应答报文(而不用经过传输层)来测试目标主机是否可达。它是一个检查系统连接性的基本诊断工具。
ICMP 回显请求和ICMP 回显应答报文是配合工作的。当源主机向目标主机发送了ICMP回显请求数据包后,它期待着目标主机的回答。目标主机在收到一个ICMP 回显请求数据包后,它会交换源、目的主机的地址,然后将收到的ICMP 回显请求数据包中的数据部分原封不动地封装在自己的ICMP 回显应答数据包中,然后发回给发送ICMP 回显请求的一方。如果校验正确,发送者便认为目标主机的回显服务正常,也即物理连接畅通。查询报文结构如下图所示:
类型字段是指请求报文(8)和回答报文(0),代码段在ICMP 查询报文没有特殊取值,其值为0,首部中的标识符和序号在ICMP 中没有正式定义该值的范围,所以发送方可以自由定义这两个字段,可以用来记录源主机发送出去的请求报文编号。数据可选区域标识回送请求报文包含数据和长度是可选的,发送放应该选择适合的长度和填充数据。在接收方它可以根据这个回送请求产生一个回送回答报文,回送报文的数据与回送请求报文的数据是相同的。
ICMP 的实现
我们可以总结一下ICMP 协议的作用,ICMP 协议是IP 协议的辅助协议,为什么ICMP 协议是IP 协议的辅助协议呢?由于IP 协议本身不提供差错报告和差错控制机制来保证数据报递交的有效性和进行主机管理与查询机制,简单来说:ICMP 协议为了解决IP 协议的缺陷而诞生的,ICMP 报文分为差错报文和查询报文,这两个报文分别解决IP 协议的两大缺陷,本小节主要讲解lwIP 是怎么样实现ICMP 协议发送及处理的。
ICMP 数据结构体
在讲述IP 协议时,它是有自己的数据结构,同样ICMP 也有它自己的数据结构icmp_echo_hdr,该数据结构在lwIP 的icmp.h 文件中定义,该结构体如下源码所示:
PACK_STRUCT_BEGIN
struct icmp_echo_hdr {
PACK_STRUCT_FLD_8(u8_t type); /* ICMP类型*/
PACK_STRUCT_FLD_8(u8_t code); /* ICMP代码号*/
PACK_STRUCT_FIELD(u16_t chksum); /* ICMP校验和*/
PACK_STRUCT_FIELD(u16_t id); /* ICMP的标识符*/
PACK_STRUCT_FIELD(u16_t seqno); /* 序号*/
} PACK_STRUCT_STRUCT;
PACK_STRUCT_END
此外lwIP 还定义了很多宏与枚举类型的变量对ICMP 的类型及代码字段进行描述,如下源码所示:
#define ICMP_ER 0 /* 回送应答*/ # define ICMP_DUR 3 /* 目标不可达*/ # define ICMP_SQ 4 /* 源站抑制*/ # define ICMP_RD 5 /* 重定向*/ # define ICMP_ECHO 8 /* 回送*/ # define ICMP_TE 11 /* 超时*/ # define ICMP_PP 12 /* 参数问题*/ # define ICMP_TS 13 /* 时间戳*/ # define ICMP_TSR 14 /* 时间戳应答*/ # define ICMP_IRQ 15 /* 信息请求*/ # define ICMP_IR 16 /* 信息应答*/ # define ICMP_AM 17 /* 地址掩码请求*/ # define ICMP_AMR 18 /* 地址掩码应答*/
/* ICMP目标不可到达的代码*/
enum icmp_dur_type {
/* 网络不可到达*/
ICMP_DUR_NET = 0,
/* 主机不可达*/
ICMP_DUR_HOST = 1,
/* 协议不可到达*/
ICMP_DUR_PROTO = 2,
/* 端口不可达*/
ICMP_DUR_PORT = 3,
/* 需要进行分片但设置不分片比特*/
ICMP_DUR_FRAG = 4,
/* 源路由失败*/
ICMP_DUR_SR = 5
};
/* ICMP时间超时代码*/
enum icmp_te_type {
/* 在运输过程中超出了生存时间*/
ICMP_TE_TTL = 0,
/* 分片重组时间超时*/
ICMP_TE_FRAG = 1
};
可以看出,这些宏定义描述了 ICMP 数据报文的类型字段,下面的icmp_dur_type 和icmp_te_type 枚举用来描述ICMP 数据报文的代码字段,它们分别为目的不可到达和超时差错报文。
lwIP 的作者为了快速读取和填写ICMP 报文首部,在icmp.h 文件还定义了ICMP 报文首部的宏定义,如下源码所示:
#define ICMPH_TYPE(hdr) ((hdr)->type) /* 读取类型字段*/
#define ICMPH_CODE(hdr) ((hdr)->code) /* 读取代码字段*/
#define ICMPH_TYPE_SET(hdr, t) ((hdr)->type = (t)) /* 填写类型字段*/
#define ICMPH_CODE_SET(hdr, c) ((hdr)->code = (c)) /* 填写代码字段*/
使用这些宏定义能快速设置ICMP 各个字段的数值。
发送ICMP 差错报文
lwIP 只实现目的不可到达和超时差错报文,它们的实现函数分别为icmp_dest_unreach 和icmp_time_exceeded,这两个函数转入的参数与icmp_dur_type 和icmp_te_type 枚举相关。如目的不可到达报文的代码字段由icmp_dur_type 枚举描述,而超时报文的代码字段由icmp_te_type 枚举描述。
打开icmp.c 文件查看icmp_dest_unreach 和icmp_time_exceeded 这两个函数,如下所示:
/* 发送目标不可达报文,该函数实际调用函数
icmp_send_response来发送ICMP差错报文
ICMP_DUR 为目的不可到达*/
void
icmp_dest_unreach(struct pbuf * p, enum icmp_dur_type t) {
MIB2_STATS_INC(mib2.icmpoutdestunreachs);
icmp_send_response(p, ICMP_DUR, t);
}
/* 发送超时报文,该函数实际调用函数
icmp_send_response来发送ICMP差错报文
ICMP_TE 为超时*/
void
icmp_time_exceeded(struct p buf * p, enum icmp_te_type t) {
MIB2_STATS_INC(mib2.icmpouttimeexcds);
icmp_send_response(p, ICMP_TE, t);
}
从上述源码可以看出,差错报文的类型已经固定为目的不可到达或者超时,它们唯一不同的是差错报文的代码值,这个代码值就是由icmp_dur_type 和icmp_te_type 枚举定义的,最后调用相同的icmp_send_response 函数发送差错报文,这个发送函数如下所示:
static void
icmp_send_response(struct pbuf * p, u8_t type, u8_t code) {
struct pbuf * q;
struct ip_hdr * iphdr;
struct icmp_echo_hdr * icmphdr;
ip4_addr_t iphdr_src;
struct netif * netif;
MIB2_STATS_INC(mib2.icmpoutmsgs);
/* 为差错报文申请pbuf,pbuf预留以太网首部和ip首部,
申请数据长度为icmp首部长度+icmp数据长度(ip首部长度+8) */
q = pbuf_alloc(PBUF_IP, sizeof(struct icmp_echo_hdr) + IP_HLEN +
ICMP_DEST_UNREACH_DATASIZE, PBUF_RAM);
if (q == NULL) {
MIB2_STATS_INC(mib2.icmpouterrors);
return;
}
/* 指向IP 数据报首部*/
iphdr = (struct ip_hdr * ) p - > payload;
/* 指向带填写的icmp首部*/
icmphdr = (struct icmp_echo_hdr * ) q - > payload;
/* 填写类型字段*/
icmphdr - > type = type;
/* 填写代码字段*/
icmphdr - > code = code;
icmphdr - > id = 0;
icmphdr - > seqno = 0;
/* 从原始数据包中复制字段,IP 数据报首部+8 字节的数据区域*/
SMEMCPY((u8_t * ) q - > payload + sizeof(struct icmp_echo_hdr), (u8_t * ) p - > payload,
IP_HLEN + ICMP_DEST_UNREACH_DATASIZE);
/* 得到源IP 地址*/
ip4_addr_copy(iphdr_src, iphdr - > src);
/* 判断是否同一网段*/
netif = ip4_route( & iphdr_src);
if (netif != NULL) {
/* 计算校验和*/
icmphdr - > chksum = 0;
ICMP_STATS_INC(icmp.xmit);
/* 发送ICMP差错报文*/
ip4_output_if(q, NULL, & iphdr_src, ICMP_TTL, 0, IP_PROTO_ICMP, netif);
}
/* 释放icmp pbuf */
pbuf_free(q);
}
可以看到,此函数申请了一个pbuf 内存,它的数据区域存储了ICMP 首部,接着对这个首部各个字段设置数值,然后在ICMP 首部后面添加引起差错数据包的IP 首部和引起差错的前8 字节数据区域,这样lwIP 内核构建差错报文完成,最后调用ip4_output_if 函数发送该差错报文。
ICMP 报文处理
IP 层把数据报递交至传输层之前,lwIP 内核会判断IP 首部的上层协议字段,若这个上层协议字段不为TCP 和UDP,则该数据报不会递交给传输层处理;若上层协议字段为ICMP,则该数据报递交给icmp_input 函数处理,该函数如下所示:
void
icmp_input(struct pbuf * p, struct netif * inp) {
u8_t type;
struct icmp_echo_hdr * iecho;
const struct ip_hdr * iphdr_in;
u16_t hlen;
const ip4_addr_t * src;
ICMP_STATS_INC(icmp.recv);
MIB2_STATS_INC(mib2.icmpinmsgs);
iphdr_in = ip4_current_header();
hlen = IPH_HL_BYTES(iphdr_in);
/* 判断IP首部的大小*/
if (hlen < IP_HLEN) {
goto lenerr;
}
/* 判断pbud的大小*/
if (p - > len < sizeof(u16_t) * 2) {
goto lenerr;
}
/* 获取ICMP的类型字段*/
type = * ((u8_t * ) p - > payload);
switch (type) {
case ICMP_ER:
/* 回送应答*/
MIB2_STATS_INC(mib2.icmpinechoreps);
break;
case ICMP_ECHO:
/* 回送*/
MIB2_STATS_INC(mib2.icmpinechos);
src = ip4_current_dest_addr();
/* 判断是否为多播*/
if (ip4_addr_ismulticast(ip4_current_dest_addr())) {
goto icmperr;
}
/* 判断是否为广播*/
if (ip4_addr_isbroadcast(ip4_current_dest_addr(),
ip_current_netif())) {
goto icmperr;
}
if (p - > tot_len < sizeof(struct icmp_echo_hdr)) {
goto lenerr;
}
if (pbuf_header(p, (s16_t)(hlen + PBUF_LINK_HLEN +
PBUF_LINK_ENCAPSULATION_HLEN))) {
struct pbuf * r;
r = pbuf_alloc(PBUF_LINK, p - > tot_len + hlen, PBUF_RAM);
if (r == NULL) {
goto icmperr;
}
if (r - > len < hlen + sizeof(struct icmp_echo_hdr)) {
pbuf_free(r);
goto icmperr;
}
MEMCPY(r - > payload, iphdr_in, hlen);
if (pbuf_header(r, (s16_t) - hlen)) {
pbuf_free(r);
goto icmperr;
}
if (pbuf_copy(r, p) != ERR_OK) {
pbuf_free(r);
goto icmperr;
}
pbuf_free(p);
p = r;
} else {
if (pbuf_header(p, -(s16_t)(hlen + PBUF_LINK_HLEN +
PBUF_LINK_ENCAPSULATION_HLEN))) {
goto icmperr;
}
}
/* 强制将数据区域转换为ICMP 报文首部*/
iecho = (struct icmp_echo_hdr * ) p - > payload;
if (pbuf_header(p, (s16_t) hlen)) {} else {
err_t ret;
struct ip_hdr * iphdr = (struct ip_hdr * ) p - > payload;
/* 拷贝源IP 地址*/
ip4_addr_copy(iphdr - > src, * src);
/* 拷贝目标IP 地址*/
ip4_addr_copy(iphdr - > dest, * ip4_current_src_addr());
/* 填写报文类型*/
ICMPH_TYPE_SET(iecho, ICMP_ER);
iecho - > chksum = 0;
/* 设置正确的TTL并重新计算头校验和。*/
IPH_TTL_SET(iphdr, ICMP_TTL);
IPH_CHKSUM_SET(iphdr, 0);
ICMP_STATS_INC(icmp.xmit);
MIB2_STATS_INC(mib2.icmpoutmsgs);
MIB2_STATS_INC(mib2.icmpoutechoreps);
/* 发送一个应答ICMP数据包*/
ret = ip4_output_if(p, src, LWIP_IP_HDRINCL,
ICMP_TTL, 0, IP_PROTO_ICMP, inp);
if (ret != ERR_OK) {}
}
break;
default:
/* 对于其他类型的报文,直接丢掉*/
if (type == ICMP_DUR) {
MIB2_STATS_INC(mib2.icmpindestunreachs);
} else if (type == ICMP_TE) {
MIB2_STATS_INC(mib2.icmpintimeexcds);
} else if (type == ICMP_PP) {
MIB2_STATS_INC(mib2.icmpinparmprobs);
} else if (type == ICMP_SQ) {
MIB2_STATS_INC(mib2.icmpinsrcquenchs);
} else if (type == ICMP_RD) {
MIB2_STATS_INC(mib2.icmpinredirects);
} else if (type == ICMP_TS) {
MIB2_STATS_INC(mib2.icmpintimestamps);
} else if (type == ICMP_TSR) {
MIB2_STATS_INC(mib2.icmpintimestampreps);
} else if (type == ICMP_AM) {
MIB2_STATS_INC(mib2.icmpinaddrmasks);
} else if (type == ICMP_AMR) {
MIB2_STATS_INC(mib2.icmpinaddrmaskreps);
}
ICMP_STATS_INC(icmp.proterr);
ICMP_STATS_INC(icmp.drop);
}
pbuf_free(p);
return;
lenerr:
pbuf_free(p);
ICMP_STATS_INC(icmp.lenerr);
MIB2_STATS_INC(mib2.icmpinerrors);
return;
icmperr:
pbuf_free(p);
ICMP_STATS_INC(icmp.err);
MIB2_STATS_INC(mib2.icmpinerrors);
return;
}
可以看出,lwIP 接收到回显请求报文时,系统会把这个回显请求报文的ICMP 类型字段修改为0(回显应答类型),接着偏移payload 指针添加IP 首部并设置IP 首部的各个字段,最后调用ip4_output_if 函数发送这个回显应答报文。注:lwIP 只处理回显请求报文,而其他类型的请求报文一律不处理。
RAW 编程接口UDP 实验
RAW 都是裸机使用的API。
本章,我们一起学习传输层UDP 协议与RAW API 编程接口,总的来说,本章节的内容会
涉及到传输层和应用层的知识。
UDP 协议简介
UDP 协议是TCP/IP 协议栈的传输层协议,是一个简单的面向数据报的协议,在传输层中
还有另一个重要的协议,那就是TCP 协议,TCP 协议的知识笔者会在下一章节中讲解。UDP
不提供数据包分组、组装,不能对数据包进行排序,当报文发送出去后无法知道是否安全、完
整的到达。UDP 除了这些缺点外肯定有它自身的优势,由于UDP 不属于连接型协议,因而消
耗资源小,处理速度快,所以通常在音频、视频和普通数据传输时使用UDP 较多。UDP 数据
报结构如下图所示。
UDP 首部有8 个字节,由4 个字段构成,每个字段都是两个字节,这些字段的作用如下:
①源端口:源端口号,需要对方回信时选用,不需要时全部置0。
②目的端口:目的端口号,在终点交付报文的时候需要用到。
③长度:UDP 的数据报的长度(包括首部和数据)其最小值为8(只有首部)。
①校验和:检测UDP 数据报在传输中是否有错,有错则丢弃。
UDP 协议使用端口号为不同的应用保留各自的数据传输通道,UDP 和TCP 协议都是采用
端口号对同一时刻内多项应用同时发送和接收数据,而数据接收方则通过目标端口接收数据。
有的网络应用只能使用预先为其预留或注册的静态端口;而另外一些网络应用则可以使用未被
注册的动态端口。因为UDP 报头使用两个字节存放端口号,所以端口号的有效范围是从0 到
65535。一般来说,大于49151 的端口号都代表动态端口。
数据报的长度是指包括报头和数据部分在内的总字节数。因为报头的长度是固定的,所以
该数据区域主要被用来计算可变长度的数据部分(又称为数据负载)。数据报的最大长度根据
操作环境的不同而各异。从理论上说,包含报头在内的数据报的最大长度为65535 字节。
UDP 协议使用报头中的校验和来保证数据的安全。校验和首先在数据发送方通过特殊的
算法计算得出,在传递到接收方之后,还需要再重新计算。如果某个数据报在传输过程中被第
三方篡改或者由于线路噪音等原因受到损坏,发送和接收方的校验计算和将不会相符,由此
UDP 协议可以检测是否出错。
UDP 报文封装流程
UDP 报文与TCP 报文一样也是由UDP/TCP 首部+数据区域组成,UDP 协议是位于传输层,
该层是应用层的下一层,当用户发送数据时候,需要选择使用那种协议发送出去,如果使用
UDP 协议,则UDP 协议就会简单的把数据封装起来,UDP 报文结构如下图所示:
UDP 报文的数据结构
(1) UDP 首部结构
从上面可知,UDP 首部包含了四个字段,这些字段在lwIP 内核中由结构体udp_hdr 描述,
该结构体如下所示:
struct udp_hdr
{
PACK_STRUCT_FIELD(u16_t src); /* 源端口*/
PACK_STRUCT_FIELD(u16_t dest); /* 目的端口*/
PACK_STRUCT_FIELD(u16_t len); /* 长度*/
PACK_STRUCT_FIELD(u16_t chksum); /* 校验和*/
} PACK_STRUCT_STRUCT;
可见,这个结构体的成员变量与图11.1.1 的UDP 首部字段一一对应。
(2) UDP 控制块
lwIP 为了更好的管理UDP 报文,它定义了一个UDP 控制块,使用该控制块来记录UDP
的通讯信息,例如源端口、目的端口,源IP 地址和目的IP 地址以及收到的数据回调函数等信
息,lwIP 把多个UDP 控制块使用链表形式连接起来,在处理时候遍历列表即可,该UDP 控制
块结构如以下所示:
#define IP_PCB \
ip_addr_t local_ip; \
\/* 本地ip 地址与远端IP 地址*/
ip_addr_t remote_ip;
u8_t netif_idx;
\ /* 绑定netif 索引*/
u8_t so_options;
\ /* Socket选项*/
u8_t tos;
\ /* 服务类型*/
u8_t ttl \ /* 生存时间*/
IP_PCB_NETIFHINT /* 链路层地址解析提示*/
struct ip_pcb
{
IP_PCB;
};
struct udp_pcb
{
IP_PCB;
struct udp_pcb *next; /* 指向下一个控制块*/
u8_t flags; /* 控制块状态*/
u16_t local_port, remote_port; /* 本地端口和目标端口*/
udp_recv_fn recv; /* 接收回调函数*/
void *recv_arg; /* 用户为recv回调提供的参数*/
};
可以看到,结构体udp_pcb 包含了指向下一个节点的指针next,多个UDP 控制块构建了
一个单向链表且各个控制块指向独立的接收回调函数,如下图所示:
对于RAW 的API 接口来讲,上图中的recv 由用户提供这个函数,而NETCONN 和
SOCKET 接口无需用户提供回调函数,因为lwIP 内核已经注册了该回调函数,所以数据到来
时,该函数把数据以邮箱的方式发送至NETCONN 和SOCKET 对应的接口。
11.1.3 发送UDP 报文
UDP 报文发送函数是由udp_sendto_if_src 实现,其实它最终调用ip_output_if_src 函数把
数据报递交给网络层处理,udp_sendto_if_src 函数如下所示:
err_t udp_sendto_if_src(struct udp_pcb *pcb, /* udp控制块*/
struct pbuf *p, /* pbuf网络数据包*/
const ip_addr_t *dst_ip, /* 目的IP地址*/
u16_t dst_port, /* 目的端口*/
struct netif *netif, /* 网卡信息*/
const ip_addr_t *src_ip) /* 源IP地址*/
{
struct udp_hdr *udphdr;
err_t err;
struct pbuf *q;
u8_t ip_proto;
u8_t ttl;
/* 第一步:判断控制块是否为空和远程IP地址是否为空*/
if (!IP_ADDR_PCB_VERSION_MATCH(pcb, src_ip) ||
!IP_ADDR_PCB_VERSION_MATCH(pcb, dst_ip))
{
return ERR_VAL; /* 放回错误*/
}
/* 如果PCB还没有绑定到一个端口,那么在这里绑定它*/
if (pcb->local_port == 0)
{
err = udp_bind(pcb, &pcb->local_ip, pcb->local_port);
if (err != ERR_OK)
{
return err;
}
}
/* 判断添加UDP首部会不会溢出*/
if ((u16_t)(p->tot_len + UDP_HLEN) < p->tot_len)
{
return ERR_MEM;
}
/* 第二步:没有足够的空间将UDP 首部添加到给定的pbuf 中*/
if (pbuf_add_header(p, UDP_HLEN))
{
/* 在单独的新pbuf中分配标头*/
q = pbuf_alloc(PBUF_IP, UDP_HLEN, PBUF_RAM);
/* 在单独的新pbuf中分配标头*/
if (q == NULL)
{
return ERR_MEM; /* 返回错误*/
}
if (p->tot_len != 0)
{
/* 把首部pbuf 和数据pbuf 连接到一个pbuf 链表上*/
pbuf_chain(q, p);
}
}
else /* 如果有足够的空间*/
{
/* 在数据pbuf 中已经预留UDP 首部空间*/
/* q 指向pbuf */
q = p;
}
/* 第三步:设置UDP首部信息*/
/* 指向它的UDP首部*/
udphdr = (struct udp_hdr *)q->payload;
/* 填写本地IP端口*/
udphdr->src = lwip_htons(pcb->local_port);
/* 填写目的端口*/
udphdr->dest = lwip_htons(dst_port);
/* 填写校验和*/
udphdr->chksum = 0x0000;
/* 设置长度*/
udphdr->len = lwip_htons(q->tot_len);
/* 设置协议类型*/
ip_proto = IP_PROTO_UDP;
/* 设置生存时间*/
ttl = pcb->ttl;
/* 第四步:发送到IP 层*/
NETIF_SET_HWADDRHINT(netif, &(pcb->addr_hint));
err = ip_output_if_src(q, src_ip, dst_ip, ttl, pcb->tos, ip_proto, netif);
NETIF_SET_HWADDRHINT(netif, NULL);
MIB2_STATS_INC(mib2.udpoutdatagrams);
if (q != p)
{
/*释放内存*/
pbuf_free(q);
q = NULL;
}
UDP_STATS_INC(udp.xmit);
return err;
}
此函数非常简单,首先判断源IP 地址和目标IP 地址是否为空,接着判断本地端口是否为
空,判断完成之后添加UDP 首部,最后调用ip_output_if_src 函数把数据报递交给网络层处理。
UDP 报文接收
网络层处理数据报完成之后,由udp_input 函数把数据报递交给传输层,该函数源码所示:
void udp_input(struct pbuf *p, struct netif *inp)
{
struct udp_hdr *udphdr;
struct udp_pcb *pcb, *prev;
struct udp_pcb *uncon_pcb;
u16_t src, dest;
u8_t broadcast;
u8_t for_us = 0;
LWIP_UNUSED_ARG(inp);
PERF_START;
UDP_STATS_INC(udp.recv);
/* 第一步:判断数据报长度少于UDP首部*/
if (p->len < UDP_HLEN)
{
UDP_STATS_INC(udp.lenerr);
UDP_STATS_INC(udp.drop);
MIB2_STATS_INC(mib2.udpinerrors);
pbuf_free(p); /* 释放内存,掉弃该数据报*/
goto end;
}
/* 指向UDP首部*/
udphdr = (struct udp_hdr *)p->payload;
/* 判断是否是广播包*/
broadcast = ip_addr_isbroadcast(ip_current_dest_addr(), ip_current_netif());
/* 得到源端口号*/
src = lwip_ntohs(udphdr->src);
/* 得到目的端口号*/
dest = lwip_ntohs(udphdr->dest);
udp_debug_print(udphdr);
pcb = NULL;
prev = NULL;
uncon_pcb = NULL;
/* 第二步:遍历UDP pcb列表以找到匹配的pcb */
for (pcb = udp_pcbs; pcb != NULL; pcb = pcb->next)
{
/* 第三步:比较PCB本地IP地址与端口*/
if ((pcb->local_port == dest) &&
(udp_input_local_match(pcb, inp, broadcast) != 0))
{
/* 判断UDP控制块的状态*/
if (((pcb->flags & UDP_FLAGS_CONNECTED) == 0) &&
((uncon_pcb == NULL)))
{
/* 如果未找到使用第一个UDP 控制块*/
uncon_pcb = pcb;
}
/* 判断目的IP是否为广播地址*/
else if (broadcast &&
ip4_current_dest_addr()->addr == IPADDR_BROADCAST)
{
/* 全局广播地址(仅对IPv4有效;之前检查过匹配)*/
if (!IP_IS_V4_VAL(uncon_pcb->local_ip) || !ip4_addr_cmp(ip_2_ip4(&uncon_pcb->local_ip),
netif_ip4_addr(inp)))
{
/* 检查此pcb ,uncon_pcb与输入netif不匹配*/
if (IP_IS_V4_VAL(pcb->local_ip) && ip4_addr_cmp(ip_2_ip4(&pcb->local_ip),
netif_ip4_addr(inp)))
{
/* 更好的匹配*/
uncon_pcb = pcb;
}
}
}
/* 比较PCB远程地址+端口和UDP源地址+端口*/
if ((pcb->remote_port == src) &&
(ip_addr_isany_val(pcb->remote_ip) ||
ip_addr_cmp(&pcb->remote_ip, ip_current_src_addr())))
{
/* 第一个完全匹配的PCB */
if (prev != NULL)
{
/* 将pcb移到udp_pcbs前面*/
prev->next = pcb->next;
pcb->next = udp_pcbs;
udp_pcbs = pcb;
}
else
{
UDP_STATS_INC(udp.cachehit);
}
break;
}
}
prev = pcb;
}
/* 第五步:找不到完全匹配的UDP 控制块
将第一个未使用的UDP 控制块作为匹配结果*/
if (pcb == NULL)
{
pcb = uncon_pcb;
}
/* 检查校验和是否匹配或是否匹配*/
if (pcb != NULL)
{
for_us = 1;
}
else
{
#if LWIP_IPV4
if (!ip_current_is_v6())
{
for_us = ip4_addr_cmp(netif_ip4_addr(inp), ip4_current_dest_addr());
}
#endif /* LWIP_IPV4 */
}
/* 第六步:如果匹配*/
if (for_us)
{
/* 调整报文的数据区域指针*/
if (pbuf_header(p, -UDP_HLEN))
{
UDP_STATS_INC(udp.drop);
MIB2_STATS_INC(mib2.udpinerrors);
pbuf_free(p);
goto end;
}
/* 如果找到对应的控制块*/
if (pcb != NULL)
{
MIB2_STATS_INC(mib2.udpindatagrams);
/* 回调函数,将数据递交给上层应用*/
if (pcb->recv != NULL)
{
/* 回调函数recv 需要负责释放p */
pcb->recv(pcb->recv_arg, pcb, p, ip_current_src_addr(), src);
}
else
{
/* 如果recv 函数没有注册,直接释放p */
pbuf_free(p);
goto end;
}
}
else /* 第七步:没有找到匹配的控制块,返回端口不可达ICMP 报文*/
{
if (!broadcast && !ip_addr_ismulticast(ip_current_dest_addr()))
{
/* 将数据区域指针移回IP 数据报首部*/
pbuf_header_force(p, (s16_t)(ip_current_header_tot_len() +
UDP_HLEN));
/* 返回一个端口不可达ICMP 差错控制报文到源主机中*/
icmp_port_unreach(ip_current_is_v6(), p);
}
UDP_STATS_INC(udp.proterr);
UDP_STATS_INC(udp.drop);
MIB2_STATS_INC(mib2.udpnoports);
pbuf_free(p); /* 掉弃该数据包*/
}
}
/* 如果不匹配,则掉弃该数据包*/
else
{
pbuf_free(p);
}
end:
PERF_STOP("udp_input");
return;
}
可以看出,此函数根据接收数据包的UDP 首部信息遍历UDP 控制块链表,找到对应的控
制块之后lwIP 内核把接收到的数据包递交给pcb->recv 回调函数处理。
RAW 的UDP 接口简介
下表给出了UDP 协议的RAW 的API 功能函数,我们使用这些函数来完成UDP 的数据发
送和接收功能。
表11.2.1 只是列出了我们在编程时需要使用到的函数,接下来我们来看一下上述表中重要
的函数:
(1) udp_new 函数
此函数用来创建一个UDP 控制块,这个控制块用来描述IP 地址、端口号和状态等信息,
该函数实现源码如下所示:
struct udp_pcb *
udp_new(void)
{
struct udp_pcb *pcb;
/* 申请一个UDP内存池*/
pcb = (struct udp_pcb *)memp_malloc(MEMP_UDP_PCB);
/* 申请成功*/
if (pcb != NULL)
{
/* 初始化PCB控制块所有零*/
memset(pcb, 0, sizeof(struct udp_pcb));
/* pcb->ttl = 255 */
pcb->ttl = UDP_TTL;
}
return pcb;
}
可以看到,该控制块的内存由内存池申请,申请成功之后设置该控制块的生存时间。
(2) udp_remove 函数
从PCB 控制块链表中移除一个控制块,并且把移除的控制块释放内存,该函数实现源码
如下所示:
void udp_remove(struct udp_pcb *pcb)
{
struct udp_pcb *pcb2;
mib2_udp_unbind(pcb);
/* 判断pcb被删除在列表的第一个*/
if (udp_pcbs == pcb)
{
/* 从第二pcb开始制作列表*/
udp_pcbs = udp_pcbs->next;
}
else /* pcb不在列表的第一个*/
{
/* 遍历pcb列表*/
for (pcb2 = udp_pcbs; pcb2 != NULL; pcb2 = pcb2->next)
{
/* 在udp_pcbs列表中查找pcb */
if (pcb2->next != NULL && pcb2->next == pcb)
{
/* 从列表中删除pcb */
pcb2->next = pcb->next;
break;
}
}
}
memp_free(MEMP_UDP_PCB, pcb);
}
以传入的控制块为条件,遍历PCB 控制块链表,若链表中的控制块等于要移除的控制块,则该控制块移除PCB 控制块链表,移除完成之后释放该控制块的内存。
(3) udp_recv 函数
此函数用来设置接收回调函数及函数参数,若用户使用RAW 接口实现UDP,则用户必须
调用此函数设置接收回调函数,该函数的源码如下所示:
void udp_recv(struct udp_pcb *pcb, udp_recv_fn recv, void *recv_arg)
{
/* 调用recv()回调和用户数据*/
pcb->recv = recv;
pcb->recv_arg = recv_arg;
}
可以看出,设置的函数和形参都是由UDP 控制块的字段指向。
RAW 的UDP 实验
硬件设计
- 例程功能
本章实验的目标是PC 端和开发板通过UDP 协议连接起来,PC 端使用网络调试助手向开
发板发送数据,开发板接收到以后在LCD 上显示接收到的数据,我们也可以通过开发板上的
按键发送数据给PC。
该实验的实验工程,请参考《lwIP 例程2 lwIP_RAW_UDP 实验》。
软件设计
11.3.2.1 UDP 配置步骤
- 创建UDP 控制块
调用函数udp_new 创建UDP 控制块。 - 连接指定的IP 地址和端口号
调用函数udp_connect 绑定远程IP 地址和远程端口号。 - 绑定本地IP 地址与端口号
调用函数udp_bind 绑定本地IP 地址和本地端口号。 - 注册接收回调函数
udp_recv 是注册接收回调函数,该函数需要自己编写。 - 发送数据
调用函数udp_send 发送数据。
11.3.2.2 程序流程图
本实验的程序流程图,如下图所示:
程序解析
本实验重点讲解lwip_deom.c 这个文件,该文件定义了五个函数,它们的作用如下表所示:
我们首先看一下lwip_demo 函数,该函数的代码如下。
/**
* @brief lwip_demo实验入口
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
err_t err;
struct udp_pcb *udppcb; /* 定义一个TCP服务器控制块*/
ip_addr_t rmtipaddr; /* 远端ip地址*/
char *tbuf;
uint8_t key;
uint8_t res = 0;
uint8_t t = 0;
lwip_demo_set_remoteip(); /* 先选择IP */
lcd_clear(BLACK); /* 清屏*/
g_point_color = WHITE;
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "UDP Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY0:Send data", g_point_color);
tbuf = mymalloc(SRAMIN, 200); /* 申请内存*/
if (tbuf == NULL)
return; /* 内存申请失败了,直接退出*/
sprintf((char *)tbuf, "Local IP:%d.%d.%d.%d", lwipdev.ip[0],
lwipdev.ip[1],
lwipdev.ip[2],
lwipdev.ip[3]); /* 服务器IP */
lcd_show_string(30, 130, 210, 16, 16, tbuf, g_point_color);
/* 远端IP */
sprintf((char *)tbuf, "Remote IP:%d.%d.%d.%d", lwipdev.remoteip[0],
lwipdev.remoteip[1],
lwipdev.remoteip[2],
lwipdev.remoteip[3]);
lcd_show_string(30, 150, 210, 16, 16, tbuf, g_point_color);
sprintf((char *)tbuf, "Remote Port:%d", UDP_DEMO_PORT); /* 客户端端口号*/
lcd_show_string(30, 170, 210, 16, 16, tbuf, g_point_color);
g_point_color = BLUE;
lcd_show_string(30, 190, 210, 16, 16, "STATUS:Disconnected", g_point_color);
udppcb = udp_new();
if (udppcb) /* 创建成功*/
{
IP4_ADDR(&rmtipaddr, lwipdev.remoteip[0], lwipdev.remoteip[1],
lwipdev.remoteip[2], lwipdev.remoteip[3]);
/* UDP客户端连接到指定IP地址和端口号的服务器*/
err = udp_connect(udppcb, &rmtipaddr, UDP_DEMO_PORT);
if (err == ERR_OK)
{
/* 绑定本地IP地址与端口号*/
err = udp_bind(udppcb, IP_ADDR_ANY, UDP_DEMO_PORT);
if (err == ERR_OK) /* 绑定完成*/
{
udp_recv(udppcb, lwip_demo_callback, NULL); /* 注册接收回调函数*/
/* 标记连接上了(UDP是非可靠连接,这里仅仅表示本地UDP已经准备好) */
lcd_show_string(30, 190, 210, 16, 16, "STATUS:Connected ",
g_point_color);
g_point_color = WHITE;
lcd_show_string(30, 210, lcddev.width - 30,
lcddev.height - 190, 16,
"Receive Data:", g_point_color); /* 提示消息*/
g_point_color = BLUE;
}
else
res = 1;
}
else
res = 1;
}
else
res = 1;
while (res == 0)
{
key = key_scan(0);
if (key == KEY1_PRES)
break;
if (key == KEY0_PRES) /* KEY0按下了,发送数据*/
{
lwip_demo_senddata(udppcb);
}
if (lwip_send_flag & 1 << 6) /* 是否收到数据*/
{
/* 清上一次数据*/
lcd_fill(30, 230, lcddev.width - 1, lcddev.height - 1, BLACK);
/* 显示接收到的数据*/
lcd_show_string(30, 230, lcddev.width - 30, lcddev.height - 230, 16,
(char *)udp_demo_recvbuf, g_point_color);
lwip_demo_flag &= ~(1 << 6); /* 标记数据已经被处理了*/
}
lwip_periodic_handle();
delay_ms(2);
t++;
if (t == 200)
{
t = 0;
LED0_TOGGLE();
}
}
lwip_demo_connection_close(udppcb);
myfree(SRAMIN, tbuf);
}
此函数非常简单,它首先设置IP 地址等信息,接着调用RAW 相关API 函数配置UDP 连
接,值得注意的是,UDP 的接收函数由用户编写,并且调用udp_recv 函数注册到UDP 控制块
当中。
设置远程IP 地址的函数为lwip_udp_set_remoteip,如下源码所示:
/**
* @brief 设置远端IP地址
* @param 无
* @retval 无
*/
void lwip_udp_set_remoteip(void)
{
char *tbuf;
uint16_t xoff;
uint8_t key;
lcd_clear(BLACK);
g_point_color = WHITE;
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "UDP Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "Remote IP Set", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY0:+ KEY2:-", g_point_color);
lcd_show_string(30, 110, 200, 16, 16, "KEY1:OK", g_point_color);
tbuf = mymalloc(SRAMIN, 100); /* 申请内存*/
if (tbuf == NULL)
return;
/* 前三个IP保持和DHCP得到的IP一致*/
lwipdev.remoteip[0] = lwipdev.ip[0];
lwipdev.remoteip[1] = lwipdev.ip[1];
lwipdev.remoteip[2] = lwipdev.ip[2];
/* 远端IP */
sprintf((char *)tbuf, "Remote IP:%d.%d.%d.", lwipdev.remoteip[0],
lwipdev.remoteip[1], lwipdev.remoteip[2]);
lcd_show_string(30, 150, 210, 16, 16, tbuf, g_point_color);
g_point_color = BLUE;
xoff = strlen((char *)tbuf) * 8 + 30;
lcd_show_xnum(xoff, 150, lwipdev.remoteip[3], 3, 16, 0, g_point_color);
while (1)
{
key = key_scan(0);
if (key == KEY1_PRES)
break;
else if (key)
{
if (key == KEY0_PRES)
lwipdev.remoteip[3]++; /* IP增加*/
if (key == KEY2_PRES)
lwipdev.remoteip[3]--; /* IP减少*/
/* 显示新IP */
lcd_show_xnum(xoff, 150, lwipdev.remoteip[3], 3, 16, 0X80,
g_point_color);
}
}
myfree(SRAMIN, tbuf);
}
可见,此函数根据开发板上的KEY0 和KEY2 按键来设置远程IP 地址,设置好IP 地址之
后按下KEY1 退出设置界面。
函数udp_recv 为注册接收回调函数lwip_udp_callback,如下源码所示:
/**
* @brief UDP服务器回调函数
* @param arg :传入参数
* @param upcb:UDP控制块
* @param p : 网络数据包
* @param addr:IP地址
* @param port:端口号
* @retval 无
*/
static void lwip_udp_callback(void *arg, struct udp_pcb *upcb, struct pbuf *p,
const ip_addr_t *addr, u16_t port)
{
uint32_t data_len = 0;
struct pbuf *q;
if (p != NULL) /* 接收到不为空的数据时*/
{
memset(udp_demo_recvbuf, 0, UDP_DEMO_RX_BUFSIZE); /* 数据接收缓冲区清零*/
for (q = p; q != NULL; q = q->next) /* 遍历完整个pbuf链表*/
{
/* 判断要拷贝到UDP_DEMO_RX_BUFSIZE中的数据是否大于
UDP_DEMO_RX_BUFSIZE的剩余空间,如果大于*/
/* 的话就只拷贝UDP_DEMO_RX_BUFSIZE中剩余长度的数据,
否则的话就拷贝所有的数据*/
/* 拷贝数据*/
if (q->len > (UDP_DEMO_RX_BUFSIZE - data_len))
memcpy(udp_demo_recvbuf + data_len, q->payload,
(UDP_DEMO_RX_BUFSIZE - data_len));
else
memcpy(udp_demo_recvbuf + data_len, q->payload, q->len);
data_len += q->len;
/* 超出TCP客户端接收数组,跳出*/
if (data_len > UDP_DEMO_RX_BUFSIZE)
break;
}
upcb->remote_ip = *addr; /* 记录远程主机的IP地址*/
upcb->remote_port = port; /* 记录远程主机的端口号*/
lwipdev.remoteip[0] = upcb->remote_ip.addr & 0xff; /* IADDR4 */
lwipdev.remoteip[1] = (upcb->remote_ip.addr >> 8) & 0xff; /* IADDR3 */
lwipdev.remoteip[2] = (upcb->remote_ip.addr >> 16) & 0xff; /* IADDR2 */
lwipdev.remoteip[3] = (upcb->remote_ip.addr >> 24) & 0xff; /* IADDR1 */
udp_demo_flag |= 1 << 6; /* 标记接收到数据了*/
pbuf_free(p); /* 释放内存*/
}
else
{
udp_disconnect(upcb);
lcd_clear(BLACK); /* 清屏*/
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "UDP Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY1:Connect", g_point_color);
lcd_show_string(30, 190, 210, 16, 16, "Connect break!", g_point_color);
}
}
之前笔者讲解过,使用RAW 接口实现远程通讯的话,它的接收函数由用户编写且调用
udp_recv 函数让控制块的recv 函数指针指向,因为lwIP 内核接收到的数据会递交给这个接收
函数,所以不注册该函数就无法接收到数据。
lwip_udp_senddata 函数是用来发送数据的,在发送数据前我们先通过pbuf_alloc 函数申请
内存,当内存申请成功以后我们将发送缓冲区lwip_demo_sendbuf 的首地址填入到ptr 的
payload 字段,然后调用udp_send 函数将数据发送出去,最后释放申请到的内存,代码如下。
/**
* @brief UDP服务器发送数据
* @param upcb: UDP控制块
* @retval 无
*/
void lwip_udp_senddata(struct udp_pcb *upcb)
{
struct pbuf *ptr;
/* 申请内存*/
ptr = pbuf_alloc(PBUF_TRANSPORT, strlen((char *)udp_demo_sendbuf),
PBUF_POOL);
if (ptr)
{
pbuf_take(ptr, (char *)udp_demo_sendbuf, strlen((char *)udp_demo_sendbuf));
/* 将tcp_demo_sendbuf中的数据打包进pbuf结构中*/
udp_send(upcb, ptr); /* udp发送数据*/
pbuf_free(ptr); /* 释放内存*/
}
}
lwip_demo_connection_close 函数是用来关闭UDP 连接的,这个函数很简单,通过调用函
数udp_disconnect 来关闭连接,然后调用udp_remove 函数将当前被关闭的连接控制块从当前
连接控制块链表中删除,代码如下。
/**
* @brief 关闭tcp连接
* @param upcb: UDP控制块
* @retval 无
*/
void lwip_demo_connection_close(struct udp_pcb *upcb)
{
udp_disconnect(upcb);
udp_remove(upcb); /* 断开UDP连接*/
udp_demo_flag &= ~(1 << 5); /* 标记连接断开*/
lcd_clear(BLACK); /* 清屏*/
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "UDP Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY1:Connect", g_point_color);
lcd_show_string(30, 190, 210, 16, 16, "STATUS:Disconnected", g_point_color);
}
main 函数一开始完成外设的初始化,如果开启DHCP 的话通过DHCP 获取IP 地址,IP 地
址获取成功以后就调用udp_demo_test 函数进入UDP 实验。我们知道在lwip_demo 函数中有一个while()循环,当从这个循环退出来以后就会进入main 函数的while()循环中,在main 函数
的while()循环中当KEY1 按下并且UDP 连接已经断开就调用lwip_demo 函数重新开始UDP 实
验,如下源码所示:
key = key_scan(0);
if (key == KEY1_PRES)
{
if ((lwip_send_flag & 1 << 5))
{
printf("UDP连接已经建立,不能重复连接\r\n"); /* 如果连接成功,不做任何处理*/
}
else
{
lwip_demo_test(); /* 当断开连接后,调用udp_demo_test()函数*/
}
}
下载验证
下载完代码后,打开网络调试助手,等待开发板的LCD 出现如图11.4.1 所示界面,在这
个界面上我们通过按键KEY2 和KEY0 设置远端IP 地址,也就是电脑的IP 地址,设置好以后
按KEY_UP 确认,确认完了以后LCD 就如图11.4.2 所示的数据接收界面。
图11.3.3.1 远端IP 地址设置
接下来设置电脑端的网络调试助手,设置完成后点击网络调试助手的“连接”,操作完后
的网络调试助手如下图所示,
设置完网络调试助手后在发送填入要发送的数据,这里输入要发送的数据:ALIENTEK
DATA,然后点击发送,这时我们开发板的LCD 上显示,我们可以看到在LCD 上显示出了电
脑端发送过来的数据,我们通过按下KEY0,向电脑端发送数据“ALIENTEK DATA”图
11.4.4 所示,表明网络调试助手接收到开发板发送的数据,这里我们按了12 次KEY0,因此在
网络调试助手上有12 行数据。
图11.3.3.4 UDP 测试
RAW 接口编程 TCP 客户端实验
本章,我们学习传输层的另一个协议,它是 TCP 协议,TCP 协议对于 UDP 协议来说,可能有点晦涩难懂,读者可以参考相关网络书籍,来学习TCP 协议。
TCP 协议
TCP 协议简介
TCP(Transmission Control Protocol 传输控制协议)是一种面向连接的、可靠的、基于字节流的传输层通信协议。
TCP 为了保证数据包传输的可靠行,会给每个包一个包序号,同时此序号也保证了发送到接收端主机能够按序接收。然后接收端主机对成功接收到的数据包发回一个相应的确认字符(ACK Acknowledgement),如果发送端主机在合理的往返时延(RTT)内未收到确认字符ACK,那么对应的数据包就被认为丢失并将被重传。
TCP 协议,它是基于连接的一种传输层协议,在发送数据之前要求系统需要在不可靠的信道上建立可靠连接,我们称之为“三次握手”。建立连接完成之后客户端与服务器才能互发数据,不需要发送数据时,可以断开连接,这里我们称之为“四次挥手”。
TCP 的建立连接
握手之前主动打开连接的客户端结束CLOSED 阶段,被动打开的服务器端也结束CLOSED 阶段,并进入LISTEN 阶段。随后开始“三次握手”:
- ①TCP 服务器进程先创建传输控制块TCB,时刻准备接受客户进程的连接请求,此时服务器就进入了LISTEN(监听)状态。
- ②TCP 客户进程也是先创建传输控制块TCB,然后向服务器发出连接请求报文,这是报文首部中的同部位SYN=1,同时选择一个初始序列号seq=x ,此时,TCP 客户端进程进入了SYN-SENT(同步已发送状态)状态。TCP 规定,SYN 报文段(SYN=1 的报文段)不能携带数据,但需要消耗一个序号。
- ③TCP 服务器收到请求报文后,如果同意连接,则发出确认报文。确认报文中应该ACK=1,SYN=1,确认号是ack=x+1,同时也要为自己初始化一个序列号seq=y,此时,TCP服务器进程进入了SYN-RCVD(同步收到)状态。这个报文也不能携带数据,但是同样要消耗一个序号。
- ④TCP 客户进程收到确认后,还要向服务器给出确认。确认报文的ACK=1,ack=y+1,自己的序列号seq=x+1,此时,TCP 连接建立,客户端进入ESTABLISHED(已建立连接)状态。TCP 规定,ACK 报文段可以携带数据,但是如果不携带数据则不消耗序号。
当服务器收到客户端的确认后也进入ESTABLISHED 状态,此后双方就可以开始通信了。
这就是“三次握手”的过程,如下图所示。
TCP 终止连接
建立一个连接需要三次握手而终止一个连接需要四次挥手,终止连接有以下过程。
- (1) 第一次挥手:客户端发送释放报文,并停止发送数据。释放数据报文首部,FIN=1,其序列号为seq=u,此时,客户端进入FIN-WAIT1(等待服务器应答FIN 报文)。
- (2) 第二次挥手:服务器收到客户端的FIN 报文后,发出确认报文ACK=1、ack=u+1,并携带自己的序列号seq=v。此时,服务器进入CLOSE-WAIT(关闭等待)状态。客户端收到服务端确认请求,此时,客户端进入FIN-WAIT2(终止等待2)状态,等待服务器发送连接释放报文。
- (3) 第三次挥手:服务器向客户端发送连接释放报文FIN=1、ack=u+1,此时,服务器进入了LAST-ACK(最后确认)等待客户端的确认。客户端接收到服务器的连接释放报文后,必须发送确认ack=1、ack=w+1,客户端的序列号为seq=u+1,此时,客户端进入TIME-WAIT(时间等待)。
- (4) 第四次挥手:服务器接收到客户端的确认报文,立刻进入CLOSED 状态。
这四次挥手就是终止TCP 协议连接,如下图所示:
上图的终止连接由客户端发起,当然服务器也可以发起终止连接。
TCP 报文结构
在传输层中,TCP 的数据包称为数据段,TCP 报文段与 UDP 报文段一样都是封装在 IP 数据报中发送。TCP 首部包含建立与断开、数据确认、窗口大小通告、数据发送相关的所有标志和控制信息,TCP 报文结构如下图所示:
(1) 源、目标端口号字段:占16 比特。TCP 协议通过使用”端口”来标识源端和目标端的应用进程。端口号可以使用0 到65535 之间的任何数字。在收到服务请求时,操作系统动态地为客户端的应用程序分配端口号。在服务器端,每种服务在”众所周知的端口”(Well-KnowPort)为用户提供服务。
(2) 序列号字段:占32 比特。用来标识从TCP 源端向TCP 目标端发送的数据字节流,它表示在这个报文段中的第一个数据字节。
(3) 确认号字段:占32 比特。只有ACK 标志为1 时,确认号字段才有效。它包含目标端所期望收到源端的下一个数据字节。
(4) 头部长度字段:占4 比特。给出头部占32 比特的数目。没有任何选项字段的TCP 头部长度为20 字节;最多可以有60 字节的TCP 头部。
(5) 标志位字段(U、A、P、R、S、F):占6 比特。各比特的含义如下:
①URG:紧急指针有效。
②ACK:为1 时,确认序号有效。
③PSH:为1 时,接收方应该尽快将这个报文段交给应用层。
④RST:为1 时,重建连接。
⑤SYN:为1 时,同步程序,发起一个连接。
⑥FIN:为1 时,发送端完成任务,释放一个连接。
(6) 窗口大小字段:占16 比特。此字段用来进行流量控制。单位为字节数,这个值是本机期望一次接收的字节数。
(7) TCP 校验和字段:占16 比特。对整个TCP 报文段,即TCP 头部和TCP 数据进行校验和计算,并由目标端进行验证。
(8) 紧急指针字段:占16 比特。它是一个偏移量,和序号字段中的值相加表示紧急数据最后一个字节的序号。
(9) 选项字段:占32 比特。可能包括”窗口扩大因子”、”时间戳”等选项。
上述的内容讲解的是TCP 首部信息,这些信息被封装在一个IP 数据报中,该数据报结构如下图所示。
lwIP 的 TCP 报文首部数据结构
实现 TCP 协议的文件有tcp.h、tcp.c、tcp_in.c 和tcp_out.c,这四个文件实现了TCP 协议全部数据结构和函数,其中tcp.c 文件包含了与TCP 编程、TCP 定时器相关的函数,tcp.h 定义了宏和结构体,tcp_in.c 文件包含了TCP 报文段输入处理函数,tcp_out.c 文件包含了TCP 报文输出处理函数。
首先我们看一下TCP 首部结构,这个结构为tcp_hdr,如下源码所示:
struct tcp_hdr {
PACK_STRUCT_FIELD(u16_t src); /* 源端口*/
PACK_STRUCT_FIELD(u16_t dest); /* 目的端口*/
PACK_STRUCT_FIELD(u32_t seqno); /* 序号*/
PACK_STRUCT_FIELD(u32_t ackno); /* 确认序号*/
PACK_STRUCT_FIELD(u16_t _hdrlen_rsvd_flags); /* 首部长度+保留位+标志位*/
PACK_STRUCT_FIELD(u16_t wnd); /* 窗口大小*/
PACK_STRUCT_FIELD(u16_t chksum); /* 校验位*/
PACK_STRUCT_FIELD(u16_t urgp); /* 紧急指针*/
} PACK_STRUCT_STRUCT;
可见,lwIP 使用tcp_hdr 结构体描述TCP 首部各个字段,该结构体_hdrlen_rsvd_flags 成员用来描述下图黄色部分的内容。
lwIP 的TCP 连接状态图
根据图12.1.2.1 和12.1.2.2 所示,发送端与接收端发送的指令会进入不同的状态,因此,lwIP 在tcpbase.h 文件中定义了枚举类型tcp_state,它是用来描述TCP 的状态,该枚举tcp_state 如下源码所示:
enum tcp_state {
CLOSED = 0, /* 关闭状态*/
LISTEN = 1, /* 监听状态*/
SYN_SENT = 2, /* 发送请求连接*/
SYN_RCVD = 3, /* 接收请求连接*/
ESTABLISHED = 4, /* 连接状态已建立*/
FIN_WAIT_1 = 5, /* 程序已关闭该连接*/
FIN_WAIT_2 = 6, /* 另一端已关闭连接*/
CLOSE_WAIT = 7, /* 等待程序关闭连接*/
CLOSING = 8, /* 两端同时收到对方的关闭请求*/
LAST_ACK = 9, /* 服务器等待对方接收关闭操作*/
TIME_WAIT = 10 /* 关闭成功*/
};
下面笔者使用TCP 状态转换图来描述连接可能在各个状态之间的转换关系,如下图所示:
如果TCP 需要建立连接,则系统需要三次握手;如果TCP 中断连接,则系统需要四次挥手,三次握手与四次挥手的状态图:
lwIP 的TCP 控制块
已经讲解了TCP 协议理论的知识,这一小节我们正式踏入lwIP 的TCP 协议大门。在此之前我们先了解一下TCP 控制块,这个控制块定义了TCP 协议运作过程中的参数,例如发送窗口、数据缓冲区等,如下源码所示:
/** TCP协议控制块*/
struct tcp_pcb
{
/** common PCB members */
IP_PCB;
TCP_PCB_COMMON(struct tcp_pcb);
/* 远端端口号*/
u16_t remote_port;
/*附加状态信息,如连接是快速恢复、一个被延迟的ACK 是否被发送等*/
tcpflags_t flags;
#define TF_ACK_DELAY 0x01 U /* 延迟发送ACK. */ #define TF_ACK_NOW 0x02 U /* 延迟发送ACK. */ #define TF_INFR 0x04 U /* 在快速恢复. */ #define TF_CLOSEPEND 0x08 U /* 关闭挂起*/ #define TF_RXCLOSED 0x10 U /* rx 由tcp_shutdown 关闭*/ #define TF_FIN 0x20 U /* 连接在本地关闭(FIN段入队) */ #define TF_NODELAY 0x40 U /* 纳格尔禁用算法*/ #define TF_NAGLEMEMERR 0x80 U /* nagle启用,本地缓冲区溢出*/
/* Timers */
u8_t polltmr, pollinterval;
/* 控制块被最后一次处理的时间*/
u8_t last_timer;
/* 该字段记录该PCB 被创建的时刻*/
u32_t tmr;
/* 接收变量*/
u32_t rcv_nxt; /* 下一个期望收到的序号*/
tcpwnd_size_t rcv_wnd; /* 当前接收窗口的大小,会随着数据的接收与递交动态变化*/
tcpwnd_size_t rcv_ann_wnd; /* 将向对方通告的窗口大小,随着数据的接收与递交动态变化*/
u32_t rcv_ann_right_edge; /* 上一次窗口通告时窗口的右边界值*/
/* 重传定时器,该值随时间递增,当大于rto 的值时重传报文*/
s16_t rtime;
u16_t mss; /* 对方可接收的最大报文段大小*/
/* RTT(往返时间)估计变量*/
u32_t rttest; /* RTT估计每秒500毫秒*/
u32_t rtseq; /* 序列号定时*/
s16_t sa, sv; /* RTT 估计得到的平均值与时间差*/
s16_t rto; /* 重新传输超时(以TCP_SLOW_INTERVAL为单位) */
u8_t nrtx; /* 重新发送的*/
/* 快速重新传输/恢复*/
u8_t dupacks; /* 上述最大确认号被重复收到的次数*/
u32_t lastack; /* 接收到的最大确认序号*/
/* 拥塞避免/控制变量*/
tcpwnd_size_t cwnd; /* 连接当前的窗口大小*/
tcpwnd_size_t ssthresh; /* 拥塞避免算法启动的阈值*/
/* 第一个字节后面最后一个rto字节*/
u32_t rto_end;
/* 发送变量*/
u32_t snd_nxt; /* 下一个要发送的序号*/
u32_t snd_wl1, snd_wl2; /* 上一次收到的序号和确认号*/
u32_t snd_lbb; /* 要缓冲的下一个字节的序列号*/
tcpwnd_size_t snd_wnd; /* 发送窗口*/
tcpwnd_size_t snd_wnd_max; /* 对方的最大发送方窗口*/
/* 可用的缓冲区空间*/
tcpwnd_size_t snd_buf;
#define TCP_SNDQUEUELEN_OVERFLOW(0xffff U - 3)
u16_t snd_queuelen; /* 可用的发送包数*/
#if TCP_OVERSIZE
/* Extra bytes available at the end of the last pbuf in unsent. */
u16_t unsent_oversize;
#endif /* TCP_OVERSIZE */
tcpwnd_size_t bytes_acked;
/* These are ordered by sequence number: */
struct tcp_seg *unsent; /* 未发送的报文段*/
struct tcp_seg *unacked; /* 已发送但未收到确认的报文段. */
struct pbuf *refused_data; /* 以前收到但上层尚未取得的数据*/
#if LWIP_CALLBACK_API || TCP_LISTEN_BACKLOG struct tcp_pcb_listen * listener;
#endif /* LWIP_CALLBACK_API || TCP_LISTEN_BACKLOG */
/* TCP 协议相关的回调函数*/
#if LWIP_CALLBACK_API
/* 当数据发送成功后被调用. */
tcp_sent_fn sent;
/* 接收数据完成后被调用*/
tcp_recv_fn recv;
/* 建立连接后被调用. */
tcp_connected_fn connected;
/* 该函数被内核周期调用. */
tcp_poll_fn poll;
/* 发送错误时候被调用. */
tcp_err_fn errf;
#endif /* LWIP_CALLBACK_API */
/* 保持活性*/
u32_t keep_idle;
/* 坚持计时器计数器值*/
u8_t persist_cnt;
/* 坚持计时器关闭*/
u8_t persist_backoff;
/* 持续探测数*/
u8_t persist_probe;
/* 保持活性报文发送次数*/
u8_t keep_cnt_sent;
};
TCP 协议控制块的成员变量有点多,TCP 协议在 lwIP 源码中占了50%之多。
首先我们先讲解一下接收数据相关的字段rcv_nxt,rcv_wnd,rcv_ann_wnd 和数据发送的相关字段snd_nxt,snd_max,snd_wnd,acked,这些字段和TCP 中滑动窗口协议有密切关系的。
下面的内容参考自《嵌入式网络那些事LWIP 协议深度剖析与实战演练》,作者朱升林。
- TCP 控制块接收窗口
在TCP 控制块中,关于接收窗口有四个变量来描述,如下图所示:
①rcv_nxt:是自己期望收到的下一个数据字节编号。
②rcv_wnd:表示接收窗口的大小。
③rcv_ann_wnd:表示将向对方通告的窗口大小值,这个值在报文发送时会被填在首部中的窗口大小字段。
④rcv_ann_right_edge:记录了上一次窗口通告时窗口右边界取值,该字段在窗口滑动过程中经常被用到。
在上图中绿色框是窗口大小(rcv_wnd = 9 ),也就是说可以发送9 个数据,而rcv_ann_wnd = 9 就是通知对方窗口大小的值,而rcv_ann_right_edge 记录了上一次窗口通告时窗口右边界取值(14),当然下一次发送时,这四个变量就不一定是上述图中的值了,它们会随着数据的发送与接收动态改变。
当接收到数据后,数据会被放在接收窗口中等待上层调用,rcv_nxt 字段会指向下一个期望接收的编号,同时窗口值rcv_wnd 值会减少,当上层取走相关的数据后,窗口的值会增加;rcv_ann_wnd 在整个过程中都是动态计算的,当rcv_wnd 值改变时,内核会计算一个合理的窗口值rcv_ann_wnd(并不一定与rcv_wnd 相等),在下一次报文发送时,通告窗口的值(rcv_ann_wnd )会被填入报文的首部,同时右边界值rcv_ann_right_edge 也在报文发送后更新数值。
- TCP 控制块发送窗口
在lwIP 源码描述TCP 的发送窗口涉及4 个变量,它们之间的关系如下图所示:
①lastack:字段记录了被接收方确认的最高序列号。
②snd_nxt:表示自己将要发送的下一个数据的起始编号。
③snd_wnd:记录了当前的发送窗口大小,它常被设置为接收方通告的接收窗口值。
④snd_lbb:记录了下一个将被应用程序缓存的数据的起始编号。
可以看出,左边部分是已经发送并确认的数据,绿色框是已经发送但未确认的数据(需要等待对方确认),红色框可以发送的数据,最右边的是不能发送的。
上面这四个字段的值也是动态变化的,每当收到接收方的一个有效ACK 后,lastack 的值就做相应的增加,指向下一个待确认数据的编号,当发送一个报文后,snd_nxt 的值就做相应的增加,指向下一个待发送数据。snd_nxt 和lastack 之间的差值不能超过sndwnd 的大小。由于实际数据发送时是按照报文段的形式组织的,因此可能存在这样的情况:即使发送窗口允许,但并不是窗口内的所有数据都能被发送以填满窗口,如上图中编号为11~13 的数据,可能因为它们太小不能组织成一个有效的报文段,因此不会被发送。发送方会等到新的确认到来,从而使发送窗口向右滑动,使得更多的数据被包含在窗口中,这样再启动下一个报文段的发送。
- 监听控制块
lwIP 除了定义结构体tcp_pcb,它还定义了结构体tcp_pcb_listen,前者我们知道有这个就行,后者结构体tcp_pcb_listen 主要描述LISTEN 状态的连接,一般用于描述处于监听状态的连接,在处于LISTEN 状态的连接只记录本地端口的信息,不记录任何远程端口的信息,当然处于该状态不会进行数据发送、连接握手之类的服务,主要是分配完整的TCP 控制块是比较消耗内存资源的,在TCP 协议在连接之初,是无法进行数据交互,那么在监听的时候只需要把对方主机的相关信息得到,然后无缝切换到完整的TCP 控制块中,这样子就能节省不少资源,tcp_pcb_listen 的庐山真面目,如下源码所示:
/** 用于监听pcb的TCP协议控制块*/
struct tcp_pcb_listen {
/** 该宏包含源IP 地址、目的IP 地址两个重要字段*/
IP_PCB;
/** 两种控制块都具有的字段*/
TCP_PCB_COMMON(struct tcp_pcb_listen);
#if LWIP_CALLBACK_API
/* 函数在连接侦听器时调用*/
tcp_accept_fn accept;#
endif /* LWIP_CALLBACK_API */
};
- 控制块链表:
为了描述TCP 控制块,lwIP 内核定义了四条链表来链接处于不同状态下的控制块,TCP操作一般对于链表上的控制块进行查找,这四个控制块链表在tcp.c 文件中,如下源码所示:
/*连接所有进行了端口号绑定,但是还没有发起连接(主动连接)或进入侦听状态(被动连接)的控制块*/
struct tcp_pcb *tcp_bound_pcbs;
/* 连接所有进入侦听状态(被动连接)的控制块*/
union tcp_listen_pcbs_t tcp_listen_pcbs;
/* 连接所有处于其他状态的控制块. */
struct tcp_pcb *tcp_active_pcbs;
/* 连接所有处于TIME-WAIT 状态的控制块*/
struct tcp_pcb *tcp_tw_pcbs;
- TCP 报文段缓冲
在内核中,所有待发送的数据或者已经接收的数据都会以报文的形式保存,一般都是保存在pbuf 中,为了很好的管理报文段的pbuf,内核引用了一个tcp_seg 的结构体,该结构体的作用就是把所有报文段连接起来,当然这些报文段可以是无发送、已发送并未确认的或者是以收到的报文,它们都保存在TCP 控制块缓冲区中,该结构体如下源码所示:
/* 定义组织 TCP 报文段的结构*/
struct tcp_seg {
struct tcp_seg *next; /* 该指针用于将报文段组织为队列的形式*/
struct pbuf *p; /* 指向装载报文段的pbuf */
u16_t len; /* 报文段中的数据长度*/
u8_t flags;
#define TF_SEG_OPTS_MSS (u8_t)0x01U /* 包含了最大报文段大小选项*/
#define TF_SEG_OPTS_TS (u8_t)0x02U /* 包含了时间戳选项*/
#define TF_SEG_DATA_CHECKSUMMED (u8_t)0x04U /* 所有数据(不是header)都是校验和为*/
#define TF_SEG_OPTS_WND_SCALE (u8_t)0x08U /* 包括WND规模选项(仅用于SYN段) */
#define TF_SEG_OPTS_SACK_PERM (u8_t)0x10U /*包括SACK允许选项(仅在SYN段中使用)*/
/* 指向报文段中的 TCP 首部*/
struct tcp_hdr *tcphdr; /* TCP报头*/
};
每个控制块中都维护了三个缓冲队列,unsent、unacked、ooseq 三个字段(这三个字段已经在TCP 控制块时候讲解了)。unsent 用于连接还未被发送出去的报文段、unacked 用于连接已经发送出去但是还未被确认的报文段、ooseq 用于连接接收到的无序报文段,如下图所示:
lwIP 的TCP 编程
(2) TCP 报文段的接收
报文段的接收函数是tcp_input,该函数位于tcp_inc.c 文件中,如下源码所示:
void tcp_input(struct pbuf *p, struct netif *inp)
{
struct tcp_pcb *pcb, *prev;
struct tcp_pcb_listen *lpcb;
u8_t hdrlen_bytes;
err_t err;
/* 指向TCP首部*/
tcphdr = (struct tcp_hdr *)p - > payload;
/* 第一步:检查TCP报头是否少于20 */
if (p - > len < TCP_HLEN)
{
/* 释放空间掉弃报文段*/
goto dropped;
}
/* 第二步:判断是否是广播与多播类型*/
if (ip_addr_isbroadcast(ip_current_dest_addr(), ip_current_netif()) ||
ip_addr_ismulticast(ip_current_dest_addr()))
{
/* 释放空间掉弃报文段*/
goto dropped;
}
/* 获取tcphdr首部字节*/
hdrlen_bytes = TCPH_HDRLEN_BYTES(tcphdr);
/* 第三步:检测TCP报文长度*/
if ((hdrlen_bytes < TCP_HLEN) || (hdrlen_bytes > p - > tot_len))
{
/* 释放空间掉弃报文段*/
goto dropped;
}
/* 移动pbuf中的有效负载指针,使其指向TCP数据*/
/* tcphdr_optlen = TCP报头选项长度(TCP报头总长度- TCP标准报头20字节) */
tcphdr_optlen = (u16_t)(hdrlen_bytes - TCP_HLEN);
tcphdr_opt2 = NULL; /* tcphdr_opt2 指向NULL */
/* 判断TCP报头是否在一个pbuf中*/
if (p - > len >= hdrlen_bytes)
{
/* 若TCP报头在第一个pbuf中*/
tcphdr_opt1len = tcphdr_optlen; /* tcphdr_opt1len = TCP报头选项长度*/
pbuf_remove_header(p, hdrlen_bytes); /* 将指针移动到pbuf数据中*/
}
else
{
u16_t opt2len;
/* 删除TCP首部*/
pbuf_remove_header(p, TCP_HLEN);
/* 确定选项的第一部分和第二部分长度*/
tcphdr_opt1len = p - > len;
opt2len = (u16_t)(tcphdr_optlen - tcphdr_opt1len);
/* 移除tcphdr_opt1len选项*/
pbuf_remove_header(p, tcphdr_opt1len);
/* 检查TCP报头选项部分是否在第二个pbuf中*/
if (opt2len > p - > next - > len)
{
/* 丢弃过短的报文*/
goto dropped;
}
/* 记住指向TCP报头选项的第二部分的指针
(有部分选项在第二个pbuf中,记录TCP报头选项的开始部分) */
tcphdr_opt2 = (u8_t *)p - > next - > payload;
/* 将第二个pbuf的指针指向pbuf 的数据部分*/
pbuf_remove_header(p - > next, opt2len);
p - > tot_len = (u16_t)(p - > tot_len - opt2len);
}
/* 提取源端口*/
tcphdr - > src = lwip_ntohs(tcphdr - > src);
/* 提取目标端口*/
tcphdr - > dest = lwip_ntohs(tcphdr - > dest);
/* 提取序号*/
seqno = tcphdr - > seqno = lwip_ntohl(tcphdr - > seqno);
/* 提取确认号*/
ackno = tcphdr - > ackno = lwip_ntohl(tcphdr - > ackno);
/* 提取窗口*/
tcphdr - > wnd = lwip_ntohs(tcphdr - > wnd);
/* 6位标志位*/
flags = TCPH_FLAGS(tcphdr);
/* TCP数据包中数据的总长度,对于有FIN或SYN标志的数据包,该长度要加1 */
tcplen = p - > tot_len;
if (flags & (TCP_FIN | TCP_SYN))
{
tcplen++;
if (tcplen<p -> tot_len)
{
/* 释放空间掉弃报文段*/
goto dropped;
}
}
/* ****************************省略代码********************************* */
/* 如果pcb在回调中被中止(通过调用tcp_abort()),则跳转目标。*/
aborted:
tcp_input_pcb = NULL;
recv_data = NULL;
if (inseg.p != NULL)
{
pbuf_free(inseg.p);
inseg.p = NULL;
}
}
else
{
/*如果在3张链表里都未找到匹配的pcb,则调用tcp_rst向源主机发送一个TCP复位数据包*/
if (!(TCPH_FLAGS(tcphdr) & TCP_RST))
{
TCP_STATS_INC(tcp.proterr);
TCP_STATS_INC(tcp.drop);
tcp_rst(NULL, ackno, seqno + tcplen, ip_current_dest_addr(),
ip_current_src_addr(), tcphdr - > dest, tcphdr - > src);
}
pbuf_free(p);
}
return;
dropped : pbuf_free(p);
}
上述的源码大概400 多行,该函数可以分为上部分与下部分,上部分主要讲述了对IP 层递交传输层的数据报检验,例如检验数据报是否正常操作、是否包含数据、该数据报是否为广播或者多播,如果以上检验成立,则系统把该数据报掉弃处理,并释放pbuf。下部分主要对tcp_active_pcbs 链表寻找对应的TCP 控制块,如果找到了TCP 控制块,则调用tcp_process 函数处理;如果找不到TCP 控制块,则内核转换到tcp_tw_pcbs 链表中查找;如果在tcp_tw_pcbs 链表中找到TCP 控制块,则内核调用tcp_timewait_input 函数处理它;如果这两个链表没有找到TCP 控制块,则系统会进入tcp_listen_pcbs 链表中查找;如果找到了就调用tcp_listen_input 函数处理;如果三个链表都找不到的话,则系统就释放pbuf 内存。
(3) TCP 报文段的发送
传输层与网络层的交互函数为tcp_output,它在tcp_output.c 文件中定义,如下源码所示:
/* 发送控制块缓冲队列中的报文段*/
err_t tcp_output(struct tcp_pcb *pcb)
{
struct tcp_seg *seg, *useg;
u32_t wnd, snd_nxt;
err_t err;
struct netif *netif;
/* 如果控制块当前正有数据被处理,这里不做任何输出,直接返回*/
if (tcp_input_pcb == pcb) /* 在控制块的数据处理完成后,内核会再次调用*/
{
return ERR_OK; /* 调用tcp_output 发送数据,见函数tcp_input */
}
/* 从发送窗口和阻塞窗口取小者得到有效发送窗口,拥塞避免会讲解到这个原理*/
wnd = LWIP_MIN(pcb - > snd_wnd, pcb - > cwnd);
/* 未发送队列*/
seg = pcb - > unsent;
if (seg == NULL)
{
/* 若要求立即确认,但该ACK 又不能被捎带出去,则只发送一个纯ACK 的报文段*/
if (pcb - > flags & TF_ACK_NOW)
{
return tcp_send_empty_ack(pcb); /* 发送只带ACK 的报文段*/
}
/* 没什么可送的*/
goto output_done;
}
else
{
}
/* 判断本地IP地址与远程IP地址是否同一网段*/
netif = tcp_route(pcb, &pcb - > local_ip, &pcb - > remote_ip);
if (netif == NULL)
{
return ERR_RTE;
}
/* 如果没有本地IP地址,从netif获得一个*/
if (ip_addr_isany(&pcb - > local_ip))
{
const ip_addr_t *local_ip = ip_netif_get_local_ip(netif, &pcb - > remote_ip);
if (local_ip == NULL)
{
return ERR_RTE;
}
ip_addr_copy(pcb - > local_ip, *local_ip);
}
/* 处理窗口中不匹配的当前段*/
if (lwip_ntohl(seg - > tcphdr - > seqno) - pcb - > lastack + seg - > len > wnd)
{
/* 开始持续定时器*/
if (wnd == pcb - > snd_wnd && pcb - > unacked == NULL &&
pcb - > persist_backoff == 0)
{
pcb - > persist_cnt = 0;
pcb - > persist_backoff = 1;
pcb - > persist_probe = 0;
}
/* 我们需要一个ACK,但是现在不能发送数据,所以发送一个空ACK */
if (pcb - > flags & TF_ACK_NOW)
{
return tcp_send_empty_ack(pcb);
}
goto output_done;
}
/* 停止持续计时器,如果以上条件不满足*/
pcb - > persist_backoff = 0;
/* useg应该指向未处理队列的最后一个tcp_seg 结构*/
useg = pcb - > unacked;
if (useg != NULL)
{
for (; useg - > next != NULL; useg = useg - > next)
;
}
/* 可用数据和窗口允许它发送报文段,直到把数据全部发送出去或者填满发送窗口*/
while (seg != NULL &&
lwip_ntohl(seg - > tcphdr - > seqno) - pcb - > lastack + seg - > len <= wnd)
{
/* 如果nagle算法可以阻止发送,就停止发送*/
if ((tcp_do_output_nagle(pcb) == 0) &&
((pcb - > flags & (TF_NAGLEMEMERR | TF_FIN)) == 0))
{
break;
}
if (pcb - > state != SYN_SENT) /* 当前不为SYN_SENT 状态*/
{
TCPH_SET_FLAG(seg - > tcphdr, TCP_ACK); /* 填写首部中的ACK 标志*/
}
/* 调用函数发送报文段*/
err = tcp_output_segment(seg, pcb, netif);
if (err != ERR_OK)
{
/* segment could not be sent, for whatever reason */
tcp_set_flags(pcb, TF_NAGLEMEMERR);
return err;
}
/* 得到下一个未发送的tcp_seg */
pcb - > unsent = seg - > next;
if (pcb - > state != SYN_SENT)
{
tcp_clear_flags(pcb, TF_ACK_DELAY | TF_ACK_NOW);
}
/* 计算snd_nxt 的值*/
snd_nxt = lwip_ntohl(seg - > tcphdr - > seqno) + TCP_TCPLEN(seg);
/* 更新下一个要发送的数据编号*/
if (TCP_SEQ_LT(pcb - > snd_nxt, snd_nxt))
{
pcb - > snd_nxt = snd_nxt;
}
/* 如果发送出去的报文段数据长度不为0,或者带有SYN、FIN 标志,则将该报
文段加入到未确认队列中以便超时后重传*/
if (TCP_TCPLEN(seg) > 0)
{
seg - > next = NULL; /* 空报文段next 字段*/
/* 若未确认队列为空,则直接挂接*/
if (pcb - > unacked == NULL)
{
pcb - > unacked = seg;
useg = seg; /* 变量useg 指向未确认队列尾部*/
}
else
{
/* 如果未确认队列不为空,则需要把当前报文按照顺序组织在队列中*/
if (TCP_SEQ_LT(lwip_ntohl(seg - > tcphdr - > seqno),
lwip_ntohl(useg - > tcphdr - > seqno)))
{
/* 如果当前报文的序列号比队列尾部报文的序列号低,则从队列首部开始
查找合适的位置,插入报文段*/
struct tcp_seg **cur_seg = &(pcb - > unacked);
while (*cur_seg &&
TCP_SEQ_LT(lwip_ntohl((*cur_seg) - > tcphdr - > seqno),
lwip_ntohl(seg - > tcphdr - > seqno)))
{
cur_seg = &((*cur_seg) - > next);
} /* 找到插入位置,将报文段插入到队列中*/
seg - > next = (*cur_seg);
(*cur_seg) = seg;
}
else
{
/* 报文段序号最高,则放在未确认队列尾部*/
useg - > next = seg;
useg = useg - > next;
}
}
}
else /* 报文段长度为0,不需要重传,直接删除*/
{
tcp_seg_free(seg);
}
seg = pcb - > unsent; /* 发送下一个报文段*/
}
#if TCP_OVERSIZE if (pcb - > unsent == NULL)
{
/* 清0 已发送的窗口探测包数目*/
pcb - > unsent_oversize = 0;
}
#endif /* TCP_OVERSIZE */
output_done : tcp_clear_flags(pcb, TF_NAGLEMEMERR);
return ERR_OK;
}
从整体来看,此函数首先检测报文是否满足发送要求,接着判断控制块的flags 字段是否被设置为TF_ACK_NOW 状态,如果是,则发送一个纯粹ACK 报文段,因此,此时unsent 队列中无数据发送或者发送窗口不允许发送数据。如果内核能发送数据,则就将ACK 应答捎带发送出去,同时在发送的时候先找到未发送链表,然后调用tcp_output_segment()-> ip_output_if()函数进行发送,直到把未发送链表的数据完全发送出去或者直到填满发送窗口,并且更新发送窗口相关字段,当然也要将这些已发送但是未确认的数据存储在未确认链表中,以防丢失数据进行重发操作,放入未确认链表的时候是按序号升序进行排序的。
lwIP 的TCP 建立与关闭连接原理
下面笔者来讲解一下lwIP 如何实现TCP 客户端以及服务器连接,这里我们可以根据TCP连接示意图来讲解lwIP 源码是如何实现TCP 连接的。在讲解之前,我们先了解TCP 客户端的配置流程,如下所示:
- TCP 客户端建立连接原理:
①创建TCP 控制块
调用函数tcp_new 创建TCP 控制块。
②连接指定的IP 地址和端口号
调用函数tcp_connect 连接到目的地址的指定端口上,注意:当连接成功后进入回调tcp_client_connected 函数。
③接收数据
调用函数tcp_recved 接收数据。
④发送数据
调用函数tcp_write 发送数据。
从上述步骤可知,我们主要调用函数tcp_connect 连接远程服务器,这个函数和TCP 连接图存在某种联系,下面笔者简单的讲解这个函数到底如何连接服务器,该函数如下所示:
err_t tcp_connect(struct tcp_pcb * pcb,
const ip_addr_t * ipaddr, u16_t port,
tcp_connected_fn connected)
{
/*.....................前面省略大部分代码......................*/
/* 发送SYN与MSS选项一起发送*/
ret = tcp_enqueue_flags(pcb, TCP_SYN);
(1)
if (ret == ERR_OK)
{
/* 设置当前TCP控制块为SYN_SENT状态*/
pcb - > state = SYN_SENT;
(2)
if (old_local_port != 0)
{
TCP_RMV( & tcp_bound_pcbs, pcb);
}
TCP_REG_ACTIVE(pcb);
MIB2_STATS_INC(mib2.tcpactiveopens);
tcp_output(pcb);
(3)
}
return ret;
}
上述的(1)表示程序调用函数tcp_enqueue_flags 构建连接请求报文(TCP_SYN);上述的(2)表示当前TCP 控制块设置为SYN_SENT 状态;上述的(3)表示程序调用函数tcp_output向服务器发送连接请求报文。
下面使用一个示意图来描述上述的内容,如下图所示:
上图中红色框框的是tcp_connect 函数实现流程,这里可以称之为TCP 第一次握手,此时客户端等待服务器的连接应答报文(TCP_ACK)。当客户端接收服务器应答报文(TCP_ACK)时,系统会在tcp_input 这个函数处理该应答报文。这个函数在上小节也讲解过,这里我们无需重复讲解了,该连接应答报文会在tcp_input–>tcp_process 函数下处理,注意:tcp_input 函数中flags 的全局变量是获取接收数据报的首部标志位(TCP_ACK+ TCP_SYN),这个过程请看tcp_in.c 文件234 行的代码,如下源码所示:
static err_t
tcp_process(struct tcp_pcb * pcb) {
/*..................此处省略了很多代码..................... */
switch (pcb - > state) {
case SYN_SENT:
/* 收到SYN ACK与预期的序列号? */
if ((flags & TCP_ACK) && (flags & TCP_SYN)(1) && (ackno == pcb - > lastack + 1)) {
pcb - > rcv_nxt = seqno + 1;
pcb - > rcv_ann_right_edge = pcb - > rcv_nxt;
pcb - > lastack = ackno;
pcb - > snd_wnd = tcphdr - > wnd;
pcb - > snd_wnd_max = pcb - > snd_wnd;
pcb - > snd_wl1 = seqno - 1;
pcb - > state = ESTABLISHED;
(2)
}
/*..................此处省略了很多代码..................... */
}
/*..................此处省略了很多代码..................... */
}
上述的的(1)就是为了判断服务器应答报文的标志位是否包含TCP_ACK 和TCP_SYN,如果该应答报文包含这些标志位,则系统执行上述(2)的代码设置TCP 控制块为ESTABLISHED状态。这里笔者也是使用一个示意图来描述上述的内容,如下图所示:
上图的红色框框就是上述内容实现的过程,这里可以称之为TCP 第二次握手,此时客户端必须发送TCP_ACK 应答报文给服务器才能实现第三次握手。上面的函数tcp_process 执行完成之后返回到tcp_input 函数,该函数的553 行代码调用了tcp_output 函数发送应答报文,该函数如下所示:
err_t tcp_output(struct tcp_pcb * pcb) {
/*..................此处省略了很多代码..................... */
if (pcb - > state != SYN_SENT) {
TCPH_SET_FLAG(seg - > tcphdr, TCP_ACK);
}
/* 发送应答包*/
err = tcp_output_segment(seg, pcb, netif);
/*..................此处省略了很多代码..................... */
}
因为TCP 控制块已经是ESTABLISHED 状态了,所以这个if 语句判断为真且执行if 语句内的代码,这个代码主要添加该数据报的首部标志位TCP_ACK ,接着系统调用tcp_output_segmen 发送该应答包,这里就完成了三次握手的动作。下面笔者使用一个示意图来讲解这个过程,如下图所示:
- TCP 服务器建立连接原理
TCP 服务器的配置流程,如下步骤所示:
①创建TCP 控制块
调用函数tcp_new 创建TCP 控制块。
②绑定本地IP 地址和端口号
调用函数tcp_bind 绑定本地IP 地址和端口号。
③连接请求
调用函数tcp_accept 等待连接。注意:有连接时,会调用函数lwip_tcp_server_accept 处理
④接收数据
调用函数tcp_recved 接收数据。
⑤发送数据
调用函数tcp_write 发送数据。
首先我们调用tcp_listen 函数让服务器进去监听状态,简单来说,TCP 服务器控制块从CLOSER 转换成LISTEN 状态,如下源码所示:
#define tcp_listen(pcb) tcp_listen_with_backlog(pcb, TCP_DEFAULT_LISTEN_BACKLOG)
struct tcp_pcb *
tcp_listen_with_backlog(struct tcp_pcb * pcb, u8_t backlog)
{
LWIP_ASSERT_CORE_LOCKED();
return tcp_listen_with_backlog_and_err(pcb, backlog, NULL);
}
struct tcp_pcb *
tcp_listen_with_backlog_and_err(struct tcp_pcb * pcb, u8_t backlog, err_t * err) {
/* ..............省略代码.............. */
lpcb - > callback_arg = pcb - > callback_arg;
lpcb - > local_port = pcb - > local_port;
lpcb - > state = LISTEN;
(1)
lpcb - > prio = pcb - > prio;
lpcb - > so_options = pcb - > so_options;
lpcb - > netif_idx = pcb - > netif_idx;
lpcb - > ttl = pcb - > ttl;
lpcb - > tos = pcb - > tos;
/* ..............省略代码.............. */
}
上述的(1)就是让TCP 服务器控制块从CLOSER 状态转换成LISTEN 状态,下面笔者使用一个图来描述上述的内容,如下图所示:
上图的红色框框就是由tcp_listen 函数实现的,下面开始讲解TCP 第一次握手流程,对于服务器而言,它是先接收客户端发来的连接请求包并判断该请求报文的首部标志位是否包含TCP_SYN,这个请求报文的处理是由tcp_input→tcp_listen_input 函数处理的,该函数与客户端请求包相关的源码如下所示:
#define tcp_listen(pcb) tcp_listen_with_backlog(pcb, TCP_DEFAULT_LISTEN_BACKLOG)
struct tcp_pcb *
tcp_listen_with_backlog(struct tcp_pcb * pcb, u8_t backlog) {
LWIP_ASSERT_CORE_LOCKED();
return tcp_listen_with_backlog_and_err(pcb, backlog, NULL);
}
struct tcp_pcb *
tcp_listen_with_backlog_and_err(struct tcp_pcb * pcb, u8_t backlog, err_t * err) {
/* ..............省略代码.............. */
lpcb - > callback_arg = pcb - > callback_arg;
lpcb - > local_port = pcb - > local_port;
lpcb - > state = LISTEN;
(1)
lpcb - > prio = pcb - > prio;
lpcb - > so_options = pcb - > so_options;
lpcb - > netif_idx = pcb - > netif_idx;
lpcb - > ttl = pcb - > ttl;
lpcb - > tos = pcb - > tos;
/* ..............省略代码.............. */
}
可见,lwIP 内核首先判断连接请求报文的首部标志位是否包含TCP_SYN,显然这个符合第一次TCP 握手,然后系统把服务器控制块的状态从LISTEN 转换成SYN-RCVD,这个过程请看上述的(1),其次系统构建连接应答TCP_ACK| TCP_SYN 报文(上述源码中的(2)),最后系统调用函数tcp_output 发送该连接应答TCP_ACK| TCP_SYN 报文到客户端当中(上述源码中的(3))。至此我们已经实现了TCP 第二次握手了,下面笔者使用一个示意图来讲解上述的内容,如下图所示:
上图的红色框框就是服务器接收客户端的连接请求报文之后发送连接应答报文,到了这里服务器必须接收客户端的确认连接应答TCP_ACK 报文才能实现TCP 第三次握手,下面笔者带大家讲解一下最后一次握手,它是在tcp_input→ tcp_process 函数下处理的,该函数如下所示:
static err_t
tcp_process(struct tcp_pcb * pcb) {
/* ...........此处省略多行代码....... */
case SYN_RCVD:
if (flags & TCP_ACK) {
if (TCP_SEQ_BETWEEN(ackno, pcb - > lastack + 1, pcb - > snd_nxt)) {
pcb - > state = ESTABLISHED;
/* ...........此处省略多行代码....... */
} else {
/* ...........此处省略多行代码....... */
}
} else if ((flags & TCP_SYN) && (seqno == pcb - > rcv_nxt - 1)) {
/* ...........此处省略多行代码....... */
}
break;
/* ...........此处省略多行代码....... */
}
服务器接收到客户端的应答ACK 报文之后会把自身的状态SYN-RCVD 转换成ESTABLISHED,至此客户端和服务器可以相互发送数据了。
- TCP 关闭连接原理:
(1) 客户端发送FIN 报文
程序关闭TCP 连接是调用tcp_close 函数实现的,在调用这个函数之前,我们必须把tcp_pcb 的recv 回调函数指针设置为NULL(应用层不再接收数据,所有数据直接被丢弃,协议层的处理仍按正常流程走,认为应用层已经接收到数据),tcp_close 函数主要作用是发送FIN 报文,进入FIN_WAIT_1 状态(第一次挥手),下面我们来看一下第一次挥手的源码,注意:以下源码的路径:tcp_close→tcp_close_shutdown→tcp_close_shutdown_fin 函数下,该函数如下所示:
static err_t
tcp_close_shutdown_fin(struct tcp_pcb *pcb)
{
err_t err;
/* 省略多余的代码行*/
switch (pcb - > state)
{
case SYN_RCVD:
err = tcp_send_fin(pcb);
if (err == ERR_OK)
{
tcp_backlog_accepted(pcb);
MIB2_STATS_INC(mib2.tcpattemptfails);
pcb - > state = FIN_WAIT_1;
}
break;
case ESTABLISHED:
err = tcp_send_fin(pcb);
if (err == ERR_OK)
{
MIB2_STATS_INC(mib2.tcpestabresets);
/* 设置TCP控制块的状态为FIN_WAIT_1 */
pcb - > state = FIN_WAIT_1;
}
break;
case CLOSE_WAIT:
err = tcp_send_fin(pcb);
if (err == ERR_OK)
{
MIB2_STATS_INC(mib2.tcpestabresets);
pcb - > state = LAST_ACK;
}
break;
default:
return ERR_OK;
}
/* 发送关闭连接请求包*/
if (err == ERR_OK)
{
tcp_output(pcb);
}
else if (err == ERR_MEM)
{
tcp_set_flags(pcb, TF_CLOSEPEND);
return ERR_OK;
}
return err;
}
大家请看上述有注释的代码,这些代码是客户端发送关闭连接请求报文过程,该包的首部包含FIN 标志位并调用函数tcp_output 发送到服务器当中。由此可见,客户端从ESTABLISHED 状态转换成FIN-WAIT-1 状态,下面笔者使用一个示意图来描述上述的内容,如下图所示:
上图红色框框表示tcp_close 函数处理过程,这里也可以称之为TCP 第一次挥手的动作。
(2) 服务器接收到FIN 报文并发送ACK 报文当服务器接收到客户端的FIN 报文时,它会进入到CLOSE_WAIT 状态,这个FIN 报文交由tcp_process 函数处理,当然它接收到的数据可以发送给应用层,但是它递交一个空的EOF数据给应用层(应用层知道接收数据已经完成,不需要再从协议栈读数据),最后系统发送客户端ACK 报文给客户端(第二次挥手),进入CLOSE_WAIT 状态,如下源码所示:
static err_t tcp_process(struct tcp_pcb * pcb) {
/* ...........省略多行代码........... */
switch (pcb - > state) {
/* ...........省略多行代码........... */
case ESTABLISHED:
tcp_receive(pcb);
if (recv_flags & TF_GOT_FIN) { /* 收到FIN被动关闭*/
tcp_ack_now(pcb); /* 构建ACK报文*/
pcb - > state = CLOSE_WAIT; /* 进入CLOSE_WAIT状态*/
}
break;
}
/* ...........省略多行代码........... */
}
上述源码是服务器接收到客户端的FIN 报文时,它构建了一个ACK 报文发送到客户端当中,然后它的状态从ESTABLISHED 转换成CLOSE-WAIT,下面笔者也是使用一个示意图来描述上述的内容,如下图所示:
上图红色框框就是上述源码运行的流程,为了理解,笔者没有把全部的代码列举出来。
(3) 客户端接收到ACK 报文并转换成FIN-WAIT-2 状态
当FIN_WAIT_1 状态的客户端收到服务器的ACK 报文时,它的状态从FIN-WAIT-1 转换成FIN-WAIT-2 状态,这个过程的源码如下所示:
static err_t
tcp_process(struct tcp_pcb *pcb)
{
/* ...........省略多行代码........... */
switch (pcb - > state)
{
/* ...........省略多行代码........... */
case FIN_WAIT_1:
/* 接收数据*/
tcp_receive(pcb);
/* 服务器还没有确认FIN报文*/
if (recv_flags & TF_GOT_FIN)
{
/* 非同时关闭*/
if ((flags & TCP_ACK) && (ackno == pcb - > snd_nxt))
{
/* ...........省略多行代码........... */
/* 发送ACK应答对端的FIN报文*/
tcp_ack_now(pcb);
TCP_RMV(&tcp_active_pcbs, pcb); /* 从tcp_active_pcbs删除tcp_pcb */
/* tcp_timewait_input处理,所有数据都丢弃,不发送给应用层,
直接确认当前收到的报文,rcv_nxt设置为当前报文的下一个字节*/
pcb - > state = TIME_WAIT;
TCP_REG(&tcp_tw_pcbs, pcb); /* 添加tcp_pcb到tcp_tw_pcbs */
}
else
{ /* (客户端、服务器同时调用tcp_close,都在FIN_WAIT_1状态收到对方的FIN报文)*/
tcp_ack_now(pcb); /* 发送FIN报文的ACK */
pcb - > state = CLOSING; /* 进入CLOSING状态*/
}
} /* 服务器确认了FIN报文*/
else if ((flags & TCP_ACK) && (ackno == pcb - > snd_nxt))
{
pcb - > state = FIN_WAIT_2; /* 进入FIN_WAIT_2状态*/
}
}
/* ...........省略多行代码........... */
}
上述源码可分为两个部分讲解,第一部分:处于FIN_WAIT_1 客户端会判断服务器有没有确认FIN 报文,如果它没有发送ACK 报文,则系统进入if 语句执行,该if 语句的代码主要为了判断服务器和客户端是否同时调用tcp_close 函数关闭连接,如果不同时,则将TCP 控制块从tcp_active_pcbs 队列移除并设置该控制块的状态为TIME_WAIT。最后把该控制块挂在tcp_tw_pcbs 队列当中;如果客户端和服务器同时关闭连接,则系统发送一个ACK 报文到服务器当中并设置TCP 控制块的状态为CLOSING;第二部分:服务器发送ACK 报文给客户端了,显然它直接设置TCP 控制块的状态为FIN_WAIT_2,下面我们使用一个示意图来描述上述的内容,如下图所示:
从上图可知:服务器的状态从FIN-WAIT-1 转换成FIN-WAIT-2 状态,FIN-WAIT-2 状态的客户端需要等待服务器发送FIN 报文。
(4) CLOSE-WAIT 状态的服务器发送FIN 报文
CLOSE-WAIT 状态的服务器发送FIN 报文如下源码所示:
static err_t
tcp_close_shutdown_fin(struct tcp_pcb * pcb) {
err_t err;
/* 省略多余的代码行*/
switch (pcb - > state) {
case SYN_RCVD:
err = tcp_send_fin(pcb);
if (err == ERR_OK) {
tcp_backlog_accepted(pcb);
MIB2_STATS_INC(mib2.tcpattemptfails);
pcb - > state = FIN_WAIT_1;
}
break;
case ESTABLISHED:
err = tcp_send_fin(pcb);
if (err == ERR_OK) {
MIB2_STATS_INC(mib2.tcpestabresets);
pcb - > state = FIN_WAIT_1;
}
break;
case CLOSE_WAIT:
/* 发送FIN报文*/
err = tcp_send_fin(pcb);
if (err == ERR_OK) {
MIB2_STATS_INC(mib2.tcpestabresets);
/* 设置状态为LAST_ACK */
pcb - > state = LAST_ACK;
}
break;
default:
return ERR_OK;
}
/* 发送关闭连接请求包*/
if (err == ERR_OK) {
tcp_output(pcb);
} else if (err == ERR_MEM) {
tcp_set_flags(pcb, TF_CLOSEPEND);
return ERR_OK;
}
return err;
}
此函数很简单,主要发送FIN 报文以及设置CLOSE_WAIT 状态的服务器为LAST_ACK状态。下面笔者也是使用一个示意图来描述上述的内容,如下图所示:
这里称之为TCP 第三次挥手,最后就是FIN-WAIT-2 状态的客户端接收服务器的FIN 报文并发送ACK 报文确认关闭。
(5) FIN-WAIT-2 状态的客户端接收FIN 报文并发送ACK 报文确认
这个过程是在tcp_input→tcp_ process 函数下处理,该函数如下所示:
static err_t
tcp_process(struct tcp_pcb * pcb) {
/* ...........省略多行代码........... */
switch (pcb - > state) {
/* ...........省略多行代码........... */
case FIN_WAIT_2:
/* 接收报文*/
tcp_receive(pcb);
if (recv_flags & TF_GOT_FIN) {
/* 构建ACK报文*/
tcp_ack_now(pcb);
tcp_pcb_purge(pcb);
TCP_RMV_ACTIVE(pcb);
/* 设置状态为TIME_WAIT */
pcb - > state = TIME_WAIT;
TCP_REG( & tcp_tw_pcbs, pcb);
}
break;
}
/* ...........省略多行代码........... */
}
此函数主要判断FIN_WAIT_2 状态的客户端是否接收到FIN 报文,如果系统接收的报文是FIN 报文,则系统发送ACK 报文给服务器并设置客户端的状态为TIME_WAIT,这里就是TCP 第四次挥手。
lwIP 中 RAW API 编程接口中与 TCP 相关的函数
tcp.c、tcp.h、tcp_in.c 和 tcp_out.c 是 lwIP 中关于TCP 协议的文件,TCP 层中函数的关系如下图所示。
lwIP 提供了很多关于TCP 协议的的 RAW 编程API 函数,我们可以使用这些函数来完成有关TCP 的实验,我们在下表列出了一部分函数。
RAW 接口的 TCP 实验
硬件设计
- 例程功能
本章实验的目标是PC 端和开发板通过TCP 协议连接起来,开发板做TCP 客户端,PC 端的网络调试助手配置成服务器。开发板接收服务器发送的数据在LCD 上显示,我们也可以通过开发板上的按键发送数据给PC。
该实验的实验工程,请参考《lwIP 例程3 lwIP_RAW_TCPClient 实验》。
软件设计
12.2.2.1 TCP 客户端配置步骤
- 创建TCP 控制块
调用 函数tcp_new 创建TCP 控制块。 - 连接指定的IP 地址和端口号
调用 函数tcp_connect 连接到目的地址的指定端口上。 - 接收数据
调用 函数tcp_recved 接收数据。 - 发送数据
调用 函数tcp_write 发送数据。
12.2.2.2 程序流程图
本实验的程序流程图,如下图所示:
12.2.2.3 程序解析
本章实验只讲解 lwip_demo.c 文件(应该是官方提供的DEMO),该文件定义了9 个函数,这些函数的作用如下所示:
程序首先执行lwip_demo 函数,此函数为lwip_demo.c 文件的入口处,如下源码所示:
/**
* @brief lwip_demo程序入口
* @param 无
* @retval 无
*/
void lwip_demo(void) {
struct tcp_pcb * tcppcb; /* 定义一个TCP服务器控制块*/
ip_addr_t rmtipaddr; /* 远端ip地址*/
char * tbuf;
uint8_t key;
uint8_t res = 0;
uint8_t t = 0;
uint8_t connflag = 0; /* 连接标记*/
lwip_tcp_client_set_remoteip(); /* 先选择IP */
lcd_clear(BLACK); /* 清屏*/
g_point_color = WHITE;
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "TCP Client Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY0:Send data", g_point_color);
lcd_show_string(30, 110, 200, 16, 16, "KEY1:Quit", g_point_color);
tbuf = mymalloc(SRAMIN, 200); /* 申请内存*/
if (tbuf == NULL) return; /* 内存申请失败了,直接退出*/
sprintf((char * ) tbuf, "Local IP:%d.%d.%d.%d", lwipdev.ip[0],
lwipdev.ip[1],
lwipdev.ip[2],
lwipdev.ip[3]); /* 服务器IP */
lcd_show_string(30, 130, 210, 16, 16, tbuf, g_point_color);
/* 远端IP */
sprintf((char * ) tbuf, "Remote IP:%d.%d.%d.%d", lwipdev.remoteip[0],
lwipdev.remoteip[1],
lwipdev.remoteip[2],
lwipdev.remoteip[3]);
lcd_show_string(30, 150, 210, 16, 16, tbuf, g_point_color);
sprintf((char * ) tbuf, "Remote Port:%d", TCP_CLIENT_PORT); /* 客户端端口号*/
lcd_show_string(30, 170, 210, 16, 16, tbuf, g_point_color);
g_point_color = BLUE;
lcd_show_string(30, 190, 210, 16, 16, "STATUS:Disconnected", g_point_color);
tcppcb = tcp_new(); /* 创建一个新的tcp */
if (tcppcb) /* 创建成功*/
{
IP4_ADDR( & rmtipaddr, lwipdev.remoteip[0], lwipdev.remoteip[1],
lwipdev.remoteip[2], lwipdev.remoteip[3]);
/* 连接到目的地址的指定端口上,当连接成功后回调tcp_client_connected()函数*/
tcp_connect(tcppcb, & rmtipaddr, TCP_CLIENT_PORT,lwip_tcp_client_connected);
}
else
{
res = 1;
}
while (res == 0)
{
key = key_scan(0);
if (key == KEY1_PRES) break;
if (key == KEY0_PRES) /* KEY0按下了,发送数据*/
{
lwip_tcp_client_usersent(tcppcb); /* 发送数据*/
}
if (lwip_client_flag & 1 << 6) /* 是否收到数据*/
{
/* 清上一次数据*/
lcd_fill(30, 230, lcddev.width - 1, lcddev.height - 1, BLACK);
/* 显示接收到的数据*/
lcd_show_string(30, 230, lcddev.width - 30, lcddev.height - 230, 16,
lwip_client_recvbuf, g_point_color);
lwip_client_flag &= ~(1 << 6); /* 标记数据已经被处理了*/
}
if (lwip_client_flag & 1 << 5) /* 是否连接上*/
{
if (connflag == 0)
{
lcd_show_string(30, 190, lcddev.width - 30,
lcddev.height - 190, 16,
"STATUS:Connected ",
g_point_color); /* 提示消息*/
g_point_color = WHITE;
lcd_show_string(30, 210, lcddev.width - 30,
lcddev.height - 190, 16,
"Receive Data:", g_point_color); /* 提示消息*/
g_point_color = BLUE;
connflag = 1; /* 标记连接了*/
}
}
else if (connflag)
{
lcd_show_string(30, 190, 190, 16, 16, "STATUS:Disconnected",g_point_color);
lcd_fill(30, 210, lcddev.width - 1,
lcddev.height - 1, BLACK); /* 清屏*/
connflag = 0; /* 标记连接断开了*/
}
lwip_periodic_handle();
delay_ms(2);
t++;
if (t == 200)
{
/* 未连接上,则尝试重连*/
if (connflag == 0 && (tcp_client_flag & 1 << 5) == 0) {
lwip_tcp_client_connection_close(tcppcb, 0); /* 关闭连接*/
tcppcb = tcp_new(); /* 创建一个新的pcb */
if (tcppcb) /* 创建成功*/
{
/* 连接到目的地址的指定端口上,当连接成功后回调tcp_client_connected()函数*/
tcp_connect(tcppcb, & rmtipaddr, TCP_CLIENT_PORT,
tcp_client_connected);
}
}
t = 0;
LED0_TOGGLE();
}
}
lwip_tcp_client_connection_close(tcppcb, 0); /* 关闭TCP Client连接*/
myfree(SRAMIN, tbuf);
}
可见,此函数和UDP 实验一样,根据开发板上的KEY0 和KEY1 设置远程IP 地址,接着调用RAW 接口函数配置TCP 客户端,配置完成之后连接服务器。
设置远程 IP 地址的函数lwip_tcp_client_set_remoteip,如下源码所示:
/**
* @brief 设置远端IP地址
* @param 无
* @retval 无
*/
void lwip_tcp_client_set_remoteip(void) {
char * tbuf;
uint16_t xoff;
uint8_t key;
lcd_clear(BLACK);
g_point_color = WHITE;
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "TCP Client Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "Remote IP Set", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY0:+ KEY2:-", g_point_color);
lcd_show_string(30, 110, 200, 16, 16, "KEY_UP:OK", g_point_color);
tbuf = mymalloc(SRAMIN, 100); /* 申请内存*/
if (tbuf == NULL) return;
/* 前三个IP保持和DHCP得到的IP一致*/
lwipdev.remoteip[0] = lwipdev.ip[0];
lwipdev.remoteip[1] = lwipdev.ip[1];
lwipdev.remoteip[2] = lwipdev.ip[2];
/* 远端IP */
sprintf((char * ) tbuf, "Remote IP:%d.%d.%d.", lwipdev.remoteip[0],
lwipdev.remoteip[1],
lwipdev.remoteip[2]);
lcd_show_string(30, 150, 210, 16, 16, tbuf, g_point_color);
g_point_color = BLUE;
xoff = strlen((char * ) tbuf) * 8 + 30;
lcd_show_xnum(xoff, 150, lwipdev.remoteip[3], 3, 16, 0, g_point_color);
while (1) {
key = key_scan(0);
if (key == KEY1_PRES) break;
else if (key) {
if (key == KEY0_PRES) lwipdev.remoteip[3] ++; /* IP增加*/
if (key == KEY2_PRES) lwipdev.remoteip[3] --; /* IP减少*/
/* 显示新IP */
lcd_show_xnum(xoff, 150, lwipdev.remoteip[3], 3, 16, 0X80,
g_point_color);
}
}
myfree(SRAMIN, tbuf);
}
此函数根据开发板上的按键设置远程IP 地址,设置完成之后按下KEY1 退出设置。
TCP 连接建立后的回调函数lwip_tcp_client_connected,如下源码所示:
/**
* @brief lwIP TCP连接建立后回调函数
* @param arg : 回调函数传入的参数
* @param tpcb : TCP控制块
* @param err : 错误码
* @retval 返回错误码
*/
err_t lwip_tcp_client_connected(void * arg, struct tcp_pcb * tpcb, err_t err) {
struct tcp_client_struct * es = NULL;
if (err == ERR_OK) {
es = (struct tcp_client_struct * ) mem_malloc(sizeof(struct tcp_client_struct)); /* 申请内存*/
if (es) /* 内存申请成功*/ {
es - > state = ES_TCPCLIENT_CONNECTED; /* 状态为连接成功*/
es - > pcb = tpcb;
es - > p = NULL;
tcp_arg(tpcb, es); /* 使用es更新tpcb的callback_arg */
/* 初始化LwIP的tcp_recv回调功能*/
tcp_recv(tpcb, lwip_tcp_client_recv);
tcp_err(tpcb, lwip_tcp_client_error); /* 初始化tcp_err()回调函数*/
/* 初始化LwIP的tcp_sent回调功能*/
tcp_sent(tpcb, lwip_tcp_client_sent);
/* 初始化LwIP的tcp_poll回调功能*/
tcp_poll(tpcb, lwip_tcp_client_poll, 1);
tcp_client_flag |= 1 << 5; /* 标记连接到服务器了*/
err = ERR_OK;
} else {
lwip_tcp_client_connection_close(tpcb, es); /* 关闭连接*/
err = ERR_MEM; /* 返回内存分配错误*/
}
} else {
lwip_tcp_client_connection_close(tpcb, 0); /* 关闭连接*/
}
return err;
}
这个回调函数由用户编写,由tcp_connect 函数注册此函数。简单来讲,就是让TCP 控制块内的函数指针指向该函数。
lwip_tcp_client_recv 函数是当接收到数据时的回调函数,在这个函数中我们根据不同的状态有不同的处理,这里最重要的就是当处于连接状态并且接收到数据时的处理,这个时候我们将遍历完接收数据的pbuf 链表,将链表中的所有数据拷贝到lwip_tcp_client_recvbuf 中,这个过程和UDP 的接收处理过程相似。数据接收成功以后我们将lwip_client_flag 的bit5 置1,表示接收到数据,lwip_tcp_client_recv 函数代码如下。
/**
* @brief lwIP tcp_recv()函数的回调函数
* @param arg : 回调函数传入的参数
* @param tpcb : TCP控制块
* @param p : 网络数据包
* @param err : 错误码
* @retval 返回错误码
*/
err_t lwip_tcp_client_recv(void * arg, struct tcp_pcb * tpcb,
struct pbuf * p, err_t err) {
uint32_t data_len = 0;
struct pbuf * q;
struct tcp_client_struct * es;
err_t ret_err;
LWIP_ASSERT("arg != NULL", arg != NULL);
es = (struct tcp_client_struct * ) arg;
if (p == NULL) /* 如果从服务器接收到空的数据帧就关闭连接*/ {
es - > state = ES_TCPCLIENT_CLOSING; /* 需要关闭TCP 连接了*/
es - > p = p;
ret_err = ERR_OK;
} else if (err != ERR_OK) /* 当接收到一个非空的数据帧,但是err!=ERR_OK */ {
if (p) pbuf_free(p); /* 释放接收pbuf */
ret_err = err;
} else if (es - > state == ES_TCPCLIENT_CONNECTED) /* 当处于连接状态时*/ {
if (p != NULL) /* 当处于连接状态并且接收到的数据不为空时*/ {
/* 数据接收缓冲区清零*/
memset(lwip_client_recvbuf, 0, TCP_CLIENT_RX_BUFSIZE);
for (q = p; q != NULL; q = q - > next) /* 遍历完整个pbuf链表*/ {
/* 判断要拷贝到TCP_CLIENT_RX_BUFSIZE中的数据是否大于
TCP_CLIENT_RX_BUFSIZE的剩余空间,如果大于*/
/* 的话就只拷贝TCP_CLIENT_RX_BUFSIZE中剩余长度的数据,
否则的话就拷贝所有的数据*/
if (q - > len > (LWIP_DEMO_RX_BUFSIZE - data_len)) memcpy(lwip_client_recvbuf + data_len, q - > payload, (LWIP_DEMO_RX_BUFSIZE - data_len)); /* 拷贝数据*/
else memcpy(lwip_client_recvbuf + data_len, q - > payload, q - > len);
data_len += q - > len;
/* 超出TCP客户端接收数组,跳出*/
if (data_len > LWIP_DEMO_RX_BUFSIZE) break;
}
tcp_client_flag |= 1 << 6; /* 标记接收到数据了*/
/*用于获取接收数据,通知LWIP可以获取更多数据*/
tcp_recved(tpcb, p - > tot_len);
pbuf_free(p); /* 释放内存*/
ret_err = ERR_OK;
}
} else /* 接收到数据但是连接已经关闭*/ {
/* 用于获取接收数据,通知LWIP可以获取更多数据*/
tcp_recved(tpcb, p - > tot_len);
es - > p = NULL;
pbuf_free(p); /* 释放内存*/
ret_err = ERR_OK;
}
return ret_err;
}
lwip_tcp_client_error 函数是控制块中errf 字段的回调函数,当出现知名错误的时候就会被调用,这里我们没有实现这个函数,用户可以根据自己的实际情况来实现这个函数。
lwip_tcp_client_poll 函数为控制块中poll 字段的回调函数,这个函数会被周期调用,因此在这个函数中我们可以将要发送的数据发送出去。通过lwip_client_flag 的bit7 来判断是否有数据要发送,因为lwIP 中处理数据用的是pbuf 结构体组成的链表,因此如果有数据要发送的话就将发送缓冲区lwip_tcp_client_sendbuf 中的待发送数据放进pbuf 链表中,这个我们使用pbuf_take 来实现这个过程,然后我们调用lwip_tcp_client_senddata 函数将数据发送出去,发送完成以后记得将lwip_client_flag 的bit7 清零,如下源码所示:
/**
* @brief lwIP tcp_poll的回调函数
* @param arg : 回调函数传入的参数
* @param tpcb: TCP控制块
* @retval ERR_OK
*/
err_t lwip_tcp_client_poll(void * arg, struct tcp_pcb * tpcb) {
err_t ret_err;
struct tcp_client_struct * es;
es = (struct tcp_client_struct * ) arg;
if (es - > state == ES_TCPCLIENT_CLOSING) /* 连接断开*/ {
lwip_tcp_client_connection_close(tpcb, es); /* 关闭TCP连接*/
}
ret_err = ERR_OK;
return ret_err;
}
lwip_tcp_client_sent 函数为控制块中的sent 字段的回调函数,这个函数中主要调用了我们下面要讲的lwip_tcp_client_senddata 这个函数,lwip_tcp_client_sent 函数源码如下。
/**
* @brief lwIP tcp_sent的回调函数(当从远端主机接收到ACK信号后发送数据)
* @param arg : 回调函数传入的参数
* @param tpcb: TCP控制块
* @param len : 长度
* @retval ERR_OK
*/
err_t lwip_tcp_client_sent(void * arg, struct tcp_pcb * tpcb, u16_t len) {
struct tcp_client_struct * es;
es = (struct tcp_client_struct * ) arg;
if (es - > p) lwip_tcp_client_senddata(tpcb, es); /* 发送数据*/
return ERR_OK;
}
lwip_tcp_client_senddata 函数用来发送数据,在这个函数中我们使用tcp_write 函数将要发送的数据加入到发送缓冲队列中,最后调用tcp_output 函数将发送缓冲队列中的数据发送出去,这个函数的代码如下。
/**
* @brief 用来发送数据
* @param tpcb: TCP控制块
* @param es : LWIP回调函数使用的结构体
* @retval 无
*/
void lwip_tcp_client_senddata(struct tcp_pcb * tpcb,
struct tcp_client_struct * es) {
struct pbuf * ptr;
err_t wr_err = ERR_OK;
/* 将要发送的数据加入到发送缓冲队列中*/
while ((wr_err == ERR_OK) && es - > p && (es - > p - > len <= tcp_sndbuf(tpcb))) {
ptr = es - > p;
wr_err = tcp_write(tpcb, ptr - > payload, ptr - > len, 1);
if (wr_err == ERR_OK) {
es - > p = ptr - > next; /* 指向下一个pbuf */
if (es - > p) pbuf_ref(es - > p); /* pbuf的ref加一*/
pbuf_free(ptr); /* 释放ptr */
} else if (wr_err == ERR_MEM) es - > p = ptr;
tcp_output(tpcb); /* 将发送缓冲队列中的数据立即发送出去*/
}
}
lwip_tcp_client_connection_close 函数的功能是关闭与服务器的连接,通过调用tcp_abort函数来关闭与服务器的连接,然后注销掉控制块中的回调函数,将lwip_client_flag 的bit5 置1,标记连接断开,lwip_tcp_client_connection_close 函数源码如下。
/**
* @brief 关闭与服务器的连接
* @param tpcb: TCP控制块
* @param es : LWIP回调函数使用的结构体
* @retval 无
*/
void lwip_tcp_client_connection_close(struct tcp_pcb * tpcb,
struct tcp_client_struct * es) {
/* 移除回调*/
tcp_abort(tpcb); /* 终止连接,删除pcb控制块*/
tcp_arg(tpcb, NULL);
tcp_recv(tpcb, NULL);
tcp_sent(tpcb, NULL);
tcp_err(tpcb, NULL);
tcp_poll(tpcb, NULL, 0);
if (es) mem_free(es);
tcp_client_flag &= ~(1 << 5); /* 标记连接断开了*/
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "TCPclient Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY1:Connect", g_point_color);
lcd_show_string(30, 190, 210, 16, 16, "STATUS:Disconnected", g_point_color);
}
至此,lwip_demo.c 文件就讲完了,接下来就是编写main 函数,main 函数基本和UDP 实验的相同。
下载验证
代码编译成功之后下载代码到开发板中。打开网络调试助手软件设置为如下图的信息。
开发板上电,等待出现12.3.3.2 所示画面,我们设置远端IP 地址为电脑的IP 地址,也就是图12.3.1 中的本地IP 地址,设置好以后按KEY_UP 键确认,确认后进入图12.2.3.3 所示界面,当STATUS 为Connected 的时候就可以和网络调试助手互相发送数据了。
图12.2.3.2 设置服务器IP 地址
图12.2.3.3 连接到服务器
我们通过网络调试助手向开发板发送:http://www.openedv.com,此时开发板LCD 上显示接收到的数据如图12.2.3.4 所示,按下KEY0 键向网络调试助手发送数据。
RAW 编程接口TCP 服务器实验
在本章中开发板做TCP 服务器,网络调试助手做TCP 客户端,实验中我们通过电脑端的
网络调试助手给开发板发送数据,开发板接收并在LCD 上显示接收到的数据,同时也可以通
过按键从开发板向网络调试助手发送数据。
RAW 编程接口TCP 简介
在上一章RAW 编程接口的TCP 客户端实验中我们已经讲解过了TCP 的基础知识,这里
就不做讲解。
RAW 接口的TCP 实验
硬件设计
- 例程功能
本章实验的目标是PC 端和开发板通过TCP 协议连接起来,开发板做TCP 服务器,PC 端
的网络调试助手配置成客户端。网络调试助手连接到开发板服务器时,网络调试助手可向开发
板发送数据并且在LCD 上显示,我们也可以通过开发板上的按键发送数据给PC。
该实验的实验工程,请参考《lwIP 例程4 lwIP_RAW_TCPServer 实验》。
软件设计
13.2.2.1 TCP 服务器配置步骤
- 创建TCP 控制块
调用函数tcp_new 创建TCP 控制块。 - 绑定本地IP 地址和端口号
调用函数tcp_bind 绑定本地IP 地址和端口号。 - 连接请求
调用函数tcp_accept 等待连接。 - 接收数据
调用函数tcp_recved 接收数据。 - 发送数据
调用函数tcp_write 发送数据。
13.2.2.2 程序流程图
本实验的程序流程图,如下图所示:
程序解析
上一章中我们简单的介绍了几个lwIP 中关于TCP 的函数,本节中我们就用这几个函数编
写我们本章的例程,本章实验的目标是PC 端和开发板通过TCP 协议连接起来,开发板做
TCP 服务器,PC 端的网络调试助手配置成客户端。网络调试助手连接到开发板服务器,网络
调试助手向开发板发送数据并在LCD 上显示接收到的数据,我们也可以通过开发板上的按键
发送数据给PC。本章实验中我们主要有两个文件lwip_demo.c 和lwip_demo.h。lwip_demo.h
文件很简单,这里就不讲解,我们重点讲解一下lwip_demo.c 这个文件,在tcp_server_demo.c
文件中我们一共定义了9 个函数,如下表所示。
lwip_tcp_server_accept 函数为控制块accpet 字段的回调函数,当一个侦听和其他主机连接
上以后调用,在这个函数中我们主要是为控制块的相应字段注册回调函数,函数的代码如下。
/**
* @brief lwIP tcp_accept()的回调函数
* @param arg :传入的参数
* @param newpcb:TCP控制块
* @param err :错误码
* @retval 返回ret_err
*/
err_t lwip_tcp_server_accept(void *arg, struct tcp_pcb *newpcb, err_t err)
{
err_t ret_err;
struct tcp_server_struct *es;
LWIP_UNUSED_ARG(arg);
LWIP_UNUSED_ARG(err);
tcp_setprio(newpcb, TCP_PRIO_MIN); /* 设置新创建的pcb优先级*/
/* 分配内存*/
es = (struct tcp_server_struct *)mem_malloc(
sizeof(struct tcp_server_struct));
if (es != NULL) /* 内存分配成功*/
{
es->state = ES_TCPSERVER_ACCEPTED; /* 接收连接*/
es->pcb = newpcb;
es->p = NULL;
tcp_arg(newpcb, es);
tcp_recv(newpcb, lwip_tcp_server_recv); /* 初始化tcp_recv()的回调函数*/
tcp_err(newpcb, lwip_tcp_server_error); /* 初始化tcp_err()回调函数*/
tcp_poll(newpcb, lwip_tcp_server_poll, 1); /* 初始化tcp_poll回调函数*/
tcp_sent(newpcb, lwip_tcp_server_sent); /* 初始化发送回调函数*/
lwip_send_flag |= 1 << 5; /* 标记有客户端连上了*/
lwipdev.remoteip[0] = newpcb->remote_ip.addr & 0xff;
lwipdev.remoteip[1] = (newpcb->remote_ip.addr >> 8) & 0xff;
lwipdev.remoteip[2] = (newpcb->remote_ip.addr >> 16) & 0xff;
lwipdev.remoteip[3] = (newpcb->remote_ip.addr >> 24) & 0xff;
ret_err = ERR_OK;
}
else
ret_err = ERR_MEM;
return ret_err;
}
lwip_tcp_server_recv 函数和上一章TCP 客户端实验的lwip_tcp_client_recv 函数功能基本
差不多,大家可以对照这两个函数的源码看一下,这里就不做讲解了。
lwip_tcp_server_error 为当出现重大错误的时候的回调函数,在TCP 客户端实验中我们没
有实现这个函数,在本章实验中我们将这个函数的参数通过串口打印出来,当然也可以根据自
己的实际情况来实现这个函数。
lwip_tcp_server_poll,lwip_tcp_server_sent 和lwip_tcp_server_senddata 这三个函数分别和上一章TCP 客户端实验中的lwip_tcp_client_poll,lwip_tcp_client_sent 和lwip_tcp_client_sendd
ata 函数功能类似,大家可以参考TCP 客户端实验中关于这三个函数的讲解。
lwip_tcp_server_connection_close 函数用来关闭TCP 连接,我们通过调用tcp_close 函数来
关闭连接,注意这里和TCP 客户端实验的不同,然后就是注销掉控制块中的回调函数,最后
释放内存,清零lwip_send_flag 的bit5,标记连接断开,函数代码如下。
/**
* @brief 关闭tcp连接
* @param tpcb :TCP控制块
* @param es :LWIP回调函数使用的结构体
* @retval 无
*/
void tcp_server_connection_close(struct tcp_pcb *tpcb, struct tcp_server_struct *es)
{
tcp_close(tpcb);
tcp_arg(tpcb, NULL);
tcp_sent(tpcb, NULL);
tcp_recv(tpcb, NULL);
tcp_err(tpcb, NULL);
tcp_poll(tpcb, NULL, 0);
if (es)
mem_free(es);
lwip_send_flag &= ~(1 << 5); /* 标记连接断开了*/
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "TCPServer Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY1:Connect", g_point_color);
lcd_show_string(30, 190, 210, 16, 16, "STATUS:Disconnected", g_point_color);
}
最后一个函数是lwip_tcp_server_remove_timewait,这个函数用来强制删除处于TIME-WA
TI 状态的控制块,函数代码如下。
/**
* @brief 强制删除TCP Server主动断开时的time wait
* @param 无
* @retval 无
*/
void lwip_tcp_server_remove_timewait(void)
{
struct tcp_pcb *pcb, *pcb2;
uint8_t t = 0;
while (tcp_active_pcbs != NULL && t < 200)
{
lwip_periodic_handle(); /* 继续轮询*/
t++;
delay_ms(10); /* 等待tcp_active_pcbs为空*/
}
pcb = tcp_tw_pcbs;
while (pcb != NULL) /* 如果有等待状态的pcbs */
{
tcp_pcb_purge(pcb);
tcp_tw_pcbs = pcb->next;
pcb2 = pcb;
pcb = pcb->next;
memp_free(MEMP_TCP_PCB, pcb2);
}
}
接下来是本实验最重要的函数lwip_demo,同UDP 实验和TCP 客户端实验一样,这个函
数一开始也是显示一些提示信息,不过不同的是和本实验不用设置需要连接的远端主机的IP
地址,因为服务器是等待其他主机来连接的。显示完提示信息以后就是本函数的重点了,代码
如下。
/**
* @brief lwip_demo 测试
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
err_t err;
struct tcp_pcb *tcppcbnew; /* 定义一个TCP服务器控制块*/
struct tcp_pcb *tcppcbconn; /* 定义一个TCP服务器控制块*/
char *tbuf;
uint8_t key;
uint8_t res = 0;
uint8_t t = 0;
uint8_t connflag = 0; /* 连接标记*/
lcd_clear(BLACK); /* 清屏*/
g_point_color = WHITE;
lcd_show_string(30, 30, 200, 16, 16, "STM32", g_point_color);
lcd_show_string(30, 50, 200, 16, 16, "TCP Server Test", g_point_color);
lcd_show_string(30, 70, 200, 16, 16, "ATOM@正点原子", g_point_color);
lcd_show_string(30, 90, 200, 16, 16, "KEY0:Send data", g_point_color);
lcd_show_string(30, 110, 200, 16, 16, "KEY_UP:Quit", g_point_color);
tbuf = mymalloc(SRAMIN, 200); /* 申请内存*/
if (tbuf == NULL)
return; /* 内存申请失败了,直接退出*/
sprintf((char *)tbuf, "Server IP:%d.%d.%d.%d", lwipdev.ip[0],
lwipdev.ip[1],
lwipdev.ip[2],
lwipdev.ip[3]);
lcd_show_string(30, 130, 210, 16, 16, tbuf, g_point_color);
sprintf((char *)tbuf, "Server Port:%d", LWIP_DEMO_PORT); /* 服务器端口号*/
lcd_show_string(30, 150, 210, 16, 16, tbuf, g_point_color);
tcppcbnew = tcp_new(); /* 创建一个新的pcb */
if (tcppcbnew) /* 创建成功*/
{
/* 将本地IP与指定的端口号绑定在一起,IP_ADDR_ANY为绑定本地所有的IP地址*/
err = tcp_bind(tcppcbnew, IP_ADDR_ANY, LWIP_DEMO_PORT);
if (err == ERR_OK) /* 绑定完成*/
{
tcppcbconn = tcp_listen(tcppcbnew); /* 设置tcppcb进入监听状态*/
/* 初始化LWIP的tcp_accept的回调函数*/
tcp_accept(tcppcbconn, tcp_server_accept);
}
else
res = 1;
}
else
res = 1;
g_point_color = BLUE;
while (res == 0)
{
key = key_scan(0);
if (key == KEY1_PRES)
break;
if (key == KEY0_PRES) /* KEY0按下了,发送数据*/
{
lwip_tcp_server_usersent(tcppcbnew); /* 发送数据*/
}
if (lwip_send_flag & 1 << 6) /* 是否收到数据*/
{
/* 清上一次数据*/
lcd_fill(30, 210, lcddev.width - 1, lcddev.height - 1, BLACK);
/* 显示接收到的数据*/
lcd_show_string(30, 210, lcddev.width - 30, lcddev.height - 210, 16,
(char *)lwip_demo_recvbuf, g_point_color);
lwip_send_flag &= ~(1 << 6); /* 标记数据已经被处理了*/
}
if (lwip_send_flag & 1 << 5) /* 是否连接上*/
{
if (connflag == 0)
{
/* 客户端IP */
sprintf((char *)tbuf,
"ClientIP:%d.%d.%d.%d", lwipdev.remoteip[0],
lwipdev.remoteip[1],
lwipdev.remoteip[2],
lwipdev.remoteip[3]);
lcd_show_string(30, 170, 230, 16, 16, tbuf, g_point_color);
g_point_color = WHITE;
connflag = 1; /* 标记连接了*/
}
}
else if (connflag)
{
lcd_fill(30, 170, lcddev.width - 1, lcddev.height - 1, BLACK);
connflag = 0; /* 标记连接断开了*/
}
lwip_periodic_handle();
delay_ms(2);
t++;
if (t == 200)
{
t = 0;
LED0_TOGGLE();
}
}
lwip_tcp_server_connection_close(tcppcbnew, 0); /* 关闭TCP Server连接*/
lwip_tcp_server_connection_close(tcppcbconn, 0); /* 关闭TCP Server连接*/
lwip_tcp_server_remove_timewait();
memset(tcppcbnew, 0, sizeof(struct tcp_pcb));
memset(tcppcbconn, 0, sizeof(struct tcp_pcb));
myfree(SRAMIN, tbuf);
}
在上面代码中主要完成一下几个功能。
(1) 通过调用tcp_new 函数创建一个tcp 控制块tcppcbnew,这个控制块用来进行监听,如
果未创建成功的话就令res 等于1。
(2) 当控制块tcppcbnew 创建成功以后就将其绑定到指定的IP 地址和端口好上,绑定成功
以后将控制块设置为监听状态,并且注册控制块accept 字段的回调函数,如果绑定未成功的话
就让res 等于1。
(3) 当res 等于0 的话就进入while 循环,在while 循环的处理过程基本和TCP 客户端的一
样。
(4) 当从while 循环退出来后,我们就关闭TCP 连接,这里我们要关闭两个:tcppcbnew 和
tcppcbconn,最后还要调用lwip_tcp_server_remove_timewait 函数将处于TIME—WAIT 状态的
pcb 控制块删除。
到这里lwip_demo.c 文件中的函数已经讲完。mian 函数就不必讲解了,代码很简单。
下载验证
在代码编译成功以后,我们下载代码到开发板中,通过网线连接开发板到路由器上,如果
没有路由器的话就连接到电脑端的RJ45 上,电脑端还要进行设置,设置过程很简单。开发板
上电,等待出现13.2.3.1 所示画面,打开网络调试助手按图13.2.3.2 所示设置好以后点击“连
接”按钮。
图13.2.3.1 开机LCD 显示画面
图13.2.3.2 网络调试助手设置
当网络调试助手连接上开发板以后,开发板LCD 上显示如图13.2.3.3 所示,表明网络调
试助手已经连接上服务器。
图13.2.3.3 连接上以后的LCD 显示
我们通过网络调试助手向开发板发送:www.openedv.com,此时开发板LCD 上显示接收
到的数据如图13.2.3.4 所示,按下开发板上的KEY0 键向网络调试助手发送数据。
图13.2.3.4 LCD 显示接收到的数据
RAW 编程接口Web Server 实验
本章采用RAW 编程API 接口在开发板上实现一个WebServer。在本章中我们通过移植并
修改ST 官方的一个WebServer 的例程来展示。在浏览器中输入开发板的IP 地址来访问开发板,
这时开发板会返回一个网页,在这个网页上我们可以控制开发板的LED 灯和蜂鸣器并查看
ADC 和内部温度等外设的数据。
Web Server 文件以及相关技术简介
(1) 实验相关文件简介
本章我们在ST 官方Web Server 例程的基础上完成本章实验,该参考实验是在
STM32Cube_FW_F4_V1.27.0\Projects\STM324x9I_EVAL\Applications\LwIP\LwIP_HTTP_Server
_Raw 路径下获取,这里我们打开《lwIP 例程4 lwIP_RAW_Webserver 实验》实验,该实验用
到Middlewares\lwip\src\apps\http 文件夹下的文件,这些文件如下图所示。
上图中altcp_proxyconnect.c 和http_client.c 文件并没有在本章实验中用到,下面我们来讲
解一下本章用到的文件,如下表所示:
表14.1.1 Web Server 各文件说明
(2) 添加分组
在工程中添加Middlewares/lwip/lwip/src/apps 分组,该分组添加上图14.1.1 的fs.c 以及
httpd.c 文件,如下图所示:
图14.1.2 Middlewares/lwip/lwip/src/apps 分组添加的文件
(3) lwipopts.h 文件添加配置项
/** 支持CGI */
#define LWIP_HTTPD_CGI 1
/** 支持SSI */
#define LWIP_HTTPD_SSI 1
/* 设置为1将包含"fsdata_custom.c"而不是"fsdata.c" */
#define HTTPD_USE_CUSTOM_FSDATA 0
上述源码的第一和第二配置项表示支持CGI 和SSI 技术,这些技术我们稍后讲解,第三
个配置项主要表示上图14.1.2 中fs.c 文件包含的文件路径,这里我们使用fsdata.c 文件路径。
注意:这个fadata.c 文件包含了网页数组,这些网页数组的生成方法我们稍后讲解,
(4) 添加工程路径
点击魔法棒并进入C/C++配置项页面,我们在这个页面下添加“…\Middlewares\lwip\src\i
nclude\lwip\apps”头文件路径。
(5) 网页数组制作
在lwip\src\apps\http\路径下包含了makefsdata 文件夹,该文件夹中的文件可以把网页生成
网页数组,这里我们不使用这个文件夹,我们使用正点原子之前的makefsdata 文件夹,该文件
夹包含了makefsdata.exe,我们使用这个软件自动生成即可。
首先我们复制正点原子之前的lwip 实验的下的makefsdata 文件夹到桌面上,makefsdata 文
件夹保存着网页数组生成器以及fs 文件夹,网页数组生成如下图14.1.3 所示,而fs 文件夹保
存着网页源文件如图14.1.4 所示:
图14.1.3 makefsdata 文件夹的内容
图14.1.4 本实验网页源文件
图14.1.4 中的是网页源文件,显然这个文件不能直接放到STM32 里面,我们要做一个转换,使其可以放到STM32 里面。这里我们通过makefsdata.exe 这个工具将原始网页文件转换
成.c 格式的网页数组,这样就可以添加到我们的工程中了,下面我们讲解makefsdata 工具的使
用。
(6) makefsdata 工具的使用
makefsdata 工具是用来将我们编辑网页文件等信息转换成二进制数的一个工具。接下来我
们讲解一下这个工具的使用方法。
①将fs 文件夹和makefsdada.exe 工具放到同一文件夹下,此处为makefsdata 文件夹,打开
makefsdata 文件夹,如图14.1.5 所示。其中图14.1.3 中的echotool.exe 和Tftpd32-3.51-setup.exe
为其他工具,这里没有使用到,cmd.reg 稍后我们会讲到。
图14.1.5 makefsdata 文件内容
②快捷键“win+r”并输入cmd 进入命令行,我们复制上图的makafsdata 文件夹路径并在
命令行上进入该路径,如图14.1.6 所示:
图14.1.6 在命令行下进入makafsdata 文件夹
如果我们不想使用命令行的方式生成网页数组,请使用我们提供的cmd.reg 文件导入注册
表注册,双击打开cmd.reg,然后一路确定下去就可以了。
③在上图14.1.6 命令行中输入“makefsdata –i”命令并按回车生成网页数组,如下图所示。
④打开makefsdata 文件夹,打开后如图14.1.8 所示,我们发现在makefsdata 文件夹下多
了一个fsdata.c 的C 文件,这个fsdata.c 文件就是我们转换后的二进制网页文件,到此
makefsdata 工具的用法介绍完毕
图14.1.8 生成的fsdata.c 文件
(7) 把上图的fsdata.c 文件替换Middlewares\lwip\src\apps\http 路径下的fsdata.c 文件。注
意:请把file__404_html 网页数组和file__index_html 网页数组添加到fsdata.c 文件中,不然
本实验无法运行。
(8) 添加httpd_cgi_ssi.c 文件
该httpd_cgi_ssi.c 文件是正点原子根据ST 官方的一个WebServer 的例程改编的,这里例程
路径为:STM32Cube_FW_F4_V1.27.0\Projects\STM324xG_EVAL\Applications\LwIP\LwIP_HTT
P_Server_Raw\Src 路径下的httpd_cgi_ssi.c,注意:ST 官方的WebServer 例程使用的是fsdata_c
ustom.c 文件,所以它把HTTPD_USE_CUSTOM_FSDATA 配置项设置为1。我们把正点原子改
编的httpd_cgi_ssi.c 文件添加到Middlewares/lwip/lwip_app 分组当中,如图14.1.10 所示:
图14.1.10 添加httpd_cgi_ssi.c 文件
(9) 在lwip_demo 函数添加httpd 初始化源码
#include "httpd.h"
#include "httpd_cgi_ssi.h"
/**
* @brief lwip_demo 测试
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
/* Httpd Init */
httpd_init();
/* 配置SSI处理程序*/
httpd_ssi_init();
/* 配置CGI处理器*/
httpd_cgi_init();
}
至此,我们已经完成了WebServer 例程改编,下面我们来讲解一下CGI 和SSI 技术。
(10) CGI 技术简介
公共网关接口CGI(Common Gateway Interface) 是WWW 技术中最重要的技术之一,有着
不可替代的重要地位。CGI 是外部应用程序与Web 服务器之间的接口标准,是在CGI 程序和
Web 服务器之间传递信息的规程。CGI 规范允许Web 服务器执行外部程序,并将它们的输出
发送给Web 浏览器,CGI 在物理上是一段程序,运行在服务器上,提供同客户端HTML 页面
的接口。
绝大多数的CGI 程序被用来解释处理来自表单的输入信息,并在服务器产生相应的处理,
或将相应的信息反馈给浏览器,CGI 程序使网页具有交互功能。在我们本章实验中我们通过浏
览器控制开发板上的LED 和蜂鸣器就是使用的CGI 技术。
(11) SSI 技术简介
服务器端嵌入:Server Side Include,是一种类似于ASP 的基于服务器的网页制作技术。
大多数的WEB 服务器等均支持SSI 命令。将内容发送到浏览器之前,可以使用“服务器端包
含(SSI)”指令将文本、图形或应用程序信息包含到网页中。例如,可以使用SSI 包含时间/日
期戳、版权声明或供客户填写并返回的表单。对于在多个文件中重复出现的文本或图形,使用
包含文件是一种简便的方法。将内容存入一个包含文件中即可,而不必将内容输入所有文件。
通过一个非常简单的语句即可调用包含文件,此语句指示Web 服务器将内容插入适当网页。
而且,使用包含文件时,对内容的所有更改只需在一个地方就能完成。因为包含SSI 指令的文
件要求特殊处理,所以必须为所有SSI 文件赋予SSI 文件扩展名。默认扩展名是.stm、.shtm
和.shtml。
SSI 是为WEB 服务器提供的一套命令,这些命令只要直接嵌入到HTML 文档的注释内容
之中即可。如:
就是一条SSI 指令,其作用是将"info.htm"的内容
拷贝到当前的页面中,当访问者来浏览时,会看到其它HTML 文档一样显示info.htm 其中的
内容。其它的SSI 指令使用形式基本同刚才的举例差不多,可见SSI 使用只是插入一点代码而
已,使用形式非常简单。
是HTML 语法中表示注释,当WEB 服务器不支持SSI 时,会
忽略这些信息。
在本实验中我们可以通过网页查看开发板的ADC,内部温度传感器和RTC 的值就是通过
SSI 来实现的。
Web Server 实验
硬件设计
- 例程功能
本实验通过网页控制开发板上的LED 和BEEP 外设,当然可以接收开发板的RTC 以及
ADC 数据并在网页上显示出来。
该实验的实验工程,请参考《lwIP 例程5 lwIP_RAW_WebServer 实验》。
软件设计
14.2.2.1 程序流程图
本实验的程序流程图,如下图所示:
图14.2.2.1.1 Webserver 实验流程图
14.2.2.2 程序解析
我们打开《lwIP 例程4 lwIP_RAW_Webserver 实验》的工程,如图14.2.2.2.1 所示,其中
fs.c 文件管理生成的网页数组文件这个文件由lwIP 提供。httpd.c 文件是本章实验的重点,这个
文件将开发板配置为Web Server,这个文件也由lwIP 官方提供的,阅读这个文件需要有网页
相关的知识,这里对这个文件不做讲解。
我们在浏览器中输入网址,服务器就会返回给我们相应的网页,然后浏览器解析并呈现给
我们。同样的,当我们通过浏览器访问开发板的时候,开发板这时是作为服务器的,服务器针
对不同的URL 在fsdata.c 文件中找出相应的网页,并且返回给浏览器,在fsdata.c 文件中查找
网页的过程就需要fs.c 里面的函数。接收浏览器发送的数据并且将网页返回给浏览器的过程都
是由httpd.c 文件里面的函数来完成的。
fs.c 和httpd.h 文件本章不做讲解,感兴趣的朋友可以去看一下,本章中我们主要讲解的是
httpd_cgi_ssi.c 这个文件,这个文件中讲解了如何使用CGI 和SSI 技术完成浏览器与服务器的
交互。
图14.2.2.2.1 Web Server 工程文件
(1) CGI 实现
我们通过浏览器控制开发板上的LED 灯和蜂鸣器就是使用的CGI 技术,我们在开发板上
对浏览器发送过来的URL 进行分析,然后根据不同的URL 调用不同的程序就可以了,下图
14.2.2.2.2 中我们控制LED1 灯亮,注意图中的URL。
图14.2.2.2.2 打开LED1
上图中的URL 为:http://192.168.1.136/leds.cgi?LED1=LED1ON&button2=SEND。我们就
是分析这一串字符串来做出相应的处理,其中leds.cgi 表示为控制LED 灯的CGI,后面的
“LED1”为变量,LEDION 为变量“LED1”的值。我们根据字符串“leds.cgi”调用处理
LED 灯的程序,然后根据后面的变量和变量值来决定是打开还是关闭LED 灯。在
httpd_cgi_ssi.c 中我们定义了一个数组ppcURLS,数组如下,在这个数组中leds.cgi 对应的
LEDS_CGI_Handler 处理程序,beep.cgi 对应的是BEEP_CGI_Handler 处理程序。
static const tCGI ppcURLs[] = /* cgi程序*/
{
{"/leds.cgi", LEDS_CGI_Handler},
{"/beep.cgi", BEEP_CGI_Handler},
};
LEDS_CGI_Handler 是一个函数,代码如下,从下面代码中可以看出我们是根据变量
“LED1”的值来做相应的处理,当为LED1 ON 的时候就打开LED1,当为LED1 OFF 的时候
就关闭LED1。
那么蜂鸣器BEEP 的处理过程也一样的,这里就不做讲解。最后我们还要还要初始化CGI 句
柄,初始化函数为httpd_cgi_init,这个函数很简单。
/* CGI LED控制句柄*/
const char *LEDS_CGI_Handler(int iIndex, int iNumParams, char *pcParam[],
char *pcValue[])
{
uint8_t i = 0; /* 注意根据自己的GET的参数的多少来选择i值范围*/
iIndex = FindCGIParameter("LED1", pcParam, iNumParams);
/* 只有一个CGI句柄iIndex=0 */
if (iIndex != -1)
{
LED1(1);
/* 检查CGI参数: example GET /leds.cgi?led=2&led=4 */
for (i = 0; i < iNumParams; i++)
{
if (strcmp(pcParam[i], "LED1") == 0) /* 检查参数"led" */
{
if (strcmp(pcValue[i], "LED1ON") == 0) /* 改变LED1状态*/
{
/* 打开LED1 */
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_RESET);
}
else if (strcmp(pcValue[i], "LED1OFF") == 0)
{
/* 关闭LED1 */
HAL_GPIO_WritePin(LED1_GPIO_Port, LED1_Pin, GPIO_PIN_SET);
}
}
}
}
if (READ_LED1 == 0 && READ_BEEP == 0)
{
return "/STM32F407LED_ON_BEEP_OFF.shtml"; /* LED1开,BEEP关*/
}
else if (READ_LED1 == 0 && READ_BEEP == 1)
{
return "/STM32F407LED_ON_BEEP_ON.shtml"; /* LED1开,BEEP开*/
}
else if (READ_LED1 == 1 && READ_BEEP == 1)
{
return "/STM32F407LED_OFF_BEEP_ON.shtml"; /* LED1关,BEEP开*/
}
else
{
return "/STM32F407LED_OFF_BEEP_OFF.shtml"; /* LED1关,BEEP关*/
}
}
(2) SSI 实现
我们通过网页查看开发板上的ADC 值,内部温度传感器和RTC 时间的时候就是用的SSI,
每隔1s 刷新一次网页,然后通过SSI 技术将这些值嵌入到网页中,这样我们就看到时钟在动
态的跟新,如下图所示。
SSIHandler 函数为SSI 的句柄函数,函数代码如下,在这个函数中我们根据参数iIndex 调
用不同的函数来完成向网页中添加数据,这几个函数比较简单,大家自行查阅一下。最后我们
还要还要初始化SSI 句柄,初始化函数为httpd_ssi_init,这个函数很简单。
/* SSI的Handler句柄*/
static u16_t SSIHandler(int iIndex, char *pcInsert, int iInsertLen)
{
switch (iIndex)
{
case 0:
ADC_Handler(pcInsert);
break;
case 1:
Temperate_Handler(pcInsert);
break;
case 2:
RTCTime_Handler(pcInsert);
break;
case 3:
RTCdate_Handler(pcInsert);
break;
}
return strlen(pcInsert);
}
下载验证
在代码编译成功以后,我们下载代码到开发板中,通过网线连接开发板到路由器上,如果
没有路由器的话就连接到电脑端的RJ45 上,电脑端还要进行设置,设置过程和UDP 实验一样。
下载完成后等待开发板LCD 出现如图14.2.3.1 所示,然后我们在浏览器里面输入开发板的IP
地址,我的开发板IP 地址为:192.168.1.136,大家根据自己的实际情况输入就行了,按回车,
服务器将网页返回给浏览器并显示出来,如图14.2.3.2 所示。
图14.2.3.1 LCD 显示界面
图14.2.3.2 WEB Server 主页
最后大家可以测试一下其他的功能,比如控制开发板的LED1 灯,蜂鸣器等,可以查看
ADC 的值和内部温度传感器,查看RTC 时间值。