【TCP/IP网络编程】最全实战精华笔记2 - 地址族与TCP服务器/客户端

地址族与数据序列


分配给套接字的 IP 与端口

IPV4 属 4 字节地址族
IPV6 属 16 字节地址族

A 类地址首字节范围:0-127
B 类地址首字节范围:128-191
C 类地址首字节范围:192-223

计算机内部的网络接口卡(NIC)传输数据时,会附带一个端口号,计算机根据此端口号传递到对应套接字里面

端口号特点

  • 无法把一个端口号分给不同套接字
  • 端口号可分配范围 0-65535
  • 0-1023 为知名端口,分给特定应用程序
  • TCP 套接字和 UDP 套接字不会共用端口号

地址信息表示

sockaddr_in

该结构体定义了套接字地址的格式,用于在套接字编程中指定服务器和客户端的网络地址

struct sockaddr_in {
    
    
    sa_family_t     sin_family;   // 地址族,通常为 AF_INET(IPv4);AF_INET6(IPV6)
    uint16_t        sin_port;     // 16 位端口号,使用网络字节序(大端序)
    struct in_addr  sin_addr;     // 表示 32 位 IP 地址的结构体
    char            sin_zero[8];  // 不使用,填充字节,通常为全零
};

通用结构体 sockaddr

该结构体为通用型,不仅仅用于 IPV4

struct sockaddr {
    
    
    sa_family_t   sin_family;   // 地址族,用于指定地址的类型
    char          sa_data[14];  // 地址信息,具体格式取决于地址族的类型
};

网络字节序与地址转换

字节序与网络字节序

CPU 向内存保存数据的方式有两种:

  • 大端序:高位字节存放到低位地址。网络字节序为大端序。
  • 小端序:高位字节存放到高位地址。目前主流的 Intel 系列 CPU 按小端序方式保存数据。

数据数组首先转换成统一的大端序格式进行网络传输——网络字节序
小端序系统传输数据时应当转换为大端序格式

接收数据是以小端存储的


字节序转换

这是几个景点的转换字节序的函数:htons、ntohs、htonl、ntohl

现在对 htons 分析:

  • h 表示主机 host,字节序
  • to 表示转化为
  • n 表示网络 network,字节序
  • s 表示 short,对应的 l 表示 long

仅有向 sockaddr_in 结构体变量填充数据之外,其余情况不需考虑字节序问题


网络地址的初始化与分配

我们需要把我们经常看到的 IP 地址形式(点分十进制)改为 sockaddr_in 所接受的 32 位整型数据

下面有两种函数可以实现此功能

inet_addr

转换类型附带网络字节序转换,还可以识别错误的 IP 地址

#include <arpa/inet.h>
in_addr_t inet_addr(const char* string);

inet_aton

他利用了 inet_addr 结构体,效率更高

他接受一个 inet_addr 指针,运行完毕后把数据原路保存到该指针

#include <arpa/inet.h>
int inet_aton(const char* string, struct in_addr* addr);

顾名思义,则可以得到 inet_ntoa 就是一个反向转换


网络地址初始化

struct sockaddr_in addr;                             // 声明一个 sockaddr_in 结构体变量 addr
char *serv_ip = "211.217.168.13";                     // 声明 IP 地址字符串
char *serv_port = "9190";                             // 声明端口号字符串
memset(&addr, 0, sizeof(addr));                       // 将 addr 的所有成员初始化为 0,主要是为了将 sockaddr_in 的成员 sin_zero 初始化为 0。
addr.sin_family = AF_INET;                            // 指定地址族为 AF_INET,表示 IPv4 地址族
addr.sin_addr.s_addr = inet_addr(serv_ip);            // 使用 inet_addr 函数将字符串形式的 IP 地址转换为二进制形式,并将结果存储在 sin_addr.s_addr 中
addr.sin_port = htons(atoi(serv_port));               // 使用 atoi 函数将端口号字符串转换为整数,并使用 htons 函数将端口号转换为网络字节序(大端序),然后存储在 sin_port 中

当然,你要是嫌麻烦的话,可以避免每次都输入 IP 地址,改为使用 INADDR_ANY 作为替代品

使用此方法,可以自动获取运行服务器端的主机 IP 地址(服务端优先采用此方法)


基于 TCP 服务器端与客户端


理解 TCP/UDP

此部分可以参阅计算机网络对应的总结笔记,或者相应面经,在此处不做具体介绍


实现基于 TCP 的服务器端和客户端

TCP 服务器默认函数调用顺序

下面即为对应的默认函数调用顺序

  1. socket() 创建套接字
  2. bind() 分配套接字地址
  3. listen() 等待链接请求状态
  4. accept() 允许连接
  5. read()/write() 数据交换
  6. close() 断开链接

进入等待链接请求状态

在调用 accept 函数前,请求会处于等待状态

服务器端处于等待连接请求状态指的是:让来自客户端的请求处于等待状态,以等待服务器端受理它们的请求。

链接等待队列:未被受理的连接请求被放置于此处,该连接池的大小取决于 backlog 定义的大小

#include <sys/socket.h>

// 返回值:成功时返回 0,失败时返回 -1
// 参数一:传递文件描述符套接字的用途
// 参数二:等待队列的最大长度
int listen(int sockfd, int backlog);

受理客户端链接请求

#include <sys/socket.h>

// sockfd:服务器套接字的文件描述符;sockaddr:用于保存发起连接请求的客户端地址信息;addrlen:第二个参数的长度。
// 返回值:成功时返回创建的套接字文件描述符,失败时返回 -1
int accept(int sockfd, struct sockaddr *addr, socklen_t addrlen);

服务器套接字只是控制是否允许连接请求进入服务器端

accept 函数会受理连接请求等待队列中待处理的客户端连接请求,它从等待队列中取出 1 个连接请求,创建套接字并完成连接请求。如果等待队列为空,accpet 函数会阻塞,直到队列中出现新的连接请求才会返回。

accept 执行完毕后会将它所受理的连接请求对应的客户端地址信息存储到第二个参数 addr 中。

accept 函数调用成功后,其会在内部产生一个新的套接字并返回其文件描述符,该套接字用于与客户端建立连接并进行数据 I/O。


客户端连接请求

客户端默认函数调用顺序为

  1. socket() 创建套接字
  2. connect() 请求连接
  3. read()/write() 交换数据
  4. close() 断开链接

#include <sys/socket.h>

// 参数:sock:客户端套接字的文件描述符;serv_addr:保存目标服务器端地址信息的结构体指针;addrlen:第二个参数的长度(单位是字节)
// 返回值:成功时返回 0,失败时返回 -1
int connect(int sockfd, struct sockaddr *serv_addr, socklen_t addrlen);

客户端调用 connect 函数后会阻塞,直到发生以下情况之一才会返回:

  • 服务器端接收连接请求。
  • 发生断网等异常情况而中断连接请求。

注意:connect 函数返回后并不立即进行数据交换


基于 TCP 的服务器端和客户端函数调用关系

在这里插入图片描述

客户端只有等到服务器端调用 listen 函数后才能调用 connect 函数


实现迭代服务器端和客户端

迭代服务器端实现

重复调用 accept 函数即可实现客户端链接请求的持续受理

目前我们仅能实现:受理完一个客户端后在受理下一个,无法同时受理多个客户端(这一技巧将在后续展开介绍)


迭代 echo 服务器端与客户端

基本运行方式:

  1. 服务器端同一时刻只与一个客户端相连接,并提供回声服务。
  2. 服务器端依次向 5 个客户端提供服务,然后退出。
  3. 客户端接收用户输入的字符串并发送到服务器端。
  4. 服务器端将接收到的字符串数据传回客户端,即”回声“。
  5. 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。

echo 客户端完美实现

一般的,服务器端不可能提前知道客户端发来的数据有多长,所以只能通过应用层协议确定数据边界以及对应长度大小

应用层协议实际就是在服务器端/客户端的实现过程中逐步定义的规则的集合。

下面依次展示了计数器服务器端和计数器客户端的代码

// 客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 50
#define OPSZ 4 // 定义每个操作数在 TCP 报文中占用的字节数

void error_handling(char *message);

int main(int argc, char *argv[])
{
    
    
    int sock;
    char opmsg[BUF_SIZE]; // opmsg 用来存储要发送的数据,注意是 char 类型数组
    struct sockaddr_in serv_addr;
    int operand_count, result;

    if (argc != 3)
    {
    
    
        printf("Usage : %s <IP> <port>\n", argv[0]);
        exit(1);
    }

    sock = socket(PF_INET, SOCK_STREAM, 0);
    if (sock == -1)
        error_handling("socket() error");

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr(argv[1]);
    serv_addr.sin_port = htons(atoi(argv[2]));

    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)))
        error_handling("connect() error");
    else
        puts("Connecting..........\n");

    fputs("Operand count: ", stdout);
    scanf("%d", &operand_count);
    opmsg[0] = (char)operand_count; // 数据的第一个字节存储操作数的数量,注意要将变量类型转换为 char。

    for (int i = 0; i < operand_count; i++)
    {
    
    
        printf("Operand %d: ", i + 1);
        scanf("%d", (int *)&opmsg[i * OPSZ + 1]); // 从第二个字节开始每四个字节存储一个操作数,向数组存数据时先取地址再转换类型。
    }

    fgetc(stdin);
    fputs("Operator: ", stdout);
    scanf("%c", &opmsg[operand_count * OPSZ + 1]); // 再用一个字节存储运算符

    write(sock, opmsg, operand_count * OPSZ + 2); // 发送数据
    read(sock, &result, OPSZ);                    // 接收运算结果:运算结果是一个 4 字节的操作数

    printf("Operation result: %d\n", result);

    close(sock);
    return 0;
}

void error_handling(char *message)
{
    
    
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

// 服务端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>

#define BUF_SIZE 50
#define OPSZ 4
void error_handling(char *message);
int calculate(int operand_count, int operands[], char operator);

int main(int argc, char *argv[])
{
    
    
    int serv_sock, clnt_sock;
    struct sockaddr_in serv_addr, clnt_addr;
    int clnt_addr_sz;
    char message[BUF_SIZE];

    if (argc != 2)
    {
    
    
        printf("Usage : %s <port>", argv[0]);
        exit(1);
    }

    serv_sock = socket(PF_INET, SOCK_STREAM, 0);

    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
    serv_addr.sin_port = htons(atoi(argv[1]));

    if (bind(serv_sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) == -1)
        error_handling("bind() error");

    if (listen(serv_sock, 5) == -1)
        error_handling("listen() error");

    clnt_addr_sz = sizeof(clnt_addr);
    for (int i = 0; i < 5; i++)
    {
    
    
        if ((clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_addr, &clnt_addr_sz)) == -1)
            error_handling("accept() error");

        int operand_count;
        read(clnt_sock, &operand_count, 1); // 首先读取第 1 个字节,获取操作数的数量

        char operands[BUF_SIZE];
        for (int i = 0; i < operand_count; i++)
        {
    
    
            read(clnt_sock, &operands[i * OPSZ], OPSZ); // 根据操作数数量,依次读取操作数
        }

        char operator;
        read(clnt_sock, &operator, 1); // 读取运算符

        int result = calculate(operand_count, (int *)operands, operator);
        write(clnt_sock, (char *)&result, sizeof(result)); // 发送计算结果
        close(clnt_sock);
    }
    close(serv_sock);
    return 0;
}

int calculate(int operand_count, int operands[], char operator)
{
    
    
    int result = operands[0];
    switch (operator)
    {
    
    
    case '+':
        for (int i = 1; i < operand_count; i++)
            result += operands[i];
        break;
    case '-':
        for (int i = 1; i < operand_count; i++)
            result -= operands[i];
        break;
    case '*':
        for (int i = 1; i < operand_count; i++)
            result *= operands[i];
        break;
    }
    return result;
}

void error_handling(char *message)
{
    
    
    fputs(message, stderr);
    fputc('\n', stderr);
    exit(1);
}

TCP 原理

调用 write 函数,数据先移入到输出缓冲,之后选择合适时间传输到对方输入缓冲内;

调用 read 函数,读取输入缓冲中的内容;

套接字对应的 IO 特性

  1. I/O 缓冲在每个套接字中单独存在。
  2. I/O 缓冲在创建套接字时自动生成。
  3. 即使关闭套接字也会继续传递输出缓冲中遗留的数据。
  4. 关闭套接字将丢失输入缓冲中的数据。

猜你喜欢

转载自blog.csdn.net/delete_you/article/details/132032390
今日推荐