智慧合約從入門到精通:Solidity Assembly

區塊鏈技術發表於2018-07-06

簡介:上一節,我們講過Solidity組合語言,這個組合語言,可以不同Solidity一起使用。這個組合語言還可以嵌入到Solidity原始碼中,以內聯彙編的方式使用。下面我們將從內聯彙編如何使用著手,介紹其與獨立使用的組合語言的不同,最後再介紹這門組合語言。

Solidity Assembly

內聯彙編

通常我們通過庫程式碼,來增強語言我,實現一些精細化的控制,Solidity為我們提供了一種接近於EVM底層的語言,內聯彙編,允許與Solidity結合使用。由於EVM是棧式的,所以有時定位棧比較麻煩,Solidty的內聯彙編為我們提供了下述的特性,來解決手寫底層程式碼帶來的各種問題:

  • 允許函式風格的操作碼:mul(1, add(2, 3))等同於push1 3 push1 2 add push1 1 mul

  • 內聯區域性變數:let x := add(2, 3) let y := mload(0x40) x := add(x, y)

  • 可訪問外部變數:function f(uint x) { assembly { x := sub(x, 1) } }

  • 標籤:let x := 10 repeat: x := sub(x, 1) jumpi(repeat, eq(x, 0))

  • 迴圈:for { let i := 0 } lt(i, x) { i := add(i, 1) } { y := mul(2, y) }

  • switch語句:switch x case 0 { y := mul(x, 2) } default { y := 0 }

  • 函式呼叫:function f(x) -> y { switch x case 0 { y := 1 } default { y := mul(x, f(sub(x, 1))) } }

下面將詳細介紹內聯編譯(inline assembly)語言。

需要注意的是內聯編譯是一種非常底層的方式來訪問EVM虛擬機器。他沒有Solidity提供的多種安全機制。

示例

下面的例子提供了一個庫函式來訪問另一個合約,並把它寫入到一個bytes變數中。有一些不能通過常規的Solidity語言完成,內聯庫可以用來在某些方面增強語言的能力。

pragma solidity ^0.4.0;
library GetCode {
    function at(address _addr) returns (bytes o_code) {
        assembly {
            // retrieve the size of the code, this needs assembly
            let size := extcodesize(_addr)
            // allocate output byte array - this could also be done without assembly
            // by using o_code = new bytes(size)
            o_code := mload(0x40)
            // new "memory end" including padding
            mstore(0x40, add(o_code, and(add(add(size, 0x20), 0x1f), not(0x1f))))
            // store length in memory
            mstore(o_code, size)
            // actually retrieve the code, this needs assembly
            extcodecopy(_addr, add(o_code, 0x20), 0, size)
        }
    }
}
複製程式碼

內聯編譯在當編譯器沒辦法得到有效率的程式碼時非常有用。但需要留意的是內聯編譯語言寫起來是比較難的,因為編譯器不會進行一些檢查,所以你應該只在複雜的,且你知道你在做什麼的事情上使用它。

pragma solidity ^0.4.0;

library VectorSum {
    // This function is less efficient because the optimizer currently fails to
    // remove the bounds checks in array access.
    function sumSolidity(uint[] _data) returns (uint o_sum) {
        for (uint i = 0; i < _data.length; ++i)
            o_sum += _data[i];
    }

    // We know that we only access the array in bounds, so we can avoid the check.
    // 0x20 needs to be added to an array because the first slot contains the
    // array length.
    function sumAsm(uint[] _data) returns (uint o_sum) {
        for (uint i = 0; i < _data.length; ++i) {
            assembly {
                o_sum := mload(add(add(_data, 0x20), mul(i, 0x20)))
            }
        }
    }
}
複製程式碼

語法

內聯編譯語言也會像Solidity一樣解析註釋,字面量和識別符號。所以你可以使用//和/**/的方式註釋。內聯編譯的在Solidity中的語法是包裹在assembly { ... },下面是可用的語法,後續有更詳細的內容。

  • 字面量。如0x123,42或abc(字串最多是32個字元)

  • 操作碼(指令的方式),如mload sload dup1 sstore,後面有可支援的指令列表

  • 函式風格的操作碼,如add(1, mlod(0)

  • 標籤,如name:

  • 變數定義,如let x := 7 或 let x := add(y, 3)

  • 識別符號(標籤或內聯區域性變數或外部),如jump(name),3 x add

  • 賦值(指令風格),如,3 =: x。

  • 函式風格的賦值,如x := add(y, 3)

  • 支援塊級的區域性變數,如{ let x := 3 { let y := add(x, 1) } }

操作碼

這個文件不想介紹EVM虛擬機器的完整描述,但後面的列表可以做為EVM虛擬機器的指令碼的一個參考。

如果一個操作碼有引數(通過在棧頂),那麼他們會放在括號。需要注意的是引數的順序可以顛倒(非函式風格,後面會詳細說明)。用-標記的操作碼不會將一個引數推到棧頂,而標記為*的是非常特殊的,所有其它的將且只將一個推到棧頂。 在後面的例子中,mem[a...b)表示成位置a到位置b(不包含)的memory位元組內容,storage[p]表示在位置p的strorage內容。

操作碼pushi和jumpdest不能被直接使用。

在語法中,操作碼被表示為預先定義的識別符號。

智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
智慧合約從入門到精通:Solidity Assembly
字面量

你可以使用整數常量,通過直接以十進位制或16進位制的表示方式,將會自動生成恰當的pushi指令。 assembly { 2 3 add "abc" and }

上面的例子中,將會先加2,3得到5,然後再與字串abc進行與運算。字串按左對齊儲存,且不能超過32位元組。

函式風格

你可以在操作碼後接著輸入操作碼,它們最終都會生成正確的位元組碼。比如: 3 0x80 mload add 0x80 mstore

下面將會新增3與memory中位置0x80的值。

由於經常很難直觀的看到某個操作碼真正的引數,Solidity內聯編譯提供了一個函式風格的表示式,上面的程式碼與下述等同: mstore(0x80, add(mload(0x80), 3))

函式風格的表示式不能在內部使用指令風格,如1 2 mstore(0x80, add)將不是合法的,必須被寫為mstore(0x80, add(2, 1))。那些不帶引數的操作碼,括號可以忽略。

需要注意的是函式風格的引數與指令風格的引數是反的。如果使用函式風格,第一個引數將會出現在棧頂。

訪問外部函式與變數

Solidity中的變數和其它識別符號,可以簡單的通過名稱引用。對於memory變數,這將會把地址而不是值推到棧上。Storage的則有所不同,由於對應的值不一定會佔滿整個storage槽位,所以它的地址由槽和實際儲存位置相對起始位元組偏移。要搜尋變數x指向的槽 位,使用x_slot,得到變數相對槽位起始位置的偏移使用x_offset。

在賦值中(見下文),我們甚至可以直接向Solidity變數賦值。

還可以訪問內聯編譯的外部函式:內聯編譯會推入整個的入口的label(應用虛擬函式解析的方式)。Solidity中的呼叫語義如下:

  • 呼叫者推入返回的label,arg1,arg2, ... argn

  • 呼叫返回ret1,ret2,..., retm

這個功能使用起來還是有點麻煩,因為堆疊偏移量在呼叫過程中基本上有變化,因此對區域性變數的引用將是錯誤的。

pragma solidity ^0.4.11;

contract C {
    uint b;
    function f(uint x) returns (uint r) {
        assembly {
            r := mul(x, sload(b_slot)) // ignore the offset, we know it is zero
        }
    }
}
複製程式碼

標籤

另一個在EVM的彙編的問題是jump和jumpi使用了絕對地址,可以很容易的變化。Solidity內聯彙編提供了標籤來讓jump跳轉更加容易。需要注意的是標籤是非常底層的特性,儘量使用內聯彙編函式,迴圈,Switch指令來代替。下面是一個求Fibonacci的例子:

{
    let n := calldataload(4)
    let a := 1
    let b := a
loop:
    jumpi(loopend, eq(n, 0))
    a add swap1
    n := sub(n, 1)
    jump(loop)
loopend:
    mstore(0, a)
    return(0, 0x20)
}
複製程式碼

需要注意的是自動訪問棧元素需要內聯者知道當前的棧高。這在跳轉的源和目標之間有不同棧高時將失敗。當然你也仍然可以在這種情況下使用jump,但你最好不要在這種情況下訪問棧上的變數(即使是內聯變數)。

此外,棧高分析器會一個操作碼接著一個操作碼的分析程式碼(而不是根據控制流),所以在下面的情況下,彙編程式將對標籤two的堆疊高度產生錯誤的判斷:

{
    let x := 8
    jump(two)
    one:
        // Here the stack height is 2 (because we pushed x and 7),
        // but the assembler thinks it is 1 because it reads
        // from top to bottom.
        // Accessing the stack variable x here will lead to errors.
        x := 9
        jump(three)
    two:
        7 // push something onto the stack
        jump(one)
    three:
}
複製程式碼

這個問題可以通過手動調整棧高來解決。你可以在標籤前新增棧高需要的增量。需要注意的是,你沒有必要關心這此,如果你只是使用迴圈或彙編級的函式。

下面的例子展示了,在極端的情況下,你可以通過上面說的解決這個問題:

{
    let x := 8
    jump(two)
    0 // This code is unreachable but will adjust the stack height correctly
    one:
        x := 9 // Now x can be accessed properly.
        jump(three)
        pop // Similar negative correction.
    two:
        7 // push something onto the stack
        jump(one)
    three:
    pop // We have to pop the manually pushed value here again.
}
複製程式碼

定義彙編-區域性變數

你可以通過let關鍵字來定義在內聯彙編中有效的變數,實際上它只是在{}中有效。內部實現上是,在let指令出現時會在棧上建立一個新槽位,來儲存定義的臨時變數,在塊結束時,會自動在棧上移除對應變數。你需要為變數提供一個初始值,比如0,但也可以是複雜的函式表示式:

pragma solidity ^0.4.0;

contract C {
    function f(uint x) returns (uint b) {
        assembly {
            let v := add(x, 1)
            mstore(0x80, v)
            {
                let y := add(sload(v), 1)
                b := y
            } // y is "deallocated" here
            b := add(b, v)
        } // v is "deallocated" here
    }
}
複製程式碼

賦值

你可以向內聯區域性變數賦值,或者函式區域性變數。需要注意的是當你向一個指向memory或storage賦值時,你只是修改了對應指標而不是對應的資料。

有兩種方式的賦值方式:函式風格和指令風格。函式風格,比如variable := value,你必須在函式風格的表示式中提供一個變數,最終將得到一個棧變數。指令風格=: variable,值則直接從棧底取。以於兩種方式冒號指向的都是變數名稱(譯者注:注意語法中冒號的位置)。賦值的效果是將棧上的變數值替換為新值。

assembly {
    let v := 0 // functional-style assignment as part of variable declaration
    let g := add(v, 2)
    sload(10)
    =: v // instruction style assignment, puts the result of sload(10) into v
}
複製程式碼

Switch

你可以使用switch語句來作為一個基礎版本的if/else語句。它需要取一個值,用它來與多個常量進行對比。每個分支對應的是對應切爾西到的常量。與某些語言容易出錯的行為相反,控制流不會自動從一個判斷情景到下一個場景(譯者注:預設是break的)。最後有個叫default的兜底。

assembly {
    let x := 0
    switch calldataload(4)
    case 0 {
        x := calldataload(0x24)
    }
    default {
        x := calldataload(0x44)
    }
    sstore(0, div(x, 2))
}
複製程式碼

可以有的case不需要包裹到大括號中,但每個case需要用大括號的包裹。

迴圈

內聯編譯支援一個簡單的for風格的迴圈。for風格的迴圈的頭部有三個部分,一個是初始部分,一個條件和一個後疊加部分。條件必須是一個函式風格的表示式,而其它兩個部分用大括號包裹。如果在初始化的塊中定義了任何變數,這些變數的作用域會被預設擴充套件到迴圈體內(條件,與後面的疊加部分定義的變數也類似。譯者注:因為預設是塊作用域,所以這裡是一種特殊情況)。

assembly {
    let x := 0
    for { let i := 0 } lt(i, 0x100) { i := add(i, 0x20) } {
        x := add(x, mload(i))
    }
}
複製程式碼

函式

組合語言允許定義底層的函式。這些需要在棧上取引數(以及一個返回的程式碼行),也會將結果存到棧上。呼叫一個函式與執行一個函式風格的操作碼看起來是一樣的。

函式可以在任何地方定義,可以在定義的塊中可見。在函式內,你不能訪問一個在函式外定義的一個區域性變數。同時也沒有明確的return語句。

如果你呼叫一個函式,並返回了多個值,你可以將他們賦值給一個元組,使用a, b := f(x)或let a, b := f(x)。

下面的例子中通過平方乘來實現一個指數函式。

assembly {
    function power(base, exponent) -> result {
        switch exponent
        case 0 { result := 1 }
        case 1 { result := base }
        default {
            result := power(mul(base, base), div(exponent, 2))
            switch mod(exponent, 2)
                case 1 { result := mul(base, result) }
        }
    }
}
複製程式碼

內聯彙編中要注意的事

內聯組合語言使用中需要一個比較高的視野,但它又是非常底層的語法。函式呼叫,迴圈,switch被轉換為簡單的重寫規則,另外一個語言提供的是重安排函式風格的操作碼,管理了jump標籤,計算了棧高以方便變數的訪問,同時在塊結束時,移除塊內定義的塊內的區域性變數。特別需要注意的是最後兩個情況。你必須清醒的知道,組合語言只提供了從開始到結束的棧高計算,它沒有根據你的邏輯去計算棧高(譯者注:這常常導致錯誤)。此外,像交換這樣的操作,僅僅交換棧裡的內容,並不是變數的位置。

Solidity中的慣例

與EVM彙編不同,Solidity知道型別少於256位元組,如,uint24。為了讓他們更高效,大多數的數學操作僅僅是把也們當成是一個256位元組的數字進行計算,高位的位元組只在需要的時候才會清理,比如在寫入記憶體前,或者在需要比較時。這意味著如果你在內聯彙編中訪問這樣的變數,你必須要手動清除高位的無效位元組。

Solidity以非常簡單的方式來管理記憶體:內部存在一個空間記憶體的指標在記憶體位置0x40。如果你想分配記憶體,可以直接使用從那個位置的記憶體,並相應的更新指標。

Solidity中的記憶體陣列元素,總是佔用多個32位元組的記憶體(也就是說byte[]也是這樣,但是bytes和string不是這樣)。多維的memory的陣列是指向memory的陣列。一個動態陣列的長度儲存在資料的第一個槽位,緊接著就是陣列的元素。

固定長度的memory陣列沒有一個長度欄位,但它們將很快增加這個欄位,以讓定長與變長陣列間有更好的轉換能力,所以請不要依賴於這點。

參考內容:https://open.juzix.net/doc

智慧合約開發教程視訊:區塊鏈系列視訊課程之智慧合約簡介

相關文章