跳到主要内容

Solidity 编译配置详解

· 阅读需 25 分钟

同大多数编程语言一样,用 Solidity 编写的智能合约无法直接在以太坊虚拟机(EVM)上运行,必须先将其编译成字节码。编译需要使用 Solidity 编译器

Solidity 编译器c++ 语言编写的,因此对于操作系统而言,可以很轻易的编译成二进制命令行程序。而对于浏览器环境则需要使用工具 Emscriptenc++ 代码编译成 js 代码。

相关仓库:

  • Solidity 编译器
  • Solidity 编译器 各版本被编译在不同平台上的文件列表 solc-bin
  • nodejs版本的编译器 solc-js,是对被编译成的 js 代码二次封装, 新增了命令行, 支持模块化等。智能合约开发框架 hardhat 正是用的该框架进行智能合约的编译。

具体的编译过程则是将指定结构的 JSON 数据输入到编译器中, 编译完成后输出编译结果。

在开始之前需要先介绍下抽象语法树(AST)。

抽象语法树

Solidity 转换为字节码,可以看成是由一种语言转换成另一种语言的过程。因此需要一种中间介质来承载原语言解析的结果,再将中间介质转换为目标语言,这种介质就是抽象语法树。也是进行语法转换的基础。如果你是一名 js 开发者,相信对这个概念并不陌生。

抽象语法树的构建:

  • 词法分析:将源代码分解成一系列的标记(token),token 是语言中的基本语法单位,例如关键字、标识符、运算符和标点符号等。
  • 语法分析:语法分析器会根据 Solidity 的语法规则,将这些 token 组合成语法结构,如表达式、声明和语句等。
  • 构建抽象语法树:在语法分析的基础上,编译器构建一个树形结构,即抽象语法树。每个节点代表一个语言构造,例如一个函数定义、一个变量声明或一个运算表达式。这棵树反映了源代码的层级和结构关系。
  • 语义分析:虽然不是直接构建 AST 的一部分,但语义分析是确保 AST 正确性的重要步骤。在这个阶段,编译器会检查类型一致性、变量和函数的作用域、以及其他语义规则。
  • 优化:在某些情况下,AST 在生成最终的机器代码或字节码之前会经过优化,以提高效率和性能。

例如对于智能合约

// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.21;
contract Main { function hack() public { } }

转换成的抽象语法树为

const ast = {
absolutePath: 'main',
exportedSymbols: {
Main: [6]
},
id: 7,
license: 'GPL-3.0',
nodeType: 'SourceUnit',
nodes: [
{
id: 1,
literals: ['solidity', '^', '0.8', '.21'],
nodeType: 'PragmaDirective',
src: '42:24:0'
},
{
abstract: false,
baseContracts: [],
canonicalName: 'Main',
contractDependencies: [],
contractKind: 'contract',
fullyImplemented: true,
id: 6,
linearizedBaseContracts: [6],
name: 'Main',
nameLocation: '82:4:0',
nodeType: 'ContractDefinition',
nodes: [
{
body: {
id: 4,
nodeType: 'Block',
src: '112:3:0',
statements: []
},
functionSelector: '4de260a2',
id: 5,
implemented: true,
kind: 'function',
modifiers: [],
name: 'hack',
nameLocation: '98:4:0',
nodeType: 'FunctionDefinition',
parameters: {
id: 2,
nodeType: 'ParameterList',
parameters: [],
src: '102:2:0'
},
returnParameters: {
id: 3,
nodeType: 'ParameterList',
parameters: [],
src: '112:0:0'
},
scope: 6,
src: '89:26:0',
stateMutability: 'nonpayable',
virtual: false,
visibility: 'public'
}
],
scope: 7,
src: '73:44:0',
usedErrors: [],
usedEvents: []
}
],
src: '42:75:0'
}

编译输入

Solidity 编译是将指定结构的 JSON 数据输入到编译器中, 编译完成后输出编译结果。

编译输入包括下列字段

数据类型见源码中 parseInput 方法

language

使用的代码语言, 支持 SolidityYulSolidityAST

Yul 是一种底层语言,它的抽象级别更低,更接近以太坊虚拟机的操作。可以在 Solidity 中通过内联汇编的方式直接编写 Yul 代码。同时 Yul 存在优化器,可以执行诸如简化表达式、消除未被使用的代码以及合并相同代码等操作,以减小生成的字节码大小和运行时的 Gas 消耗。

SolidityAST 则是直接输入 AST 到编译器中, 省略了编译成 AST 的过程。

sources

代码字符串, 结构为

sources: {
'main.sol': {
keccak256: '0x...',
content: `// SPDX-License-Identifier: GPL-3.0
pragma solidity ^0.8.21;
contract Main { uint a = 1; function add() public { a += 1; } }`
urls: [
"bzzr://...",
"ipfs://...",
"/path"
]
},
'file-key': {
...
},
}

sources 字段提供需要编译的文件 key 和文件内容的映射,文件 key 通常设置为文件名。文件内容有以下字段

  • keccak256, 可选,源码内容的keccak256哈希值
  • content: 需要编译的源码内容字符串
  • urls: 需要编译的源码内容链接, contenturls 只需要存在一个即可。同时存在时则只读取 content, 忽略 urls

settings

详细编译配置, 包括以下字段:

stopAfter

在达到给定阶段后停止编译, 目前仅支持传入 parsing

编译阶段有

enum State {
Empty, // 初始状态
SourcesSet, // 设置需要编译的源码内容
Parsed, // 解析AST完成
ParsedAndImported, // 语言为 SolidityAST 时存在的阶段, 表示已解析并导入了AST
AnalysisSuccessful, // 对AST进行分析完成, 包括语法检查、注释识别等
CompilationSuccessful // 编译完成
};

如果传入了 stopAfter, 则设置该字段值为 Parsed, 表示 AST 解析完成后停止编译。

if (settings.isMember("stopAfter"))
{
if (!settings["stopAfter"].isString())
return formatFatalError(Error::Type::JSONError, "\"settings.stopAfter\" must be a string.");

if (settings["stopAfter"].asString() != "parsing")
return formatFatalError(Error::Type::JSONError, "Invalid value for \"settings.stopAfter\". Only valid value is \"parsing\".");

ret.stopAfter = CompilerStack::State::Parsed;
}

默认为 CompilationSuccessful, 表示编译完成则停止。

viaIR

是否通过 IR 编译,默认为 false

IR(Intermediate Representation) 翻译为中间表示, 是编译器设计和编程语言理论领域的一个重要概念。它作为程序代码的中间形式存在,位于高级源代码和底层机器代码之间。

在以太坊智能合约编译的背景下,中间表示 代表 Yul 代码。如果 viaIRtrue, 则会将 Solidity 源代码转换为 Yul 中间代码,并在转换为最终的字节码之前使用 Yul 优化器优化 Yul 代码,因此建议将该字段设置为 true

evmVersion

以太坊虚拟机版本, 存在以下版本:

  • homestead
  • tangerineWhistle
  • spuriousDragon
  • byzantium
  • constantinople
  • petersburg
  • istanbul
  • berlin
  • london
  • paris
  • shanghai

仅支持 constantinople 以上的版本

eofVersion

EVM Object Format(EOF)的版本, 一种新的以太坊虚拟机字节码格式,目前仅支持设置为 1

debug

调试设置, 包括以下字段

  • revertStrings: 如何处理编译器自动插入以及合约代码中的 revertrequire 报错的字符串, 有以下取值:
    • default 默认值,不注入编译器生成的 revert 字符串,仅保留合约代码中提供的报错字符串
    • strip 移除所有 revert 字符串
    • debug 仅注入编译器生成的 revert 字符串
    • verboseDebug 暂未实现
  • debugInfo: 包含的调试位置信息, 值为字符串数组, 取值
    • location@src <index>:<start>:<end>形式注释,指示原始 Solidity 文件中相应元素的位置
    • snippet@src指示的位置处的单行代码片段
    • * 包括以上两种

remappings

路径映射, 在编译的合约文件中以更简洁的方式导入其他文件。例如有合约的路径是 /usr/local/contracts/Animal.sol, 定义 remappings 字段为

{
"remappings": [
"libs/=usr/local/contracts/"
]
}

则在需要编译的合约中通过路径映射的方式引入,而不必写入完整路径

import "libs/Animal.sol";
contract Cat is Animal {}

optimizer

优化器设置。

Solidity 编译器中的优化器会试图简化复杂的表达式,减少代码大小和执行成本,即可以减少合约部署以及对合约进行外部调用所需的 gas

目前存在两种不同的优化器模块:

  • 基于操作码级别的旧优化器
  • 基于 Yul IR 代码的新优化器

在编译输入中, 优化器设置可以传入下列字段:

  • enabled:是否启用优化器, 默认为false,
  • runs: 已部署代码在合约的生命周期内预计会被执行的次数。这个值帮助优化器决定应该如何平衡代码的初始部署成本和执行成本。如果合约会频繁执行,可以设置一个较高的值,使得优化器倾向于优化执行时的gas 消耗,也就意味着部署成本会更高。相反的,较低的值则会减少部署成本并增加执行成本。通常设置为 200,v3-core 则设置为了 800
  • details: 优化细节, 存在值时则会忽略enabled
    • peephole: 是否启用窥孔优化, 默认开启
    • inliner: 是否启用内联函数, 例如在合约代码中多次调用某个函数, 则内联会把函数调用替换为函数实现。内联的决策基于 runs 参数,优化器会计算合并后的代码存储成本和执行成本,并与执行了 runs 次数的预估成本比较,如果内联后整体更省成本,则会进行内联操作。
    • jumpdestRemover: 是否移除不必要的 JUMPDEST 指令
    • orderLiterals: 是否重新排列交换操作中的数据
    • deduplicate: 是否删除重复的代码块
    • cse: 是否开启 Common Subexpression Eliminator, 其负责识别和合并在多个地方重复出现的相同表达式。并将这些表达式计算一次然后复用其结果
    • constantOptimizer: 是否启用常量优化,用于处理常量表达式, 如代码中有 3 + 5 这样的常量表达式,constantOptimizer 会将其直接替换为计算结果 8,以此减少每次调用时的计算开销
    • yul: 启用 yul 优化器, 在 Solidity 0.6.0 前需手动启用
    • simpleCounterForLoopUncheckedIncrement: 在 for 循环中对每次增加的循环次数忽略溢出检查
    • yulDetailsyul 优化器设置, 当 yul 设置为 true 时生效
      • stackAllocation: 是否改进变量的堆栈插槽分配,可以提前释放堆栈插槽
      • optimizerSteps: 优化步骤, 见optimizer-steps

libraries

在合约代码中,如果引入了库,并且调用的库函数是 internal 时,则编译器会将库函数的代码直接嵌入到调用的合约中。如果库包含 publicexternal 函数,则可以被部署到区块链上,在调用库中的 publicexternal 函数时,编译器则会通过库地址去调用库函数。

libraries 字段指定了合约中调用的库合约地址,如

"libraries": {
// main.sol 对应于 sources 字段中的文件 key
"main.sol": {
"SafeMath": "0x..." // 库名称及其地址
}
}

metadata

通过编译器命令 solc main.sol --metadata 可以生成合约的 metadata 数据, 如下所示

{
"compiler": {
"version": "0.8.22+commit.4fc1097e"
},
"language": "Solidity",
"output": {
"abi": [...],
// 见 https://docs.soliditylang.org/en/latest/natspec-format.html
"devdoc": {...},
"userdoc": {...}
},
"settings": {
"compilationTarget": {
"main.sol": "Main"
},
"evmVersion": "shanghai",
"libraries": {},
"metadata": {
"bytecodeHash": "ipfs"
},
"optimizer": {
"enabled": false,
"runs": 200
},
"remappings": []
},
"sources": {
"main.sol": {
"keccak256": "0x9f3260bd62699741761323058d94da76ee11a7b7e35387b9bafae9e901b0aa18",
"license": "GPL-3.0",
"urls": [
"bzz-raw://93271a80165a0846ac1fb451e7d99d1700963758a21b6b3d20ee1b2374cec839",
"dweb:/ipfs/Qme4eQFfPX88wjF7xCKCxzNnoNPp1ubuGbnJgz74kNhSpi"
]
}
},
"version": 1
}

metadata包含的字段有

  • compiler: 编译器版本
  • language: 代码语言
  • output: 编译输出, 主要包括合约的 ABI 数据
  • settings: 编译合约时设置的编译器配置
  • sources: 编译输入的源码

当编译输入时,针对 metadata 数据存在以下设置

  • appendCBOR: CBOR, 全称是Concise Binary Object Representation, 意为简明二进制对象展现,一种二进制数据序列化格式。 该字段意为是否在编译后的字节码末尾添加metadata hash。设置为 true 时, 首先根据下方 bytecodeHash 哈希方法计算 metadata 内容的哈希值。再通过 CBOR 编码格式添加到编译后的字节码的末尾。
  • useLiteralContent:是否在生成的 metadata 包含合约内容。当设置为 true 时, metadata 中的 sources 会移除 urls, 而增加 content 字段, 用来显示合约内容
  • bytecodeHash:定义计算 metadata hash 的方法 支持以下取值。
    • none: 不计算
    • ipfs: 默认值, 使用 ipfs 的哈希算法计算
    • bzzr1: 使用 bzzr1 的哈希算法计算
字节码

默认情况下编译后的字节码包含三部分内容, 并以 fe 分割

  • init code 或称作 creation code, 这部分包括了合约初始化、构造函数等操作码。
  • runtime code 运行时字节码,主要包括合约方法相关的字节码。当合约被调用时,这部分代码将会被执行
  • metadata hash

modelChecker

理解这个字段前,先介绍下 SMTChecker, 一个静态分析工具。主要用于检查智能合约中的潜在错误。

SMTSatisfiability Modulo Theories(可满足性模理论) 的缩写。这个工具使用形式化验证技术来分析智能合约中的代码。形式化验证是一种验证方法,它使用数学和逻辑上的技术来证明智能合约代码在逻辑上的正确性,以及是否按照预期的结果执行。

modelChecker 是形式化验证使用的具体的数学模型配置,包含了以下字段。

  • engine: 使用的引擎
    • bmc:全称是 Bounded Model Checker, 单独分析合约中的每个函数是否存在错误
    • chc:全称是 Constrained Horn Clauses, 在分析合约中的函数时,会考虑整个合约在无限数量的交易中的行为,因此更适合于全面的检查
    • all: 默认值,同时使用两种引擎
    • none: 不检查
  • bmcLoopIterations: 在合约代码中的 for 循环可能会存在无限次数, 该字段则在 bmc 检查时对循环次数设置一个上限。
  • contracts: 对哪个合约进行检查
  • divModNoSlacks: 进行除法或者取模运算时是否引入松弛(slack)变量。
  • extCalls: 如何处理外部调用, 可设置为 trusteduntrusted; trusted会假定所有外部调用都是可信的且不会引入安全风险或不会有恶意行为,当你对合约的外部调用的安全性足够确定时使用,而且可以减少模型检查器的分析范围,提高分析速度。
  • invariants: 验证特定的不变性(初始状态下为真,并且每次合约状态变化后仍然保持为真), 有:
    • contract: 合约不变性,合约状态变量在每个可能的交易前后都是正确的
    • reentrancy: 重入不变性, 外部调用前后状态变量值都是正确的
  • printQuery: 是否在模型检查器运行时输出查询信息。设置为 true 会打印出发送给 SMT 求解器的每个查询
  • showProvedSafe: 是否输出验证通过的信息
  • showUnproved: 是否输出无法确定安全性的信息
  • showUnsupported: 是否输出模型检查器不支持的功能的信息
  • solvers: 指定用于模型检查的求解器,求解器是用来确定逻辑公式在给定某些约束下是否可满足的工具。常见的求解器有 z3cvc4
  • targets: 检查哪些属性,有:
    • constantCondition: 验证条件语句不是常数,以避免无用的代码。
    • underflow: 检查算术运算是否存在下溢的风险。
    • overflow: 检查算术运算是否存在溢出的风险。
    • divByZero: 确保除法运算中没有除以零的情况。
    • balance: 检查智能合约的余额操作是否符合预期,防止意外的余额变动。
    • assert: 验证 assert 语句永远不会失败。
    • popEmptyArray: 检查空数组不能 pop 元素。
    • outOfBounds: 验证数组越界/字节索引越界
    • all: 检查以上全部属性
  • timeout: 验证超时设置

outputSelection

对输入的合约指定输出结果, 对象结构配置,如下所示:

"outputSelection": {
"main.sol": { // 输入时指定的文件key
"Main": [ "abi", "evm.bytecode.opcodes" ], // 文件内的合约名称及其对应的期望输出结果
"": [ "ast" ] // 输出文件生成的抽象语法树
},
"*": { // 所有文件
"*": [ // 所有合约
"metadata", "evm.bytecode", "evm.bytecode.sourceMap"
],
"": [ "ast"] // 输出所有文件生成的抽象语法树
}
}

keysources 字段指定的文件 key, value 为编译文件中合约名称及其对应的期望输出结果。或者配置为 * 表示对所有文件生效。对于空合约名称则用于整个文件的输出配置(如 AST)。

针对某个具体的合约来说,输出可以配置为

  • *:输出下面所有选项
  • abi:输出合约 abi 内容
  • devdoc:输出合约 devdoc
  • userdoc:输出合约 userdoc
  • metadata:输出合约 metadata
  • ir:输出合约被编译成的 yul 代码
  • irAst:输出合约被编译成的 yul 代码生成的抽象语法树
  • irOptimized:输出合约被编译成的 yul 代码优化后的结果
  • irOptimizedAst:输出合约被编译成的 yul 代码优化后生成的抽象语法树
  • storageLayout:输出合约状态变量的存储布局,包括存储槽编号, 占据的字节大小
  • evm:输出下面所有以 evm 开头的选项
    • evm.assembly:输出合约的底层汇编代码
    • evm.legacyAssembly:输出合约的旧版本底层汇编代码 JSON 结构
    • evm.bytecode:输出下面所有以 evm.bytecode 开头的选项
      • evm.bytecode.functionDebugData:输出合约函数的调试数据
      • evm.bytecode.generatedSources:输出合约编译过程中生成的源代码
      • evm.bytecode.linkReferences:输出合约链接的库信息
      • evm.bytecode.object:输出合约编译后的字节码, 即待部署的合约字节码
      • evm.bytecode.opcodes:输出合约编译后的操作码
      • evm.bytecode.sourceMap:输出合约编译后的字节码与原始源代码之间的映射
    • evm.deployedBytecode:输出下面以 evm.deployedBytecode 开头的选项,deployedBytecode是上文提到的运行时字节码(runtime code),需要在链上存储的代码。
      • evm.deployedBytecode.functionDebugData:输出合约运行时代码中函数的调试数据
      • evm.deployedBytecode.generatedSources:输出合约运行时代码编译过程中生成的源代码
      • evm.deployedBytecode.immutableReferences:输出合约用 immutable 声明的变量在抽象语法树中的映射
      • evm.deployedBytecode.linkReferences:输出合约运行时字节码中链接的库信息
      • evm.deployedBytecode.object:输出合约编译后运行时字节码
      • evm.deployedBytecode.opcodes:输出合约编译后运行时字节码的操作码
      • evm.deployedBytecode.sourceMap:输出合约编译后运行时字节码与原始源代码之间的映射
    • evm.methodIdentifiers:输出合约函数 selector
    • evm.gasEstimates:输出合约预估 gas 信息,包括部署 creation 和外部调用 external

编译输出

编译器编译后的输出结果包括下列字段:

  • errors: 编译过程中出现的错误信息,例如编译输入配置错误、合约缺少了 License、代码出现语法错误等
  • sources: 文件级别的输出,如果在 outputSelection 配置了 ast, 则输出合约文件生成的 ast 信息
  • contracts: 合约级别的输出, 输出结果来自于 outputSelection 中对具体合约的配置

总结

本文详细介绍了 Solidity 编译器编译输入中每个配置字段以及编译输出的每个字段所代表的含义,从基础的设置如代码语言和优化选项,到更高级的功能如元数据的包含和链接库的处理,每一部分都进行了全面的解析。这些知识对于理解和优化 Solidity 智能合约至关重要,不仅有助于提升合约的性能,也有助于增强其安全性和可维护性。

尽管合约编译配置复杂,但在多数应用场景中,使用默认配置即可。只有在需要特定优化时,才需调整配置。