之前用C#做服务器没搞明白于是从笔者比较熟悉的C++开始入手从头学了一遍,整理一下笔记。 资料来源于《网络多人游戏架构与编程》第三章,这本书讲的很明白,比起网上每篇博客都在介绍的原理,这本书更偏向于代码实现。 代码应该没什么问题,笔者已经成功和C#写的客户端连接上了。
头文件及使用
头文件部分
#include <WinSock2.h>
#include <Ws2tcpip.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
Windows系统启动socket库
在POIX平台中,库在默认情况下就是开启状态,而Winsock2需要显示的启动和关闭,并允许用户指定使用什么版本,使用WSAStartup 激活,使用WSACleanup 关闭
int WSAStartup(WORD wVersionRequested, LPWSADATA lpWSAData);
int WSACleanup();
WSADATA wsaData;
WSAStartup(MAKEWORD(2, 2), &wsaData);
WSACleanup();
报错
错误报告在各个平台上略有不同,所有平台上大部分函数错误时返回-1 ,在Windows系统中,可以使用宏SOCKET_ERROR 代替-1 ,因为-1 不能显示错误来源,所以Winsock2提供了获取额外错误代码的函数来得到错误原因。
int WSAGetLastError();
这个函数仅返回当前运行线程的最近错误代码。
类似的,POSIX兼容库也提供了获取错误信息的方法,但是,它们使用C语言标准库中的全局变量errno报告错误代码,所以需要包含<errno.h> 文件。
创建SOCKET
SOCKET socket(int af, int type, int protocol);
参数
-
af 参数表示协议簇,指明socket所使用的网络层协议。
宏 | 含义 |
---|
AF_INEX | IPv4 | AF_INET6 | IPv6 |
-
type 指明socket发送和接收分组的形式。
宏 | 含义 |
---|
SOCK_STREAM | 有序可靠的(适用于TCP) | SOCK_DGRAM | 离散的(适用于UDP) |
-
protocol 表示发送数据时应使用的协议,值为0时表示用type默认的形式
使用
SOCKET tcpsocket = socket(AF_INET, SOCK_STREAM, 0);
SOCKET udpsocket = socket(AF_INET, SOCK_DGRAM, 0);
int closesocket(SOCKET sock);
当关闭TCPsocket时,应保证所有发送、接收的数据都已经传输确认完毕,所以应停止SOCKET传输,再关闭socket连接。
int shutdown(SOCKET socket, int how);
shutdown() 参数how:
宏 | 含义 |
---|
SD_SEND | 停止发送 | SD_RECEIVE | 停止接收 | SD_BOTH | 停止发送和接收 |
SOCKET地址
每一个网络层数据包都需要一个源地址和一个目的地址,如果数据包封装传输层数据,还需要一个源端口和一个目的端口。为了将地址信息传入和传出socket库,API提供了sockaddr数据类型
struct sockaddr {
uint16_t sa_family;
char sa_data[14];
};
虽然可以手动填写sa_data,但是你需要各种地址族的内存布局,为了弥补这一点,API为常用地址族提供了帮助地址初始化的专用数据类型。但是因为C语言莫得多态继承,所以传入地址的时候需要手动把专用数据类型转换成sockaddar类型。
sockaddr_in类型:IPv4
struct sockaddr_in{
short sin_family;
uint16_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
struct in_addr {
union {
struct {
uint8_t s_b1, s_b2, s_b3, s_b4;
} S_un_b;
struct {
uint16_t s_w1, s_w2;
} S_un_w;
uint32_t S_addr;
} S_un;
};
当利用4字节整数设置IP地址或者设置端口号时,很重要的一件事情是考虑TCP/IP协议族和主机有可能在多字节数的字节序上采用不用的标准。为了实现将主机字节序转换为网络字节序的功能,socketAPI提供了htons 函数和htonl 函数。
uint16_t htons(uint16_t hostshort);
uint32_t htons(uint32_t hostlong);
当然,你可以用如下函数去将网络字节序转换成你能读懂的字节序
uint16_t ntohs(uint16_t networkshort);
uint32_t ntohs(uint32_t networklong);
初始化sockaddr_in
下面的代码展示了如何创建一个IP地址为127.0.0.1,端口为80的socket地址
sockaddr_in myAddr;
memset(myAddr.sin_zero, 0, sizeof(myAddr.sin_zero));
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(80);
myAddr.sin_addr.S_un.S_un_b.s_b1 = 127;
myAddr.sin_addr.S_un.S_un_b.s_b2 = 0;
myAddr.sin_addr.S_un.S_un_b.s_b3 = 0;
myAddr.sin_addr.S_un.S_un_b.s_b4 = 1;
类型安全
因为socket库最初建立的时候很少考虑类型安全,所以在应用层把基本的socket数据类型和函数封装为自定义的面向对象的结构体是很有帮助的。有助于将socketAPI与你的业务代码中分离出来,以备之后决定将socket库替换成为其它网络库。
用字符串初始化sockaddr
向socket地址添加IP地址和端口有一定发的工作量,特别是地址信息很可能来自程序配置文件或者命令行中的一个字符串,如果是将字符串输入sockaddr,你可以不做处理工作,而是使用如下函数
int inet_pton(int af, const char* src, void* dst);
int inet_pton(int af,const PCTSTR src, void* dst);
参数
af 即地址族,即AF_INET或AF_INET6。src 应指向空字符(NULL)结尾的字符串,存储英文句号分割的地址。dst 应该指向待赋值的sockaddr和sin_addr字段。
这个函数成功时返回1,源字符串错误返回0,发生其它系统错误返回-1
如下使用inet_pton初始化sockaddr
sockaddr_in myAddr;
myAddr.sin_family = AF_INET;
myAddr.sin_port = htons(80);
inet_pton(AF_INET, "127.0.0.1", &myAddr.sin_addr);
当然这个字符串需要是纯数字的IP地址,如果是域名的话需要将域名解析为地址
int getaddrinfo(
const char* hostname,
const char* servname,
const addrinfo* hints,
addrinfo** res
);
struct addrinfo {
int ai_flags;
int ai_family;
int ai_socktype;
int ai_protocol;
size_t ai_addrlen;
char* ai_canonname;
sockaddr* ai_addr;
addrinfo* ai_next;
};
void freeaddrinfo(addrinfo* ai);
注意:getaddrinfo 没有内置的异步操作,会阻塞线程,需要大量的时间(毫秒甚至秒级)。
绑定socket
通知操作系统socket将使用一个特定地址和传输层端口的过程称为绑定。使用bind函数:
int bind(SOCKET sock, const sockaddr* address, int address_len);
bind成功时返回0,失败时返回-1。
通常,你只能将一个socket绑定到一个给定的地址和端口。如果这个地址和端口已经被占用,那么bind返回-1,这种情况下,你可以反复尝试绑定不同端口,知道找到可用端口,地可以给需要绑定的端口赋值为0来自动完成这个操作。
如果一个进程试图使用一个未被绑定的socket发送数据,网络库将自动为这个socket绑定一个可用的端口,因此,手动调用bind函数的唯一原因是指定绑定的地址和端口。
UDP Socket
一旦创建好socket,就可以通过UDP socket发送数据,如果没有绑定,网络模块将在动态端口范围内找一个空闲的端口自动绑定,使用sendto函数发送数据:
int sendto(
SOCKET sock,
const char* buf,
int len,
int flags,
const sockaddr* to,
int tolen
);
如果操作成功,返回等待发送数据的长度,否则返回-1,若返回0,代表数据已经成功进入发送队列,并不代表数据已经成功发出。
使用recvfrom 函数从UDPsocket接收数据:
int recvfrom(SOCKET sock,
char* buf,
int len,
int flags,
sockaddr* from,
int* fromlen
);
如果成功执行,返回复制到buf的字节数,如果发生错误则返回-1
TCP Socket
TcpSocket在使用socket和bind函数创建和绑定一个socket之后,需要使用listen函数启动监听:
int listen(
SOCKET sock,
int backlog
);
成功时返回0,错误时返回-1
接收传入的连接并继续TCP握手过程的时候,调用accept函数:
SOCKET accept(
SOCKET sock,
sockaddr* addr,
int* addrlen
);
若accept执行成功,将创建并返回一个可以与远程主机通信的新socket,这个socket被绑定到与监听socket相同的端口号上。默认情况下,如果没有待接收的传入连接,accept函数将阻塞调用线程,直到收到一个传入的连接,或者超时。
客户端应使用connect函数主动与远程服务器握手:
int connect(
SOCKET sock,
const sockaddr* addr,
int addrlen
);
函数成功时返回0,错误时返回-1,默认情况下,connect会阻塞线程直到连接被接受或者超时。
连接完毕后,使用send函数通过连接的TCP socket发送数据:
int send(
SOCKET sock,
const char* buf,
int len,
int flags
);
如果send成功,返回发送数据的大小。如果socket的输出缓冲区有一些空余的空间,但不足以容纳整个buf时,这个值可能会比参数len小。如果没有空间,默认情况下,调用线程将被阻塞,直到调用超时或者发送了足够的数据后产生空间。如果发生错误,send函数返回-1。请注意,非零的返回值并不代表数据已经成功发送出去了,只能说明数据被存入队列中等待发送。
调用recv函数从上图一个连接的TCPsocket接收数据
int recv(
SOCKET sock,
char* buf,
int len,
int flags
);
如果recv调用成功,返回接受的数据大小,这个值小于等于len。当len非零时,如果recv返回0,说明连接的另外一段发送了一个FIN数据包,承诺没有更多需要发送的数据(即可断开)。如果发生错误,recv函数返回-1。默认情况下,recv函数会阻塞调用线程,直到数据流中的下一组数据到达,或者超时。
|