Ethereum learning path · 09.03

Solidity 如何
把规则写进链上?Type · Function · Modifier · Event · Mapping · Struct

一份合约不是“上传到区块链的网页代码”。它是一组被编译成字节码、 由 EVM 确定性执行、用 storage 保存状态、用 log 向链外留下线索的规则。 本课用同一份 StudyRegistry,把目录里的六个关键词连成一个系统。

  • 预计90 分钟
  • 难度从零到核心
  • 示例Solidity 0.8.36

Meaning

类型定义数据的意义 同一串比特,只有按类型解释才成为地址、整数或文本。

Boundary

函数定义可调用边界 visibility、mutability 与 ABI 一起决定外界怎样进入。

State

Mapping + Struct 组织状态 一个负责按 key 定位,一个负责把相关字段组成记录。

Signal

Modifier 把关,Event 留痕 前者包裹执行路径,后者把可检索日志交给链外应用。

00 · Orientation

先画一张不会混乱的地图

Solidity、合约、字节码、ABI、状态和日志,不是同一个东西。

Solidity 是源语言。编译器读取源代码,产出供 EVM 执行的 bytecode,以及供钱包、前端和其他工具理解调用方式的 ABI。部署交易把创建字节码送进 EVM;成功后,某个地址获得运行时字节码和自己的持久化 storage。

一句精确定义

一份已部署的智能合约,是某个以太坊地址上的 EVM 代码与状态。 Solidity 只是编写它的高级语言;链上通常保存的是编译结果,不是你在编辑器里看到的源文件。

一笔调用里,六个主题各自做什么?

  1. 数据类型告诉编译器和 ABI:每个值是什么、占多大范围、允许哪些操作。
  2. 函数定义入口、参数、返回值、可见性和是否允许改变状态或接收 ETH。
  3. Modifier把可复用的前置或后置逻辑包在函数体外,例如权限与暂停检查。
  4. Mapping把一个 key 确定性地映射到一个值,适合余额、权限和按地址索引的记录。
  5. Struct把多个有名字、可能不同类型的字段组成一条业务记录。
  6. Event把 ABI 编码的日志写进交易收据,让链外应用可以过滤、索引与展示。
版本纪律

本课示例按 Solidity 0.8.36 编写,并用 pragma solidity 0.8.36; 精确声明。若写成 ^0.8.36, 则允许后续兼容的 0.8.x 编译器;无论哪种写法,真实项目还应在构建配置与锁文件中固定经过测试的版本。

01 · Anatomy

先读懂一份合约的骨架

从文件级声明,到合约级状态,再到一次调用内部的局部变量。

MinimalMap.sol
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;

contract MinimalMap {
    address public immutable owner;
    mapping(address => uint256) private scores;

    event ScoreChanged(address indexed student, uint256 score);

    constructor() {
        owner = msg.sender;
    }

    function setScore(uint256 newScore) external {
        scores[msg.sender] = newScore;
        emit ScoreChanged(msg.sender, newScore);
    }
}
FILE

许可证与 pragma

SPDX 标识授权方式;pragma 声明可接受的编译器版本范围。两者不在每次函数调用里执行。

CONTRACT

状态、类型与成员

ownerscores 属于合约状态;event 和 function 是合约接口与行为的一部分。

DEPLOY

constructor

只在创建合约时执行一次。它的代码参与创建过程,但不会作为普通入口留在运行时接口里。

CALL

参数与局部值

newScore 只属于当前调用;状态写入成功后,scores 的变化才会跨调用保留。

三层变量,三种寿命

层级例子何时存在典型位置
状态变量scores与合约状态同寿命storage,或 constant / immutable 的特殊编译布局
函数参数newScore当前调用calldata、memory 或栈上的值
局部变量uint256 next当前执行路径stack、memory,或 storage 引用
不要把“private”读成“秘密”

private 只限制其他 Solidity 合约通过语言级成员访问它; 全节点保存的链上状态仍可由链外工具分析。任何私钥、密码、未公开答案都不应写进合约 storage。

02 · Types

类型,是对比特的解释规则

Solidity 是静态类型语言:每个变量在编译时就必须有确定类型。

EVM 最终处理的是 256 位字、字节序列和存储槽;Solidity 在上面加了一套类型系统。 类型不仅决定“能存什么”,还决定 ABI 怎样编码、算术是否检查溢出、赋值是复制还是引用,以及哪些位置可以使用这个值。

Copied as values

值类型

  • booltrue / false
  • uint8 … uint256无符号整数
  • int8 … int256有符号整数
  • address20 字节账户地址
  • bytes1 … bytes32定长字节
  • enum有限命名状态

赋值或传参时通常得到独立的值。uintuint256 的别名。

Location matters

引用类型

  • stringUTF-8 字节序列的语言抽象
  • bytes动态长度原始字节
  • T[] / T[n]动态 / 定长数组
  • struct命名字段组成的记录
  • mapping仅存在于 storage 的键值结构

数组、struct 等必须考虑 storagememorycalldata;位置会改变语义。

本课示例为什么选这些类型?

address 谁的记录?

用调用者地址作为 mapping 的 key。它是标识,不自动证明现实身份。

uint32 完成课数

业务上限远小于 2³²;明确范围,也可与相邻小字段打包进一个存储槽。

uint64 更新时间

显式转换 block.timestamp;范围足够,又比 uint256 更贴近业务约束。

enum Stage 有限阶段

比魔法数字可读,值只能落在声明的枚举成员范围内。

bool exists 区分未写入

mapping 对任意 key 都返回默认值;显式存在位能区分“没有记录”和“恰好全为零”。

string 学习备注

长度动态、写入成本随内容变化;生产系统应限制长度或把大内容放到链外。

Checked arithmetic · Solidity 0.8.x
uint320 ≤ x ≤ 2³² − 1

默认算术越界会 revert;只有显式进入 unchecked { ... } 才使用环绕语义。类型转换仍要单独审查截断。

没有 null,也没有浮点数直觉

Solidity 变量有类型默认值:整数为 0、地址为零地址、bool 为 false。 语言没有通用 null。定点类型目前仍不能像常规数值那样完整使用;金额通常用最小整数单位表示,例如 wei。 还要注意:0.8.x 的算术检查不等于所有转换都安全;运行期把 uint256 x 显式缩窄为 uint8(x) 可能截去高位,而不是自动 revert。

03 · Data location

同一种数据,放在哪里会改变语义

storage 是持久账本,memory 是本次调用的工作台,calldata 是只读输入封包。

Interactive lab · 01

把同一份 Struct 放到三个位置

选择数据位置

function inspect(Progress calldata p)
    external pure
    returns (uint32)
{
    return p.completedLessons;
}

切换后观察:能否修改、能否跨调用保留、赋值时是复制还是引用。

calldata:调用输入的只读视图,不能在函数中原地修改。

寿命
本次外部调用
可修改
典型用途
读取动态参数,避免不必要的 memory 副本
赋值语义
移到 memory / storage 时产生复制

最容易写错的是“局部 storage 变量”

Reference to state

Progress storage p =
    progressOf[msg.sender];

p.completedLessons = 9;
// 直接改动合约状态

Independent copy

Progress memory p =
    progressOf[msg.sender];

p.completedLessons = 9;
// 只改临时副本,除非再写回
  • storage → memory复制出独立副本;改 memory 不会自动写回。
  • storage → local storage建立引用;通过局部变量改字段,就是改状态。
  • calldata → memory / storage跨位置时复制;calldata 本身不可修改。
  • memory → memory引用类型的局部赋值可能共享同一片数据,不能机械理解为深拷贝。
  • calldata ≠ external 专属自 Solidity 0.6.9 起,public、internal、private 的引用类型参数也可声明为 calldata;它只描述位置与只读语义。

04 · State shape

Struct 负责“长什么样”,Mapping 负责“到哪里找”

它们常常一起出现,却解决完全不同的问题。

Record schema

Struct:一条记录的字段

struct Progress {
    uint32 completedLessons;
    uint64 updatedAt;
    Stage stage;
    bool exists;
    string note;
}

像一张有固定列名的记录:每个字段有类型,顺序还会影响 storage 布局。

Keyed address space

Mapping:由 key 定位记录

mapping(
    address student
    => Progress progress
) private progressOf;

像一片“对所有可能 key 都定义了默认值”的虚拟地址空间,不保存可遍历的 key 清单。

Mapping 的五条硬边界

01没有 length

mapping 不记录 key 列表,所以不能问“共有多少项”。需要遍历时另存数组或使用链外事件索引。

02每个 key 都“存在”

未写入 key 读取到值类型的默认表示;业务上的存在性要自己编码,例如 exists

03不能整体清空

delete mappingVariable 不能枚举并抹去所有未知 key;要按已知 key 逐项处理或更换命名空间。

04只能位于 storage

mapping 可以是状态变量、storage 引用或库函数的 storage 参数,不能作为 public / external ABI 参数或返回值搬运。

05public getter 有边界

编译器可生成按 key 查询的 getter;若 value 是复杂 struct,其中的 mapping / array 成员不会被完整自动返回。

再深入一层:它不是传统哈希表

对地址等静态 key,可把 value 起始位置直观理解为 keccak256(abi.encode(key, p)),其中 p 是 mapping 自身的基础槽。 这里只用于建立确定性寻址直觉;嵌套 mapping、动态 key 与 struct 字段偏移要按正式 storage layout 规则计算。

05 · Functions & ABI

函数,是合约对外暴露的状态转换入口

名字只是人类标签;对 EVM 调用而言,入口由 selector 与 ABI 编码的数据确定。

第一组开关:谁能调用?

visibility外部交易 / 合约本合约内部派生合约
external可以不能直接 f()this.f() 是外部调用只能按外部接口调用
public可以可以可以
internal不在外部 ABI可以可以
private不在外部 ABI可以不可以
状态变量的 public 是自动 getter

把状态变量标为 public,编译器会生成一个 external view getter; 它不会生成 setter,也不会让外部调用者绕过你的写函数直接修改 storage。

第二组开关:函数承诺做什么?

view不修改状态

可读取 storage,但不能写状态或 emit Event。链外用 eth_call 查询时用户不发交易;被链上合约调用仍消耗 gas。

pure不读也不改状态

只根据参数和局部计算返回结果。编译器会检查这项承诺。

payable允许随调用接收 ETH

不写 payable 的函数在收到非零 msg.value 时会拒绝调用。

nonpayable默认状态

可以读写状态,但不接受随调用发送的 ETH;关键字通常不显式写出。

Interactive lab · 02

组装一条函数声明

选择 visibility

选择 mutability

function inspect(address student)
    external
    returns (Progress memory)

external + nonpayable:在 ABI 中形成外部入口,可读写状态,但不能随调用接收 ETH。

ABI 入口
内部直调
不能直接调用
读 storage
可以
写 storage
可以
接收 ETH
不可以

外部调用的前四个字节

CANONICAL SIGNATURE updateProgress(uint32,uint8,string)

Stage 在外部 ABI 中按其底层整数类型编码。

FUNCTION SELECTOR 0x••••••••

本页不伪造具体哈希;用编译器或 ABI 工具得到真实 selector。

public 不等于安全,view 不等于免费

visibility 只描述可达性,不提供业务授权;任何外部入口都要独立判断调用者和输入。 view 只承诺不改状态:链外查询通常不让用户支付 gas,但链上调用仍占用 EVM 资源。

06 · Modifier

Modifier 不是注解,而是函数体的包装器

关键符号 _; 表示“把被修饰的函数体插到这里执行”。

Modifier expansion
modifier whenActive() {
    if (paused) revert ContractPaused();
    _; // 在此处执行被修饰的函数体
}

function updateProgress(...) external whenActive {
    // 只有检查通过才到这里
}

多个 Modifier 的顺序

function f() external A B { BODY }

进入:A 前置 → B 前置 → BODY

退出:B 后置 → A 后置

  • Modifier 可以接收参数参数必须在函数声明处显式传入;它不会神秘地抓取同名函数参数。
  • Modifier 可以阻止函数体在到达 _; 前 revert,或根本不执行 _;,函数体就不会运行。
  • Modifier 可以有后置逻辑写在 _; 之后;多个 modifier 会形成嵌套,而非简单拼接。
  • Modifier 只是复用机制名字叫 onlyOwner 并不自动安全;真正安全来自正确的条件、身份模型和完整测试。
  • 函数体 return 后仍可能有外层逻辑显式 return 离开当前函数体后,控制流仍会继续执行外层 modifier 在 _; 之后的代码。
权限检查用 msg.sender,不用 tx.origin

msg.sender 是当前调用帧的直接调用者;tx.origin 是整条交易最初的 EOA。 用 tx.origin 做授权会破坏合约组合性,并可能被中间恶意合约诱导调用。

07 · Events

Event 是链上日志,不是第二份状态

合约发出 log;节点把它放进交易收据;索引器与前端再按 topics 检索。

为什么有 indexed?

非 anonymous event 最多把三个参数标为 indexed。它们进入 topics,客户端可以按 topic 过滤; 未 indexed 的参数进入 data,适合完整展示。选择 indexed 字段,本质上是在设计未来的查询入口。

Filter by student

address indexed student 适合 indexed

地址是常见筛选维度,值可直接放进 32 字节 topic。

Filter by stage

Stage indexed stage 适合 indexed

枚举值很小,适合按学习阶段过滤。

Display the note

string note 保留在 data

动态类型若 indexed,topic 只保存特殊编码后的 Keccak-256 哈希,不能从 topic 还原原文。

问题storageevent log
合约以后能直接读取吗?不能;连发出它的合约也不能直接回读历史 log
适合表达当前真相吗?否;它表达“发生过什么”,当前状态要重放或查询
链外是否容易按字段搜索?通常需节点状态查询indexed topics 专门用于过滤
revert 后保留吗?不保留不保留;同一回滚范围内的 log 一并撤销
日志也受链重组语义影响

应用监听到事件,不代表它已经达到所需确定性。前端与索引器要处理被移出 canonical chain 的日志, 并根据业务风险等待足够确认或最终确定性。

08 · Complete example

把六个概念装进同一份合约

这是一份教学合约:足够小,可以完整读懂;足够真实,能展示状态、权限、日志与 ABI。

Type Function Modifier Event Mapping Struct
StudyRegistry.sol · Solidity 0.8.36
// SPDX-License-Identifier: MIT
pragma solidity 0.8.36;

contract StudyRegistry {
    // TYPE:有限状态,外部 ABI 中按整数编码
    enum Stage { Beginner, Core, Ecosystem, Builder }

    // STRUCT:一条学习记录的形状
    struct Progress {
        uint32 completedLessons;
        uint64 updatedAt;
        Stage stage;
        bool exists;
        string note;
    }

    address public immutable teacher;
    bool public paused;

    // MAPPING:学生地址 → Progress 记录
    mapping(address student => Progress progress)
        private progressOf;

    // EVENT:indexed 字段进入 topics,其余进入 data
    event ProgressUpdated(
        address indexed student,
        Stage indexed stage,
        uint32 completedLessons,
        string note
    );
    event PauseChanged(bool paused);

    error OnlyTeacher();
    error ContractPaused();
    error InvalidLessonCount();

    // MODIFIER:复用调用者检查
    modifier onlyTeacher() {
        if (msg.sender != teacher) revert OnlyTeacher();
        _;
    }

    // MODIFIER:复用暂停检查
    modifier whenActive() {
        if (paused) revert ContractPaused();
        _;
    }

    constructor() {
        teacher = msg.sender;
    }

    // FUNCTION:外部写入口,只有 teacher 可调用
    function setPaused(bool value)
        external
        onlyTeacher
    {
        paused = value;
        emit PauseChanged(value);
    }

    // calldata:动态字符串的只读调用输入
    function updateProgress(
        uint32 completedLessons,
        Stage stage,
        string calldata note
    )
        external
        whenActive
    {
        if (completedLessons > 1000) {
            revert InvalidLessonCount();
        }

        progressOf[msg.sender] = Progress({
            completedLessons: completedLessons,
            updatedAt: uint64(block.timestamp),
            stage: stage,
            exists: true,
            note: note
        });

        emit ProgressUpdated(
            msg.sender,
            stage,
            completedLessons,
            note
        );
    }

    // memory:把 storage 中的 struct 复制为返回值
    function getProgress(address student)
        external view
        returns (Progress memory)
    {
        return progressOf[student];
    }
}

按执行责任,而不是按行号阅读

SCHEMAenum + struct

先定义业务词汇与记录形状;它们决定后续状态和 ABI 的类型边界。

STATEteacher + paused + mapping

描述当前链上真相。immutable teacher 在部署时固定;mapping 随成功调用更新。

FAILUREcustom errors

给失败路径命名。revert 会撤销当前调用范围内的状态与日志。

GUARDmodifiers

把调用者与暂停检查集中表达,再包裹目标函数。

WRITEupdateProgress

验证输入、构造 struct、写入 mapping,并发出对应 event。

READgetProgress

只读 storage,把 Progress 复制到 memory 后按 ABI 返回。

教学示例不等于生产合约

它没有升级机制、角色轮换、备注长度上限、批量迁移、完整测试与审计。 真正持有资产或权限的系统应优先使用成熟库、最小化自定义机制,并做单元测试、模糊测试、静态分析与独立审计。

09 · Execution trace

亲手跑一遍:输入怎样变成状态与日志

改变参数、调用者或暂停状态,观察同一条函数路径会成功还是 revert。

Interactive lab · 03

模拟 updateProgress 调用

这是确定性的页面内演示,不连接钱包、RPC 或真实网络,也不估算实际 Gas。

准备完成:点击“执行模拟调用”,跟踪五个阶段。

  1. 01
    ABI decode

    解析 selector 与 calldata 参数。

    WAIT
  2. 02
    Modifier

    读取 paused,决定继续或 revert。

    WAIT
  3. 03
    Function check

    验证 completedLessons ≤ 1000。

    WAIT
  4. 04
    SSTORE

    按 msg.sender 更新 mapping 中的 struct。

    WAIT
  5. 05
    LOG

    发出 ProgressUpdated event。

    WAIT

Persistent state

progressOf[key]

key
0x71…A9
completedLessons
updatedAt
stage
exists
false
note

Transaction receipt

logs[0]

status
topic0
topic1
topic2
data

成功与失败的原子性

SUCCESS

所有检查通过 → mapping 更新 → event 写入收据 → 调用成功提交。

REVERT

任一步 revert → 当前调用范围内的状态变化和日志一起回滚;已执行计算仍可能消耗 Gas。

10 · Boundaries & security

会写语法,不等于会写安全合约

语法把意图变成字节码;安全来自正确模型、受限边界、测试、工具和审查。

误区 01“private 数据是秘密”

错。访问限制不是加密;链上数据对观察者可见。

误区 02“public 函数可信”

错。public 只表示可调用;输入、权限与状态条件仍要验证。

误区 03“mapping 能自动遍历”

错。它没有 key 清单和 length;枚举能力必须另行设计。

误区 04“event 就是数据库”

错。合约不能直接读历史 log;当前真相仍应存在 storage。

误区 05“memory 会在下次调用保留”

错。memory 只属于当前调用上下文。

误区 06“modifier 名字就是安全证明”

错。它只是代码包装;错误条件仍会产生错误权限。

误区 07“view 永远不花 Gas”

错。链外 eth_call 不收费;链上合约调用 view 仍消耗 Gas。

误区 08“event 发出后,即使 revert 也在”

错。revert 范围内的日志与状态一起撤销。

误区 09“block.timestamp 是精密时钟”

错。它适合粗粒度时间约束,不应承担高精度、公平随机性。

误区 10“编译成功即可上主网”

错。编译器只证明类型与语法等约束,不证明业务正确或经济安全。

当函数开始调用外部合约

本课示例没有外部调用,因此没有展示重入路径。真实合约一旦把控制权交给未知地址, 对方可能在原函数完成前回调。常见起点是 Checks → Effects → Interactions: 先检查、再更新自身状态、最后与外部交互;必要时再叠加成熟的重入保护与 pull-payment 设计。

上线前的最低自问
  • 谁能调用每个状态写函数?权限能否转移、撤销或被卡死?
  • 每个 mapping 的“未写入默认值”是否会被误当成有效状态?
  • 数组、字符串和循环是否有可证明的上限?最坏 Gas 是多少?
  • 每个外部调用前,自己的状态是否已经进入安全形态?
  • 事件是否足以重建审计轨迹,又没有把敏感信息误写上链?
  • 实际构建使用的编译器、优化器、EVM 目标和依赖是否锁定并公开验证?

11 · Synthesis

把整课压缩成一条因果链

从源代码的类型约束,到一次成功调用留下的状态与日志。

  1. 01

    Type 定义值的范围、操作与 ABI 表示。

  2. 02

    Struct 把相关字段组成一条有类型的记录。

  3. 03

    Mapping 用 key 确定性定位记录,但不保存可遍历 key 表。

  4. 04

    Function 把参数、可见性、mutability 与返回值组成调用边界。

  5. 05

    Modifier_; 周围包裹复用检查或后置逻辑。

  6. 06

    storage / memory / calldata 决定引用类型的寿命、可写性与复制语义。

  7. 07

    Event 把成功执行的事实编码为可过滤日志;它不是合约可读状态。

最终心智模型

类型回答“数据是什么”,位置回答“数据活在哪里”, Struct回答“一条记录长什么样”,Mapping回答“按什么 key 找到它”, 函数回答“外界如何请求变化”,Modifier回答“进入前后要经过什么”, Event回答“链外怎样知道成功发生过什么”。

六题校准

1. 哪个声明会让局部变量直接引用并修改状态中的 Struct?

请选择一个答案。

2. 读取一个从未写入的 mapping key,会发生什么?

请选择一个答案。

3. 对 view 函数,哪句话最准确?

请选择一个答案。

4. Modifier 里的 _; 表示什么?

请选择一个答案。

5. 把 string note 标为 indexed 后,topic 中是什么?

请选择一个答案。

6. updateProgress 写入后又在同一调用中 revert,结果如何?

请选择一个答案。

尚未作答。完成 6 题后,尝试不用术语表复述整条调用链。

12 · Glossary & sources

术语与一手资料

语言会更新;把稳定心智模型与可核对的版本文档放在一起。

核心术语

ABI
Application Binary Interface。描述函数、事件与错误怎样在链上字节和高层类型之间编码、解码。
Bytecode
EVM 可执行的字节序列。部署创建代码与部署后留在地址上的运行时代码不是同一段。
Calldata
外部调用的只读输入区域,包含 selector 和 ABI 编码参数,不跨调用持久化。
Memory
一次调用内的临时、可修改字节区域,随调用结束消失。
Storage
与合约地址关联的持久状态;写入通常是 EVM 操作中成本较高的一类。
Value type
赋值时按值复制的类型,例如 bool、整数、地址、定长 bytes 与 enum。
Reference type
语义受数据位置影响的类型,例如 array、struct;mapping 是只能位于 storage 的特殊引用类型。
Function selector
规范函数签名 Keccak-256 哈希的前 4 字节,用来选择外部函数入口。
Modifier
_; 周围包装函数体的可复用 Solidity 结构。
Topic
日志中最多四个 32 字节槽;非 anonymous event 通常用 topic0 保存事件签名哈希。
Custom error
用名字和可选参数描述 revert 原因的 ABI 项,通常比长字符串更节省部署与调用数据。
Atomicity
调用成功时相关变化一起提交;revert 时当前回滚范围内的状态与日志一起撤销。

官方与规范资料

资料版本

页面于 2026-07-25 核对;语言细节以 Solidity 0.8.36 固定版本文档为准。 当你使用不同编译器时,应切换到对应版本文档,并检查该版本的已知问题清单。

09.03 · Complete

现在,你看到的不是六个关键词,而是一条完整的状态转换路径。

输入经 ABI 进入函数,Modifier 把关,类型约束计算,Mapping 找到 Struct,storage 保存新状态,Event 留下可检索的成功记录。

返回书架