前几章的实验我们都没有使用操作系统,因此它们采用的是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 连接步骤
图16.2.2.2.1 NETCONN 编程UDP 实验流程图
16.2.2.3 程序解析
在本章实验中,我们最主要关注两个文件,它们分别为lwip_demo.c 和lwip_demo.h 文件,lwip_demo.h 文件主要定义了端口号、数据标识位以及声明lwip_demo 函数,所以这些不需要我们去讲解,这里我们主要看lwip_demo.c 文件的函数。在16.1 小节中笔者已经列出实现UDP 实验的步骤,在此基础上调用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.3 LCD 显示
图16.2.3.4 网络调试助手接收数据
本章实验中开发板做TCP 客户端,网络调试助手为TCP 服务器。开发板连接到TCP 服务器(网络调试助手),网络调试助手给开发板发送数据,开发板接收数据并通过串口将接收到的数据发送到串口调试助手上,也可以通过按键从开发板向网络调试助手发送数据。
NETCONN 实现TCP 客户端连接有以下几步:
至于TCP 协议的知识,请读者擦看第十二章的内容。
17.2.2.1 netconn 的TCPClient 连接步骤
17.2.2.2 程序流程图
本实验的程序流程图,如下图所示:
17.2.2.3 程序解析
打开我们的例程,找到lwip_demo.c 和lwip_demo.h 两个文件,这两个文件就是我本章实验的源码,在lwip_demo.c 中我们实现了一个函数lwip_demo,同上一章一样,都有操作系统的支持下,如下源码所示:
void lwip_demo(void) {
uint32_t data_len = 0;
struct pbuf * q;
err_t err, recv_err;
ip4_addr_t server_ipaddr, loca_ipaddr;
static uint16_t server_port, loca_port;
char * tbuf;
server_port = LWIP_DEMO_PORT;
IP4_ADDR( & server_ipaddr, DEST_IP_ADDR0, DEST_IP_ADDR1,
DEST_IP_ADDR2, DEST_IP_ADDR3); /* 构造目的IP地址*/
tbuf = mymalloc(SRAMIN, 200); /* 申请内存*/
sprintf((char * ) tbuf, "Port:%d", LWIP_DEMO_PORT); /* 客户端端口号*/
lcd_show_string(5, 150, 200, 16, 16, tbuf, BLUE);
myfree(SRAMIN, tbuf);
while (1) {
tcp_clientconn = netconn_new(NETCONN_TCP); /*创建一个TCP链接*/
/*连接服务器*/
err = netconn_connect(tcp_clientconn, & server_ipaddr, server_port);
if (err != ERR_OK) {
printf("接连失败\r\n");
/*返回值不等于ERR_OK,删除tcp_clientconn连接*/
netconn_delete(tcp_clientconn);
} else if (err == ERR_OK) /*处理新连接的数据*/ {
struct netbuf * recvbuf;
tcp_clientconn - > recv_timeout = 10;
/*获取本地IP主机IP地址和端口号*/
netconn_getaddr(tcp_clientconn, & loca_ipaddr, & loca_port, 1);
printf("连接上服务器%d.%d.%d.%d,本机端口号为:%d\r\n",
DEST_IP_ADDR0,
DEST_IP_ADDR1,
DEST_IP_ADDR2,
DEST_IP_ADDR3, loca_port);
while (1) {
/*有数据要发送*/
if ((tcp_client_flag & LWIP_SEND_DATA) == LWIP_SEND_DATA) {
/* 发送tcp_server_sentbuf中的数据*/
err = netconn_write(tcp_clientconn, tcp_client_sendbuf,
strlen((char * ) tcp_client_sendbuf), NETCONN_COPY);
if (err != ERR_OK) {
printf("发送失败\r\n");
}
tcp_client_flag &= ~LWIP_SEND_DATA;
}
/*接收到数据*/
if ((recv_err = netconn_recv(tcp_clientconn, & 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;
if (data_len > TCP_CLIENT_RX_BUFSIZE) {
break; /*超出TCP客户端接收数组,跳出*/
}
}
taskEXIT_CRITICAL(); /*退出临界区*/
data_len = 0; /*复制完成后data_len要清零*/
printf("%s\r\n", lwip_demo_recvbuf);
netbuf_delete(recvbuf);
} else if (recv_err == ERR_CLSD) /*关闭连接*/ {
netconn_close(tcp_clientconn);
netconn_delete(tcp_clientconn);
printf("服务器%d.%d.%d.%d断开连接\r\n", DEST_IP_ADDR0,
DEST_IP_ADDR1, DEST_IP_ADDR2, DEST_IP_ADDR3);
lcd_fill(5, 89, lcddev.width, 110, WHITE);
break;
}
}
}
}
}
上述的源码结构和上一章节的UDP 实验非常相似,它们唯一不同的是连接步骤以及发送函数不同,注意:上述函数做了一个判断服务器与客户端的连接状态,如果这个连接状态是断开状态,则系统不断的调用函数netconn_connect 连接服务器,直到连接成功才进入第二个while 循环执行发送接收工作。
代码编译完成后下载到开发板中,初始化完成之后我们来看一下LCD 显示的内容,如下图所示。
我们在来看一下串口调试助手如图17.2.3.2 所示,在串口调试助手上也输出了我们开发板的IP 地址,子网掩码、默认网关等信息。
我们通过网络调试助手发送数据到开发板当中,结果如图17.2.3.3 所示,当然我们可以通过开发板上的KEY0 发送数据到网络调式助手当中,如图17.2.3.4 所示:
图17.2.3.3 LCD 显示
图17.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 连接步骤
程序解析
打开我们的例程,找到lwip_demo.c 和lwip_demo.h 两个文件,这两个文件就是我本章实验的源码,在lwip_demo.c 中我们实现了一个函数lwip_demo,同上一章一样,都有操作系统的支持下,如下源码所示:
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.3 LCD 显示
图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 硬件设计
程序解析
在本章节中,我们最主要关注两个文件,分别为lwip_demo.c 和lwip_demo.h 文件,lwip_demo.h 文件主要定义了发送标志位以及声明lwip_demo 函数,这里比较简单我们不需要去讲解,主要看lwip_demo.c 文件的函数,我们在lwip_demo 函数中配置相关UDP 参数以及创建了一个发送数据线程lwip_send_thread,这个发送线程就是调用scokec 函数发送数据到服务器中,下面我们分别地讲解以下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);
}
/**
* @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 客户端,并在此基础上实现收发功能。
实现TCP 客户端之前,用户必须先配置结构体sockaddr_in 的成员变量才能实现TCPClient 连接,该配置步骤如下所示:
①sin_family 设置为AF_INET 表示IPv4 网络协议。
②sin_port 为设置端口号。
③sin_addr.s_addr 设置远程IP 地址。
④调用函数Socket 创建Socket 连接,注意:该函数的第二个参数SOCK_STREAM 表示TCP 连接,SOCK_DGRAM 表示UDP 连接。
⑤调用函数connect 连接远程IP 地址。
⑥调用收发函数实现远程通讯。
21.2.2.1 程序流程图
本实验的程序流程图,如下图所示:
1.2.2.2 程序解析
本实验,我们着重讲解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(void) {
struct sockaddr_in atk_client_addr;
err_t err;
int recv_data_len;
BaseType_t lwip_err;
char * tbuf;
lwip_data_send(); /* 创建发送数据线程*/
while (1) {
sock_start: lwip_connect_state = 0;
atk_client_addr.sin_family = AF_INET; /* 表示IPv4网络协议*/
atk_client_addr.sin_port = htons(LWIP_DEMO_PORT); /* 端口号*/
atk_client_addr.sin_addr.s_addr = inet_addr(IP_ADDR); /* 远程IP地址*/
sock = Socket(AF_INET, SOCK_STREAM, 0); /* 可靠数据流交付服务既是TCP协议*/
memset( & (atk_client_addr.sin_zero), 0,
sizeof(atk_client_addr.sin_zero));
tbuf = mymalloc(SRAMIN, 200); /* 申请内存*/
sprintf((char * ) tbuf, "Port:%d", LWIP_DEMO_PORT); /* 客户端端口号*/
lcd_show_string(5, 150, 200, 16, 16, tbuf, BLUE);
/* 连接远程IP地址*/
err = connect(sock, (struct sockaddr * ) & atk_client_addr,
sizeof(struct sockaddr));
if (err == -1) {
printf("连接失败\r\n");
sock = -1;
closeSocket(sock);
myfree(SRAMIN, tbuf);
vTaskDelay(10);
goto sock_start;
}
printf("连接成功\r\n");
lwip_connect_state = 1;
while (1) {
recv_data_len = recv(sock, lwip_demo_recvbuf,
LWIP_DEMO_RX_BUFSIZE, 0);
if (recv_data_len <= 0) {
closeSocket(sock);
sock = -1;
lcd_fill(5, 89, lcddev.width, 110, WHITE);
lcd_show_string(5, 90, 200, 16, 16, "State:Disconnect", BLUE);
myfree(SRAMIN, tbuf);
goto sock_start;
}
/* 接收的数据*/
lwip_err = xQueueSend(Display_Queue, & lwip_demo_recvbuf, 0);
if (lwip_err == errQUEUE_FULL) {
printf("队列Key_Queue已满,数据发送失败!\r\n");
}
vTaskDelay(10);
}
}
}
根据21.1 小节的流程配置server_addr 结构体的字段,配置完成之后调用connect 连接远程服务器,接着调用recv 函数接收客户端的数据,并且把数据以消息的方式发送至其他线程当中。
/**
* @brief 发送数据线程函数
* @param pvParameters : 传入参数(未用到)
* @retval 无
*/
void lwip_send_thread(void * pvParameters) {
pvParameters = pvParameters;
err_t err;
while (1) {
while (1) {
if (((lwip_send_flag & LWIP_SEND_DATA) == LWIP_SEND_DATA) && (lwip_connect_state == 1)) /* 有数据要发送*/ {
err = write(sock, lwip_demo_sendbuf, sizeof(lwip_demo_sendbuf));
if (err < 0) {
break;
}
lwip_send_flag &= ~LWIP_SEND_DATA;
}
vTaskDelay(10);
}
closeSocket(sock);
}
}
此线程函数非常简单,它主要判断lwip_send_flag 变量的状态,若该变量的状态为发送状态,则程序调用write 函数发送数据,并且清除lwip_send_flag 变量的状态。
初始化完成之后LCD 显示以下信息,如下图所示:
我们通过网络调试助手发送数据至开发板,开发板接收完成之后LCD 在指定位置显示接收的数据,如下图所示:
当然,读者可通过KEY0 按键发送数据至网络调试助手。
关于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 按键发送数据至网络调试助手。
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_time 函数计算时间。
(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/iOS 等多个平台,它也可以测试最大TCP 和UDP 带宽性能,具有多种参数和UDP 特性,可以根据需要调整,可以报告带宽、延迟抖动和数据包丢失,该软件下载地址是:https://iperf.fr/iperf-download.php。
下载完成之后打开该软件,可以看到该软件划分为几个区域,这些区域的作用如下所示:
网速相关数据输出窗口:以文本的形式输出
开始和停止JPerf
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 地址和端口号
图24.2.3.2 网速波形图
可以看到,我们的网速接近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/HTTPS 请求方式,将项目下应用数据、设备数据推送给用户指定服务器。本章主要介绍 lwIP 如何通过HTTP 协议将设备连接到OneNET 平台,并实现远程互通。
关于OneNET 平台HTTP 接入方式可参考该官方的文档手册,该文档手册地址https://open.iot.10086.cn/,本实验主要参考官方文档的多协议接入/HTTP/上传数据点的内容。
OneNTE 的HTTP 服务器流程
第一步:注册OneNTE 服务器账号,注册完成之后打开右上角的控制台/ 全部产品服务/多协议接入,如下图所示。
第二步:选择HTTP 协议/添加产品。
第三步:填写产品信息,如下图所示。
上图中的几个技术参数非常重要,剩下的技术参数根据用户的爱好填写。
第四步:双击创建的产品并点击设备列表且在设备列表中添加设备,如下图所示。
这些参数用户可以随便填写。
第五步:打开数据流,如下图所示。
第六步:打开数据流管理/添加数据流模板,如下图所示。
注意:上图的数据名称必须与程序发送数据的标志一样。
第七步:打开设备列表/设备详情查看设备信息,如下图所示。
上图中的设备ID 和APIKey 是我们需要的信息。
28.3.2.1 HTTP 配置步骤
28.3.2.2 程序流程图
本实验的程序流程图,如下图所示。
程序解析
本章实验中我们重点讲解lwip_demo.c 和lwip_demo.h。
lwip_demo.h 文件很简单,主要声明OneNET 平台的设备ID 和设备密钥,而lwip_demo.c 文件定义了2 个函数,这些函数的作用如下表所示。
uint32_t lwip_onehttp_postpkt(char * pkt, /* 保存的数据*/
char * key, /* 连接onenet的apikey */
char * devid, /* 连接onenet的onenet_id */
char * dsid, /* onenet的显示字段*/
char * val ) /* 该字段的值*/
{
char dataBuf[100] = { 0 };
char lenBuf[10] = {0 };
* pkt = 0;
sprintf(dataBuf, ",;%s,%s", dsid, val); /* 采用分割字符串格式:type = 5 */
sprintf(lenBuf, "%d", strlen(dataBuf));
strcat(pkt, "POST /devices/");
strcat(pkt, devid);
strcat(pkt, "/datapoints?type=5 HTTP/1.1\r\n");
strcat(pkt, "api-key:");
strcat(pkt, key);
strcat(pkt, "\r\n");
strcat(pkt, "Host:api.heclouds.com\r\n");
strcat(pkt, "Content-Length:");
strcat(pkt, lenBuf);
strcat(pkt, "\r\n\r\n");
strcat(pkt, dataBuf);
return strlen(pkt);
}
上述源码主要采用典型的C 语言基础,调用函数strcat 把两个字符串拼接成一个字符串,如果我们使用网络调试助手接收该数据包,那么我们发现该数据与OneNET 平台HTTP 协议接入文档描述是一致,该数据如下所示:
POST /devices/655766336/datapoints?type=5 HTTP/1.1
api-key:rw2p2Fq=VW4fhhhkj4CwpVcqJq8=
Host:api.heclouds.com
Content-Length:13
,;humidity,00 ---------------->湿度数据
POST /devices/655766336/datapoints?type=5 HTTP/1.1
api-key:rw2p2Fq=VW4fhhhkj4CwpVcqJq8=
Host:api.heclouds.com
Content-Length:16
,;temperature,00 ---------------->温度数据
/**
* @brief lwip_demo程序入口
* @param 无
* @retval 无
*/
void lwip_demo(void)
{
uint32_t data_len = 0;
struct pbuf * q;
err_t err;
ip4_addr_t server_ipaddr, loca_ipaddr;
static uint16_t server_port, loca_port;
server_port = TCP_DEMO_PORT;
netconn_gethostbyname(DEST_MANE, & server_ipaddr);
while (1) {
atk_start: tcp_clientconn = netconn_new(NETCONN_TCP); /* 创建一个TCP链接*/
err = netconn_connect(tcp_clientconn, & server_ipaddr, server_port); /* 连接服务器*/
if (err != ERR_OK) {
printf("接连失败\r\n");
/* 返回值不等于ERR_OK,删除tcp_clientconn连接*/
netconn_delete(tcp_clientconn);
} else if (err == ERR_OK) /* 处理新连接的数据*/ {
struct netbuf * recvbuf;
tcp_clientconn - > recv_timeout = 10;
/* 获取本地IP主机IP地址和端口号*/
netconn_getaddr(tcp_clientconn, & loca_ipaddr, & loca_port, 1);
lcd_show_string(5, 170, 200, 16, 16, "link succeed", BLUE);
while (1)
{
temp_rh[0] = 30 + rand() % 10 + 1; /* 温度的数据*/
temp_rh[1] = 54.8 + rand() % 10 + 1; /* 湿度的数据*/
tempStr[0] = temp_rh[0] / 10 + 0x30; /* 上传温度*/
tempStr[1] = temp_rh[0] % 10 + 0x30;;
humiStr[0] = temp_rh[1] / 10 + 0x30; /* 上传湿度*/
humiStr[1] = temp_rh[1] % 10 + 0x30;
/* 发送tcp_server_sentbuf中的数据*/
len = lwip_onehttp_postpkt(buffer, apikey,onenet_id, "temperature", tempStr); // 组包HTTP数据
netconn_write(tcp_clientconn, buffer, len, NETCONN_COPY);
/* 发送tcp_server_sentbuf中的数据*/
len = lwip_onehttp_postpkt(buffer, apikey,onenet_id, "humidity", humiStr); // 组包HTTP数据
netconn_write(tcp_clientconn, buffer, len, NETCONN_COPY);
vTaskDelay(1000);
/* 接收到数据*/
if (netconn_recv(tcp_clientconn, & recvbuf) == ERR_OK)
{
taskENTER_CRITICAL(); /* 进入临界区*/
/* 数据接收缓冲区清零*/
memset(tcp_client_recvbuf, 0, TCP_CLIENT_RX_BUFSIZE);
/*遍历完整个pbuf链表*/
for (q = recvbuf - > p; q != NULL; q = q - > next) {
if (q - > len > (TCP_CLIENT_RX_BUFSIZE - data_len))
{
memcpy(tcp_client_recvbuf + data_len, q - > payload, (TCP_CLIENT_RX_BUFSIZE - data_len));
}
else
{
memcpy(tcp_client_recvbuf + data_len, q - > payload, q - > len);
}
data_len += q - > len;
if (data_len > TCP_CLIENT_RX_BUFSIZE)
{
break; /* 超出TCP客户端接收数组,跳出*/
}
}
taskEXIT_CRITICAL(); /* 退出临界区*/
data_len = 0; /* 复制完成后data_len要清零*/
printf("%s\r\n", tcp_client_recvbuf);
netbuf_delete(recvbuf);
}
else /*关闭连接*/
{
netconn_close(tcp_clientconn);
netconn_delete(tcp_clientconn);
goto atk_start;
}
}
}
}
}
我们编译代码下载到开发板并运行,打开数据流展示,如下图所示。
本章实验我们在开发板上搭建一个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”请求是向服务器转送数据。
程序解析
本实验重点看lwip_demo.c 文件,该文件定义了三个函数,如下表所示:
lwip_demo 函数用来配置网络环境,这里笔者把开发板设置为TCP 服务器,其端口号为80。
/**
* @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 地址,如下图所示:
本章主要学习lwIP 提供的 MQTT 协议文件使用,通过MQTT 协议将设备连接到阿里云服务器,实现远程互通。由于 MQTT 协议是基于TCP 的协议实现的,所以我们只需要在单片机端实现TCP 客户端程序并使用 lwIP 提供的MQTT 文件来连接阿里云服务器。
(1) MQTT 是什么?
MQTT(Message Queuing Telemetry Transport,消息队列遥测传输协议),是一种基于发布/订阅(Publish/Subscribe)模式的轻量级通讯协议,该协议构建于TCP/IP 协议上,由IBM 在1999 年发布,目前最新版本为v3.1.1。MQTT 最大的优点在于可以以极少的代码和有限的带宽,为远程设备提供实时可靠的消息服务。做为一种低开销、低带宽占用的即时通讯协议,MQTT在物联网、小型设备、移动应用等方面有广泛的应用,MQTT 协议属于应用层。
(2) MQTT 协议特点
MQTT 是一个基于客户端与服务器的消息发布/订阅传输协议。MQTT 协议是轻量、简单开放和易于实现的,这些特点使它适用范围非常广泛。在很多情况下,包括受限境中,如:机器与机器(M2M)通信和物联网(IoT)。其在,通过卫星链路通信传感器、医疗设备、智能家居、及一些小型化设备中已广泛使用。
(3) MQTT 协议原理及实现方式
实现MQTT 协议需要:客户端和服务器端MQTT 协议中有三种身份:发布者(Publish)、代理(Broker)(服务器)、订阅者(Subscribe)。其中,消息的发布者和订阅者都是客户端,消息代理是服务器,消息发布者可以同时是订阅者,如下图所示。
MQTT 传输的消息分为:主题(Topic)和消息的内容(payload)两部分。
Topic:可以理解为消息的类型,订阅者订阅(Subscribe)后,就会收到该主题的消息内容(payload)。
Payload:可以理解为消息的内容,是指订阅者具体要使用的内容。
如果发布和订阅时的质量级别QoS 都是至多一次,那代理服务则检查当前订阅这个主题的客户端是否在线,在线则转发一次,收到与否不再做任何处理。这种质量对系统压力最小。
如果发布和订阅时的质量级别QoS 都是至少一次,那要保证代理服务和订阅的客户端都有成功收到才可以,否则会尝试补充发送(具体机制后面讨论)。这也可能会出现同一主题多次重复发送的情况。这种质量对系统压力较大。
如果发布和订阅时的质量级别QoS 都是只有一次,那要保证代理服务和订阅的客户端都有成功收到,并只收到一次不会重复发送(具体机制后面讨论)。这种质量对系统压力最大。
其实移植 lwIP 的 MQTT 文件是非常简单的,只将 lwip\src\apps\mqt 路径下的 mqtt.c 文件添加到工程当中,这里我们在工程中添加一个名为 Middlewares/lwip/src/apps 分组,该分组用来添加 lwIP 应用层的文件,如下图所示所示:
mqtt.c 文件是 lwIP 根据 MQTT 协议规则编写而来的,如果用户不使用这个文件,请自行移植MQTT 协议包。
在Middlewares/lwip/lwip_app 分组添加hmac_sha1 和sha1 文件,这些文件用来计算核心密钥,这两个文件可在阿里云官方下载。
配置阿里云服务器步骤
第一步:注册阿里云平台,打开产品分类/物联网Iot/物联网应用开发,如下图所示。
点击上图中的“立刻使用”按键进去物联网应用开发页面。
第二步:在物联网应用开发页面下点击项目管理/新建项目/新建空白项目,在此界面下填写项目名称等相关信息,如下图所示:
创建项目完成之后在项目管理页面下点击项目进去子项目管理界面,如下图所示:
第三步:在上图中点击产品,如下图所示:
注:上图中的节点类型、连网方式、数据格式以及认证模式的选择,其他产品参数根据用户爱好设置。
第三步:创建产品之后点击图26.1.3.3 中的设备选项添加设备,如下图所示。
第五步:在设备页面下找到我们刚刚创建的设备,如下图所示。
这三个参数非常重要!!!!!!!!!!,在本章实验中会用到。
第六步:打开“产品/查看/功能定义”路径,在该路径下添加功能定义,如下图所示。
第七步:打开自定义功能并发布上线,这里我们添加了两个CurrentTemperature 和RelativeHumidity 标签。
26.2.2.2 程序流程图
本实验的程序流程图,如下图所示。
程序解析
我们打开 lwip_deom.h 文件,在这个文件中我们定义了阿里云服务器创建设备的配置项,另外还声明了 lwip_demo 函数,关于阿里云服务器的MQTT 主题请大家查看阿里云相关手册。
重点关注的是lwip_deom.c 这个文件,在这个文件定义了8 个函数,如下表所示。
我们首先看一下lwip_demo 函数,该函数的代码如下。
/**
* @brief lwip_demo进程
* @param 无
* @retval 无
*/
void lwip_demo(void) {
struct hostent * server;
static struct mqtt_connect_client_info_t mqtt_client_info;
server = gethostbyname((char * ) HOST_NAME); /* 对oneNET服务器地址解析*/
/* 把解析好的地址存放在mqtt_ip变量当中*/
memcpy( & mqtt_ip, server - > h_addr, server - > h_length);
char * PASSWORD;
PASSWORD = mymalloc(SRAMIN, 300); /* 为密码申请内存*/
/* 通过hmac_sha1算法得到password */
lwip_ali_get_password(DEVICE_SECRET, CONTENT, PASSWORD);
/* 设置一个空的客户端信息结构*/
memset( & mqtt_client_info, 0, sizeof(mqtt_client_info));
/* 设置客户端的信息量*/
mqtt_client_info.client_id = (char * ) CLIENT_ID; /* 设备名称*/
mqtt_client_info.client_user = (char * ) USER_NAME; /* 产品ID */
mqtt_client_info.client_pass = (char * ) PASSWORD; /* 计算出来的密码*/
mqtt_client_info.keep_alive = 100; /* 保活时间*/
mqtt_client_info.will_msg = NULL;
mqtt_client_info.will_qos = NULL;
mqtt_client_info.will_retain = 0;
mqtt_client_info.will_topic = 0;
myfree(SRAMIN, PASSWORD); /* 释放内存*/
/* 创建MQTT客户端控制块*/
mqtt_client = mqtt_client_new();
/* 连接服务器*/
mqtt_client_connect(mqtt_client, /* 服务器控制块*/
& mqtt_ip,
MQTT_PORT, /* 服务器IP与端口号*/
mqtt_connection_cb, /* 设置服务器连接回调函数*/
LWIP_CONST_CAST(void * , & mqtt_client_info), & mqtt_client_info); /* MQTT连接信息*/
while (1) {
if (publish_flag == 1) {
temp = 30 + rand() % 10 + 1; /* 温度的数据*/
humid = 54.8 + rand() % 10 + 1; /* 湿度的数据*/
sprintf((char * ) payload_out,
"{\"params\":{\"CurrentTemperature\":+ % 0.1 f, \"RelativeHumidity\":%0.1f},\"method\":\
"thing.event.property.post\"}", temp, humid);
payload_out_len = strlen((char * ) payload_out);
mqtt_publish(mqtt_client, DEVICE_PUBLISH, payload_out,payload_out_len, 1, 0, mqtt_publish_request_cb, NULL);
}
vTaskDelay(1000);
}
}
此函数非常简单,首先我们调用gethostbyname 函数解析阿里云的域名,根据这个域名来连接阿里云服务器,其次使用一个结构体配置MQTT 客户端的信息并调用mqtt_client_new 函数创建MQTT 服务器控制块,接着我们调用mqtt_client_connect 函数连接阿里云服务器并添加mqtt_connection_cb 连接回调函数,最后在while()语句中判断是否订阅操作成功,如果系统订阅成功,则构建MQTT 消息,并调用mqtt_publish 函数发布。
接下来我们来讲解一下 mqtt_connection_cb 函数的作用,如下源码所示:
/**
* @brief mqtt连接回调函数
* @param client:客户端控制块
* @param arg:传入的参数
* @param status:连接状态
* @retval 无
*/
static void mqtt_connection_cb(mqtt_client_t * client, void * arg,mqtt_connection_status_t status)
{
err_t err;
const struct mqtt_connect_client_info_t * client_info = (const struct mqtt_connect_client_info_t * ) arg;
LWIP_UNUSED_ARG(client);
printf("\r\nMQTT client \"%s\" connection cb: status %d\r\n", client_info - > client_id, (int) status);
/* 判断是否连接*/
if (status == MQTT_CONNECT_ACCEPTED)
{
/* 判断是否连接*/
if (mqtt_client_is_connected(client))
{
/* 设置传入发布请求的回调*/
mqtt_set_inpub_callback(mqtt_client,
mqtt_incoming_publish_cb,
mqtt_incoming_data_cb,
NULL);
/* 订阅操作,并设置订阅响应会回调函数mqtt_sub_request_cb */
err = mqtt_subscribe(client, DEVICE_SUBSCRIBE, 1,mqtt_request_cb, arg);
if (err == ERR_OK) {
printf("mqtt_subscribe return: %d\n", err);
lcd_show_string(5, 170, 210, 16, 16,"mqtt_subscribe succeed", BLUE);
}
}
}
else /* 连接失败*/
{
printf("mqtt_connection_cb: Disconnected, reason: %d\n", status);
}
}
此函数也是非常简单,它主要调用函数mqtt_client_is_connected 判断是否已经连接服务器,如果连接成功,则程序调用函数mqtt_set_inpub_callback 添加mqtt_incoming_publish_cb 和mqtt_incoming_data_cb 回调函数,这些回调函数需要根据客户端以及服务器的发布操作才能进去该回调函数,最后我们调用函数mqtt_subscribe 对服务器进行订阅操作并且添加mqtt_request_cb 订阅响应回调函数。
下载完代码后,在浏览器上打开阿里云平台,并在指定的网页查看上存数据,如下图所示。
本章主要介绍 lwIP 如何通过 MQTT 协议将设备连接到 OneNET 平台,并通过MQTT 协议远程互通。关于 MQTT 协议的知识,请参考第二十六章节的内容。
配置OneNET 服务器步骤:
第一步:首先打开OneNET 服务器并注册账号,注册之后在主界面下打开产品服务页面下的MQTT 物联网套件,如下图所示:
第二步:在上图中点击“立刻使用”选项,页面跳转完成之后点击“添加产品”选项,此时该页面会弹出产品信息小界面,这里我们根据自己的项目填写相关的信息,如下图所示:
上图中,我们重点添加的选项有联网方式和设备接入协议,这里笔者选择移动蜂窝网络以及MQTT 协议接入,至于其他选项根据爱好选择。创建MQTT 产品之后用户可以得到该产品的信息,如下图示:
本实验会用到上述的产品信息,例如产品ID(366007)、“access_key”产品密钥以及产品名称(MQTT_TSET)等。
第三步:在产品页面下点击设备列表添加设备,如下图所示:
第四步:在上图创建的设备中,点击右边的详情标签进入标签的链接页面,在这个页面下我们得到以下设备信息,如下图所示:
本实验会用到上图中的设备ID(617747917)、设备名称MQTT 以及“key”设备的密钥。
下面我们打开OneNET 在线开发指南,在这个指南中找到服务器地址,这些服务器地址就是MQTT 服务器地址,如下图所示:
上图中,OneNTE 的MQTT 服务器具有两个连接方式,一种是加密接口连接,而另一种是非加密接口连接,本章实验使用的是非加密接口连接MQTT 服务器。
注:MQTT 物联网套件采用安全鉴权策略进行访问认证,即通过核心密钥计算的 token 进行访问认证,简单来讲,用户想连接OneNET的MQTT 服务器必须计算核心密钥,这个密钥是根据我们前面创建的产品和设备相关的信息计算得来的,密钥的计算方法可以使用OneNET提供的token生成工具计算,该软件可在这个网址下载:https://open.iot.10086.cn/doc/v5/develop/detail/242。
下面笔者简单讲解一下token 生成工具的使用,如图27.1.1.7 所示:
res:输入格式为“products/{pid}/devices/{device_name}”,这个输入格式中的“pid”就是我们MQTT 产品ID,而“device_name”就是设备的名称。根据前面创建的产品和设备来填写res 选项的参数,如下图所示:
et:访问过期时间(expirationTime,unix)时间,这里笔者选择参考文档中的数值(1672735919),如下图所示:
key:指选择设备的key 密钥,如下图所示:
最后按下上图中的“Generate”按键生成核心密钥,如下图所示。
这个核心密钥会在MQTT 客户端的结构体client_pass 成员变量保存。
小上节我们使用token 生成工具根据产品信息以及设备信息来计算核心密钥,这样的方式导致每次创建一个设备都必须根据这个设备信息再一次计算核心密钥才能连接,这种方式会大大地降低我们的开发效率,为了解决这个问题,笔者使用另一个方法,那就是使用代码的方式计算核心密钥,它和上一章节中的方式不一样,因为阿里云和OneNET 计算的方式不同,所以不能使用阿里云的那两个文件来计算OneNET 的密钥。OneOS 源码中有几个文件是用来计算MQTT 协议连接OneNET 平台的核心密钥,这些文件在oneos2.0\components\cloud\onenet\mqtt-kit\authorization 路径下,大家先下载OneOS 源码并在该路径下复制这些文件到工程当中。
打开工程并在Middlewares/lwip/lwip_app 分组下添加以下文件,如下图所示:
这些文件都在oneos2.0\components\cloud\onenet\mqtt-kit\authorization 路径下获取。
程序解析
我们打开lwip_deom.h 文件,在这个文件中我们定义了OneNET 服务器创建设备的配置项,另外还声明了lwip_demo 函数,关于OneNET 服务器的MQTT 主题请大家查看OneNET 相关手册,该手册地址为https://open.iot.10086.cn/doc/v5/develop/detail/251,这个地址里面已经说明了OneNET 的MQTT 服务器相关主题信息。至于lwip_deom.c 文件前面我们已经讲解过了,它们唯一不同的是计算核心密钥方式。
我们编译代码,并把下载到开发板上运行,打开OneNET 的MQTT 服务器查看数据流展示,如下图所示。
网络摄像头是传统摄像机与网络视频技术相结合的新一代产品,除了具备一般传统摄像机
所有的图像捕捉功能外,机内还内置了数字化压缩控制器和基于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,2623 * 1951)的执行部分,也就是在传感器里面开窗(X_ADDR_ST、Y_ADDR_ST、X_ADDR_END、Y_ADDR_END),开窗范围从00~26231951 都可以设置,该窗口所设置的范围,将输入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 网络调试助手显示内容