概述

前往我的博客获得更好地阅读体验。

本文主要介绍最小化代理合约EIP1167的相关内容。为了实现最小化,EIP1167使用了bytecode(字节码)作为主要编码方式,即直接使EVM汇编指令进行编写。本文将在openzeppelin提供的合约基础上,为读者逐个字节码的解析EIP1167,帮助读者理解EVM底层汇编和EIP1167的实现原理。

注意虽然EIP1167也实现了代理合约,但其不具有合约升级能力,如果你希望构造可升级合约,请阅读以下文章:

  • Foundry教程:使用多种方式编写可升级的智能合约(上)
  • Foundry教程:使用多种方式编写可升级的智能合约(下)

如果读者没有代理合约开发经验,也建议阅读上文获得一些关于代理合约的基本知识。

建议读者在阅读后文之前可以简单读一下EIP1167标准。

openzeppelin实现

我们在此处首先给出openzeppelin的合约实现,代码如下:

function clone(address implementation) internal returns (address instance) {/// @solidity memory-safe-assemblyassembly {let ptr := mload(0x40)mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)mstore(add(ptr, 0x14), shl(0x60, implementation))mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)instance := create(0, ptr, 0x37)}require(instance != address(0), "ERC1167: create failed");
}

上述代码描述了代理合约生成的基本结构。我们采用从顶向下分析的方法,首先关注合约生成的核心代码instance := create(0, ptr, 0x37)。查阅EVM汇编表格,我们可以知道此函数接受三个变量,分别是:

  • value, 传递给新合约的ETH(以wei计费)
  • offset, 新合约代码在内存中的起始位置
  • size, 新合约的代码长度

本质上来说,此函数实现获取内存中的合约代码并将其进行部署的功能。在此处,我们没有向新合约传递ETH,规定了新合约的代码在内存中的起始位置为ptr,长度为0x37 byte,即55 byte 或 110 个16进制数字。

我们可以断定以下代码的功能是构造新合约的字节码:

let ptr := mload(0x40)
mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000)
mstore(add(ptr, 0x14), shl(0x60, implementation))
mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)

正如前文所述,由于EIP1167完全使用字节码编程,而solidity对内存级控制并不擅长,所以我们在此处只能提供内联汇编实现代码构建。当然,对于一般的solidity合约,你可以参考此文。

接下来,我们对字节码构造部分进行解释,注意在此节中,我们不会指明生成的字节码的作用,此部分内容位于下一节。

此处用到了implementation变量,即需要被代理合约的地址,我们在此处假设其值为0xbebebebebebebebebebebebebebebebebebebebe

let ptr := mload(0x40)。此处对ptr的值进行初始化。初始化的方法是读取(使用mload函数读取指定地址的值) 0x40 地址的值。此处使用0x40地址进行读取的原因是此地址内存储着空闲内存的起始位置。在此处举一个例子,如果你的合约已经把0x60前的内存都填满了,读取0x40位置时,会获得0x61这个值。使用0x40中存储的地址可以有效避免内存覆写冲突问题的出现。

实际上mload(0x40)返回的是内存目前的占用量,其等同于空闲内存的起始位置,具体可以参考Layout in Memory

当我们获取到空闲内存的起始位置后,我们接下来就可以构造EIP1167合约的字节码。

代码中各个汇编函数的作用如下:

  • mstore(offset, value)的作用为向指定内存地址内写入value数据。注意offset的单位为bytevalue的长度必须为32 byte
  • add(a, b)的作用为a + b
  • shl(shift, value)的作用为将value左移shiftbit。注意单位为bit

综合以上内容,我们可以得到每行代码的具体作用。

mstore(ptr, 0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000),我们首先在ptr后插入了0x3d602d80600a3d3981f3363d3d373d3d3d363d73000000000000000000000000(32 byte)。

mstore(add(ptr, 0x14), shl(0x60, implementation)),我们首先将implementation的地址(20 byte)通过shl左移0x60 bit,即12 byte,形成32 byte的标准数据。得到标准数据后,我们将数据写入ptr + 0x14处,即ptr20 byte(40个16进制数),最终形成以下数据:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe000000000000000000000000

最后,我们通过mstore(add(ptr, 0x28), 0x5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000)写入数据,形成以下数据:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf30000000000000000000000000000000000

根据上文给出的create的参数,我们发现部署合约时仅读取此字节码的前0x37 byte的数据,即使用以下数据构造合约:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

关于此此字节码的作用,我们会在下文进行解释。

上述流程可以用下图进行概括:

此图展示了上述汇编代码对内存的修改情况。其中最上方的ptrptr + 0x14ptr + 0x28等值表示当前的内存地址,0x14等值的单位均为byte

字节码解析

运行流程

在进行字节码分析前,我们需要在顶层理解EIP1167是如何运行的,其核心在于delegatecall的使用。

合约运行分为以下几个步骤:

  1. 获得calldata,用户发送的calldata中包含需要调用的函数和对应的参数,我们需要获得calldata以便于后期进行转发。
  2. 使用delegatecall发送calldata。合约在获得calldata后可以通过delegatecall进行委托调用,代理合约会把被代理合约内的代码拉取到本地输入calldata进行运行,并将结果保存到代理合约内。
  3. 获得delegatecall返回的结果并并储存到内存中
  4. 向用户返回结果或错误

以上就是EIP1167的运行流程,接下来我们会解释如何通过0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3字节码实现这一功能。

初始化

我们将智能合约分为两部分,一部分是在创建合约时运行的代码,我们称为创建代码(creation code 或 Deploy code),另一部分则是逻辑代码(runtime code)。

前者主要实现以下功能:

  • 运行constructor构造器函数
  • 进行合约变量初始化
  • runtime code复制到内存中

一个比较好的类比是创建代码类似软件的安装包,它会根据用户的输入选择安装文件夹释放文件并进行软件的初始化。类比无法使我们接近本质,所以我们在此处给出go-ethereum的合约创建源代码:

func (evm *EVM) create(caller ContractRef, codeAndHash *codeAndHash, gas uint64, value *big.Int, address common.Address, typ OpCode) ([]byte, common.Address, uint64, error) {// Depth check execution. Fail if we're trying to execute above the// limit.if evm.depth > int(params.CallCreateDepth) {return nil, common.Address{}, gas, ErrDepth}if !evm.Context.CanTransfer(evm.StateDB, caller.Address(), value) {return nil, common.Address{}, gas, ErrInsufficientBalance}nonce := evm.StateDB.GetNonce(caller.Address())if nonce+1 < nonce {return nil, common.Address{}, gas, ErrNonceUintOverflow}evm.StateDB.SetNonce(caller.Address(), nonce+1)// We add this to the access list _before_ taking a snapshot. Even if the creation fails,// the access-list change should not be rolled backif evm.chainRules.IsBerlin {evm.StateDB.AddAddressToAccessList(address)}// Ensure there's no existing contract already at the designated addresscontractHash := evm.StateDB.GetCodeHash(address)if evm.StateDB.GetNonce(address) != 0 || (contractHash != (common.Hash{}) && contractHash != emptyCodeHash) {return nil, common.Address{}, 0, ErrContractAddressCollision}// Create a new account on the statesnapshot := evm.StateDB.Snapshot()evm.StateDB.CreateAccount(address)if evm.chainRules.IsEIP158 {evm.StateDB.SetNonce(address, 1)}evm.Context.Transfer(evm.StateDB, caller.Address(), address, value)// Initialise a new contract and set the code that is to be used by the EVM.// The contract is a scoped environment for this execution context only.contract := NewContract(caller, AccountRef(address), value, gas)contract.SetCodeOptionalHash(&address, codeAndHash)if evm.Config.Debug {if evm.depth == 0 {evm.Config.Tracer.CaptureStart(evm, caller.Address(), address, true, codeAndHash.code, gas, value)} else {evm.Config.Tracer.CaptureEnter(typ, caller.Address(), address, codeAndHash.code, gas, value)}}start := time.Now()ret, err := evm.interpreter.Run(contract, nil, false)// Check whether the max code size has been exceeded, assign err if the case.if err == nil && evm.chainRules.IsEIP158 && len(ret) > params.MaxCodeSize {err = ErrMaxCodeSizeExceeded}// Reject code starting with 0xEF if EIP-3541 is enabled.if err == nil && len(ret) >= 1 && ret[0] == 0xEF && evm.chainRules.IsLondon {err = ErrInvalidCode}// if the contract creation ran successfully and no errors were returned// calculate the gas required to store the code. If the code could not// be stored due to not enough gas set an error and let it be handled// by the error checking condition below.if err == nil {createDataGas := uint64(len(ret)) * params.CreateDataGasif contract.UseGas(createDataGas) {evm.StateDB.SetCode(address, ret)} else {err = ErrCodeStoreOutOfGas}}// When an error was returned by the EVM or when setting the creation code// above we revert to the snapshot and consume any gas remaining. Additionally// when we're in homestead this also counts for code storage gas errors.if err != nil && (evm.chainRules.IsHomestead || err != ErrCodeStoreOutOfGas) {evm.StateDB.RevertToSnapshot(snapshot)if err != ErrExecutionReverted {contract.UseGas(contract.Gas)}}if evm.Config.Debug {if evm.depth == 0 {evm.Config.Tracer.CaptureEnd(ret, gas-contract.Gas, time.Since(start), err)} else {evm.Config.Tracer.CaptureExit(ret, gas-contract.Gas, err)}}return ret, address, contract.Gas, err
}

上述代码的核心为:

ret, err := evm.interpreter.Run(contract, nil, false)

此行代码将合约字节码进行了运行,我们再次给出合约字节码:

0x3d602d80600a3d3981f3363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

当我们进行合约运行时,EVM解释器会从头进行运行字节码,在此处给出字节码代表的操作码:

[00] RETURNDATASIZE
[01]    PUSH1   2d
[03]    DUP1
[04]    PUSH1   0a
[06]    RETURNDATASIZE
[07]    CODECOPY
[08]    DUP2
[09]    RETURN

完整的字节码结果可以参考这里

我们仅给出了3d602d80600a3d3981f3代表的代码,因为RETURN操作码会中止合约运行。在创建过程中,我们仅会运行上述9个操作码。

操作码编号 操作码名称 操作码作用 运行后的堆栈情况 运行后的内存
3d RETURNDATASIZE 向堆栈中写入CALLDELEGATECALL的返回值的长度 0 -
602d PUSH1 2d 向堆栈中的第一个位置推入2d 2d 0 -
80 DUP1 复制堆栈中的第一个值 2d 2d 0 -
600a PUSH1 0a 向堆栈中的第一个位置推入0a 0a 2d 2d 0 -
3d RETURNDATASIZE 同上 0 0a 2d 2d 0 -
39 CODECOPY 读取堆栈中的数据依次作为代码写入内存的起始位置、代码起始的读取位置和读取的代码长度 2d 0 [0-2d]:runtime code
81 DUP2 复制堆栈中第2个元素推入堆栈 0 2d 0 [0-2d]: runtime code
f3 RETURN 在堆栈中依顺序读出元素作为返回值的内存起始位置和长度 0 [0-2d]: runtime code

如果读者不太理解上述的操作码的含义,可以参考EVM Codes查询操作码的参数和功能。

读者可能发现我们使用RETURNDATASIZE进行了堆栈操作,而在此处写显然没有任何CALL之类的操作。我们在此处使用RETURNDATASIZE的作用只是向堆栈中写入0。使用PUSH1 0写入0会消耗3 gas,而使用RETURNDATASIZE写入则仅消耗2 gas。后者更节省gas费用。

EVM运行完上述代码后,go-ethereum客户端会把返回的字节码存储到ret变量中,通过evm.StateDB.SetCode(address, ret)将合约地址和合约代码保存到StateDB数据库中,在我们后期进行调用时,仅运行EVM返回的以下字节码:

363d3d373d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af43d82803e903d91602b57fd5bf3

接下来我们会介绍上述runtime代码的具体功能。

获取Calldata

正如本节概述,我们进行合约代理的第一步是获得用户向合约发送的calldata,使用的字节码为363d3d37

获得calldata的操作码为CALLDATACOPY,要求以下参数:

  • destOffset, 将calldata复制到内存中的起始位置
  • offset, 需要复制的calldata的起始位置
  • size, 需要复制的calldata的长度

在此处,我们将calldata整体进行复制到内存中,具体操作如下表:

操作码编号 操作码名称 操作码作用 运行后的堆栈情况 运行后的内存
36 CALLDATASIZE 获得calldata的长度 cds -
3d RETURNDATASIZE 如前文所述,一种向堆栈中推入0的廉价方式 0 cds -
3d RETURNDATASIZE 同上 0 0 cds -
37 CALLDATACOPY 如前所述复制calldata到内存 - [0-cds]Calldata

在上文给出的流程中,我们仍使用了RETURNDATASIZE向堆栈中填入0 。

另一点需要注意的是栈属于后进先出(LIFO)的数据类型,所以我们需要先推入size参数再推入offset最后推入destOffset参数。

经过以上流程,我们成功把calldata复制到内存中,下一步则需要使用calldata进行delegatecall

Delegatecall

此过程对应3d3d3d363d73bebebebebebebebebebebebebebebebebebebebe5af4字节码。

此流程最核心的操作码为DELEGATECALL,所需参数如下:

  • gas, 委托调用所需要的gas费用
  • address, 委托调用目标合约地址
  • argsOffset, 委托调用所需要的calldata在内存中的起始位置
  • argsSize, calldata的长度(以byte计)
  • retOffset, 返回值在内存中存储的起始位置
  • retSize, 返回值长度(以byte计)

在运行完成后,此操作码会向堆栈推入1(运行成功)或0(运行失败)。

此处我们介绍用于辅助的操作码GAS,该操作码不需要读取堆栈而直接向堆栈内推入gas数据。

根据我们目前的内存情况和目标,我们应该构建以下堆栈以供DELEGATECALL调用:

[ GAS | address | 0 | cds | 0 | 0 ]

此处把retOffsetretSize设置为0的原因是我们在此处无法知道返回值的长度,所以将其设置为0。但设置为0并不意味着我们读返回值,返回值仍会被保存在return data的特殊区域,我们会在下一环节读取返回值。

EVM中,存储区域有代码存储区域、堆栈、内存、存储、CallData存储和Return data存储区域。我们在上文中,由于不清楚返回值的情况,所以通过设置retOffsetretSize为0,阻止了返回值直接写入内存,在后文我们会在Return data中提取返回值。

更多关于EVM存储可以参考此网站

我们依旧采用表格的方式逐步分析字节码:

操作码编号 操作码名称 运行后的堆栈情况
3d RETURNDATASIZE 0
3d RETURNDATASIZE 0 0
3d RETURNDATASIZE 0 0 0
36 CALLDATASIZE cds 0 0 0
3d RETURNDATASIZE 0 cds 0 0 0
73bebebebebebebebebebebebebebebebebebebebe PUSH20 bebe… addr 0 cds 0 0 0
5a GAS gas addr 0 cds 0 0 0
f4 DELEGATECALL success 0

*由于此处不涉及内存读写,所以我们删除了此列。同时考虑到读者可以理解此列表中大大部分操作码,所以也删掉了"操作码作用"一列

读者可能发现了此处我们在堆栈中多写入了一个0,这是因为delegatecall后,我们无法再通过RETURNDATASIZE操作码以廉价的方式写入0,我们在此处多填入一个0以方便后期使用。

获取Returndata

此流程对应的字节码为3d82803e

核心操作码为RETURNDATACOPY,所需参数为:

  • destOffset, Returndata复制到内存中的起始位置
  • offset, 需要复制的Returndata的起始位置
  • size, 需要复制的Returndata的长度

当然,此处主要使用的辅助操作码为DUPn(其中n∈[1, 16]),其主要为将堆栈中的第n个元素复制并推入堆栈。如目前堆栈中存在0 1两个元素,使用DUP2后运行完后堆栈为1 0 1,即将第二个元素1复制并推入堆栈。

在此处我们给出分析表格:

操作码编号 操作码名称 运行后的堆栈情况 内存
3d RETURNDATASIZE rds success 0 [0-cds]Calldata
82 DUP3 0 rds success 0 [0-cds]Calldata
80 DUP1 0 0 rds success 0 [0-cds]Calldata
3e RETURNDATACOPY success 0 [0-rds]Returndata

在此过程中,我们完成将返回值复制进入内存,在下一步中,我们会真正把返回值或错误返回给用户。

返回

此流程对应操作码为903d91602b57fd5bf3

在返回值之前,我们需要判断success的值,如果此值为1,说明delegatecall成功,我们以正常形式返回内存中的结果; 如果此值为0,说明delegatecall失败,我们则使用REVERT,以错误信息的形式返回内存中的值。

上述过程依赖于JUMPI操作码,此操作码接受以下参数:

  • counter, 需要跳转的代码位置
  • b, 若b不为0则进行跳转,否则则不跳转继续运行。

JUMPI对应的跳转位置需要存在JUMPDEST操作码标识跳转位置。

此处所使用的另两个重要操作码RETURNREVERT所需参数相同,均为:

  • offset, 返回值在内存中存储的起始位置
  • size, 返回值的长度

注意这两个操作码一旦运行则标志合约运行的结束。EVM读取到这两个操作码后,不会再继续运行。

为了方便读者理解跳转关系,我们使用一下图像:

|           0x00000024      90             swap1                 0 suc
|           0x00000025      3d             returndatasize        rds 0 suc
|           0x00000026      91             swap2                 success 0 rds
|           0x00000027      602b           push1 0x2b            0x2b success 0 rds
|       ,=< 0x00000029      57             jumpi                 0 rds
|       |   0x0000002a      fd             revert
|       `-> 0x0000002b      5b             jumpdest              0 rds
\           0x0000002c      f3             return

上图来自EIP1167文档

第一列为操作码的位置,第二列为操作码的编号,第三列为操作码名称,最后一列为堆栈情况。

为了最后进行值的返回,我们首先构造通过一系列操作将堆栈中最后两个元素设置为0 rds,方便RETURNREVERT操作码使用。同时也构造了0x2b success作为JUMPI所需要的参数。图中也表示了不同的跳转方式,当success不为0时,即delegatecall运行成功,代码跳转到0x2b运行执行RETURN,返回正常值。如果success为0,则执行revert,将返回值作为异常抛出。

总结

本文主要介绍了以下内容:

  • openzeppelinclone函数生成字节码的过程
  • go-ethereum创建智能合约的源代码
  • EIP1167字节码具体运作流程

除此之外,我们还介绍EVM运行环境的基本情况和常见字节码的含义。

读者可以阅读EIP1167文档中给出的流程图进一步理解字节码运行流程。

EVM底层探索:字节码级分析最小化代理标准EIP1167相关推荐

  1. Java字节码角度分析多态原理 ——提升硬实力8

    在前面的文章中,有详细地介绍java字节码相关的知识,有兴趣的可以提前了解一下. 1.Java字节码的一段旅行经历--提升硬实力1 2.Java字节码角度分析a++ --提升硬实力2 3.Java字节 ...

  2. Java字节码角度分析:Synchronized ——提升硬实力11

    在前面的文章中,有详细地介绍java字节码相关的知识,有兴趣的可以提前了解一下. 1.Java字节码的一段旅行经历--提升硬实力1 2.Java字节码角度分析a++ --提升硬实力2 3.Java字节 ...

  3. fork的黑科技,它到底做了个啥,源码级分析linux内核的内存管理

    最近一直在学习linux内核源码,总结一下 https://github.com/xiaozhang8tuo/linux-kernel-0.11 一份带注释的源码,学习用. fork的黑科技,它到底做 ...

  4. 【Java 虚拟机原理】Class 字节码二进制文件分析 七 ( 局部变量表分析 )

    文章目录 前言 一.编译生成带局部变量表的字节码文件 二.局部变量表 前言 上一篇博客 [Java 虚拟机原理]Class 字节码二进制文件分析 二 ( 常量池位置 | 常量池结构 | tag | i ...

  5. 【Java 虚拟机原理】Class 字节码二进制文件分析 六 ( 属性类型 | Code 属性 | 属性名称索引 | 属性长度 | 操作数栈最大深度 | 局部变量存储空间 | 字节码长度 )

    文章目录 前言 一.属性类型 二.Code 属性表数据结构 三.属性名称索引 四.属性长度 五.操作数栈最大深度 六.局部变量存储空间 七.字节码长度 八.存储字节码指令的一系列字节流 前言 上一篇博 ...

  6. 【Java 虚拟机原理】Class 字节码二进制文件分析 五 ( 方法计数器 | 方法表 | 访问标志 | 方法名称索引 | 方法返回值类型 | 方法属性数量 | 方法属性表 )

    文章目录 前言 一.方法表结构 二.方法计数器 三.方法表数据解析 ( init 构造方法 ) 1.方法访问标志 2.方法名称索引 3.方法返回类型 4.方法属性数量 前言 上一篇博客 [Java 虚 ...

  7. 【Java 虚拟机原理】Class 字节码二进制文件分析 四 ( 字段表数据结构 | 字段表详细分析 | 访问标志 | 字段名称 | 字段描述符 | 属性项目 )

    文章目录 前言 一.字段表总数据结构 二.访问标志 三.字段名称 四.字段描述符 五.属性项目数 前言 上一篇博客 [Java 虚拟机原理]Class 字节码二进制文件分析 三 ( 访问和修饰标志 | ...

  8. 【Java 虚拟机原理】Class 字节码二进制文件分析 三 ( 访问和修饰标志 | 类索引 | 父类索引 | 接口计数器 | 接口表 | 字段计数器 | 字段表 )

    文章目录 前言 一.访问和修饰标志 二.类索引 三.父类索引 四.接口计数器 五.接口表 六.字段计数器 七.字段表 前言 上一篇博客 [Java 虚拟机原理]Class 字节码二进制文件分析 二 ( ...

  9. 【Java 虚拟机原理】Class 字节码二进制文件分析 二 ( 常量池位置 | 常量池结构 | tag | info[] | 完整分析字节码文件中的常量池二进制数据 )

    文章目录 前言 一.常量池结构分析 1.常量池位置 2.常量池结构 3.常量池单个常量 4.常量池单个常量 tag 标签 二.常量池字节码文件分析 0.常量池附加信息 1.常量池 #1 常量分析 2. ...

最新文章

  1. 基于pytorch的卷积神经网络量化实现
  2. mysql处理含中文的SQL文件_mysql命令行还原phpMyAdmin导出的含有中文的SQL文件
  3. 《转》atomic assign retain
  4. 计算机管理学科,计算机学院学科经费使用与管理细则(试行)
  5. 7.3 TensorFlow笔记(基础篇):加载数据之从队列中读取
  6. Kickstart之添加自动化脚本
  7. 大数据会如何影响VC领域?
  8. mysql上传到阿里云服务器地址_从0部署Web项目到阿里云服务器上
  9. linux php 源码安装,Linux下PHP的源码安装与配置
  10. 51单片机温度控制系统报警器,不会做课程设计的就拿走
  11. 我常去的编程技术网站
  12. c语言中getnumber函数作用,C语言函数是什么
  13. element ui实现抽屉效果_抽屉效果的导航菜单
  14. maven发布SNAPSHOT版本到私服仓库
  15. 经验分享:新媒体运营离不开的几款运营软件
  16. 水星usb无线网卡MW150US驱动 for Mac
  17. video视频快进拖动限制
  18. 一个女测试工程师的成长之路
  19. mysql数据库自动降级_mysql降级caveats
  20. 单目标跟踪算法:Siamese RPN论文解读和代码解析

热门文章

  1. oracle查看rman进度,监控数据备份恢复完成进度(EXPDP/IMPDP/RMAN)
  2. 百度 ip定位 获取省市 webAPI
  3. STM32CubeMX+STM32F407+FreeRTos+LAN8720 以太网通信实现数据收发功能
  4. arduino控制触控传感器
  5. 打开visio2013 32位报VisProWW.MSI的文件夹路径错误
  6. HarmonyOS APP开发入门3——组件(四 CommonDialog普通弹框组件 )
  7. 编译原理------语法分析器C/C++代码实现
  8. android通知栏快捷设置开发,即添加快捷磁贴指北
  9. eset文件服务器,eset架设ftp更新服务器
  10. jiathis的使用