文章目录
- [Qt | 基于 GVCP 协议的 GigE 工业相机 IP 配置工具](#Qt | 基于 GVCP 协议的 GigE 工业相机 IP 配置工具)
-
- 一、背景
- [二、前置知识:GigE Vision 与 GVCP](#二、前置知识:GigE Vision 与 GVCP)
- 三、整体流程
- 四、数据结构设计
-
- [4.1 相机信息 `CamInfo`](#4.1 相机信息
CamInfo) - [4.2 FORCEIP 命令包 `GVCP_FORCEIP_CMD`](#4.2 FORCEIP 命令包
GVCP_FORCEIP_CMD) - [4.3 厂商 0x8004 命令包 `GVCP_8004_CMD`](#4.3 厂商 0x8004 命令包
GVCP_8004_CMD)
- [4.1 相机信息 `CamInfo`](#4.1 相机信息
- 五、配置文件持久化:QSettings
-
- [5.1 加载](#5.1 加载)
- [5.2 实时保存](#5.2 实时保存)
- [六、核心一:广播枚举相机 `enumCam()`](#六、核心一:广播枚举相机
enumCam()) -
- [6.1 遍历本机网卡](#6.1 遍历本机网卡)
- [6.2 发送 DISCOVERY 广播](#6.2 发送 DISCOVERY 广播)
- [6.3 循环接收 ACK](#6.3 循环接收 ACK)
- [6.4 解析 DISCOVERY_ACK](#6.4 解析 DISCOVERY_ACK)
- [6.5 判断相机状态:不可达 / 被占用 / OK](#6.5 判断相机状态:不可达 / 被占用 / OK)
- [七、核心二:修改相机 IP `set8004Cmd()`](#七、核心二:修改相机 IP
set8004Cmd()) -
- [7.1 组包](#7.1 组包)
- [7.2 填写 MAC、IP、掩码、网关](#7.2 填写 MAC、IP、掩码、网关)
- [7.3 IP 冲突检查与发送](#7.3 IP 冲突检查与发送)
- [7.4 软复位让新 IP 生效](#7.4 软复位让新 IP 生效)
- [7.5 定时轮询确认结果](#7.5 定时轮询确认结果)
- 八、字节序:一个"写反"的技巧
- 九、踩坑记录与改进建议
-
- [9.1 配置文件不存在时程序会崩溃](#9.1 配置文件不存在时程序会崩溃)
- [9.2 MAC 地址格式化会丢掉前导零](#9.2 MAC 地址格式化会丢掉前导零)
- [9.3 `user_defined_name` 没有结束符](#9.3
user_defined_name没有结束符) - [9.4 网段判断只支持 3 种掩码](#9.4 网段判断只支持 3 种掩码)
- [9.5 读 CCP 失败时的状态会被覆盖](#9.5 读 CCP 失败时的状态会被覆盖)
- [9.6 "修改失败"的判定依赖扫描到相机](#9.6 "修改失败"的判定依赖扫描到相机)
- [9.7 其他](#9.7 其他)
- 十、总结
Qt | 基于 GVCP 协议的 GigE 工业相机 IP 配置工具
一、背景
在机器视觉项目里,GigE 工业相机出厂或者换了现场以后,它的 IP 常常和工控机网卡不在同一网段。这时候上位机 ping 不通相机,也打不开它。常见做法是装厂商自带的 IP 配置工具,但这类工具往往体积大、依赖多,也不方便集成到自己的软件里。
本文用 Qt + GVCP 协议 实现一个轻量的 IP 配置工具,功能包括:
- 在本机所有网卡上广播搜索 GigE 相机,获取型号、序列号、MAC、IP、子网掩码、网关、IP 模式、占用状态等信息;
- 通过 MAC 地址广播修改相机的静态 IP、子网掩码、网关,跨网段也能改;
- 改完后软复位 相机,并定时重新扫描,判断是否修改成功;
- 把用户填写的 IP 参数保存到 ini 配置文件,下次打开自动加载。
二、前置知识:GigE Vision 与 GVCP
GigE Vision 相机的控制协议叫 GVCP (GigE Vision Control Protocol,GigE Vision 控制协议),它基于 UDP,默认端口是 3956 (代码中的 GEV_GVCP_PORT)。
一个 GVCP 命令包由 8 字节头部 + 负载 组成:
| 字段 | 长度 | 说明 |
|---|---|---|
| magic | 1 字节 | 固定 0x42 |
| flag | 1 字节 | bit0 = 1 表示要求设备回 ACK |
| command | 2 字节 | 命令码,例如 0x0002 DISCOVERY、0x0004 FORCEIP |
| length | 2 字节 | 负载长度(不含头部) |
| req_id | 2 字节 | 请求 ID,用来匹配 ACK |
本工具用到的几条命令:
| 命令 | 命令码 | 作用 |
|---|---|---|
| DISCOVERY | 0x0002 |
广播发现设备,设备回复自己的网络信息 |
| FORCEIP | 0x0004 |
标准命令,按 MAC 强制设置 IP(临时生效,断电丢失) |
| 厂商自定义命令 | 0x8004 |
命令码最高位为 1,表示设备私有命令。本工具用它写入持久化静态 IP |
| WRITEREG / READREG | --- | 读写寄存器,例如 CCP 权限寄存器、软复位寄存器 |
注意:GVCP 所有多字节字段都是网络字节序(大端),而 x86 主机是小端。这一点贯穿整个代码,后面单独讲。
三、整体流程
#mermaid-svg-e6esxZtYcT2tfhVM{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-e6esxZtYcT2tfhVM .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-e6esxZtYcT2tfhVM .error-icon{fill:#552222;}#mermaid-svg-e6esxZtYcT2tfhVM .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-e6esxZtYcT2tfhVM .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-e6esxZtYcT2tfhVM .marker{fill:#333333;stroke:#333333;}#mermaid-svg-e6esxZtYcT2tfhVM .marker.cross{stroke:#333333;}#mermaid-svg-e6esxZtYcT2tfhVM svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-e6esxZtYcT2tfhVM p{margin:0;}#mermaid-svg-e6esxZtYcT2tfhVM .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-e6esxZtYcT2tfhVM .cluster-label text{fill:#333;}#mermaid-svg-e6esxZtYcT2tfhVM .cluster-label span{color:#333;}#mermaid-svg-e6esxZtYcT2tfhVM .cluster-label span p{background-color:transparent;}#mermaid-svg-e6esxZtYcT2tfhVM .label text,#mermaid-svg-e6esxZtYcT2tfhVM span{fill:#333;color:#333;}#mermaid-svg-e6esxZtYcT2tfhVM .node rect,#mermaid-svg-e6esxZtYcT2tfhVM .node circle,#mermaid-svg-e6esxZtYcT2tfhVM .node ellipse,#mermaid-svg-e6esxZtYcT2tfhVM .node polygon,#mermaid-svg-e6esxZtYcT2tfhVM .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-e6esxZtYcT2tfhVM .rough-node .label text,#mermaid-svg-e6esxZtYcT2tfhVM .node .label text,#mermaid-svg-e6esxZtYcT2tfhVM .image-shape .label,#mermaid-svg-e6esxZtYcT2tfhVM .icon-shape .label{text-anchor:middle;}#mermaid-svg-e6esxZtYcT2tfhVM .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-e6esxZtYcT2tfhVM .rough-node .label,#mermaid-svg-e6esxZtYcT2tfhVM .node .label,#mermaid-svg-e6esxZtYcT2tfhVM .image-shape .label,#mermaid-svg-e6esxZtYcT2tfhVM .icon-shape .label{text-align:center;}#mermaid-svg-e6esxZtYcT2tfhVM .node.clickable{cursor:pointer;}#mermaid-svg-e6esxZtYcT2tfhVM .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-e6esxZtYcT2tfhVM .arrowheadPath{fill:#333333;}#mermaid-svg-e6esxZtYcT2tfhVM .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-e6esxZtYcT2tfhVM .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-e6esxZtYcT2tfhVM .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e6esxZtYcT2tfhVM .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-e6esxZtYcT2tfhVM .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e6esxZtYcT2tfhVM .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-e6esxZtYcT2tfhVM .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-e6esxZtYcT2tfhVM .cluster text{fill:#333;}#mermaid-svg-e6esxZtYcT2tfhVM .cluster span{color:#333;}#mermaid-svg-e6esxZtYcT2tfhVM div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-e6esxZtYcT2tfhVM .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-e6esxZtYcT2tfhVM rect.text{fill:none;stroke-width:0;}#mermaid-svg-e6esxZtYcT2tfhVM .icon-shape,#mermaid-svg-e6esxZtYcT2tfhVM .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-e6esxZtYcT2tfhVM .icon-shape p,#mermaid-svg-e6esxZtYcT2tfhVM .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-e6esxZtYcT2tfhVM .icon-shape .label rect,#mermaid-svg-e6esxZtYcT2tfhVM .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-e6esxZtYcT2tfhVM .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-e6esxZtYcT2tfhVM .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-e6esxZtYcT2tfhVM :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
是
计数达到 9
程序启动
LoadIPConfigIni 加载 ipconfig.ini
InitConnect 绑定信号槽
点击 刷新
enumCam: 遍历本机 IPv4 网卡
广播 DISCOVERY
解析 DISCOVERY_ACK
填充 CamInfo 列表
RefreshCameraName 刷新下拉框
选择相机 + 填写 IP/掩码/网关
点击 发送: set8004Cmd
目标 IP 与主机 IP 冲突?
提示冲突
广播 0x8004 命令写入静态 IP
写 CCP 寄存器获取控制权
写 0x01000040 软复位
启动 10s 定时器循环 enumCam
扫描到新 IP?
修改 IP 成功
修改 IP 失败
四、数据结构设计
4.1 相机信息 CamInfo
cpp
struct CamInfo {
QString manufacturer; // 厂商
QString name; // 型号
QString userId; // 用户自定义名称
QString serialNumber; // 序列号
QString macAddress; // MAC 地址
QString status; // OK / 被占用 / 不可达
QString ipConfiguratior; // Static IP / DHCP / Auto IP
QString ipAddress; // 当前 IP
QString subnetMask; // 子网掩码
QString gateWay; // 网关
QString strHostIP; // 发现该相机的本机网卡 IP(字符串)
unsigned int hostIP; // 发现该相机的本机网卡 IP(主机字节序)
unsigned int ipSupportMode; // 支持的 IP 模式
};
每台相机用 QSharedPointer<CamInfo> 管理,存在 QList<QSharedPointer<CamInfo>> all_camera_info_ 中。hostIP 很关键:它记录了相机是从哪块网卡上被发现的,后续发修改命令时要从同一块网卡发出去。
4.2 FORCEIP 命令包 GVCP_FORCEIP_CMD
cpp
struct GVCP_FORCEIP_CMD {
unsigned char magic; // 0x42
unsigned char flag; // 0x01
unsigned short command; // 0x0004
unsigned short length; // 0x0038 = 56
unsigned short req_id;
unsigned short reserved0;
unsigned short mac_address_high;
unsigned int mac_address_low;
unsigned int reserved1, reserved2, reserved3;
unsigned int static_IP;
unsigned int reserved4, reserved5, reserved6;
unsigned int subnet_mask;
unsigned int reserved7, reserved8, reserved9;
unsigned int gate_way;
};
字段布局和 GigE Vision 标准的 FORCEIP 一致:IP、掩码、网关各占 16 字节,前 12 字节保留(给 IPv6 预留),后 4 字节才是 IPv4 地址。
4.3 厂商 0x8004 命令包 GVCP_8004_CMD
cpp
struct GVCP_8004_CMD {
unsigned char magic;
unsigned char flag;
unsigned short command; // 0x8004
unsigned short length; // 0x004C = 76
unsigned short req_id;
unsigned short reserved0;
unsigned short mac_address_high;
unsigned int mac_address_low;
unsigned int network_interface_configuration; // IP 模式配置
unsigned int reserved1, reserved2, reserved3;
unsigned int static_IP;
unsigned int reserved4, reserved5, reserved6;
unsigned int subnet_mask;
unsigned int reserved7, reserved8, reserved9;
unsigned int gate_way;
unsigned char use_id[16]; // 用户自定义名称
};
它比 FORCEIP 多了两项:
network_interface_configuration:对应 GigE Vision 的 Network Interface Configuration 寄存器(bit0 持久化 IP、bit1 DHCP、bit2 LLA),用来指定 IP 模式;use_id[16]:用户自定义名称。
负载长度:2+2+4+4+12+4+12+4+12+4+16 = 76 字节 = 0x4C ,和 length 字段对得上。
这些结构体按自然对齐刚好没有填充字节,所以可以直接
reinterpret_cast成字节流发送。为了保险,建议加上#pragma pack(push, 1),或者用static_assert(sizeof(GVCP_8004_CMD) == 84)做编译期检查。
五、配置文件持久化:QSettings
用户填写的 IP、掩码、网关保存在可执行程序目录下的 ipconfig.ini:
ini
[IPConfig]
IP=192.168.1.100
SubnetMask=255.255.255.0
GateWay=192.168.1.1
5.1 加载
cpp
bool IPConifg::LoadIPConfigIni()
{
QString fullFilename = QCoreApplication::applicationDirPath() + "/ipconfig.ini";
QFileInfo fileInfo(fullFilename);
if (!fileInfo.isFile())
return false;
configIniWrite = new QSettings(fullFilename, QSettings::IniFormat);
IP_qstr = configIniWrite->value("IPConfig/IP").toString();
SubnetMask_qstr = configIniWrite->value("IPConfig/SubnetMask").toString();
GateWay_qstr = configIniWrite->value("IPConfig/GateWay").toString();
ui.ip_lineEdit->setText(IP_qstr);
ui.subnet_mask_lineEdit->setText(SubnetMask_qstr);
ui.gate_way_lineEdit->setText(GateWay_qstr);
}
5.2 实时保存
输入框的 textChanged 信号连到写配置的槽函数,用户每改一个字符就写一次:
cpp
connect(ui.ip_lineEdit, &QLineEdit::textChanged, this, &IPConifg::SlotWriteIP2ConfigINI);
void IPConifg::SlotWriteIP2ConfigINI()
{
configIniWrite->setValue("IPConfig/IP", ui.ip_lineEdit->text());
}
QSettings 内部有缓存,会在合适的时机(或析构时)统一写回磁盘,所以频繁 setValue 不会频繁读写磁盘。
六、核心一:广播枚举相机 enumCam()
6.1 遍历本机网卡
cpp
QList<QHostAddress> allIp = QNetworkInterface::allAddresses();
for (auto ip : allIp)
{
// 跳过空地址、非 IPv4、回环地址
if (ip.isNull() || ip.protocol() != QAbstractSocket::IPv4Protocol || ip.isLoopback())
continue;
...
}
工控机通常有多块网卡,相机可能接在任意一块上,所以每块 IPv4 网卡都要单独发一次广播。
6.2 发送 DISCOVERY 广播
cpp
GVCP_CMD_HEADER cmdHeader = { GVCP_CMD::GVCP_CMD_DISCOVERY, 0, 0xffff };
port = std::make_unique<SocketPort>(
std::underlying_type<SocketAddr>::type(SocketAddr::SOCKET_ADDR_BROADCAST),
GEV_GVCP_PORT, // 3956
ip.toIPv4Address(), // 绑定到当前网卡
0, "Gev");
rtn = port->SendData(reinterpret_cast<const unsigned char*>(&cmdHeader), sizeof(cmdHeader));
SocketPort 是对 UDP socket 的封装:目标地址为 255.255.255.255,源地址绑定在指定网卡上。DISCOVERY 命令没有负载,只发 8 字节头部。
为什么用广播? 广播包不经过路由,只要相机和网卡在同一个物理链路上,不管它的 IP 在哪个网段,都能收到并回复。这正是"跨网段找相机"的关键。
6.3 循环接收 ACK
cpp
while (1)
{
rtn = port->RecvData(buff, buffSize, 1000); // 超时 1s
if (RESULT_IS_ERROR(rtn))
break; // 超时说明没有更多相机
if (rtn != sizeof(GVCP_ACK_HEADER) + sizeof(GVCP_DISCOVERY_ACK))
continue; // 长度不对,丢弃
GVCP_DISCOVERY_ACK* pAck =
reinterpret_cast<GVCP_DISCOVERY_ACK*>(buff + sizeof(GVCP_ACK_HEADER));
...
}
一个广播可能收到多台相机的回复,所以要循环 RecvData,直到 1 秒内收不到新包为止。
6.4 解析 DISCOVERY_ACK
标准 DISCOVERY_ACK 负载为 248 字节,主要字段如下:
| 字段 | 长度 | 说明 |
|---|---|---|
| device_mac_high / device_mac | 2 + 4 | MAC 地址 |
| ip_config_options | 4 | 支持的 IP 模式 |
| ip_config_current | 4 | 当前 IP 模式 |
| current_ip / current_subnet_mask / default_gateway | 4 × 3 | 当前网络参数 |
| manufacturer_name | 32 | 厂商 |
| model_name | 32 | 型号 |
| serial_number | 16 | 序列号 |
| user_defined_name | 16 | 用户自定义名称 |
字符串字段 :拷贝到多留 1 字节的缓冲区里,保证以 \0 结尾:
cpp
char name[GVCP_DISCOVERY_ACK_MODEL_NAME_LEN + 1] = { 0 };
memcpy(name, pAck->model_name, GVCP_DISCOVERY_ACK_MODEL_NAME_LEN);
pCamInfo->name = name;
网络参数:先转成主机字节序,再转成点分十进制字符串:
cpp
pCamInfo->ipAddress = NetHelper::Host2String(NetHelper::Net2HostInt32(pAck->current_ip)).c_str();
pCamInfo->subnetMask = NetHelper::Host2String(NetHelper::Net2HostInt32(pAck->current_subnet_mask)).c_str();
pCamInfo->gateWay = NetHelper::Host2String(NetHelper::Net2HostInt32(pAck->default_gateway)).c_str();
IP 模式:
cpp
unsigned int currentIpConfigure = NetHelper::Net2HostInt32(pAck->ip_config_current);
if (currentIpConfigure & 0x1) pCamInfo->ipConfiguratior = "Static IP";
else if (currentIpConfigure & 0x2) pCamInfo->ipConfiguratior = "DHCP";
else pCamInfo->ipConfiguratior = "Auto IP";
6.5 判断相机状态:不可达 / 被占用 / OK
- 网段判断:本机 IP 和相机 IP 分别和子网掩码做与运算,结果不同就说明不在同一网段,标记为"不可达";
- 占用判断 :在同一网段时,读取 CCP(Control Channel Privilege,控制通道权限)寄存器
0x0A00。如果已经有其他程序拿到了控制权或独占权,就标记为"被占用"。
cpp
unsigned int hostIPMask = pCamInfo->hostIP & gateIPMask;
unsigned int addrIPMask = NetHelper::Net2HostInt32(pAck->current_ip) & gateIPMask;
if (hostIPMask != addrIPMask)
{
pCamInfo->status = "不可达";
}
else
{
unsigned int value = 0;
gevCmdHelper.ReadRegister(enumType(BootstrapAddress::ControlChannelPrivilege), value);
pCamInfo->status = (value == CCP::CCPfromPrivilege(CCPPrivilege::ControlAcces) ||
value == CCP::CCPfromPrivilege(CCPPrivilege::ExclusiveAcces) ||
value == CCP::CCPfromPrivilege(CCPPrivilege::ControlAccesWithSwitchoverEnabled))
? "被占用" : "OK";
}
最后调用 RefreshCameraName(),把相机 IP 填进下拉框。
七、核心二:修改相机 IP set8004Cmd()
7.1 组包
cpp
m_8004Cmd = { 0 };
m_8004Cmd.magic = 0x42;
m_8004Cmd.flag = 0x11;
m_8004Cmd.command = 0x0480; // 网络序 0x8004
m_8004Cmd.length = 0x4c00; // 网络序 0x004C = 76
m_8004Cmd.req_id = 0xffff;
m_8004Cmd.network_interface_configuration = 0x05000000; // 网络序 0x00000005
这里的 0x0480、0x4c00 看起来像"写反了",原因在第八节。
network_interface_configuration 的值是 0x00000005,即 bit0(持久化 IP)+ bit2(LLA)。GigE Vision 规定 LLA 必须始终开启,所以真正的含义是"启用持久化静态 IP"。
7.2 填写 MAC、IP、掩码、网关
先在列表里找到下拉框选中的相机:
cpp
for (auto camera : all_camera_info_)
{
if (camera->ipAddress == ui.select_camera_comboBox->currentText())
{
pCamInfo = camera;
break;
}
}
把 MAC 字符串 aa:bb:cc:dd:ee:ff 拆开填进去:
cpp
m_8004Cmd.mac_address_high = mac[0] | mac[1] << 8;
m_8004Cmd.mac_address_low = mac[2] | mac[3] << 8 | mac[4] << 16 | mac[5] << 24;
IP 也是同样的方法,a.b.c.d 拆成 a | b<<8 | c<<16 | d<<24。
7.3 IP 冲突检查与发送
cpp
if (NetHelper::Net2HostInt32(m_8004Cmd.static_IP) == m_hostIp)
{
ui.ip_config_status_label->setText("设置IP与主机IP冲突!");
}
else
{
port = std::make_unique<SocketPort>(..., SOCKET_ADDR_BROADCAST, GEV_GVCP_PORT, m_hostIp, 0);
port->SendData(reinterpret_cast<const unsigned char*>(&m_8004Cmd), sizeof(m_8004Cmd));
}
和 DISCOVERY 一样,这条命令也是广播 + 用 MAC 匹配目标设备,所以即使相机当前 IP 不在本机网段,也能收到。
源码里用
#if 0保留了标准 FORCEIP(0x0004)的实现。FORCEIP 修改的 IP 是临时的,相机重启后就会恢复。如果你的相机不支持厂商的 0x8004 命令,可以改用 FORCEIP 搭配写 Persistent IP 寄存器(0x064C/0x065C/0x066C)来实现持久化。
7.4 软复位让新 IP 生效
cpp
NET_PARAM localParam = { pCamInfo->hostIP, iCurMask, 0 };
NET_PARAM cameraParam = { iCurIP, iCurMask, 0 };
GevCmdHelper gevCmdHelper{ localParam, cameraParam, 500, cameraReqId };
// 1. 获取控制权限:CCP 寄存器写 0x2(Control Access)
ret = gevCmdHelper.WriteRegister(enumType(BootstrapAddress::ControlChannelPrivilege), 0x00000002);
if (RESULT_IS_ERROR(ret))
{
ui.ip_config_status_label->setText("寄存器权限打开失败");
return false;
}
// 2. 写厂商软复位寄存器
ret = gevCmdHelper.WriteRegister(0x01000040, 0x00000001);
写寄存器是单播 ,用的是相机当前的 IP ,所以要求相机和网卡在同一网段。0x01000040 是厂商自定义的软复位寄存器,不同厂商的地址可能不一样,要查对应的寄存器手册。
软复位命令发出后,相机会立即重启,通常来不及回 ACK,所以源码里没有检查这一步的返回值。
7.5 定时轮询确认结果
cpp
ui.ip_config_status_label->setText("刷新相机中...");
auto_scan_camera_qtimer_.start(10000); // 每 10s 扫描一次
find_camera_10s_ = 0; // 进入"确认模式"
enumCam() 中,当 find_camera_10s_ != -1 时,会把扫描到的 IP 和用户输入的目标 IP 比较:
cpp
if (ip_ == ui.ip_lineEdit->text())
{
ui.ip_config_status_label->setText("修改IP成功");
auto_scan_camera_qtimer_.stop();
find_camera_10s_ = -1;
return;
}
else if (find_camera_10s_ == 9)
{
ui.ip_config_status_label->setText("修改IP失败");
auto_scan_camera_qtimer_.stop();
find_camera_10s_ = -1;
return;
}
else
{
++find_camera_10s_;
}
find_camera_10s_ 用 -1 表示普通扫描,0~9 表示正在确认修改结果。
八、字节序:一个"写反"的技巧
GVCP 是大端,x86 是小端。代码里没有调用 htons/htonl,而是直接写好"反过来"的值:
| 期望在网络上发出的字节 | 代码里写的值 | 小端内存中的实际字节 |
|---|---|---|
80 04(命令 0x8004) |
0x0480 |
80 04 |
00 4C(长度 76) |
0x4c00 |
00 4C |
00 00 00 05 |
0x05000000 |
00 00 00 05 |
IP 和 MAC 也一样:a | b<<8 | c<<16 | d<<24 在小端内存里就是 a b c d,正好是网络序。
所以冲突检查时需要先 Net2HostInt32(static_IP) 转回主机序,再和 m_hostIp(toIPv4Address() 返回的是主机序)比较。
这种写法简单,但依赖小端平台。如果要移植到大端平台,建议改用显式转换:
cpp
m_8004Cmd.command = qToBigEndian<quint16>(0x8004);
m_8004Cmd.length = qToBigEndian<quint16>(76);
m_8004Cmd.static_IP = qToBigEndian<quint32>(QHostAddress(ui.ip_lineEdit->text()).toIPv4Address());
九、踩坑记录与改进建议
在整理代码的过程中,发现了几个值得注意的问题,改进方法一并列出。
9.1 配置文件不存在时程序会崩溃
LoadIPConfigIni() 在 ini 文件不存在时直接返回,configIniWrite 没有初始化。之后只要输入框内容变化,或者程序析构,就会访问野指针。另外,函数声明返回 bool,但末尾没有 return true;,这是未定义行为。
cpp
// 头文件中初始化
QSettings* configIniWrite = nullptr;
// 无论文件是否存在都创建,QSettings 会自动生成文件
configIniWrite = new QSettings(fullFilename, QSettings::IniFormat, this);
...
return true;
指定 this 为父对象后,析构函数里的 delete 也可以去掉。
9.2 MAC 地址格式化会丢掉前导零
cpp
QString::number(NetHelper::Net2HostInt32(pAck->device_mac), 16).insert(2, ':')...
如果低 4 字节的最高位小于 0x10(例如 0a:bb:cc:dd),QString::number 会输出 7 位,冒号就插错位置了。另外 '00::' 是多字符字面量(类型是 int,不是字符串),属于无效代码。建议逐字节格式化:
cpp
QString formatMac(quint16 high, quint32 low)
{
QStringList parts;
parts << QString("%1").arg((high >> 8) & 0xff, 2, 16, QChar('0'))
<< QString("%1").arg(high & 0xff, 2, 16, QChar('0'));
for (int shift = 24; shift >= 0; shift -= 8)
parts << QString("%1").arg((low >> shift) & 0xff, 2, 16, QChar('0'));
return parts.join(':').toUpper();
}
9.3 user_defined_name 没有结束符
cpp
char user_defined_name[16];
memcpy(user_defined_name, pAck->user_defined_name, 16);
如果名称正好 16 字节,转成 QString 时会越界读取。应和其他字段一样开 [16 + 1] = { 0 }。
9.4 网段判断只支持 3 种掩码
现在的代码只识别 /8、/16、/24 三种掩码,遇到 255.255.255.128 这类掩码就会判断错。实际上直接用相机返回的掩码做与运算就行:
cpp
quint32 mask = NetHelper::Net2HostInt32(pAck->current_subnet_mask);
bool sameSubnet = (pCamInfo->hostIP & mask) == (NetHelper::Net2HostInt32(pAck->current_ip) & mask);
9.5 读 CCP 失败时的状态会被覆盖
cpp
if (RESULT_IS_ERROR(r))
pCamInfo->status = QString(" ");
pCamInfo->status = (value == ...) ? "被占用" : "OK"; // 总会执行,覆盖上一行
应改为 if / else。
9.6 "修改失败"的判定依赖扫描到相机
find_camera_10s_ 只在扫描到相机且 IP 不匹配时才加 1。如果相机复位后一直没有响应,计数不会增加,定时器就会一直跑下去,界面始终显示"刷新中"。另外,如果多台相机同时在线,一次扫描中计数可能加好几次。建议把计数放到定时器的槽函数里,每次超时加 1:
cpp
connect(&auto_scan_camera_qtimer_, &QTimer::timeout, this, [this] {
if (find_camera_10s_ >= 0 && ++find_camera_10s_ > 9) {
ui.ip_config_status_label->setText("修改IP失败");
auto_scan_camera_qtimer_.stop();
find_camera_10s_ = -1;
return;
}
enumCam();
});
9.7 其他
- 输入校验 :IP、掩码、网关输入框可以加
QRegularExpressionValidator,或者用QHostAddress::setAddress()检查格式,避免发出非法地址; - 网卡列表只在启动时获取 :
allIp是成员变量,只在构造时初始化一次,插拔网线或修改网卡 IP 后需要重启程序。建议在enumCam()开头重新获取; - 阻塞 UI :
enumCam()中每块网卡至少阻塞 1 秒,网卡多时界面会卡住。可以用QtConcurrent::run放到后台线程执行,再通过信号把结果传回界面; - IP 字符串转换 :重复的
section('.', i, i).toInt() << n写法可以封装成一个函数,或者直接用QHostAddress::toIPv4Address()。
十、总结
这个工具的核心思路:
- 广播 DISCOVERY:不管相机在哪个网段都能找到它,并拿到 MAC 和当前网络参数;
- 广播 + MAC 匹配:用 0x8004(或标准 FORCEIP)命令跨网段修改 IP;
- 单播写寄存器:获取 CCP 控制权后软复位,让新 IP 生效;
- 定时重扫:确认修改结果,并在界面上反馈;
- QSettings:保存用户输入,下次直接使用。
整个实现只依赖 Qt 和一层很薄的 GVCP 封装(SocketPort、GevCmdHelper、NetHelper),很容易集成到自己的视觉软件里,作为"一键配置相机网络"的功能模块。
需要注意的是,0x8004 命令和 0x01000040 软复位寄存器都是厂商私有的,换了品牌的相机要查对应的协议文档。如果想做成通用工具,建议用标准的 FORCEIP 加 Persistent IP 寄存器来实现。