研究生阶段项目开发用到了socket技术,写个博客简单记录一下socket通信相关的基础知识,包含我个人对socket技术的一些理解,个人经验,如有错误烦请大佬们批评指正
什么是socket
中文翻译过来叫“套接字”,可以理解为一个通信端点,我们都知道主机与主机之间通信是通过ip和端口(传输层和网络层),那么两台主机上的应用程序(应用层)如果想相互交流,也需要借助主机间的通信机制,但是应用程序不是主机本身,想要使用这一机制,就需要借助于socket,也就是说,socket是连接应用层和各种网络协议的接口。
socket的通信机制有点类似于我们用手机通信,当两个人需要远程对话的时候,首先要各有一部手机,然后需要插入电话卡(类似于ip地址和端口号),然后一方拨打另一方的手机号码,接通之后,可以相互发送/接收信息。
socket的类型
流格式套接字
流格式套接字(Stream Sockets)也叫面向连接的套接字,代码中使用SOCK_STREAM表示。提到面向连接,一定是基于TCP协议的,SOCK_STREAM是一种可靠的、双向的通信数据流,在底层实现了重传机制,使得数据可以准确无误地到达另一台计算机。
SOCK_STREAM的特征:
- 数据按照顺序传输(早发送的早到)
- 在传输过程中数据不会丢失(TCP协议保证)
-
数据发送和接收是不同步的(传输的数据存入缓冲区)
实际应用:HTTP协议传输数据
数据报格式套接字
数据报格式套接字(Datagram Sockets),也叫无连接的套接字(听名字就知道基于UDP),代码中使用SOCK_DGRAM表示。拥有UDP协议传输的优缺点,数据传送速度快,消耗小,但是数据容易丢失或损坏
SOCK_DGRAM的特征:
- 快速传输(不注重顺序)
- 数据可能会丢失或损坏(只是小概率事件,只是相对TCP来说不稳定)
- 数据的发送和接收是同步的
-
每次传输的数据大小有限制
实际应用: QQ视频、语音
原始套接字
原始套接字(Raw Socket)是一个特殊的额套接字类型,代码中使用SOCK_RAW表示,原始套接字的特殊之处在于:能够实现从应用层到数据链路层的所有数据操作。TCP/UDP类型的套接字只能访问传输层以及传输层以上的数据(因为TCP/UDP是传输层协议,当传到传输层的时候,下层的IP包头/帧头帧尾都已经被丢弃)
关于原始套接字,在此仅仅作一个简单介绍,我了解和使用的不是很多,不敢妄言,感兴趣的同学可以自行百度。
C/S架构下的socket设计
我参与开发的项目主要是C/S架构(网上的大多数socket教程也都采用C/S架构举例),就从这一方向简单介绍一下。
下图是我自己绘制的Stream Sockets在客户端和服务器端上的建立创建、连接、通信和关闭流程图,并包含了通过程中各种状态的变化。

Datagram Sockets的流程与Stream Sockets流程类似,只是其中的TCP协议换成了UDP协议,少了几次握手和挥手的过程。
socket常用数据结构
socket描述符
int类型,用于保存创建好的套接字
sockaddr
struct sockaddr {
unsigned short sa_family; /* 地址家族, AF_xxx */
char sa_data[14]; /*14字节协议地址*/
};
其中sa_family是两字节的地址家族,一般都是“AF_xxx”的形式,用于指定函数返回的地址信息类型
AF_INET:返回IPV4地址信息
AF_INET6:返回IPV6地址信息
AF_UNSPEC:可以返回任何协议族的地址
我们使用的基本都是第一种。
sa_data包含套接字中的目标地址和端口信息
sockaddr_in
struct sockaddr_in {
short int sin_family; /* 通信类型 */
unsigned short int sin_port; /* 端口 */
struct in_addr sin_addr; /* Internet 地址 */
unsigned char sin_zero[8]; /* 与sockaddr结构的长度相同*/
};
sin_family:等同于sa_family,实际应用中大多选择AF_INET
sin_port:存储端口号
sin_addr:存储IP地址,使用in_addr这个数据就够,并且要使用网络字节序
sin_zero:是为了让sockaddr与sockaddr_in两个结构体保持大小相同而保留的空字节
设计sin_zero,目的就是能让这两个结构体大小相等,进而能够相互转换,实际编程中多数使用第二个结构体来设置和获取地址信息,而作为参数传递时通常转换成sockaddr结构
in_addr
typedef struct in_addr {
union {
struct{ unsigned char s_b1,s_b2, s_b3,s_b4;} S_un_b;
struct{ unsigned short s_w1, s_w2;} S_un_w;
unsigned long S_addr;
} S_un;
} IN_ADDR;
是一个存储ip地址的共用体,有三种表达方式
第一种用四个字节表示IP地址的四个数字。
第二种用两个双字节表示IP地址
第三种用一个长整型来表示IP地址
给in_addr赋值最简单的方式就是inet_addr()函数,可以将一个代表IP地址的字符串转换为in_addr类型,其反函数是inet_ntoa()。
sockaddr_in ina;
ina.sin_addr.s_addr=inet_addr("192.168.0.1");
实际使用时需要对inet_addr()的返回值进行检查,如果为-1则说明函数错误,如果不检查无符号的-1则与广播地址255.255.255.255相同
网络与主机字节序的相互转换
需要先介绍一下网络字节序和主机字节序
网络字节序
是TCP/IP中已经规定好的一种数据表示格式,是一种固定格式,保证数据在不同主机之间传输时能够被正确解释。网络字节序采用大端存储方式(低位字节放在内存高位地址,高位字节放在内存低位地址)
主机字节序
主机字节序是多样性的,其存储方式取决于CPU等
判断主机字节序的方法
bool am_little_endian()
{
unsigned short i=1;
return (int)*((char*)(&i))?true:false; //返回true则为小端存储
}
转换方法
htons()——主机字节序转换为网络字节序(short类型,两个字节)
htonl()——主机字节序转换为网络字节序(long类型,四个字节)
ntohs()——网络字节序转换为主机字节序(short类型)
ntohl()——网络字节序转换为主机字节序(long类型)
为了程序的可移植性,当数据需要被传输到网络上时,一定要判断主机字节序,并确保其和网络字节序相同
如sockaddr_in结构体中的sin_port和sin_addr就都需要确保为网络字节序
socket相关函数
终于写到这里了。。。以下有些函数是客户端独有的,有些函数是服务器端独有的,为了行文方便,我就混着写了,读者可以参考前面的图示来确定这些函数执行的先后顺序
socket()
#include<sys/types.h>
#include<sys/socket.h>
int socket(int domian,int type,int protocol);
一个一个参数来看
domain:需要设置为AF_INET,等同于sa_family和sin_family。
type:告诉内核,我们使用的socket是SOCK_STREAM还是SOCK_DGRAM类型(一般用不到SOCK_RAW类型)。
protocol:指使用的协议,设置为0时,是默认跟随type参数来选择协议,如果type参数值为SOCK_STREAM,protocol默认为IPPROTO_TCP;如果type值为SOCK_DGRAM,protocol默认为IPPROTO_UDP。
这个函数返回一个int类型套接字,我们后面要用这个套接字实现客户端和服务器的连接
bind()
#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, struct sockaddr *my_addr, int addrlen);
这是服务器端独有的函数,需要错误检查,出错时返回值为-1
sockfd就是调用socket()函数返回的套接字
sockaddr就是前面讲的结构体的指针,赋值时转换成sockaddr_in,存放的是服务器端的ip、端口等信息
addrlen设置为sizeof(struct sockaddr)
一些可以交由系统自动填入的数据:
sin_port=0时,表示由系统随机选取一个没有被使用的端口
sin_addr=htonl(INADDR_ANY) 表示使用本机IP地址
connect()
#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, struct sockaddr *serv_addr, int addrlen);
这是客户端独有的函数,需要错误检查,出错时返回值为-1
sockfd是调用socket()返回的套接字
serv_addr记录了要连接的服务器端的地址(IP和端口号)
addrlen,同上,sizeof(struct sockaddr)
listen()
这是服务器端独有的函数,发生错误时返回-1
sockdf 不多说了
backlog 用于设置进入等待队列的最大连接数(客户端的连接请求先进入等待队列,直到服务器端接受连接),系统与允许的最大数目是20,也可以设置为5-10
accept()
#include <sys/socket.h>
int accept(int sockfd, void *addr, int *addrlen);
这是服务器端独有的函数,发生错误时返回-1
sockfd 继续不多说了
addr是一个空类型的指针,指向sockaddr_in结构体
addrlen设为sizeof(struct sockaddr_in)
函数执行后返回一个int类型的新套接字,我的理解是,前面一个套接字用于继续监听(listen),新套接字是为了与已经建立连接的客户端通信,后面的send()/recv()函数都是使用这个新套接字
accept()是一个阻塞(block)函数,运行时会取出连接等待队列中的队首元素,当等待队列为空时,这个函数就会阻塞等待,直至接收到消息为止,如果连接数量已经达到上限,系统会自动屏蔽accept()函数,直至有客户端退出连接,系统再将其唤醒。
send()/recv()
之所以将这两个函数放在一起,是因为他们是要互相配合使用的,send函数发送的数据通过recv函数接收
sockfd:注意如果是服务器端使用这个函数,这里的套接字要使用accept()函数返回的新套接字,而不是socket()函数返回的套接字。而客户端使用这个函数,就只需要使用socket()函数返回的套接字即可
msg:需要传输的数据的指针
len:需要传输的数据的长度
flags:设为0,实际上这个flag可以包含两个值,只是我们通常用不到另一个,实际应用中一般是设为0,感兴趣的同学可以自己查一下
send()函数返回值是实际传送出去的字符数(虽然说send()是发送函数,但是实际上这个函数只是起到一个将应用层数据拷贝到内核层缓冲区的作用,而发送数据则是通过协议),失败时返回-1
sockfd 同上,不多赘述了
buf是需要读取的数据缓冲区的指针
len是buf指向的缓冲区的数据长度
flags依旧设置为0
函数执行错误时返回-1
recv()函数默认也是一个阻塞函数,只有接收到消息才会执行,也可以设置为非阻塞模式,感兴趣的同学可以自己搜索相关内容,我后续应该也会写一篇关于accept()函数和recv()函数的阻塞/非阻塞模式的博客
到这里为止,SOCK_STREAM类型的socket程序已经可以正常通信了,如果需要使用SOCK_DGRAM类型的socket程序进行通信,需要将send()/recv()函数替换成sendto()/recvfrom()函数
##sendto()/recvfrom()
SOCK_DGRAM类型的socket客户端和服务器端之间是不建立TCP连接的,因此每次发送数据时,都需要填入目标地址
前三个参数和send()函数一致,第四个参数中记录的是目标主机的IP地址和端口信息,第五个参数tolen可以设置为sizeof(struct sockaddr)
sendto()函数返回实际发送的字节数,错误时返回-1
前三个参数也和recv()函数一致,第四个参数记录的是源主机的IP和端口信息,第五个参数fromlen初始值为sizeof(struct sockaddr),函数执行之后,fromlen中保存着实际存储在from中的地址的长度
recvform返回收到的字节长度,错误时返回-1
如果SOCK_DGRAM类型的socket客户端调用connect()函数连接一个服务器端,也可以直接使用send()/recv()函数,此时发送的依然是UDP报文,系统接口会自动加上目标和源的信息
closesocket()/shutdown()函数
准备关闭套接字的时候,就需要调用closesocket()函数
如果是服务器端程序要关闭了,就需要将用于send()/recv()函数的套接字和用于listen()的套接字一并关闭
单方向关闭了套接字之后,另一端读写套接字都会返回错误信息,如果想要在一定方向上的通讯做一些限制,就可以使用shutdown()函数
how的值有以下几种
0——不允许接收
1——不允许发送
2——不允许发送和接收(实际上等同于close())
shutdown()函数执行成功时返回0,失败时返回-1,在SOCK_DGRAM的socket程序中使用shutdown,只是让send()/recv()函数不能使用了
getpeername()
#include <sys/socket.h>
int getpeername(int sockfd, struct sockaddr *addr, int *addrlen);
这个函数返回值为:SOCK_STREA的socket程序中,与当前套接字连接的另一端的信息,这个信息被存储在addr中,addrlen则存储这个信息的长度
gethostname()
#include <unistd.h>
int gethostname(char *hostname, size_t size);
这个函数返回当前程序运行的主机的名字,然后借助gethostbyname()函数可以获得当前主机的IP地址
函数调用成功时返回0,失败时返回-1
gethostbyname()
#include <netdb.h>
struct hostent *gethostbyname(const char *name);
name就是前面获取到的主机名
struct hostent的结构如下
struct hostent {
char *h_name;
char **h_aliases;
int h_addrtype;
int h_length;
char **h_addr_list;
};
h_name 主机的正式名称。
h_aliases 空字节-地址的预备名称的指针。
h_addrtype 地址类型; 通常是AF_INET。
h_length –地址的比特长度。
h_addr_list 主机网络地址指针。网络字节序。
h_addr 是h_addr_list中的第一地址。
服务器端实例
C++简单的服务器端实现
SocketServer.h
#include<WinSock2.h>
#include<windows.h>
#include<iostream>
#define LENGTH_COMM 1024 //设置消息的最大长度为1024,当然也可以设置为其他大小
#pragma comment(lib, "ws2_32.lib") //这个库一定要加载
using namespace std;
class SocketServer
{
public:
int _sock=0;
int _acc_sock = 0;
sockaddr_in _sin;
sockaddr_in clientaddr;
SocketServer();
~SocketServer();
int SocketConn(); //与客户端socket连接
int SocketSR(); //与客户端通信
};
SocketServer.cpp
#ifndef _SOCKET_
#define _SOCKET_
#include"SocketServer.h"
#endif
SocketServer::SocketServer()
{
WSADATA wsadata;
if (WSAStartup(MAKEWORD(2, 2), &wsadata)!=0)
{
cout << "socket库初始化失败" << endl;
exit(0);
}
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == _sock)
{
cout << "socket初始化失败" << endl;
exit(0);
}
_sin.sin_family = AF_INET;
_sin.sin_port = 50001;
_sin.sin_addr.S_un.S_addr = inet_addr("192.168.126.1");
}
SocketServer::~SocketServer()
{
if (_sock != 0)
{
closesocket(_sock);
_sock = 0;
}
if (_acc_sock != 0)
{
closesocket(_acc_sock);
_acc_sock = 0;
}
WSACleanup();
}
int SocketServer::SocketConn()
{
int sock = _sock;
sockaddr_in sin = _sin;
if (-1 == bind(sock, (sockaddr*)&sin, sizeof(sockaddr)))
{
cout << "绑定失败!" << endl;
return -1;
}
if (-1 == listen(sock, 10))
{
cout << "监听失败!" << endl;
return -1;
}
return 0;
}
int SocketServer::SocketSR()
{
int acc_len = sizeof(sockaddr_in);;
_acc_sock = accept(_sock, (sockaddr*)&clientaddr, &acc_len);
if (-1 == _acc_sock)
{
cout << "接收失败" << endl;
return -1;
}
cout << "已经与客户端建立连接,客户端IP为:" << inet_ntoa(clientaddr.sin_addr) << " 端口号:" << clientaddr.sin_port << endl;
int acc_sock = _acc_sock;
char buff[LENGTH_COMM] = {"\0"};
strcpy_s(buff, "可以开始通信了!");
int err_send=send(acc_sock, buff, strlen(buff), 0);
if (err_send < 0)
{
cout << "信息发送失败!" << endl;
return -1;
}
while(true)
{
char buffer[LENGTH_COMM] = {'\0'};
int err_recv = recv(acc_sock, buffer, LENGTH_COMM, 0);
if (err_recv < 0)
{
cout << "接收消息失败" << endl;
return -1;
}
cout << "客户端(" << inet_ntoa(clientaddr.sin_addr) << ":" << clientaddr.sin_port << ")发来消息:" << buffer << endl;
}
return 0;
}
客户端实现
SocketClient.h
#include<WinSock2.h>
#include<windows.h>
#include<iostream>
#define LENGTH_COMM 1024
#pragma comment(lib,"ws2_32.lib")
using namespace std;
class SocketClient
{
public:
int _sock=0;
sockaddr_in _sin;
SocketClient();
~SocketClient();
int SocketConn();
int SocketSR();
};
SocketClient.cpp
#ifndef _SOCKET_
#define _SOCKET_
#include"SocketClient.h"
#endif
SocketClient::SocketClient()
{
WSADATA wsadata;
int err = WSAStartup(MAKEWORD(2, 2), &wsadata);
if (err != 0)
{
cout << "socket库初始化失败!" << endl;
exit(0);
}
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == _sock)
{
cout << "socket初始化失败!" << endl;
exit(0);
}
_sin.sin_addr.S_un.S_addr = inet_addr("192.168.126.1");
_sin.sin_family = AF_INET;
_sin.sin_port = 50001;
}
SocketClient::~SocketClient()
{
if (_sock != 0)
closesocket(_sock);
WSACleanup();
}
int SocketClient::SocketConn()
{
int sock = _sock;
sockaddr_in sin = _sin;
int err=connect(sock, (sockaddr*)&sin, sizeof(sockaddr));
if (-1 == err)
{
cout << "连接失败!" << endl;
return -1;
}
return 0;
}
int SocketClient::SocketSR()
{
int sock = _sock;
char buff[LENGTH_COMM] = { "\0" };
int err = recv(sock, buff, LENGTH_COMM, 0);
cout << buff << endl;
if (err < 0)
{
cout << "消息接收失败!" << endl;
return -1;
}
while(true)
{
char buffer[LENGTH_COMM] = { "\0" };
cin >> buffer;
int err_send=send(sock, buffer, strlen(buffer), 0);
if (err_send < 0)
{
cout << "消息发送失败!" << endl;
return -1;
}
}
return 0;
}
当然这只是一个简单实例,细节设计的还不是很好。如果想用一个服务器接收多个客户端的数据,就需要在服务器端建立多个acc_sock套接字,创建多个线程,整个SocketServer类的结构也要发生变化。这些功能就留到以后实现(挖个坑)。