1. 先搞清楚你写的是什么:pallet 和 Polkadot runtime 的关系
如果你打开 Substrate 相关文档,十有八九会先碰见一个词,pallet。我一开始听到这个词也觉得挺唬人,还以为是某种特殊的数据结构。等你真正上手才发现,pallet 就是一条链上“可以插入的功能小插件”。放到 Polkadot 生态里,它通常指基于 FRAME 开发的一个 Rust 模块。这篇分享把话挑明:从零写一个链上模块,本质上就是写一个 pallet,把它挂到 runtime 里,然后让节点运行时能读能写你定义的链上状态。
Polkadot 的平行链大多基于 Substrate 构建,而 Substrate 把链拆成了 client 和 runtime 两层。Client 负责网络、共识、同步这些底层活,你平时关心的业务逻辑全部住在 runtime 里。runtime 可以粗略理解成“区块链状态的转换函数”,一组输入进来,状态机往新状态走一步,而每一个状态转换的“动作”恰恰就是由各种 pallet 提供的。所以写 pallet,就是在给状态机装新的能力。这件事没你想的那么神秘,但也没有简单到可以完全不看原理。这篇内容适合刚接触 Polkadot/Substrate、想赶紧跑通一个自定义模块的开发者,也适合那些翻过文档但被宏和 trait 绕晕的人。
1.1 所谓的“链上模块”就是一块会执行状态变更的积木
在传统后端里,你写一个功能可能就是一个服务、一个 API 接口。在区块链开发里,pallet 是这个概念的上链版本:它由 Rust 代码定义,运行时节点执行它,调用结果写进 Merkle 树保护的链上状态。同一个 pallet 可以只负责一个很小的功能,比如存一个数字、转一笔余额、管理一个社区的投票结果。你完全可以把它理解成一堆积木中的一个,runtime 通过一个叫construct_runtime!的宏把这些积木拼进同一条链。
FRAME 是 Substrate 官方封装的一套 pallet 开发框架,也是 Polkadot SDK 里最常用的方式。FRAME 做的事情非常像装修公司给毛坯房做标准布线:它把存储读写、事件分发、错误处理、交易权重这些高频但容易出错的底层逻辑,通过宏和 trait 帮你生成好。你写业务模块时只需要关注“输入什么、改什么状态、触发什么事件”,不需要自己从零解析交易格式。对绝大多数开发者来说,这就是 15 分钟能跑通的底气来源。
1.2 为什么模板比手写 Runtime 更适合新手
有人会问:Substrate 不是说可以自定义 runtime 吗,为什么非要学 FRAME pallet?因为手写一个实现Runtimetrait 的完整 runtime,相当于你要自己处理监控、交易解码、可升级性、权威节点切换等一堆东西。这些活繁重且容易踩坑。FRAME pallet 把“加一个功能”的成本压缩到:定义状态、定义调用入口、实现事件和错误,然后注册。就像你已经把电脑主板装好了,接下来只是往内存槽里插一根内存条。
所以我特别推荐从官方substrate-node-template仓库开始,而不是自己建一个空 Rust 项目。模板里已经带了一个最小 runtime、一套节点可执行文件,还塞了一个pallet-template示例。你只需要在这个示例里改代码,非常快。很多人觉得“模板不是从零”,但实际操作时,从模板下手才是符合工程效率的选择。你没必要把时间和耐心浪费在重复搭脚手架这件事上。
2. 实际动手前,用 5 分钟把工具链备齐
这个标题比较大胆,写模块只需要 15 分钟,但前提是你的环境已经能编译 Substrate。我遇到的绝大多数新人卡住的环节,其实不是逻辑写不出来,而是本地工具链没配对。Substrate 依赖 Rust 的 nightly 工具链,而且很多 crate 版本有严格约束。如果你之前只是写过普通 Rust 应用,第一步可能就需要花点耐心。
2.1 你需要装的东西其实没那么多
准备一台带 Linux 或者 macOS 的机器,Windows 也不是不能跑,但会麻烦不少。至少需要这些:
- Rust 工具链,特别是 nightly 版本,以及
wasm32-unknown-unknown编译目标 git,拉取模板仓库用cmake、clang、pkg-config等系统级依赖,具体看官方文档
安装 Rust 建议用rustup。装好稳定版后,手动添加 nightly:
rustup toolchain install nightly rustup target add wasm32-unknown-unknown --toolchain nightly我踩过的第一个坑是:Substrate 的依赖版本如果和本机 Rust 工具链不匹配,编译时会爆一堆莫名其妙的 trait 错误。官方模板通常会锁定某个特定日期版本的 nightly,有时你直接cargo build也能通过,但为了少受罪,最好进项目后看rust-toolchain.toml文件,里面写了工具链版本。遇到格式不对时,检查当前工具链是不是被意外切换了。
2.2 拉取 substrate-node-template 并确认能编译
环境准备好后,执行:
git clone https://github.com/substrate-developer-hub/substrate-node-template.git cd substrate-node-template接下来不是直接开写,而是先跑一次编译,确认整条编译链路没坏。这一步很关键,因为 Substrate 依赖几百上千个 crate,第一次下载及编译时间很可能远超 15 分钟。如果你先改完代码再编译,很难判断报错是环境问题还是业务代码问题。先把基准跑通,后续增量编译会快很多。
运行:
cargo build --release如果这一步顺利结束,等于告诉你:后面 15 分钟的高效开发具备了前提。我个人的经验是,编译时磁盘剩余空间至少要留 20GB,网络要稳定,依赖下载时不要频繁中断。真的遇到下载慢,耐心等,不要反复ctrl+c,因为中断后再来又得从头编译一堆依赖。
3. 核心环节:15 分钟写一个可运行的简单模块
我选择的例子很朴素:一个simple-storage模块,支持用户通过签名交易把一个数字累加进链上存储,并存一条“谁在什么时间更新成了多少”的事件。它麻雀虽小,但覆盖了 FRAME pallet 最核心的几个要素:存储、调用、事件、错误。你完全可以在模板基础上 15 分钟内写完。
3.1 先改 Cargo.toml,让模块有个正式身份
进入模板后,你会看到一个pallets/template目录。建议先复制一份,把目录改名为pallets/simple-storage,然后打开它的Cargo.toml。这里要做的事是让 crate 有一个独立的名字,避免和模板默认名混淆。关键片段如下:
[package] name = "pallet-simple-storage" version = "0.1.0" description = "A simple storage pallet used for demo" edition = "2021" [dependencies] frame-support = { version = "4.0.0-dev", default-features = false } frame-system = { version = "4.0.0-dev", default-features = false } scale-codec = { package = "parity-scale-codec", version = "3.0.0", default-features = false, features = ["derive"] } scale-info = { version = "2.5.0", default-features = false, features = ["derive"] } sp-runtime = { version = "28.0.0", default-features = false } sp-std = { version = "14.0.0", default-features = false } [features] default = ["std"] std = [ "frame-support/std", "frame-system/std", "scale-codec/std", "scale-info/std", "sp-runtime/std", "sp-std/std", ]版本号我写的是参考格式,实际以模板仓库里的Cargo.lock和 workspace 中其他 pallet 的Cargo.toml为准。不要自己手动改版本,否则会出现 crate 类型冲突。这一步的逻辑是让当前 package 能作为 workspace 的一员,和 runtime 共享同一套依赖链。
3.2 在 lib.rs 里用 FRAME 宏拼装核心逻辑
打开pallets/simple-storage/src/lib.rs,完整代码可以这样写:
#![cfg_attr(not(feature = "std"), no_std)] pub use pallet::*; #[frame_support::pallet] pub mod pallet { use frame_support::pallet_prelude::*; use frame_system::pallet_prelude::*; #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>; } #[pallet::pallet] pub struct Pallet<T>(_); #[pallet::storage] #[pallet::getter(fn my_value)] pub type MyValue<T: Config> = StorageValue<_, u32, ValueQuery>; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event<T: Config> { ValueSet(T::AccountId, u32), } #[pallet::error] pub enum Error<T> { Overflow, } #[pallet::call] impl<T: Config> Pallet<T> { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn set_value( origin: OriginFor<T>, value: u32, ) -> DispatchResult { let who = ensure_signed(origin)?; let current = MyValue::<T>::get(); let new_value = current .checked_add(value) .ok_or(Error::<T>::Overflow)?; MyValue::<T>::set(new_value); Self::deposit_event(Event::<T>::ValueSet(who, new_value)); Ok(()) } } }这段代码量不大,但它已经把 FRAME 的架子演示得很完整。
Configtrait 是 pallet 与外界的约定。这里的RuntimeEvent关联类型会把 pallet 内定义的Event和 runtime 的事件类型打通。StorageValue<_, u32, ValueQuery>是一个很典型的存储项:只有一个 key,存一个 u32,查询时默认返回 0。#[pallet::getter(fn my_value)]会生成一个fn my_value()的查询函数,方便后续读取。
set_value是链上调用入口。ensure_signed(origin)?表示这个函数只允许签名账户调用,拿到的是调用者账号。checked_add是为了防溢出,如果溢出就返回Error::<T>::Overflow。最后更新存储、存事件、返回Ok(())。
你可以照着模板代码改,不需要背宏规则。需要注意的是,Event的泛型参数T不能省,因为事件里的AccountId依赖T::AccountId;但使用T::AccountId时,必须确保T: Config,这个 trait 会自动带上frame_system::Config提供的关联类型。
3.3 单独编译 pallet,把最常见的错误挡在门外
写完代码后,不要在根目录直接cargo build --release,那会很慢。先在 workspace 里只编译这个 pallet:
cargo check -p pallet-simple-storage如果是在模板里改的包名,记得改成你实际的 crate 名。这一步很快,专门验证你这个模块本身的代码是否合法。为什么要单独检查?因为 Substrate 的宏错误提示经常很长,来自 runtime 或 wasm builder 的错误会掩盖真实问题。单独编译能让报错集中在pallet-simple-storage本身,排查成本低很多。
如果这里通过了,说明最核心的代码是有效的。我实测时,绝大多数所谓编译错误都是小问题,比如泛型参数少了T、事件类型没加T::RuntimeEvent、DispatchResult返回类型不对。这些在单包层面就能被守护住,不需要等整个 runtime 编译失败再后悔。
4. 把模块接入 Polkadot 风格的 runtime
pallet 写好但没接进 runtime,就是一段孤岛代码。把模块挂上去,才真正变成“链上模块”。这一步的核心动作有三个:声明依赖、实现Config、在construct_runtime!里注册。真正操作不会超过五分钟。
4.1 runtime 的依赖列表和 Config 实现
打开runtime/Cargo.toml,在dependencies区域加入这一行:
pallet-simple-storage = { path = "../pallets/simple-storage", default-features = false }同时记得在 runtime 的features里给std列表追加pallet-simple-storage/std。这一步如果不做,你会遇到 runtime 在非标准环境下编译失败的情况,原因是非 std 构建缺少了该 crate 的 std feature。
然后打开runtime/src/lib.rs。在文件靠上的位置,找到其他 pallet 的impl ...::Config for Runtime区块,照葫芦画瓢:
impl pallet_simple_storage::Config for Runtime { type RuntimeEvent = RuntimeEvent; }看到这里你可能会想:为什么实现就一行?因为简单 pallet 的Config里目前只有RuntimeEvent一个关联类型。真正业务复杂的 pallet 可能会要求你指定手续费、管理权限、治理来源等。这里先保持最小化,一切从简。
4.2 用 construct_runtime 注册模块并跳过 Wasm 快速验证
construct_runtime!宏是拼装整条链的地方。在runtime/src/lib.rs里找到这个宏,把你新写的模块加进列表,比如在TemplateModule附近加入:
construct_runtime!( pub enum Runtime where Block = Block, NodeBlock = node_primitives::Block, UncheckedExtrinsic = UncheckedExtrinsic { System: frame_system, Balances: pallet_balances, ... SimpleStorage: pallet_simple_storage, } );注册后,pallet 的调用入口会自动出现在链的交易接口里。注意,construct_runtime!里每个模块的先后顺序并不是随意的,它会影响模块编号和部分默认初始化顺序。对测试环境来说差别不大,但在真实平行链上升级时,如果你调整了已有模块的位置,会导致既有调用索引改变,进而产生兼容性问题。所以规范做法是:新模块永远加在列表末尾,不要随便插入中间。
注册完先别跑完整构建。我们可以用环境变量跳过 WASM 构建,只做本机运行时的快速检查:
SKIP_WASM_BUILD=1 cargo check -p node-template这条命令对日常调试很管用。因为 Substrate 节点的 runtime 编译会生成 wasm blob,整个过程很耗时;跳过 wasm 后,你还能验证当前 Rust 代码在 runtime 层是否能合得起来。等所有业务逻辑稳定了再做一次完整的cargo build --release即可。
4.3 本地链启动与链上调用验证
完整构建完成后,你会在target/release下得到一个节点二进制。启动开发网络:
./target/release/node-template --dev启动后,默认开放ws://127.0.0.1:9944。你可以用 Polkadot.js Apps 连接到这个本地节点,在 Developer 页面的 extrinsics 里选择simpleStorage.setValue,填入一个数字,签名提交。再切到链上状态查询,选择simpleStorage.myValue,就能看到存储从默认值 0 变成了刚才提交的数字。如果提交两次,数值会累加,这就验证了你的存储读取和更新逻辑都是真实跑在状态机里的。
第一次完整构建可能要十几分钟甚至更久,但这不是写代码的 15 分钟,而是一次性的环境成本。增量编译后续会快很多,改了 pallet 再重新跑cargo build --release,通常只要一分钟到几分钟。
5. 新手最容易踩的坑:问题排查实录
写 pallet 的门槛不在写代码本身,而在查错。Substrate 里宏生成的代码非常多,编译期报错有时会把真实问题淹没在几千行类型检查里。我把自己遇到过的高频问题和排查思路整理成几条,供你参考。
5.1 版本错位导致的编译错乱
最典型的报错是“expected struct X, found struct Y”或者一堆 “trait not implemented”。为什么会出现同一个sp_runtime类型却有不同实现?因为不同 crate 被解析到了不同版本。Substrate 生态里有大量 crate 被派发同步发布,你得保证 workspace 里所有 pallet 指向的版本能和你 node-template 的锁定版本兼容。降低风险的做法有两个:第一,克隆官方模板后用它的Cargo.lock作为起点;第二,不要手动修改依赖版本号,尽量沿用模板仓库已有的版本约束。如果你发现改了某个依赖版本后编译各种炸,最快回退方式就是git checkout还原相关文件,而不是原地改版本。
5.2 事件和 Error 类型没接好
如果你在impl pallet_simple_storage::Config for Runtime里写错了RuntimeEvent,通常会报这种错:
Event<Self>无法转换成RuntimeEventimpl From<Event<Self>> for RuntimeEvent这个 trait 不满足
出现这类问题,先确认你的pallet中Event确实写了#[pallet::generate_deposit]。因为 runtime 要根据From<Event<Self>>把 pallet 的事件映射成 runtime 的枚举变体。如果漏了 generate_deposit,事件系统没法自动积累。另一个容易错的地方是把RuntimeEvent拼错成RuntimeCall或Event,这类错误在construct_runtime!里看不出来,在 trait 检查时才会暴露。
5.3 链上调用失败但没明显效果
一种很隐蔽的情况是:set_value调用没有报错,但你查存储还是旧值。原因很可能不是逻辑写错,而是你调用的模块还没真正注册到当前链,或者你连接的节点不是最新构建的二进制。开发中我经常改完代码忘记重新编译节点,还在用旧进程测试,结果查半天代码找不出 bug。解决方案是每次改完 pallet 后,确保停止旧节点、重新构建、再启动新节点。如果你连接的链已经包含了早前注册的TemplateModule,但你新模块还没加进construct_runtime!,那么交易提交时节点会提示找不到对应 pallet 的 call。
另一个隐蔽点是存储默认值。StorageValue<_, u32, ValueQuery>的默认值是 0,如果你第一次查出 0,不代表没存上,也可能只是还没有任何人写入。建议提交一次set_value(5)后再查询,看到 5 才能确定写入成功。
6. 跑完一遍才总结出的提速心得
说是 15 分钟,其实真正影响速度的是工具链、模板和调试手段。下面这几条经验能帮你把后续开发节奏拉得更快。
6.1 用模板脚手架,而不是从空目录开荒
我见过不少人一上来就cargo new,然后手动实现完整 runtime,结果一搞就是一个星期。严格来说,你确实可以从零写,但没必要。官方模板已经解决了大量细节,比如如何把 runtime 编译成 wasm、如何配置 exec 节点、如何把 pallet 导出给前端。站在模板上改,15 分钟的任务就是“填空”,这个效率收益非常明显。如果你想验证自己是否真的理解了,可以尝试从模板里删掉一个不用的 pallet,再重新注册,这比从零开始更能锻炼手感。
6.2 多利用 cargo expand 和单包检查
FRAME 宏会生成大量你不直接看到的代码。调试时,安装cargo-expand是非常值的投资:
cargo +nightly install cargo-expand cargo expand -p pallet-simple-storage > expanded.rs打开expanded.rs,你能看到宏最终生成了什么,比如Pallet结构体里有哪些函数、事件 deposit 函数长什么样。很多时候你觉得“这里为什么报错”,其实是宏展开后的类型约束不满足。看展开代码比自己盲想快得多。
开发时也要养成“单包优先”的习惯。业务逻辑改动先cargo check -p pallet-simple-storage,通过后再检查 runtime。如果一开始就直接构建整个节点,浪费时间和带宽,排查错误也会更痛苦。
6.3 别忽视功能测试和你的“手感”节奏
模板里自带一个简单的测试模块,你也可以照着写测试。测试并不只是为了证明代码正确,它更是快速验证逻辑的方式。比如你改了一个StorageValue的查询类型,普通编译通过不代表运行期行为符合预期,但一个cargo test能给你真实的反馈。
最后再分享一个小技巧,也是我自己反复用过很多次的:在写 pallet 前,先明确“这个模块要暴露给用户哪几个调用入口、要维护哪几个状态”,然后把这些状态和调用翻译成 storage 和 call 函数,最后补事件和错误。顺序反了容易越写越乱。等你这样写过两三个 pallet,开发节奏就会变成肌肉记忆——15 分钟真的绰绰有余。