在谈到socket编程之前,首先我们要知道一点预备知识。
预备知识:
1、网路字节序全部采用大端字节序。
关于字节序的详解,戳链接 查看,这里不做解释。
2、在编程之前,我们有必要了解,什么是socket?
socket,又叫做套接字。我们都应该知道,在网络中,IP地址+ 端口号,可以唯一表示互联网中的一个进程,因此,我们将 IP地址+端口号 称为socket。
socket API是一套抽象的网络编程接口,适用于各种底层网络协议,包括IPv4,IPv6以及UNIX Domain Socket等,但各种网络协议地址格式并不相同,举两个的例子,IPv4和Unix Domain Socket,如图:
现在网络协议中最常用的依旧是IPv4,本文以IPv4为重点进行介绍。
可以发现,IPv4的地址结构大小为16字节,末尾填充了8字节的其他内容,这个我们不关心,需要我们注意的是上面三段。
a、前16位表示的是地址类型,可以注意到,其他类型的地址结构也有,这是用来区分不同协议类型的部分;
b、16位端口号,指明协议使用的端口;
c、32位IP地址,指明通信时使用的IP地址。
对于不同的协议,地址结构很明显是不同的,举个例子,IPv6的IP地址长度和Ipv6的IP地址长度很明显不同。互联网中有众多的协议,难道要针对每种协议提供一套接口?
当然,Linux不会这么干,Linux提供了一套抽象出来的标准接口,叫做struct sockaddr。对于不同的网络协议的地址格式,有一个共同点,就是前16位用来表示地址类型【注1】。那么对待不同的网络协议,可以各自定义自己的地址类型,在使用的过程中,只需要强制类型转化为标准格式即可。区分不同的地址协议,仅仅需要struct sockaddr的前16位足以。
基于POSIX规范,对于IPv4协议,我们只需要关注整个地址结构中的3个字段sin_family、sin_addr、sin_port(分别表示地址类型,IP地址,port端口号,具体信息后面说)。
【注1】:IPv4的地址类型为 AF_INET;IPv6的地址类型为AF_INET6;UNIX Domain Socket的地址类型为AF_UNIX。
socket通信:
在TCP协议中,当我们使用socket通信时,通信双方(连接的两个进程)都需要有一套自己的socket来标识,那么这两个socket组成的socket pair就唯一标识了这一组连接。
我们建立的通信是在应用层之上的,TCP/IP协议设计的应用层编程接口又叫做socket API ,本文的重点是如何利用这些API来实现两个进程之间通过网络通信。
首先了解一下关于socket通信的整个流程。如下图。
对上图首先有一个大致印象即可,下面我们来看TCP协议提供的这一套API。
首先讨论server端:
1、创建 socket
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int socket(int domain, int type, int protocol); # creates an endpoint for communication and returns a descriptor # domain:地址类型,上面解释过 # type:流式套接,SOCK_STREAM代表TCP,SOCK_DGRAM代表UDP # 一般设置为0 # 返回值为文件描述符,默认从3开始。失败返回-1
2、绑定套接字 bind
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); # 参数1 --> 创建socket时获得的文件描述符 # 参数2 --> 指向特定协议的地址结构的指针 # 参数3 --> 该地址结构的长度 sockaddr_in的长度,即网络地址的长度 # 成功返回0, 失败返回-1
在struct sockaddr 结构中,可以指定IP或者端口号(下面说具体如何做),当然可以任意指定其中一个,或者都不指定。
端口:如果 server 没有使用bind绑定端口号的话,内核会为该 socket 选择一个临时端口,这个端口的选择往往是随机的。但是对于TCP服务器而言,它的端口应该是被众所周知(well_known)的。如果端口都是随机的话,那么 client 就不能保证可以正常访问到 server 。当然对于client而言是,让内核来选择端口是一件很正常的事。
IP地址:对于服务器而言,如果绑定了IP地址,这就限定了该socket只接收目的地为这个IP地址的client连接;如果TCP服务器没有将IP地址捆绑到该 socket 上,内核就把客户发送的SYN(TCP/IP建立连接时的握手信号)作为服务器的源IP地址。
当我们希望内核自动分配一个端口地址的话,必须注意的是,bind函数本身并不返回所选择的端口号,为了得到内核选择的端口号,必须调用 getsockname 函数。
接下来是IPv4的地址空间结构,
struct sockaddr_in { sa_family_t sin_family; __be16 sin_port; struct in_addr sin_addr; unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) - sizeof(unsigned short int) - sizeof(struct i n_addr)]; }; # 参数1 --> 地址类型 # 参数2 --> 端口号 # 参数3 --> IP地址,结构体定义如下 # 参数4 --> 填充位 PAD ,不需要时不用关心该成员 struct in_addr { __be32 s_addr; };
如果想由内核选择端口,则sin_port值为0;
如果想由内核选择IP,则sin_addr.s_addr = htonl(INADDR_ANY);
这里的 IP地址 和 端口号由于存在大小端的问题,因此需要进行转换,转换函数如下:
端口转换函数
#include <arpa/inet.h> uint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort); uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort);
IP 地址转换函数
#include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> in_addr_t inet_addr(const char *cp);
3、监听 listen
监听函数仅由TCP服务器来调用,主要做两件事:
a、调用 listen 函数,使得套接字从 CLOSED 转换到 LISTEN状态,将一个未连接的主动套接字转换为被动套接字,指示内核应该接受指向该套接字的连接请求;
b、该函数的第二个参数指定了内核应该为相应套接字排队的最大连接个数。
#include <sys/types.h> #include <sys/socket.h> int listen(int sockfd, int backlog); # 参数1 --> 创建socket时获得的文件描述符 # 参数2 --> 一般设置为5 # 成返回0, 失败返回-1
该函数通常在调用socket()、bind()函数之后,accept()函数之前调用。
理解backlog参数:
内核在为任何一个给定的监听套接字维护两个队列:
a、未完成连接队列。由client发起连接并且已经完成第一次握手,但尚未完成三次握手,这些套接字处于SYN_RCVD状态;
b、已完成连接队列。已经完成三次握手的客户端对应一项,这些套接字呼吁ESTABLISHED状态。
注意:永远不要将backlog设置为0,即使不想任何客户连接到你的监听套接字上。
4、 accept
该函数有TCP服务器调用,用于从已完成的连接队列头返回下一个已完成连接。如果已完成连接列队为空,那么进程进入睡眠状态。套接字默认为阻塞状态。
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen); # 参数1 --> 监听套接字;创建socket时获得的文件描述符 # 参数2、3为输出型参数,用来获取连接client端的协议地址 # socket_t len = sizeof(struct socketaddr_in);一变量,两应用,既做输入,又做输出 # 成功返回一个全新的文件描述符,我们把它叫做连接套接字。失败后-1。返回值是真正实现数据通信的套接字
需要注意的是,server通过socket建立的自己的套接字,这个套接字是well_known的,只是用来建立连接的,并不是真正数据传输使用的。真正的数据传输是建立在client的套接字上的,server通过accept的返回值获取client的套接字。
由于套接字也是文件,读写可以使用read 和 write 函数,使用完毕,需要close 文件。
这就需要重提一个概念, Linux一切皆文件,只不过是相同的接口,不同的底层实现。
client端:
1、socket
用法同server
2、连接 connect
#include <sys/types.h> /* See NOTES */ #include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen); # client 用来发起连接请求(三次握手) # 参数1 --> client的套接字描述符 # 参数2 --> 指向套接字地址结构的指针,需要手动来初始化 # 参数3 --> 地址结构的大小
关于server 和client的读写操作,可以直接使用write和read 函数,这里不再细说,接下来给出测试用例,
//server.c #include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <stdlib.h> #include <string.h> void Usage(char *msg) { printf("invalid Input!\n"); printf("Usage: %s [ip] [port]\n",msg); } int create_socket(char *port, char *addr) { // 1.create an endpoint for communication int sock = socket(AF_INET, SOCK_STREAM, 0); if(sock < 0) { perror("socket error"); exit(1); } // struct sockaddr_in local; local.sin_family = AF_INET; local.sin_port = htons(atoi(port)); local.sin_addr.s_addr = inet_addr(addr); if(bind(sock, (struct sockaddr *)&local, sizeof(local)) < 0) { perror("bing error"); close(sock); exit(2); } if(listen(sock, 5) < 0) { perror("listen error"); close(sock); exit(3); } return sock; } int main(int argc, char* argv[]) { if(argc != 3) { Usage(argv[0]); return 1; } // create socket int listen_sock = create_socket(argv[2], argv[1]); struct sockaddr_in client; socklen_t len = sizeof(struct sockaddr); while(1){ char buf[1024]; memset(buf, 0, sizeof(buf)); int ret = 0; if((ret = accept(listen_sock, (struct sockaddr*)&client, &len)) < 0) { perror("accept error"); continue; } while(1) { ssize_t _s = read(ret, buf, sizeof(buf)-1); printf("*************************\n"); if(_s > 0) { printf("client# "); fflush(stdout); buf[_s-1] = 0; printf("%s\n", buf); } else if (_s == 0) { printf("client quit!\n"); break; } } } return 0; } /***************************************************************************/ // client.c #include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <stdlib.h> #include <string.h> int main(int argc, char *argv[]) { // check Input if(argc != 3) { printf("Invalid Input!\n"); printf("Usage# %s [ip] [port]\n"); return 1; } // create socket int client_sock = socket(AF_INET, SOCK_STREAM, 0); if(client_sock < 0) { perror("socket error"); exit(1); } // connect struct sockaddr_in server_addr; server_addr.sin_family = AF_INET; server_addr.sin_port = htons(atoi(argv[2])); server_addr.sin_addr.s_addr = inet_addr(argv[1]); int ret = connect(client_sock, (struct sockaddr *)&server_addr, sizeof(struct sockaddr_in)); if(ret < 0) { perror("connect error"); printf("*****************8"); close(client_sock); return 4; } printf("connect success!\n"); // send while(1) { printf("Send# "); fflush(stdout); char buf[1024]; memset(buf, 0, sizeof(buf)); ssize_t _s = read(0, buf, sizeof(buf)); printf("*************************\n"); if(_s < 0) { perror("read error"); close(client_sock); return 5; } write(client_sock ,buf, _s); memset(buf, 0, sizeof(buf)); } return 0; }
在不同的终端下,分别运行server和client,IP地址选择server端的IP,端口号自定义,然后可以看到下面的打印结果:
这里可以实现的网络通信,是建立在局域网范围内的,当然如果找不下两台主机的话,可以使用127.0.0.1的IP地址,做本地环回测试,也就是说server和client的端口都放在了一台主机上。这里自定义的端口号,最好选用大于1024的端口,防止造成端口冲突。
上面的代码只是用来测试使用的,真正服务器上写出这样的代码是要出大麻烦的,之后,我们会谈到一些特殊的情况,关于上面的测试代码,这里给出链接,可以自己下载使用:
https://github.com/muhuizz/Linux/tree/master/Linux%E7%BD%91%E7%BB%9C%E7%BC%96%E7%A8%8B/code/socket-1
-----muhuizz整理