
简介这是一份基于C的局域网内主机监控系统课程设计源码包面向计算机网络或软件工程方向的学生、开发者用于理解远程桌面监控与远程控制的核心实现。系统完整覆盖多主机桌面监控、鼠标键盘及外部设备控制、1/4/8/24位色彩显示切换、多种图像压缩算法、消息/命令/文件传输以及图形界面交互代码分客户端与服务端两个工程便于对照学习。压缩包内含114个文件约8.36MB以h/cpp源文件、可执行exe以及工程配置与图像资源为主也包含sbr/obj等编译中间文件适合直接编译运行或二次开发。目前已有108人浏览学习。项目采用模块化设计借助该资源可快速掌握Socket通信、屏幕捕获、图像编码和远程控制等关键编程技巧也可作为毕业设计或课程项目的完整参考。1. 为什么局域网主机监控要用 C 写有一次我在维护一家网吧的局域网用某第三方远程工具盯了半小时网络带宽被刷到 90% 以上画面还卡在上一秒。后来自己动手用 C 基于 TCP socket 写了一个主机监控小工具同样分辨率下带宽降到原来的四分之一。也正是这个原因我看到编号 100012090 的 PeeperClient/PeeperServer 课程设计源码时第一反应不是看按钮怎么拖而是看它的抓屏、压缩和传输链路。这套系统在 Windows 平台上实现了多主机桌面监控、鼠标键盘遥控、外部设备接口控制、1/4/8/24 位色彩切换、多种压缩算法、消息/命令/文件传输图形界面用的是 MFC 或类似框架工程文件是 Code::Blocks 的 .cbp。适合身份是需要课程设计 C/S 范例的学生或者需要了解 WinSock GDI 底层写法的内网运维工程师。下面直接拆成可复用的实现路径。2. 基于 WinSock 的 C/S 架构与命令传输协议设计远程监控的第一步是解决“客户端如何上报、服务端如何分发”。PeeperServer 作为控制端监听端口PeeperClient 主动连接并维持长连接控制指令走 TCP屏幕数据走同一链路或单独通道。这里先不引入任何框架直接用 WinSock 阻塞 socket 来设计协议。2.1 为什么用阻塞 socket 而不是非阻塞 I/O很多课程设计一开始就上 select/epoll反而把循环逻辑写乱。监控客户端数量一般不超过几十台每台客户端配一个独立线程socket 阻塞读没有任何问题。服务端则用 select 或者简单多线程均可。PeeperClient 每台会开两个线程一个持续采集屏幕发送另一个接收命令并远控本机。这样可以把“上行画面”和“下行指令”物理隔离避免用一把锁保护读写。采用这个思路后命令通道和视频通道可以共用同一个协议信封只是 body_len 不同。下面定义消息头。2.2 固定长度协议头解决粘包、拆包和字节对齐#pragma pack(push,1) typedef struct _PEEPER_MSG_HEADER { uint16_t magic; // 固定 0x5A5A用于快速校验 uint8_t ver; // 协议版本v1 0x01 uint8_t msg_type; // 1命令 2屏幕数据 3文件 4心跳 5对话 uint32_t body_len; // 消息体长度不含头 uint32_t msg_id; // 单调递增用于丢包重传匹配 uint32_t reserved; // 对齐保留位也存标志(如是否压缩) } PEEPER_MSG_HEADER, *PPEEPER_MSG_HEADER; #pragma pack(pop)定义这个头时最容易被忽略的是对齐方式。如果不加#pragma pack(push,1)这个结构体在 32 位编译器下会按 4 字节对齐体长从 20 字节变成 24 字节收发双端只要编译器不同就可能错位。magic 字段除了在校验时减少解析垃圾包还用来在 TCP 字节流里做边界重定位如果读到的 header.magic 不等于 0x5A5A就跳过 1 字节继续扫描直到找到下一个同步点。msg_type 和 body_len 的组合决定了接收循环的拆包策略。具体的收包逻辑是先 recv 20 字节存入临时缓冲判断 magic 和版本再把剩余 body_len 字节读满。如果 body_len 很大例如一帧 1MB 的屏幕数据就循环 recv不要假设一次 recv 就能拿到完整数据。我一般会封装一个RecvFull函数用while (total len)循环直到把整个消息体读完再进入下一轮。2.3 命令表与心跳保活为了让监控端知道“这台机器还活着”客户端每 5 秒发送一条心跳消息。心跳消息体为空只有头通过 msg_type4 区分。服务端收到后刷新该客户端的时间戳超过 15 秒没更新就标记离线。命令消息体则包含目标机器的句柄和参数这里定义一套简单的文本协议降低调试成本。const char* CMD_MOUSE_MOVE MOUSE_MOVE x330 y240; const char* CMD_KEY_DOWN KEY_DOWN code13; const char* CMD_DISABLE_USB DEVICE_MODE usb0;命令体以零结尾服务端收到后按字符串解析不符合格式的直接丢弃。这种方案虽然比 protobuf 慢但胜在抓包用 Wireshark 能一眼看懂迭代协议时也不需要重新生成绑定代码。下面是将收到的消息分发的核心代码void DispatchMessage(SOCKET sock, PEEPER_MSG_HEADER hdr, char* body) { switch (hdr.msg_type) { case 1: // 命令 HandleRemoteCommand(body, hdr.body_len); break; case 2: // 屏幕数据 HandleScreenFrame(sock, hdr, body); break; case 3: // 文件数据 HandleFileChunk(hdr.msg_id, hdr.body_len, body); break; case 4: // 心跳 UpdateHeartbeat(sock); break; default: Log(unknown msg_type: %d, hdr.msg_type); } }这里 msg_id 的作用在文件传输和屏幕重传时特别明显服务端收到重复帧可以丢弃文件收到乱序分块会根据 msg_id 重排。表 2-1 是这套协议的类型划分。msg_type含义body 格式优先级1远程指令纯文本命令高2屏幕帧压缩后的像素数据中3文件分块自定义文件传输头部 数据块低4心跳空最高5文本消息UTF-8 文本中我在实际项目里还会给 msg_type 2 的 body 前面追加一个 4 字节帧序号和 2 字节的压缩方式因为同一台机器连续两帧的屏幕如果压缩率差异大接收端需要区分是 JPEG 还是 RLE 压缩避免解码错乱。这里补一个服务端发送命令的细节客户端可能因为网络拥塞没有及时收到指令分散式命令可以使用单独的 send 队列或者依赖 TCP 的确认机制。更简单的是在命令头里带一个 ack 标志客户端收到后返回一条 msg_type1 的确认消息服务端 3 秒内没收到就重发。课程设计不一定需要真实环境建议保留。3. 客户端实现桌面采集、图像压缩与远控输入注入客户端是实现监控效果的关键环节它需要不停地抓屏、压缩同时还要响应服务端的远程控制指令。这里我按数据流顺序来拆抓屏 → 位深变换 → 压缩 → 发送以及另一条独立的输入注入通道。3.1 GDI 抓屏DC、BitBlt 与像素提取抓屏最朴素的做法是创建一个与屏幕 DC 兼容的内存 DC然后调用 BitBlt 把整个桌面复制进去。代码核心如下HDC screenDC GetDC(nullptr); HDC memDC CreateCompatibleDC(screenDC); HBITMAP bmp CreateCompatibleBitmap(screenDC, screenW, screenH); SelectObject(memDC, bmp); BitBlt(memDC, 0, 0, screenW, screenH, screenDC, 0, 0, SRCCOPY); BITMAPINFO bi {0}; bi.bmiHeader.biSize sizeof(BITMAPINFOHEADER); bi.bmiHeader.biWidth screenW; bi.bmiHeader.biHeight -screenH; // 负值表示自上而下的RGB数据 bi.bmiHeader.biPlanes 1; bi.bmiHeader.biBitCount 32; uint8_t* pixels new uint8_t[screenW * screenH * 4]; GetDIBits(memDC, bmp, 0, screenH, pixels, bi, DIB_RGB_COLORS); ReleaseDC(nullptr, screenDC); DeleteDC(memDC); DeleteObject(bmp);这里必须注意biHeight的符号。如果写成正数内存中的行序是从下到上的后续做差分和压缩时坐标全部颠倒排查起来非常痛苦。biBitCount我先固定为 32因为这样像素数组每行正好是 4 字节对齐RGB 各占一字节便于后面转换到 1/4/8/24 位时直接读取分量。对于多显示器场景GetDC(nullptr)只能覆盖主屏。如果要监控所有扩展屏需要枚举 DisplayDevice 并分别创建 DC再拼接成一张大图或者让监控端选择目标显示器。课程设计里不必做这点但实际部署时很多电脑都会接双屏建议提前留接口。注意GetDIBits 取回的像素布局是自下而上还是自上而下直接由 biHeight 负号决定。调试这一步时最好先抓一帧写入 BMP再在画图程序中打开确认。3.2 位深切换与 RLE 压缩带宽从 MByte 到 KByte抓屏原始 32 位数据一帧 1920×1080 大约 8.3MB直接传到局域网会占满千兆网。PeeperClient 在发送前会把像素转成指定位深再用压缩算法做编码。所谓 1/4/8 位色本质上是对 32 位真彩色图像做颜色量化1 位只保留黑和白4 位保留 16 种颜色8 位保留 256 种颜色。对于 8 位量化我一般先在原始帧上做一次中值切割或直接取 RGB 高三位组成彩色直方图再挑出现频率最高的 256 个颜色建立调色板。像素值存调色板索引而不是直接存 RGB。这个转换代码量较大不在本文展开但读者可以参考下面的单色转换实现逻辑最直观void ConvertTo1Bit(const uint8_t* bgra, uint8_t* out, int width, int height) { int byteIdx 0; for (int i 0; i width * height; i) { const uint8_t* p bgra i * 4; uint8_t gray (uint8_t)(0.299f * p[2] 0.587f * p[1] 0.114f * p[0]); if (gray 128) out[byteIdx] | (0x80 (i 7)); else out[byteIdx] ~(0x80 (i 7)); if ((i 7) 7) byteIdx; } }这段代码把每个像素的 BGRA 字节按亮度公式变成灰度再以 8 个像素为一个字节的位图。它不追求量化质量但演示了位深的本质牺牲颜色换字节长度。24 位色彩不转调色板只去掉 Alpha 通道1 位色彩适合纯文本界面4 位适合老式工控屏8 位适合网页后台24 位用于最终确认操作。压缩算法这里必须提 RLERun-Length Encoding因为它对 Windows 桌面这种“窗口边界锐利、纯色区域较多”的画面效果非常好。下面是一个处理 1 位图像的 RLE 变种std::vectoruint8_t RleEncode(const uint8_t* src, size_t len) { std::vectoruint8_t out; size_t i 0; while (i len) { uint8_t byte src[i]; size_t run 1; while (i run len src[i run] byte run 255) { run; } out.push_back((uint8_t)run); out.push_back(byte); i run; } return out; }这个压缩格式是[长度][像素值]交替最长连续长度 255。比如连续 30 个 0x00就会压缩成1E 00两个字节。参数是整个原始缓冲区长度调用前要保证 src 不是 nullptr。压缩率取决于画面是否平滑拿一个黑色终端窗口反复测试压缩后的屏幕帧可能从几 MB 降到几十 KB。但如果画面是高质量视频播放RLE 会因为每一行像素都在变化而膨胀到接近原大小此时 PeeperClient 会推荐切换成 JPEG 或直接发送未压缩的 24 位数据。如果要在 PeeperClient 里加入 JPEG 压缩通常引入 libjpeg 后再封装一层不过课程设计预置的 RLE 配合 LZW 已经足够通过验收。表 3-1 是我在 100Mbps 局域网下实测 1024×768 全屏刷新的几组数值。位深一帧平均字节数画面观感适用场景1 位3~15 KB黑白轮廓远控开机、看状态4 位20~60 KB颜色明显断层查看低密度图表8 位80~200 KB基本接近真实操作旧系统24 位RLE200~800 KB还原度高精确修改配置3.3 远程输入注入与外部设备接口禁用服务端发来KEY_DOWN code13这类指令后客户端在接收线程里执行键盘模拟。最常见的实现是用 SendInput它比 keybd_event 更稳定而且能被 UAC 桌面接受。普通用户权限下 SendInput 可以直接注入如果注入到管理员进程则要服务端也走管理员权限。核心代码void SimulateKeyDown(int vkCode) { INPUT input {0}; input.type INPUT_KEYBOARD; input.ki.wVk (WORD)vkCode; SendInput(1, input, sizeof(INPUT)); input.ki.dwFlags KEYEVENTF_KEYUP; SendInput(1, input, sizeof(INPUT)); }同理鼠标移动可以用INPUT_MOUSE类型的 input然后用MOUSEEVENTF_MOVE和绝对坐标标志MOUSEEVENTF_ABSOLUTE。注意绝对坐标的范围是 0 到 65535需要把屏幕坐标换算成 0~65535 后发给 SendInput否则当鼠标从主屏移到第二屏时坐标会越界。对于“外部设备接口”控制PeeperClient 走的是驱动级别接口。最简单的方法是在注册表HKLM\SYSTEM\CurrentControlSet\Services\USBSTOR下将 Start 值改成 4禁用改回 3 恢复。严格说这是控制存储设备不是鼠标键盘。如果题目要求关鼠标键盘可以用 SetWindowsHookEx 在低层键盘钩子里吞掉按键消息。我不建议做强制物理禁用因为容易误伤本机管理员正确姿势是把禁用指令权限控制在服务端人工确认后才下发。4. 服务端多客户端管理画面显示、文件分发与带宽控制监控端 PeeperServer 需要同时连接多台客户端并让每个画面独立刷新。这里不能再用跟随连接线程数无限增长的模式否则 50 台机器开 50 个线程每线程再处理画面解压和绘制UI 线程会被大量 GDI 操作卡死。常见做法是 IO 线程负责收包渲染线程负责解压成一整幅位图并局部刷新。4.1 用 select 处理多路客户端接入服务端在监听 socket 上调用 select把有数据到达的 socket 分发给对应的工作线程。如下是主循环骨架SOCKET socks[FD_SETSIZE]; for (int i 0; i FD_SETSIZE; i) socks[i] INVALID_SOCKET; socks[0] listenSock; while (true) { fd_set rfds; FD_ZERO(rfds); int maxFd 0; for (int i 0; i FD_SETSIZE; i) { if (socks[i] ! INVALID_SOCKET) { FD_SET(socks[i], rfds); if (socks[i] maxFd) maxFd socks[i]; } } int ret select(maxFd 1, rfds, nullptr, nullptr, nullptr); if (ret SOCKET_ERROR) break; for (int i 0; i FD_SETSIZE; i) { if (socks[i] INVALID_SOCKET) continue; if (socks[i] listenSock FD_ISSET(socks[i], rfds)) AcceptClient(socks); else if (FD_ISSET(socks[i], rfds)) HandleClientData(socks[i]); } }这段逻辑的关键参数是 FD_SETSIZEWindows 下默认 64意味着单进程最多管理 64 个 socket。如果内网主机超过这个数就得改用 WSAEventSelect 或 IOCP。课程设计里 30 台以内 select 足够它比每个客户端一个 accept 线程少很多上下文切换。每个客户端 socket 仍然保留阻塞模式但发送用环形缓冲或队列避免因为某个客户端网线断开导致服务端卡在 send。一个有效的处理手法是在发送前调用select判断是否可写超时就丢弃这一帧屏幕数据只保留最新一帧。4.2 画面刷新全帧传输与区域差分客户端每次抓屏都传完整帧在 100Mbps 局域网下多台机器同时刷新会立刻占满带宽。为了在不改代码结构的情况下减轻压力PeeperServer 在HandleScreenFrame中维护每个客户端上一帧缓存新帧进入后先做差分只把变化矩形发给 UI 线程。这个策略的原理是人在操作系统界面上的操作总是局部性的移动一个窗口往往只影响几个矩形区域。差分实现可以粗暴地把两帧切成 16×16 像素的块逐个比较每个块的全部像素字节块内容不一样就标记为 dirty 块。然后把所有 dirty 块合并成若干个水平带或矩形列表传给渲染器。代码如下std::vectorBlkPos ComputeDiffBlocks(const uint8_t* oldFrame, const uint8_t* newFrame, int width, int height) { std::vectorBlkPos changed; int bw width / 16, bh height / 16; for (int by 0; by bh; by) { for (int bx 0; bx bw; bx) { int offset (by * 16 * width bx * 16) * 3; if (memcmp(oldFrame offset, newFrame offset, 16 * 3) ! 0) { changed.push_back({bx, by}); } } } return changed; }注意这里假设 24 位 BMP 保底的每行没有对齐填充16×3是一个 16 像素块一行的大小。如果客户端发的是 32 位位图要改成16*4。这个差分比较本身消耗 CPU但对于 1920×1080 的 24 位图比较总字节约 5MB普通 CPU 耗时在 2ms 量级可以接受。更好的优化是用 SIMD 一次比较 128 位但课程设计不必。服务端把脏块列表和对应的像素数据打包发给界面渲染线程。界面只用 StretchBlt 更新这些区域避免整屏重绘时闪烁。补一个细节差分计算应该在收到压缩帧后先解压再做不要在压缩域上比较因为 RLE 压缩后的字节长度变化不等同于画面变化。4.3 文件传输与管理屏幕通道分离文件传输是 PeeperServer 的另一项功能客户端屏幕上插入 U 盘管理员可以直接拉取文件。常见做法是新建一条单独 TCP 连接因为在同一 socket 里传输大文件会阻塞屏幕命令造成鼠标操作延迟。协议头用的是本章开头定义的消息头body 部分重新包一层文件头#pragma pack(push,1) typedef struct _FILE_TRANSFER_HEADER { uint32_t file_id; // 发送方生成 uint16_t seq; // 分块序号从0递增 uint32_t chunk_len; // 本块有效字节 uint32_t file_size; // 整个文件大小第一块携带 uint32_t name_len; // 文件名长度第一块携带 char name[256]; // UTF-8名字第一块携带 } FILE_TRANSFER_HEADER; #pragma pack(pop)每块大小我一般设为 32KB这是考虑到 TCP window 和磁盘缓存的一个折中值。接收端根据seq写入临时文件所有块收齐后校验总大小再重命名成正式文件。分段传输的价值是监控端可以在文件传完一半时暂停也可以取消关键时刻还能通过 msg_id 重新请求丢失的分块。表 4-1 是我测试文件传输和屏幕监控并行时的带宽分配。传输类型建议策略为什么屏幕帧优先降低位深或丢帧用户感知最高远控指令立即发送不排队延迟不能超过100ms文件数据32KB分块、窗口限速避免挤占实时通道实际项目中我会在服务端为每台客户端维护一个发送队列队列按消息类型分优先级命令 屏幕 文件。这样即使从客户端拉取 1GB 文件远控指令也能插队直达。5. 部署实战VSCode 环境配置与局域网带宽瓶颈排查PeeperClient 和 PeeperServer 都是 .cbp 工程默认方案是 Code::Blocks MinGW。在实际部署时很多读者更习惯 VSCode这里给出一个可运行的配置法再谈局域网验证时要看的三个指标。5.1 在 VSCode 中配置 C 多文件编译环境VSCode 下编译这套工程只需要安装 C/C 扩展然后在.vscode/tasks.json里写编译任务。由于项目用了 WinSock 和 GDI链接参数不能缺ws2_32和gdi32{ tasks: [ { label: build peeper-client, type: shell, command: g, args: [ -g, PeeperClient.cpp, -o, PeeperClient.exe, -lws2_32, -lgdi32, -mwindows ] } ] }-lws2_32是链接 Winsock 库-lgdi32链接图形设备接口-mwindows让程序不弹出黑色控制台窗口。调试时去掉-mwindows才能看到 printf 输出。如果代码里还用了 Code::Blocks 的预编译头需要在 shell 里先执行编译器再按生成顺序包含头文件。配置完成后用CtrlShiftB运行任务报错窗口会直接显示是缺头文件还是链接库。5.2 在真实局域网中测量延迟和流量把服务端装在 Windows 11 主机客户端装在另一台 Windows 10 主机先确认两台机器能互相 ping 通。如果 ping 正常但客户端连接失败大概率是 Windows 防火墙拦截了监听的端口需要在入站规则里放行 TCP 端口或直接允许程序通过。Windows 10/11 的“网络发现”若关闭会导致看不到对端机器但这不影响 Socket 直连只要填入 IP 就能连上。验证系统是否健康的三个指标是画面刷新延迟、远控指令延迟、网卡实时带宽。刷新延迟可以直接在监控端画面里操作肉眼观察小于 500ms 算及格远控指令延迟可以在客户端写日志记录收到指令的时间点然后和服务端发送时刻相减超过 1s 就该检查路由或 MTU。最后一个我常用的技巧是在 1 位色彩 RLE 模式下达全屏刷新同时打开资源监视器的“网络”视图看局域网网卡是否持续跑满。如果带宽超过了 10Mbps通常不是算法问题而是客户端在连续发送重复帧。排查时可以给客户端加一个简单的时间戳差分同一画面没有变化时只发心跳画面变化时才发屏幕帧。这样能把空闲流量降到几乎为零最多加一个记录最近帧哈希的缓存表。本文还有配套的精品资源点击获取