Linux 网络编程基础:构建你的第一个 TCP 服务器

一、什么是 IO ?

IO 是 input/output的简写,即输入输出。在操作系统以上的软件层面,特别是在Linux上的 IO 都可以视为 fd (文件描述符),网络 IO 也可以视为 socket

下图是TCP服务器与客户端的交互示意图:
在这里插入图片描述

TCP服务会监听一个端口,客户端连接到端口时服务器会为其生成对应的 listen fd(每一个TCP连接都会在服务器生成相应的socket)。

只要能构建出输入和输出,在口语表述的时候都可以称为IO

网络 IO 编程主要围绕两个核心:

  1. 阻塞、非阻塞,异步、同步。
  2. IO多路复用。

二、网络编程接口函数

假设我们要实现一个 TCP 服务器,我们就要先了解有哪些接口可以使用。在Linux中,使用C++进行网络编程通常涉及几个基本的socket函数。

那么什么是 socket 呢?

socket 直接翻译过来叫做 “插座”,很难与 IO 联想到一起。插座一般分为两个部分,即插头和座排;socket也同样如此,带有两层属性:

  1. fd,即它是一个文件,可以通过openreadwrite去操作它。
  2. 同样也具备网络的属性,是网络通信的一个 TCB 控制块。

这两个是联系在一起的,当我们调用writeread这个函数的时候,它通过 fd 查找到对应的收发网络控制块,然后把数据放到里面去。

2.1、socket() 函数

函数原型

#include <sys/socket.h>
int socket(int domain, int type, int protocol);
参数描述取值示例
domain指定协议族(地址族),决定使用哪种网络协议。AF_INET (IPv4)
AF_INET6 (IPv6)
AF_UNIX (本地通信)
AF_PACKET (链路层)
type指定套接字类型,决定套接字的传输协议类型。SOCK_STREAM (TCP,面向连接)
SOCK_DGRAM (UDP,无连接)
SOCK_RAW (原始套接字)
SOCK_SEQPACKET (面向消息的可靠连接)
protocol指定具体的传输协议,通常设为0以选择默认协议。IPPROTO_TCP (用于 TCP)
IPPROTO_UDP (用于 UDP)
0 (使用默认)

返回值:成功时返回一个非负整数(socket描述符),失败时返回 -1。

socket()函数为什么一直是使用三个参数呢?

这是有一定历史原因在里面的,几十年了一直都这么写;这三个参数中,除了第二个参数可能要变之外,其他的参数可能都没有变过(当然,这说到是绝大多数情况下)。比如,第一个参数是指定协议族,要指定的原因是因为几十年前不仅仅只有TCP协议,还有其他各式各样的协议同时存在(百花齐放时期),那时候就是用第一个参数来指定的,只是后面经过淘汰,就只留下了AF_*几个(目前用的最多的还是AF_INET)。然而,随着历史的迁移,为了兼容性,这三个参数就一直没有更改过。

这里可能会有一个疑问,socket是一个int类型的值,为什么是int的呢?
这意味着后面很多东西是基于int值进行操作的,socket默认从0开始,依次递增,其中0,1,2是系统确定的,代着这标准输入输出,即stdin、stdout、stderr

2.2、bind() 函数

socket()函数解决了fd的问题,即插口,还需要使用bind()函数提供“座排”,即网络部分。

函数原型

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数

  • sockfd: socket描述符。
  • addr: 指向 sockaddr 结构的指针,包含要绑定的地址信息。
  • addrlen: 地址结构的大小。

返回值:成功时返回0,失败时返回-1。

要想使用bind()函数,还需要了解一个非常重要的结构体:struct sockaddr_in ,它是在 C 和 C++ 编程中用于表示 IPv4 地址的结构体,其定义通常在 <netinet/in.h> 头文件中。

struct sockaddr_in 结构体定义:

struct sockaddr_in {
    short sin_family;        // 地址族,通常为 AF_INET
    unsigned short sin_port; // 端口号(网络字节序)
    struct in_addr sin_addr; // IPv4 地址
    char sin_zero[8];        // 填充字段,用于保持结构体的对齐
};
成员数据类型描述
sin_familyshort地址族字段。对于 IPv4,通常设置为 AF_INET。 其他的还有AF_INET6 (IPv6)、 AF_UNIX (本地通信)、 AF_PACKET (链路层)。
sin_portunsigned short16 位端口号,使用网络字节序(Big Endian)。可以使用 htons() 函数转换主机字节序到网络字节序。
sin_addrstruct in_addr表示 IPv4 地址的结构体,包含一个无符号长整型 s_addr。可以使用 inet_pton() 函数将字符串形式的地址转换为此格式。
sin_zerochar[8]保留字段,用于填充,确保 struct sockaddr 的大小与 sockaddr_in 结构体的一致性。 通常不使用,可将其置为0。

其中sin_addr 是一个类型为 struct in_addr 的结构体,其定义如下:

struct in_addr {
    unsigned long s_addr; // 32 位 IPv4 地址,以网络字节序存储
};

struct in_addr 结构体用于表示 IPv4 地址,关键字段是 s_addr,它是一个无符号长整型(32 位)。设置 s_addr 的值可以用来指定不同的网络地址。常用的 struct in_addr 值:

名称描述
INADDR_ANY0.0.0.0表示接收来自任意 IP 地址的连接。服务器常用此地址进行绑定,允许接收来自所有接口的请求。
INADDR_LOOPBACK127.0.0.1本地回环地址,表示本机,常用于测试和调试网络应用。通常可用于测试本地服务。
INADDR_BROADCAST255.255.255.255广播地址,用于向网络中所有主机发送数据包。适用于需要向所有网络上的设备发送数据的情况。
INADDR_NONE0xffffffff用于指示无效地址,无论何时应视为无效或未指定。
INADDR_ALLHOSTS_GROUP224.0.0.1特殊的多播地址,用于IPv4多播,表示所有主机组,接收此地址在同一子网的所有主机。

要想绑定到一个指定的 IPv4 地址(例如 192.168.1.1),需要使用 inet_pton() 函数来将字符串形式的 IP 地址转换为 struct in_addr 结构体。

inet_pton() 函数的原型定义在 <arpa/inet.h> 头文件中。其函数原型如下:

int inet_pton(int family, const char *str, void *addr);

参数说明:

  • int family: 地址族,通常使用 AF_INET(IPv4)或 AF_INET6(IPv6)。
  • const char *str: 要转换的 IP 地址字符串,通常是点分十进制格式的 IPv4 地址(例如:“192.168.1.1”)或者是标准的 IPv6 地址字符串。
  • void *addr: 指向 struct in_addr(对于 IPv4)或 struct in6_addr(对于 IPv6)结构的指针,用于存储转换后的地址。

返回值:

  • 返回值为 1,表示转换成功。
  • 返回值为 0,表示输入字符串不是有效的地址。
  • 返回值为 -1,表示发生错误,可以使用 errno 获取错误码。

inet_pton() 是一种更安全和现代的方法,用于将 IP 地址字符串转换为二进制格式,推荐使用,取代了更早期的 inet_addr() 函数。

2.3、listen() 函数

函数原型

int listen(int sockfd, int backlog);

参数

  • sockfd: socket描述符。
  • backlog: 指定等待连接的最大数量。

返回值:成功时返回0,失败时返回-1。

listen()就像一个酒店里的迎宾的人,当客户端连接进来是首先接触到的就是这个listen,然后由将其带到大堂交给服务员来为他提供其他服务。

2.4、accept() 函数

函数原型

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

参数

  • sockfd: socket 描述符。
  • addr: 可选的 sockaddr 结构指针,用来存储客户端的地址。
  • addrlen: 可选的指针,用于表示地址结构的大小。

返回值:成功时返回一个新的 socket 描述符,代表与客户端的连接,失败时返回-1。

在调用这个函数之前,如何没有调用fcntl()函数设置socket fd为非阻塞模式,程序会一直阻塞住,直到有客户端连接进来。

2.5、fcntl() 函数

fcntl() 函数用于在 UNIX 和类 UNIX 系统编程中对文件描述符执行控制操作。fcntl() 提供了多种功能,包括复制文件描述符、改变描述符的属性、获取当前状态等。

fcntl() 函数的原型定义在 <fcntl.h> 头文件中,如下所示:

int fcntl(int fd, int cmd, ... /* arg */ );

参数说明:

  • int fd: 要操作的文件描述符,通常是通过 open()socket() 等函数获得的。
  • int cmd: 控制命令,决定执行的操作。
  • ... /* arg */: 可选参数,具体依赖于 cmd 的值。例如,某些命令需要额外的参数来执行特定的操作。

以下是 fcntl() 函数中 int cmd 参数的常用值:

命令 (cmd)含义
F_DUPFD0复制文件描述符,并返回新的文件描述符。
F_GETFD1获取文件描述符的标志。
F_SETFD2设置文件描述符的标志。
F_GETFL3获取文件状态标志。
F_SETFL4设置文件状态标志。 (O_NONBLOCK, O_APPEND 等)
F_GETLK5获取文件锁定的信息。
F_SETLK6设置文件锁定(非阻塞)。
F_SETLKW7设置文件锁定(阻塞)。
F_GETOWN8获取异步 I/O 所有者。
F_SETOWN9设置异步 I/O 所有者。
F_GETSIG10获取异步 I/O 的信号。
F_SETSIG11设置异步 I/O 的信号。
F_GETLK6412获取文件锁定的信息(64 位版)。
F_SETLK6413设置文件锁定(64 位版,非阻塞)。
F_SETLKW6414设置文件锁定(64 位版,阻塞)。

注意:关于文件锁的 F_GETLK, F_SETLK, 和 F_SETLKW 命令需要一个 struct flock 结构体作为可选参数,用于描述锁的类型、偏移量和长度等。F_GETLK64, F_SETLK64, 和 F_SETLKW64 是针对大文件(通常是大于 2GB 的文件)的锁定操作。

返回值:

  • 成功时,fcntl() 返回取决于 cmd 的特定值。对于 F_GETFDF_GETFL等命令,这将是所请求的状态标志;对于 F_DUPFD,返回新的文件描述符。
  • 失败时,返回 -1,并设置 errno 以指示错误。

对于网络 IO 设置阻塞和非阻塞,主要使用F_GETFLF_SETFL两个。设置标志时最好要先把之前的标志获取出来,然后通过位运算把网络 IO 模式 “或(|)”进去,不要直接设置,这会把它之前的其他标志全部清除了,是非常不建议直接设置的。

2.6、connect() 函数

函数原型

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数

  • sockfd: socket描述符。
  • addr: 指向 sockaddr 结构的指针,包含要连接的服务器地址。
  • addrlen: 地址结构的大小。

返回值:成功时返回0,失败时返回-1。

2.7、send() 函数

函数原型

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

参数

  • sockfd: socket描述符。
  • buf: 指向要发送的数据的指针。
  • len: 要发送的字节数。
  • flags: 通常设置为0。

返回值:成功时返回发送的字节数,失败时返回-1。

注意send()返回一个大于0的数并不意味发送成功,数据并不一定已经发送出去了。send()只是将数据拷贝到协议栈,什么时候发送出去不是我们决定的。要判断发送的数据是否已经被对端接收到,只有一个标准:对端回了确认消息。虽然TCP 有 ACK 机制,但在业务层是感知不到的;特别是进程退出的时候,它很难知道是否发送成功。

2.8、recv() 函数

函数原型

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

参数

  • sockfd: socket描述符。
  • buf: 指向接收数据的缓冲区。
  • len: 缓冲区的大小。
  • flags: 通常设置为0。

返回值:成功时返回接收到的字节数,失败时返回-1,连接关闭时返回0。

2.9、close() 函数

函数原型

#include <unistd.h>

int close(int fd);

fd:是需要关闭的文件描述符(在这里是socket描述符)。成功时返回0,失败时返回-1。

三、阻塞和非阻塞的区别

阻塞会一直等待数据到来才会返回。非阻塞会立即返回,不管有没有数据。

那么,在网络编程中accept()函数到底阻塞在哪里?阻塞的条件是什么?

accept() 函数阻塞在 等待新的连接请求 的过程中。accept() 函数的阻塞并非发生在特定的代码行,而是在 内核等待连接请求到达 的过程中。内核会在后台持续监听网络接口,一旦检测到新的连接请求,就会将请求放入连接队列中,并将 accept() 函数从阻塞状态唤醒。

阻塞的条件包括:

  1. 没有连接请求 。

  2. 连接队列已满。

四、如何开发一个TCP服务器?

开发 TCP 服务器的基本步骤:

  1. 创建一个 socket
  2. 设置端口并绑定。
  3. 设置网络IO的阻塞模式还是非阻塞模式,默认是阻塞模式。
  4. 监听端口。
  5. 接受客户端连接。
  6. 设置客户端socket fd 的网络 IO 是阻塞模式还是非阻塞模式,默认是阻塞模式。
  7. 收发数据,数据处理。

下面是一个简单地“一问一答”的服务器示意图:
在这里插入图片描述
下面的代码每次只能处理一个客户端的数据,因此也是最基础的“一问一答”的服务器。

服务器端代码

#include <sys/socket.h>
#include <netinet/in.h>
#include <errno.h>
#include <fcntl.h>
#include <unistd.h>

#include <cstring>
#include <iostream>

#define PORT            8080
#define LINSTEN_BLOCK   20
#define BUFFER_LEN      4096
#define SET_NONBLOCK    0

bool setIoMode(int fd, int mode);

int main(int argc, char**argv)
{
    // 1. Create socket
    int listenfd = socket(AF_INET, SOCK_STREAM, 0);
    if (listenfd == -1) {
        std::cout << "socket return " << errno << ", " << strerror(errno) << std::endl;
        return -1;
    }

    // 2. Set the port and bind it.
    sockaddr_in serverAddr;
    memset(&serverAddr, 0, sizeof(sockaddr_in));
    serverAddr.sin_family = AF_INET;
    serverAddr.sin_addr.s_addr = htons(INADDR_ANY); // bind ip address.
    serverAddr.sin_port = htons(PORT);  // bind port.
    if (bind(listenfd, (sockaddr*)&serverAddr, sizeof(serverAddr)) == -1) {
        std::cout << "bind return " << errno << ", " << strerror(errno) << std::endl;
        return -2;
    }

#if SET_NONBLOCK
    // set nonblock mode.
    setIoMode(listenfd, O_NONBLOCK);

#endif

    // 3. listening port.
    if (listen(listenfd, LINSTEN_BLOCK) == -1) {
        std::cout << "listen return " << errno << ", " << strerror(errno) << std::endl;
        return -3;
    }

    std::cout << "server listening port " << PORT << std::endl;
    while(1) {
        // 4. accept connect.
        sockaddr_in clientAddr;
        memset(&clientAddr, 0, sizeof(clientAddr));
        socklen_t clienLen = sizeof(clientAddr);
        int clientfd = accept(listenfd, (sockaddr *)&clientAddr, &clienLen);
        if (clientfd == -1) {
            std::cout << "accept return " << errno << ", " << strerror(errno) << std::endl;
            continue;
        }
        std::cout << "client fd " << clientfd << std::endl;

        // 5. send message.
        const char *msg = "Hello, Client!";
        if (send(clientfd, msg, strlen(msg), 0) == -1) {
            std::cout << "send buffer return " << errno << ", " << strerror(errno) << std::endl;
            close(clientfd);
            continue;
        }

        // 6. recv message
        char buffer[BUFFER_LEN];
        if (recv(clientfd, buffer, BUFFER_LEN, 0) == -1) {
            std::cout << "recv buffer return " << errno << ", " << strerror(errno) << std::endl;
            close(clientfd);
            continue;
        }
        std::cout << "recv buffer: " << buffer << std::endl;
        close(clientfd);
    }
    close(listenfd);
    return 0;
}

bool setIoMode(int fd, int mode)
{
    int flag = fcntl(fd, F_GETFL, 0);
    if (flag == -1) {
        std::cout << "fcntl get flags return " << errno << ", " << strerror(errno) << std::endl;
        return false;
    }
    flag |= O_NONBLOCK;
    if (fcntl(fd, F_SETFL, flag) == -1) {
        std::cout << "fcntl set flags return " << errno << ", " << strerror(errno) << std::endl;
        return false;
    }
    return true;
}

客户端代码

#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>

int main() {
    int sockfd = socket(AF_INET, SOCK_STREAM, 0);
    sockaddr_in serv_addr;
    
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

    connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
    
    char buffer[1024] = {0};
    recv(sockfd, buffer, sizeof(buffer), 0);
    std::cout << "Message from server: " << buffer << std::endl;

    close(sockfd);
    return 0;
}

五、总结

以上是Linux C++ socket 编程的一些基本函数及其用法。通过这些函数,你可以实现基本的网络通信,包括建立连接、数据传输等。实际应用中,错误处理、非阻塞和多线程等都会涉及,但这些内容可以在逐步学习中深入了解。

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Lion 莱恩呀

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值