繁體中文
English 简体中文 繁體中文
Methodox 首頁

在 Divooka 中處理二進位制資料

在 Divooka 中讀寫二進位制資料

在 Divooka 中寫出二進位制 PPM P6 格式影像並用 GIMP 檢視

二進位制格式無處不在:影像、網格、存檔檔案、網路資料包、自訂資源包、壓縮流、硬體協議、遊戲資料,以及幾十年後仍然有用的舊檔案格式。

然而在應用程式程式碼中,二進位制解析往往很快就會變得雜亂。你從一個 byte[] 開始,接著是一個表示偏移量的整數,然後是幾個輔助方法,再然後是巢狀的偏移量計算,很快解析器就同時承擔了兩項職責:理解檔案格式,以及手動管理遊標所在的位置。

ImmutableByteArray、ConstructiveByteArray 和 ImmutableConstructiveByteArray 的目標,就是把這種遊標管理邏輯移到一個小巧的流式抽象中。

與其編寫這樣的程式碼:

int offset = 0;

string magic = Encoding.ASCII.GetString(bytes, offset, 4);
offset += 4;

int version = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, 4));
offset += 4;

int count = BinaryPrimitives.ReadInt32LittleEndian(bytes.AsSpan(offset, 4));
offset += 4;

我們更希望這樣寫:

var remaining = ImmutableByteArray.FromFile("Data.bin")
    .ReadString(4, out string magic)
    .ReadInt(out int version)
    .ReadInt(out int count);

解析器讀起來就像二進位制格式本身。

寫入器與之相對應:

var data = new ConstructiveByteArray()
    .WriteStringFixedSize("DATA")
    .WriteInt(version)
    .WriteInt(count);

思路很簡單:二進位制資料應當表示為一系列顯式的讀寫操作,而不是零散的偏移量算術。

讀取二進位制資料

讀取器:ImmutableByteArray

ImmutableByteArray 是面向讀取的二進位制資料抽象。

它是不可變的。從中讀取資料不會改變目前物件,而是由讀取操作返回緩衝區中尚未讀取的剩餘部分。

主要的 API 形式如下:

public ImmutableByteArray ReadInt(out int value);
public int ReadInt();
public int ReadIntWithOffset(int byteIndex);

這三種形式覆蓋了三種常見的用例。

out 形式用於流式解析:

var remaining = data
    .ReadString(4, out string magic)
    .ReadInt(out int width)
    .ReadInt(out int height);

直接返回的形式用於在不需要剩餘資料時,從緩衝區開頭讀取一個值:

int width = data.ReadInt();

偏移量形式用於隨機存取,不消耗任何資料:

int width = data.ReadIntWithOffset(4);

這為該型別確立了一條清晰的規則:

順序讀取通過返回新的剩餘緩衝區來消耗資料;偏移量讀取不消耗資料。

在內部,ImmutableByteArray 可以安全地使用一個私有的 byte[] 加上偏移量和長度。外部輸入應在構造時複製,但內部切片無需複製,因為底層陣列是私有的,從不以可變方式對外公開。

這意味著下面這類操作的開銷很小:

var remaining = data.ReadBytes(32, out ImmutableByteArray header);

remaining 和 header 都可以是同一個內部位元組陣列上的檢視。由於兩個物件都無法修改底層儲存,這樣做仍然是安全的。

讀取塊

二進位制格式經常包含固定大小的塊。例如,一個檔案可能包含一個 32 位元組的檔案頭,然後是 32 位元組的填充,再然後是記錄。

位元組的 API 形式如下:

public ImmutableByteArray ReadBytes(int count, out ImmutableByteArray value);
public ImmutableByteArray ReadBytes(int count);
public ImmutableByteArray ReadBytesWithOffset(int byteIndex, int count);
public ImmutableByteArray ReadBytes(int count, out ImmutableByteArray value, Action<ImmutableByteArray> continuation);

當需要單獨解析某個子塊時,延續(continuation)形式很有用:

int versionNumber = 0;

var remaining = ImmutableByteArray.FromFile("MyData.bin")
    .ReadString(4, out string magic)
    .ReadBytes(32, out ImmutableByteArray _, header => header.ReadUInt(out versionNumber))
    .ReadBytes(32, out _)
    .ReadInt(out int count);

主資料流前進 32 個位元組,而延續則從隔離出來的檔案頭塊中讀取。

對於塊大小固定、但目前只關心塊內部分欄位的格式,這一點尤其有用。

字串

在二進位制格式中,字串通常以兩種常見形式出現。

第一種是固定大小的:

public ImmutableByteArray ReadString(int size, out string value);
public string ReadString(int size);
public string ReadStringWithOffset(int byteIndex, int size);

例如,檔案的魔數值:

data.ReadString(4, out string magic);

第二種是以零結尾的:

public ImmutableByteArray ReadString(out string value);
public string ReadString();
public string ReadStringWithOffset(int byteIndex);

它會一直讀取到第一個 0x00 位元組為止。

data.ReadString(out string name);

固定大小的字串常見於檔案頭中。以零結尾的字串常見於較舊的二進位制格式、網格檔案、嵌入式後設資料和 C 風格的結構中。

寫入二進位制資料

寫入器:ConstructiveByteArray

ConstructiveByteArray 是可變的寫入器。

它是一個流式的、僅追加的位元組構建器:

var current = new ConstructiveByteArray()
    .WriteStringFixedSize("CBA1")
    .WriteInt(1)
    .WriteDouble(3.14159);

它是就地可變的。每次呼叫都會把位元組追加到同一個底層緩衝區,並返回 this。

這使它成為生成二進位制檔案時實用的預設選擇。

該 API 有意使用顯式的方法名:

WriteInt(...)
WriteUInt(...)
WriteDouble(...)
WriteStringFixedSize(...)
WriteStringZeroTerminated(...)
WriteBytes(...)

這避免了單個過載的 Write(...) 方法帶來的歧義。在二進位制程式碼中,顯式通常值得多打幾個字元。

不可變寫入器:ImmutableConstructiveByteArray

ImmutableConstructiveByteArray 與寫入器的 API 相對應,但每次寫入都會返回一個新物件。

var baseFile = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("TEST")
    .WriteInt(1);

var pathA = baseFile.WriteStringZeroTerminated("Variant A");
var pathB = baseFile.WriteStringZeroTerminated("Variant B");

當你要嘗試不同的後續寫入路徑時,這會很有用。

例如,你可能有一個公共的檔案頭,然後有幾種可能的負載佈局:

var common = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("MESH")
    .WriteInt(version);

var triangleMesh = common
    .WriteStringZeroTerminated("triangles")
    .WriteInt(triangleCount);

var pointCloud = common
    .WriteStringZeroTerminated("points")
    .WriteInt(pointCount);

可變寫入器更快,記憶體效率也更高。不可變寫入器則更適合分支、測試和函式式構建模式。

寫入固定大小的塊

寫入器同樣支援固定大小的子塊:

public ConstructiveByteArray WriteBytes(int count);
public ConstructiveByteArray WriteBytes(int count, Action<ConstructiveByteArray> subblock);
public ConstructiveByteArray WriteBytes(ConstructiveByteArray source);

對於可變版本:

int versionNumber = 1;
int count = 15;

ConstructiveByteArray current = new ConstructiveByteArray()
    .WriteStringFixedSize("CBA1")
    .WriteBytes(32, con => con.WriteUInt((uint)versionNumber))
    .WriteBytes(32)
    .WriteInt(count);

for (int i = 0; i < count; i++)
    current = current.WriteBytes(128);

current.Save("Copy1.bin");
current.Save("Copy2.bin");
current.Dispose();

塊延續會寫入一個臨時的子緩衝區。如果寫入的位元組少於 32 個,結果會自動用零填充;如果寫入的位元組多於 32 個,寫入器應當丟擲異常。

這讓固定大小的二進位制結構表達起來容易得多。

不可變版本則改用函式式延續:

ImmutableConstructiveByteArray current = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("CBA1")
    .WriteBytes(32, con => con.WriteUInt((uint)versionNumber))
    .WriteBytes(32)
    .WriteInt(count);

在內部,該延續的形式應當是:

Func<ImmutableConstructiveByteArray, ImmutableConstructiveByteArray>

因為不可變寫入會返回新物件。

示例

示例:讀取 PPM 影像

PPM 是 Netpbm 家族中一種簡單的影像格式。由於其結構小巧直接,它很適合用作教學格式。

二進位制 PPM 檔案通常以一個 ASCII 檔案頭開頭:

P6
width height
maxValue

其後是原始的 RGB 像素資料。

例如:

P6
256 256
255
<binary RGB bytes>

檔案頭是文字,而像素負載是二進位制的。這使 PPM 成為文字與二進位制混合解析的一個好例子。

一個簡單的 PPM 讀取器可能是這樣的:

public sealed class PpmImage
{
    public int Width { get; }
    public int Height { get; }
    public byte[] Pixels { get; }

    public PpmImage(int width, int height, byte[] pixels)
    {
        Width = width;
        Height = height;
        Pixels = pixels;
    }
}

使用 ImmutableByteArray,可以這樣編寫一個基本的解析器:

public static PpmImage ReadPpm(string path)
{
    ImmutableByteArray data = ImmutableByteArray.FromFile(path);

    data = ReadAsciiToken(data, out string magic);

    if (magic != "P6")
        throw new InvalidDataException("Only binary P6 PPM files are supported.");

    data = ReadAsciiToken(data, out string widthText);
    data = ReadAsciiToken(data, out string heightText);
    data = ReadAsciiToken(data, out string maxValueText);

    int width = int.Parse(widthText);
    int height = int.Parse(heightText);
    int maxValue = int.Parse(maxValueText);

    if (maxValue != 255)
        throw new InvalidDataException("Only 8-bit PPM files with max value 255 are supported.");

    int pixelByteCount = width * height * 3;

    data.ReadBytes(pixelByteCount, out ImmutableByteArray pixelData);

    return new PpmImage(width, height, pixelData.ToArray());
}

輔助的標記讀取器可以實現為作用於不可變緩衝區的一個小例程:

private static ImmutableByteArray ReadAsciiToken(ImmutableByteArray data, out string token)
{
    data = SkipAsciiWhitespace(data);

    int count = 0;

    while (count < data.Length && !IsAsciiWhitespace(data[count]))
        count++;

    token = Encoding.ASCII.GetString(data.AsSpan(0, count));
    return data.Skip(count);
}

private static ImmutableByteArray SkipAsciiWhitespace(ImmutableByteArray data)
{
    int count = 0;

    while (count < data.Length && IsAsciiWhitespace(data[count]))
        count++;

    return data.Skip(count);
}

private static bool IsAsciiWhitespace(byte value)
{
    return value == (byte)' ' || value == (byte)'\t' || value == (byte)'\r' || value == (byte)'\n';
}

解析器無需與偏移量纏鬥。每個輔助方法接收一個緩衝區,並返回剩餘的緩衝區。

這就是核心模式。

示例:寫入 PPM 影像

使用 ConstructiveByteArray 寫入二進位制 PPM 檔案非常簡單。

public static void WritePpm(string path, int width, int height, ReadOnlySpan<byte> rgbPixels)
{
    int expectedLength = width * height * 3;

    if (rgbPixels.Length != expectedLength)
        throw new ArgumentException($"Expected {expectedLength} RGB byte(s).", nameof(rgbPixels));

    new ConstructiveByteArray()
        .WriteStringFixedSize("P6\n")
        .WriteStringFixedSize(width.ToString())
        .WriteByte((byte)' ')
        .WriteStringFixedSize(height.ToString())
        .WriteByte((byte)'\n')
        .WriteStringFixedSize("255\n")
        .WriteBytes(rgbPixels)
        .Save(path)
        .Dispose();
}

它會寫入:

P6
width height
255

然後追加原始的 RGB 位元組。

字串方法依然是顯式的。我們並沒有假裝整個檔案都是文字,而是在構建一個包含若干 ASCII 部分的二進位制位元組序列。

示例:讀取 PLY 多邊形檔案

PLY 是一種常見的多邊形網格格式。它可以是 ASCII 的,也可以是二進位制的。二進位制小端序 PLY 檔案通常以一個 ASCII 檔案頭開頭,其後是二進位制的頂點和麵資料。

一個簡化的二進位制 PLY 檔案頭可能是這樣的:

ply
format binary_little_endian 1.0
element vertex 3
property float x
property float y
property float z
element face 1
property list uchar int vertex_indices
end_header

然後是二進位制主體。

對於這個檔案頭,主體包含:

三個頂點,每個頂點有三個 32 位浮點數:

x y z
x y z
x y z

然後是一個面:

uchar count
int index0
int index1
int index2

一個小型的模型型別:

public readonly record struct Vertex(float X, float Y, float Z);

public sealed class PlyMesh
{
    public List<Vertex> Vertices { get; } = new List<Vertex>();
    public List<int[]> Faces { get; } = new List<int[]>();
}

一個簡化的二進位制 PLY 讀取器:

public static PlyMesh ReadBinaryLittleEndianPly(string path)
{
    ImmutableByteArray data = ImmutableByteArray.FromFile(path, ByteArrayEndianness.LittleEndian, Encoding.ASCII);

    data = ReadPlyHeader(data, out int vertexCount, out int faceCount);

    var mesh = new PlyMesh();

    for (int i = 0; i < vertexCount; i++)
    {
        data = data
            .ReadFloat(out float x)
            .ReadFloat(out float y)
            .ReadFloat(out float z);

        mesh.Vertices.Add(new Vertex(x, y, z));
    }

    for (int i = 0; i < faceCount; i++)
    {
        data = data.ReadByte(out byte indexCount);

        int[] indices = new int[indexCount];

        for (int j = 0; j < indexCount; j++)
            data = data.ReadInt(out indices[j]);

        mesh.Faces.Add(indices);
    }

    return mesh;
}

檔案頭讀取器可以保持面向文字的方式:

private static ImmutableByteArray ReadPlyHeader(ImmutableByteArray data, out int vertexCount, out int faceCount)
{
    vertexCount = 0;
    faceCount = 0;

    var lines = new List<string>();

    while (true)
    {
        data = ReadAsciiLine(data, out string line);
        lines.Add(line);

        if (line == "end_header")
            break;
    }

    foreach (string line in lines)
    {
        string[] parts = line.Split(' ', StringSplitOptions.RemoveEmptyEntries);

        if (parts.Length == 3 && parts[0] == "element" && parts[1] == "vertex")
            vertexCount = int.Parse(parts[2]);

        if (parts.Length == 3 && parts[0] == "element" && parts[1] == "face")
            faceCount = int.Parse(parts[2]);
    }

    return data;
}

private static ImmutableByteArray ReadAsciiLine(ImmutableByteArray data, out string line)
{
    int count = 0;

    while (count < data.Length && data[count] != (byte)'\n')
        count++;

    line = Encoding.ASCII.GetString(data.AsSpan(0, count)).TrimEnd('\r');

    if (count < data.Length)
        count++;

    return data.Skip(count);
}

同樣,二進位制主體的解析器之所以簡潔,是因為遊標隱含在返回的 ImmutableByteArray 中。

示例:寫入二進位制 PLY 檔案

將一個簡單的三角形網格寫入二進位制小端序 PLY:

public static void WriteBinaryLittleEndianPly(string path, IReadOnlyList<Vertex> vertices, IReadOnlyList<int[]> faces)
{
    var current = new ConstructiveByteArray(ByteArrayEndianness.LittleEndian, Encoding.ASCII)
        .WriteStringFixedSize("ply\n")
        .WriteStringFixedSize("format binary_little_endian 1.0\n")
        .WriteStringFixedSize($"element vertex {vertices.Count}\n")
        .WriteStringFixedSize("property float x\n")
        .WriteStringFixedSize("property float y\n")
        .WriteStringFixedSize("property float z\n")
        .WriteStringFixedSize($"element face {faces.Count}\n")
        .WriteStringFixedSize("property list uchar int vertex_indices\n")
        .WriteStringFixedSize("end_header\n");

    foreach (Vertex vertex in vertices)
    {
        current = current
            .WriteFloat(vertex.X)
            .WriteFloat(vertex.Y)
            .WriteFloat(vertex.Z);
    }

    foreach (int[] face in faces)
    {
        if (face.Length > byte.MaxValue)
            throw new InvalidDataException("PLY face has too many indices for uchar list count.");

        current = current.WriteByte((byte)face.Length);

        for (int i = 0; i < face.Length; i++)
            current = current.WriteInt(face[i]);
    }

    current.Save(path);
    current.Dispose();
}

然後可以這樣寫入一個三角形:

var vertices = new[]
{
    new Vertex(0, 0, 0),
    new Vertex(1, 0, 0),
    new Vertex(0, 1, 0),
};

var faces = new[]
{
    new[] { 0, 1, 2 },
};

WriteBinaryLittleEndianPly("Triangle.ply", vertices, faces);

這段程式碼讀起來是一系列格式欄位,而不是一組陣列修改操作。

過程式構建與資料流構建

之所以有兩種寫入器型別,是因為它們解決的是不同的問題。

ConstructiveByteArray 用於普通的構建。它是可變的、僅追加的、高效的,並且可以多次儲存。

var data = new ConstructiveByteArray()
    .WriteStringFixedSize("FILE")
    .WriteInt(1);

data.Save("A.bin");
data.Save("B.bin");
data.Dispose();

ImmutableConstructiveByteArray 用於分支式構建。

var common = new ImmutableConstructiveByteArray()
    .WriteStringFixedSize("FILE")
    .WriteInt(1);

var a = common.WriteStringZeroTerminated("A");
var b = common.WriteStringZeroTerminated("B");

不可變寫入器並不是要在高吞吐量輸出場景中取代可變寫入器。它的存在是為了安全性、實驗,以及可分叉的構建。

另請參閱