看了很久Linux网络编程,代码都基本能看懂,但是自己一写就废,菜是原罪/(ㄒoㄒ)/~~,所以准备写一个系列关于自己学习Linux网络编程的过程。
说明:之后的开发环境如下:
- 系统: Ubuntu 20.04.1 LTS
- 语言:C/C++
- 编译器:g++
- IED:CLion 2020.2.1
0. 从一个简单的程序开始
0.1 服务器接收
需求:实现服务器接收第一个客户端发送过来的第一个数据,将其打印后关闭连接,并退出程序。
- 服务器端代码实现如下:
#include <cstdio>
#include <cstring>
#include <cstdlib>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <cassert>
#include <cerrno>
#include <unistd.h>
int main(int argc, char** argv) {
if(argc <= 2) {
printf("usage: %s ip port\n", basename(argv[0]));
return 1;
}
const char* ip = argv[1];
int port = atoi(argv[2]);
int ret = 0;
struct sockaddr_in addr{};
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
inet_pton(AF_INET, ip, &addr.sin_addr);
addr.sin_port = htons(port);
// 创建socket
int listenfd = socket(PF_INET, SOCK_STREAM, 0);
assert(listenfd != -1);
// 绑定端口
ret = bind(listenfd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
assert(ret != -1);
// 监听
ret = listen(listenfd, 5);
assert(ret != -1);
struct sockaddr_in client{};
socklen_t client_addr_len = sizeof(client);
// 等待连接, 此处将会阻塞,直到有一个客户端连接到来
int connfd = accept(listenfd, reinterpret_cast<struct sockaddr*>(&client), &client_addr_len);
if(connfd <= 0) {
printf("connection error, errno is:%d\n", errno);
exit(1);
}
printf("连接成功, 客户端地址:[%s:%d]\n", inet_ntoa(client.sin_addr), client.sin_port);
char buffer[1024] = {0};
// 接收数据,此处将会阻塞,直到有数据到来
ret = recv(connfd, buffer, sizeof(buffer) - 1, 0);
printf("接受到%d字节数据, 内容为:%s\n", ret, buffer);
close(connfd);
close(listenfd);
return 0;
}
上面代码的运行需要输入地址和端口号两个参数,在CLion中指定该两个参数步骤如下:
运行上面的代码,它会阻塞在accept()
函数,直到有连接到来后,接着运行到recv()
函数,在此处阻塞直到有数据到来。
客户端给服务的发送数据我们用python来做,步骤如下:
>>> import socket
>>> sock = socket.socket()
>>> sock.connect(("192.168.148.20", 12345)) # 和服务端建立了连接
>>> sock.send(bytes("Hello", encoding="utf-8")) # 发送数据
服务端运行结果如下:
连接成功, 客户端地址:[192.168.148.20:51841]
接受到5字节数据, 内容为:Hello
0.2 服务器接收与发送
现在,我们增加一个需求:在收到客户端数据后,服务器回应一条数据再关闭。大致的步骤与之前的没有区别,所以我们只增加服务器回应的部分,在recv()
之后添加:
// 服务器做出回应
bzero(&buffer, sizeof(buffer));
snprintf(buffer, sizeof(buffer), "This is from server.");
ret = send(connfd, buffer, strlen(buffer), 0);
printf("发送%d字节数据, 内容为:%s\n", ret, buffer);
再用python来充当客户端测试:
>>> import socket
>>> sock = socket.socket()
>>> sock.connect(("192.168.148.20", 12345))
>>> sock.send(bytes("Hello", encoding="utf-8"))
5
>>> data = sock.recv(1024)
>>> print(data.decode())
This is from server.
>>>
到此,服务器发送和接收数据都大概了解了。但是如何让服务器一直接收和发送,而不是收到/发送一条数据后就关闭呢?
0.3 服务器不断发送和接收
现在,我们再加一个需求:要求服务器不断接收/发送数据。其实改动不大,只需要将接收数据的代码块放到一个while循环中,循环终止的条件是客户端断开(recv()
的返回值<= 0),代码如下:
......
char buffer[1024] = {0};
while (1) {
// 接收数据,此处将会阻塞,直到有数据到来
bzero(&buffer, sizeof(buffer));
ret = recv(connfd, buffer, sizeof(buffer) - 1, 0);
if(ret <= 0) {
break;
}
printf("接受到%d字节数据, 内容为:%s\n", ret, buffer);
// 服务器做出回应
bzero(&buffer, sizeof(buffer));
snprintf(buffer, sizeof(buffer), "This is from server.");
ret = send(connfd, buffer, strlen(buffer), 0);
printf("发送%d字节数据, 内容为:%s\n", ret, buffer);
}
......
现在我们再用python来测试一下:
>>> import socket
>>> sock = socket.socket()
>>> sock.connect(("192.168.148.20", 12345))
>>> sock.send(bytes("Hello", encoding="utf-8"))
5
>>> data = sock.recv(1024)
>>> data
b'This is from server.'
>>> sock.send(bytes("I am Damon", encoding="utf-8"))
10
>>> data = sock.recv(1024)
>>> data
b'This is from server.'
>>> sock.close()
>>>
服务器端的输出如下:
连接成功, 客户端地址:[192.168.148.20:56449]
接受到5字节数据, 内容为:Hello
发送20字节数据, 内容为:This is from server.
接受到10字节数据, 内容为:I am Damon
发送20字节数据, 内容为:This is from server.
那么,如果有多个客户端同时连接服务器怎么办呢?那就是IO复用的部分了,这部分我们下个章节再讲。
0.4 本章用到的API
在此之前先补充一个知识:字节序的大端和小端。所谓大端小端指的是数据在内存中的排列顺序,大端:数据的低位放到内存高地址;小端:数据的地位放到内存低地址。还有个概念:网络字节序指的是大端(操作系统一般用小端)。一个测试代码如下:
void TestByteOrder() {
union {
short value;
char union_bytes[sizeof(short)];
} test;
test.value = 0x0102;
// 大端: 低地址存放高位(看尾部是否在高地址)
// 小端: 低地址存放低位(看尾部是否在低地址)
if(test.union_bytes[0]/*低地址*/ == 1/*高位*/ && test.union_bytes[1]/*高地址*/ == 2) {
printf("大端\n");
} else if(test.union_bytes[0]/*低地址*/ == 2/*低位*/ && test.union_bytes[1]/*高地址*/ == 1) {
printf("小端\n");
} else {
printf("unknow\n");
}
}
0.4.1 socket地址相关
表示socket地址使用的是结构体,分为“通用socket地址”和“专用socket地址”。
0.4.1.1 通用socket地址
#include <bits/socket.h>
struct sockaddr {
sa_family_t sa_family; // PF_* AF_*(如PF_INET, AF_INET)
char sa_data[14]; // 存放socket地址
};
0.4.1.2 专用socket地址
struct sockaddr_in {
sa_family_t sa_family; // 地址簇: AF_INET
u_int16_t sin_port; // 端口号,要用网络字节序表示
struct in_addr sin_addr;// IPv4地址结构体
};
struct in_addr{
u_int32_t s_addr; // IPv4地址,要用网络字节序表示
};
注意:所有专用socket地址类型的变量在使用时都要转成通用socket地址类型
sockaddr
(比如前面bind()
函数的第二个参数)。
实例如下:
struct sockaddr_in addr{};
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
inet_pton(AF_INET, ip, &addr.sin_addr);
// 或者用下面的
// addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(port);
0.4.2 IP地址转换函数
所谓的地址转换是“十进制IPv4地址”和“网络字节序整数”之间的转换(因为使用api时用的时网络字节序的整数)。
0.4.2.1 IP->整数
将用点分十进制字符串表示的IPv4地址转换成网络字节序整数,需要用到inet_pton
函数,API描述如下:
extern int inet_pton (int __af, const char *src, void *dst);
af
: 指定地址簇(AF_INET);src
: 用点分十进制字符串表示的IPv4地址;dst
: 保存转换成网络字节序整数的结果;- 返回值:成功返回
1
,失败返回0
并设置errno
。
其使用如下:
#include <arpa/inet.h>
const char* ip = "127.0.0.1";
uint32_t adr;
int res = inet_pton(AF_INET, ip, &adr);
if(res == 1) {
printf("转换成功:点分十进制地址[%s]->网络字节序整数[%d], 返回值:%d\n", ip, adr, res);
} else if (res == 0){
printf("转换失败!, errno:%d", errno);
}
0.4.2.1 整数->IP
将用网络字节序整数地址转换成点分十进制字符串表示的IPv4地址,需要用到inet_ntop
函数,API描述如下:
const char *inet_ntop (int af, const void *src,char *dst, socklen_t len);
af
: 指定地址簇(AF_INET);src
: 网络字节序整数表示的地址;dst
: 点分十进制字符串表示的IPv4地址;
用法:
struct sockaddr_in addr{};
bzero(&addr, sizeof(addr));
addr.sin_family = AF_INET;
inet_pton(AF_INET, ip, &addr.sin_addr);
addr.sin_port = htons(port);
char bf[INET_ADDRSTRLEN]; // 该值为14
const char* result = inet_ntop(AF_INET, &addr.sin_addr, bf, INET_ADDRSTRLEN);
printf("网络字节序整数[%d]->点分十进制地址[%s]", addr.sin_addr.s_addr, result);
0.4.3 创建socket
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain
: 底层协议簇, 一般都填PF_INET
(IPv4)的;type
: TCP填SOCK_STREAM
,UDP填SOCK_UGRAM
;protocol
: 一般填0;- 返回值:成功返回socket fd,失败返回
-1
并设置errno
。
用法:
int sock_fd = socket(PF_INET, SOCK_STREAM, 0);
// 在Linux 2.6.17之后可用下面将新建的socket设置为非阻塞的
int sock_fd = socket(PF_INET, SOCK_STREAM & SOCK_NONBLOCK, 0);
0.4.4 绑定socket
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr* my_addr, socklen_t addrlen);
sockfd
: 未命名的文件描述符;my_adr
: 地址(注意要将其转换成struct sockaddr *
)addrlen
: sizeof(my_addr);- 返回值: 成功返回
0
,失败返回-1
并设置errno
。
用法:
// 绑定端口
int ret = bind(listenfd, reinterpret_cast<struct sockaddr*>(&addr), sizeof(addr));
assert(ret != -1);
0.4.5 监听socket
#include <sys/socket.h>
int listen(int sockfd, int backlog/*典型值为5*/);
sockfd
: 指定被监听的socket;backlog
: 提示内核监听队列的最大长度,监听队列的长度如果超过backlog,服务器将不受理新的客户连接,客户端也将收到ECONNREFUSED
错误信息;- 返回值:成功返回
0
,失败返回-1
并设置errno
。
0.4.6 接受连接
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr* addr, socklen_t* addrlen);
sockfd
: 执行过listen系统调用的监听socket;addr
: 用来获取被接受连接的远端socket地址;addrlen
: addr的长度(注意此处为socklen_t *
,指针类型;- 返回值:成功返回连接fd,失败返回
-1
并设置errno
。
用法:
struct sockaddr_in client{};
socklen_t client_addr_len = sizeof(client);
// 等待连接, 此处将会阻塞,直到有一个客户端连接到来
int connfd = accept(listenfd, reinterpret_cast<struct sockaddr*>(&client), &client_addr_len);
0.4.7 发起连接
之前部分为服务器上的操作,现在的是客户端要做的:向服务器发起连接。
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr* serv_addr, socklen_t addrlen);
sockfd
: 又socket系统调用返回的一个socket;serv_addr
: 服务器监听的socket地址;addrlen
: serv_addr的长度;- 返回值:成功返回
0
,失败返回-1
并设置errno
。
一旦成功建立连接,
sockfd
就唯一标识了这个连接,客户端可通过读写sockfd来与服务器通信。
0.4.8 关闭连接
#include <unistd.h>
int close(int fd);
fd
: 待关闭的socket。
注意:close系统调用并非总是立即关闭一个连接的,而是将fd的引用计数减1.只有当fd的引用计数为0时,才真正关闭连接。(比如多进程中,一次fork系统调用默认将父进程中打开的socket的引用计数加1,所以必须在父进程和子进程中都对该socket执行close调用才能将连接关闭)。
如果无论如何都要立即终止连接,而不是将socket的引用计数减1,使用shutdown
系统调用:
#include <sys/socket.h>
int shutdown(int sockfd, int howto);
sockfd
: 待关闭的socket;howto
:SHUT_RD
(关闭sockfd上读的一半,应用程序不能再读,且接收缓冲区中数据全部丢弃);SHUT_WR
(关闭sockfd上写的一半);SHUT_RDWR
(同时关闭读写)。- 返回值:成功返回
0
,失败返回-1
并设置errno
。
0.4.9 TCP数据读写
对文件的读写操作read
和write
虽然也可以用于socket,但是socket编程通常还是使用一些专门用于网络编程的系统调用,下面两个是TCP流数据的读写系统调用:
#include <sys/socket.h>
ssize_t recv(int sockfd, void* buf, size_t len, int flags);
sockfd
: 通常是服务器/客户端使用accept
/connect
后得到的;buf
: 读缓冲区位置;len
: 读缓冲区大小;flags
: 额外的控制,通常填0
即可(只对当前调用有效,永久修改使用setsockopt
)。- 返回值:成功时返回实际读取到的数据长度(可能小于我们期望的长度len,因此需要多次调用recv)。为
0
时对方已关闭连接(收到FIN),出错时返回-1
并设置errno
。
用法:
char buffer[1024]={0};
int ret = recv(connfd, buffer, sizeof(buffer)-1, 0); // 注意,需要留1B存`\0`
发送数据:
ssize_t send(int sockfd, const void* buf, size_t len, int flags);
sockfd
: 通常是服务器/客户端使用accept
/connect
后得到的;buf
: 缓冲区位置;len
: 写缓冲区大小;flags
: 额外的控制,通常填0
即可(只对当前调用有效,永久修改使用setsockopt
)。- 返回值:成功时返回实际写入的数据长度,失败返回
-1
并设置errno
。
用法:
const char* data = "Hello";
int ret = send(connfd, data, strlen(data), 0); // 注意,需要留1B存`\0`
注意:recv中一般用
sizeof()
,而send中一般用strlen()
。
0.4.10 UDP数据读写
用于UDP数据报读写的系统调用如下:
#include <sys/socket.h>
ssize_t recvfrom(int sockfd, void* buf, size_t len, int flags, struct sockaddr* src_addr, socklen_t* addrlen);
ssize_t sendto(int sockfd, const void* buf, size_t len, int flags, const struct sockaddr* dest_addr, socklen_t addrlen);
参数和TCP的类似,不过因为UDP没有连接的概念,所以每次发送/接受都需要对方的socket地址。如果最后两个参数设置为NULL
,那也可以当作recv()
和send()
来用。