生成树协议中的拓扑变化通知(Topology Change Notification)是二层网络里一个容易被忽视但非常关键的机制。当某条链路断开或端口状态发生变化时,交换机会沿根端口向根桥发送TCN BPDU,根桥收到后再通过配置BPDU中的TC标志通知全网,让所有交换机把MAC地址表的老化时间从默认的300秒缩短到转发延迟(约15秒),从而加快收敛。本文将用Ruby来实现这套流程的简化版本,包括TCN BPDU的二进制构造、以太网帧的发送,以及接收端解析后触发MAC表刷新的完整逻辑。

一、TCN BPDU的二进制结构分析
STP相关的BPDU都封装在LLC帧中,以太网目的地址是保留的组播地址01:80:C2:00:00:00,LLC头部为固定的AA-AA-03-42-42-03。BPDU本身分为两类:配置BPDU长度至少35字节,而TCN BPDU只有4字节,结构非常精简。理解这4个字节是整个实现的基础。
TCN BPDU的结构如下:第1字节是协议ID,固定为0x00;第2字节是协议版本,IEEE 802.1D中固定为0x00;第3、4字节是BPDU类型字段,其中Topology Change BPDU的类型值是0x80。与配置BPDU不同,TCN BPDU没有携带桥ID、根ID、计时器等信息,它的职责非常单一,就是把“拓扑变了”这个事件沿树向上传递。
用Ruby构造这个报文时,推荐使用Array#pack方法来处理二进制数据。下面是完整的构造代码:
class TcnBpdu
PROTOCOL_ID = 0x00
PROTOCOL_VER = 0x00
BPDU_TYPE_TCN = 0x80
attr_reader :raw
def initialize
@raw = [PROTOCOL_ID, PROTOCOL_VER, BPDU_TYPE_TCN].pack('CCC')
end
# 完整以太网帧:目的地址 + 源地址 + 长度 + LLC头 + BPDU
def to_frame(src_mac)
dst = [0x01, 0x80, 0xC2, 0x00, 0x00, 0x00].pack('C6')
src = src_mac.split(':').map { |b| b.to_i(16) }.pack('C6')
llc = [0xAA, 0xAA, 0x03, 0x42, 0x42, 0x03].pack('C6')
dst + src + [4 + llc.length + @raw.length].pack('n') + llc + @raw
end
end这段代码中有几个细节值得注意。第一,MAC地址字符串转二进制时用to_i(16)按十六进制解析,每个字节独立处理后再pack,避免大小端问题。第二,长度字段是16位网络字节序,所以格式字符用'n'而不是'v',这一点在调试抓包对照时尤其重要。第三,LLC头部的0x42是SNAP PID,表示生成树协议,写错这个值对方交换机会直接丢弃报文。
二、发送与接收:定时器和重传逻辑的实现
真实的STP实现中,检测到拓扑变化的端口会立即发送TCN,之后每隔Hello Time(默认2秒)重发一次,直到收到上游确认。确认的标志是上游配置BPDU中的TCA(Topology Change Acknowledgement)位被置1。这个重传机制用Ruby的事件模型模拟起来很直观。
下面的代码用Thread实现了一个简单的定时重发器,实际项目中也可以换成EventMachine或 celluloid 等事件框架。为了突出协议逻辑,这里只保留核心流程:
class TcnSender
HELLO_TIME = 2
def initialize(sock, src_mac)
@sock = sock
@bpdu = TcnBpdu.new
@src_mac = src_mac
@confirmed = false
end
def notify_topology_change
@confirmed = false
5.times do
break if @confirmed
frame = @bpdu.to_frame(@src_mac)
@sock.send(frame, 0, '127.0.0.1', 9999) # 发送到本地模拟的对端
sleep HELLO_TIME
end
end
def confirm!
@confirmed = true
end
end接收端的解析逻辑与构造过程互为逆操作。收到UDP报文后,先剥离以太网头14字节和LLC头6字节,再检查剩下的内容是否为合法的TCN BPDU。判定依据是长度等于4且第三字节的类型值为0x80。解析通过后,将事件交给MAC表管理模块处理。接收代码示例如下:
require 'socket'
class TcnReceiver
def initialize(mac_table)
@mac_table = mac_table
@sock = UDPSocket.new
@sock.bind('127.0.0.1', 9999)
end
def start
Thread.new do
loop do
data, _addr = @sock.recvfrom(2048)
handle_frame(data)
end
end
end
private
def handle_frame(frame)
bpdu = frame[20..] # 跳过14字节以太网头 + 6字节LLC头
return unless bpdu&.length == 4
proto_id, version, btype = bpdu.unpack('CCC')
if btype == 0x80
puts "收到TCN BPDU,协议ID=#{proto_id},版本=#{version}"
@mac_table.flush_for_topology_change
end
end
end这里用UDP在127.0.0.1上模拟以太网链路只是简化手段。如果要做真实的二层实验,需要借助bind到原始套接字或者使用pcaprub这样的抓包库,把报文写到指定网卡上,并设置网卡为混杂模式才能收到组播帧。不过对于验证协议逻辑而言,本地回环方案已经足够,而且排障成本低得多。
三、MAC地址表的快速老化与刷新策略
收到拓扑变化通知后,交换机并不是简单地清空整张MAC表,而是把老化时间临时缩短为转发延迟(Forward Delay,默认15秒)。这样既能让失效的转发条目尽快消失,又避免了全表清空带来的瞬时泛洪风暴。模拟实现时要抓住两个要点:一是计时器的切换,二是恢复时机的判断。
下面的代码展示了一个支持动态老化时间的MAC表实现:
class MacTable
DEFAULT_AGING = 300 # 正常老化时间(秒)
SHORT_AGING = 15 # 拓扑变化后的短老化时间(秒)
Entry = Struct.new(:port, :last_seen)
def initialize
@entries = {}
@aging = DEFAULT_AGING
end
def learn(mac, port)
@entries[mac] = Entry.new(port, Time.now)
end
def lookup(mac)
@entries[mac]&.port
end
def flush_for_topology_change
puts "拓扑变化,老化时间 #{DEFAULT_AGING}s -> #{SHORT_AGING}s"
@aging = SHORT_AGING
end
def tick
now = Time.now
expired = @entries.select { |_, e| now - e.last_seen > @aging }
expired.each_key { |mac| @entries.delete(mac) }
@aging = DEFAULT_AGING if expired.empty? && @aging == SHORT_AGING
end
end这段实现中tick方法模拟了交换机每秒执行一次的老化扫描。当拓扑变化发生时,所有条目的剩余寿命被压缩到15秒内;条目陆续过期后,如果没有新的拓扑变化信号,老化时间自动恢复到300秒。真实的交换机还有一层细节:拓扑变化状态本身也有持续时间限制(等于Max Age加Forward Delay),超时后自动退出,代码中用expired.empty?近似模拟了这个退出条件。
验证整体流程时,可以写一个简单的测试脚本:先学习几条MAC地址,然后调用notify_topology_change发送TCN,观察控制台输出和tick后的表项数量变化。正常情况下应该在15秒左右看到所有旧条目被清除,而新学习的条目不受影响。如果想进一步贴近真实设备,还可以在TCN路径上加入根桥确认机制,即根桥在配置BPDU中置位TC和TCA标志,下游收到TCA后停止重发TCN,这一步读者可以基于上面的框架自行扩展。
通过这近百行Ruby代码,TCN BPDU从报文构造、定时重传到触发MAC表刷新的完整链路都被串联了起来。相比直接阅读RFC或交换机配置手册,亲手实现一遍协议状态机能帮助理解每个字段存在的意义,也为后续分析RSTP的Proposal/Agreement快速收敛机制打下基础。