實踐:二進位制資料處理與封裝

2022-08-04 21:02:08

實踐:二進位制資料處理與封裝

作者:哲思

時間:2022.8.4

郵箱:[email protected]

GitHub:zhe-si (哲思) (github.com)

前言

最近在研究所做網路終端測試的專案,包括一些嵌入式和底層資料框的封裝呼叫。之前很少接觸對二進位制原始資料的處理與封裝,所以在此進行整理。

以下例子主要以 c++ 語言進行說明。

什麼是二進位制資料

在電腦上一切資料都是通過二進位制(0或1)進行儲存的,通過多位二進位制資料可以進而表示整形、浮點型、字元、字串等各種基礎型別資料或者一些更復雜的資料格式。

針對日常中一般的需求進行程式設計,我們通常無需關注底層的二進位制資料。但如果要處理二進位制檔案(音訊、視訊、圖片等)、設計空間上更高效的資料結構(網路資料框、位元組碼、protobuf)或者處理某些底層時,需要我們處理這些二進位制資料。

計算機中,稱每一個二進位制位為位元(bit,也稱:位),是計算機中的最小儲存單位。

每 8 位元組成一個位元組(byte),一般是計算機實際儲存和處理的最小單位(可以是它的倍數),也就是說,計算機是以位元組為最小單位分配空間或進行計算的,不能分配比位元組更小的儲存空間(如,最小的資料型別是char,長度 1 位元組,不支援申請 6 位元儲存空間)或者直接處理小於位元組單位的資料(如,兩個 4 位元的資料相加減)。

若干位元組構成一個計算機字(簡稱:字,word),表示計算機一次性處理事務的固定長度二進位制資料,字的位數為字長。計算機是以字為單位處理或運算的,兩個常見的概念是CPU位數作業系統位數

CPU 的位數就是指 CPU 執行一次指令能處理的最大位數(一個字長),和 CPU 中的暫存器的位數對應。其中,地址暫存器 MAR 限制了計算機的定址範圍,資料暫存器 MDR 限制了一次處理的資料長度。更多的位數帶來了更大的定址空間和更強的運算能力。

說明:定址範圍不等於記憶體大小,定址物件有記憶體條、顯示卡記憶體、音效卡、網路卡和其他裝置。之所以常把定址範圍當作記憶體上限,是因為記憶體是CPU的主要定址物件。

這裡解釋一下常見的指令架構:x86 是 intel 推出的一種指令集架構(複雜指令集 CISC 架構),一開始只有32位元的,叫 x86_32;後來 AMD 公司推出了相容 x86_32 的 64 位指令集 amd64,被業界接受,intel 將其改名為 x86_64,簡稱 x64,而 x86_32 和 x86_64 可統稱為 x86。與 x86 相對的是基於精簡指令集RISC架構的 ARM 指令集架構,多用於移動裝置。

作業系統基於 CPU 指令集實現,所以作業系統位數也直接對應 CPU 位數。由於 CPU 指令集的向下相容性,所以 32 位元運算系統也可以執行在 64 位的 CPU 上,但反過來不行。作業系統對軟體提供了向下相容的能力,64 位的作業系統支援 64 和 32 位的程式,但 32 位的作業系統只支援 32 位的程式。

處理二進位制資料

在大多語言中,最小的資料型別是 char,一個位元組,二進位制資料多用 unsigned char 表示,並寫作 uint8。語言底層常把它當作 int 進行運算。

二進位制常數以「0b」開頭,如:0b001。二進位制資料也常用8進位制(以「0」開頭)和 16 進位制(以「0x」開頭)表示,如:0257(175,八進位制)、0x1f(31,16進位制)。8 進位制 1 個數位表示 3 位二進位制資料,16 進位制 1 個數位表示 4 位二進位制資料,一個位元組可以用 2 個 16 進位制數表示。

若要處理小於一位元組的資料,就要使用位運運算元(&、|、^、~、>>、<<)。

位運運算元 描述 運算規則 用途
& 兩個位都為1時,結果才為1 二進位制位清零或得到指定位資料
| 兩個位都為0時,結果才為0 二進位制位設定為1;與對應位為0的資料相加
^ 互斥或 兩個位相同為0,相異為1 反轉指定位
~ 取反 0變1,1變0 二進位制位全部取反
<< 左移 各二進位全部左移若干位,高位丟棄,低位補0 \(x*2^n\);將資料移到高位
>> 右移 各二進位全部右移若干位,對無符號數,高位補0,有符號數,各編譯器處理方法不一樣,有的補符號位(算術右移),有的補0(邏輯右移) \(x/2^n\);將資料移到低位

舉個例子,判斷某個位元組的第3位是否是1:

// 先清0其他位,再判斷是否等於0b100
bool isOne = (byte & 0b100) == 0b100;

再舉個例子,計算機網路 IP 協定中的 control flag 和 fragment offset 合起來儲存在 IP 頭部的第 7、8 位元組,flag 佔前三位,後 13 位為 fragment offset,可以通過以下運算獲得 flag 和 offset:

// 獲得flag要擷取byte7前3位資料:先清空後5位,保留前3位資料,再右移5位將前3位資料移到起始
uint8_t flag = (byte7 & 0b11100000) >> 5;
// 此處以大端儲存,獲得offset要擷取byte7的低5位作為高位,byte8作為低位,求和:先清空byte7前3位,保留後5位資料,把它移到高8位元上,再通過全0的低8位元與byte8按位元求或來求二者之和
((byte7 & 0b00011111) << 8) | byte8;

補充說明,當需要多個位元組表示一個資料型別時,需要定義資料的高位位元組是儲存在高位地址空間還是低位地址空間,這就是大小端的定義。大端指高位位元組存在低位地址,這是人的手寫習慣;小端指低位位元組存高位地址。在處理用多個位元組表示的資料時,首先要搞清楚資料是大端還是小端。

所以,我們可以基於上述知識寫一個無符號整形與位元組流相互轉換的通用方法:

// true為大端,低位地址存高位位元組
bool ENDIAN = true;

/**
 * 將data轉換為無符號整形數位(無符號char,short,int,long,long long等)
 * @tparam T 目標型別,預設為 uint32_t
 * @param data 載荷資料 byte陣列
 * @param valueSize 資料長度,單位:byte,-1表示根據T型別自動計算
 * @param default_value 預設值,預設為0
 * @return 根據data轉換的無符號整形資料
 */
template<typename T = uint32_t>
T payloadToUnsignedInt(std::vector<uint8_t> data, int valueSize = -1, T default_value = uint32_t(0)) {
    if (valueSize == -1) valueSize = sizeof(T);
    if (valueSize > data.size()) return default_value;
    T value = 0;
    for (int i = 0; i < valueSize; i++) {
        if (ENDIAN) {
            value |= (data[i] & 0xff) << ((valueSize - 1 - i) << 3);
        } else {
            value |= (data[i] & 0xff) << (i << 3);
        }
    }
    return value;
}

/**
 * 無符號整形轉換為載荷 byte陣列
 * @param value 無符號整形資料
 * @param valueSize 資料長度,單位:byte,-1表示根據T型別自動計算
 * @return 載荷 byte陣列
 */
template<typename T>
std::vector<uint8_t> uintToPayload(T value, int valueSize = -1) {
    if (valueSize == -1) valueSize = sizeof(T);
    std::vector<uint8_t> data(valueSize, 0);
    for (int i = 0; i < valueSize; i++) {
        if (ENDIAN) {
            data[i] = (value >> ((valueSize - 1 - i) << 3)) & 0xff;
        } else {
            data[i] = (value >> (i << 3)) & 0xff;
        }
    }
    return data;
}

封裝二進位制資料

掌握了二進位制資料的處理方法,接下來就是對二進位制資料的封裝,將其封裝為人可以理解的物件。

二進位制資料通常以 uint8_t 陣列表示,不同位有不同的含義,需要根據實際含義進行解析後得到有意義的目標資訊。所以重點就是描述每一位的含義,並基於該描述解析二進位制資料,提供二進位制資料與有含義的物件的相互轉換。

思路1:基於組態檔

此處以自定義的二進位制指令封裝為例進行說明(專案地址),但該設定專案適用於任意二進位制資料封裝場景。面對這個需求,首先想到的是通過組態檔描述二進位制流每一位的含義,載入組態檔後根據一些過濾條件設定確定當前二進位制流段實際對應的設定並解析為字典。

由於專案包括一些嵌入式的內容,需要把所有檔案編譯後燒入板子,不支援儲存普通檔案格式的組態檔,所以採用變數形式的設定,全域性宣告設定的型別資訊和設定物件(cmd_manager),專案內任意位置定義該設定物件即可。在其他場景也可選擇 Json、xml 等設定格式。

本文設計的設定物件定義方式如下:

/**
 * 載荷設定項
 */
const CmdManager cmd_manager = { 2, {  // 指令個數,下面是每一個指令的設定
        {"TCRQ", 3, {  // 設定項名,設定項對應的欄位數
            {"TE_SEQ_NO", -1, &FT_SHORT, 0},  // 具體設定項內欄位設定(欄位名,欄位偏移,欄位型別,設定項該欄位過濾條件
            {"CMD", -1, &FT_CHARS_4, "TCRQ"},  // 設定項要求該欄位等於"TCRQ",資料不滿足則不匹配該設定項
            {"REPEAT_COUNT", -1, &FT_SHORT, 0}}}
}};

專案會自動載入該設定物件,之後針對原始二進位制資料通過 PayloadObjectMapFactory 工廠匹配對應設定並生成資料物件,可從資料物件獲得該物件型別(設定項名)並讀寫其中的欄位值。或者指定設定項建立空的資料物件,進行資料設定後獲得其原始二進位制資料載荷。

評價:

該思路通過組態檔可以自由且動態的調整解析方式,易於複用、拓展或調整。其難點在於設定格式的設計,同時字典型別資料無法如直接宣告型別結構那樣清晰易用。

思路2:基於資料底層儲存方式

此處以計算機網路資料框封裝為例進行說明。c++ 底層對物件/結構體的成員欄位採用型別對齊連續儲存方式,使用該特性可以基於實際含義自然宣告、使用欄位,同時可以直接作為二進位制資料流處理。實現範例如下:

/**
 * 資料抽象類,提供二進位制流到物件的相互轉化能力
 * 內部類,只複用程式碼,不用於多型
 * @tparam size 資料位元組長度
 */
template<int size>
class DataType {
public:
    DataType() { resetData(); }
    // 初始化所有資料
    void resetData() const { memset((void *) (this), 0, size); }
    // 從二進位制流載入資料
    bool loadData(const std::vector<uint8_t>& data, int startIndex=0) {
        auto * p = (uint8_t *) this;  // 將自身當作二進位制陣列處理
        for (int i = 0; i < size; i++) {
            *p = data[i + startIndex];
            p++;
        }
        return true;
    }
    // 基於自身生成新的二進位制資料流
    [[nodiscard]] std::vector<uint8_t> createData() const {
        std::vector<uint8_t> result;
        auto p = (uint8_t const *) this;
        for (int i = 0; i < size; i++) {
            result.push_back(*p);
            p++;
        }
        return result;
    }
    [[nodiscard]] int getSize() const { return size; }
};

// 以順序宣告方式定義具體的二進位制資料型別,支援巢狀宣告
class MACHeader : public DataType<14> {
public:
    // 通過上述無符號整形與位元組流相互轉化的方法將netType的讀寫進行封裝
    [[nodiscard]] uint16_t getNetType() const {
        return payloadToUnsignedInt(std::vector<uint8_t>(netType.begin(), netType.end()), 2, uint16_t(0));
    }
    void setNetType(uint16_t _netType) {
        auto data = uintToPayload(_netType, 2);
        std::copy(data.begin(), data.end(), netType.begin());
    }

    // 提供與json互轉的能力,為了提供對映為python物件的能力
    bool loadJson(const Json::Value& json);
    [[nodiscard]] Json::Value createJson() const;

    std::array<uint8_t, 6> desMac;  // 佔多個位元組的資料採用std::array陣列描述,可避免型別丟失,同時保證資料型別仍然一致對其
    std::array<uint8_t, 6> srcMac;
    std::array<uint8_t, 2> netType;
};

本專案還需要提供 c++ 的資料框物件對映到 python 物件的能力,為了簡化 CPython 的拓展方法介面,c++ 層提供從 json 載入或生成 json 的能力,在 python 層實現一個 json 快取,通過快取提交和更新實現資料管理。為了致敬git,專案實際提交和更新方法命名為 push 和 pull,(╯▔^▔)╯。

評價:

該思路通過一種類似順序宣告的方式(有點像設定)定義資料流每個位置的實際含義,使用時清晰直接,並巧妙的通過其底層原理便捷的在物件和二進位制資料流之間提供轉化操作。但由於其需要實際宣告型別,不如思路1動態靈活易複用。