NTP(Network Time Protocol)网络时间协议基于UDP,用于网络时间同步的协议,使网
络中的计算机时钟同步到UTC,再配合各个时区的偏移调整就能实现精准同步对时功能。本
章在开发板上使用UDP 协议连接阿里云的NTP 服务器,向这个服务器发送NTP 报文来获取
实时时间。
NTP 服务器(Network Time Protocol(NTP))是用来使计算机时间同步化的一种协议,它
可以使计算机对其服务器或时钟源(如石英钟,GPS 等等)做同步化,它可以提供高精准度
的时间校正(LAN 上与标准间差小于1 毫秒,WAN 上几十毫秒),且可介由加密确认的方式
来防止恶毒的协议攻击。时间按NTP 服务器的等级传播。按照离外部UTC 源的远近把所有服
务器归入不同的Stratum(层)中。
NTP 数据报文格式,如下图所示。
NTP 数据报文格式的各个字段的作用,如下表所示:
从上表可知,NTP 报文的字段非常多,这些字段并不是每一个都必须设置的,请大家根
据项目的需要来构建NTP 请求报文。下面笔者使用网络调式助手制作一个简单的NTP 实验,
如下图所示:
图23.1.2 获取阿里云NTP 数据
上图中,笔者使用网络调试助手以UDP 协议连接阿里云NTP 服务器,接着在发送框上填
入NTP 请求报文,发送完成之后网络调试助手接收到一段数据,这里我们只取第40 位到43
位的十六进制数值,该数值就是当前时间的总秒数。我们把总秒数转换成十进制,并且在在线
转换器(https://tool.lu/timestamp/)上计算当前时间,如下图所示:
从上面的内容可知,我们知道获取NTP 实时时间需要哪些步骤了,这些步骤如下所示:
①以UDP 协议连接阿里云NTP 服务器。
②发送NTP 报文到阿里云NTP 服务器。
③获取阿里云NTP 服务器返回的数据,取第40 位到43 位的十六进制数值。
④把40 位到43 位的十六进制数值转成十进制。
⑤把十进制数值减去1900-1970 的时间差(2208988800 秒)。
⑥数值转成年月日时分秒。
23.2.2.1 程序流程图
本实验的程序流程图,如下图所示。
程序解析
为了描述NTP 报文结构的字段,笔者在lwip_demo.h 文件下定义了NPTformat 结构体,
它用来描述NTP 报文结构体的各个字段,该结构体如下所示:
typedef struct _NPTformat
{
char version; /* 版本号*/
char leap; /* 时钟同步*/
char mode; /* 模式*/
char stratum; /* 系统时钟的层数*/
char poll; /* 更新间隔*/
signed char precision; /* 精密度*/
unsigned int rootdelay; /* 本地到主参考时钟源的往返时间*/
unsigned int rootdisp; /* 统时钟相对于主参考时钟的最大误差*/
char refid; /* 参考识别码*/
unsigned long long reftime; /* 参考时间*/
unsigned long long org; /* 开始的时间戳*/
unsigned long long rec; /* 收到的时间戳*/
unsigned long long xmt; /* 传输时间戳*/
} NPTformat;
该结构体的成员变量与表23.1.1 的NTP 报文结构体的字段是一一对应的。
打开lwip_demo.c 文件,在此文件下定义了四个函数,这些函数的作用如下表所示:
(1) lwip_ntp_client_init 函数
此函数用来构建NTP 请求报文,通过设置NPTformat 结构体的成员变量来描述NTP 报文
的字段信息,构建完成之后把该报文存储在缓冲区当中。构建NTP 报文的源码如下所示:
/**
*@brief 初始化NTP Client信息
*@param 无
*@retval 无
*/
void lwip_ntp_client_init(void)
{
uint8_t flag;
g_ntpformat.leap = 0; /* 时钟同步*/
g_ntpformat.version = 3; /* 版本号*/
g_ntpformat.mode = 3; /* 模式*/
g_ntpformat.stratum = 0; /* 系统时钟的层数*/
g_ntpformat.poll = 0; /* 更新间隔*/
g_ntpformat.precision = 0; /* 精密度*/
g_ntpformat.rootdelay = 0; /* 本地到主参考时钟源的往返时间*/
g_ntpformat.rootdisp = 0; /* 统时钟相对于主参考时钟的最大误差*/
g_ntpformat.refid = 0; /* 参考识别码*/
g_ntpformat.reftime = 0; /* 参考时间*/
g_ntpformat.org = 0; /* 开始的时间戳*/
g_ntpformat.rec = 0; /* 收到的时间戳*/
g_ntpformat.xmt = 0; /* 传输时间戳*/
flag = (g_ntpformat.version << 3) + g_ntpformat.mode;
memcpy(g_ntp_message, (void const *)(&flag), 1);
}
可以看到,笔者只设置NTP 报文的版本和模式字段,其他字段我们设置为0。
(2) lwip_get_seconds_from_ntp_server 函数
此函数用来获取NTP 服务器返回的数据,从这个数据截取40~43 位的数值,并且强制转
换成十进制数值,最后递交给其他函数处理。
/**
*@brief 从NTP服务器获取时间
*@param buf:存放缓存
*@param idx:定义存放数据起始位置
*@retval 无
*/
void lwip_get_seconds_from_ntp_server(uint8_t *buf, uint16_t idx)
{
unsigned long long atk_seconds = 0;
uint8_t i = 0;
for (i = 0; i < 4; i++) /* 获取40~43位的数据*/
{
/* 把40~43位转成16进制再转成十进制*/
atk_seconds = (atk_seconds << 8) | buf[idx + i];
}
/* 减去减去1900-1970的时间差(2208988800秒)*/
atk_seconds -= NTP_TIMESTAMP_DELTA;
lwip_calc_date_time(atk_seconds); /* 由UTC时间计算日期*/
}
调用此函数时,该函数的idx 形参为40,经过for 语句的作用,可在数据中截取40~43 位
的数值,截取完成之后强制转换成十进制并减去1900-1970 的时间差,最后由lwip_calc_date_t
ime 函数计算时间。
(3) lwip_calc_date_time 函数
此函数是把总秒数转换成时间信息,该函数的源码如下所示:
/**
*@brief 计算日期时间
*@param seconds UTC 世界标准时间
*@retval 无
*/
void lwip_calc_date_time(unsigned long long time)
{
unsigned int Pass4year;
int hours_per_year;
if (time <= 0)
{
time = 0;
}
nowdate.second = (int)(time % 60); /* 取秒时间*/
time /= 60;
nowdate.minute = (int)(time % 60); /* 取分钟时间*/
time /= 60;
nowdate.hour = (int)(time % 24); /* 小时数*/
/* 取过去多少个四年,每四年有1461*24 小时*/
Pass4year = ((unsigned int)time / (1461L * 24L));
nowdate.year = (Pass4year << 2) + 1970; /* 计算年份*/
time %= 1461 * 24; /* 四年中剩下的小时数*/
for (;;) /* 校正闰年影响的年份,计算一年中剩下的小时数*/
{
hours_per_year = 365 * 24; /* 一年的小时数*/
if ((nowdate.year & 3) == 0) /* 判断闰年*/
{
hours_per_year += 24; /* 是闰年,一年则多24小时,即一天*/
}
if (time < hours_per_year)
{
break;
}
nowdate.year++;
time -= hours_per_year;
}
time /= 24; /* 一年中剩下的天数*/
time++; /* 假定为闰年*/
if ((nowdate.year & 3) == 0) /* 校正闰年的误差,计算月份,日期*/
{
if (time > 60)
{
time--;
}
else
{
if (time == 60)
{
nowdate.month = 1;
nowdate.day = 29;
return;
}
}
}
/* 计算月日*/
for (nowdate.month = 0; Days[nowdate.month] < time; nowdate.month++)
{
time -= Days[nowdate.month];
}
nowdate.day = (int)(time);
return;
}
总秒数经过算法的处理,计算得出的年、月、‘时、分和秒都保存在DateTime 结构体当中。
(40) lwip_demo 函数
此函数调用lwIP 相关的API 接口,以UDP 协议连接阿里云NTP 服务器,连接完成之后
开启定时器定时发送NTP 请求报文,最后处理NTP 服务器返回的数据。
/**
* @brief lwip_demo程序入口
* @param 无
* @retval 无
*/
static void lwip_demo(void)
{
err_t err;
static struct netconn *udpconn;
static struct netbuf *recvbuf;
static struct netbuf *sentbuf;
ip_addr_t destipaddr;
uint32_t data_len = 0;
struct pbuf *q;
atk_ntp_client_init();
/* 第一步:创建udp控制块*/
udpconn = netconn_new(NETCONN_UDP);
/* 定义接收超时时间*/
udpconn->recv_timeout = 10;
if (udpconn != NULL) /* 判断创建控制块释放成功*/
{
/* 第二步:绑定控制块、本地IP和端口*/
err = netconn_bind(udpconn, IP_ADDR_ANY, NTP_DEMO_PORT);
/* 域名解析*/
netconn_gethostbyname((char *)(HOST_NAME), &(destipaddr));
/* 第三步:连接或者建立对话框*/
netconn_connect(udpconn, &destipaddr, NTP_DEMO_PORT); /* 连接到远端主机*/
if (err == ERR_OK) /* 绑定完成*/
{
while (1)
{
sentbuf = netbuf_new();
netbuf_alloc(sentbuf, 48);
memcpy(sentbuf->p->payload, (void *)ntp_message,
sizeof(ntp_message));
err = netconn_send(udpconn, sentbuf);
if (err != ERR_OK)
{
printf("发送失败\r\n");
netbuf_delete(sentbuf); /* 删除buf */
}
netbuf_delete(sentbuf); /* 删除buf */
/* 第五步:接收数据*/
netconn_recv(udpconn, &recvbuf);
vTaskDelay(1000); /* 延时1s */
if (recvbuf != NULL) /* 接收到数据*/
{
/* 数据接收缓冲区清零*/
memset(ntp_demo_recvbuf, 0, NTP_DEMO_RX_BUFSIZE);
/* 遍历完整个pbuf链表*/
for (q = recvbuf->p; q != NULL; q = q->next)
{
/* 判断要拷贝到UDP_DEMO_RX_BUFSIZE中的数据是否大于
UDP_DEMO_RX_BUFSIZE的剩余空间,如果大于
的话就只拷贝UDP_DEMO_RX_BUFSIZE中剩余长度的数据,
否则的话就拷贝所有的数据*/
if (q->len > (NTP_DEMO_RX_BUFSIZE - data_len))
/* 拷贝数据*/
memcpy(ntp_demo_recvbuf + data_len, q->payload,
(NTP_DEMO_RX_BUFSIZE - data_len));
else
memcpy(ntp_demo_recvbuf + data_len,
q->payload, q->len);
data_len += q->len;
/* 超出TCP客户端接收数组,跳出*/
if (data_len > NTP_DEMO_RX_BUFSIZE)
break;
}
data_len = 0; /* 复制完成后data_len要清零*/
/*从NTP服务器获取时间*/
atk_get_seconds_from_ntp_server(ntp_demo_recvbuf, 40);
printf("北京时间:%02d-%02d-%02d %02d:%02d:%02d\r\n",
nowdate.year,
nowdate.month + 1,
nowdate.day,
nowdate.hour + 8,
nowdate.minute,
nowdate.second);
sprintf((char *)lwip_time_buf,
"Beijing time:%02d-%02d-%02d %02d:%02d:%02d",
nowdate.year,
nowdate.month + 1,
nowdate.day,
nowdate.hour + 8,
nowdate.minute,
nowdate.second);
lcd_show_string(5, 170, lcddev.width, 16, 16,
(char *)lwip_time_buf, RED);
netbuf_delete(recvbuf); /* 删除buf */
}
else
vTaskDelay(5); /* 延时5ms */
}
}
else
printf("NTP绑定失败\r\n");
}
else
printf("NTP连接创建失败\r\n");
}
编译代码并下载到开发板中,打开串口调式助手查看当前时间如下图所示。
我们为什么测试网速呢?原因很简单,在我们开发时候,有一些特殊的原因导致掉包、堵
塞、延迟抖动等情况,一般都是发送和接收速率的问题,如果网速偏低或者达不到PHY 芯片
的最大网速,则开发过程中会遇到很多的问题。
JPerf 网络测速工具是一个跨平台的网络性能测试工具,它支持Win/Linux/Mac/Android/iO
S 等多个平台,它也可以测试最大TCP 和UDP 带宽性能,具有多种参数和UDP 特性,可以根
据需要调整,可以报告带宽、延迟抖动和数据包丢失,该软件下载地址是:https://iperf.fr/iperf
-download.php。
下载完成之后打开该软件,可以看到该软件划分为几个区域,这些区域的作用如下所示:
24.2.2.1 程序解析
测试开发板收发速度的代码很简单,只需要移植lwip-2.1.2\src\apps\lwiperf 的文件到工程
中,接着在lwip_demo.c 文件下添加以下源码,如下所示:
/* 报告状态*/
const char *report_type_str[] =
{
"TCP_DONE_SERVER", /* LWIPERF_TCP_DONE_SERVER*/
"TCP_DONE_CLIENT", /* LWIPERF_TCP_DONE_CLIENT*/
"TCP_ABORTED_LOCAL", /* LWIPERF_TCP_ABORTED_LOCAL */
"TCP_ABORTED_LOCAL_DATAERROR", /*LWIPERF_TCP_ABORTED_LOCAL_DATAERROR*/
"TCP_ABORTED_LOCAL_TXERROR", /* LWIPERF_TCP_ABORTED_LOCAL_TXERROR */
"TCP_ABORTED_REMOTE", /* LWIPERF_TCP_ABORTED_REMOTE */
"UDP_STARTED", /* LWIPERF_UDP_STARTED,*/
"UDP_DONE", /* LWIPERF_UDP_DONE */
"UDP_ABORTED_LOCAL", /* LWIPERF_UDP_ABORTED_LOCAL*/
"UDP_ABORTED_REMOTE" /* LWIPERF_UDP_ABORTED_REMOTE */
};
/* 当测试结束以后会调用此函数,此函数用来报告测试结果*/
static void lwiperf_report(void *arg,
enum lwiperf_report_type report_type,
const ip_addr_t *local_addr,
u16_t local_port,
const ip_addr_t *remote_addr,
u16_t remote_port,
u32_t bytes_transferred,
u32_t ms_duration,
u32_t bandwidth_kbitpsec)
{
printf("-------------------------------------------------\r\n");
if ((report_type < (sizeof(report_type_str) / sizeof(report_type_str[0]))) && local_addr && remote_addr)
{
printf(" %s \r\n", report_type_str[report_type]);
printf(" Local address : %u.%u.%u.%u ", ((u8_t *)local_addr)[0],
((u8_t *)local_addr)[1],
((u8_t *)local_addr)[2],
((u8_t *)local_addr)[3]);
printf(" Port %d \r\n", local_port);
printf(" Remote address : %u.%u.%u.%u ", ((u8_t *)remote_addr)[0],
((u8_t *)remote_addr)[1],
((u8_t *)remote_addr)[2],
((u8_t *)remote_addr)[3]);
printf(" Port %d \r\n", remote_port);
printf(" Bytes Transferred %d \r\n", bytes_transferred);
printf(" Duration (ms) %d \r\n", ms_duration);
printf(" Bandwidth (kbitpsec) %d \r\n", bandwidth_kbitpsec);
}
else
{
printf(" IPERF Report error\r\n");
}
}
/**
* @brief lwip_demo实验入口
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
uint8_t t = 0;
if (lwiperf_start_tcp_server_default(lwiperf_report, NULL))
{
printf("\r\n************************************************\r\n");
printf(" IPERF Server example\r\n");
printf("************************************************\r\n");
printf(" IPv4 Address : %u.%u.%u.%u\r\n", lwipdev.ip[0],
lwipdev.ip[1],
lwipdev.ip[2],
lwipdev.ip[3]);
printf(" IPv4 Subnet mask : %u.%u.%u.%u\r\n", lwipdev.netmask[0],
lwipdev.netmask[1],
lwipdev.netmask[2],
lwipdev.netmask[3]);
printf(" IPv4 Gateway : %u.%u.%u.%u\r\n", lwipdev.gateway[0],
lwipdev.gateway[1],
lwipdev.gateway[2],
lwipdev.gateway[3]);
printf("************************************************\r\n");
}
else
{
printf("IPERF initialization failed!\r\n");
}
while (1)
{
vTaskDelay(5);
}
}
测试网速的相关原理这里笔者不会讲解,有兴趣的小伙伴可以看一下lwiperf.c/.h 文件。
编译程序并下载到开发板上,双击jperf.bat,填写IP 地址与端口号,如下所示:
图24.2.3.1 测试网速的IP 地址和端口号
可以看到,我们的网速接近95M,虽然离100M 有一点点差距,但是速率受很多因素影响。
提高网速的速率可设置以下几个配置项,如下所示:
/* 堆内存的大小,如果需要更大的堆内存,那么设置高一点*/
#define MEM_SIZE (30 * 1024)
/* MEMP_NUM_PBUF: 设置内存池的数量*/
#define MEMP_NUM_PBUF 25
/* MEMP_NUM_UDP_PCB: UDP协议控制块的数量. */
#define MEMP_NUM_UDP_PCB 4
/* MEMP_NUM_TCP_PCB: TCP的数量. */
#define MEMP_NUM_TCP_PCB 4
/* MEMP_NUM_TCP_PCB_LISTEN: 监听TCP的数量. */
#define MEMP_NUM_TCP_PCB_LISTEN 2
/* MEMP_NUM_TCP_SEG: 同时排队的TCP的数量段. */
#define MEMP_NUM_TCP_SEG 150
/* MEMP_NUM_SYS_TIMEOUT: 超时模拟活动的数量. */
#define MEMP_NUM_SYS_TIMEOUT 6
/* ---------- Pbuf选项---------- */
/* PBUF_POOL 内存池中每个内存块大小*/
#define PBUF_POOL_SIZE 20
/* PBUF_POOL_BUFSIZE: pbuf池中每个pbuf的大小. */
#define PBUF_POOL_BUFSIZE LWIP_MEM_ALIGN_SIZE(TCP_MSS + 40 + PBUF_LINK_ENCAPSULATION_HLEN + PBUF_LINK_HLEN)
/* TCP接收窗口*/
#define TCP_WND (20 * TCP_MSS)
本章实验我们在开发板上搭建一个HTTP 服务器,通过浏览器去访问我们的开发板,这个
实验和第十四章的实验不同的是该实验使用字符串的形式描述网页数据,而第十四章的实验使
用的是网页数组形式搭建HTTP 服务器。本实验参考contrib-2.1.0\apps\httpserver 路径下的
httpserver-netconn.c/.h 下的例程。
HTTP 协议是Hyper Text Transfer Protocol(超文本传输协议)的缩写,是用于从万维网
(WWW:World Wide Web )服务器传输超文本到本地浏览器的传送协议。HTTP 是一种无状态
协议,即服务器不保留与客户交易时的任何状态。这就大大减轻了服务器记忆负担,从而保持
较快的响应速度。HTTP 是一种面向对象的协议。允许传送任意类型的数据对象。它通过数据
类型和长度来标识所传送的数据内容和大小,并允许对数据进行压缩传送。当用户在一个
HTML 文档中定义了一个超文本链后,浏览器将通过TCP/IP 协议与指定的服务器建立连接,
如下所示:
图25.1.1 HTTP 协议交互
HTTP:定义了与服务器交互的不同方法,其最基本的方法是GET、PORT 和HEAD。如
下图所示。
①GET:从服务端获取数据。
②PORT:向服务器传送数据。
③HEAD:检测一个对象是否存在。
浏览器Client (Server)
(PORT)提交更新和控制
(LED/BEEP)
图25.1.2 HTTP 基本方法使用
可以知道,“GET”请求用来获取服务器的数据,而“PORT”请求是向服务器转送数据。
/**
* @brief lwip_demo程序入口
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
struct netconn *conn, *newconn;
err_t err;
/* 创建一个新的TCP连接句柄*/
/* 使用默认IP地址绑定到端口80 (HTTP) */
conn = netconn_new(NETCONN_TCP);
netconn_bind(conn, IP_ADDR_ANY, 80);
/* 将连接置于侦听状态*/
netconn_listen(conn);
do
{
err = netconn_accept(conn, &newconn);
if (err == ERR_OK)
{
http_server_netconn_serve(newconn);
netconn_delete(newconn);
}
} while (err == ERR_OK);
netconn_close(conn);
netconn_delete(conn);
}
连接完成之后调用http_server_netconn_serve 函数实现本章节的功能。
lwip_server_netconn_serve 函数源码如下所示:
static void
lwip_server_netconn_serve(struct netconn *conn)
{
struct netbuf *inbuf;
char *buf;
u16_t buflen;
err_t err;
char *ptemp;
/* 从端口读取数据,如果那里还没有数据,则阻塞。
我们假设请求(我们关心的部分)在一个netbuf中*/
err = netconn_recv(conn, &inbuf);
if (err == ERR_OK)
{
netbuf_data(inbuf, (void **)&buf, &buflen);
/* 这是一个HTTP GET命令吗?只检查前5个字符,因为
GET还有其他格式,我们保持简单)*/
if (buflen >= 5 &&
buf[0] == 'G' &&
buf[1] == 'E' &&
buf[2] == 'T' &&
buf[3] == ' ' &&
buf[4] == '/')
{
start_html:
/* 发送HTML标题
从大小中减去1,因为我们没有在字符串中发送\0
NETCONN_NOCOPY:我们的数据是常量静态的,所以不需要复制它*/
netconn_write(conn, http_html_hdr, sizeof(http_html_hdr) - 1,
NETCONN_NOCOPY);
/* 发送我们的HTML页面*/
netconn_write(conn, http_index_html, sizeof(http_index_html) - 1,
NETCONN_NOCOPY);
}
else if (buflen >= 8 && buf[0] == 'P' && buf[1] == 'O' && buf[2] == 'S' && buf[3] == 'T')
{
ptemp = lwip_data_locate((char *)buf, "led1=");
if (ptemp != NULL)
{
/* 查看led1的值。为1则灯亮,为2则灭,此值与HTML网页中设置有关*/
if (*ptemp == '1')
{
/* 点亮LED1 */
LED0(0);
}
else
{
/* 熄灭LED1 */
LED1(1);
}
}
/* 查看beep的值。为3则灯亮,为4则灭,此值与HTML网页中设置有关*/
ptemp = atk_data_locate((char *)buf, "beep=");
if (ptemp != NULL)
{
if (*ptemp == '3')
{
/* 打开蜂鸣器*/
BEEP(1);
}
else
{
/* 关闭蜂鸣器*/
BEEP(0);
}
}
goto start_html;
}
}
/* 关闭连接(服务器在HTTP中关闭) */
netconn_close(conn);
/* 删除缓冲区(netconn_recv给我们所有权,
所以我们必须确保释放缓冲区) */
netbuf_delete(inbuf);
}
上述的源码很简单理解,主要分为三步:
①当浏览器输入IP 地址并且回车确认时,程序调用函数netconn_write 把网页数据发送
到浏览器当中。
②当网页发送一个PORT 命令时,程序调用函数lwip_data_locate 判断触发源,判断完
成之后根据触发源来执行相应的动作。
③程序执行goto 语句重新发送网页字符串到网页当中,这个步骤相当于更新网页。
编译程序并把程序下载到开发板中,打开网页同时,需要查看分配的IP 地址为多少,接
着在浏览器上输入IP 地址,如下图所示:
网络摄像头是传统摄像机与网络视频技术相结合的新一代产品,除了具备一般传统摄像机
所有的图像捕捉功能外,机内还内置了数字化压缩控制器和基于WEB 的操作系统,使得视频
数据经压缩加密后,通过局域网,internet 或无线网络送至终端用户。而远端用户可在PC 上使
用标准的网络浏览器,根据网络摄像机的IP 地址,对网络摄像机进行访问,实时监控目标现
场的情况,并可对图像资料实时编辑和存储,同时还可以控制摄像机的云台和镜头,进行全方
位地监控。本章的实验是以网络调试助手作为客户端,开发板作为服务器。服务器把摄像头处
理的数据使用网卡发送至服务器当中,并且在服务器实时更新图像。
ATK-MC5640 模块通过2*9 的排针(2.54mm 间距)同外部相连接,该模块可直接与正点
原子探索者STM32F407 开发板和正点原子MiniSTM32H750 开发板等开发板的CAMERA 摄像
头接口连接。正点原子的大部分开发板,我们都提供了本模块相应的例程,用户可以直接在这
些开发板上,对模块进行测试。
ATK-MC5640 模块的外观,如下图所示:
图30.1.1 ATK-MC5640 模块实物图
ATK-MC5640 模块的原理图,如下图所示:
从上图可以看出,ATK-MC5640 模块自带了有源晶振,用于产生24MHz 的时钟作为
OV5640 传感器的XCLK 输入,模块的闪光灯(LED1 和LED2)可由OV5640 的STROBE 脚
控制(可编程控制)或外部引脚控制,只需焊接R2 或R3 的电阻进行切换控制,同时,模块
同时自带了稳压芯片,用于提供OV5640 稳定的2.8V 和1.5V 工作电压。
ATK-MC5640 模块通过一个2*9 的排针(P1)同外部电路连接,各引脚的详细描述,如
下表所示:
SCCB(Serial Camera Control Bus,串行摄像头控制总线)是OmniVision 开发的一种总线
协议,且广泛被应用于OV 系列图像传感器上。SCCB 协议与IIC 协议十分相似,SCCB 协议
由两条信号线组成:SIO_C(类似IIC 协议的SCL)和SIO_D(类似IIC 协议的SDA)。与IIC
协议一样,SCCB 协议也有起始信号和停止信号,只不过与IIC 协议不同的是,IIC 协议在传输完1 字节数据后,需要传输的接收方发送1 比特的确认位,而SCCB 协议一次性要传输9 位
数据,前8 位为读写的数据位,第9 位在写周期为Don’t-Care 位,在读周期为NA 位。这样一
次性传输的9 个位,在SCCB 协议中被定义为一个相(Phase)。
在SCCB 协议中共包含了三种传输周期,分别为3 相写传输(三个相均由主机发出,一般
用于主机写从机寄存器,三个相分别从设备地址、寄存器地址、写入的数据)、2 相写传输
(两个相均由主机发出,一般配合2 相读传输用与主机读从机寄存器值,两个相分别为从设备
地址、寄存器地址)和2 相读传输(第一个相由主机发出,第二个相由从机回应,一般配合2
相写传输用于主机读从机寄存器值,两个相分别为从设备地址、寄存器数据)。
关于SCCB 协议的详细介绍,请见《OmniVision Technologies Seril Camera Control
Bus(SCCB) Specification.pdf》。
在OV5640 图像传感器的初始化阶段,主机MCU 需要使用SCCB 协议配置OV5640 中大
量的寄存器,有关OV5640 寄存器的介绍,请见《OV5640_CSP3_DS_2.01_Ruisipusheng.pdf》
和《OV5640_camera_module_software_application_notes_1.3_Sonix.pdf》。
OV5640 支持数字视频接口(DVP)和MIPI 接口,因为正点原子探索者STM32F407 和正
点原子MiniSTM32H750 等开发板的CANERA 接口使用的是DCMI 接口,仅支持DVP 接口,
因此OV5640 必须使用DVP 输出接口,才能够连正点原子探索者STM32F407 和正点原子
MiniSTM32H750 等开发板使用。
OV5640 提供了一个10 位的DVP 接口(支持8 位接发),可通过程序设置DVP 以MSB
或LSB 输出,ATK-MC5640 模块采用8 位DVP 连接的方式,如下图所示:
图30.1.1.1 ATK-MC56408 位DVP 连接方式
OV5640 输出的图像与ISP(Image Signal Processor)输入窗口、预缩放窗口和数据输出窗
口的大小有关,如下图所示:
ISP 输入窗口(ISP imput size)
该窗口的大小允许用于设置整个传感器区域(physical pixel size,26231951)的执行部分,
也就是在传感器里面开窗(X_ADDR_ST、Y_ADDR_ST、X_ADDR_END、Y_ADDR_END),
开窗范围从00~2623*1951 都可以设置,该窗口所设置的范围,将输入ISP 进行处理。
ISP 输入窗口通过寄存器地址为0x3800~0x3807 的八个寄存器进行配置。
预缩放窗口(pre-scaling size)
该窗口允许用于在ISP 输入窗口的基础上再次设置想要用于缩放的窗口大小。该窗口仅在
ISP 输入窗口内进行X、Y 方向的偏移(X_OFFSET、Y_OFFSET)。
预缩放窗口通过寄存器地址为0x3808~0x380B 的四个寄存器进行配置。
数据输出窗口(data output size)
该窗口是OV5640 输出给外部的图像尺寸,当数据输出窗口的宽高比例与预缩放窗口的宽
高比例不一致时,输出的图像数据会变形,只有当两者比例一致时,输出图像的尺寸才不会变
形。
OV5640 图像传感器的数据输出(通过D[9:0]),是在PCLK、VSYNC、HREF(HSYNV)
的控制下进行的。行输出时序,如下图所示:
从上图可以看出,图像数据在HREF 为高的时候输出,当HREF 变高后,每一个PCLK
时钟,输出一个8 位或10 位的数据,ATK-MC5640 模块采用8 位,所以每个PCLK 输出1 个
字节图像数据,且在RGB/YUV 输出格式下,每个像素数据需要两个PCLK 时钟,在Raw 输
出格式下,每个像素数据需要一个PCLK 时钟。例如,采用QSXGA 分辨率RGB565 格式输出,
那么一个像素的信息由两个字节组成(低字节在前,高字节在后),这样每行图像数据就需要
25922 个PCLK 时钟,输出25922 个字节。
接下来以QSXGA 分辨率为例,介绍帧输出的时序,如下图所示:
图30.1.4.2 OV5640 帧输出时序图
上图清楚的展示了OV5640 在QSXGA 分辨率下的数据输出。只需按照这个时序去读取
OV5640 的数据,就可以得到图像数据。
OV5640 的自动对焦(Auto Focus)由其内置的微控制器完成,并且VCM(Voice Coil
Motor,音圈马达)驱动器也集成在传感器内部。OV5640 内置微控制器的自动对焦控制固件
(Firmware)需要从外接的主控芯片下载。当固件运行后,内置微处理器从OV5640 传感器自
动获取自动对焦所需的信息,然后计算并驱动VCM 带动镜头达到正确的对焦位置。外接主控
芯片可以通过SCCB 协议控制OV5640 内置微处理器的各种功能。
OV5640 自动对焦相关的寄存器,如下表所示:
OV5640 内置处理器接收到自动对焦命令后会自动将CMD_MAIN 寄存器清零,当命令执
行完成后则会将CMD_ACK 寄存器清零。
自动对焦过程
30.2.2.1 程序流程图
本实验的程序流程图,如下图所示:
程序解析
相关ATK-MC5640 驱动文件介绍,请参考《ATK-MC5640 模块使用说明》和《ATK-
MC5640 模块用户手册》文档。
实验的测试代码为文件lwip_demo.c,在工程下的Middlewares\lwip\lwip_app 路径中。测
试代码的入口函数为lwip_demo(),具体的代码,如下所示:
/**
* @brief lwip_demo实验入口
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
err_t err;
struct netconn *conn;
static ip_addr_t ipaddr;
uint8_t remot_addr[4];
static u16_t port;
uint8_t *p_jpeg_buf;
uint32_t jpeg_len;
uint32_t jpeg_index;
uint32_t jpeg_start_index;
uint32_t jpeg_end_index;
conn = netconn_new(NETCONN_TCP); /* 创建一个TCP链接*/
netconn_bind(conn, IP_ADDR_ANY, 8088); /* 绑定端口8088号端口*/
netconn_listen(conn); /* 进入监听模式*/
while (1) /* 等待连接*/
{
err = netconn_accept(conn, &g_newconn); /* 接收连接请求*/
if (err == ERR_OK) /* 成功检测到连接*/
{
/* 获取远端IP地址和端口号*/
netconn_getaddr(g_newconn, &ipaddr, &port, 0);
remot_addr[3] = (uint8_t)(ipaddr.addr >> 24);
remot_addr[2] = (uint8_t)(ipaddr.addr >> 16);
remot_addr[1] = (uint8_t)(ipaddr.addr >> 8);
remot_addr[0] = (uint8_t)(ipaddr.addr);
lwip_camera_init();
delay_ms(1000); /* 此延时一定要加!!*/
while (1) /* 开始视频传输*/
{
p_jpeg_buf = (uint8_t *)g_jpeg_buf;
jpeg_len = DEMO_JPEG_BUF_SIZE / (sizeof(uint32_t));
memset((void *)g_jpeg_buf, 0, DEMO_JPEG_BUF_SIZE);
/* 获取ATK-MC5640模块输出的一帧JPEG图像数据*/
atk_mc5640_get_frame((uint32_t)g_jpeg_buf,
ATK_MC5640_GET_TYPE_DTS_32B_INC, NULL);
/* 获取JPEG图像数据起始位置*/
for (jpeg_start_index = UINT32_MAX, jpeg_index = 0;
jpeg_index < DEMO_JPEG_BUF_SIZE - 1; jpeg_index++)
{
if ((p_jpeg_buf[jpeg_index] == 0xFF) &&
(p_jpeg_buf[jpeg_index + 1] == 0xD8))
{
jpeg_start_index = jpeg_index;
break;
}
}
if (jpeg_start_index == UINT32_MAX)
{
continue;
}
/* 获取JPEG图像数据结束位置*/
for (jpeg_end_index = UINT32_MAX, jpeg_index = jpeg_start_index;
jpeg_index < DEMO_JPEG_BUF_SIZE - 1; jpeg_index++)
{
if ((p_jpeg_buf[jpeg_index] == 0xFF) &&
(p_jpeg_buf[jpeg_index + 1] == 0xD9))
{
jpeg_end_index = jpeg_index;
break;
}
}
if (jpeg_end_index == UINT32_MAX)
{
continue;
}
/* 获取JPEG图像数据的长度*/
jpeg_len = jpeg_end_index - jpeg_start_index +
(sizeof(uint32_t) >> 1);
err = netconn_write(g_newconn, g_jpeg_buf,
DEMO_JPEG_BUF_SIZE, NETCONN_COPY); /* 发送数据*/
if ((err == ERR_CLSD) || (err == ERR_RST))
{
myfree(SRAMCCM, g_jpeg_buf);
netconn_close(g_newconn);
netconn_delete(g_newconn);
break;
}
vTaskDelay(2); /* 延时2ms */
}
}
}
}
上面的代码还是比较简单的,首先把开发板配置为TCP 服务器,配置完成且连接成功之
后将ATK-MC5640 模块输出的JPEG 图像数据读取至缓冲空间,由于JPEG 图像数据的大小是
不确定的,因此首先就要计算出JPEG 图像数据的大小,然后将JPEG 图像数据通过网络输出
至ATK-XCAM 上位机进行显示。
将ATK-MC5640 模块按照前面介绍的连接方式与开发板连接,同时将开发板与上位机通
讯的串口连接至PC,并将实验代码编译烧录至开发板中,如果DHCP 服务器分配完成,那么
串口调试助手显示如下信息:
图30.2.3.1 串口调试助手显示内容
接下来,如果ATK-MC5640 模块初始化成功,则会在上位机上显示ATK-MC5640 模块输
出的JPEG 图像,如下图所示:
图30.2.3.2 网络调试助手显示内容
本章,我们来实现一下ATK-MC2640 模块的网络摄像头实验。
ATK-MC2640 模块通过2*9 的排针(2.54mm 间距)同外部相连接,该模块可直接与正点
原子战舰STM32F103 开发板、正点原子探索者STM32F407 开发板和正点原子
MiniSTM32H750 开发板等开发板的CAMERA 摄像头接口连接。正点原子的大部分开发板,
我们都提供了本模块相应的例程,用户可以直接在这些开发板上,对模块进行测试。
ATK-MC2640 模块的外观,如下图所示:
图31.1.1.1 ATK-MC2640 模块实物图
ATK-MC2640 模块的原理图,如下图所示:
图31.1.1.2 ATK-MC2640 模块原理图
从上图可以看出,ATK-MC2640 模块自带了有源晶振,用于产生24MHz 的时钟作为OV2640 传感器的XCLK 输入,模块的闪光灯(LED1 和LED2)可由OV2640 的STROBE 脚
控制(可编程控制)或外部引脚控制,只需焊接R2 或R3 的电阻进行切换控制,同时,模块
同时自带了稳压芯片,用于提供OV2640 稳定的2.8V 和1.3V 工作电压
ATK-MC2640 模块通过一个2*9 的排针(P1)同外部电路连接,各引脚的详细描述,如
下表所示:
SCCB 协议相关知识,请读者查看30.1.1 小节内容。
在OV2640 图像传感器的初始化阶段,主机MCU 需要使用SCCB 协议配置OV2640 中大
量的寄存器,有关OV2640 寄存器的介绍,请见《OV2640_DS(1.6).pdf》和《OV2640 Software
Application Notes 1.03.pdf》。
OV2640 图像传感器的数据输出,是在行参考信号的像素时钟的控制下,有序输出的,默
认的行像素输出时序,如下图所示:
图31.1.2.1 OV2640 图像传感器行像素输出时序图
如上图所示,当行参考信号(HREF)为高电平时,表示数据端口的数据有效,此时,每
输出一个像素时钟(PCLK),就输出一个数据(8bit 或10bit)。数据在PCLK 的下降沿更新,
所以外接主控须在PCLK 的上升沿读取数据。
注意:图中的tP表示像素周期,像素周期可能等于一个像素时钟周期或两个像素时钟周
期。在RGB/YUV 输出格式下,每个像素周期等于两个像素时钟周期,在RawRGB 输出格式
下,每个像素周期等于一个像素时钟周期。
以RGB565 的输出格式为例,一个像素周期等于两个像素时钟周期,每一个像素需要用
两个字节表示,低字节在前,高字节在后,那么如果采用UXGA 分辨率输出图像数据,那么
每输出一行图像数据,就需要1600*2 个像素时钟。
当使用JPEG 格式输出图像数据时,输出的图像数据是经过压缩的数据,这里与普通的行
橡树输出时序略有不同,普通的行像素输出时,行参考信号是连续的,也就是在一行数据输出
的过程中,行参考信号是一直保持高电平的,而JPEG 格式输出图像数据时,行参考信号并不
是连续的,有可能在一行图像数据输出的过程中多次出现低电平,但这并不影响数据的读取,
只需判断行参考信号为高电平的时候,再读取数据就可以了。JPEG 格式输出的图像数据,不
存在高低字节的概念,只需要从头到尾,将所有的数据读取保存下来,就可以完成一次JPEG
数据采集。
注意:PCLK 的频率可达36MHz,所以外接主控在读取数据的时候,必须速度够快才可
以,否则就可能出现数据丢失。对于速度不够快的MCU,我们可以通过设置OV2640 的寄存
器(0xD3 和0x11),设置PCLK 和时钟的分频来降低PCLK 速度,从而使得低速外接主控也
可以读取OV2640 的数据。不过这样会降低帧率。
一帧图像数据,实际上就是由多行像素输出的数据组成的。这里以UXGA 的帧时序为例
进行介绍,UXGA 的帧时序如下图所示:
图31.1.3.1 UXGA 帧时序图
如上图所示,tLINE为行输出时间,tP为像素周期,VSYNC 为帧同步信号,每一个VSYNC
脉冲,表示一个新帧的开始,而整个帧周期内,由1200 次行像素(Row)输出,每一行为
1600 个像素,这样得到的数据,正好为1600*1200 的分辨率图像数据。
HSYNC 为行同步信号,用于同步行输出数据,不过ATK-MC2640 模块并没有引出该信号,
因此使用HREF 做同步即可。
31.2.2.1 程序流程图
本实验的程序流程图,如下图所示:
程序解析
相关ATK-MC2640 驱动文件介绍,请参考《ATK-MC2640 模块使用说明》和《ATK-MC2640 模块用户手册》文档。
实验的测试代码为文件lwip_demo.c,在工程下的Middlewares\lwip\lwip_app 路径中。测
试代码的入口函数为lwip_demo(),具体的代码,如下所示:
void lwip_demo(void)
{
struct netconn *conn;
static ip_addr_t ipaddr;
uint8_t remot_addr[4];
static u16_t port;
uint32_t *jpeg_buf;
uint32_t jpeg_len;
conn = netconn_new(NETCONN_TCP); /* 创建一个TCP链接*/
netconn_bind(conn, IP_ADDR_ANY, 8088); /* 绑定端口8088号端口*/
netconn_listen(conn); /* 进入监听模式*/
while (1)
{
err = netconn_accept(conn, &g_newconn); /* 接收连接请求*/
if (err == ERR_OK)
{
/* 初始化ATK-MC2640模块*/
lwip_camera_init();
/* 为JPEG缓存空间申请内存*/
jpeg_buf = mymalloc(SRAMIN, DEMO_JPEG_BUF_SIZE);
delay_ms(1000);
while (1)
{
jpeg_len = DEMO_JPEG_BUF_SIZE / (sizeof(uint32_t));
memset((void *)jpeg_buf, 0, DEMO_JPEG_BUF_SIZE);
/* 获取ATK-MC2640模块输出的一帧JPEG图像数据*/
atk_mc2640_get_frame((uint32_t)jpeg_buf,
ATK_MC2640_GET_TYPE_DTS_32B_INC, NULL);
/* 获取JPEG图像数据的长度*/
while (jpeg_len > 0)
{
if (jpeg_buf[jpeg_len - 1] != 0)
{
break;
}
jpeg_len--;
}
jpeg_len *= sizeof(uint32_t);
/* 发送JPEG图像数据*/
err = netconn_write(g_newconn, jpeg_buf, jpeg_len, NETCONN_COPY);
if ((err == ERR_CLSD) || (err == ERR_RST))
{
myfree(SRAMIN, (void *)jpeg_buf);
netconn_close(g_newconn);
netconn_delete(g_newconn);
break;
}
vTaskDelay(2); /* 延时2ms */
}
}
}
}
上面的代码还是比较简单的,首先开发板配置为TCP 服务器模式,配置完成且连接成功
之后调用函数atk_mc2640_get_frame 获取ATK-MC2640 模块输出的一帧JPEG 图像数据,同时,
调用netconn_write 函数把这一帧的图像数据传输至ATK-XCAM 上位机显示。
将ATK-MC2640 模块按照前面介绍的连接方式与开发板连接,同时将开发板与上位机通
讯的串口连接至PC,并将实验代码编译烧录至开发板中,如果DHCP 服务器分配完成,那么
串口调试助手显示如下信息:
图31.2.3.1 串口调试助手显示内容
接下来,如果ATK-MC2640 模块初始化成功,则会在上位机上显示ATK-MC2640 模块输
出的JPEG 图像,如下图所示:
图31.2.3.2 网络调试助手显示内容
对于嵌入式系统而言,内存管理始终是最重要的一环,内存管理的选择将从根本上决定内
存分配和回收效率,最终决定系统的性能。lwIP 为使用者提供了两种简单却又高效的内存管
理机制,它们分别为动态内存池管理和动态内存堆管理。
在lwIP 中内存分配策略有两种,一种是:动态内存池管理策略,另一种是:动态内存堆
管理策略,它们在lwIP 中起到以长补短的作用,lwIP 内核根据不同的场景而选择不同的分配
方式使系统的内存开销和分配效率大大的提高。说到内存分配,我们不得不想起C 语言也是
有提供内存分配,它是使用库中的malloc 和free 进行内存分配,当然lwIP 也是支持这种分配
方式的,但是lwIP 不建议使用C 标准库内存分配策略,主要原因笔者留到本章的4.5 小节来
讲解。
lwIP 的宏配置及内存管理
在lwIP 中内存的选择需要以下几个宏定义的值来决定,用户可以根据宏值来判断lwIP 使
用那种内存管理策略,如下表所示:
注:lwIP 内存堆管理策略和C 标准库管理策略只能选其一,若MEM_LIBC_MALLOC 为
0,则lwIP 内核选择内存堆管理策略。
动态内存堆也叫可变长分配方式,这种可变长的内存块分配在很多系统中被用到,系统本
身就是一个很大的内存堆,随着系统的运行,不断的申请和释放内存造成了系统的内存块的大
小和数量随之改变,严重一点可能造成内存碎片。lwIP 动态内存堆策略采用First Fit(首次拟
合)内存管理算法。该算法倾向于优先利用内存中低址部分的空闲分区,从而保留了高址部分
的大空闲区,这为以后到达的大作业分配大的内存空间创造了条件,但是缺点也是明显的,因
为首次拟合(First Fit)算法是从低地址不断被划分的,所以系统会留下许多难以利用的且很
小的空闲分区,我们称为内存碎片。每次申请内存时系统每次查找都是从低地址部分开始的,
这无疑又会增加查找可用空闲分区时的时间。
下面笔者分几个部分解析lwIP 内存堆算法的实现代码,该算法由mem.c 和mem.h 文件组
成,其中mem.c 尤为重要,它实现了lwIP 内存堆的分配和释放原理。
(1) 内存堆的结构体
管理内存块的结构体,如下源码所示:
struct mem {
mem_size_t next; /* 保存下一个内存块的索引*/
mem_size_t prev; /* 保存前一个内存块的索引*/
u8_t used; /* 此内存快是否被用。1使用、0 未使用*/
};
可以看出,这个结构体只定义了三个成员变量,其中next、prev 变量用来保存下一个和前
一个内存块的索引,而used 变量用来声明被管理的内存块是否可用。
(2) 内存堆的对齐及最小配置值
#ifndef MIN_SIZE
#define MIN_SIZE 12
#endif /* MIN_SIZE */
/* 最小大小做对齐处理,后面均用对齐后的该宏值*/
#define MIN_SIZE_ALIGNED LWIP_MEM_ALIGN_SIZE(MIN_SIZE)
/* 内存块头大小做对齐处理,后面均用对齐后的该宏值*/
#define SIZEOF_STRUCT_MEM LWIP_MEM_ALIGN_SIZE(sizeof(struct mem))
/* 用户定义的堆大小做对齐处理,后面均用对齐后的该宏值*/
#define MEM_SIZE_ALIGNED LWIP_MEM_ALIGN_SIZE(MEM_SIZE)
lwIP 内核为了有效防止内存碎片,它定义了最小分配大小MIN_SIZE,若用户申请的内存
小于最小分配内存,则系统分配MIN_SIZE 大小的内存资源。往下的宏定义是对内存大小进
行4 字节对齐。注:内存对齐的作用:1,平台原因:不是全部的硬件平台都能访问随意地址
上的随意类型数据的;某些硬件平台仅仅能在某些地址处取某些特定类型的数据,否则抛出硬
件异常。2,性能原因:经过内存对齐后,CPU 的内存访问速度大大提升。
(3) 定义内存堆的空间
#ifndef LWIP_RAM_HEAP_POINTER
/*定义堆内存空间*/
LWIP_DECLARE_MEMORY_ALIGNED(ram_heap, MEM_SIZE_ALIGNED + (2U * SIZEOF_STRUCT_MEM));
#define LWIP_RAM_HEAP_POINTER ram_heap
#endif
无论是内存堆还是内存池,它们都是对一个大数组进行操作,上述的宏定义就是指向一个
名为ram_heap 数组,该数组的大小为MEM_SIZE_ALIGNED + (2U*SIZEOF_STRUCT_MEM),
lwIP 内存堆申请的内存就是从这个数组分配得来的。
(4) 操作内存堆的变量
/* 指向对齐后的内存堆的地址*/
static u8_t *ram;
/* 指向对齐后的内存堆的最后一个内存块*/
static struct mem *ram_end;
/* 指向已被释放的索引号最小的内存块(内存堆最前面的已被释放的)*/
static struct mem *LWIP_MEM_LFREE_VOLATILE lfree;
ram_heap 数组就是lwIP 定义的内存堆总空间,如何从这个总空间申请合适大小的内存,
就是利用上述源码的三个指针,ram 指针指向对齐后的内存堆总空间首地址,ram_end 指针指向内存堆总空间尾地址(接近总空间的尾地址),而lfree 指针指向最低内存地址的空闲内存块。
注:lwIP 内核就是根据lfree 指针指向空闲内存块来分配内存,而ram_end 指针用来检测该总
内存堆空间是否有空闲的内存。
(5) 内存堆的初始化
结合以上的(1)~(4)的内容,我们来看一下lwIP 动态内存堆是如何实现的,如下源码所示:
void mem_init(void)
{
struct mem *mem;
/* 对内存堆的地址(全局变量的名)进行对齐指向ram_heap。*/
ram = (u8_t *)LWIP_MEM_ALIGN(LWIP_RAM_HEAP_POINTER);
/* 建立第一个内存块,内存块由内存块头+空间组成。*/
mem = (struct mem *)(void *)ram;
/* 下一个内存块不存在,因此指向内存堆的结束*/
mem->next = MEM_SIZE_ALIGNED;
/* 前一个内存块就是它自己,因为这是第一个内存块*/
mem->prev = 0;
/* 第一个内存块没有被使用*/
mem->used = 0;
/* 初始化堆的末端(指向MEM_SIZE_ALIGNED底部位置)*/
ram_end = ptr_to_mem(MEM_SIZE_ALIGNED);
/* 最后一个内存块被使用。因为其后面没有可用空间,必须标记为已被使用*/
ram_end->used = 1;
/* 下一个不存在,因此指向内存堆的结束*/
ram_end->next = MEM_SIZE_ALIGNED;
/* 前一个不存在,因此指向内存堆的结束*/
ram_end->prev = MEM_SIZE_ALIGNED;
/* 已释放的索引最小的内存块就是上面建立的第一个内存块。*/
lfree = (struct mem *)(void *)ram;
/* 这里建立一个互斥信号量,主要是用来进行内存的申请、释放的保护*/
if (sys_mutex_new(&mem_mutex) != ERR_OK)
{
}
}
上述源码就是对堆空间初始化,一开始lfree 指针指向第一个内存块,该内存块有两个部
分组成,一个是控制块(struct mem 大小,标志管理的内存是否可用),另一个是可用内存。
ram_end 指针指向尾内存块,它用来标志这个堆空间是否有可用内存,若lfree 指针指向
ram_end 指针,则该堆空间没有可用内存分配,由此可以看出,lfree 指针从堆空间低地址不断查找和划分内存,最终在ram_end 指针指向的地址结束分配。内存堆初始化示意图如下所示:
注:struct mem 结构体的next 和prev 变量并不是指针类型,它们保存的是内存块的索引,
例如定义一个a[10]数组,next 和prev 保存的是0~9 的索引号,lwIP 内核根据索引号获取a 数
组的索引地址(&a[0~9])。
2. mem_malloc 函数
void *
mem_malloc(mem_size_t size_in)
{
mem_size_t ptr, ptr2, size;
struct mem *mem, *mem2;
/*******第一:检测用户申请的内存块释放满足LWIP的规则*******/
/*******第二:从内存堆中划分用户的内存块******/
/* 寻找足够大的空闲块,从最低的空闲块开始.*/
for (ptr = mem_to_ptr(lfree); ptr < MEM_SIZE_ALIGNED - size;
ptr = ((struct mem *)(void *)&ram[ptr])->next)
{
mem = ptr_to_mem(ptr); /* 取它的地址*/
/* 空间大小必须排除内存块头大小*/
if ((!mem->used) &&
(mem->next - (ptr + SIZEOF_STRUCT_MEM)) >= size)
{
/* 这个地方需要判断剩余的内存块是否可以申请size内存块*/
if (mem->next - (ptr + SIZEOF_STRUCT_MEM) >= (size + SIZEOF_STRUCT_MEM +
MIN_SIZE_ALIGNED))
{
/* 上面注释一大堆,主要就是说,
剩余内存可能连一个内存块的头都放不下了,
这个时候就没法新建空内存块。其索引也就不能移动*/
/* 指向申请后的位置,即:
建立下一个未使用的内存块的头部。
即:插入一个新空内存块*/
ptr2 = (mem_size_t)(ptr + SIZEOF_STRUCT_MEM + size);
/*从Ptr2地址开始创建mem2的结构体*/
mem2 = ptr_to_mem(ptr2); /* 调用(struct mem *)(void *)&ram[ptr]; */
mem2->used = 0;
/* 这个根据下面的if(mem2->next != MEM_SIZE_ALIGNED)判定*/
mem2->next = mem->next;
mem2->prev = ptr; /* 空闲内存块的前一个指向上面分配的内存块*/
/* 前一个内存块指向上面建立的空闲内存块*/
mem->next = ptr2;
mem->used = 1; /* 将当前分配的内存块标记为已使用*/
/* 如果mem2内存块的下一个内存块不是链表中最后一个内存块(结束地址),
那就将它下一个的内存块的prve指向mem2 */
if (mem2->next != MEM_SIZE_ALIGNED)
{
((struct mem *)(void *)&ram[mem2->next])->prev = ptr2;
}
}
else
{ /* 内存块太小了会产生的碎片*/
mem->used = 1;
}
/* 这里处理:当分配出去的内存正好是lfree时,
因为该内存块已经被分配出去了,
必须修改lfree的指向下一个最其前面的已释放的内存块*/
if (mem == lfree)
{
struct mem *cur = lfree;
/* 只要内存块已使用且没到结尾,则继续往后找*/
while (cur->used && cur != ram_end)
{
cur = ptr_to_mem(cur->next); /* 下一个内存块*/
}
/* 指向找到的第一个已释放的内存块。如果上面没有找到,则lfree = lfree不变*/
lfree = cur;
}
/* 这里返回内存块的空间的地址,排除内存块的头*/
return (u8_t *)mem + SIZEOF_STRUCT_MEM + MEM_SANITY_OFFSET;
}
}
return NULL;
}
}
从上述源码可以看出,lwIP 内存堆申请的内存是从低地址往高地址方向查找合适的内存
块,每一个内存块由两个部分组成,一个是(struct mem)大小的内存块,它用来描述和管理
可用的内存块,另一个是可用内存块,用户可直接操作它。根据上图4.2.1 图解可以看出,
lfree 指针指向的是未被使用的控制块,若用户申请size 大小的内存,则lwIP 内核会把lfree 指
针指向的控制块标志为已用内存,并且往高地址偏移(struct mem)结构体+对齐后的size 大
小,偏移完成之后lfree 指针指向的地址附加一个struct mem 结构体(下一个控制块)。注:下
一个控制块被标志为未使用即used=0,至此我们可以得到以下示意图。
3. mem_free 函数
void mem_free(void *rmem)
{
struct mem *mem;
/* 第一步:检查内存块的参数*/
/* 判断释放的内存块释放为空*/
if (rmem == NULL)
{
return; /* 为空则返回*/
}
/* 除去指针就剩下内存块了,通过mem_malloc的到的地址是不含struct mem 的*/ * /
mem = (struct mem *)(void *)((u8_t *)rmem - (SIZEOF_STRUCT_MEM +
MEM_SANITY_OFFSET));
/* 第二步:查找指定的内存块,标记为未使用*/
mem->used = 0;
/* 第三步:需要移动全局的释放指针,因为lfree始终指向内存堆中最小索引的
那个已经释放的内存块*/
if (mem < lfree)
{
/* 新释放的结构现在是最低的*/
lfree = mem;
}
}
lwIP 内存堆释放内存是非常简单的,它一共分为三个步骤,第一、检测传入的地址是否
正确,第二、对这个地址进行偏移,偏移大小为struct mem,这样可以得到释放内存的控制块
首地址,并且设置该控制块为未使用标志,第三、判断该控制块的地址是否小于lfree 指针指
向的地址,若小于,则证明mem 的内存块在lfree 指向的内存块之前即更接近堆空间首地址,
系统会把lfree 指针指向这个释放的内存块(控制块+ 可用内存),以后申请内存时会在lfree
指针的内存块开始查找合适的内存。注:申请内存时lwIP 内核会从lfree 指针指向的内存块开
始查找,若该内存块不满足申请要求,则lwIP 内核根据这个内存块的next 变量保存的数值作
为下一跳查询的地址。
若申请内存时lfree 指针指向的内存块不满足申请需求,则该内存块的next 数值作为下一
跳查询的索引。注:lfree 指针永远指向最低地址的内存空间。
在内存池初始化时候,系统会将可用的内存块划分为N 个固定大小的内存,这些内存块
通过单链表的方式连接起来,在用户申请内存块时,直接从单链表的头部取出一个内存块进行
分配,释放内存块时也是挺简单的,只要将内存块释放到链表的头部即可,虽然这样的分配很
高效,但是有很明显的缺点,如浪费资源等。
lwIP 内存池的实现是受制于两个宏值MEMP_MEM_MALLOC 和MEM_USE_POOLS 的限
制,在该动态内存池的源码文件中,仍然到处可见这两个宏值。
1,IP 内存池的应用场景
lwIP 存在很多固定的数据结构,这些结构的特点就是在使用之前就已经知道了数据结构
的大小,而且这些数据结构在使用的过程中不会发生大小改变的。比如在建立一个TCP 连接
的时候,lwIP 需要使用一种叫做TCP 控制块的数据结构,这种数据结构大小是固定的,所以
为了满足这些数据类型分配的需要,在内存初始化的时候就建立了一定数量的动态内存池
POOL。
2,IP 内存池实现的文件
对于内存堆来说,动态内存池分配还是挺麻烦的,主要就是对于宏的巧妙运用,现在笔者
就以文件的形式讲解动态内存池分配的原理。动态内存池分配在这四个文件memp.c、memp.h、
memp_std.h 和memp_prive.h 有所介绍,下面笔者分别地讲解这四个文件的作用。
(1) memp_std.h 文件
该文件定义了lwIP 内核所需的内存池,由于lwIP 内核的固定数据结构多种多样,所以它
们使用宏定义声明是否使用该类型的内存池,如TCP、UDP、DHCP、ICMP 等协议。这些宏
定义一般在lwippools.h 文件中声明启用。该文件的源码如下所示:
#if LWIP_RAW
LWIP_MEMPOOL(RAW_PCB, MEMP_NUM_RAW_PCB, sizeof(struct raw_pcb), "RAW_PCB")
#endif /* LWIP_RAW */
#if LWIP_UDP
LWIP_MEMPOOL(UDP_PCB, MEMP_NUM_UDP_PCB, sizeof(struct udp_pcb), "UDP_PCB")
#endif /* LWIP_UDP */
#if LWIP_TCP
LWIP_MEMPOOL(TCP_PCB, MEMP_NUM_TCP_PCB, sizeof(struct tcp_pcb), "TCP_PCB")
LWIP_MEMPOOL(TCP_PCB_LISTEN, MEMP_NUM_TCP_PCB_LISTEN,
sizeof(struct tcp_pcb_listen), "TCP_PCB_LISTEN")
LWIP_MEMPOOL(TCP_SEG, MEMP_NUM_TCP_SEG, sizeof(struct tcp_seg), "TCP_SEG")
#endif /* LWIP_TCP */
/* …………………………………………………………………………………忽略以下源码……………………………………………………………………………………… */
从上述源码可以看出两个重点内容,第一点:不同类型的内存池是由相应的宏定义声明启
用,第二点:LWIP_MEMPOOL 宏定义用来初始化各类型的内存池。
(2) memp_priv.h 文件
/* 管理内存块*/
struct memp
{
struct memp *next;
};
/* 管理和描述各类型的内存池*/
struct memp_desc
{
/** 每个内存块的大小*/
u16_t size;
/** 内存块的数量*/
u16_t num;
/** 指向内存的基地址*/
u8_t *base;
/** 每个池的第一个空闲元素。元素形成一个链表*/
struct memp **tab;
};
这个文件主要定义了两个结构体,它们分别为memp 和memp_desc 结构体,其中memp
结构体是把同一类型的内存池以链表的形式链接起来,而memp_desc 结构体是用来管理和描
述各类型的内存池,如数量、大小、内存池的起始地址和指向空闲内存池的指针。memp 和
memp_desc 结构体的关系如下图所示:
从上图可以看出,每一个描述符都是用来管理同一类型的内存池,而这些内存池即内存块
是以链表的形式链接起来。
(3) memp.h 文件
在memp.h 文件中,笔者重点讲解memp_t 枚举类型以及LWIP_MEMPOOL_DECLARE 宏
定义,它们的作用如下所示:
typedef enum
{
/* ##为C语言的连接符,例如MEMP_##A,A = NAME ,所以等于MEMP_NAME */
#define LWIP_MEMPOOL(name, num, size, desc) MEMP_##name,
#include "lwip/priv/memp_std.h"
MEMP_MAX
} memp_t;
#include "lwip/priv/memp_priv.h" /* 该文件需要使用上面的枚举*/
#include "lwip/stats.h"
该文件最主要的是memp_t 枚举类型,它主要获取各类内存池的数量,这里用到宏的巧妙
运用,根据memp_std.h 文件启用的内存池来计算各类内存池的数量MEMP_MAX。如何计算?
请看下面内容:
1,LWIP_MEMPOOL 宏定义指向MEMP_##name(##是C 语言的连接符)
2,根据#include "lwip/priv/memp_std.h 文件启用了哪些类型内存池。
如果memp_std.h 文件只启用了LWIP_RAW 和LWIP_UDP 类型的内存池,那么
MEMP_MAX 变量就等于2。这个枚举类型展开之后如下源码所示:
typedef enum {
MEMP_RAW_PCB,
MEMP_UDP_PCB,
MEMP_MAX
} memp_t;
根据枚举类型的特性,MEMP_RAW_PCB 为0,MEMP_UDP_PCB 为1,由此类推。
注:memp.h 文件最主要的任务是计算各类的内存池,最后得出MEMP_MAX 数值。
#define LWIP_MEMPOOL_DECLARE(name, num, size, desc) \
LWIP_DECLARE_MEMORY_ALIGNED(memp_memory_ ## name ## _base,
((num) * (MEMP_SIZE + MEMP_ALIGN_SIZE(size))));
LWIP_MEMPOOL_DECLARE_STATS_INSTANCE(memp_stats_##name)
static struct memp *memp_tab_##name;
const struct memp_desc memp_##name = {
DECLARE_LWIP_MEMPOOL_DESC(desc)
LWIP_MEMPOOL_DECLARE_STATS_REFERENCE(memp_stats_##name)
LWIP_MEM_ALIGN_SIZE(size),
(num),
memp_memory_##name##_base,
&memp_tab_##name}; \
};
此宏定义非常重要,各类型的内存池都使用这个宏定义声明,例如内存池的内存由来,各
类型内存池的数量、大小、内存由来的地址以及指向空闲的指针。这个宏定义展开后如下源码
所示:
#define LWIP_MEMPOOL_DECLARE(name,num,size,desc) \
u8_t memp_memory_ ## name ## _base[((((((num) * (MEMP_SIZE + (((size) +
MEM_ALIGNMENT - 1U) & ~(MEM_ALIGNMENT-1U))))) + MEM_ALIGNMENT - 1U)))];\
static struct memp *memp_tab_ ## name;\
const struct memp_desc memp_ ## name = { \
LWIP_MEM_ALIGN_SIZE(size), \
(num), \
memp_memory_ ## name ## _base, \
&memp_tab_ ## name \
};
展开之后可以看出,各类型的内存池的内存由来和lwIP 内存堆一样,都是由数组分配的。
这个宏定义的使用笔者会在memp.c 文件中讲解。
(4) memp.c 文件
在讲解函数之前,我们必须知道LWIP_MEMPOOL 和const memp_pools[MEMP_MAX]这
两部分的内容,其中LWIP_MEMPOOL 指向LWIP_MEMPOOL_DECLARE 宏定义,该宏定义
笔者已经在memp.h 文件展开过,稍后重点讲解,而const memp_pools[MEMP_MAX]数组是用
来管理各类型的内存池描述符。下面笔者分别地讲解这两部分的内容,如下所示:
#define LWIP_MEMPOOL(name,num,size,desc)
LWIP_MEMPOOL_DECLARE(name,num,size,desc)
#include "lwip/priv/memp_std.h"
这里也是一样,对宏的巧妙运用,例如memp_std.h 只启用LWIP_RAW 和LWIP_UDP 类
型的内存池,展开之后如下所示:
u8_t memp_memory_RAW_PCB_base[((((((num) * (MEMP_SIZE +
(((size) + MEM_ALIGNMENT - 1U) & ~(MEM_ALIGNMENT - 1U))))) +
MEM_ALIGNMENT - 1U)))];
static struct memp *memp_tab_RAW_PCB;
const struct memp_desc memp_RAW_PCB = {
LWIP_MEM_ALIGN_SIZE(size),
(num),
memp_memory_TCPIP_MSG_API_base,
&memp_tab_TCPIP_MSG_API};
u8_t memp_memory_UDP_PCB_base[((((((num) * (MEMP_SIZE +
(((size) + MEM_ALIGNMENT - 1U) & ~(MEM_ALIGNMENT - 1U))))) +
MEM_ALIGNMENT - 1U)))];
static struct memp *memp_tab_UDP_PCB;
const struct memp_desc memp_UDP_PCB = {
LWIP_MEM_ALIGN_SIZE(size),
(num),
memp_memory_UDP_PCB_base,
&memp_tab_UDP_PCB};\
};
LWIP_MEMPOOL_DECLARE 宏定义展开流程笔者已经上面讲解过,这里无需重复讲解。
总的来说,这两段代码声明了各类内存池描述和管理信息,例如memp_desc memp_
RAW_PCB 结构体,它描述了该类型的内存池的数量、大小、分配内存地址以及指向空闲内存
池的指针。
const struct memp_desc *const memp_pools[MEMP_MAX] = {
#define LWIP_MEMPOOL(name, num, size, desc) &memp_##name,
#include "lwip/priv/memp_std.h"
};
这一个数组的大小就是由MEMP_MAX 变量声明,这个变量无需讲解,请看上面的内容。
若memp_std.h 只启用LWIP_RAW 和LWIP_UDP 类型的内存池,则这个数组展开之后如下所示:
const struct memp_desc* const memp_pools[MEMP_MAX] = {
&memp_memp_RAW_PCB,
&memp_memp_UDP_PCB,
};
数组的第一个元素取memp_memp_RAW_PCB 地址,它就是我们前面展开之后的
memp_RAW_PCB 变量。
memp_init 函数和memp_init_pool 函数
该函数是内存池的初始化,该函数如下所示:
void memp_init(void)
{
u16_t i;
/* 遍历,需要多少个内存池*/
for (i = 0; i < LWIP_ARRAYSIZE(memp_pools); i++)
{
memp_init_pool(memp_pools[i]);
}
}
void memp_init_pool(const struct memp_desc *desc)
{
int i;
struct memp *memp;
*desc->tab = NULL;
/* 内存对齐*/
memp = (struct memp *)LWIP_MEM_ALIGN(desc->base);
/* 将内存块链接成链表形式*/
for (i = 0; i < desc->num; ++i)
{
memp->next = *desc->tab;
*desc->tab = memp;
/* 地址偏移*/
memp = (struct memp *)(void *)((u8_t *)memp +
MEMP_SIZE + desc->size);
}
}
从上述源码可以看出,每一个类型的描述符都是用来管理和描述该类型的内存池,这些同
一类型的内存池里面包含了指向下一个节点的指针,根据第二个for 循环语句让这些同一类型
的内存池以链表的形式链接起来,最后不断的循环,我们可以得到以下示意图:
从上图可知,memp_pool 数组包含了多个类型的内存池描述符,这些描述符管理同一类型
的内存池,这些内存池以链表的形式链接起来,最后形成一个单向链表。注:同一类型的内存
池都是在同一个数组分配得来,而base 指针指向该数组的首地址,tab 指针指向第一个空闲的
内存池,若用户向申请一个内存池,则从tab 指针指向的内存池分配,分配完成之后tab 指针
偏移至下一个空闲内存池的地址。
memp_malloc 函数和memp_malloc_pool 函数
前面讲解到,内存池具有多种类型的,所以用户申请内存池时,必须知道申请内存池的类
型是哪个?lwIP 内存池申请函数为memp_malloc,该函数如下所示:
void *
memp_malloc(memp_t type)
{
void *memp;
memp = do_memp_malloc_pool(memp_pools[type]);
return memp;
}
static void *
do_memp_malloc_pool(const struct memp_desc *desc)
{
struct memp *memp;
memp = *desc->tab;
if (memp != NULL)
{
*desc->tab = memp->next;
return ((u8_t *)memp + MEMP_SIZE);
}
else
{
}
return NULL;
}
memp_malloc 函数需要传入申请内存池的类型,如UDP_PCB…,接着根据传入的类型来
查找对应的内存池描述符,查找完成之后根据该内存池描述符的tab 指针指向内存池分配给用
户,并且把tab 指针偏移至下一个空闲内存池。分配流程如下图所示:
memp_free 函数与memp_free_pool 函数
内存池释放函数非常简单,它需要传入两个形参,第一个是释放内存池的类型,第二个是
释放内存池的地址。lwIP 内核根据这两个形参就可以知道该类型的内存池描述符位置和该类
型内存池描述符的哪个内存池需要释放。内存池释放函数如下所示:
void memp_free(memp_t type, void *mem)
{
if (mem == NULL) /* 判断内存块的起始地址释放为空*/
{
return;
}
do_memp_free_pool(memp_pools[type], mem);
}
static void do_memp_free_pool(const struct memp_desc *desc, void *mem)
{
struct memp *memp;
/* 据内存块的地址偏移得到内存块的起始地址*/
memp = (struct memp *)(void *)((u8_t *)mem - MEMP_SIZE);
/* 内存块的下一个就是链表中的第一个空闲内存块*/
memp->next = *desc->tab;
/* *desc->tab指向memp内存块中*/
*desc->tab = memp;
}
释放函数非常简单,只需对内存池描述符的tab 指针偏移至释放的内存池。释放流程如下
图所示:
lwIP 内核是可以支持C 标准库管理策略,它与lwIP 内存堆管理策略二者只能选其一。打
开mem.c 文件找到MEM_LIBC_MALLOC 配置项如下源码所示:
/* in case C library malloc() needs extra protection,
* allow these defines to be overridden.
*/
#ifndef mem_clib_free
#define mem_clib_free free
#endif
#ifndef mem_clib_malloc
#define mem_clib_malloc malloc
#endif
#ifndef mem_clib_calloc
#define mem_clib_calloc calloc
#endif
#if LWIP_STATS && MEM_STATS
#define MEM_LIBC_STATSHELPER_SIZE LWIP_MEM_ALIGN_SIZE(sizeof(mem_size_t))
#else
#define MEM_LIBC_STATSHELPER_SIZE 0
#endif
上述的free、malloc 以及calloc 就是C 库中的内存管理函数。注:C 标准库内存管理不能
与相邻的空闲内存块合并,且容易造成内存碎片。
lwIP 支持多网口设计,它是使用netif 来描述每种网络接口的特性:如IP 地址、接口状态
等。为了实现对所有网络接口的有效管理,协议栈内部使用了一个名为netif 的网络接口结构
来描述各种网络设备,如果项目中使用多个网卡,那么lwIP 是如何管理这些网卡的呢?这里
笔者不得不提一下链表netif_list,该链表就是管理多个netif 网络接口的,当上层应用有数据
要发送的时候,lwIP 会从netif_list 链表中选择一个合适的网卡来将数据发送出去。
总所周知,网卡的种类多种多样的,对于lwIP 来说,它是怎么样兼容众多网卡的呢?
lwIP 有一个数据结构—netif 来描述一个网卡,因为网卡是与硬件相关的,不同的硬件处理的
方式也是不同的,所以lwIP 提供了统一接口函数来管理这些网卡。由于网卡的种类繁多,所
以各个网卡的底层函数需要用户来完成,例如网卡的初始化、网卡的接收发数据等函数,同样
lwIP 底层得到网络数据时,需要层层递交才会传入内核处理,相反lwIP 发送数据也是调用网
卡发送函数。对于没有接触lwIP 的学员来说,我们该怎么样写底层驱动呢?lwIP 还是做的挺
好的,它已经提供了一个ethernetif.c 文件,该文件是底层接口的驱动模版,用户根据自己的网
络设备参照修改即可。
下面笔者来讲解netif 的数据结构,该数据结构是在netif.h 文件中定义的,该结构如下所
示:
struct netif
{
/* 指向下一个netif结构的指针*/
struct netif *next;
/* IP地址相关配置*/
ip_addr_t ip_addr; /* 网络接口的IP 地址*/
ip_addr_t netmask; /* 子网掩码*/
ip_addr_t gw; /* 网关地址*/
/* 该函数向IP 层输入数据包*/
netif_input_fn input;
/* 该函数发送IP 包*/
netif_output_fn output;
/* 该函数实现底层数据包发送*/
netif_linkoutput_fn linkoutput;
/* 该字段用户可以自由设置,例如用于指向一些底层设备相关的信息*/
void *state;
void *client_data[LWIP_NETIF_CLIENT_DATA_INDEX_MAX + LWIP_NUM_NETIF_CLIENT_DATA];
/* 该接口允许的最大数据包长度*/
u16_t mtu;
/* 该接口物理地址长度*/
u8_t hwaddr_len;
/* 该接口的物理地址*/
u8_t hwaddr[NETIF_MAX_HWADDR_LEN];
/* 该接口的状态、属性字段*/
u8_t flags;
/* 该接口的名字*/
char name[2];
/* 接口的编号*/
u8_t num;
/* 需要发送的路由器请求消息的数量*/
u8_t rs_count;
};
该结构体包含了多个字段,这些字段的作用如下:
(1) next: 该字段指向下一个neitif 类型的结构体,因为lwIP 可以支持多个网络接口,当设
备有多个网络接口的话lwIP 就会把所有的netif 结构体组成链表来管理这些网络接口。
(2) ipaddr,netmask 和gw:分别为网络接口的IP 地址、子网掩码和默认网关。
(3) input:此字段为一个函数,这个函数将网卡接收到的数据交给IP 层。
(4) output:此字段为一个函数,当IP 层向接口发送一个数据包时调用此函数。这个函数
通常首先解析硬件地址,然后发送数据包。此字段我们一般使用etharp.c 中的
etharp_output()函数。
(5) linkoutput:此字段为一个函数,该函数被ARP 模块调用,完成网络数据的发送。上
面说的etharp_output 函数将IP 数据包封装成以太网数据帧以后就会调用linkoutput 函
数将数据发送出去。
(6) state:用来定义一些关于接口的信息,用户可以自行设置。
(7) mtu:网络接口所能传输的最大数据长度,一般设置为1500。
(8) hwaddr_len:网卡MAC 地址长度,6 个字节。
(9) hwaddr:MAC 地址。
(10) flags:网络的接口状态,属性信息字段。
(11) name:网卡的名字。
(12) num:编号从0 开始,此字段为协议栈为每个网络接口设置的一个编号。
(13) rs_count:发送的路由器请求消息的数量。
这些字段就是用来描述各个网卡的差异,每一个网卡都使用一个netif 结构体来抽象,多
个网卡就有多个netif,这些netif 以链表的形式链接起来,形参一个单向的链表。
这些netif 链表的首个节点由netif_list 指针指向,lwIP 内核就是使用netif_list 指针对netif
链表进行遍历查询。管理和描述netif 链表由三个全局变量,这些变量如下所示:
struct netif *netif_list; /* 网络接口链表指针*/
struct netif *netif_default; /* 哪个网络接口(多网口时候) */
static u8_t netif_num; /* 为网口分配唯一标识*/
netif_default 指针指向netif 链表的默认网卡,如网络层下发一个数据包时,系统优先选择
netif_default 指针指向的网卡发送数据,如该网卡没有响应,则选择其他的网卡发送。
netif_num 描述网卡的数量。下面笔者重点讲解netif.c 重要的几个函数,这些函数如下所示:
(1) netif_add 函数
该函数是把新创建的netif 插入到netiflist 队列当中,以表示添加一个网络接口,该函数如
下所示:
struct netif *
netif_add(struct netif *netif,
const ip4_addr_t *ipaddr, const ip4_addr_t *netmask,
const ip4_addr_t *gw,
void *state, netif_init_fn init, netif_input_fn input)
{
/* 清空主机IP 地址、子网掩码、网关等信息。*/
ip_addr_set_zero_ip4(&netif->ip_addr);
ip_addr_set_zero_ip4(&netif->netmask);
ip_addr_set_zero_ip4(&netif->gw);
netif->output = netif_null_output_ip4;
/* 传输的最大数据长度*/
netif->mtu = 0;
/* 网络的接口状态*/
netif->flags = 0;
memset(netif->client_data, 0, sizeof(netif->client_data));
/* 传递进来的参数填写网卡state、input等字段的相关信息*/
netif->state = state;
/* 并为当前网卡分配唯一标识num */
netif->num = netif_num;
/* 网卡输入*/
netif->input = input;
/* 调用网卡设置函数netif_set_addr()设置网卡IP 地址、子网掩码、网关*/
netif_set_addr(netif, ipaddr, netmask, gw);
/* 为netif调用用户指定的初始化函数*/
if (init(netif) != ERR_OK)
{
return NULL;
}
/* 将这个netif添加到列表中*/
netif->next = netif_list;
netif_list = netif;
mib2_netif_added(netif);
netif_invoke_ext_callback(netif, LWIP_NSC_NETIF_ADDED, NULL);
return netif;
}
从上述源码可以看出,每一个netif 结构体就是对一个网卡进行抽象,例如该网卡的收发
函数、状态等信息。根据上述函数的运行流程,可得到以下示意图:
①只有一个网络接口
②两个网络接口
注:新插入的netif 结构体是在netiflist 队列的首部插入。
(2) netif_set_default 函数
该函数就是设置某一个netif 结构体为默认的网卡,lwIP 内核优先对这个网卡操作,该函
数如下所示:
void netif_set_default(struct netif *netif)
{
if (netif == NULL)
{
/* 删除默认路由*/
mib2_remove_route_ip4(1, netif);
}
else
{
/* 添加默认路由*/
mib2_add_route_ip4(1, netif);
}
netif_default = netif; /* 选择那个网络接口*/
}
/*********************怎么使用函数netif_set_default()*********************/
/* 通过该函数,将网络接口添加到链表中*/
netif_add(&xnetif, &ipaddr, &netmask, &gw, NULL, ðernetif_init, &tcpip_input);
/* 注册默认的网络接口*/
netif_set_default(&xnetif);
方法很简单,就是让netif_default 指针指向默认的网卡。
TCP/IP 协议本质上就是对数据包的处理过程,lwIP 作者为了提高对数据包的处理工作效
率,它提供一种高效的数据包管理机制,使得各层之间对数据包灵活操作,同时避免在各层之
间的复制数据的巨大开销和减少各层间的传递时间。在linux 的BSD 协议中,它描述数据包的
结构体叫做mbuf,而lwIP 与它类似的结构叫做pbuf,pbuf 数据包的种类和大小也可以说是多
种多样的,从网卡读取出来的数据包可以是一千个字节也可以是几个字节的IP 数据报,这些
数据包可能存在于RAM 和ROM 中,这个根据用户来决定的,所以lwIP 为了处理的数据高效,
它需要把这些数据进行统一的管理。
我们知道传统的TCP/IP 协议各层之间是独立存在的,每一层只处理该层的数据,它们绝
对不允许越界读写数据,如果lwIP 按照这种严格的分层模式来实现TCP/IP 协议,会使数据包
在各层间的递交变得非常慢,它涉及到一系列的内存拷贝的问题,所以系统总体性能也会受到
影响。因此,lwIP 内部并没有采用完整的分层结构,它会假设各层间的部分数据结构和实现
原理在其他层是可见的,这样在数据包递交过程中,各层协议可以直接对数据包中属于其他层
次协议的字段进行操作。
从上述可以看出,lwIP 的优点有以下几个:
①不需要数据层层拷贝。
②用户程序可以直接访问内部各层数据包。
③各个层次之间存在交叉存取数据的现象,既节省系统的空间也节省处理的时间,而且
更加灵活。
④lwIP 的内存共享机制,使得应用程序能直接对协议栈内核的内存区域直接操作,减少
时间和空间的损耗。
在操作系统中,任务的创建与任务管理是常见的东西,如果把协议栈的各层变成独立的任
务或者线程,那么会导致各层之间是严格分层的,在这种模式下,能够使编程简便、代码组织
灵活,但是缺点也是很明显的,例如数据递交时需要进行拷贝和切换任务,任务或者线程频繁
切换可能对用户程序不能够准时的处理,一个数据包在各个层次间的递交至少需要进行3 次切
换任务,如应用层发送数据时,需要切换到传输层任务处理,当传输层处理完毕之后会把数据
报递交给网络层,由此类推,导致任务频繁切换使得协议栈的效率低下。
还有一种方法就是协议栈与操作系统结合,相当于把协议栈成为操作系统的一部分,这样
用户任务与协议栈之间通过操作系统的API 函数实现,虽然提高了效率,各层也可以交叉存
取,但是协议栈与操作系统融合会导致很严重的后果,总所周知,操作系统最大的优势是实时
性高,能准确的运行相关的线程,如果协议栈成为了操作系统的一部分,那么协议栈处理的数
据包过慢的话,会导致操作系统的实时性变低。
lwIP 采用了另一种方式,让协议栈与操作系统相互隔离,这样不会影响操作系统的实时性,协议栈只作为操作系统的一个独立的任务,这样我们可以得出两个方法,第一种方法就是
让用户程序驻留在协议栈任务里,协议栈通过回调函数实验用户与协议栈之间的数据交互,这
个也是lwIP 所说的RAW API 编程。第二种方法就是用户程序可以作为操作系统的独立任务,
用户任务与协议栈任务之间的通信通多IPC 通信机制交互,这种在lwIP 叫做NETCONN API
和Socket API 编程。
lwIP 使用pbuf 对数据进行发送与接收,灵活的pbuf 结构体使得数据在不同层之间传输时
可以减少内存的开销以及减少内存复制所占用的时间,一切都是为了节约内存,提高数据在不
同层之间传递的速度。lwIP 源码中的pbuf.c 和pbuf.h 这两个文件就是关于pbuf 的,pbuf 结构
如下源码所示:
struct pbuf
{
/* pbuf链表中指向下一个pbuf结构*/
struct pbuf *next;
/* 数据指针,指向该pbuf所记录的数据区域*/
void *payload;
/* 当前pbuf及后续所有pbuf中所包含的数据总长度*/
u16_t tot_len;
/* 当前pbuf中数据的长度*/
u16_t len;
/* 当前pbuf的类型*/
u8_t type;
/* 状态位未用到*/
u8_t flags;
/* 指向该pbuf的指针数,即该pbuf被引用的次数*/
LWIP_PBUF_REF_T ref;
/* 对于传入的数据包,它包含输入netif的索引*/
u8_t if_idx;
};
pbuf 结构体具有多个字段,这些字段的作用如下所示:
从表可以看出,pbuf 具有四个类型,它们的数据存储在不同的区域,下面笔者重点讲解
着四个类型的pbuf。
(1) PBUF_RAM 类型
PBUF_RAM 是lwIP 用的最多的一种类型,pbuf 空间大小是通过内存堆来分配的,一般协
议栈中要发送的数据都是采用这种形式,这个类型也是常用的类型之一,申请PBUF_RAM 类
型的pbuf 时协议栈会在内存堆中分配相应空间,这里的大小包括如前面所述的pbuf 结构和相
应数据缓冲区的大小,并且它们是在一片连续的存储空间。分配完成后的结构如下图所示:
注:payload 指向并不一定是数据区域的首地址,可以设定一定的offset 偏移,这个offset
偏移量常用来存储TCP 报文首部、IP 首部等。当然layer 的大小也可以是0,具体是多少就与
数据包的申请方式有关。
(2) PBUF_POOL 类型
PBUF_POOL 类型和PBUF_RAM 类型的pbuf 有很大的相似之处,不同之处时它的空间通
过内存池分配得到的,这种类型的pbuf 可以在极短的时间内得到分配。
在网卡接收数据包的时候,我们就使用这种方式包装数据或者存储接收到的数据。其中在
系统初始化内存池的时候,还会初始化两类与数据报pbuf 密切相关的POOL,如下源码所示:
LWIP_PBUF_MEMPOOL(PBUF, MEMP_NUM_PBUF, 0, "PBUF_REF/ROM")
LWIP_PBUF_MEMPOOL(PBUF_POOL,PBUF_POOL_SIZE, PBUF_POOL_BUFSIZE, "PBUF_POOL")
内存池是一个固定大小的内存块,若用户数据大于固定大小的内存池,则lwIP 内核会以
多个固定大小的内存池来存储这些数据,存储完成之后系统把多个pbuf 以链表的形式链接起
来,构建了一个单向链表,如下图所示:
(3) PBUF_ROM&&和PBUF_REF 类型
剩余的两个PBUF_ROM 和PBUF_REF 比较类似,它们都是在内存池中分配一个相应的
pbuf 结构,但不申请数据区的空间,它们两者的区别在于PBUF_ROM 指向ROM 空间内的数
据,后者指向RAM 空间内的某段数据。在发送某些静态数据时,可以采用这两种类型的pbuf,
这可以大大节省协议栈的内存空间,结构如下图所示:
另外,对于一个数据包来讲,它可能使用上述任意的pbuf 类型来描述,还可以一大串不
同类型的pbuf 连在一起,共同保存一个数据包的数据,如下图所示:
lwIP 网络数据包pbuf 提供了5 个函数,这些函数如下所示:
static void
pbuf_init_alloced_pbuf(struct pbuf *p, void *payload, u16_t tot_len, u16_t len, pbuf_type type, u8_t flags)
{
p->next = NULL; /* 指向NULL */
p->payload = payload; /* 指向数据区域*/
p->tot_len = tot_len; /* 总长度*/
p->len = len; /* 该pbuf长度*/
p->type_internal = (u8_t)type; /* 申请的pbuf类型*/
p->flags = flags; /* 状态位*/
p->ref = 1; /* 指向该pbuf的指针数,即该pbuf被引用的次数*/
p->if_idx = NETIF_NO_INDEX; /* 对于传入的数据包,它包含输入netif的索引*/
}
struct pbuf *
pbuf_alloc(pbuf_layer layer, u16_t length, pbuf_type type)
{
struct pbuf *p;
u16_t offset = (u16_t)layer; /* 申请那个层的首部*/
/* 判断以太网首部*/
switch (type)
{
case PBUF_REF: /* 失败*/
case PBUF_ROM:
p = pbuf_alloc_reference(NULL, length, type);
break;
case PBUF_POOL:
{
struct pbuf *q, *last;
u16_t rem_len; /* 总大小*/
p = NULL;
last = NULL;
rem_len = length; /* rem_len赋值为总长度*/
do
{
u16_t qlen; /* 减去首部的长度*/
/* 申请内存池*/
q = (struct pbuf *)memp_malloc(MEMP_PBUF_POOL);
if (q == NULL) /* 申请内存池失败*/
{
PBUF_POOL_IS_EMPTY();
if (p)
{
pbuf_free(p);
}
return NULL;
}
/* 总长度减去offset(首部大小)并赋值给qlen(去除首部的长度)
LWIP_MIN(x , y) (((x) < (y)) ? (x) : (y)) */
qlen = LWIP_MIN(rem_len, (u16_t)(PBUF_POOL_BUFSIZE_ALIGNED – LWIP_MEM_ALIGN_SIZE(offset)));
/* 分配后初始化struct pbuf成员*/
pbuf_init_alloced_pbuf(q, LWIP_MEM_ALIGN((void *)((u8_t *)q + SIZEOF_STRUCT_PBUF + offset)),
rem_len, qlen, type, 0);
if (p == NULL) /* 第一次分配p必定指向NULL */
{
/* pbuf链分配头*/
p = q;
}
else
/* 让前面的pbuf指向这个pbuf */
last->next = q;
}
last = q;
/* 判断是否还有剩余长度*/
rem_len = (u16_t)(rem_len - qlen);
offset = 0;
}
while (rem_len > 0)
; /* 如果有剩余,还需要执行一次do语句*/
break;
}
case PBUF_RAM:
{
u16_t payload_len = (u16_t)(LWIP_MEM_ALIGN_SIZE(offset) +
LWIP_MEM_ALIGN_SIZE(length));
mem_size_t alloc_len =
(mem_size_t)(LWIP_MEM_ALIGN_SIZE(SIZEOF_STRUCT_PBUF) + payload_len);
if ((payload_len < LWIP_MEM_ALIGN_SIZE(length)) ||
(alloc_len < LWIP_MEM_ALIGN_SIZE(length)))
{
return NULL;
}
/* 如果要在RAM中分配pbuf,请为它分配内存。*/
p = (struct pbuf *)mem_malloc(alloc_len);
if (p == NULL)
{
return NULL;
}
pbuf_init_alloced_pbuf(p, LWIP_MEM_ALIGN((void *)((u8_t *)p + SIZEOF_STRUCT_PBUF + offset)),
length, length, type, 0);
break;
}
default:
return NULL;
}
return p;
}
此函数首先判断申请pbuf 的类型,根据type 的值来运行相应的代码段,layer 变量是为了
让pbuf 中的payload 指针偏移,lwIP 网络数据包pbuf 就是根据这个指针偏移来添加各层的首
部。
2. pbuf_free 函数
此函数是对各类型的数据包pbuf 进行释放,该函数实现原理如下所示:
u8_t pbuf_free(struct pbuf *p)
{
u8_t alloc_src;
struct pbuf *q;
u8_t count;
/* 如果数据包为空则返回0 */
if (p == NULL)
{
return 0;
}
PERF_START;
count = 0;
/* 判断数据包不为空*/
while (p != NULL)
{
LWIP_PBUF_REF_T ref;
SYS_ARCH_DECL_PROTECT(old_level);
SYS_ARCH_PROTECT(old_level);
/* 减少引用计数(指向pbuf的指针数) */
ref = --(p->ref);
SYS_ARCH_UNPROTECT(old_level);
if (ref == 0)
{
/* 为了下一次迭代,请记住链中的下一个pbuf */
q = p->next;
alloc_src = pbuf_get_allocsrc(p);
#if LWIP_SUPPORT_CUSTOM_PBUF
/* is this a custom pbuf? */
if ((p->flags & PBUF_FLAG_IS_CUSTOM) != 0)
{
struct pbuf_custom *pc = (struct pbuf_custom *)p;
pc->custom_free_function(p);
}
else
#endif /* LWIP_SUPPORT_CUSTOM_PBUF */
{
/* 判断释放的内存池的类型*/
if (alloc_src == PBUF_TYPE_ALLOC_SRC_MASK_STD_MEMP_PBUF_POOL)
{
memp_free(MEMP_PBUF_POOL, p);
/* is this a ROM or RAM referencing pbuf? */
}
else if (alloc_src == PBUF_TYPE_ALLOC_SRC_MASK_STD_MEMP_PBUF)
{
memp_free(MEMP_PBUF, p);
/* type == PBUF_RAM */
}
else if (alloc_src == PBUF_TYPE_ALLOC_SRC_MASK_STD_HEAP)
{
mem_free(p);
}
else
{
}
}
count++;
/* 继续到下一个pbuf */
p = q;
}
else
{
p = NULL;
}
}
return count;
}
此函数可以分为两个部分讲解,第一、对pbuf 的ref 参数减1 操作,并调用memp_free/m
em_free 释放内存池或内存堆,第二、如果数据包具有两个或者两个以上的(pbuf 链表),也
是和第一点一样的操作。
3. pbuf_realloc 函数
把相应的pbuf 链表尾部释放一定的空间,并将在数据包pbuf 的数据长度减少到某个长度
值,注意:该函数只是修改pbuf 中的长度字段值,并不释放对应的内存池空间。
4. pbuf_header 函数
用于调整pbuf 的payload 指针(向前或向后移动一定字节数),可以调用pbuf_header 函数
使payload 指针指向数据区前的首部字段,这就为各层对数据包首部的操作提供了方便。当然,
进行这个操作的时候,len 和tot_len 字段值也会随之改动。
5. pbuf_take 函数
用于向pbuf 的数据区域拷贝数据。pbuf_copy 函数用于将一个任何类型的pbuf 中的数据
拷贝到一个PBUF_RAM 类型的pbuf 中。pbuf_chain 函数用于连接两个pbuf(链表)为一个
pbuf 链表。pbuf_ref 函数用于将pbuf 中的值加1。
在上几章节中,笔者简单介绍了lwIP 内存管理、网络接口和网络数据包等知识,相信大
家对于lwIP 内部结构有了一定的了解,这些只不过是lwIP 的冰山一角,我们不仅仅学习lwIP
使用方法,还要懂得如何分析它的实现原理,这样我们才有牢固的知识,本章笔者就和大家揭
露lwIP 的神秘面纱:lwIP 具体框架,超时timeout 处理和数据收发框架等知识。
STM32 基本上使用ETH 接口来接收数据后产生一个ETH 中断,在中断中释放一个信号
量(s_xSemaphore)通知网络接口任务(ethernetif_input)处理接收的数据,这个任务对数据
封装成消息并传递给tcpip_mbox 邮箱,以邮箱发送消息。lwIP 内核有一个协议栈线程,它的
作用就是接收tcpip_mbox 邮箱的消息,并且对接收的消息进行解析处理,在处理之前先判断
消息的类型,lwIP 内核根据消息的类型处理不同的代码段,如下图所示:
从上图可以看出,ethernetif_input 是一个接收线程的任务函数,它用来获取ETH 中断释放
的信号量,若接收到信号量,则调用low_level_input 函数获取描述符管理缓冲区的数据,并且
把这些数据调用tcp_input 函数构建消息,以tcpip_mbox 邮箱的方式发送消息。lwIP 内核在初
始化时,创建了TCP/IP 线程,它的作用是接收tcpip_mbox 邮箱的消息,并且对接收的消息进
行解析处理,在处理之前先判断消息的类型,lwIP 内核根据消息的类型处理不同的代码段。
在lwIP 中很多时候会使用到超时处理,超时处理的实现是TCP/IP 协议栈中一个重要部分。
它为每个与外界网络连接的任务都设定了timeout 属性,即等待超时时间。
lwIP 为什么需要超时定时器
lwIP 中为什么需要做超时处理呢?这可从其实现的TCP/IP 协议栈功能可以知道,TCP 的
建立连接超时、重传超时机制,IP 分片数据报的重装等待超时,ARP 缓存表项的时间管理、
ping 接收数据包超时处理等等,都需要使用超时操作来处理。超时处理的相关代码在
timeouts.c/h 中实现,下面笔者分别地讲解这两个文件的内容。
(1) timeouts.h 文件
该文件主要定义了两个结构体,它们分别为lwip_cyclic_timer 和sys_timeo,第一个结构
体定义了超时等待时间和超时处理函数,另外一个是管理这些超时的定时器,着两个结构体的
原型如下所示:
struct lwip_cyclic_timer
{
u32_t interval_ms; /* 超时间隔*/
lwip_cyclic_timer_handler handler; /* 超时处理*/
};
const struct lwip_cyclic_timer lwip_cyclic_timers[] = {
{TCP_TMR_INTERVAL, HANDLER(tcp_tmr)},
{IP_TMR_INTERVAL, HANDLER(ip_reass_tmr)},
{ARP_TMR_INTERVAL, HANDLER(etharp_tmr)},
{DHCP_COARSE_TIMER_MSECS, HANDLER(dhcp_coarse_tmr)},
{DHCP_FINE_TIMER_MSECS, HANDLER(dhcp_fine_tmr)},
{AUTOIP_TMR_INTERVAL, HANDLER(autoip_tmr)},
{IGMP_TMR_INTERVAL, HANDLER(igmp_tmr)},
{DNS_TMR_INTERVAL, HANDLER(dns_tmr)},
};
可以看到,interval_ms 变量就是超时等待时间,而handler 就是超时处理函数,即超时事
件,若超时了,则触发一个超时事件。lwip_cyclic_timers 数组就是定义了lwIP 内核所需的超
时定时器,即超时事件。
2. sys_timeo 结构体:
typedef void (*sys_timeout_handler)(void *arg);
struct sys_timeo
{
struct sys_timeo *next; /* 下一个超时事件的指针*/
u32_t time; /* 当前超时事件的等待时间*/
sys_timeout_handler h; /* 指向超时的回调函数*/
void *arg; /* 超时的回调函数参数*/
};
这个结构体是用来管理这些超时事件,它的next 指针指向下一个超时事件,最后这些超
时事件形成了单向链表。这些超时事件都调用同一的超时回调函数,这个函数由h 函数指针指
向,最后根据arg 回调函数形参来调用哪个超时事件处理。注:time 变量等于系统节拍加上超时等待时间,例如系统当前节拍是1s,超时定时器的等待时间为5s,所以系统在节拍等于6s
时才执行超时事件。
(2) timeouts.c 文件
void sys_timeouts_init(void)
{
size_t i;
for (i = (LWIP_TCP ? 1 : 0); i < LWIP_ARRAYSIZE(lwip_cyclic_timers); i++)
{
sys_timeout(lwip_cyclic_timers[i].interval_ms, cyclic_timer,
LWIP_CONST_CAST(void *, &lwip_cyclic_timers[i]));
}
}
此函数很简单,获取lwip_cyclic_timers 元素地址和等待超时时间之后调用sys_timeout 函
数把超时事件插入到超时链表当中。sys_timeout 函数如下所示:
#define LWIP_MAX_TIMEOUT 0x7fffffff
/* 当前插入超时事件时间与next_timeout指向超时事件时间对比是否大于0x7fffffff
如果t – compare_to为负值的话,由于类型为u32_t所以导致该值比0x7fffffff 大,
如果比LWIP_MAX_TIMEOUT 大则为1,否则为0*/
#define TIME_LESS_THAN(t, compare_to) ((((u32_t)((t) - (compare_to))) > \
LWIP_MAX_TIMEOUT) \
? 1 \
: 0)
void sys_timeout(u32_t msecs, sys_timeout_handler handler, void *arg)
{
u32_t next_timeout_time;
/* 由TIME_LESS_THAN宏处理的溢出*/
next_timeout_time = (u32_t)(sys_now() + msecs);
sys_timeout_abs(next_timeout_time, handler, arg);
}
从这里可以看出,next_timeout_time 变量等于系统当前节拍加上某个超时事件的等待时间,
其实next_timeout_time 变量最终赋给sys_timeo 结构体下的time 成员变量。这个超时事件由这
个sys_timeout_abs 函数插入到超时链表当中,该函数如下所示:
static void
sys_timeout_abs(u32_t abs_time, sys_timeout_handler handler, void *arg)
{
struct sys_timeo *timeout, *t;
/* 申请节点内存*/
timeout = (struct sys_timeo *)memp_malloc(MEMP_SYS_TIMEOUT);
if (timeout == NULL)
{ /* 申请内存失败直接返回*/
return;
}
/* 节点各变量赋值*/
timeout->next = NULL;
timeout->h = handler;
timeout->arg = arg;
/* abs_time = (u32_t)(sys_now() + msecs) */
timeout->time = abs_time;
/* 如果创建的是第一个定时器,则不用特殊处理,
next_timeout是一个全局指针,指向定时器链表中第一个定时器*/
if (next_timeout == NULL)
{
next_timeout = timeout;
return;
}
/* 如果新添加的定时器小于当前链首定时器的时长,则进入该代码段*/
if (TIME_LESS_THAN(timeout->time, next_timeout->time))
{
timeout->next = next_timeout;
next_timeout = timeout;
}
else
{
for (t = next_timeout; t != NULL; t = t->next)
{
if ((t->next == NULL) || TIME_LESS_THAN(timeout->time, t->next->time))
{
timeout->next = t->next;
t->next = timeout;
break;
}
}
}
}
首先此函数为超时事件申请内存,以内存池的方式申请,接着对超时事件各个成员变量赋
值,可以看到h 函数指针指向超时回调函数,arg 指针指向lwip_cyclic_timers 数组的某个元素
地址,超时回调函数就是根据arg 形参来运行某个超时事件,time 变量等于了
next_timeout_time 变量即当前系统节拍加上超时等待时间,最后插入到超时链表当中。下面笔
者使用几个示意图来讲解这个函数,如下所示:
从上图可以知道,该超时事件的time 等于21 即当前系统节拍加上超时事件等待函数,它
的next 指针指向为NULL,因为一开始这个超时链表没有超时事件,所以next_timeout 指向新
插入的超时事件。
当我们插入第二个超时事件时,系统需要逐一判断这个超时事件的time 是否大于超时链
表挂载的超时事件time,逐一对比之后发送插入的超时事件time 比超时链表挂载的超时事件
time 要大,则系统把这个超时事件插入这张链表的尾部。如下图所示:
如果插入的超时事件time 与挂载超时链表的超时事件time 对比之后,发现插入的超时事
件time 在两个挂载的超时事件time 之间即a
void sys_untimeout(sys_timeout_handler handler, void *arg)
{
struct sys_timeo *prev_t, *t;
/* 从链表头开始遍历这个链表*/
for (t = next_timeout, prev_t = NULL; t != NULL; prev_t = t, t = t->next)
{
/* 查找删除的超时事件,判断超时事件的回调函数与函数参数是否一致*/
if ((t->h == handler) && (t->arg == arg))
{
if (prev_t == NULL)
{
next_timeout = t->next;
}
else
{
prev_t->next = t->next;
}
memp_free(MEMP_SYS_TIMEOUT, t);
return;
}
}
return;
}
此函数非常简单,只需遍历这个超时链表,在遍历过程中判断超时事件的回调函数与函数
参数是否一致,若一致,则对超时链表的超时事件排序,排序完成之后调用memp_free 删除这
个超时事件。
超时定时器检查:
不管是OS 的还是裸机的都可以对其进行超时检查和处理,lwIP 使用两个函数来实现超时
检查处理。
这个线程由tcpip_init 函数创建,该函数如下所示:
void tcpip_init(tcpip_init_done_fn initfunc, void *arg)
{
lwip_init();
tcpip_init_done = initfunc;
tcpip_init_done_arg = arg;
if (sys_mbox_new(&tcpip_mbox, TCPIP_MBOX_SIZE) != ERR_OK)
{
LWIP_ASSERT("failed to create tcpip_thread mbox", 0);
}
#if LWIP_TCPIP_CORE_LOCKING
if (sys_mutex_new(&lock_tcpip_core) != ERR_OK)
{
LWIP_ASSERT("failed to create lock_tcpip_core", 0);
}
#endif /* LWIP_TCPIP_CORE_LOCKING */
sys_thread_new(TCPIP_THREAD_NAME, tcpip_thread, NULL,
TCPIP_THREAD_STACKSIZE, TCPIP_THREAD_PRIO);
}
这个函数在lwip_init 函数调用,它负责几个任务,第一、创建邮箱为数据传输准备,第
二、创建互斥锁为防止优先级翻转问题,第三、创建TCP/IP 线程。下面笔者重点讲解
tcpip_thread 任务函数的实现源码,如下所示:
static void
tcpip_thread(void *arg)
{
struct tcpip_msg *msg;
LWIP_UNUSED_ARG(arg);
LWIP_MARK_TCPIP_THREAD();
LOCK_TCPIP_CORE();
if (tcpip_init_done != NULL)
{
tcpip_init_done(tcpip_init_done_arg);
}
while (1)
{
LWIP_TCPIP_THREAD_ALIVE();
/* 第一步:等待消息时,将在等待时处理超时*/
/* TCPIP_MBOX_FETCH的宏定义为sys_timeouts_mbox_fetch
等待消息并且处理超时事件*/
TCPIP_MBOX_FETCH(&tcpip_mbox, (void **)&msg);
if (msg == NULL) /* 如果没有等到消息就继续等待*/
{
continue;
}
tcpip_thread_handle_msg(msg);
}
}
static void
tcpip_thread_handle_msg(struct tcpip_msg *msg)
{
/* 第二步:等待到消息就对消息进行处理*/
/* 不同类型进行不同的处理*/
switch (msg->type)
{
#if !LWIP_TCPIP_CORE_LOCKING
/* 执行对应的API 函数*/
case TCPIP_MSG_API:
msg->msg.api_msg.function(msg->msg.api_msg.msg);
break;
case TCPIP_MSG_API_CALL:
msg->msg.api_call.arg->err =
msg->msg.api_call.function(msg->msg.api_call.arg);
sys_sem_signal(msg->msg.api_call.sem);
break;
#endif /* !LWIP_TCPIP_CORE_LOCKING */
#if !LWIP_TCPIP_CORE_LOCKING_INPUT
/* 直接交给ARP 层处理*/
case TCPIP_MSG_INPKT:
if (msg->msg.inp.input_fn(msg->msg.inp.p,
msg->msg.inp.netif) != ERR_OK)
{
pbuf_free(msg->msg.inp.p);
}
memp_free(MEMP_TCPIP_MSG_INPKT, msg);
break;
#endif /* !LWIP_TCPIP_CORE_LOCKING_INPUT */
#if LWIP_TCPIP_TIMEOUT && LWIP_TIMERS
/* 注册一个超时事件*/
case TCPIP_MSG_TIMEOUT:
sys_timeout(msg->msg.tmo.msecs, msg->msg.tmo.h, msg->msg.tmo.arg);
memp_free(MEMP_TCPIP_MSG_API, msg);
break;
/* 删除一个超时事件*/
case TCPIP_MSG_UNTIMEOUT:
sys_untimeout(msg->msg.tmo.h, msg->msg.tmo.arg);
memp_free(MEMP_TCPIP_MSG_API, msg);
break;
#endif /* LWIP_TCPIP_TIMEOUT && LWIP_TIMERS */
/* 通过回调方式执行一个回调函数
他们的回调函数相同*/
case TCPIP_MSG_CALLBACK:
msg->msg.cb.function(msg->msg.cb.ctx);
memp_free(MEMP_TCPIP_MSG_API, msg);
break;
case TCPIP_MSG_CALLBACK_STATIC:
msg->msg.cb.function(msg->msg.cb.ctx);
break;
default:
break;
}
}
协议栈线程主要负责接收邮箱的消息、递交数据至网络层、遍历超时链表等任务。
在上一个小节笔者讲解了tcpip_thread 线程的作用,其中接收邮箱的消息到底如何构建,
这里涉及到lwIP 数据包消息机制,它专门把ETH 中断接收的数据封装成消息,以邮箱的方式
发送至tcpip_thread 线程处理,注:这里以带操作系统为例。
/* 7种tcpip_msg消息类型*/
enum tcpip_msg_type
{
TCPIP_MSG_API, /* 用户调用应用层的接口时,就属于API消息类型*/
TCPIP_MSG_API_CALL, /* API 函数调用*/
TCPIP_MSG_INPKT, /* 底层数据包输入*/
TCPIP_MSG_TIMEOUT, /* 注册超时事件*/
TCPIP_MSG_UNTIMEOUT, /* 删除超时事件*/
TCPIP_MSG_CALLBACK, /* 执行回调函数*/
TCPIP_MSG_CALLBACK_STATIC /* 执行静态回调函数*/
};
/* tcpip_msg结构体*/
struct tcpip_msg
{
/* tcpip_msg消息的类型*/
enum tcpip_msg_type type;
/* 消息内容,共用体,不同消息类型使用不同的结构*/
union
{
struct
{
/* 内核执行函数*/
tcpip_callback_fn function;
/* 执行函数的参数*/
void *msg;
} api_msg;
struct
{
/* 回调函数*/
tcpip_api_call_fn function;
/* 回调函数的参数*/
struct tcpip_api_call_data *arg;
/* 用户同步的信号量*/
sys_sem_t *sem;
} api_call;
struct
{
/* 接收的数据包*/
struct pbuf *p;
/* 接收的数据包的网络接口*/
struct netif *netif;
/* 输入的函数接口*/
netif_input_fn input_fn;
} inp;
struct
{
/* tcpip回调函数*/
tcpip_callback_fn function;
/* 回调函数参数*/
void *ctx;
} cb;
struct
{
/* 超时时间*/
u32_t msecs;
/* 超时执行的回调函数*/
sys_timeout_handler h;
/* 传入超时回调函数的形参*/
void *arg;
} tmo;
#endif /* LWIP_TCPIP_TIMEOUT && LWIP_TIMERS */
} msg;
};
上述的源码中,我们可以看到消息结构的msg 字段是一个共用体union,共用体中定义了
各类型消息的具体内容,每种类型的消息对应了共用体中的一个字段,其中注册超时事件和删
除超时事件消息共用一个tmo 结构体;回调事件与静态回调事件消息也共用一个cb 结构体;
API 调用与NETIF 的API 调用相关的消息具体内容比较多,不宜直接放在tcpip_msg 中,系统
用了专门的结构体api_msg 来描述对应消息的具体内容。注:tcpip_msg 中只保存了一个指向
api_msg 指针。
tcpip_thread 线程处理每种类型的消息时,lwIP 内核就会产生与之对应的消息函数,首先
产生的消息传递到系统邮箱(tcpip_mbox),tcpip_thread 线程需要判断该消息的类型,从而做
出相应的处理,在图7.1.1 中,笔者大概描述了lwIP 接收数据的流程图,直观上它是通过函数
tcpip_input 对消息进行构造和投递,当然该函数真正执行的是函数tcpip_inpkt,如下源码所示:
err_t tcpip_input(struct pbuf *p, struct netif *inp)
{
#if LWIP_ETHERNET
if (inp->flags & (NETIF_FLAG_ETHARP | NETIF_FLAG_ETHERNET))
{
/* 把ethernet_input()作用该函数的一部分
内核接收到这个数据包就调用该函数*/
return tcpip_inpkt(p, inp, ethernet_input);
}
else
#endif /* LWIP_ETHERNET */
return tcpip_inpkt(p, inp, ip_input);
}
err_t tcpip_inpkt(struct pbuf *p, struct netif *inp, netif_input_fn input_fn)
{
#if LWIP_TCPIP_CORE_LOCKING_INPUT
err_t ret;
ret = input_fn(p, inp);
return ret;
#else /* LWIP_TCPIP_CORE_LOCKING_INPUT */
struct tcpip_msg *msg;
msg = (struct tcpip_msg *)memp_malloc(MEMP_TCPIP_MSG_INPKT);
if (msg == NULL)
{
return ERR_MEM;
}
msg->type = TCPIP_MSG_INPKT;
msg->msg.inp.p = p; /* 指向pbuf数据包*/
msg->msg.inp.netif = inp; /* 网络接口*/
/* 构造消息,消息的类型是数据包消息,处理函数是ethernet_input() */
msg->msg.inp.input_fn = input_fn;
if (sys_mbox_trypost(&mbox, msg) != ERR_OK) /* 构造消息完成,发送邮箱*/
{
memp_free(MEMP_TCPIP_MSG_INPKT, msg); /* 释放内存池*/
return ERR_MEM;
}
return ERR_OK;
#endif /* LWIP_TCPIP_CORE_LOCKING_INPUT */
}
总的来说,接收数据都是通过函数ethernet_input,当然无操作系统也是如此,只不过就
是传递消息的方式不同,无操作系统一般使用回调函数传递消息,而操作系统一般使用IPC
通信机制,例如邮箱,信号量等通信机制,lwIP 的IPC 通讯示意图如下图所示:
所谓API 消息,其实就是两个API 部分的交互的消息,它是由用户调用API 函数为起点,
使用IPC 通信机制告诉内核需要执行那个部分的API 函数,内核的具体消息内容都可以直接
包含内核消息结构tcpip_msg,但是API 消息除外,由于它的消息内容实在庞大,所以协议栈
专门用结构体api_msg 来描述API 消息内容,而在tcpip_msg 结构体中只维护该类型的指针,
前面笔者也讲解到tcpip_msg 时候,它里面包含了一个api_msg 指针,这个指针只是指向
api_msg 结构体,现在的api_msg 结构体在api_msg.h 文件定义的,该结构体如下所示:
struct api_msg
{
struct netconn *conn; /* 当前连接*/
err_t err; /* 返回结果*/
union
{
/* 用于函数lwip_netconn_do_send()参数*/
struct netbuf *b;
/* 用于函数lwip_netconn_do_newconn()参数*/
struct
{
u8_t proto;
} n;
/* 用于函数lwip_netconn_do_bind()和函数lwip_netconn_do_connect()参数*/
struct
{
API_MSG_M_DEF_C(ip_addr_t, ipaddr); /* ip 地址*/
u16_t port; /* 端口号*/
u8_t if_idx;
} bc;
/* 用于函数lwip_netconn_do_getaddr()参数*/
struct
{
ip_addr_t API_MSG_M_DEF(ipaddr); /* ip 地址*/
u16_t API_MSG_M_DEF(port); /* 端口号*/
u8_t local;
} ad;
/* 用于函数lwip_netconn_do_write()参数*/
struct
{
/** 当前要写的向量e */
const struct netvector *vector;
/** 未写向量的个数*/
u16_t vector_cnt;
/** 偏移成矢量*/
size_t vector_off;
/** 向量的总长度*/
size_t len;
/** 当err == ERR_OK时写入的字节的总长度/输出的偏移量*/
size_t offset;
u8_t apiflags;
#if LWIP_SO_SNDTIMEO
u32_t time_started;
#endif /* LWIP_SO_SNDTIMEO */
} w;
/** 用于函数lwip_netconn_do_recv()参数*/
struct
{
u32_t len;
} r;
#if LWIP_TCP
/* 用于函数wip_netconn_do_close (/shutdown)参数*/
struct
{
u8_t shut;
#if LWIP_SO_SNDTIMEO || LWIP_SO_LINGER
u32_t time_started;
#else /* LWIP_SO_SNDTIMEO || LWIP_SO_LINGER */
u8_t polls_left;
#endif /* LWIP_SO_SNDTIMEO || LWIP_SO_LINGER */
} sd;
#endif /* LWIP_TCP */
#if LWIP_IGMP || (LWIP_IPV6 && LWIP_IPV6_MLD)
/* 用于函数lwip_netconn_do_join_leave_group()参数*/
struct
{
API_MSG_M_DEF_C(ip_addr_t, netif_addr);
u8_t if_idx;
enum netconn_igmp join_or_leave;
} jl;
#endif /* LWIP_IGMP || (LWIP_IPV6 && LWIP_IPV6_MLD) */
#if TCP_LISTEN_BACKLOG
struct
{
u8_t backlog;
} lb;
#endif /* TCP_LISTEN_BACKLOG */
} msg;
#if LWIP_NETCONN_SEM_PER_THREAD
sys_sem_t *op_completed_sem;
#endif /* LWIP_NETCONN_SEM_PER_THREAD */
};
这个结构体只包含了三个字段,分别为描述连接信息的conn、内核返回的执行结果err、
以及msg。在api_msg 结构体中保存conn 字段是必须的,因为conn 结构中包含了与该连接相
关的邮箱和信号量等信息,协议栈进程要用这些信息来完成与应用进程间的同步与通信;共用
体类型msg 的各个成员与调用它的函数密切相关,如lwip_netconn_do_xxx(xxx 表示不一样的NETCONN 的API 接口)类型的函数执行需要用这些信息来完成与应用线程的通信与同步;
内核执行lwip_netconn_do_xxx 类型的函数返回结果会被记录在err 中;msg 的各个参数记录各
个函数执行时需要的详细参数。
到了这里,我们已经理解了底层数据包消息,同理API 函数的调用也是如此,如果用户
要与内核进行数据传递,也是需要lwIP 的消息机制,毕竟用户和内核都是独立的线程或者任
务,例如我们使用SOCKET 的API 接口和NETCONN 的API 接口时候,lwIP 会把用户调用的
函数与参数做成消息传递给tcpip_thraed 线程,这个消息就是lwIP 中的API 消息,lwIP 为什么
会使用这些方式呢?首先对于用户来说,不需要很深入的理解lwIP 内核,只需要调用API 函
数接口就可以完成实验,例如在NETCONN 的API 中构造数据包时,就会调用
netconn_apimsg 函数进行投递消息。
其实lwIP 的协议栈API 实现有两个部分组成,一部分为用户编程接口函数提供给用户,
这些函数在用户进程执行,另一部分驻留在内核进程,这两个部分的通信方法是使用IPC 通
信机制,被用到的进程通信机制有以下四种:
图7.4.2.1 用户API 与内核进程的关系图
接下来笔者就以NETCONN 的API 为例,来讲解lwIP 的API 消息使用,如下源码所示:
err_t netconn_bind(struct netconn *conn, const ip_addr_t *addr, u16_t port)
{
/*声明api_msg消息结构体*/
API_MSG_VAR_DECLARE(msg);
err_t err;
#if LWIP_IPV4
if (addr == NULL)
{
addr = IP4_ADDR_ANY;
}
#endif /* LWIP_IPV4 */
/* 第一步:构建api_msg结构体*/
API_MSG_VAR_ALLOC(msg); /* 申请内存*/
/* 连接的信息*/
API_MSG_VAR_REF(msg).conn = conn;
/* IP地址*/
API_MSG_VAR_REF(msg).msg.bc.ipaddr = API_MSG_VAR_REF(addr);
/* 端口号*/
API_MSG_VAR_REF(msg).msg.bc.port = port;
/* 发送API消息并等待信号量*/
err = netconn_apimsg(lwip_netconn_do_bind, &API_MSG_VAR_REF(msg));
API_MSG_VAR_FREE(msg);
return err;
}
static err_t
netconn_apimsg(tcpip_callback_fn fn, struct api_msg *apimsg)
{
err_t err;
/* 发送API消息并等待信号量*/
err = tcpip_send_msg_wait_sem(fn, apimsg, LWIP_API_MSG_SEM(apimsg));
if (err == ERR_OK)
{
return apimsg->err; /* 返回API的错误码*/
}
return err;
}
err_t tcpip_send_msg_wait_sem(tcpip_callback_fn fn, void *apimsg, sys_sem_t *sem)
{
#if LWIP_TCPIP_CORE_LOCKING
LWIP_UNUSED_ARG(sem);
LOCK_TCPIP_CORE();
fn(apimsg);
UNLOCK_TCPIP_CORE();
return ERR_OK;
#else /* LWIP_TCPIP_CORE_LOCKING */
/*声明tcpip_msg消息结构体*/
TCPIP_MSG_VAR_DECLARE(msg);
/* 第二步:构造tcpip_msg消息*/
TCPIP_MSG_VAR_ALLOC(msg); /* 申请内存*/
TCPIP_MSG_VAR_REF(msg).type = TCPIP_MSG_API; /* 消息类型*/
TCPIP_MSG_VAR_REF(msg).msg.api_msg.function = fn; /* 设置回调函数*/
TCPIP_MSG_VAR_REF(msg).msg.api_msg.msg = apimsg; /* 指向api_msg消息*/
/* 第三步:释放邮箱*/
sys_mbox_post(&mbox, &TCPIP_MSG_VAR_REF(msg));
/* 第四步:等待信号量*/
sys_arch_sem_wait(sem, 0);
TCPIP_MSG_VAR_FREE(msg);
return ERR_OK;
#endif /* LWIP_TCPIP_CORE_LOCKING */
}
上述的源码,我们可分为四步讲解,它们分别构造tcpip_msg、api_msg、发送邮箱消息和
等待信号量,如下图所示:
其实上图并不是lwIP 的最优先的流程图,因为在函数tcpip_send_msg_wait_sem 中宏定义
LWIP_TCPIP_CORE_LOCKING 是为1 的,表示无需操作系统的邮箱与信号量参与,在该函数
只执行以下源码:
err_t tcpip_send_msg_wait_sem(tcpip_callback_fn fn, void *apimsg, sys_sem_t *sem)
{
#if LWIP_TCPIP_CORE_LOCKING
LWIP_UNUSED_ARG(sem);
LOCK_TCPIP_CORE();
fn(apimsg);
UNLOCK_TCPIP_CORE();
return ERR_OK;
#else /* LWIP_TCPIP_CORE_LOCKING */
/* 代码省略*/
#endif /* LWIP_TCPIP_CORE_LOCKING */
}
上述函数也非常简单理解,首先此函数调用LOCK_TCPIP_CORE 函数上锁,记者系统直
接调用函数lwip_netconn_do_bind 对api_msg 消息做处理,这样的方法省去了tcpip_mag 消息
构建、邮箱以及信号量等操作。如下图所示:
本章,我们一起学习传输层UDP 协议与RAW API 编程接口,总的来说,本章节的内容会
涉及到传输层和应用层的知识。
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 报文结构如下图所示:
(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_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 回调函数处理。
下表给出了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 控制块的字段指向。
11.3.2.1 UDP 配置步骤
/**
* @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 测试
在本章中开发板做TCP 服务器,网络调试助手做TCP 客户端,实验中我们通过电脑端的
网络调试助手给开发板发送数据,开发板接收并在LCD 上显示接收到的数据,同时也可以通
过按键从开发板向网络调试助手发送数据。
在上一章RAW 编程接口的TCP 客户端实验中我们已经讲解过了TCP 的基础知识,这里
就不做讲解。
13.2.2.1 TCP 服务器配置步骤
/**
* @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 编程API 接口在开发板上实现一个WebServer。在本章中我们通过移植并
修改ST 官方的一个WebServer 的例程来展示。在浏览器中输入开发板的IP 地址来访问开发板,
这时开发板会返回一个网页,在这个网页上我们可以控制开发板的LED 灯和蜂鸣器并查看
ADC 和内部温度等外设的数据。
(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 来实现的。
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 时间值。
前几章的实验我们都没有使用操作系统,因此它们采用的是RAW 编程接口,RAW 编程
接口使得程序效率高,但是需要对lwIP 有深入的了解,而且不适合大数据量等场合。本章我
们就讲解一下NETCONN 编程接口,使用NETCONN API 时需要有操作系统的支持,我们使
用的是FreeRTOS 操作系统。
声明:本章内容参考自《嵌入式网络那些事LWIP 协议深度剖析与实战演练》,作者朱升
林!由于该书是使用lwIP1.3.2 版本,所以它和本教程的lwIP2.1.2 源码存在许多小差异。
在前面笔者讲过了描述数据包的pbuf 结构,这里我们就不做详细讲解了,本章我们来讲
解一下另一个数据包的netbuf 结构,netbuf 是NETCONN 编程API 接口使用的描述数据包的
结构,我们可以使用netbuf 来管理发送数据、接收数据的缓冲区。有关netbuf 的详细描述在
netbuf.c 和netbuf.h 这两个文件中,netbuf 是一个结构体,它在netbuf.h 中定义了这个结构体,
代码如下,
/* 网络缓冲区包含数据和寻址信息*/
struct netbuf
{
/* p 字段的指针指向pbuf 链表*/
struct pbuf *p, *ptr;
ip_addr_t addr; /* 记录了数据发送方的IP 地址*/
u16_t port; /* 记录了数据发送方的端口号*/
};
从上述源码可知,其中p 和ptr 都指向pbuf 链表,不同的是p 一直指向pbuf 链表的第一
个pbuf 结构,而ptr 可能指向链表中其他的位置,lwIP 提供的netbuf_next 和netbuf_first 函数
都是操作ptr 字段的。addr 和port 字段用来记录数据发送方的IP 地址和端口号,lwIP 提供的
netbuf_fromaddr 和netbuf_fromport 函数用于返回addr 和port 这两个字段。从上述的描述,我
们可以知道netbuf 是用来管理发送数据和接收数据的缓冲区,这些收发数据都存储在pbuf 数
据缓冲区当中,所以netbuf 和pbuf 肯定有着某种联系,下面我们根据上述的netbuf 结构体来
介绍一下netbuf 和pbuf 之间的关系如下图所示。
从上图可知:netbuf 结构体中的p 指针永远指向pbuf 链表的第一个,而ptr 可能指向链表
中其他的位置。
不管是TCP 连接还是UDP 连接,接收到数据包后会将数据封装在一个netbuf 中,然后将
这个netbuf 交给应用程序去处理。在数据发送时,根据不同的连接有不同的处理:对于TCP
连接,用户只需要提供待发送数据的起始地址和长度,内核会根据实际情况将数据封装在合适
大小的数据包中,并放入发送队列中,对于UDP 来说,用户需要自行将数据封装在netbuf 结
构中,当发送函数被调用的时候内核直接将数据包中的数据发送出去。
在netbuf.c 中提供了几个操作netbuf 的函数,如下表所示。
注意:用户使用NETCONN 编程API 接口时必须在lwipopts.h 把LWIP_NETCONN 配置
项设置为1 启动NETCONN 编程API 接口。
我们前面在使用RAW 编程接口的时候,对于UDP 和TCP 连接使用的是两种不同的编程
函数:udp_xxx 和tcp_xxx。NETCONN 对于这两种连接提供了统一的编程接口,用于使用同
一的连接结构和编程函数,在api.h 中定了netcon 结构体,代码如下。
/* netconn描述符*/
struct netconn
{
/* 连接类型,TCP UDP或者RAW */
enum netconn_type type;
/* 当前连接状态*/
enum netconn_state state;
/* 内核中与连接相关的控制块指针*/
union
{
struct ip_pcb *ip; /* IP控制块*/
struct tcp_pcb *tcp; /* TCP控制块*/
struct udp_pcb *udp; /* UDP控制块*/
struct raw_pcb *raw; /* RAW控制块*/
} pcb;
/* 这个netconn 最后一个异步未报告的错误*/
err_t pending_err;
#if !LWIP_NETCONN_SEM_PER_THREAD
/* 用于两部分API同步的信号量*/
sys_sem_t op_completed;
#endif
/* 接收数据的邮箱*/
sys_mbox_t recvmbox;
#if LWIP_TCP
/* 用于TCP服务器端,连接请求的缓冲队列*/
sys_mbox_t acceptmbox;
#endif /* LWIP_TCP */
/* Socket描述符,用于Socket API */
#if LWIP_SOCKET
int Socket;
#endif /* LWIP_SOCKET */
#if LWIP_SO_RCVTIMEO
/* 接收数据时的超时时间*/
u32_t recv_timeout;
#endif /* LWIP_SO_RCVTIMEO */
/* 标识符*/
u8_t flags;
#if LWIP_TCP
/* TCP:当传递到netconn_write的数据不适合发送缓冲区时,
这将临时存储消息。
也用于连接和关闭。*/
struct api_msg *current_msg;
#endif /* LWIP_TCP */
/* 连接相关回调函数,实现Socket API时使用*/
netconn_callback callback;
};
在api.h 文件中还定义了连接状态和连接类型,这两个都是枚举类型。
/* 枚举类型,用于描述连接类型*/
enum netconn_type
{
NETCONN_INVALID = 0, /* 无效类型*/
NETCONN_TCP = 0x10, /* TCP */
NETCONN_UDP = 0x20, /* UDP */
NETCONN_UDPLITE = 0x21, /* UDPLite */
NETCONN_UDPNOCHKSUM = 0x22, /* 无校验UDP */
NETCONN_RAW = 0x40 /* 原始链接*/
};
/* 枚举类型,用于描述连接状态,主要用于TCP连接中*/
enum netconn_state
{
NETCONN_NONE, /* 不处于任何状态*/
NETCONN_WRITE, /* 正在发送数据*/
NETCONN_LISTEN, /* 侦听状态*/
NETCONN_CONNECT, /* 连接状态*/
NETCONN_CLOSE /* 关闭状态*/
};
下面我们来结合一下netconn 的api 函数来讲解这个结构体的作用。
本节我们就讲解一下NETCONN 编程的API 函数,这些函数在api_lib.c 文件中实现的,其
中这个文件包含了很多netconn 接口的函数,它们大部分是lwIP 内部调用的,少部分是给用户
使用的,用户能使用的函数如下表所示。
netconn_new 函数是函数netconn_new_with_proto_and_callback 的宏定义,此函数用来为新
连接申请一个netconn 空间,参数为新连接的类型,连接类型在上一节已经讲过了,常用的值
是NETCONN_UDP 和NETCONN_TCP,分别代表UDP 连接和TCP 连接。该函数如下所示:
#define netconn_new(t) netconn_new_with_proto_and_callback(t, 0, NULL)
struct netconn *
netconn_new_with_proto_and_callback(enum netconn_type t, u8_t proto,
netconn_callback callback)
{
struct netconn *conn;
API_MSG_VAR_DECLARE(msg);
/* 第一步:构建api_msg结构体*/
API_MSG_VAR_ALLOC_RETURN_NULL(msg);
/* 申请内存*/
conn = netconn_alloc(t, callback);
if (conn != NULL)
{
err_t err;
/* 连接协议*/
API_MSG_VAR_REF(msg).msg.n.proto = proto;
/* 连接的信息*/
API_MSG_VAR_REF(msg).conn = conn;
/* 构建API消息*/
err = netconn_apimsg(lwip_netconn_do_newconn, &API_MSG_VAR_REF(msg));
if (err != ERR_OK) /* 构建失败*/
{
/* 释放信号量*/
sys_sem_free(&conn->op_completed);
/* 释放邮箱*/
sys_mbox_free(&conn->recvmbox);
/* 释放内存*/
memp_free(MEMP_NETCONN, conn);
API_MSG_VAR_FREE(msg);
return NULL;
}
}
API_MSG_VAR_FREE(msg);
return conn;
}
上述源码可知,系统对conn 申请内存,如果内存申请成功,则系统构建API 消息。所谓
API 消息,其实就是两个API 部分的交互的消息,它是由用户的调用API 函数为起点,使用IPC 通信机制告诉内核需要执行那个部分的API 函数,API 消息的知识点笔者已经在第七章
7.4 小节讲解了,下面笔者使用一个示意图来描述netconn_new 函数交互流程,如下图所示:
图15.3.1 用户的应用线程与内核交互示意图
相信大家对上图很熟悉吧,没错,这个图我们在第七章7.4.2 小节讲解过,这些用户函数
就是以这个形式调用的。
netconn_delete 函数是用来删除一个netconn 连接结构,如果函数调用时双方仍然处于连接
状态,则相应连接将被关闭。其中对于UDP 连接,它的连接会立即被关闭,UDP 控制块被删
除;对于TCP 连接,该函数执行主动关闭,内核完成剩余的断开握手过程,该函数如下所示:
err_t netconn_delete(struct netconn *conn)
{
err_t err;
if (conn == NULL)
{
return ERR_OK;
}
/* 构建API消息*/
err = netconn_prepare_delete(conn);
if (err == ERR_OK)
{
netconn_free(conn);
}
return err;
}
此函数就是调用netconn_prepare_delete 函数构建API 消息,API 构建流程请参考图15.3.1
的构建流程。
netconn_getaddr 函数是用来获取一个netconn 连接结构的源IP 地址和源端口号或者目的IP
地址和目的端口号,IP 地址保存在addr 当中,而端口信息保存在port 当中,参数local 表示是
获取源地址还是目的地址,当local 为1 时表示本地地址,此函数原型如下。
err_t netconn_getaddr(struct netconn*conn,ip_addr_t*addr,u16_t*port,u8_t local);
netconn_bind 函数将一个连接结构与本地IP 地址addr 和端口号port 进行绑定,服务器端
程序必须执行这一步,服务器必须与指定的端口号绑定才能结接受客户端的连接请求,该函数原型如下。
err_t netconn_bind(struct netconn *conn, const ip_addr_t *addr, u16_t port);
netconn_connect 函数的功能是连接服务器,它将指定的连接结构与目的IP 地址addr 和目
的端口号port 进行绑定,当作为TCP 客户端程序时,调用此函数会产生握手过程,该函数原
型如下。
err_t netconn_connect(struct netconn *conn, const ip_addr_t *addr, u16_t port);
netconn_disconnect 函数只能使用在UDP 连接中,功能是断开与服务器的连接。对于UDP
连接来说就是将UDP 控制块中的remote_ip 和remote_port 字段值清零,函数原型如下。
err_t netconn_disconnect (struct netconn *conn);
netconn_listen 函数只有在TCP 服务器程序中使用,将一个连接结构netconn 设置为侦听状
态,既将TCP 控制块的状态设置为LISTEN 状态,该函数原型如下:
#define netconn_listen(conn)
netconn_listen_with_backlog(conn, TCP_DEFAULT_LISTEN_BACKLOG)
netconn_accept 函数也只用于TCP 服务器程序,服务器调用此函数可以从acceptmbox 邮箱
中获取一个新建立的连接,若邮箱为空,则函数会一直阻塞,直到新连接的到来。服务器端调
用此函数前必须先调用netconn_listen 函数将连接设置为侦听状态,函数原型如下。
err_t netconn_accept(struct netconn *conn, struct netconn **new_conn);
netconn_recv 函数是从连接的recvmbox 邮箱中接收数据包,可用于TCP 连接,也可用于
UDP 连接,函数会一直阻塞,直到从邮箱中获得数据消息,数据被封装在netbuf 中。如果从
邮箱中接收到一条空消息,表示对方已经关闭当前的连接,应用程序也应该关闭这个无效的连
接,函数原型如下。
err_t netconn_recv(struct netconn *conn, struct netbuf **new_buf);
netconn_send 函数用于在UDP 连接上发送数据,参数conn 指出了要操作的连接,参数
buf 为要发送的数据,数据被封装在netbuf 中。如果IP 层分片功能未使能,则netbuf 中的数据
不能太长,不能超过MTU 的值,最好不要超过1000 字节。如果IP 层分片功能使能的情况下
就可以忽略此细节,函数原型如下。
err_t netconn_send(struct netconn *conn, struct netbuf *buf);
netconn_write 函数用于在稳定的TCP 连接上发送数据,参数dataptr 和size 分别指出了待
发送数据的起始地址和长度,函数并不要求用户将数据封装在netbuf 中,对于数据长度也没
有限制,内核会直接处理这些数据,将他们封装在pbuf 中,并挂接到TCP 的发送队列中。
netconn_close 函数用来关闭一个TCP 连接,该函数会产生一个FIN 握手包的发送,成功
后函数便返回,而后剩余的断开握手操作由内核自动完成,用户程序不用关心,该函数只是断
开一个连接,但不会删除连接结构netconn,用户需要调用netconn_delete 函数来删除连接结构,
否则会造成内存泄漏,函数原型如下。
err_t netconn_close(struct netconn *conn);
本章,我们开始学习NETCONN API 函数的使用,本章实验中我们通过电脑端的网络调
试助手给开发板发送数据,开发板接收数据并通过串口将接收到的数据发送到串口调试助手上,
也可以通过按键从开发板向网络调试助手发送数据。
上一章节,我们已经知道lwIP 的NETCONN 编程接口API 的使用方法,本章实验就是调
用这些API 函数来实现UDP 连接实验。用户使用NETCONN 编程接口实现UDP 连接分以下
几个步骤:
①调用函数netconn_new 创建UDP 控制块。
②定义时间超时函数。
③调用函数netconn_bind 绑定本地IP 和端口。
④调用函数netconn_connect 建立连接。
关于UDP 的基础知识我们在第十一章时候已经讲过了,这里就不重复讲解。
16.2.2.1 netconn 的UDP 连接步骤
void lwip_demo(void)
{
err_t err;
static struct netconn *udpconn;
static struct netbuf *recvbuf;
static struct netbuf *sentbuf;
ip_addr_t destipaddr;
uint32_t data_len = 0;
struct pbuf *q;
/* 第一步:创建udp控制块*/
udpconn = netconn_new(NETCONN_UDP);
/* 定义接收超时时间*/
udpconn->recv_timeout = 10;
if (udpconn != NULL) /* 判断创建控制块释放成功*/
{
/* 第二步:绑定控制块、本地IP和端口*/
err = netconn_bind(udpconn, IP_ADDR_ANY, UDP_DEMO_PORT);
IP4_ADDR(&destipaddr, DEST_IP_ADDR0,
DEST_IP_ADDR1,
DEST_IP_ADDR2,
DEST_IP_ADDR3); /*构造目的IP地址*/
/* 第三步:连接或者建立对话框*/
netconn_connect(udpconn, &destipaddr, LWIP_DEMO_PORT);
if (err == ERR_OK) /* 绑定完成*/
{
while (1)
{
/* 第四步:如果指定的按键按下时,会发送信息*/
if ((lwip_send_flag & LWIP_SEND_DATA) == LWIP_SEND_DATA)
{
sentbuf = netbuf_new();
netbuf_alloc(sentbuf, strlen((char *)udp_demo_sendbuf));
memcpy(sentbuf->p->payload, (void *)udp_demo_sendbuf, |
strlen((char *)udp_demo_sendbuf));
err = netconn_send(udpconn, sentbuf);
if (err != ERR_OK)
{
printf("发送失败\r\n");
netbuf_delete(sentbuf); /* 删除buf */
}
lwip_send_flag &= ~LWIP_SEND_DATA; /* 清除数据发送标志*/
netbuf_delete(sentbuf); /* 删除buf */
}
/* 第五步:接收数据*/
netconn_recv(udpconn, &recvbuf);
if (recvbuf != NULL) /* 接收到数据*/
{
/*数据接收缓冲区清零*/
memset(lwip_demo_recvbuf, 0, LWIP_DEMO_RX_BUFSIZE);
/*遍历完整个pbuf链表*/
for (q = recvbuf->p; q != NULL; q = q->next)
{
if (q->len > (LWIP_DEMO_RX_BUFSIZE - data_len))
memcpy(lwip_demo_recvbuf + data_len, q->payload,
(LWIP_DEMO_RX_BUFSIZE - data_len)); /* 拷贝数据*/
else
memcpy(lwip_demo_recvbuf + data_len,
q->payload, q->len);
data_len += q->len;
if (data_len > LWIP_DEMO_RX_BUFSIZE)
break;
}
data_len = 0; /* 复制完成后data_len要清零*/
printf("%s\r\n", lwip_demo_recvbuf); /* 打印接收到的数据*/
netbuf_delete(recvbuf); /* 删除buf */
}
else
vTaskDelay(5); /* 延时5ms */
}
}
else
printf("UDP绑定失败\r\n");
}
else
printf("UDP连接创建失败\r\n");
}
可以看出,笔者调用NETCONN 接口配置UDP 连接,配置完成之后调用netconn_send 发
送数据到服务器当中,当然我们也可以调用netconn_recv 函数接收服务器的数据。
代码编译完成后下载到开发板中,初始化完成之后我们来看一下LCD 显示的内容,如下
图所示:
图16.2.3.1 LCD 显示
我们在来看一下串口调试助手如图16.2.3.2 所示,在串口调试助手上也输出了我们开发板
的IP 地址,子网掩码、默认网关等信息。
图16.2.3.2 串口调试助手
我们通过网络调试助手发送数据到开发板当中,结果如图16.2.3.3 所示,当然我们可以通
过开发板上的KEY0 发送数据到网络调式助手当中,如图16.2.3.4 所示:
图16.2.3.4 网络调试助手接收数据
本章实验中开发板做TCP 服务器,网络调试助手做客户端,网络调试助手连接TCP 服务
器(开发板)。连接成功后网络调试助手可以给开发板发送数据,开发板接收数据并通过串口将
接收到的数据发送到串口调试助手上,也可以通过按键从开发板向网络调试助手发送数据。本
章分为如下几个部分:
19.1 NETCONN 实现TCP 服务器
19.2 NETCONN 接口的TCPServer 实验
NETCONN 实现TCP 服务器有以下几步:
①调用函数netconn_new 创建TCP 控制块。
②调用函数netconn_bind 绑定TCP 控制块、本地IP 地址和端口号。
③调用函数netconn_listen 进入监听模式。
④设置接收超时时间conn->recv_timeout。
⑤调用函数netconn_accept 接收连接请求。
⑥调用函数netconn_getaddr 获取远端IP 地址和端口号。
⑦调用函数netconn_write 和netconn_recv 收发数据。
至于TCP 协议的知识,请大家参看第十二章的内容。
18.2.2.1 netconn 的TCPServer 连接步骤
void lwip_demo(void)
{
uint32_t data_len = 0;
struct pbuf *q;
err_t err, recv_err;
uint8_t remot_addr[4];
struct netconn *conn, *newconn;
static ip_addr_t ipaddr;
static u16_t port;
/* 第一步:创建一个TCP控制块*/
conn = netconn_new(NETCONN_TCP); /* 创建一个TCP链接*/
/* 第二步:绑定TCP控制块、本地IP地址和端口号*/
netconn_bind(conn, IP_ADDR_ANY, LWIP_DEMO_PORT); /* 绑定端口8088号端口*/
/* 第三步:监听*/
netconn_listen(conn); /* 进入监听模式*/
conn->recv_timeout = 10; /* 禁止阻塞线程等待10ms */
while (1)
{
/* 第四步:接收连接请求*/
err = netconn_accept(conn, &newconn); /* 接收连接请求*/
if (err == ERR_OK)
newconn->recv_timeout = 10;
if (err == ERR_OK) /* 处理新连接的数据*/
{
struct netbuf *recvbuf;
netconn_getaddr(newconn, &ipaddr, &port, 0); /* 获取远端IP地址和端口号*/
remot_addr[3] = (uint8_t)(ipaddr.addr >> 24);
remot_addr[2] = (uint8_t)(ipaddr.addr >> 16);
remot_addr[1] = (uint8_t)(ipaddr.addr >> 8);
remot_addr[0] = (uint8_t)(ipaddr.addr);
while (1)
{
/*有数据要发送*/
if ((lwip_send_flag & LWIP_SEND_DATA) == LWIP_SEND_DATA)
{
err = netconn_write(newconn, tcp_server_sendbuf,
strlen((char *)tcp_server_sendbuf),
NETCONN_COPY); /*发送tcp_server_sendbuf中的数据*/
if (err != ERR_OK)
{
}
lwip_send_flag &= ~LWIP_SEND_DATA;
}
/*接收到数据*/
if ((recv_err = netconn_recv(newconn, &recvbuf)) == ERR_OK)
{
taskENTER_CRITICAL(); /*进入临界区*/
/*数据接收缓冲区清零*/
memset(lwip_demo_recvbuf, 0, LWIP_DEMO_RX_BUFSIZE);
for (q = recvbuf->p; q != NULL; q = q->next) /*遍历完整个pbuf链表*/
{
if (q->len > (LWIP_DEMO_RX_BUFSIZE - data_len))
memcpy(lwip_demo_recvbuf + data_len, q->payload,
(LWIP_DEMO_RX_BUFSIZE - data_len)); /* 拷贝数据**/
else
memcpy(lwip_demo_recvbuf + data_len, q->payload,
q->len);
data_len += q->len;
/* 超出TCP客户端接收数组,跳出*/
if (data_len > LWIP_DEMO_RX_BUFSIZE)
break;
}
taskEXIT_CRITICAL(); /* 退出临界区*/
data_len = 0; /* 复制完成后data_len要清零*/
netbuf_delete(recvbuf);
}
else if (recv_err == ERR_CLSD) /* 关闭连接*/
{
netconn_close(newconn);
netconn_delete(newconn);
break;
}
}
}
}
}
上述的源码结构和上一章节的TCPClient 实验非常相似,它们唯一不同的是连接步骤不同。
代码编译完成后下载到开发板中,初始化完成之后我们来看一下LCD 显示的内容,如下
图所示。
图18.2.3.1 LCD 显示。
我们在来看一下串口调试助手如图18.2.3.2 所示,在串口调试助手上也输出了我们开发板
的IP 地址,子网掩码、默认网关等信息。
图18.2.3.2 串口调试助手
我们通过网络调试助手发送数据到开发板当中,结果如图18.2.3.3 所示,当然我们可以通
过开发板上的KEY0 发送数据到网络调式助手当中,如图18.2.3.4 所示:
图18.2.3.4 网络调试助手接收数据
lwIP 作者为了能更大程度上方便开发者将其他平台上的网络应用程序移植到lwIP 上,也
为了能让更多开发者快速上手lwIP,他设计了第三种应用程序编程接口,即Socket API,但
是该接口受嵌入式处理器资源和性能的限制,部分Socket 接口并未在lwIP 中完全实现。
说到Socket,我们不得不提起BSD Socket,BSD Socket 是由加州伯克利大学为Unix 系统
开发出来的,所以被称为伯克利套接字(Internet Berkeley Sockets),BSD Socket 是采用C 语
言进程间通信库的应用程序接口(API),允许不同主机或者同一个计算机上的不同进程之间
的通信,支持多种I/O 设备和驱动,具体的实现是依赖操作系统的。这种接口对于TCP/IP 是
必不可少的,所以是互联网的基础技术之一,所以LWIP 也是引入该程序编程接口,虽然不能
完全实现BSD Socket,但是对于开发者来说,已经足够了。
在lwIP 抽象出来的Socket API 中,lwIP 内核为用户提供了最多NUM_SOCKETS 个可使
用的Socket 描述符,并定义了结构体lwip_socket(对netconn 结构的封装和增强)来描述一个
具体连接。内核定义了数组Sockets,通过一个Socket 描述符就可以索引得到相应的连接结构
lwip_socket,从而实现对连接的操作。连接结构lwip_socket 的数据结构实现源码如下:
#define NUM_SOCKETS MEMP_NUM_NETCONN
#ifndef SELWAIT_T
#define SELWAIT_T u8_t
#endif
union lwip_sock_lastdata
{
struct netbuf *netbuf;
struct pbuf *pbuf;
};
/* 包含套接字使用的所有内部指针和状态*/
struct lwip_sock
{
/* 套接字目前构建在网络上,每个套接字有一个netconn */
struct netconn *conn;
/* 读上一次读取中留下的数据*/
union lwip_sock_lastdata lastdata;
#if LWIP_SOCKET_SELECT || LWIP_SOCKET_POLL
/* 接收数据的次数,由event_callback()设置,
通过接收和选择功能进行测试*/
s16_t rcvevent;
/* 数据被隔离的次数(发送缓冲区),由event_callback()设置,
测试选择*/
u16_t sendevent;
/* 这个套接字发生错误,由event_callback()设置,由select测试*/
u16_t errevent;
/* 使用select计算有多少线程正在等待这个套接字*/
SELWAIT_T select_waiting;
#endif /* LWIP_SOCKET_SELECT || LWIP_SOCKET_POLL */
#if LWIP_NETCONN_FULLDUPLEX
/* 多少线程使用struct lwip_sock(不是'int')的计数器*/
u8_t fd_used;
/* 挂起的关闭/删除操作的状态*/
u8_t fd_free_pending;
#define LWIP_SOCK_FD_FREE_TCP 1
#define LWIP_SOCK_FD_FREE_FREE 2
#endif
};
lwip_socket 结构是对连接结构netconn 的再次封装,在内核中,它对lwip_socket 的操作最
终都会映射到对netconn 结构的操作上。简单来讲,Socket API 完全依赖netconn 接口实现的。
(1) socket 函数
该函数的原型,如下源码所示:
#define socket(domain,type,protocol) lwip_socket(domain,type,protocol)
向内核申请一个套接字,本质上该函数调用了函数lwip_socket,该函数的参数如下表所
示:
(2) bind 函数
该函数的原型,如下源码所示:
#define bind(s,name,namelen) lwip_bind(s,name,namelen)
int bind(int s, const struct sockaddr *name, socklen_t namelen)
该函数与netconn_bind 函数一样,用于服务器端绑定套接字与网卡信息,本质上就是对函数netconn_bind 再一次封装,从上述源码可以知道参数name 指向一个sockaddr 结构体,它包
含了本地IP 地址和端口号等信息;参数namelen 指出结构体的长度。结构体sockaddr 定义如
下源码所示:
struct sockaddr
{
u8_t sa_len; /* 长度*/
sa_family_t sa_family; /* 协议簇*/
char sa_data[14]; /* 连续的14 字节信息*/
};
struct sockaddr_in
{
u8_t sin_len; /* 长度*/
u8_t sin_family; /* 协议簇*/
u16_t sin_port; /* 端口号*/
struct in_addr sin_addr; /* IP地址*/
char sin_zero[8];
}
可以看出,lwIP 作者定义了两个结构体,结构体sockaddr 中的sa_family 指向该套接字所
使用的协议簇,本地IP 地址和端口号等信息在sa_data 数组里面定义,这里暂未用到。由于
sa_data 以连续空间的方式存在,所以用户要填写其中的IP 字段和端口port 字段,这样会比较
麻烦,因此lwIP 定义了另一个结构体sockaddr_in,它与sockaddr 结构对等,只是从中抽出IP
地址和端口号port,方便于用于的编程操作。
(3) connect 函数
该函数与netconn 接口的netconn_connect 函数作用是一样的,因此它是被netconn_connect
函数封装了,该函数的作用是将Socket 与远程IP 地址和端口号绑定,如果开发板作为客户端,
通常使用这个函数来绑定服务器的IP 地址和端口号,对于TCP 连接,调用这个函数会使客户
端与服务器之间发生连接握手过程,并建立稳定的连接;如果是UDP 连接,该函数调用不会
有任何数据包被发送,只是在连接结构中记录下服务器的地址信息。当调用成功时,函数返回
0;否则返回-1。该函数的原型如下源码所示:
#define connect(s,name,namelen) lwip_connect(s,name,namelen)
int lwip_connect(int s, const struct sockaddr *name, socklen_t namelen);
(4) listen 函数
该函数和netconn 的函数netconn_listen 作用一样,它是由函数netconn_listen 封装得来的,
内核同时接收到多个连接请求时,需要对这些请求进行排队处理,参数backlog 指明了该套接
字上连接请求队列的最大长度。当调用成功时,函数返回0;否则返回-1。该函数的原型如下
源码所示:
#define listen(s,backlog) lwip_listen(s,backlog)
int lwip_listen(int s, int backlog);
注意:该函数作用于TCP 服务器程序。
(5) accept 函数
该函数与netconn_accept 作用是一样的,当接收到新连接后,连接另一端(客户端)的地
址信息会被填入到地址结构addr 中,而对应地址信息的长度被记录到addrlen 中。函数返回新
连接的套接字描述符,若调用失败,函数返回-1。该函数的原型如下源码所示:
#define accept(s,addr,addrlen) lwip_accept(s,addr,addrlen)
int lwip_accept(int s, struct sockaddr *addr, socklen_t *addrlen);
注意:该函数作用于TCP 服务器程序。
(6) send()/sendto()函数
该函数是被netconn_send 封装的,其作用是向另一端发送UDP 报文,这两个函数的原型
如下源码所示:
#define send(s,dataptr,size,flags) lwip_send(s,dataptr,size,flags)
#define sendto(s,dataptr,size,flags,to,tolen) lwip_sendto(s,dataptr,size,flags,to,tolen)
ssize_t lwip_send(int s, const void *dataptr, size_t size, int flags);
ssize_t lwip_sendto(int s, const void *dataptr, size_t size, int flags,
const struct sockaddr *to, socklen_t tolen);
可以看出,函数sendto 比函数send 多了两个参数,该函数如下表所示:
(7) write 函数
该函数用于在一条已经建立的连接上发送数据,通常使用在TCP 程序中,但在UDP 程序
中也能使用。该函数本质上是基于前面介绍的send 函数来实现的,其参数的意义与send 也相
同。当函数调用成功时,返回成功发送的字节数;否则返回-1。
(8) read()/recv()/recvfrom()函数
函数recvfrom 和recv 用来从一个套接字中接收数据,该函数可以在UDP 程序使用,也可
在TCP 程序中使用。该函数本质上是被函数netconn_recv 的封装,其参数与函数sendto 的参
数完全相似,如表20.2.3 所示,数据发送方的地址信息会被填写到from 中,fromlen 指明了缓
存from 的长度;mem 和len 分别记录了接收数据的缓存起始地址和缓存长度,flags 指明用户
控制接收的方式,通常设置为0。两个函数的原型如下源码所示:
#define recv(s, mem, len, flags) lwip_recv(s, mem, len, flags)
#define recvfrom(s, mem, len, flags, from, fromlen) lwip_recvfrom(s, mem, len, flags, from, fromlen)
ssize_t lwip_readv(int s, const struct iovec *iov, int iovcnt);
ssize_t lwip_recvfrom(int s, void *mem, size_t len, int flags,
struct sockaddr *from, socklen_t *fromlen);
#define read(s, mem, len) lwip_read(s, mem, len)
ssize_t lwip_read(
int s,
void *mem,
size_t len);
(9) close 函数
函数close 作用是关闭套接字,对应的套接字描述符不再有效,与描述符对应的内核结构
lwip_Socket 也将被全部复位。该函数本质上是被netconn_delete 的封装,对于TCP 连接来说,
该函数将导致断开握手过程的发生。若调用成功,该函数返回0;否则返回-1。该函数的原型
如下源码所示:
#define close(s) lwip_close(s)
int lwip_close(int s);
这些函数到底如何调用,请大家看第十五章节的内容,这里已经很详细讲解了
API消息如何发送到内核并调用相关的函数。
对于lwIP 的Socket 的使用方式,其实和文件操作相类似的。如果我们打开一个文件首先
打开-读/写-关闭,在TCP/IP 网络通信中同样存在这些操作,不过它使用的接口不是文件描述
符或者FILE*,而是一个称做Socket 的描述符。对于Socket,它也可以通过读、写、打开、
关闭操作来进行网络数据传送。同时,还有一些辅助的函数,如域名/IP 地址查询、Socket 功
能设置等。本章我们使用Scokrt 编程接口实现UDP 实验。本章分为如下几个部分:
20.1 Socket 编程UDP 连接流程
20.2 Socket 接口的UDP 实验
实现UDP 协议之前,用户必须先配置结构体sockaddr_in 的成员变量才能实现UDP 连接,
该配置步骤如下所示:
①sin_family 设置为AF_INET 表示IPv4 网络协议。
②sin_port 为设置端口号,笔者设置为8080。
③sin_addr.s_addr 设置本地IP 地址。
④调用函数Socket 创建Socket 连接,注意:该函数的第二个参数SOCK_STREAM 表
示TCP 连接,SOCK_DGRAM 表示UDP 连接。
⑤调用函数bind 将本地服务器地址与Socket 进行绑定。
⑥调用收发函数接收或者发送。
20.2.1 硬件设计
/**
* @brief 发送数据线程
* @param 无
* @retval 无
*/
void lwip_data_send(void)
{
sys_thread_new("lwip_send_thread", lwip_send_thread, NULL,
512, LWIP_SEND_THREAD_PRIO);
}
/**
* @brief lwip_demo实验入口
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
BaseType_t lwip_err;
lwip_data_send(); /* 创建发送数据线程*/
memset(&local_info, 0, sizeof(struct sockaddr_in)); /* 将服务器地址清空*/
local_info.sin_len = sizeof(local_info);
local_info.sin_family = AF_INET; /* IPv4地址*/
local_info.sin_port = htons(LWIP_DEMO_PORT); /* 设置端口号*/
local_info.sin_addr.s_addr = htons(INADDR_ANY); /* 设置本地IP地址*/
sock_fd = Socket(AF_INET, SOCK_DGRAM, 0); /* 建立一个新的Socket连接*/
/* 建立绑定*/
bind(sock_fd, (struct sockaddr *)&local_info, sizeof(struct sockaddr_in));
while (1)
{
memset(lwip_demo_recvbuf, 0, sizeof(lwip_demo_recvbuf));
recv(sock_fd, (void *)lwip_demo_recvbuf, sizeof(lwip_demo_recvbuf), 0);
lwip_err = xQueueSend(Display_Queue, &lwip_demo_recvbuf, 0);
if (lwip_err == errQUEUE_FULL)
{
printf("队列Key_Queue已满,数据发送失败!\r\n");
}
}
}
/**
* @brief 发送数据线程函数
* @param pvParameters : 传入参数(未用到)
* @retval 无
*/
void lwip_send_thread(void *pvParameters)
{
pvParameters = pvParameters;
local_info.sin_addr.s_addr = inet_addr(IP_ADDR); /* 需要发送的远程IP地址*/
while (1)
{
if ((lwip_send_flag & LWIP_SEND_DATA) == LWIP_SEND_DATA)
{
sendto(sock_fd, /* Socket */
(char *)lwip_demo_sendbuf, /* 发送的数据*/
sizeof(lwip_demo_sendbuf), 0, /* 发送的数据大小*/
(struct sockaddr *)&local_info, /* 接收端地址信息*/
sizeof(local_info)); /* 接收端地址信息大小*/
lwip_send_flag &= ~LWIP_SEND_DATA;
}
vTaskDelay(100);
}
}
从上述的源码可知:笔者在lwip_demo 函数中调用lwip_data_send 函数创建
“lwip_send_thread”发送数据线程,创建发送任务之后配置Socket 的UDP 协议,这个配置流
程笔者已经在21.1 小节讲解过了,这里无需重复讲解,该函数的while()循环主要调用recv 函
数获取数据并使用消息队列发送。至于lwip_send_thread 发送数据数据线程函数非常简单,它
主要判断发送标志位是否有效,如果标志位有效,则程序调用sendto 发送数据并设置标志位
无效。
注意:函数recv 一般处于阻塞状态,当然它可以设置为非阻塞。在lwip_send_thread 线程
中,我们还需要执行“local.sin_addr.s_addr = inet_addr(IP_addr)”这段代码,因为我们必须知
道数据发送到哪里,所以宏定义IP_addr 需要根据自己的远程IP 地址来设置。
打开串口调试助手和网络调试助手,注意必须查看PC 机上的IP 地址为多少才能确定程
序的宏定义IP_addr 数值,如下源码所示:
#define IP_addr "192.168.1.37" /* 远程IP */
代码编译完成后下载到开发板中,等待lwIP 一系列初始化和等待DHCP 分配IP 地址,下
面我们来看一下LCD 显示的内容,如图20.2.3.1 所示:
我们在来看一下串口调试助手如图20.2.3.2 所示,在串口调试助手上也输出了我们开发板
的IP 地址,子网掩码、默认网关等信息。
图20.2.3.2 串口调试助手
我们通过网络调试助手发送数据到开发板当中,结果如图20.2.3.3 所示,当然我们可以通
过开发板上的KEY0 发送数据到网络调式助手当中,如图20.2.3.4 所示:
图20.2.3.3 LCD 显示
图20.2.3.4 网络调试助手接收数据
关于TCP 协议的相关知识,请参考第12 章的内容。本章,笔者重点讲解lwIP 的Socket
接口如何配置TCP 服务器,并在此基础上实现收发功能。本章分为如下几个部分:
22.1 Socket 编程TCP 服务器流程
22.2 Socket 接口的TCPServer 实验
实现TCP 服务器之前,用户必须先配置结构体sockaddr_in 的成员变量才能实现
TCPServer 连接,该配置步骤如下所示:
①sin_family 设置为AF_INET 表示IPv4 网络协议。
②sin_port 为设置端口号。
③sin_addr.s_addr 设置本地IP 地址。
④调用函数Socket 创建Socket 连接,注意:该函数的第二个参数SOCK_STREAM 表
示TCP 连接,SOCK_DGRAM 表示UDP 连接。
⑤调用函数bind 绑定本地IP 地址和端口号。
⑥调用函数listen 监听连接请求。
⑦调用函数accept 监听连接。
⑧调用收发函数进行通讯。
上述的步骤就是Socket 编程接口配置TCPServer 的流程。
22.2.2.1 程序流程图
本实验的程序流程图,如下图所示:
程序解析
本实验,我们着重讲解lwip_demo.c 文件,该文件实现了三个函数,它们分别为
lwip_data_send、lwip_demo 和lwip_send_thread 函数,下面笔者分别地讲解它们的实现功能。
/**
* @brief 发送数据线程
* @param 无
* @retval 无
*/
void lwip_data_send(void)
{
sys_thread_new("lwip_send_thread", lwip_send_thread, NULL,
512, LWIP_SEND_THREAD_PRIO);
}
此函数调用sys_thread_new 函数创建发送数据线程,它的线程函数为lwip_send_thread,
稍后我们重点会讲解。
/**
* @brief lwip_demo实验入口
* @param 无
* @retval 无
*/
void lwip_demo()
{
struct sockaddr_in server_addr; /* 服务器地址*/
struct sockaddr_in conn_addr; /* 连接地址*/
socklen_t addr_len; /* 地址长度*/
int err;
int length;
int sock_fd;
char *tbuf;
BaseType_t lwip_err;
lwip_data_send(); /* 创建一个发送线程*/
sock_fd = Socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);
memset(&server_addr, 0, sizeof(server_addr)); /* 将服务器地址清空*/
server_addr.sin_family = AF_INET; /* 地址家族*/
server_addr.sin_addr.s_addr = htonl(INADDR_ANY); /* 注意转化为网络字节序*/
/* 使用SERVER_PORT指定为程序头设定的端口号*/
server_addr.sin_port = htons(LWIP_DEMO_PORT);
tbuf = mymalloc(SRAMIN, 200); /* 申请内存*/
sprintf((char *)tbuf, "Port:%d", LWIP_DEMO_PORT); /* 客户端端口号*/
lcd_show_string(5, 150, 200, 16, 16, tbuf, BLUE);
/* 建立绑定*/
err = bind(sock_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
if (err < 0) /* 如果绑定失败则关闭套接字*/
{
closeSocket(sock_fd); /* 关闭套接字*/
myfree(SRAMIN, tbuf);
}
err = listen(sock_fd, 4); /* 监听连接请求*/
if (err < 0) /* 如果监听失败则关闭套接字*/
{
closeSocket(sock_fd); /* 关闭套接字*/
}
while (1)
{
lwip_connect_state = 0;
addr_len = sizeof(struct sockaddr_in); /* 将链接地址赋值给addr_len */
/* 对监听到的请求进行连接,状态赋值给sock_conn */
sock_conn = accept(sock_fd, (struct sockaddr *)&conn_addr, &addr_len);
if (sock_conn < 0) /* 状态小于0代表连接故障,此时关闭套接字*/
{
closeSocket(sock_fd);
}
else
{
lwip_connect_state = 1;
}
while (1)
{
length = recv(sock_conn, (unsigned int *)lwip_demo_recvbuf,
sizeof(lwip_demo_recvbuf), 0);
if (length <= 0)
{
goto atk_exit;
}
lwip_err = xQueueSend(Display_Queue, &lwip_demo_recvbuf, 0);
if (lwip_err == errQUEUE_FULL)
{
printf("队列Key_Queue已满,数据发送失败!\r\n");
}
}
atk_exit:
if (sock_conn >= 0)
{
closeSocket(sock_conn);
sock_conn = -1;
lcd_fill(5, 89, lcddev.width, 110, WHITE);
lcd_show_string(5, 90, 200, 16, 16, "State:Disconnect", BLUE);
myfree(SRAMIN, tbuf);
}
}
}
根据22.1 小节的流程配置server_addr 结构体的字段,配置完成之后调用listen 和accept 监
听客户端连接请求,接着调用recv 函数接收客户端的数据,并且把数据以消息的方式发送至
其他线程当中。
/**
* @brief 发送数据线程函数
* @param pvParameters : 传入参数(未用到)
* @retval 无
*/
static void lwip_send_thread(void *pvParameters)
{
pvParameters = pvParameters;
while (1)
{
if (((lwip_send_flag & LWIP_SEND_DATA) == LWIP_SEND_DATA) && (lwip_connect_state == 1)) /* 有数据要发送*/
{
/* 发送数据*/
send(sock_conn, lwip_demo_sendbuf, sizeof(lwip_demo_sendbuf), 0);
lwip_send_flag &= ~LWIP_SEND_DATA;
}
vTaskDelay(10);
}
}
此线程函数非常简单,它主要判断lwip_send_flag 变量的状态,若该变量的状态为发送状
态,则程序调用send 函数发送数据,并且清除lwip_send_flag 变量的状态。
初始化完成之后LCD 显示以下信息,如下图所示:
我们通过网络调试助手发送数据至开发板,开发板接收完成之后LCD 在指定位置显示接
收的数据,如下图所示:
图22.2.3.2 LCD 显示
当然,读者可通过KEY0 按键发送数据至网络调试助手。