Solidity 内联汇编(Inline Assembly)完全指南:与 Yul 交互、内存管理与 memory-safe 注解

发布时间:2026/9/12 14:21:08
Solidity 内联汇编(Inline Assembly)完全指南:与 Yul 交互、内存管理与 memory-safe 注解 Solidity 内联汇编Inline Assembly完全指南与 Yul 交互、内存管理与 memory-safe 注解【免费下载链接】soliditySolidity, the Smart Contract Programming Language项目地址: https://gitcode.com/GitHub_Trending/so/solidity导读本文基于 Solidity 官方文档 docs/assembly.rst 编写系统讲解如何在 Solidity 代码中嵌入 Yul 内联汇编assembly { ... }包括与外围 Solidity 变量的交互规则值类型、memory、calldata、storage、外部函数指针、Solidity 的内存管理约定自由内存指针、scratch space、0x60 零值区以及memory-safe注解的严格定义与进阶用法。读完本文你将能够在库代码中安全地使用内联汇编完成extcodesize/extcodecopy等纯 Solidity 难以优雅表达的操作绕过数组越界检查优化热点循环并正确声明汇编块的内存安全性从而让 Yul IR 代码生成管线可以放心地使用栈转内存与内存优化。什么是内联汇编内联汇编允许你把接近以太坊虚拟机EVM指令级别的代码嵌入 Solidity 语句中。相比纯 Solidity它能提供更细粒度的控制在以下两类场景中尤其有用通过编写库来增强语言本身实现纯 Solidity 做不到或做起来很别扭的操作优化 Gas 消耗绕开编译器在某些场景下生成的冗余检查。内联汇编使用的语言是Yul本仓库中 Yul 的完整文档见 docs/yul.rst。本节只讨论内联汇编如何与周围的 Solidity 代码互相交互Yul 语言本身的语法由 Yul 文档单独说明。内联汇编块以assembly { ... }标记花括号内的代码就是 Yul 代码assembly { // 这里是 Yul 代码 }需要注意不同的内联汇编块之间不共享命名空间你不能在一个汇编块中调用另一个汇编块里定义的 Yul 函数或访问其 Yul 变量。警告内联汇编是在底层访问 EVM 的方式它会绕过 Solidity 的多项重要安全特性与检查。只应在确实需要它的任务中使用并且前提是你对它有充分把握。从编译器实现角度看内联汇编块在语法解析阶段就与普通语句区分开在 libsolidity/parsing/Parser.cpp 中assembly关键字Token::Assembly会直接进入parseInlineAssembly该函数通过yul::EVMDialect::strictAssemblyForEVM(m_evmVersion)为汇编块选定与当前 EVM 版本对应的严格汇编方言并支持紧跟的字符串字面量当前仅允许evmasm用于将来切换方言以及圆括号内的 flag 列表assembly (memory-safe) { ... }中的memory-safe就是在这里被解析出来的。示例一用库读取外部合约代码下面的库代码读取另一个合约的代码并加载进bytes变量。用纯 Solidity也可以做到这一点直接使用address.code但这里的关键在于可复用的汇编库可以在不修改编译器的情况下增强 Solidity 语言。// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.4.16 0.9.0; library GetCode { // 这会报告一个警告 - at 将被提升为保留关键字 function at(address addr) public view returns (bytes memory code) { assembly { // 获取代码大小这需要汇编 let size : extcodesize(addr) // 分配输出字节数组 - 不用汇编也能做到 // code new bytes(size) code : mload(0x40) // 新的 memory end包含 padding mstore(0x40, add(code, and(add(add(size, 0x20), 0x1f), not(0x1f)))) // 在内存中存储长度 mstore(code, size) // 实际读取代码这需要汇编 extcodecopy(addr, add(code, 0x20), 0, size) } } }这段代码演示了内联汇编与 Solidity 交互的经典模式Yul 中的extcodesize/extcodecopy返回/写入的值直接赋给 Solidity 函数返回值codememory 引用变量在汇编中求值为其内存地址而内存分配则遵循 Solidity 的自由内存指针约定见下文内存管理一节。示例二用汇编优化热点循环当优化器无法生成高效代码时内联汇编也能派上用场。下面的VectorSum库给出了三个版本// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.4.16 0.9.0; library VectorSum { // 这个函数效率较低因为优化器目前无法消除数组访问中的越界检查 function sumSolidity(uint[] memory data) public pure returns (uint sum) { for (uint i 0; i data.length; i) sum data[i]; } // 我们知道只会越界内访问数组所以可以避免检查。 // 需要给数组加上 0x20因为第一个槽存放的是数组长度。 function sumAsm(uint[] memory data) public pure returns (uint sum) { for (uint i 0; i data.length; i) { assembly { sum : add(sum, mload(add(add(data, 0x20), mul(i, 0x20)))) } } } // 与上面相同但整个循环都在内联汇编内完成 function sumPureAsm(uint[] memory data) public pure returns (uint sum) { assembly { // 读取长度前 32 字节 let len : mload(data) // 跳过长度字段。 // // 保留临时变量以便就地递增。 // // 注意递增 data 会导致 data 变量在此汇编块之后不可用 let dataElementLocation : add(data, 0x20) // 迭代直到不满足边界条件 for { let end : add(dataElementLocation, mul(len, 0x20)) } lt(dataElementLocation, end) { dataElementLocation : add(dataElementLocation, 0x20) } { sum : add(sum, mload(dataElementLocation)) } } } }三个函数依次展示了三种策略纯 Solidity 循环带越界检查、内联汇编配合 Solidity 循环跳过检查、完全在汇编内实现循环。sumAsm中的add(data, 0x20)是因为 memory 数组的第一个槽存放长度sumPureAsm特意用一个临时变量dataElementLocation来遍历而不是递增data本身否则汇编块结束后data变量将指向错误位置而不可用。访问外部变量、函数与库可以通过名字访问 Solidity 变量和其他标识符。不同类别变量的可访问性差异很大变量类别在汇编中的求值结果可否赋值值类型局部变量uint、address等直接是值本身可读可写引用 memory 的局部变量数组、struct、bytes等变量在内存中的地址不是值可写但只改变指针不改变数据引用静态大小 calldata 数组 / calldata struct 的局部变量变量在 calldata 中的地址可赋新偏移但不校验是否越过calldatasize()外部函数指针通过x.address地址和x.selector选择器访问二者均可赋值动态 calldata 数组通过x.offset字节偏移和x.length元素个数访问均可赋值不校验边界局部 storage 变量 / 状态变量含 transient storage通过x.slot槽号和x.offset槽内字节偏移访问局部 storage 指针的.slot可赋值状态变量的.slot/.offset不可赋值几个要点memory 引用变量赋值只改指针对 memory 引用变量赋值只改变指针而非数据且需要你自己负责遵守 Solidity 的内存管理约定见 docs/internals/layout_in_memory.rst 的Solidity 中的约定。calldata 静态数组 / struct同样求值为 calldata 地址可以赋新偏移但不做任何校验需要自己确保不会越过calldatasize()。外部函数指针选择器是四个右对齐的字节。例如下面的combineToFunctionPointer函数为返回值fun拼接新的选择器和地址// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.10 0.9.0; contract C { // 为返回值 fun 赋新的选择器和地址 function combineToFunctionPointer(address newAddress, uint newSelector) public pure returns (function() external fun) { assembly { fun.selector : newSelector fun.address : newAddress } } }storage 变量storage 变量不一定占用一个完整存储槽其地址由**槽号slot和槽内字节偏移offset**两部分组成。必须用x.slot和x.offset分别访问直接使用x本身会报错。局部 storage 变量指针struct、数组或 mapping的.offset部分恒为零且只有局部 storage 指针的.slot可以赋值状态变量的.slot/.offset都不可赋值。下面的示例同时演示了 storage 槽访问与 transient storage瞬态存储的写入其中tstore是 0.8.28 起引入的瞬态存储指令transient storage 的完整说明见 docs/transient-storage.rst// SPDX-License-Identifier: GPL-3.0 pragma solidity 0.8.28 0.9.0; // 这会报告一个警告 contract C { bool transient a; uint b; function f(uint x) public returns (uint r) { assembly { // 我们忽略存储槽偏移在这种特殊情况下我们知道它是零 r : mul(x, sload(b.slot)) tstore(a.slot, true) } } }关于窄类型的高位清理警告如果访问的变量类型小于 256 位例如uint64、address或bytes16你不能对不属于该类型编码的位做任何假设尤其不要假定它们是零。为了安全务必在使用前清理数据uint32 x f(); assembly { x : and(x, 0xffffffff) /* 现在可以放心使用 x */ }对于有符号类型可以使用signextend指令清理assembly { signextend(num_bytes_of_x_minus_one, x) }命名约束0.6.0 / 0.7.0 起自 Solidity 0.6.0 起内联汇编变量的名字不得遮蔽汇编块作用域内可见的任何声明包括变量、合约和函数声明。自 Solidity 0.7.0 起汇编块内部声明的变量和函数不得包含.但使用.访问汇编块外部的 Solidity 变量是合法的例如上面的b.slot、fun.address。若使用 Yul-only 模式编写 Solidity则仍然允许使用点号。要避免的事情内联汇编看起来层次很高但实际上极其底层。函数调用、循环、if和switch都会通过简单的重写规则转换之后汇编器为你做的只剩下重排函数式风格的 opcode、为变量访问统计栈高度、以及当汇编局部变量所在块结束时移除其栈槽位。这意味着你基本是在直接和 EVM 栈打交道——写内联汇编时必须时刻清楚栈的布局和每条指令对栈的精确影响。Solidity 中的约定窄类型变量的值与 EVM 汇编不同Solidity 拥有小于 256 位的类型如uint24。为了效率大部分算术运算会忽略类型可以短于 256 位这一事实高位只在必要时才被清理即在写入内存之前、或在执行比较之前。因此在汇编中访问这类变量时你可能需要手动先清理高位见上文关于窄类型的高位清理。内存管理Solidity 按以下方式管理内存内存位置0x40处存放自由内存指针free memory pointer。要分配内存就从该指针指向的位置开始使用然后更新指针。无法保证该内存此前未被使用因此不能假设其内容为零。没有内置机制释放或回收已分配的内存。Solidity 不保证也不要求内存中的值按某个数值对齐放置。下面是可以遵循上述流程分配内存的 Yul 片段function allocate(length) - pos { pos : mload(0x40) mstore(0x40, add(pos, length)) }内存布局的关键区间内存前 64 字节偏移 0x00 ~ 0x3f可用作短期分配的scratch space自由内存指针之后的 32 字节即从0x60开始应永久保持为零它被用作空动态内存数组的初始值因此可分配内存从0x80开始这也是自由内存指针的初始值。其他约定Solidity 内存数组中的元素总是占用 32 字节的整数倍bytes1[]也如此但bytes和string例外多维内存数组是指向内存数组的指针动态数组的长度存储在该数组的第一个槽其后才是数组元素。警告静态大小的内存数组没有长度字段但未来可能会加上它以便更好地在静态与动态数组之间转换因此不要依赖这一现状。内存安全memory-safe不使用内联汇编时编译器可以始终信赖内存处于良好定义的状态。这一点对Yul IR 新代码生成管线见 docs/ir-breaking-changes.rst尤其重要该管线可以为了避免 stack-too-deep 错误而把局部变量从栈移动到内存并在能依赖内存使用假设时执行额外的内存优化。虽然官方建议始终遵守 Solidity 的内存模型但内联汇编允许你以不兼容的方式使用内存。因此只要存在任何包含内存操作、或对 Solidity 内存变量赋值的汇编块栈变量转内存和额外内存优化默认就会在全局被禁用。你可以用注解显式声明某个汇编块确实遵守 Solidity 的内存模型assembly (memory-safe) { ... }具体而言一个 memory-safe 的汇编块只能访问以下内存范围由你自己分配的、通过类似上文allocate的机制分配的内存由 Solidity 分配的内存例如你引用的内存数组边界内的内存上面提到的偏移 0 到 64 之间的 scratch space位于汇编块开始时自由内存指针所指向位置之后的临时内存即在自由内存指针处分配且没有更新自由内存指针的内存。此外如果汇编块对 Solidity 内存变量赋值你需要确保对这些 Solidity 变量的访问也只落在上述范围内。由于这主要关乎优化器即使汇编块 revert 或终止这些限制仍然必须遵守。例如下面这段汇编不安全因为returndatasize()的值可能超过 64 字节的 scratch spaceassembly { returndatacopy(0, 0, returndatasize()) revert(0, returndatasize()) }而下面这段是安全的因为自由内存指针指向位置之后的内存可以放心用作临时 scratch spaceassembly (memory-safe) { let p : mload(0x40) returndatacopy(p, 0, returndatasize()) revert(p, returndatasize()) }注意如果后续没有分配需求你不需要更新自由内存指针但只能使用从自由内存指针当前偏移开始的内存。如果内存操作的长度为零使用任意偏移都是安全的不限于 scratch space 内assembly (memory-safe) { revert(0, 0) }还要注意内存不安全不仅来自汇编块内的内存操作也来自对 Solidity 引用类型内存变量的赋值。例如下面这段就是内存不安全的把x指向0x40随后的高层写入会破坏自由内存指针附近的数据bytes memory x; assembly { x : 0x40 } x[0x20] 0x42;既不涉及内存访问、也不对 Solidity 内存变量赋值的汇编块会被自动视为 memory-safe无需注解。警告确保汇编真的满足内存模型是你的责任。如果你把汇编块标记为 memory-safe 却违反了内存假设将导致无法通过测试轻易发现的错误和未定义行为。从源码实现看memory-safe 注解的处理横跨多个编译阶段语法解析assembly (memory-safe)中的 flag 列表在 libsolidity/parsing/Parser.cpp 中解析语义校验在 libsolidity/analysis/SyntaxChecker.cpp 中只有memory-safe是合法 flag重复标记会触发7026_errorInline assembly marked memory-safe multiple times.未知 flag 会触发4430_error警告 Unknown inline assembly flag校验结果记录在 AST 注解字段markedMemorySafe中见 libsolidity/ast/ASTAnnotations.h优化器限制同一文件还检查到启用 Yul 优化器时汇编块内禁止使用msize指令6553_error因为它会改变语义见 libsolidity/analysis/SyntaxChecker.cpp。仓库测试中也存在大量memory-safe的实际用例例如 test/libsolidity/semanticTests/externalContracts/snark.sol 等语义测试可作参考。跨版本兼容的旧式注解如果你在开发一个要兼容多个 Solidity 版本的库可以用特殊注释来注解汇编块为 memory-safe/// solidity memory-safe-assembly assembly { ... }该特殊注释的解析与弃用警告实现在 libsolidity/analysis/DocStringTagParser.cpp 中。警告memory-safe-assembly特殊注释已弃用并计划移除。面向新编译器的代码请使用汇编块注解assembly (memory-safe)。内存的进阶安全用法除了上面严格定义的内存安全范围某些情况下你可能想使用从内存偏移0开始、超过 64 字节的 scratch space。只要足够小心使用到不含0x80偏移的内存、同时仍然安全地把汇编块声明为memory-safe也是允许的前提满足以下任一条件汇编块结束时偏移0x40处的自由内存指针被恢复为合理值要么恢复为原值要么因手动内存分配而递增并且偏移0x60处的内存字被恢复为零汇编块终止即执行永远不会回到高层 Solidity 代码。例如汇编块无条件以revert指令结束时即属此情形。此外你还需要注意Solidity 中动态数组的默认值指向内存偏移0x60因此在临时修改0x60处值的期间读取动态数组时将无法再依赖其准确的长度值直到你把0x60恢复为零。更准确地说只有当汇编片段剩余部分不与高层 Solidity 对象的内存交互包括不读取之前存储在变量中的偏移所指向的内存时才保证覆盖这个零指针是安全的。总结内联汇编是 Solidity 中能力最强也最危险的特性之一。它的价值集中在两处一是以库的形式扩展语言能力如读取外部合约代码二是在确认安全的场景下跳过编译器的冗余检查以优化 Gas如遍历内存数组。使用时的三条主线贯穿始终变量交互规则值类型直接用值memory/calldata 引用用地址指针storage 用.slot/.offset外部函数指针用.address/.selector内存约定尊重0x40自由内存指针、0x60零值区与0x80起可分配内存的模型不假设内存内容为零内存安全声明仅在汇编块确实只访问允许的四类内存范围时标注assembly (memory-safe)否则编译器将全局禁用栈转内存与相关内存优化而错误标注则会导致难以排查的未定义行为。相关的进一步阅读材料均位于本仓库 docs 目录下Yul 语言本身见 docs/yul.rst内存布局见 docs/internals/layout_in_memory.rst存储布局见 docs/internals/layout_in_storage.rstcalldata 布局见 docs/internals/layout_in_calldata.rst瞬态存储见 docs/transient-storage.rstYul IR 管线的破坏性变更见 docs/ir-breaking-changes.rst。【免费下载链接】soliditySolidity, the Smart Contract Programming Language项目地址: https://gitcode.com/GitHub_Trending/so/solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考