云计算百科
云计算领域专业知识百科平台

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

Linux网络编程基础以及第一个TCP服务器

  • 一、什么是 IO ?
  • 二、网络编程接口函数
    • 2.1、socket() 函数
    • 2.2、bind() 函数
    • 2.3、listen() 函数
    • 2.4、accept() 函数
    • 2.5、fcntl() 函数
    • 2.6、connect() 函数
    • 2.7、send() 函数
    • 2.8、recv() 函数
    • 2.9、close() 函数
  • 三、阻塞和非阻塞的区别
  • 四、如何开发一个TCP服务器?
  • 五、总结

一、什么是 IO ?

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

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

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

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

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

  • 阻塞、非阻塞,异步、同步。
  • IO多路复用。
  • 二、网络编程接口函数

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

    那么什么是 socket 呢?

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

  • fd,即它是一个文件,可以通过open、read、write去操作它。
  • 同样也具备网络的属性,是网络通信的一个 TCB 控制块。
  • 这两个是联系在一起的,当我们调用write或read这个函数的时候,它通过 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_family short 地址族字段。对于 IPv4,通常设置为 AF_INET。 其他的还有AF_INET6 (IPv6)、 AF_UNIX (本地通信)、 AF_PACKET (链路层)。
    sin_port unsigned short 16 位端口号,使用网络字节序(Big Endian)。可以使用 htons() 函数转换主机字节序到网络字节序。
    sin_addr struct in_addr 表示 IPv4 地址的结构体,包含一个无符号长整型 s_addr。可以使用 inet_pton() 函数将字符串形式的地址转换为此格式。
    sin_zero char[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_ANY 0.0.0.0 表示接收来自任意 IP 地址的连接。服务器常用此地址进行绑定,允许接收来自所有接口的请求。
    INADDR_LOOPBACK 127.0.0.1 本地回环地址,表示本机,常用于测试和调试网络应用。通常可用于测试本地服务。
    INADDR_BROADCAST 255.255.255.255 广播地址,用于向网络中所有主机发送数据包。适用于需要向所有网络上的设备发送数据的情况。
    INADDR_NONE 0xffffffff 用于指示无效地址,无论何时应视为无效或未指定。
    INADDR_ALLHOSTS_GROUP 224.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_DUPFD 0 复制文件描述符,并返回新的文件描述符。
    F_GETFD 1 获取文件描述符的标志。
    F_SETFD 2 设置文件描述符的标志。
    F_GETFL 3 获取文件状态标志。
    F_SETFL 4 设置文件状态标志。 (O_NONBLOCK, O_APPEND 等)
    F_GETLK 5 获取文件锁定的信息。
    F_SETLK 6 设置文件锁定(非阻塞)。
    F_SETLKW 7 设置文件锁定(阻塞)。
    F_GETOWN 8 获取异步 I/O 所有者。
    F_SETOWN 9 设置异步 I/O 所有者。
    F_GETSIG 10 获取异步 I/O 的信号。
    F_SETSIG 11 设置异步 I/O 的信号。
    F_GETLK64 12 获取文件锁定的信息(64 位版)。
    F_SETLK64 13 设置文件锁定(64 位版,非阻塞)。
    F_SETLKW64 14 设置文件锁定(64 位版,阻塞)。

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

    返回值:

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

    对于网络 IO 设置阻塞和非阻塞,主要使用F_GETFL和F_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() 函数从阻塞状态唤醒。

    阻塞的条件包括:

  • 没有连接请求 。

  • 连接队列已满。

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

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

  • 创建一个 socket。
  • 设置端口并绑定。
  • 设置网络IO的阻塞模式还是非阻塞模式,默认是阻塞模式。
  • 监听端口。
  • 接受客户端连接。
  • 设置客户端socket fd 的网络 IO 是阻塞模式还是非阻塞模式,默认是阻塞模式。
  • 收发数据,数据处理。
  • 下面是一个简单地“一问一答”的服务器示意图: 在这里插入图片描述 下面的代码每次只能处理一个客户端的数据,因此也是最基础的“一问一答”的服务器。

    服务器端代码:

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

    在这里插入图片描述

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » Linux 网络编程基础:构建你的第一个 TCP 服务器
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!