EVM hoạt động thế nào? Giải mã máy ảo Ethereum từng bước
1. Tổng quan
EVM (Ethereum Virtual Machine) là “CPU” chạy mọi smart contract trên Ethereum. Hiểu cách EVM thực thi bytecode giúp bạn đọc được transaction trace, debug lỗi out of gas, và viết contract Solidity tiết kiệm gas hơn. Bài này đi từ mô hình account, kiến trúc stack-based, đến cách gas được tính, kèm ví dụ Solidity và code Java (Web3j) để bạn thấy toàn bộ luồng từ bytecode đến kết quả thực thi.
2. EVM là gì
EVM là một máy ảo stack-based, deterministic (cùng input luôn ra cùng output), chạy trên mọi node Ethereum. Mỗi node thực thi lại y nguyên các transaction trong một block để đồng thuận về state cuối cùng.
Điểm khác biệt lớn nhất so với JVM hay CPU thường: EVM không có khái niệm thread, không có I/O, không gọi được API ngoài. Mọi thứ nó cần (dữ liệu, số dư, code của contract khác) đều nằm trong world state — một cây Merkle Patricia Trie lưu toàn bộ account.
3. Mô hình tài khoản (Account Model)
Ethereum có hai loại account:
- EOA (Externally Owned Account): điều khiển bởi private key, không có code.
- Contract Account: có
code(bytecode EVM) vàstorageriêng.
Account {
nonce: số lượng transaction đã gửi (EOA) hoặc contract đã tạo
balance: số dư ETH (đơn vị wei)
storage: Merkle Trie key-value 256-bit (chỉ contract account)
codeHash: hash của bytecode (chỉ contract account)
}
Khi bạn gửi transaction tới một địa chỉ có code, EVM sẽ nạp code đó và bắt đầu thực thi — đây chính là lúc “máy ảo” vào cuộc.
4. Kiến trúc máy ảo: Stack, Memory, Storage, Calldata
EVM có bốn vùng dữ liệu chính, mỗi vùng khác nhau về chi phí gas và phạm vi tồn tại.
4.1. Stack
Stack chứa các word 256-bit, tối đa 1024 phần tử. Mọi phép toán (ADD, MUL, SUB…) đều lấy operand từ stack và đẩy kết quả trở lại stack.
PUSH1 0x05 ; stack: [5]
PUSH1 0x03 ; stack: [5, 3]
ADD ; stack: [8]
Ba dòng opcode trên thực hiện 5 + 3: hai PUSH1 đẩy số 5 và 3 lên stack, ADD lấy hai phần tử trên cùng, cộng lại, đẩy kết quả 8 trở lại.
4.2. Memory
Memory là vùng byte array tạm, chỉ tồn tại trong thời gian thực thi một lệnh gọi (call). Dùng cho dữ liệu lớn như struct, array trước khi trả về hoặc ghi vào storage.
4.3. Storage
Storage là key-value (uint256 -> uint256) persistent, gắn với từng contract, ghi vào state trie của blockchain. Đây là vùng tốn gas nhất vì phải được đồng thuận và lưu vĩnh viễn.
4.4. Calldata
Calldata là dữ liệu đầu vào của transaction — read-only, không tốn gas để “cấp phát” như memory.
Lưu ý: nhầm lẫn giữa
memoryvàstoragetrong Solidity là lỗi phổ biến nhất của người mới — khai báo biếnstoragekhông cần thiết khiến gas cost tăng vọt.
5. Bytecode và Opcode
Solidity compile ra bytecode, một chuỗi hex. Mỗi byte (hoặc một byte + operand) ứng với một opcode — lệnh cấp thấp mà EVM hiểu trực tiếp.
Ví dụ contract cộng hai số và lưu kết quả:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;
contract Counter {
uint256 public count;
function increment() external {
count += 1;
}
}
Biên dịch bằng solc:
solc --bin --asm Counter.sol
PUSH1 0x01
PUSH1 0x00
DUP2
SLOAD
ADD
SWAP1
SSTORE
Đoạn opcode này đọc count từ storage (SLOAD), cộng thêm 1 (ADD), rồi ghi lại (SSTORE) — đúng với logic count += 1.
Để thấy rõ cách EVM decode bytecode thành opcode, mình viết một decoder Java đơn giản (không cần node thật, chỉ minh họa cơ chế):
public class OpcodeDecoder {
private static final Map<Integer, String> OPCODES = Map.of(
0x00, "STOP",
0x01, "ADD",
0x54, "SLOAD",
0x55, "SSTORE",
0x60, "PUSH1"
);
public List<String> decode(String hexBytecode) {
byte[] code = HexFormat.of().parseHex(hexBytecode.replace("0x", ""));
List<String> ops = new ArrayList<>();
int i = 0;
while (i < code.length) {
int opcode = code[i] & 0xFF;
String name = OPCODES.getOrDefault(opcode, "UNKNOWN_0x" + Integer.toHexString(opcode));
ops.add(name);
// PUSH1 có 1 byte operand đi theo, phải nhảy qua
i += name.equals("PUSH1") ? 2 : 1;
}
return ops;
}
}
OPCODESmap mã hex sang tên opcode (bảng thật có 140+ mã).decodeđọc từng byte, tra tên opcode; nếu làPUSH1thì nhảy thêm 1 byte vì opcode này có kèm operand.- Kết quả là danh sách opcode theo đúng thứ tự EVM sẽ thực thi.
Test với JUnit 5 + AssertJ:
@Test
void decode_shouldReturnPushAddSstoreSequence() {
OpcodeDecoder decoder = new OpcodeDecoder();
List<String> ops = decoder.decode("0x600101550054");
assertThat(ops).containsExactly(
"PUSH1", "ADD", "SSTORE", "SLOAD"
);
}
Test này khẳng định decoder đọc đúng thứ tự opcode từ bytecode thô — đúng là bước đầu tiên EVM thực hiện trước khi chạy từng lệnh.
6. Gas: đơn vị đo chi phí thực thi
Mỗi opcode có một gas cost cố định (theo EIP hiện hành), phản ánh mức tải lên CPU/storage của node. Transaction phải kèm gasLimit; nếu chạy quá mức đó, EVM revert toàn bộ state change nhưng vẫn giữ lại gas đã tiêu (tránh spam).
ADD -> 3 gas
SLOAD -> 2100 gas (cold) / 100 gas (warm)
SSTORE -> 20000 gas (tạo mới) / 2900 gas (cập nhật)
PUSH1 -> 3 gas
Một util nhỏ để ước lượng gas của chuỗi opcode:
public class GasEstimator {
private static final Map<String, Long> COST = Map.of(
"ADD", 3L,
"PUSH1", 3L,
"SLOAD", 2100L,
"SSTORE", 20000L
);
public long estimate(List<String> opcodes) {
return opcodes.stream()
.mapToLong(op -> COST.getOrDefault(op, 0L))
.sum();
}
}
@Test
void estimate_shouldSumGasForCounterIncrement() {
GasEstimator estimator = new GasEstimator();
List<String> ops = List.of("PUSH1", "SLOAD", "ADD", "SSTORE");
long gas = estimator.estimate(ops);
assertThat(gas).isEqualTo(3 + 2100 + 3 + 20000);
}
estimate cộng gas của từng opcode theo bảng giá; kết quả 22106 cho thấy riêng một lệnh count += 1 đã tốn phần lớn gas vào SSTORE — đây là lý do Solidity dev luôn khuyên giảm số lần ghi storage.
7. Vòng đời xử lý một transaction trong EVM
Khi một transaction đến contract, EVM thực hiện theo trình tự:
- Kiểm tra
nonce,balanceđủ trảgasLimit * gasPricekhông. - Nạp
calldatavàcodecủa contract đích. - Khởi tạo stack, memory rỗng; đọc
storagehiện tại. - Thực thi từng opcode theo program counter (PC), trừ gas tương ứng mỗi bước.
- Nếu gặp
REVERThoặc hết gas → hoàn tác mọi thay đổi state, nhưng gas đã dùng vẫn bị trừ. - Nếu thành công → commit storage mới vào world state, phát
receiptkèmlogs(event).
Transaction
│
▼
Validate nonce & balance
│
▼
Load code + calldata ──► Execute opcodes (PC tăng dần)
│ │
│ trừ gas mỗi bước
│ │
▼ ▼
Out of gas / REVERT Thành công
│ │
▼ ▼
Rollback state Commit state + logs
8. Ví dụ thực hành: gọi contract từ Spring Boot với Web3j
Để tương tác thật với EVM (qua node local như Ganache/Anvil hoặc testnet), bạn cần thư viện Web3j:
<dependency>
<groupId>org.web3j</groupId>
<artifactId>core</artifactId>
<version>4.11.1</version>
</dependency>
Một service đọc giá trị count từ contract đã deploy:
@Service
public class CounterService {
private final Web3j web3j;
private final String contractAddress;
public CounterService(Web3j web3j,
@Value("${evm.counter.address}") String contractAddress) {
this.web3j = web3j;
this.contractAddress = contractAddress;
}
public BigInteger readCount() throws IOException {
Function function = new Function(
"count",
Collections.emptyList(),
List.of(new TypeReference<Uint256>() {})
);
String encoded = FunctionEncoder.encode(function);
EthCall response = web3j.ethCall(
Transaction.createEthCallTransaction(null, contractAddress, encoded),
DefaultBlockParameterName.LATEST
).send();
List<Type> results = FunctionReturnDecoder.decode(
response.getValue(), function.getOutputParameters()
);
return (BigInteger) results.get(0).getValue();
}
}
Functionmô tả hàmcount()cần gọi và kiểu dữ liệu trả về (Uint256).FunctionEncoder.encodebiến thành calldata hex (function selector + params) — đúng dữ liệu EVM sẽ nạp vào stack khi thực thi.ethCallgửi requesteth_call— chạy EVM ở chế độ đọc, không tạo transaction thật, không tốn gas.FunctionReturnDecoder.decodegiải mã output hex trả về thànhBigInteger.
# application.properties
evm.counter.address=0x5FbDB2315678afecb367f032d93F642f64180aa
Lưu ý:
eth_callkhông ghi state, nên không cầngasLimitthực sự và không trảtransactionHash. Nếu bạn cần ghi dữ liệu (gọiincrement()), phải dùngeth_sendRawTransactionkèm private key ký transaction — và lúc đó mới tốn gas thật.
9. Lỗi thường gặp và khi nào cần cẩn trọng
- Out of gas: ước lượng
gasLimitquá thấp cho vòng lặp hoặc ghi storage nhiều lần. Luôn test vớieth_estimateGastrước khi gửi transaction thật. - Reentrancy: EVM cho phép contract A gọi contract B, B gọi lại A trước khi A cập nhật state — dẫn tới rút tiền nhiều lần (vụ The DAO). Dùng pattern checks-effects-interactions hoặc
nonReentrantmodifier. - Stack too deep: Solidity compiler giới hạn số biến local do stack chỉ có 1024 slot; chia hàm nhỏ hơn khi gặp lỗi này.
- Không nên chạy logic nặng tính toán (hash phức tạp, loop lớn) trực tiếp on-chain — mỗi vòng lặp đều nhân gas cost, nên để off-chain tính rồi submit kết quả kèm proof nếu có thể.
10. Kết luận
EVM là một máy ảo stack-based đơn giản về tập lệnh nhưng chặt chẽ về gas và state — mọi opcode đều có giá, mọi thay đổi storage đều vĩnh viễn và tốn kém. Hiểu rõ stack/memory/storage, cách gas được tính, và vòng đời một transaction giúp bạn viết smart contract hiệu quả hơn và debug được khi giao dịch revert. Nếu bạn làm việc với contract thường xuyên, hãy tập đọc trace opcode thật bằng evm disasm hoặc debugger trong Hardhat/Foundry — nó trực quan hơn nhiều so với chỉ đọc Solidity.