资讯动态

WTF-Solidity 深入 EVM:智能合约外部方法调用与 ABI 编码机制全解析

发布时间:2026/9/15 18:12:22 来源:尧图企业网站定制
WTF-Solidity 深入 EVM智能合约外部方法调用与 ABI 编码机制全解析【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity本文为 WTF-Solidity 仓库中《深入以太坊虚拟机》系列译文第 4 篇的技术解读。该译文源自 Howard 于 2017 年撰写的经典系列文章原编译器版本为 0.4.x但所描述的 EVM 底层工作原理至今仍然适用完整系列见 Topics/Translation/DiveEVM2017/readme.md。智能合约是数据与外界之间的中介以太坊网络上的每一笔状态变更、每一次函数调用本质上都只是一段原始字节在 EVM 中被解释和执行。本文将以方法调用为主线拆解 Solidity 与 EVM 如何让外部程序调用合约方法并引起状态变化——从交易 calldata 的字节结构、方法选择器的计算原理到 ABI 对复杂参数动态数组、string、嵌套数组的编码规则再到编译产物中派发逻辑的汇编实现最终落到 gas 费用结构如何反向塑造了 ABI 的古怪设计。读完本文你将能够手工解析任意一笔交易输入数据、独立计算函数选择器、读懂solc编译出的方法派发汇编并理解 ABI 作为跨语言 RPC 序列化格式的完整内涵。在 Remix 中发起交易后交易详情里的 input 字段即本次调用的完整 calldata前 4 字节为函数选择器来源29_Selector/readme.md交易之于智能合约犹如 HTTP 请求之于 Web 服务外部程序并不限于 DApp 或 JavaScript。任何能够通过 HTTP RPC 与以太坊节点通信的程序都可以通过创建交易与部署在区块链上的任意合约进行交互。创建一个交易就像发出一个 HTTP 请求Web 服务器接受请求并对数据库做出更改交易被网络接受后底层区块链扩展到包含状态变化。这一类比贯穿全篇传统 Web 架构以太坊对应物Web 服务智能合约数据库区块链全球共识状态HTTP 请求交易协议缓冲区Protobuf等数据交换格式ABI让我们看一个把状态变量设置为0x1的交易。被调用的合约拥有变量a的 setter 与 getter使用当时流行的^0.4.11编译pragma solidity ^0.4.11; contract C { uint256 a; function setA(uint256 _a) { a _a; } function getA() returns(uint256) { return a; } }创建一个调用setA(1)的交易后交易输入数据input data为0xee919d500000000000000000000000000000000000000000000000000000000000000001对于 EVM 而言这仅仅是 36 字节的原始数据未经任何处理地被作为calldata传递给智能合约。如果合约是用 Solidity 编写的那么它会把这段输入字节解释为一次方法调用并为setA(1)执行相应的汇编代码。输入数据可以分解为两个子部分# The method selector (4 bytes) 0xee919d50 # The 1st argument (32 bytes) 0000000000000000000000000000000000000000000000000000000000000001前四个字节是方法选择器method selector其余输入数据是以 32 字节为块的方法参数。此例只有一个参数即值0x1。方法选择器函数签名哈希的前 4 字节方法选择器是方法签名的 Keccak256 哈希的前 4 个字节。此例中方法签名为setA(uint256)——即方法的名称加上其参数的类型注意参数名不参与。用 Python 计算选择器使用当年的 pyethereum 库# Install pyethereum https://github.com/ethereum/pyethereum/#installation from ethereum.utils import sha3 sha3(setA(uint256)).hex() ee919d50445cd9f463621849366a537968fe1ce096894b0d0c001528383d4769然后取哈希的前 4 个字节 sha3(setA(uint256))[0:8].hex() ee919d50注意每个字节在 Python 十六进制字符串中由 2 个字符表示所以[0:8]恰好切出 4 个字节。这一哈希取前 4 字节的约定在现代 Solidity 中依然完全一致。仓库的 29_Selector/Selector.sol 用纯 Solidity 给出了同一原理的实现——bytes4(keccak256(mint(address)))的结果正是0x6a627842// 输出selector // mint(address) 0x6a627842 function mintSelector() external pure returns(bytes4 mSelector){ return bytes4(keccak256(mint(address))); }函数签名的书写规范在计算 selector 之前必须把参数类型规范化为标准形式。根据 29_Selector/readme.md 的总结需要注意基础类型参数如uint256uint8…uint256、bool、address等直接拼写为函数名(参数类型1,参数类型2,...)例如elementaryParamSelector(uint256,bool)→0x3ec37834固定长度类型参数如uint256[3]原样书写例如fixedSizeParamSelector(uint256[3])→0xead6b8bd可变长度类型参数如uint256[]、string例如nonFixedSizeParamSelector(uint256[],string)→0xf0ca01de映射类复杂参数contract转成address、struct转成tuple如(uint256,bytes)、enum转成uint8例如mappingParamSelector(address,(uint256,bytes),uint256[],uint8)→0xe355b0ce关键注意点函数签名中uint、int必须写成uint256、int256否则哈希结果不一致selector 将无法匹配。在现代 Solidity 中还可以通过.selector成员直接获取函数选择器避免了手写签名的出错风险见 29_Selector/Selector.sol 中this.nonParamSelector.selector的用法。ABIEVM 语言之间约定的通用编码方案就 EVM 而言交易的输入数据calldata只是一个字节序列EVM 没有对调用方法的内置支持。智能合约选择通过结构化方式处理输入数据来模拟方法调用——而要让 EVM 上的不同语言能够轻松地相互操作就需要一份通用的编码规范。合约应用程序二进制接口Contract ABI 正是这样一份通用编码方案它规定方法选择器如何计算、参数如何按 32 字节块排列、动态类型如何编码。我们已经在setA(1)的例子中看到 ABI 如何编码简单方法调用。本文后面将逐步展开更复杂参数的编码细节。调用 Getter 与eth_call在本地模拟交易如果调用的方法改变状态那么整个网络都必须达成共识——这需要交易并消耗 gas。像getA()这样的 getter 不改变任何状态。我们可以把方法调用发送给本地以太坊节点而不是要求整个网络计算。eth_callRPC 请求允许在本地模拟交易适用于只读方法调用或 gas 消耗估算。eth_call类似于缓存的 HTTP GET 请求它不会改变全球共识状态本地区块链缓存可能稍稍过时。使用eth_call调用getA()以读取状态a。先计算选择器 sha3(getA())[0:8].hex() d46300fd由于没有参数输入数据本身就只有方法选择器。向任意以太坊节点发送eth_call请求此例发给 Infura 托管的公共节点$ curl -X POST \ -H Content-Type: application/json \ https://rinkeby.infura.io/YOUR_INFURA_TOKEN \ --data { jsonrpc: 2.0, id: 1, method: eth_call, params: [ { to: 0x62650ae5c5777d1660cc17fcd4f48f6a66b9a4c2, data: 0xd46300fd }, latest ] } EVM 执行计算并返回原始字节作为结果{ jsonrpc:2.0, id:1, result:0x0000000000000000000000000000000000000000000000000000000000000001 }根据 ABI这段返回字节应被解释为值0x1。编译视角外部方法调用的汇编实现现在让我们看看编译后的合约如何用汇编处理原始输入数据完成方法调用。考虑定义了setA(uint256)的合约注意payable关键字能让汇编略微简化// call.sol pragma solidity ^0.4.11; contract C { uint256 a; // Note: payable makes the assembly a bit simpler function setA(uint256 _a) payable { a _a; } }编译--asm输出带注释汇编--optimize开启优化solc --bin --asm --optimize call.sol被调用方法的汇编代码位于合约主体中组织在sub_0之下sub_0: assembly { mstore(0x40, 0x60) and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff) 0xee919d50 dup2 eq tag_2 jumpi tag_1: 0x0 dup1 revert tag_2: tag_3 calldataload(0x4) jump(tag_4) tag_3: stop tag_4: /* call.sol:95:96 a */ 0x0 /* call.sol:95:101 a _a */ dup2 swap1 sstore tag_5: pop jump // out auxdata: 0xa165627a7a7230582016353b5ec133c89560dea787de20e25e96284d67a632e9df74dd981cc4db7a0a0029 }有两段样板代码与本次讨论无关仅供参考FYI最顶部的mstore(0x40, 0x60)在内存中保留前 64 字节用于 sha3 哈希无论合约是否需要它始终存在最底部的auxdata用于验证发布的源码与部署的字节码一致是可选的但编译器默认包含。将剩余汇编分成两部分分析(1) 匹配选择器并跳转到方法(2) 加载参数、执行方法并返回。选择器匹配calldata 前 4 字节的提取与分派// Load the first 4 bytes as method selector and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff) // if selector matches 0xee919d50, goto setA 0xee919d50 dup2 eq tag_2 jumpi // No matching method. Fail revert. tag_1: 0x0 dup1 revert // Body of setA tag_2: ...除了开始时从 calldata 中加载 4 字节的位运算外逻辑非常直白。用低级伪代码表示methodSelector calldata[0:4] if methodSelector 0xee919d50: goto tag_2 // goto setA else: // No matching method. Fail revert. revert方法体执行参数入栈与状态写入实际方法调用的注释汇编// setA tag_2: // Where to goto after method call tag_3 // Load first argument (the value 0x1). calldataload(0x4) // Execute method. jump(tag_4) tag_4: // sstore(0x0, 0x1) 0x0 dup2 swap1 sstore tag_5: pop // end of program, will goto tag_3 and stop jump tag_3: // end of program stop进入方法体前汇编做了两件事保存方法调用结束后要返回的位置将 calldata 中的参数加载到堆栈上。伪代码如下// Saves the position to return to after method call. returnTo tag_3 tag_2: // setA // Loads the arguments from call data onto the stack. arg1 calldata[4:432] tag_4: // a _a sstore(0x0, arg1) tag_5 // return jump(returnTo) tag_3: stop把两部分拼在一起就是整个外部调用在 EVM 层面的完整执行流程methodSelector calldata[0:4] if methodSelector 0xee919d50: goto tag_2 // goto setA else: // No matching method. Fail. revert returnTo tag_3 tag_2: // setA(uint256 _a) arg1 calldata[4:36] tag_4: // a _a sstore(0x0, arg1) tag_5 // return jump(returnTo) tag_3: stopFun triviarevert的操作码是fd但你在黄皮书中找不到它的规范也找不到它的实现——事实上fd并不真实存在它是一个无效操作码当 EVM 遇到无效操作时会放弃执行并作为副作用恢复revert状态。多方法分派一串 if-else 分支Solidity 编译器如何为拥有多个方法的合约生成汇编答案很简单——一个接一个的更多if-else分支pragma solidity ^0.4.11; contract C { uint256 a; uint256 b; function setA(uint256 _a) { a _a; } function setB(uint256 _b) { b _b; } }// methodSelector calldata[0:4] and(div(calldataload(0x0), 0x100000000000000000000000000000000000000000000000000000000), 0xffffffff) // if methodSelector 0x9cdcf9b 0x9cdcf9b dup2 eq tag_2 // SetB jumpi // elsif methodSelector 0xee919d50 dup1 0xee919d50 eq tag_3 // SetA jumpi伪代码methodSelector calldata[0:4] if methodSelector 0x9cdcf9b: goto tag_2 elsif methodSelector 0xee919d50: goto tag_3 else: // Cannot find a matching method. Fail. revert这正是 Solidity 方法派发dispatch的本质一段基于函数选择器的线性分支链。当没有任何分支匹配时合约以revert结束。仓库中的 22_Call/Call.sol 展示了对不存在函数的调用会得到success false——因为目标合约在派发阶段找不到匹配的 selector 而回滚。ABI 编码复杂方法调用的参数布局对于方法调用交易输入数据的前 4 个字节永远是方法选择器随后参数以 32 字节为单位排列。ABI 编码规范对复杂类型的参数编码有详细说明但阅读起来可能相当痛苦。另一条学习路径是直接用 pyethereum 的 ABI 编码函数研究不同类型数据的编码结果——从简单案例开始逐步构建更复杂的类型。首先导入encode_abifrom ethereum.abi import encode_abi多个定长参数顺序拼接对于拥有三个uint256参数的方法如foo(uint256 a, uint256 b, uint256 c)编码参数就是把 uint256 数字一个接一个放好# The first array lists the types of the arguments. # The second array lists the argument values. encode_abi([uint256, uint256, uint256],[1, 2, 3]).hex() 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000002 0000000000000000000000000000000000000000000000000000000000000003小于 32 字节的类型会被填充左对齐补零到 32 字节 encode_abi([int8, uint32, uint64],[1, 2, 3]).hex() 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000002 0000000000000000000000000000000000000000000000000000000000000003固定大小数组的元素同样是 32 字节块必要时补零一个接一个放置 encode_abi( [int8[3], int256[3]], [[1, 2, 3], [4, 5, 6]] ).hex() // int8[3]. Zero-padded to 32 bytes. 0000000000000000000000000000000000000000000000000000000000000001 0000000000000000000000000000000000000000000000000000000000000002 0000000000000000000000000000000000000000000000000000000000000003 // int256[3]. 0000000000000000000000000000000000000000000000000000000000000004 0000000000000000000000000000000000000000000000000000000000000005 0000000000000000000000000000000000000000000000000000000000000006动态数组头尾编码head-tail encodingABI 为动态数组引入了一个间接层采用**头尾编码head-tail encoding**方案动态数组的元素被紧凑地打包在 calldata 的尾部tail而参数区头head存放的是指向数组数据的引用偏移量。假如调用一个拥有 3 个动态数组参数的方法编码结果如下为清晰起见添加注释与换行 encode_abi( [uint256[], uint256[], uint256[]], [[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]] ).hex() /************* HEAD (32*3 bytes) *************/ // arg1: look at position 0x60 for array data 0000000000000000000000000000000000000000000000000000000000000060 // arg2: look at position 0xe0 for array data 00000000000000000000000000000000000000000000000000000000000000e0 // arg3: look at position 0x160 for array data 0000000000000000000000000000000000000000000000000000000000000160 /************* TAIL (128**3 bytes) *************/ // position 0x60. Data for arg1. // Length followed by elements. 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000a1 00000000000000000000000000000000000000000000000000000000000000a2 00000000000000000000000000000000000000000000000000000000000000a3 // position 0xe0. Data for arg2. 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000b1 00000000000000000000000000000000000000000000000000000000000000b2 00000000000000000000000000000000000000000000000000000000000000b3 // position 0x160. Data for arg3. 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000c1 00000000000000000000000000000000000000000000000000000000000000c2 00000000000000000000000000000000000000000000000000000000000000c3head 有三个 32 字节参数指向尾部位置尾部包含三个动态数组的真实数据。例如第一个参数是0x60指向 calldata 的第 96 个字节——那里正是数组的开头前 32 字节是长度随后是三个元素。动态与静态参数可以混用。这是(static, dynamic, static)参数的例子静态参数原样编码动态数组数据放进尾部 encode_abi( [uint256, uint256[], uint256], [0xaaaa, [0xb1, 0xb2, 0xb3], 0xbbbb] ).hex() /************* HEAD (32*3 bytes) *************/ // arg1: 0xaaaa 000000000000000000000000000000000000000000000000000000000000aaaa // arg2: look at position 0x60 for array data 0000000000000000000000000000000000000000000000000000000000000060 // arg3: 0xbbbb 000000000000000000000000000000000000000000000000000000000000bbbb /************* TAIL (128 bytes) *************/ // position 0x60. Data for arg2. 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000b1 00000000000000000000000000000000000000000000000000000000000000b2 00000000000000000000000000000000000000000000000000000000000000b3有很多零但没关系——这正是 ABI 以 32 字节为字长的自然结果。字符串与字节数组长度 紧凑字节块string和bytes同样是头尾编码唯一的区别是字节被紧密打包进 32 字节的块 encode_abi( [string, string, string], [aaaa, bbbb, cccc] ).hex() // arg1: look at position 0x60 for string data 0000000000000000000000000000000000000000000000000000000000000060 // arg2: look at position 0xa0 for string data 00000000000000000000000000000000000000000000000000000000000000a0 // arg3: look at position 0xe0 for string data 00000000000000000000000000000000000000000000000000000000000000e0 // 0x60 (96). Data for arg1 0000000000000000000000000000000000000000000000000000000000000004 6161616100000000000000000000000000000000000000000000000000000000 // 0xa0 (160). Data for arg2 0000000000000000000000000000000000000000000000000000000000000004 6262626200000000000000000000000000000000000000000000000000000000 // 0xe0 (224). Data for arg3 0000000000000000000000000000000000000000000000000000000000000004 6363636300000000000000000000000000000000000000000000000000000000每个字符串/字节数组前 32 字节编码长度紧跟着是实际字节。若字符串超过 32 字节则占用多个 32 字节块// encode 48 bytes of string data ethereum.abi.encode_abi( [string], [a * (3216)] ).hex() 0000000000000000000000000000000000000000000000000000000000000020 // length of string is 0x30 (48) 0000000000000000000000000000000000000000000000000000000000000030 6161616161616161616161616161616161616161616161616161616161616161 6161616161616161616161616161616100000000000000000000000000000000嵌套数组每层嵌套一次间接寻址嵌套数组的每一层嵌套都会引入一层间接寻址 encode_abi( [uint256[][]], [[[0xa1, 0xa2, 0xa3], [0xb1, 0xb2, 0xb3], [0xc1, 0xc2, 0xc3]]] ).hex() // arg1: The outer array is at position 0x20. 0000000000000000000000000000000000000000000000000000000000000020 // 0x20. Each element is the position of an inner array. 0000000000000000000000000000000000000000000000000000000000000003 0000000000000000000000000000000000000000000000000000000000000060 00000000000000000000000000000000000000000000000000000000000000e0 0000000000000000000000000000000000000000000000000000000000000160 // array[0] at 0x60 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000a1 00000000000000000000000000000000000000000000000000000000000000a2 00000000000000000000000000000000000000000000000000000000000000a3 // array[1] at 0xe0 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000b1 00000000000000000000000000000000000000000000000000000000000000b2 00000000000000000000000000000000000000000000000000000000000000b3 // array[2] at 0x160 0000000000000000000000000000000000000000000000000000000000000003 00000000000000000000000000000000000000000000000000000000000000c1 00000000000000000000000000000000000000000000000000000000000000c2 00000000000000000000000000000000000000000000000000000000000000c3是的有很多零——再次说明32 字节字长 偏移量是 ABI 编码的主旋律。现代 Solidity 中的 ABI 工具函数以上规则在 Solidity 中由abi.encode、abi.encodeWithSignature、abi.encodeWithSelector等内置函数直接实现。仓库 27_ABIEncode/ABIEncode.sol 给出了可运行对照// SPDX-License-Identifier: MIT pragma solidity ^0.8.34; contract ABIEncode{ uint x 10; address addr 0x7A58c0Be72BE218B41C608b7Fe7C5bB630736C71; string name 0xAA; uint[2] array [5, 6]; // 标准 ABI 编码与合约交互使用每个元素包括偏移量、长度都填充为 32 字节 function encode() public view returns(bytes memory result) { result abi.encode(x, addr, name, array); } // 紧打包编码省略填充 0常用于计算 hash注意可能有拼接冲突风险 function encodePacked() public view returns(bytes memory result) { result abi.encodePacked(x, addr, name, array); } // 以函数签名为第一参数等价于 abi.encode 结果前加 4 字节函数选择器 function encodeWithSignature() public view returns(bytes memory result) { result abi.encodeWithSignature(foo(uint256,address,string,uint256[2]), x, addr, name, array); } // 以函数选择器为第一参数函数签名 Keccak 哈希前 4 字节结果与 encodeWithSignature 一致 function encodeWithSelector() public view returns(bytes memory result) { result abi.encodeWithSelector(bytes4(keccak256(foo(uint256,address,string,uint256[2]))), x, addr, name, array); } // 解码 abi.encode 生成的二进制编码 function decode(bytes memory data) public pure returns(uint dx, address daddr, string memory dname, uint[2] memory darray) { (dx, daddr, dname, darray) abi.decode(data, (uint, address, string, uint[2])); } }可以观察到abi.encode的产物与上文用 pyethereum 手动推演的编码结构完全同构——定长类型按 32 字节块排布string等动态类型在 head 中只放偏移量此例name参数的偏移量为0xa0真实数据长度 UTF-8 字节放在尾部。27_ABIEncode/readme.md 中详细分解了name 0xAA的编码片段偏移量之后先是数组元素5、6然后是 name 的长度4与 UTF-8 字节30784141。而abi.encodeWithSignature/abi.encodeWithSelector只是在abi.encode结果前拼接了 4 字节的选择器——这正是构造 calldata 供address.call(...)使用时的标准做法参见 22_Call/Call.sol 中abi.encodeWithSignature(setX(uint256), x)的用法。在 Remix 中部署 27_ABIEncode/ABIEncode.sol 后四种编码函数返回的 calldata 对比来源27_ABIEncode/readme.mdGas 成本与 ABI 编码设计两个看似矛盾的选择为什么 ABI 把方法选择器截断为 4 字节如果不用满 sha256 的 32 字节不同的方法是否会不幸冲突如果截断是为了省钱那么既然零填充浪费了更多字节又何必在选择器上省下 28 字节这两种设计看似矛盾——直到我们考虑交易的 gas 费用结构每笔交易支付21000gas交易的每个零字节数据或代码支付4gas交易的每个非零字节数据或代码支付68gas。零值便宜 17 倍因此零填充其实没那么糟糕。方法选择器是一个加密哈希具有伪随机性——随机字符串的字节大多非零每个字节只有约 0.3%即 1/255 的概率为 00x1填充到 32 字节需要 192 gas4 * 31 68完整的 32 字节 sha256 哈希约 2176 gas32 * 68截断为 4 字节的选择器约 272 gas4 * 68。选择器通常碰撞概率足够低又比完整哈希便宜约 8 倍。ABI 由此展示了另一个被 gas 费用结构激励出的古怪低级设计实例省在选择器上的 28 字节每个约 68 gas远比零填充每个 4 gas更值得优化。负整数二进制补码与 gas 代价负整数通常用**二进制补码twos complement**表示。int8类型的-1编码为全 1 的1111 1111。ABI 对负整数用1填充因此-1被填充为ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff小的负数大部分是1非零字节这会让调用者支付高昂的 gas。¯_(ツ)_/¯结论方法调用是 ABI 创造的集体幻觉要与智能合约交互你需要向它发送原始字节它执行一些计算可能改变自身状态然后返回原始字节。方法调用实际上并不存在——它只是 ABI 约定下的一种集体幻觉collective illusion。ABI 虽然被规范为一种低级格式但在功能上它更像是跨语言 RPC 框架的序列化格式。本文从 Topics/Translation/DiveEVM2017/DiveEVM2017-Part4.md 出发结合仓库 29_Selector/Selector.sol、27_ABIEncode/ABIEncode.sol 与 22_Call/Call.sol 的源码证据完整还原了交易 → calldata → 选择器匹配 → 参数解码 → 状态变更的整条链路。若想继续深入可接着阅读本系列的 深入以太坊虚拟机 Part5 — 智能合约创建过程 与 深入以太坊虚拟机 Part6 — Solidity 事件实现它们分别从合约诞生与日志记录两个维度补全 EVM 运行时的全貌。【免费下载链接】WTF-SolidityWTF Solidity 极简入门教程供小白们使用。Now supports English! 官网: https://wtf.academy项目地址: https://gitcode.com/GitHub_Trending/wt/WTF-Solidity创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价