Socket编程学习记录
前言
最近的一次面试中面试官让我手撕Socket服务端,我当时打开编译器就懵了,写不出来,最后大体思想也没说明白,于是想回头再学习一下。面试问的真的很广,觉得自己还是太心急了,还是一定要把基础打牢!面试官跟我说,“你敢往简历上写的东西,就不要怕别人问!”。这是我第一次接触大厂的面试,虽说抓不住的机会,就不是机会,但这次面试对我来说,确实是一支警惕针!生于忧患,死于安乐!希望下一次自己能够抓住机会!
一、Socket是什么?
Socket又名套接字,是一种网络通信的工具,是应用层与TCP/IP协议族通信的中间软件抽象层,它是一组接口。
二、基于Socket的网络通信
1.基本的SOCKET接口函数
在生活中,A要电话给B,A拨号,B听到电话铃声后提起电话,这时A和B就建立起了连接,A和B就可以讲话了。等交流结束,挂断电话结束此次交谈。 打电话很简单解释了这工作原理:“open—write/read—close”模式。
服务器先初始化socket,绑定ip和端口号,设置监听队列,调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
2.代码实现
服务器代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <error.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <ctype.h>
#include <arpa/inet.h>#define SERVER_PORT 6666void perror_exit(const char *des){fprintf(stderr,"%s error,reason:%s\n",des,strerror(errno));exit(1);
}
int main(void){int sock;//创建socketint ret;struct sockaddr_in server_addr;//创建地址变量sock = socket(AF_INET,SOCK_STREAM,0);//创建socketif(sock == -1) perror_exit("create socket");bzero(&server_addr,sizeof(server_addr));//清空地址变量server_addr.sin_family = AF_INET;//确定协议族server_addr.sin_addr.s_addr = htonl(INADDR_ANY);//设置监听网络地址server_addr.sin_port = htons(SERVER_PORT);//改变地址编码方式ret = bind(sock,(struct sockaddr*)& server_addr,sizeof(server_addr));//绑定socket和端口if(ret == -1) perror_exit("bind");listen(sock,128); //监听printf("等待客户连接!\n");int done = 1;while(done){struct sockaddr_in client;int client_socket,len;char client_ip[64];char buf[256];socklen_t client_addr_len;client_addr_len = sizeof(client);client_socket = accept(sock,(struct sockaddr *)& client,&client_addr_len); //接受连接if(client_socket == -1) perror_exit("accept");printf("client ip: %s\t port : %d\n",inet_ntop(AF_INET,&client.sin_addr.s_addr,client_ip,sizeof(client_ip)),ntohs(client.sin_port)); 地址转换字符串格式len = read(client_socket,buf,sizeof(buf) - 1);if(len == -1) perror_exit("read");buf[len] = '\0';printf("recive[%d]: %s\n",len,buf);for(int i = 0;i < len;i ++)buf[i] = toupper(buf[i]); //小写转大写len = write(client_socket,buf,len); //发给客户端if(len == -1) perror_exit("write");printf("write finished.len: %d\n",len);close(client_socket);}return 0;}
客户端代码:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<string.h>
#include<sys/socket.h>
#include<netinet/in.h>
#include<arpa/inet.h>
#include<error.h>
#include<string.h>
#define SERVER_PORT 6666
#define SERVER_IP "101.7.144.47" void perror_exit(const char *des){perror(des);exit(1);
}
int main(int argc,char * argv[]){ int sockfd; char *message;struct sockaddr_in servaddr;int n,ret;char buf[64];if(argc != 2){fputs("Usage:./echoclient message\n",stderr);exit(1);} //确定运行格式正确message = argv[1];printf("meaage:%s\n",message);sockfd = socket(AF_INET,SOCK_STREAM,0);//初始化套接字if(sockfd == -1) perror_exit("create socket");memset(&servaddr,'\0',sizeof(struct sockaddr_in));servaddr.sin_family = AF_INET;//协议族inet_pton(AF_INET,SERVER_IP,&servaddr.sin_addr);//将字符串转成网络编码格式servaddr.sin_port = htons(SERVER_PORT);//将16位主机字节序转为网络字节序ret = connect(sockfd,(struct sockaddr *) &servaddr,sizeof(servaddr));//发出连接请求if(ret == -1) perror_exit("connect");ret = write(sockfd,message,strlen(message)); //发送数据if(ret == -1) perror_exit("write");n = read(sockfd,buf,sizeof(buf) - 1);if(n == -1) perror_exit("read");if(n > 0){buf[n] = '\0';printf("receiver:%s\n",buf);}else{perror("error!!!");}printf("finiahed!\n");close(sockfd);return 0;
}
服务端运行结果:
客户端运行结果:
三、代码细节分析
1.网络字节序
可以看到代码中有很多字节序转换的操作,为什么需要这个操作呢?需要了解主机字节序和网络字节序的区别。通常情况下主机使用的是小段字节序,而网络字节序一般是大端字节序。(小端字节序是低地址存低字节,高地址存高字节,大端字节序是高地址存低字节,低地址存高字节)。由于存在大小端不一致问题,C库函数<arpa/inet.h>就提供了网络字节序和主机字节序转换的函数。
// 16位整数转换 uint16_t htons(uint16_t hostshort); // 主机到网络(short) uint16_t ntohs(uint16_t netshort); // 网络到主机(short)// 32位整数转换 uint32_t htonl(uint32_t hostlong); // 主机到网络(long) uint32_t ntohl(uint32_t netlong); // 网络到主机(long)
2.sockaddr地址结构
bind(sock,(struct sockaddr*)& server_addr,sizeof(server_addr));
client_socket = accept(sock,(struct sockaddr *)& client,&client_addr_len);
connect(sockfd,(struct sockaddr *) &servaddr,sizeof(servaddr));
可以看到,上面的几个函数,都有对sockaddr_in地址进行类型转换的操作,为什么要有这样的操作呢?
struct sockaddr 很多网络编程函数诞生早于P4 协议,那时候都使用的是 sockaddr 结构体,为了向前兼容,现在 sockaddr退化成了(void*)的作用,传递一个地址给函数,至于这个函数是 sockaddr_in还是其他的,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。
2.1 sockaddr
sockaddr在头文件#include <sys/socket.h>
中定义。sockaddr的缺陷是:sa_data把目标地址和端口信息混在一起了,如下:
struct sockaddr { sa_family_t sin_family;//地址族char sa_data[14]; //14字节,包含套接字中的目标地址和端口信息 };
2.2 sockaddr_in
sockaddr_in在头文件#include<netinet/in.h>或#include <arpa/inet.h>
中定义,该结构体解决了sockaddr的缺陷,把port和addr 分开储存在两个变量中,如下:
sin_port和sin_addr都必须是网络字节序(NBO),一般可视化的数字都是主机字节序(HBO)。
3.IP地址转换
3.1 转换函数
#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *sre, char *dst, socklen t size);
af 取值可选为 AF_INET 和 AF_INET6,即和 ipv4 和ipv6 对应支持 IPv4 和 Pv6。其中 inet _pton 和 inet_ntop, 不仅可以转换 IPv4 的 in_addr,还可以转換 IPv6 的 in6_addr因此函数接口是 void *addrptr。
3.2 示例
#include <stdio.h>
#include <string.h>
#include <arpa/inet.h>int main(){char ip[] = "101.7.144.47";char server_ip[64];struct sockaddr_in server_addr;inet_pton(AF_INET,ip,&server_addr.sin_addr.s_addr);printf("s_addr:%x\n",server_addr.sin_addr.s_addr);printf("s_addr from net to host: %x\n",ntohl(server_addr.sin_addr.s_addr));inet_ntop(AF_INET,&server_addr.sin_addr.s_addr,server_ip,64);printf("server_ip:%s\n",server_ip);return 0;
}
这段程序的功能就是字符串类型的ip地址转换成网络字节序打印出来,然后转换成主机字节序打印出来,再从网络字节序转换成字符串格式打印出来。从输出的前两行可以看出,网络字节序和主机字节序的存储格式是同的,分别是大端存储和小段存储。
四.Socket编程函数
1.socket函数
在 Linux 下使用 <sys/socket.h> 头文件中 socket() 函数来创建套接字,原型为:
int socket(int af, int type, int protocol);
af 为地址族(Address Family),也就是 IP 地址类型,常用的有 AF_INET 和 AF_INET6。
type 为数据传输方式/套接字类型,常见的有以下几种:
- SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的 socket类型,这个 socket 是使用 TCP 来进行传输。
- SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用 UDP 来进行它的连接。
- SOCK_SEQPACKET 该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
- SOCK_RAW socket 类型提供单一的网络访问,这个 socket 类型使用ICMP 公共协议。(ping、traceroute,使用该协议)
- SOCK_RDM! 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序。
protocol传0表示使用默认协议。
返回值,成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno。
socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write 在网络上收发数据,如果socketO调用出错则返回-1。对于 IPv4,domain参数指定为 AF_INET。对于 TCP 协议,type参数指定为 SOCK STREAM,表示面向流的传输协议。如果是UDP 协议,则type 参数指定为 SOCK DGRAM,表示面向数据报的传输协议。protocol 参数的介绍从略,指定为0即可。
2.bind函数
int bind (int sockfd,const struet sockaddr *addr,socklen_t addrlen);
第一个参数sockfd是文件描述符,第二个是参数转换为sockaddr,第三个参数是只addr的长度。
服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用 bind 绑定一个固定的网络地址和端口号。
bind()的作用是将参数 sockfd和 addr绑定在一起,使 sockfd这个用于网络通讯的文件描述符监听addr,所描述的地址和端口号。前面提过,srucet sockadr是一个通用指针类型,addr参数实际上可以接受多种协议的 sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。
3.listen函数
int listen(int sockfd,int backlog);
第一个参数sockfd是文件描述符,第二个参数backlog在Linux 系统中,它是指排队等待建立3次握手队列长度。如下图所示TCP协议的三次握手发生在客户端的connect和读写操作中间这个阶段,再服务器端的listen和accept中间的阶段,设置的耳朵等待队列大小就是未完成三次握手连接请求的上限,超出上限系统就会报错。
典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未acept 的客户端就处于连接等待状态,listen()声明 sockfd处于监听状态,并且最多允许有 backlog 个客户端处于连接待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。
4.accept函数
int accept(int sockfd,struct sockaddr *addr,socklen_t *addrlen);
三方握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。addr 是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen,参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区addr 的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr 参教传NULL。表示不关心客户端的地址。
5.connect函数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
客户端需要调用 conect()连接服务器,conect 和 bind 的参数形式一致,区别在于bind的参数是自己的地址,而connect 的参数是对方的地址。connectO)成功返回 0,出错返回-1。
6.出错处理函数
6.1 strerror
我们知道,系统函数调用不能保证每次都成功,必须进行出错处理,这样一方面可以保证程序逻辑正常,另一方面可以迅速得到故障信息。
#include<error.h>
#include<string.h>
char *strerror(int errnum);
errnum,传入参数,错误编号的值,一般取 errng 的值。返回值为错误原因,字符串类型。
6.2 perror
#include<error.h>
#include<string.h>
void perror(const char *s);
perror,向标准出错stderr 输出出错原因,类似于服务器端写的fprintf函数得到功能。
总结
总算写完了,学了一整天socket,写了一晚上,写的比较仓促,也许有些错误,先不管了,回宿舍了。