此为牛客Linux C++课程和黑马Linux系统编程笔记。
1. 什么是socket
所谓 socket(套接字),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。
一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处
的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,
是应用程序与网络协议根进行交互的接口。
socket 可以看成是两个网络应用程序进行通信时,各自通信连接中的端点,这是一个逻辑上的概
念。它是网络环境中进程间通信的 API,也是可以被命名和寻址的通信端点,使用中的每一个套接
字都有其类型和一个与之相连进程。通信时其中一个网络应用程序将要传输的一段信息写入它所在
主机的 socket 中,该 socket 通过与网络接口卡(NIC)相连的传输介质将这段信息送到另外一台
主机的 socket 中,使对方能够接收到这段信息。socket 是由 IP 地址和端口结合的,提供向应用
层进程传送数据包的机制。
socket 本身有“插座”的意思,在 Linux 环境下,用于表示进程间网络通信的特殊文件类型。本质为
内核借助缓冲区形成的伪文件。既然是文件,那么理所当然的,我们可以使用文件描述符引用套接
字。与管道类似的,Linux 系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文
件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传
递。
套接字的内核实现较为复杂,不宜在学习初期深入学习。
在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。
套接字通信原理如下图所示:
在网络通信中,套接字一定是成对出现的。一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。
2. socket相关函数
2.1 socket( )函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int socket(int domain, int type, int protocol);
功能:创建一个套接字
参数:
-
domain: 协议族:
AF_INET : ipv4
AF_INET6 : ipv6
AF_UNIX, AF_LOCAL : 本地套接字通信(进程间通信) -
type: 通信过程中使用的协议类型:
SOCK_STREAM : 流式协议
SOCK_DGRAM : 报式协议 -
protocol : 具体的一个协议。一般写0:
SOCK_STREAM : 流式协议默认使用 TCP
SOCK_DGRAM : 报式协议默认使用 UDP -
返回值: 成功:返回文件描述符,操作的就是内核缓冲区。失败:-1
socket( )
打开一个网络通讯端口,如果成功的话,就像open()
一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()
调用出错则返回-1。对于IPv4,domain参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。
2.2 bind( )函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen); // socket命名
功能:绑定,将文件描述符fd和本地的<IP , 端口>进行绑定
参数:
- sockfd : 通过
socket()
函数得到的文件描述符 - addr : 需要绑定的socket地址,这个地址封装了ip和端口号的信息
- addrlen : 第二个参数结构体占的内存大小
返回值:成功返回0,失败返回-1, 设置errno
服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。
bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。上一篇讲过,struct sockaddr *是一个通用指针类型,addr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。
2.3 listen( )函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int listen(int sockfd, int backlog);
功能:监听这个socket上的连接
参数:
- sockfd : 通过socket()函数得到的文件描述符
- backlog : 未连接的和已经连接的和的最大值(排队建立3次握手队列和刚刚建立3次握手队列的链接数和的最大值)
典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。
2.4 accept( )函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
功能:接收客户端连接,默认是一个阻塞的函数,阻塞等待客户端连接
参数:
- sockfd : 用于监听的文件描述符
- addr : 传出参数,记录了连接成功后客户端的地址信息(ip,port)
- addrlen : 传入传出参数(值-结果), 指定第二个参数的对应的内存大小,传出第二个参数传出时的内存大小
返回值:
成功:返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno
三次握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。addr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr参数传NULL,表示不关心客户端的地址。
一般来说,我们的服务器程序结构是这样的:
while (1) {cliaddr_len = sizeof(cliaddr);connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);n = read(connfd, buf, MAXLINE);......close(connfd);
}
整个是一个while死循环,每次循环处理一个客户端连接。由于cliaddr_len是传入传出参数,每次调用accept()之前应该重新赋初值。accept()的参数listenfd是先前的监听文件描述符,而accept()的返回值是另外一个文件描述符connfd,之后与客户端之间就通过这个connfd通讯,最后关闭connfd断开连接,而不关闭listenfd,再次回到循环开头listenfd仍然用作accept的参数。accept()成功返回一个文件描述符,出错返回-1。
一定要区分开connfd和listenfd的作用,listenfd仅用于监听,监听到了以后并不用它来进行信息传输。
2.5 connect( )函数
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h> // 包含了这个头文件,上面两个就可以省略
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
功能: 客户端连接服务器
参数:
- sockfd : 用于通信的文件描述符
- addr : 客户端要连接的服务器的地址信息
- addrlen : 第二个参数的内存大小
返回值:成功 0, 失败 -1
客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。
3. 实现一个简单的服务端-客户端通信
接下来使用以上函数实现一个简单的服务端客户端通信,实现将客户端的小写字母转换为大写字母。
3.1 通信流程
上图给了一个C/S模型网络编程的socket模板。
服务端:
- 调用 socket() 建立套接字
- 创建 struct sockaddr_in ,并初始化服务端的ip和端口号
- 调用 bind() 将第一步建立的套接字与第二步的sockaddr_in(ip和端口号)绑定
- 调用 listen() 设置最大同时发起连接数量
- 调用 accept() 阻塞等待客户端发起连接
- 调用 read() 读取客户端发出的数据
- 处理请求
- 调用 write() 发送数据给客户端
- 调用 close() 关闭socket伪文件
客户端:
- 调用 socket() 建立套接字
- 调用 connect() 向客户端发起连接
- 调用 write() 发送数据给服务端
- 调用 read() 读取服务端返回的数据
- 调用 close() 关闭socket伪文件
其中还有很多细节,需要写代码的时候才能体会。
3.2 服务端
/*实现一个简单的服务器-客户端通信*/#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>// 设定一个服务器端口号
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int lfd; // 用于监听的socket的文件描述符,真正用于通信的套接字是接下来accept函数返回的cfd套接字struct sockaddr_in serv_addr;lfd = socket(AF_INET, SOCK_STREAM, 0);serv_addr.sin_family = AF_INET;// 注意这里,要把小端存储的端口号改为大端存储serv_addr.sin_port = htons(SERV_PORT); inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 调用ip转换函数,把字符串ip转化为网络字节序bind(lfd, (struct sockaddr * )&serv_addr, sizeof(serv_addr));listen(lfd, 128); // 最大连接与待连接数设为128int cfd; // 已连接的客户端的socket的文件描述符, 以便一会儿read用struct sockaddr_in clie_addr; // 作为accept的第二个参数,为传出参数,传出的是客户端的sockadd_insocklen_t clie_addr_len = sizeof(clie_addr); // 作为accept的第三个参数,为传入传出参数,之所以要单独定义出来是因为要传出cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);while(1) {// 此处输出连接的客户端的ip和端口char clie_IP[BUFSIZ];printf("Client IP: %s, client port: %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),ntohs(clie_addr.sin_port));char buf[BUFSIZ]; // 给read使用,存储读出的数据,BUFSIZ宏是系统用来专门给buf赋长度的宏,为8k(Default buffer size)int len; // read的返回值,是读入字符的长度len = read(cfd, buf, sizeof(buf));// 把小写字母转化为大写字母int i;for(i = 0; i < len; ++i) {if(buf[i] <= 'z' && buf[i] >= 'a') {buf[i] -= 32;}}write(cfd, buf, len);}close(lfd);close(cfd);return 0;
}
以上代码为突出主体,没有写错误判断与错误提示。带错误提示代码如下:
/*实现一个简单的服务器-客户端通信*/#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>// 设定一个服务器端口号
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int lfd; // 用于监听的socket的文件描述符struct sockaddr_in serv_addr;int ret; // 用于错误检测lfd = socket(AF_INET, SOCK_STREAM, 0);if(lfd == -1) {perror("socket error");exit(1);}serv_addr.sin_family = AF_INET;// 注意这里,要把小端存储的端口号改为大端存储serv_addr.sin_port = htons(SERV_PORT); inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 调用ip转换函数,把字符串ip转化为网络字节序ret = bind(lfd, (struct sockaddr * )&serv_addr, sizeof(serv_addr));if(ret == -1) {perror("bind error");exit(1);}ret = listen(lfd, 128); // 最大连接与待连接数设为128if(ret == -1) {perror("listen error");exit(1);}int cfd; // 已连接的客户端的socket的文件描述符, 以便一会儿read用struct sockaddr_in clie_addr; // 作为accept的第二个参数,为传出参数,传出的是客户端的sockadd_insocklen_t clie_addr_len = sizeof(clie_addr); // 作为accept的第三个参数,为传入传出参数,之所以要单独定义出来是因为要传出cfd = accept(lfd, (struct sockaddr *)&clie_addr, &clie_addr_len);if(cfd == -1) {perror("accept error");exit(1);}// 此处输出连接的客户端的ip和端口char clie_IP[BUFSIZ];printf("Client IP: %s, client port: %d\n", inet_ntop(AF_INET, &clie_addr.sin_addr.s_addr, clie_IP, sizeof(clie_IP)),ntohs(clie_addr.sin_port));while(1) {char buf[BUFSIZ]; // 给read使用,存储读出的数据,BUFSIZ宏是系统用来专门给buf赋长度的宏,为8k(Default buffer size)int len; // read的返回值,是读入字符的长度len = read(cfd, buf, sizeof(buf));if(len == -1) {perror("read error");exit(1);}// 把小写字母转化为大写字母int i;for(i = 0; i < len; ++i) {if(buf[i] <= 'z' && buf[i] >= 'a') {buf[i] -= 32;}}ret = write(cfd, buf, len);if(ret == -1) {perror("write error");exit(1);}}close(cfd);close(lfd);return 0;
}
3.3 客户端
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>// 服务器的ip和端口
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int cfd; // 用于写入数据传输给服务端的socket的文件描述符cfd = socket(AF_INET, SOCK_STREAM, 0);// bind() 可以不调用bind(), linux会隐式地绑定struct sockaddr_in serv_addr; // 因为要连接服务端,这里的sockadd_in是用于指定服务端的ip和端口bzero(&serv_addr, sizeof(serv_addr));serv_addr.sin_family = AF_INET;serv_addr.sin_port = htons(SERV_PORT);inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 调用ip转换函数,把字符串ip转化为网络字节序connect(cfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));while(1) {// 从终端读取内容char buf[BUFSIZ];fgets(buf, sizeof(buf), stdin); // 读一行// 写入到cfd中,传输给服务端write(cfd, buf, strlen(buf)); // 注意不要写成sizeof(buf),sizeof是在内存中所占的大小,strlen是到第一个'\0'位止。// read在读socket时默认时阻塞的,阻塞等待服务端传输数据int len;len = read(cfd, buf, sizeof(buf));printf("%s", buf);}close(cfd);return 0;
}
以上代码为突出主体,没有写错误判断与错误提示。带错误提示代码如下:
#include <stdio.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>// 服务器的ip和端口
#define SERV_IP "127.0.0.1"
#define SERV_PORT 6666int main()
{int ret; // 用于错误检测int cfd; // 用于写入数据传输给服务端的socket的文件描述符cfd = socket(AF_INET, SOCK_STREAM, 0);if(cfd == -1) {perror("socket error");exit(1);}// bind() 可以不调用bind(), linux会隐式地绑定struct sockaddr_in serv_addr; // 因为要连接服务端,这里的sockadd_in是用于指定服务端的ip和端口bzero(&serv_addr, sizeof(serv_addr));serv_addr.sin_family = AF_INET;serv_addr.sin_port = htons(SERV_PORT);inet_pton(AF_INET, SERV_IP, &serv_addr.sin_addr.s_addr); // 调用ip转换函数,把字符串ip转化为网络字节序ret = connect(cfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));if(ret == -1) {perror("connect error");exit(1);}// 从终端读取内容while(1) {char buf[BUFSIZ];fgets(buf, sizeof(buf), stdin); // 读一行// 写入到cfd中,传输给服务端ret = write(cfd, buf, strlen(buf)); // 注意不要写成sizeof(buf),sizeof是在内存中所占的大小,strlen是到第一个'\0'位止。if(ret == -1) {perror("write error");exit(1);}// read在读socket时默认时阻塞的,阻塞等待服务端传输数据int len;len = read(cfd, buf, sizeof(buf));if(len == -1) {perror("read error");exit(1);}printf("%s", buf);}close(cfd);return 0;
}
3.4 程序运行结果及注意事项
先启动server后启动client,在client的终端输入小写字符后,可见翻译成了大写:
同时在server端输出了client的ip和端口号
程序有两个注意事项:
- 先启动服务端,再启动客户端。因为先启动客户端的话,服务器来不及监听,客户端就connect了,会导致connect失败。
- 关闭时先关闭客户端,再关闭服务端。如果先关闭服务端,服务器程序处于TIME_WAIT状态,程序中写死的6666端口号仍然被占用,可以使用
netstat -apn | grep 6666
查看程序对6666端口占用情况: