00 · Orientation
先画一张不会混乱的地图
Solidity、合约、字节码、ABI、状态和日志,不是同一个东西。
Solidity 是源语言。编译器读取源代码,产出供 EVM 执行的 bytecode,以及供钱包、前端和其他工具理解调用方式的 ABI。部署交易把创建字节码送进 EVM;成功后,某个地址获得运行时字节码和自己的持久化 storage。
一份已部署的智能合约,是某个以太坊地址上的 EVM 代码与状态。 Solidity 只是编写它的高级语言;链上通常保存的是编译结果,不是你在编辑器里看到的源文件。
contract · struct · function
parse · type-check · optimize
deploy + runtime
functions · events · errors
一笔调用里,六个主题各自做什么?
- 数据类型告诉编译器和 ABI:每个值是什么、占多大范围、允许哪些操作。
- 函数定义入口、参数、返回值、可见性和是否允许改变状态或接收 ETH。
- Modifier把可复用的前置或后置逻辑包在函数体外,例如权限与暂停检查。
- Mapping把一个 key 确定性地映射到一个值,适合余额、权限和按地址索引的记录。
- Struct把多个有名字、可能不同类型的字段组成一条业务记录。
- Event把 ABI 编码的日志写进交易收据,让链外应用可以过滤、索引与展示。
本课示例按 Solidity 0.8.36 编写,并用
pragma solidity 0.8.36; 精确声明。若写成 ^0.8.36,
则允许后续兼容的 0.8.x 编译器;无论哪种写法,真实项目还应在构建配置与锁文件中固定经过测试的版本。
01 · Anatomy
先读懂一份合约的骨架
从文件级声明,到合约级状态,再到一次调用内部的局部变量。
// 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);
}
}
许可证与 pragma
SPDX 标识授权方式;pragma 声明可接受的编译器版本范围。两者不在每次函数调用里执行。
状态、类型与成员
owner、scores 属于合约状态;event 和 function 是合约接口与行为的一部分。
constructor
只在创建合约时执行一次。它的代码参与创建过程,但不会作为普通入口留在运行时接口里。
参数与局部值
newScore 只属于当前调用;状态写入成功后,scores 的变化才会跨调用保留。
三层变量,三种寿命
| 层级 | 例子 | 何时存在 | 典型位置 |
|---|---|---|---|
| 状态变量 | scores | 与合约状态同寿命 | storage,或 constant / immutable 的特殊编译布局 |
| 函数参数 | newScore | 当前调用 | calldata、memory 或栈上的值 |
| 局部变量 | uint256 next | 当前执行路径 | stack、memory,或 storage 引用 |
private 只限制其他 Solidity 合约通过语言级成员访问它;
全节点保存的链上状态仍可由链外工具分析。任何私钥、密码、未公开答案都不应写进合约 storage。
02 · Types
类型,是对比特的解释规则
Solidity 是静态类型语言:每个变量在编译时就必须有确定类型。
EVM 最终处理的是 256 位字、字节序列和存储槽;Solidity 在上面加了一套类型系统。 类型不仅决定“能存什么”,还决定 ABI 怎样编码、算术是否检查溢出、赋值是复制还是引用,以及哪些位置可以使用这个值。
Copied as values
值类型
booltrue / falseuint8 … uint256无符号整数int8 … int256有符号整数address20 字节账户地址bytes1 … bytes32定长字节enum有限命名状态
赋值或传参时通常得到独立的值。uint 是 uint256 的别名。
Location matters
引用类型
stringUTF-8 字节序列的语言抽象bytes动态长度原始字节T[] / T[n]动态 / 定长数组struct命名字段组成的记录mapping仅存在于 storage 的键值结构
数组、struct 等必须考虑 storage、memory 或 calldata;位置会改变语义。
本课示例为什么选这些类型?
address
谁的记录?
用调用者地址作为 mapping 的 key。它是标识,不自动证明现实身份。
uint32
完成课数
业务上限远小于 2³²;明确范围,也可与相邻小字段打包进一个存储槽。
uint64
更新时间
显式转换 block.timestamp;范围足够,又比 uint256 更贴近业务约束。
enum Stage
有限阶段
比魔法数字可读,值只能落在声明的枚举成员范围内。
bool exists
区分未写入
mapping 对任意 key 都返回默认值;显式存在位能区分“没有记录”和“恰好全为零”。
string
学习备注
长度动态、写入成本随内容变化;生产系统应限制长度或把大内容放到链外。
uint320 ≤ x ≤ 2³² − 1默认算术越界会 revert;只有显式进入 unchecked { ... } 才使用环绕语义。类型转换仍要单独审查截断。
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 清单。
0x71…A9student A
0x42…C0student B
0x99…E1never written
9 · Core · true
Progress3 · Beginner · true
default0 · Beginner · false
Mapping 的五条硬边界
mapping 不记录 key 列表,所以不能问“共有多少项”。需要遍历时另存数组或使用链外事件索引。
未写入 key 读取到值类型的默认表示;业务上的存在性要自己编码,例如 exists。
delete mappingVariable 不能枚举并抹去所有未知 key;要按已知 key 逐项处理或更换命名空间。
mapping 可以是状态变量、storage 引用或库函数的 storage 参数,不能作为 public / external ABI 参数或返回值搬运。
编译器可生成按 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,编译器会生成一个 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
- 不可以
外部调用的前四个字节
updateProgress(uint32,uint8,string)
Stage 在外部 ABI 中按其底层整数类型编码。
0x••••••••
本页不伪造具体哈希;用编译器或 ABI 工具得到真实 selector。
visibility 只描述可达性,不提供业务授权;任何外部入口都要独立判断调用者和输入。
view 只承诺不改状态:链外查询通常不让用户支付 gas,但链上调用仍占用 EVM 资源。
06 · Modifier
Modifier 不是注解,而是函数体的包装器
关键符号 _; 表示“把被修饰的函数体插到这里执行”。
if (paused) revert;
validate → write storage → emit event
可选的后置逻辑}
函数体、状态写入和 event 全部不保留。
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 是整条交易最初的 EOA。
用 tx.origin 做授权会破坏合约组合性,并可能被中间恶意合约诱导调用。
07 · Events
Event 是链上日志,不是第二份状态
合约发出 log;节点把它放进交易收据;索引器与前端再按 topics 检索。
emit ProgressUpdated(student, stage, lessons, note);
0xContract…keccak256(event signature)
topic1student · indexed
topic2stage · indexed
completedLessons + note为什么有 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 还原原文。
| 问题 | storage | event log |
|---|---|---|
| 合约以后能直接读取吗? | 能 | 不能;连发出它的合约也不能直接回读历史 log |
| 适合表达当前真相吗? | 是 | 否;它表达“发生过什么”,当前状态要重放或查询 |
| 链外是否容易按字段搜索? | 通常需节点状态查询 | indexed topics 专门用于过滤 |
| revert 后保留吗? | 不保留 | 不保留;同一回滚范围内的 log 一并撤销 |
应用监听到事件,不代表它已经达到所需确定性。前端与索引器要处理被移出 canonical chain 的日志, 并根据业务风险等待足够确认或最终确定性。
08 · Complete example
把六个概念装进同一份合约
这是一份教学合约:足够小,可以完整读懂;足够真实,能展示状态、权限、日志与 ABI。
// 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];
}
}
按执行责任,而不是按行号阅读
先定义业务词汇与记录形状;它们决定后续状态和 ABI 的类型边界。
描述当前链上真相。immutable teacher 在部署时固定;mapping 随成功调用更新。
给失败路径命名。revert 会撤销当前调用范围内的状态与日志。
把调用者与暂停检查集中表达,再包裹目标函数。
验证输入、构造 struct、写入 mapping,并发出对应 event。
只读 storage,把 Progress 复制到 memory 后按 ABI 返回。
它没有升级机制、角色轮换、备注长度上限、批量迁移、完整测试与审计。 真正持有资产或权限的系统应优先使用成熟库、最小化自定义机制,并做单元测试、模糊测试、静态分析与独立审计。
09 · Execution trace
亲手跑一遍:输入怎样变成状态与日志
改变参数、调用者或暂停状态,观察同一条函数路径会成功还是 revert。
Interactive lab · 03
模拟 updateProgress 调用
准备完成:点击“执行模拟调用”,跟踪五个阶段。
- 01ABI decodeWAIT
解析 selector 与 calldata 参数。
- 02ModifierWAIT
读取 paused,决定继续或 revert。
- 03Function checkWAIT
验证 completedLessons ≤ 1000。
- 04SSTOREWAIT
按 msg.sender 更新 mapping 中的 struct。
- 05LOGWAIT
发出 ProgressUpdated event。
Persistent state
progressOf[key]
- key
- 0x71…A9
- completedLessons
- —
- updatedAt
- —
- stage
- —
- exists
- false
- note
- —
Transaction receipt
logs[0]
- status
- —
- topic0
- —
- topic1
- —
- topic2
- —
- data
- —
成功与失败的原子性
所有检查通过 → mapping 更新 → event 写入收据 → 调用成功提交。
任一步 revert → 当前调用范围内的状态变化和日志一起回滚;已执行计算仍可能消耗 Gas。
10 · Boundaries & security
会写语法,不等于会写安全合约
语法把意图变成字节码;安全来自正确模型、受限边界、测试、工具和审查。
错。访问限制不是加密;链上数据对观察者可见。
错。public 只表示可调用;输入、权限与状态条件仍要验证。
错。它没有 key 清单和 length;枚举能力必须另行设计。
错。合约不能直接读历史 log;当前真相仍应存在 storage。
错。memory 只属于当前调用上下文。
错。它只是代码包装;错误条件仍会产生错误权限。
错。链外 eth_call 不收费;链上合约调用 view 仍消耗 Gas。
错。revert 范围内的日志与状态一起撤销。
错。它适合粗粒度时间约束,不应承担高精度、公平随机性。
错。编译器只证明类型与语法等约束,不证明业务正确或经济安全。
当函数开始调用外部合约
本课示例没有外部调用,因此没有展示重入路径。真实合约一旦把控制权交给未知地址, 对方可能在原函数完成前回调。常见起点是 Checks → Effects → Interactions: 先检查、再更新自身状态、最后与外部交互;必要时再叠加成熟的重入保护与 pull-payment 设计。
- 谁能调用每个状态写函数?权限能否转移、撤销或被卡死?
- 每个 mapping 的“未写入默认值”是否会被误当成有效状态?
- 数组、字符串和循环是否有可证明的上限?最坏 Gas 是多少?
- 每个外部调用前,自己的状态是否已经进入安全形态?
- 事件是否足以重建审计轨迹,又没有把敏感信息误写上链?
- 实际构建使用的编译器、优化器、EVM 目标和依赖是否锁定并公开验证?
11 · Synthesis
把整课压缩成一条因果链
从源代码的类型约束,到一次成功调用留下的状态与日志。
- 01
Type 定义值的范围、操作与 ABI 表示。
- 02
Struct 把相关字段组成一条有类型的记录。
- 03
Mapping 用 key 确定性定位记录,但不保存可遍历 key 表。
- 04
Function 把参数、可见性、mutability 与返回值组成调用边界。
- 05
Modifier 在
_;周围包裹复用检查或后置逻辑。 - 06
storage / memory / calldata 决定引用类型的寿命、可写性与复制语义。
- 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 时当前回滚范围内的状态与日志一起撤销。
官方与规范资料
- Solidity team · 2026Solidity 0.8.36 Release Announcement本课示例采用的稳定版本与安全修复说明。
- Solidity docs · v0.8.36Solidity Documentation固定版本的语言与编译器文档入口。
- Language referenceTypes值类型、引用类型、数据位置、Struct 与 Mapping。
- Language referenceContracts可见性、getter、Modifier、函数、Event 与 Custom Error。
- Compiler internalsContract ABI Specificationselector、参数编码、Event topics/data 与错误数据。
- Language referenceLayout of a Solidity Source FileSPDX、pragma、import 与顶层声明。
- Language referenceStructure of a Contract状态变量、函数、Modifier、Event、Error、Struct 与 Enum。
- Compiler internalsLayout of State Variables in Storage存储槽、打包、Mapping 与动态数组寻址。
- Compiler internalsLayout of Call Data外部调用参数与 calldata 布局。
- AdvisorySecurity Considerations重入、Gas 限制、授权、失败模式与安全建议。
- ethereum.orgIntroduction to smart contracts智能合约作为代码与状态的总体定位。
- ethereum.orgAnatomy of smart contracts状态、函数、环境变量、Event 与示例结构。
页面于 2026-07-25 核对;语言细节以 Solidity 0.8.36 固定版本文档为准。 当你使用不同编译器时,应切换到对应版本文档,并检查该版本的已知问题清单。