这只是一篇教程的笔记,用于帮助新手快速上手进行Unix Socket开发,要详细了解网络编程,还得看《Unix网络编程》、《TCP/IP协议》、或者其他的计算机网络教材。
基础知识
IPV4和IPV6
IPV4的表示是类似192.168.0.1这样的由点号分隔的4个0~255之间的数字,255=2^8,IPV4是32位二进制数字组成的地址,一共可以表示2^32个不同的地址,起初这个数字看起来很大,但如今世界上每个人、每个动植物、每个电子设备、甚至每一个电磁炉、冰箱、洗衣机、门把手都需要拥有自己的IP地址,从而联通到整个互联网中,这个数字已经不够大了。
为此,出现了IPV6,IPV6由128位二进制数字组成,这是个大到无法形容的数字,即使为地球上的每个人分配1亿亿个IP地址也措措有余,甚至还不到这个数字的零头。
IPV6不使用像IPV4那样的十进制数字来表示,它使用十六进制数字组来表示,一个十六进制数字可以被拆解为4个二进制数字,因此一个IPV6地址需要128 / 4 = 32个十六进制数字表示,世界上的标准化机构为IPV6的表示方式达成了一致,决定将每4个十六进制数字分为一组,使用一个32 / 4 = 8组4位十六进制数字来表示一个IPV6地址,组中间使用冒号分隔,比如:2001:0db8:c9d2:aee5:73e3:934a:a5ae:9551;因为IPV6所蕴含的地址数量实在太多,我们目前甚至用不完其中的千亿亿分之一,因此现实中使用的这个128位的地址中的很多位都被保留为0,如果有连续的多组全部是0,就可以将它们省略,使用::表示,比如下面这个IPV6地址:2001:0db8:c9d2:0012:0000:0000:0000:0051就可以表示成2001:db8:c9d2:12::51。
Unix编程接口为了迎接IPV6,也作出了改变,虽然在最初制定接口时,有考虑到未来的扩展,为表示网络地址的struct预留了很多暂不使用的空间,但令人感到尴尬的是,要正确表示IPV6地址,原来预留的那些空间还是不够用,后面在提到struct addrinfo和struct sockaddr时,会看到看起来很奇怪的类型定义和类型转换,都和这令人尴尬的历史有关。
网络协议栈
著名的ISO七层网络协议栈和事实上的Unix实现的五层网络协议栈是非常常规的授课内容,在Unix系统中,Ethernet以太网属于物理层和数据链路层的协议,IP协议属于网络层的协议,UDP和TCP属于传输层的协议,HTTP属于网络层的协议。关于这部分内容,《TCP/IP详解卷1:协议》有详略得当的说明。
端口号
IP地址确定了一台机器在网络中的地址,而端口号则代表着这台机器上运行着的许多程序,1024以下的端口号都是保留给历史上著名的程序使用的。
需要注意的是,对于服务来说,同一个端口号可以同时与多个客户端建立链接,比如http服务就能够同时处理多个客户端的请求,并且这些客户端的目的服务端口号都是80,操作系统会负责区分来自不同客户端的80端口上的链接,后面提到listen, accept这些函数时可以看到这一点。
端口号是一个16位的无符号整数。
字节序
网络字节序是Big Endian的,和Intel的处理器正好相反。
在C程序中,存在一系列名字很诡异的函数,它们是:htons, htonl, ntohs, ntohl, 等等,这些就是用来将本机的超过一个字节的数据转换为网络字节序的函数,中间的to就是to的意思,而h表示host,n表示network,最后的字母表示被转换的数据的类型,比如s就是short,l就是long;
Unix Socket 数据结构
Unix Socket地址结构如下,其中比较特殊的就是ai_addr指针,它看起来指向一个struct sockaddr结构体,但实际上,它指向的是sockaddr_in或者sockaddr_in6结构体,这其实是C编程中很常见的技巧了,因为在定义接口时,不仅需要兼容IPV4 Socket,还需要兼容其他类型的Socket,所以不能严格按照IPV4的Socket地址需求来定义sockaddr结构体,而是先定义一个空间足够大的结构体,然后通过结构体中一个确定的变量来表示结构体的实际类型,这个变量就是sa_family。addrinfo_in是IPV4的32位Socket地址结构体,可以看到通过在结构体后面填充0,将这个结构体的长度撑的和sockaddr长度一样;但是addrinfo_in6这个IPV6的结构体却打破了队形,它的长度明显远远超过了sockaddr,这就是令人尴尬的现实,一开始设想的“预留的足够大的空间”在遇到扩展需求时,并不够大,因此只好干脆不去维持两个结构体的长度的一致性了,在今天看来,或许一开始将sockaddr中的sa_data部分定义成0长数组还比较好。(将结构体中的最后一个部分定义成0长数组是另一个C编程技巧,搜索零长数组或者zero sized array来了解更多相关内容吧。)很多时候一个程序需要同时支持IPV4和IPV6,为此需要预留足够的空间,这种情况下可以使用struct sockaddr_storage预占空间,确定内容后再转型。
struct addrinfo {
int ai_flags; // AI_PASSIVE, AI_CANONNAME, etc.
int ai_family; // AF_INET, AF_INET6, AF_UNSPEC
int ai_socktype; // SOCK_STREAM, SOCK_DGRAM
int ai_protocol; // use 0 for "any"
size_t ai_addrlen; // size of ai_addr in bytes
struct sockaddr *ai_addr; // struct sockaddr_in or _in6
char *ai_canonname; // full canonical hostname
struct addrinfo *ai_next; // linked list, next node, getaddrinfo may build a list
}
struct sockaddr {
unsigned short sa_family; // address family, AF_xxx
char sa_data[14]; // 14 bytes of protocol address
};
// (IPv4 only--see struct sockaddr_in6 for IPv6)
struct sockaddr_in {
short int sin_family; // Address family, AF_INET
unsigned short int sin_port; // Port number
struct in_addr sin_addr; // Internet address
unsigned char sin_zero[8]; // Same size as struct sockaddr
};
// Internet address (a structure for historical reasons)
struct in_addr {
uint32_t s_addr; // that's a 32-bit int (4 bytes)
};
// (IPv6 only--see struct sockaddr_in and struct in_addr for IPv4)
struct sockaddr_in6 {
u_int16_t sin6_family; // address family, AF_INET6
u_int16_t sin6_port; // port number, Network Byte Order
u_int32_t sin6_flowinfo; // IPv6 flow information
struct in6_addr sin6_addr; // IPv6 address
u_int32_t sin6_scope_id; // Scope ID
};
struct in6_addr {
unsigned char s6_addr[16]; // IPv6 address
};
struct sockaddr_storage {
sa_family_t ss_family; // address family
// all this is padding, implementation specific, ignore it:
char __ss_pad1[_SS_PAD1SIZE];
int64_t __ss_align;
char __ss_pad2[_SS_PAD2SIZE];
};
IP地址转换函数
当需要struct sockaddr_in或者struct sockaddr_in6时,可以使用inet_pton来将IP地址的字符串表示转换成需要的类型,p表示presentation/print,n表示network/native。inet_addr和inet_aton都已经废弃了,因为它们不支持IPV6.类似的,将n和p反过来写,则是可以将struct转换为字符串表示的函数:inet_ntop。
NAT
如果使用路由器宽带上网,会发现PC的IP地址都是10.x.x.x,192.168.x.x,172.y.x.x这样的IP,这是因为路由器做了NAT,让多个PC利用同一个对外IP同时上网。这些特殊的IP都是专门给内部网络使用的。使用IPV6,内部网络依然存在,标准规定IPV6的内部网络IP以fd或者fc开头,不过通常内部网络机器数量不会那么多,内部网络使用IPV4就够了。
相关系统调用
getaddrinfo()
getaddrinfo用于填充后续要用到的struct addrinfo:
- 第一个参数接受一个域名字符串;
- 第二个参数则是协议或端口号字符串,https和443等价,而http则和80等价;
- 第三个参数接受一个struct addrinfo指针,但是这个结构体并不是要填充的结构体,而是用来给系统调用提供一些基础信息,告知应该如何填充结构体,通常直接在当前栈上构造该struct,使用后等栈自动被回收就好;
- ai_family
- AF_UNSPEC - IPV4、IPV6都可以
- ai_socktype
- SOCK_STREAM - 要建立的是TCP socket
- ai_flags
- AI_PASSIVE - 指定该flags时,getaddrinfo首个参数应该传null,该flag表示host为本机
- ai_family
- 最后,是一个指向struct addrinfo指针的指针,最后这个struct addrinfo结构体不需要我们来分配空间,会由这个系统调用分配好空间,初始化好其中的内容,再帮我们把传入的指针指向的那一块内存区域修改,以指向分配好的struct addrinfo的起时内存位置,这样传入的指针指向的内存区域的内容就是一个指向实际的struct addrinfo的指针了。当用完这个结构体后,需要使用freeaddrinfo函数来释放相关内存,避免内存泄漏。(这也是一个C常用操作,尽快习惯指向指针的指针吧。)
- 这个函数返回值为非0时表示有错误,可以使用gai_strerror()方法取到错误原因字符串;
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *node, // e.g. "www.example.com" or IP
const char *service, // e.g. "http" or port number
const struct addrinfo *hints,
struct addrinfo **res);
socket()
socket用于创建socket文件描述符:
- 应该使用上面介绍的getaddrinfo获取的struct addrinfo中的ai_family, ai_socktype, ai_protocol分别填充socket的三个参数。
- 通常该函数返回一个文件描述符,当返回-1时表示错误,错误原因通过errno()函数取得的错误码表示。对于这种用特定值表示错误的,通常应该在if里判断并立即打印错误原因后终止:
- if (socket() == -1) { print error(); return; }
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
- if (socket() == -1) { print error(); return; }
bind()
bind用于将socket绑定到本机的某个端口上。当需要接受来自其他机器的连接时,bind是必须的,但如果只是想要连接到其他机器上,通常不应该调用bind,在发起连接时内核会自动地分配空闲的端口。
- 第一个参数是通过上面介绍的socket()创建的socket文件描述符;
- 第二个参数和第三个参数分别是一个指向struct sockaddr的指针,以及一个用于说明指针指向的结构体长度的整数,通常应该使用getaddrinfo获取的struct addrinfo中的ai_socktype和ai_addrlen;
- 返回-1表示错误,errno()取出的错误码表示错误原因。
关于bind,可能需要关注的一个概念是端口复用,意思是多个进程可以同时绑定到同一机器的同一个端口,使用SO_REUSEADDR可以启用端口复用,端口复用是用于解决某些场景下的问题的,详细内容可以参考《Unix网络编程:卷1》。#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
connect()
connect用于使用制定的socket文件描述符链接到指定机器上:
- connect的参数和bind完全一致,除了这些参数用于描述要连接到的机器地址和端口,而不是要绑定的;
- 返回-1表示错误,可以用errno()获取错误码。
通常在connect前不需要调用bind,因为一般没人会关心客户是从哪个端口发起的链接。在不调用bind的情况下调用connect,内核会帮我们自动分配一个用于建立连接的端口。#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
listen()
listen和accept配合使用,用于接受到来的连接请求并负责建立连接:
- 第一个参数是socket()创建的socket文件描述符;
- 第二个参数表示队列中的最大连接数量,或者说待处理的最大连接数,很多系统都将这个值的最大值限制在20以内,一般将它设置成5或者10就可以。
- 返回-1表示错误,errno()用于检查错误码。
int listen(int sockfd, int backlog);
accept()
accept用于接收到来的连接请求,当有连接到来时accept会创建一个新的socket文件描述符并返回,使用这个新的文件描述符就可以和客户进行通信了,至于listen和accept调用时传入的文件描述符则依然用于监听到来的连接:
- 第一个参数是要监听的socket文件描述符;
- 第二个参数是一个指向已经分配好内存空间的struct sockaddr_storage的指针,accept在返回一个socket文件描述符后会填充这个指针指向的结构体以反映客户的网络地址(注意这里不要根据函数参数类型使用struct sockaddr,因为IPV6的结构体大小超出了struct sockaddr,这其实是一个历史遗留问题,毕竟接口一旦定了,就改不了了,像这种问题只能通过文档来描述了);
- 第三个参数是一个指向已经分配好内存空间的socklen_t的指针,会由accept返回时填充,表示第二个参数:struct sockaddr_storage结构体的有效内容大小。
- 返回-1表示失败,errno()用于查询错误码;正常情况下返回一个新的socket文件描述符,使用该返回的socket fd就可以和客户通信。
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
send() & recv()
send和recv函数用于在两个stream socket之间或者在两个链接着的datagram socket之间通讯(可以在udp类型的socket上调用connect,这样后续就不用调用sendto和recvfrom了),如果要使用没有链接的datagram socket,需要使用sendto和recvfrom。
send和recv的几个参数含义都很直接,第一个是操作的socket fd,第二三个参数是要发送或者接收的数据缓冲区和一个指示缓冲区长度的整数,第四个参数用于更进一步调整函数的行为,返回值为实际发送或者读取的字节数,返回-1表示失败,可以使用errno()获取错误码;对于recv,返回值为0代表另一端已经关闭了接收数据的通道。
int send(int sockfd, const void *msg, int len, int flags);
int recv(int sockfd, void *buf, int len, int flags);
sendto() & recvfrom()
这两个函数是无连接的socket的发送和接收数据的函数。前面的参数包括返回值的含义都是一样的,唯一的区别在于后面的两个参数,需要用这两个参数指定目的地址和地址struct的长度。无连接的UDP也可以使用上面的connect,但此时connect的作用并不是建立一个向TCP那样的链接,而仅仅是让我们不必使用sendto和recvfrom了,可以使用send和recv,系统会自动帮我们设置好地址。
int sendto(int sockfd, const void *msg, int len, unsigned int flags,
const struct sockaddr *to, socklen_t tolen);
int recvfrom(int sockfd, void *buf, int len, unsigned int flags,
struct sockaddr *from, int *fromlen);
close() & shutdown()
close用于关闭并释放socket fd,对于TCP而言,关闭过程可能不是那么直接,但只要我们调用了close,剩下的关闭过程就交给操作系统就好了;如果希望仅关闭接收端或者发送端,可以使用shutdown函数,当然了仅对于TCP而言才有所谓的发送和接收端的关闭/开启状态,对UDP调用shutdown仅仅是告诉操作系统后续不要再允许通过该UDP socket fd发送和接收数据。调用shutdown不回释放socket fd,释放socket fd必须使用close。
close(sockfd);
int shutdown(int sockfd, int how); // how: 1 - disallow further recv, 2 - disallow further send, 3 - disallow both
getpeername()
用于获取已经建立好链接的tcp socket fd另一端的机器地址:
#include <sys/socket.h>
int getpeername(int sockfd, struct sockaddr *addr, int *addrlen);
gethostname()
用于获取本机名称:
#include <unistd.h>
int gethostname(char *hostname, size_t size);
更进一步
阻塞
上面介绍的很多函数默认都是阻塞的,阻塞的函数被调用后可能不会立即返回,而是等到特定条件满足后才返回,并且用返回结果表示函数的任务是否成功完成。不过,Unix允许我们将一个socket fd设置成非阻塞的,这样在函数调用时,如果函数的条件不满足,就会立即返回特定错误,如果条件满足,就立即执行并返回结果。比如,一个recv函数默认是阻塞的,通过将它配置成非阻塞的,在被调用时系统会检查网络缓冲区,如果有数据就立即读取并返回成功,如果没有数据就直接返回失败。
sockfd = socket(PF_INET, SOCK_STREAM, 0);
fcntl(sockfd, F_SETFL, O_NONBLOCK); // 将socket fd设置成非阻塞模式
多路IO复用:select()
上面介绍的非阻塞IO虽然可以避免阻塞,但并不好用,想象一下如果你需要在监听新连接的同时还要检查已经建立的连接上是否有新的数据可读,你应该怎么做?用一个循环不停的调用非阻塞的accept和recv,把CPU的某个核心跑满吗?
Unix提供了更优雅的解决方案:select()。select允许同时监听多个文件描述符,在任意一个文件描述符可读、可写甚至遇到异常时再返回,通过返回值我们可以知道此时可以非阻塞地操作哪个文件描述符。不过,select的历史比较久,用今天的眼光回头看,select的效率很差,现代软件开发中我们应该使用libevent或者其他类似的系统调用如poll, epoll来完成相同的事情。anyway,select是最早的解决多路IO复用的系统调用,其他更优秀的方法实现更高效,不过解决的问题和select没有本质区别。
下面是select函数定义:
- numfds应该被设置成所有文件描述符中值最高的描述符+1,如果我们监听的是文件描述符包括1, 3, 5, 6, 7,那么numfds就应该设置成8;
- readfds, writefds, excepfds表示我们关心哪些文件描述符上的可读、可写、异常事件,当select返回时,这个指针指向的fd_set中将不再是我们设置的那些文件描述符,而是当前可以不阻塞地调用对应方法的文件描述符,如果我们不关心特定事件,传NULL也就是0进去就好。
- 最后,timeout是select的最大阻塞时间,一个常用技巧是将timeout设置成0,意思是要select不阻塞地直接检查所有传入的文件描述符的状态。
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int numfds, fd_set *readfds, fd_set *writefds,
fd_set *exceptfds, struct timeval *timeout);
几个有趣的事实:
当一个socket被另一端关闭时,select会将这个socket fd通过readfds提供给调用方,毕竟调用recv并返回0的意思是socket被关闭。
一个调用过listen的socket应该被放在readfds里,每当有新连接时,select就会标记socket fd可读,这时候就可以不阻塞地调用accept来建立新连接了。
Unix不保证select返回时通过fd_set反映的文件描述符的状态的准确性,在一些特殊场景下,可能select通过readfds告诉我们某个文件描述符是可读的,但实际上它并不立即可读,调用依然会遭到阻塞。因此,我们总是应该在使用select的场景为文件描述符设置上O_NONBLOCK标记,并检查EWOULDBLOCK相关错误码,在无法非阻塞地获取结果时,宽容地原谅select,并再给select一次机会。
调用send时判断是否仅发送了部分数据
send函数不保证一次性将提供给它的数据全部发送,它会用一个整数表示本次发送了多少字节,如果这个数字比我们的数据总量小,就说明本次send没有发完所有数据,此时应该继续尝试调用send。
read也有类似的问题,单次调用send发送的数据可能会需要多个read调用才能读完,或者多次调用send发送的数据也有可能只由单词read调用读取完毕。因此,不可以假设每次read和send都可以读写某一段数据,当我们要发送一组数据时,必须要制定某种协议,让接收方能够正确的解析数据,这引出下面的数据序列化问题:
数据序列化
有三个数据序列化的思路:
- 将数据序列化为可读的字符串,由接收方反序列化,好处是方便调试,含义直接,缺点是字符串表示通常空间效率非常低,比如一个字节可以表示的十进制整数183943,用字符串表示至少需要6个字节;
- 直接将要传递的数据在本机的内存表示发送出去,好处是简单且空间效率高,缺点是没有可移植性,目标机器如果和本机稍有差异,就无法正确解析数据;
- 指定可移植的二进制数据协议,将数据打包成二进制包发送,由接收方解析,基本上这是个只有优点没有缺点的方案,兼具空间效率和可移植性,如果说有缺点,那就是二进制数据协议一般比较啰嗦,包含非常多的细节,而且非常不直观。
现实世界中,大家采用的方案既有字符串,也有二进制协议。比如,很多服务都通过传递JSON来相互通信,JSON就是基于字符串的序列化方案;Google的Protocol Buffer PB协议则致力于减少消息体积和加快消息序列化和反序列化速度,规定并实现了一套二进制的序列化和反序列化协议,既有C实现,又有Java实现,是现在最流行的开源二进制序列化协议。
当然,你也可以制定自己的二进制协议,不过一般而言你的方案不太可能比google花了几年搞出来的方案更优秀,而且二进制协议需求并没有什么需要根据业务定制的点,业界最流行的方案是经过大量优化的,除非是为了学习,不然最好不要轻易决定要设计新的协议。
广播报文
广播指的是broadcast,多播指的是multicast,IPV4既有广播的概念,又有多播的概念,而到了IPV6,彻底干掉了广播,因为之前的实践表明广播没有有意义的实用场景,而且多播从各个方面而言都更好。如果要发送IPV4的广播报文,只要将目的IP地址的host number(也就是网络掩码之后的剩余表示网络内机器的部分)全部设置为1即可,此时报文会发送到子网的每一台机器上,不过,由于广播消息会导致网络内信道压力剧增,很多路由器和防火墙可能都设置了拒绝或者忽略这样的报文。因此,如果实验发现广播消息并没有发送到子网的每一台机器,可以检查下路由器设置或者PC的防火墙设置。