简介:这是一份基于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_type=4 区分。服务端收到后刷新该客户端的时间戳,超过 15 秒没更新就标记离线。命令消息体则包含目标机器的句柄和参数,这里定义一套简单的文本协议,降低调试成本。
const char* CMD_MOUSE_MOVE = "MOUSE_MOVE x=330 y=240"; const char* CMD_KEY_DOWN = "KEY_DOWN code=13"; const char* CMD_DISABLE_USB = "DEVICE_MODE usb=0";命令体以零结尾,服务端收到后按字符串解析,不符合格式的直接丢弃。这种方案虽然比 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_type=1 的确认消息,服务端 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 位用于最终确认操作。
压缩算法这里必须提 RLE(Run-Length Encoding),因为它对 Windows 桌面这种“窗口边界锐利、纯色区域较多”的画面效果非常好。下面是一个处理 1 位图像的 RLE 变种:
std::vector<uint8_t> RleEncode(const uint8_t* src, size_t len) { std::vector<uint8_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 位+RLE | 200~800 KB | 还原度高 | 精确修改配置 |
3.3 远程输入注入与外部设备接口禁用
服务端发来KEY_DOWN code=13这类指令后,客户端在接收线程里执行键盘模拟。最常见的实现是用 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_SETSIZE,Windows 下默认 64,意味着单进程最多管理 64 个 socket。如果内网主机超过这个数,就得改用 WSAEventSelect 或 IOCP。课程设计里 30 台以内 select 足够,它比每个客户端一个 accept 线程少很多上下文切换。
每个客户端 socket 仍然保留阻塞模式,但发送用环形缓冲或队列,避免因为某个客户端网线断开导致服务端卡在 send。一个有效的处理手法是在发送前调用select判断是否可写,超时就丢弃这一帧屏幕数据,只保留最新一帧。
4.2 画面刷新:全帧传输与区域差分
客户端每次抓屏都传完整帧,在 100Mbps 局域网下多台机器同时刷新会立刻占满带宽。为了在不改代码结构的情况下减轻压力,PeeperServer 在HandleScreenFrame中维护每个客户端上一帧缓存,新帧进入后先做差分,只把变化矩形发给 UI 线程。这个策略的原理是:人在操作系统界面上的操作总是局部性的,移动一个窗口往往只影响几个矩形区域。
差分实现可以粗暴地把两帧切成 16×16 像素的块,逐个比较每个块的全部像素字节,块内容不一样就标记为 dirty 块。然后把所有 dirty 块合并成若干个水平带或矩形列表,传给渲染器。代码如下:
std::vector<BlkPos> ComputeDiffBlocks(const uint8_t* oldFrame, const uint8_t* newFrame, int width, int height) { std::vector<BlkPos> 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 里先执行编译器,再按生成顺序包含头文件。配置完成后用Ctrl+Shift+B运行任务,报错窗口会直接显示是缺头文件还是链接库。
5.2 在真实局域网中测量延迟和流量
把服务端装在 Windows 11 主机,客户端装在另一台 Windows 10 主机,先确认两台机器能互相 ping 通。如果 ping 正常但客户端连接失败,大概率是 Windows 防火墙拦截了监听的端口,需要在入站规则里放行 TCP 端口,或直接允许程序通过。Windows 10/11 的“网络发现”若关闭,会导致看不到对端机器,但这不影响 Socket 直连,只要填入 IP 就能连上。
验证系统是否健康的三个指标是:画面刷新延迟、远控指令延迟、网卡实时带宽。刷新延迟可以直接在监控端画面里操作,肉眼观察小于 500ms 算及格;远控指令延迟可以在客户端写日志记录收到指令的时间点,然后和服务端发送时刻相减,超过 1s 就该检查路由或 MTU。
最后一个我常用的技巧是:在 1 位色彩 + RLE 模式下达全屏刷新,同时打开资源监视器的“网络”视图,看局域网网卡是否持续跑满。如果带宽超过了 10Mbps,通常不是算法问题,而是客户端在连续发送重复帧。排查时可以给客户端加一个简单的时间戳差分:同一画面没有变化时只发心跳,画面变化时才发屏幕帧。这样能把空闲流量降到几乎为零,最多加一个记录最近帧哈希的缓存表。
本文还有配套的精品资源,点击获取