# Wasm介紹之7:文字格式

買賣虛擬貨幣

前面的文章詳細介紹了WebAssembly(簡稱Wasm)二進位制格式和指令集,這篇文章將介紹Wasm文字格式(WebAssembly Text Format,後面簡稱WAT)。

整體結構

WAT採用了S-表示式寫法,整體結構如下所示:

(module  (type   ... )  (import ... )  (func   ... )  (table  ... )  (mem    ... )  (global ... )  (export ... )  (start  ... )  (elem   ... )  (data   ... ))

文字格式是二進位制格式的另外一種表現形式,但是對人類更加友好。二進位制格式更適合機器(比如編譯器)生成和(比如Wasm直譯器)理解,文字格式則更適合人類編寫和閱讀。除了表現形式有明顯不同,在結構上,兩種格式主要有下面這些不同點:

二進位制格式是以段(Section)為單位組織資料,文字格式是以域(Field)為單位組織內容。WAT編譯器需要把同型別的域收集起來,合併成二進位制段。

在二進位制格式中,除了自定義段以外,其他段都最多隻能出現一次,且必須按段ID遞增順序出現。文字格式沒這個限制,域的順序沒那麼嚴格。不過,匯入域必須出現在函式域、表域、記憶體域和全域性域之前。另外文字格式沒有自定義域,沒辦法表達自定義段。

域和段基本上是一一對應的,但是沒有單獨的程式碼域,程式碼域和函式域是合併在一起的。

文字格式提供了多種內聯形式,方便編寫。例如:

函式域、表域、記憶體域、全域性域可以內聯匯入或匯出域。

表域可以內聯元素域。

記憶體域可以內聯資料域。

函式域和匯入域可以內聯型別域

接下來按照段ID自增的順序介紹各個域。

型別域(Type Field)

型別域定義函式型別,下面這個例子定義了一個接收兩個i32型別引數、返回一個i32型別值的函式型別:

(module  (type (func (param i32) (param i32) (result i32))))

我們可以給函式型別分配一個識別符號(Identifier)作為它的名字,這樣就可以在其他地方透過名字來引用函式型別,而不必直接透過索引。module、type、func、param、result等屬於WAT語言的關鍵字。識別符號必須以$符開頭,後面跟一個或多個數字或字母。完整的識別符號詞法規則請參考Wasm規範6.3.5小節。另外,函式型別的引數也可以簡寫在同一個(param)裡。下面的例子展示了識別符號和引數的簡寫形式:

(module  (type $ft1 (func (param i32 i32) (result i32)))  (type $ft2 (func (param f64))))

匯入和匯出域(Import & Export Field)

Wasm模組可以匯入或者匯出四種型別的元素:函式、表、記憶體、全域性變數。相應的,匯入和匯出域也分別有四種寫法。下面的例子展示了四種匯入域的寫法:

(module  (type $ft1 (func (param i32 i32) (result i32)))  (import "env" "f1" (func   $f1 (type $ft1)))  (import "env" "t1" (table  $t 1 8 funcref))  (import "env" "m1" (memory $m 4 16))  (import "env" "g1" (global $g1 i32))       ;; immutable  (import "env" "g2" (global $g2 (mut i32))) (;; mutable ;;))

由上面的例子可知,在匯入域中,需要指明模組名、元素名、以及匯入元素的具體型別。模組名和元素名用字串表示,需要用雙引號"包圍。匯入域也可以像型別域那樣,帶一個識別符號,這樣就可以在後面透過名字引用被匯入的元素。WAT支援兩種型別的註釋。以;;開頭的單行註釋,以及以(;;開頭,以;;)結尾的跨行註釋 。

在上面的例子中,型別域是單獨出現的,並在匯入函式中透過名字進行引用。這種寫法對於很多匯入函式共用一個型別是非常友好的。如果某個函式型別只被使用一次,為了方便,也可以把它內聯進匯入域中,像下面這樣:

(module  (import "env" "f1"     (func $f1       (param i32 i32) (result i32) ;; inline type    )  ))

相比匯入域,匯出域的寫法要簡單一些。因為匯出域只要指定匯出名和具體元素索引即可。匯出名在整個模組內必須唯一,這點一定要注意。下面的例子展示了四種匯出域的寫法:

(module  ;; ...  (export "f1" (func   $f1))  (export "f2" (func   $f2))  (export "t1" (table  $t ))  (export "m1" (memory $m ))  (export "g1" (global $g1))  (export "g2" (global $g2)))

匯入和匯出域可以內聯在函式、表、記憶體、全域性域中。下面的例子展示了匯入域的內聯寫法:

(module  (type $ft1 (func (param i32 i32) (result i32)))  (func   $f1 (import "env" "f1") (type $ft1))  (table  $t1 (import "env" "t" ) 1 8 funcref)  (memory $m1 (import "env" "m" ) 4 16)  (global $g1 (import "env" "g1") i32)  (global $g2 (import "env" "g2") (mut i32)))

下面的例子展示了匯出域的內聯寫法(函式、表、記憶體和全域性域的完整寫法詳見後文):

(module  (func   $f (export "f1") ... )   (table  $t (export "t" ) ... )  (memory $m (export "m" ) ... )  (global $g (export "g1") ... ))

函式域(Function Field)

函式域宣告函式的區域性變數,並給出函式的指令。編譯器會把函式域拆開,把型別索引放在函式段中,區域性變數資訊和位元組碼放在程式碼段中。下面的例子展示了函式域的寫法(指令的寫法詳見後文):

(module  (type $ft1 (func (param i32 i32) (result i32)))  (func $add (type $ft1)    (local i64 i64)    (local.get 3) (drop)    (i32.add (local.get 0) (local.get 1))  ))

其實函式的引數也是普通的區域性變數,同函式域裡宣告的區域性變數一起構成了函式的區域性變數空間,索引從0開始遞增。

上面給出的是函式域的精簡寫法,直接引用了函式型別,並且區域性變數寫在了同一個(local)裡。我們可以把函式型別內聯進函式域並把(param)拆成多個,這樣就可以給引數起名字。同理,可以把(local)拆成多個,這樣就可以給區域性變數起名字。給引數和區域性變數起了名字,就可以在變數指令中透過名字而非索引來定位引數或區域性變數,這樣有助於提高程式碼的可讀性。我們把上面的例子改寫一下,內聯型別,並給引數和區域性變數分配識別符號,如下所示:

(module  (func $f1 (param $a i32) (param $b i32) (result i32)    (local $c i64) (local $d i64)    (local.get $c) (drop)    (i32.add (local.get $a) (local.get $b))  ))

表和元素域(Table & Element Field)

由於Wasm1.0規範規定模組最多隻能有一個表,所以表域最多隻能出現一次。元素域可以出現多次,裡面可以指定多個函式索引,以及第一個函式索引對應的表索引。下面的例子展示了表和元素域的寫法:

(module  (func $f1) (func $f2) (func $f3)  (table 10 20 funcref)  (elem (offset (i32.const 5)) $f1 $f2 $f3))

表域中也可以內聯一個元素域,但使用這種形式無法指定表的限制,只能由編譯器根據內聯元素進行推測。也無法指定元素的起始索引,只能從0開始。下面的例子展示了元素域的內聯寫法:

(module  (func $f1) (func $f2) (func $f3)  (table funcref       ;; min: 3, max: 3    (elem $f1 $f2 $f3) ;; inline elem  ))

記憶體和資料域(Memory & Data Field)

和表類似,由於Wasm1.0規範規定模組最多隻能有一塊記憶體,所以記憶體域也是最多隻能出現一次。資料域可以出現多次,裡面需要用常量指令指定起始記憶體偏移量(地址),並用字串指定記憶體初始值。下面的例子展示了記憶體和資料域的寫法:

(module  (memory 4 16)  (data (offset (i32.const 100)) "Hello, ")  (data (offset (i32.const 108)) "World!\n"))

記憶體域中也可以內聯一個資料域,但是使用這種形式無法指定記憶體的頁數限制,只能由編譯器根據內聯資料進行推測。也無法指定記憶體的起始地址,只能從0開始。另外,初始資料可以寫成多個字串。下面的例子展示了資料域的內聯寫法:

(module  (memory                       ;; min: 1, max: 1    (data "Hello, " "World!\n") ;; inline data  ))

使用跳脫字元可以很方便的在字串中嵌入回車換行等特殊符號、十六進位制編碼的位元組、以及Unicode程式碼點。具體請參考Wasm規範6.3.3小節。

全域性域(Global Field)

在全域性域中可以指定全域性變數的識別符號、型別、可變性、以及初始值。下面的例子展示了全域性段的寫法:

(module  (global $g1 (mut i32) (i32.const 100)) ;; mutable  (global $g2 (mut i32) (i32.const 200)) ;; mutable  (global $g3 f32 (f32.const 3.14))      ;; immutable  (global $g4 f64 (f64.const 2.71))      ;; immutable  (func    (global.get $g1)    (global.set $g2)  ))

起始域(Start Field)

起始域最為簡單,用於指定起始函式索引。下面的例子展示了起始域的寫法:

(module  (func $main ... )  (start $main))

前面介紹了WAT的整體結構和各種域的寫法,下面介紹各種指令的寫法。

指令普通形式(Plain Instruction)

指令的普通形式非常直白,對於大部分指令來說,就是操作碼後跟立即數。下面的例子展示了除控制指令外其他指令的一般寫法:

(module  (memory 1 2)  (global $g1 (mut i32) (i32.const 0))  (func $f1)  (func $f2 (param $a i32)    i32.const 123    i32.load offset=100 align=4    i32.const 456    i32.store offset=200    global.get $g1    local.get $a    i32.add    call $f1    drop  ))

可以看到,大部分指令的立即數引數都是不能省略的,以數值或者名字的形式跟在操作碼後面。記憶體讀寫系列指令是個例外,offset和align這兩個立即數引數都是可選的,且需要明確指定(數值跟在等號後面)。

block、loop、if這三條結構化控制指令,可以指定可選的結果型別,必須以end結尾。if指令還可以用else分割成兩條分支。下面的例子展示了block、loop、if、br和br_if等控制指令的一般寫法:

(module  (func $foo    block $l1 (result i32)       i32.const 123      br $l1      loop $l2        i32.const 123         br_if $l2      end    end    drop  )  (func $max (param $a i32) (param $b i32) (result i32)    local.get $a    local.get $b    i32.gt_s    if (result i32)      local.get $a    else      local.get $b    end  ))

br_table指令的寫法和br指令差不多,下面是一個例子:

(module  (func    block      block        block          i32.const 3          br_table 0 1 2 0        end      end    end  )  )

指令摺疊形式(Folded Instruction)

除了上面介紹的普通形式,指令還可以寫成更為精簡的摺疊形式。可以對普通指令做三步調整,讓它變為摺疊形式。第一步,給指令加上圓括號。第二步,如果是block、loop和if指令,把end去掉。if指令要稍微複雜一些,具體請看下面的例子。第三步(這一步是可選的),如果某條指令(無論是普通還是摺疊形式)和它前面的幾條指令從邏輯上可以看成一組操作,則可以把前幾條指令摺疊進該指令。比如說local.get $a、local.get $b、i32.add這三條指令,邏輯上是一組操作,進行加法計算。那麼可以把這三條指令摺疊起來,寫成(i32.add (local.get $a) (local.get $b))。

摺疊指令實際上表達了一顆指令樹,WAT編譯器會按照後續遍歷(從左到右遍歷子樹,最後根節點)的方式展開摺疊指令。我們按照上面的三個步驟改寫前面那個包含foo()和max()函式的例子,改寫後的程式碼應該是下面這樣

(module  (func $foo    (block $l1 (result i32)       (i32.const 123)      (br $l1)      (loop $l2        (br_if $l2 (i32.const 123))      )    )    (drop)  )  (func $max (param $a i32) (param $b i32) (result i32)    (if (result i32)      (i32.gt_s (local.get $a) (local.get $b))      (then (local.get $a))      (else (local.get $b))    )  ))

可以看到,程式碼的確是好看了不少。為了加深對摺疊指令的理解,讓我們把max()函式的if指令展開一層,把i32.gt_s指令提出來,改寫成下面的等價形式:

(module  (func $max (param $a i32) (param $b i32) (result i32)    (i32.gt_s (local.get $a) (local.get $b))    (if $l (result i32)      (then (local.get $a))      (else (local.get $b))    )  ))

我們可以繼續展開i32.gt_s指令,把local.get指令提出來,改寫成下面的等價形式:

(module  (func $max (param $a i32) (param $b i32) (result i32)    (local.get $a) (local.get $b) (i32.gt_s)    (if $l (result i32)      (then (local.get $a))      (else (local.get $b))    )  ))

到此,WAT的基本語法就都介紹完畢了。

*本文由CoinEx Chain開發團隊成員Chase撰寫。CoinEx Chain是全球首條基於Tendermint共識協議和Cosmos SDK開發的DEX專用公鏈,藉助IBC來實現DEX公鏈、智慧合約鏈、隱私鏈三條鏈合一的方式去解決可擴充套件性(Scalability)、去中心化(Decentralization)、安全性(security)區塊鏈不可能三角的問題,能夠高效能的支援數字資產的交易以及基於智慧合約的Defi應用。

免責聲明:

  1. 本文版權歸原作者所有,僅代表作者本人觀點,不代表鏈報觀點或立場。
  2. 如發現文章、圖片等侵權行爲,侵權責任將由作者本人承擔。
  3. 鏈報僅提供相關項目信息,不構成任何投資建議

推荐阅读

;