Solidity 小白书

Solidity 长得像 JavaScript,但骨子里完全是另一套东西。

在 JS 里,你随便声明变量、随便改状态、随便发 HTTP 请求。在 Solidity 里,每一个操作都在烧钱(Gas),每一次存储都在写账本,每一次函数调用都在跟一台全球共享的虚拟计算机打交道。

这篇不堆砌官方文档的废话,只讲你真正写合约时会碰到的东西。


一、变量类型:别被名字骗了

Solidity 的变量分三类,但真正重要的是它们怎么传值

1. 值类型(Value Types)

booluintaddressbytesNenum
赋值时直接拷贝数值。改新变量,不影响旧的。

2. 引用类型(Reference Types)

arraystructstringbytes
占地方大,赋值时默认传的是地址(指针)。改新变量,旧的也会跟着变。

3. 映射类型(Mapping)

mapping(Key => Value)。链上的哈希表。只能存在 storage 里。

[!tip] 类型转换的潜规则

  • 数值(uint/bytes):截断或补位时,数值保留低位(右边),bytes 保留高位(左边)
  • uint8(0x1234)0x34(砍掉高位)
  • bytes1(0x1234)0x12(砍掉低位)
  • 地址转 bytes32 左侧补零,bytesuint 看低位。

二、数据存储位置:钱都花在哪了

引用类型必须指定存储位置。选错了,Gas 直接翻倍。

位置 存在哪 生命周期 能不能改 Gas 成本
storage 链上硬盘 永久 💰💰💰 最贵
memory 运行时内存 函数执行期 💰 中等
calldata 交易 payload 只读 🪙 最便宜

核心原则

  • 外部函数参数:默认用 calldata。不用拷贝,直接读交易数据。
  • 内部计算/临时变量:用 memory
  • 状态变量:默认 storage

赋值时的坑

1
2
3
4
5
6
uint[] public x = [1, 2, 3]; // 状态变量 (storage)

function test() public {
uint[] storage ref = x; // ✅ 创建引用,改 ref 会改 x
uint[] memory copy = x; // ✅ 创建副本,改 copy 不影响 x
}

[!warning] 别乱用 storage
函数里声明 storage 变量必须指向一个已有的状态变量,否则编译不过。它不是“更持久的 memory”,而是“直接操作链上数据”。


三、全局变量:合约的上帝视角

不用声明就能直接用,Solidity 预留的环境信息:

1
2
3
4
5
msg.sender      // 谁调的函数
msg.value // 转了多少 ETH
block.number // 当前区块高度
block.timestamp // 当前时间戳(矿工可微调,别做精确计时)
tx.origin // 最初发起交易的 EOA(小心钓鱼攻击,尽量别用)

四、映射(Mapping):链上数据库

1
mapping(address => uint256) public balances;

四条铁律

  1. Key 必须是内置值类型uintaddressbytes32 等),不能用自定义结构体。Value 随意。
  2. 只能在 storage。不能做函数参数或返回值(public 函数会自动生成 getter)。
  3. 所有 Key 默认都存在,没写过的 Value 返回类型默认值(uint 是 0,address0x0)。
  4. 不能遍历。EVM 不知道有哪些 Key 被写过。需要遍历得自己维护一个 address[] 索引数组。

五、构造函数与修饰器

构造函数(constructor)

部署时只跑一次。用来设 owner、初始化状态、铸造初始代币。

1
2
3
constructor(address _owner) {
owner = _owner;
}

修饰器(modifier)

函数的前置检查器。钢铁侠的智能盔甲,穿上才有权限。

1
2
3
4
5
6
7
8
modifier onlyOwner {
require(msg.sender == owner, "Not owner");
_; // 被修饰的函数体插在这里
}

function withdraw() external onlyOwner {
// 只有 owner 能进
}

[!example] 执行顺序

  1. modifier_ 之前的代码
  2. 跑函数本体(替换 _
  3. modifier_ 之后的代码(如果有)

六、事件(Events):链上日志

合约执行过程是黑盒,但事件可以把它“打印”出来,存在节点的日志里,便宜且方便前端检索。

1
2
3
event Transfer(address indexed from, address indexed to, uint256 amount);

emit Transfer(msg.sender, receiver, 100);

indexed 的作用

  • 最多 3 个。带 indexed 的字段会存在 topics 里,前端可以按它高效过滤(比如“查某人的所有转账”)。
  • 不带 indexed 的字段存在 data 里,只能全量拉下来再解析。
  • 如果 indexed 字段太大(比如 string),会自动存它的 keccak256 哈希。
    关于index:一个事件最多允许存在3个自定义的index变量,每个 indexed 参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在topics中。

假设有两个转账:

1
2
emit Transfer(Alice, Bob, 100);
emit Transfer(Alice, Carol, 200);

对应日志会是:

  • 第 1 条日志
    • topics[0] = keccak256(“Transfer(address,address,uint256)”)//事件签名
    • topics[1] = Alice
    • topics[2] = Bob
    • data = 100
  • 第 2 条日志
    • topics[0] = keccak256(“Transfer(address,address,uint256)”)
    • topics[1] = Alice
    • topics[2] = Carol
    • data = 200

七、库合约(Library):不占地的工具包

库合约不能存状态、不能收钱、不能被继承。它本质是一段可以复用的代码片段

两种调用方式

1
2
3
4
5
6
using Strings for uint256; // 1. 绑定类型

function test(uint256 _num) public pure returns(string memory) {
return _num.toString(); // 像成员函数一样调用
// return Strings.toString(_num); // 2. 直接调用,效果一样
}

[!tip] internal vs external
库函数如果是 internal,编译时会直接内联到你的合约里(增加合约体积,但省一次调用)。
如果是 public/external,会走 DELEGATECALL(合约体积小,但多一层调用开销)。


八、发送 ETH:三兄弟的恩怨

方法 Gas 限制 失败处理 推荐度
transfer() 2300 自动 revert ❌ 已过时
send() 2300 返回 bool ❌ 麻烦
call{value: }() 无限制 返回 (bool, bytes) ✅ 唯一推荐

为什么只推荐 call

transfersend 锁死 2300 gas,只够目标合约记个账。如果对方 receive() 里稍微写点逻辑,直接 Out of Gas 失败。

1
2
3
// ✅ 标准写法
(bool success, ) = _to.call{value: amount}("");
require(success, "Transfer failed");

[!bug] address payable
transfersend 必须用在 address payable 上。
call 是低级调用,普通 address 也能用,因为编译器默认你清楚自己在发钱。


九、调用其他合约

1. 类型强转(灵活但危险)

1
2
3
function callSetX(address _addr, uint256 x) external {
OtherContract(_addr).setX(x); // 强转成合约接口调用
}

任何地址都能传,但如果 _addr 根本不是 OtherContract,调用会静默失败或触发 fallback。

2. 合约类型参数(安全)

1
2
3
function callGetX(OtherContract _contract) external view returns(uint256) {
return _contract.getX();
}

编译器会检查类型,但底层 ABI 还是 address


十、低级调用(call)与 ABI 编码

当你需要动态调用函数,或者处理未知接口时,call 是最终手段。

1
2
3
(bool success, bytes memory data) = _addr.call(
abi.encodeWithSignature("setX(uint256)", 123)
);

abi.encode* 家族

函数 作用 场景
abi.encode(a, b) 参数按 32 字节对齐填充 标准合约交互
abi.encodePacked(a, b) 紧凑拼接,不补 0 计算哈希、省空间
abi.encodeWithSignature("f(t)", a) 自动算 4 字节 selector + 编码参数 动态调用
abi.decode(data, (uint, address)) 把 bytes 解回原始类型 解析 call 返回值

[!warning] encodePacked 的哈希碰撞
abi.encodePacked("a", "bc")abi.encodePacked("ab", "c") 结果一样。做签名验证时,优先用 abi.encode 或显式加长度前缀。


十一、Delegatecall:借壳执行

call 是去别人家办事,用别人的环境。当用户A通过合约B来call合约C的时候,执行的是合约C的函数,上下文(Context,可以理解为包含变量和状态的环境)也是合约C的:msg.sender是B的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约C的变量上。
alt text
delegatecall把别人的代码拉到自家跑,用自家的环境(storage、balance、msg.sender)。当用户A通过合约B来delegatecall合约C的时候,执行的是合约C的函数,但是上下文仍是合约B的:msg.sender是A的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B的变量上。
alt text

存储槽对齐(致命重点)

EVM 的 storage 只有 32 字节的槽位(slot 0, 1, 2…),没有类型概念。delegatecall 执行逻辑合约的代码,但读写的是调用方合约的槽位

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Logic 合约
contract Logic {
uint public x; // slot 0
address public o; // slot 1
}

// Proxy 合约
contract Proxy {
uint public a; // slot 0
address public b; // slot 1

function run(address _logic) external {
_logic.delegatecall(abi.encodeWithSignature("setX(uint256)", 100));
}
}

变量名不同无所谓,只要顺序和类型大小一致Logic 改 slot 0,实际改的就是 Proxy.a

应用场景:

代理合约(Proxy Contract):将智能合约的存储合约和逻辑合约分开:代理合约(Proxy Contract)存储所有相关的变量,并且保存逻辑合约的地址;所有函数存在逻辑合约(Logic Contract)里,通过delegatecall执行。当升级时,只需要将代理合约指向新的逻辑合约即可。

EIP-2535 Diamonds(钻石):钻石是一个支持构建可在生产中扩展的模块化智能合约系统的标准。钻石是具有多个实施合约的代理合约。 更多信息请查看:钻石标准简介。

[!danger] 代理合约铁律
顺序错一位、类型差一点,数据就会写穿。升级逻辑合约时,只能往后加变量,绝不能改已有变量的顺序或类型


十二、代币标准速查

ERC20(同质化代币)

  • balanceOf / totalSupply:查余额、查总量
  • transfer / transferFrom:自己转 / 授权代转
  • approve / allowance:给额度 / 查剩余额度
  • 核心逻辑:账本模型,所有地址共享一个 mapping(address => uint256)

ERC721(NFT)

  • ownerOf(tokenId):查主人
  • safeTransferFrom:安全转账(接收方是合约时必须实现 IERC721Receiver,防止资产被锁死)
  • approve / setApprovalForAll:单枚授权 / 批量授权
  • 核心逻辑mapping(uint256 => address) 记录每个 tokenId 的归属。

写在最后

Solidity 的难点从来不是语法,而是思维转换

  • 从“随便改”变成“写一次就要对一辈子”
  • 从“内存不要钱”变成“每个字节都在烧 Gas”
  • 从“信任代码”变成“假设所有人都在找漏洞”

跑通第一个合约只是开始。多读源码、多写测试、多看审计报告,你会逐渐习惯这台昂贵但诚实的虚拟机。