EVM · 控制流的第二层 ■ 上下文 A(父/当前) ■ 上下文 B(CALL 新建)

JUMP 还是 CALL
你的困惑,其实是「几个执行环境」的问题

你已经吃透了 JUMP/JUMPI、循环、条件分支——那是同一个执行环境里「代码跳到哪儿执行」。过程调用和外部 call 是新的一层:什么时候还在原地跳,什么时候会开启一个全新环境并把内存里一段字节当作它的 calldata 递进去。

internal 内部调用 → JUMP

编译成子例程。PUSH 返回地址 → JUMP 到函数体,算完再 JUMP 回来。全程同一个上下文,不产生 CALL,不新建 calldata,栈/内存原样共享。

external 外部调用 → CALL 族

触发 CALL / STATICCALL / DELEGATECALL分叉出新执行环境。父合约内存里组装好的 ABI 字节被复制过边界,成为子环境只读的 calldata

01

先把 calldata 摆回它真正的位置

最常见的误解是把 calldata 当成「函数传参的通用载体」。不是。calldata 只在一次「消息调用」抵达合约时才存在,是只读的、这次调用的输入字节。谁给你发消息,谁就决定你的 calldata。

交易 → 合约 合约的 calldata = tx.data(4 字节选择器 + ABI 编码参数)
合约 A CALL 合约 B B 的执行环境里新建一份独立 calldata,内容由 A 在 CALL 入参里指定
internal 函数互调 完全复用当前上下文,不会新建 calldata——foo 看到的还是外层 bar 的输入
02

逐步追踪:同一段流程,两套机制

切换下面两个标签,一步步走。留意青色琥珀色两个上下文卡片——internal 全程只有 A 亮着;外部 CALL 会当场点亮 B,并把 A 内存里的一段字节复制成 B 的 calldata


        
上下文 A 当前合约
Calldata 只读输入
Memory 可读写暂存
Stack 栈顶在右
CALL:复制内存 → 子 calldata
上下文 B 尚未创建
internal 调用不会产生这个环境
只有 CALL 族才会分叉出上下文 B
Calldata 来自 A 的内存拷贝
Memory B 自己的,全新清零
Stack
按「下一步」开始执行。
0 / 0
03

补全全景:JUMP / JUMPI / JUMPDEST · PC · if/while/for/三元

这是 CALL 之外、你已经熟的单上下文控制流层。这里给一个可运行的迷你 EVM:字节码是字节数组,PC(程序计数器)指着当前字节;每个 opcode 占 1 字节,只有 PUSH1 多带 1 个数据字节、把它之后所有指令的 PC 整体往后顶。跳转目标是绝对字节偏移,且必须落在 JUMPDEST(0x5b),否则 EVM 直接回滚(invalid jump)。

PC 与字节

PUSH1 0x05 = 字节 60 05,占 2 字节;其余 opcode 占 1 字节。所以插一条 PUSH,会把它之后所有指令的 PC 全部后移——这正是跳转写「地址」而不是「第几条指令」的原因。

JUMPDEST 铁律

JUMP / JUMPI 只能落在 0x5b(JUMPDEST)字节上,否则 invalid jump → 整笔回滚。JUMPDEST 本身不做任何事,只是一个「合法落点」标记,供 EVM 预扫描校验。

结构对照

if / 三元 = 一次前向 JUMPI(不满足就跳过一段);while / for = 头部 JUMPDEST + 条件 JUMPI 出口 + 尾部回边 JUMP(往回跳,把直线弯成环)。

if(c){A}else{B}算 c → ISZEROJUMPI 到 else;then 段末尾 JUMP 跳过 else;两段在末尾 JUMPDEST 汇合
c ? A : B和 if/else 完全同构——同样是一次 JUMPI 选择两个值,编出的骨架几乎一样
while(c){B}JUMPDEST → 算 c → JUMPI 出口 → 体 B → JUMP 回头(回边)
for(i;c;p){B}同 while,只是 init 在环外post(如 i++) 固定放在回边 JUMP 之前

        
换个输入 x,看 JUMPI 走哪条分支:
Program Counter
pc 0
Stack 栈顶在右
按「下一步」逐条执行。
0 / 0
04

一张表锁死差异

维度 internal 内部调用 CALL / DELEGATECALL 外部调用
核心 opcode JUMP / JUMPI 纯跳转 CALL / STATICCALL / DELEGATECALL
执行上下文 同一个 EVM 上下文,共享 storage、calldata、msg.sender 新建独立子上下文(分叉)
Calldata 不新建,沿用外层那份 子调用拥有全新独立 calldata
参数传递 栈 / 内存传递 父内存组装 ABI 复制为子 calldata
Gas 普通指令消耗,无调用开销 有 CALL 基础开销 + 内存扩展成本
状态归属 直接读写当前合约 storage 按 call 类型决定(delegatecall 用调用方存储)
05

回答你盘过的几个具体困惑

+字节码里什么时候才会冒出 CALLDATALOAD / CALLDATACOPY?
两种场景:① 合约被外部触发(交易或被别的合约 CALL)时,入口 dispatcher 必须用 CALLDATALOAD(0) 取选择器、再取参数;② 参数标了 calldata 关键字(如 uint[] calldata arr),Solidity 直接就地读 calldata、不拷进内存,会大量出现 CALLDATALOAD。

反过来:如果你只是internal 函数互调,几乎见不到 CALLDATALOAD——因为没有新消息进来。
+循环里套外部 CALL,在 EVM 里长什么样?
循环骨架还是你熟悉的那套:计数器放栈/内存,JUMPI 判断条件跳转。关键坑在循环体内——每一轮都要重新在内存组装 calldata 载荷 → 执行一次完整 CALL → 读返回值。也就是说,每次 CALL 都是独立的消息上下文,每轮都生成一份全新的子 calldata,不是复用的。
+calldata 和 returndata 到底啥关系?
方向相反的两条管道。calldata 向下传递:父 → 子的输入。returndata 向上返回:子 → 父的输出。子调用执行完,返回字节落在一块临时缓冲区,父合约用 RETURNDATASIZE / RETURNDATACOPY 去读——它不属于 calldata 体系,是另一套。
+DELEGATECALL / STATICCALL 的 calldata 有区别吗?
calldata 机制完全一样:都新建子上下文、都有独立 calldata,父内存组装、复制过去。区别在别处——DELEGATECALL 让子代码用调用方的 storage / msg.sender / msg.value(借代码、用自己的家底);STATICCALL 禁止一切状态修改。传参逻辑三者一致。
+this.foo() 算内部还是外部?
外部。虽然是同一个合约,this.foo() 走的是外部调用路径——会发一次 CALL 给自己的地址,新建上下文、新 calldata、重走 dispatcher。所以它比直接 internal 调用 贵得多,也是为什么有人用它来「强制走 external 语义」。