Qt | 基于 GVCP 协议的 GigE 工业相机 IP 配置工具

文章目录

  • [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)
    • 五、配置文件持久化: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

  1. 网段判断:本机 IP 和相机 IP 分别和子网掩码做与运算,结果不同就说明不在同一网段,标记为"不可达";
  2. 占用判断 :在同一网段时,读取 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()。

十、总结

这个工具的核心思路:

  1. 广播 DISCOVERY:不管相机在哪个网段都能找到它,并拿到 MAC 和当前网络参数;
  2. 广播 + MAC 匹配:用 0x8004(或标准 FORCEIP)命令跨网段修改 IP;
  3. 单播写寄存器:获取 CCP 控制权后软复位,让新 IP 生效;
  4. 定时重扫:确认修改结果,并在界面上反馈;
  5. QSettings:保存用户输入,下次直接使用。

整个实现只依赖 Qt 和一层很薄的 GVCP 封装(SocketPort、GevCmdHelper、NetHelper),很容易集成到自己的视觉软件里,作为"一键配置相机网络"的功能模块。

需要注意的是,0x8004 命令和 0x01000040 软复位寄存器都是厂商私有的,换了品牌的相机要查对应的协议文档。如果想做成通用工具,建议用标准的 FORCEIP 加 Persistent IP 寄存器来实现。

相关推荐
爱和冰阔落1 小时前
【Linux网络】Socket 编程前要搞懂什么?从 IP、端口到字节序与 sockaddr,一次理清网络通信基础
linux·运维·网络·tcp/ip
机器视觉知识推荐、就业指导1 小时前
线扫相机拍出来的圆,怎么变成椭圆了?
数码相机
ITxiaobing202311 小时前
中国公司海外广告投放的 IP 地址库选型:从“查归属”到“验真实”的工程视角
网络·网络协议·tcp/ip
Software攻城狮13 小时前
8 【 entity ,primitive,3D tiles 3者的含义和区别】
数码相机·3d
明天也要努力学习16 小时前
TCP 协议的相关特性
网络·网络协议·tcp/ip
SHENGWEI胜为17 小时前
局域网出现 IP 地址冲突怎么办?如何从 ARP 表定位故障设备
网络协议·tcp/ip·php
2401_8734794017 小时前
源IP告警太多用什么工具分层?ASN与风险评分操作指南
网络·网络协议·tcp/ip
80s77721 小时前
态住宅IP在跨境电商防关联中的技术应用
网络·网络协议·tcp/ip
Novproxy_Z21 小时前
代理 IP 黑白名单体系搭建:自动标记风控 IP,减少封号
网络·网络协议·tcp/ip