封包組合與剖析器設計@node.js (I) 封包組合

一、前言

  本系列文章,我想要跟大家分享一下如何在 node.js 下,製作封包組合器與解析器。對於封包的組合與解析,可能讀計算機或通訊的朋友會蠻了解的;不過為了讓大家都能夠略懂略懂,我還是隨意地說明一下。

  封包組合器在英文裡面會看到很多種不同的字眼,例如 framer, packker, frame generator, packet generator 等等,不過 packet 通常是指稱大的、完整的包裝格式資料,frame 指的是小的、片段性的包裝格式資料,字眼混用也所在多有,對更小的資訊片段也可能被稱之為 segament。一個 packet 可能會由多種不同的 frames 所組成,而這些 frames 也有各自的格式以表示不同的意義、或負責不同的功能。剖析器的英文字眼好像比較單純,都是用 parser 這個字居多,它的目的就是要將 packet 或 frame 給解析為程式中所要使用的資料。當然,這些字眼在不同的場合可能有它不同的意思,例如 parser 也不一定是指在趴協定封包,也有可能是趴字串或單純進行資料分離等。

  一般我們講的通訊協定,它正是規範 packet 組成格式的合約,亦即在賦予數據特定的意義,只要收發雙方遵照同樣的規範,就可以進行溝通。對於協定的上層而言,協定可視為一種介面。(當然,更複雜的協定本身還會規範一些網路或系統行為,這就跟本文的主旨無關啦!)
 
  在使用標準的通訊協定(protocol)時,當應用程式需要把資料(或命令)透過該介面拋到傳輸介質上,你的資料(payload)就會往下傳遞給協定堆疊(protocol stack),在 protocol stack 內部一層層往下傳遞,每個通訊層(layer)會將需要的資訊再繼續包裝上去,最後到達 physical layer,由它所包裝好的資料封包,就是準備要在傳輸媒體上跑的那個格式。當對方接收到封包時,封包同樣會經過 protocol stack 內部一層一層往上傳遞,將封包給層層剝開,最後(通常)到達最上層的應用程式的東西就是應用程式所傳遞的資料 (payload)。
 
  上面的敘述只是很簡單的理解,實際上可能複雜許多,特別是標準的通訊協定、或者是協定之上掛有其它協定的情況。標準通訊協定,視其職責範圍而有不同的複雜度,協定堆疊可能還有一些網路管理或其它系統行為要照顧,不是單單組合封包跟解析封包而已。在這一系列文章,我會把重心限縮在封包組合跟剖析這種介面性的問題。
 
  本系列文章的目標,我是想要用比較簡單的 UART(底層標準協定)再搭配自訂的 protocol 來展示怎麼樣在 node.js 中進行封包的組合跟解析。最終,我會在 node.js 用一個 module 來模擬一顆單晶片的控制介面,用另一個 module 下指令給它,讓它能做一些工作。
 
  我的目標並不是要寫那種下指令去控制 LED 之類的東西,而是一種可以調用單晶片內部函式的介面,就是一般講的 Monitor and Test (MT) interface (另一種常見的介面為 AT,請自己估狗一下吧!),這也是能執行 Remote Process Control (RPC) 的方式之一。如果你能夠設計出這種介面,你的單晶片就可以讓外部的 application processor (AP) 控制,這樣就能將更複雜的邏輯交給 AP 去照顧,而資源有限的單晶片本身,就照顧好一些基礎工作就可以了。又或者,單晶片本身可以做一些可組態化的設計,以在外部有機會能透過這個介面,讓單晶片在執行期動態地改變一些行為或條件。
 
  雖然聽起來好像有點複雜,但是我會用比較簡易的角度來說明,或許大家可以從中獲得一點啟發。我相信這對於一些想要將作品轉換為產品的(部分) maker 或學生可能會有幫助。呵呵~ 我還是廢話少說吧!

二、工具

  要在 node 中製作 framer 或 parser,你可以用 node 的核心模組 Buffer 來完成,我曾經在工作專案上直接使用 Buffer 模組對 raw data 直接進行組合跟拆解。雖然 Buffer 的 APIs 可以很容易滿足這些需要,但是要處理的細節就會比較多。所幸,有些強者大大已經將這些事情抽象成更好用的模組,利用他們可以讓事情更容易一些。總地來講,我還是喜歡用別人已經做好的 module,方便嘛!
 
  以下是我們將使用的兩個模組:
  • concentrate: 用來組合封包(或是說組合出 buffer 也可以)
  • dissolve: 用來解析封包 (或是說,將 buffer 解析為有意義的資料也可以,buffer 的內容是 binary raw data)
這兩個模組的主要開發者都是 deoxxa,看他所發布的模組,顯然是一位資料解析高手啊~

三、準備

  先準備一下實驗的環境,第一步就是開個資料夾,把上面兩個模組都裝一下:
  1. ~$ mkdir myProto
  2. ~$ cd myProto
  3. ~/myProto$ npm install concentrate
  4. ~/myProto$ npm isntall dissolve
  5. 裝好之後 ~/myProto 底下會出現 mode_modules 目錄,這兩個模組都在裡面

四、使用 Concentrate 來組合數據

  首先來看一下 concentrate 的介紹:

"Concentrate allows you to efficiently create buffers by chaining together calls to write numbers, strings and even other buffers! Concentrate is also easily extendable so you can implement your own custom types"

  待會我們就會看到,如何使用 Concentrate 的串鏈調用(chaining),以及它的 APIs 是有多麼地直覺,簡單易懂。

4.1 編排資料格式

假設,現在有一筆用於描述"人"的資料,它長的像以下這樣,我們要怎麼樣將這筆資料依照一定的格式編排成二進位資料,然後發送出去呢?

var person = {
    sex: 1,        // [無號整數, uint8 ]: 0 表示女生, 1 表示男生
    name: 'simen', // [字串, utf8]: 長度不一定
    age: 37,       // [無號整數, uint8 ]: 年齡數字
    height: 17200  // [無號整數, uint16]: 單位是 mm
}
註:這看起來就是個資料物件,你可以將它序列化為 json 格式的字串,接收的一方再使用 JSON.parse() 去解析它。不過,我們不打算用 json,萬一單晶片上沒有 json 的剖析器呢?而且,json 的 overhead 比起待會要講的自訂協定格式還要大,因為你是用字串來傳輸它們的鍵值對,例如數字 188 好了,如果當成無號整數,可以用 1 個 byte 表示,但若用字串 '188',依照編碼的不同你則至少需要 3 個 bytes)

  我依照上面資料物件中各欄位由上到下的順序來編排(順序你可以自己決定),最簡單的格式安排方法是 sex 用 1 byte 表示、name 依照它的長度需要用 n 個 bytes 表示、age 也是用 1 個 byte 表示、height 用 2 個 bytes 表示。以下我用 sex(1) 就表示這個欄位代表 sex,它需要用掉 1 byte,所以這筆資料可以用以下的方式編成:



這裡面,name 字串比較特別,因為你需要告訴接收的人,這個字串長度是多少,好讓它可以剖析,所以在 name 之前需要規劃一個 len 前導欄位,用來引導解析器接下來的字串長度。
(另一種方式是使用哨兵字元(\0 或 NUL)讓剖析器知道字串的結尾,不過它的缺點就是你需要逐字檢查。)


4.2 產生 buffer

現在請在 ~/myProto 下開一支檔案,內容如下面的 personBuf_01.js。這裡面,首先 require 了 concentrate 這個 module。當我們要開始編排 buffer 時,先呼叫 Concentrate() 產生一個實例,接著就在這個實例上使用 chaining 依序調用對應格式的 API 來寫入 buffer,看看程式碼,很容易懂,友善度也比 node 的 Buffer 模組要好一些~

personBuf_01.js

var Concentrate = require('concentrate');

var person = {
    sex: 1,
    name: 'simen',
    age: 37,
    height: 17200
};

var personBuf = Concentrate().uint8(person.sex)
                             .uint8(person.name.length)
                             .string(person.name, 'utf8')
                             .uint8(person.age)
                             .uint16(person.height)
                             .result(); // 最後記得調用.result()
                                        // 就能依前述規則產生編好的buffer

console.log(personBuf);

接下來執行它,稍微對照一下 buffer 的內容!

simen@ubuntu:~/myProto$ node personBuf_01.js 
<Buffer 01 05 73 69 6d 65 6e 25 30 43>

/* Buffer 中的 hex 碼依序是
 *             01: sex
 *             05: 字串長度 len, 'simen' 的長度為 5
 * 73 69 64 65 6e: 'simen' 各字元的 ASCII 碼
 *             25: age, 0x25 即十進位的 37
 *          30 43: height, uint16() 預設是小端排列(效果跟調用uint16le()相同),
 *                 所以 30 43 即相當於十六進位的 0x4330,正是十進位的 17200
 */

4.3 更有效的排列

因為一般人的年齡應該是不會高達 255 歲,所以 age 用 1 個 byte 表示其實有點多;另一方面,sex 只有 0 或 1 兩種情況,卻要消耗 1 個 byte 是有點浪費了。我們不如把 (sex, age) 合併在一起用一個 byte 來表示吧!
  假設這個合併的欄位稱為 info 好了,info 會用掉 1 個 byte,我們拿 info 的最高位元 (MSB) 來表示 sex,而剩下的 7 個位元則用來表示 age,不過這樣子的話 age 最大只能夠表示到 127 歲 (假設 127 歲很夠用啦)!當然,這只是欄位合併很簡單的例子,跟 C struct 的 bit fields 感覺很像吼,你可以依樣畫葫蘆,看實際需求是什麼再自己細心規劃囉~ 下圖是我們用 info 合併 (sex, age) 的資料格式:


  接著將上支程式小修改如下,看看如何使用 bitwise 操作完成 sex 與 age 的欄位合併:

personBuf_02.js

var Concentrate = require('concentrate');

var person = {
    sex: 1,
    name: 'simen',
    age: 37,
    height: 17200
};

var info = (person.sex << 7) | (0x7F & person.age); // bitwise operation

var personBuf = Concentrate().uint8(info)
                             .uint8(person.name.length)
                             .string(person.name, 'utf8')
                             .uint16(person.height)
                             .result();

console.log('info: 0x' + info.toString(16));
console.log(personBuf);

接下來執行它,並對照一下 buffer 的內容!

simen@ubuntu:~/myProto$ node personBuf_02.js 
info: 0xa5
<Buffer a5 05 73 69 6d 65 6e 30 43>
// 將 sex 與 age 編在一起之後的 info 為 0xa5

五、小結

這篇文章的說明應該是很好的熱身~ 要按格式編出 buffer 其實挺簡單的!下一篇我們會來看看如何使用 Dissolve 來解析出 personBuf1 與 personBuf2 的資料。面對上面這麼簡單的格式,Dissolve 用起來其實也蠻直覺的。但隨著我們繼續將格式擴充、複雜化,Dissolve 就會變得比較難理解一點,到時候我們會介紹如何使用 Dissolve 的 tap() 與 loop() 來幫助我們完成複雜 raw data 的解析工作!
  這系列的第一篇文章就暫時寫到這裡囉!感謝大家收看~



 
 
 

沒有留言

技術提供:Blogger.