# EVM学习- internal / external函数执行过程 **Published by:** [rbtree](https://paragraph.com/@rbtree/) **Published on:** 2022-09-19 **URL:** https://paragraph.com/@rbtree/evm-internal-external ## Content 本文讲从EVM操作码的角度,对比internal函数和external函数在执行上的区别。 solidity version = 0.8.15,optimizer = true,optimizer_runs = 200,evm_version = "london" 在线反汇编 https://ethervm.io/decompile evm执行模拟使用foundry环境https://book.getfoundry.sh/reference/forge/forge-debug1 internal函数代码如下,我们主要关注internal函数setNumberInternal的调用方式和执行过程。pragma solidity 0.8.15; contract Test { uint256 number1; uint256 number2; function setNumber(uint256 newNumber1, uint256 newNumber2) external { setNumberInternal(newNumber1, newNumber2); number1 += 0x4321; } function setNumberInternal(uint256 newNumber1, uint256 newNumber2) internal { number1 = newNumber1; number2 = newNumber2; } } 操作码看起来比较费劲,这里贴出反汇编的结果以帮助我们更好地理解。 main()中展示出来的内容大体上对应setNumber, func_0038(var arg0, var arg1)大体对应setNumberInternal。 认真读代码可以看出,func_0038(var arg0, var arg1)做了优化,其实是把number1 += 0x4321和setNumberInternal整合到了一块。 实际上没有一个完整的代码段完美对应setNumberInternal,我们也不需要,因为这个函数并非external函数,它不会被外部调用。function main() { ...... var2, var3 = func_005E(var3, var4); func_0038(var2, var3); stop(); } function func_0038(var arg0, var arg1) { storage[0x00] = arg0; storage[0x01] = arg1; var var0 = 0x4321; var var1 = 0x00; var var2 = var1; var var3 = 0x55; var var4 = var0; var var5 = arg0; var3 = func_007F(var4, var5); storage[var1] = var3; } function func_005E(var arg0, var arg1) returns (var r0, var arg0) { var var0 = 0x00; var var1 = var0; if (arg0 - arg1 i< 0x40) { revert(memory[0x00:0x00]); } var temp0 = arg1; r0 = msg.data[temp0:temp0 + 0x20]; arg0 = msg.data[temp0 + 0x20:temp0 + 0x20 + 0x20]; return r0, arg0; } 下面从操作码层面分析setNumber(0x1234, 0x5678)的执行过程。 和 var2, var3 = func_005E(var3, var4)对应的操作码如下:0030 60 PUSH1 0x38 0032 36 CALLDATASIZE 0033 60 PUSH1 0x04 0035 60 PUSH1 0x5e 0037 56 *JUMP PUSH1 0x38: 0x38是返回地址,这个函数执行完毕之后,会通过JUMP返回到0x38处继续执行。 CALLDATASIZE:calldata大小入栈。calldata实际上是:函数签名hash的前4字节+函数参数。这里的例子里我们有2个uint256参数,它们都是0x20字节,加上签名hash前4字节,应该总共是0x44. PUSH1 0x04:这里的0x04的含义其实就是签名hash的4字节。这个数字入栈是为了后面从calldata中获取参数的方便。第n个参数在calldata中的偏移地址是0x04 + (n - 1) * 0x20。 PUSH1 0x5e:配合下一句跳转。 *JUMP:跳转到0x5e。接下来就正式进入func_005E(var arg0, var arg1),我们来看这个函数的执行逻辑。 注意arg0和arg1并不是我们在solidity里面的函数参数,而是我们刚才入栈的calldatasize和函数签名大小(也就是4)。这里首先做了个判断,如果calldatasize-4<0x40,那么说明出错了。这一步是为了防止从calldata中取参数越界。005E 5B JUMPDEST 005F 60 PUSH1 0x00 0061 80 DUP1 0062 60 PUSH1 0x40 0064 83 DUP4 0065 85 DUP6 0066 03 SUB 0067 12 SLT 0068 15 ISZERO 0069 60 PUSH1 0x70 006B 57 *JUMPI 这里的操作码就不逐行解释了,大意就是calldatasize-4(SUB)之后,判断是否小于0x40(SLT)。如果小于0x40,SLT会返回1,ISZERO会返回0,跳转条件不成立,会继续往下走(revert逻辑,这里没有贴出来),这属于异常情况。正常情况下,跳转条件会成立,会跳到0x70开始执行。接下来就是从calldata中取得参数。0070 5B JUMPDEST 0071 50 POP 0072 50 POP 0073 80 DUP1 0074 35 CALLDATALOAD 0075 92 SWAP3 0076 60 PUSH1 0x20 0078 90 SWAP1 0079 91 SWAP2 007A 01 ADD 007B 35 CALLDATALOAD 007C 91 SWAP2 007D 50 POP 007E 56 *JUMP 从calldata中取参数的是CALLDATALOAD指令,取参数的偏移地址是执行指令是栈顶的数字。下面是取第一个参数0x1234的过程。在取完两个参数之后,我们的栈会变成下面这样:可以注意到,通过一些SWAP指令,2个参数不在00和01位置,而在01和02位置,栈顶是0x38。还记得它吗?这就是我们在执行这个函数之前压栈的返回地址,现在函数执行完毕,它终于派上用场了。接下来,我们就会回到0x38指令。 至此var2, var3 = func_005E(var3, var4)执行完毕,接下来是func_0038(var2, var3)。0038 5B JUMPDEST 0039 60 PUSH1 0x3e 003B 56 *JUMP 跳转到003E,这里对应solidity里面setNumberInternal内部代码以及number1 += 0x4321。003E 5B JUMPDEST 003F 60 PUSH1 0x00 0041 82 DUP3 0042 81 DUP2 0043 55 SSTORE 0044 60 PUSH1 0x01 0046 82 DUP3 0047 90 SWAP1 0048 55 SSTORE 值得注意的是,在这段代码中,我们并没有看到CALLDATALOAD指令,这是一个internal函数,对internal函数的调用并不是message call,并不会生成一个新的calldata。这里需要的calldata中的内容,我们在上一步都已经把它们取到栈内了。 这段代码中,我们可以看到,0x1234和0x5678会被SSTORE指令(0043、0048)存入storage。在这之后,0x4321入栈,这是因为我们有number1 += 0x4321这行代码。0049 61 PUSH2 0x4321 004C 90 SWAP1 004D 80 DUP1 004E 60 PUSH1 0x55 0050 83 DUP4 0051 86 DUP7 0052 60 PUSH1 0x7f 0054 56 *JUMP PUSH1 0x55:又是一个返回地址入栈,我们执行完number1 += 0x4321的逻辑之后需要回来。 PUSH1 0x7f,*JUMP:跳转到0x7f。 0x7f这一段对应var3 = func_007F(var4, var5);007F 5B JUMPDEST 0080 60 PUSH1 0x00 0082 82 DUP3 0083 19 NOT 0084 82 DUP3 0085 11 GT 0086 15 ISZERO 0087 60 PUSH1 0x9f 0089 57 *JUMPI 这里的代码可能有点让人费解,为什么会有GT(大小比较)?因为这里要做一个加法,而加法是有可能超出uint256的范围导致翻转的。如何判断a+b的结果有没有翻转?判断a是否大于~b,如果大于则没有翻转。在上面的例子里,是把0x4321进行了按位取反,判断0x1234是否大于取反后的数字。这里的当然没有翻转,所以GT结果为0,ISZERO结果为1,跳转0x9f(0x8A开始是加法翻转的处理代码,这里并未贴出)。009F 5B JUMPDEST 00A0 50 POP 00A1 01 ADD 00A2 90 SWAP1 00A3 56 *JUMP 执行加法,0x1234+0x4321,跳转0x55(刚才保存的返回地址)。可以看到加法的结果0x5555已经保存在栈中了。0055 5B JUMPDEST 0056 90 SWAP1 0057 91 SWAP2 0058 55 SSTORE 0059 50 POP 005A 50 POP 005B 50 POP 005C 50 POP 005D 56 *JUMP 最后这一步很简单,SSTORE保存结果。清理栈内容。至此,整个setNumber(0x1234, 0x5678)执行完毕。 我们回顾一下其中setNumberInternal的逻辑部分,基本上都是在不停地jump,并没有call指令。2 external函数代码如下,我们主要关注external函数setNumberExternal的调用方式和执行过程。pragma solidity 0.8.15; contract Test { uint256 number1; uint256 number2; function setNumber(uint256 newNumber1, uint256 newNumber2) external { this.setNumberExternal(newNumber1, newNumber2); number1 += 0x4321; } function setNumberExternal(uint256 newNumber1, uint256 newNumber2) external { number1 = newNumber1; number2 = newNumber2; } } 反汇编代码如下, if (var0 == 0x6b19e8c7)分支对应函数setNumber(uint256,uint256), func_0049对应setNumberExternal(uint256 newNumber1, uint256 newNumber2)和number1 += 0x4321。 else if (var0 == 0xc91bdb7f)分支对应setExternalNumber(uint256,uint256),在我们message call调用setNumberExternal的时候会用到。function main() { ...... if (var0 == 0x6b19e8c7) { // Dispatch table entry for setNumber(uint256,uint256) var var1 = 0x004e; var var2 = 0x0049; var var3 = msg.data.length; var var4 = 0x04; var2, var3 = func_00DD(var3, var4); func_0049(var2, var3); stop(); else if (var0 == 0xc91bdb7f) { // Dispatch table entry for setNumberExternal(uint256,uint256) var1 = 0x004e; var2 = 0x005e; var3 = msg.data.length; var4 = 0x04; var2, var3 = func_00DD(var3, var4); func_005E(var2, var3); stop(); } ...... } function func_0049(var arg0, var arg1) { var temp0 = memory[0x40:0x60]; memory[temp0:temp0 + 0x20] = 0xc91bdb7f << 0xe0; memory[temp0 + 0x04:temp0 + 0x04 + 0x20] = arg0; memory[temp0 + 0x24:temp0 + 0x24 + 0x20] = arg1; var var0 = address(this); var var1 = 0xc91bdb7f; var var2 = temp0 + 0x44; var var3 = 0x00; var var4 = memory[0x40:0x60]; var var5 = var2 - var4; var var6 = var4; var var7 = 0x00; var var8 = var0; var var9 = !address(var8).code.length; if (var9) { revert(memory[0x00:0x00]); } var temp1; temp1, memory[var4:var4 + var3] = address(var8).call.gas(msg.gas).value(var7)(memory[var6:var6 + var5]); var3 = !temp1; if (!var3) { var0 = 0x4321; var1 = 0x00; var2 = var1; var3 = 0x00d4; var4 = var0; var5 = storage[var2]; var3 = func_00FF(var4, var5); storage[var1] = var3; return; } else { var temp2 = returndata.length; memory[0x00:0x00 + temp2] = returndata[0x00:0x00 + temp2]; revert(memory[0x00:0x00 + returndata.length]); } } function func_005E(var arg0, var arg1) { storage[0x00] = arg0; storage[0x01] = arg1; } function func_00DD(var arg0, var arg1) returns (var r0, var arg0) { var var0 = 0x00; var var1 = var0; if (arg0 - arg1 i< 0x40) { revert(memory[0x00:0x00]); } var temp0 = arg1; r0 = msg.data[temp0:temp0 + 0x20]; arg0 = msg.data[temp0 + 0x20:temp0 + 0x20 + 0x20]; return r0, arg0; } 下面从操作码层面分析setNumber(0x1234, 0x5678)的执行过程。 我们从var2, var3 = func_00DD(var3, var4)开始, 下面的代码和上一节Internal里面是一致的,取参数之前,判断calldata足够大。00DD 5B JUMPDEST 00DE 60 PUSH1 0x00 00E0 80 DUP1 00E1 60 PUSH1 0x40 00E3 83 DUP4 00E4 85 DUP6 00E5 03 SUB 00E6 12 SLT 00E7 15 ISZERO 00E8 61 PUSH2 0x00f0 00EB 57 *JUMPI 然后是取参数。00F0 5B JUMPDEST 00F1 50 POP 00F2 50 POP 00F3 80 DUP1 00F4 35 CALLDATALOAD 00F5 92 SWAP3 00F6 60 PUSH1 0x20 00F8 90 SWAP1 00F9 91 SWAP2 00FA 01 ADD 00FB 35 CALLDATALOAD 00FC 91 SWAP2 00FD 50 POP 00FE 56 *JUMP 取到参数后,0x1234和0x5678被放入栈中。接下来,终于来到了体现external函数和internal函数不同的地方。 external函数的调用时一个message call,需要构造calldata。calldata时函数签名hash前4字节+函数参数。所以首先需要的是setNumberExternal(uint256,uint256)的hash前4字节,它是0xc91bdb7f。0069 5B JUMPDEST 006A 60 PUSH1 0x40 006C 51 MLOAD 006D 63 PUSH4 0xc91bdb7f 0072 60 PUSH1 0xe0 0074 1B SHL 0075 81 DUP2 0076 52 MSTORE 注意evm数据操作的基本单位是32字节,而我们只需要4字节,所以多了一步移位操作(SHL),左移224位(28字节)。然后使用MSTORE命令,把它复制到了memory中。(这里memory可以看成一个比栈空间更大的存储区域,因为存储函数参数的话,栈可能会不够用,用到了memory) memory的起始位从默认0x80开始,0x80之前有其他用途。 0x40存放的就是这个起始位置(自由空间指针)。 https://docs.soliditylang.org/en/latest/internals/layout_in_memory.html接下来我们还需要放入函数的2个参数。 这段代码我们还看到了一些ADD指令,其实这里是在计算Memory里面的偏移地址,刚才已经放入了0xc91bdb7f,所以再往里放参数的时候需要+0x4,第二次需要+0x24。0077 60 PUSH1 0x04 0079 81 DUP2 007A 01 ADD 007B 83 DUP4 007C 90 SWAP1 007D 52 MSTORE 007E 60 PUSH1 0x24 0080 81 DUP2 0081 01 ADD 0082 82 DUP3 0083 90 SWAP1 0084 52 MSTORE 这段代码我们还看到了一些ADD指令,其实这里是在计算Memory里面的偏移地址,刚才已经放入了0xc91bdb7f,所以再往里放参数的时候需要+0x4,第二次需要+0x24。至此,calldata数据组装完毕。 message call需要的不仅是calldata,还需要sender和value。 在这里,sender就是当前合约的地址,通过ADDRESS指令获得。 后面两条指令再次让函数签名入栈,似乎有点没必要,可能是为了后面的使用。0085 30 ADDRESS 0086 90 SWAP1 0087 63 PUSH4 0xc91bdb7f 下面的代码则是计算了memory中calldata的始末位置,在这个例子里是0x80 ~ 0x80+0x44(即0xc4)008C 90 SWAP1 008D 60 PUSH1 0x44 008F 01 ADD 0090 60 PUSH1 0x00 0092 60 PUSH1 0x40 0094 51 MLOAD 计算calldata的大小(0x44)0095 80 DUP1 0096 83 DUP4 0097 03 SUB EXTCODESIZE获取即将调用合约的代码大小(这个例子里就是自己),判断是否为0. 如果合约代码大小为0,说明地址对应的是并非一个合约,无法完成message call,会revert(0xA5代码,未展示)。0098 81 DUP2 0099 60 PUSH1 0x00 009B 87 DUP8 009C 80 DUP1 009D 3B EXTCODESIZE 009E 15 ISZERO 009F 80 DUP1 00A0 15 ISZERO 00A1 61 PUSH2 0x00a9 00A4 57 *JUMPI 合约代码校验通过后,跳转到0xa9. 通过GAS指令获得msg.gas。这个数字代表message cal中可以使用的gas。至此,message call所需要的数据准备完毕。 我们可以在栈内00-06分别对应:gas、sender address、value、argsOffset、argsSize、retOffset、retSize。 栈内argsOffset、argsSize指定了memory中参数真正的存储位置。 retOffset、retSize,是message call完成之后,返回值在memory中存放的位置和大小,本例中没有返回值,忽略。接下来会执行call函数,开始message call。 执行之后,我们可以看到,stack和memory的内容都刷新了,实际上它们都开启了一个新的实例。 然后,需要从新的calldata(也就是刚才构造的calldata)中获取参数来执行setNumberExternal(uint256 newNumber1, uint256 newNumber2)的逻辑。这里并不能直接定位到setNumberExternal所在的代码,而是像外部账户发起合约交易一样,需要根据函数签名寻找函数。这里其实是回到了main函数之中。 在经过一些if判断之后,会走到else if (var0 == 0xc91bdb7f) 这里。 然后进入func_00DD(var3, var4),从calldata中获取参数,这个刚才已经分析过了,不再赘述。 接下来进入func_005E(var2, var3)。005E 5B JUMPDEST 005F 60 PUSH1 0x00 0061 91 SWAP2 0062 90 SWAP1 0063 91 SWAP2 0064 55 SSTORE 0065 60 PUSH1 0x01 0067 55 SSTORE 0068 56 *JUMP 这一段很容易理解,就是用SSTORE指令把0x1234和0x5678赋值给storage上的两个变量。 接下来是STOP,消息调用的返回。004E 5B JUMPDEST 004F 00 *STOP 至此,我们的message call结束。message call结束之后,其对应的stack和memory都会销毁,我们再次回到了之前的stack和memory。栈顶存储的应该是message call的执行状态,1表示成功。 确认message call成功之后,别忘了我们还有number1 += 0x4321这个逻辑。 这些逻辑第1节里已经讲过,不再赘述。00BD 5B JUMPDEST 00BE 50 POP 00BF 50 POP 00C0 50 POP 00C1 50 POP 00C2 61 PUSH2 0x4321 00C5 60 PUSH1 0x00 00C7 80 DUP1 00C8 82 DUP3 00C9 82 DUP3 00CA 54 SLOAD 00CB 61 PUSH2 0x00d4 00CE 91 SWAP2 00CF 90 SWAP1 00D0 61 PUSH2 0x00ff 00D3 56 *JUMP ...... 00FF 5B JUMPDEST 0100 60 PUSH1 0x00 0102 82 DUP3 0103 19 NOT 0104 82 DUP3 0105 11 GT 0106 15 ISZERO 0107 61 PUSH2 0x0120 010A 57 *JUMPI ...... 0120 5B JUMPDEST 0121 50 POP 0122 01 ADD 0123 90 SWAP1 0124 56 *JUMP ...... 00D4 5B JUMPDEST 00D5 90 SWAP1 00D6 91 SWAP2 00D7 55 SSTORE 00D8 50 POP 00D9 50 POP 00DA 50 POP 00DB 50 POP 00DC 56 *JUMP ...... 004E 5B JUMPDEST 004F 00 *STOP 可以看出,external函数调用是message call,它比internal函数调用要麻烦很多,需要构造calldata,需要新开stack和memory的实例,而internal函数调用只需要简单的指令跳转。 ## Publication Information - [rbtree](https://paragraph.com/@rbtree/): Publication homepage - [All Posts](https://paragraph.com/@rbtree/): More posts from this publication - [RSS Feed](https://api.paragraph.com/blogs/rss/@rbtree): Subscribe to updates