;)
Solidity 小白书
Solidity 长得像 JavaScript,但骨子里完全是另一套东西。
在 JS 里,你随便声明变量、随便改状态、随便发 HTTP 请求。在 Solidity 里,每一个操作都在烧钱(Gas),每一次存储都在写账本,每一次函数调用都在跟一台全球共享的虚拟计算机打交道。
这篇不堆砌官方文档的废话,只讲你真正写合约时会碰到的东西。
一、变量类型:别被名字骗了
Solidity 的变量分三类,但真正重要的是它们怎么传值。
1. 值类型(Value Types)
bool、uint、address、bytesN、enum。
赋值时直接拷贝数值。改新变量,不影响旧的。
2. 引用类型(Reference Types)
array、struct、string、bytes。
占地方大,赋值时默认传的是地址(指针)。改新变量,旧的也会跟着变。
3. 映射类型(Mapping)
mapping(Key => Value)。链上的哈希表。只能存在 storage 里。
[!tip] 类型转换的潜规则
- 数值(uint/bytes):截断或补位时,数值保留低位(右边),bytes 保留高位(左边)。
uint8(0x1234)→0x34(砍掉高位)bytes1(0x1234)→0x12(砍掉低位)- 地址转
bytes32左侧补零,bytes转uint看低位。
二、数据存储位置:钱都花在哪了
引用类型必须指定存储位置。选错了,Gas 直接翻倍。
| 位置 | 存在哪 | 生命周期 | 能不能改 | Gas 成本 |
|---|---|---|---|---|
storage |
链上硬盘 | 永久 | ✅ | 💰💰💰 最贵 |
memory |
运行时内存 | 函数执行期 | ✅ | 💰 中等 |
calldata |
交易 payload | 只读 | ❌ | 🪙 最便宜 |
核心原则
- 外部函数参数:默认用
calldata。不用拷贝,直接读交易数据。 - 内部计算/临时变量:用
memory。 - 状态变量:默认
storage。
赋值时的坑
1 | uint[] public x = [1, 2, 3]; // 状态变量 (storage) |
[!warning] 别乱用 storage
函数里声明storage变量必须指向一个已有的状态变量,否则编译不过。它不是“更持久的 memory”,而是“直接操作链上数据”。
三、全局变量:合约的上帝视角
不用声明就能直接用,Solidity 预留的环境信息:
1 | msg.sender // 谁调的函数 |
四、映射(Mapping):链上数据库
1 | mapping(address => uint256) public balances; |
四条铁律
- Key 必须是内置值类型(
uint、address、bytes32等),不能用自定义结构体。Value 随意。 - 只能在
storage里。不能做函数参数或返回值(public 函数会自动生成 getter)。 - 所有 Key 默认都存在,没写过的 Value 返回类型默认值(
uint是 0,address是0x0)。 - 不能遍历。EVM 不知道有哪些 Key 被写过。需要遍历得自己维护一个
address[]索引数组。
五、构造函数与修饰器
构造函数(constructor)
部署时只跑一次。用来设 owner、初始化状态、铸造初始代币。
1 | constructor(address _owner) { |
修饰器(modifier)
函数的前置检查器。钢铁侠的智能盔甲,穿上才有权限。
1 | modifier onlyOwner { |
[!example] 执行顺序
- 跑
modifier里_之前的代码- 跑函数本体(替换
_)- 跑
modifier里_之后的代码(如果有)
六、事件(Events):链上日志
合约执行过程是黑盒,但事件可以把它“打印”出来,存在节点的日志里,便宜且方便前端检索。
1 | event Transfer(address indexed from, address indexed to, uint256 amount); |
indexed 的作用
- 最多 3 个。带
indexed的字段会存在topics里,前端可以按它高效过滤(比如“查某人的所有转账”)。 - 不带
indexed的字段存在data里,只能全量拉下来再解析。 - 如果
indexed字段太大(比如string),会自动存它的keccak256哈希。
关于index:一个事件最多允许存在3个自定义的index变量,每个indexed参数的大小为固定的256比特,如果参数太大了(比如字符串),就会自动计算哈希存储在topics中。
假设有两个转账:
1 | emit Transfer(Alice, Bob, 100); |
对应日志会是:
- 第 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 | using Strings for uint256; // 1. 绑定类型 |
[!tip] internal vs external
库函数如果是internal,编译时会直接内联到你的合约里(增加合约体积,但省一次调用)。
如果是public/external,会走DELEGATECALL(合约体积小,但多一层调用开销)。
八、发送 ETH:三兄弟的恩怨
| 方法 | Gas 限制 | 失败处理 | 推荐度 |
|---|---|---|---|
transfer() |
2300 | 自动 revert | ❌ 已过时 |
send() |
2300 | 返回 bool | ❌ 麻烦 |
call{value: }() |
无限制 | 返回 (bool, bytes) |
✅ 唯一推荐 |
为什么只推荐 call?
transfer 和 send 锁死 2300 gas,只够目标合约记个账。如果对方 receive() 里稍微写点逻辑,直接 Out of Gas 失败。
1 | // ✅ 标准写法 |
[!bug] address payable
transfer和send必须用在address payable上。call是低级调用,普通address也能用,因为编译器默认你清楚自己在发钱。
九、调用其他合约
1. 类型强转(灵活但危险)
1 | function callSetX(address _addr, uint256 x) external { |
任何地址都能传,但如果 _addr 根本不是 OtherContract,调用会静默失败或触发 fallback。
2. 合约类型参数(安全)
1 | function callGetX(OtherContract _contract) external view returns(uint256) { |
编译器会检查类型,但底层 ABI 还是 address。
十、低级调用(call)与 ABI 编码
当你需要动态调用函数,或者处理未知接口时,call 是最终手段。
1 | (bool success, bytes memory data) = _addr.call( |
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的变量上。
delegatecall 是把别人的代码拉到自家跑,用自家的环境(storage、balance、msg.sender)。当用户A通过合约B来delegatecall合约C的时候,执行的是合约C的函数,但是上下文仍是合约B的:msg.sender是A的地址,并且如果函数改变一些状态变量,产生的效果会作用于合约B的变量上。
存储槽对齐(致命重点)
EVM 的 storage 只有 32 字节的槽位(slot 0, 1, 2…),没有类型概念。delegatecall 执行逻辑合约的代码,但读写的是调用方合约的槽位。
1 | // Logic 合约 |
变量名不同无所谓,只要顺序和类型大小一致,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”
- 从“信任代码”变成“假设所有人都在找漏洞”
跑通第一个合约只是开始。多读源码、多写测试、多看审计报告,你会逐渐习惯这台昂贵但诚实的虚拟机。








