【学习笔记】《TCP/IP网络编程》读书笔记(一)

在学习完C++基础语法后,想写个项目练一练手。但是刚看到 WebServer 项目时有点不知所措,故开始阅读尹圣雨老师的《TCP/IP网络编程》。由于书中代码均为C语言实现,我想以C++重构文章案例代码,特此记录成博客。

理解网络编程与套接字

我们可以借助电话这一场景来理解套接字(socket)的概念。

服务端

电话可以接受和发送信息。如果有两个人想通过电话相互联系,那么我们首先需要为他们安装电话,对应在网络编程即为创建套接字

使用 <sys/socket.h> 中提供的 socket() 函数创建套接字。

#include <sys/socket.h>
/* 
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.
*/
int socket(int domain, int type, int protocol);
// 成功时返回文件描述符,失败时返回 -1。

在装好电话机后我们需要分配电话号码,这样别人才能通过电话号码联系到我们。对应在网络编程中即为为套接字分配地址信息(IP 地址和端口号)

使用 <sys/socket.h> 中提供的 bind() 函数为套接字分配地址信息。

#include <sys/socket.h>
/* Give the socket FD the local address ADDR (which is LEN bytes long). */
int bind(int socket_fd, struct sockaddr* addr, socklen_t addrlen);
// 成功时返回 0,失败时返回 -1。

为了让电话能够接通,还需要为电话接上电话线。接通电话线后电话就转为可接通状态,这时其他人可以拨打电话请求连接到该机。同样,需要把套接字转换成可接收连接的状态

使用 <sys/socket.h> 中提供的 listen() 函数将套接字转换成可接收连接的状态。

#include <sys/socket.h>
/*
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. 
*/
int listen(int socket_fd, int n);
// 成功时返回 0,失败时返回 -1。

在接好电话线后,如果有人拨打电话就会响铃,拿起话筒才能接通电话。拿起电话意味着接收了对方的连接请求。套接字同样如此,如果有人为了完成数据传输而请求连接,就需要调用 accept() 函数接收请求

#include <sys/socket.h>
/*
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.
*/
int accept(int socket_fd, struct sockaddr* addr, socklen_t* addr_len);
// 成功时返回文件描述符,失败时返回 -1。

网络编程中接受连接请求的套接字创建过程可整理如下:

  1. 调用 socket() 创建套接字;
  2. 调用 bind() 分配 IP 地址和端口号;
  3. 调用 listen() 转为可接收请求状态;
  4. 调用 accept() 受理连接请求。

示例代码

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

#define PORT 8080

int main() {
    int server_fd, new_socket;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[1024] = {0};
    const char* message = "Hello from server";

    // 创建Socket文件描述符
    // AF_INET是一个常量,它指定了socket的地址族(address family),即使用IPv4地址。
    if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
        std::cerr << "socket failed" << std::endl;
        return -1;
    }

    // 设置Socket选项
    if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt))) {
        // setsockopt()函数用于设置SO_REUSEADDR选项。
    	// 这个选项告诉操作系统:如果端口已经被占用,而你想bind一个新的socket到该端口上时,可以重用该端口而不报错。
    	// 这个选项通常用于服务器程序在重启后能够快速重新绑定端口,而无需等待一段时间(TCP TIME_WAIT状态)。
        std::cerr << "setsockopt failed" << std::endl;
        return -1;
    }

    // 初始化地址结构体
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    // 绑定Server Socket到指定地址和端口
    if (bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0) {
        std::cerr << "[server] bind failed" << std::endl;
        return -1;
    }

    // 监听传入连接请求
    if (listen(server_fd, 3) < 0) {
        std::cerr << "[server] listen failed" << std::endl;
        return -1;
    }

    std::cout << "[server] listening at prot " << PORT << "\n";

    // 接受传入连接请求并创建新的Socket文件描述符
    if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {
        // 使用指向socklen_t类型变量的指针作为接收参数,可确保accept()方法正确地填充地址信息,并避免了缓冲区溢出等安全问题。
        std::cerr << "[server] accept failed" << std::endl;
        return -1;
    }

    // 从客户端读取数据
    int valread = read(new_socket, buffer, 1024);
    std::cout << "[server] Recv : " << buffer << std::endl;

    // 向客户端发送消息
    send(new_socket, message, strlen(message), 0);
    std::cout << "[server] Hello message sent" << std::endl;

    // 关闭Socket连接
    close(new_socket);
    close(server_fd);

    return 0;
}

客户端

客户端套接字时用于请求连接的套接字,其创建过程比服务器端套接字的创建要简单。

在有了电话后,如果要向某人传递信息,则需要向其打电话。同样,客户端需要向服务器端请求连接,其调用的即为客户端套接字。

使用 <sys/socket.h> 中的 connect() 函数向服务器端发送连接请求。

#include <sys/socket.h>
/*
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.
*/
int connect(int socket_fd, struct sockaddr* serv_addr, socklen_t addrlen);
// 成功时返回 0,失败时返回 -1。

网络编程中用于发送请求的套接字创建过程可整理如下:

  1. 调用 socket()connect(),创建套接字并向服务器端发送连接;
  2. 与服务器共同运行以收发字符串数据。

示例代码

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

#define PORT 8080

int main() {
    int sock = 0, valread;
    struct sockaddr_in serv_addr;
    char buffer[1024] = {0};
    const char* message = "Hello from client";

    // 创建Socket文件描述符
    if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {
        std::cerr << "[client] socket failed" << std::endl;
        return -1;
    }

    // 初始化服务器地址结构体
    memset(&serv_addr, '0', sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_port = htons(PORT);

    // 将IPv4地址从点分十进制转换为二进制格式
    if(inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr)<=0) {
        std::cerr << "[client] Invalid address/ Address not supported" << std::endl;
        return -1;
    }

    // 连接到服务器
    if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
        std::cerr << "[client] connect failed" << std::endl;
        return -1;
    }

    // 向服务器发送消息
    send(sock, message, strlen(message), 0);
    std::cout << "[client] Hello message sent" << std::endl;

    // 从服务器读取数据
    valread = read(sock, buffer, 1024);
    std::cout << "[client] Recv : " << buffer << std::endl;

    // 关闭Socket连接
    close(sock);

    return 0;
}

该部分仅作初步体验,详细内容将在余下章节进行解释。

基于 Linux 的文件操作

在 Linux 中,socket 也被认为是文件的一种,因此在网络数据传输的过程中自然可以使用文件 I/O 的相关函数。但 Windows 中则需要区分 socket 和文件。

底层文件访问(Low-Level File Access)和文件描述符(File Descriptor)

文件和套接字一般经过创建过程才会被分配文件描述符。

文件描述符是一个非负整数,它通常是一个小的非零整数。Linux操作系统为每个进程维护一个文件描述符表,该表包含当前进程打开的所有文件和套接字等资源。当进程打开一个文件或者创建一个套接字时,Linux会返回一个文件描述符,进程可以使用该文件描述符来引用这个资源。

打开文件

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>

int open(const char* path, int flag);
// path 文件名的字符串地址
// flag 文件打开模式
// 成功时返回文件描述符,失败时返回 -1

文件打开模式

打开模式含义
O_CREAT必要时创建文件
O_TRUNC删除全部现有数据
O_APPEND维持现有数据,保存到其后面
O_RDONLY只读打开
O_WRONLY只写打开
O_RDWR读写打开

关闭文件

#include <unistd.h>

int close(int fd);
// fd 需要关闭的文件/套接字的文件描述符
// 成功时返回 0,失败时返回 -1

该函数不仅可以关闭文件,还可以关闭套接字。

补充内容:

unistd.h 中的 open()close() 与C++标准库中的同名函数有何不同?

unistd.h中的open()close()函数是系统级别的文件操作函数,直接操作文件描述符,对于需要与底层操作系统进行交互的场景比较适用;而C++标准库中的open()close()函数则提供了更加面向对象、易于使用的接口,更适合在C++程序中进行文件读写操作。

两者的一些主要区别:

  1. 参数类型:unistd.h中的open()close()函数的参数类型是整数型的文件描述符(file descriptor),而C++标准库中的open()close()函数的参数类型是字符串型的文件名。
  2. 返回值:unistd.h中的open()函数会返回一个非负整数型的文件描述符,或者-1表示打开文件失败;而C++标准库中的open()函数会返回一个表示文件状态的流对象。同样,unistd.h中的close()函数返回值为0表示操作成功,-1表示操作失败;C++标准库中的close()函数没有返回值。
  3. 错误处理:unistd.h中的open()close()函数在出现错误时会将errno变量设置为相应的错误码来指示具体的错误信息,错误处理需要通过errno值进行判断和处理;而C++标准库中的open()close()函数则可以结合异常机制进行错误处理。

将数据写入文件

#include <unistd.h>

ssize_t write(int fd, const void* buf, size_t nbytes);
// 成功时返回写入的字节数,失败时返回 -1

由于 Linux 中不区分文件和套接字,因此,通过套接字向其他计算机传递数据时也会用到该函数。

由于 size_t 是通过 typedef 声明的 unsigned int 类型。而 ssize_tsize_t 之前加的 s 代表 signed,即 ssize_t 是通过 typedef 声明的 signed int 类型。

补充内容:

_t 为后缀的数据类型

在 C 和 C++ 中,一些数据类型的名称以 _t 结尾。这种命名约定表明这些类型是通过 typedef 关键字定义的自定义类型。

这种命名约定的目的在于避免与系统或其他库中已经定义的标准类型名称冲突,同时提高代码的可读性和可维护性。

例如,在 POSIX 标准中,定义了许多以 _t 结尾的类型名称,如:

  • size_t: 用于表示对象的大小,通常是一个无符号整数类型。
  • pid_t: 用于表示进程 ID,通常是一个整型类型。
  • uid_tgid_t: 用于表示用户 ID 和组 ID,通常是一个整型类型。
  • time_t: 用于表示时间值,通常是一个整型或浮点型类型。

习题

(1)套接字在网络编程中的作用是什么?为何它被称为套接字?

在网络编程中,套接字用于实现不同计算机之间的通信。它是一种抽象概念,可以被看作是在网络上进行通信的端点。

具体来说,套接字包含了通信所需的各种参数,如IP地址、端口号、协议等等,它们被用来唯一标识一个网络连接。通过套接字,程序可以向指定的目标计算机发送数据,并接收该计算机返回的数据。

套接字被称为套接字,是因为它的工作方式类似于插头和插座的连接方式。在网络编程中,一个程序需要创建一个套接字并将其绑定到本地的IP地址与端口号上,这就相当于在本地计算机上安装了一个插座。另一个程序则需要创建另一个套接字并将其连接到远程计算机的IP地址与端口号上,这就相当于在远程计算机上安装了一个插头。当两个程序都准备就绪后,它们可以通过套接字建立连接,实现数据的传输。

(2)在服务器端创建套接字后,会依次调用 listenaccept 函数。请比较并说明二者作用。

在服务器端创建套接字后,需要调用 listen() 函数来监听客户端的连接请求,等待客户端发起连接。当一个客户端尝试连接时,服务器会调用 accept() 函数来接受该连接,从而建立客户端与服务器之间的通信。

具体来说,listen() 函数用于将套接字设置为被动监听模式,使其可以接受来自客户端的连接请求。该函数需要传入两个参数:套接字文件描述符和等待连接队列的最大长度。其中,等待连接队列用于存储已经连接但还未被服务器处理的客户端请求。如果有多个客户端同时尝试连接,且等待连接队列已满,则后续的连接请求会被拒绝。

accept() 函数用于接受客户端的连接请求,并返回一个新的套接字文件描述符,该描述符用于与客户端之间的通信。该函数需要传入一个参数:套接字文件描述符。执行该函数时,服务器会阻塞等待客户端的连接请求,直到有客户端请求到达为止。一旦有客户端请求到达,accept() 函数会创建一个新的套接字文件描述符,并返回给服务器,以便后续的数据传输。

(3)Linux中,对套接字数据进行 I/O 时可以直接使用文件 I/O 相关函数;而在 Windows 中则不可以。原因为何?

在Linux中,一切皆文件的思想是其设计哲学之一。在Linux中,套接字(socket)也被视为一种文件描述符,因此可以直接使用文件I/O相关函数进行读写操作。

而在Windows中,套接字则不是一种文件描述符。虽然Windows提供了 ReadFile()WriteFile() 等文件I/O相关函数,但这些函数并不支持对套接字数据的读写。

相反,Windows提供了一组专门用于套接字I/O的函数,包括 WSASend()WSARecv()send()recv() 等函数。这些函数提供了与文件I/O相关函数类似的功能,但是底层实现却有很大的差别。

这个差别主要在于操作系统的内核实现方式不同。Windows和Linux的内核实现方式不同,导致它们在套接字I/O方面的实现也不同。正因为如此,在Windows中不能像在Linux中那样直接使用文件I/O相关函数来进行套接字I/O操作。

(4)创建套接字后一般会给它分配地址,为什么?为了完成地址分配需要调用哪个函数?

在网络编程中,创建套接字后需要给它分配地址,以便让其他实体能够通过该地址找到这个套接字。在TCP/IP协议族中,使用IP地址和端口号来标识一个套接字。

因此,在创建套接字后,需要为其指定地址信息,包括IP地址和端口号。这样才能与其他的套接字建立连接,并进行数据传输。

在Linux中,要为套接字分配地址,需要使用 bind() 函数。该函数的原型如下:

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

其中,sockfd 参数是套接字文件描述符,addr 参数是指向用于存储地址信息的结构体指针,addrlen 参数则是地址结构体的长度。

在调用 bind() 函数时,需要将套接字文件描述符、地址结构体指针和地址结构体的长度作为参数传递进去。执行成功后,套接字就与指定的地址绑定起来了。

需要注意的是,在使用 bind() 函数时,还需要根据套接字类型选择不同的地址结构体,如 IPv4 使用 struct sockaddr_in 结构体,IPv6 使用 struct sockaddr_in6 结构体等。

(5)Linux 中的文件描述符与 Windows 的句柄实际上非常相似。请以套接字为对象说明它们的含义。

在 Linux 中,每个进程都有一组文件描述符(file descriptors),用于访问打开的文件、套接字和其他 I/O 资源。文件描述符是一个非负整数,它作为进程内部对打开文件或套接字的引用。

在 Windows 中,句柄(handle)则类似于文件描述符,表示一个对象的引用,可以是文件、套接字、命名管道、信号等等。

对于套接字,文件描述符和句柄都是用来表示该套接字在进程中的引用。通过它们,进程可以进行读取、写入、关闭等操作。在 Linux 中,套接字被视为一种特殊的文件类型,因此使用文件描述符来表示;而在 Windows 中,套接字则被视为一种对象,使用句柄来表示。

无论是文件描述符还是句柄,它们都允许进程对套接字进行访问与操作,这样就使得进程可以通过网络进行通信。

(6)底层文件 I/O 函数与 ANSI 标准定义的文件 I/O 函数有何区别?

底层文件 I/O 函数与 ANSI 标准定义的文件 I/O 函数有以下区别:

  1. 接口不同:底层文件 I/O 函数是操作系统提供的,一般使用系统调用进行操作,并且通常是在 C 语言中编写的。而 ANSI 标准定义的文件 I/O 函数则是由 ANSI(美国国家标准协会)定义的,通常以库函数形式提供,可以在多种编程语言中使用。
  2. 实现方式不同:底层文件 I/O 函数直接访问系统资源,可以实现更为底层的读写操作;而 ANSI 标准定义的文件 I/O 函数则是基于底层文件 I/O 函数实现的,提供了更为便捷的使用方式。
  3. 性能不同:由于底层文件 I/O 函数直接操作系统资源,因此它们的性能一般比标准文件 I/O 函数更优秀。但是,由于底层文件 I/O 函数需要处理的细节较多,所以使用起来也更为复杂。
  4. 移植性不同:由于不同操作系统提供的底层文件 I/O 函数不尽相同,因此在移植代码时,需要对不同操作系统进行适配。而 ANSI 标准定义的文件 I/O 函数则是跨平台的,可以在不同操作系统上使用。

(7)

以下是一个使用底层文件 I/O 函数实现的简单的文件读写程序,该程序读取一个文件并将其内容复制到另一个文件中:

#include <stdio.h>
#include <stdlib.h>
#include <fcntl.h>
#include <unistd.h>

#define BUFFER_SIZE 4096

int main(int argc, char *argv[])
{
    int source_fd, dest_fd; // 源文件和目标文件的文件描述符
    ssize_t bytes_read, bytes_written; // 读取和写入的字节数
    char buffer[BUFFER_SIZE]; // 缓冲区

    if (argc != 3) {
        fprintf(stderr, "Usage: %s source_file dest_file\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    // 打开源文件,只读模式
    source_fd = open(argv[1], O_RDONLY);
    if (source_fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    // 创建目标文件,如果已经存在则覆盖
    dest_fd = open(argv[2], O_WRONLY | O_CREAT | O_TRUNC, 0644);
    if (dest_fd == -1) {
        perror("open");
        exit(EXIT_FAILURE);
    }

    // 从源文件读取数据,并写入目标文件
    while ((bytes_read = read(source_fd, buffer, BUFFER_SIZE)) > 0) {
        bytes_written = write(dest_fd, buffer, bytes_read);
        if (bytes_written == -1) {
            perror("write");
            exit(EXIT_FAILURE);
        }
    }

    // 关闭文件描述符
    close(source_fd);
    close(dest_fd);

    return 0;
}

该程序中使用了 openreadwrite 函数来进行文件读写操作。首先通过 open 函数打开源文件和目标文件,然后循环调用 read 函数从源文件中读取数据,并调用 write 函数将数据写入目标文件中。在读取或写入数据出现错误时,程序会输出错误信息并退出。

需要注意的是,在使用底层文件 I/O 函数进行文件读写操作时,需要对函数的返回值进行判断,以确保操作成功。同时也要记得在操作完成后关闭文件描述符,以释放系统资源。

以下是一个使用 ANSI 标准 I/O 函数实现的简单的文件读写程序,该程序读取一个文件并将其内容复制到另一个文件中:

#include <stdio.h>
#include <stdlib.h>

#define BUFFER_SIZE 4096

int main(int argc, char *argv[])
{
    FILE *source_file, *dest_file; // 源文件和目标文件指针
    size_t bytes_read; // 读取的字节数
    char buffer[BUFFER_SIZE]; // 缓冲区

    if (argc != 3) {
        fprintf(stderr, "Usage: %s source_file dest_file\n", argv[0]);
        exit(EXIT_FAILURE);
    }

    // 打开源文件,只读模式
    source_file = fopen(argv[1], "r");
    if (source_file == NULL) {
        perror("fopen");
        exit(EXIT_FAILURE);
    }

    // 创建目标文件,覆盖已有文件
    dest_file = fopen(argv[2], "w");
    if (dest_file == NULL) {
        perror("fopen");
        exit(EXIT_FAILURE);
    }

    // 从源文件读取数据,并写入目标文件
    while ((bytes_read = fread(buffer, 1, BUFFER_SIZE, source_file)) > 0) {
        fwrite(buffer, 1, bytes_read, dest_file);
    }

    // 关闭文件指针
    fclose(source_file);
    fclose(dest_file);

    return 0;
}

该程序中使用了 fopenfreadfwrite 函数来进行文件读写操作。首先通过 fopen 函数打开源文件和目标文件,然后循环调用 fread 函数从源文件中读取数据,并调用 fwrite 函数将数据写入目标文件中。在读取或写入数据出现错误时,程序会输出错误信息并退出。

需要注意的是,在使用 ANSI 标准 I/O 函数进行文件读写操作时,可以使用高级函数来进行更为便捷的操作。但同时也要注意内存占用问题,当处理大文件时可能需要使用缓冲区来避免内存溢出。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值