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。装好稳定版后手动添加 nightlyrustup 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网络要稳定依赖下载时不要频繁中断。真的遇到下载慢耐心等不要反复ctrlc因为中断后再来又得从头编译一堆依赖。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: FromEventSelf IsTypeSelf as frame_system::Config::RuntimeEvent; } #[pallet::pallet] pub struct PalletT(_); #[pallet::storage] #[pallet::getter(fn my_value)] pub type MyValueT: Config StorageValue_, u32, ValueQuery; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum EventT: Config { ValueSet(T::AccountId, u32), } #[pallet::error] pub enum ErrorT { Overflow, } #[pallet::call] implT: Config PalletT { #[pallet::call_index(0)] #[pallet::weight(10_000)] pub fn set_value( origin: OriginForT, 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 里只编译这个 palletcargo check -p pallet-simple-storage如果是在模板里改的包名记得改成你实际的 crate 名。这一步很快专门验证你这个模块本身的代码是否合法。为什么要单独检查因为 Substrate 的宏错误提示经常很长来自 runtime 或 wasm builder 的错误会掩盖真实问题。单独编译能让报错集中在pallet-simple-storage本身排查成本低很多。如果这里通过了说明最核心的代码是有效的。我实测时绝大多数所谓编译错误都是小问题比如泛型参数少了T、事件类型没加T::RuntimeEvent、DispatchResult返回类型不对。这些在单包层面就能被守护住不需要等整个 runtime 编译失败再后悔。4. 把模块接入 Polkadot 风格的 runtimepallet 写好但没接进 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_BUILD1 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通常会报这种错EventSelf无法转换成RuntimeEventimpl FromEventSelf for RuntimeEvent这个 trait 不满足出现这类问题先确认你的pallet中Event确实写了#[pallet::generate_deposit]。因为 runtime 要根据FromEventSelf把 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 分钟真的绰绰有余。