Categories
程式開發

智能合約編寫之Solidity運行原理


引 言

作為一門面向智能合約的語言,Solidity與其他經典語言既有差異也有相似之處。

一方面,服務於區塊鏈的屬性使其與其他語言存在差異。例如,合約的部署與調用均要經過區塊鍊網絡確認;執行成本需要被嚴格控制,以防止惡意代碼消耗節點資源。

另一方面,身為編程語言,Solidity的實現並未脫離經典語言,比如Solidity中包含類似棧、堆的設計,採用棧式虛擬機來進行字節碼處理。

本系列前幾篇文章介紹瞭如何開發Solidity程序,為了讓讀者知其然更知其所以然,本文將進一步介紹Solidity的內部運行原理,聚焦於Solidity程序的生命週期和EVM工作機制。

Solidity的生命週期

與其他語言一樣,Solidity的代碼生命週期離不開編譯、部署、執行、銷毀這四個階段。下圖整理展現了Solidity程序的完整生命週期:

智能合約編寫之Solidity運行原理 1

經編譯後,Solidity文件會生成字節碼。這是一種類似jvm字節碼的代碼。部署時,字節碼與構造參數會被構建成交易,這筆交易會被打包到區塊中,經由網絡共識過程,最後在各區塊鏈節點上構建合約,並將合約地址返還用戶。

當用戶準備調用該合約上的函數時,調用請求同樣也會經歷交易、區塊、共識的過程,最終在各節點上由EVM虛擬機來執行。

下面是一個示例程序,我們通過remix探索它的生命週期。

pragma solidity ^0.4.25;

contract Demo{
    uint private _state;
    constructor(uint state){
        _state = state;
    }
    function set(uint state) public {
        _state = state;
    }
}

編譯

源代碼編譯完後,可以通過ByteCode按鈕得到它的二進制:

608060405234801561001057600080fd5b506040516020806100ed83398101806040528101908080519060200190929190505050806000819055505060a4806100496000396000f300608060405260043610603f576000357c0100000000000000000000000000000000000000000000000000000000900463ffffffff16806360fe47b1146044575b600080fd5b348015604f57600080fd5b50606c60048036038101908080359060200190929190505050606e565b005b80600081905550505600a165627a7a723058204ed906444cc4c9aabd183c52b2d486dfc5dea9801260c337185dad20e11f811b0029

還可以得到對應的字節碼(OpCode):

PUSH1 0x80 PUSH1 0x40 MSTORE CALLVALUE DUP1 ISZERO PUSH2 0x10 JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x40 MLOAD PUSH1 0x20 DUP1 PUSH2 0xED DUP4 CODECOPY DUP2 ADD DUP1 PUSH1 0x40 MSTORE DUP2 ADD SWAP1 DUP1 DUP1 MLOAD SWAP1 PUSH1 0x20 ADD SWAP1 SWAP3 SWAP2 SWAP1 POP POP POP DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP PUSH1 0xA4 DUP1 PUSH2 0x49 PUSH1 0x0 CODECOPY PUSH1 0x0 RETURN STOP PUSH1 0x80 PUSH1 0x40 MSTORE PUSH1 0x4 CALLDATASIZE LT PUSH1 0x3F JUMPI PUSH1 0x0 CALLDATALOAD PUSH29 0x100000000000000000000000000000000000000000000000000000000 SWAP1 DIV PUSH4 0xFFFFFFFF AND DUP1 PUSH4 0x60FE47B1 EQ PUSH1 0x44 JUMPI JUMPDEST PUSH1 0x0 DUP1 REVERT JUMPDEST CALLVALUE DUP1 ISZERO PUSH1 0x4F JUMPI PUSH1 0x0 DUP1 REVERT JUMPDEST POP PUSH1 0x6C PUSH1 0x4 DUP1 CALLDATASIZE SUB DUP2 ADD SWAP1 DUP1 DUP1 CALLDATALOAD SWAP1 PUSH1 0x20 ADD SWAP1 SWAP3 SWAP2 SWAP1 POP POP POP PUSH1 0x6E JUMP JUMPDEST STOP JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP LOG1 PUSH6 0x627A7A723058 KECCAK256 0x4e 0xd9 MOD DIFFICULTY 0x4c 0xc4 0xc9 0xaa 0xbd XOR EXTCODECOPY MSTORE 0xb2 0xd4 DUP7 0xdf 0xc5 0xde 0xa9 DUP1 SLT PUSH1 0xC3 CALLDATACOPY XOR 0x5d 0xad KECCAK256 0xe1 0x1f DUP2 SHL STOP 0x29 

其中下述指令集為set函數對應的代碼,後面會解釋set函數如何運行。

JUMPDEST DUP1 PUSH1 0x0 DUP2 SWAP1 SSTORE POP POP JUMP STOP

部署

編譯完後,即可在remix上對代碼進行部署,構造參數傳入0x123:

智能合約編寫之Solidity運行原理 2

部署成功後,可得到一條交易回執:

智能合約編寫之Solidity運行原理 3

點開input,可以看到具體的交易輸入數據:

智能合約編寫之Solidity運行原理 4

上面這段數據中,標黃的部分正好是前文中的合約二進制;而標紫的部分,則對應了傳入的構造參數0x123。

這些都表明,合約部署以交易作為介質。結合區塊鏈交易知識,我們可以還原出整個部署過程:

  • 客戶端將部署請求(合約二進制,構造參數)作為交易的輸入數據,以此構造出一筆交易
  • 交易經過rlp編碼,然後由發送者進行私鑰簽名
  • 已簽名的交易被推送到區塊鏈上的節點
  • 區塊鏈節點驗證交易後,存入交易池
  • 輪到該節點出塊時,打包交易構建區塊,廣播給其他節點
  • 其他節點驗證區塊並取得共識。不同區塊鏈可能採用不同共識算法,FISCO BCOS中採用PBFT取得共識,這要求經曆三階段提交(pre-prepare,prepare, commit)
  • 節點執行交易,結果就是智能合約Demo被創建,狀態字段_state的存儲空間被分配,並被初始化為0x123

執行

根據是否帶有修飾符view,我們可將函數分為兩類:調用與交易。由於在編譯期就確定了調用不會引起合約狀態的變更,故對於這類函數調用,節點直接提供查詢即可,無需與其他區塊鏈節點確認。而由於交易可能引起狀態變更,故會在網絡間確認。

下面將以用戶調用了set(0x10)為假設,看看具體的運行過程。

首先,函數set沒有配置view/pure修飾符,這意味著其可能更改合約狀態。所以這個調用信息會被放入一筆交易,經由交易編碼、交易簽名、交易推送、交易池緩存、打包出塊、網絡共識等過程,最終被交由各節點的EVM執行。

在EVM中,由SSTORE字節碼將參數0xa存儲到合約字段_state中。該字節碼先從棧上拿到狀態字段_state的地址與新值0xa,隨後完成實際存儲。

下圖展示了運行過程:

智能合約編寫之Solidity運行原理 5

這裡僅粗略介紹了set(0xa)是如何運行,下節將進一步展開介紹EVM的工作機制以及數據存儲機制。

銷毀

由於合約上鍊後就無法篡改,所以合約生命可持續到底層區塊鏈被徹底關停。若要手動銷毀合約,可通過字節碼selfdestruct。銷毀合約也需要進行交易確認,在此不多作贅述。

EVM原理

在前文中,我們介紹了Solidity程序的運行原理。經過交易確認後,最終由EVM執行字節碼。對EVM,上文只是一筆帶過,這一節將具體介紹其工作機制。

運行原理

EVM是棧式虛擬機,其核心特徵就是所有操作數都會被存儲在棧上。下面我們將通過一段簡單的Solidity語句代碼看看其運行原理:

uint a = 1;
uint b = 2;
uint c = a + b;

這段代碼經過編譯後,得到的字節碼如下:

PUSH1 0x1
PUSH1 0x2
ADD

為了讀者更好了解其概念,這裡精簡為上述3條語句,但實際的字節碼可能更複雜,且會摻雜SWAP和DUP之類的語句。

我們可以看到,在上述代碼中,包含兩個指令:PUSH1和ADD,它們的含義如下:

  • PUSH1:將數據壓入棧頂。
  • ADD:POP兩個棧頂元素,將它們相加,並壓回棧頂。

這裡用半動畫的方式解釋其執行過程。下圖中,sp表示棧頂指針,pc表示程序計數器。當執行完push1 0x1後,pc和sp均往下移:

智能合約編寫之Solidity運行原理 6

類似地,執行push1 0x2後,pc和sp狀態如下:

智能合約編寫之Solidity運行原理 7

最後,當add執行完後,棧頂的兩個操作數都被彈出作為add指令的輸入,兩者的和則會被壓入棧:

智能合約編寫之Solidity運行原理 8

存儲探究

在開發過程中,我們常會遇到令人迷惑的memory修飾符;閱讀開源代碼時,也會看到各種直接針對內存進行的assembly操作。不了解存儲機制的開發者遇到這些情況就會一頭霧水,所以,這節將探究EVM的存儲原理。

在前文《智能合約編寫之Solidity的基礎特性》中我們介紹過,一段Solidity代碼,通常會涉及到局部變量、合約狀態變量。

而這些變量的存儲方式存在差別,下面代碼表明了變量與存儲方式之間的關係。

contract Demo{
    //状态存储
    uint private _state;

    function set(uint state) public {
        //栈存储
        uint i = 0;
        //内存存储
        string memory str = "aaa";
    }
}

棧用於存儲字節碼指令的操作數。在Solidity中,局部變量若是整型、定長字節數組等類型,就會隨著指令的運行入棧、出棧。

例如,在下面這條簡單的語句中,變量值1會被讀出,通過PUSH操作壓入棧頂:

uint i = 1;

對於這類變量,無法強行改變它們的存儲方式,如果在它們之前放置memory修飾符,編譯會報錯。

內存

內存類似java中的堆,它用於儲存”對象”。在Solidity編程中,如果一個局部變量屬於變長字節數組、字符串、結構體等類型,其通常會被memory修飾符修飾,以表明存儲在內存中。

本節中,我們將以字符串為例,分析內存如何存儲這些對象。

1. 對象存儲結構

下面將用assembly語句對複雜對象的存儲方式進行分析。

assembly語句用於調用字節碼操作。 mload指令將被用於對這些字節碼進行調用。 mload§表示從地址p讀取32字節的數據。開發者可將對像變量看作指針直接傳入mload。

在下面代碼中,經過mload調用,data變量保存了字符串str在內存中的前32字節。

string memory str = "aaa";
bytes32 data;
assembly{
    data := mload(str)
}  

掌握mload,即可用此分析string變量是如何存儲的。下面的代碼將揭示字符串數據的存儲方式:

function strStorage() public view returns(bytes32, bytes32){
    string memory str = "你好";
    bytes32 data;
    bytes32 data2;
    assembly{
        data := mload(str)
        data2 := mload(add(str, 0x20))
    }   
    return (data, data2);
}

data變量表示str的0~31字節,data2表示str的32~63字節。運行strStorage函數的結果如下:

0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000006
1: bytes32: 0xe4bda0e5a5bd0000000000000000000000000000000000000000000000000000

可以看到,第一個數據字得到的值為6,正好是字符串”你好”經UTF-8編碼後的字節數。第二個數據字則保存的是”你好”本身的UTF-8編碼。

熟練掌握了字符串的存儲格式之後,我們就可以運用assembly修改、拷貝、拼接字符串。讀者可搜索Solidity的字符串庫,了解如何實現string的concat。

2. 內存分配方式

既然內存用於存儲對象,就必然涉及到內存分配方式。

memory的分配方式非常簡單,就是順序分配。下面我們將分配兩個對象,並查看它們的地址:

function memAlloc() public view returns(bytes32, bytes32){
    string memory str = "aaa";
    string memory str2 = "bbb";
    bytes32 p1;
    bytes32 p2;
    assembly{
        p1 := str
        p2 := str2
    }   
    return (p1, p2);
}

運行此函數後,返回結果將包含兩個數據字:

0: bytes32: 0x0000000000000000000000000000000000000000000000000000000000000080
1: bytes32: 0x00000000000000000000000000000000000000000000000000000000000000c0

這說明,第一個字符串str1的起始地址是0x80,第二個字符串str2的起始地址是0xc0,之間64字節,正好是str1本身佔據的空間。此時的內存佈局如下,其中一格表示32字節(一個數據字,EVM採用32字節作為一個數據字,而非4字節):

智能合約編寫之Solidity運行原理 9

  • 0x40~0x60:空閒指針,保存可用地址,本例中是0x100,說明新的對象將從0x100處分配。可以用mload(0x40)獲取到新對象的分配地址。
  • 0x80~0xc0:對象分配的起始地址。這里分配了字符串aaa
  • 0xc0~0x100:分配了字符串bbb
  • 0x100~…:因為是順序分配,新的對象將會分配到這裡。

狀態存儲

顧名思義,狀態存儲用於存儲合約的狀態字段。

從模型而言,存儲由多個32字節的存儲槽構成。在前文中,我們介紹了Demo合約的set函數,裡面0x0表示的是狀態變量_state的存儲槽。所有固定長度變量會依序放到這組存儲槽中。

對於mapping和數組,存儲會更複雜,其自身會佔據1槽,所包含數據則會按相應規則佔據其他槽,比如mapping中,數據項的存儲槽位由鍵值k、mapping自身槽位p經keccak計算得來。

從實現而言,不同的鏈可能採用不同實現,比較經典的是以太坊所採用的MPT樹。由於MPT樹性能、擴展性等問題,FISCO BCOS放棄了這一結構,而採用了分佈式存儲,通過rocksdb或mysql來存儲狀態數據,使存儲的性能、可擴展性得到提高。

結 語

本文介紹了Solidity的運行原理,運行原理總結如下。

首先,Solidity源碼會被編譯為字節碼,部署時,字節碼會以交易為載體在網絡間確認,並在節點上形成合約;合約函數調用,如果是交易類型,會經過網絡確認,最終由EVM執行。

EVM是棧式虛擬機,它會讀取合約的字節碼並執行。

在執行過程中,會與棧、內存、合約存儲進行交互。其中,棧用於存儲普通的局部變量,這些局部變量就是字節碼的操作數;內存用於存儲對象,採用length+body進行存儲,順序分配方式進行內存分配;狀態存儲用於存儲狀態變量。

理解Solidity的運行方式及其背後原理,是成為Solidity編程高手必經之路。

關於作者

儲雨知,FISCO BCOS 核心開發者。