LapTrinhBlockchain

Chia sẻ kiến thức về Lập Trình Blockchain

Kiến thức Blockchain, Kiến thức lập trình, Lập trình Blockchain, Lập trình Smart Contract, Nâng cao Kiến thức

Lập trình Smart Contract sử dụng Assembly trên Solidity

Lập trình Smart Contract sử dụng Assembly trên Solidity EVM

Lập trình Smart Contract sử dụng Assembly trên Solidity EVM

Chia sẻ bài viết
5
(8)

Ai cũng biết bộ môn lập trình tốn nhiều não nhất của mọi lập trình viên là lập trình bằng Assembly (Hợp ngữ – Cũng được gọi là Assembler language). Nôm na thì bất cứ ngôn ngữ bậc cao nào như C , Go, Java,… được sinh ra để người dễ hiểu và dễ code , tuy nhiên chúng đều sẽ được biên dịch ra ngôn ngữ bậc thấp để máy có thể hiểu và thực thi, đó là assembly.

Ethereum xây dựng virtual machine (EVM : Ethereum virtual machine) cũng có bộ lệnh của riêng nó, chi tiết bộ lệnh có thể xem tại Ethereum Evm Opcodes . Solidity là ngôn ngữ bậc cao để viết Smart Contract, tuy nhiên Solidity vẫn hỗ trợ viết mã bằng Assembly. Ngôn ngữ nhúng Assembly trên Solidity được gọi là Yul, vì thế nếu muốn tìm hiểu sâu hơn thì bạn nên xem tài liệu về Yul.

Tại sao phải sử dụng Assembly trong Solidity?

Tại sao phải sử dụng một ngôn ngữ khó hiểu (Assembly) trong một ngôn ngữ dễ hiểu (Solidity)? Thực tế thì Assembly mang lại một số lợi ích đặc biệt khi sử dụng:

Điều khiển chi tiết

Assembly cho phép bạn thực thi một số logic có thể không thực hiện được chỉ với Solidity. Ví dụ: trỏ đến một một vùng nhớ (memory slot) xác định nào đó

Điều khiển chi tiết đặc biệt hữu ích khi chúng ta tự viết một library, vì chúng sẽ được sử dụng lại, và vì vậy chúng cần phải được tối ưu. Hãy xem các thư viện Solidity bên dưới. Bạn sẽ ngạc nhiên khi thấy họ tin tưởng vào việc sử dụng Assembly.

Giảm phí Gas

Một trong những lợi ích chính của sử dụng assembly trong Solidity là tiết kiệm GAS. Cùng thử so sanh gas cost giữa Solidity và Assembly bằng cách tạo một hàm cộng 2 giá trị x và y và trả lại kết quả.

// Gas: 254
function addAssembly(uint x, uint y) public pure returns (uint) {
    assembly {
        let result := add(x, y)
        mstore(0x0, result)
        return(0x0, 32)
    }
}

// Gas: 346
function addSolidity(uint x, uint y) public pure returns (uint) {
    return x + y;
}

Một ví dụ khác là sử dụng hàm để hash:

// Gas: 313
function solidityHash(uint256 a, uint256 b) public view {
    //unoptimized
    keccak256(abi.encodePacked(a, b));
}

// Gas: 231
function assemblyHash(uint256 a, uint256 b) public view {
    //optimized
    assembly {
        mstore(0x00, a)
        mstore(0x20, b)
        let hashedVal := keccak256(0x00, 0x40)
    }
} 

Bằng cách sử dụng Assembly, chúng ta có quyền truy cập trực tiếp vào ngăn xếp và có thể tối ưu hóa mã của mình để tiết kiệm bộ nhớ hơn, tiết kiệm lượng gas cần thiết để thực hiện giao dịch. Điều này cuối cùng làm giảm chi phí giao dịch cho người dùng của chúng ta. Khi OpenSea phát hành bản nâng cấp Seaport, họ đã báo cáo rằng việc sử dụng Assembly giúp giảm 35% phí gas, tiết kiệm cho người dùng ước tính khoảng 460 triệu USD mỗi năm.

Một số lợi ích khác

Có một vài thứ khác có thể làm được với assembly mà không thể làm được với Solidity:

Hiểu về EVM và Stack

Bits và Bytes

Khi sử dụng Assembly, bạn sẽ tiếp xúc với Stack (Ngăn xếp) và quản lý bộ nhớ dữ liệu cấp thấp. Điều quan trọng là phải hiểu rõ về cách thức hoạt động của nó.

Tất cả dữ liệu bao gồm 10. Chúng được gọi là bit, ví dụ: biến uint256 sẽ có 256 số 1 và 0 duy nhất để biểu thị một số điểm cố định. 8 bit = 1 byte, vì vậy, một byte sẽ trông giống như thế này đối với chữ cái A: 01000001. Máy ảo của Ethereum được xây dựng xung quanh các khe 32 bytes, tương đương 32*8= 256 bit đó là lý do tại sao biến uint256 được sử dụng rộng rãi trong Solidity.

Hợp đồng của chúng ta càng yêu cầu nhiều khe lưu trữ và bộ nhớ thì chi phí gas sẽ càng đắt. Một ví dụ thực tế có thể tiết kiệm xăng là giữ các chuỗi câu lệnh yêu cầu ở dưới 32 ký tự để tiết kiệm việc chiếm nhiều slot.

Cơ bản về EVM và Stack

EVM (Ethereum Virtual Machine) cũng có tập các chỉ lệnh riêng. Tính đến 2020-01-31 thì EVM có chứa 144 chỉ lệnh Opcodes, chi tiết xem tại: EVM Opcodes. Các chỉ lệnh này được đóng gói lại trong Solidity, một ngôn ngữ cấp cao để các nhà phát triển dễ dàng hơn trong lập trình Smart Contract. Nhưng Solidity cũng cho phép nhúng Assembly bên trong để bạn có thể tận dụng sức mạnh của Assembly.

Bạn cũng cần biết rằng EVM là một Stack Machine (Một máy xử lý theo cơ chế Stack) nên dữ liệu trên EVM được xử lý trên một Stack (Ngăn xếp). Trong đó Stack là một cấu trúc dữ liệu nơi bạn chỉ có thể THÊM (PUSH) và XÓA (POP) các giá trị ở đầu Stack. Tức là chúng được xử lý theo thứ tự LIFO (Last in, First out – Vào sau, Ra trước). Bạn muốn hiểu hơn về Stack Machine, bạn nên xem bài viết: Stack Machines: Fundamentals

Ethereum Virtual Machine

Kiểu dữ liệu giữa Assembly và Solidity

Trên Solidity có nhiều kiểu dữ liệu khác nhau, các kiểu dữ liệu này nhỏ hơn hoặc bằng 256 bit, ví dụ như: uint24, int24, uint160… Nhưng trên Assembly thì hầu hết các phép tính số học đều thao tác với 256 bit, để hiệu quả các bit cao hơn chỉ làm sạch tại điểm cần thiết, tức là ngay trước khi chúng được ghi vào bộ nhớ hoặc trước khi thực hiện so sánh. Điều này có nghĩa là nếu bạn truy cập một biến như vậy từ bên trong khối Assembly, trước tiên bạn có thể phải làm sạch các bit bậc cao hơn theo cách thủ công.

Quản lý bộ nhớ trên Solidity

Solidity quản lý bộ nhớ theo cách rất đơn giản: Có một “con trỏ trỏ tới vùng bộ nhớ trống” (free memory pointer) ở vị trí 0x40 trong bộ nhớ. Tức ở slot 0x40 chứa giá trị là địa chỉ của vùng nhớ “free memory“, tức là toàn bộ vùng nhớ sau địa chỉ này là vùng nhớ trống và có thể được cấp phát để sử dụng.

Chi tiết hơn thì 64 byte đầu tiên của bộ nhớ có thể được sử dụng làm “vùng bộ nhớ tạm” (scratch space) để phân bổ ngắn hạn. 32 byte sau “free memory pointer” (tức địa chỉ 0x60) có nghĩa là vĩnh viễn bằng 0 và được sử dụng làm giá trị ban đầu cho mảng bộ nhớ động trống. Điều này có nghĩa là bộ nhớ có thể cấp phát (allocatable memory) bắt đầu ở 0x80, là giá trị ban đầu của con trỏ bộ nhớ trống.

Bộ nhớ trên Evm Assembly

Như vậy, nếu bạn muốn cấp phát bộ nhớ, chỉ cần sử dụng bộ nhớ bắt đầu từ nơi con trỏ này trỏ tới và cập nhật nó cho phù hợp. Ví dụ chúng ta cần cấp phát 128 bytes dữ liệu thì vùng nhớ chúng ta cần sẽ là bắt đầu từ địa chỉ x (x=mem[0x40]) đến x+127. Sau khi chúng ta sử dụng vùng nhớ này thì chúng ta phải cập nhật lại “free memory” thành x+128, để vùng nhớ tiếp theo sẽ được cấp phát nếu cần. Nếu bạn không cập nhật lại “free memory”, thì lần cấp phát sau, vùng nhớ đã cấp phát trước đó sẽ được cấp phát lại, gây ra hiện tượng đè dữ liệu.

Không có gì đảm bảo rằng bộ nhớ chưa từng được sử dụng trước đó và do đó bạn không thể cho rằng nội dung của nó là 0 byte. Không có cơ chế tích hợp nào để giải phóng hoặc giải phóng bộ nhớ được phân bổ. Đây là đoạn mã lắp ráp có thể được sử dụng để cấp phát bộ nhớ:

function allocate(length) -> pos {
    pos := mload(0x40)
    mstore(0x40, add(pos, length))
}

Các phần tử trong mảng bộ nhớ trong Solidity luôn chiếm bội số của 32 byte (vâng, điều này thậm chí đúng với mảng byte[], nhưng không đúng với byte và chuỗi). Mảng bộ nhớ đa chiều (Multi-dimensional memory array) là con trỏ tới mảng bộ nhớ. Độ dài của mảng động được lưu trữ ở vị trí đầu tiên của mảng và theo sau là các phần tử mảng.

Mảng bộ nhớ - Memory array

Sử dụng Assembly trong Solidity

Có hai dạng sử dụng Assembly trong Solidity

Inline Assembly

Chúng ta sử dụng xen kẽ các lệnh Assembly với các lệnh Solidity. Điều này mang lại cho bạn khả năng kiểm soát chi tiết hơn, đặc biệt khi bạn cần tối ưu khi viết thư viện.

EVMstack machine nên thường khó xác định đúng vị trí ngăn xếp và cung cấp các đối số cho các opcode tại đúng điểm trên ngăn xếp. Việc nhúng Assembly trong Solidity giúp bạn thực hiện việc này. Việc nhúng Assembly là một cách để truy cập Máy ảo Ethereum ở mức độ thấp, điều này bỏ qua một số tính năng an toàn quan trọng và kiểm tra của Solidity. Vì vậy, bạn chỉ nên sử dụng nó cho những công việc cần đến nó và chỉ khi bạn tự tin sử dụng nó.

Một số tính năng khi sử dụng Inline Assembly:

// functional-style opcodes
mul(1, add(2, 3))

// assembly-local variables
let x := add(2, 3)  let y := mload(0x40)  x := add(x, y)

// access to external variables
function f(uint x) public { assembly { x := sub(x, 1) } }

// loops
for { let i := 0 } lt(i, x) { i := add(i, 1) } { y := mul(2, y) }

// if statements
if slt(x, 0) { x := sub(0, x) }

// switch statements
switch x case 0 { y := mul(x, 2) } default { y := 0 }

// function calls
function f(x) -> y { switch x case 0 { y := 1 } default { y := mul(x, f(sub(x, 1))) }   }

Cú pháp nhúng Assembly

Để viết Assembly trong Solidity, bạn sử dụng có pháp sau:

assembly {
    // Code assembly ở đây
    ...
}

Một số cú pháp sử dụng trong Inline Assembly:

// Comments giống Solidity
// //... or /*...*/

// Hằng số: literals
// Tối đa lên 32 kí tự
0x123
42
"abc"

// Gọi hàm opcode
add(1, mload(0))

// Khai báo biến
// Nếu biến không được khởi tạo trước sẽ mặc định là 0
let x := 7
let x := add(y, 3)
let x

// Mã định danh
// Sử dụng các biến cục bộ trong Assembly hoặc biến bên ngoài
add(3, x)
sstore(x_slot, 2)

// Phép gán
x := add(y, 3)

// Khối lồng nhau
{ let x := 3 { let y := add(x, 1) } }

Thực ra code bên trong khối assembly được viết dưới dạng ngôn ngữ Yul theo tài liệu của Solidity. Theo tài liệu của solidity thì các khối inline assembly không chia sẻ các namespace với nhau. Nghĩa là chúng ta không thể gọi biến được định nghĩa trong một khối assembly trong một khối assembly khác được .

assembly {
    let x := 2
}

assembly {
    let y := x // Error
}

// DeclarationError: identifier not found
// let y := x
// ^

Ví dụ sử dụng Inline Assembly

Ví dụ sau viết một thư viện để truy cập mã của hợp đồng khác và tải mã đó vào biến kiểu bytes. Điều này là không thể với Solidity thuần.

pragma solidity >=0.4.0 <0.6.0;

library GetCode {
    function at(address _addr) public view returns (bytes memory 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)
        }
    }
}

Sử dụng Assembly nhúng cũng có lợi trong trường hợp trình tối ưu hóa không tạo ra mã hiệu quả, ví dụ:

pragma solidity >=0.4.16 <0.6.0;

library VectorSum {
    // This function is less efficient because the optimizer currently fails to
    // remove the bounds checks in array access.
    function sumSolidity(uint[] memory _data) public pure 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[] memory _data) public pure returns (uint o_sum) {
        for (uint i = 0; i < _data.length; ++i) {
            assembly {
                o_sum := add(o_sum, mload(add(add(_data, 0x20), mul(i, 0x20))))
            }
        }
    }

    // Same as above, but accomplish the entire code within inline assembly.
    function sumPureAsm(uint[] memory _data) public pure returns (uint o_sum) {
        assembly {
           // Load the length (first 32 bytes)
           let len := mload(_data)

           // Skip over the length field.
           //
           // Keep temporary variable so it can be incremented in place.
           //
           // NOTE: incrementing _data would result in an unusable
           //       _data variable after this assembly block
           let data := add(_data, 0x20)

           // Iterate until the bound is not met.
           for
               { let end := add(data, mul(len, 0x20)) }
               lt(data, end)
               { data := add(data, 0x20) }
           {
               o_sum := add(o_sum, mload(data))
           }
        }
    }
}

Mã Opcodes

Dưới đây là các mã Opcode để bạn tham khảo. Bạn cần lưu ý một số vấn đề sau:

  • Nếu một opcode lấy các đối số (luôn ở đầu ngăn xếp), chúng sẽ được đưa vào trong ngoặc đơn. Lưu ý rằng thứ tự của các đối số có thể được đảo ngược theo kiểu phi chức năng (được giải thích bên dưới).
  • Các mã được đánh dấu bằng không trả về kết quả. Những mã được đánh dấu * là đặc biệt và tất cả các mã khác đẩy giá trị trả về của chúng vào ngăn xếp.
  • Các mã được đánh dấu bằng F, H, B hoặc C lần lượt xuất hiện kể từ Frontier, Homestead, Byzantium hoặc Constantinople.
  • mem[a…b) biểu thị các byte bộ nhớ bắt đầu từ vị trí a đến nhưng không bao gồm vị trí b
  • storage[p] biểu thị nội dung lưu trữ tại vị trí p.
  • Các opcodes pushijumpdest không thể được sử dụng trực tiếp.

Chi tiết hơn bạn có thể xem tại: https://ethervm.io

Instruction    Explanation
stopFstop execution, identical to return(0,0)
add(x, y)Fx + y
sub(x, y) Fx – y
mul(x, y) Fx * y
div(x, y) Fx / y
sdiv(x, y) Fx / y, for signed numbers in two’s complement
mod(x, y) Fx % y
smod(x, y) Fx % y, for signed numbers in two’s complement
exp(x, y) Fx to the power of y
not(x) F~x, every bit of x is negated
lt(x, y) F1 if x < y, 0 otherwise
gt(x, y) F1 if x > y, 0 otherwise
slt(x, y) F1 if x < y, 0 otherwise, for signed numbers in two’s complement
sgt(x, y) F1 if x > y, 0 otherwise, for signed numbers in two’s complement
eq(x, y) F1 if x == y, 0 otherwise
iszero(x) F1 if x == 0, 0 otherwise
and(x, y) Fbitwise and of x and y
or(x, y) Fbitwise or of x and y
xor(x, y) Fbitwise xor of x and y
byte(n, x) Fnth byte of x, where the most significant byte is the 0th byte
shl(x, y) Clogical shift left y by x bits
shr(x, y) Clogical shift right y by x bits
sar(x, y) Carithmetic shift right y by x bits
addmod(x, y, m) F(x + y) % m with arbitrary precision arithmetic
mulmod(x, y, m) F(x * y) % m with arbitrary precision arithmetic
signextend(i, x) Fsign extend from (i*8+7)th bit counting from least significant
keccak256(p, n) Fkeccak(mem[p…(p+n)))
jump(label)Fjump to label / code position
jumpi(label, cond)Fjump to label if cond is nonzero
pc Fcurrent position in code
pop(x)Fremove the element pushed by x
dup1 … dup16 Fcopy nth stack slot to the top (counting from top)
swap1 … swap16*Fswap topmost and nth stack slot below it
mload(p) Fmem[p…(p+32))
mstore(p, v)Fmem[p…(p+32)) := v
mstore8(p, v)Fmem[p] := v & 0xff (only modifies a single byte)
sload(p) Fstorage[p]
sstore(p, v)Fstorage[p] := v
msize Fsize of memory, i.e. largest accessed memory index
gas Fgas still available to execution
address Faddress of the current contract / execution context
balance(a) Fwei balance at address a
caller Fcall sender (excluding delegatecall)
callvalue Fwei sent together with the current call
calldataload(p) Fcall data starting from position p (32 bytes)
calldatasize Fsize of call data in bytes
calldatacopy(t, f, s)Fcopy s bytes from calldata at position f to mem at position t
codesize Fsize of the code of the current contract / execution context
codecopy(t, f, s)Fcopy s bytes from code at position f to mem at position t
extcodesize(a) Fsize of the code at address a
extcodecopy(a, t, f, s)Flike codecopy(t, f, s) but take code at address a
returndatasize Bsize of the last returndata
returndatacopy(t, f, s)Bcopy s bytes from returndata at position f to mem at position t
extcodehash(a) Ccode hash of address a
create(v, p, n) Fcreate new contract with code mem[p…(p+n)) and send v wei and return the new address
create2(v, p, n, s) Ccreate new contract with code mem[p…(p+n)) at address keccak256(0xff . this . s . keccak256(mem[p…(p+n))) and send v wei and return the new address, where 0xff is a 8 byte value, this is the current contract’s address as a 20 byte value and s is a big-endian 256-bit value
call(g, a, v, in, insize, out, outsize) Fcall contract at address a with input mem[in…(in+insize)) providing g gas and v wei and output area mem[out…(out+outsize)) returning 0 on error (eg. out of gas) and 1 on success
callcode(g, a, v, in, insize, out, outsize) Fidentical to call but only use the code from a and stay in the context of the current contract otherwise
delegatecall(g, a, in, insize, out, outsize) Hidentical to callcode but also keep caller and callvalue
staticcall(g, a, in, insize, out, outsize) Bidentical to call(g, a, 0, in, insize, out, outsize) but do not allow state modifications
return(p, s)Fend execution, return data mem[p…(p+s))
revert(p, s)Bend execution, revert state changes, return data mem[p…(p+s))
selfdestruct(a)Fend execution, destroy current contract and send funds to a
invalidFend execution with invalid instruction
log0(p, s)Flog without topics and data mem[p…(p+s))
log1(p, s, t1)Flog with topic t1 and data mem[p…(p+s))
log2(p, s, t1, t2)Flog with topics t1, t2 and data mem[p…(p+s))
log3(p, s, t1, t2, t3)Flog with topics t1, t2, t3 and data mem[p…(p+s))
log4(p, s, t1, t2, t3, t4)Flog with topics t1, t2, t3, t4 and data mem[p…(p+s))
origin Ftransaction sender
gasprice Fgas price of the transaction
blockhash(b) Fhash of block nr b – only for last 256 blocks excluding current
coinbase Fcurrent mining beneficiary
timestamp Ftimestamp of the current block in seconds since the epoch
number Fcurrent block number
difficulty Fdifficulty of the current block
gaslimit Fblock gas limit of the current block

Hằng số (Literals)

Bạn có thể sử dụng các hằng số nguyên bằng cách nhập chúng theo ký hiệu thập phân hoặc dạng hexa và lệnh PUSHi thích hợp sẽ tự động được tạo.

Đoạn mã sau đây tạo mã để cộng 2 và 3 dẫn đến 5 rồi tính toán AND theo bit với chuỗi “abc”. Giá trị cuối cùng được gán cho một biến cục bộ có tên là x. Các chuỗi được lưu trữ căn trái và không thể dài hơn 32 byte.

assembly { let x := and("abc", add(3, 2)) }

Cú pháp hàm “Functional Style”

Đối với một chuỗi các opcode, thường rất khó để biết các đối số thực sự của một số opcode nhất định là gì. Trong ví dụ sau, 3 được thêm vào nội dung trong bộ nhớ ở vị trí 0x80.

3 0x80 mload add 0x80 mstore

Khi sử dụng Assembly nhúng, chúng ta sử dụng cú pháp “Functional Style” như sau:

mstore(0x80, add(mload(0x80), 3))

Nếu bạn đọc mã từ phải sang trái, bạn sẽ nhận được chính xác cùng một chuỗi các hằng số và mã lệnh, nhưng sẽ rõ ràng hơn nhiều về vị trí kết thúc của các giá trị.

Nếu bạn quan tâm đến bố cục ngăn xếp chính xác, chỉ cần lưu ý rằng đối số cú pháp đầu tiên cho một hàm hoặc mã lệnh sẽ được đặt ở đầu ngăn xếp.

Truy cập vào các biến, hàm và thư viện bên ngoài

Bạn có thể truy cập các biến Solidity và các mã định danh khác bằng cách sử dụng tên của chúng. Đối với các biến được lưu trữ trong Memory, thao tác này sẽ đẩy địa chỉ chứ không phải giá trị vào ngăn xếp. Các biến được lưu trữ trong Storage, và chúng có thể không chiếm toàn bộ Slot (Khe lưu trữ), do đó, “địa chỉ” của chúng bao gồm một slot và một byte bù bên trong slot. Để truy xuất vị trí được chỉ định bởi biến x, bạn sử dụng x_slot và để truy xuất byte-offset bạn sử dụng x_offset.

Các biến cục bộ trong Solidity có sẵn để sử dụng trong Assembly. Ví dụ:

pragma solidity >=0.4.11 <0.6.0;

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

Nếu bạn truy cập các biến có độ dài nhỏ hơn 256 bit (ví dụ: uint64, address, byte16 hoặc byte), bạn không thể đưa ra bất kỳ giả định nào về các bit không nằm trong mã hóa của loại đó. Đặc biệt, đừng cho rằng chúng bằng 0. Để an toàn, hãy luôn xóa dữ liệu đúng cách trước khi bạn sử dụng dữ liệu đó trong bối cảnh điều này quan trọng:

uint32 x = f();
assembly {
    x := and(x, 0xffffffff)
    /* now use x */
}

Để xóa các kiểu có dấu, bạn có thể sử dụng opcode signextend.

Nhãn (Labels)

Trong Assembly, nhãn là tên tượng trưng được gán cho các vị trí bộ nhớ cụ thể trong chương trình của bạn. Chúng hoạt động giống như dấu trang, giúp mã của bạn dễ đọc, dễ hiểu và sửa đổi hơn. Bản thân chúng không sử dụng bất kỳ mã máy nào mà trình biên dịch mã sẽ thay thế chúng bằng địa chỉ thực tế của chúng trong quá trình lắp ráp.

Từ Solidity phiên bản 0.5.0, Labels không được hỗ trợ. Thay vào đó hãy sử dụng các Hàm (Function), Vòng lặp, câu lệnh if hoặc switch.

Khai báo biến cục bộ trong Assembly

Bạn có thể sử dụng từ khóa let để khai báo các biến chỉ hiển thị trong khối Assembly hiện tại. Lệnh let sẽ tạo một slot mới trong vùng ngăn xếp cho biến và tự động xóa khi đến cuối khối. Bạn cần cung cấp giá trị ban đầu cho biến, có thể chỉ bằng 0 nhưng nó cũng có thể là một biểu thức kiểu hàm phức tạp.

pragma solidity >=0.4.16 <0.6.0;

contract C {
    function f(uint x) public view 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
    }
}

Phép gán

Có thể gán các biến cục bộ và biến hàm cục bộ. Hãy cẩn thận rằng khi bạn gán cho các biến trỏ tới bộ nhớ hoặc bộ lưu trữ, bạn sẽ chỉ thay đổi con trỏ chứ không thay đổi dữ liệu.

Các biến chỉ có thể được gán các biểu thức mang lại chính xác một giá trị. Nếu bạn muốn gán các giá trị được trả về từ một hàm có nhiều tham số trả về, bạn phải cung cấp nhiều biến.

{
    let v := 0
    let g := add(v, 2)
    function f() -> a, b { }
    let c, d := f()
}

Lệnh rẽ nhánh If

Câu lệnh if có thể được sử dụng để thực thi mã có điều kiện. Nếu không có phần “else”, hãy cân nhắc sử dụng lệnh “switch” (xem bên dưới) nếu bạn cần nhiều lựa chọn thay thế. Các dấu ngoặc nhọn cho phần thân là cần thiết.

{
    if eq(value, 0) { revert(0, 0) }
}

Lệnh switch

Bạn có thể sử dụng câu lệnh switch như một phiên bản cơ bản của “if/else”. Nó lấy giá trị của một biểu thức và so sánh nó với một số hằng số. Nhánh tương ứng với hằng số phù hợp sẽ được chọn. Trái ngược với hành vi dễ xảy ra lỗi của một số ngôn ngữ lập trình, luồng điều khiển không tiếp tục từ trường hợp này sang trường hợp khác. Có thể có trường hợp fallback hoặc trường hợp mặc định được gọi là default. Danh sách các trường hợp không yêu cầu dấu ngoặc nhọn nhưng phần thân của trường hợp lại yêu cầu chúng.

{
    let x := 0
    switch calldataload(4)
    case 0 {
        x := calldataload(0x24)
    }
    default {
        x := calldataload(0x44)
    }
    sstore(0, div(x, 2))
}

Vòng lặp

Assembly hỗ trợ một vòng lặp kiểu for đơn giản. Vòng lặp kiểu for có phần header bao gồm đoạn khởi tạo, đoạn điều kiện và đoạn lặp lại. Điều kiện phải là một biểu thức kiểu hàm, trong khi hai biểu thức còn lại là các khối. Nếu phần khởi tạo khai báo bất kỳ biến nào, phạm vi của các biến này sẽ được mở rộng vào phần thân (bao gồm cả phần điều kiện và phần sau lặp).

Ví dụ sau tính tổng của một vùng trong bộ nhớ.

{
    let x := 0
    for { let i := 0 } lt(i, 0x100) { i := add(i, 0x20) } {
        x := add(x, mload(i))
    }
}

Vòng lặp for cũng có thể được viết sao cho chúng hoạt động giống như vòng lặp while. Đơn giản chỉ cần để trống phần khởi tạo và phần sau lặp.

{
    let x := 0
    let i := 0
    for { } lt(i, 0x100) { } {     // while(i < 0x100)
        x := add(x, mload(i))
        i := add(i, 0x20)
    }
}

Hàm trong Assembly

Assembly cho phép định nghĩa các hàm cấp thấp. Chúng lấy các đối số của chúng (và một PC trả về) từ ngăn xếp và cũng đưa kết quả vào ngăn xếp. Việc gọi một hàm trông giống như cách thực thi một opcode kiểu hàm.

Các hàm có thể được xác định ở bất cứ đâu và hiển thị trong khối mà chúng được khai báo. Bên trong một hàm, bạn không thể truy cập các biến cục bộ được xác định bên ngoài hàm đó. Không có tuyên bố trả lại rõ ràng.

Nếu bạn gọi một hàm trả về nhiều giá trị, bạn phải gán chúng cho một bộ bằng cách sử dụng
a, b := f(x)
hoặc
let a, b := f(x)

Ví dụ sau đây triển khai hàm lũy thừa sử dụng phép bình phương và nhân.

{
    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) }
        }
    }
}

Standalone Assembly

Assembly cũng có thể sử dụng độc lập một mình nó, và trên thực tế hay được sử dụng làm ngôn ngữ trung gian cho trình biên dịch Solidity. Trong trường hợp này, nó cần đảm bảo các điều kiện:

  • (1) Các chương trình được viết trong đó phải có thể đọc được, ngay cả khi mã được tạo bởi trình biên dịch từ Solidity.
  • (2) Bản dịch từ Assembly sang mã byte phải chứa càng ít “bất ngờ” càng tốt.
  • (3) Luồng điều khiển phải dễ phát hiện để giúp xác minh và tối ưu hóa.

Để đạt được mục tiêu (1) và (3), Assembly hỗ trợ các cấu trúc cấp cao như vòng lặp, câu lệnh if và switch, đóng gói hàm. Điều này giúp viết các chương trình Assembly mà không cần sử dụng các câu lệnh như SWAP, DUP, JUMP và JUMPI:

  • SWAP, DUP: Hai lệnh này làm xáo trộn luồng dữ liệu
  • JUMP, JUMPI: Hai lệnh này làm xáo trộn luồng điều khiển

Hơn nữa, các câu lệnh có dạng functional-style như mul(add(x, y), 7) được ưu tiên hơn các câu lệnh opcode thuần túy như 7 y x add mul vì ở dạng functional-style, việc xem toán hạng nào được sử dụng cho opcode nào sẽ dễ dàng hơn nhiều.

Mục tiêu (2) đặt được bằng cách biên dịch các cấu trúc cấp cao hơn thành mã byte một cách rất thường xuyên. Hoạt động phi cục bộ duy nhất được trình biên dịch mã thực hiện là tra cứu tên của các mã định danh do người dùng xác định (hàm, biến, …), tuân theo các quy tắc xác định phạm vi rất đơn giản và thông thường cũng như dọn sạch các biến cục bộ khỏi ngăn xếp. Các quy tắc gồm:

  • Một mã định danh được khai báo (nhãn, biến, hàm, tập hợp) chỉ hiển thị trong khối nơi nó được khai báo (bao gồm các khối lồng nhau bên trong khối hiện tại).
  • Việc truy cập các biến cục bộ qua ranh giới hàm là không hợp pháp, ngay cả khi chúng nằm trong phạm vi. Bóng tối không được phép.
  • Các biến cục bộ không thể được truy cập trước khi chúng được khai báo, nhưng các hàm (Function) và Tập hợp (Assemblie) thì có thể. Assemblie là các khối đặc biệt được sử dụng cho trả lại mã thực thi hoặc tạo hợp đồng.
  • Nếu luồng điều khiển đi qua cuối khối, các lệnh pop sẽ được chèn khớp với số lượng biến cục bộ được khai báo trong khối đó. Bất cứ khi nào một biến cục bộ được tham chiếu, trình tạo mã cần biết vị trí tương đối hiện tại của nó trong ngăn xếp và do đó nó cần theo dõi cái gọi là chiều cao ngăn xếp hiện tại. Vì tất cả các biến cục bộ được loại bỏ ở cuối khối, nên chiều cao ngăn xếp trước và sau khối phải giống nhau.
  • Sử dụng switch, for function, có thể viết mã phức tạp mà không cần sử dụng jump hoặc jumpi theo cách thủ công. Điều này giúp việc phân tích luồng điều khiển dễ dàng hơn nhiều, cho phép cải thiện việc xác minh và tối ưu hóa chính thức.
  • Hơn nữa, nếu cho phép nhảy thủ công thì việc tính toán chiều cao ngăn xếp khá phức tạp.

Ví dụ:

Giờ chúng ta sẽ tìm hiểu một ví dụ về quá trình biên dịch từ Solidity sang Assembly Chúng ta sẽ làm theo phần tổng hợp ví dụ từ Solidity đến lắp ráp. Dưới đây là đoạn mã viết bằng Solidity:

pragma solidity >=0.4.16 <0.6.0;

contract C {
    function f(uint x) public pure returns (uint y) {
        y = 1;
        for (uint i = 0; i < x; i++)
            y = 2 * y;
    }
}

Đoạn mã trên sẽ được biên dịch sang Assembly như sau:

{
    // store the "free memory pointer"
    mstore(0x40, 0x80)

    // function dispatcher
    switch div(calldataload(0), exp(2, 226))
    case 0xb3de648b {
        let r := f(calldataload(4))
        let ret := $allocate(0x20)
        mstore(ret, r)
        return(ret, 0x20)
    }
    default { revert(0, 0) }

    // memory allocator
    function $allocate(size) -> pos {
        pos := mload(0x40)
        mstore(0x40, add(pos, size))
    }

    // the contract function
    function f(x) -> y {
        y := 1
        for { let i := 0 } lt(i, x) { i := add(i, 1) } {
            y := mul(2, y)
        }
    }
}

Tìm hiểu cách lưu trữ dữ liệu một số đối tượng trên bộ nhớ

Để có thể sử dụng Assembly linh hoạt, thì chúng ta phải hiểu được các đối tượng được lưu trữ thế nào trong bộ nhớ, từ đó mới có thể dùng Assembly truy cập / thiết lập vào đúng ô nhớ cần thiết.

Cách lưu trữ đối tượng struct trong bộ nhớ

Tôi có cấu trúc dữ liệu như dưới, như tôi được biết Struct là cấu trúc dữ liệu nén chặt, lúc đầu tôi nghĩ cấu trúc này được lưu trữ 10 slot trên memory (Trường pool, zeroForOne, version sẽ lưu trữ trong 1 slot). Nhưng thực tế không phải, trên Memory, cấu trúc này sử dụng 12 slots, tức là mỗi field sẽ được lưu trữ trong 1 slot. Còn 10 slot có lẽ chỉ đúng khi lưu vào Storage.

struct PoolData {
    uint256 mappingIndex;
    address pool;
    uint8 zeroForOne;
    uint8 version;
    uint256 fee;
    uint256 feeDenominator;
    uint256 reserve0;
    uint256 reserve1;
    uint256 liquidity;
    uint256 sqrtPriceX96;
    uint256 localReserve0;
    uint256 localReserve1;
}

Vậy làm sao tôi có thể biết được điều này? Tôi đã viết một contract StructMemData.sol khỏi tạo struct và lấy dữ liệu struct ra, qua đó tôi mới biết được cách lưu trữ dữ liệu:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

contract StructMemData {
    struct PoolData {
        uint256 mappingIndex;
        address pool;
        uint8 zeroForOne;
        uint8 version;
        uint256 fee;
        uint256 feeDenominator;
        uint256 reserve0;
        uint256 reserve1;
        uint256 liquidity;
        uint256 sqrtPriceX96;
        uint256 localReserve0;
        uint256 localReserve1;
    }

    function getStruct01() public pure returns(PoolData memory data) {
        data.mappingIndex = 10;
        data.pool = 0x406AB5033423Dcb6391Ac9eEEad73294FA82Cfbc;
        data.zeroForOne = 1;
        data.version = 1;
        data.fee = 600;
        data.feeDenominator = 1000000;
        data.reserve0 = 0;
        data.reserve1 = 0;
        data.liquidity = 0;
        data.sqrtPriceX96 = 0;
        data.localReserve0 = 0;
        data.localReserve1 = 0;
    }

    function getStruct02() public pure returns(bytes memory data) {
        PoolData memory item = getStruct01();
        uint slotSize = 12;
        uint dataSize = slotSize * 32;
        assembly {
            data := mload(0x40)
            mstore(data, dataSize)
            mstore(0x40, add(add(data, dataSize), 0x20))

            let pData := add(data, 0x20)
            let pTemp := item
            for { let i:=0 } lt(i, slotSize) { i := add(i, 1) } {
                mstore(pData, mload(pTemp))
                pData := add(pData, 0x20)
                pTemp := add(pTemp, 0x20)
            }
        }
    }
}

Tôi đã triển khai trên Sepolia: 0x86eD693C181AE6f29D3F8ab2F4E0BDf9e2e8f494. Gọi hàm getStruct01() sẽ nhận được dữ liệu dạng Struct, gọi hàm getStruct02() sẽ nhận được dữ liệu dạng bytes, tôi tách dữ liệu thành từng dòng, mỗi dòng tương đương 1 slot, bạn kiểm tra sẽ thấy mỗi trường dữ liệu trong 1 slot và theo đúng thứ tự các trường dữ liệu:

000000000000000000000000000000000000000000000000000000000000000a  => mappingIndex=10
000000000000000000000000406ab5033423dcb6391ac9eeead73294fa82cfbc  => pool = 0x406AB5033423Dcb6391Ac9eEEad73294FA82Cfbc
0000000000000000000000000000000000000000000000000000000000000001  => zeroForOne = 1
0000000000000000000000000000000000000000000000000000000000000001  => version = 1
0000000000000000000000000000000000000000000000000000000000000258  => fee = 600
00000000000000000000000000000000000000000000000000000000000f4240  => feeDenominator = 1000000
0000000000000000000000000000000000000000000000000000000000000000  => reserve0 = 0
0000000000000000000000000000000000000000000000000000000000000000  => reserve1 = 0
0000000000000000000000000000000000000000000000000000000000000000  => liquidity = 0
0000000000000000000000000000000000000000000000000000000000000000  => sqrtPriceX96 = 0
0000000000000000000000000000000000000000000000000000000000000000  => localReserve0 = 0
0000000000000000000000000000000000000000000000000000000000000000  => localReserve1 = 0

Lưu trữ đối tượng mảng bytes trong bộ nhớ

Lưu trữ đối tượng mảng struct trong bộ nhớ

Mảng cấu trúc cũng khác so mảng thông thường:

  • Mảng bytes, uint,… thông thường thì slot đầu tiên chứa số phần tử trong mảng (Số byte trong mảng), còn sau là dữ liệu
  • Với mảng cấu trúc thì slot đầu tiên chứa số phần tử (Giả sử n), tiếp theo là phần header chứa n slot, tiếp theo là phần data.

Dưới đây là đoạn code tôi sử dụng để in ra dữ liệu trong memory của một mảng cấu trúc. Từ đó tôi mới biết được dữ liệu được lưu trữ như thế nào trong memory:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

contract ArrStructDataMem {
    struct InputData {
        uint256 mappingIndex;
        address pool;
        uint8 zeroToOne;
        uint8 version;
        uint256 fee;
        uint256 feeDenominator;
        uint256 reserve0;
        uint256 reserve1;
        uint256 liquidity;
        uint256 sqrtPriceX96;
        uint256 localReserve0;
        uint256 localReserve1;
    }

    function getArrayStruct01() public pure returns(InputData[] memory data) {
        data = new InputData[](3);

        // Item 1
        data[0].mappingIndex = 10;
        data[0].pool = 0x406AB5033423Dcb6391Ac9eEEad73294FA82Cfbc;
        data[0].zeroToOne = 1;
        data[0].version = 1;
        data[0].fee = 600;
        data[0].feeDenominator = 1000000;
        data[0].reserve0 = 0;
        data[0].reserve1 = 0;
        data[0].liquidity = 0;
        data[0].sqrtPriceX96 = 0;
        data[0].localReserve0 = 0;
        data[0].localReserve1 = 0;

        // Item 2
        data[1].mappingIndex = 12;
        data[1].pool = 0x9206D9d8F7F1B212A4183827D20De32AF3A23c59;
        data[1].zeroToOne = 0;
        data[1].version = 2;
        data[1].fee = 800;
        data[1].feeDenominator = 1000000;
        data[1].reserve0 = 600;
        data[1].reserve1 = 980;
        data[1].liquidity = 10005675;
        data[1].sqrtPriceX96 = 0;
        data[1].localReserve0 = 0;
        data[1].localReserve1 = 0;

        // Item 3
        data[2].mappingIndex = 12;
        data[2].pool = 0x9206D9d8F7F1B212A4183827D20De32AF3A23c59;
        data[2].zeroToOne = 0;
        data[2].version = 2;
        data[2].fee = 800;
        data[2].feeDenominator = 1000000;
        data[2].reserve0 = 600;
        data[2].reserve1 = 980;
        data[2].liquidity = 10005675;
        data[2].sqrtPriceX96 = 0;
        data[2].localReserve0 = 0;
        data[2].localReserve1 = 0;
    }

    function getArrayStruct02() public pure returns(bytes memory data) {
        InputData[] memory item = getArrayStruct01();
        uint slotSize = 12*item.length;
        uint dataSize = (slotSize +3 )* 32;
        assembly {
            data := mload(0x40)
            mstore(data, dataSize)
            mstore(0x40, add(add(data, dataSize), 0x20))

            let pData := add(data, 0x20)
            let pTemp := item
            for { let i:=0 } lt(i, slotSize) { i := add(i, 1) } {
                mstore(pData, mload(pTemp))
                pData := add(pData, 0x20)
                pTemp := add(pTemp, 0x20)
            }
        }
    }
}

Dữ liệu in ra với số lượng các phần tử khác nhau như sau. Phần data thì chứa dữ liệu của Struct, đặc biệt phần data có thể nằm rải rác ở các vùng nhớ khác nhau:

// Mảng 1 phần tử
0000000000000000000000000000000000000000000000000000000000000001  => 1
00000000000000000000000000000000000000000000000000000000000000c0  => 192 (32*6)

// Mảng 2 phần tử
0000000000000000000000000000000000000000000000000000000000000002  => 2
00000000000000000000000000000000000000000000000000000000000000e0  => 224 (32*7)
0000000000000000000000000000000000000000000000000000000000000260  => 608 (32*19) => 608 - 224 = 384

// Mảng 3 phần tử
0000000000000000000000000000000000000000000000000000000000000003  => 3
0000000000000000000000000000000000000000000000000000000000000100  => 256 (32*8)
0000000000000000000000000000000000000000000000000000000000000280  => 640 (32*20)
0000000000000000000000000000000000000000000000000000000000000400  => 1024 (32*32)

// Mảng 4 phần tử
0000000000000000000000000000000000000000000000000000000000000004 => 4
0000000000000000000000000000000000000000000000000000000000000120 => 288
00000000000000000000000000000000000000000000000000000000000002a0 => 672
0000000000000000000000000000000000000000000000000000000000000420 => 1056
00000000000000000000000000000000000000000000000000000000000005a0 => 1440

Qua dữ liệu thu được tôi phác thảo bộ nhớ lưu thông tin mảng cấu trúc dạng như ảnh dưới. Gồm 3 phần:

  • (I) NumOfItems: Vùng này chiếm 1 slot, chứa số phần tử trong mảng. Giả sử số phần tử mảng là n.
  • (II) Header: Vùng này chiếm n slot, mỗi slot sẽ chứa địa chỉ mà ở địa chỉ đó trong bộ nhớ lưu dữ liệu của Struct
  • (III) Data: Vùng chứa dữ liệu các phần tử, mỗi phần tử có kích thước bằng sizeof(struct). Vùng dữ liệu này không nhất thiết phải liên tiếp nhau mà được xác định thông qua vùng Header.
Memory of struct array in solidity

Code trong tệp ArrStructDataMem01.sol, sử dụng để in ra vùng bộ nhớ chứa mảng cấu trúc để dự đoán cách lưu trữ:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

contract ArrStructDataMem01 {
    struct InputData {
        uint256 mappingIndex;
        address pool;
        uint8 zeroToOne;
        uint8 version;
        uint256 fee;
        uint256 feeDenominator;
        uint256 reserve0;
        uint256 reserve1;
        uint256 liquidity;
        uint256 sqrtPriceX96;
        uint256 localReserve0;
        uint256 localReserve1;
    }

    function getArrayStruct01() public pure returns(InputData[] memory data) {
        data = new InputData[](1);

        // Item 1
        data[0].mappingIndex = 10;
        data[0].pool = 0x406AB5033423Dcb6391Ac9eEEad73294FA82Cfbc;
        data[0].zeroToOne = 1;
        data[0].version = 1;
        data[0].fee = 600;
        data[0].feeDenominator = 1000000;
        data[0].reserve0 = 0;
        data[0].reserve1 = 0;
        data[0].liquidity = 0;
        data[0].sqrtPriceX96 = 3900;
        data[0].localReserve0 = 7900;
        data[0].localReserve1 = 6800;
    }

    function getArrayStruct02() public pure returns(InputData[] memory data) {
        assembly {
            // Allocate memory
            data := mload(0x40)
            mstore(0x40, add(data, mul(14, 0x20)))

            // Store number of items
            mstore(data, 1)

            // Store header
            let pTmpData := add(data, 0x20)
            mstore(pTmpData, 192)

            // Store data
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 10)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 0x406AB5033423Dcb6391Ac9eEEad73294FA82Cfbc)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 1)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 1)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 600)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 1000000)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 0)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 0)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 0)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 3900)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 7900)
            pTmpData := add(pTmpData, 0x20)
            mstore(pTmpData, 6800)
        }
    }
}

Bits và Bytes

Khi sử dụng Assembly, bạn sẽ tiếp xúc với Stack (Ngăn xếp) và quản lý bộ nhớ dữ liệu cấp thấp. Điều quan trọng là phải hiểu rõ về cách thức hoạt động của nó.

Một số ví dụ khác sử dụng Assembly

Lưu trữ dữ liệu

Hãy bắt đầu với một ví dụ đơn giản trong đó chúng ta lưu trữ dữ liệu vào bộ lưu trữ và sau đó gọi lại dữ liệu đó bằng hai hàm.

contract StoringData {
    function setData(uint256 newValue) public {
        assembly {
           sstore(0, newValue)
        }
    }

    function getData() public view returns(uint256) {
        assembly {
            let v := sload(0)
            mstore(0x80, v)
            return(0x80, 32)
        }
    }
}

Hàm đầu tiên sử dụng một dòng Assembly duy nhất và mã opcode sstore để ghi biến newValue vào bộ lưu trữ. Hàm thứ hai sử dụng sload để gọi lại dữ liệu, tuy nhiên dữ liệu này không thể được trả về trực tiếp từ bộ lưu trữ, vì vậy trước tiên chúng ta cần ghi nó vào bộ nhớ bằng mã opcode mstore. Sau đó, chúng tôi trả về tham chiếu đến nơi chúng tôi đã lưu trữ dữ liệu đó trong bộ nhớ ở vị trí 0x80 và độ dài của dữ liệu là 32 byte.

Gửi ETH bằng Assembly

Ví dụ này là Contract chứa ETH và có hai chủ sở hữu. Mục tiêu ví dụ này để giới thiệu logic điều khiển dưới dạng các vòng lặp for và các câu lệnh if trong Assembly.

contract SendETH {
    address[2] owners = [0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2,0xdD870fA1b7C4700F2BD7f44238821C26f7392148];
    function withdrawETH(address _to, uint256 _amount) external payable {
        bool success;
        assembly {
            for { let i := 0 } lt(i, 2) { i := add(i, 1) } {
                let owner := sload(i)
                if eq(_to, owner) {
                    success := call(gas(), _to, _amount, 0, 0, 0, 0)
                } 
            }
        }
        require(success, "Failed to send ETH");
    }
}

Khi test contract này, chúng ta gửi gọi hàm withdrawETH rút một số ETH bằng giá trị _amount. Chúng ta đặt giá trị boolean ban đầu được gọi là successfasle và sau đó chuyển tới đoạn mã Assembly.

Vòng lặp for sử dụng giá trị i để lặp mã hai lần. Sau đó, chúng ta sử dụng sload và giá trị i để lấy từng chủ sở hữu. Nếu giá trị _to do người dùng xác định khớp với một trong các tài khoản chủ sở hữu, chúng ta sẽ sử dụng mã lệnh gọi để gửi tiền.

Lưu ý rằng mã này có thể không an toàn hoặc là trường hợp sử dụng tốt cho việc hợp ngữ vì rất nhiều hoạt động quản lý bộ nhớ và kiểm tra bảo mật bị bỏ qua.

Gọi hàm lấy dữ liệu sử dụng Assembly

Trong ví dụ này tôi viết contract để sử dụng Assembly gọi hàm lấy dữ liệu trong contract khác, với hai loại hàm khác nhau:

  • Hàm lấy dữ liệu không có tham số
  • Hàm lấy dữ liệu có tham số

Chúng ta viết hàm AssemblyTestCallStatic.sol với code như dưới. Trong contract này ta cài đặt:

  • Hai hàm lấy dữ liệu là getData()sumData(uint, uint)
  • Cài đặt hai hàm khác callGetData()callSumData(uint,uint) bằng Assembly để gọi hai hàm trên lấy dữ liệu.

Ví dụ này để các bạn biết cách sử dụng hàm staticcall và biết cách tạo dữ liệu đầu vào calldata. Từ đó bạn mở rộng để gọi nhiều hàm phức tạp hơn. Tôi đã triển khai trên Testnet tại địa chỉ 0xa89cc526ec212a114862ec0736e729221d846763, các bạn có thể vào test thử.

// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;

contract AssemblyTestCallStatic {
    function getData() public pure returns (uint) {
        return 68;
    }

    function sumData(uint x, uint y) public pure returns (uint) {
        return (x + y);
    }

    function callGetData() public view returns (uint data) {
        bytes4 sig = bytes4(keccak256("getData()"));
        address addr = address(this);
        assembly {
            // Allocate memory to store input data (32 bytes)
            let callInput := mload(0x40)
            mstore(callInput, sig)

            // Allocate memory to store result (64 bytes)
            let result := add(callInput, 0x20)
            let status := staticcall(gas(), addr, callInput, 0x04, result, 0x40)
            if eq(status, 0) {
                revert(result, 0x40)
            }

            // Convert from result to data
            data := mload(result)
        }
    }

    function callSumData(uint x, uint y) public view returns (uint data) {
        bytes4 sig = bytes4(keccak256("sumData(uint256,uint256)"));
        address addr = address(this);
        assembly {
            // Allocate memory to store input data (96 bytes)
            let callInput := mload(0x40)
            mstore(callInput, sig)
            mstore(add(callInput, 0x04), x)
            mstore(add(callInput, 0x24), y)


            // Allocate memory to store result (64 bytes)
            let result := add(callInput, 0x60)
            let status := staticcall(gas(), addr, callInput, 0x44, result, 0x40)
            if eq(status, 0) {
                revert(result, 0x40)
            }

            // Convert from result to data
            data := mload(result)
        }
    }
}

Gọi hàm lấy dữ liệu với kết quả trả về phức tạp hơn sử dụng Assembly

Trong ví dụ trên hàm trả về của chúng ta đơn giản chỉ có 1 giá trị lưu trong 1 slot. Bây giờ chúng ta thử ví dụ khác với giá trị trả về phức tạp hơn.

Đầu tiên chúng ta viết hàm AddressMapping.sol với để lưu trữ ánh xạ từ một số sang cấu trúc như sau:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

contract AddressMapping {
    struct PoolInfo {
        address addr;
        uint8 version;
        uint16 feeT1;
        uint16 feeT2;
    }

    mapping(address => uint256) private indexes;
    mapping(uint256 => PoolInfo) private poolInfos;
    uint256 public nextIndex;
    
    constructor() {
        nextIndex = 0;

        // Fix code to simplify for testing
        register(0x78a087d713Be963Bf307b18F2Ff8122EF9A63ae9, 31, 600, 800);
        register(0xA4AD394B9C6c214bD17104A2Aec7442138EA8A3e, 36, 900, 1300);
        register(0x1231DEB6f5749EF6cE6943a275A1D3E7486F4EaE, 20, 300, 400);
        register(0x83eC81Ae54dD8dca17C3Dd4703141599090751D1, 36, 500, 200);
        register(0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913, 36, 100, 100);
    }

    function poolInfo(uint256 index) public view returns (address addr, uint8 version, uint16 feeT1, uint16 feeT2)  {
        PoolInfo memory poolData = poolInfos[index];
        require(poolData.addr != address(0), "Index not found");

        addr = poolData.addr;
        version = poolData.version;
        feeT1 = poolData.feeT1;
        feeT2 = poolData.feeT2;
    }

    function poolIndex(address poolAddr) public view returns (uint256)  {
        return indexes[poolAddr];
    }

    // Register an address
    function register(address poolAddr, uint8 version, uint16 feeT1, uint16 feeT2) public {
        require(poolAddr != address(0), "Invalid address");
        require(indexes[poolAddr] == 0, "Pool address existed");
        PoolInfo memory pool = PoolInfo({
            addr: poolAddr,
            version: version,
            feeT1: feeT1,
            feeT2: feeT2
        });
        poolInfos[nextIndex] = pool;
        indexes[poolAddr] = nextIndex;
        nextIndex = nextIndex + 1;
    }

    // Update an address
    function update(uint256 index, address poolAddr, uint8 version, uint16 feeT1, uint16 feeT2) public {
        require(poolAddr != address(0), "Invalid address");
        PoolInfo memory pool = poolInfos[index];
        require(pool.addr != address(0), "Pool not existed");

        delete indexes[pool.addr];
        poolInfos[index] = PoolInfo({
            addr: poolAddr,
            version: version,
            feeT1: feeT1,
            feeT2: feeT2
        });
        indexes[poolAddr] = index;
    }
}

Bây giờ chúng ta cần viết contract khác AssemblyTestCallStatic01.sol sử dụng assembly, gọi contract trên để lấy dữ liệu:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

interface IAddressMapping {
    function poolInfo(uint256 index) external view returns (address addr, uint8 version, uint16 feeT1, uint16 feeT2);
}

contract AssemblyTestCallStatic01 {
    function getPoolInfo(address addrMapping, uint256 index) external view returns (address addr, uint8 version, uint16 feeT1, uint16 feeT2) {
        bytes4 sig = bytes4(keccak256("poolInfo(uint256)"));
        assembly {
            // Allocate memory to store output of poolInfo() (4 slot ~ 128 bytes)
            let callResult := mload(0x40)

            // Allocate memory to store input data (2 slot ~ 64 bytes)
            let callInput := add(callResult, 0x80)
            mstore(callInput, sig)
            mstore(add(callInput, 0x04), index)

            // Call to function poolInfo()
            let status := staticcall(gas(), addrMapping, callInput, 0x24, callResult, 0x80)
            if eq(status, 0) {
                revert(callResult, 0x40)
            }

            // Parse output
            addr := and(mload(callResult), 0xffffffffffffffffffffffffffffffffffffffff)
            version := and(mload(add(callResult, 0x20)), 0xff)
            feeT1 := and(mload(add(callResult, 0x40)), 0xffff)
            feeT2 := and(mload(add(callResult, 0x60)), 0xffff)
        }
    }
}

Contract triển khai tại địa chỉ 0x9D90349eD683E0257716860accF14545866BD4A5, bạn gọi thử hàm:
getPoolInfo(0x66172b26F51F6579eD0bDc67038A86742B72aF9d, 1)
Sẽ thấy dữ liệu trả về.

Parse mảng dữ liệu bytes đầu vào thành mảng cấu trúc

Vì các lệnh trong Assembly chỉ thao tác với slot, vì thế để lấy được dữ liệu uint8, uint16,… từ mảng bytes thì chúng ta phải xác định được vị trí slot sao cho các byte dữ liệu chúng ta cần nó phải nằm ở cuối slot (Tức là byte thấp nhất trong slot). Ví dụ ta có mảng dữ liệu bytes trong đó, mỗi phần tử gồm 3 byte trong đó 2 byte đầu là poolIndex (uint16), 1 byte sau là zeroForOne (uint8). Ảnh dưới là dữ liệu memory của mảng bytes với 6 bytes dữ liệu (Tương đương hai phần tử), có xác định slot cần để lấy dữ liệu đúng:

Parse dữ liệu bytes bằng Assembly trên Solidity

Phần sau là code bằng asembly để parse dữ liệu sử dụng Assembly:

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

contract BytesParser {
    // In memory, need to 2 slot
    struct MyData {
        uint16 poolIndex;
        uint8 zeroForOne;
    }

    function parseData(bytes memory data, uint256 index) external pure returns(uint16 poolIndex, uint8 zeroForOne) {
        assembly {
            let pItem := add(data, mul(index, 0x03))
            poolIndex := and(mload(add(pItem, 0x02)), 0xffff)
            zeroForOne := and(mload(add(pItem, 0x03)), 0xff)
        }
    }

    function parseDatas(bytes memory data) external pure returns(MyData[] memory output) {
        assembly {
            // Idenfity number of items
            let numOfItem := div(mload(data), 3)

            // Allocate memory for output
            output := mload(0x40)
            mstore(output, numOfItem)
            let outputHeader := add(output, 0x20)
            let outputData := add(outputHeader, mul(numOfItem, 0x20))

            // Parse data
            let pItem := data
            for { let pEnd := add(data, sub(mload(data), 2)) } lt(pItem, pEnd) { pItem := add(pItem, 0x03) } {
                // Store data into struct item
                let poolIndex := and(mload(add(pItem, 0x02)), 0xffff)
                mstore(outputData, poolIndex)
                let zeroForOne := and(mload(add(pItem, 0x03)), 0xff)
                mstore(add(outputData, 0x20), zeroForOne)

                // Update for struct array
                mstore(outputHeader, outputData)
                outputHeader := add(outputHeader, 0x20)
                outputData := add(outputData, 0x40)
            }
            mstore(0x40, outputData)
        }
    }
}

Sau khi triển khai được 0x479aD823f19fA1C310857aaDd72d7AbCD5f739bC, chúng ta gọi hàm:

  • parseData(0x000100010208, 1)
    Trả về dữ liệu phần tử 1
  • parseDatas(0x000100010208)
    Trả về toàn bộ dữ liệu ở dạng mảng cấu trúc.

Chuyển hàm tính căn bậc hai từ Solidity sang Assembly

Trước đây, trong bài viết Hàm tính căn bậc 2 (Square root – Sqrt) hiệu quả trên Solidity theo phương pháp tìm kiếm nhị phân, bây giờ chúng ta sẽ chuyển hàm này sang ngôn ngữ Assembly và đánh giá chi phí Gas tương ứng.

// SPDX-License-Identifier: MIT
pragma solidity >=0.7.0;

contract AsemblySqrt {
    constructor() {
    }

    function sqrt01(uint256 x) internal pure returns (uint256) {
        if (x == 0) return 0;
        else{
            uint256 xx = x;
            uint256 r = 1;
            if (xx >= 0x100000000000000000000000000000000) { xx >>= 128; r <<= 64; }
            if (xx >= 0x10000000000000000) { xx >>= 64; r <<= 32; }
            if (xx >= 0x100000000) { xx >>= 32; r <<= 16; }
            if (xx >= 0x10000) { xx >>= 16; r <<= 8; }
            if (xx >= 0x100) { xx >>= 8; r <<= 4; }
            if (xx >= 0x10) { xx >>= 4; r <<= 2; }
            if (xx >= 0x8) { r <<= 1; }
            r = (r + x / r) >> 1;
            r = (r + x / r) >> 1;
            r = (r + x / r) >> 1;
            r = (r + x / r) >> 1;
            r = (r + x / r) >> 1;
            r = (r + x / r) >> 1;
            r = (r + x / r) >> 1;
            uint256 r1 = x / r;
            return r < r1 ? r : r1;
        }
    }

    function sqrt02(uint256 x) internal pure returns (uint256 out) {
        assembly {
            if gt(x, 0) {
                let xx := x
                let r := 1
                if eq(lt(xx, 0x100000000000000000000000000000000), 0) {
                    xx := shr(128, xx)
                    r := shl(64, r)
                }
                if eq(lt(xx, 0x10000000000000000), 0) {
                    xx := shr(64, xx)
                    r := shl(32, r)
                }
                if eq(lt(xx, 0x100000000), 0) {
                    xx := shr(32, xx)
                    r := shl(16, r)
                }
                if eq(lt(xx, 0x10000), 0) {
                    xx := shr(16, xx)
                    r := shl(8, r)
                }
                if eq(lt(xx, 0x100), 0) {
                    xx := shr(8, xx)
                    r := shl(4, r)
                }
                if eq(lt(xx, 0x10), 0) {
                    xx := shr(4, xx)
                    r := shl(2, r)
                }
                if eq(lt(xx, 0x8), 0) {
                    r := shl(1, r)
                }
                r := shr(1, add(r, div(x, r)))
                r := shr(1, add(r, div(x, r)))
                r := shr(1, add(r, div(x, r)))
                r := shr(1, add(r, div(x, r)))
                r := shr(1, add(r, div(x, r)))
                r := shr(1, add(r, div(x, r)))
                r := shr(1, add(r, div(x, r)))

                let r1 := div(x, r)
                switch lt(r, r1)
                case 1 {
                    out := r
                }
                default {
                    out := r1
                }
            }
        }
    }

    function test01(uint256 x) external view returns(uint256 sqrt, uint256 gasUsed) {
        uint256 startGas = gasleft();
        sqrt = sqrt01(x);
        gasUsed = startGas - gasleft();
    }

    function test02(uint256 x) external view returns(uint256 sqrt, uint256 gasUsed) {
        uint256 startGas = gasleft();
        sqrt = sqrt02(x);
        gasUsed = startGas - gasleft();
    }
}

Trong code trên hàm sqrt01() là hàm viết bằng Solidity cũ, và hàm sqrt02() là hàm viết bằng Assembly. Chúng ta tạo ra hai hàm test01()test02() để gọi hai hàm trên và đo gas sử dụng. Sau khi triển khai trên Base Sepolia thì thấy Gas giảm đáng kể, bên Solidity gas khoảng trên 3200 thì code Assembly gas chỉ khoảng 650.

Nguồn tham khảo:

Bài viết này có hữu ích với bạn?

Kích vào một biểu tượng ngôi sao để đánh giá bài viết!

Xếp hạng trung bình 5 / 5. Số phiếu: 8

Bài viết chưa có đánh giá! Hãy là người đầu tiên đánh giá bài viết này.

Trả lời

Giao diện bởi Anders Norén