AIGC标识 002-linux-tcp-file-transfer-cmake

从 CMake 构建到 Socket 通信:Linux 文件传输小项目实践笔记

Linux 开发学习笔记 · 第 002 篇
整理日期:2026-10-26

这个项目完成一件事:客户端读取文件,通过 TCP 发给服务器;服务器保存文件,再返回处理结果。本文结合项目代码,整理 CMake、Socket、文件操作和错误处理中的关键知识。

1. 项目结构

文件 作用
file_client.c 读取文件、连接服务器、发送数据、接收结果
file_server.c 监听连接、接收数据、保存文件、返回结果
common.c 实现完整收发和端口解析函数
common.h 声明公共函数,定义大小限制和缓冲区大小
CMakeLists.txt 指定编译设置,生成客户端、服务器和公共库

当前版本使用阻塞 Socket,依次处理连接。每个连接上传一个文件,最大 64 MiB,成功后统一保存为 received.bin。再次成功上传会替换原文件。

源码位于 outputs/file-transfer。

2. CMake:把源文件构建成程序

2.1 CMake、Make 和 GCC 的关系

三者分工不同:

  • CMake:读取 CMakeLists.txt,生成构建规则。
  • Make / Ninja:按照规则调度编译和链接。
  • GCC:编译源文件,并在构建过程中驱动链接器完成链接。

项目中有两个可执行程序,它们共用 common.c:

项目的构建依赖关系

2.2 读懂项目的 CMakeLists.txt

cmake_minimum_required(VERSION 3.16)
project(file_transfer LANGUAGES C)

set(CMAKE_C_STANDARD 11)
set(CMAKE_C_STANDARD_REQUIRED ON)
set(CMAKE_C_EXTENSIONS OFF)

add_compile_definitions(_POSIX_C_SOURCE=200809L)
add_compile_options(-Wall -Wextra -Wpedantic)

add_library(transfer_common STATIC common.c)
add_executable(file_server file_server.c)
add_executable(file_client file_client.c)

target_link_libraries(file_server PRIVATE transfer_common)
target_link_libraries(file_client PRIVATE transfer_common)
配置 含义
cmake_minimum_required() 要求 CMake 不低于指定版本
project(... LANGUAGES C) 设置项目名称,启用 C 语言支持
CMAKE_C_STANDARD 11 选择 C11 标准
CMAKE_C_STANDARD_REQUIRED ON 不允许退回较旧标准来满足配置
CMAKE_C_EXTENSIONS OFF 请求标准模式,而非 GNU 扩展模式
_POSIX_C_SOURCE=200809L 让系统头文件提供相应 POSIX 接口声明,例如 mkstemp()、fdopen()
-Wall -Wextra -Wpedantic 启用常用警告、额外警告和标准相关诊断;这些是 GCC/Clang 风格选项
add_library(... STATIC ...) 将公共代码构建为静态库
add_executable() 声明要生成的可执行目标及其源文件
target_link_libraries() 将公共库链接到目标;PRIVATE 表示这项依赖不作为接口向使用者传播

#include "common.h" 让编译器看到函数声明;链接 transfer_common 才把函数实现接入程序。头文件声明不能代替链接。

目标与库的基础用法可参见 CMake 官方教程。

2.3 最常用的构建命令

在包含 CMakeLists.txt 的目录执行:

# 配置并生成构建规则
cmake -S . -B build -DCMAKE_BUILD_TYPE=Debug

# 编译和链接
cmake --build build

-S . 指定当前目录为源码目录,-B build 将构建产物放在 build/,-D 设置 CMake 缓存变量。对 Linux 常用的 Makefiles 或单配置 Ninja 生成器,Debug 用于选择调试配置,通常包含调试信息。

修改 .c 或 .h 后,通常再次执行 cmake --build build 即可。修改构建选项时,重新执行配置命令,再构建。

只构建客户端,也可以写:

cmake --build build --target file_client

它依赖的公共库也会按需构建。命令参数见 CMake 命令行手册。

3. Socket:建立连接并收发数据

3.1 服务端与客户端的调用顺序

服务器:socket → setsockopt → bind → listen → accept
客户端:socket → connect
连接建立后:send / recv → close

客户端与服务器的连接和文件传输时序

函数 项目中的作用 成功返回值
socket(AF_INET, SOCK_STREAM, 0) 创建 IPv4 TCP Socket 非负文件描述符
setsockopt() 设置 Socket 选项,本项目设置 SO_REUSEADDR 0
bind() 给服务器 Socket 绑定本地地址和端口 0
listen() 把 Socket 设为监听状态 0
accept() 取出一个连接,得到通信 Socket 新的非负文件描述符
connect() 客户端连接指定服务器 0
send() / recv() 发送 / 接收字节 实际收发的字节数,需单独处理 0
close() 关闭文件描述符 0

这些函数失败时返回 -1,通过 errno 提供错误原因。

SO_REUSEADDR 用于调整地址重用规则,常用于服务器重启后的重新绑定;它不保证可以抢占其他进程正在监听的端口。

listen(listen_fd, 8) 中的 8 是等待被接受的连接队列长度参数,实际还受系统限制。它不会创建线程,也不代表同时处理 8 个文件。

3.2 两个 Socket 不要混淆

int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
/* 设置选项、绑定地址、开始监听,并检查返回值。 */
int client_fd = accept(listen_fd, NULL, NULL);
  • listen_fd:一直保留,用于接受后续连接。
  • client_fd:与当前客户端收发数据,处理完成后关闭。

accept() 的两个 NULL 表示不获取客户端地址。它返回新的 Socket,原监听 Socket 保持不变。参见 accept(2)。

本项目没有设置非阻塞模式。没有待处理连接时,accept() 会等待;没有可读数据时,recv() 通常也会等待。服务器在处理完当前文件后才再次调用 accept(),所以它是顺序处理模型。

4. 地址、端口和字节序

客户端构造服务器地址的核心代码如下,假设 host 和 port 已完成参数检查:

struct sockaddr_in addr = {0};
addr.sin_family = AF_INET;
addr.sin_port = htons(port);

if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
    /* IPv4 地址转换失败。 */
}

/* connect(fd, (struct sockaddr *)&addr, sizeof(addr)); */

sockaddr_in 保存 IPv4 地址信息:sin_family 指定地址族,sin_port 保存端口,sin_addr 保存 IP。调用接口时转换成 struct sockaddr *,是为了匹配通用地址参数。

inet_pton() 把点分十进制 IP 字符串转为网络地址,成功返回 1,格式无效返回 0,出错返回 -1。它不负责域名解析。

4.1 三种地址的用途

地址 含义
127.0.0.1 当前机器的回环地址
0.0.0.0 / INADDR_ANY 在服务器绑定地址时,表示所有本地 IPv4 接口
服务器实际 IP 客户端连接的目标地址

服务器代码使用 htonl(INADDR_ANY);客户端应填写服务器实际 IP,或在本机通信时使用回环地址。

4.2 为什么转换字节序

不同机器保存多字节整数的顺序可能不同。协议统一使用网络字节序,即大端序。

函数 转换方向 项目用途
htons() 主机序 → 网络序,16 位 设置端口
htonl() 主机序 → 网络序,32 位 发送文件长度
ntohl() 网络序 → 主机序,32 位 解析文件长度

文件内容按原始字节传输,不需要逐字节转换。长度字段按字节计算,不是字符数。

5. 协议与完整收发

5.1 先告诉对方需要接收多少字节

本项目的协议是:

客户端 → 服务器:[4 字节无符号文件长度 N][N 字节文件内容]
服务器 → 客户端:[1 字节状态:0 成功,1 失败]

N 不包含长度字段本身。空文件的 N 为 0,仍要发送长度并等待结果。

TCP 提供字节流,一次 send() 不对应一次 recv()。因此,项目用两个辅助函数完成收发:

  • send_all():循环发送,直到指定字节全部交给发送接口。
  • recv_exact():循环接收,直到收齐指定字节。

它们是项目自己实现的函数,成功返回 0,失败返回 -1;不要与 send()、recv() 的返回值含义混淆。

5.2 recv_exact() 如何收齐数据

下面保留项目中的核心实现:

int recv_exact(int fd, void *buffer, size_t length)
{
    unsigned char *p = buffer;
    size_t done = 0;

    while (done < length) {
        ssize_t n = recv(fd, p + done, length - done, 0);
        if (n < 0) {
            if (errno == EINTR) continue;
            return -1;
        }
        if (n == 0) {
            errno = ECONNRESET;
            return -1;
        }
        done += (size_t)n;
    }
    return 0;
}

这段代码抓住了三个量:

表达式 含义
done 已接收的字节数
p + done 下一批数据写入的位置
length - done 还需要接收多少字节

例如要接收 100 字节,第一次收到 60 字节,下一次就从 p + 60 开始,最多再接收 40 字节。之前收到的内容一直保留。

对这里的 TCP 接收,recv() 返回 0 表示对端有序关闭了发送方向。若还没收齐预期长度,项目将它视为传输失败,并自行设置 errno = ECONNRESET;这不代表内核一定收到了 TCP 重置报文。返回值语义见 recv(2)。

5.3 send_all() 的处理方式

发送也使用相同的累计方式:

ssize_t n = send(fd, p + done, length - done, MSG_NOSIGNAL);

返回正数后推进 done;遇到 EINTR 重试;其他错误返回失败。项目还将无进展的 0 返回值按失败处理,避免循环停不下来。

MSG_NOSIGNAL 用于阻止本次发送因连接断开而触发 SIGPIPE 信号终止进程;发送仍可能返回 -1,错误仍要处理。参见 send(2)。

这两个辅助函数针对阻塞 Socket。改为非阻塞模式后,还需要处理 EAGAIN 等情况,不能直接套用当前循环。

5.4 为什么还要等服务器应答

发送成功只说明发送接口接受了数据,不代表服务器已经写入文件。

因此,客户端发完内容后继续读取 1 字节状态。服务器完成写入、关闭文件和重命名后才返回成功。若连接中断,应答可能收不到;此时客户端无法仅凭发送完成确认保存结果。

6. 文件读取与保存

6.1 客户端:检查大小,再分块读取

FILE *input = fopen(path, "rb");
/* 检查 input 后再继续。 */
struct stat st;
/* 检查 fstat() 返回值后,才可使用 st。 */
fstat(fileno(input), &st);
函数或宏 作用
fopen(path, "rb") 以二进制读取方式打开文件
fileno(input) 获取文件流底层的文件描述符
fstat(fd, &st) 获取已打开文件的信息,st_size 是大小
S_ISREG(st.st_mode) 判断是否为普通文件
fread(buffer, 1, want, input) 最多读取 want 字节;这里返回实际字节数

FILE * 是 C 标准库的文件流对象,带有缓冲等状态;文件描述符是内核接口使用的整数。fileno() 获取描述符,不会复制它。

项目先检查文件类型和 64 MiB 上限,再使用 4096 字节缓冲区读取。每轮处理量为:

size_t want = remaining < sizeof(buffer)
            ? remaining : sizeof(buffer);

这样不用把整个文件载入内存。发送后按实际读取的 n 减少 remaining。上传期间不要修改源文件,否则事先发送的长度可能与后续内容不一致。

6.2 服务器:先写临时文件,再发布结果

文件接收、保存与失败清理流程

函数 在项目中的作用
chdir(directory) 切换到接收目录,让后续相对路径指向这里;不会创建目录
mkstemp(temporary) 根据可修改的 .upload-XXXXXX 模板创建唯一临时文件,返回描述符
fdopen(output_fd, "wb") 将已有描述符关联为文件流
fwrite(buffer, 1, want, output) 写入收到的数据;返回值应等于 want
fclose(output) 刷新流缓冲区并关闭文件;必须检查失败
rename(temporary, "received.bin") 将完整文件变成最终文件,本项目允许替换旧结果
unlink(temporary) 失败时移除临时文件

先写临时文件,可以避免把未收完的文件当作成功结果。这里临时文件和最终文件位于同一目录,重命名完成前,原来的 received.bin 不会因写入临时文件而被截断。

fclose() 成功不等于数据已经获得断电持久性保证;本项目没有实现这层保证。强制终止进程也可能跳过清理,留下临时文件。

7. 错误处理与资源释放

7.1 检查返回值,再读取 errno

if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
    perror("connect");
    goto cleanup;
}

perror() 输出提示文本和当前 errno 对应的错误信息。应先根据函数返回值判断失败,再使用 errno;成功调用不一定把它清零。

EINTR 表示调用被信号中断。项目在 send()、recv()、accept() 的相应失败分支中重试。

7.2 用 cleanup 集中释放资源

客户端使用一个出口处理已经打开的资源:

cleanup:
    if (fd >= 0) close(fd);
    fclose(input);
    return result;

该路径只在 input 成功打开后进入。Socket 初始值设为 -1,因此可以判断是否需要关闭;0 也是合法描述符,不能用 fd > 0 判断。

服务器还要注意:

  • fdopen() 成功后,通过 fclose() 关闭流及其底层描述符,不再重复 close(output_fd)。
  • fdopen() 失败时,仍由程序关闭原描述符。
  • 调用 fclose(output) 后将 output 设为 NULL,避免清理分支重复关闭。
  • 接收失败时清理临时文件;正常处理完连接后关闭 client_fd,继续保留 listen_fd。

7.3 parse_port() 为什么使用 strtol()

errno = 0;
long value = strtol(text, &end, 10);

项目检查转换错误、是否读到数字、是否还有多余字符,以及数值是否处于 1~65535。这样可以拒绝空输入、9000abc 和越界端口。检查通过后才转换为 uint16_t,比直接使用 atoi() 更容易发现非法输入。

8. 复习题

  1. 包含 common.h 就能调用函数了吗? 编译器能看到声明,但还需要链接对应实现。
  2. 关闭 client_fd 会停止监听吗? 不会,监听使用的是另一个描述符 listen_fd。
  3. 需要 100 字节,只收到 60 字节怎么办? 保留已有数据,从后续位置继续接收 40 字节。
  4. 为什么先写临时文件? 让未完成的上传不会直接覆盖最终文件。
  5. 客户端为什么等状态码? 只有发送完成,还不能确认服务器保存成功。

下一步可在此基础上增加原文件名传输、同名文件处理、超时和多客户端并发。

posted @ 2026-10-27 21:02  雪花哗哗  阅读(3)  评论(0)    收藏  举报