写自研协议的解码器,最让人头疼的往往不是协议有多复杂,而是某个字符串字段长得没边。你辛辛苦苦在 Wireshark 的 Lua 插件里注册好ProtoField.string,打开抓包一看,Packet Details 面板里每条数据都拖出一段几十上百字节的文本,翻页翻到崩溃,给同事截图都截不全。这个“自定义协议字段显示长度:截断配置”,就是想解决这个体验问题:让协议字段在协议树里按照你设定的字节数显示,超出部分自动加省略号,同时把完整原始值保留在底层,过滤、复制、二次解析都不受影响。这篇文章会把这套方案的原理、Lua 实现、常见坑一次讲透,适合正在写 Lua dissector、或者接手过自定义协议解析插件的同学参考。
1. 先搞清楚:字段显示长度到底由谁决定
1.1 一条显示记录是怎么生成的
Wireshark 里每个协议字段的显示,本质上是一行文本:字段名加字段值。字段值不是凭空产生的,而是由 dissector 里的某个tvbrange(字节范围)按字段类型转换出来的。比如ftypes.STRING,就是把那个范围内的原始字节当作 UTF-8 文本直接抛到树里。
这里有一个很关键的设计:协议树里显示的内容,与过滤器引擎用来匹配的内容,是两套体系。过滤器匹配的是tvbrange对应的原始数据,而文本框里显示什么,只是表现层的东西。也就是说,我们完全可以在不改变原始数据的前提下,只改“这行字滚出去的长度”,这正是做截断显示的基础。
很多自定义协议里字符串字段都很夸张,常见的场景包括:一次请求里带一整段 JSON 配置、一条 MQTT 消息的 topic 带了完整路径链、某个设备管理协议把 base64 图片直接塞在报文里。这类字段如果不做约束,协议树会变成灾难现场。
1.2 什么时候需要截断:三个典型信号
我自己总结过,出现下面三种情况,基本就该做截断配置了。
第一种,单条报文解析完,协议树超过一屏。尤其是有嵌套子树的协议,一个长字符串往往让下面所有字段都被推到屏幕外,用户想看的第二层字段反而看不见。
第二种,同一字段在会话中高频出现。比如日志类协议,一次抓包几百条记录,每条都有个超长的日志内容字段。看着满屏相同前缀的巨长文本,人是会生理性反感的。
第三种,需要把包详情导出给他人的时候。不管是导出为 txt 还是截图,长字段会把关键信息淹没,最后得到的是一份没人愿意读的文档。截断显示并不是删除数据,而是做“视觉压缩”。完整数据必须留在报文里,这是底线。
2. 截断方案怎么选:三种思路对比
2.1 思路一:用 add() 的 value 参数覆盖显示值
Wireshark 的 Lua API 里,tree:add()除了常见的tree:add(protofield, tvbrange)之外,还可以额外传一个 value 参数。对于字符串类型的字段,这个参数可以是一个 Lua 字符串,用来替代默认显示的文本。这样做的优势是代码量最小,改动集中在 add 那行:
local preview = utf8_truncate(msg_tvbr:string(), 64) subtree:add(f_msg, msg_tvbr, preview)但要注意,这个思路有一个隐含问题:如果长度超过了 64,你就得在显示值上自己做省略号拼接,而f_msg字段本身的“语义”还是完整的字符串字段,显示出来的却是截断文本,阅读时容易产生“这数据被改了”的错觉。它适合小幅调整,不适合做严格的截断管理。
2.2 思路二:用 set_text() 重写整行文本
另一个更稳的办法,是先把字段正常 add 进去,再用set_text()重写这行的显示文本。字段对象与原始tvbrange的绑定关系不会被破坏,过滤器照常工作,但界面看起来就是截断后的样子:
local item = subtree:add(f_msg, msg_tvbr) item:set_text(string.format("%s: %s", f_msg.description, preview))这也是我在生产插件里最常用的方式。它足够显式,显示成什么样完全由我们自己控制,而且不依赖add()第三个参数在不同 Wireshark 版本里可能产生的行为差异。
2.3 思路三:把截断长度做成协议偏好
前两种思路解决了“怎么截”,但每次想改长度都得改 Lua 代码、重载插件。更好的做法是用Pref把截断开关、截断长度、后缀符号都暴露到Preferences → Protocols界面,用户不碰代码就能调。
这就是“截断配置”这四个字真正的分量所在。一个可配置的截断方案,本质上是一个功能模块,而不是写死的补丁。使用者可以:关掉截断看完整原文、调小长度让协议树更紧凑、改变后缀提示。对团队协作来说,这意味着不同的人在不同场景下,不用各自维护一个插件分支。
2.4 三种方案取舍对照
| 方案 | 灵活性 | 对过滤影响 | 实现成本 | 适用场景 |
|---|---|---|---|---|
| add() 的 value 参数 | 中 | 无 | 低 | 临时调试、轻微缩短 |
| set_text() 重写 | 中 | 无 | 中 | 需要精确控制显示文本 |
| Pref 偏好配置 | 高 | 无 | 中高 | 交付给团队使用的插件 |
三种方案都不影响原始数据,这是底线。差距只在于“想改的时候要不要改代码”。如果只是自己临时看一眼,思路一足够;如果是维护一个长期使用的插件,直接上思路三,省心。
3. 完整可复用的 Lua 实现:可配置截断
3.1 协议框架与字段注册
下面这套实现,基于一个很典型的自定义协议结构:开头 2 字节表示消息长度,随后是 UTF-8 消息字符串,最后是剩余负载。先看字段注册和偏好定义:
-- myproto.lua do local proto = Proto("myproto", "My Custom Protocol") local f_len = ProtoField.uint16("myproto.len", "消息长度", base.DEC) local f_msg = ProtoField.string("myproto.msg", "消息内容") local f_rest = ProtoField.bytes("myproto.rest", "剩余负载") proto.fields = { f_len, f_msg, f_rest } proto.prefs = { enable_trunc = Pref.bool("启用截断显示", true, "仅影响协议树显示,不影响过滤"), max_len = Pref.uint("最大显示长度(字节)", 64, "超过该长度时截断显示"), suffix = Pref.string("截断后缀", "...", "截断后追加的提示文本"), }Pref.bool、Pref.uint、Pref.string都是 Wireshark 内建的偏好类型。第一个参数是界面显示名,第二个是默认值,第三个是鼠标悬停时的说明文字。只要把proto.prefs赋值好,Preferences 里会自动出现这个协议的分组。
3.2 UTF-8 安全截断函数
如果只是用string.sub(s, 1, max_len),一旦截断点在多字节字符中间,界面就会出现乱码。我写过一版按字节截断、但不切断 UTF-8 字符的函数:
-- 按字节数截断,并保证不切断多字节字符 local function utf8_truncate(s, max_bytes) if #s <= max_bytes then return s, false end local n = max_bytes while n > 0 do local b = s:byte(n) if b < 0x80 then -- 当前字节是单字节字符,直接截到这儿 return s:sub(1, n), true elseif b >= 0xC0 then -- 当前字节是某个多字节字符的首字节,说明该字符不完整 return s:sub(1, n - 1), true end n = n - 1 end return s:sub(1, max_bytes), true end逻辑是这样的:从max_bytes位置往前倒退,如果碰到 ASCII 字节(小于 0x80),说明这里天然是安全边界;如果碰到 0xC0 到 0xFF 的首字节,说明这个字符只有开头、没有后续的续字节,属于被切了一半,就再退一个字节,把半个字符整个去掉。这样截出来的一定是完整的 Unicode 字符序列。
3.3 解包与截断逻辑
核心 dissector 里,先解析长度,再取出消息字段,最后做条件截断:
function proto.dissector(buffer, pinfo, tree) if buffer:len() < 2 then return end local subtree = tree:add(proto, buffer(), "My Custom Protocol Data") local offset = 0 local msg_len = buffer(offset, 2):uint() offset = offset + 2 if buffer:len() < offset + msg_len then pinfo.cols.info = "长度越界" return end subtree:add(f_len, buffer(0, 2)) if msg_len > 0 then local msg_tvbr = buffer(offset, msg_len) offset = offset + msg_len local item = subtree:add(f_msg, msg_tvbr) if proto.prefs.enable_trunc.value then local raw = msg_tvbr:string() local max_len = proto.prefs.max_len.value local shown, was_cut = utf8_truncate(raw, max_len) if was_cut then local label = f_msg.description or f_msg.name item:set_text(string.format( "%s: %s%s", label, shown, proto.prefs.suffix.value)) end end else offset = offset + 0 end if buffer:len() > offset then subtree:add(f_rest, buffer(offset)) end pinfo.cols.protocol = "MYPROTO" end这里有个细节:msg_len为 0 时,buffer(offset, 0)是不合法的零长度范围,直接 add 会报错,所以加了if msg_len > 0的守卫。实际协议里字符串长度为 0 的情况并不少见,处理不当就是插件崩溃。
3.4 把插件挂到端口并验证
在文件末尾注册到端口,比如 TCP 40001:
DissectorTable.get("tcp.port"):add(40001, proto) end启动 Wireshark 后,可以在Decode As里看到或者直接监听 40001 端口。验证时准备一份测试 pcap,里面放一个长度超过 64 字节的中英文混合字符串。解析后,协议树里应当显示前 64 个字节加省略号,同时底部状态栏点击该字段时,还能看到完整的原始字节。这说明显示被截断了,数据本身没丢。
如果不想监听固定端口,也可以用启发式注册proto:register_heuristic("tcp"),在启发式协议列表里勾选。固定端口适合测试环境,启发式适合交付现场。
4. 排查与避坑:截断显示后最容易踩的四个坑
4.1 过滤条件没变,但显示值看起来不对
使用item:set_text()之后,协议树里显示的是截断文本,但myproto.msg contains "某个关键词"这样的过滤器匹配的还是完整原始数据。也就是说,会出现界面上看不到关键词、过滤却命中的情况。
这不是 bug,而是显示层与过滤层解耦带来的正常现象。但如果你在帮别人排查问题时,对着过滤结果反推显示文本,就会觉得“骗人”。我建议在偏好说明里写清楚:截断只影响协议树显示,不影响过滤。这也能避免同事误以为数据损坏。
4.2 中文乱码与半个字符
不用utf8_truncate,直接用string.sub(raw, 1, max_len),大概率会切到某个多字节字符中间。Wireshark 遇到非法 UTF-8 时,会在树里显示一个替换符,整个字符串后半段看起来都是乱的。
解决方式就是 3.2 那个函数。另外注意,这个函数是按字节截断,不是按“字符个数”截断。如果你希望严格按字符数,得先把字符串转成 UTF-8 字符序列再做计算,成本会高不少。我的经验是,做显示截断时按字节数够用,因为真正的问题是“屏幕宽度”而不是“字数”。
4.3 导出与复制拿到的是截断文本
File → Export Packet Dissections → As Plain Text导出的内容,是协议树里显示出来的文本,也就是说,截断后的省略号会出现在导出文件里。如果你用这个文件给数据分析同事用,他们会认为原报文的字段就这么长,进而做出错误判断。
需要完整字段值时,别依赖 GUI 导出,改用命令行:tshark -r demo.pcap -Y "myproto" -T fields -e frame.number -e myproto.msg。这样输出的是完整原始值,不再经过显示层。如果觉得太长,再用cut -c1-80这类外部命令做后处理,数据链路上始终保留着完整值。
4.4 偏好修改不生效
改完Preferences → Protocols → My Custom Protocol里的“最大显示长度”,当前 packet 的协议树有时候不会立刻刷新,尤其是静态读取 pcap 文件时更明显。这时按Ctrl+R重新加载文件,或者重新选中一次包,新值就会生效。
还有一个更隐蔽的问题:如果 Lua 脚本是用-X lua_script:myproto.lua临时加载的,Preferences 里可能不出现该协议分组,需改用插件目录或Analyze → Reload Lua Plugins加载方式。我第一次就是被这个问题卡住,以为 Pref 写错了,换完加载方式立刻正常。
5. 列视图与命令行场景的截断策略
5.1 包列表自定列的长字段处理
截断配置主要影响 Packet Details 面板,但很多人会忽略包列表(Packet List)里的自定列。右键协议树里的myproto.msg字段,选择Apply as Column,它会变成一列。列宽不够时,Wireshark 会按列宽做视觉截断,但这套截断逻辑和 Preferences 里的偏好无关,纯粹是 UI 层面的宽度限制。
我通常会把两个场景分开理解:列视图的截断是“临时折叠”,完整值还在详情树里;详情树的截断是“主动整理”,让协议树保持可读。写插件时,这两个场景的取舍不同:列视图里我一般展示一个短摘要字段,比如myproto.msg的前 16 字节;详情树里才做完整截断显示。
5.2 TShark 导出大字段的取舍
命令行场景下,TShark 不经过 GUI 显示层,因此不受 Lua 截断偏好影响。-T fields输出的是字段的完整原始值,处理超大字段时需要自己想办法瘦身。我常用的组合是这样:
tshark -r demo.pcap -Y "myproto" -T fields \ -e frame.number -e myproto.msg \ -E header=y -E separator=, | cut -c1-200-E header=y会在首行输出字段名,-E separator=,把多个字段用逗号隔开,cut则把整行输出限制在前 200 个字符。如果你需要按字段截断而不是整行截断,可以写一个非常简单的 Lua tap,在输出监听器里套用同一个utf8_truncate函数,这样终端输出的就是自研协议专用的“短版本”。这也是我刚才为什么坚持把截断函数单独拎出来的原因,它能同时在 GUI 和命令行两种场景复用。
最后再分享一个我在实际项目中积累的小经验:给字符串字段做截断显示时,默认长度别设太大,64 字节已经能覆盖绝大多数人眼可读的场景,比如短标识、文件名、状态描述。一旦超过这个长度,要么是调试用的日志数据,要么是二进制转文本后的展示,这两种场景都需要通过偏好关掉截断,而不是无脑放宽限制。把方案做成可配置,不是为了让你一开始就找到一个完美值,而是让不同场景下的人都有权自己决定这个值。