双网卡下Qt程序数据包“乱飞”之谜:为什么必须断开再连接

双网卡下Qt程序数据包"乱飞"之谜:为什么必须断开再连接

适用环境:Qt 5.x、Windows 10/11、银河麒麟/Kylin、双网卡或多网卡工业计算机

典型协议:UDP 单播、UDP 广播、UDP 组播、TCP 客户端通信

在工业控制、显控软件、设备通信和局域网部署中,一台计算机同时配置两张网卡是非常常见的:

  • 一张网卡连接办公网、管理网或上级系统;
  • 另一张网卡连接雷达、光电、控制器、测试机台等专用设备网络。

此时经常出现一种看似"玄学"的问题:

Qt 程序没有指定本地 IP 时,数据发送失败;禁用网卡1后,程序立即恢复;重新启用网卡1后,有时仍然可以继续通信。

这个问题并不是 Qt 随机选择网卡,也不是拔插网线后程序"被修复"了。

真正原因是:

应用程序没有指定出口地址时,操作系统必须根据路由表替应用选择出口网卡。只要路由配置存在歧义、冲突或优先级变化,程序就可能从错误的网卡发送。

需要强调的是:

  • 不指定网卡时,"操作系统进行选路"一定会发生;
  • 但"选错网卡"不一定每次发生;
  • 当网络配置存在重叠网段、多默认网关、Metric 不合理、广播目标不明确等问题时,错误就会变得高概率甚至必然发生。

一、问题场景

假设工业计算机配置了两张网卡。

网卡1:办公网

text 复制代码
IP地址:192.168.1.100
子网掩码:255.255.255.0
默认网关:192.168.1.1

网卡2:设备专网

text 复制代码
IP地址:192.168.20.10
子网掩码:255.255.255.0
默认网关:无

Qt 程序需要向设备发送 UDP 数据:

text 复制代码
目标IP:192.168.20.50
目标端口:9000

程序只写了:

cpp 复制代码
QUdpSocket *socket = new QUdpSocket(this);

socket->writeDatagram(
    data,
    QHostAddress("192.168.20.50"),
    9000
);

代码中没有执行:

cpp 复制代码
socket->bind(QHostAddress("192.168.20.10"), 0);

这意味着程序只告诉操作系统:

text 复制代码
请把数据发到 192.168.20.50

但没有告诉操作系统:

text 复制代码
请从 192.168.20.10 对应的设备专网发送

因此,出口网卡、源 IP 和下一跳全部由操作系统决定。


二、为什么会这样:核心原理

2.1 Qt 不负责替你选择"业务网卡"

当程序没有绑定本地 IP 时,Qt 通常不会按照"网卡1""网卡2"的名称选择网卡。

Qt 最终会调用操作系统提供的 Socket 接口,真正负责出口选择的是操作系统网络协议栈。

发送过程可以概括为:

text 复制代码
Qt应用程序
    ↓
Socket
    ↓
操作系统路由查询
    ↓
确定出口网卡
    ↓
确定源IP
    ↓
ARP/邻居解析
    ↓
发送数据包

因此,问题的核心不是"Qt 默认使用网卡1",而是:

Qt 没有指定本地出口,Windows 或 Kylin 根据当前路由表选出了网卡1。


2.2 操作系统如何选择出口网卡

操作系统通常按照以下顺序选择路由。

第一步:最长前缀匹配

目标地址与哪条路由匹配得更精确,就优先使用哪条路由。

例如目标地址为:

text 复制代码
192.168.20.50

路由表中存在:

text 复制代码
192.168.20.0/24 → 网卡2
0.0.0.0/0       → 网卡1

因为 /24/0 更精确,所以正常情况下应选择网卡2。

第二步:比较路由 Metric

如果存在多条相同前缀长度的路由,系统会比较路由 Metric。

通常 Metric 越小,优先级越高。

例如:

text 复制代码
192.168.20.0/24 → 网卡1,Metric 10
192.168.20.0/24 → 网卡2,Metric 25

系统可能优先选择网卡1。

第三步:比较接口 Metric

部分系统还会综合网卡接口自身的 Metric。

Windows 中最终优先级通常与以下因素有关:

text 复制代码
路由Metric + 接口Metric

因此,网卡启停、网络重新连接、DHCP 更新、系统重启后,出口选择结果可能发生变化。


2.3 为什么断开网卡1后就能正常发送

禁用网卡1后,操作系统会将与网卡1相关的有效路由移除或标记为不可用。

原来的路径可能是:

text 复制代码
Qt程序
  ↓
操作系统选路
  ↓
网卡1
  ↓
目标设备不可达

禁用网卡1后,系统只能重新选择:

text 复制代码
Qt程序
  ↓
操作系统重新选路
  ↓
网卡2
  ↓
发送成功

所以禁用网卡1后恢复通信,本质上是在强制操作系统重新计算路由。


2.4 为什么重新启用网卡1后仍然可能正常

重新启用网卡1后仍能继续通信,常见原因有以下几种。

原因一:TCP 连接已经绑定到网卡2

TCP 连接建立后,四元组已经确定:

text 复制代码
本地IP:本地端口 → 远端IP:远端端口

例如:

text 复制代码
192.168.20.10:51023 → 192.168.20.50:9000

只要该连接没有断开,重新启用网卡1通常不会立即改变已有 TCP 连接的本地地址。

原因二:UDP Socket 已经确定了源地址

如果 UDP Socket 调用了 connectToHost(),或者第一次发送时系统已经为 Socket 确定了源地址,后续数据可能继续使用原来的源地址。

但这不能作为可靠保证。

原因三:路由表和邻居缓存已经刷新

禁用、启用网卡会触发:

  • 路由重新计算;
  • 接口状态刷新;
  • ARP 或邻居表变化;
  • DHCP 配置重新加载;
  • NetworkManager 或 Windows 网络服务重新应用配置。

因此问题可能暂时消失。

但程序重启、计算机重启、网线重新插拔或网络切换后,问题仍可能再次出现。


2.5 哪些网络配置最容易触发问题

两张网卡都配置了默认网关

例如:

text 复制代码
0.0.0.0/0 → 网卡1
0.0.0.0/0 → 网卡2

系统需要通过 Metric 决定使用哪条默认路由。

当 Metric 自动变化时,出口网卡也可能变化。


两张网卡处于相同网段

例如:

text 复制代码
网卡1:192.168.1.10/24
网卡2:192.168.1.20/24
目标:  192.168.1.100

此时两张网卡都认为目标设备位于自己的直连网络中。

仅根据目标 IP,操作系统无法理解你的业务意图。

这是典型的网络设计冲突。

最佳做法是让两张网卡使用不同网段,而不是依靠程序"猜"应该走哪张网卡。


两张网卡的网段重叠

例如:

text 复制代码
网卡1:192.168.0.10/16
网卡2:192.168.20.10/24
目标:  192.168.20.50

两张网卡都能匹配目标地址:

text 复制代码
网卡1匹配:192.168.0.0/16
网卡2匹配:192.168.20.0/24

正常情况下 /24 更精确,应选择网卡2。

但如果路由被手工修改、存在策略路由或接口状态异常,仍可能出现非预期行为。


使用全局广播地址

例如:

cpp 复制代码
socket->writeDatagram(
    data,
    QHostAddress::Broadcast,
    9000
);

QHostAddress::Broadcast 对应:

text 复制代码
255.255.255.255

这个地址本身不能明确表示要从哪张网卡广播。

在多网卡环境中,广播报文尤其容易从错误网卡发送。


目标地址没有明确路由

如果目标设备与本机不在同一网段,同时又没有配置静态路由,系统只能尝试默认路由。

默认路由通常指向办公网卡,因此设备专网数据可能被发送到办公网。


三、常见错误做法

3.1 每次出问题就禁用、启用网卡

这种方式只能临时改变系统路由状态。

它无法保证:

  • 下一次开机仍然有效;
  • 程序重启后仍然有效;
  • 网线重新连接后仍然有效;
  • Kylin NetworkManager 重载配置后仍然有效;
  • 现场部署到其他计算机后仍然有效。

这是一种排障手段,不是解决方案。


3.2 两张网卡都设置默认网关

专用设备网通常不需要默认网关。

推荐配置:

text 复制代码
办公网卡:
IP:192.168.1.100
掩码:255.255.255.0
网关:192.168.1.1

设备网卡:
IP:192.168.20.10
掩码:255.255.255.0
网关:留空

除非设备网络确实需要跨网段路由,否则不要给设备网卡配置默认网关。


3.3 绑定 AnyIPv4 后认为已经指定网卡

下面的代码没有指定具体网卡:

cpp 复制代码
socket->bind(QHostAddress::AnyIPv4, 9000);

AnyIPv4 的含义是:

text 复制代码
监听本机所有IPv4地址

它不等于:

text 复制代码
使用网卡2发送

因此,绑定 AnyIPv4 主要解决接收端口监听问题,不能可靠解决多网卡出口选择问题。


3.4 只调整网卡优先级,不绑定业务 IP

调整 Metric 可以改善操作系统选路,但不能代替应用层明确配置。

因为系统优先级可能受以下因素影响:

  • DHCP;
  • VPN;
  • 虚拟网卡;
  • Docker;
  • WSL;
  • NetworkManager;
  • 网卡驱动;
  • 系统升级;
  • 网卡启停顺序。

对于关键工业通信,应用程序应保留明确指定本地 IP 的能力。


3.5 使用 255.255.255.255 进行多网卡广播

在多网卡环境中,建议使用目标网段的定向广播地址。

例如设备网卡为:

text 复制代码
192.168.20.10/24

对应广播地址为:

text 复制代码
192.168.20.255

相比:

text 复制代码
255.255.255.255

定向广播更能表达业务意图。


3.6 只检查发送返回值,不抓包

writeDatagram() 返回成功,只能说明:

text 复制代码
数据成功交给了本机操作系统

它不能证明:

  • 数据从正确网卡发出;
  • 对端已经收到;
  • 对端能够返回;
  • ARP 解析正确;
  • 防火墙允许报文通过。

因此,多网卡问题必须结合路由查询和抓包分析。


四、正确解决方案

完整解决方案应分为两个层次:

text 复制代码
系统层:保证路由表正确、网络拓扑清晰
应用层:明确绑定业务网卡对应的本地IP

只修改其中一层,通常都不够稳健。


4.1 方案一:Qt 程序显式绑定本地 IP

这是最直接、最可控的应用层方案。

假设设备专网网卡地址为:

text 复制代码
192.168.20.10

UDP 发送

cpp 复制代码
#include <QUdpSocket>
#include <QHostAddress>
#include <QDebug>

bool sendUdpData(const QByteArray &data)
{
    QUdpSocket socket;

    const QHostAddress localAddress("192.168.20.10");
    const QHostAddress targetAddress("192.168.20.50");
    const quint16 targetPort = 9000;

    // 端口传0,让操作系统自动分配本地临时端口
    if (!socket.bind(localAddress, 0)) {
        qWarning() << "UDP绑定本地IP失败:"
                   << localAddress.toString()
                   << socket.errorString();
        return false;
    }

    const qint64 sentBytes = socket.writeDatagram(
        data,
        targetAddress,
        targetPort
    );

    if (sentBytes < 0) {
        qWarning() << "UDP发送失败:"
                   << socket.errorString();
        return false;
    }

    qDebug() << "UDP发送成功"
             << "本地IP:" << socket.localAddress().toString()
             << "本地端口:" << socket.localPort()
             << "目标IP:" << targetAddress.toString()
             << "目标端口:" << targetPort
             << "字节数:" << sentBytes;

    return true;
}

正确顺序是:

text 复制代码
创建Socket
→ 绑定本地IP
→ 发送数据

4.2 UDP 固定接收端口并绑定指定网卡

如果程序还要接收设备返回的数据,可以绑定设备网卡 IP 和固定端口:

cpp 复制代码
#include <QUdpSocket>
#include <QDebug>

class DeviceUdpClient : public QObject
{
    Q_OBJECT

public:
    explicit DeviceUdpClient(QObject *parent = nullptr)
        : QObject(parent),
          m_socket(new QUdpSocket(this))
    {
    }

    bool start()
    {
        const QHostAddress localAddress("192.168.20.10");
        const quint16 localPort = 9001;

        const auto bindMode =
            QUdpSocket::ShareAddress |
            QUdpSocket::ReuseAddressHint;

        if (!m_socket->bind(localAddress, localPort, bindMode)) {
            qWarning() << "UDP监听失败:"
                       << localAddress.toString()
                       << localPort
                       << m_socket->errorString();
            return false;
        }

        connect(
            m_socket,
            &QUdpSocket::readyRead,
            this,
            &DeviceUdpClient::readPendingDatagrams
        );

        qDebug() << "UDP监听成功:"
                 << m_socket->localAddress().toString()
                 << m_socket->localPort();

        return true;
    }

    bool send(const QByteArray &data)
    {
        const QHostAddress targetAddress("192.168.20.50");
        const quint16 targetPort = 9000;

        const qint64 result = m_socket->writeDatagram(
            data,
            targetAddress,
            targetPort
        );

        if (result < 0) {
            qWarning() << "UDP发送失败:"
                       << m_socket->errorString();
            return false;
        }

        return true;
    }

private slots:
    void readPendingDatagrams()
    {
        while (m_socket->hasPendingDatagrams()) {
            QByteArray datagram;
            datagram.resize(
                static_cast<int>(m_socket->pendingDatagramSize())
            );

            QHostAddress senderAddress;
            quint16 senderPort = 0;

            const qint64 size = m_socket->readDatagram(
                datagram.data(),
                datagram.size(),
                &senderAddress,
                &senderPort
            );

            if (size < 0) {
                qWarning() << "UDP接收失败:"
                           << m_socket->errorString();
                continue;
            }

            qDebug() << "收到UDP数据"
                     << "来源IP:" << senderAddress.toString()
                     << "来源端口:" << senderPort
                     << "字节数:" << size;
        }
    }

private:
    QUdpSocket *m_socket;
};

4.3 TCP 客户端绑定本地 IP(注意)

TCP 客户端必须在 connectToHost() 之前绑定本地 IP。

cpp 复制代码
#include <QTcpSocket>
#include <QHostAddress>
#include <QDebug>

bool connectDevice()
{
    auto *socket = new QTcpSocket;

    const QHostAddress localAddress("192.168.20.10");
    const QHostAddress serverAddress("192.168.20.50");
    const quint16 serverPort = 9000;

    if (!socket->bind(localAddress, 0)) {
        qWarning() << "TCP绑定本地IP失败:"
                   << socket->errorString();
        socket->deleteLater();
        return false;
    }

    socket->connectToHost(serverAddress, serverPort);

    if (!socket->waitForConnected(3000)) {
        qWarning() << "TCP连接失败:"
                   << socket->errorString();
        socket->deleteLater();
        return false;
    }

    qDebug() << "TCP连接成功"
             << "本地IP:" << socket->localAddress().toString()
             << "本地端口:" << socket->localPort()
             << "远端IP:" << socket->peerAddress().toString()
             << "远端端口:" << socket->peerPort();

    return true;
}

错误顺序:

cpp 复制代码
socket->connectToHost(serverAddress, serverPort);
socket->bind(localAddress, 0);

连接发起后再绑定已经太晚。


4.4 UDP 广播必须绑定网卡并使用定向广播地址(注意)

假设设备网卡配置为:

text 复制代码
IP:192.168.20.10
掩码:255.255.255.0

广播地址为:

text 复制代码
192.168.20.255

代码如下:

cpp 复制代码
QUdpSocket socket;

const QHostAddress localAddress("192.168.20.10");
const QHostAddress broadcastAddress("192.168.20.255");
const quint16 targetPort = 9000;

if (!socket.bind(localAddress, 0)) {
    qWarning() << "绑定设备网卡失败:"
               << socket.errorString();
    return;
}

const QByteArray data = "DEVICE_DISCOVERY";

const qint64 result = socket.writeDatagram(
    data,
    broadcastAddress,
    targetPort
);

if (result < 0) {
    qWarning() << "广播发送失败:"
               << socket.errorString();
}

不要只写:

cpp 复制代码
socket.writeDatagram(
    data,
    QHostAddress::Broadcast,
    targetPort
);

因为 255.255.255.255 在多网卡环境中不能明确表达目标网段。


4.5 UDP 组播应显式指定组播接口(注意)

如果程序使用组播,除了绑定本地地址,还应指定组播网卡。

cpp 复制代码
#include <QUdpSocket>
#include <QNetworkInterface>
#include <QDebug>

bool joinDeviceMulticast()
{
    auto *socket = new QUdpSocket;

    const QHostAddress localAddress("192.168.20.10");
    const QHostAddress multicastAddress("239.10.10.10");
    const quint16 multicastPort = 9000;

    if (!socket->bind(
            QHostAddress::AnyIPv4,
            multicastPort,
            QUdpSocket::ShareAddress |
            QUdpSocket::ReuseAddressHint)) {
        qWarning() << "组播端口绑定失败:"
                   << socket->errorString();
        return false;
    }

    QNetworkInterface targetInterface;

    const auto interfaces = QNetworkInterface::allInterfaces();

    for (const QNetworkInterface &networkInterface : interfaces) {
        for (const QNetworkAddressEntry &entry :
             networkInterface.addressEntries()) {
            if (entry.ip() == localAddress) {
                targetInterface = networkInterface;
                break;
            }
        }

        if (targetInterface.isValid()) {
            break;
        }
    }

    if (!targetInterface.isValid()) {
        qWarning() << "未找到本地IP对应的网卡:"
                   << localAddress.toString();
        return false;
    }

    socket->setMulticastInterface(targetInterface);

    if (!socket->joinMulticastGroup(
            multicastAddress,
            targetInterface)) {
        qWarning() << "加入组播失败:"
                   << socket->errorString();
        return false;
    }

    qDebug() << "已通过网卡加入组播:"
             << targetInterface.humanReadableName()
             << multicastAddress.toString();

    return true;
}

组播环境中只调用:

cpp 复制代码
joinMulticastGroup(multicastAddress);

仍然可能由系统自动选择错误接口。


4.6 不要把本地 IP 硬编码在源码中

虽然绑定本地 IP 是正确方向,但直接硬编码:

cpp 复制代码
QHostAddress localAddress("192.168.20.10");

不适合批量部署。

更好的方式是通过配置文件管理。

例如:

ini 复制代码
[DeviceNetwork]
LocalIp=192.168.20.10
TargetIp=192.168.20.50
TargetPort=9000
LocalPort=9001

Qt 读取配置:

cpp 复制代码
#include <QSettings>

QSettings settings(
    "config.ini",
    QSettings::IniFormat
);

settings.beginGroup("DeviceNetwork");

const QString localIp =
    settings.value("LocalIp").toString();

const QString targetIp =
    settings.value("TargetIp").toString();

const quint16 targetPort =
    settings.value("TargetPort").toUInt();

const quint16 localPort =
    settings.value("LocalPort").toUInt();

settings.endGroup();

绑定前还应验证该 IP 是否真实存在于本机。


4.7 启动时检查配置 IP 是否属于本机

cpp 复制代码
#include <QNetworkInterface>
#include <QHostAddress>
#include <QString>

bool isLocalIpv4Address(const QString &ipText)
{
    const QHostAddress expectedAddress(ipText);

    if (expectedAddress.isNull() ||
        expectedAddress.protocol() !=
            QAbstractSocket::IPv4Protocol) {
        return false;
    }

    const auto interfaces =
        QNetworkInterface::allInterfaces();

    for (const QNetworkInterface &networkInterface : interfaces) {
        const auto flags = networkInterface.flags();

        if (!flags.testFlag(QNetworkInterface::IsUp) ||
            !flags.testFlag(QNetworkInterface::IsRunning)) {
            continue;
        }

        for (const QNetworkAddressEntry &entry :
             networkInterface.addressEntries()) {
            if (entry.ip() == expectedAddress) {
                return true;
            }
        }
    }

    return false;
}

使用:

cpp 复制代码
const QString localIp = "192.168.20.10";

if (!isLocalIpv4Address(localIp)) {
    qCritical() << "配置的本地IP不存在或网卡未启用:"
                << localIp;
    return;
}

这比等待发送失败后再排查更可靠。


4.8 根据目标网段自动选择本地 IP

对于部署环境较多的项目,可以根据目标 IP 和本地网卡掩码自动匹配。

cpp 复制代码
#include <QNetworkInterface>
#include <QHostAddress>
#include <QString>

QHostAddress findLocalAddressForTarget(
    const QHostAddress &targetAddress)
{
    if (targetAddress.protocol() !=
        QAbstractSocket::IPv4Protocol) {
        return QHostAddress();
    }

    const auto interfaces =
        QNetworkInterface::allInterfaces();

    for (const QNetworkInterface &networkInterface : interfaces) {
        const auto flags = networkInterface.flags();

        if (!flags.testFlag(QNetworkInterface::IsUp) ||
            !flags.testFlag(QNetworkInterface::IsRunning) ||
            flags.testFlag(QNetworkInterface::IsLoopBack)) {
            continue;
        }

        for (const QNetworkAddressEntry &entry :
             networkInterface.addressEntries()) {
            const QHostAddress localAddress = entry.ip();
            const QHostAddress netmask = entry.netmask();

            if (localAddress.protocol() !=
                    QAbstractSocket::IPv4Protocol ||
                netmask.isNull()) {
                continue;
            }

            const int prefixLength =
                netmask.toIPv4Address() == 0
                    ? 0
                    : qPopulationCount(
                          netmask.toIPv4Address());

            if (targetAddress.isInSubnet(
                    localAddress,
                    prefixLength)) {
                return localAddress;
            }
        }
    }

    return QHostAddress();
}

不过自动选择只能作为辅助。

如果存在以下情况,不建议只依靠自动匹配:

  • 两张网卡位于同一网段;
  • 网段重叠;
  • 目标需要经过网关;
  • 使用策略路由;
  • 使用 VPN 或虚拟网卡;
  • 业务明确要求固定物理接口。

这类场景应使用显式配置。


五、Windows 系统排查方法

5.1 查看网卡地址、掩码和默认网关

bat 复制代码
ipconfig /all

重点检查:

  • 两张网卡的 IPv4 地址;
  • 子网掩码;
  • 默认网关;
  • DHCP 是否启用;
  • 是否存在虚拟网卡;
  • 是否存在重复 IP。

5.2 查看完整路由表

bat 复制代码
route print -4

重点关注:

text 复制代码
网络目标
网络掩码
网关
接口
跃点数

例如:

text 复制代码
网络目标        网络掩码          网关        接口           跃点数
192.168.20.0    255.255.255.0     在链路上    192.168.20.10  281
0.0.0.0         0.0.0.0           192.168.1.1 192.168.1.100  25

如果目标是 192.168.20.50,正常应匹配:

text 复制代码
192.168.20.0/24

而不是默认路由:

text 复制代码
0.0.0.0/0

5.3 使用 PowerShell 查看路由

powershell 复制代码
Get-NetRoute -AddressFamily IPv4 |
Sort-Object DestinationPrefix, RouteMetric

查看接口优先级:

powershell 复制代码
Get-NetIPInterface -AddressFamily IPv4 |
Sort-Object InterfaceMetric

查看网卡配置:

powershell 复制代码
Get-NetIPConfiguration

5.4 查看系统准备使用哪条路由

powershell 复制代码
Test-NetConnection 192.168.20.50 -DiagnoseRouting

重点观察:

text 复制代码
SelectedSourceAddress
OutgoingInterfaceIndex
SelectedNetRoute

如果 SelectedSourceAddress 不是设备专网 IP,说明系统选路与业务预期不一致。


5.5 检查 ARP 表

bat 复制代码
arp -a

或者:

powershell 复制代码
Get-NetNeighbor -AddressFamily IPv4

检查目标 IP 是否被解析到正确接口。

如果目标设备与本机处于同一网段,但没有对应 ARP 记录,可能存在:

  • 网线未连接;
  • 目标设备未启动;
  • VLAN 不一致;
  • IP 或掩码配置错误;
  • 报文从错误网卡发送;
  • 交换机端口配置异常。

5.6 指定源地址进行连通性测试

Windows 自带 ping 对源地址控制能力有限,可以使用:

powershell 复制代码
Test-NetConnection 192.168.20.50

还可以结合抓包确认实际接口。


5.7 使用 Wireshark 分别监听两张网卡

在两张网卡上同时抓包,过滤条件示例:

text 复制代码
ip.addr == 192.168.20.50

UDP:

text 复制代码
ip.dst == 192.168.20.50 && udp.port == 9000

TCP:

text 复制代码
ip.addr == 192.168.20.50 && tcp.port == 9000

抓包时重点确认:

  • 数据出现在哪张网卡;
  • 源 IP 是什么;
  • 目标 IP 是否正确;
  • 是否发出了 ARP 请求;
  • 是否收到对端响应;
  • TCP 是否完成三次握手。

5.8 Windows 路由修复原则

对于设备专网,通常应满足:

text 复制代码
目标设备网段 → 设备网卡
默认路由     → 办公网卡

例如为目标网段增加静态路由:

bat 复制代码
route -p add 192.168.20.0 mask 255.255.255.0 192.168.20.1

但需要注意:

如果 192.168.20.50 与设备网卡 192.168.20.10/24 处于同一网段,系统会自动生成直连路由,通常不需要配置网关。

这时应优先检查:

  • 子网掩码;
  • 重复网段;
  • 接口 Metric;
  • 是否存在手工错误路由;
  • 两张网卡是否都配置了默认网关。

不要在没有理解网络拓扑的情况下直接添加静态路由。


六、银河麒麟 Kylin 系统排查方法

银河麒麟桌面版和服务器版大多基于 Linux 网络栈,常见网络管理方式包括:

  • NetworkManager;
  • systemd-networkd;
  • 传统网络配置脚本;
  • 厂商定制网络管理组件。

无论图形界面如何变化,底层排查逻辑仍然是:

text 复制代码
地址
→ 路由
→ 策略规则
→ 邻居表
→ Socket
→ 抓包

6.1 快速查看所有网卡和 IP

bash 复制代码
ip -br addr

输出示例:

text 复制代码
lo       UNKNOWN  127.0.0.1/8
enp2s0   UP       192.168.1.100/24
enp3s0   UP       192.168.20.10/24

查看详细信息:

bash 复制代码
ip addr show

查看网卡链路状态:

bash 复制代码
ip link show

重点确认:

  • 网卡是否为 UP
  • IP 是否配置在预期网卡上;
  • 掩码是否正确;
  • 是否存在重复 IP;
  • 网卡名称是否发生变化。

Kylin 中网卡名称不一定是 eth0eth1,也可能是:

text 复制代码
enp2s0
enp3s0
ens33
eno1

不要在程序或脚本中默认网卡一定叫 eth0


6.2 查看路由表

bash 复制代码
ip route

输出示例:

text 复制代码
default via 192.168.1.1 dev enp2s0 metric 100
192.168.1.0/24 dev enp2s0 proto kernel scope link src 192.168.1.100 metric 100
192.168.20.0/24 dev enp3s0 proto kernel scope link src 192.168.20.10 metric 200

对于目标:

text 复制代码
192.168.20.50

应匹配:

text 复制代码
192.168.20.0/24 dev enp3s0

6.3 直接查询目标 IP 将使用哪张网卡

这是 Kylin/Linux 中最有价值的排查命令之一:

bash 复制代码
ip route get 192.168.20.50

正常输出可能为:

text 复制代码
192.168.20.50 dev enp3s0 src 192.168.20.10

重点看两个字段:

text 复制代码
dev enp3s0
src 192.168.20.10

它们分别表示:

  • 出口网卡;
  • 默认源 IP。

如果结果为:

text 复制代码
192.168.20.50 via 192.168.1.1 dev enp2s0 src 192.168.1.100

说明系统正在通过办公网卡发送,路由配置明显不符合业务预期。

还可以指定源地址验证:

bash 复制代码
ip route get 192.168.20.50 from 192.168.20.10

6.4 检查策略路由

Linux 除主路由表外,还可能存在策略路由。

查看规则:

bash 复制代码
ip rule

典型输出:

text 复制代码
0:      from all lookup local
32766:  from all lookup main
32767:  from all lookup default

如果存在额外规则,例如:

text 复制代码
1000: from 192.168.20.10 lookup 200

还需要检查对应路由表:

bash 复制代码
ip route show table 200

这类规则常见于:

  • 多网卡服务器;
  • VPN;
  • 容器环境;
  • 特殊安全网络;
  • 厂商定制系统;
  • 手工配置的源地址路由。

6.5 查看 NetworkManager 状态

查看设备状态:

bash 复制代码
nmcli device status

查看连接配置:

bash 复制代码
nmcli connection show

查看指定连接:

bash 复制代码
nmcli connection show "设备专网"

重点检查:

text 复制代码
ipv4.addresses
ipv4.gateway
ipv4.routes
ipv4.route-metric
ipv4.never-default

6.6 防止设备网卡生成默认路由

设备专网通常不应成为默认出口。

可以设置:

bash 复制代码
sudo nmcli connection modify "设备专网" ipv4.never-default yes

重新加载连接:

bash 复制代码
sudo nmcli connection down "设备专网"
sudo nmcli connection up "设备专网"

执行前必须确认当前远程管理连接不会因此中断。


6.7 调整路由 Metric

办公网卡优先作为默认网络:

bash 复制代码
sudo nmcli connection modify "办公网络" ipv4.route-metric 100

设备专网设置更高 Metric:

bash 复制代码
sudo nmcli connection modify "设备专网" ipv4.route-metric 500

但要注意:

Metric 主要用于相同前缀长度路由之间的优先级比较,不能代替明确的目标网段路由。

对于设备网段的 /24 路由,它仍然会优先于默认路由 /0


6.8 查看邻居表

Linux 推荐使用:

bash 复制代码
ip neigh

示例:

text 复制代码
192.168.20.50 dev enp3s0 lladdr 00:11:22:33:44:55 REACHABLE

如果状态为:

text 复制代码
FAILED
INCOMPLETE

说明 ARP 或邻居解析失败。

可以清理指定邻居缓存:

bash 复制代码
sudo ip neigh del 192.168.20.50 dev enp3s0

不要把清缓存作为长期解决方案,它只是用于验证问题。


6.9 指定网卡或源 IP 进行 Ping

指定网卡:

bash 复制代码
ping -I enp3s0 192.168.20.50

指定源 IP:

bash 复制代码
ping -I 192.168.20.10 192.168.20.50

对比测试:

bash 复制代码
ping 192.168.20.50
ping -I enp3s0 192.168.20.50

如果普通 Ping 失败、指定网卡后成功,说明问题很可能与默认选路有关。


6.10 查看 Socket 使用的本地地址

查看 UDP:

bash 复制代码
ss -uapn

查看 TCP:

bash 复制代码
ss -tapn

过滤端口:

bash 复制代码
ss -uapn | grep 9000

重点观察本地地址:

text 复制代码
192.168.20.10:9001

如果显示:

text 复制代码
0.0.0.0:9001

说明 Socket 监听所有本地地址,并没有绑定到特定设备网卡。


6.11 使用 tcpdump 抓取指定网卡

监听设备专网网卡:

bash 复制代码
sudo tcpdump -ni enp3s0 host 192.168.20.50

监听办公网卡:

bash 复制代码
sudo tcpdump -ni enp2s0 host 192.168.20.50

监听 UDP 端口:

bash 复制代码
sudo tcpdump -ni enp3s0 \
    'host 192.168.20.50 and udp port 9000'

监听 TCP 端口:

bash 复制代码
sudo tcpdump -ni enp3s0 \
    'host 192.168.20.50 and tcp port 9000'

同时在两张网卡上抓包,可以直接确认报文究竟从哪里发出。


6.12 临时添加静态路由

例如目标网段应从 enp3s0 发送:

bash 复制代码
sudo ip route add \
    192.168.20.0/24 \
    dev enp3s0 \
    src 192.168.20.10

如果路由已存在,可以修改:

bash 复制代码
sudo ip route replace \
    192.168.20.0/24 \
    dev enp3s0 \
    src 192.168.20.10

这种方式重启后通常失效,适合排查验证。

永久配置应通过 Kylin 当前使用的网络管理方式完成,例如 NetworkManager。


6.13 高级场景:绑定到具体 Linux 网卡

对于同网段双网卡、重叠网段等复杂环境,仅绑定本地 IP 仍可能不够。

Linux 支持 SO_BINDTODEVICE,可以将 Socket 绑定到具体接口,例如 enp3s0

Qt 没有直接提供统一的高级接口,可以在获得原生 Socket 描述符后调用 setsockopt()

cpp 复制代码
#ifdef Q_OS_LINUX

#include <sys/socket.h>
#include <net/if.h>
#include <cstring>
#include <QDebug>
#include <QUdpSocket>

bool bindSocketToLinuxInterface(
    QUdpSocket *socket,
    const QByteArray &interfaceName)
{
    if (!socket) {
        return false;
    }

    // 先进行一次bind,使Qt创建底层Socket描述符
    if (socket->state() ==
        QAbstractSocket::UnconnectedState) {
        if (!socket->bind(QHostAddress::AnyIPv4, 0)) {
            qWarning() << "创建底层Socket失败:"
                       << socket->errorString();
            return false;
        }
    }

    const qintptr descriptor =
        socket->socketDescriptor();

    if (descriptor < 0) {
        qWarning() << "无效Socket描述符";
        return false;
    }

    const int result = ::setsockopt(
        static_cast<int>(descriptor),
        SOL_SOCKET,
        SO_BINDTODEVICE,
        interfaceName.constData(),
        static_cast<socklen_t>(
            interfaceName.size() + 1)
    );

    if (result != 0) {
        qWarning() << "绑定Linux网卡失败:"
                   << interfaceName;
        return false;
    }

    return true;
}

#endif

调用:

cpp 复制代码
#ifdef Q_OS_LINUX
QUdpSocket socket;

if (!bindSocketToLinuxInterface(
        &socket,
        QByteArray("enp3s0"))) {
    return;
}

socket.writeDatagram(
    data,
    QHostAddress("192.168.20.50"),
    9000
);
#endif

需要注意:

  • 该方案只适用于 Linux/Kylin;
  • 可能需要额外权限;
  • 网卡名称可能因硬件或系统配置变化;
  • 跨平台项目应优先使用本地 IP 配置;
  • 网络拓扑冲突仍应从系统设计层解决。

七、推荐的工程化排查流程

多网卡问题不应依靠反复试错,可以固化为以下排查流程。

第一步:确认业务网络拓扑

记录以下内容:

项目 网卡1 网卡2
用途 办公网 设备专网
本地IP 192.168.1.100 192.168.20.10
掩码 255.255.255.0 255.255.255.0
默认网关 192.168.1.1
目标设备 192.168.20.50
协议 管理通信 UDP 9000

先明确哪张网卡应该负责哪类业务。


第二步:确认目标通信类型

区分以下场景:

text 复制代码
UDP单播
UDP广播
UDP组播
TCP客户端
TCP服务端

不同通信类型的接口选择方法不同。

尤其是广播和组播,必须额外关注出口接口。


第三步:询问操作系统准备怎么走

Windows:

powershell 复制代码
Test-NetConnection 192.168.20.50 -DiagnoseRouting

Kylin:

bash 复制代码
ip route get 192.168.20.50

不要先猜测程序走了哪张网卡,直接让操作系统给出答案。


第四步:检查应用 Socket 本地地址

Qt 中输出:

cpp 复制代码
qDebug() << "Local:"
         << socket->localAddress().toString()
         << socket->localPort();

qDebug() << "Peer:"
         << socket->peerAddress().toString()
         << socket->peerPort();

qDebug() << "State:"
         << socket->state();

qDebug() << "Error:"
         << socket->errorString();

如果本地地址为空、0.0.0.0 或不是设备专网 IP,就需要继续确认 Socket 是否正确绑定。


第五步:检查邻居解析

Windows:

bat 复制代码
arp -a

Kylin:

bash 复制代码
ip neigh

确认目标设备 MAC 是否出现在正确网卡下。


第六步:在两张网卡同时抓包

Windows 使用 Wireshark,Kylin 使用 tcpdump。

抓包应回答三个问题:

  1. 报文有没有发送;
  2. 报文从哪张网卡发送;
  3. 对端有没有返回。

第七步:分别修复系统层和应用层

系统层:

  • 不配置多余默认网关;
  • 消除相同或重叠网段;
  • 修正路由表;
  • 设置合理 Metric;
  • 修正策略路由;
  • 检查 VLAN 和交换机配置。

应用层:

  • 本地 IP 配置化;
  • Socket 创建后先绑定本地 IP;
  • 广播使用定向广播地址;
  • 组播指定 QNetworkInterface
  • 启动时验证配置 IP;
  • 输出本地地址和网络日志。

第八步:进行状态变化回归测试

不能只验证"当前能通信"。

至少应测试以下场景:

text 复制代码
程序先启动,网卡后连接
网卡先连接,程序后启动
禁用再启用网卡1
禁用再启用网卡2
拔插网线
程序重启
操作系统重启
目标设备后启动
两张网卡连接顺序变化
DHCP地址更新

只有这些场景都稳定,才能认为问题真正解决。


八、建议增加的运行日志

工业软件应将网络选路信息记录到日志中。

cpp 复制代码
void printSocketInfo(
    const QAbstractSocket *socket,
    const QString &tag)
{
    if (!socket) {
        return;
    }

    qInfo() << tag
            << "状态:" << socket->state()
            << "本地地址:"
            << socket->localAddress().toString()
            << "本地端口:"
            << socket->localPort()
            << "远端地址:"
            << socket->peerAddress().toString()
            << "远端端口:"
            << socket->peerPort()
            << "错误:"
            << socket->errorString();
}

同时建议程序启动时输出所有有效网卡:

cpp 复制代码
void printNetworkInterfaces()
{
    const auto interfaces =
        QNetworkInterface::allInterfaces();

    for (const QNetworkInterface &networkInterface :
         interfaces) {
        const auto flags = networkInterface.flags();

        qInfo() << "网卡名称:"
                << networkInterface.name()
                << "显示名称:"
                << networkInterface.humanReadableName()
                << "是否启用:"
                << flags.testFlag(
                       QNetworkInterface::IsUp)
                << "是否运行:"
                << flags.testFlag(
                       QNetworkInterface::IsRunning);

        for (const QNetworkAddressEntry &entry :
             networkInterface.addressEntries()) {
            if (entry.ip().protocol() !=
                QAbstractSocket::IPv4Protocol) {
                continue;
            }

            qInfo() << "  IP:"
                    << entry.ip().toString()
                    << "掩码:"
                    << entry.netmask().toString()
                    << "广播:"
                    << entry.broadcast().toString();
        }
    }
}

现场出现问题时,可以通过日志快速判断:

  • 配置 IP 是否存在;
  • 网卡是否启动;
  • Socket 是否绑定成功;
  • 实际源 IP 是否正确;
  • 发送失败发生在哪个阶段。

九、总结:把网络问题转化为可复用的方法论

双网卡通信问题的本质不是"Qt 选错了网卡",而是:

应用程序没有声明业务出口,因此操作系统只能根据路由表替应用作出选择。

解决这类问题可以固化为一套通用方法论。

方法一:业务网络必须显式建模

程序配置中至少应包含:

text 复制代码
本地IP
目标IP
本地端口
目标端口
通信协议
广播或组播参数

不能只配置目标 IP,而忽略本地出口。

方法二:系统路由负责可达性,应用绑定负责确定性

系统层回答:

text 复制代码
这个目标能不能到达?

应用层回答:

text 复制代码
这个业务必须从哪张网卡发送?

两者缺一不可。

方法三:先问路由,再看 Socket,最后抓包

标准排查顺序应为:

text 复制代码
确认地址
→ 查询路由
→ 检查Socket绑定
→ 检查邻居表
→ 双网卡抓包
→ 修复配置

不要从禁用网卡、重启程序等偶然操作开始。

方法四:多网卡广播和组播必须显式指定接口

单播可以根据目标路由选择出口,但广播和组播天然缺少明确的单一目标,因此更需要显式指定本地 IP 或网卡接口。

方法五:禁止把临时恢复当成永久修复

禁用再启用网卡能够恢复,只能证明:

text 复制代码
问题与路由、接口状态或缓存有关

它不能证明问题已经解决。

方法六:网络设计优先于代码补救

如果两张网卡使用相同或重叠网段,最优先的处理方式应是调整网络规划。

推荐结构:

text 复制代码
办公网:192.168.1.0/24
设备网:192.168.20.0/24

不推荐:

text 复制代码
办公网:192.168.1.0/24
设备网:192.168.1.0/24

代码可以提高确定性,但不能长期掩盖错误的网络拓扑。

最终可以将整个问题归纳为一句工程原则:

多网卡程序不能只配置"发给谁",还必须明确"从哪里发";系统路由保证能到达,应用绑定保证走对路。

相关推荐
从零开始的代码生活_2 小时前
C++ 继承详解:访问控制、对象模型、菱形继承与设计取舍
开发语言·c++·后端·学习·算法
云小逸2 小时前
【C++ 第七阶段:模板、泛型编程与工程综合详解】
开发语言·c++
Quz2 小时前
QML 信号与槽:直接绑定与跨文件通信
qt·编程语言·设计
GIS阵地3 小时前
QgsSingleBandPseudoColorRenderer 完整详解(QGIS 3.40.13 C++)
开发语言·前端·c++·qt·qgis
yaoxin5211233 小时前
470. Java 反射 - Member 接口与 AccessFlag
java·开发语言·python
groundhappy3 小时前
idalib安装和codex ida-mcp配置
linux·开发语言·python
小钻风33664 小时前
Spring Boot 文件上传详解:深入理解 MultipartFile 的使用与原理
java·开发语言
qq_452396235 小时前
第二篇:《Go 开发环境搭建:SDK、IDE、Module 与 Hello World》
开发语言·ide·golang
天青色等烟雨..6 小时前
全流程ArcGISPro空间分析、三维建模、可视化及Python融合应用技术
开发语言·python
丰锋ff6 小时前
1.2 Qt常用基础类型
开发语言·qt