STP(生成树协议)的核心任务是在二层网络中消除环路,而根端口的选举是每个非根桥都要完成的动作。一个非根桥可能从多个端口收到不同邻居发来的BPDU,这些BPDU携带了到达根桥的路径开销、发送者桥ID和发送者端口ID等信息。根端口就是那个综合比较后最优的端口。用Ruby模拟这一过程,可以把复杂的协议流程压缩成几个类和一个排序函数,特别适合理解选举的优先级顺序。

根端口选举的字段与比较顺序
根端口选举遵循一套严格的比较规则。非根桥会先比较每个候选端口到达根桥的累计路径开销,数值越小越优先。路径开销来源于端口带宽,旧版802.1D标准中10Mbps端口开销为100,100Mbps为19,1Gbps为4,10Gbps为2。在现代网络中,管理员也可以手工调整端口开销来引导流量。如果两个端口到达根桥的路径开销相同,就比较发送者桥ID。桥ID由两部分组成:桥优先级和MAC地址,桥优先级的取值范围是0到61440,步长为4096,默认值是32768。比较时先看优先级数字,数字小者胜出;优先级相同再看MAC地址,同样按字典序比较小的更优。如果发送者桥ID还相同,最后比较发送者端口ID,端口ID同样由端口优先级和端口号组成。这种层层递进的比较逻辑,在Ruby中可以用嵌套数组排序实现。
可以把每一个候选端口表示成一个三元组 [路径开销, 发送者桥ID, 发送者端口ID],然后直接使用Ruby的数组比较。Ruby会先比较第一个元素,如果相同再比较第二个元素,依此类推。这里有一个容易混淆的地方:根端口比较的是BPDU中携带的累计路径开销,不是本地端口自己的端口开销。在真实协议中,累计路径开销在BPDU传播过程中不断累加,每个转发交换机会把出端口的端口开销加到Root Path Cost字段上。本文为了简化模拟,直接在端口对象上保存已经计算好的总路径开销值。
另外,根端口选举只发生在非根桥上。根桥自己不会有根端口,因为根桥就是生成树的根,它所有端口都是指定端口。在代码中需要先做一次判断,如果当前桥ID等于根桥ID,直接返回空值。
用Ruby定义桥和端口模型
为了让选举函数足够直观,可以先定义两个类。Bridge类保存桥ID和端口列表,Port类保存端口名称、本地端口开销以及从该端口收到的BPDU中提取的三个比较字段。桥ID使用数组 [优先级, MAC地址] 表示,端口ID使用数组 [端口优先级, 端口号] 表示。这种数组表示方便直接利用Ruby内建的元素比较规则,不需要为桥ID和端口ID单独定义比较方法。
class Bridge
attr_accessor :bridge_id, :ports
def initialize(bridge_id, ports = [])
@bridge_id = bridge_id
@ports = ports
end
end
class Port
attr_accessor :name, :port_cost, :path_cost_to_root,
:designated_bridge_id, :designated_port_id
def initialize(name, port_cost)
@name = name
@port_cost = port_cost
@path_cost_to_root = nil
@designated_bridge_id = nil
@designated_port_id = nil
end
end
Port类中的 path_cost_to_root 表示从该端口到达根桥的总路径开销,这个值在真实网络中等于收到的BPDU中的Root Path Cost加上本端口的端口开销。这里直接保存算好的总开销,是为了让选举函数只关注比较逻辑。如果希望模拟BPDU转发过程,可以再加一个方法,让交换机在转发BPDU时把出端口的 port_cost 累加进去。
实际项目里,桥ID的MAC地址建议统一格式,例如 "00:00:00:00:00:01",不能混用缩写。Ruby字符串比较基于字节顺序,只要所有MAC地址格式一致,比较结果就与STP规范一致。端口ID中的端口优先级和端口号也需要保持相同的数据类型,避免整数和字符串混用导致排序错误。
实现选举函数与测试用例
选举函数可以写成下面这样。先判断当前桥是不是根桥,如果是则返回 nil,因为根桥不需要根端口。然后从端口列表中筛选出所有已经收到BPDU的端口,避免空值参与排序。最后使用 min_by 返回按三元组排序后的最小值。这里没有显式写出比较运算符,因为Ruby数组本身已经实现了完整的 <=> 方法,会按照我们需要的顺序逐个比较字段。
def elect_root_port(bridge, root_bridge_id)
return nil if bridge.bridge_id == root_bridge_id
candidates = bridge.ports.select { |port| !port.designated_bridge_id.nil? }
return nil if candidates.empty?
candidates.min_by do |port|
[
port.path_cost_to_root,
port.designated_bridge_id,
port.designated_port_id
]
end
end
这段代码的核心在于 min_by 的块返回一个数组。Ruby会先比较所有候选端口的第一个元素,即路径开销;如果某个端口路径开销最小,它直接成为根端口。如果多个端口路径开销相同,Ruby继续比较第二个元素 designated_bridge_id,也就是发送者桥ID。发送者桥ID本身是一个数组 [优先级, MAC],所以同样会先比优先级,再比MAC地址。最后才比较第三个元素 designated_port_id。这种写法比手动写多层 if 更清晰,也更容易扩展。
接下来构造一个具体场景验证选举结果。假设有三台交换机:SW1是根桥,优先级设为4096,MAC地址为 00:00:00:00:00:01;SW2是非根桥,优先级32768,MAC地址为 00:00:00:00:00:02;SW3优先级32768,MAC地址为 00:00:00:00:00:03。SW2有两个端口:eth0收到的BPDU来自SW1,路径总开销为4;eth1收到的BPDU来自SW3,路径总开销同样为4。由于路径开销相同,SW2需要比较发送者桥ID。SW1的优先级4096小于SW3的32768,所以eth0应该成为根端口。
sw1 = Bridge.new([4096, "00:00:00:00:00:01"])
sw2 = Bridge.new([32768, "00:00:00:00:00:02"])
sw3 = Bridge.new([32768, "00:00:00:00:00:03"])
p1 = Port.new("eth0", 4)
p1.path_cost_to_root = 4
p1.designated_bridge_id = sw1.bridge_id
p1.designated_port_id = [128, 1]
p2 = Port.new("eth1", 4)
p2.path_cost_to_root = 4
p2.designated_bridge_id = sw3.bridge_id
p2.designated_port_id = [128, 2]
sw2.ports = [p1, p2]
root_port = elect_root_port(sw2, sw1.bridge_id)
puts root_port.name # eth0
运行这段代码会输出 eth0。如果把eth0的路径开销改成19,eth1保持4,那么eth1会胜出,因为路径开销差异优先于桥ID比较。这个例子说明了比较顺序的重要性:任何低优先级字段的差异都不能覆盖高优先级字段的结果。
模拟实现的局限与扩展方向
上面的实现只覆盖了根端口选举的最小逻辑,离真正的STP交换机还有很大距离。真实STP中,交换机需要周期性发送和接收BPDU,维护端口状态机,处理拓扑变更通知,并配合定时器完成端口从阻塞到转发的过渡。本文的代码没有模拟BPDU报文结构、没有处理端口优先级配置、也没有考虑多生成树实例。这些省略的部分对理解选举规则没有影响,但如果要用于教学演示或故障模拟,可以逐步补全。
一个自然的扩展方向是引入 BPDU 类,把根桥ID、累计路径开销、发送者桥ID、发送者端口ID封装起来。然后让 Port 类维护上次收到的BPDU,并提供一个 receipt 方法。选举函数就可以直接从BPDU对象中取值,数据流更接近真实实现。另一个方向是加入端口角色标记,比如根端口、指定端口、阻塞端口,并在交换机对象上维护这些状态。
对于Ruby代码本身,也可以进一步抽象:把选举逻辑做成模块,方便在多个交换机对象之间复用。如果桥ID和端口ID需要支持自定义比较,可以实现 Comparable 模块,但考虑到数组已经提供了所需行为,不必过度设计。重点仍然是保持与STP规范一致的比较顺序,先路径开销,再发送者桥ID,最后发送者端口ID。这个顺序一旦写反,选举结果就可能在冗余链路中选出次优路径。