你已经吃透了 JUMP/JUMPI、循环、条件分支——那是同一个执行环境里「代码跳到哪儿执行」。过程调用和外部 call 是新的一层:什么时候还在原地跳,什么时候会开启一个全新环境并把内存里一段字节当作它的 calldata 递进去。
编译成子例程。PUSH 返回地址 → JUMP 到函数体,算完再 JUMP 回来。全程同一个上下文,不产生 CALL,不新建 calldata,栈/内存原样共享。
触发 CALL / STATICCALL / DELEGATECALL,分叉出新执行环境。父合约内存里组装好的 ABI 字节被复制过边界,成为子环境只读的 calldata。
最常见的误解是把 calldata 当成「函数传参的通用载体」。不是。calldata 只在一次「消息调用」抵达合约时才存在,是只读的、这次调用的输入字节。谁给你发消息,谁就决定你的 calldata。
切换下面两个标签,一步步走。留意青色和琥珀色两个上下文卡片——internal 全程只有 A 亮着;外部 CALL 会当场点亮 B,并把 A 内存里的一段字节复制成 B 的 calldata。
这是 CALL 之外、你已经熟的单上下文控制流层。这里给一个可运行的迷你 EVM:字节码是字节数组,PC(程序计数器)指着当前字节;每个 opcode 占 1 字节,只有 PUSH1 多带 1 个数据字节、把它之后所有指令的 PC 整体往后顶。跳转目标是绝对字节偏移,且必须落在 JUMPDEST(0x5b),否则 EVM 直接回滚(invalid jump)。
PUSH1 0x05 = 字节 60 05,占 2 字节;其余 opcode 占 1 字节。所以插一条 PUSH,会把它之后所有指令的 PC 全部后移——这正是跳转写「地址」而不是「第几条指令」的原因。
JUMP / JUMPI 只能落在 0x5b(JUMPDEST)字节上,否则 invalid jump → 整笔回滚。JUMPDEST 本身不做任何事,只是一个「合法落点」标记,供 EVM 预扫描校验。
if / 三元 = 一次前向 JUMPI(不满足就跳过一段);while / for = 头部 JUMPDEST + 条件 JUMPI 出口 + 尾部回边 JUMP(往回跳,把直线弯成环)。
| 维度 | internal 内部调用 | CALL / DELEGATECALL 外部调用 |
|---|---|---|
| 核心 opcode | JUMP / JUMPI 纯跳转 | CALL / STATICCALL / DELEGATECALL |
| 执行上下文 | 同一个 EVM 上下文,共享 storage、calldata、msg.sender | 新建独立子上下文(分叉) |
| Calldata | 不新建,沿用外层那份 | 子调用拥有全新独立 calldata |
| 参数传递 | 栈 / 内存传递 | 父内存组装 ABI → 复制为子 calldata |
| Gas | 普通指令消耗,无调用开销 | 有 CALL 基础开销 + 内存扩展成本 |
| 状态归属 | 直接读写当前合约 storage | 按 call 类型决定(delegatecall 用调用方存储) |
CALLDATALOAD(0) 取选择器、再取参数;② 参数标了 calldata 关键字(如 uint[] calldata arr),Solidity 直接就地读 calldata、不拷进内存,会大量出现 CALLDATALOAD。JUMPI 判断条件跳转。关键坑在循环体内——每一轮都要重新在内存组装 calldata 载荷 → 执行一次完整 CALL → 读返回值。也就是说,每次 CALL 都是独立的消息上下文,每轮都生成一份全新的子 calldata,不是复用的。RETURNDATASIZE / RETURNDATACOPY 去读——它不属于 calldata 体系,是另一套。this.foo() 算内部还是外部?this.foo() 走的是外部调用路径——会发一次 CALL 给自己的地址,新建上下文、新 calldata、重走 dispatcher。所以它比直接 internal 调用 贵得多,也是为什么有人用它来「强制走 external 语义」。