16. 使用Socket接口编程¶
16.1. 什么是Socket¶
Socket英文原意是“孔”或者“插座”的意思,在网络编程中,通常将其称之为“套接字”,当前网络中的主流程序设计都是使用Socket进行编程的,因为它简单易用,更是一个标准,能在不同平台很方便移植。本章讲解的是LwIP中的Socket编程接口,因为LwIP作者为了能让更多开发者直接上手LwIP的编程,专门设计了LwIP的第三种编程接口——Socket API,它兼容BSD Socket。
Socket虽然是能在多平台移植,但是LwIP中的Socket并不完善,因为LwIP设计之初就是为了在嵌入式平台中使用,它只实现了完整Socket的部分功能,不过,在嵌入式平台中,这些功能早已足够。
在Socket中,它使用一个套接字来记录网络的一个连接,套接字是一个整数,就像我们操作文件一样,利用一个文件描述符,可以对它打开、读、写、关闭等操作,类似的,在网络中,我们也可以对Socket套接字进行这样子的操作,比如开启一个网络的连接、读取连接主机发送来的数据、向连接的主机发送数据、终止连接等操作。
16.2. LwIP中的Socket¶
在LwIP中,Socket API是基于NETCONN API之上来实现的,系统最多提供MEMP_NUM_NETCONN 个netconn连接结构,因此Socket套接字的个数也是那么多个,为了更好对netconn进行封装,LwIP还定义了一个套接字结构体——lwip_sock(我称之为Socket连接结构),每个lwip_sock内部都有一个netconn的指针,实现了对netconn的再次封装,那怎么找到lwip_sock这个结构体呢?LwIP定义了一个lwip_sock 类型的sockets数组,通过套接字就可以直接索引并且访问这个结构体了,这也是为什么套接字是一个整数的原因,lwip_sock结构体是比较简单的,因为基本上全是依赖netconn实现,具体见 代码清单16_1。
代码清单 16‑1 LwIP中的Socket相关数据结构
#define NUM_SOCKETS MEMP_NUM_NETCONN
/** 全局可用套接字数组(默认是4) */
static struct lwip_sock sockets[NUM_SOCKETS];
union lwip_sock_lastdata
{
struct netbuf *netbuf;
struct pbuf *pbuf;
};
/** 包含用于套接字的所有内部指针和状态 */
struct lwip_sock
{
/** 套接字当前是在netconn上构建的,每个套接字都有一个netconn */
struct netconn *conn;
/** 从上一次读取中留下的数据 */
union lwip_sock_lastdata lastdata;
/** 收到数据的次数由event_callback()记录,下面的字段在select机制上使用 */
s16_t rcvevent;
/** 发送数据的次数,也是由回调函数记录的 */
u16_t sendevent;
/** Socket上的发生的错误次数 */
u16_t errevent;
/** 使用select等待此套接字的线程数 */
SELWAIT_T select_waiting;
};
16.3. Socket API¶
16.3.1. socket()¶
这个函数的功能是向内核申请一个套接字,在本质上该函数其实就是对netconn_new()函数进行了封装,虽然说不是直接调用它,但是主体完成的工作就做了 netconn_new()函数的事情,而且该函数本质是一个宏定义,具体见 代码清单16_2。
代码清单 16‑2 socket()
#define socket(domain,type,protocol) \
lwip_socket(domain,type,protocol)
int
lwip_socket(int domain, int type, int protocol);
#define AF_INET 2
/* Socket服务类型 (TCP/UDP/RAW) */
#define SOCK_STREAM 1
#define SOCK_DGRAM 2
#define SOCK_RAW 3
参数domain表示该套接字使用的协议簇,对于TCP/IP协议来说,该值始终为AF_INET。
参数type指定了套接字使用的服务类型,可能的类型有3种:
SOCK_STREAM:提供可靠的(即能保证数据正确传送到对方)面向连接的Socket服务,多用于资料(如文件)传输,如TCP协议。
SOCK_DGRAM:是提供无保障的面向消息的Socket 服务,主要用于在网络上发广播信息,如UDP协议,提供无连接不可靠的数据报交付服务。
SOCK_RAW:表示原始套接字,它允许应用程序访问网络层的原始数据包,这个套接字用得比较少,暂时不用理会它。
参数protocol指定了套接字使用的协议,在IPv4中,只有TCP协议提供SOCK_STREAM这种可靠的服务,只有UDP协议提供SOCK_DGRAM服务,对于这两种协议,protocol的值均为0。
当申请套接字成功的时候,该函数返回一个int类型的值,也是Socket描述符,用户通过这个值可以索引到一个Socket连接结构——lwip_sock,当申请套接字失败时,该函数返回-1。
16.3.2. bind()¶
该函数的功能与netconn_bind()函数是一样的,用于服务器端绑定套接字与网卡信息, 实际上就是对netconn_bind()函数进行了封装,可以将一个申请成功的套接字与网卡信息进行绑定, 其函数原型具体见 代码清单16_3
代码清单 16‑3 bind()
#define bind(s,name,namelen) \
lwip_bind(s,name,namelen)
int lwip_bind(int s,
const struct sockaddr *name,
socklen_t namelen);
参数s是表示要绑定的Socket套接字,注意了,这个套机字必须是从socket()函数中返回的索引,否则将无法完成绑定操作。
参数name是一个指向sockaddr结构体的指针,其中包含了网卡的IP地址、端口号等重要的信息,LwIP为了更好描述这些信息,使用了sockaddr结构体来定义了必要的信息的字段,它常被用于Socket API的很多函数中,我们在使用bind()的时候,只需要直接填写相关字段即可,sockaddr结构体具体见 代码清单16_4。
参数namelen指定了name结构体的长度。
代码清单 16‑4sockaddr结构体
struct sockaddr
{
u8_t sa_len; /* 长度 */
sa_family_t sa_family; /* 协议簇 */
char sa_data[14]; /* 连续的14字节信息 */
};
咋一看这个结构体,好像没啥信息要我们填写的,确实也是这样子,我们需要填写的IP地址与端口号等信息,都在sa_data连续的14字节信息里面,但是这个数据对我们不友好,因此LwIP还定义了另一个对开发者更加友好的结构体——sockaddr_in,我们一般也是用这个结构体,具体见 代码清单16_5
代码清单 16‑5sockaddr_in结构体
struct sockaddr_in
{
u8_t sin_len;
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
#define SIN_ZERO_LEN 8
char sin_zero[SIN_ZERO_LEN];
};
这个结构体的前两个字段是与sockaddr结构体的前两个字段一致,而剩下的字段就是sa_data连续的14字节信息里面的内容,只不过从新定义了成员变量而已,sin_port字段是我们需要填写的端口号信息,sin_addr字段是我们需要填写的IP地址信息,剩下sin_zero 区域的8字节保留未用。
那么这个函数应该怎么使用呢?具体见 代码清单16_6,
代码清单 16‑6 bind()函数的使用方法
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
printf("Socket error\n");
}
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(5001);
memset(&(server_addr.sin_zero), 0, sizeof(server_addr.sin_zero));
if(bind(sock,(struct sockaddr *)&server_addr,sizeof(struct sockaddr))==-1)
{
printf("Unable to bind\n");
}
16.3.3. connect()¶
这个函数的作用与netconn_connect()函数的作用基本一致,因为就是封装了netconn_connect()函数。它用于客户端中,将Socket与远端IP地址、端口号进行绑定,在TCP客户端连接中,调用这个函数将发生握手过程(会发送一个TCP连接请求),并最终建立新的TCP连接,而对于UDP协议来说,调用这个函数只是在UDP控制块中记录远端IP地址与端口号,而不发送任何数据,参数信息与bind()函数是一样的,具体见 代码清单16_7。
代码清单 16‑7 connect()
#define connect(s,name,namelen) \
lwip_connect(s,name,namelen)
int
lwip_connect(int s,
const struct sockaddr *name,
socklen_t namelen);
16.3.4. listen()¶
该函数是对netconn_listen()函数的封装,只能在TCP服务器中使用,让服务器进入监听状态, 等待远端的连接请求,LwIP中可以接收多个客户端的连接,因此参数backlog指定了请求队列的大小,具体见 代码清单16_8。
代码清单 16‑8 listen()
#define listen(s,backlog) \
lwip_listen(s,backlog)
int
lwip_listen(int s, int backlog);
16.3.5. accept()¶
accept()函数与netconn_accept()函数作用一样,用于TCP服务器中,等待着远端主机的连接请求, 并且建立一个新的TCP连接,在调用这个函数之前需要通过调用listen()函数让服务器进入监听状态。 accept()函数的调用会阻塞应用线程直至与远程主机建立TCP连接。参数addr是一个返回结果参数, 它的值由accept()函数设置,其实就是远程主机的地址与端口号等信息,当新的连接已经建立后, 远端主机的信息将保存在连接句柄中,它能够唯一的标识某个连接对象。同时函数返回一个int类型的套接字描述符, 根据它能索引到连接结构,如果连接失败则返回-1,具体见 代码清单16_9。
代码清单 16‑9 accept()
#define accept(s,addr,addrlen) \
lwip_accept(s,addr,addrlen)
int
lwip_accept(int s,
struct sockaddr *addr,
socklen_t *addrlen)
16.3.6. read()、recv()、recvfrom()¶
read()与recv()函数的核心是调用recvfrom()函数,而recvfrom()函数是基于netconn_recv()函数来实现的, recv()与read()函数用于从Socket中接收数据,它们可以是TCP协议和UDP协议, 具体见 代码清单16_10。
代码清单 16‑10 read()、recv()、recvfrom()
#define read(s,mem,len) \
lwip_read(s,mem,len)
ssize_t
lwip_read(int s, void *mem, size_t len)
{
return lwip_recvfrom(s, mem, len, 0, NULL, NULL);
}
#define recv(s,mem,len,flags) \
lwip_recv(s,mem,len,flags)
ssize_t
lwip_recv(int s, void *mem, size_t len, int flags)
{
return lwip_recvfrom(s, mem, len, flags, NULL, NULL);
}
#define recvfrom(s,mem,len,flags,from,fromlen) \
lwip_recvfrom(s,mem,len,flags,from,fromlen)
ssize_t
lwip_recvfrom(int s, void *mem, size_t len, int flags,
struct sockaddr *from, socklen_t *fromlen)
men参数记录了接收数据的缓存起始地址,len用于指定接收数据的最大长度,如果函数能正确接收到数据,将会返回一个接收到数据的长度,否则将返回-1,若返回值为0,表示连接已经终止,应用程序可以根据返回的值进行不一样的操作。recv()函数包含一个flags参数,我们暂时可以直接忽略它,设置为0即可。注意,如果接收的数据大于用户提供的缓存区,那么多余的数据会被直接丢弃。
16.3.7. sendto()¶
这个函数主要是用于UDP协议传输数据中,它向另一端的UDP主机发送一个UDP报文,本质上是对netconn_send()函数的封装, 参数data指定了要发送数据的起始地址,而size则指定数据的长度,参数flag指定了发送时候的一些处理,比如外带数据等, 此时我们不需要理会它,一般设置为0即可,参数to是一个指向sockaddr结构体的指针, 在这里需要我们自己提供远端主机的IP地址与端口号,并且用tolen参数指定这些信息的长度,具体见 代码清单16_11。
代码清单 16‑11 sendto()
#define sendto(s,dataptr,size,flags,to,tolen) \
lwip_sendto(s,dataptr,size,flags,to,tolen)
ssize_t
lwip_sendto(int s, const void *data, size_t size, int flags,
const struct sockaddr *to, socklen_t tolen)
16.3.8. send()¶
send()函数可以用于UDP协议和TCP连接发送数据。在调用send()函数之前,必须使用connect()函数将远端主机的IP地址、 端口号与Socket连接结构进行绑定。对于UDP协议,send()函数将调用lwip_sendto()函数发送数据,而对于TCP协议, 将调用netconn_write_partly()函数发送数据。相对于sendto()函数,参数基本是没啥区别的,但无需我们设置远端主机的信息, 更加方便操作,因此这个函数在实际中使用也是很多的,具体见 代码清单16_12。
代码清单 16‑12 send()
#define send(s,dataptr,size,flags) \
lwip_send(s,dataptr,size,flags)
ssize_t
lwip_send(int s, const void *data, size_t size, int flags)
16.3.9. write()¶
这个函数一般用于处于稳定的TCP连接中传输数据,当然也能用于UDP协议中,它也是基于lwip_send上实现的, 但是无需我们设置flag参数,具体见 代码清单16_13。
代码清单 16‑13 write()
#define write(s,dataptr,len) \
lwip_write(s,dataptr,len)
ssize_t
lwip_write(int s, const void *data, size_t size)
{
return lwip_send(s, data, size, 0);
}
16.3.10. close()¶
close()函数是用于关闭一个指定的套接字,在关闭套接字后,将无法使用对应的套接字描述符索引到连接结构, 该函数的本质是对netconn_delete()函数的封装(真正处理的函数是netconn_prepare_delete()), 如果连接是TCP协议,将产生一个请求终止连接的报文发送到对端主机中,如果是UDP协议,将直接释放UDP控制块的内容, 具体见 代码清单16_14。
代码清单 16‑14 close()
#define close(s) \
lwip_close(s)
int
lwip_close(int s)
16.3.11. ioctl()、ioctlsocket()¶
这两个函数很有意思(其实是一样的,本质是宏定义,都是调用lwip_ioctl()函数),它用于获取与设置套接字相关的操作参数,参数cmd指明对套接字的操作命令,在LwIP中只支持FIONREAD与FIONBIO命令:
FIONREAD命令确定套接字s自动读入的数据量,这些数据已经被接收,但应用线程并未读取的, 所以可以使用这个函数来获取这些数据的长度,在这个命令状态下,argp参数指向一个无符号长整型, 用于保存函数的返回值(即未读数据的长度)。如果套接字是SOCK_STREAM类型,则FIONREAD命令会返回recv()函数中所接收的所有数据量,这通常与在套接字接收缓存队列中排队的数据总量相同;而如果套接字是SOCK_DGRAM类型的,则FIONREAD命令将返回在套接字接收缓存队列中排队的第一个数据包大小。
FIONBIO命令用于允许或禁止套接字的非阻塞模式。在这个命令下,argp参数指向一个无符号长整型, 如果该值为0则表示禁止非阻塞模式,而如果该值非0则表示允许非阻塞模式则。当创建一个套接字的时候, 它就处于阻塞模式,也就是说非阻塞模式被禁止,这种情况下所有的发送、接收函数都会是阻塞的,直至发送、接收成功才得以继续运行;而如果是非阻塞模式下,所有的发送、接收函数都是不阻塞的,如果发送不出去或者接收不到数据,将直接返回错误代码给用户,这就需要用户对这些“意外”情况进行处理,保证代码的健壮性,这与BSD Socket是一致的。
其函数原型具体见 代码清单16_15。
代码清单 16‑15 ioctl()、ioctlsocket()
#define ioctl(s,cmd,argp) \
lwip_ioctl(s,cmd,argp)
#define ioctlsocket(s,cmd,argp) \
lwip_ioctl(s,cmd,argp)
int
lwip_ioctl(int s, long cmd, void *argp)
16.3.12. setsockopt()¶
看名字就知道,这个函数是用于设置套接字的一些选项的,参数level有多个常见的选项,如:
SOL_SOCKET:表示在Socket层。
IPPROTO_TCP:表示在TCP层。
IPPROTO_IP: 表示在IP层。
参数optname表示该层的具体选项名称,比如:
对于SOL_SOCKET选项,可以是SO_REUSEADDR(允许重用本地地址和端口)、SO_SNDTIMEO(设置发送数据超时时间)、 SO_SNDTIMEO(设置接收数据超时时间)、SO_RCVBUF(设置发送数据缓冲区大小)等等。
对于IPPROTO_TCP选项,可以是TCP_NODELAY(不使用Nagle算法)、TCP_KEEPALIVE(设置TCP保活时间)等等。
对于IPPROTO_IP选项,可以是IP_TTL(设置生存时间)、IP_TOS(设置服务类型)等等。
代码清单 16‑16 setsockopt()
#define setsockopt(s,level,optname,opval,optlen) \
lwip_setsockopt(s,level,optname,opval,optlen)
int
lwip_setsockopt(int s,
int level,
int optname,
const void *optval,
socklen_t optlen)
16.3.13. getsockopt()¶
这个函数与setsockopt()函数的选项参数及名称都是差不多的,只不过是作用是获得这些选项信息在这里就不过多讲解。
16.4. 实验¶
16.4.1. TCP Client¶
这个实验现象与NETCONN API中实验的是一样的,我们直接把上次的工程拷贝过来,然后将NETCONN API替换成Socket API就基本差不多了,我们首先在lwipopts.h文件中将宏LWIP_SOCKET配置为1,在文件中添加以下代码,注意,不要删除LWIP_NETCONN宏定义。
#define LWIP_SOCKET 1
在client.c文件中添加 代码清单16_17 所示代码,当然,端口号等信息根据你们自己的网络环境修改即可, 然后编译工程,下载到开发板上,电脑端的操作步骤与NETCONN API中实验操作步骤是一样的,就不再过多赘述了。
代码清单 16‑17client.c文件内容
#include "client.h"
#include "lwip/opt.h"
#include "lwip/sys.h"
#include "lwip/api.h"
#include <lwip/sockets.h>
#define PORT 5001
#define IP_ADDR "192.168.0.181"
static void client(void *thread_param)
{
int sock = -1;
struct sockaddr_in client_addr;
uint8_t send_buf[]= "This is a TCP Client test...\n";
while (1)
{
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
printf("Socket error\n");
vTaskDelay(10);
continue;
}
client_addr.sin_family = AF_INET;
client_addr.sin_port = htons(PORT);
client_addr.sin_addr.s_addr = inet_addr(IP_ADDR);
memset(&(client_addr.sin_zero), 0, sizeof(client_addr.sin_zero));
if (connect(sock,
(struct sockaddr *)&client_addr,
sizeof(struct sockaddr)) == -1)
{
printf("Connect failed!\n");
closesocket(sock);
vTaskDelay(10);
continue;
}
printf("Connect to iperf server successful!\n");
while (1)
{
if (write(sock,send_buf,sizeof(send_buf)) < 0)
break;
vTaskDelay(1000);
}
closesocket(sock);
}
}
void
client_init(void)
{
sys_thread_new("client", client, NULL, 512, 4);
}
16.4.2. TCP Server¶
同理,这个实验也只需把NETCONN API中的实验拷贝过来,然后在lwipopts.h文件中将宏LWIP_SOCKET配置为1,再将tcpecho.c文件的内容替换为 代码清单16_18 的内容即可,操作步骤也是一样的,然后编译工程并且下载到开发板上即可看到实验现象。
代码清单 16‑18tcpecho.c文件内容
#include "tcpecho.h"
#include "lwip/opt.h"
#if LWIP_SOCKET
#include <lwip/sockets.h>
#include "lwip/sys.h"
#include "lwip/api.h"
/*--------------------------------------------------------------------*/
#define PORT 5001
#define RECV_DATA (1024)
static void
tcpecho_thread(void *arg)
{
int sock = -1,connected;
char *recv_data;
struct sockaddr_in server_addr,client_addr;
socklen_t sin_size;
int recv_data_len;
recv_data = (char *)pvPortMalloc(RECV_DATA);
if (recv_data == NULL)
{
printf("No memory\n");
goto __exit;
}
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0)
{
printf("Socket error\n");
goto __exit;
}
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
memset(&(server_addr.sin_zero), 0, sizeof(server_addr.sin_zero));
if (bind(sock, (struct sockaddr *)&server_addr, sizeof(struct sockaddr)) == -1)
{
printf("Unable to bind\n");
goto __exit;
}
if (listen(sock, 5) == -1)
{
printf("Listen error\n");
goto __exit;
}
while (1)
{
sin_size = sizeof(struct sockaddr_in);
connected = accept(sock, (struct sockaddr *)&client_addr, &sin_size);
printf("new client connected from (%s, %d)\n",
inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));
{
int flag = 1;
setsockopt(connected,
IPPROTO_TCP, /* set option at TCP level */
TCP_NODELAY, /* name of option */
(void *) &flag, /* the cast is historical cruft */
sizeof(int)); /* length of option value */
}
while (1)
{
recv_data_len = recv(connected, recv_data, RECV_DATA, 0);
if (recv_data_len <= 0)
break;
printf("recv %d len data\n",recv_data_len);
write(connected,recv_data,recv_data_len);
}
if (connected >= 0)
closesocket(connected);
connected = -1;
}
__exit:
if (sock >= 0) closesocket(sock);
if (recv_data) free(recv_data);
}
void
tcpecho_init(void)
{
sys_thread_new("tcpecho_thread", tcpecho_thread, NULL, 512, 4);
}
16.4.3. UDP¶
同理,这个实验也只需把NETCONN API中的实验拷贝过来,然后在lwipopts.h文件中将宏LWIP_SOCKET配置为1,再将udpecho.c文件的内容替换为 代码清单16_19 的内容即可,操作步骤也是一样的,然后编译工程并且下载到开发板上即可看到实验现象。
代码清单 16‑19udpecho.c文件内容
#include "udpecho.h"
#include "lwip/opt.h"
#include <lwip/sockets.h>
#include "lwip/api.h"
#include "lwip/sys.h"
#define PORT 5001
#define RECV_DATA (1024)
/*-------------------------------------------------------------*/
static void
udpecho_thread(void *arg)
{
int sock = -1;
char *recv_data;
struct sockaddr_in udp_addr,seraddr;
int recv_data_len;
socklen_t addrlen;
while (1)
{
recv_data = (char *)pvPortMalloc(RECV_DATA);
if (recv_data == NULL)
{
printf("No memory\n");
goto __exit;
}
sock = socket(AF_INET, SOCK_DGRAM, 0);
if (sock < 0)
{
printf("Socket error\n");
goto __exit;
}
udp_addr.sin_family = AF_INET;
udp_addr.sin_addr.s_addr = INADDR_ANY;
udp_addr.sin_port = htons(PORT);
memset(&(udp_addr.sin_zero), 0, sizeof(udp_addr.sin_zero));
if (bind(sock, (struct sockaddr *)&udp_addr, sizeof(struct sockaddr)) == -1)
{
printf("Unable to bind\n");
goto __exit;
}
while (1)
{
recv_data_len=recvfrom(sock,recv_data,
RECV_DATA,0,
(struct sockaddr*)&seraddr,
&addrlen);
/*显示发送端的IP地址*/
printf("receive from %s\n",inet_ntoa(seraddr.sin_addr));
/*显示发送端发来的字串*/
printf("recevce:%s",recv_data);
/*将字串返回给发送端*/
sendto(sock,recv_data,
recv_data_len,0,
(struct sockaddr*)&seraddr,
addrlen);
}
__exit:
if (sock >= 0) closesocket(sock);
if (recv_data) free(recv_data);
}
}
/*---------------------------------------------------------------------*/
void
udpecho_init(void)
{
sys_thread_new("udpecho_thread", udpecho_thread, NULL, 2048, 4);
}