简介:用Visual C++开发远程控制软件的源码包,适合需要学习Windows网络编程和系统控制原理的初学者及中级开发者,能够帮助快速搭建基于TCP/IP的远程控制原型。压缩包内共30个文件,其中10个头文件与8个C++源代码文件构成了程序主体,负责接口声明与具体功能实现;其余的图标、对话框资源以及工程文件支撑起整个项目的界面和编译体系,整套源码仅37KB,体积小巧,非常便于逐行阅读和深入调试。源码中“获取信息”模块演示了通过套接字请求来获取远程计算机系统状态与硬件配置的完整方法,“登录验证”部分则涉及用户登录会话和权限管理,体现了远程控制场景下对安全性的关注;同时还可看到图形设备接口截屏、键盘鼠标事件模拟、多线程并发处理以及数据加密等关键技术点。整体代码结构清晰,将网络传输、控制指令和界面资源进行分层组织,方便使用者按模块复用或二次扩展。目前已有687人学习下载,这份源码框架不仅能让学习者快速理解远程控制底层的实现思路,也为后续自主开发提供了现成且可改写的模板,性价比高,值得研究。
1. 拿到 VC++ 远程控制源码后,先想清楚这三件事
看到 “visual c++ vc编写远程控制软件源码” 这个标题,我第一反应是:这不就是十几年前那批“远控工具”的经典套路?但别急着往灰色方向想。现在公司机房维护服务器、给同事远程排除演示机故障、在家操作实验室那台没接显示器的工控机,这类需求依然很硬。用 visual c++ 写远程控制,最可靠的方案不是去啃什么高大上的框架,而是 Winsock 通信 + MFC/对话框界面 + 管道调命令行,这套组合在局域网管理场景里比上全套物联网平台轻得多。动手读源码前先确认三件事:源码跑在哪个 Windows 版本、被控端要不要常驻、控制端和被控端之间是同一局域网还是跨网段。这三件事定了,后面读代码、改功能才不会迷路。
2. 骨架:先用 Winsock 把双向链路跑通
2.1 为什么优先 Winsock + 阻塞多线程,而不是 CAsyncSocket
很多人拿到 VC++ 远程控制源码,一看里面有 MFC,第一个念头就是把 CAsyncSocket 搬出来用,理由是网络事件到了会自动回调,不用自己开线程。实际一跑就露馅:回调发生在系统的工作线程里,你直接在回调里弹一个对话框,界面线程没准备好,程序要么闪退要么消息乱序。我的习惯是 Winsock 裸接口 + 监听线程 + 每连接一个工作线程,UI 只接收工作线程 PostMessage 过来的通知。逻辑是直线的,出错看调用栈就知道卡在哪一行。
阻塞模型对远程控制这种“一问一答”为主的交互足够用,吞吐量根本不是瓶颈。WSAStartup 负责初始化 Winsock,socket 创建套接字,bind 固定端口,listen 进入监听状态,accept 接收新连接,recv/send 收发数据。控制端只需 socket + connect,比服务端少三个步骤。下面两段代码分别是被控端监听和控制端连接的最小实现,可以直接照着搭。
2.2 被控端监听循环:核心代码与逐行说明
// 被控端:初始化 Winsock 并开始监听 #include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib") #define CTRL_PORT 5566 SOCKET StartListen() { WSADATA ws; WSAStartup(MAKEWORD(2, 2), &ws); // 1. 初始化,必须最先调用 SOCKET listenSock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSock == INVALID_SOCKET) { return INVALID_SOCKET; } SOCKADDR_IN addr; addr.sin_family = AF_INET; addr.sin_port = htons(CTRL_PORT); // 2. 端口转网络字节序 addr.sin_addr.s_addr = htonl(INADDR_ANY); // 3. 绑定本机所有网卡 if (bind(listenSock, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(listenSock); return INVALID_SOCKET; } if (listen(listenSock, SOMAXCONN) == SOCKET_ERROR) { // 4. 开始监听 closesocket(listenSock); return INVALID_SOCKET; } return listenSock; }注意 bind 成功后可以立刻 setsockopt 打开 SO_REUSEADDR,方便你反复重启被控端调试时端口不被 TIME_WAIT 占住。listen 的第二个参数是内核帮排队的连接数量,局域网场景直接给 SOMAXCONN 就好。这段代码里我没写 accept,留给服务端线程循环里调用,accept 返回一个新的 socket,后续收发都走这个新 socket,listenSock 继续等下一路连接。
2.3 控制端连接与简单握手
// 控制端:连接被控端,并验证握手口令(示意) SOCKET ConnectToHost(const char* ip, const char* password) { WSADATA ws; WSAStartup(MAKEWORD(2, 2), &ws); SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); SOCKADDR_IN srv; srv.sin_family = AF_INET; srv.sin_port = htons(5566); inet_pton(AF_INET, ip, &srv.sin_addr); // 点分十进制转 in_addr if (connect(sock, (SOCKADDR*)&srv, sizeof(srv)) == SOCKET_ERROR) { closesocket(sock); return INVALID_SOCKET; } // 握手:先把固定长度的口令发给被控端 send(sock, password, strlen(password) + 1, 0); char ack[16] = {0}; recv(sock, ack, sizeof(ack), 0); // 等被控端确认 if (strcmp(ack, "OK") != 0) { closesocket(sock); return INVALID_SOCKET; } return sock; }握手的作用不是高安全,而是避免局域网里有人猜个 IP 把你的被控端连走。口令先写死在源码里,后期再改成配置项。inet_pton 这个函数在老 VC6 里没有,如果你手里的源码包是 VC6 工程,建议换用较新版本的 visual studio 打开,socket 部分调用的是平台 API,源码改动面积很小,只把 inet_pton 换成 inet_addr 即可。send 和 recv 的返回值这里没做长度循环,下一章讲协议时会补上,这是代码能跑和能稳定跑的分水岭。
提示:开发阶段 Windows 防火墙会拦截入站连接,先在防火墙设置里放行 5566 端口,或者在被控端弹窗时点允许。交付前记得收敛规则,别留下全校广播用的开放端口。
3. 指令协议:控制端和被控端靠什么对齐
3.1 三段式包结构:Magic + Type + Length
现在两端能连通了,但如果直接裸 send 一段字符串“dir”或者“screenshot”,收方很容易拆错。TCP 是字节流,不保证一次 recv 就收到完整一包。在远程控制源码里,我习惯定义一个固定头部:魔数(Magic)、指令类型(Type)、载荷长度(Length)。
#pragma pack(push, 1) typedef struct _PKT_HEADER { DWORD magic; // 固定 0x5A5A, 用来过滤错包 DWORD type; // 指令类型: 1=Shell, 2=Screen, 3=File DWORD length; // 载荷长度, 不包含头部自身 } PKT_HEADER; #pragma pack(pop)头部固定 12 字节,所有字段用本机字节序还是网络字节序?只在你自己的两端之间传输,本机字节序省事;但如果你将来要写一个 C# 控制端或者接入其他语言,建议发送前统一 htonl,接收后再转回来。pack(1) 字节对齐是为了让结构体大小可预测,否则编译器按 4 字节对齐后结构体可能变成 16 字节,两端定义不一致直接导致解析错位。
3.2 粘包和半包:recv 的正确姿势
远程控制源码里最容易翻车的就是收数据。一次 recv 可能只收到半个包,也可能一次收到两个包,必须靠循环收够指定长度,再用头部里的 length 字段切出完整一包。
// 完整接收 n 字节,处理半包 int recvn(SOCKET s, char* buf, int n) { int got = 0; while (got < n) { int ret = recv(s, buf + got, n - got, 0); if (ret == 0) return -1; // 对端关闭连接 if (ret == SOCKET_ERROR) return -1; got += ret; } return got; } // 收一包:先收头,再收体 BOOL RecvPacket(SOCKET s, PKT_HEADER* hdr, char** payload) { if (recvn(s, (char*)hdr, sizeof(PKT_HEADER)) <= 0) return FALSE; if (hdr->magic != 0x5A5A) return FALSE; // 魔数不对,丢弃 if (hdr->length > MAX_PAYLOAD_SIZE) return FALSE; // 防恶意长度 *payload = new char[hdr->length + 1]; (*payload)[hdr->length] = '\0'; if (recvn(s, *payload, hdr->length) <= 0) { delete[] *payload; return FALSE; } return TRUE; }核心逻辑就是 while (got < n),recv 单次返回长度小于请求长度是正常的,尤其包大或者网络有波动,必须循环收。粘包则靠“先收足头部、再收足载荷”天然消化,因为下一次调用 recvn 会从下一个包边界开始。MAX_PAYLOAD_SIZE 我一般设成 8MB,防止被控端发来一个异常长度把内存吃爆。这两个函数是后面所有功能模块的地基,先运行一遍再往下接功能。
3.3 指令分发:一条连接上跑多个功能
协议定了,主循环就不难写:
// 被控端主循环:收包 -> 分发 -> 应答 while (RecvPacket(sock, &hdr, &payload)) { switch (hdr.type) { case CMD_SHELL: HandleShell(sock, payload, hdr.length); break; case CMD_SCREEN: HandleScreen(sock); break; case CMD_FILE: HandleFile(sock, payload, hdr.length); break; default: break; } delete[] payload; }循环里每处理完一包,建议 Sleep(1) 让出时间片,不然被控端单核老机器上 CPU 占用直接拉满。屏幕抓取这类耗时指令,控制端一次只发一个请求就好,不要做成多指令交错流水线,否则协议要加 seq 序号和 ACK 队列,复杂度直接翻倍。远程控制这个定位,一问一答最稳。指令名和常量对应关系写在源码头部注释里,方便两个端同步维护。
4. 往源码里塞功能:Shell、截屏、传文件的三段实现
4.1 远程 Shell:CreatePipe 接管 cmd 的标准输入输出
远程控制最常用的功能就是让控制端能敲命令。常见做法是启动一个 cmd.exe,把它的 stdin 和 stdout 重定向到管道,控制端发命令进去,读输出回来。
void HandleShell(SOCKET sock) { HANDLE hReadPipe = NULL, hWritePipe = NULL; SECURITY_ATTRIBUTES sa = { sizeof(SECURITY_ATTRIBUTES), NULL, TRUE }; CreatePipe(&hReadPipe, &hWritePipe, &sa, 0); STARTUPINFOA si = { sizeof(si) }; si.hStdOutput = hWritePipe; // cmd 输出进管道写端 si.hStdError = hWritePipe; // 错误输出也走同一条管道 si.hStdInput = hReadPipe; // 键盘输入从管道读端来 si.dwFlags = STARTF_USESTDHANDLES; PROCESS_INFORMATION pi; BOOL ok = CreateProcessA(NULL, "cmd.exe /Q", NULL, NULL, TRUE, CREATE_NO_WINDOW, NULL, NULL, &si, &pi); if (!ok) { // 创建失败,返回错误码给控制端 return; } // 之后开一个线程: 循环 ReadFile(hReadPipe) 拿到输出, 打包发给 sock // 再开一个线程: 循环 recv(sock) 拿到命令, WriteFile(hWritePipe) }两个坑必须提前避开。第一,管道缓冲区只有 64KB,cmd 跑 dir 输出一大坨时管道会满,cmd 就僵住,所以读管道必须单独开线程,边读边发。第二,CREATE_NO_WINDOW 标志让控制台窗口不弹出来,被控端用户看不到黑框;但用了这个标志后,cmd 里执行的交互式命令(比如需要按 y 确认的 del)会不好处理,初版远程 Shell 只接受非交互命令比较省心。CreateProcess 第一个参数传 NULL 时,第二个参数的命令行必须以可执行名开头,写 "cmd.exe /Q" 而不是 " cmd.exe /Q",否则返回 87 号参数错误,这是老源码注释里经常被一笔带过的坑。
4.2 屏幕抓取:BitBlt 抓屏再压 JPEG
远程看屏幕是另一个高频功能。被控端用 BitBlt 把整块屏幕复制到位图,转成 JPEG 再发过去。
void HandleScreen(SOCKET sock) { int w = GetSystemMetrics(SM_CXSCREEN); int h = GetSystemMetrics(SM_CYSCREEN); HDC hScreen = GetDC(NULL); HDC hMem = CreateCompatibleDC(hScreen); HBITMAP hBmp = CreateCompatibleBitmap(hScreen, w, h); SelectObject(hMem, hBmp); BOOL ok = BitBlt(hMem, 0, 0, w, h, hScreen, 0, 0, SRCCOPY); if (ok) { // 转 JPEG: 把 HBITMAP 转成 DIB 数据,交给 GDI+ 编码 // 质量参数设 70,一帧约 100~300KB // 组装成 CMD_SCREEN_DATA 包发给控制端 } DeleteObject(hBmp); DeleteDC(hMem); ReleaseDC(NULL, hScreen); }BitBlt 抓到的是设备相关位图,直接裸传一张 1920x1080 的 BMP 是 8MB 级别,局域网线速传输也吃力。常见做法是抓完立刻转 JPEG,质量 70 左右足够看清文字。帧率控制在每秒 2~5 帧,远程看点按钮、看个报错信息不需要 60 帧;刷新率太高不只是带宽问题,被控端 CPU 占用会难看得离谱。如果只需要看某个窗口,可以用 GetWindowDC 替代 GetDC(NULL),抓指定窗口区域,帧率可以提上去不少。
4.3 文件传输:分块 + 校验,注意方向
传输文件时最怕发到一半连接断了,两端都不知道传了多少。
#define FILE_CHUNK (64 * 1024) void SendFileBegin(SOCKET sock, const char* path) { // 先发文件名和总大小元数据,然后循环发内容 HANDLE f = CreateFileA(path, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, 0, NULL); if (f == INVALID_HANDLE_VALUE) return; char buf[FILE_CHUNK]; DWORD n = 0; while (ReadFile(f, buf, FILE_CHUNK, &n, NULL) && n > 0) { // 每 block 发一个 CMD_FILE_DATA 包,等 ACK 再发下一块 if (SendPacketAndWaitAck(sock, CMD_FILE_DATA, buf, n) == FALSE) { break; } n = 0; } CloseHandle(f); }64KB 一块,走的是“发一块等一个 ACK”的停止等待协议。局域网延迟只有 1ms 级别,吞吐损失可以忽略;好处是任何一块丢了,重传位置很好定位。断点续传看起来体面,但要在控制端维护每个文件的偏移量、校验和、剩余块列表,源码工程量翻倍,初版不建议上。另一个方向是从控制端往被控端发文件,逻辑对称,注意收端要先创建目录、再写入文件,文件名用宽字符处理中文路径。
5. 避坑记录:远程控制代码在 Windows 上常见的 5 个翻车现场
5.1 界面假死:MFC 消息循环与阻塞收发打架
现象:控制端一按“截屏”按钮,窗口转圈,拖一下标题栏就白屏。
原因:把 recvn 循环写在了按钮的 BN_CLICKED 处理函数里,UI 线程在阻塞等包,消息循环转不动了。
解决:按钮处理函数只负责往工作线程里塞任务,工作线程完成后 PostMessage 回 UI 线程更新画面。网络线程绝不能直接读写控件,这是 VC++ 远程控制源码里最常见的改造点。
5.2 被控端空闲一段时间就“断连”
现象:什么都不操作,两三分钟后再发指令没反应,抓包发现连接已经 RST。
原因:Windows 电源策略或中间设备把闲置连接关了,而源码里没有心跳机制。
解决:被控端每 5 秒发一个心跳包,控制端连续 3 个心跳没收到就判定连接断开并重连。心跳包在协议里给独立 type=0,和功能指令分开处理。
5.3 传大文件时其他指令排队卡住
现象:传一个 2GB 镜像期间,再发 Shell 指令十几秒才有回应。
原因:一条 TCP 连接上数据流串行,文件块把连接占满了。
解决:把文件传输拆到第二条独立连接,控制指令继续走原连接。初版如果不拆连接,也要在控制端 UI 上把“文件传输中”状态显示清楚,避免使用者以为程序挂了。
5.4 截屏黑块:会话 0 隔离和安全桌面
现象:被控端锁屏或弹出 UAC 授权窗口时,截屏回来是黑块或者只剩壁纸。
原因:安全桌面和会话隔离机制,普通 BitBlt 拿不到高权限窗口内容。
解决:检测被控端当前会话状态,发现前台窗口属于安全桌面或者会话 ID 为 0,直接返回“屏幕当前不可见”的明文提示,比发一帧黑图让使用方困惑好得多。合盖笔记本截屏也是常见黑屏源,不要在电源选项里强制关屏。
5.5 中文乱码且不定时触发
现象:远程执行 dir 命令,文件名的中文变成“????”。
原因:cmd 管道输出按系统代码页(GBK)编码,而控制端界面按 Unicode 直接解析。
解决:读管道拿到的字节先按本地代码页转成 UTF-8 再打包。工程里统一写一个 ToUtf8(buf, len) 包装函数,所有文本型输出都过它。还有一个玄学点:cmd 切代码页是滞后生效的,命令下发后 Sleep 200ms 再读输出,乱码会少很多。
提示:第 5.4 条和第 5.5 条是“环境相关”问题,换机器复现不出来很正常。调试时把系统版本、屏幕状态、代码页一起写进日志,比瞎猜高效得多。
6. 验证与进阶:跑通后先把日志和自启动管控做好
6.1 日志:远程控制的第一等公民
void LogEvent(const char* fmt, ...) { // 本地写文件, 每次会话一行: 时间, 指令类型, 对端IP, 结果 FILE* f = fopen("ctrl_audit.log", "a"); if (f) { // 拼时间戳 + 格式化字符串 fputs(line, f); fclose(f); } }日志是我的强迫症。远程控制天然敏感,被控端每次收到的指令都应该有据可查:谁在什么时候连过、发过什么命令、传输过哪个文件。调试时它是救命稻草,部署后它是合规凭据。建议在连接建立、指令分发、文件传输完成这三个节点各打一条日志,字段统一,别随手写。
6.2 开机自启别做成静默自启
HKEY key = NULL; RegOpenKeyExA(HKEY_CURRENT_USER, "Software\\Microsoft\\Windows\\CurrentVersion\\Run", 0, KEY_SET_VALUE, &key); RegSetValueExA(key, "RemoteControlAgent", 0, REG_SZ, (BYTE*)exePath, strlen(exePath)); RegCloseKey(key);这是写 HKCU Run 键实现当前用户开机自启的标准做法。但自启功能不能偷偷来:部署脚本里要显式声明“该程序会开机自启”,并在设置界面留一个“取消自启动”按钮,调用 RegDeleteValueA 删除刚才的键。很多翻车现场就是自启装上之后找不到清除入口,被控端用户只能手动去注册表删,体验极差。
6.3 验证方法:两台虚拟机跑一遍全链路
最小可用版本的验证步骤:两台虚拟机同一网段,被控端防火墙放行端口,控制端命令行带 IP 启动。依次验证:连接握手 → 远程 Shell 执行 dir → 截屏一帧 → 传一个小文件 → 断开重连。全链路通了你再扩展协议加新指令。每加一个功能,我习惯先把旧功能回归一遍,远程控制这玩意儿改一处挂三处的概率特别高。日志开着,命令一条条发,等返回,看时间戳——这三个习惯救过我很多次,希望帮到你。
本文还有配套的精品资源,点击获取