Solidity 代碼在部署到以太坊之前,會編譯為 EVM 字節碼。鏈上運行的不是 Solidity 源碼,而是這些字節碼。理解字節碼和 EVM 執行模型,對於 gas 優化、安全審計、合約調試和前端交互都至關重要。本文從 EVM 架構入手,逐步拆解從 Solidity 到字節碼到操作碼的完整鏈路。
EVM 架構概述
以太坊虛擬機(EVM)是一個基於棧的虛擬機,具有以下核心特徵:
- 棧深度:1024 個槽位,每個 256-bit(32 字節)
- 字長:256-bit(32 字節),所有計算都在 256-bit 整數上進行
- 存儲:每個合約有獨立的持久化存儲空間,256-bit key → 256-bit value
- 內存:臨時內存,交易執行後銷燬,按 32 字節 word 尋址
- Gas:每條操作碼有固定的 Gas 消耗,防止無限循環
EVM 的計算模型
┌──────────────────────────────┐
│ Calldata │ ← 輸入數據(只讀)
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Stack │ ← 1024 × 256-bit
│ (LIFO, max depth 16) │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Memory │ ← 臨時可讀寫
│ (byte-addressable) │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ Storage │ ← 持久化
│ (256-bit key → 256-bit val) │
└──────────────────────────────┘
EVM 不是圖靈完備的——它的"圖靈完備"是通過跳轉指令實現的有限循環。Gas 機制保證了計算必然終止。
操作碼分類與功能
EVM 共有約 140 個操作碼,分為以下幾類:
算術運算
ADD 0x01 // 棧頂兩元素相加
SUB 0x03 // 減法
MUL 0x02 // 乘法
DIV 0x04 // 除法
MOD 0x06 // 取模
ADDMOD 0x08 // 模加法
MULMOD 0x09 // 模乘法
EXP 0x0A // 指數運算
SIGNEXTEND 0x0B // 符號擴展
比較與位運算
LT 0x10 // 小於
GT 0x11 // 大於
EQ 0x14 // 等於
ISZERO 0x15 // 是否為零
AND 0x16 // 按位與
OR 0x17 // 按位或
XOR 0x18 // 按位異或
NOT 0x19 // 按位取反
SHL 0x1B // 左移
SHR 0x1C // 右移
棧操作
POP 0x50 // 彈出棧頂
PUSH0 0x5F // 壓入 0(EIP-3855,上海升級)
PUSH1-PUSH32 0x60-0x7F // 壓入 1-32 字節
DUP1-DUP16 0x80-0x8F // 複製棧中元素
SWAP1-SWAP16 0x90-0x9F // 交換棧中元素
內存與存儲
MLOAD 0x51 // 從內存讀取 32 字節
MSTORE 0x52 // 向內存寫入 32 字節
MSTORE8 0x53 // 向內存寫入 1 字節
SLOAD 0x54 // 從存儲讀取
SSTORE 0x55 // 向存儲寫入
控制流
JUMP 0x56 // 無條件跳轉
JUMPI 0x57 // 條件跳轉
PC 0x58 // 獲取當前程序計數器
MSIZE 0x59 // 獲取內存大小
GAS 0x5A // 獲取剩餘 Gas
JUMPDEST 0x5B // 跳轉目標標記
環境信息
ADDRESS 0x30 // 當前合約地址
BALANCE 0x31 // 地址餘額
CALLER 0x33 // 調用者地址(msg.sender)
CALLVALUE 0x34 // 發送的 ETH(msg.value)
CALLDATALOAD 0x35 // 從 calldata 讀取
CALLDATASIZE 0x36 // calldata 長度
CALLDATACOPY 0x37 // 複製 calldata 到內存
CODESIZE 0x38 // 代碼大小
CODECOPY 0x39 // 複製代碼到內存
SELFBALANCE 0x47 // 合約自身餘額
CHAINID 0x46 // 鏈 ID
調用與創建
CALL 0xF1 // 調用另一個合約
CALLCODE 0xF2 // 調用代碼(已不推薦)
DELEGATECALL 0xF4 // 委託調用
STATICCALL 0xFA // 靜態調用(不修改狀態)
CREATE 0xF0 // 創建合約
CREATE2 0xF5 // CREATE2 創建合約
Gas 計算模型
每個操作碼都有 Gas 消耗,分為兩類:
- 靜態 Gas:操作碼本身的固定費用
- 動態 Gas:取決於操作參數的額外費用
常見操作碼的 Gas 消耗
ADD/SUB/MUL/DIV 3 Gas (算術運算)
LT/GT/EQ/ISZERO 3 Gas (比較運算)
AND/OR/XOR/NOT 3 Gas (位運算)
PUSH1-PUSH32 3 Gas (壓棧)
DUP1-DUP16 3 Gas (複製)
SWAP1-SWAP16 3 Gas (交換)
MLOAD/MSTORE 3 Gas (內存讀寫)
SLOAD 2100 Gas (存儲讀取,冷)
SLOAD (warm) 100 Gas (存儲讀取,熱)
SSTORE (cold, from zero to non-zero) 22100 Gas
SSTORE (warm) 100 Gas (存儲寫入,熱)
BALANCE 700 Gas (冷) / 100 Gas (熱)
CALL 2600 Gas (冷) / 100 Gas (熱)
CREATE/CREATE2 32000 Gas (合約創建)
存儲槽的冷熱模型
EIP-2929 引入了訪問列表(Access List)機制,將存儲槽和地址分為"冷"和"熱":
- 冷訪問:交易中首次訪問,Gas 較高
- 熱訪問:同一交易中已訪問過,Gas 較低
// 首次 SLOAD:2100 Gas(冷)
// 後續 SLOAD:100 Gas(熱)
// 首次 SSTORE(0→非0):22100 Gas
// 後續 SSTORE:100 Gas(熱)
這就是為什麼在合約中緩存存儲變量到內存中可以節省 Gas:
// Gas 高:多次讀取存儲
function bad() public view returns (uint256) {
uint256 sum = 0;
for (uint256 i = 0; i < array.length; i++) { // 每次 array.length 讀存儲
sum += array[i]; // 每次讀存儲
}
return sum;
}
// Gas 低:緩存到內存
function good() public view returns (uint256) {
uint256[] memory arr = array; // 一次性讀取
uint256 sum = 0;
for (uint256 i = 0; i < arr.length; i++) {
sum += arr[i]; // 內存讀取,3 Gas
}
return sum;
}
合約部署:constructor bytecode 與 runtime bytecode
合約部署涉及兩種字節碼:
Creation Bytecode(部署字節碼)
包含 constructor 代碼 + runtime bytecode。部署時執行 constructor,然後將 runtime bytecode 存儲到鏈上。
// SimpleStorage.sol
pragma solidity 0.8.17;
contract SimpleStorage {
uint256 public value;
constructor(uint256 _value) {
value = _value;
}
function setValue(uint256 _value) external {
value = _value;
}
function getValue() external view returns (uint256) {
return value;
}
}
編譯後的字節碼結構:
Creation Bytecode:
┌─────────────────────────────────┐
│ Constructor 代碼 │
│ (執行初始化邏輯) │
├─────────────────────────────────┤
│ Runtime Bytecode │
│ (返回給 EVM 存儲的代碼) │
└─────────────────────────────────┘
部署過程:
1. EVM 執行 Creation Bytecode
2. Constructor 設置初始狀態
3. Constructor 返回 Runtime Bytecode
4. Runtime Bytecode 被存儲到合約地址
Runtime Bytecode(運行時字節碼)
這是鏈上存儲的、實際響應交易的代碼。可以通過 eth_getCode 獲取:
import { ethers } from 'ethers'
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
// 獲取鏈上 runtime bytecode
const bytecode = await provider.getCode(contractAddress)
// "0x608060405234801561001057600080fd5b5060..."
字節碼反編譯工具
在線反編譯工具
- ethervm.io:可視化展示操作碼,支持主網和測試網
- dedaub.com:Decompiler,將字節碼反編譯為可讀的偽代碼
- etherscan.io:合約頁面的 "Disassemble" 功能
使用 ethers 解析字節碼
import { ethers } from 'ethers'
// 解析字節碼為操作碼
function disassemble(bytecode: string): { opcode: string; pc: number }[] {
// 去除 0x 前綴
const code = bytecode.startsWith('0x')
? bytecode.slice(2)
: bytecode
const opcodes: { opcode: string; pc: number }[] = []
let pc = 0
while (pc < code.length) {
const byte = parseInt(code.slice(pc, pc + 2), 16)
const opcode = OPCODE_MAP[byte] || `UNKNOWN(0x${byte.toString(16)})`
opcodes.push({ opcode, pc })
// PUSH1-PUSH32 需要跳過數據
if (byte >= 0x60 && byte <= 0x7f) {
const pushSize = byte - 0x5f
pc += 2 + pushSize * 2
} else {
pc += 2
}
}
return opcodes
}
// 常見操作碼映射表
const OPCODE_MAP: Record<number, string> = {
0x00: 'STOP',
0x01: 'ADD',
0x02: 'MUL',
0x03: 'SUB',
0x04: 'DIV',
0x10: 'LT',
0x11: 'GT',
0x14: 'EQ',
0x15: 'ISZERO',
0x16: 'AND',
0x17: 'OR',
0x34: 'CALLVALUE',
0x35: 'CALLDATALOAD',
0x36: 'CALLDATASIZE',
0x50: 'POP',
0x51: 'MLOAD',
0x52: 'MSTORE',
0x54: 'SLOAD',
0x55: 'SSTORE',
0x56: 'JUMP',
0x57: 'JUMPI',
0x5b: 'JUMPDEST',
0x80: 'DUP1',
0x90: 'SWAP1',
0xf3: 'RETURN',
0xfd: 'REVERT',
}
// 使用
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
const bytecode = await provider.getCode('0x...')
const opcodes = disassemble(bytecode)
opcodes.forEach((op) => console.log(`${op.pc}: ${op.opcode}`))
執行追蹤
debug_traceTransaction 是 Geth 的調試 RPC 方法,可以獲取交易的完整執行追蹤:
import { ethers } from 'ethers'
// 需要支持 debug_traceTransaction 的節點
const provider = new ethers.providers.JsonRpcProvider(
'https://eth-mainnet.alchemyapi.io/v2/YOUR_KEY'
)
async function traceTransaction(txHash: string) {
const trace = await provider.send('debug_traceTransaction', [
txHash,
{
disableStorage: false,
disableMemory: false,
disableStack: false,
},
])
// trace.structLogs 是操作碼執行序列
trace.structLogs.forEach((log: any, index: number) => {
console.log(`[${index}] PC: ${log.pc}, OP: ${log.op}`)
console.log(` Stack: ${log.stack}`)
if (log.storage) {
console.log(` Storage: ${JSON.stringify(log.storage)}`)
}
})
return trace
}
使用 Trace 進行 Gas 分析
function analyzeGasUsage(trace: any) {
const gasPerOpcode: Record<string, { count: number; gas: number }> = {}
trace.structLogs.forEach((log: any) => {
const op = log.op
if (!gasPerOpcode[op]) {
gasPerOpcode[op] = { count: 0, gas: 0 }
}
gasPerOpcode[op].count++
// 估算每步 Gas(實際需要更復雜的計算)
})
// 按 Gas 消耗排序
const sorted = Object.entries(gasPerOpcode).sort(
(a, b) => b[1].count - a[1].count
)
console.log('操作碼使用統計:')
sorted.forEach(([op, data]) => {
console.log(` ${op}: ${data.count} 次`)
})
}
存儲佈局
Solidity 合約的存儲是 256-bit key → 256-bit value 的映射,共 2^256 個槽位。編譯器按照聲明順序分配存儲槽。
存儲槽分配規則
contract StorageLayout {
// Slot 0: state variables 按聲明順序打包
uint256 public a; // slot 0(佔用整個槽)
uint128 public b; // slot 1(與 c 打包)
uint128 public c; // slot 1(與 b 打包)
// Slot 2: 數組長度存儲在此
uint256[] public array; // slot 2 存長度,數據在 keccak256(2) + i
// Slot 3: mapping 不存儲值,key 通過 keccak256(key . slot) 計算
mapping(address => uint256) public balances; // slot 3
// Slot 4: 嵌套 mapping
mapping(address => mapping(uint256 => uint256)) public nested; // slot 4
}
前端讀取存儲槽
import { ethers } from 'ethers'
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
// 讀取 slot 0
const slot0 = await provider.getStorageAt(contractAddress, 0)
console.log('Slot 0:', ethers.BigNumber.from(slot0).toString())
// 讀取 mapping 中的值
// balances[user] 存儲在 keccak256(userAddress . slotNumber)
async function getMappingValue(
contractAddress: string,
slot: number,
key: string
) {
// 計算 key 的存儲位置
const paddedKey = ethers.utils.hexZeroPad(key, 32)
const paddedSlot = ethers.utils.hexZeroPad(slot, 32)
const concatenated = paddedKey + paddedSlot.slice(2)
const hash = ethers.utils.keccak256(concatenated)
const value = await provider.getStorageAt(contractAddress, hash)
return ethers.BigNumber.from(value)
}
// 讀取 balances[0xUser...]
const balance = await getMappingValue(
contractAddress,
3, // mapping 的 slot
'0xUserAddress...'
)
讀取動態數組元素
// array[i] 存儲在 keccak256(slot) + i
async function getArrayElement(
contractAddress: string,
slot: number,
index: number
) {
// 計算數組數據起始位置
const paddedSlot = ethers.utils.hexZeroPad(slot, 32)
const arrayStart = ethers.utils.keccak256(paddedSlot)
// 元素位置 = arrayStart + index
const elementSlot = ethers.BigNumber.from(arrayStart).add(index)
const value = await provider.getStorageAt(
contractAddress,
elementSlot.toHexString()
)
return ethers.BigNumber.from(value)
}
完整的 Solidity → Bytecode → Opcode 分析
// 一個最簡單的合約
pragma solidity 0.8.17;
contract Adder {
function add(uint256 a, uint256 b) external pure returns (uint256) {
return a + b;
}
}
編譯後的 runtime bytecode(簡化版):
0x6080604052
// 函數選擇器檢查
34156100... // CALLVALUE, ISZERO, ...
6004351460... // CALLDATALOAD 4字節, EQ, JUMPI
// add(uint256,uint256) 的函數選擇器: 0x771602f7
// calldata 佈局: [4字節selector][32字節 a][32字節 b]
// 讀取參數 a 和 b
35 // CALLDATALOAD
6004602410... // offset 4, 加載參數 a
35 // CALLDATALOAD
6024602410... // offset 36, 加載參數 b
// 執行加法
01 // ADD
// 返回結果
602052... // MSTORE 到內存
f3 // RETURN
對應的操作碼序列:
PC OP 說明
0 PUSH1 0x80 壓入 0x80
2 PUSH1 0x40 壓入 0x40
4 MSTORE 在內存 0x40 處存儲 0x80(空閒內存指針)
5 CALLVALUE 獲取 msg.value
6 DUP1 複製
7 ISZERO 檢查是否為 0
8 PUSH1 0x0f 壓入跳轉目標
10 JUMPI 如果 msg.value == 0,跳轉
...
函數選擇器
Solidity 使用函數選擇器來路由調用。選擇器是函數簽名的 keccak256 哈希的前 4 字節:
import { ethers } from 'ethers'
// 計算函數選擇器
const selector = ethers.utils
.id('add(uint256,uint256)')
.slice(0, 10) // 0x + 4字節
console.log(selector) // 0x771602f7
// 從 calldata 中提取選擇器
function parseCalldata(calldata: string) {
const selector = calldata.slice(0, 10) // 0x + 4字節
const params = '0x' + calldata.slice(10)
return {
selector,
params,
}
}
前端工具:用 ethers 解析 bytecode
import { ethers } from 'ethers'
class BytecodeAnalyzer {
constructor(private bytecode: string) {}
// 檢查是否是合約(而非 EOA)
isContract(): boolean {
return this.bytecode !== '0x' && this.bytecode.length > 2
}
// 提取 immutable 變量
// immutable 變量在字節碼中以 PUSH32 存儲
extractImmutableValues(): string[] {
const values: string[] = []
const code = this.bytecode.slice(2)
for (let i = 0; i < code.length; i += 2) {
const byte = parseInt(code.slice(i, i + 2), 16)
// PUSH32 (0x7f)
if (byte === 0x7f) {
const value = '0x' + code.slice(i + 2, i + 66)
if (value !== '0x' + '0'.repeat(64)) {
values.push(value)
}
i += 64 // 跳過 32 字節數據
}
}
return values
}
// 檢測代理合約模式
isProxyContract(): boolean {
// 代理合約通常在開頭有 DELEGATECALL (0xf4)
// EIP-1167 最小代理模式
const minimalProxyPattern =
'0x3d602d80600a3d3981f3363d3d373d3d3d363d73'
return this.bytecode.startsWith(minimalProxyPattern)
}
// 提取合約接口(通過分析 calldata 路由)
extractFunctionSelectors(): string[] {
const selectors: string[] = []
// 查找 PUSH4 後跟 EQ 的模式
const code = this.bytecode.slice(2)
for (let i = 0; i < code.length - 10; i += 2) {
const byte = parseInt(code.slice(i, i + 2), 16)
// PUSH4 (0x63)
if (byte === 0x63) {
const selector = '0x' + code.slice(i + 2, i + 10)
// 檢查後面是否有 EQ (0x14)
const nextByte = parseInt(code.slice(i + 10, i + 12), 16)
if (nextByte === 0x14) {
selectors.push(selector)
}
}
}
return [...new Set(selectors)]
}
}
// 使用
const provider = new ethers.providers.JsonRpcProvider(RPC_URL)
const bytecode = await provider.getCode(contractAddress)
const analyzer = new BytecodeAnalyzer(bytecode)
console.log('Is contract:', analyzer.isContract())
console.log('Is proxy:', analyzer.isProxyContract())
console.log('Function selectors:', analyzer.extractFunctionSelectors())
理解 EVM 對 DApp 開發的實際價值
Gas 優化
理解 EVM 操作碼的 Gas 消耗可以幫助編寫更高效的合約:
// Gas 優化示例:減少存儲寫入
contract GasOptimized {
mapping(address => uint256) public balances;
// 優化前:2 次存儲寫入
function update_bad(address user, uint256 amount) external {
balances[user] = 0; // SSTORE (0→0, 100 Gas if warm)
balances[user] = amount; // SSTORE (0→nonzero, 22100 Gas)
}
// 優化後:1 次存儲寫入
function update_good(address user, uint256 amount) external {
balances[user] = amount; // SSTORE (一次寫入)
}
}
安全審計
理解存儲佈局對於安全審計至關重要。許多漏洞源於對存儲打包的誤解:
// 漏洞示例:存儲碰撞
contract Vulnerable {
address public owner; // slot 0, 20 字節
bool public locked; // slot 0, 與 owner 打包(1 字節)
uint256 public funds; // slot 1
// 攻擊者可能通過特定方式覆蓋 locked
// 如果合約有 delegatecall 到惡意合約
}
調試
當合約交易失敗時,理解字節碼可以幫助定位問題:
async function debugFailedTransaction(
txHash: string,
provider: ethers.providers.Provider
) {
const tx = await provider.getTransaction(txHash)
const receipt = await provider.getTransactionReceipt(txHash)
if (receipt.status === 0) {
// 交易失敗,嘗試 replay
try {
await provider.call({
to: tx.to,
data: tx.data,
from: tx.from,
value: tx.value,
}, tx.blockNumber)
} catch (error) {
// 解析 revert 原因
const revertReason = ethers.utils.toUtf8String(
'0x' + error.data.slice(138)
)
console.log('Revert reason:', revertReason)
}
}
}
小結
EVM 字節碼分析是 Web3 開發中的深度技能。雖然日常開發中不需要手寫字節碼,但理解操作碼、Gas 模型和存儲佈局對三個方面有直接價值:
Gas 優化是立竿見影的收益。知道 SLOAD 是 2100 Gas 而 MLOAD 是 3 Gas,就會本能地把循環中的存儲讀取緩存到內存。知道存儲打包規則,就會合理安排變量聲明順序以減少槽位使用。
安全審計需要理解存儲佈局。代理合約的 delegatecall 會複用調用者的存儲,如果佈局不一致就會導致存儲碰撞。理解 slot 分配規則才能設計安全的代理模式。
調試能力是最後的保障。當交易失敗且沒有 revert 原因時,通過 debug_traceTransaction 追蹤操作碼執行是定位問題的最後一招。前端開發者雖然不常做字節碼級調試,但知道這個工具的存在和基本用法,在關鍵時刻能節省大量排查時間。
隨著 EVM 的持續演進(EIP-3540 EOF、EIP-3074 等提案),字節碼結構本身也在變化。保持對 EVM 規範的關注,是 Web3 開發者的長期必修課。
