1. 从“substrate”这个词说起:它到底指什么
第一次看到“substrate”这个词,很多人会愣一下。它在不同圈子里含义差别很大:做区块链的人第一反应是 Parity 那套区块链框架;做材料、化学、生物的人想到的是“基底”“底物”“培养基”;做半导体和微电子的人想到的是“衬底”;做软件架构的人可能想到的是“底层基座”。所以单看一个标题“substrate”,如果不结合上下文,几乎没法判断要聊什么。
我这次就按最主流、讨论度最高的方向来展开——区块链开发框架 Substrate。如果你是因为材料、半导体方向点进来的,也别急着走,我在第 2 节会把“substrate 作为通用概念”的几种含义先理清楚,帮你判断自己到底要找的是哪一个,然后再聚焦到区块链框架这条主线上。
Substrate 是 Parity 团队开源的一套区块链开发框架,用 Rust 写成。它的核心价值一句话概括:让你不用从零造一条链,而是像搭积木一样,通过组合现成的模块(pallet)快速拼出一条符合自己业务需求的区块链。这跟“从比特币源码 fork 改一改”或者“从以太坊客户端魔改”是完全不同的思路——前者是框架化、模块化,后者是复制粘贴式改造。
为什么它值得单独拿出来讲?因为绝大多数人第一次接触 Substrate 时,都会经历三个阶段:第一阶段觉得“哇,模块化真香”;第二阶段发现“编译一次半小时,报错看不懂”;第三阶段才慢慢摸到“哪些 pallet 能直接用、哪些必须自己写、runtime 升级到底怎么玩”。这篇就把这三个阶段里最容易卡住的地方,按我自己的实操经验拆开讲。
适合谁看:想入门区块链底层开发、想评估“要不要用 Substrate 做一条业务链”、或者已经在写 pallet 但被 runtime 和存储搞晕的人。不需要你精通 Rust,但至少要能看懂基本的 Rust 语法,否则后面会有点吃力。
2. 先把“substrate”的几种含义分清楚,别走错门
2.1 材料与半导体语境下的 substrate
在材料科学里,substrate 通常翻译成“基底”或“衬底”,指的是承载某种反应、生长或沉积过程的底层材料。比如化学气相沉积(CVD)里,薄膜是长在衬底上的;生物实验里,培养基就是微生物生长的 substrate;半导体制造里,硅片本身就是 substrate,上面再外延、光刻、镀膜。
这个语境下的核心关注点是:表面特性、晶格匹配、热膨胀系数、洁净度。举个具体例子,在硅衬底上外延生长氮化镓(GaN),晶格失配率大概在 17% 左右,这个数字直接决定了外延层里会积累多少位错密度。工程师要做的就是在衬底和外延层之间加缓冲层来缓解应力。这类问题的分析逻辑是“材料参数决定工艺窗口”,跟软件框架完全是两套语言。
2.2 生物化学语境下的 substrate
生物化学里 substrate 一般叫“底物”,指酶催化反应中被作用的那个分子。比如淀粉酶作用于淀粉,淀粉就是底物;蛋白酶作用于蛋白质,蛋白质就是底物。这里的关键概念是“酶与底物的特异性结合”,也就是锁钥模型。做实验的人关心的是 Km 值、Vmax、抑制类型这些动力学参数。
2.3 区块链框架 Substrate
回到主线。区块链领域的 Substrate 是一个用于构建区块链的 Rust 框架,由 Parity Technologies 开发,Polkadot 中继链本身就是用它写的。它的设计哲学是“链即 runtime,runtime 即状态转换函数”。你写的所有业务逻辑,最终都编译成一个 Wasm 二进制,作为链的“状态转换函数”存在。
这三种含义虽然都叫 substrate,但底层逻辑完全不同。下面所有内容都聚焦在区块链框架 Substrate上。如果你要找的是材料或生物方向,那这篇可能帮不上太多,但至少你现在知道该往哪个方向搜了。
3. Substrate 的模块化设计:pallet 到底解决了什么问题
3.1 从“改源码”到“拼模块”的思维转变
传统做链的方式是 fork 一个成熟项目,然后在源码里改。改着改着你会发现:升级一次上游版本,你的改动全冲突;想加个新功能,得在几千行代码里找插入点;团队里两个人改同一个文件,合并起来痛不欲生。Substrate 的思路是把链拆成一个个功能独立的 pallet(托盘/模块),每个 pallet 负责一块业务,比如余额、治理、质押、身份。
这种设计带来的直接好处是:功能边界清晰,升级和替换成本低。你想加一个“资产发行”功能,直接用现成的pallet-assets;想加“多签账户”,用pallet-multisig;想加“治理投票”,用pallet-democracy。每个 pallet 都有自己的存储、事件、错误类型和可调用函数(extrinsic)。
我个人的体会是,Substrate 最值钱的地方不是它自带的那些 pallet,而是它强制你用模块化的方式思考链的设计。以前你可能把“转账”“手续费”“治理”混在一个大合约里,现在你必须想清楚:哪些逻辑属于哪个 pallet,pallet 之间怎么通过 trait 通信。
3.2 一个 pallet 的最小结构长什么样
一个标准 pallet 通常包含这几块:
- Config trait:定义这个 pallet 依赖哪些外部类型和参数,比如关联类型
RuntimeEvent、Currency、常量MaxLocks等。 - Storage:用
#[pallet::storage]声明的链上存储,比如StorageMap、StorageValue、StorageDoubleMap。 - Events:用
#[pallet::event]声明,链上发生重要动作时抛出,方便前端和索引器监听。 - Errors:用
#[pallet::error]声明,交易失败时返回具体原因。 - Calls(Extrinsics):用
#[pallet::call]声明,用户或治理可以调用的函数。 - Hooks:
on_initialize、on_finalize、on_runtime_upgrade等生命周期钩子。
下面是一个极简的 pallet 骨架,展示存储和调用怎么配合:
#[pallet::pallet] pub struct Pallet<T>(_); #[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>; } #[pallet::storage] pub type CounterValue<T> = StorageValue<_, u32, ValueQuery>; #[pallet::event] #[pallet::generate_deposit(pub(super) fn deposit_event)] pub enum Event<T: Config> { CounterIncremented { new_value: 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 increment(origin: OriginFor<T>) -> DispatchResult { let _who = ensure_signed(origin)?; let current = CounterValue::<T>::get(); let next = current.checked_add(1).ok_or(Error::<T>::Overflow)?; CounterValue::<T>::put(next); Self::deposit_event(Event::CounterIncremented { new_value: next }); Ok(()) } }这段代码看起来简单,但里面有几个新手最容易忽略的点:call_index必须唯一且一旦上线就不能改,否则前端编码会错位;weight是预估值,写小了会导致交易被拒,写大了浪费区块空间;ensure_signed用来校验调用者签名,不写的话任何人都能调。
3.3 pallet 之间怎么通信
pallet 不是孤岛。比如你的自定义 pallet 想给用户转账,就需要依赖pallet-balances提供的Currencytrait。做法是在 Config 里声明关联类型:
#[pallet::config] pub trait Config: frame_system::Config { type RuntimeEvent: From<Event<Self>> + IsType<<Self as frame_system::Config>::RuntimeEvent>; type Currency: Currency<Self::AccountId>; }然后在 runtime 组装时把pallet_balances::Pallet<Runtime>传进去。这种通过 trait 解耦的方式,让 pallet 可以独立测试、独立升级,也方便你在不同链之间复用同一套业务逻辑。
注意:pallet 之间的依赖关系要尽量保持单向,避免 A 依赖 B、B 又依赖 A 的循环。真遇到循环依赖,通常说明业务边界没切干净,需要重新拆分。
4. Runtime 组装与升级:Substrate 最容易被低估的能力
4.1 Runtime 是什么,为什么它这么特殊
在 Substrate 里,runtime 就是链本身。它定义了:账户怎么创建、交易怎么执行、状态怎么变更、区块怎么出。更关键的是,runtime 被编译成Wasm 二进制,以“状态”的形式存在链上。这意味着你可以通过链上治理投票,直接替换 runtime 代码,实现无分叉升级。
这一点是 Substrate 相比很多传统链最大的差异化能力。传统链升级往往要硬分叉,社区吵半天,节点运营者手动升级客户端。Substrate 的链上 runtime 升级,只要提案通过,新 Wasm 被写入链上,下一个区块就按新逻辑执行,节点软件本身都不用动。
4.2 组装 runtime 的实操要点
组装 runtime 的核心文件是runtime/src/lib.rs,主要做几件事:
- 用
construct_runtime!宏把所有 pallet 拼起来,并给每个 pallet 分配一个索引。 - 为每个 pallet 实现它的
Configtrait,把关联类型填上。 - 定义
Runtime类型、Executive、Block、UncheckedExtrinsic等。 - 配置
parameter_types!里的各种常量,比如出块时间、手续费、押金。
一个典型的construct_runtime!片段:
construct_runtime!( pub enum Runtime { System: frame_system, Timestamp: pallet_timestamp, Balances: pallet_balances, TransactionPayment: pallet_transaction_payment, Sudo: pallet_sudo, Template: pallet_template, } );这里每个 pallet 前面的名字(如System、Balances)会成为它在链上存储和事件里的前缀。顺序和索引一旦上线就不能随意改,因为 extrinsic 的编码里包含 pallet 索引和 call 索引。改索引等于让所有历史交易的编码失效。
4.3 无分叉升级的完整流程
我实际跑过一遍的流程大致是这样:
- 改完 runtime 代码,编译出新的 Wasm 文件(
cargo build --release后产物在target/release/wbuild/...)。 - 计算 Wasm 的 blake2-256 哈希,作为版本标识。
- 通过治理提案(或 sudo,测试网常用)提交
system.set_code调用,把新 Wasm 写进去。 - 提案通过后,
on_runtime_upgrade钩子被触发,你可以在里面写存储迁移逻辑。 - 下一个区块开始,链就按新 runtime 运行了。
这里最容易踩的坑是存储迁移。如果你改了某个存储项的类型或结构,旧数据不会自动适配,必须在on_runtime_upgrade里手动迁移。我见过有人改了StorageValue的类型但忘了迁移,结果链上读出来的数据全是乱码,只能回滚。
提示:每次 runtime 升级前,务必在本地用
try-runtime工具跑一遍迁移测试。它能模拟升级前后的状态,帮你提前发现存储不兼容问题,比上线后回滚省事得多。
5. 开发环境搭建:从零到跑通一条本地链
5.1 工具链安装里那些“文档没写”的细节
Substrate 官方推荐用rustup管理 Rust 工具链。但直接装最新版 Rust 往往会编译失败,因为 Substrate 对 Rust 版本有要求。我的做法是:
rustup install nightly-2024-01-01 rustup target add wasm32-unknown-unknown --toolchain nightly-2024-01-01 rustup component add rust-src --toolchain nightly-2024-01-01为什么要指定日期?因为 Substrate 的 Wasm 编译依赖 nightly 的某些特性,而 nightly 每天都在变,用固定日期能保证团队里每个人编译结果一致。wasm32-unknown-unknown这个 target 必须装,否则 runtime 编译不出 Wasm。
另外,protobuf和clang这两个系统依赖经常被忽略。在 Ubuntu 上:
sudo apt install protobuf-compiler clang没有它们,编译到一半会报链接错误,而且报错信息很隐晦,新手很难定位。
5.2 用模板快速起一条链
Parity 提供了substrate-node-template,这是最快的起步方式:
git clone https://github.com/substrate-developer-hub/substrate-node-template cd substrate-node-template cargo build --release ./target/release/node-template --dev--dev模式会启动一条单节点开发链,自动出块,预置了 Alice、Bob 等测试账户。第一次编译大概要 20 到 40 分钟,取决于机器性能。编译完之后,你可以用 Polkadot.js Apps 连到本地ws://127.0.0.1:9944,直接看到链上状态、发起交易、查事件。
我建议新手先别急着改代码,而是先用模板链把“转账、查余额、看事件”这套流程走一遍。这样你对“extrinsic 怎么进块、事件怎么产生、状态怎么变”会有一个直观感受,再去读 pallet 代码就顺很多。
5.3 前端交互的最小验证
跑通链之后,用 Polkadot.js Apps 做几个验证:
- 在“开发者 -> 链状态”里读
System.Account,看 Alice 的余额。 - 在“开发者 -> 交易”里调
Balances.transfer,给 Bob 转 100 个单位。 - 在“网络 -> 浏览器”里看刚出的块,找到你发的交易和对应事件。
这套操作看起来简单,但它把“签名、编码、进块、执行、出事件”整条链路串起来了。很多人卡在“我写的 pallet 为什么没反应”,其实是因为没搞清楚 extrinsic 从提交到执行中间经过了哪些环节。
6. 写第一个自定义 pallet:从需求到上链的完整链路
6.1 需求拆解:先想清楚“状态怎么变”
假设我要做一个“打卡”pallet:用户每天可以打卡一次,累计打卡次数存在链上。这个需求拆成状态和动作:
- 状态:每个账户的打卡次数(
StorageMap<AccountId, u32>)、上次打卡时间(StorageMap<AccountId, BlockNumber>)。 - 动作:
check_in,校验距离上次打卡是否超过一天,更新计数和时间,抛出事件。 - 错误:
AlreadyCheckedIn、Overflow。
这里的关键设计决策是:“一天”怎么定义。用区块高度还是时间戳?区块高度在不同链上出块速度不同,时间戳更直观但依赖pallet-timestamp。我选时间戳,因为业务语义更清晰。
6.2 存储设计里的取舍
存储是要花钱的(押金),所以设计时要考虑:
- 用
StorageMap还是StorageDoubleMap?单键够用就别用双键。 - 值类型能不能压缩?
u32够用就别用u128。 - 需不需要
ValueQuery?如果读不存在的键要返回默认值,就用ValueQuery;如果要区分“不存在”和“零”,就用OptionQuery。
我的打卡 pallet 存储这样写:
#[pallet::storage] pub type CheckInCount<T: Config> = StorageMap< _, Blake2_128Concat, T::AccountId, u32, ValueQuery, >; #[pallet::storage] pub type LastCheckIn<T: Config> = StorageMap< _, Blake2_128Concat, T::AccountId, T::Moment, OptionQuery, >;Blake2_128Concat是哈希策略,128表示哈希长度,Concat表示把原始键拼在哈希后面。这样既能防碰撞,又能在需要时还原原始键。选哈希策略时,如果键本身很短且唯一,可以用Twox64Concat省点计算;如果键是用户可控的,用Blake2更安全。
6.3 权重计算:别让交易把区块撑爆
每个 extrinsic 都要声明weight,它代表这个调用消耗的计算资源。写小了,交易可能因为超出区块上限被拒;写大了,区块空间被浪费。Substrate 提供了#[pallet::weight]属性,可以写固定值,也可以用T::DbWeight动态计算。
对于打卡这种简单操作,我一般先估一个保守值:
#[pallet::weight(T::DbWeight::get().reads_writes(2, 2).saturating_add(10_000))]意思是读两次存储、写两次存储,再加一点计算开销。上线前最好用 benchmark 工具跑出真实值,但在开发阶段,保守估计能让你先把功能跑通。
6.4 测试:别等上链才发现逻辑错
pallet 的单元测试用mock.rs搭一个最小 runtime,然后写测试用例。我习惯至少覆盖这几种情况:
- 正常打卡成功,计数加一。
- 同一天重复打卡,返回
AlreadyCheckedIn。 - 跨天后打卡成功。
- 计数溢出时返回
Overflow。
#[test] fn check_in_works() { new_test_ext().execute_with(|| { assert_ok!(Template::check_in(RuntimeOrigin::signed(1))); assert_eq!(CheckInCount::<Test>::get(1), 1); }); }测试跑通再上链,能省掉大量“上链后发现逻辑不对、又要重新升级”的时间。我踩过的坑是:本地测试用的是 mock 时间,上链后用的是真实时间戳,结果“一天”的判断逻辑在两条链上表现不一致。后来我在 mock 里也模拟了时间推进,才把问题复现出来。
7. 那些让我卡了半天的坑,以及怎么爬出来
7.1 编译报错看不懂:从错误信息里找线索
Substrate 编译报错经常是一大串 trait bound 不满足。新手看到几百行报错直接懵。我的经验是:从最后一行往前看,找第一个提到你自己代码文件位置的错误。前面的报错往往是连锁反应,真正的根因在最后。
另一个常见问题是wasm32target 没装,报错会说找不到core或std的 wasm 版本。这时候回去检查rustup target list --installed,确认wasm32-unknown-unknown在列表里。
7.2 存储迁移忘了写,链上数据读不出来
前面提过,改存储结构必须写迁移。我具体踩的坑是:把StorageValue<u32>改成StorageValue<u64>,以为只是类型变宽,旧数据能自动读。实际上 SCALE 编码变了,旧数据按新类型解码会出错。正确做法是在on_runtime_upgrade里读旧值、转新值、写回去,并用StorageVersion标记迁移状态,防止重复执行。
#[pallet::storage] pub type StorageVersion<T> = StorageValue<_, Releases, ValueQuery>; #[pallet::hooks] impl<T: Config> Hooks<BlockNumberFor<T>> for Pallet<T> { fn on_runtime_upgrade() -> Weight { // 读旧值、迁移、更新版本号 T::DbWeight::get().reads_writes(1, 1) } }7.3 事件没抛出来,前端看不到
事件要在deposit_event里抛,而且RuntimeEvent的关联类型要正确配置。我遇到过事件定义了但前端查不到,最后发现是construct_runtime!里 pallet 的Event没带上,或者 runtime 的Event枚举没包含这个 pallet。检查方法是看编译后的 metadata,确认事件类型在列表里。
7.4 权重估小了,交易被拒
开发阶段用固定权重没问题,但上线后如果实际消耗超过声明值,交易会被区块拒绝。表现是“交易提交成功但一直不打包”。解决办法是用frame-benchmarking跑真实权重,或者把权重估得保守一些,宁可浪费一点区块空间也别让交易失败。
8. 关于 Substrate 学习路径的一点个人建议
如果你是从其他语言转过来的,我建议的顺序是:先跑通模板链,再读一个简单 pallet 的源码,然后照着写一个自己的 pallet,最后再碰 runtime 组装和升级。别一上来就啃construct_runtime!和Executive,那些是框架最复杂的部分,等你对 pallet 有感觉了再回头看会轻松很多。
Rust 这块,不需要你精通所有权和生命周期才能开始,但至少要能看懂Result、Option、trait、泛型这些基础。遇到编译错误别慌,Substrate 的报错虽然长,但信息量其实很大,耐心读往往能找到线索。
最后分享一个我自己的习惯:每次改完 pallet,先在本地cargo test跑一遍,再用try-runtime模拟升级,最后才上测试网。这套流程看起来多花时间,但比起上线后回滚,省下来的时间多得多。链上代码不像普通应用,改错了代价很高,慢一点反而快。