linux下Socket编程

游戏服务器开发说到底根本技术在两点网络通信和数据库读写,所以最近先把linux下的socket编程再系统复习一遍。

Linux下的一个简单网络通信程序

废话少说先直接上两个简单的示例代码

server.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <string>

int main()
{
//创建套接字
int serv_sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//将套接字和IP、端口绑定
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
serv_addr.sin_port = htons(1400); //端口
bind(serv_sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

//进入监听状态,等待用户发起请求
//listen(serv_sock, SOMAXCONN);
listen(serv_sock, 20);

//接收客户端请求
int clientCount = 0;
while (true)
{
struct sockaddr_in clnt_addr;
socklen_t clnt_addr_size = sizeof(clnt_addr);
int clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_addr, &clnt_addr_size);

if(-1 == clnt_sock){
break;
}

++clientCount;
//向客户端发送数据
std::string str = "hello client" + std::to_string(clientCount);
write(clnt_sock, str.c_str(), str.length());

//关闭套接字
close(clnt_sock);
}

close(serv_sock);

return 0;
}

client.cpp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

int main()
{
//创建套接字
int sock = socket(AF_INET, SOCK_STREAM, 0);

//向服务器(特定的IP和端口)发起请求
struct sockaddr_in serv_addr;
memset(&serv_addr, 0, sizeof(serv_addr));
serv_addr.sin_family = AF_INET; //使用IPv4地址
serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); //具体的IP地址
serv_addr.sin_port = htons(1400); //端口
connect(sock, (struct sockaddr*)&serv_addr, sizeof(serv_addr));

//读取服务器传回的数据
char buffer[40];
read(sock, buffer, sizeof(buffer)-1);

printf("Message form server: %s\n", buffer);

//关闭套接字
close(sock);

return 0;
}

这就是最简单的服务器客户端示例,开启server程序监听后,开启client程序会对server连接,打印收到的服务器下发的字符串。

源码解析

server

首先通过 socket() 函数创建了一个套接字,参数 AF_INET 表示使用 IPv4 地址,SOCK_STREAM 表示使用面向连接的套接字,IPPROTO_TCP 表示使用 TCP 协议。在 Linux 中,socket 也是一种文件,有文件描述符,可以使用 write() / read() 函数进行 I/O 操作。

通过 bind() 函数将套接字 serv_sock 与特定的 IP 地址和端口绑定,IP 地址和端口都保存在 sockaddr_in 结构体中。socket() 函数确定了套接字的各种属性,bind() 函数让套接字与特定的IP地址和端口对应起来,这样客户端才能连接到该套接字。

listen()让套接字处于监听状态。所谓监听,是指套接字一直处于“睡眠”中,直到客户端发起请求才会被“唤醒”。

accept() 函数用来接收客户端的请求。程序一旦执行到 accept() 就会被阻塞(暂停运行),直到客户端发起请求。

write() 函数用来向套接字文件中写入数据,也就是向客户端发送数据。

和普通文件(再次体现了linux下一切皆文件,fd文件描述符亦是)一样,socket 在使用完毕后也要用 close() 关闭。

client

client中创建套接字以及对应结构体和server一样,就不赘述了。

connect() 向服务器发起请求,服务器的IP地址和端口号保存在 sockaddr_in 结构体中。直到服务器传回数据后,connect() 才运行结束。

read() 从套接字文件中读取数据。

linux下socket相关函数

下面分别来分析在 Linux 下使用 <sys/socket.h> 头文件中几个重要函数。下面涉及到的函数源码,均是在CentOS Linux release 7.5.1804版本下摘抄。

socket()

原型

1
2
3
4
/* Create a new socket of type TYPE in domain DOMAIN, using
protocol PROTOCOL. If PROTOCOL is zero, one is chosen automatically.
Returns a file descriptor for the new socket, or -1 for errors. */
extern int socket (int __domain, int __type, int __protocol) __THROW;
  1. __domain: 这个参数本身含义是指通信中的“域”的含义,网上大部分文章更愿意叫这个参数按老版本函数原型int af中的af的原意–Address Family。就是地址族,从参数本身意义来说也就是IP地址类型。常用的有 AF_INET 和 AF_INET6。AF_INET 表示 IPv4 地址,例如 127.0.0.1;AF_INET6 表示 IPv6 地址,例如 fe80:0000:0001:0000:0440:44ff:1233:5678。

    也可以使用 PF 前缀,PF 是“Protocol Family”的简写,它和 AF 是一样的。例如,PF_INET 等价于 AF_INET,PF_INET6 等价于 AF_INET6。

  2. __type: 为数据传输方式/套接字类型,常用的有 SOCK_STREAM(流格式套接字/面向连接的套接字)SOCK_DGRAM(数据报套接字/无连接的套接字)

  3. __protocol: protocol 表示传输协议,常用的有 IPPROTO_TCPIPPTOTO_UDP,分别表示 TCP 传输协议和 UDP 传输协议。

有个问题,有了地址类型和数据传输方式,还不足以决定采用哪种协议吗?为什么还需要第三个参数呢?

一般情况下有了 af 和 type 两个参数就可以创建套接字了,操作系统会自动推演出协议类型,除非遇到这样的情况:有两种不同的协议支持同一种地址类型和数据传输类型。如果我们不指明使用哪种协议,操作系统是没办法自动推演的。(因为我只用过tcp和udp两种,所以暂时还不了解有没有这种情况真实存在)

那么tcp和udp创建套接字代码也可以简写成如下:

1
2
int tcp_socket = socket(AF_INET, SOCK_STREAM, 0);  //创建TCP套接字
int udp_socket = socket(AF_INET, SOCK_DGRAM, 0); //创建UDP套接字

bind()

原型

1
2
3
4
5
6
/* Give the socket FD the local address ADDR (which is LEN bytes long).  */
extern int bind (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len)
__THROW;

// __CONST_SOCKADDR_ARG定义
#define __CONST_SOCKADDR_ARG const struct sockaddr *

fd为socket文件描述符,addr为sockaddr结构体指针,len为addr变量的大小,可由sizeof()计算得出。

下面的代码,将创建套接字,并使其与ip地址127.0.0.1、端口2222绑定:

1
2
3
4
5
6
7
8
9
10
11
12
//创建套接字
int servSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP);

//创建sockaddr_in结构体变量
struct sockaddr_in servAddr;
memset(&servAddr, 0, sizeof(servAddr)); //初始化
servAddr.sin_family = AF_INET; //使用IPv4地址
servAddr.sin_addr.s_addr = inet_addr("127.0.0.1"); //要绑定的IP地址
servAddr.sin_port = htons(2222); //端口

//将套接字和IP、端口绑定
bind(servSocket, (struct sockaddr*)&servAddr, sizeof(servAddr));

上面代码使用了sockaddr_in结构体,然后再强制转换成sockaddr类型,后面会提到为什么要这样做。

sockaddr_in 结构体

原型

1
2
3
4
5
6
7
8
9
10
11
12
13
/* Structure describing an Internet socket address.  */
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_);
in_port_t sin_port; /* Port number. */
struct in_addr sin_addr; /* Internet address. */

/* Pad to size of `struct sockaddr'. */
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
};
  1. __SOCKADDR_COMMON就是成员sin_family ,外层又一个宏定义,这个包装先不细究。sin_family和socket()的第一个参数含义相同,取值也要保持一致。
  2. sin_prot 为端口号。in_port_t最终类型是int16,它的长度为两个字节,理论上端口号的取值范围为 065536,但 01023 的端口一般由系统分配给特定的服务程序,例如 Web 服务的端口号为 80,FTP 服务的端口号为 21,所以我们的程序要尽量在 1024~65536 之间分配端口号。端口号需要用htons()进行转换,这个后面有机会也做一下记录,网络大小端的转换。
  3. sin_addr 是 struct in_addr 结构体类型的变量,下面会详细讲解。
  4. sin_zero[8] 是多余的8个字节(根据ide显示其中算式算出来是8),暂时不关注,一般使用 memset() 函数填充为 0。上面的代码中,先用 memset() 将结构体的全部字节填充为 0,再给前3个成员赋值,剩下的 sin_zero 自然就是 0 了。

in_addr结构体

sockaddr_in 的第3个成员是 in_addr 类型的结构体,该结构体只包含一个成员,如下所示:

1
2
3
4
5
6
/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr;
};

in_addr_t 在头文件 <netinet/in.h> 中定义,等价于 unsigned long,长度为4个字节。也就是说,s_addr 是一个整数,而IP地址是一个字符串,所以需要 inet_addr() 函数进行转换,例如:

1
2
unsigned long ip = inet_addr("127.0.0.1");
printf("%ld\n", ip);

运行结果:
16777343

为什么要搞这么复杂,结构体中嵌套结构体,而不用 sockaddr_in 的一个成员变量来指明IP地址呢?

这或许是历史原因吧,后面的接口总要兼容前面的代码。暂时不理解没有关系,后续看有没有机会知道原因。

为什么使用sockaddr_in而不使用sockaddr

bind() 第二个参数的类型为 sockaddr,而代码中却使用 sockaddr_in,然后再强制转换为 sockaddr,这是为什么呢?

sockaddr 结构体的定义如下:

1
2
3
4
5
6
/* Structure describing a generic socket address.  */
struct sockaddr
{
__SOCKADDR_COMMON (sa_); /* Common data: address family and length. */
char sa_data[14]; /* Address data. */
};

下图是 sockaddr 与 sockaddr_in 的对比(括号中的数字表示所占用的字节数,__SOCKADDR_COMMON依旧是sin_family的宏定义):

sockaddr 和 sockaddr_in 的长度相同,都是16字节,只是将IP地址和端口号合并到一起,用一个成员 sa_data 表示。要想给 sa_data 赋值,必须同时指明IP地址和端口号,例如”127.0.0.1:80“,遗憾的是,没有相关函数将这个字符串转换成需要的形式,也就很难给 sockaddr 类型的变量赋值,所以使用 sockaddr_in 来代替。这两个结构体的长度相同,强制转换类型时不会丢失字节,也没有多余的字节。

可以认为,sockaddr 是一种通用的结构体,可以用来保存多种类型的IP地址和端口号,而 sockaddr_in 是专门用来保存 IPv4 地址的结构体。另外还有 sockaddr_in6,用来保存 IPv6 地址,它的定义如下:

1
2
3
4
5
6
7
8
9
10
11
#if !__USE_KERNEL_IPV6_DEFS
/* Ditto, for IPv6. */
struct sockaddr_in6
{
__SOCKADDR_COMMON (sin6_);
in_port_t sin6_port; /* Transport layer port # */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
#endif /* !__USE_KERNEL_IPV6_DEFS */

正是由于通用结构体 sockaddr 使用不便,才针对不同的地址类型定义了不同的结构体。

connect()函数

connect() 函数用来建立连接,它的原型为:

1
2
3
4
5
6
7
8
/* Open a connection on socket FD to peer at ADDR (which LEN bytes long).
For connectionless socket types, just set the default address to send to
and the only address from which to accept transmissions.
Return 0 on success, -1 for errors.

This function is a cancellation point and therefore not marked with
__THROW. */
extern int connect (int __fd, __CONST_SOCKADDR_ARG __addr, socklen_t __len);

各个参数的说明和 bind() 相同,不再赘述。

listen()函数

对于服务器端程序,使用 bind() 绑定套接字后,还需要使用 listen() 函数让套接字进入被动监听状态,再调用 accept() 函数,就可以随时响应客户端的请求了。

1
2
3
4
/* Prepare to accept connections on socket FD.
N connection requests will be queued before further requests are refused.
Returns 0 on success, -1 for errors. */
extern int listen (int __fd, int __n) __THROW;

__fd为要监听的套接字,n为请求队列的最大长度。

所谓被动监听,是指当没有客户端请求时,套接字处于“睡眠”状态,只有当接收到客户端请求时,套接字才会被“唤醒”来响应请求。

请求队列

当套接字正在处理客户端请求时,如果有新的请求进来,套接字是没法处理的,只能把它放进缓冲区,待当前请求处理完毕后,再从缓冲区中读取出来处理。如果不断有新的请求进来,它们就按照先后顺序在缓冲区中排队,直到缓冲区满。这个缓冲区,就称为请求队列(Request Queue)

缓冲区的长度(能存放多少个客户端请求)可以通过 listen() 函数的 backlog 参数指定,但究竟为多少并没有什么标准,可以根据你的需求来定,并发量小的话可以是10或者20。

如果将 n的值设置为 SOMAXCONN(也在socket.h文件中),就由系统来决定请求队列长度,这个值一般比较大,可能是几百,或者更多。

1
2
/* Maximum queue length specifiable by listen.  */
#define SOMAXCONN 128

当请求队列满时,就不再接收新的请求,对于 Linux,客户端会收到 ECONNREFUSED 错误,对于 Windows,客户端会收到 WSAECONNREFUSED 错误。

注意:listen() 只是让套接字处于监听状态,并没有接收请求。接收请求需要使用 accept() 函数。

accept() 函数

当套接字处于监听状态时,可以通过 accept() 函数来接收客户端请求。它的原型为:

1
2
3
4
5
6
7
8
9
10
/* Await a connection on socket FD.
When a connection arrives, open a new socket to communicate with it,
set *ADDR (which is *ADDR_LEN bytes long) to the address of the connecting
peer and *ADDR_LEN to the address's actual length, and return the
new socket's descriptor, or -1 for errors.

This function is a cancellation point and therefore not marked with
__THROW. */
extern int accept (int __fd, __SOCKADDR_ARG __addr,
socklen_t *__restrict __addr_len);

它的参数与 listen() 和 connect() 是相同的:fd为服务器端套接字,addr 为 sockaddr_in 结构体变量,addr__len 为参数 addr 的长度,可由 sizeof() 求得。

accept() 返回一个新的套接字来和客户端通信,addr 保存了客户端的IP地址和端口号,而 sock 是服务器端的套接字,要注意区分。后面和客户端通信时,要使用这个新生成的套接字,而不是原来服务器端的套接字。

最后需要说明的是:listen() 只是让套接字进入监听状态,并没有真正接收客户端请求,listen() 后面的代码会继续执行,直到遇到 accept()。accept() 会阻塞程序执行,直到有新的请求到来。

write()/read()

Linux 不区分套接字文件和普通文件,使用 write() 可以向套接字中写入数据,使用 read() 可以从套接字中读取数据。

在linux下,两台计算机之间的通信相当于两个套接字之间的通信,在服务器端用 write() 向套接字写入数据,客户端就能收到,然后再使用 read() 从套接字中读取出来,就完成了一次通信。

write()的原型为:

1
2
3
4
5
/* Write N bytes of BUF to FD.  Return the number written, or -1.

This function is a cancellation point and therefore not marked with
__THROW. */
extern ssize_t write (int __fd, const void *__buf, size_t __n) __wur;

fd 为要写入的文件的描述符,buf 为要写入的数据的缓冲区地址,n 为要写入的数据的字节数。

size_t 是通过 typedef 声明的 unsigned int 类型;ssize_t 在 “size_t” 前面加了一个”s”,代表 signed,即 ssize_t 是通过 typedef 声明的 signed int 类型。

write() 函数会将缓冲区 buf 中的 nbytes 个字节写入文件 fd,成功则返回写入的字节数,失败则返回 -1。

read() 的原型为:

1
2
3
4
5
6
/* Read NBYTES into BUF from FD.  Return the
number read, -1 for errors or 0 for EOF.

This function is a cancellation point and therefore not marked with
__THROW. */
extern ssize_t read (int __fd, void *__buf, size_t __nbytes) __wur;

fd 为要读取的文件的描述符,buf 为要接收数据的缓冲区地址,nbytes 为要读取的数据的字节数。

read() 函数会从 fd 文件中读取 nbytes 个字节并保存到缓冲区 buf,成功则返回读取到的字节数(但遇到文件结尾则返回0),失败则返回 -1。

以上就是linux下socket编程主要用的的一些函数的介绍,后面应该会写一个简易的网络框架来作为实践,再有更深入的网络编程知识会另起文章记录。