二进制协议设计首先要定义接收方如何从字节中得到明确、有限且可验证的消息。紧凑编码只是其中一项;还要规定字段含义、重复处理、未知值、长度上限、状态转换和错误行为。文本也可以有严格 framing,二进制也可能比某段文本更大,不能用形式直接推导质量。
| 层次 | 应明确的内容 | 常见混淆 |
| 传输 | TCP 字节流、UDP 数据报或已有流协议 | 把一次 read 当成一条消息 |
| Framing | 消息从哪里开始、何时完整、最大多长 | 只有 payload schema,没有消息边界 |
| 字段编码 | 类型、字节序、长度和整数表示 | 与内存结构直接等同 |
| 语义 | 单位、范围、缺省、状态、错误 | 字节能解析就认为请求可执行 |
| 演进 | 未知字段、能力与关键扩展规则 | 所有未知内容无条件忽略 |
Framing:长度前缀也需要完整契约
TCP 不保留应用写入边界。两次 send 可以在一次 read 中出现,一次 send 也可被多次 read 才读完。UDP 保留数据报边界,但仍要判断应用消息是否完整、是否允许分片或多条记录,以及接收缓冲是否导致截断。
| 方法 | 优点 | 必须补齐的规则 |
| 固定长度 | 边界直接可算 | 版本变化、填充和浪费 |
| 长度前缀 | 支持任意 payload 字节 | 长度是否含头、上限、溢出、截断 |
| 分隔符 | 适合流式文本或记录 | 转义、跨 read 匹配、最长记录 |
| 分块/结束帧 | 无需预知总长 | 块上限、结束状态、总量与超时 |
HTTP 头部的空行只界定头部结束,不独立决定整个响应 body 的长度;SMTP DATA 还依赖补点转义。不能把这些局部机制当成无需其他状态的通用 framing。
长度字段不是可信的内存分配指令。接收方应先验证固定头和长度上限,再逐步接收;对于长连接,还应限制未完成消息数量、总缓冲、组装时间和解压后的大小。限制单帧 1 MiB,仍不能防止一万个连接各保留半帧。
一个可推演的教学协议
下面定义一个仅用于说明解析边界的小协议,不是现有标准。它运行在可靠有序字节流上,使用 12 B 固定头和 TLV body;空 body 允许,所有多字节整数为大端。
| 字段 | 定义 |
| Magic | 两个字节 KB,用于辨认格式,不是认证 |
| Version | 当前只接受 1,其他版本失败 |
| Flags | 当前必须为 0,避免默默忽略未知关键语义 |
| Body length | uint32,单位字节,不含固定头,最大 4096 |
| Request ID | uint32,用于本连接内关联请求;不天然是业务幂等键 |
| TLV | uint8 type + uint16 length + value |
TLV type 的最高位表示关键字段,低 7 位为字段 ID。本例已知 ID 1 为严格 UTF-8 名称,ID 2 为 4 B uint32 数量;已知 ID 不允许重复,其他可选字段跳过,未知关键字段拒绝。缺少已知字段在语法层允许,业务操作可另行要求必填。
这种“关键位”是本文协议主动定义的规则,不是所有 TLV 格式共有。读者只要知道长度就能跳过可选值,但不能因此理解其语义,更不能保证丢弃后仍可正确转发。
增量解析:等待、失败和正常完成分开
下面的解析器限制 body 长度,并逐帧验证 TLV。它接收调用者提供的 chunk,要求上层以有界读取喂入,并负责连接级预算和超时;示例不是完整服务器。
import struct
HEADER = struct.Struct('>2sBBII')
MAX_BODY = 4096
def parse_tlvs(body):
pos, values = 0, {}
while pos < len(body):
if len(body) - pos < 3:
raise ValueError('truncated TLV header')
kind, size = struct.unpack_from('>BH', body, pos)
pos += 3
if size > len(body) - pos:
raise ValueError('TLV exceeds body')
value = body[pos:pos + size]
pos += size
field_id = kind & 0x7f
if field_id not in (1, 2):
if kind & 0x80:
raise ValueError('unknown critical field')
continue
if field_id in values:
raise ValueError('duplicate known field')
if field_id == 1:
values[1] = value.decode('utf-8', errors='strict')
else:
if size != 4:
raise ValueError('quantity must be uint32')
values[2] = struct.unpack('>I', value)[0]
return values
class Decoder:
def __init__(self):
self.buffer = bytearray()
def feed(self, chunk):
self.buffer.extend(chunk)
messages = []
while len(self.buffer) >= HEADER.size:
magic, version, flags, size, request_id = HEADER.unpack_from(self.buffer)
if magic != b'KB' or version != 1 or flags != 0 or size > MAX_BODY:
raise ValueError('invalid header')
end = HEADER.size + size
if len(self.buffer) < end:
break
fields = parse_tlvs(bytes(self.buffer[HEADER.size:end]))
del self.buffer[:end]
messages.append((request_id, fields))
return messages
def eof(self):
if self.buffer:
raise ValueError('truncated frame')
def tlv(kind, value):
return struct.pack('>BH', kind, len(value)) + value
def frame(body, request_id=7):
return HEADER.pack(b'KB', 1, 0, len(body), request_id) + body
body = tlv(1, '温度'.encode()) + tlv(2, struct.pack('>I', 300))
wire = frame(body)
assert len(body) == 16 and len(wire) == 28
expected = [(7, {1: '温度', 2: 300})]
for split in range(len(wire) + 1):
decoder = Decoder()
assert decoder.feed(wire[:split]) + decoder.feed(wire[split:]) == expected
decoder.eof()
assert Decoder().feed(wire + wire) == expected + expected
assert Decoder().feed(frame(body + tlv(3, b'optional'))) == expected
bad = [
frame(body + tlv(0x83, b'critical')),
frame(body + tlv(1, b'duplicate')),
frame(tlv(1, b'\xff')),
frame(tlv(2, b'\x01')),
frame(b'\x01\x00\x04x'),
HEADER.pack(b'KB', 1, 0, MAX_BODY + 1, 7),
wire[:-1],
]
for candidate in bad:
decoder = Decoder()
try:
decoder.feed(candidate)
decoder.eof()
except ValueError:
pass
else:
raise AssertionError('invalid input accepted')
print('28 B 消息:所有二分边界、粘帧、可选字段和 7 类拒绝通过')
解析错误后,本例要求调用者终止该流的解码,不继续猜测下一个 KB 是不是帧头,因为这个字节序列也可能合法出现在 payload 内。若确实需要噪声中的重新同步,应另行设计同步、完整性与恢复规则。
生产实现还需考虑逐字节移动缓冲的成本、异常前已解析消息如何交付、内存所有权和异步取消。零拷贝并不免除生命周期约束:保留一小段 view 可能使整个大缓冲都无法释放。
收到半个头,是等待还是报错?
在11字节处切换EOF,等待会变成截断;在17字节处第一帧已经完整,即使第二帧尚未到达也可交付。再切换超长声明,到12字节就应拒绝,而不是等待4097字节把错误坐实。
正在呈现知识画面
收到半个头,是等待还是报错?字节不足在流未结束时可以等待,EOF后则是截断。长度超上限一旦头完整就拒绝,不能等待巨量body或先分配。
{"id":"binary-incremental-framing","title":"收到半个头,是等待还是报错?","summary":"字节不足在流未结束时可以等待,EOF后则是截断。长度超上限一旦头完整就拒绝,不能等待巨量body或先分配。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"presets\" role=\"group\" data-i18n-label=\"mode\"><button data-mode=\"good\" data-i18n=\"good\"></button><button data-mode=\"oversize\" data-i18n=\"oversize\"></button></div><div class=\"control\"><label for=\"available\" data-i18n=\"available\"></label><output for=\"available\" id=\"available-value\"></output><input id=\"available\" type=\"range\" min=\"0\" max=\"29\" value=\"11\" step=\"1\"></div><label class=\"check\"><input type=\"checkbox\" id=\"eof\"><span data-i18n=\"eof\"></span></label><div id=\"bytes\" class=\"rangegrid\"></div><div class=\"metrics\"><div><span data-i18n=\"frames\"></span><strong id=\"frames\"></strong></div><div><span data-i18n=\"status\"></span><strong id=\"status\"></strong></div></div><p id=\"trace\" class=\"message\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n.rangegrid{grid-template-columns:repeat(10,minmax(0,1fr))}.metrics strong{font-size:17px}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}let mode='good';function draw(){const data=new Uint8Array(29);data.set([75,66,1,0,0,0,0,5,0,0,0,7,1,0,2,76,105],0);data.set([75,66,1,0,0,0,0,0,0,0,0,8],17);if(mode==='oversize'){data[6]=16;data[7]=1}const n=+$('#available').value,eof=$('#eof').checked;let pos=0,frames=0,error='',trace=[];while(n-pos>=12){const len=new DataView(data.buffer).getUint32(pos+4);trace.push('at '+pos+': body '+len);if(len>4096){error='REJECT LENGTH';break}if(n-pos<12+len)break;pos+=12+len;frames++}const status=error||((n>pos)?(eof?'TRUNCATED':'WAIT'):eof?'EOF CLEAN':'BOUNDARY');set('available-value',n);cells('bytes',Array.from({length:30},(_,i)=>({text:i<29?data[i].toString(16).padStart(2,'0'):'·',cls:i>=n?'empty':i<pos?'on':'gold'})));set('frames',frames);set('status',status);set('trace',trace.join('\\n')+'\\nconsumed '+pos+'; buffered '+(n-pos));$('#status').dataset.snapshot=JSON.stringify({frames,pos,status});pressed(mode);}document.querySelectorAll('input').forEach(e=>e.oninput=draw);document.querySelectorAll('[data-mode]').forEach(b=>b.onclick=()=>{mode=b.dataset.mode;draw()});draw();","audio":false,"strings":{"available":"已到达字节","eof":"此处流已结束 EOF","frames":"完整已解析帧数","good":"两个合法帧","heading":"收到半个头,是等待还是报错?","intro":"移动已到达字节数,并单独声明EOF;查看两个拼接帧的边界。","mode":"输入模式","oversize":"首帧声明4097 B","reset":"重置","scope":"沿用正文KB协议12 B头,第一帧body为合法TLV name=Li共5 B,第二帧空body,共29 B。只验证固定头/framing,TLV语义由正文解析器验证。","scopeLabel":"模型范围与约定","status":"解析状态"}}
整数编码:Protobuf varint 与 QUIC varint 不同
| 编码 | 边界规则 | 数值 300 |
| uint32 大端 | 固定 4 B,高字节在前 | 00 00 01 2c |
| uint32 小端 | 固定 4 B,低字节在前 | 2c 01 00 00 |
| Protobuf 无符号 varint | 每字节 7 位值,最高位表示后续字节 | ac 02 |
| QUIC 变长整数 | 首字节最高 2 位决定总长 1/2/4/8 B | 41 2c |
Protobuf varint 的低位组先出现,uint64 最长 10 B;QUIC 在长度标记之外按网络字节序编码,最大 62 位。QUIC 的一般整数编码允许某些非最短表示,但具体字段如 frame type 另有最短编码要求,不能通用化成“所有 varint 都必须最短”。QUIC §16
def uvarint(n):
if not 0 <= n < 1 << 64:
raise ValueError('not uint64')
result = bytearray()
while n >= 128:
result.append((n & 127) | 128)
n >>= 7
result.append(n)
return bytes(result)
def quic_integer(n):
for length, bits, prefix in [(1, 6, 0), (2, 14, 1), (4, 30, 2), (8, 62, 3)]:
if 0 <= n < 1 << bits:
return (n | (prefix << (8 * length - 2))).to_bytes(length, 'big')
raise ValueError('not uint62')
assert uvarint(300).hex() == 'ac02'
assert quic_integer(300).hex() == '412c'
assert len(uvarint(127)) == 1 and len(uvarint(128)) == 2
assert len(quic_integer(63)) == 1 and len(quic_integer(64)) == 2
assert len(uvarint(2**64 - 1)) == 10
assert len(quic_integer(2**62 - 1)) == 8
zigzag = lambda n: 2*n if n >= 0 else -2*n-1
assert [zigzag(n) for n in [0, -1, 1, -2]] == [0, 1, 2, 3]
assert uvarint(zigzag(-2)) == b'\x03'
print('两类 varint、边界和 ZigZag 映射通过')
ZigZag 是值映射,之后才用 varint 编码。Protobuf 的 sint32/sint64 使用它;int32/int64 的负数采用另一规则,负数可能占 10 B。不能仅因为它们的 wire type 都是 0 就互换声明。
同一个300,为什么两个varint完全不同?
先在63/64附近观察QUIC增加到2字节,再在127/128附近观察Protobuf的变化;它们不是同一套“最高位表示长度”规则。移动到300,对照ac02与412c中的位组顺序。
正在呈现知识画面
同一个300,为什么两个varint完全不同?编码的组顺序与长度信号不同。Protobuf按低7位组先发,QUIC先声明总长并保留大端数值顺序。
{"id":"integer-wire-boundaries","title":"同一个300,为什么两个varint完全不同?","summary":"编码的组顺序与长度信号不同。Protobuf按低7位组先发,QUIC先声明总长并保留大端数值顺序。","height":1000,"html":"<h2 data-i18n=\"heading\"></h2><p class=\"intro\" data-i18n=\"intro\"></p><div class=\"control\"><label for=\"value\" data-i18n=\"value\"></label><output for=\"value\" id=\"value-value\"></output><input id=\"value\" type=\"range\" min=\"0\" max=\"65535\" value=\"300\" step=\"1\"></div><section class=\"panel\"><h3 data-i18n=\"wire\"></h3><div id=\"wire\"></div></section><p id=\"equation\" class=\"formula\"></p><details class=\"scope\"><summary data-i18n=\"scopeLabel\"></summary><p data-i18n=\"scope\"></p></details>","css":".intro{margin:8px 0 18px;color:var(--muted);font-size:14px}.caption,.hint{font-size:12px;color:var(--muted);margin-top:12px}.control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:6px 12px;align-items:center;margin:14px 0;font-size:13px}.control label{font-size:13px}.control output{color:var(--accent);min-width:5em;text-align:right;font-variant-numeric:tabular-nums}.control input{grid-column:1/-1;width:100%;margin:0}.presets{display:flex;gap:4px;align-items:stretch}.presets button{flex:1 1 0;min-width:0;overflow-wrap:anywhere;font-size:13px}.actions{display:flex;gap:8px;flex-wrap:wrap;margin:14px 0}.actions button{flex:1;min-width:0;font-size:13px;min-height:42px}.copy{display:grid;font-size:14px;margin:16px 0}.copy>*{grid-area:1/1;margin:0}.reserve{visibility:hidden}.scope{margin-top:14px;color:var(--muted);font-size:12px}.scope summary{padding:8px 0;cursor:pointer}.scope p{margin-top:10px}.two{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.card{border:1px solid var(--rule);border-radius:9px;padding:14px;min-width:0}.card header{font-size:14px;font-weight:600;margin-bottom:10px}.card button{font-size:12px;width:100%;min-width:0}.card small{display:block;font-size:12px;color:var(--muted)}.card code{font-size:12px;overflow-wrap:anywhere}.metrics{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.metrics>div{border-left:2px solid var(--accent);padding-left:10px;min-width:0}.metrics span{display:block;min-height:3.2em;color:var(--muted);font-size:12px}.metrics strong{font-size:23px;display:block;font-weight:500;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.formula{border-top:1px solid var(--rule);padding-top:12px;margin-top:14px;font-size:13px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}.value{font:500 20px var(--viz-font-ui)}.legend{display:flex;flex-wrap:wrap;gap:6px 14px;font-size:12px;color:var(--muted);margin:12px 0}.legend span:before{content:'';display:inline-block;width:8px;height:8px;border-radius:2px;background:var(--accent);margin-right:6px}.legend .second:before{background:var(--second)}.check{display:flex;align-items:center;gap:8px;font-size:13px;margin:12px 0}.check input{width:16px;height:16px;flex-shrink:0;accent-color:var(--accent)}select,input[type=text],input[type=number]{background:var(--paper);color:var(--ink);font:inherit;font-size:13px;padding:10px;border:1px solid var(--rule);border-radius:7px;max-width:100%;min-width:0}select{width:100%;min-height:42px}.band{height:20px;display:flex;background:var(--surface);border-radius:4px;overflow:hidden}.band i{height:100%;display:block;background:var(--accent);transition:width .25s}.band i.second{background:var(--second)}button:disabled{cursor:default}.status{font-size:13px;min-height:4.8em}.mono{font-family:var(--viz-font-mono);font-variant-numeric:tabular-nums}.muted{color:var(--muted)}\n@media(max-width:480px){.two{gap:8px}.card{padding:12px 9px}.actions button,.presets button{font-size:12px}.metrics strong{font-size:20px}.metrics{gap:8px}}\n@media(prefers-reduced-motion:reduce){*,*::before,*::after{transition:none!important;animation:none!important}}\n.track-row{margin:16px 0}.track-row>span{display:block;font-size:12px;color:var(--muted);margin-bottom:7px}.track{display:flex;gap:5px;min-width:0}.cell{flex:1;min-width:0;min-height:48px;border:1px solid var(--rule);border-radius:5px;background:var(--surface);display:flex;align-items:center;justify-content:center;text-align:center;padding:5px 3px;font-size:12px;overflow-wrap:anywhere;font-variant-numeric:tabular-nums}.cell.on{background:var(--accent-soft);border-color:var(--accent)}.cell.gold{background:var(--second-soft);border-color:var(--second)}.cell.empty{border-style:dashed;color:var(--muted)}.cell.error{border-color:var(--second);text-decoration:line-through}.choices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px;margin:14px 0}.choices button{min-width:0;padding:10px 4px;font-size:12px;overflow-wrap:anywhere}.choices button[aria-current=true]{border-color:var(--accent);background:var(--accent-soft)}.relation{display:grid;grid-template-columns:minmax(0,1fr) 28px minmax(0,1fr);align-items:center;gap:6px;margin:12px 0}.relation .cell{min-height:72px}.relation>i{text-align:center;font-style:normal;color:var(--accent)}.readout{font-size:13px;white-space:pre-line;min-height:6.4em;overflow-wrap:anywhere}.plot{position:relative;border-left:1px solid var(--rule);border-bottom:1px solid var(--rule);padding:8px 0;margin:18px 0}.plot-row{height:28px;position:relative;margin:4px 0;background:var(--surface)}.plot-row i{position:absolute;inset-block:5px;background:var(--accent);min-width:2px;transition:width .2s,left .2s}.plot-row b{position:relative;padding:4px 6px;font:11px/28px var(--viz-font-ui);background:var(--paper);border-radius:3px}.axis{display:flex;justify-content:space-between;font-size:11px;color:var(--muted)}\n\n/* Action labels must remain readable across locales and narrow embeds. */\n.actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}\n.actions button{overflow-wrap:anywhere;white-space:normal;min-height:58px;padding:8px}\nbutton{overflow-wrap:anywhere}\n@media(min-width:640px){.actions{grid-template-columns:repeat(3,minmax(0,1fr))}}\n\n.metrics span{overflow-wrap:anywhere}\n.panel{border:1px solid var(--rule);border-radius:9px;padding:12px;margin:14px 0;min-width:0}.panel h3{font-size:13px;margin:0 0 10px;color:var(--muted)}.source{font:13px/1.8 var(--viz-font-mono);white-space:pre-wrap;overflow-wrap:anywhere;margin:12px 0}.source mark{background:var(--accent-soft);color:var(--ink);border-bottom:2px solid var(--accent)}.source .fault{background:var(--second-soft);border-color:var(--second)}.diagram{display:block;width:100%;height:auto;max-height:320px;margin:14px 0;overflow:visible}.diagram text{fill:var(--ink);font:12px var(--viz-font-ui)}.diagram .edge{stroke:var(--muted);stroke-width:1.5;fill:none}.diagram .edge.on{stroke:var(--accent);stroke-width:3}.diagram .node{fill:var(--surface);stroke:var(--rule);stroke-width:1.5}.diagram .node.on{fill:var(--accent-soft);stroke:var(--accent)}.diagram .node.gold{fill:var(--second-soft);stroke:var(--second)}.log{font:12px/1.8 var(--viz-font-mono);white-space:pre-line;overflow-wrap:anywhere;min-height:7.2em}.table{display:grid;gap:5px;margin:14px 0}.table .tr{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:5px}.table .tr>*{padding:8px 5px;background:var(--surface);border-radius:4px;min-width:0;overflow-wrap:anywhere;font-size:12px}.table .tr .on{background:var(--accent-soft)}.table .tr .gold{background:var(--second-soft)}.choices button{min-height:44px}.metrics strong{min-height:1.6em}.code-line{display:block}.code-line.on{background:var(--accent-soft);border-left:2px solid var(--accent);padding-left:6px}.status-badge{font-size:12px;color:var(--accent)}.tile-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:6px}.tile-grid .cell{min-height:40px}.actions button{min-height:48px}.form-field{display:grid;gap:8px;margin:14px 0;font-size:13px}.form-field input{width:100%;font-family:var(--viz-font-ui)}.metrics.three{grid-template-columns:repeat(3,minmax(0,1fr))}.formula{min-height:3.2em}.presets{flex-wrap:wrap}.presets button{flex-basis:25%;min-height:44px}\n\n.budget{display:flex;height:28px;margin:16px 0;background:var(--surface);border-radius:5px;overflow:hidden}.budget i{display:block;background:var(--accent);transition:width .2s}.budget i.gold{background:var(--second)}.intervals{margin:16px 0}.interval{position:relative;height:36px;background:var(--surface);margin:6px 0;border-radius:4px;overflow:hidden}.interval i{position:absolute;height:100%;background:var(--accent-soft);border-left:2px solid var(--accent)}.interval i.gold{background:var(--second-soft);border-color:var(--second)}.interval span{position:relative;z-index:1;font:12px/36px var(--viz-font-ui);padding-left:7px}.table .tr>*{min-height:4.8em}.formula{white-space:pre-line;min-height:7.2em}.actions:has(>button:only-child){grid-template-columns:1fr}.metrics strong{min-height:3.2em}.rangegrid{display:grid;grid-template-columns:repeat(12,minmax(0,1fr));gap:3px;margin:14px 0}.rangegrid .cell{min-height:30px;padding:3px 1px;font-size:10px}.message{min-height:7.2em;white-space:pre-line;font:13px/1.8 var(--viz-font-ui);overflow-wrap:anywhere}\n/* Allow CJK hanging punctuation without overflowing a metric's border box. */\n.metrics>div{padding-right:4px}.metrics span{line-break:anywhere}\n\n#wire .tr{grid-template-columns:1fr 1.6fr .5fr}.tr>*{min-height:4.8em;font-family:var(--viz-font-ui)}","js":"const $=s=>document.querySelector(s);const set=(id,v)=>$('#'+id).textContent=v;const t=k=>viz.t(k);function cells(id,values){$('#'+id).replaceChildren(...values.map(v=>{const e=document.createElement('div');e.className='cell '+(v.cls||'');e.textContent=v.text;e.title=v.title||v.text;return e;}));}function pressed(mode){document.querySelectorAll('[data-mode]').forEach(e=>e.setAttribute('aria-pressed',String(e.dataset.mode===mode)));}const esc=s=>String(s).replace(/[&<>\"']/g,c=>({'&':'&','<':'<','>':'>','\"':'"',\"'\":'''}[c]));const fmt=s=>'{'+[...s].sort().join(', ')+'}';function graph(id,nodes,edges){const el=$('#'+id);el.setAttribute('viewBox','0 0 360 200');el.innerHTML='<defs><marker id=\"arrow-'+id+'\" viewBox=\"0 0 10 10\" refX=\"8\" refY=\"5\" markerWidth=\"5\" markerHeight=\"5\" orient=\"auto-start-reverse\"><path d=\"M 0 0 L 10 5 L 0 10 z\" fill=\"var(--muted)\"/></marker></defs>'+edges.map(e=>{const a=nodes[e[0]],b=nodes[e[1]],dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||1;if(edges.some(r=>r[0]===e[1]&&r[1]===e[0])){const nx=-dy/d,ny=dx/d;return '<path class=\"edge '+(e[2]?'on':'')+'\" d=\"M '+(a.x+dx/d*22+nx*12)+' '+(a.y+dy/d*22+ny*12)+' Q '+((a.x+b.x)/2+nx*38)+' '+((a.y+b.y)/2+ny*38)+' '+(b.x-dx/d*25+nx*12)+' '+(b.y-dy/d*25+ny*12)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}return '<line class=\"edge '+(e[2]?'on':'')+'\" x1=\"'+(a.x+dx/d*25)+'\" y1=\"'+(a.y+dy/d*25)+'\" x2=\"'+(b.x-dx/d*28)+'\" y2=\"'+(b.y-dy/d*28)+'\" marker-end=\"url(#arrow-'+id+')\"/>';}).join('')+nodes.map(n=>'<g><circle class=\"node '+(n.cls||'')+'\" cx=\"'+n.x+'\" cy=\"'+n.y+'\" r=\"25\"/><text x=\"'+n.x+'\" y=\"'+(n.y+4)+'\" text-anchor=\"middle\">'+esc(n.name)+'</text>'+(n.sub?'<text x=\"'+n.x+'\" y=\"'+(n.y+43)+'\" text-anchor=\"middle\">'+esc(n.sub)+'</text>':'')+'</g>').join('');}function table(id,rows){$('#'+id).classList.add('table');$('#'+id).replaceChildren(...rows.map(r=>{const row=document.createElement('div');row.className='tr';r.forEach(x=>{const c=document.createElement('div');if(typeof x==='object'){c.textContent=x.text;c.className=x.cls||'';}else c.textContent=x;row.append(c)});return row;}));}function band(id,parts,total){$('#'+id).replaceChildren(...parts.map((p,i)=>{const e=document.createElement('i');e.style.width=(100*p.value/total)+'%';e.className=i%2?'gold':'on';e.title=p.name+': '+p.value;return e}));}function intervals(id,items,total){$('#'+id).replaceChildren(...items.map(p=>{const row=document.createElement('div');row.className='interval';const bar=document.createElement('i');bar.style.left=(100*p.start/total)+'%';bar.style.width=(100*(p.end-p.start)/total)+'%';bar.className=p.cls||'';const label=document.createElement('span');label.textContent=p.label;row.append(bar,label);return row;}));}function draw(){const n=+$('#value').value;let x=n,p=[];do{const low=x%128;x=Math.floor(x/128);p.push(low+(x?128:0))}while(x);const len=n<64?1:n<16384?2:4,q=Array.from({length:len},(_,i)=>Math.floor(n/256**(len-1-i))%256);q[0]|=({1:0,2:64,4:128})[len];const be=[0,0,n>>8,n&255],hex=b=>b.map(x=>x.toString(16).padStart(2,'0')).join(' ');set('value-value',n);table('wire',[['encoding','bytes','B'],['uint32 BE',hex(be),4],['Protobuf',hex(p),p.length],['QUIC',hex(q),q.length]]);set('equation','Protobuf thresholds: 128, 16384\\nQUIC thresholds: 64, 16384\\n'+n+' = '+(n%128)+' + 128×'+Math.floor(n/128));$('#wire').dataset.values=JSON.stringify({be,p,q});}$('#value').oninput=draw;draw();","audio":false,"strings":{"heading":"同一个300,为什么两个varint完全不同?","intro":"修改整数,比较固定大端、7位续接与QUIC长度前缀的实际字节。","reset":"重置","scope":"无符号0…65535,QUIC使用最短编码;不含Protobuf字段tag,完整uint64与QUIC62位边界见正文。","scopeLabel":"模型范围与约定","value":"待编码整数","wire":"实际字节(十六进制)"}}
Protobuf:wire type 给边界,schema 给语义
syntax = "proto3";
message Person {
string name = 1;
uint32 age = 2;
}
name="Li"、age=300 的一种编码是 0a 02 4c 69 10 ac 02:字段 1 的 tag 为 (1 << 3) | 2 = 0a,随后长度 2 和 UTF-8;字段 2 的 tag 为 10,随后 varint ac 02。字段名不出现在 wire 中,年龄单位与约束也不由 tag 自己解释。
| Wire type | 边界 | 可能的语义 |
| 0 | varint 结束位 | int、uint、sint、bool、enum |
| 1 | 8 B | fixed64、double 等 |
| 2 | 长度前缀 | string、bytes、嵌套消息、packed repeated |
| 5 | 4 B | fixed32、float 等 |
| 3 / 4 | 旧 group 起止 | 已弃用的 group 表达 |
因此 Protobuf 并不是每个字段都显式携带独立 length 的统一三段 TLV。fixed32/fixed64 等使用小端,与 TCP/IP 头的常见大端不同;变长整数则应按自己的位组规则理解。Protobuf Encoding
未知字段的保留、字段存在性与 JSON 转换行为要依据具体 runtime 和接口。把二进制消息读成对象,再手工只复制已知字段,可能丢失原本可保留的未知内容;wire 可兼容不等于所有中间处理都无损。Proto3 未知字段
字节序、规范化和完整性分别决定
新协议应明文规定字段宽度、符号、字节序、浮点表示、文本编码和单位,而不是“选 CPU native 就好”。直接发送 C struct 会引入 padding、对齐和 ABI 差异;CPU 允许未对齐访问,也不代表另一端的语言和硬件同样允许。
签名、哈希和缓存键还要求定义究竟绑定原始字节还是规范化后的语义。两个对象等价,不代表它们的序列化字节唯一。若需要唯一表示,应明确字段顺序、重复键、浮点 NaN、数字最短编码等规则;CBOR 的确定性编码就是将这些要求显式写出的例子,而非“所有 CBOR 都天然规范化”。CBOR 确定性编码
CRC 或校验和主要发现适用故障模型下的意外损坏,不能证明报文来自可信对端;认证需要 MAC、AEAD 或签名及密钥信任。压缩也要规定解压上限,原始帧不大不等于展开后资源开销很小。
设计完成的判据是失败输入也有确定行为
| 输入或状态 | 应规定的结果 |
| 半个头、半个 body | 等待;EOF 或超时后明确失败 |
| 超长声明、整数溢出 | 在分配或加法溢出前拒绝 |
| 未知可选/关键字段 | 按明确规则跳过或失败 |
| 重复字段 | 拒绝、合并或优先规则必须唯一 |
| 不合法 UTF-8、超深嵌套 | 明确接受范围和预算 |
| 不在当前状态允许的消息 | 协议错误或规定的恢复 |
| 重试同一业务请求 | 由业务幂等契约决定效果,不靠 framing 保证 |
优先测试截断、拼接、边界长度、未知关键字段、重复值和跨语言向量,再做模糊测试与资源预算测试。容错应是明文定义的互操作行为,不是各实现猜着接受不同的输入。Maintaining Robust Protocols