namedstruct: 二進位結構體的正則表達式

今天想要介紹一點具體的技術。namedstruct是從vlcp項目中分拆出去的Python二進位結構體打包/解包庫,它是SDN控制器vlcp中發明的最重要的輪子之一(非同步I/O核心算主驅動輪的話,這個差不多主轉向輪的感覺?)

項目的代碼開源在 github.com/hubo1016/nam,項目的文檔(英文)則可以在 namedstruct.readthedocs.org 讀到。最近不知道怎麼的readthedocs抽風,所以也可以使用 pythonhosted.org/nstruc (這官方空間簡直慢哭了)。

由於PyPI上已經有一個叫做namedstruct的項目了,在PyPI上使用的項目名叫nstruct,想要迅速嘗試一下的話可以使用pip來安裝:

pip install nstructn

如果之前安裝過vlcp,作為依賴項,namedstruct應該已經成功安裝了。

雖然起了這個標題,但namedstruct並不是基於字元串的,只是從解析複雜結構體的作用上來說,它跟使用正則表達式解析字元串有相似之處。在解析二進位結構體的領域中,它可以滿足你所有的那些最狂野的願望。它甚至可以看成是一種用Python實現的新語言,我們接下來可以看一下它的語法。

本篇文章里的代碼有些多,不需要細看,看個樣子、了解個大概就足夠了,因為其實很簡單直白。也可以直接翻到後面幾章照著實驗一下。

複雜結構體

本章的大量代碼僅僅用於展示,可以不需要仔細閱讀。下一章會仔細講解。

我們從OpenFlow講起,OpenFlow的整個協議都運行在TCP之上,使用一種二進位的格式來封裝消息,方便硬體適配,同時提高協議運行的效率。然而這個二進位格式是很複雜的,我們以OpenFlow1.3中修改流表的ofp_flow_mod命令為例:

/* Flow setup and teardown (controller -> datapath). */nstruct ofp_flow_mod {n struct ofp_header header;n uint64_t cookie; /* Opaque controller-issued identifier. */n uint64_t cookie_mask; /* Mask used to restrict the cookie bitsn that must match when the command isn OFPFC_MODIFY* or OFPFC_DELETE*. A valuen of 0 indicates no restriction. */n uint8_t table_id; /* ID of the table to put the flow in.n For OFPFC_DELETE_* commands, OFPTT_ALLn can also be used to delete matchingn flows from all tables. */n uint8_t command; /* One of OFPFC_*. */n uint16_t idle_timeout; /* Idle time before discarding (seconds). */n uint16_t hard_timeout; /* Max time before discarding (seconds). */n uint16_t priority; /* Priority level of flow entry. */n uint32_t buffer_id; /* Buffered packet to apply to, orn OFP_NO_BUFFER.n Not meaningful for OFPFC_DELETE*. */n uint32_t out_port; /* For OFPFC_DELETE* commands, requiren matching entries to include this as ann output port. A value of OFPP_ANYn indicates no restriction. */n uint32_t out_group; /* For OFPFC_DELETE* commands, requiren matching entries to include this as ann output group. A value of OFPG_ANYn indicates no restriction. */n uint16_t flags; /* Bitmap of OFPFF_* flags. */n uint8_t pad[2];n struct ofp_match match; /* Fields to match. Variable size. */n /* The variable size and padded match is always followed by instructions. */n /*struct ofp_instruction instructions[0];*/ /* Instruction set - 0 or more.n The length of the instructionn set is inferred from then length field in the header. */n};n

這個注釋比代碼多的定義,其實真正解析起來是很複雜的,最主要的複雜度在後面。首先是一個變長的ofp_match結構體,它在OpenFlow1.3當中對應的結構體當中是一系列OXM格式的定義;然後是一系列ofp_instruction結構體,然而ofp_instruction只是一系列instruction的結構體的頭,每個頭後面都會跟不同長度的結構體內容。我們看一下ofp_match_oxm與ofp_instruction的定義:

/* Fields to match against flows */nstruct ofp_match {n uint16_t type; /* One of OFPMT_* */n uint16_t length; /* Length of ofp_match (excluding padding) */n /* Followed by:n * - Exactly (length - 4) (possibly 0) bytes containing OXM TLVs, thenn * - Exactly ((length + 7)/8*8 - length) (between 0 and 7) bytes ofn * all-zero bytesn * In summary, ofp_match is padded as needed, to make its overall sizen * a multiple of 8, to preserve alignment in structures using it.n */n uint8_t oxm_fields[0]; /* 0 or more OXM match fields */n uint8_t pad[4]; /* Zero bytes - see above for sizing */n};nn/* Instruction header that is common to all instructions. The length includesn * the header and any padding used to make the instruction 64-bit aligned.n * NB: The length of an instruction *must* always be a multiple of eight. */nstruct ofp_instruction {n uint16_t type; /* Instruction type */n uint16_t len; /* Length of this struct in bytes. */n};nOFP_ASSERT(sizeof(struct ofp_instruction) == 4);nn/* Instruction structure for OFPIT_GOTO_TABLE */nstruct ofp_instruction_goto_table {n uint16_t type; /* OFPIT_GOTO_TABLE */n uint16_t len; /* Length of this struct in bytes. */n uint8_t table_id; /* Set next table in the lookup pipeline */n uint8_t pad[3]; /* Pad to 64 bits. */n};nOFP_ASSERT(sizeof(struct ofp_instruction_goto_table) == 8);nn/* Instruction structure for OFPIT_WRITE_METADATA */nstruct ofp_instruction_write_metadata {n uint16_t type; /* OFPIT_WRITE_METADATA */n uint16_t len; /* Length of this struct in bytes. */n uint8_t pad[4]; /* Align to 64-bits */n uint64_t metadata; /* Metadata value to write */n uint64_t metadata_mask; /* Metadata write bitmask */n};nOFP_ASSERT(sizeof(struct ofp_instruction_write_metadata) == 24);nn/* Instruction structure for OFPIT_WRITE/APPLY/CLEAR_ACTIONS */nstruct ofp_instruction_actions {n uint16_t type; /* One of OFPIT_*_ACTIONS */n uint16_t len; /* Length of this struct in bytes. */n uint8_t pad[4]; /* Align to 64-bits */n struct ofp_action_header actions[0]; /* 0 or more actions associated withn OFPIT_WRITE_ACTIONS andn OFPIT_APPLY_ACTIONS */n};nOFP_ASSERT(sizeof(struct ofp_instruction_actions) == 8);nn/* Instruction structure for OFPIT_METER */nstruct ofp_instruction_meter {n uint16_t type; /* OFPIT_METER */n uint16_t len; /* Length is 8. */n uint32_t meter_id; /* Meter instance. */n};nOFP_ASSERT(sizeof(struct ofp_instruction_meter) == 8);nn/* Instruction structure for experimental instructions */nstruct ofp_instruction_experimenter {n uint16_t type;tt/* OFPIT_EXPERIMENTER */n uint16_t len; /* Length of this struct in bytes */n uint32_t experimenter; /* Experimenter ID which takes the same formn as in struct ofp_experimenter_header. */n /* Experimenter-defined arbitrary additional data. */n};nOFP_ASSERT(sizeof(struct ofp_instruction_experimenter) == 8);n

ofp_match當中對於OXM的定義乾脆就放棄治療了,直接寫了個uint8_t[0]在裡面。而且需要自動補0對齊到8位邊界。ofp_instruction則實際上有5種可能的類型,需要按照第一個欄位的值進行判斷,然後進行不同的解析。

這種結構體嵌套結構體,同一個頭的結構體有不同變種,有時候還有可變數組,還要考慮位元組邊界對齊問題的複雜結構體解析就是OpenFlow協議解析的基本要求。這也是幾乎所有其他會需要的複雜二進位結構體解析時會遇到的問題,總結來說主要是以下幾點:

  1. 變體結構體:有相同的結構體頭部,但後續欄位隨頭部欄位值的不同而不同
  2. 結構體嵌套:另一個結構體中需要使用我們定義的其他結構體,包括變體結構體
  3. 定長/變長數組: 需要使用結構體形成的數組,可能是定長,也可能具有不確定數量的元素(變長)。構成數組的類型也可能是變體結構體。
  4. 位元組邊界對齊:當結構體的尺寸不是對齊位元組數的整數倍時,自動補零對齊到對齊位元組數的整數倍,解析時則自動忽略對齊位元組。注意由於變長數組的存在,補零的數量可能是不確定的。

傳統上,解析這樣的複雜結構體需要非常多的代碼。我們知道Python有系統的struct庫,可以用來解析二進位結構體,但是需要你寫出像 QQBBHHHIIIH2x 這樣的結構體定義表達式,當然,寫出來了恐怕我們都認不出它是啥。然後還需要把解析得到的元組賦給相應的欄位。這還只是比較容易的那部分,實現變體、嵌套、還有數組才是複雜的部分,通常要麼需要很複雜的面向對象編程,要麼需要一大堆if else條件。許多開源控制器框架中,解析協議的介面佔了總代碼量的一大部分,而且類很複雜很難維護,這也是促使我重新開發一套開源控制器框架的重要原因之一。

namedstruct給了這些需求一個完整、全面、一鍋端的解決方案。那麼,在使用namedstruct的Python代碼當中,ofp_flow_mod這樣的結構體是怎麼表示的呢?

n/* Flow setup and teardown (controller -> datapath). */nnofp_flow_mod = nstruct(n (uint64, cookie), # /* Opaque controller-issued identifier. */n# /* Mask used to restrict the cookie bitsn# that must match when the command isn# OFPFC_MODIFY* or OFPFC_DELETE*. A valuen# of 0 indicates no restriction. */n (uint64, cookie_mask),n# /* ID of the table to put the flow in.n# For OFPFC_DELETE_* commands, OFPTT_ALLn# can also be used to delete matchingn# flows from all tables. */n (ofp_table, table_id),n (ofp_flow_mod_command.astype(uint8), command), # /* One of OFPFC_*. */n (uint16, idle_timeout), # /* Idle time before discarding (seconds). */n (uint16, hard_timeout), # /* Max time before discarding (seconds). */n (uint16, priority), # /* Priority level of flow entry. */n# /* Buffered packet to apply to, orn# OFP_NO_BUFFER.n# Not meaningful for OFPFC_DELETE*. */n (ofp_buffer_id, buffer_id),n# /* For OFPFC_DELETE* commands, requiren# matching entries to include this as ann# output port. A value of OFPP_ANYn# indicates no restriction. */n (ofp_port_no, out_port),n# /* For OFPFC_DELETE* commands, requiren# matching entries to include this as ann# output group. A value of OFPG_ANYn# indicates no restriction. */n (ofp_group, out_group),n (ofp_flow_mod_flags, flags), # /* Bitmap of OFPFF_* flags. */n (uint8[2],),n (ofp_match, match), # /* Fields to match. Variable size. */n# /* The variable size and padded match is always followed by instructions. */n# /* Instruction set - 0 or more.n# The length of the instructionn# set is inferred from then# length field in the header. */n (ofp_instruction[0], instructions),n base = ofp_msg,n name = ofp_flow_mod,n criteria = lambda x: x.header.type == OFPT_FLOW_MOD,n classifyby = (OFPT_FLOW_MOD,),n init = packvalue(OFPT_FLOW_MOD, header, type)n)n

不你沒看錯,我不是不小心把C的代碼又複製了一遍,這的確是Python。不過它也的確是用前面的openflow.h的代碼經過簡單修改產生的。仔細看,它是一個nstruct函數返回的結果,這個函數首先接受了一組位置參數,每個都是一個元組,元組前一半是欄位類型,後一段則是字元串表示的欄位名。為了看得更清晰,我們把注釋去掉:

ofp_flow_mod = nstruct(n (uint64, cookie),n (uint64, cookie_mask),n (ofp_table, table_id),n (ofp_flow_mod_command.astype(uint8), command),n (uint16, idle_timeout),n (uint16, hard_timeout),n (uint16, priority),n (ofp_buffer_id, buffer_id),n (ofp_port_no, out_port),n (ofp_group, out_group),n (ofp_flow_mod_flags, flags),n (uint8[2],),n (ofp_match, match),n (ofp_instruction[0], instructions),n base = ofp_msg,n name = ofp_flow_mod,n criteria = lambda x: x.header.type == OFPT_FLOW_MOD,n classifyby = (OFPT_FLOW_MOD,),n init = packvalue(OFPT_FLOW_MOD, header, type)n)n

在位置參數之後,這個nstruct函數緊接著接受了一些命名參數。name=ofp_flow_mod為這個結構體起了名字,一般跟左邊變數名一致,在__repr__的時候會有作用;base, criteria, classifyby等參數構成了變體結構體解析的基礎;init相當於構造函數,在我們構造新結構體的時候,它會自動執行,將某些欄位預先賦給相應的值。接下來我們一步一步講解namedstruct的寫法和用法。

使用namedstruct定義複雜結構體

我們前面看到了,使用namedstruct定義複雜結構體的過程基本只需要從C的定義中進行對應的修改即可。下面我們一步一步講如何定義一個可以方便使用的結構體。可以同時參考github上的openflow13.py源代碼 github.com/hubo1016/vlc

如果你希望跟著接下來的操作自己做,你首先應當安裝namedstruct,然後在所有代碼的開頭(或者Interactive Shell當中)加入:

from namedstruct import *n

namedstruct當中有大量的預定義的類型,比如uint8, uint16等等,一個一個進行import太麻煩,使用from namedstruct import *是推薦的用法,可以少寫很多代碼。由於大部分需要這麼做的模塊都是在定義結構體,可以在定義完之後使用這些結構體時再進行命名空間的保護。

當然如果你堅持要用import namedstruct as n也沒有什麼問題。

首先第一步,ofp_flow_mod自己就是一個可變結構體,因為所有的OpenFlow消息都通過相同的ofp_header開始,根據type的不同分配到不同的結構體中。在namedstruct中,這樣的變體結構體使用類似C++的類繼承的方式定義,我們首先將結構體頭定義為基類,然後將所有變體定義成基類的子類。作為基類的ofp_msg可以這麼定義:

ofp_type = enum(ofp_type, globals(), uint8,n OFPT_HELLO = 0, #/* Symmetric message */n OFPT_ERROR = 1, #/* Symmetric message */n OFPT_ECHO_REQUEST = 2, #/* Symmetric message */n OFPT_ECHO_REPLY = 3, #/* Symmetric message */n OFPT_EXPERIMENTER = 4,n OFPT_FEATURES_REQUEST = 5,n OFPT_FEATURES_REPLY = 6,n OFPT_GET_CONFIG_REQUEST = 7,n OFPT_GET_CONFIG_REPLY = 8,n OFPT_SET_CONFIG = 9,n OFPT_PACKET_IN = 10,n OFPT_FLOW_REMOVED = 11,n OFPT_PORT_STATUS = 12,n OFPT_PACKET_OUT = 13,n OFPT_FLOW_MOD = 14,n OFPT_GROUP_MOD = 15,n OFPT_PORT_MOD = 16,n OFPT_TABLE_MOD = 17,n OFPT_MULTIPART_REQUEST = 18,n OFPT_MULTIPART_REPLY = 19,n OFPT_BARRIER_REQUEST = 20,n OFPT_BARRIER_REPLY = 21,n OFPT_QUEUE_GET_CONFIG_REQUEST = 22,n OFPT_QUEUE_GET_CONFIG_REPLY = 23,n OFPT_ROLE_REQUEST = 24,n OFPT_ROLE_REPLY = 25,n OFPT_GET_ASYNC_REQUEST = 26,n OFPT_GET_ASYNC_REPLY = 27,n OFPT_SET_ASYNC = 28,n OFPT_METER_MOD = 29,n)nnofp_header = nstruct((ofp_version, version),n (ofp_type, type),n (uint16, length),n (uint32, xid),n name = ofp_header)nnofp_msg = nstruct((ofp_header, header),n name = ofp_msg,n padding = 1,n size = sizefromlen(65536, header, length),n prepack = packrealsize(header, length))n

注意到我們定義了一個枚舉,它使用enum方法進行定義,基礎類型是uint8。enum方法使用globals()的返回值作為一個參數輸入,它會自動將後面定義的名稱導入到當前的globals()當中,這樣後面的代碼可以將這些名稱當作常量來使用。如果傳入None則可以禁用這一過程。

使用枚舉作為欄位類型與使用基礎類型基本一樣,它的好處在格式化輸出時會有體現,我們後面再說。

uint16,uint32等是namedstruct當中預定義的基礎類型,正如名稱一樣,它代表無符號的16位或32位整數。任何struct(Python的系統庫)中定義了的類型都可以作為一種基礎類型,只是它們中大部分都在

ofp_msg嵌入了ofp_header作為自己的一部分,就像你們想像的那樣,通過使用ofp_header作為欄位類型來定義這樣的嵌套關係。ofp_msg使用size命名參數定義了計算自己結構體正確大小的方法,通過sizefromlen幫助函數返回的是一個函數,它也可以用lambda表達式:lambda x: x.header.length來取代,代表使用header.length獲取正確的結構體大小。prepack命名參數同樣輸入一個函數,它在打包即將開始時執行,在這裡使用這個機制將正確的ofp_msg的大小寫入header.length。變體結構體必須使用size命名參數在基類中正確計算出自己的實際大小,這保證了結構體能正確解析,不會出現歧義。

實際上這是正式版本的openflow13.py的一個簡化版本,正式版本需要兼容其他版本的OpenFlow,因此還有額外的兩層繼承關係。

Padding影響結構體的邊界對齊行為,當忽略時,默認為8,這是因為OpenFlow中的結構體默認使用8位元組對齊。這對於很多用戶來說會是個意外,可能會導致意想不到的效果,因此如果用戶忽略了這個參數,現在的版本會報一個warning。

在定義ofp_flow_mod之前,我們首先看一下如何定義需要嵌套到它中間的結構體。ofp_flow_mod中需要嵌套ofp_instruction的數組,而ofp_instruction中的其中一種ofp_instruction_actions需要ofp_action類型。所以我們先來定義ofp_action類型:

ofp_action_type = enum(ofp_action_type, globals(), uint16,n OFPAT_OUTPUT = 0,n OFPAT_COPY_TTL_OUT = 11,n OFPAT_COPY_TTL_IN = 12,n OFPAT_SET_MPLS_TTL = 15,n OFPAT_DEC_MPLS_TTL = 16,nn OFPAT_PUSH_VLAN = 17,n OFPAT_POP_VLAN = 18,n OFPAT_PUSH_MPLS = 19,n OFPAT_POP_MPLS = 20,n OFPAT_SET_QUEUE = 21,n OFPAT_GROUP = 22,n OFPAT_SET_NW_TTL = 23,n OFPAT_DEC_NW_TTL = 24,n OFPAT_SET_FIELD = 25,n OFPAT_PUSH_PBB = 26,n OFPAT_POP_PBB = 27,n OFPAT_EXPERIMENTER = 0xffffn)nnofp_action = nstruct((ofp_action_type, type),n (uint16, len),n name = ofp_action,n size = sizefromlen(512, len),n prepack = packsize(len),n classifier = lambda x: x.typen )nnofp_controller_max_len = enum(ofp_controller_max_len, globals(), uint16,n OFPCML_MAX = 0xffe5,n OFPCML_NO_BUFFER = 0xffffn)nnofp_port_no = enum(ofp_port_no,n globals(),n uint32,n OFPP_MAX = 0xffffff00,n OFPP_IN_PORT = 0xfffffff8,n OFPP_TABLE = 0xfffffff9,n OFPP_NORMAL = 0xfffffffa,n OFPP_FLOOD = 0xfffffffb,n OFPP_ALL = 0xfffffffc,n OFPP_CONTROLLER = 0xfffffffd,n OFPP_LOCAL = 0xfffffffe,n OFPP_ANY = 0xffffffff)nnofp_action_output = nstruct((ofp_port_no, port),n (ofp_controller_max_len, max_len),n (uint8[6],),n name = ofp_action_output,n base = ofp_action,n criteria = lambda x: x.type == OFPAT_OUTPUT,n classifyby = (OFPAT_OUTPUT,),n init = packvalue(OFPAT_OUTPUT, type))n

ofp_action基類的定義跟剛才沒有太大區別,不再細說。我們給ofp_action定義一個子類ofp_action_output。在解析時,首先使用父類進行解析,完成後會將父類根據父類欄位的值,自動轉換為相應的子類進行進一步的解析。父類使用base命名參數指定,它相當於C++的繼承關係,當指定了base之後,不需要在欄位中再次定義父類的欄位。

父類轉換到子類主要有兩種機制,第一種是基於子類的criteria命名參數,它是一個lambda表達式,使用父類解析的結果作為參數輸入,在父類應當進一步轉化成自己時返回True,否則返回False。由於需要按順序一個一個調用子類的criteria方法進行判斷,當子類過多時效率會比較低,這時候可以使用第二種機制,即classifier/classifyby機制。父類中使用classifier命名參數定義一個lambda表達式,它將父類解析的結果作為參數,返回一個可以hash的值(比如說一個數值);子類中定義classifyby命名參數,它是一個元組,當父類classifier的運行結果在元組中時將父類解析到自己,這樣只需要執行一次classifier,然後使用字典進行查找就可以快速找到相應的子類。

有了ofp_action,現在我們來定義ofp_instruction:

ofp_instruction_type = enum(ofp_instruction_type, globals(), uint16,n OFPIT_GOTO_TABLE = 1,n OFPIT_WRITE_METADATA = 2,n OFPIT_WRITE_ACTIONS = 3,n OFPIT_APPLY_ACTIONS = 4,n OFPIT_CLEAR_ACTIONS = 5,n OFPIT_METER = 6,nn OFPIT_EXPERIMENTER = 0xFFFFn)nnofp_instruction = nstruct(n (ofp_instruction_type, type),n (uint16, len),n name = ofp_instruction,n size = sizefromlen(65536, len),n prepack = packsize(len),n classifier = lambda x: x.typen)nnofp_instruction_actions = nstruct(n (uint8[4],),n (ofp_action[0], actions),n base = ofp_instruction,n name = ofp_instruction_actions,n criteria = lambda x: x.type == OFPIT_WRITE_ACTIONS or x.type == OFPIT_APPLY_ACTIONS or x.type == OFPIT_CLEAR_ACTIONS,n classifyby = (OFPIT_WRITE_ACTIONS, OFPIT_APPLY_ACTIONS, OFPIT_CLEAR_ACTIONS),n init = packvalue(OFPIT_APPLY_ACTIONS, type)n)n

注意幾件事:

  1. 我們使用(uint8[4],)這個沒有名稱的欄位定義了一個填充欄位,它表示在這個位置填充4個位元組用於對齊,這四個位元組不需要被解析到具體欄位的值。實際上任何基礎類型(比如uint8, uint16)以及它們的定長數組都可以用來表示填充。匿名欄位的另一種用法會在後面提到,也就是匿名結構體,它與匿名的基礎類型有一些區別。
  2. 就像你剛才注意到的一樣,欄位類型的[]操作被我們重載了,變成了生成數組類型,這和C當中的用法類似(當然更像是Java和C#,因為[]跟著類型名而不是變數名)。
  3. ofp_action[0],這就是變長數組。這就是變長數組。 你不需要任何更多的努力來實現它了。當然,變長數組有一些限制,它必須在某個結構體的結尾,在解析時,它會用掉這個結構體剩餘的全部大小。在打包時,相應欄位的list的大小有多大,就打包多少個元素進去。
  4. 當然,就像前面說的,我們甚至用了一個變體結構體來當作數組的類型,no problem。

接下來是ofp_match,這裡用ofp_match_oxm表示,並作為ofp_match的子類。我們先寫出基類。同時,match當中使用一種可擴展的OXM格式進行填充,我們也將這個格式的基類定義出來:

ofp_match = nstruct(n (ofp_match_type, type), n (uint16, length), n name = ofp_match,n size = sizefromlen(4096, length),n prepack = packrealsize(length)n)nnofp_oxm = nstruct(n (ofp_oxm_header, header),n name = ofp_oxm,n padding = 1,n size = lambda x: OXM_LENGTH(x.header) + 4n)n

我們忽略了枚舉ofp_oxm_header的定義,它太長了。OXM_LENGTH是openflow.h中定義的宏,我們在Python當中把它寫成了等效的函數。OXM格式的頭部是一個32位整數,4位元組。緊跟著是數據部分,頭部當中的長度(可以使用OXM_LENGTH獲取)並不包括頭部本身的長度,所以要加上4個位元組,我們使用一個lambda表達式作為size參數的值。

接下來我們會第一次遇到一些困難。OXM有兩種格式,第一種格式中,header後面緊跟著一個變長欄位,變長欄位的長度與OXM_LENGTH的結果相同,這個還比較好辦;困難在於第二種格式中,header後面跟著兩個可變長欄位,每個長度各自是OXM_LENGTH的一半。這可怎麼辦呢,我們前面說過,可變長欄位必須是結構體最後一個成員,它使用全部的剩餘大小。怎麼將剩下的大小均分給兩個欄位呢?

這時候我們就要祭出namedstruct的終極大殺器——匿名結構體了。

這個設計的靈感來自於微軟的C編譯器的一個擴展,它允許在結構體當中嵌套使用一個沒有欄位名的結構體,編譯時,前一個結構體的欄位會直接嵌入到這個結構體當中。在這裡也是完全一樣的效果,我們看下面的定義:

ofp_oxm_nomask = nstruct(n (raw, value),n base = ofp_oxm,n criteria = lambda x: not OXM_HASMASK(x.header),n init = packvalue(OXM_OF_IN_PORT, header),n name = ofp_oxm_nomaskn)nn_ofp_oxm_mask_value = nstruct(n (raw, value),n name = ofp_oxm_mask_value,n size = lambda x: OXM_LENGTH(x.header) // 2,n padding = 1n)nnofp_oxm_mask = nstruct(n (_ofp_oxm_mask_value,),n (raw, mask),n base = ofp_oxm,n criteria = lambda x: OXM_HASMASK(x.header),n init = packvalue(OXM_OF_METADATA_W, header),n name = ofp_oxm_mask,n)n

我們使用前面提到的技巧將ofp_oxm派生為ofp_oxm_mask和ofp_oxm_nomask兩個子類,其中ofp_oxm_nomask使用了raw作為最後一個欄位的類型,它與char[0]相同,類似於變長數組,不過與uint8[0]不同,它解析的結果是一個bytes對象,包含了剩下的所有位元組。ofp_oxm_mask則使用了一個匿名的結構體_ofp_oxm_mask_value,我們重點看一下這個結構體。

我們前面說過,變長欄位必須是結構體的最後一個欄位。反過來說,所有結構體的最後一個欄位都可以是變長欄位,包括匿名結構體。匿名結構體的大部分特性與普通結構體相同,實際上任何普通結構體也都可以作為匿名結構體使用;這其中也就包括匿名結構體可以使用size,可以使用padding,甚至可以使用繼承關係。被嵌入到其他結構體之後,在它中間定義的成員會直接變成被嵌入的結構體的成員,比如_ofp_oxm_mask_value.value可以直接使用ofp_oxm_mask.value來訪問。匿名結構體也可以嵌套其他匿名結構體,效果和你們想像的一樣,所有欄位都會自動加入最外層的非匿名結構體。

匿名結構體還有一個有趣的特性,在計算大小的時候,它表現的像是一個普通結構體,只包含了在它裡面定義的成員;但從匿名結構體可以直接訪問被嵌入的結構體中的成員。通過這個特性可以實現許多有用的功能,比如說上面的_ofp_oxm_mask_value結構體,它在lambda表達式中使用了x.header來獲取OXM頭,這是定義在被嵌入的結構體(甚至是被嵌入結構體的基類)中的欄位。

使用匿名結構體基本上可以解決所有可能遇到的疑難雜症。定義完ofp_match和ofp_instruction,我們終於可以定義ofp_flow_mod了,代碼前面貼過,不再細講,使用的技巧前面都提到過了。

測試打包/解包

我們來演示一下如何使用我們剛剛定義好的結構體,順便測試一下它的各種功能。我們最好使用一套定義好的結構體,省的我們把代碼全部複製到Interactive Shell裡面。簡單的方法是安裝vlcp,其中包含了一套定義好的完整的OpenFlow協議。安裝的方法自然是使用pip install vlcp。

安裝好之後打開Python,使用下面的代碼導入OpenFlow1.3的定義:

from vlcp.protocol.openflow.defs.openflow13 import *n

我們下面用我們前面展示過的ofp_flow_mod的定義,來創建一條插入流表的命令。定義好的結構體可以像類一樣使用,大概是這個樣子:

flowmod = ofp_flow_mod(cookie = 0x1,n table_id = 0,n command = OFPFC_ADD,n priority = OFP_DEFAULT_PRIORITY,n buffer_id = OFP_NO_BUFFER,n out_port = OFPP_ANY,n out_group = OFPG_ANY,n match = ofp_match_oxm(n oxm_fields = [n create_oxm(OXM_OF_IN_PORT, 1),n create_oxm(OXM_OF_ETH_SRC,n bxa0x45x92x12x00xa1)]n ),n instructions = [ofp_instruction_actions(n actions = n [ofp_action_set_field(n field = create_oxm(OXM_OF_ETH_SRC,n bx00x02x11x22x33x44)n ),n ofp_action_output(port = 2)],n )]n )n

不好意思,第一次見到這樣的寫法也許會看暈。結構體的「構造函數」可以傳入kwargs,用來初始化相應的欄位,實際上跟直接給欄位賦值是一樣的,不過用這樣的寫法可以一次性初始化完所有的欄位,一氣呵成。當然如果你更喜歡慢慢來別太暴力,也是可以的:

flowmod2 = ofp_flow_mod()nflowmod2.cookie = 0x1nflowmod2.table_id = 0nflowmod2.command = OFPFC_ADDnflowmod2.priority = OFP_DEFAULT_PRIORITYnflowmod2.buffer_id = OFP_NO_BUFFERnflowmod2.out_port = OFPP_ANYnflowmod2.out_group = OFPG_ANYnflowmod2.match = ofp_match_oxm()nflowmod2.match.oxm_fields.append(create_oxm(OXM_OF_IN_PORT, 1))nflowmod2.match.oxm_fields.append(create_oxm(OXM_OF_ETH_SRC,n bxa0x45x92x12x00xa1))nflowmod2.instructions.append(ofp_instruction_actions())nflowmod2.instructions[0].actions.append(ofp_action_set_field())nflowmod2.instructions[0].actions[0].field = create_oxm(OXM_OF_ETH_SRC,n bx00x02x11x22x33x44)nflowmod2.instructions[0].actions.append(ofp_action_output())nflowmod2.instructions[0].actions[1].port = 2n

這看上去就像是在修改普通的對象的屬性一樣。應該說,這就是在修改普通的對象的屬性。新創建的對象的所有屬性都跟前面定義的欄位是對應的,數組則會被自動創建為list,可以使用普通list的任何方法(比如取索引,比如append之類),也可以直接將整個欄位賦值為新的list,都沒有什麼問題。讀取屬性自然也是可以的,根據讀取的屬性進行修改自然也可以,總之跟普通的對象和列表的數據結構沒有任何區別。

這個創建出來的對象的價值在於,它可以立即轉換為二進位位元組流:

>>> flowmod._tobytes()nx04x0ex00px00x00x00x00x00x00x00x00x00x00x00x01x00x00x00x00x00x00x00x00x00x00x00x00x00x00x80x00xffxffxffxffxffxffxffxffxffxffxffxffx00x00x00x00x00x01x00x16x80x00x00x04x00x00x00x01x80x00x08x06xa0Ex92x12x00xa1x00x00x00x04x00(x00x00x00x00x00x19x00x10x80x00x08x06x00x02x11"3Dx00x00x00x00x00x10x00x00x00x02x00x00x00x00x00x00x00x00n

這是打包,那麼解包也很簡單,我們馬上試一下把剛剛打包的結果重新解析成二進位結構體:

>>> d = flowmod._tobytes()n>>> flowmod2, size = ofp_msg.parse(d)n>>> sizen112n>>> flowmod2n<ofp_flow_mod at 00000000030B9FB0>n>>> len(d)n112n>>> flowmod2.priorityn32768n>>> flowmod2.instructions[0].actions[1].portn2n

注意我們可以直接使用基類來解析,解析的結果自動變為相應的子類。parse方法在成功時返回兩個值的元組,前一個值為解析的結果,後一個值為使用了的位元組數,這在從一個位元組流中連續解析出結構體(比如在socket上解析OpenFlow協議)時非常有用。如果parse方法發現當前傳入的數據不足,則會返回None,可以在獲取到更多數據之後再次嘗試使用parse。

可以很容易驗證parse得到的結果的欄位與打包前相同,而且再次打包得到完全一樣的二進位表示形式。雖然我們也花了不少功夫來定義這些結構體,但是跟使用傳統方法定義的結構體相比,還是簡單得太多了。

格式化輸出

如果前面這些還不足以讓你驚訝,我們最後要展示的一個功能,不知道能否超越你的想像。現在我們嘗試這條語句:

>>> from pprint import pprintn>>> pprint(dump(flowmod))n{_type: <ofp_flow_mod>,n buffer_id: OFP_NO_BUFFER,n command: OFPFC_ADD,n cookie: 1,n cookie_mask: 0,n flags: 0,n hard_timeout: 0,n header: {length: 112,n type: OFPT_FLOW_MOD,n version: OFP13_VERSION,n xid: 0},n idle_timeout: 0,n instructions: [{_type: <ofp_instruction_actions>,n actions: [{_type: <ofp_action_set_field>,n field: {_type: <ofp_oxm_nomask_eth>,n header: OXM_OF_ETH_SRC,n value: 00:02:11:22:33:44},n len: 16,n type: OFPAT_SET_FIELD},n {_type: <ofp_action_output>,n len: 16,n max_len: 0,n port: 2,n type: OFPAT_OUTPUT}],n len: 40,n type: OFPIT_APPLY_ACTIONS}],n match: {_type: <ofp_match_oxm>,n length: 22,n oxm_fields: [{_type: <ofp_oxm_nomask_port>,n header: OXM_OF_IN_PORT,n value: 1},n {_type: <ofp_oxm_nomask_eth>,n header: OXM_OF_ETH_SRC,n value: a0:45:92:12:00:a1}],n type: OFPMT_OXM},n out_group: OFPG_ANY,n out_port: OFPP_ANY,n priority: 32768,n table_id: 0}n

dump是namedstruct中的一個內置方法,在我們from ... import *的時候將它一起導入了進來。它可以將我們定義的結構體轉化成類似JSON結構體的字典和list的組合。不僅僅如此,仔細看會發現,它還將本來應該是整數的許多欄位轉化成了對應的枚舉名稱,甚至將MAC地址都從uint8[6]的數組轉換成了我們習慣的字元串格式。這就是前面使用枚舉作為欄位類型的作用。

在使用dump輸出的過程中,可以通過許多機制將欄位中具體的數值轉換為可讀的格式。最常見的是枚舉,但這個例子中的MAC地址的轉換使用了其他的機制,具體的使用方法在這裡不展開介紹了,庫的文檔當中介紹得比較詳細。可想而知這個功能在想要把結構體輸出到日誌之類的情況下是多麼有用。

這個轉化為可讀形式的功能在dump時也是可以關閉的,這樣可以得到適合計算機存儲的形式,我們可以對比一下原始的數值:

>>> pprint(dump(flowmod, False))n{_type: <ofp_flow_mod>,n buffer_id: 4294967295L,n command: 0,n cookie: 1,n cookie_mask: 0,n flags: 0,n hard_timeout: 0,n header: {length: 112, type: 14, version: 4, xid: 0},n idle_timeout: 0,n instructions: [{_type: <ofp_instruction_actions>,n actions: [{_type: <ofp_action_set_field>,n field: {_type: <ofp_oxm_nomask_eth>,n header: 2147485702L,n value: x00x02x11"3D},n len: 16,n type: 25},n {_type: <ofp_action_output>,n len: 16,n max_len: 0,n port: 2,n type: 0}],n len: 40,n type: 4}],n match: {_type: <ofp_match_oxm>,n length: 22,n oxm_fields: [{_type: <ofp_oxm_nomask_port>,n header: 2147483652L,n value: x00x00x00x01},n {_type: <ofp_oxm_nomask_eth>,n header: 2147485702L,n value: xa0Ex92x12x00xa1}],n type: 1},n out_group: 4294967295L,n out_port: 4294967295L,n priority: 32768,n table_id: 0}n

基於namedstruct的抓包小程序

最後我們講一個有意思的應用,代碼在 github.com/hubo1016/nam,這是一個Python編寫的類似於tcpdump的抓包程序,只有250行左右,它使用RAW_SOCKET獲取二進位的乙太網數據包,對於數據包的解析、展示則完全使用namedstruct進行。乙太網幀的定義位於另一個文件ethernet.py(github.com/hubo1016/nam)當中。如果你想要測試這個小程序,你必須要使用Linux操作系統,首先你需要安裝namedstruct庫,然後將這兩個文件複製到同一個目錄中,然後執行

python packetdump.py -4 -vn

和tcpdump一樣,它也支持-i綁定網卡等功能,也支持對包進行過濾,但格式與tcpdump完全不同。輸出上也和tcpdump有些差異。作為一個玩具還不錯。也可以參考來學習在Python當中使用RAW_SOCKET的方法。Windows不支持RAW_SOCKET,所以Windows上這個小程序不能用,如果你很閑的話,可以嘗試把抓包的部分改寫成使用Pcap。作為這篇文章的主旨來說,告訴大家一種新的使用Python解析複雜結構體的方法,就足夠了。

總結

namedstruct是一個神奇的庫,它可以很方便地解析很複雜的二進位結構體:

  1. 基本只需要通過C/C++的定義略加修改
  2. 支持嵌套使用類型

  3. 支持變體結構體(結構體頭+可變後繼)
  4. 支持定長數組和變長數組的自動打包與解析
  5. 支持將整個結構體連通子結構體一起轉化為類似JSON的結構(字典 + list),並自動將欄位的值轉化為更加可讀的形式

如果你正好遇到了類似的需求(解析某種特定的網路協議;解析某種特定的文件格式;解析某個C介面傳入傳出的數據等),不妨一試,相信你不會再想直接使用系統自帶的struct庫。注意由於主要用於解析網路數據,默認的結構體位元組序是大端的,如果需要使用小端格式,可以指定endian命名參數,具體可以參看文檔或者代碼注釋。

推薦閱讀:

Python數據分析之讀取文件
為什麼 Python 中的複數形式是 (a + bj) 而不是 (a + bi) ?
11、從零開始做一個完整的Django項目

TAG:Python | 计算机网络 | SDN |