碰到一个很实际的需求:设备上插了多块网卡,程序需要知道每块网卡都配了什么IP、谁是对外默认网关,并且在访问某个目标IP时,能自己决定走哪条出口。Go语言里第一反应是翻net包,但翻完会发现它只管给你 IP 和掩码,路由表和网关一概不提供。真正能拿到完整信息的地方,只有syscall这一层直接和内核交互。
这篇文章我用自己的实现思路聊一遍:在Linux下用Go的syscall读取本机网卡IP、网关,并实现一段按目标IP做最长前缀匹配的选路逻辑。内容面向的是需要做多网卡路由策略、边缘网关设备选路、局域网资源探测这类场景的程序员,看完可以直接把方案搬进自己的工程里。
1. 为什么非要用syscall:标准库的边界与痛点
1.1 net包能给你什么,不能给你什么
Go标准库的net包提供了比较方便的接口枚举能力,net.Interfaces()可以列出所有网卡,net.InterfaceAddrs()可以取到每块网卡的地址列表,net.InterfaceByName("eth0")可以按名字单独查。桌面开发、简单运维脚本基本用这些就够了。
但它的能力边界很明显:它拿不到路由表,拿不到每张路由表项对应的网关,也拿不到内核视角下“访问某个目标IP到底该走哪块网卡”的决策结果。你拿着net.Interface的结构体看半天,里面只有索引、名字、MTU、硬件地址和标记位,没有路由信息。
这还不是最难受的。更麻烦的是在多网卡场景下,默认路由可能指向任意一块网卡,也可能不存在。程序想判断“目标IP是内网还是外网”“该走有线还是无线”“该走专线还是走备份链路”,只靠标准库答案永远是零。
1.2 syscall在这里补上哪块拼图
syscall翻译成人话,就是直接向操作系统内核发起调用,绕开标准库的封装。在Linux上,获取网关和路由表最稳的途径是读/proc/net/route,这个文件由内核实时生成,内容就是系统当前的路由表快照。获取网卡细节则可以用ioctl发给内核,比如SIOCGIFADDR拿IP地址、SIOCGIFNETMASK拿子网掩码、SIOCGIFHWADDR拿MAC地址。
用Go的syscall做这些事,本质就是在标准库的舒适区之外,接管一部分“问内核要情报”的工作。这件事值得做,是因为它把选路的主动权完全拿到应用层:网关设备里的程序,往往有比内核默认策略更复杂的分流需求,比如指定某条业务流量必须走特定出口,而其他流量走默认路由。
1.3 方案选型:什么时候需要这套东西
先说清楚,不是所有项目都需要这么折腾。如果你只是临时查一下本机IP,ip addr、ifconfig命令足够。但下面几种情况,syscall方案几乎是绕不开的:
- 需要无外部依赖地获取默认网关,程序要部署在裁剪版Linux或嵌入式环境中,系统里不一定有
ip命令。 - 需要在进程内做多条链路的健康探测和自动切换,读取路由表是为了拿到“当前到底哪个是默认出口”。
- 需要根据目标IP前缀把请求分到不同网卡,比如访问A网段走有线口,访问B网段走无线口,且不想依赖内核策略路由。
总结一下:标准库负责“看见网卡”,syscall负责“看见路由和网关”,二者配合才构成完整选路能力。
注意:以下代码面向Linux实现,目标架构按amd64和arm64的常规路径编写。Windows和macOS的系统调用接口不一样,后半部分单独说差异。
2. 环境准备与底层数据结构
2.1 先弄清楚/proc/net/route里到底是什么
先花点时间把信息源搞明白,后面代码才不会写歪。/proc/net/route是内核输出的路由表文本,每一行是一条路由,第一行是表头。看一个典型输出:
Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT eth0 0000A8C0 00000000 0001 0 0 100 00FFFFFF 1500 0 0 eth0 00000000 0100A8C0 0003 0 0 100 00000000 1500 0 0逐列解读:
Iface:出接口名,比如eth0、wlan0。Destination:目标网段,十六进制小端表示。比如0000A8C0翻转字节顺序后是C0 A8 00 00,也就是192.168.0.0。Gateway:下一跳网关,同样是十六进制小端。0100A8C0翻转后是C0 A8 00 01,即192.168.0.1。Flags:路由标志位,0001表示UP,0003表示UP且GATEWAY,0002表示GATEWAY。重点记住掩码。Metric:路由优先级,数值小的优先。Mask:网段掩码,也是小端十六进制。00FFFFFF翻转后是255.255.255.0。
专业提示:这个文件里所有的IP都是小端存储的,直接拿来和net.IP比较会出大问题。我在第一次写解析器时就在这里踩了坑,后面单独讲。
2.2 ioctl与网络接口相关的常量和结构体
获取单个网卡信息时,syscall里最常配合的是ioctl函数。对网卡发起SIOCGIFADDR(拿IPv4)、SIOCGIFNETMASK(拿掩码)、SIOCGIFHWADDR(拿MAC),内核会通过一个struct ifreq结构把数据写回来,Go里就需要自己定义对应的结构体。
Linux下的ifreq通常是40字节:前16字节是接口名,后面24字节是数据区。好的地方在于,接口名的16字节是固定偏移的,数据区我们只需要关心的那几段,不必完整复刻C语言里的union。
定义完结构体之后,还需要把常量写出来。Linux下的值比较固定:
SIOCGIFADDR = 0x8915SIOCGIFNETMASK = 0x891BSIOCGIFHWADDR = 0x8927
这些常量可以从系统头文件里查到,Go的syscall包没有直接暴露,需要自己声明。使用的时候,配合syscall.Socket打开一个AF_INET的UDP套接字作为ioctl的句柄,然后发起调用。
2.3 指针操作与小端转换的先决知识
用syscall不可避免要接触unsafe.Pointer。ioctl的调用签名里需要传入uintptr(unsafe.Pointer(&ifr)),也就是说把ifreq结构体的内存地址传给内核,内核通过这个指针写回数据。Go的内存管理在这个环节是“睁一只眼闭一只眼”的,所以调用前要保证ifreq对象是局部栈变量,且调用过程中不会被GC顺手搬走。
小端转换是另一个必须提前掌握的技能。/proc/net/route给出的十六进制看起来是一串整数,实际含义是“按内存里的字节顺序倒着读”。最稳妥的转换方式是用位运算逐字节拆出来,而不要依赖binary.LittleEndian以外的方式。后面代码里你会看到,我写了一个hexToIP,它做的事就是把一个uint32拆成四个字节,按“低字节在前”的规则还原成点分十进制。
有了这些前置知识,接下来就可以写实际的代码了。
3. 获取本机网卡IP与网卡列表的完整实现
3.1 用标准库遍历接口
获取网卡列表这部分,我选择先用标准库拿到所有接口,再用syscall补充细节。这样最稳,因为接口的枚举逻辑标准库已经很成熟,不必从零解析/sys/class/net。
func listInterfaces() ([]net.Interface, error) { ifaces, err := net.Interfaces() if err != nil { return nil, err } return ifaces, nil }net.Interfaces()返回的每个net.Interface里面已经有Index、Name、MTU、HardwareAddr、Flags这些字段,多网卡场景下通常足够区分物理网卡和虚拟接口。判断一块网卡是不是“活着且能用的”,标准做法是检查Flags&net.FlagUp != 0以及Flags&net.FlagLoopback == 0。
拿到接口名之后,调用iface.Addrs()理论上可以拿到IP,但有些环境里返回的地址列表可能包含IPv6、链路本地地址等,我一般会把IPv4单独过滤出来:
func getIPv4ByIface(iface net.Interface) (net.IP, net.IPMask, error) { addrs, err := iface.Addrs() if err != nil { return nil, nil, err } for _, addr := range addrs { ipNet, ok := addr.(*net.IPNet) if !ok { continue } ip4 := ipNet.IP.To4() if ip4 == nil { continue } return ip4, ipNet.Mask, nil } return nil, nil, fmt.Errorf("no ipv4 address on %s", iface.Name) }这里To4()很关键。IPv4-mapped IPv6地址长得很像IPv4,但直接拿来做路由选路可能会带出多余的字节,调用To4()能把真正的4字节IP取出来,后续所有匹配逻辑才干净。
3.2 用ioctl补齐硬件信息
标准库已经能拿IP和掩码,为什么还要单独写一个用ioctl的函数?我给出的理由是:标准库拿不到MAC地址偏移量稳定的接口信息;而且既然本篇文章的主题是syscall,就一并演示如何通过SIOCGIFHWADDR补全网卡的物理地址。
const ( SIOCGIFHWADDR = 0x8927 ) type ifreq struct { Name [16]byte Data [24]byte } func getHardwareAddr(name string) (net.HardwareAddr, error) { fd, err := syscall.Socket(syscall.AF_INET, syscall.SOCK_DGRAM, 0) if err != nil { return nil, err } defer syscall.Close(fd) var ifr ifreq copy(ifr.Name[:], name) _, _, errno := syscall.Syscall(syscall.SYS_IOCTL, uintptr(fd), SIOCGIFHWADDR, uintptr(unsafe.Pointer(&ifr))) if errno != 0 { return nil, errno } // 在Linux的ifreq中,MAC地址位于Data[0:6] hw := make(net.HardwareAddr, 6) copy(hw, ifr.Data[:6]) return hw, nil }值得留意的是syscall.Syscall的返回值。第三个返回值是errno,只有它不为0时才说明调用出错,前两个返回值在ioctl场景下基本用不上。很多初学者在这里只判断第一个返回值,结果在某些系统上拿到一个正常返回的错误码,容易误导排查方向。
3.3 完整枚举函数
把上面的片段组合成一个能输出所有网卡基本信息的函数:
type InterfaceInfo struct { Name string IP net.IP Mask net.IPMask Gateway net.IP MAC net.HardwareAddr Up bool } func gatherInterfaces() ([]InterfaceInfo, error) { ifaces, err := net.Interfaces() if err != nil { return nil, err } var result []InterfaceInfo for _, iface := range ifaces { info := InterfaceInfo{ Name: iface.Name, Up: iface.Flags&net.FlagUp != 0, MAC: iface.HardwareAddr, } ip, mask, err := getIPv4ByIface(iface) if err == nil { info.IP = ip info.Mask = mask } result = append(result, info) } return result, nil }实际项目里,这个函数返回的数据可以用来做后续的“网关归属”判断。典型的例子是:默认路由的出口是eth1,但eth1却已经Down了,此时程序就应该放弃默认路由,转向其他仍然存活且有默认路由的接口。这类逻辑完全依赖上面的接口状态信息。
4. 读取路由表和默认网关的完整实现
4.1 /proc/net/route逐字段解析
路由表的解析是整篇文章的核心之一。我设计了一个结构体保存一条路由的完整信息:
type RouteEntry struct { Interface string Destination net.IP Gateway net.IP Mask net.IPMask Flags uint16 Metric int }解析时按行拆分,跳过表头:字段之间用空白分隔。特别要强调,每一行必须至少11个字段,不能只取前4个就完事,因为Mask和Metric都在后面。
func parseProcNetRoute() ([]RouteEntry, error) { data, err := os.ReadFile("/proc/net/route") if err != nil { return nil, err } lines := strings.Split(strings.TrimSpace(string(data)), "\n") if len(lines) < 2 { return nil, fmt.Errorf("empty route table") } var routes []RouteEntry for _, line := range lines[1:] { fields := strings.Fields(line) if len(fields) < 11 { continue } dest := hexToIP(fields[1]) gw := hexToIP(fields[2]) mask := hexToMask(fields[7]) flags, _ := strconv.ParseUint(fields[3], 16, 32) metric, _ := strconv.Atoi(fields[6]) routes = append(routes, RouteEntry{ Interface: fields[0], Destination: dest, Gateway: gw, Mask: mask, Flags: uint16(flags), Metric: metric, }) } return routes, nil }这段代码里隐藏着一个容易忽略的规则:Destination的值是网段起始地址,Mask是它对应的掩码。判断一条路由是否匹配某个目标IP,不能只比较目标IP等于Destination,而是要“目标IP与掩码相与”再和“Destination与掩码相与”比较。
4.2 十六进制字符串转IP和掩码
小端转换写起来看着简单,却是最容易出错的地方。我给出一个可以放进工具库的版本:
func hexToIP(hex string) net.IP { n, err := strconv.ParseUint(hex, 16, 32) if err != nil { return nil } return net.IPv4(byte(n), byte(n>>8), byte(n>>16), byte(n>>24)) } func hexToMask(hex string) net.IPMask { n, err := strconv.ParseUint(hex, 16, 32) if err != nil { return nil } return net.IPv4Mask(byte(n), byte(n>>8), byte(n>>16), byte(n>>24)) }解释一下为什么这样转换是正确的:内核在输出路由表时,把32位的IP按主机字节序打包成一个整数,而这个整数的内存布局在小端机器上是“低位字节在前”。byte(n)取的是最低8位,恰好是网络序里的第一个字节。四句组合起来,就完成了从内核格式到net.IP的还原。
同样,hexToMask返回net.IPMask,后续做最长前缀匹配时可以直接调用Mask.Size()拿到前缀长度,不需要自己数掩码里有几个1。这是Go标准库给我们省下的功夫。
4.3 从路由表里挑默认网关
有了完整的路由表,挑默认网关就很简单了。默认网关在路由表里通常表现为目标地址0.0.0.0、掩码0.0.0.0、且Flags里带有GATEWAY标记的一行。
func findDefaultGateway(routes []RouteEntry) *RouteEntry { for i := range routes { if r := &routes[i]; r.Gateway != nil && r.Destination.Equal(net.IPv4zero) && r.Mask.String() == "ffffffff" { return r } } return nil }注意这里Mask的判断:0.0.0.0/0的掩码在net.IPMask里是4个零字节,但net.IPMask.String()会输出ffffffff?实际上net.IPv4Mask(0,0,0,0).String()输出ffffffff是掩码的十六进制字符串表示,零掩码应该输出00000000。更严谨的写法是直接比较掩码长度:
func isDefaultRoute(r RouteEntry) bool { ones, bits := r.Mask.Size() return bits == 32 && ones == 0 && r.Destination.Equal(net.IPv4zero) }这点很关键:Mask.Size()返回两个值,ones是前缀中1的个数,bits是总位数。默认路由的前缀长度是0,也就是ones == 0。判断目标地址是否全零,用net.IPv4zero会更直观。
实际上,绝大多数情况下判断默认网关并不需要那么严格。只要掩码全0且目标是全0,不管Gateway是不是0.0.0.0,这一行都可以作为兜底出口。有些直连路由的Gateway是0.0.0.0,但真正的默认路由必然带一个非零网关。
4.4 为什么不用exec调用ip route命令
我遇到不少同学在实现网关获取时第一反应是exec.Command("ip", "route")。这个思路的优点是简单,缺点是脆弱。
ip命令不是所有系统都自带,裁剪版Linux和嵌入式设备上尤其如此;每次执行都要fork一个子进程,在高频探测场景下开销可观;输出格式随ip版本和系统配置略有差异,解析时容易出边界问题。相比之下,读/proc/net/route是内核直接暴露的文件,格式稳定、无额外进程开销、权限要求低,是更符合“程序化”的做法。
顺便说一句,如果你看内核文档,会发现/proc/net/route只支持IPv4路由。IPv6路由在/proc/net/ipv6_route里,格式完全不同,本文不展开,但知道有这么个边界很重要。
5. 按目标IP做路由选择:最长前缀匹配实战
5.1 核心逻辑与为什么是最长前缀
目标IP选路的原理可以这样理解:路由表里每条规则都是一个“网段范围”,比如192.168.1.0/24和192.168.1.128/25都包含了目标IP 192.168.1.200,但后者范围更小、更精确,内核会优先走后者。这种“范围越小越优先”的规则,在网络里就叫最长前缀匹配。
生活里类比一下快递分拣:送某个小区门口的快递,系统先查“这个城市有没有?!”全城都能送,但送到小区门口这层更具体,所以优先按更具体的地址派送。路由表也是一样,默认路由是城市级别兜底,具体网段是街道级别优先。
代码实现我采用线性扫描。对绝大多数设备,路由表条目数量是几十到几百,线性扫描的性能完全够用。如果真的遇到几万条BGP全量路由表的场景,再考虑用基数树之类的结构。
5.2 完整实现代码
func selectRoute(routes []RouteEntry, target net.IP) (RouteEntry, net.IP) { var best *RouteEntry bestPrefixLen := -1 target4 := target.To4() if target4 == nil { return RouteEntry{}, nil } for i := range routes { r := &routes[i] // 只考虑UP且GATEWAY或直连的路由 if r.Destination == nil || r.Mask == nil { continue } ones, bits := r.Mask.Size() if bits != 32 { continue } if !target4.Mask(r.Mask).Equal(r.Destination.To4()) { continue } if ones > bestPrefixLen { best = r bestPrefixLen = ones } } if best == nil { return RouteEntry{}, nil } return *best, best.Gateway }这个函数输入是解析好的路由表和目标IP,输出是命中的路由项以及网关。注意我的判断条件包含bits != 32,这是为了过滤掉IPv6掩码,避免下面对32位IP做掩码操作时产生类型混乱。另外target4.Mask(r.Mask)返回的是一个IP,参与比较时用net.IP.Equal方法,不能直接==。
5.3 将接口信息与选路结果合并
选路最终不但要返回网关,还要告诉上层“从哪块网卡出去”。这一步把接口IP和路由表的Interface字段合并起来即可:
func ResolveRoute(target net.IP) (ifaceName string, localIP net.IP, gateway net.IP, err error) { routes, err := parseProcNetRoute() if err != nil { return "", nil, nil, err } route, gw := selectRoute(routes, target) if route.Interface != "" { ifaces, _ := net.Interfaces() for _, iface := range ifaces { if iface.Name == route.Interface { ip, _, err := getIPv4ByIface(iface) if err == nil { return route.Interface, ip, gw, nil } } } } return "", nil, nil, fmt.Errorf("no route to %s", target) }这里有三个返回值:出接口名、本机在该接口上的源IP、下一跳网关。很多网络编程场景需要“绑定源IP发起连接”,这三样恰好就是关键参数。
5.4 边界情况:多网卡出口决策
多网卡场景下,最长前缀匹配帮你决定的是“匹配哪条路由”,但哪块网卡做出口,还跟路由的Flags有关:
- 命中一条带GATEWAY的路由,说明目标网段不是直连的,要经过网关转发,源IP应使用接口的IP。
- 命中一条没有GATEWAY的路由,说明目标网段和本机直连,通常意味着目标和你在同一个二层网络,网关字段会是0.0.0.0。
- 一条路由都没有命中,理论上应该走默认路由,默认路由就是0.0.0.0/0,前缀长度为0,所以匹配兜底依然有效。
- 默认路由也不存在,程序只能返回错误,这时候连本机自己都访问不了外网,没法强行造路。
值得注意的是,多网卡设备上经常出现“默认路由在eth0,但eth0的链路已经断了”的情况。解析出的路由表里可能会残留这条不健康的默认路由。判断接口是否Up,可以在合并阶段做二次校验:如果命中项的接口不是Up状态,就退而求其次选其他前缀稍短但仍然匹配的路由。这个策略我建议按业务场景决定,不能一概而论。
6. 常见问题与排查心得
6.1 /proc/net/route读出来是空的
排查顺序按经验从高到低:容器环境里通常看不到宿主机的路由表,读到的是容器自己的network namespace,里面可能只有一条默认路由或干脆为空;权限不足时文件也可能读不了,但实际出现概率较低;系统/proc没有正确挂载时也会出类似问题。
核实的方法很简单,先在命令行执行cat /proc/net/route,看看有没有内容。如果命令行有、程序里没有,大概率是路径拼错或者权限问题。如果命令行里也没有,就要检查程序是否运行在容器或者无特权模式下。
6.2 拿到的是IPv6或者链路本地地址
很多同学的初版程序会把fe80::...这种链路本地IPv6地址当成网卡IP,导致后续连接失败。过滤标准有三条:必须是IPv4(To4() != nil);不能是链路本地地址(ip.IsLinkLocalUnicast()为false);不能是回环地址(ip.IsLoopback()为false)。
另外,DHCP客户端未完成时,接口可能短暂持有169.254.x.x这种APIPA地址,这种地址通常在选路时要跳过。判断方法是ip.IsLinkLocalUnicast()在IPv4下会返回true。
6.3 大端小端问题导致路由匹配错误
这是我在实际项目中踩过最深的一道坑。第一次解析/proc/net/route时,我以为十六进制串直接和点分十进制的十六进制表示一致就行了,结果用192.168.0.0去匹配路由,永远匹配不上。原因就是文件里的小端表示需要翻转字节顺序。
排查技巧:先写一个单元测试,解析0000A8C0,断言它等于192.168.0.0;解析00FFFFFF,断言它等于255.255.255.0。只要这两个断言过了,基本可以认定转换函数没有错。
6.4 跨平台差异一瞥
这套实现默认Linux。Windows没有/proc,获取路由表通常要靠GetIpForwardTable或GetBestRoute,对应的Go封装在golang.org/x/sys/windows里;macOS基于BSD,可以用sysctl读路由信息,但数据格式跟Linux完全不同。
如果你需要一个跨平台方案,建议把接口枚举、路由读取、选路决策三个模块抽象成接口,然后为每个平台单独实现。核心的最长前缀匹配逻辑可以共用。这个抽象很值得做,因为无论底层怎么拿数据,选路算法本身是平台无关的。
最后再分享一个实测体会:程序在普通笔记本上编译运行一切正常,不代表在你的网关设备上也正常。嵌入式Linux经常没有
/proc/net/route的标准结构,或者接口命名完全不是你想象的eth0/wlan0;路由器上往往还有多条策略路由和规则表,/proc/net/route只显示main表,看不到策略路由的分流效果。所以,真正上线前,一定要把程序放到目标设备上,手工构造一个多网卡场景跑一遍验证。检查项目是否要兼容IPv6、是否要用策略路由,如果答案都是“是”,那这篇文章的代码只能作为第一版起点,还需要结合平台能力继续扩展。