04 · Solidity 进阶:EVM 的高级语言(★★★★★)

这里其实只是 EVM 的高级语言。第一层的语法你可能已经会了,本篇重点放在 每个特性到底在 EVM 层面对应什么、为什么套利/协议合约会这样设计,而不是复述 Solidity 手册。 与 03-EVM原理.md 强绑定:这里每一个 Solidity 特性都能回答"它编译成了什么 opcode"。


0. 一句话本质

Solidity 的全部语法,最终目的只有一个:让人类能写出 EVM 能执行的字节码, 又不必手写 opcode。理解"这个语法背后对应什么底层行为", 比记住语法本身更重要——这决定你能不能看懂陌生合约、写出 gas 高效的代码、以及安全地做升级/委托调用。


1. 基础层:变量 / 函数 / 返回值

  • 状态变量存 Storage(永久,见 03 §1),局部变量存 Memory/Stack(临时)。
  • 函数可见性(public/external/internal/private)决定谁能调用、是否生成外部 ABI 入口—— external 函数参数直接从 calldata 读取(不拷贝到 memory),比 public 更省 gas。
  • 返回值可以是多个(Solidity 支持 tuple 返回),编译后按 ABI 规则打包进 RETURN 的数据里。

2. Struct:把相关字段打包成一个类型

例如一个链上订单簿:

struct Order {
    address maker;
    uint256 price;
    uint256 amount;
    uint64  expiry;
}

为什么重要:struct 在 storage 里会按字段顺序紧密打包(packing)—— 如果你把 uint64 expiry 放在两个 uint256 中间,会浪费一个完整的 storage slot; 把体积小的字段放一起(比如两个 uint64 挨着),编译器能把它们塞进同一个 32 字节 slot, 省下一次 SSTORE(约 20000 gas)。struct 字段顺序 = 直接的 gas 优化点

3. Mapping:几乎所有余额/授权的实现方式

mapping(address => uint256) public balanceOf;
  • 本质是 keccak256(abi.encode(key, slot)) → 该 key 对应的 storage 位置的哈希寻址, 不像数组那样连续存储,因此无法遍历(这是为什么很多合约要专门维护一个地址数组来支持遍历)。
  • 嵌套 mapping(如 ERC20 的 allowance[owner][spender])本质是对 key 做两次哈希嵌套定位。

4. Array:动态集合(订单列表等)

  • storage 数组和 memory 数组的 gas 特性完全不同:memory 数组一次性开辟连续空间, storage 数组每个元素独立寻址(类似 mapping 的哈希偏移)。
  • 循环遍历一个很长的链上数组是gas 炸弹——这是很多历史漏洞/DoS 攻击的成因, 套利合约应尽量避免"链上无界循环"。

5. Modifier:权限与前置条件复用

modifier onlyOwner() {
    require(msg.sender == owner, "not owner");
    _;
}
  • _; 是函数体插入点;modifier 本质是"编译时把这段代码原样插入到函数开头",不是运行时的额外调用开销。
  • 套利合约常见 onlyOwner(限制谁能触发策略执行)、nonReentrant(防重入锁)—— 后者本质是一个 storage 布尔位,在函数开始设 true、结束设回 false,中途再进入就 revert。

6. Interface:调用外部协议(如 Uniswap)

interface IUniswapV2Router {
    function swapExactTokensForTokens(
        uint amountIn, uint amountOutMin,
        address[] calldata path, address to, uint deadline
    ) external returns (uint[] memory amounts);
}
  • Interface 不含实现,只声明函数签名;编译器靠它生成正确的 ABI 编码(03 §4), 实际调用哪个地址由你在代码里传入的目标地址决定。
  • 这是套利合约"调用 Uniswap/Aave/任意协议"的标准方式——你不需要那些协议的完整源码, 只需要它们公开的 interface

7. Library:无状态逻辑复用

  • library 不能有状态变量(大多数场景),函数逻辑可以被 using ... for ... 语法直接"挂"到某个类型上, 编译后要么被内联(internal 函数),要么通过 DELEGATECALL 调用(已部署的外部库)。
  • Uniswap V2 的 UQ112x112(定点数运算库)、OpenZeppelin 的 SafeERC20 都是典型例子—— 后者专门处理"一些 ERC20 的 transfer 不严格遵守返回 bool 规范"这个历史遗留问题(见 06-Token标准.md)。

8. Event:机器人监听的入口

event Swap(address indexed sender, uint amount0In, uint amount1In,
           uint amount0Out, uint amount1Out, address indexed to);
  • 编译成 LOG 系列 opcode(见 03 §2),indexed 参数(最多 3 个)会被单独存进 topics, 可以被高效按条件过滤(比如"只看某个地址的所有 Swap 事件")。
  • 这是套利机器人价格监听/机会发现的核心数据源:不需要解析每笔交易的 calldata, 订阅相关合约的 Swap/Sync/Transfer 事件即可拿到结构化的状态变化。

9. Fallback / Receive:合约怎么"接住"未知调用和纯转账

receive() external payable {}          // 纯 ETH 转账(无 calldata)触发
fallback() external payable {}         // 调用了不存在的函数 / 有 calldata 但无 receive 时触发
  • 这是代理合约模式的基础:fallback 里用 DELEGATECALL 把调用转发给实现合约地址, 从而实现"逻辑可升级、地址不变"(见本篇 §11 Proxy)。
  • 闪电贷回调(如 Aave 的 executeOperation、Uniswap V3 的 uniswapV3FlashCallback)也是这种 "合约暴露一个特定函数等待外部协议回调"的模式变体,虽然名字不是 fallback,但设计哲学一致。

10. Assembly(Yul):直接对话 opcode

assembly {
    let ptr := mload(0x40)
    mstore(ptr, value)
    return(ptr, 32)
}
  • 跳过 Solidity 编译器的"安全垫"(自动溢出检查、边界检查等),直接写接近 opcode 的 Yul 代码, 用于极致 gas 优化(省去冗余检查)或标准语法无法表达的操作(如直接读某个非标准 storage slot)。
  • 代价:完全丧失编译器的安全检查,一个位移写错就可能造成资金损失,是审计中重点关照的部分。

11. DelegateCall 与 Proxy:合约怎么"升级"

  • 以太坊合约一旦部署,字节码不可变——但可以用代理模式实现"看起来能升级":
    • Proxy 合约:地址不变,fallbackDELEGATECALLImplementation 合约地址。
    • Implementation 合约:真正的业务逻辑,可以被替换成新地址(Proxy 只需修改"指向哪个实现"这一个 storage slot)。
  • 常见标准:Transparent Proxy(OpenZeppelin)、UUPS(升级逻辑在 Implementation 里而不是 Proxy 里,更省 gas)、 Diamond(EIP-2535,多个 Implementation 按函数选择器路由)。
  • 风险点DELEGATECALL 下,Implementation 合约的 storage 布局必须和 Proxy 完全对齐, 否则升级后数据错位——这是历史上多起"升级后资金消失"事故的直接原因。

12. CREATE2:提前算出合约地址

  • 公式:地址 = Keccak256(0xff ++ 部署者地址 ++ salt ++ Keccak256(字节码))[12:]
  • 套利/工程应用
    • 跨链协议可以在多条链上用相同 salt + 相同字节码,部署出完全相同的合约地址,简化多链集成。
    • 可以先把"要交互的合约地址"算出来并预授权(approve),再实际部署,减少上线流程里的时序依赖。
    • 反面:也被部分攻击者用于"生成一次性地址接收资金后自毁再复用地址"的手法,是链上核查时需要警惕的模式。

13. 前置名词小词典

  • Storage slot:Storage 中 32 字节为单位的一个存储槽位,变量按声明顺序被分配 slot。
  • Packing(打包):多个小尺寸变量共享同一个 slot 以节省 gas 的编译器优化。
  • 重入攻击(Reentrancy):外部调用尚未返回前,被调用方回调回来再次进入本合约, 利用状态未及时更新的窗口套利/转移资金。
  • Proxy 模式:地址不变、逻辑可替换的合约架构,依赖 DELEGATECALL

14. 常见误区

  • ❌ "Struct 字段顺序无所谓" → 顺序直接影响 storage packing,进而影响 gas 成本。
  • ❌ "Library 一定更省 gas" → internal library 函数会被内联(省一次 CALL),但外部部署的 library 走 DELEGATECALL,反而多一次调用开销。
  • ❌ "有 fallback 就等于有漏洞" → fallback/receive 是合法设计模式,关键看里面逻辑是否安全(是否有权限校验)。
  • ❌ "Assembly 就是"黑魔法",正常项目不该用" → 头部协议(Uniswap V3、Seaport)大量使用 Yul 做 gas 优化,关键是配合严格审计。

15. 与套利实践的对应 / 自检问题

对应07-DEX与做市.md(Uniswap 的 Interface 调用)、08-DeFi协议全景.md(FlashLoan 回调即 Fallback 模式变体)、 09-跨链生态.md(多链合约常用 CREATE2 保证地址一致)。

自检

  1. 一个 struct 里放 uint256, uint64, uint64, uint128,和放 uint256, uint128, uint64, uint64, 哪种顺序更省 storage slot?为什么?
  2. 为什么 mapping 不能被遍历,但数组可以?这对你写"批量结算"逻辑有什么影响?
  3. Proxy 升级后如果 Implementation 的变量声明顺序和旧版本不一致,会发生什么?
  4. CREATE2 预测出的合约地址,在合约真正部署之前,能不能先给它转账/授权?为什么这个特性有用?

── Q&A / 更正记录区(按日期追加)──

原始讨论记录见 Q&A/(按日期归档,保留完整来回过程);本节是同步后的结论摘要, 只增不删——若后续发现本篇正文表述不准确,按更正记录追加在此,不删改正文, 保留"原来写的是什么 → 后来发现哪里不准 → 更正为什么"的轨迹。规范见 Q&A/README.md

  • (2026-07-26 建立。暂无 Q&A——后续围绕本篇的提问/质疑/更正在此累积。)