Skip to content

第 1 章 网络通信究竟在传输什么 ​

返回目录 · 编写进度

配置里出现目标端口 443,抓包工具却显示 01 bb;配置中的一段身份字符串很长,线上可能只有固定数量的字节;文字“你好”看起来只有两个字,却可能占六个字节。它们没有矛盾:人看到的值、程序中的对象和网络上的编码是不同层次。

首先要建立一个约定:本书展示的 01 bb 默认是两个字节的十六进制表示,不是由 0、1、空格、b、b 五个字符组成的文本。每个示例仍明确说明偏移和数据来源,避免仅凭排版猜测。

1.1 比特与字节:通信数据的计量单位 ​

从实际问题建立直觉 ​

假设本地代理收到一段应用数据,长度为 1024 字节。代理软件显示当前速度 1 MB/s,另一个工具显示 8 Mb/s。若两者都使用十进制单位,它们可以表示相同的数据速率:每字节八比特,1024 字节就是 8192 比特。这只是单位换算,不能据此断言实际测速应该完全一致,因为两个工具可能观测不同层的数据,也可能采用不同采样窗口。

可以把一个字节想成八个只能取 0 或 1 的位置;但网络并不保证用八次独立“开关动作”传一个字节。电信号、光信号、无线调制、纠错和链路编码属于更底层实现。这里讨论的是协议与程序操作的逻辑数据,不是物理波形。

严谨定义 ​

比特(bit)是取值集合为 {0,1} 的二进制位。一个长度为 n 的比特串是该集合的 n 重笛卡尔积中的元素,共有 2n 个不同取值。比特表示取值容量;它本身不规定取值的语义。

字节(byte)在本书及所讨论的现代网络协议中固定为八比特。标准中更精确的术语是八位组(octet),它明确排除历史上不同字节宽度的机器。因而本书的“一字节字段”就是一八位组字段。

若一个字节的各位由高到低写为 b7b6⋯b0,且把它解释为无符号整数,则数值为

x=∑i=07bi2i,0≤x≤255.

这里的“无符号整数”是解释规则,不是字节自带的标签。同一字节也可以代表字符编码、命令码、位标志或加密输出中的一部分。协议规范决定如何解释;抓包工具必须选对解析器才能显示相应名称。

一个有限字节序列可以写成

B=(x0,x1,…,xm−1),xi∈{0,…,255}.

其长度为 m 字节。下标 i 称为从序列开始计数的偏移(offset),本书偏移从 0 开始。区间 [a,b) 包含偏移 a 到 b−1,长度为 b−a。明确采用半开区间可以避免“第 1 字节”与“偏移 1”混淆。

底层机制:字节并不自带意义 ​

两个程序只要对同一段数据采用不同解释,就会得到不同结果。例如,单字节二进制 01000001 作为无符号整数是 65;作为 ASCII 字符编码是 A。ASCII 是一种字母、数字及控制符的编码表,下一小节系列会详细建立它的规则。[R20]

另一段字节 00000000 00000001 有两个字节。它可能表示“一个两字节整数”,也可能表示“命令 0 后面跟一个长度 1”。没有字段布局不能决定哪一种正确。因此,阅读报文需要同时掌握数据和协议格式,而不是看到几个十六进制数就能识别全部含义。

位标志使用一个字节中的若干位分别表达独立条件。例如,本章教学约定 bit 0 代表“需要响应”,bit 1 代表“允许压缩”。值 3 的二进制是 00000011,两个标志均为 1;值 2 的二进制是 00000010,只有第二个标志为 1。这个约定是教学设计,不属于任何实际代理协议。

完整实例:三字节教学数据 ​

定义一段本地教学数据,第一字节为标志,第二字节为对象数量,第三字节为对象值。所有字段都是一个无符号字节,因此暂时没有多字节的字节序问题。

偏移区间长度二进制十进制本例含义
[0,1)1000000113需要响应并允许压缩
[1,2)1000000011一个对象
[2,3)10100000165对象值为 65

完整十六进制为 03 01 41。这是根据教学规则确定的合成数据,不是生产抓包,不是加密数据。总长度为 1+1+1=3 字节,即 24 比特。偏移 2 的数值为 65,并不意味着本教学协议把它定义成字符 A。

在后续协议设计中,接收程序先确认数据至少包含三字节,再检查标志、数量和对象值;如何在网络读取中保证“已经获得三字节”,由第 2—3 章解决。不能把这里已存在的完整内存序列误认为每次网络读取都能获得完整报文。

实例复核:计数、显示与值域 ​

三字节实例 03 01 41 的每个值都在 0—255 内,总量为 3×8=24 比特。将它们各写成八位二进制,分别得到 00000011、00000001、01000001;这些二进制文本只是显示形式,原始序列仍然只有三个字节。

若某一字段被设置为 256,就超出一字节无符号值域。正确处理是拒绝该值或由协议明确采用更宽字段;未经约定把它截断成零,会改变请求语义。显示八个二进制字符与传输八字节文本,也是不同的操作。

单位和观测范围 ​

本文采用 B 表示字节,bit 表示比特,明确写出单位以免软件标签歧义。十进制单位:1 kB = 1000 B,1 MB = 106 B;二进制单位:1 KiB = 1024 B,1 MiB = 220 B。KB 在软件中可能存在不同习惯,应检查工具说明。相同数值的 MB 和 MiB 不是相同数据量。

若应用交付有效内容 P 字节,协议另增加 H 字节,单从这一层计算,总量为 P+H,有效比例为 P/(P+H)。下层还会增加其他数据,稍后讨论;当前公式不把链路头、重传和双向流量混入一个未经定义的“总开销”。

常见误解与本节结论 ​

“八个字符一定是八个字节”并不成立,字符编码决定字节长度。“二进制比文本更低级”也不是恰当分类:文本需要编码成字节,所谓二进制协议通常只是字段直接使用字节值而非十进制文字。两种协议在线上都是字节数据。

本节的关键结论是:字节是长度单位,数值是解释结果,字段语义是协议约定。三者必须分别确认。下一节建立十六进制表示,使之后每一个实例都能按偏移逐字节阅读。

本节依据:[R20] 的 ASCII 对照用于说明同一字节的字符解释;计数和教学字段属于本书定义与可复现算术,不是 RFC 中的代理协议。

1.2 二进制、十进制与十六进制:改变表示,不改变数据 ​

实际问题与直观解释 ​

抓包工具几乎总提供十六进制视图。它既不是一种加密,也不是网络上的第三种数据类型;它是人阅读字节的一种记法。一个字节有 256 种取值,用二进制写八位较长,用十六进制恰好写两位。把二进制每四位分组,就能逐组对应一个十六进制数字。

例如 10101011 可以分成 1010 和 1011。第一组对应十进制 10,写为 a;第二组对应十进制 11,写为 b。所以这个字节写成 ab。这一步没有压缩字节:字节还是八比特,只是显示从八个字符缩短成两个字符。

严谨定义与转换 ​

在基数为 r 的位值记数法中,数字串 dn−1⋯d1d0 表示

x=∑i=0n−1diri,0≤di<r.

二进制取 r=2,十进制取 r=10,十六进制取 r=16。十六进制数字 a 到 f 对应 10 到 15,大小写不改变数值。0x 是某些程序语言和文档用来标识十六进制的前缀,不属于字段的真实字节。

十进制四位二进制十六进制十进制四位二进制十六进制
000000810008
100011910019
200102101010a
300113111011b
401004121100c
501015131101d
601106141110e
701117151111f

以 ab 为例,10×16+11=171。再用二进制展开,可得 128+32+8+2+1=171。两种结果相同,说明改变进制没有改变数值。

反过来,把非负整数转成十六进制,可以反复除以 16,记录余数,直到商为 0;将余数逆序排列。例如 443 除以 16 得商 27、余数 11;27 除以 16 得商 1、余数 11;1 除以 16 得商 0、余数 1。结果是 1bb。若协议要求两个字节并按高位先写,就补成 01 bb。这里“按高位先写”是一项编码约定,第 1.6 节会证明字节序怎样改变解码结果。

数值宽度与报文长度 ​

数值表示可以省略前导零,协议字段却往往不能。例如数字 1、01 和 0001 都可表示同一整数;但作为十六进制字节转储,01 是一个字节,00 01 是两个字节。把固定两字节字段的前导 00 删除会让后续字段位置改变,接收端将解析错误。

因此必须区分“十六进制整数”和“十六进制字节序列”。0x1bb 是整数记法;01 bb 是两个字节的排列。对于转储,本书每个字节写两个十六进制数字,用空格分隔。十六进制行首的偏移也可能使用十六进制,需要先确认工具约定。

例如行首 00000010 若是十六进制偏移,表示该行从第 16 字节开始,而不是第 10 字节。偏移只帮助定位,不是数据本身。Wireshark 左侧位置栏与右侧字符栏都是显示内容,不能直接复制全部列并声称复制了报文。

完整实例:读一段转储 ​

以下八字节是本书手工定义的确定性样例,不属于现有代理协议:

text
偏移(十进制): 0  1  2  3  4  5  6  7
原始字节(hex): 01 bb 00 04 41 42 00 ff
偏移区间长度字节本例解释
[0,2)201 bb高位在前的整数 443
[2,4)200 04高位在前的整数 4
[4,8)441 42 00 ff四个不作文本解释的原始字节

总长度为 8 字节,十六进制数字总数为 16。字段 [4,8) 的第一个字节是 41,数值为 65;最后一个字节是 ff,数值为 255。中间的 00 也是有效字节,不表示“这一行结束”。文本显示可能给 00、ff 画点号,但点号不是实际字节值。

完整恢复过程是:先把每一对十六进制数字还原成一个字节;按字段区间切片;再按约定解释数值。由于这里尚无网络通信,“完整过程”是编码、存储、解码三个本地步骤。网络发送和接收流程从第 2 章引入。

实例复核:逐步还原转储 ​

把 01 bb 00 04 41 42 00 ff 去掉分隔空格,得到 16 个十六进制数字;两位一组正好恢复八字节。前两字节按高权重先放的约定计算为 1×256+187=443。偏移 [4,8) 则恢复为数值 65、66、0、255,并不需要把它们解码为可显示文本。

如果转储只剩 1bb,不能直接声称它是几个完整字节:数字数为奇数,字节分组不完整。若它本来是整数记法,则仍能表示 443;若协议要求两字节,必须按字段宽度补成 01 bb。这再次说明数值文本和字节转储具有不同契约。

位操作与协议解析的联系 ​

检查某位是否为 1 可以对字节与位掩码作按位与。若标志值为 x,检查 bit i 就计算 x&2i 是否非零。3 & 1 非零,3 & 2 非零,3 & 4 为零。位掩码是挑出特定位的规则,不能把结果非零误认为结果一定为 1。

四位一组只用于便利表示;协议可以把多个位放在一个字节,或者跨字节定义字段。不能因为屏幕按两位十六进制分组,就认为每个字段必然占一个完整字节。第 10—12 章将接触位标志与可变长度编码。

本节结论:先固定字段宽度,再改变显示方式;先还原字节,再解释语义。最重要的检查式是“转储字节数 = 十六进制数字数 / 2”,其中空格、换行、行首偏移和注释都不计入数字数。本节所有数值由位值公式与算术交叉核对。

1.3 ASCII 与控制字符:文本也有不可见的字节 ​

实际问题与直觉 ​

浏览器发出的 HTTP 请求通常能在抓包中读出 GET 和 Host。可是从终端复制相同的可见文字再发送,服务器可能拒绝:行尾不对、空行缺失或混入了其他字节。肉眼相同的屏幕显示,不保证底层字节相同。

先考虑最小情形:字母 A 对应一个约定的编号。双方使用同一张编号表,接收方才能把字节显示为相同字母。这张表也包含一些不画出字形的值,例如换行。用“编号表”建立直觉很合适,但它不解释程序遇到该编号必须怎样行动:编码表定义字符身份,具体协议才定义它的控制作用。

严谨定义 ​

美国信息交换标准代码(American Standard Code for Information Interchange,ASCII)是一套七比特字符编码,编码值范围为 0—127。在本书的八位字节中表示 ASCII 时,最高位为 0。[R20] 从 00 到 1f 的值以及 7f 不属于通常可见的图形字符;空格 20 常表现为留白。21 到 7e 包含标点、数字和英文字母。

字符或名称十进制值十六进制字节说明
NUL000空字符,某些接口用作结束标志
HT909水平制表
LF100a换行字符
CR130d回车字符
SPACE3220空格
04830数字字符,不是整数零的单字节编码
A6541大写字母
a9761小写字母
DEL1277f删除字符

这里仅引用编码表;现代协议对 CR、LF 的语法使用仍需查对应规范,不能用 RFC 20 描述过的历史主机习惯来推断 HTTP。[R20]

底层机制:文本中的数字与数值字段 ​

整数 443 若作为十进制文本编码,写成三个字符 4、4、3,ASCII 字节是 34 34 33,总长度为 3。若作为固定两字节无符号整数、高位先写,则是 01 bb,总长度为 2。它们传达的数值可以相同,解析过程却不同。

文本解析器先确认每个字符属于十进制数字,再累积数值:初始 v=0,读取数字字符的数值 d 后令 v←10v+d。443 依次得到 4、44、443。二进制整数解析器则直接按字节权重计算。这解释了“文本协议”和“二进制协议”的实际区别,也解释了为什么把文本 443 填入一个两字节端口字段会破坏后续布局。

控制字符也是普通的字节值。00 是否意味着字符串结束,要看接口约定:C 风格字符串常采用 NUL 结束,而带显式长度的字节缓冲区可以包含任意数量的 00。网络读取返回的缓冲区不能因为中间出现 00 就自动丢弃后面的数据。

完整实例:六字节文本与转义表示 ​

定义文本为大写 A、数字字符 0、一个空格、一个 HT、一个 CR、一个 LF。确定性原始字节为:

text
41 30 20 09 0d 0a
偏移区间长度字节恢复的字符
[0,1)141A
[1,2)1300
[2,3)120SPACE
[3,4)109HT
[4,5)10dCR
[5,6)10aLF

在 Python 源码中可写成 b'A0 \t\r\n'。源码中的 \r 是转义写法,解析后对应一个字节 0d,不是反斜线和字母 r 两个字节。如果真的需要反斜线加字母 n,字节是 5c 6e,写成 b'\\n'。

“转义”表示用特定写法表达不便直接书写的字符;它属于这里的源码语法。不同语言、JSON、终端和 URL 可能采用不同转义规则,不能跨层直接套用。屏幕输出可能把 CR/LF 表现为移动光标或换行,因此比较数据时应同时检查长度和十六进制。

实例复核:不可见字符怎样改变报文 ​

六字节文本的可见转义表示为 A0 \t\r\n,但其原始内容仍是 41 30 20 09 0d 0a。若发送方误把末尾的反斜线与字母当成普通文本,所谓 \r\n 就成为 5c 72 5c 6e 四字节,接收方将看不到原本要求的 CRLF。

同样,十进制文本 443 是 34 34 33 三字节,而本书两字节整数规则是 01 bb。文本 A、NUL、文本 B 的序列为 41 00 42,带显式长度的字节缓冲区应保留全部三字节;采用 NUL 结束接口的组件可能只看到第一部分。跨接口时必须核验这项区别。

这些例子直接说明:排查文本协议不能只看终端中的字形,要核对控制字符、原始长度和转换规则。ASCII 不定义中文,不能靠给最高位附加某个数值就得到通用的中文编码。

常见误解与本节结论 ​

CRLF 指两个字符及相应两个字节 0d 0a,不是一个“通用换行字节”。同样,字符 0 是 30,空字符是 00。这两对概念混淆,会造成地址、长度和文本分隔符解析错误。

此外,80—ff 没有标准 ASCII 字符意义。某个工具把这些值显示为字符,不等于它们属于 ASCII;工具可能使用了另一种编码或只做展示。协议应明确字符编码,不能依赖机器的本地默认设置。

本节结论:文本的可见部分和不可见部分都必须按字节保存;源码中的转义写法不等于网络字节。与第 1.2 节结合,已有能力可完整核对英文字段及控制字符;下一节解决非 ASCII 文本的长度与合法性。

1.4 Unicode 与 UTF-8:字符数为什么不是字节数 ​

实际问题与直觉 ​

如果教学协议用一字节保存“文本长度”,发送“你好”时应填写 2 还是 6?必须先问长度的单位。若协议定义该字段为后续 UTF-8 字节数,正确值是 6;若误填 2,接收端只取两个字节,会截断第一个汉字的编码。

可以把 Unicode 理解为给字符分配编号,UTF-8 则规定怎样把编号写成一个到四个字节。这个类比只帮助区分“字符身份”和“序列化方式”;它不保证每个视觉上的符号仅由一个编号组成,也不保证所有编号都是已经分配的字符。

严谨定义 ​

Unicode 的码点(code point)是编号,惯例用 U+ 加十六进制表示。Unicode 标量值(Unicode scalar value)排除 U+D800—U+DFFF 的代理码点,其余位于 U+0000—U+10FFFF 的编号可由 UTF-8 的形式规则编码。标量值与已经分配的字符是不同概念。

UTF-8(Unicode Transformation Format — 8-bit)是一种把标量值编码成八位组序列的编码方式。现代 UTF-8 每个标量值使用 1—4 字节,ASCII 范围使用同样的单字节编码。它不使用第五、第六字节,也不允许把代理码点直接编码进去。[R3629 §3—4]

标量值范围编码长度位模板
U+0000—U+007F10xxxxxxx
U+0080—U+07FF2110xxxxx 10xxxxxx
U+0800—U+FFFF,排除代理码点31110xxxx 10xxxxxx 10xxxxxx
U+10000—U+10FFFF411110xxx 10xxxxxx 10xxxxxx 10xxxxxx

模板中的 x 保存码点数值的位。多字节首字节指明编码长度;后续字节称为续字节,形如 10xxxxxx,取值 80—bf。不过只检查这些前缀还不够,还必须检查最短编码、代理码点和上界。[R3629 §3—4]

底层机制:亲手推导“你”的三个字节 ​

“你”的码点是 U+4F60,数值为 4×163+15×162+6×16=20320。它位于三字节范围,写成 16 位二进制为 0100 1111 0110 0000。

三字节模板留有 4、6、6 个有效位,因此重新分组为 0100 | 111101 | 100000。分别填进模板:

text
1110 0100 = e4
10 111101 = bd
10 100000 = a0

得到 e4 bd a0。反方向解码时,去掉三个字节的固定前缀,再按权重还原:

((e4&0f)≪12)+((bd&3f)≪6)+(a0&3f)=20320.

& 挑出有效位,<< 表示左移,即乘相应的 2 的幂。这里的加法也可写为按位或,因为三部分占据互不重叠的位区间。亲手计算用于理解结构;实际网络程序应使用经过验证的编码库,并检查解码错误。

“好”的码点是 U+597D,UTF-8 为 e5 a5 bd。因此“你好”完整字节是 e4 bd a0 e5 a5 bd,长度为 6。两个字的码点编号、模板计算和库输出按位公式相互核验。

完整实例:混合文本的精确偏移 ​

文本 A你好 的完整确定性编码为:

text
41 e4 bd a0 e5 a5 bd
偏移区间长度字节码点字符
[0,1)141U+0041A
[1,4)3e4 bd a0U+4F60你
[4,7)3e5 a5 bdU+597D好

总长度为 7 字节、3 个码点。偏移 2 是“你”的第二个字节,不是第二个字符。若随意切出 [0,3),所得 41 e4 bd 不是完整 UTF-8 字符序列;网络读取恰好在这里停止时,不能立刻把它当成独立文本解码。第 3 章建立增量读取之后会处理这个问题。

首字节为 00 的文本也不表示“UTF-8 结束”;它编码标量值 U+0000。相反,c0 80 虽然某些错误解码器可能计算出零,却不是零的合法 UTF-8 编码,因为零必须使用最短单字节 00。[R3629 §3、§10] 同一输入在不同组件中被接受和拒绝,会破坏双方对标识符或边界的共识。

实例复核:完整编码与非法编码 ​

A你好 对应码点 U+0041、U+4F60、U+597D,共三码点、七字节。将 [1,4) 的 e4 bd a0 按有效位重组,得到 4×4096+61×64+32=20320,与 U+4F60 相同。将 [4,7) 的 e5 a5 bd 重组,得到 5×4096+37×64+61=22909,与 U+597D 相同。

对比四种不应作为完整合法文本接受的数据:c0 80 是过长编码;ed a0 80 对应代理码点 U+D800;f4 90 80 80 超过 U+10FFFF;e4 bd 缺少“你”的第三字节。严格的完整文本解析应拒绝;若数据尚处于接收过程中,最后一例可能只是未到齐,需保留后继续读取。

静默删除非法字节并不是恢复原文。例如删除 e4 bd 后只剩 A,即使剩余字节能解码,也改变了发送者的内容。显示日志时的替换策略与协议接受策略应分别定义。

字符、码点与屏幕上的一个符号 ​

Python len(str) 统计本节文本的码点数量。一个视觉上的符号可能由多个码点组成,例如 é 可以用一个预组合码点表示,也可以用 e 加组合重音表示。[R3629 §10] 因而不能把“用户看起来有几个字”作为协议长度定义。

字节序列相等、码点序列相等、显示外观相同,是三个不同判断。在用户名、路径或认证标识中,是否要对等价形式进行规范化,必须由相关协议指定;UTF-8 编码自身不会替应用选择这种政策。本书样例不对输入进行隐式规范化。

本节结论:编码后的长度来自实际字节,而不是肉眼数出的字符;合法性来自完整规则,而不是“看起来像中文”。之后所有文本字段都先明确字符编码,再计算长度,最后决定是否允许特定字符或格式。

1.5 字符串、原始字节与编码:配置文件不是线上报文 ​

实际问题与直觉 ​

配置文件中写下 "data": "4142",程序最后发送的是 41 42,还是 34 31 34 32?仅看配置无法回答。若软件规定这个字符串是十六进制,它应先解码为两个字节;若规定它是普通 UTF-8 文本,就应编码为四个字节。

程序中的字符串可理解为供文本处理的一串码点,原始字节则是可以直接放进字节缓冲区的一串 0—255 整数。两者之间必须指定转换规则。这个区分与语言实现相关:本书以 Python 的 str 和 bytes 类型说明接口区别,不把 Python 的对象存储布局当成所有语言的实现。

严谨定义与接口 ​

编码(encoding)在本节指把一个规定的数据域映射为字节序列或受限字符序列的规则;解码(decoding)指按该规则恢复数据。该规则可能只允许部分输入,错误处理也是规则的一部分。不能只说“这是编码”,而不指明源数据类型、目标类型和合法范围。

在 Python 中,str.encode('utf-8') 把文本转换为字节,bytes.decode('utf-8') 把合法字节转换为文本。bytes.hex() 把任意字节转换为十六进制文本,bytes.fromhex() 反方向解码。bytes 的元素是整数,切片仍为 bytes。[PYTYPES]

操作输入输出是否按十六进制解释内容
'4142'.encode('ascii')四字符字符串34 31 34 32否
bytes.fromhex('4142')十六进制字符串41 42是
bytes([65,66])两个整数41 42无文本解析
b'AB'.hex()两字节字符串 4142生成表示
b'AB'.decode('ascii')两字节字符串 AB按 ASCII 解码

类型转换不等于内容解释。把任意对象转成调试字符串 str(obj),并不会自动得到协议要求的字节;str(b'AB') 产生类似 b'AB' 的表示文本,而不是单纯的 AB。协议写入函数必须收到经过正确序列化的字节。

Base64 的底层机制 ​

某些配置要保存任意二进制材料,但 JSON 字符串或链接希望使用便于复制的字符。这时可以使用 Base64:把字节的位序列分成六位一组,以 64 个字符作索引;每完整三字节对应四个字符。标准 Base64 使用 +、/,URL 安全变体改用 -、_。是否省略末尾 = 由上层规范决定,不能任意猜测。[R4648 §3—5]

对带补位的标准 Base64,输入长度 n 字节,输出长度为

4⌈n3⌉

个 ASCII 字符;空输入长度为零。Base64 不是压缩,也不要求秘密信息才能解码,因此不会提供保密性。编码、压缩与加密解决不同问题:格式可表示性、数据冗余和针对攻击者的保护;加密的精确定义留给第 13—14 章。

以 Hi 为例,原始字节为 48 69,比特为 01001000 01101001。取六位一组,并在最后不足处补零,得到索引 18、6、36,即 S、G、k。由于输入只有两字节,再添加一个 =,结果 SGk=。补入的零与末尾标记辅助解码,不增加原始输入字节。

完整实例:从 JSON 恢复两个字节 ​

本节自行定义教学配置的 payload_hex 字段必须是偶数个十六进制数字,不允许空格:

json
{"payload_hex":"4142"}

完整处理过程为:读取 JSON 文本 → JSON 解析获得字符串 4142 → 校验字符与长度 → 十六进制解码 → 得到 41 42 两字节 → 交给以后介绍的网络写入接口。JSON 中的花括号、引号、字段名都不会作为该字段的线上负载发送,除非协议另规定发送整个 JSON 文本。

对恢复后的字节,偏移 [0,1) 为 41,[1,2) 为 42,总长度 2。JSON 文件长度和负载长度没有必要相等。这个案例解释了为什么代理配置中的身份文本、密钥文本和网络格式必须分别讨论;具体身份和密钥格式将在相应协议章节依据源码确认,不在此从长度猜算法。

实例复核:转换规则和边界 ​

在前述 JSON 例子中,解析 JSON 后得到四字符字符串 4142;按十六进制规则恢复为 41 42,两字节。如果直接按 ASCII 或 UTF-8 编码字符串,则得到 34 31 34 32,四字节。两种路径都可以被程序执行,但只有其中一条符合本例字段定义。

Base64 例子中,SGk= 按标准字母表解码后为 48 69,即 Hi;含不允许字符的 SG!k= 不符合本例约定。校验字母表仍不自动验证所有上层要求:若协议要求唯一的规范形式,还需要按规定检查补位和补零位。例如把解码结果再编码为指定形式,比较它与原输入是否完全一致。

配置中的格式错误应在对应解析层报告,不能连续套不同编码直至偶然得到可接受数据。长度上限也需在接受字段前确定,以免文本表示虽合法却超出协议或资源限制。

常见误解与本节结论 ​

把字符串开头的 0x 或 b'...' 一并当作线上字段,会发送表示形式而非原始值。把十六进制文本编码成 UTF-8,也不会自动进行十六进制解码。能看见字母数字的 Base64 文本,同样可能仅是任意字节的表示,不代表字节本来就是文本。

本节结论:每一层都写清输入类型、转换规则、输出长度和错误条件。未来解释一项配置时,应追问“哪个解析器读取它,解析后得到什么对象,序列化后发送什么字节”,而不是只比较配置的可见字符。

1.6 多字节整数与网络字节序:协议必须规定先放哪一字节 ​

实际问题与直观解释 ​

上一节解决了文本转成字节的规则。现在考虑整数:443 超过一个无符号字节的最大值 255,需要至少两字节。两个字节应该写成 01 bb 还是 bb 01?两种排列都能表示 443,但发送和接收双方必须采用相同约定。

类似十进制数字的高位与低位,一个多字节整数也可以分成不同权重的位置。不过“字节序”只讨论多字节值中各字节的排列,不讨论字母顺序,不意味着把整个报文逆序,更不意味着反转每个字节内部的比特。

严谨定义与一般公式 ​

对 k 字节无符号整数,范围为 0≤x<256k。大端序(big-endian)规定高权重字节先出现。若偏移 0 起依次为 b0,…,bk−1,则

xBE=∑i=0k−1bi256k−1−i.

小端序(little-endian)规定低权重字节先出现,对相同序列解码为

xLE=∑i=0k−1bi256i.

常说的网络字节序(network byte order)指大端序。Python struct 模块的 ! 表示网络字节序及标准大小、不自动对齐填充;> 也表示大端标准格式,< 表示小端标准格式。[PYSTRUCT] 具体应用协议仍可规定小端或其他编码,不能凭“联网”二字假定每个字段都是大端。

对两字节端口 443,由 443=1×256+187,大端得到 01 bb;小端得到 bb 01。若把 01 bb 错按小端解析,结果是 1+187×256=47873。程序可能尝试连接完全不同的端口,且网络层不会知道应用层选错了数值。

底层机制:内存表示不等于线上表示 ​

主机字节序(host byte order)是机器或语言底层对多字节整数的存储习惯。线上协议应该有明确序列化规则,与机器类型无关。不能直接把内存中的结构体按其本地布局写到网络,再指望不同系统自动理解。

除了字节序,内存结构体可能还有对齐填充、类型宽度和对象引用等实现细节。线上字段布局必须独立定义。Python struct.pack('!H',443) 明确得到两字节;不带前缀的 struct.pack('H',443) 则使用本地布局,不能作为本书可移植协议编码。[PYSTRUCT]

C 接口中 htons、ntohs 分别做主机与网络的 16 位转换,32 位常用 htonl、ntohl。[PYSOCKET] 如果使用的序列化函数已经按网络顺序生成字节,不应再转换一次。例如,Python struct.pack('!H',443) 前面没有必要再调用 socket.htons(443);重复处理在部分主机上会把正确字节变成错误排列。

完整实例:不同字段各自编码 ​

本节定义一个教学结构:一字节版本、一字节标志、两字节大端端口、四字节大端计数,再跟三字节 UTF-8 文本“你”。版本为 1、标志为 3、端口为 443、计数为 65538。完整字节如下:

text
01 03 01 bb 00 01 00 02 e4 bd a0
偏移区间长度字节解码规则值
[0,1)101无符号单字节1
[1,2)103位标志bit 0、bit 1 为 1
[2,4)201 bb大端无符号整数443
[4,8)400 01 00 02大端无符号整数65538
[8,11)3e4 bd a0UTF-8你

计数恢复为 0×2563+1×2562+0×256+2=65538。每个整数字段独立应用字节序,UTF-8 文本按自己的编码规则原样排列。把整个 11 字节序列逆序会破坏版本、字段位置与文本,不是大小端转换。

这个教学结构规定正文只含已知三字节文本;它尚未规定任意文本的定界方式,不能拿它直接做通用网络协议。下一节将增加长度字段,形成完整可解析的本地请求与响应。

实例复核:正确排列与错误排列 ​

端口 443 的大端表示 01 bb 与小端表示 bb 01 长度相同。错误按小端读取 01 bb,得到 47873;这不是数据损坏,而是双方对权重的约定不一致。真正的大小端转换在本例仅交换端口的两个字节,不改变版本、标志、计数和文本字段的位置。

八字节固定结构 01 03 01 bb 00 01 00 02 先恢复版本 1、标志 3、端口 443、计数 65538;随后 [8,11) 的三个字节恢复“你”。正确解码要求每个字段的长度确实存在,不能因为一个切片返回了较短数据就继续解释。

两字节无符号字段可容纳 65535,不能容纳 65536。默默截断溢出值会让 65536 变成零,违反原来的数值语义。显式拒绝越界与明确字节序是两项独立要求。

常见误解:范围、符号与比特顺序 ​

网络字段即使占两字节,也不一定是无符号数。若规范定义有符号整数,还要指定负数表示方式;相同 ff ff 可以在无符号规则下表示 65535,在 16 位补码规则下表示 -1。本书的端口与长度实例明确采用无符号规则,不能自行套用有符号解码。

字节序转换不会把 01 的二进制 00000001 变成 10000000。比特编号、比特在物理链路上的发送顺序、多字节整数的字节序是不同层面的约定。读协议时要分别确认,不能从大端一词推导全部细节。

本节结论:一个整数的线上表示至少需要类型、宽度和字节序三个约定;一个报文还需要各字段的顺序与长度。显式序列化把协议从主机内存布局中分离出来,使不同机器得到相同字节。

1.7 完整字节实例:文本请求与二进制请求 ​

从前面知识到实际协议 ​

前六节建立了字节、数制、字符编码与整数布局。现在比较两种实际可用的数据表达方式:HTTP/1.1 的文本请求,以及一个严格定义的教学二进制请求。HTTP 例子按标准语法构造,教学例子用于解释格式设计;两者都不是声称从生产环境捕获的报文。[R9112 §2.1、§3]

HTTP 请求在网络上的字节 ​

一个请求目标为 /、主机为 example.com、没有请求体的 HTTP/1.1 请求,可以显示为:

http
GET / HTTP/1.1
Host: example.com

上面的代码块用于阅读;线上每一行按本例使用 CRLF。精确转义表示为 GET / HTTP/1.1\r\nHost: example.com\r\n\r\n。完整字节为:

text
47 45 54 20 2f 20 48 54 54 50 2f 31 2e 31 0d 0a
48 6f 73 74 3a 20 65 78 61 6d 70 6c 65 2e 63 6f 6d 0d 0a
0d 0a
偏移区间长度内容作用
[0,14)14GET / HTTP/1.1方法、请求目标和版本
[14,16)20d 0a请求行结束
[16,33)17Host: example.com一个字段行的可见内容
[33,35)20d 0a字段行结束
[35,37)20d 0a空行结束头部

全长 37 字节。方法 GET 在此占三字节;/ 是 2f;两个分隔空格均为 20;可见版本文本 HTTP/1.1 占八字节,并不是单字节版本号。末尾四字节 0d 0a 0d 0a 包含字段行末尾和空行。

这里不要求读者提前掌握完整 HTTP 定界规则,后续第 7、9 章继续说明。此刻应理解的是:可见文字只是协议字节的一部分,控制字符也是数据;这些字符的排列与字段含义由 HTTP 规定,而不是由 TCP 决定。

二进制请求:字段布局与合法范围 ​

再定义一个本书教学格式,表达“版本 1、命令 1、目标端口 443、文本你好”。命令 1 仅表示教学中的文本处理,不代表某个现有代理协议。请求头为六字节,后跟 L 个 UTF-8 字节:

偏移区间长度字段规则
[0,1)1version无符号字节,必须为 1
[1,2)1command无符号字节,必须为 1
[2,4)2length大端无符号整数,仅统计正文的字节数
[4,6)2port大端无符号整数,本例允许 1—65535
[6,6+L)Ltext完整且合法的 UTF-8,允许空文本

正文长度最多 65535 字节。长度字段不包括头部,也不数码点或显示字符。完整请求必须满足 len(B)=6+L。这是单条完整数据的契约,后续流式解析不能把一次读取误认成一条数据。

“你好”为六字节,所以长度字段为 00 06;443 为 01 bb。完整请求为:

text
01 01 00 06 01 bb e4 bd a0 e5 a5 bd

总长十二字节。接收方先确认至少六字节,恢复版本、命令、长度和端口,检查合法范围及总长度,最后解码六字节正文。若长度写为 7 而只有六字节正文,完整缓冲区不合法;若网络数据尚未到齐,则应继续等候,而不是立即把后续数据丢弃。

响应和完整处理过程 ​

定义成功响应为一字节版本、一字节状态、两字节大端正文长度、UTF-8 正文。版本 1,状态 0,正文 OK 对应 4f 4b:

text
01 00 00 02 4f 4b
响应偏移区间长度字节意义
[0,1)101版本 1
[1,2)100成功状态
[2,4)200 02正文两字节
[4,6)24f 4b文本 OK

发送方按请求布局生成十二字节;接收方恢复端口与文本,完成本例文本处理,再按响应布局生成六字节;发送方按响应规则读取状态及正文。格式规定双方怎样解释数据,但此刻还没有规定怎样通过操作系统传递它,也没有提供用户认证、完整性保护或保密性。

图解:解释数据以前必须检查什么 ​

正在绘制图表…

查看图表原文
flowchart TD
    A[完整输入字节] --> B{至少六字节?}
    B -->|否| R[拒绝]
    B -->|是| C{版本与命令合法?}
    C -->|否| R
    C -->|是| D{端口与总长度合法?}
    D -->|否| R
    D -->|是| E{正文为合法 UTF-8?}
    E -->|否| R
    E -->|是| F[恢复请求含义]

比较与常见误解 ​

文本格式和二进制格式最终都是字节。HTTP 使用可见方法名、版本文本及行分隔符,教学格式使用固定宽度的版本、命令和长度;不能仅凭“可见”或“二进制”判断安全性、速度或功能完整性。

对于六字节正文,教学头部也是六字节,头部占总量 6/12=50%,相对于正文的增加量是 6/6=100%。对于 6000 字节正文,头部占总量 6/6006≈0.0999001%。比较开销必须说明分母与统计层次,不能把较短教学格式与承担不同语义的完整 HTTP 请求直接比较成性能排名。

本节结论:原始字节、字段规则和解释流程必须一起给出。确定性样例可以准确计算,每个运行时值也必须说明来源;加密或随机数据更不能靠随手写一段十六进制冒充实测。

1.8 从字节表示走向协议解释 ​

贯穿本章的技术结论 ​

网络程序传递的应用数据最终以字节表示。字节只规定八比特的宽度;整数、字符、字段语义分别来自数值规则、字符编码与协议规则。十六进制是显示形式,不是加密,也不自动决定字节序。

文本字段必须指定字符编码与长度单位。ASCII 覆盖七比特编号,UTF-8 对一个标量值使用一到四字节。码点数量不等于字节长度,视觉上的一个符号也不一定只有一个码点。完整数据中的非法编码与接收过程中的未完整编码必须分开处理。

多字节整数必须明确宽度、符号和字节序。大端与小端对相同序列赋予不同权重,主机内存结构体布局不等于线上布局。固定字段的前导零不能仅因为整数文本可省略它就被删掉。

完整解析不仅拆字段,还检查头部长度、版本、命令、数值范围、声明长度和正文合法性。格式合法不等于发送者可信。第 2 章说明程序与操作系统如何传递这些字节,第 3 章说明 TCP 字节流中的应用边界,第 13 章以后才讨论安全保护。

常见混淆对照 ​

易混淆项精确区别
字节 41 与文本 41一字节与两字符文本;后者 ASCII 为 34 31
NUL 与字符零00 与 30 不同
CRLF 与反斜线文字0d 0a 与 5c 72 5c 6e 不同
字数与正文长度对 UTF-8 字节字段,先编码再确定长度
大端序与报文逆序字节序只作用于按该规则定义的多字节字段
Base64 与加密公开规则可还原数据,不提供保密性
格式合法与身份可信格式检查不提供认证或防篡改
完整消息与一次读取此刻尚未建立它们的相等关系,TCP 也不保证相等

延伸阅读与本章参考资料 ​

  • [R20] RFC 20:ASCII 编码表及控制字符说明,不以历史主机换行习惯替代现代协议规则。
  • [R3629] RFC 3629:§3 编码与解码,§4 合法字节语法,§10 非法序列及等价文本的风险。
  • [R4648] RFC 4648:§3 格式差异,§4 标准 Base64,§5 URL 安全变体。
  • [R9112] RFC 9112:§2.1 HTTP/1.1 消息格式,§3 请求行。本章只使用字节布局,完整语义和定界留给后文。
  • [PYTYPES] Python 内置类型:文本对象与字节对象的接口区分,仅作为语言实现参考。
  • [PYSTRUCT] Python struct:显式字节序、标准字段大小与本地对齐的区别。

HTTP 例子依据标准语法构造,教学二进制格式由本书定义,均已按字节规则复核,不冒充生产抓包。统一依据见 参考资料,术语见 术语表。

从字节与状态机出发,理解每一层的职责。