资讯详情

Go实战:多网卡环境用syscall获取IP与路由表实现源IP选路

📅 2026/10/20 12:37:50 | 华诺云谱 👁 阅读
Go实战:多网卡环境用syscall获取IP与路由表实现源IP选路
干网络编程的都知道多网卡服务器是最容易翻车的地方。尤其是那种既挂了内网、又有外网出口还起了几个虚拟网卡的机器net.InterfaceAddrs()返回的你根本不敢信——经常第一个就是127.0.0.1要不就是10.0.0.2这种内网地址可真正对外通信要用的192.168.x.x反而排在后面。我一开始也图省事直接遍历net.Interface拿 IP结果在某个跨网段项目里吃了大亏客户端发出去的 UDP 包源地址居然选了 docker0 的地址对端一看来源不对直接丢包。后来老老实实用syscall去读网卡信息和路由表把“本机有哪些网卡、每张网卡什么网段、默认网关是谁”这三个问题彻底搞清楚才算把多网卡路由选择这件事理顺。这篇东西适合谁看如果你正在做网关程序、网络采集器、组播通信、或者任何需要指定源 IP 进行 socket 通信的项目尤其是机器上有多张物理/虚拟网卡的情况那这套用 syscall 拿 IP、拿网关、按目标 IP 选出口的实现思路可以直接抄走。1. 为什么多网卡环境下要用 syscall 拿 IP 和路由先别急着看代码我先把思路讲清楚。很多人会用标准库net包来获取本机 IP但多网卡环境下它有几个很难绕开的短板。1.1 net 包在多网卡场景下的尴尬net.InterfaceAddrs()返回的是所有接口的地址列表其中包括回环地址、虚拟网卡、隧道地址等。你以为你在遍历网卡实际上你在遍历一堆你根本不想用的地址。想要过滤出“能用的 IPv4 地址”传统的做法是挨个判断addrs, _ : net.InterfaceAddrs() for _, addr : range addrs { if ipNet, ok : addr.(*net.IPNet); ok { if ipNet.IP.To4() ! nil !ipNet.IP.IsLoopback() { // 拿它当本机 IP } } }这个逻辑在小服务器上没问题但多网卡环境下致命点在于你无法确定拿到的 IP 属于哪张网卡更无法判断它能不能访问某个目标地址。比如我有两张网卡eth0配了192.168.1.0/24eth1配了10.10.0.0/16。从遍历结果看它们都是合法 IP。你要访问的远端在10.10.8.8按说应该走eth1才对可 net 包给你的只有地址列表它不会告诉你“目标地址和哪张网卡在同一网段”。另一个坑是顺序不稳定。不同系统、不同网卡热插拔场景下接口的遍历顺序可能会变化你今天取Addrs()[1]是外网 IP明天机器重启后可能就变成内网 IP 了。这种不确定性在线上环境是没法接受的。1.2 syscall 能多拿到什么关键信息syscall 本质上就是在直接跟内核对话。它不光能拿到 IP 地址还能拿到网卡名、子网掩码、MAC、MTU、甚至直接读路由表。这就有意思了——拿到掩码之后我可以自己算目标 IP 是否在这张网卡的子网内拿到路由表之后我能知道默认网关到底是哪个地址。有了这三样信息多网卡路由选择就从一个“猜”的问题变成了一个“算”的问题。而且 syscall 拿到的数据更原始没有经过标准库的“加工过滤”反而更可靠。后面我会用一个 ioctl 的调用把网卡列表和地址一次性取回来再用掩码配对最后读/proc/net/route拿网关整个过程完全不依赖 net 包的接口顺序。2. syscall 枚举网卡 IP 的完整实现这一节直接上干货。下面这套代码的思路是通过 socket 文件描述符和ioctl系统调用向内核发起SIOCGIFCONF请求拿到所有网卡的ifreq结构。每个ifreq里包含了网卡名和该网卡上的 IPv4 地址一次调用就能把全部网卡枚举出来。2.1 Linux 下 ioctl 的核心结构体先定义两个关键结构体。ifreq是网卡请求结构前 16 字节存网卡名后 16 字节存地址信息ifconf是网卡列表缓冲结构告诉内核把枚举结果写到哪、能写多长。type ifreq struct { Name [16]byte Data [16]byte } type ifconf struct { Len int32 Buf uintptr }这里有个关键点Data是一个 16 字节的sockaddr结构。当协议族是AF_INET时前 2 字节是地址族标识值为 2紧接着 2 字节是端口第 5 到第 8 字节才是真正的 IPv4 地址。所以从Data里取 IP 时偏移量是从第 4 个字节开始取 4 字节。func parseIPv4(data [16]byte) net.IP { return net.IPv4(data[4], data[5], data[6], data[7]) }2.2 遍历网卡并解析 IP 地址的代码下面是完整的枚举函数。先创建一个AF_INET、SOCK_DGRAM的 socket然后用syscall.Syscall发起SYS_IOCTL。这里要提醒一下Go 在 Linux 下可以直接用syscall.SYS_IOCTL、syscall.SIOCGIFCONF这些常亮但跨平台切换时需要留意不同架构下的数值差异最好封装成平台文件。func listAllInterfaceIPv4() ([]net.IP, []string, error) { fd, err : syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) if err ! nil { return nil, nil, err } defer syscall.Close(fd) // 先分配一个 1024 字节的缓冲区如果不够内核会截断并设置 Len buf : make([]byte, 1024) ifc : ifconf{ Len: int32(len(buf)), Buf: uintptr(unsafe.Pointer(buf[0])), } _, _, errno : syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.SIOCGIFCONF, uintptr(unsafe.Pointer(ifc))) if errno ! 0 { return nil, nil, errno } var ips []net.IP var names []string n : int(ifc.Len) for offset : 0; offsetunsafe.Sizeof(ifreq{}) uintptr(n); offset int(unsafe.Sizeof(ifreq{})) { req : *(*ifreq)(unsafe.Pointer(buf[offset])) // 只处理 AF_INET family : uint16(req.Data[0]) | uint16(req.Data[1])8 if family ! syscall.AF_INET { continue } name : strings.TrimRight(string(req.Name[:]), \x00) ip : parseIPv4(req.Data) names append(names, name) ips append(ips, ip) } return ips, names, nil }这里有个容易被忽视的问题SIOCGIFCONF只会返回每个网卡上“第一个” IPv4 地址。如果一张网卡配了多个 IP比如弹性 IP 场景上面这个函数就只能抓到其中一个。后面常见问题部分我会讲怎么用SIOCGIFADDR把这几个地址全部拿全。2.3 拿掩码、MAC 等附加信息光拿 IP 还不够做路由选择的核心依据是“目标 IP 与网卡子网是否匹配”这时候必须把每张网卡的掩码也取回来。方法跟上面类似用SIOCGIFNETMASK请求传入网卡名内核把掩码填到Data[4:8]。func getInterfaceMask(fd int, name string) (net.IPMask, error) { req : ifreq{} copy(req.Name[:], name) _, _, errno : syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.SIOCGIFNETMASK, uintptr(unsafe.Pointer(req))) if errno ! 0 { return nil, errno } mask : net.IPv4Mask(req.Data[4], req.Data[5], req.Data[6], req.Data[7]) return mask, nil }同样可以拿 MAC 地址用SIOCGIFHWADDR取Data[0:6]。这套“逐个网卡查询附加属性”的方式比一次性拿SIOCGIFCONF更准因为你可以按需请求避免枚举时丢信息。实际项目里我一般是先把网卡名列表拿全然后对每张网卡单独调SIOCGIFNETMASK和SIOCGIFHWADDR这样数据最完整。顺便说一句也可以用netlink拿这些信息但那个要自己拼 netlink 消息、解析属性代码量大很多。对于 IPv4 场景ioctl 这套是真正“短平快”的路线。3. 读取系统路由表获取默认网关拿到网卡和掩码之后下一步是搞清楚“出口在哪”。很多人以为手工ip route看一眼就行但程序里不可能每发一个包都去跑外部命令。好在 Linux 把路由表直接暴露在/proc/net/route文件里用os.ReadFile就能读到然后用 syscall 或普通字符串解析都能处理。3.1 /proc/net/route 的字段解读打开/proc/net/route大概长这个样子Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT eth0 00000000 0100A8C0 0003 0 0 0 00000000 0 0 0 eth0 0001A8C0 00000000 0001 0 0 0 00FFFFFF 0 0 0这里有几个关键字段Iface网卡名。Destination目的网段小端十六进制表示。00000000表示默认路由。Gateway下一跳网关地址同样是小端十六进制。Flags路由标志位。重点看第 2 位值为0x2是否置位置了表示这是一条需要走网关的间接路由如果Destination不是00000000且Gateway是00000000这就是直连网段路由。Mask掩码小端十六进制。3.2 解析默认网关的代码实现下面的函数解析Flags和Destination来定位默认网关。核心逻辑很简单找Destination为全零、Flags带RTF_GATEWAY的那一行然后把Gateway字段从十六进制转成点分十进制。const RTF_GATEWAY 0x2 func getDefaultGateway() (string, error) { data, err : os.ReadFile(/proc/net/route) if err ! nil { return , err } lines : strings.Split(string(data), \n) for _, line : range lines[1:] { fields : strings.Fields(line) if len(fields) 5 { continue } if fields[1] ! 00000000 { continue } flags, err : strconv.ParseUint(fields[3], 16, 32) if err ! nil || flagsRTF_GATEWAY 0 { continue } gw : fields[2] addr, err : hexToIPv4(gw) if err ! nil { return , err } return addr, nil } return , fmt.Errorf(no default gateway found) } func hexToIPv4(hex string) (string, error) { if len(hex) ! 8 { return , fmt.Errorf(invalid hex address: %s, hex) } // 小端序反转两两一组倒序 b : []string{hex[6:8], hex[4:6], hex[2:4], hex[0:2]} ip : make([]byte, 4) for i, v : range b { n, _ : strconv.ParseUint(v, 16, 8) ip[i] byte(n) } return net.IP(ip).String(), nil }顺带提一下如果你想要的是“某张网卡自己的网关”而不是全局默认网关那就把判断条件从Destination 00000000换成Iface 你指定的网卡名再取该行的Gateway。这在多网卡选路时很常用比如内网网卡和外网网卡各自有不同的下一跳。3.3 网关与出口网卡的对应关系默认网关看起来只有一条但它背后对应着一张出口网卡。在上面的路由表示例里eth0的Gateway是192.168.0.1说明默认流量走eth0。如果程序里只知道网关是192.168.0.1但不知道对应网卡名后面定源 IP 还是会有问题。所以我在工程里会顺手封装一个“默认出口”函数解析路由表时不仅返回网关地址还把Iface字段一并返回。这样拿到一个网关之后就能立刻对应到某张网卡再用上一节的getInterfaceMask去拿掩码、用SIOCGIFADDR拿 IP整套数据就闭环了。type RouteEntry struct { Interface string Gateway string Dest string Mask string }4. 多网卡路由选择的决策逻辑前面准备好了三样东西每张网卡的 IP、每张网卡的掩码、默认网关。现在可以写一个“按目标 IP 选路”的函数了。4.1 最长前缀匹配思路路由选择的内核策略叫“最长前缀匹配”Longest Prefix Match。说白了就是如果目标 IP 同时落在两个子网里谁的掩码更长网段更小更精确就走谁。举个例子目标 IP 是10.10.3.8eth0的子网是10.0.0.0/8eth1的子网是10.10.0.0/16那么应该选eth1因为/16比/8更精确系统会把数据交给更具体的路由。在程序里这个决策就是“算一遍所有网卡的子网包含关系记录掩码最长的那一个”。4.2 完整的路由选择函数代码下面这个函数接收一个目标 IP返回应该使用的本地 IP、对应网卡名和网关。它的步骤是解析目标 IP 为net.IP。获取所有网卡名和 IPv4 地址。对每张网卡取掩码算出网段边界。判断目标 IP 是否落在该网段内若是且掩码比当前最优更长则记录为候选。如果最终没有候选就走默认网关那条路。func SelectRouteByDestIP(dest string) (localIP, ifaceName, gateway string, err error) { destIP : net.ParseIP(dest).To4() if destIP nil { return , , , fmt.Errorf(invalid destination IP: %s, dest) } fd, err : syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) if err ! nil { return , , , err } defer syscall.Close(fd) ips, names, err : listAllInterfaceIPv4() if err ! nil { return , , , err } var bestMask net.IPMask var bestIP net.IP var bestName string for i, ip : range ips { mask, err : getInterfaceMask(fd, names[i]) if err ! nil { continue } network : ip.Mask(mask) if network ! nil network.Contains(destIP) { // 选更长掩码更精确的匹配 if bestMask nil || mask.Bits() bestMask.Bits() { bestMask mask bestIP ip bestName names[i] } } } if bestIP ! nil { gw, _ : getGatewayByInterface(bestName) return bestIP.String(), bestName, gw, nil } // 没有直连匹配走默认网关 gw, err : getDefaultGateway() if err ! nil { return , , , err } ifaceName getInterfaceOfGateway(gw) localIP getIPv4OfInterface(ifaceName) return localIP, ifaceName, gw, nil }getGatewayByInterface就是解析路由表时把Iface eth0那行的Gateway拿出来getIPv4OfInterface则是根据网卡名从前面枚举结果里找 IP。这两个函数实现都不复杂代码省略关键是整个决策链已经完整了。4.3 多网卡环境下的实测效果我把这套函数放到某台三网卡机器上实测过一张网卡eno1连着办公内网192.168.50.0/24一张enp2s0连着生产网段172.16.1.0/24还有一张eth0连着数据中心出口10.0.0.0/8。传入192.168.50.88函数正确返回eno1的 IP192.168.50.33作为源地址传入172.16.1.77返回enp2s0的 IP传入公网地址比如114.114.114.114返回默认网关注册给eth0的那个 IP。整个决策时间在微秒级完全不需要调用外部命令。实测中发现一个关键细节不能用“网卡名直接查路由表”来代替最长前缀匹配。因为 Linux 本身允许不同网卡配置重叠子网比如都配了10.0.0.0/8的一部分这在内网环境里并不罕见。这种情况下谁掩码长就走谁跟拿到数据的顺序无关决策是确定性的。5. 常见问题与排查实录这套方案我在两个项目里踩过不少坑直接整理成速查表省得你重新踩一遍。5.1 ioctl 拿到的 IP 列表不完整SIOCGIFCONF默认只返回每个网卡的第一个 IP。如果你的网卡上绑定了多个 IPv4 地址比如高可用虚IP场景一定要对每张网卡单独发起SIOCGIFADDR请求。做法是枚举完之后对每个网卡名循环调用SIOCGIFADDR如果返回EADDRNOTAVAIL就说明没有更多地址了可以停止。func getAllIPv4OfInterface(fd int, name string) ([]net.IP, error) { var ips []net.IP for i : 0; i 16; i { req : ifreq{} copy(req.Name[:], name) // 某些实现需要设置 index 或地址序号Go 里可以直接复用 SIOCGIFADDR // 实际场景通常取第一个就够如果要做多IP发现可改用 netlink _, _, errno : syscall.Syscall(syscall.SYS_IOCTL, fd, syscall.SIOCGIFADDR, uintptr(unsafe.Pointer(req))) if errno ! 0 { break } ips append(ips, parseIPv4(req.Data)) } return ips, nil }诚实说要完全获取一张网卡上的所有 IP还是 netlink 最干净RTM_GETADDR一次能把所有地址和属性带出来。但如果你目标只是“找到出口 IP”那SIOCGIFCONF配合默认网关反查已经完全够用了。5.2 网卡名变化、虚拟接口干扰机器上常驻着lo、docker0、vethxxxx这些虚拟接口。它们也有 IP也可能被子网匹配逻辑选中这在最常见的问题榜单里排第一。解决方案是过滤枚举时排除lo再一眼看出前缀是docker、veth、br-的接口直接跳掉。如果你用的是云主机还可能遇到eth0、ens3、enp1s0f0这种名字别在代码里写死eth0当默认网卡。一个更稳的策略是先用默认网关反查网卡名再把这个网卡名列为“默认出口网卡”这样就算机器命名规则五花八门程序也能自适配。5.3 字节序与 32/64 位平台差异解析/proc/net/route时最容易踩的坑就是字节序。文件里存的0100A8C0对应 IP 其实是192.168.0.1因为内核用 little-endian 存储。如果你在代码里直接ParseUint(hex, 16, 32)然后转net.IPv4出来的顺序肯定是反的。最简单的处理就是像我上面那样把字符串两位一组反转后再拼接。另外ifreq结构里有些字段在不同架构下对齐方式不同比如 32 位系统下指针是 4 字节64 位下是 8 字节。使用unsafe.Sizeof(ifreq{})来取结构体大小别用硬编码的 16否则在 32 位机器上会踩内存越界。如果是纯 IPv4 场景unsafe.Pointer的转换是安全的但要注意 Go 的 GC 不会移动这些由make([]byte)分配的栈上缓冲可以放心传指针。5.4 什么时候回到 net 包更合适如果只是“知道本机有几个 IP”这种轻量需求用net包完全没错别为了炫技去写一坨 ioctl。但如果你明确知道“我要给某个目标 IP 选一个合适的源地址”那 net 包就真不顶用必须结合掩码和路由表。另一种选择是golang.org/x/net/route它封装好了 FreeBSD 和 Linux 的路由表读取但返回的字段解析起来也不比直接读/proc省事多少还多一层依赖。我的建议是一分为二net包用来拿接口名列表、判断up状态syscall 部分只负责拿掩码和路由表。两套结合代码可读性和稳定性都能兼顾。最后说两句我的体会在线上环境里调试多网卡问题最怕的就是“看起来一切都对就是不通”。我后来总结出的经验是不要相信任何默认选路结果一定要在代码里把源IP、目标IP、出口网卡、网关这四个字段打出来在启动时给自己留一份日志。一旦发现源IP选错了立刻回到这套 syscall 方案里做路由决策基本都能定位到问题。另外一个小技巧把路由表缓存起来每隔 30 秒刷新一次即可不用每次发包都读一遍/proc/net/route。网卡的up/down事件监听可以用net.Interface配合轮询状态标记实现或者直接订阅 netlink 消息这个要看你对实时性的要求。对于大多数 UDP 采集器、TCP 转发器来说30 秒的缓存足够用了既省了 syscall 次数又能在网卡切换后自动恢复。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑