# 网络工程实践3 **Repository Path**: gaohongy/network-engineering-practice-3 ## Basic Information - **Project Name**: 网络工程实践3 - **Description**: 网络工程实践3的资源。包括要求、例子等。 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 1 - **Created**: 2023-05-17 - **Last Updated**: 2024-10-24 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 1. TCP通信的原理 在linux当中,网络编程通常使用一种叫做socket的机制完成。Linux有一套API函数来完成网络通信的各种功能。关于socket,大家可以参考: 1. https://blog.csdn.net/hguisu/article/details/7445768/ 2. https://blog.51cto.com/u_15169172/2710206 这里我们用一个文件上传的例子来讲解TCP编程的基本流程。 :warning:该代码均在Linux下完成,Windows下的socket编程会不一样。Windows下可需要使用MinGW、或者cygwin环境才可以编译和运行。 这个例子实现了一个文件服务器和客户端。服务器在一个端口上监听,等待客户端的连接;客户端连接到服务器后,向服务器发送一个**文本**文件;当发送完成后,客户端断开与服务器的连接。 ### 1.1. 服务器编程 ### 1.2. 向操作系统申请socket资源 ```c int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("[-]Error in socket"); exit(1); } ``` 改函数返回一个整形,表示申请资源的ID(或者叫做句柄)。如果返回的整形不小于0,表示申请socket资源成功,以后的代码需要使用到这个整形sockfd; * AF_INET 表示socket类型是 ipv4, * SOCK_STREAM 是表示socket 是TCP类型。 上述两个标识都是系统常量,其他类型请参考相关文档。 ### 1.3. 绑定监听端口 服务器在指定IP上的一个端口(port)上面进行监听;这个IP地址和端口用来接受客户端的连接。接下来就需要告诉服务器是在哪个个IP地址上的哪个端口进行监听了。这里需要一个bind函数和一个 sockaddr_in 的结构体。 ```c struct sockaddr_in server_addr; char *ip = "127.0.0.1"; int port = 8080; // 初始化server_addr这个结构体 server_addr.sin_family = AF_INET; // AF_INET 是预定义常量,表示IPV4,其他参数请参考相关文档 server_addr.sin_port = port; // 整形表示监听端口 server_addr.sin_addr.s_addr = inet_addr(ip); // 字符串,表示需要监听的IP地址 int e = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); if (e < 0) { perror("[-]Error in bind"); exit(1); } ``` bind 函数是告诉socket在哪个端口上监听,同样返回值小于0代表错误。注意bind函数的三个参数。 IP地址 `127.0.0.1` 表示本机 Localhost,因此其他网络上的计算机不能连接该IP地址,只能是本机上的应用才可以。如果希望网络上的其他计算机访问这个服务,请替换成真是IP地址,或者写成`0.0.0.0`表示在所有的地址上面进行监听。 ### 1.4. 进入到监听模式 ```c if (listen(sockfd, 10) == 0) { printf("[+]Listening....\n"); } else { perror("[-]Error in listening"); exit(1); } ``` listen函数进入到监听模式。sockfd是socket的句柄;10代表等待队列的长度。因为一个服务可以同时接受多个客户端的连接,指定一个适当等待队列的长度是有必要的。不过这个例子中,我们只实现了接受一个客户端连接的功能。 ### 1.5. 接受客户端连接 接下来,当客户端连接的时候,服务器需要进行相应的动作: ```c struct sockaddr_in new_addr; addr_size = sizeof(new_addr); int new_sock = accept(sockfd, (struct sockaddr *)&new_addr, &addr_size); ``` 这里最重要的函数就是 accept 函数了。对于一个服务器来说,这里其实是 sockefd 所代表的一个监听socket,需要一直在端口上监听,等待新的客户端连接。当有新的连接来到时,调用accept函数,生成一个新的socket对象`new_sock`表示一个客户端的连接。以后在这个新的socket上进行读写就表示和远端的客户端进行通信。 accept函数是一个阻塞调用。什么是阻塞?这个和scanf函数(读取键盘输入)一样,当没有键盘输入的时候,线程会一直在这条语句等待,一直到有键盘输入(一般是读取一个回车)。accept函数也是这样,当调用accept函数的时候,线程就在这里等待了,因为这时还没有客户端连接;一旦客户端成功连接,accept函数就返回一个新的socket(new_socket),并且把连接参数写入到new_addr这个结构体中。 好了,这时我们有了一个新的socket(new_sock),代表与客户端连接的一个资源,向这个socket写入数据,客户端会收到;反之,客户端发送给服务器的数据可以通过这个socket读取出来。接下来看看是如何读取客户端发送来的文件的。 ### 1.6. 读取客户端发送的数据 ```c write_file(new_sock); ``` write_file是一个自定义的函数,其输入参数是 new_socket 表示一个客户端的连接。 write_file 函数如下: ```c void write_file(int sockfd) { int n; FILE *fp; char *filename = "recv.txt"; char buffer[SIZE]; // SIZE是自定义常量,这里是 1024 fp = fopen(filename, "w"); while (1) { n = recv(sockfd, buffer, SIZE, 0); if (n <= 0) { break; return; } fprintf(fp, "%s", buffer); bzero(buffer, SIZE); // 把缓冲数组清空,全部填写 0x00 } return; } ``` 首先,以写的方式打开一个文件`fp`,然后在个死循环中使用`recv(sockfd, buffer, SIZE, 0);`函数读取客户端发送的数据,其返回值表示读取了多少个字节的数据。如果`n<=0`表示没有读取到数据,可以认为客户端已经发送完成了,这时就退出循环。 在循环中,每次通过recv函数读取到的数据被临时放在一个数组`buffer`中,然后通过`fprintf`函数把缓冲区的数据写入到文件中。 :warning:注意,这里写入的方式是 fprintf,是以文本的方式写入的,因此只能接受文本文件。如果是发送图片等二进制文件可能会出错。 ### 1.7. 完整的服务器代码 ```c #include #include #include #include #define SIZE 1024 void write_file(int sockfd) { int n; FILE *fp; char *filename = "recv.txt"; char buffer[SIZE]; fp = fopen(filename, "w"); while (1) { n = recv(sockfd, buffer, SIZE, 0); if (n <= 0) { break; return; } fprintf(fp, "%s", buffer); bzero(buffer, SIZE); } return; } int main() { char *ip = "127.0.0.1"; int port = 8080; int e; int sockfd, new_sock; struct sockaddr_in server_addr, new_addr; socklen_t addr_size; char buffer[SIZE]; sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("[-]Error in socket"); exit(1); } printf("[+]Server socket created successfully.\n"); server_addr.sin_family = AF_INET; server_addr.sin_port = port; server_addr.sin_addr.s_addr = inet_addr(ip); e = bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); if (e < 0) { perror("[-]Error in bind"); exit(1); } printf("[+]Binding successfull.\n"); if (listen(sockfd, 10) == 0) { printf("[+]Listening....\n"); } else { perror("[-]Error in listening"); exit(1); } addr_size = sizeof(new_addr); new_sock = accept(sockfd, (struct sockaddr *)&new_addr, &addr_size); write_file(new_sock); printf("[+]Data written in the file successfully.\n"); return 0; } ``` ### 1.8. 注意事项 通过`recv(sockfd, buffer, SIZE, 0);`的返回值小于等于0来判断文件是否发送完成其实是有问题的。想一下,如果server所在的计算机运行速度较快,而网络(或者是客户端)发送的速度较慢。会出现客户端还来不及发送,服务器的`while`循环就又重新执行了一次了。因此可能错误的在文件并没有接收完成的时候就退出循环了。 这个程序只能传输文本文件,如果要传输任意文件应该如何修改。可以认为文本文件是二进制文件的特例,如果可以传输二进制文件就可以传输所有类型的文件。 这个代码演示的是客户端发送文件给服务器,我们的要求是服务器发送文件给客户端,应该如何修改? 要回答上面三个问题,还需要看看客户端的实现。 ## 2. 客户端编程 对socket资源的申请相关的代码几乎完全一样: ```c char *ip = "127.0.0.1"; // 服务器的IP地址 int port = 8080; // 服务器的端口 int e; int sockfd; struct sockaddr_in server_addr; FILE *fp; char *filename = "send.txt"; sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("[-]Error in socket"); exit(1); } printf("[+]Server socket created successfully.\n"); server_addr.sin_family = AF_INET; server_addr.sin_port = port; server_addr.sin_addr.s_addr = inet_addr(ip); ``` 注意,这里的IP地址和端口是指服务器的IP地址和端口。 ### 2.1. 连接到服务器 服务器使用bind函数建立监听,客户端使用 `connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr));`函数连接到服务器。同样,返回值如果为负数表示连接不成功。 ```c e = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); if (e == -1) { perror("[-]Error in socket"); exit(1); } ``` 当连接成功后,就可以使用 `sockfd`这个句柄与服务器收发数据了。 ### 2.2. 向服务器发送数据 发送数据使用`send_file(fp, sockfd);`这个函数,fp是打开的文件(文件操作大家应该都会)。 send_file函数: ```c void send_file(FILE *fp, int sockfd) { int n; char data[SIZE] = {0}; // SIZE 是定义的常量 1024 while (fgets(data, SIZE, fp) != NULL) { if (send(sockfd, data, sizeof(data), 0) == -1) { perror("[-]Error in sending file."); exit(1); } bzero(data, SIZE); } } ``` 这个函数很简单。首先是需要一个字符串的缓冲区 data,长度是1024。然后在循环中把文件读入到缓冲区中;在使用 send函数通过 socket发送给服务器。sned函数返回值如果是-1 表示遇到错误。 :warning:fgets函数是读取文本文件,每次读取一行。这个函数也有一个问题,如果文件中的一行文本超过1024个字节可能会出错。 ### 2.3. 完整的客户端代码 ```c #include #include #include #include #include #define SIZE 1024 void send_file(FILE *fp, int sockfd) { int n; char data[SIZE] = {0}; while (fgets(data, SIZE, fp) != NULL) { if (send(sockfd, data, sizeof(data), 0) == -1) { perror("[-]Error in sending file."); exit(1); } bzero(data, SIZE); } } int main() { char *ip = "127.0.0.1"; int port = 8080; int e; int sockfd; struct sockaddr_in server_addr; FILE *fp; char *filename = "send.txt"; sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd < 0) { perror("[-]Error in socket"); exit(1); } printf("[+]Server socket created successfully.\n"); server_addr.sin_family = AF_INET; server_addr.sin_port = port; server_addr.sin_addr.s_addr = inet_addr(ip); e = connect(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)); if (e == -1) { perror("[-]Error in socket"); exit(1); } printf("[+]Connected to Server.\n"); fp = fopen(filename, "r"); if (fp == NULL) { perror("[-]Error in reading file."); exit(1); } send_file(fp, sockfd); printf("[+]File data sent successfully.\n"); printf("[+]Closing the connection.\n"); close(sockfd); return 0; } ``` ## 3. 编译与使用 该示例采用 cmake 作为构建工具,在Linux下完成编译和运行。如果是Windows环境,可能需要安装MinGW环境。请确保环境中有以下工具: * gcc * cmake ### 3.1. CMAKE 脚本 C的编译是一个比较复杂的过程,除了编译工具(GCC、MSC)外,可能还需要第三方的动态静态链接库。用于辅助C编译的工具有很多,常用的cmake可以简化C的编译工作。以下cmake文件进行了大约的解释,具体可查阅网上的相关资料。 ```cmake # cmake 所需要的最低版本,通过 cmake --version 可以查看cmake工具的版本 cmake_minimum_required(VERSION 2.8) # 项目名称,注意命名规则,按照C的变量名的规则 project(TCP_Example) # 因为cmake是工作在linux下面的,在windows中,需要一套linux的工具提供支持,例如MSYS2。建议大家在Linux下完成。这句是标注C下面的环境兼容性。 SET(CMAKE_GENERATOR “MSYS Makefiles”) # C编译器使用的标准 set(CMAKE_C_FLAGS "-std=gnu99") # 生成的代码可以用于GDB调试 SET(CMAKE_CXX_FLAGS_DEBUG "$ENV{CXXFLAGS} -O0 -Wall -g2 -ggdb") # 编译过程中警告级别 SET(CMAKE_CXX_FLAGS_RELEASE "$ENV{CXXFLAGS} -O3 -Wall") # 设置动态链接库的搜索路径RPATH,可执行文件在linux下可以在当前路径搜索动态链接库 SET(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-rpath,'$ORIGIN'") SET(CMAKE_SHARED_LINKER_FLAGS "${CMAKE_SHARED_LINKER_FLAGS} -Wl,-rpath,'$ORIGIN'") # 源文件的预编译选项,可以在这里传入预编译宏的定义,而不必写在代码中。类似代码中的 #define 宏 # ADD_DEFINITIONS(-D_TEST) # 打印消息 MESSAGE("current platform: Linux ") # Server 源文件,第一行 SOURCE_SERVER 代表整个源文件的一个标识,后面是源代码列表(可以多个源文件)。以后直接使用 SOURCE_SERVER 就可以指代后面的所有源文件。 set(SOURCE_SERVER src/server.c ) # Client 源文件,这里的标识是 SOURCE_CLIENT set(SOURCE_CLIENT src/client.c ) # 工程中头文件存放位置。${PROJECT_SOURCE_DIR}是预定义变量,表示项目所在的目录。 include_directories( "${PROJECT_SOURCE_DIR}/src" ) # 需要连接的静态库文件,如果需要三方库,请在这里列出 link_libraries( ) # 设置编译后文件的输出位置 set(EXECUTABLE_OUTPUT_PATH ${PROJECT_SOURCE_DIR}/bin) # Server主程序, # server 编译后的可执行文件的名称 # 注意 ${SOURCE_SERVER} 标识一个变量,就是前面定义的 SOURCE_SERVER 源文件标识,可以指代多个源文件。 add_executable(server ${SOURCE_SERVER}) # Client主程序, # client 编译后的可执行文件的名称 # 注意 ${SOURCE_CLIENT} 标识一个变量,就是前面定义的 SOURCE_CLIENT 源文件标识,可以指代多个源文件。 add_executable(client ${SOURCE_CLIENT}) ``` ### 3.2. 编译 在CMakeLists.txt文件所在的文件夹中建立一个新的文件夹,例如`build`。 ![image-20230516230416181](./img/image-20230516230416181.png) 通过命令行进入到build这个文件夹: ```sh cd build cmake .. make ``` 编译完成后在 bin 文件夹下有三个文件: ![image-20230516230753785](./img/image-20230516230753785.png) ### 3.3. 运行 进入 `bin` 文件夹,使用: ```sh ./server ``` 运行服务器,出现: ![image-20230516230913286](./img/image-20230516230913286.png) 在开一个终端,运行: ```sh ./client ``` ![image-20230516231333439](./img/image-20230516231333439.png) 运行完成后,bin文件夹中出现了`recv.txt`文件就是客户端上传的文件。 ### 3.4. 问题 当客户端运行完成后,服务器也退出了,这是为什么?如何让服务器不退出,并且可以继续接受新的连接请求?