随着网络编程(C/C++)的学习,发现自己对于基本套接字编程有些细节还是不太清楚,然后又查看了对应的书籍和资料,趁着这段时间把这些基础知识梳理了一下,便于自己回顾,同时大家也可以作为参考 :) ~
套接字地址结构
struct in_addr { in_addr_t s_addr; // 32-bit IPv4 address //network byte ordered } struct sockaddr_in { sa_family_t sin_family; //AF_INET in_port_t sin_port; //16-bit TCP or UDP port nummber, network byte ordered struct in_addr sin_addr; //32-bit IPv4 address, network byte ordered char sin_zero[8]; //unused }
sockaddr_in是网络套接字地址结构,大小为16字节,定义在<netinet/in>头文件中,一般我们在程序中是使用该结构体,但是作为参数传递给套接字函数时需要强转为sockaddr类型,注意该结构体中port和addr成员是网络序的(大端结构)。
struct sockaddr { sa_family_t sa_family; //address family: AF_XXX value char sa_data[14]; //protocol-specific address }
sockaddr是通过套接字地址结构,当作为参数传递给套接字函数时,套接字地址结构总是以指针方式来使用,比如bind/accept/connect函数等。
htons、ntohs、htonl和ntohl函数
#include <netinet/in.h> uint16_t htons(uint16_t host16bitvalue); uint32_t htonl(uint32_t host32bitvalue); uint16_t ntohs(uint16_t net16bitvalue); uint32_t ntohl(uint32_t net32bitvalue);
Linux提供了4个函数来完成主机字节序和网络字节序之间的转换。这些函数名字中,h表示host,n表示net,s表示short,l表示long。使用这些函数时,并不关心主机字节序和网络字节序的真实值,也就是为大端还是小端,要做的只是调用适当的函数在主机和网络字节序之间转换为某个特定值。
ient_aton、inet_addr和inet_ntoa函数
#include <arpa/inet.h> int inet_aton(const char *strptr, struct in_addr *addrptr); // 返回:若字符有效则为1,否则为0 in_addr_t inet_addr(const char *strptr); // 返回:若字符串有效则为32位二进制网络字节序地址,否则为INADDR_NONE char *inet_ntoa(struct in_addr inaddr); // 返回:指向一个点分十进制数串的地址
inet_aton、inet_addr和inet_ntoa在点分十进制数串(比如"192.168.1.1")与它长度为32位的网络字节序二进制值间转换IPv4地址。在调用inet_addr时需特别注意,inet_ntoa函数的输入参数是unsigned int型的ip地址,返回的却是指向ip字符串的指针,很明显,ip字符串所占的内存是在函数内部分配的,而我们并不需要释放该内存,所以,它分配的内存是静态的,内部使用static变量存储IP点分十进制数串,也就是说第二次调用该函数时会覆盖第一次调用该函数时的内存。
inet_pton和inet_ntop函数
#include <arpa/inet.h> int inet_pton(int family, const char *strptr, void *addrptr); // 返回:成功为1,输入不是有效表达式返回0,出错为-1 const char *inet_ntop(int family, const void *addrptr, char *strptr, size_t len); // 返回:成功为指向结果的指针,出错为NULL
这两个函数对于IPv4和IPv6都适用,p代表表达式(presentation)、n表示数值(numeric)。第一个函数尝试转化由strptr指针所指的字符串,通过addptr指针存放二进制结果,成功返回1,如果对指定的family而言输入的不是有效的表达格式,那么返回0
inet_ntop进行相反的操作,如果len的值太小,不足以存放表达式结果,则返回一个空指针,并置error为ENOSPC。inet_ntop函数的strptr参数不可以是一个空指针,调用者必须为目标存储单元分配内存并制定其大小,调用成功时,这个指针就是该函数返回值。
socket函数
为了执行网络IO,一个进程必须做的第一件事就是调用socket函数,指定期望的通信协议类型(比如使用IPv4的TCP、使用IPv6的UDP、Unix域字节流协议)和套接字字类型(字节流、数据报或原始套接字)。
#include <sys/socket.h> int socket(int family, int type, int protocol); // 成功返回非负描述符,出错-1
family指定协议族,type指定套接字类型,protocol指定某个协议类型常值,或者设为0。
family的值有:
- AF_INET IPv4协议
- AF_INET6 Ipv6协议
- AF_LOCAL Unix协议域
- AF_ROUTE 路由套接字
- AF_KEY 秘钥套接字
type的值有:
- SOCK_STREAM 字节流套接字
- SOCK_DGRAM 数据报套接字
- SOCK_SEQPACKET 有序分组套接字
- SOCK_RAW 原始套接字
protocol的值有:
- IPPROTO_CP TCP传输协议
- IPPROTO_UDP UDP传输协议
- IPPROTO_SCTP SCTP传输协议
socket函数在成功时返回一个小的非负整数值,与文件描述符类似,成为套接字描述符,为了得到这个描述符,需要指定协议族和套接字类型,但是并没有指定本地协议地址和远端协议地址。
connect函数
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr* servaddr, socklen_t addrlen); // 返回:成功为0,出错-1
TCP客户用connect函数来建立一个与TCP服务器连接,sockfd是由socket函数返回的套接字描述符,第二个、第三个参数分别是指向一个套接字地址结构的指针和该结构的大小,套接字结构必须含有服务器的IP地址和端口号。注意:如果connect失败后,就必须close当前的套接字描述符并重新调用socket。客户端在调用connect前不必非得调用bind函数(比如UDP客户端编程中一般就不用调用bind),内核会确定源IP地址,并选择一个临时端口作为源端口。
如果是TCP套接字,调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或出错时才返回。注意:connect是在接收到服务端响应的SYN+ACK时的返回的,也就是三次握手的第二次动作之后。
UDP是可以调用connect函数的,但是UDP的connect函数和TCP的connect函数调用确是大相径庭的,这里没有三次握手过程。内核只是检查是否存在立即可知的错误(比如目的地址不可达),记录对端的IP和端口号,然后立即返回调用进程。使用了connect的UDP编程就可不必使用sendto函数了,直接使用write/read即可。
bind函数
#include <sys/socket.h> int bind(int sockfd, const struct sockaddr *myaddr, socklen_t addrlen); // 返回:成功为0,出错-1
bind函数把一个本地协议地址赋予一个套接字,它只是把一个协议地址赋予一个套接字,至于协议地址的含义则取决于协议本身。第二个参数指向协议地址结构的指针,第三个参数是协议地址的长度,对于TCP,调用bind函数可以指定一个端口号,或指定一个IP地址,或两者都指定,也可以两者都不指定。
bind函数绑定特定的IP地址必须属于其所在主机的网络接口之一,服务器在启动时绑定它们众所周知的端口,如果一个TCP客户端或服务端未曾调用bind绑定一个端口,当调用connect或listen时,内核就要为响应的套接字选择一个临时端口。让内核选择临时端口对于TCP客户端来说是正常的额,然后对于TCP服务端来说确实罕见的,因为服务端通过他们众所周知的端口被大家认识的。
listen函数
#include <sys/socket.h> int listen(int sockfd, int backlog); // 返回:成功返回0,出错-1
socket创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的一个客户套接字。listen函数把一个未连接的套接字转换为一个被动套接字,指示内核应接受指向该套接字的连接请求,调用listen函数将导致套接字从CLOSEE状态转换到LISTEN状态。第二个参数规定了内核应为相应套接字排队的最大连接个数。
- 未完成连接队列:每一个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RCVD状态。
- 已完成连接队列:每个完成TCP三路握手过程的客户对应其中一项,这些套接字处于ESTABLISHED状态。
图片来自《UNIX网络编程-卷一》
backlog参数在不同的系统中有不同的解释,不过大致类似。UNP(第3版)给出的定义为:listen()的backlog应该指定某个给定套接字上内核为之排队的最大已完成连接数。
当一个客户端SYN达到时,若这些队列是满的,TCP就忽略该分节,也即是不发送RST,这样做是暂时的,客户端将重新发送SYN,期望不就就能得到服务。假如服务端响应一个RST,客户端的connect就会返回错误,而不是让重传机制来处理,这样客户无法区分SYN的RST是因为"该端口没有在监听"还是"该端口在监听,只不过它的队列满了"。
在三路握手完成之后,但在服务端调用accept之前到达的数据应由服务端TCP排队,最大数据量为相应已连接套接字的接收缓冲区大小。
在TCP服务端套接字编程中,执行完listen后,而没有执行accept,客户端是可以成功建立连接的,只不过是该连接被加入到了已连接队列中,当调用accept时会被提取出来。
accept函数
#include <sys/socket.h> int accept(int sockfd, struct sockaddr *cliaddr, socklen_t *addrlen); // 返回:成功返回已连接描述符(非负),出错-1
accept函数有TCP服务器调用,用于从已完成队列中列头返回下一个已完成连接,如果已完成队列为空,则进程被投入睡眠(如果该套接字为阻塞方式的话)。如果accept成功,那么其返回值是由内核自动生成的一个全新套接字,代表与返回客户的TCP连接,函数的第一个参数为监听套接字,返回值为已连接套接字。
close函数
#include <unistd.h> int close(int sockfd); // 若成功返回0,出错-1
close一个TCP套接字的默认行为是把该套接字标记为已关闭,然后立即返回到调用进程。注意,close实质把该套接字引用值减1,如果该引用值大于0,则对应的套接字不会被真正关掉。
服务器、客户端交互流程图
TCP状态转换图
getsockname和getpeername函数
#include <sys/socket.h> int getsockname(int sockfd, struct sockaddr *localaddr, &addrlen); int getpeername(int sockfd, struct sockaddr *peeraddr, &addrlen); // 返回:成功为0, 出错为-1
getsockname获取sockfd对应的本端socket地址,并将其存储于address参数指定的内存地址,该socket长度存储于addrlen指向的变量中。getpeername获取远端的socket地址。
UDP客户端如果调用connect之后也是可以使用getpeername的。
recv和send函数
#include <sys/socket.h> ssize recv(int sockfd, void *buff, size_t nbytes, int flags); ssize send(int sockfd, void *buff, size_t nbytes, int flags); // 返回:成功为读入或写入的字节数,出错为-1
TCP流数据读写操作函数。flag取值如下所示:
- MSG_OOB 对于send,表明将要发送带外数据,TCP连接上只有一个字节可以作为带外数据发送,对于recv,本标志表明即将要读入的是带外数据而不是普通数据。
- MSG_PEEK 该标志适用于recv和recvfrom,它允许我们查看已可读取的数据,而且在系统不在recv和recvfrom返回丢弃其这些数据
注意的是,flags参数只对send和recv的当前调用有效,当然也可以通过setsockopt系统调用永久性地修 改socket的某些属性。
recvfrom和sendto函数
#include <sys/socket.h> ssize_t recvfrom(int sockfd, void *buf, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen); ssize_t recvto(int sockfd, void *buf, size_t nbytes, int flags, struct sockaddr *to, socklen_t addrlen); // 返回:成功为读或写的字节数,失败为-1
recvfrom和snedto的前3个参数和read/write的前3个参数一样。flags表示设置的标志值,简单的UDP程序可以直接设置为0,最后两个参数表示服务端地址(对于sendto来说)或者是对端地址(对于recvfrom来说)。如果不关心对端的地址,则设置为NULL,此时addrlen也可以设置为NULL了。
注意:recvfrom和sendto也可以应用于TCP编程,不过一般不这样用。UDP编程会有数据包的丢失问题,因为UDP是不可靠的,如果一个客户的数据包丢失,客户端将永远阻塞在recvfrom函数调用;类似的,如果客户数据到达了服务端,然后响应数据包丢失了,则客户永远阻塞在recvfrom调用。为了防止这样的问题出现,一般可以给recvfrom设置一个超时时间。简单的UDP使用recvfrom和sendto函数例子:探索UDP套接字编程。
参考资料:
1、《UNIX网络编程-卷一》