用C++实现局域网主机监控:协议选型与无代理探测实战
2026/9/16 2:14:34 网站建设 项目流程

简介:基于C++实现的局域网内主机监控系统,是一份面向C++课程设计与网络编程训练的完整工程。系统具备多主机桌面实时监控、远程控制鼠标/键盘与外设接口的能力,同时提供1/4/8/24位色彩显示及多种图像压缩算法,并集成消息传输、命令传输、文件传输和图形化操作界面,功能覆盖监控交互主要环节。压缩包共114个文件,包含13个cpp与15个h源代码文件、工程配置与可执行程序,另有界面位图、图标、PDF说明等辅助材料,整体大小约8.36MB。已有108人次学习浏览。资源中客户端与服务器端工程结构完整,打包文件区分清晰,便于运行演示或二次开发,适合学习Socket通信、多线程、图像处理及远程控制原理的学生参考,也可作为毕业设计或求职项目的基础模板。

1. 用C++写局域网主机监控,图的不是性能,而是可控和底盘

做局域网主机监控,用Python或Shell也能搭出能跑的版本,但当目标网络里混杂着服役多年的Windows 7、旧Linux服务器、网络打印机和无agent能力的摄像头时,最先崩掉的不是采集逻辑,而是运行环境:目标机器缺库、缺解释器,或者运维团队根本不敢往生产内网里放一个带依赖的采集端。C++版本的核心价值是把采集端编成一个单文件可执行程序,扔到目标机器上解压即跑,同时把ICMP、SNMP、TCP探活这些协议差异收敛到一个适配层里。这套系统的落地形态通常覆盖三类需求:资产盘点时自动发现内网在线设备、日常巡检时盯着主机存活和关键端口、断电断网时快速定位是交换机还是末端设备失联。对五年以上工程师来说,更值得读的是在线判定策略和超时参数背后的取舍。

2. 先把监控协议选对:ICMP、SNMP、ARP和TCP探测各管哪一段

2.1 主机监控系统里协议选型的三个约束

选协议之前先回答三个问题:目标设备允不允许装agent,监控器与目标之间隔不隔三层,以及你要的只是在线状态还是CPU、内存这类真实指标。允许装agent就优先用主动上报,但现实里大量网络打印机、老交换机、摄像头根本没有装agent的能力,无代理探测因此成为局域网主机监控的标配。

我一般会把探活结果分成三层记录:ARP/ICMP负责主机层可达性,TCP connect负责服务层连通性,SNMP负责指标层数据。告警阈值建立在这三层之上,而不是只盯一条ping命令的结果。三层模型的好处是排障时可以直接告诉值班人员“主机在线但服务端口不通”,而不是一个笼统的掉线。

2.2 ICMP探活:最廉价但误判率最高的在线判断

ICMP不需要目标机装任何东西,发一个echo request就能确认网络栈活着,成本在所有协议里最低。误判率也最高:不少办公网的Windows防火墙默认不响应ping,老式网络设备在高流量时段还会刻意丢弃ICMP报文以保护CPU,离线列表跟着假阳性刷屏是常态,也是最容易让监控系统失去信任的环节。

运维日志里最常见的现象是连续ping四次只有一次超时,单独拿这一次超时去判离线,误报率会高到没法用。所以ICMP只适合做初筛和资产发现,状态迁移必须让下一轮探测来确认。常见做法是把超时设在1500到3000毫秒,并且连续两轮失败再迁移到离线状态。算法层面的优化点不在这,重试策略和超时设计才是决定稳定性的地方。对于服役超过五年的老设备,建议放宽超时而不是单纯提高重试次数,因为低端设备的协议栈处理慢,频繁重试反而加剧它的负担。

2.3 SNMP:真正能拿指标的协议,代价是开启成本

SNMP走UDP 161端口,用community字符串做最低限度的访问控制,返回的不是“活着没”,而是接口流量、CPU占用、内存余量、系统版本。Linux下开snmpd只改一个配置文件,Windows要在“启用或关闭Windows功能”里勾上SNMP服务,再配置团体名和允许访问的源地址。这个门槛决定了无agent场景下SNMP通常先覆盖服务器和网络设备,而不是办公终端。

C++侧走net-snmp时,有三个参数容易搞混:snmp_timeout是单次请求超时,snmp_retries是重试次数,两者乘积才是单个目标最坏情况下的等待时间。很多人把retries设成默认的5,内网目标一多,SNMP轮次会比TCP探活慢一个数量级。常见做法是retries压到2,timeout保持1000到1500毫秒,性价比最高。SNMP的community字符串不要用public,即使在内网也要单独设一个读字符串,并绑定监控服务器的源IP。

2.4 ARP探活与TCP端口探测的适用边界

ARP只能在同一广播域内工作,对整个子网发request,拿到回包说明二层可达,适合资产盘点阶段快速扫网段。跨网段必须经过三层网关,所以多网段网络得一段一段扫,而且部分安全设备会抑制ARP扫描,结果偏保守。ARP不适合做持续监控,广播量在几百台规模下会挤压正常业务流量,我一般只在每天凌晨跑一次全量发现。

TCP connect比ICMP更接近业务实际。一台文件服务器ICMP通但445端口握手失败,说明服务停了或防火墙策略变了;反过来445通而ping不通的情况也很常见。最终在线认定应该以关键端口为准。局域网里常用探活端口是22、80、443、3389、445,配合内网自己起的文件传输页面、打印共享和业务Web服务,再按实际端口补进去。探活对象的选取原则是:只选目标上必定开放且防火墙放行的端口,宁少勿多。

2.5 用适配器模式封装协议差异

四种协议的信息粒度、端口和成本都不一样,探测逻辑不能散落在监控主流程里。常见做法是定义探测基类,ICMP、SNMP、TCP各自实现一个派生类,配置里用probe_type字符串选择,主流程完全不感知协议细节。

enum class HostStatus { Unknown, Online, Offline }; struct HostTarget { std::string ip; int port = 80; std::string community; // 仅 SNMP 探测使用 int timeout_ms = 2000; }; class IProbe { public: virtual ~IProbe() = default; virtual HostStatus check(const HostTarget& target) const = 0; virtual const char* name() const = 0; }; class TcpConnectProbe : public IProbe { public: HostStatus check(const HostTarget& target) const override { int fd = socket(AF_INET, SOCK_STREAM, 0); bool ok = nonblocking_connect(fd, target, target.timeout_ms); close(fd); return ok ? HostStatus::Online : HostStatus::Offline; } const char* name() const override { return "tcp_connect"; } };

代码说明:check的语义是同步阻塞直到得到结果,内部超时由target.timeout_ms控制;不阻塞主流程,因为调用方在worker线程里。name()用在日志和配置文件里,运维侧看到probe_type=tcp_connect就能定位到TcpConnectProbe这个实现。

各协议选型对比如下,这个表可以直接写进方案文档:

探测方式依赖条件能确认的结论不能确认的结论主要代价
ICMP echo目标允许回显网络栈在线服务是否可访问防火墙丢弃导致误报
SNMP get目标开启snmpd且community正确CPU/内存/接口流量等指标应用层是否健康老设备兼容性差
ARP request同一广播域二层可达跨网段存活状态广播压力
TCP connect目标端口对外开放特定服务在线服务内部健康度目标侧留连接日志

表格里的四行基本覆盖了无agent场景下能用的全部手段。实际落地时很少只用一种协议,通常TCP连不上时回退到ICMP,ICMP也不通再回退到ARP,按这个顺序把探测结果逐层降级,最后综合出一个可信状态。

3. 用非阻塞socket和多线程轮询撑起采集骨架

3.1 选select还是epoll:局域网规模下的实际差异

几百到几千台设备的局域网,连接数和事件量根本到不了select的上限。select单进程默认可监视1024个fd,Linux上可以改内核或换poll,但都不是重点。我选epoll不是因为高并发,而是它的水平触发语义和“一批socket一次回收”的轮询模式匹配得很好。

典型的事件循环骨架长这样:

struct epoll_event ev, events[64]; int epfd = epoll_create1(0); ev.events = EPOLLIN; ev.data.fd = listen_fd; epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev); while (running) { int n = epoll_wait(epfd, events, 64, 1000); if (n == 0) { // 超时醒来,执行周期性的离线状态检查 run_scheduled_tasks(); continue; } for (int i = 0; i < n; i++) { handle_event(events[i].data.fd); } }

epoll_wait的timeout设成1000毫秒,每秒醒一次处理定时任务,就不需要额外维护一个定时器线程。这里要留意的是n==0并不能说明网络空闲,它只是表示这一秒没有新事件;定时任务仍然要独立记录上次执行时间,避免因为事件频繁触发而饿死。

3.2 非阻塞connect里最容易错的三处

TCP探活最怕connect卡在握手阶段,所以必须非阻塞。第一处易错:connect返回-1且errno等于EINPROGRESS,这只是表示连接还在继续,不是失败。第二处易错:select之后不能看了“可写”就默认成功,因为对端RST时socket同样可写,必须用getsockopt读SO_ERROR判断真实结果。第三处易错:select超时要基于单调时钟重新计算,Linux的select调用后tv会被改写,Windows反而不会改,依赖返回值写出来的代码在两个平台行为不一致。

bool nonblocking_connect(int fd, const HostTarget& target, int timeout_ms) { sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(static_cast<uint16_t>(target.port)); inet_pton(AF_INET, target.ip.c_str(), &addr.sin_addr); int flags = fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); int rc = connect(fd, reinterpret_cast<sockaddr*>(&addr), sizeof(addr)); if (rc == 0) return true; if (errno != EINPROGRESS) return false; timeval tv{}; tv.tv_sec = timeout_ms / 1000; tv.tv_usec = (timeout_ms % 1000) * 1000; fd_set wfds; FD_ZERO(&wfds); FD_SET(fd, &wfds); rc = select(fd + 1, nullptr, &wfds, nullptr, &tv); if (rc <= 0) return false; int err = 0; socklen_t len = sizeof(err); if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &err, &len) < 0) return false; return err == 0; }

参数说明:timeout_ms建议在1500到3000之间。对同一目标上的多端口探活,timeout可以并发执行而不是相加;socket用完直接close,没必要恢复阻塞标志。getsockopt返回0且err为0才算连接建立,如果err是ECONNREFUSED,目标主机的网络栈是活的,但端口没人监听,这个状态要保留给上层做服务级告警,不能简单当成主机离线。

3.3 线程池与扫描轮次:不要让一次慢探测拖垮整轮

设400台设备、每台超时2秒,串行探测一轮就是800秒,中途任何状态变化都反映不进来。常见做法是把目标列表按IP排序切成片,每片一个worker线程,线程数配成参数而不是用hardware_concurrency自动推导,因为容器里看到的核数是宿主机的,自动推导会被严重误导。

线程数通常取4到8,同一IP的多个端口放在同一个worker里,避免两台目标机争抢同一个fd池。三种探测要错峰启动:ARP全量扫描5分钟一轮,SNMP采集1分钟一轮,TCP探活30秒一轮,启动时刻加一个0到5秒的随机偏移,防止同时突刺把交换机的CPU打断。线程池里的任务获取用条件变量或环形队列都可以,局域网监控场景下无锁队列带来的收益可以忽略,代码简洁更重要。

3.4 结果落盘:攒批还是逐条写

几十台设备顺序写SQLite足够,几百台设备还要保留历史曲线时,SQLite的写放大就会拖慢采集线程。常见做法是内存里保留最新状态快照,状态变更以批量事务刷进SQLite,历史曲线单独交给时间序列库。这样采集线程对磁盘的依赖降到最低,崩了也不丢当前快照。

sqlite3_stmt* stmt = nullptr; sqlite3_prepare_v2(db, "INSERT INTO host_snapshot(ip, status, latency_ms, ts) " "VALUES(?1, ?2, ?3, strftime('%s','now'));", -1, &stmt, nullptr); sqlite3_exec(db, "BEGIN", nullptr, nullptr, nullptr); for (const auto& rec : snapshot) { sqlite3_bind_int(stmt, 1, rec.ip_int); sqlite3_bind_int(stmt, 2, static_cast<int>(rec.status)); sqlite3_bind_int(stmt, 3, rec.latency_ms); if (sqlite3_step(stmt) != SQLITE_DONE) { // 记录错误,继续处理下一条 } sqlite3_reset(stmt); } sqlite3_exec(db, "COMMIT", nullptr, nullptr, nullptr); sqlite3_finalize(stmt);

说明:一条事务包含几百条INSERT时,提交时间远小于逐条autocommit;latency_ms用int保存,采样精度到毫秒就够用。批量写入的批次上限建议控制在1000条以内,避免单次事务持锁时间过长挡着查询端。

4. 实战:在Linux下把采集端编出来并接到Web页面

4.1 编译环境与最小依赖

开发环境用VSCode配g++,tasks.json里能直接复用下面这行编译命令。SNMP支持依赖net-snmp开发库,机器上没装就把probe_snmp.cpp排除掉,其余模块依然能编译运行。

g++ -std=c++17 -O2 -Wall -pthread \ main.cpp probe_tcp.cpp probe_icmp.cpp store.cpp \ -o hostmon

逻辑说明:-O2对网络程序足够了,-O3不会带来可感知的提升;-pthread必须显式加,否则链接pthread库时会有undefined reference。Linux部署时把这个二进制拷过去就能跑,这就是C++做采集端最省心的地方,不需要在目标机器上准备任何解释器或运行时。

4.2 从/proc里读系统指标:CPU、内存、负载

无agent探测拿不到目标机的CPU和内存,但监控机自己也要被监控,或者可以在目标机上放一个agent版采集端。对于能读/proc的Linux目标,CPU利用率最容易算错,因为/proc/stat里是累计值,必须两次采样做差。

struct CpuStat { unsigned long long idle, total; }; bool read_cpu_stat(CpuStat& out) { std::ifstream f("/proc/stat"); std::string line; if (!std::getline(f, line)) return false; std::istringstream ss(line); std::string tag; unsigned long long user, nice, system, idle, iowait; unsigned long long irq, softirq, steal; ss >> tag >> user >> nice >> system >> idle >> iowait >> irq >> softirq >> steal; out.idle = idle + iowait; out.total = user + nice + system + idle + iowait + irq + softirq + steal; return true; } double cpu_usage_percent() { CpuStat a, b; if (!read_cpu_stat(a)) return -1.0; std::this_thread::sleep_for(std::chrono::milliseconds(500)); if (!read_cpu_stat(b)) return -1.0; unsigned long long dt = b.total - a.total; if (dt == 0) return 0.0; return 100.0 * (1.0 - static_cast<double>(b.idle - a.idle) / static_cast<double>(dt)); }

说明:500毫秒采样间隔在低负载机器上已经能稳定出数值,间隔更短会看到明显噪声。iowait算进idle是Linux对CPU“空闲但等待I/O”的定义,想单独观察I/O压力就分别取idle和iowait;dt等于0说明两次读取发生在同一个时钟节拍内,直接返回0比除零安全。

内存指标里MemAvailable比MemFree更接近应用可用内存,因为它考虑了page cache可回收的部分。用MemTotal减MemAvailable做分子,直接看MemFree会高估使用率,这是新手上路最容易踩的偏差。

4.3 用最简单的HTTP接口把状态暴露给浏览器

采集端要能自证“我看到的和你看到的一样”,挂一个只读HTTP接口最省事。手拼JSON的好处是不引入框架,生产环境换成cpp-httplib这类单头文件库,差别只是代码量。

std::string make_json(const std::vector<HostRecord>& records) { std::ostringstream oss; oss << "{\"hosts\":["; bool first = true; for (const auto& r : records) { if (!first) oss << ","; first = false; oss << "{\"ip\":\"" << r.ip << "\"," << "\"status\":" << static_cast<int>(r.status) << "," << "\"latency_ms\":" << r.latency_ms << "}"; } oss << "]}"; return oss.str(); }

HTTP响应要用write一次性写完整包,Content-Length必须和body字节数严格一致,哪怕差一个换行,浏览器解析JSON也会失败。局域网内直接访问http://监控机IP:8080/hosts就能看到结果,前端页面用fetch轮询,或者把这份JSON接到内网已有的资产页面上,不需要独立的UI体系。

4.4 三个必调的监控参数

采集端跑起来后,真正决定监控系统能不能用的就是后面这几个参数:

参数建议值依据
probe_interval30s配合连续两轮失败判定,离线确认延迟控制在1分钟以内
connect_timeout_ms1500办公网正常RTT在1到20毫秒,1500毫秒足够宽
offline_confirm_rounds2抵消单次丢包带来的假阳性
scan_threads4到8不要依赖hardware_concurrency,容器里不准确

如果做的是agent模式,probe_interval可以压到5秒,因为agent主动上报不消耗交换机CPU,也不需要每次建连握手。参数改动后至少跑满一个离线/恢复周期再评估效果,只跑几分钟就下结论,每次的调整都会变成随机调参。

4.5 编译、权限和探测不到目标时的排错顺序

编译报错先看是不是缺net-snmp头文件,没有SNMP需求直接去掉probe_snmp.cpp。运行报permission denied,基本是ICMP raw socket需要root或cap_net_raw权限,用sudo跑或setcap给二进制授权。探测不到目标时先在本机手动ping和nc -zv验证,再查监控机与目标之间的防火墙策略。

Windows主机“网络发现”开着但探活不到,网络位置类型多半是公用网络,公用档位下防火墙默认丢弃所有探测报文,切到专用网络即可解决。内网部分机器能扫到部分扫不到,优先怀疑交换机端口隔离或VLAN划分,而不是监控程序本身。排查顺序固定下来以后,大多数问题五分钟内能定位。

5. 进阶:跨平台适配与误报压制的三个技巧

5.1 Windows下的ICMP探测用IcmpSendEcho

Linux下ICMP探测要开raw socket,Windows上更省事的做法是直接调IcmpSendEcho,它来自iphlpapi.dll,不需要额外权限:

#include <winsock2.h> #include <iphlpapi.h> #pragma comment(lib, "iphlpapi.lib") HANDLE hIcmp = IcmpCreateFile(); char send_buf[32] = {0}; char reply_buf[sizeof(ICMP_ECHO_REPLY) + 32] = {0}; DWORD result = IcmpSendEcho( hIcmp, inet_addr("192.168.1.10"), send_buf, sizeof(send_buf), nullptr, reply_buf, sizeof(reply_buf), 1500); bool online = (result != 0); IcmpCloseHandle(hIcmp);

参数说明:最后一个1500是超时毫秒,result为0说明没有回显应答;要区分“目标不可达”和“超时”,读ICMP_ECHO_REPLY里的Status字段。Windows部署包要带上对应架构的Visual C++ Redistributable,否则老Windows机器上会直接弹缺DLL。TCP探活部分在Windows上几乎不用改,把非阻塞connect的错误码从errno换成WSAGetLastError即可。

5.2 误报压制的三个技巧

第一,多轮确认。状态迁移设计成confirmed_online、suspect_offline、confirmed_offline三态,至少连续两轮失败才判离线,恢复只需要一轮成功。这样交换机重启、局域网瞬时大流量造成的“ping四次有一次超时”不会抖成告警。

第二,同网段分批轮询。扫描不要对整段地址全量爆发,而是切成小段顺序执行,段间留几十毫秒间隔。交换机ARP表和路由器缓存不会被瞬间刷爆,上报网络设备的性能数据也更稳。

第三,端口权重判定。多端口探活时按权重判断,80、443、22这些关键端口全部存活才算在线;只有一个端口响应而其他端口全超时,先别急着判离线,更可能是服务进程异常或防火墙策略收紧了,这本身就是监控系统该暴露给运维的信息。

5.3 用构造故障来验收监控系统

搭建完成后,在受控机器上模拟一次真实掉线,比直接看界面有没有数据有价值得多。iptables丢包比直接关网卡更接近真实宕机的网络表现,而且不碰目标机的远程管理通道:

# 监控机上模拟目标主机断网 iptables -A INPUT -s 192.168.1.10 -j DROP # 观察状态迁移到离线后恢复 iptables -D INPUT -s 192.168.1.10 -j DROP

验收时固定记录两个数:状态迁移延迟和48小时内误报次数。状态迁移延迟应当约等于probe_interval乘以offline_confirm_rounds,明显偏大说明探测队列里有积压;偏小说明离线判定条件太宽松。误报占比超过3%,就回头检查是不是扫描周期太短、超时参数太紧或网段里有设备周期性休眠。把这两个数作为基线写进部署文档,后面每次调参都拿它对照,系统才算从“能跑”推到“敢用”。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询