生成树协议(STP)的核心任务是在二层交换网络中消除环路,而端口状态机则是这一任务的具体执行者。当一个交换端口被启用后,它不会立刻开始转发数据帧,而是需要依次经历阻塞(Blocking)、侦听(Listening)、学习(Learning)和转发(Forwarding)四个状态。只有完整走完这个过程,端口才被允许进入转发状态。这个设计看似增加了收敛时间,却有效避免了临时环路和广播风暴。本文用Ruby实现一个简化版STP端口状态机,把端口抽象为事件驱动对象,展示四种状态之间的迁移逻辑以及MAC地址学习过程。

STP端口状态模型:为什么必须等待
二层交换网络中最危险的故障是环路。如果一台交换机新接入一条链路,端口立即进入转发状态,而网络中已经存在另一条冗余路径,就可能形成二层环路。广播帧会在环路中不断复制,最终耗尽带宽和CPU资源。STP的解决办法是让端口先经历一段只参与拓扑计算、不转发用户数据的时间。这样即使物理链路已经接通,数据面也不会立刻打通,生成树算法可以在这段时间内完成根桥选举和端口角色判定。
在四种主要状态中,blocking状态端口只接收BPDU,不学习MAC地址,也不转发数据帧;listening状态端口开始参与生成树计算,可以发送BPDU,但仍然不学习MAC、不转发;learning状态端口已经可以学习源MAC地址,为MAC表积累信息,但数据帧仍被丢弃;forwarding状态才完全开放学习和转发。真实协议的定时参数通常为:阻塞时间约20秒,侦听和学习各约15秒,这也是很多人感觉STP收敛慢的原因。
用状态机来描述这一过程会非常清晰。下面先定义状态常量和默认的Forward Delay参数,后续所有迁移都基于这些符号。
STATES = {
blocking: 0,
listening: 1,
learning: 2,
forwarding: 3
}.freeze
FORWARD_DELAY = 15
核心实现:用Ruby类封装状态机
把端口设计成一个StpPort类,可以让状态、MAC表和迁移方法集中在一个对象中。类初始化时默认进入blocking状态,同时准备好MAC地址表和转发延迟参数。为了便于观察状态变化,我在类中增加了一个简单的log方法,专门输出带端口名称的日志。
状态迁移的核心是trigger方法。它接收一个事件符号,根据当前状态和事件组合决定下一状态。这里使用Ruby的case对数组进行模式匹配,示例中列出了从阻塞到侦听、从侦听到学习、从学习到转发,以及拓扑变化回退到阻塞的迁移规则。未匹配的非法事件不会改变状态,实际项目中还可以抛出异常或记录告警。
数据帧处理放在receive_frame方法中。如果当前状态是learning或forwarding,先执行MAC学习;如果状态是forwarding,则转发帧;其他状态一律丢弃。这样做与STP语义保持一致:学习状态只积累地址表,不转发数据。完整类实现如下:
class StpPort
STATES = [:blocking, :listening, :learning, :forwarding]
attr_reader :state
def initialize(name, forward_delay: 15)
@name = name
@state = :blocking
@forward_delay = forward_delay
@mac_table = {}
log("initialized as #{@state}")
end
def trigger(event)
old_state = @state
@state = case [@state, event]
when [:blocking, :listen] then :listening
when [:listening, :timer_expired] then :learning
when [:learning, :timer_expired] then :forwarding
when [:forwarding, :topology_change] then :blocking
when [:listening, :topology_change] then :blocking
when [:learning, :topology_change] then :blocking
else
@state
end
log("transition #{old_state} -#{event}-> #{@state}") unless old_state == @state
end
def receive_frame(frame)
src = frame[:src]
dst = frame[:dst]
learn_mac(src) if @state == :learning || @state == :forwarding
case @state
when :forwarding
forward_frame(frame)
else
log("discard frame from #{src} to #{dst} because state is #{@state}")
end
end
def learn_mac(mac)
unless @mac_table.key?(mac)
@mac_table[mac] = @name
log("learned MAC #{mac} on #{@name}")
end
end
def forward_frame(frame)
log("forward frame #{frame[:src]} -> #{frame[:dst]}")
end
private
def log(message)
puts "[#{@name}] #{message}"
end
end
这个实现刻意保持轻量,没有引入线程或定时器。实际使用中,timer_expired事件可以由一个调度器在到达Forward Delay时间后触发,也可以用sleep模拟。由于状态迁移是原子操作,测试时手动触发反而更容易验证边界情况。
测试与验证:从阻塞到转发的完整路径
状态机实现完成后,需要验证迁移顺序是否正确。一个典型测试场景是:端口初始为blocking,收到listen事件后进入listening;随后连续收到两次timer_expired,分别进入learning和forwarding。接着模拟两帧数据,观察端口在转发状态下的学习与转发行为。
下面的测试脚本可以直接运行,输出会清楚展示状态变化和帧处理结果:
port = StpPort.new("Gi0/1", forward_delay: 15)
port.trigger(:listen)
port.trigger(:timer_expired)
port.trigger(:timer_expired)
port.receive_frame(src: "00:11:22:33:44:55", dst: "66:77:88:99:aa:bb")
port.receive_frame(src: "66:77:88:99:aa:bb", dst: "00:11:22:33:44:55")
运行后,日志会依次显示端口初始化、三次状态迁移、两次MAC学习和两次帧转发。如果希望更严格地测试,可以把StpPort放入RSpec或Minitest中,断言每一次trigger后的state值,确保非法迁移不会破坏状态。
当然,这篇文章实现的是STP端口状态机的教学版本,并未涉及BPDU格式、根桥选举、端口角色和链路开销等完整生成树协议内容。真实交换机中,状态迁移由收到配置BPDU、定时器超时以及拓扑变化通知共同驱动。借助Ruby对象模型,我们已经把核心的状态切换和MAC学习逻辑跑通,后续可以在trigger方法中继续扩展更复杂的事件类型,或者把状态封装成独立类以实现完整的State模式。