
简介一套基于C语言与Qt框架实现的简易文件传输系统源码完整包含客户端与服务端两个工程非常适合Qt初学者、C网络编程学习者以及需要快速搭建局域网文件传输工具的学生、开发者。项目围绕TCP socket通信展开客户端负责选取文件、发起传输并显示状态服务端基于socketthread实现多线程接收结合数据模型完成文件信息管理与落盘界面采用Qt Widgets搭建配有qss样式表与图标资源加载后即可获得较为完整的桌面程序外观。压缩包共28个文件以cpp、h源码为主体另含ui界面文件、pro工程文件、qrc资源文件和qss样式表附带README说明整体仅32KB结构清晰、便于阅读或二次开发。已有308人学习下载非常适合作为Qt网络编程、C文件流处理、线程与界面设计相结合的综合性练习参考。1. 一个Qt/C文件传输系统该解决什么问题压测刚结束几十GB的日志散落在内网两台机器上微信传会压缩U盘来回跑又慢临时搭FTP还要配用户和目录。这个基于 C 和 Qt项目标题里写作 QT的文件传输系统用最直接的客户端/服务端模型解决局域网点对点传文件的需求。它没有复杂协议核心就是 Qt 网络模块里的 QTcpSocket 和 QTcpServer传输逻辑写透了还顺带做了接收记录的本地数据模型。项目源码分两个工程client 负责选文件、分块发送server 里能看到 socketthread、myserver、datamodel接收完的文件会落盘并在界面表格里留下记录。如果你准备研究 Qt 网络编程、C 客户端与服务端交互或者需要在内网搭一个轻量传文件工具这份源码值得拆开看一遍。真正需要调试的不是业务逻辑而是 TCP 流边界、线程生命周期和部署时缺失插件这一类问题后面都会逐个讲。2. 客户端发送端QTcpSocket 的连接建立与分块传输2.1 先认清 TCP 流和消息边界的差别很多从 Qt 入门 C 的开发者第一次写传输功能会把文件读出来一次性socket-write(file.readAll())然后接收端直接socket-readAll()看起来跑通了传大文件时就会出现文件损坏。原因很简单TCP 是字节流协议不保证一次read对应一次write更不保证包的边界。要在一个流上表达“这是一个文件”需要在数据前面加自定义包头。这个项目里的做法是先发送固定结构的文件头包含文件名的 UTF-8 长度、文件名数据、文件大小字节数接收端先把头部读完整再按文件大小循环接收剩余数据。我一般会把这个头定义成qint32 文件名长度 文件名内容 qint64 文件大小的组合用QDataStream在两端序列化因为QDataStream会处理好大小端和qint64的对齐问题不用手工 memcpy 拼接。2.2 建立连接connectToHost 与 waitForConnected 的取舍客户端连接入口通常在 client.cpp 里创建QTcpSocket指定服务端 IP 和端口然后调用connectToHost。下面是一段能在工程里直接落地的建连代码// ClientSocket::connectToServer socket new QTcpSocket(this); connect(socket, QTcpSocket::connected, this, ClientSocket::onConnected); connect(socket, QTcpSocket::errorOccurred, this, ClientSocket::onError); socket-connectToHost(m_serverIp, m_port); if (!socket-waitForConnected(3000)) { qWarning() 连接服务端失败: socket-errorString(); emit statusChanged(无法连接服务端); return false; } return true;这里connectToHost是异步发起连接waitForConnected(3000)会阻塞当前线程最多 3 秒等待连接信号。阻塞写法适合在纯工作线程里用界面线程里直接waitForConnected会卡界面所以我通常只在这个函数用于“发送按钮点击后进入工作状态”的场景。项目里客户端是单窗口发送在点击事件里触发短时间内阻塞可以接受。参数说明m_serverIp用字符串传入 IPv4 地址m_port是服务端监听端口waitForConnected的单位是毫秒。Qt 5.15 里连接错误信号推荐用errorOccurred老代码里的error信号在 Qt 6 已经被移掉这是从 5 迁移到 6 要注意的点。2.3 分块发送循环 write 而不是一次性写入文件头构造完成后紧接着就是文件本体。一次性write整个文件会把数据先塞进 socket 写缓冲区极端情况下内存占用高也无法及时反映进度。推荐按 64KB 分块读然后循环写。核心代码QFile file(m_filePath); if (!file.open(QIODevice::ReadOnly)) { qWarning() 文件打开失败: file.errorString(); return; } QByteArray headBlock; QDataStream out(headBlock, QIODevice::WriteOnly); out.setVersion(QDataStream::Qt_5_15); QByteArray fileNameUtf8 QFileInfo(file).fileName().toUtf8(); out (qint32)fileNameUtf8.size(); out.writeRawData(fileNameUtf8.constData(), fileNameUtf8.size()); out (qint64)file.size(); socket-write(headBlock); socket-flush(); char buf[64 * 1024]; qint64 remaining file.size(); while (remaining 0) { qint64 n file.read(buf, sizeof(buf)); if (n 0) break; qint64 written 0; while (written n) { qint64 w socket-write(buf written, n - written); if (w -1) { qWarning() 发送错误: socket-errorString(); return; } written w; } remaining - n; }这段代码的关键在两层循环。内层while (written n)是为了处理socket-write只写入部分数据的情况返回值代表实际写入 socket 缓冲区的字节数不等于对端收到的字节数。外层循环按文件剩余大小推进确保最终把所有字节交给 socket。flush()只负责把 Qt 内部写缓冲交给操作系统内核不能当作数据已到达服务端的凭证。参数表参数推荐值作用读文件缓冲区64KB减少系统调用与内存占用QDataStream 版本Qt_5_15对齐新老 Qt 序列化格式单次 write 循环直到写入全部避免 TCP 窗口背压丢数据3. 服务端接收端QTcpServer 与 SocketThread 多线程落盘3.1 为什么服务端要继承 QTcpServer 重写 incomingConnection服务端工程里myserver.h继承QTcpServer这一点很关键。默认用法是连接newConnection信号然后在槽里调用nextPendingConnection()拿 socket但这个 socket 仍然在服务端所在线程的事件循环里。如果直接在这个线程里接收大文件一旦界面刷新或鼠标操作传输就会卡顿。这个项目把每个连接到来的 socket 交给独立的SocketThread做法是重写incomingConnection(qintptr socketDescriptor)void MyServer::incomingConnection(qintptr socketDescriptor) { SocketThread *thread new SocketThread(socketDescriptor, this); connect(thread, SocketThread::finished, thread, QObject::deleteLater); thread-start(); }incomingConnection在底层接受 TCP 连接后被回调socketDescriptor是操作系统层的 socket 句柄。重写后不再走信号槽分发直接把句柄交给线程。SocketThread继承QThread在run()里创建QTcpSocket并调用setSocketDescriptor把它和已有的句柄关联起来避免跨线程处理已经存在的 socket 对象。这里有一个线程生命周期设计线程对象父对象是 serverfinished信号触发后调用deleteLater保证线程结束时不泄漏。如果直接new线程而不 connect频繁传输会上涨的是内存中的线程对象数量而不是系统线程数注意区分。3.2 SocketThread 接收循环与文件保存接收端最难处理的是“包头可能粘在文件数据里”和“文件数据可能一次没读完”。项目里 socketthread.cpp 的接收流程我拆开看大致是这样void SocketThread::run() { QTcpSocket socket; if (!socket.setSocketDescriptor(m_socketDescriptor)) return; if (!readExact(socket, m_headBuf, HEAD_SIZE)) return; QDataStream in(m_headBuf); qint32 nameLen; qint64 totalSize; in nameLen m_fileName totalSize; if (!readExact(socket, m_fileDataBuf, totalSize)) return; QFile dest(m_saveDir / m_fileName); if (dest.open(QIODevice::WriteOnly)) { dest.write(m_fileDataBuf); dest.close(); } }如果把整个文件先读进内存再写盘传几个 GB 文件时内存就爆了所以我更推荐保持原有的分块循环先用waitForReadyRead等待数据逐段读取并写入QFile。readExact是一个工具函数负责循环接收直到取满指定字节数避免socket-read返回值小于请求值导致包头解析不完整。参数说明HEAD_SIZE根据包头结构计算m_saveDir是接收目录m_fileName是 UTF-8 文件名。接收时还有一点容易忽略QTcpSocket::waitForReadyRead(30000)的超时时间。30 秒内没有数据就退出线程适合内网传输如果走公网超时要调大到两分钟以上否则大文件写入磁盘慢时会误判超时把连接断开。3.3 并发连接上限与线程终止这个项目用线程处理每个客户端连接好处是逻辑简单坏处是连接数一多线程切换开销大。常见做法是维护一个原子计数器限制同时接收的任务数// SocketThread 构造时 concurrentCount.ref(); // run() 退出前 concurrentCount.deref(); if (concurrentCount.load() kMaxThreads) { socket.disconnectFromHost(); return; }kMaxThreads我一般设为 8。内网传文件通常不会同时有几十个客户端8 个线程已经能跑满大部分场景。如果要支持更多并发可以把SocketThread改成QRunnable放入QThreadPool再配合QtConcurrent处理。这个改造在源码包基础上做并不困难但需要额外处理线程池里每个线程的数据库独立连接所以小项目用 QThread 反而更直观。参数表参数推荐值作用kMaxThreads8限制并发接收线程数接收缓冲区64KB匹配客户端发送分块读超时30s断开无数据连接4. 文件记录怎么进 DataModelSQLite 建表与界面刷新4.1 createdatamodel 与 dataexecution 的职责服务端工程里有createdatamodel.h、dataexecution.h、datamodel.h和datamodel.ui从命名看这个项目把“建表”和“执行 SQL”分开了。我习惯把createdatamodel做成一个初始化函数在程序启动时调用确保数据库文件存在且表结构正确然后再让dataexecution提供增查删改的封装。用 SQLite 而不是 MySQL 的好处是无需额外部署服务端程序启动时指定一个本地 db 文件即可。下面是建文件记录表的通用实现bool createFileTable(QSqlDatabase db) { QSqlQuery query(db); QString sql CREATE TABLE IF NOT EXISTS file_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, file_name TEXT NOT NULL, file_size INTEGER, receive_time DATETIME DEFAULT CURRENT_TIMESTAMP); if (!query.exec(sql)) { qWarning() 建表失败: query.lastError().text(); return false; } return true; }id自增主键receive_time用数据库默认时间戳这样接收端插入记录时不需要再取系统时间。file_size用INTEGER存字节数避免出现 32 位溢出。表名file_records是这个资源里最合适的通用命名如果改成你自己的业务名注意同步改掉dataexecution和datamodel.ui里的 SQL。4.2 SocketThread 写完文件后插入记录接收线程完成文件落盘后不能直接操作界面但可以访问数据库。这里有个 Qt 数据库的硬性规则QSqlDatabase连接不能跨线程复用。多个线程共用同一个连接会出现QSqlDatabase: database connection used by another thread的警告。所以我在每个接收线程里单独创建连接用线程标识区分连接名void SocketThread::insertRecord(const QString fileName, qint64 fileSize) { QString connName QString(recv_conn_%1).arg(m_socketDescriptor); QSqlDatabase db QSqlDatabase::addDatabase(QSQLITE, connName); db.setDatabaseName(m_dbPath); if (!db.open()) { qWarning() 打开数据库失败: db.lastError().text(); return; } QSqlQuery query(db); query.prepare(INSERT INTO file_records (file_name, file_size) VALUES (?, ?)); query.addBindValue(fileName); query.addBindValue(fileSize); query.exec(); db.close(); QSqlDatabase::removeDatabase(connName); }这里addDatabase第二个参数指定连接别名避免覆盖默认连接。removeDatabase必须在连接关闭后调用否则 Qt 会在程序退出时打印连接泄漏警告。prepare和addBindValue的另一个作用是避免文件名里的单引号把 SQL 拼坏。4.3 Widget 端用 QSqlTableModel 刷新表格datamodel.h 承接了界面展示逻辑最直接的方案是QSqlTableModel直接绑定到file_records表。代码m_model new QSqlTableModel(this, db); m_model-setTable(file_records); m_model-setEditStrategy(QSqlTableModel::OnManualSubmit); m_model-select(); ui-tableView-setModel(m_model);setTable指定模型绑定的表select()执行SELECT * FROM file_records。OnManualSubmit策略表示只有调用submitAll()才写库适合纯展示场景。每次新文件接收完成只需调用一次m_model-select()刷新。参数对比表编辑策略行为适用场景OnManualSubmit手动提交才生效只读列表OnRowChange行切换时提交单行编辑OnFieldChange字段修改即提交实时修改5. QSS 美化与客户端打包myStyleSheetB 与 windeployqt5.1 在 Client 工程里加载 QSS 皮肤项目里有myStyleSheetB.qss和myresource.qrc说明样式表被放进了 Qt 资源系统。加载 QSS 的常规做法是在 main.cpp 里创建QApplication后调用QFile qssFile(:/styles/myStyleSheetB.qss); if (qssFile.open(QFile::ReadOnly)) { qApp-setStyleSheet(QString::fromUtf8(qssFile.readAll())); qssFile.close(); }:/styles/前缀来自 qrc 文件里的file项。假设 qrc 内容把myStyleSheetB.qss注册为别名:/styles/myStyleSheetB.qss那上面代码就能读到。常见坑是 qrc 里路径写错程序不报错但界面没有任何样式排查时先打开 qrc 确认前缀和文件名是否一致。5.2 windeployqt 打包与运行依赖客户端在开发机跑没问题拷到别的机器缺 DLL 是最常见的。Qt 官方提供的windeployqt工具能自动拷贝依赖库。以 Windows MSVC 为例进入 release 构建目录后执行cd build-Client-Desktop_Qt_5_15_2_MSVC2019_64bit-Release D:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe --release --no-angle --no-opengl-sw Client.exe命令里的--release告诉工具部署 release 版依赖避免拷出 debug 版 DLL。--no-angle和--no-opengl-sw过滤掉用不到的系统库减少体积。部署完成后exe 同级目录下会出现platforms、styles、imageformats等文件夹。如果目标机器是裸机还需要装 Microsoft Visual C Redistributable因为 MSVC 编译的 exe 依赖 VCRUNTIME 运行库。5.3 运行时报错 QT_QPA_PLATFORM_PLUGIN_PATH 的处理部署后双击 exe 如果弹出 could not find or load the Qt platform plugin windows十有八九是platforms/qwindows.dll没找到或者环境变量指向了别的 Qt 安装路径。网上搜索这个问题时常看到有人把环境变量QT_QPA_PLATFORM_PLUGIN_PATH设成D:\Qt\5.15.2\msvc2019_64\plugins\platforms这只是临时绕过手段。真正原因要看 exe 同级的platforms目录是否被拷贝完整以及 exe 是否在PATH系统路径之外被直接双击启动。排错顺序先确认windeployqt成功再看platforms/qwindows.dll存在最后检查杀毒软件有没有隔离 DLL。QT_QPA_PLATFORM_PLUGIN_PATH环境变量适合开发期调试不适合作为最终交付方案因为不同编译套件的插件不能混用。比如用 MinGW 编译的 Qt 程序就不该指向 MSVC 2019 的插件目录。6. 传输完整性校验与性能优化的三个细节6.1 用 MD5 给文件收尾增加校验原来的传输流程只保证字节到达不能确认文件没变化。我一般会在文件头发送一段 MD5 摘要接收端在SocketThread写完后重新计算。代码QCryptographicHash hash(QCryptographicHash::Md5); QFile file(m_saveDir / m_fileName); if (file.open(QIODevice::ReadOnly)) { while (!file.atEnd()) { hash.addData(file.read(1 * 1024 * 1024)); } file.close(); } QString actual QString::fromLatin1(hash.result().toHex()); if (actual ! m_expectedMd5) { qWarning() 文件校验失败; }MD5 对网络误码检测足够接口安全校验建议换成QCryptographicHash::Sha256。计算时按 1MB 分段读取避免一次性把大文件读进内存。6.2 关闭 Nagle 算法与调整 socket 缓冲区传输大量小包时Nagle 算法会把多个小包合并发送增加延迟。在发送文件头这种小数据包前可以设置socket-setSocketOption(QAbstractSocket::LowDelayOption, 1); socket-setSocketOption(QAbstractSocket::SendBufferSizeSocketOption, 256 * 1024);LowDelayOption等同于关闭 Nagle 算法适合小包多的交互协议。大文件传输时作用不大因为分块大小本身已经超过 MSS。SendBufferSizeSocketOption和ReceiveBufferSizeSocketOption可以提升吞吐但值不是越大越好超过内核默认上限会触发警告。6.3 把本地写盘改成 QSaveFile 原子写接收线程里的QFile直接写入一旦程序中途崩溃会留下半个文件。QSaveFile先写临时文件commit()时才原子替换目标文件避免覆盖完整文件。写盘时数据长度较大QFile::write本身有缓存不需要每次写完都flush等所有块写完再关闭文件即可。把这些改动放到SocketThread后通过任务管理器观察网卡和磁盘占用率就能判断瓶颈是网络带宽还是磁盘顺序写性能再决定是否增大 64KB 缓冲区。本文还有配套的精品资源点击获取