1. 程式人生 > >智能合約從入門到精通:Solidity Assembly

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

長度 output oss 解決 struct 都是 cti .com 偏移量

簡介:上一節,我們講過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不能被直接使用。
在語法中,操作碼被表示為預先定義的標識符。
技術分享圖片技術分享圖片技術分享圖片技術分享圖片技術分享圖片技術分享圖片技術分享圖片技術分享圖片技術分享圖片

字面量
你可以使用整數常量,通過直接以十進制或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
智能合約開發教程視頻:區塊鏈系列視頻課程之智能合約簡介

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