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. 复习题
- 包含
common.h就能调用函数了吗? 编译器能看到声明,但还需要链接对应实现。 - 关闭
client_fd会停止监听吗? 不会,监听使用的是另一个描述符listen_fd。 - 需要 100 字节,只收到 60 字节怎么办? 保留已有数据,从后续位置继续接收 40 字节。
- 为什么先写临时文件? 让未完成的上传不会直接覆盖最终文件。
- 客户端为什么等状态码? 只有发送完成,还不能确认服务器保存成功。
下一步可在此基础上增加原文件名传输、同名文件处理、超时和多客户端并发。

浙公网安备 33010602011771号