目录
前言
网间进程通信要解决的是不同主机进程间的相互通信问题。首先要解决的是网间进程标识问题。同一主机上,不同进程可用进程号(process ID)唯一标识。但在网络环境下,各主机独立分配的进程号不能唯一标识该进程。其次,操作系统支持的网络协议众多,不同协议的工作方式不同,地址格式也不同。因此,网间进程通信还要解决多重协议的识别问题。
通过TCP/IP协议族学习可知网络层的“ip地址”可以唯一标识网络中的主机,而传输层的“协议+端口”可以唯一标识主机中的应用程序(进程)。利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰)来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用socket。
通过之前TCP/IP协议族的学习可以大致的了解应用程序和tcp/ip协议的大致关系,我们只是知道socket编程是在tcp/IP上的网络编程,但是socket在上述的模型的什么位置呢?
由上图可知Socket是应用层与TCP/IP协议族通信的中间软件抽象层。传输层的底一层的服务提供给socket抽象层,socket抽象层再提供给应用层 。
一、socket介绍
套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。套接字是通信的基石,是支持TCP/IP协议的路通信的基本操作单元。
表示方法:
套接字Socket=(IP地址:端口号),套接字的表示方法是点分十进制的lP地址后面写上端口号,中间用冒号或逗号隔开。每一个传输层连接唯一地被通信两端的两个端点(即两个套接字)所确定。例如:如果IP地址是210.37.145.1,而端口号是23,那么得到套接字就是(210.37.145.1:23)
socket地址结构:
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函数等。
二、socket通信过程
服务端实现流程:
创建套接字:即实例化。server = socket.socket()
绑定地址:地址是一个元组,里面包括ip和端口,为自己创建了一个地址,用于客户端的连接。server.bind(('127.0.0.5',8520))
开始监听:此时的套接字server才被真正叫做监听套接字,此前,客户端是无法连接过来的。代码中的5表示最大能同时连接到客户端的数量。server.listen(5)
收到连接请求就建立与客户端连接:返回结果由两个变量接收,第一个变量是对等连接套接字,第二个是客户端的地址(ip和端口)a,b = server.accept(),这里要特别注意是会有阻塞现象。
利用对等连接套接字开启接收信息状态:若接到空值,表示客户端已主动断开连接。这里也会产生一次阻塞,客户端是无法发送空值的。代码的1024表示可以接收的最大字节数。a.recv(1024)
信息收发:信息传递讲究一收一发,一发一收。若收到信息,应给客户端一个回复。这里要注意的是信息的传递是以字节的形式。a.send(date)
断开连接:若收到空值,最后一步是断开连接。a.close()
客户端实现上述效果流程:
创建套接字:即实例化生成客户端套接字。client = socket.socket()
向服务端发送连接请求:连接成功后,原本的客户端套接字实际上就变成了对等连接套接字。代码中的ip和端口是服务端的ip和端口。client.connect(('127.0.0.5',8520))
向服务端发送信息:client.send(mess)
向服务端接收信息:这里会发生一次阻塞。client.recv(1024)
主动断开与服务端的连接:这时客户端会自动向服务端发送一个空值。client.close()
三、socket接口函数
(1)创建socket——socket()
socket是可读、可写、可控制、可关闭的文件描述符,而socket()用于创建一个socket。socket系统调用成功时返回一个socket文件描述符(socket descriptor),它唯一标识一个socket,失败则返回-1并设置errno。这个socket描述符跟文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);//返回sockfd,sockfd是描述符。
创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:
- domain:告诉系统使用哪个底层协议族。常用的协议族有,AF_INET(IPV4)、AF_INET6(IPV6)、AF_UNIX(称Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用ipv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。
- type:指定服务类型。常用的socket类型:SOCK_STREAM(流服务)、SOCK_DGRAM(数据报服务),对于TCP/IP协议族而言,取值SOCK_STREAM(流服务)表示传输层使用TCP协议,取SOCK_UGRAM表示传输层使用UDP协议。而Linux2.6.17版本,SOCK_NONBLOCK、SOCK_CLOEXEC分别表示将新创的socket设置为非阻塞和用fork调用创建子进程时在子进程中关闭socket。
- protocol:在前两个参数构成的协议集合下,选择一个具体的协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。
【注意】:并不是上面的type和protocol可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。通常情况下我们将protocol设置0,表示使用默认协议。
创建socket时,我们给它指定了地址族,但是并未指定使用该地址族中的哪个具体socket地址。
(2)命名socket——bind()
将一个socket与socket地址绑定称为给socket命名。在服务器程序中,我们通常要命名socket,因为只有命名后客户端才能知道如何连接它。客户端通常不需要命名socket,而是采用匿名方式(即使用操作系统自动分配的socket地址)。正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind将addr所指的socket地址分配给未命名的sockfd文件描述符,addrlen参数指出socket地址长度。
bind函数参数说明:
- sockfd:socket描述字,它是通过socket()函数创建了,唯一标识一个socket。bind()函数就是将给这个描述字绑定一个名字。
- addr:一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同,
ipv4对应的是:
struct sockaddr_in {
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
ipv6对应的是:
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
};
struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */
};
Unix域对应的是:
#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};
- addrlen:对应的是地址的长度。
- bind成功返回0,失败则返回-1并设置errno。
网络字节序与主机字节序
CPU的累加器一次能装载(至少)4字节(即一个整数),那么这4个字节在内存中排列的顺序将影响它被累加器装载成的整数的值,这就是字节序问题。
字节序分为大端字节序( Big-Endian)和小端字节序(Little-Endian)。
- 小端字节序就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。(又称主机字节序,应用在PC)
- 大端字节序就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。(又称网络字节序,应用于手机端)
在两台使用不同字节序的主机之间直接传递时,接收端必然错误解释。
解决方法:
发送端总是把要发送的数据转化成大端字节序再发送,而接收端知道对方传过来的数据总是采用大端字节序,所以接收端可以根据自身采用的字节序决定是否对接收的数据进行转换。
Linux提供了4个函数来完成主机字节序和网络字节序之间的转换:
htonl表示“host to network long”,将长整形的主机字节序转换成网络字节序。长整形函数通常用来转换IP地址,短整型函数用来转换端口号。
【注意】在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。
(3)监听socket——listen()函数
如果作为一个服务器,socket被命名之后,还不能马上接受客户连接,我们需要使用系统调用listen()来创建一个监听队列以存放待处理的客户连接。
#include <sys/socket.h>
int listen(int sockfd, int backlog);
listen函数参数说明
- sockfd参数指定要监听的socket描述字
- backlog参数提示内核监听队列的最大长度。(监听队列的长度如果大于backlog,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED错误信息)
- listen成功时返回0,失败时返回-1并设置errno。
socket()函数创建的socket默认是一个主动类型的,listen函数将socket变为被动类型的,等待客户的连接请求。
(4)接受连接——accept函数
accept()函数从listen监听队列中接受一个连接。
#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
accept函数参数说明:
- sockfd参数为执行过listen系统调用的监听socket
- addr参数用来获取被接受连接的远端socket地址
- addrlen参数为socket地址的长度。
- accept成功时返回一个新的连接socket,该socket唯一地标识了被接受的这个连接,服务器可以通过读写该socket来与被接受连接对应的客户端通信;失败时返回-1并设置errno
【思考】如果监听队列中处于establish状态的连接对应的客户端出现网络异常或提前退出,那么服务器对这个连接执行accept调用能否成功?
可以。因为accept只是从监听队列中取出连接,而不论连接处于何种状态,更不关心任何网络状况的变化。
【注意】accept默认会阻塞进程,直到有一个客户连接建立后返回,它返回的是一个新可用的套接字,这个套接字是连接套接字。
两种套接字区分:
监听套接字: 监听套接字正如accept的参数sockfd,它是监听套接字,在调用listen函数之后,是服务器开始调用socket()函数生成的,称为监听socket描述字(监听套接字)
连接套接字:一个套接字会从主动连接的套接字变身为一个监听套接字;而accept函数返回的是已连接socket描述字(一个连接套接字),它代表着一个网络已经存在的点点连接。
(一个服务器通常通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接受的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。)
【思考】为什么要有两种套接字?
因为如果使用一个描述字的话,那么它的功能太多,使得使用很不直观,同时在内核确实产生了一个这样的新的描述字。连接套接字socketfd_new 并没有占用新的端口与客户端通信,依然使用的是与监听套接字socketfd一样的端口号。
(5)发起连接——connect()函数
如果服务器通过调用listen()来被动接受连接,那么客户端需要通过调用connect()来主动与服务器建立连接。
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
connect函数参数说明:
- sockfd参数由socket系统调用返回一个socket,
- addr参数为服务器监听的socket地址,
- socklen参数为指定的socket地址的长度。
- connect调用成功返回0,一旦成功建立连接,sockfd就唯一标识了这个连接,客户端就可以通过读写sockfd来与服务器通信,connect失败则返回-1,并设置errno。
errno常见econnrefused和etimedout:
- econnrefused:目标端口不存在,连接被拒绝
- etimeout:连接超时
(6)关闭连接——close()函数
关闭一个连接实际上就是关闭该连接对应的socket,可以通过关闭普通文件描述符的系统调用来完成
#include <unistd.h>
int close(int fd);
参数说明:
fd参数是待关闭的socket。
【注意】close系统调用并非总是立即关闭一个连接,而是将fd的引用计数减1,。只有当fd的引用计数为0时,才真正关闭连接。
在多进程程序中,一个fork系统调用默认将父进程中打开的socket的引用计数加1,因此我们必须在父进程和子进程中都对该socket执行close调用才能将连接关闭。
如果无论如何都要立即终止连接,可以使用shutdown系统调用:
#include <sys/socket.h>
int shutdown(int sockfd,int howto);
参数说明:
- sockfd参数是待关闭的socket
- howto参数决定shutdown的行为
- shutdown成功时返回0,失败时返回-1并设置errno。
shutdown能够分别关闭socket上的读和写,或者都关闭。而close在关闭时只能将socket上的读和写同时关闭。
(7)数据读写——read()、write()函数
服务器与客户建立好连接可以调用网络I/O进行读写操作了,即实现了网络中不同进程之间的通信!
①TCP流数据读写
#include <sys/types.h>
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
参数说明:
①recv
- recv读取sockfd上的数据
- buf和len参数分别指定读缓冲区的位置和大小
- flag参数为数据收发提供额外的控制,通常设置为0即可。
- 返回值:
recv成功时返回实际读取的数据长度,它可能小于期望长度len。因此可能需要多次调用recv才能读取完整的数据。
recv返回0表示通信双方已经关闭连接。
recv出错时返回-1并设置errno
②send
- send往sockfd上写入数据
- buf和len参数分别指定写缓冲区的位置和大小
- flag参数为数据收发提供额外的控制,通常设置为0即可。
- 返回值:
send成功时返回实际写入的数据长度;
send失败时返回-1并设置errno
②UDP数据报读写
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数说明:
①recvfrom
- recvfrom读取sockfd上数据,
- buf和len参数分别指定读缓冲区的位置和大小;
- src_addr参数是发送端的socket地址(因为UDP通信没有连接的概念,因此每次读取数据都需要获取发送端的socket地址)
- addrlen参数指该地址的长度
②sendto
- sendto往sockfd写入数据,
- buf和len参数分别指定写缓冲区的位置和大小;
- src_addr参数是指定接收端的socket地址
- addrlen参数指该地址的长度
【注意】recvfrom()/sendto()也可以用于面向连接(流)的socket的数据读写,只需要把最后两个参数设置为NULL以忽略发送端/接收端的socket地址(因为已经和对方建立了连接,就知道其socket地址)
③通用数据读写函数——recvmsg()/sendmsg()
它们不仅能用于TCP流数据,也能用于UDP数据报。
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
参数说明:
sockfd参数指定被操作的目标socket
msg参数是msghdr结构体类型的指针,msghdr结构体如下:
四、实例演示
(1)基于UDP的服务器端/客户端
服务端server.cpp:
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <Winsock2.h>
#pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
#pragma warning(disable:4996)
#define BUF_SIZE 1024
int main() {
//初始化DLL
WSADATA wsadata;
WSAStartup(MAKEWORD(2, 2), &wsadata);
//创建套接字
SOCKET ser_socket = socket(AF_INET, SOCK_DGRAM, 0);
//绑定套接字
sockaddr_in serAddr;
memset(&serAddr, 0, sizeof(serAddr));//每个字节用0填充
serAddr.sin_family = PF_INET;//使用IPv4地址
serAddr.sin_port = htons(8888);//端口
serAddr.sin_addr.s_addr = htonl(INADDR_ANY);//自动获取IP地址
bind(ser_socket, (SOCKADDR*)&serAddr, sizeof(SOCKADDR));
//接收客户端请求
SOCKADDR cli_Addr;//客户端地址信息
int addsize = sizeof(SOCKADDR);
char buffer[BUF_SIZE];//读缓冲区
while (1) {
int str_Len = recvfrom(ser_socket, buffer, BUF_SIZE, 0, &cli_Addr, &addsize);
sendto(ser_socket, buffer, str_Len, 0, &cli_Addr, addsize);
}
closesocket(ser_socket);
WSACleanup();
}
客户端client.cpp:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <Winsock2.h>
#pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
#pragma warning(disable:4996)
#define BUF_SIZE 1024
int main() {
//初始化DLL
WSADATA wsadata;
WSAStartup(MAKEWORD(2, 2), &wsadata);
//创建套接字
SOCKET cli_socket = socket(PF_INET, SOCK_DGRAM, 0);
//服务器地址信息
sockaddr_in ser_Addr;
memset(&ser_Addr, 0, sizeof(ser_Addr));//每个字节用0填充
ser_Addr.sin_family = PF_INET;
ser_Addr.sin_port = htons(8888);
ser_Addr.sin_addr.s_addr = inet_addr("127.0.0.1");
//不断获取用户输入并发送给服务器,然后接受服务器数据
sockaddr fromAddr;
int addrLen = sizeof(fromAddr);
while (1) {
char buffer[BUF_SIZE] = { 0 };
printf("Input a string:");
scanf("%s", buffer);
sendto(cli_socket, buffer, strlen(buffer), 0, (struct sockaddr*)&ser_Addr, sizeof(ser_Addr));
int str_Len = recvfrom(cli_socket, buffer, BUF_SIZE, 0, &fromAddr, &addrLen);
buffer[str_Len] = 0;
printf("Message from server:%s\n", buffer);
}
closesocket(cli_socket);
WSACleanup();
}
(2)基于TCP的服务器端/客户端
服务端server.cpp:
#include <WINSOCK2.H>
#include <stdio.h>
#pragma comment(lib,"ws2_32.lib")
void main()
{
//创建套接字
WORD myVersionRequest;
WSADATA wsaData;
myVersionRequest = MAKEWORD(1, 1);
int err;
err = WSAStartup(myVersionRequest, &wsaData);
if (!err)
{
printf("已打开套接字\n");
}
else
{
//进一步绑定套接字
printf("嵌套字未打开!");
return;
}
SOCKET serSocket = socket(AF_INET, SOCK_STREAM, 0);//创建了可识别套接字
//需要绑定的参数
SOCKADDR_IN addr;
addr.sin_family = AF_INET;
addr.sin_addr.S_un.S_addr = htonl(INADDR_ANY);//ip地址
addr.sin_port = htons(6000);//绑定端口
bind(serSocket, (SOCKADDR*)&addr, sizeof(SOCKADDR));//绑定完成
listen(serSocket, 5);//其中第二个参数代表能够接收的最多的连接数
//开始进行监听
SOCKADDR_IN clientsocket;
int len = sizeof(SOCKADDR);
SOCKET serConn = accept(serSocket, (SOCKADDR*)&clientsocket, &len);
while (1)
{
char sendBuf[101];
cin>>sendBuf ;
sendBuf[strlen(sendBuf)] = '\0';
send(serConn, sendBuf, strlen(sendBuf) + 1, 0);
char receiveBuf[101];//接收
recv(serConn, receiveBuf, strlen(receiveBuf) + 1, 0);
printf("%s\n", receiveBuf);
}
closesocket(serConn);//关闭
WSACleanup();//释放资源的操作
}
客户端client.cpp
#include <iostream>
#include <WINSOCK2.H>
#include <stdio.h>
#pragma comment(lib, "ws2_32.lib") //加载 ws2_32.dll
using namespace std;
void main()
{
WSADATA wsaData;
int err = WSAStartup(MAKEWORD(1, 1), &wsaData);//协议库的版本信息
if (!err)
{
printf("客户端嵌套字已经打开!\n");
}
else
{
printf("客户端的嵌套字打开失败!\n");
return;//结束
}
//创建套接字
SOCKET clientSocket = socket(AF_INET, SOCK_STREAM, 0);
//套接字地址信息
SOCKADDR_IN clientsock_in;
clientsock_in.sin_addr.S_un.S_addr = inet_addr("127.0.0.1");
clientsock_in.sin_family = AF_INET;
clientsock_in.sin_port = htons(6000);
//开始连接
if (connect(clientSocket, (SOCKADDR*)&clientsock_in, sizeof(SOCKADDR)) == SOCKET_ERROR) {
cout << "服务器连接失败!" << endl;
WSACleanup();
}
else {
cout << "服务器连接成功!" << endl;
}
//不断获取用户输入并发送给服务器,然后接受服务器数据
while (1) {
char receiveBuf[101];
recv(clientSocket, receiveBuf, 101, 0);
printf("%s\n", receiveBuf);
char sendBuf[100];
cin >> sendBuf;
send(clientSocket, sendBuf, strlen(sendBuf) + 1, 0);
}
//关闭套接字
closesocket(clientSocket);
//释放DLL资源
WSACleanup();
}