生成树协议(Spanning Tree Protocol,简称STP)是二层交换网络中防止环路的核心协议。它通过在交换机之间交换BPDU报文,选举出根桥、根端口和指定端口,最终把存在环路的物理拓扑修剪成一棵无环的逻辑树。在这套选举机制中,路径开销(Path Cost)扮演着极其重要的角色:它决定了根端口的归属,也决定了指定端口的胜负。本文将使用Ruby来实现一个简化版的STP指定端口路径开销计算模型,帮助读者从代码层面理解这一经典协议。

一、STP路径开销的基本原理
在STP的比较流程中,每台交换机收到来自根桥的BPDU后,会在报文中的根路径开销基础上加上本端口的链路开销,得到一个新的累计开销值。这个值越小,说明该路径到达根桥的总代价越低。当一台交换机在多个端口上收到不同的根路径信息时,它会选择累计开销最小的端口作为根端口;而在同一条链路的两端,开销较小的那一侧(如果开销相同则比较桥ID)将成为该链路的指定端口。
路径开销与链路带宽直接相关。老的IEEE 802.1D标准使用16位开销,例如10Mbps链路开销为100,100Mbps为19,1Gbps为4,10Gbps为2。后来的802.1t标准引入了32位长整型开销,计算方式约为链路带宽(以kbps计)的倒数,例如1Gbps链路开销为20000,10Gbps为2000。在Ruby实现中,我们可以用哈希表来维护速率到开销的映射,这样既清晰又便于扩展。
需要特别注意的是,路径开销只累计入方向的链路开销。也就是说,交换机收到BPDU时,报文中携带的开销是发送方累计的值,接收方在此基础上加上自己接收端口的端口开销后向外转发。理解了这一点,才能正确模拟BPDU在拓扑中的传播过程。
二、用Ruby建模链路开销与端口结构
接下来我们用Ruby构建基础的数据结构。首先定义一个模块存放开销表,然后定义端口类和交换机类,让它们携带选举所需的全部属性:桥ID、端口ID、累计根路径开销等。
# 链路速率到路径开销的映射(802.1t标准)
module StpCost
COST_TABLE = {
10_000 => 2_000_000, # 10Mbps
100_000 => 200_000, # 100Mbps
1_000_000 => 20_000, # 1Gbps
10_000_000 => 2_000 # 10Gbps
}.freeze
def self.cost_for(speed_kbps)
COST_TABLE.fetch(speed_kbps) do
raise ArgumentError, "未知的链路速率: #{speed_kbps}kbps"
end
end
end
# 端口类:记录速率、邻居以及累计开销
class StpPort
attr_reader :name, :speed_kbps, :owner
attr_accessor :designated, :root_path_cost
def initialize(owner, name, speed_kbps)
@owner = owner
@name = name
@speed_kbps = speed_kbps
@root_path_cost = 0
@designated = false
end
def port_cost
StpCost.cost_for(@speed_kbps)
end
end
# 交换机类:桥ID由优先级和MAC地址组成
class StpSwitch
attr_reader :bridge_id, :ports
def initialize(priority, mac)
@bridge_id = [priority, mac]
@ports = []
end
def add_port(name, speed_kbps)
port = StpPort.new(self, name, speed_kbps)
@ports << port
port
end
# 连接两台交换机的端口,记录邻居关系
def connect(my_port, peer_port)
my_port.instance_variable_set(:@peer, peer_port)
peer_port.instance_variable_set(:@peer, my_port)
end
end
上面的代码中,StpCost.cost_for方法封装了开销查询逻辑,遇到未知速率时直接抛出异常,避免错误数据悄悄混入计算。StpSwitch#connect用实例变量保存了邻居端口的引用,这样后续传播BPDU时可以直接找到对端。桥ID用数组表示,Ruby中数组比较是逐元素进行的,天然符合STP中先比优先级再比MAC地址的规则,这是Ruby语言在建模时的一个便利之处。
三、实现根路径开销的计算与传播
有了基本结构,我们就可以模拟BPDU的传播。为了简化,假设网络已经通过某种方式选出了根桥(通常是桥ID最小的交换机),然后从根桥开始向邻居发送累计开销为0的BPDU,邻居收到后加上接收端口的开销再继续转发,直到所有交换机的开销值收敛。
class StpSwitch
# 计算本机当前最优的根路径开销(所有端口中的最小累计值)
def best_root_cost
@ports.map(&:root_path_cost).min || 0
end
# 向所有邻居传播当前的最优开销信息
def propagate
cost_to_send = root_bridge? ? 0 : best_root_cost
@ports.each do |port|
peer = port.instance_variable_get(:@peer)
next if peer.nil?
# 对端收到后加上它自己接收端口的开销
received_cost = cost_to_send + peer.port_cost
if received_cost < peer.owner.best_root_cost ||
peer.owner.root_path_cost == 0 && !peer.owner.root_bridge?
peer.root_path_cost = received_cost
end
end
end
def root_bridge?
# 简化处理:标记为根桥的交换机
@is_root == true
end
def mark_as_root
@is_root = true
end
end
# 模拟收敛:重复传播直到数值不再变化
def converge(switches, rounds = 10)
rounds.times do
switches.each(&:propagate)
end
end
这段代码的核心在于propagate方法。它先算出本机当前的最优开销,然后逐个端口向外通告;对端收到后在发送值基础上叠加接收端口的端口开销,只有当新值更小时才更新,这实际上就是分布式最短路径的松弛操作。外层的converge通过多轮迭代让全网数值稳定,模拟了真实STP中BPDU周期性泛洪直至收敛的过程。
这种实现虽然简化了BPDU的报文格式和定时器机制,但保留了开销计算的本质:累计、比较、取最小值。如果读者想进一步贴近真实协议,可以把propagate改造成事件驱动的形式,只有当本机最优值发生变化时才触发新一轮通告,这会更接近真实STP的行为。
四、指定端口的选举与完整示例
指定端口的选举发生在每一条链路上。对于连接交换机A和交换机B的链路,双方比较各自的根路径开销,开销小的那一侧成为指定端口负责转发流量;如果开销相同,则比较桥ID,桥ID小者胜出。下面的代码给出指定端口的判定函数,并构造一个三台交换机的小型拓扑来验证计算结果。
class StpSwitch
# 判断某个端口是否为所在链路的指定端口
def designated?(port)
peer = port.instance_variable_get(:@peer)
return true if peer.nil? # 无邻居则默认为指定端口
my_cost = root_bridge? ? 0 : best_root_cost
peer_cost = peer.owner.root_bridge? ? 0 : peer.owner.best_root_cost
if my_cost != peer_cost
my_cost < peer_cost
else
@bridge_id < peer.owner.bridge_id # 开销相同时比桥ID
end
end
end
# 构建拓扑:SW1为根桥,SW2通过千兆链路接SW1,
# SW3通过百兆链路接SW1,同时通过千兆链路接SW2
sw1 = StpSwitch.new(0, "00:00:00:00:00:01")
sw2 = StpSwitch.new(32768, "00:00:00:00:00:02")
sw3 = StpSwitch.new(32768, "00:00:00:00:00:03")
p1a = sw1.add_port("G1", 1_000_000)
p1b = sw1.add_port("F1", 100_000)
p2a = sw2.add_port("G1", 1_000_000)
p2b = sw2.add_port("G2", 1_000_000)
p3a = sw3.add_port("F1", 100_000)
p3b = sw3.add_port("G2", 1_000_000)
sw1.connect(p1a, p2a) # SW1-SW2 千兆
sw1.connect(p1b, p3a) # SW1-SW3 百兆
sw2.connect(p2b, p3b) # SW2-SW3 千兆
sw1.mark_as_root
converge([sw1, sw2, sw3])
puts "SW2 最优根路径开销: #{sw2.best_root_cost}" # 期望 20000
puts "SW3 最优根路径开销: #{sw3.best_root_cost}" # 期望 20000
[sw1, sw2, sw3].each do |sw|
sw.ports.each do |port|
role = sw.designated?(port) ? "指定端口" : "非指定端口"
puts "#{sw.bridge_id[1]} #{port.name} => #{role}"
end
end
运行这段代码,SW2的最优开销为20000,说明它选择了千兆直连链路到达根桥;SW3虽然有百兆直连链路(开销200000),但通过SW2转发的累计开销同样是20000,且更优,因此SW3的根端口会落在连接SW2的千兆端口上。在SW2与SW3的链路两端,双方根路径开销相同,此时比较桥ID,SW2的桥ID更小,所以SW2一侧被判定为指定端口,SW3一侧则进入阻塞状态,环路的修剪就此完成,这与真实STP的选举结果完全一致。
通过这个Ruby实现,我们可以清晰地看到路径开销如何一步步影响端口角色。读者可以尝试修改链路速率或桥优先级,观察选举结果的变化,例如把SW1与SW2之间的链路降为百兆,SW3的路径选择就会随之改变。用代码做协议实验的好处在于一切可量化、可重复,非常适合用来验证对协议细节的理解。