Linux Bridge - Part 2

概览

前一篇文章中,我描述了Linux 网桥(bridge)的配置,并展示了一个实验,其中使用Wireshark来分析流量。在本文中,我将讨论当创建一个网桥时会发生什么,以及Linux 网桥(bridge)的工作原理。

与网桥(bridge)相关的源代码可以在这里找到。

网桥设备

摘自《深入理解LINUX网络内幕》:

在Linux中,网桥(bridge)是一个虚拟设备。因此,除非你将其与一个或多个实际设备绑定,否则它无法接收或传输任何数据。

阅读上一篇文章后,有人问我,既然网桥(bridge)是一个第2层设备,为什么我们在运行ifconfig br0时会看到一个IP地址与之相关联呢?答案是,Linux的实现将网桥(bridge)和路由器(router)的功能结合在了一起。我们知道,网桥(bridge)有一个上行链路,它可以连接到路由器。而在Linux网桥(bridge)中,这种连接被内置于内核内部。这个路由器实际上就是网桥(bridge)所绑定的实际设备。我们稍后会详细讨论这部分内容。为了理解网桥(bridge),我们可以简单地将这个IP地址视为网桥(bridge)的默认网关。如果我们想要一个私有网桥(bridge)的话,这个IP地址并不是必需的。

注:在Linux网络模型中,网桥(bridge)虽然本质上是二层设备,但其设计允许它拥有IP地址并执行层三的功能,主要是因为Linux将网桥(bridge)与路由器的部分功能进行了融合。这样做的目的是为了提供更加灵活的网络配置能力。然而,对于纯粹的层二桥接需求,这个IP地址可以忽略,因为桥接器的主要任务是在同一广播域内的设备之间转发数据包。

网桥数据结构

net_bridge 结构体的定义可以在这里找到。

下面仅列出了一些重要的字段:

struct net_bridge
{
    spinlock_t          lock;
    struct list_head    port_list;
    struct net_device   *dev;

    spinlock_t          hash_lock;
    struct hlist_head   hash[BR_HASH_SIZE];
    bridge_id           bridge_id;
    ...
}

port_list 是桥接器所拥有的端口列表。每个桥接器最多可以拥有 BR_MAX_PORTS(1024)个端口。dev 是指向表示桥接设备的 net_device 结构的指针。hash 是一个具有 BR_HASH_SIZE(256)个条目的转发哈希表。以便于快速查找和转发数据包。

创建网桥设备

可以通过命令 brctl addbr br0 来创建一个网桥。最终,这会调用带有请求 SIOCBRADDBRioctl 函数。

通过strace跟踪创建网桥的系统调用:

# strace brctl addbr br0
execve("/sbin/brctl", ["brctl", "addbr", "br0"], [/* 17 vars */]) = 0
...
ioctl(3, SIOCBRADDBR, "br0")            = 0
...

如果我们搜索 SIOCBRADDBR 在源代码中的实现,我们会发现它是由 br_ioctl_deviceless_stub 函数处理的,同时处理的还有另外三个请求 - SIOCGIFBR, SIOCSIFBRSIOCBRDELBR

int br_ioctl_deviceless_stub(struct net *net, unsigned int cmd, void __user
*uarg) {
    switch (cmd) {
    case SIOCGIFBR:
    case SIOCSIFBR:
        ...

    case SIOCBRADDBR:
    case SIOCBRDELBR:
        ...
    }
    ...
}

该函数在初始化函数 br_init 中进行注册。

int __init br_init(void) {
...
brioctl_set(br_ioctl_deviceless_stub);
...
}

br_ioctl_deviceless_stub 调用 br_add_bridge,后者分配一个表示网桥的 net_device 结构,并使用 register_netdev 将此设备添加到内核的接口列表中。

int br_add_bridge(struct net *net, const char *name)
{
    struct net_device *dev;
    int res;
    dev = alloc_netdev(sizeof(struct net_bridge), name, NET_NAME_UNKNOWN,
               br_dev_setup);
    ...
    res = register_netdev(dev);
    ...
    return res;
}

alloc_netdev 被定义为一个宏,它是 alloc_netdev_mqs 的简化版本,而后者实际上是一种通用方法,用于分配 net_device 结构体,并非特指网桥。网桥作为私有数据存储在 net_device 结构中。(私有数据是指附加在 net_device 结构后的内存段。)alloc_netdev_mqs 所接受的回调函数用于设置这部分私有数据。在网桥的情况下,这个回调函数是 br_dev_setup

struct net_device *alloc_netdev_mqs(..., void (*setup)(struct net_device *), ...) {
    struct net_device *dev;
    size_t alloc_size;
    ...
    alloc_size = sizeof(struct net_device);
    if (sizeof_priv) {
        /* ensure 32-byte alignment of private area */
        alloc_size = ALIGN(alloc_size, NETDEV_ALIGN);
        alloc_size += sizeof_priv;
    }
    /* ensure 32-byte alignment of whole construct */
    alloc_size += NETDEV_ALIGN - 1;
    p = kzalloc(alloc_size, GFP_KERNEL | __GFP_NOWARN | __GFP_REPEAT);
    ...
    dev = PTR_ALIGN(p, NETDEV_ALIGN);
    ...
    setup(dev);
    ...
    return dev
}

br_dev_setup 设置 net_device 的私有数据区域。它将这一段内存转换为 net_bridge 类型,并初始化每一个字段。

void br_dev_setup(struct net_device *dev) {
    struct net_bridge *br = netdev_priv(dev);
    ...
    dev->priv_flags = IFF_EBRIDGE;
    br->dev = dev;
    spin_lock_init(&br->lock);
    INIT_LIST_HEAD(&br->port_list);
    spin_lock_init(&br->hash_lock);

    br->bridge_id.prio[0] = 0x80;
    br->bridge_id.prio[1] = 0x00;
    ...
}

总结一下,创建一个网桥的调用栈如下:

ioctl(3, SIOCBRADDBR, "br0")
 |- br_ioctl_deviceless_stub
     |- br_add_bridge
         |- alloc_netdev
             |- br_dev_setup
         |- register_netdev

经过这一步骤后,我们就有了一个 Linux 网桥设备。但是,此时还没有任何接口绑定到它上面,这意味着网桥还不能传输或接收任何数据。

添加接口

创建网桥之后,我们可以向它添加接口(端口)。尽管《深入理解LINUX网络内幕》一书中提到网桥必须绑定到一个"真实设备",我认为这句话只对了一半。确实,一个网桥设备必须绑定到一个网络接口。然而,这并不一定意味着它必须绑定到一个"真实设备",也就是说,它不必是实际物理网卡的接口。即使是 tap 接口,也可以绑定到网桥上使其正常工作。

用于绑定接口的命令是 brctl addif br0 tap0。这也被称为"从属(enslave)",即我们让 tap0 成为 br0 的从属接口。

使用strace跟踪网桥添加端口的系统调用:

# strace brctl addif br0 tap0
execve("/sbin/brctl", ["brctl", "addif", "br0", "tap0"], [/* 17 vars */]) = 0
brk(NULL)                               = 0xf28000
...
ioctl(4, SIOCGIFINDEX, {ifr_name="tap0", }) = 0
close(4)                                = 0
ioctl(3, SIOCBRADDIF)                   = 0

它发出两个 ioctl 请求 - SIOCGIFINDEXSIOCBRADDIF。第一个用于查询 tap0 接口的索引,第二个用于将 tap0 接口添加到网桥。我们只关注第二个。

SIOCBRADDIFbr_dev_ioctl 函数处理。

int br_dev_ioctl(struct net_device *dev, struct ifreq *rq, int cmd) {
    struct net_bridge *br = netdev_priv(dev);

    switch (cmd) {
    ...
    case SIOCBRADDIF:
    case SIOCBRDELIF:
        return add_del_if(br, rq->ifr_ifindex, cmd == SIOCBRADDIF);
    }
    ...
}

add_del_ifbr_add_ifbr_del_if 的包装器。在添加接口的情况下,会调用 br_add_if。以下是 br_add_if 中的一些重要步骤。

int br_add_if(struct net_bridge *br, struct net_device *dev) {
    struct net_bridge_port *p;
    ... (Validation)
    p = new_nbp(br, dev);
    ...
    err = netdev_rx_handler_register(dev, br_handle_frame, p);
    ...
    err = netdev_master_upper_dev_link(dev, br->dev);
    ...
    list_add_rcu(&p->list, &br->port_list);
    nbp_update_port_count(br);
    ...
    if (br_fdb_insert(br, p, dev->dev_addr, 0))
        netdev_err(dev, "failed insert local address bridge forwarding table\n");
    ...
    return err;
}

基本上,br_add_if 执行以下操作:

  1. 进行一系列验证,确保该设备可以被绑定到网桥下。部分规则包括: a) 不允许类似非以太网的设备;

    b) 已经被绑定的设备不允许再次绑定;

    c) 设备本身不能是网桥;

    d) 设备中不应存在 IFF_DONT_BRIDGE 标志;等等。

  2. 分配并初始化一个 net_bridge_port 结构体。稍后,该端口会被添加到网桥的 port_list 中。

  3. 为设备注册一个接收处理函数 br_handle_frame。发送到该设备的帧将由这个函数处理。我们后续会看到这个处理器具体做什么。

  4. 绑定设备,即让网桥成为这个设备的主控者。

  5. 将该设备的以太网地址作为本地条目添加到转发表中。

值得注意的是,在旧版本中,br_add_if 明确地将设备置于混杂模式。在 4.0 版本的内核中,这一点由 nbp_update_port_count 函数处理。

下图来自"Linux 网桥剖析",展示了上述提及的函数之间的关系。

转发数据库

转发数据库存储了MAC地址与端口的映射关系。实现上,它使用了一个大小为 BR_HASH_SIZE(256)的哈希表(实际上是一个数组)作为转发数据库。数组中的每一项存储了一个单向链表的头指针(一个桶),该链表存储了所有哈希值落入该桶的MAC地址条目。参考上述 net_bridge 结构体中的 struct hlist_head hash[BR_HASH_SIZE]。MAC地址的哈希值由 br_mac_hash 计算得出。关于哈希算法的细节,我将略过不提。

表项结构

net_bridge_fdb_entry 是上述提到的链表的元素类型。

struct net_bridge_fdb_entry
{
    struct hlist_node       hlist;
    struct net_bridge_port  *dst;

    struct rcu_head         rcu;
    unsigned long           updated;
    unsigned long           used;
    mac_addr                addr;
    unsigned char           is_local:1,
                            is_static:1,
                            added_by_user:1,
                            added_by_external_learn:1;
    __u16                   vlan_id;
};

查找

由于实现方式采用了哈希表,查找过程与任何哈希表的查找相同。当网桥需要确定特定MAC地址的数据帧应转发到哪个端口时,它会在转发数据库中查找。因此,哈希表的键就是MAC地址。首先,它通过 br_mac_hash 获取MAC地址的哈希值,然后从该表项获取链表。接下来,它遍历整个链表,将MAC地址与每个元素进行比较,直到找到匹配的那一项。

更新条目

添加、更新和删除条目的操作都是典型的哈希表操作。我不会重复介绍它们是如何工作的,而是专注于这些操作何时发生。

当一个接口被添加到网桥上(即调用 br_add_if 时),会调用 br_fdb_insert 将被绑定设备的MAC地址插入到转发数据库中。 当本地端口上的设备更改其MAC地址(例如,通过命令 ifconfig eth0 hw ether 11:22:33:44:55:66),转发表中的条目会被更新。 当条目过期时,会删除该条目。通常情况下,如果一段时间内未使用某个条目,则该条目会过期。默认过期时间为5分钟,但这可以配置。定期调用 br_fdb_cleanup 来清理已过期的条目。

帧处理

入站数据首先由 netif_receive_skb 处理,这是一个通用的、与设备无关的函数。它会调用接收数据所在设备的 rx_handler。还记得在 br_add_if 中,我们为被绑定的设备注册了一个接收处理器 br_handle_frame 吗?在这里,这个处理器将被调用来处理设备上收到的数据。

br_handle_frame 首先进行一些基本检查,以确保这个帧是一个有效的以太网帧。然后它检查目的地是否为一个预留地址,这意味着这是一个控制帧。如果是的话,需要进行特殊处理。否则,它会调用 br_handle_frame_finish 来处理这个帧。

rx_handler_result_t br_handle_frame(struct sk_buff **pskb) {
    const unsigned char *dest = eth_hdr(skb)->h_dest;
    ... (Validation code)
    if (unlikely(is_link_local_ether_addr(dest))) {
        /*
         * See IEEE 802.1D Table 7-10 Reserved addresses
         *
         * Assignment               Value
         * Bridge Group Address     01-80-C2-00-00-00
         * (MAC Control) 802.3      01-80-C2-00-00-01
         * (Link Aggregation) 802.3 01-80-C2-00-00-02
         * 802.1X PAE address       01-80-C2-00-00-03
         *
         * 802.1AB LLDP         01-80-C2-00-00-0E
         *
         * Others reserved for future standardization
         */
        ... (Special processing for control frame)
    }

    ...
    NF_HOOK(NFPROTO_BRIDGE, NF_BR_PRE_ROUTING, skb, skb->dev, NULL,
            br_handle_frame_finish);
    ...
}

br_handle_frame_finish 执行以下步骤:

  1. 学习源MAC地址并更新转发数据库。

  2. 如果目标地址是一个多播地址,那么调用 br_multicast_rcv 进行一些处理。

  3. 如何转发这个帧的决策取决于目标地址是广播地址、多播地址还是单播地址。规则如下:

注:如果接口处于混杂模式,那么无论目标地址是什么,这个帧都将被本地传递。而且,不一定需要开启混杂模式,因为在任何情况下,这个网桥处理器都会转发它。

下图来自"Linux 网桥剖析",可能也有助于理解帧的处理过程。

结论

在这篇文章中,我们探讨了Linux网桥的实现方式,以及当我们配置网桥时所发生的事件,还有网桥如何处理帧的过程。这里有一个实验你可以尝试。首先,添加一个网桥。然后启动两个连接到这个网桥的虚拟机。通过命令 brctl showmacs br0 查看网桥的MAC地址表。看看两台虚拟机之间是否可以互相ping通。你还可以使用 tcpdump 或者 Wireshark 来捕获流量。回想一下每一步操作时发生了什么。

参考文献

[1] Benvenuti, Christian. 《深入理解LINUX网络内幕》. "O'Reilly Media, Inc.", 2006.

[2] Linux网桥的剖析

[3] Linux网桥 - 其工作原理

Ref

https://hechao.li/2018/01/31/linux-bridge-part2/

相关推荐
龙哥说跨境41 分钟前
如何利用指纹浏览器爬虫绕过Cloudflare的防护?
服务器·网络·python·网络爬虫
懒大王就是我1 小时前
C语言网络编程 -- TCP/iP协议
c语言·网络·tcp/ip
Elaine2023911 小时前
06 网络编程基础
java·网络
海绵波波1072 小时前
Webserver(4.3)TCP通信实现
服务器·网络·tcp/ip
热爱跑步的恒川5 小时前
【论文复现】基于图卷积网络的轻量化推荐模型
网络·人工智能·开源·aigc·ai编程
云飞云共享云桌面6 小时前
8位机械工程师如何共享一台图形工作站算力?
linux·服务器·网络
音徽编程8 小时前
Rust异步运行时框架tokio保姆级教程
开发语言·网络·rust
幺零九零零9 小时前
【C++】socket套接字编程
linux·服务器·网络·c++
23zhgjx-NanKon10 小时前
华为eNSP:QinQ
网络·安全·华为
23zhgjx-NanKon10 小时前
华为eNSP:mux-vlan
网络·安全·华为