手写RIP路由协议模拟器:从距离矢量算法到计数到无穷大

发布时间:2026/10/26 19:34:41
手写RIP路由协议模拟器:从距离矢量算法到计数到无穷大

1. 为什么我要动手写一个 RIP 模拟器

路由协议这东西,看书和看 RFC 文档是一回事,真正把它跑起来、看着路由表一条条收敛,又是另一回事。我最早接触内部网关协议 RIP(Routing Information Protocol)是在一个网络基础课程里,当时老师讲距离矢量算法,黑板上画几个路由器,说"邻居之间互相告诉对方我知道的路由,跳数加一传下去",听起来特别简单。但等到自己动手配真实设备、抓包看更新报文的时候,才发现里面全是细节:度量值怎么累加、水平分割什么时候生效、路由失效计时器到底多久触发、拓扑变化后为什么会出现短暂的环路。这些问题光靠背概念是记不住的。

所以我决定写一个 RIP 的模拟程序。这个项目的定位很明确:不是做一个生产级的协议栈,而是做一个能看得见、摸得着、可以单步调试的教学型模拟器。它能模拟多个路由器节点之间交换 RIP 报文、维护路由表、处理拓扑变化、演示计数到无穷大和水平分割这些经典机制。适合谁看?我觉得有三类人:正在学计算机网络、想搞懂距离矢量协议到底怎么运转的学生;准备网络相关认证、需要理解 RIP 细节的备考者;以及像我这样,喜欢通过"自己造一遍"来验证理解的从业者。

这个模拟程序的核心价值在于可观测性。真实设备上你只能看到最终的路由表,中间过程一闪而过。而自己写的模拟器,可以把每一次报文交换、每一次路由表更新、每一个计时器触发都打印出来,甚至能人为制造网络故障,观察协议怎么反应。这种"把黑盒拆开看"的体验,是单纯读书替代不了的。下面我就把这个项目的设计思路、核心实现、踩过的坑,完整地拆一遍。

2. 整体设计与核心思路拆解

2.1 先想清楚:模拟到什么粒度

动手之前第一个要决策的问题是模拟粒度。RIP 模拟可以做得非常粗——比如只用一个二维数组表示距离矩阵,跑几轮 Bellman-Ford 迭代就完事;也可以做得非常细——模拟真实报文的字节格式、UDP 端口 520、多播地址 224.0.0.9。我最终选择的是一条中间路线,理由如下。

如果只做距离矩阵迭代,那本质上是在解一个最短路问题,跟 RIP 协议本身关系不大,你体会不到"周期性广播""超时失效""触发更新"这些协议行为。但如果完全模拟字节级报文,又会把大量精力耗在序列化和解析上,偏离了理解协议逻辑的主线。所以我定的粒度是:以"路由条目"为基本单位,模拟路由器之间的报文交换和路由表状态机,报文用结构化的对象表示,不追求字节级兼容,但保留 RIP 报文的核心字段语义(目的网络、度量值、下一跳)。

这个取舍背后的逻辑是:RIP 的难点不在报文格式,而在分布式状态收敛的过程。报文格式查 RFC 就行,但"为什么这个路由会被替换""为什么这条路由进入了 holddown"这些问题,只有把状态机跑起来才能理解。所以我把模拟重心放在路由表的状态转换和计时器机制上。

2.2 三个核心抽象:路由器、路由表、报文

整个程序我抽象出三个核心概念,这个划分直接决定了代码结构。

路由器节点(Router)是模拟的基本单元,每个节点持有自己的路由表、邻居列表,以及一组计时器。路由表(Routing Table)里每一行是一个路由条目,包含目的网络、度量值(跳数)、下一跳、以及一个关键但容易被忽略的字段——路由来源(从哪个邻居学来的)。这个"来源"字段是后面实现水平分割和毒性逆转的基础。报文(Message)则是路由器之间传递的数据结构,本质上就是"我这里有这些路由,度量值是多少"的列表。

为什么要把"路由来源"单独拎出来强调?因为很多初学者写 RIP 模拟时只存目的和度量,结果实现水平分割时发现无从下手——你根本不知道这条路由是从哪个接口学来的,怎么"不往回发"?这是设计阶段就必须想清楚的点。我在第一版就栽在这上面,后来重构才补上。

2.3 用离散事件驱动,而不是死循环轮询

模拟器的时间推进方式也是个关键选择。最朴素的做法是开线程、用真实时间 sleep,让每个路由器定时广播。但这样有两个问题:一是模拟速度受真实时间限制,想快进看收敛过程很痛苦;二是多线程下路由表并发读写容易出竞态,调试起来头大。

我采用的是离散事件模拟(Discrete Event Simulation)的思路:维护一个事件队列,每个事件带一个"虚拟时间戳",程序不断取出最早的事件执行,虚拟时间随之推进。周期性广播就是一个"每 30 个虚拟秒重新入队一次"的事件。这样做的好处是模拟可以瞬间跑完几分钟的协议过程,而且完全单线程,没有并发问题,行为可复现。这个设计选择让整个项目的调试难度下降了一个数量级,我强烈建议做类似模拟的人都走这条路。

3. 核心机制解析与实现要点

3.1 路由表条目到底该存哪些字段

先把路由表条目的数据结构定下来,这是整个模拟器的地基。我最终的字段设计如下表,每一项都对应 RIP 协议里的一个真实概念。

字段含义为什么需要
destination目的网络地址路由的查找键
metric度量值(跳数)RIP 用跳数衡量距离,最大 15,16 表示不可达
next_hop下一跳地址转发时用,也用于判断路由来源
learned_from从哪个邻居学来水平分割、毒性逆转的依据
route_timer路由老化计时器默认 180 秒未更新则失效
garbage_timer垃圾回收计时器失效后 120 秒内仍保留,度量置 16
state路由状态active / holddown / garbage 等

这里要特别说metric 上限 16 的设计。RIP 把 16 定义为无穷大,意味着一条路由最多经过 15 跳。这个数字不是随便定的,它是 RIP 应对"计数到无穷大"问题的一种笨办法——当网络出现环路时,度量值会不断累加,但因为封顶在 16,最多累加到 16 就停了,不会无限增长。代价是网络直径被限制在 15 跳以内。理解了这个取舍,你就明白为什么大型网络不能用 RIP。

3.2 距离矢量算法:邻居告诉我的,我加一跳再用

RIP 的核心算法是距离矢量(Distance Vector),用一句话概括就是:每个路由器周期性地把自己的整张路由表告诉邻居,邻居收到后,把每条路由的度量值加一,如果比自己现有的更优,就更新。这个"加一"就是跳数的累加,代表"到我这里还要再经过一跳"。

实现时有个细节必须注意:收到邻居报文后,对报文里每一条路由,计算new_metric = received_metric + 1,然后和本地路由表比较。比较逻辑分几种情况:如果本地没有这个目的网络,直接添加;如果本地有但下一跳就是这个邻居,无条件更新(因为这是同一来源的最新信息,哪怕度量变大了也要接受,否则拓扑变差时无法感知);如果本地有且下一跳是别人,只有当 new_metric 更小时才替换。这个"下一跳相同则无条件更新"的规则非常关键,很多简化实现漏掉它,导致链路故障后路由无法及时失效。

我用一段伪代码把这个逻辑固化下来,实际代码结构基本就是这个样子:

def process_update(router, neighbor, received_routes): for route in received_routes: new_metric = route.metric + 1 if new_metric > 16: new_metric = 16 existing = router.table.find(route.destination) if existing is None: router.table.add(route.destination, new_metric, neighbor) elif existing.next_hop == neighbor: existing.metric = new_metric existing.reset_timer() elif new_metric < existing.metric: existing.update(new_metric, neighbor)

这段逻辑看着简单,但它是整个协议的心脏。我建议每个学 RIP 的人都亲手写一遍,写完你对"为什么距离矢量收敛慢"会有完全不同的体感。

3.3 水平分割与毒性逆转:治环路的两种药

距离矢量协议最臭名昭著的问题就是计数到无穷大。场景是这样的:A 和 B 互相连接,A 到网络 X 的路径是经过 B。某天 B 到 X 的链路断了,B 把 X 的度量改成 16。但在此之前,A 可能刚好把自己的路由表发给了 B,B 一看"A 说它到 X 只要 2 跳",于是 B 更新为 3 跳,下一跳指向 A。然后 B 又告诉 A"我到 X 是 3 跳",A 更新为 4 跳……两个路由器互相"喂"错误信息,度量值一路涨到 16 才停。这个过程可能持续好几分钟,期间流量全打进黑洞。

水平分割(Split Horizon)的思路很直白:从某个邻居学来的路由,不要再发回给这个邻居。因为对方告诉你"我到 X 要 3 跳",你回头告诉它"我到 X 要 4 跳",对它毫无价值——它本来就知道你到 X 的路径要经过它自己。实现上就是发送路由更新时,跳过那些learned_from == 目标邻居的条目。

毒性逆转(Poison Reverse)是水平分割的加强版:从邻居学来的路由,发回给这个邻居时,不是不发,而是把度量值设成 16(无穷大),明确告诉对方"你那条路在我这儿已经不可达了"。这比单纯不发更主动,能更快地打断环路。

我在模拟器里把这两种机制都实现了,并且做成可开关的配置项。这样你可以跑三组对比实验:都不开、只开水平分割、开毒性逆转,观察同一个拓扑故障下收敛时间的差异。实测下来,不开任何机制时,一个三节点的环状拓扑在链路断开后,路由表要经历十几次无效更新才收敛;开了水平分割后,无效更新大幅减少;毒性逆转则能进一步压缩收敛时间。这种对比实验做一遍,比看十页教材都管用。

3.4 四个计时器:RIP 的时间维度

RIP 的行为很大程度上由计时器驱动,这是它区别于静态路由的核心。标准 RIP 有四个计时器,我在模拟器里全部实现了,参数采用 RFC 2453 的默认值。

计时器默认值作用
Update Timer30 秒周期性发送完整路由表
Timeout Timer180 秒路由 180 秒未更新则标记失效
Garbage Collection Timer120 秒失效后再保留 120 秒,度量置 16
Holddown Timer180 秒路由度量变大后进入抑制期,期间不接受更差的路由

这里最容易搞混的是Timeout 和 Garbage Collection 的关系。一条路由 180 秒没收到更新,不是立刻删除,而是先标记为失效(度量置 16),然后启动 120 秒的垃圾回收计时器,这期间路由还在表里但不可用,等 120 秒到了才真正删除。为什么要多留这 120 秒?因为要确保这个"坏消息"能传播出去——如果立刻删除,邻居可能还以为这条路通着,继续往这儿发流量。这个设计体现了 RIP"宁可慢一点也要传达到位"的保守哲学。

Holddown则更微妙。当一条路由的度量值变大(比如从 2 跳变成 5 跳),说明网络可能出了状况,这时路由进入 holddown 状态,在 180 秒内即使收到更优的更新也不接受(除非是同一来源的更新)。目的是防止路由在故障期间反复震荡。这个机制在实际中争议不小,因为它会拖慢正常收敛,但作为学习项目,把它实现出来观察行为非常有价值。

4. 实操过程:从零把模拟器跑起来

4.1 环境与项目结构

这个模拟器我用 Python 实现,没有第三方依赖,标准库足够。选 Python 的理由很简单:数据结构表达清晰,调试方便,不用编译,适合教学演示。项目结构按职责划分成几个模块:

rip_sim/ router.py # 路由器节点,持有路由表和计时器 route_table.py # 路由表及条目定义 message.py # 报文结构 simulator.py # 离散事件引擎 topology.py # 拓扑定义与加载 main.py # 入口,跑实验场景

这个划分的关键是把事件引擎和协议逻辑分开。simulator.py只管"什么时候执行什么事件",完全不懂 RIP;router.py只管"收到报文怎么处理路由表",不关心时间怎么推进。这种解耦让两边都能独立测试,也方便以后换协议(比如换成 OSPF 的模拟,引擎可以复用)。

4.2 拓扑怎么定义:用邻接表描述网络

拓扑我用一个简单的邻接表来描述,每个节点记录它的邻居和链路开销。RIP 里链路开销默认都是 1(按跳数算),但为了演示,我允许配置成不同值——虽然标准 RIP 不区分链路带宽,但很多实现支持"按带宽折算跳数"的扩展,模拟器里留这个口子方便做实验。

topology = { "R1": {"R2": 1, "R3": 1}, "R2": {"R1": 1, "R4": 1}, "R3": {"R1": 1, "R4": 1}, "R4": {"R2": 1, "R3": 1, "NET_X": 1}, }

上面这个拓扑里,R1、R2、R3、R4 构成一个环,NET_X 挂在 R4 上。这个环状结构是演示计数到无穷大的经典场景——断开 R4 到 NET_X 的链路,观察 R2 和 R3 怎么互相"喂"错误路由。我特意用这个拓扑做默认实验,因为它足够小,日志能看全,又足够暴露问题。

4.3 事件引擎的核心循环

离散事件引擎的核心就是一个优先队列,按虚拟时间戳排序。主循环不断弹出最早的事件执行,执行过程中可能产生新事件(比如收到更新后触发一次触发更新),新事件带着未来的时间戳重新入队。

import heapq class Simulator: def __init__(self): self.now = 0 self.events = [] self.seq = 0 def schedule(self, delay, callback, *args): heapq.heappush(self.events, (self.now + delay, self.seq, callback, args)) self.seq += 1 def run(self, until=1000): while self.events and self.now <= until: time, _, callback, args = heapq.heappop(self.events) self.now = time callback(*args)

这里用seq做第二排序键,是为了保证同一时刻的多个事件按入队顺序执行,避免行为不确定。这个细节在调试时特别重要——如果同一虚拟时刻的事件顺序随机,你复现问题时会被搞得怀疑人生。我第一版没加这个,结果同一个场景跑两次结果不一样,排查了半天才发现是堆排序的不稳定性导致的。

4.4 一次完整的收敛过程实录

把上面这些拼起来,跑一次从冷启动到收敛的完整过程,日志大概是这样(虚拟时间,单位秒):

[t=0] R1 启动,路由表只有直连网络 [t=0] R2 启动,路由表只有直连网络 [t=0] R4 启动,路由表包含直连 NET_X [t=30] R4 向 R2、R3 发送更新,通告 NET_X 度量 1 [t=30] R2 收到,添加 NET_X 度量 2,下一跳 R4 [t=30] R3 收到,添加 NET_X 度量 2,下一跳 R4 [t=60] R2 向 R1 发送更新,通告 NET_X 度量 2 [t=60] R1 收到,添加 NET_X 度量 3,下一跳 R2 [t=60] R3 也向 R1 通告 NET_X 度量 2 [t=60] R1 比较:来自 R3 的度量 3 与现有相同,保持原路由

看到最后一行了吗?R1 同时从 R2 和 R3 收到到 NET_X 的等代价路由,度量都是 3。标准 RIP 在这种情况下只保留一条(先到的),不做等价多路径。这个行为我在模拟器里如实还原了,因为它是理解 RIP"单路径"特性的好例子。如果你想扩展成支持 ECMP,那就是另一个话题了。

4.5 制造故障:观察计数到无穷大

真正有意思的是故障场景。在 t=200 时,我人为把 R4 到 NET_X 的链路断开,然后观察:

[t=200] 链路 R4-NET_X 断开 [t=200] R4 将 NET_X 路由度量置 16,启动垃圾回收计时器 [t=230] R4 向 R2、R3 发送更新,通告 NET_X 度量 16 [t=230] R2 收到,但此时 R2 的路由表里 NET_X 下一跳是 R4,无条件更新为 16 [t=230] R3 同理更新为 16

如果不开水平分割,情况就精彩了。假设在 R4 的坏消息到达之前,R2 刚好向 R3 发过一次更新(通告 NET_X 度量 2),R3 会想"R2 到 NET_X 只要 2 跳,那我经过 R2 就是 3 跳",于是 R3 把 NET_X 更新为 3 跳、下一跳 R2。接着 R3 又把这个"好消息"告诉 R2,R2 一看"R3 说 3 跳,那我经过它就是 4 跳",更新为 4……两个节点你来我往,度量值 3、4、5、6 一路涨上去,直到 16 才停。日志里能看到这条路由的度量值像爬楼梯一样往上走,这就是活生生的计数到无穷大。

我把这个场景做成了模拟器的内置演示,跑一次就能直观看到环路怎么形成、怎么靠 16 的上限兜底。开了水平分割后,R2 不会把从 R4 学来的 NET_X 路由发回给 R4,R3 同理,环路被切断,收敛干净利落。这种对比是纯理论讲解给不了的。

5. 常见问题与排查技巧实录

5.1 路由表更新了但流量还是走错路

这是我在调试时遇到的第一个坑。现象是路由表看起来已经收敛到正确路径,但模拟转发时数据包还是往旧方向走。排查后发现是路由表和转发表的分离没做好——我更新路由表时忘了同步更新一个用于快速查找的索引结构。真实路由器里路由表(RIB)和转发表(FIB)是两层,模拟器虽然简化,但如果引入任何缓存或索引,就必须保证和主表一致。教训是:要么别搞缓存,要么更新时严格同步,别图省事。

5.2 计时器事件重复入队导致路由"复活"

第二个坑更隐蔽。周期性更新事件是"执行时重新入队下一次",但如果某条路由被删除后,它的垃圾回收事件还在队列里,执行时可能访问到已经不存在的数据,或者更糟——把一条已删除的路由又"复活"了。解决办法是给每个路由条目加一个版本号或唯一 ID,计时器事件携带这个 ID,执行时先校验 ID 是否还匹配,不匹配就丢弃。这个模式在事件驱动系统里非常通用,值得记住。

5.3 收敛判定:怎么知道模拟"跑完了"

模拟器跑起来后,怎么判断网络已经收敛?我一开始用"固定跑 1000 秒然后停",但这样既慢又不准。后来改成检测路由表是否还在变化:如果连续 N 个更新周期内所有路由表都没有任何变动,就判定收敛。这个判定逻辑本身也有坑——如果拓扑里存在持续震荡(比如 holddown 期间),可能永远判定不了收敛。所以我又加了个上限,超过一定虚拟时间强制停止并报警。这个"收敛检测"看似是模拟器的辅助功能,其实反映了真实网络运维里的一个核心问题:你怎么知道网络稳定了?

5.4 常见问题速查表

把调试过程中反复出现的问题整理成表,方便对照排查:

现象可能原因排查方向
路由度量值异常增大计数到无穷大检查水平分割/毒性逆转是否开启
路由该失效却没失效计时器未正确重置或事件丢失检查 timeout 事件是否入队、版本号是否匹配
同一目的出现多条路由路由表未做去重检查更新逻辑的替换条件
收敛后路由来回跳变holddown 未生效或参数错误检查度量变大时是否进入抑制状态
模拟结果不可复现事件顺序不确定检查优先队列是否用了稳定的第二排序键

5.5 几个让我少走弯路的实操心得

第一,日志要带虚拟时间戳和节点标识。我最初的日志就是干巴巴的"更新路由",出了问题根本不知道是谁在什么时候干的。加上[t=xxx][R2]前缀后,整个收敛过程一目了然,排查效率翻倍。

第二,把关键机制做成开关。水平分割、毒性逆转、holddown 全部做成配置项,能随时开关对比。这不仅是教学需要,调试时也极其有用——怀疑某个机制有问题,关掉它看行为变化,能快速定位。

第三,先用手算验证小拓扑。在跑模拟器之前,我会先在纸上把三节点拓扑的收敛过程手推一遍,得到预期结果,再和模拟器输出对比。如果对不上,要么是我理解错了协议,要么是代码有 bug,两种情况都是收获。这个习惯让我避免了很多"跑出来看着对其实错"的假象。

6. 这个模拟器还能怎么玩

代码跑通之后,我陆续加了一些扩展实验,每个都能加深对协议的理解。比如把 update timer 从 30 秒改成 5 秒,观察收敛变快但报文量暴增的权衡;比如构造一个超过 15 跳的链式拓扑,亲眼看到第 16 跳的网络怎么都学不到,理解 RIP 的网络直径限制;再比如故意让两个节点同时通告冲突的路由,观察协议怎么在震荡中最终稳定。

我个人在实际操作中的体会是,写模拟器最大的收获不是代码本身,而是被迫把每个模糊的概念都精确化。书上说"路由失效后进入垃圾回收",但垃圾回收期间这条路由到底能不能用来转发?下一跳还保不保留?这些问题不写代码你永远不会去深究,而一旦要写,就必须给出明确答案。这种"被逼着较真"的过程,才是模拟器真正的价值所在。如果你也在学网络协议,我强烈建议挑一个协议自己造一遍,哪怕造得很粗糙,收获也远超被动阅读。