概览
在前一篇文章中,我描述了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
来创建一个网桥。最终,这会调用带有请求 SIOCBRADDBR
的 ioctl
函数。
通过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
, SIOCSIFBR
和 SIOCBRDELBR
。
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
请求 - SIOCGIFINDEX
和 SIOCBRADDIF
。第一个用于查询 tap0
接口的索引,第二个用于将 tap0
接口添加到网桥。我们只关注第二个。
SIOCBRADDIF
由 br_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_if
是 br_add_if
和 br_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
执行以下操作:
-
进行一系列验证,确保该设备可以被绑定到网桥下。部分规则包括: a) 不允许类似非以太网的设备;
b) 已经被绑定的设备不允许再次绑定;
c) 设备本身不能是网桥;
d) 设备中不应存在
IFF_DONT_BRIDGE
标志;等等。 -
分配并初始化一个
net_bridge_port
结构体。稍后,该端口会被添加到网桥的port_list
中。 -
为设备注册一个接收处理函数
br_handle_frame
。发送到该设备的帧将由这个函数处理。我们后续会看到这个处理器具体做什么。 -
绑定设备,即让网桥成为这个设备的主控者。
-
将该设备的以太网地址作为本地条目添加到转发表中。
值得注意的是,在旧版本中,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
执行以下步骤:
-
学习源MAC地址并更新转发数据库。
-
如果目标地址是一个多播地址,那么调用
br_multicast_rcv
进行一些处理。 -
如何转发这个帧的决策取决于目标地址是广播地址、多播地址还是单播地址。规则如下:
注:如果接口处于混杂模式,那么无论目标地址是什么,这个帧都将被本地传递。而且,不一定需要开启混杂模式,因为在任何情况下,这个网桥处理器都会转发它。
下图来自“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/