MPLS多协议标签交换的核心思想,是在IP转发之上引入短而定长的标签,让数据包在骨干网中按标签而非IP地址转发。而让标签真正流转起来的,正是控制平面上的LDP标签分发协议。不少网络工程师平时接触的都是路由器上的现成配置,对LDP报文内部如何完成FEC与标签绑定往往只有一个模糊印象。这篇文章用Ruby写一个极简版的LDP标签映射交互流程,把邻居发现、会话建立、标签映射三个阶段用代码串起来,帮助读者真正理解FEC与标签绑定消息的报文结构。

一、LDP协议中FEC与标签绑定的基本原理
动手写代码前,需要先弄清两个概念。FEC即转发等价类,它定义了一组按同样方式处理的报文,最常见的形式是一个IPv4前缀,比如192.168.1.0/24。同属一个FEC的流量会被打上相同标签,沿同一条标签交换路径LSP转发。标签绑定则是指某台LSR向邻居宣告:对于这个FEC,我分配了某个标签值,你以后把属于这个FEC的报文发给我时,顶层标签就用它。
LDP报文分为两大类:发现消息使用UDP 646端口组播发送,用于邻居发现;会话消息通过TCP 646端口可靠传输,包括初始化、KeepAlive、标签映射、标签请求、标签释放等。一个完整的标签绑定流程通常是这样的:两台LSR先通过链路Hello消息发现对方,然后建立TCP会话并交换初始化消息协商参数,会话进入Operational状态后,各自将本地的FEC与标签映射关系以标签映射消息发给邻居。下游LSR为FEC分配标签并主动告知上游,这就是下游自主分配模式,也是目前最常用的模式。
理解了这条主线,接下来的代码就围绕三步展开:用UDP socket模拟Hello发现,用TCP socket模拟会话建立,最后在会话通道上编码并解析一条标签映射消息。虽然是简化实现,但报文头部和TLV的编码规则完全遵循RFC 5036的定义。
二、用Ruby实现Hello发现与会话建立
先看邻居发现部分。LDP规定链路Hello消息发往组播地址224.0.0.2的UDP 646端口,消息体由LDP头部加一个或多个TLV构成。LDP头部固定10字节:2字节版本号(值为1)、2字节PDU长度、6字节LDP标识符(由4字节LSR ID加2字节标签空间组成)。下面用Ruby构造一个最小的Hello PDU:
require 'socket'
# 构造LDP PDU头部:版本号1 + PDU长度 + 6字节LDP ID
def build_ldp_header(ldp_id)
version = [1].pack('n') # 网络字节序16位版本号
lsr_id = ldp_id[0, 4] # 4字节LSR ID
space = [0].pack('n') # 标签空间编号,0表示每平台标签空间
body_len = lsr_id.length + space.length
pdu_len = [body_len].pack('n') # 此处简化,实际还应包含消息体长度
version + pdu_len + lsr_id + space
end
# 发送链路Hello消息
def send_hello(local_lsr)
sock = UDPSocket.new
sock.setsockopt(Socket::IPPROTO_IP, Socket::IP_MULTICAST_TTL, 1)
pdu = build_ldp_header(local_lsr)
# 实际还需追加Hello消息头与保持时间TLV,此处演示头部构造
sock.send(pdu, 0, '224.0.0.2', 646)
sock.close
end
send_hello("\xC0\xA8\x01\x01".b) # LSR ID: 192.168.1.1
这段代码的重点在于pack方法的使用。pack('n')按网络字节序大端打包16位整数,这是所有LDP字段的标准编码方式。LSR ID直接用4字节二进制字符串表示,注意要调用.b确保字符串是ASCII-8BIT编码,否则在Ruby 3中拼接二进制数据可能报编码不兼容错误。
会话建立阶段改用TCP。传输地址较大的一方主动发起TCP连接到对端646端口,连接成功后双方各发一条初始化消息,内容包含KeepAlive保持时间、LDP ID等参数,协商一致后互相确认并周期性发送KeepAlive。下面是服务端监听与初始化交互的骨架:
require 'socket'
server = TCPServer.new('0.0.0.0', 646)
puts 'LDP会话监听已启动,等待邻居接入...'
loop do
client = server.accept
Thread.new(client) do |sock|
# 初始化消息:类型0x0200,消息ID任取
msg_type = [0x0200].pack('n')
msg_id = [1].pack('N')
keepalive = [15].pack('n') # 会话保持时间15秒
ldp_id = ("\xC0\xA8\x01\x01".b + [0].pack('n'))
pv_len = [keepalive.length + ldp_id.length].pack('n')
msg_len = [msg_id.length + pv_len.length + keepalive.length + ldp_id.length].pack('n')
header = [1].pack('n') + [6 + msg_len.unpack1('n')].pack('n') + ldp_id
sock.write(header + msg_type + msg_len + msg_id + pv_len + keepalive + ldp_id)
# 读取对端的初始化消息,此处略去完整解析
response = sock.readpartial(256)
puts "收到对端消息 #{response.unpack1('H*')}"
sock.close
end
end
初始化消息的结构稍微复杂:LDP消息统一为2字节消息类型、2字节消息长度、4字节消息ID,后面跟必选参数和可选参数TLV。初始化消息的必选参数就是协议版本、KeepAlive时间、PDU最大长度和本地LDP ID。双方参数一致后交换KeepAlive消息,会话才算真正建立,标签映射的舞台就此搭好。
三、编码与解析标签映射消息
标签映射消息是整个协议的灵魂,它把FEC和标签正式绑定在一起。消息类型为0x0400,必选参数包括一个FEC TLV和一个标签TLV。FEC TLV类型值0x0100,内部是FEC元素列表;对IPv4前缀元素来说,元素类型为2,后面跟1字节地址族(值为1表示IPv4)、1字节前缀长度(按比特计)以及变长的前缀字节。标签TLV类型值0x0200,内部4字节标签字段中,高20位才是标签本体,低4位保留。
下面的代码演示如何为192.168.1.0/24这个FEC生成一条标签映射消息,并模拟接收方完成解析:
require 'socket'
# 构造FEC TLV:类型0x0100,内含一个IPv4前缀FEC元素
def build_fec_tlv(prefix_str)
ip, plen = prefix_str.split('/')
prefix_bytes = ip.split('.').map(&:to_i).pack('C4')
plen = plen.to_i
# 前缀长度按比特数换算成实际占用的字节数,例如/24只发3字节
used_bytes = prefix_bytes[0, (plen + 7) / 8]
fec_elem = [2].pack('C') + [1].pack('C') + [plen].pack('C') + used_bytes
[0x0100].pack('n') + [fec_elem.length].pack('n') + fec_elem
end
# 构造标签TLV:类型0x0200,标签值左移4位放入32位字段
def build_label_tlv(label)
body = [label << 4].pack('N')
[0x0200].pack('n') + [body.length].pack('n') + body
end
# 组装完整的标签映射消息
def build_label_mapping(prefix, label, msg_id = 2)
fec_tlv = build_fec_tlv(prefix)
label_tlv = build_label_tlv(label)
msg_len = 4 + fec_tlv.length + label_tlv.length
[0x0400].pack('n') + [msg_len].pack('n') + [msg_id].pack('N') + fec_tlv + label_tlv
end
# 解析标签映射消息,还原FEC与标签
def parse_label_mapping(data)
_msg_type, msg_len, msg_id = data.unpack('nnN')
rest = data[8, msg_len]
fec_len = rest[6, 2].unpack1('n')
fec_body = rest[8, fec_len]
_elem_type, _afi, plen = fec_body.unpack('CCC')
prefix_ip = fec_body[3, (plen + 7) / 8].unpack('C*').join('.')
offset = 8 + fec_len + 4
label_field = rest[offset, 4].unpack1('N')
{ fec: "#{prefix_ip}/#{plen}", label: label_field >> 4, msg_id: msg_id }
end
msg = build_label_mapping('192.168.1.0/24', 1024)
puts "发送标签映射: #{msg.unpack1('H*')}"
puts "解析结果: #{parse_label_mapping(msg).inspect}"
运行后解析结果会得到FEC为192.168.1.0/24、标签为1024的绑定关系,这正是路由器上show mpls ldp bindings命令输出里每一行的来源。有几个细节值得注意:前缀长度不足整字节时只发送实际占用的字节,例如/24只发3字节,解析时要按比特数换算;标签字段右移4位才能得到真实标签值,这个坑踩过一次就会印象深刻。如果要模拟双向交互,只需把编码逻辑放进前面TCP会话的读写环节,两端各自发送自己分配的标签映射,就能观察到一个最小LSP的建立过程。
四、从简化实现到真实协议的差距
上面的代码刻意省略了不少内容,了解这些差距有助于进一步学习。首先是标签分发控制方式,真实LDP有独立控制和有序控制两种模式:独立控制下本地标签可以立即分发,有序控制则要等IGP路由就绪。其次是标签保留模式,保守保留只在必要时保留标签,自由保留则全部保留以备快速切换。这些模式在初始化协商中通过可选TLV交换,简化实现里没有体现。
另一个重要遗漏是环路检测与标签回收机制。当网络拓扑变化时,LSR需要发送标签撤销消息和标签释放消息回收旧绑定,否则会出现标签黑洞。此外真实的LDP还有定向Hello、会话保护、优雅重启等特性,完整的消息定义和状态机描述可以在RFC 5036中找到。用Ruby实现协议的乐趣在于:当你亲手把每个字节拼成合法报文并在Wireshark中看到它被正确解析时,协议细节就不再是抽象文字,而是可以触摸的结构。读者可以在本文代码基础上扩展标签请求消息、增加TLV解析容错,逐步逼近一个能与真实路由器对话的LDP实现。