返回知识中心
指纹

DataView:字节序、对齐与越界

一次针对固定缓冲区的观察:默认字节序、非对齐读取、越界错误、浮点精度、共享缓冲区、偏移视图与 BigInt 取值方法。

BotBrowser Team

文档中心

想直接进入 指纹 文档吗?

这篇文章属于知识中心的指南库。若你要步骤化配置、参考说明和持续更新,请直接进入对应 docs 分区。

BotBrowser 支持在声明的 profile 下回放一个已授权的同源固定样本,并保留一份有界的记录,说明页面读取了哪种二进制布局、以何种字节序读取;但它不能改变某个协议使用的字节序,不能让类型化数组容忍非对齐偏移,也不能在一次越界读取抛出之后恢复某个字段。本篇综述在九个固定缓冲区上运行一次观察,记录默认字节序与显式字节序的对比、非对齐读取可以工作而类型化数组视图却拒绝、越界读取会抛出而类型化数组会静默丢弃写入、每种位宽给出怎样的浮点精度、视图与类型化数组如何共享同一缓冲区、偏移视图报告什么、字节如何变成文本,以及 BigInt 取值方法额外提供了什么。

摘要

  • setUint32 默认先写入高字节,因此 0x01020304 落为 01020304,读回时大端为 16909060,小端为 67305985。
  • DataView 可在任意偏移读取,因此 setUint32(1, ...) 可用,而偏移为一处的 Uint32Array 会抛出 RangeError。
  • 越界的 getUint32 抛出 RangeError,而在四字节数组上执行 typed[10] = 7 会被静默丢弃,长度仍为四。
  • Float32 无法精确容纳 0.1,读回为 0.10000000149011612,而 Float64 读回 0.1,因此字段所用位宽是其含义的一部分。
  • 同一缓冲区上的 DataView 与 Uint8Array 能看到彼此的写入,因此一次字节写入会改变视图读到的值。
  • 偏移视图报告自己的 byteLength 与 byteOffset,而 buffer.byteLength 仍是整个分配,且读取越过视图末端一个字节会抛出。
  • 字节不是文本:同样的四个字节在 UTF-8 下解码为 hié,在 latin1 下解码为 hié。
  • getBigUint64 返回 BigInt,向它加上一个 Number 会抛出 TypeError。

目录

第一个面板写入一个四字节整数,显示它按高字节在前存储,所以默认读取给出大端 16909060,小端为 67305985。第二个面板显示同一整数在偏移为一处读取,DataView 允许而类型化数组会抛出。第三个显示越界读取抛出,而类型化数组静默丢弃该写入。第四个显示三十二位浮点读回时带有尾随数字,而六十四位浮点精确。

DataView 默认以哪种字节序写入

DataView 在每次调用时选择字节序,默认是大端。观察写入一个整数,然后以两种方式读回:

node=v26.9.0
default-bytes -> 01020304 readLE=67305985 readBE=16909060
unaligned-read -> deadbeef
typed-aligned-required -> RangeError
bounds -> dataview=RangeError typed-set-ignored=true length=4
float-precision -> f32=0.10000000149011612 f64=0.1 f32-exact=false
method-set -> Int8,Uint8,Int16,Uint16,Int32,Uint32,Float32,Float64,BigInt64,BigUint64
shared-buffer -> bytes=12340000 view=1234
write-through -> view-after-byte-write=12ff
offset-view -> byteLength=6 byteOffset=4 bufferByteLength=12
getUint8-at-view-end -> 0 end-throws=RangeError
decode -> utf8=hié latin1=hié bytes=4
bigint -> value=18446744073709551615 mixed-add=TypeError

内存中的四个字节按顺序是 01 02 03 04,同一段内存在不同标志下读出两个不同的数。16909060 是 0x01020304,这正是与写入一致的读取;67305985 是 0x04030201,是同样字节反过来读。因此这次写入把 0x01020304 的高字节放在偏移零处。在写入方使用默认值的地方传 true 的读取者会看到该整数字节被反转,而这个错误在十六进制转储中不可见,因为字节看起来是对的。

正是这种不对称,使得网络字段的字节序总在规范中写明,而由本地进程写出的文件格式通常继承主机序。默认是大端,恰好与多数网络头部一致,而在所有当前主流目标上又与本机序相反,所以省略标志的页面会把本地写出的二进制文件读成字节反转。显式传入标志是记录能说明自己读了什么的唯一方式,无论朝哪个方向。

还有一个二阶后果,会绊住确实记得该标志的读取者。该标志是按调用而非按视图:同一个 DataView 可以以大端读取第一个字段、以小端读取第二个字段,两者都合法。一个带有旧式大端头部、随后是小端主体的二进制格式正是这种形状,而在构造时一次性设定字节序的解析器无法表达它。标志必须随字段一起传递,这意味着解析器中的字段表必须在位宽与有符号性之外一并写明字节序。

浮点方法接受相同的标志,而那里的失败更加安静。一个大端写入、小端读取的 Float32 不会抛出,也不会产生可辨认的错误整数;它会产生一个很小的次正规数或一个极大的值,两者在被页面与范围比较之前都像合理的测量值。把浮点数与预期边界比较的读取者,只有当字节反转后的位恰好落在范围之外时才能发现这次冲突,而盲目信任该值的页面则完全无法察觉。

视图比类型化数组更宽松之处

DataView 不要求其偏移与所读位宽对齐,而类型化数组视图则要求。观察在偏移一处写入并读取一个整数:

unaligned-read -> deadbeef
typed-aligned-required -> RangeError

DataView 读取了从偏移一开始的四个字节并返回了它写入的值。同一缓冲区、同一偏移上的 Uint32Array 抛出了 RangeError,因为类型化数组视图要求其 byteOffset 是元素大小的整数倍。这就是结构性差异:DataView 是字节上的游标,而类型化数组是一个必须与自身元素网格对齐的窗口。

类型化数组的元素语义由 ArrayBuffer 与类型化数组视图 讲解。两者之间的取舍由此而来。一个逐字段遍历、位宽混杂且无填充的头部解析器使用 DataView。一个读取单一类型的大块、例如像素缓冲区或样本数组的页面使用类型化数组,并一次性支付对齐要求。两者都需要的页面可以在同一缓冲区上创建一个 DataView 和多个类型化数组视图,各司其职,因为它们共享同一段内存。

对齐规则有一个容易被反转的性能后果。一次对齐的类型化数组读取可以映射为单条机器加载,而一次 DataView 读取必须逐字节拼装,在同一字段上允许更慢。这就是为什么解析单一元素类型热循环的页面即使缓冲区起始处并未对齐也使用类型化数组,通过前进到第一个对齐偏移并跳过开头字节,而解析冷态、位宽混杂头部的页面使用 DataView 并接受逐字段开销。两者在相同速度下不可互换,用一方的理由选择另一方,是解析器变慢且日后难以修复的常见来源。

边界在两者中表现不同,而这个差异是危险的那个。观察越过末端读取一个整数,并越过末端写入一个字节:

bounds -> dataview=RangeError typed-set-ignored=true length=4

DataView 读取抛出了 RangeError,使解析器停在那无法读取的确切字段上。在四字节数组上向索引十的类型化数组写入什么也没做,长度仍为四,因此一个按索引填充缓冲区、事后再检查长度的页面无法区分被丢弃的写入与成功写入。在解码不可信输入的代码路径中,抛出的行为是大声失败的那个,而静默的行为会产生看似合理却错误的结果。

每种位宽的精度代价

视图暴露十种元素类型,两种浮点位宽在可表示范围上不同:

method-set -> Int8,Uint8,Int16,Uint16,Int32,Uint32,Float32,Float64,BigInt64,BigUint64
float-precision -> f32=0.10000000149011612 f64=0.1 f32-exact=false

两种位宽都被赋予同一个字面量 0.1,只有六十四位读回为 0.1。三十二位读回 0.10000000149011612,这是三十二位浮点能容纳的最近值。一个把字段读为 Float32、为显示而四舍五入、再与存储的 0.1 比较的页面,会得到一个与数据无关的不匹配。

整数位宽以另一种形式有同样的问题。Int8 与 Uint8 只在 127 以上不同,那里有符号形式会回绕为负数,因此发送方写为 200 的一个字节通过 getInt8 读为 -56。一个为规范称为无符号的字段选择有符号取值方法的页面,会在一个被记录为正数的范围内得到负数,而错误只在某个值越过中点时才出现。

Float64 的情形还说明,为何跨两个运行时比较两个浮点数需要谨慎。值 0.1 在任何位宽下都无法在二进制中精确表示,六十四位读回打印 0.1 只是因为显示对它做了四舍五入。一个保存显示值而非原始位的记录保存的是四舍五入后的数,两个在显示上一致的运行时仍可能保存不同的位。

同样的精度要点决定如何比较摘要或校验和。一个把三十二位摘要字读为 Float32 并与存储的字面量比较的页面,比较的是四舍五入后的值,两个仅在低位不同的摘要在往返后可能比较相等。修复方法是将二进制摘要保留在其整数位宽中,使用 getUint32 而不是浮点取值方法,并直接比较整数。校验和永远不是浮点数,把它读作浮点正是让一次本该失败的验证通过的错误。

视图与类型化数组如何共享同一个缓冲区

DataView 持有对 ArrayBuffer 的引用而非副本,因此该缓冲区上的其他每个视图都看到同一段内存。观察先通过 DataView、再通过 Uint8Array 写入:

shared-buffer -> bytes=12340000 view=1234
write-through -> view-after-byte-write=12ff

0x1234 的大端写入落为 12 34 00 00,这正是字节视图所看到的。向第二个字节写入 0xff 使视图读到的整数变为 0x12ff,完全没有触碰 DataView。一个对某个视图做快照并期望它在另一个视图写入后保持不变的页面拿到的是过时值;一个有意依赖共享的页面则有一种廉价的重新解释同一段内存的方式。

正是这种共享使 DataView 适合同时想要字节级校验和的协议解析器。解析器通过视图读取字段,同一缓冲区上的 Uint8Array 对字节求和,两者都不复制缓冲区。代价是任何改动处处可见,因此一个写回自己所读缓冲区的解析器,除非固定顺序,否则可能破坏先前计算的校验和。

偏移视图报告什么以及缓冲区如何共享

可以在一段缓冲区切片上创建视图,它报告的是该切片而非整个分配:

offset-view -> byteLength=6 byteOffset=4 bufferByteLength=12
getUint8-at-view-end -> 0 end-throws=RangeError

该视图覆盖从偏移四开始的六个字节,其 buffer.byteLength 仍报告整个十二字节分配。一个从 buffer.byteLength 而非视图自身 byteLength 计算字段偏移的页面会读过切片,这正是最后一行重要的原因:读取索引六、即越过视图自身六个字节一个位置,即便底层缓冲区还有余量也会抛出。决定一次读取可以触碰哪里的是视图的边界,而不是缓冲区的边界。

正是这一区别绊住子解析器。一个收到覆盖负载切片的视图的函数无法读取外层帧的下一个字段,因为视图止于切片结束处,而抛出是调用方传入比函数所假定更窄视图的唯一信号。一个只保存缓冲区长度却不保存视图偏移与长度的记录,无法重建哪些读取是合法的。

字节如何变成文本以及 BigInt 在哪里进入

同一个缓冲区的文本一侧有自己的边界,atob 与 btoa 的边界 覆盖了 base64 的情形。字节在被指定编码之前不是文本,同样的四个字节有两种解码:

decode -> utf8=hié latin1=hié bytes=4

这四个字节是 68 69 c3 a9。按 UTF-8 读取,它们是 h、i 以及 é 的两字节序列,得到 hié。按 latin1 读取,每个字节是各自的字符,得到 hié。一个用错误标签解码字段的页面得到的是看似合理的字符串而非错误,因此一个不明示编码的协议让读取者在两个有效答案之间猜测。

最大的字段需要各自的取值方法,而它们会改变值的类型:

bigint -> value=18446744073709551615 mixed-add=TypeError

getBigUint64 将完整的无符号六十四位值作为 BigInt 返回,这是表示它的唯一方式,因为 Number 在 2^53 以上会丢失精度。观察随后以 Number 的形式向它加 1 并得到 TypeError,这是语言拒绝静默混用两种数值类型。一个读取六十四位字段并想与 Number 比较的页面必须刻意转换,而转换一旦发生,正是精度丢失之处。

有符号形式 getBigInt64 出于同样原因在零的另一侧也重要。一个会回绕的六十四位计数器,或来自允许负值的协议的有符号时间戳,需要有符号取值方法,而无符号读取会把一个小负数变成巨大的正数。两个取值方法读取同样的八个字节,只在如何解释最高位上不同,因此一个只记录「六十四位整数」而不记录有符号性的字段表,没有记录足够的信息来重现该值。

还有一个值得点名的边界。BigInt 无法传给 JSON 序列化器,因此一个读取六十四位字段并尝试记录解析后对象的页面,会在那个是 BigInt 的字段上抛出,而其他每个字段都记录正常。记录路径必须刻意转换,通常转为十进制字符串,而跳过转换的页面丢失的是整行日志而不是那一个字段。这与偏移视图是同一种形状:失败在确切的边界处大声出现,而记录必须点名类型才能解释它。

记录一次二进制读取

一次二进制读取支持一个狭窄的论断:此运行时、此缓冲区、此偏移与此字节序,产生了此值。五个字段使它保持可核查:

  1. 每个字段读取时所用的字节序,因为默认是大端,而小端文件里的字段会读出字节反转的结果。
  2. 视图的偏移与长度,而不只是缓冲区,因为越过视图末端的读取即便缓冲区还有余量也会抛出。
  3. 每个整数字段的有符号性与位宽,因为对一个无符号字节做有符号读取会把 200 变成 -56。
  4. 浮点位宽,因为 Float32 与 Float64 对同一字面量返回的值不同。
  5. 文本编码,因为同样的字节在 UTF-8 下是 hié,在 latin1 下是 hié。

公开来源

实践结论

在每次读写时传入字节序标志,因为默认是大端,而本地小端文件里的字段不是。对位宽混杂的头部使用 DataView,对同质数据块使用类型化数组,并记住决定一次读取可以触碰哪里的是视图自身的边界。为每个字段点明有符号性、位宽与文本编码,并把一次 BigInt64 读取当作独立类型而非一个很大的 Number。至于元素视图本身,请阅读 ArrayBuffer 与类型化数组视图;至于把字节变成字符串,请阅读 atob 与 btoa 的边界。

#DataView#ArrayBuffer#Endianness#TextDecoder#BigInt64

本文属于指南: 什么是浏览器指纹?原理、风险、信号与隐私边界

让 BotBrowser 从研究走向生产

先用这些指南理解模型,再进入跨平台验证、隔离上下文和面向规模化的浏览器部署。