二层环路是交换网络里最隐蔽也最致命的故障之一。两台交换机之间多接了一根网线,或者Hub使用不当,广播帧就会在环里无限复制,CPU飙高、链路被打满,整个局域网几分钟内瘫痪。生成树协议(Spanning Tree Protocol)通过阻塞冗余链路把有环的物理拓扑裁剪成无环的逻辑树来解决这个问题。本文先分析环路检测的原理,再用Ruby写一个可运行的生成树状态机仿真,最后对比STP与RSTP的收敛差异。

环路是怎么形成的,生成树为什么能消除它
交换机对广播帧的处理方式是:除了接收端口外,向所有其他端口转发。这个行为在无环网络中没问题,帧最终会到达所有节点然后停止传播。但一旦拓扑中存在环路,广播帧就会被永久复制下去。假设A发给B一个广播帧,而A和B之间有两条路径,帧从路径一到达B后会被转发回路径二,回到A后又从路径一发出,如此循环,每循环一轮帧的数量还会翻倍,这就是广播风暴的由来。
生成树协议的思路借鉴了图论中的最小生成树算法:在所有交换机中选出一个根桥(Root Bridge),其他每台交换机都计算自己到根桥的最短路径,负责转发最短路径的端口保留为指定端口或根端口,剩下的冗余端口被置为阻塞状态,不转发数据帧。这样物理上虽然连成了环,逻辑上数据通路是一棵树,没有任何环路。
协议运转靠的是BPDU(Bridge Protocol Data Unit)报文。每台交换机周期性(默认2秒)发送BPDU,报文里携带根桥ID、到根桥的路径开销和发送者桥ID三个关键字段。交换机之间通过比较这些字段来收敛出一致的拓扑视图。理解了这一点,用程序模拟STP就变成了模拟BPDU的交换与比较过程。
STP的核心机制:根桥选举、端口角色与状态迁移
根桥选举的规则很简单:桥ID最小的交换机成为根桥。桥ID由优先级和MAC地址拼接而成,优先级相同则比较MAC地址。初始时刻每台交换机都认为自己是根桥,向外发送BPDU时把自己的桥ID填进根桥字段。收到BPDU后,如果报文里的根桥ID比自己记录的更小,就更新自己的根桥记录,后续发出的BPDU改用新的根桥ID,直到全网收敛。
确定根桥之后,每台非根交换机要选出根端口,也就是到根桥开销最小的端口。开销取决于链路带宽,例如百兆链路开销为19,千兆为4。如果多个端口开销相同,依次比较对端桥ID和对端端口ID来打破平局。每个网段还要选出一个指定端口,负责向该网段转发来自根桥的流量,通常由网段上到根桥开销较小的那台交换机的端口担任。既不是根端口也不是指定端口的端口,就被置为阻塞角色。
端口状态迁移是STP最容易被诟病的部分。一个端口从阻塞(Blocking)到转发(Forwarding)要经历监听(Listening)和学习(Learning)两个中间状态,默认计时器加起来大约需要30到50秒。监听阶段参与拓扑协商但不学习MAC地址,学习阶段开始建立MAC表但不转发数据,这样做是为了防止在拓扑未收敛时形成临时环路。下面的Ruby代码实现了这个状态机的核心逻辑:
# STP端口状态机(简化版)
class StpPort
STATES = %i[blocking listening learning forwarding].freeze
TRANSITION_DELAY = { listening: 15, learning: 15 } # 秒
attr_reader :role, :state, :port_id
attr_accessor :cost
def initialize(port_id, cost = 19)
@port_id = port_id
@cost = cost
@state = :blocking
@role = :designated # 初始都认为自己是指定端口
end
# 处理收到的BPDU,返回是否触发了状态变化
def handle_bpdu(bpdu)
if bpdu.root_id < @best_root_seen
@best_root_seen = bpdu.root_id
@role = :root_port
transition_to(:listening) if @state == :blocking
true
else
# 收到更优的BPDU说明本端口不是指定端口
@role = :nondesignated
transition_to(:blocking)
false
end
end
def transition_to(new_state)
return if @state == new_state
@state = new_state
# 非阻塞状态都要启动计时器推进到下一状态
schedule_next if TRANSITION_DELAY[new_state]
end
def schedule_next
next_idx = STATES.index(@state) + 1
# 实际实现中用定时器,这里用延迟示意
transition_to(STATES[next_idx])
end
def forwarding?
@state == :forwarding
end
end上面的代码刻意简化了计时器部分,真实协议中监听和学习状态各有独立的转发延迟计时器,任何拓扑变化都会重置状态。接下来实现交换机级别的BPDU生成与比较逻辑:
class Bridge
attr_reader :bridge_id, :ports, :root_id, :root_cost
def initialize(bridge_id, port_count = 3)
@bridge_id = bridge_id
@root_id = bridge_id # 初始认为自己是根桥
@root_cost = 0
@ports = Array.new(port_count) { |i| StpPort.new("#{bridge_id}-p#{i}") }
end
# 生成BPDU报文
def build_bpdu
{ root_id: @root_id, root_cost: @root_cost, sender_id: @bridge_id }
end
# 接收并处理来自邻居的BPDU
def receive_bpdu(bpdu, port)
return false if bpdu[:root_id] > @root_id # 更差的根信息直接丢弃
if bpdu[:root_id] < @root_id || bpdu[:root_cost] + port.cost < @root_cost
@root_id = bpdu[:root_id]
@root_cost = bpdu[:root_cost] + port.cost
return true
end
false
end
end搭建仿真环境验证环路检测效果
有了Bridge和StpPort两个类,就可以搭一个三台交换机成环的拓扑来验证。用一个简单的调度器模拟每2秒一轮的BPDU交换,把每台交换机的BPDU发给所有邻居,运行若干轮后观察端口角色。如果实现正确,最终三台交换机中会有一个端口进入持续的阻塞状态,逻辑拓扑变成链状,环路被打破。
# 构建三台交换机成环:A - B - C - A
a = Bridge.new('00:00:00:00:00:01')
b = Bridge.new('00:00:00:00:00:02')
c = Bridge.new('00:00:00:00:00:03')
links = [[a, 0, b, 0], [b, 1, c, 0], [c, 1, a, 1]]
20.times do
links.each do |(sw1, p1, sw2, p2)|
sw1.receive_bpdu(sw2.build_bpdu, sw1.ports[p1])
sw2.receive_bpdu(sw1.build_bpdu, sw2.ports[p2])
end
end
puts "根桥: #{a.root_id}"
[a, b, c].each do |sw|
sw.ports.each do |p|
puts "#{sw.bridge_id} #{p.port_id} 角色=#{p.role} 状态=#{p.state}"
end
end运行结果中可以看到桥ID最小的交换机A成为根桥,B和C各自选出一个根端口指向A,环上最后一条链路的两端中有一端被判定为非指定端口并保持阻塞。这正是真实交换机上show spanning-tree命令输出的核心信息。在调试这类状态机时,建议把每轮BPDU交换后的根桥记录和端口角色打印出来,观察收敛过程是否稳定,是否存在角色反复震荡的情况。震荡通常意味着比较逻辑中打破平局的规则没有实现完整。
还有一个实用的验证手段:在仿真收敛后,从逻辑拓扑中删掉所有阻塞端口,用深度优先搜索检查剩余图是否连通且无环。连通性保证没有被过度裁剪,无环保证环路检测确实生效,这两个断言可以直接写成Ruby的单元测试,方便后续扩展协议功能时做回归验证。
从STP到RSTP:收敛速度的演进与实现要点
经典STP最大的问题是收敛慢,链路故障后要等30秒以上才能恢复转发。快速生成树协议(RSTP,IEEE 802.1w)对此做了重要改进:把端口状态精简为丢弃、学习、转发三种,引入边缘端口和点对点链路的概念,并允许备份端口在根端口失效时通过proposal和agreement握手机制快速切换,收敛时间可以缩短到几秒甚至亚秒级。
在Ruby仿真中实现RSTP的要点是增加一个BPDU的flags字段,携带proposal和agreement标志位。当一个端口希望快速进入转发状态时,发送带proposal标志的BPDU给下游,下游如果所有端口都处于同步状态,就回一个agreement报文,上游端口立即转发,跳过漫长的计时器等待。这个握手机制本质上是把STP中被动等待的超时换成了主动的确认,思路类似TCP三次握手取代盲等。
此外,实现协议仿真时还有两点经验值得注意。一是比较运算必须严格全序化:桥ID、路径开销、发送者ID、端口ID要按顺序比较,任何一处含糊都会导致不同交换机对同一网段的指定端口判断不一致,拓扑无法收敛。二是日志要记录状态迁移的触发原因,STP类协议的bug几乎都表现为意外的状态跳转,只有把触发BPDU的内容一并记录,才能定位是比较逻辑还是计时器的问题。掌握了这些,你不仅理解了环路检测的协议本质,也拥有了一套可以用Ruby自由实验的协议沙盒。