ARTICLE · 1168187
比特币源码精读:区块结构与默克尔树
比特币区块结构源码解析:从 CBlock 到默克尔树
80 字节的区块头,一棵 O(log n) 的树,以及轻节点为什么能相信一个陌生人
比特币源码精读 · 第 03 期
💡 你将学到:CBlockHeader 六个字段的分工、默克尔树的奇数补位规则、CVE-2012-2459 事件留下的防御代码
⏱ 阅读时间:约 10 分钟
01 为什么从区块头讲起
比特币的状态模型是 UTXO(未花费交易输出)。但 UTXO 是从交易里来的,交易是从区块里来的,区块又是从区块头的哈希里挖出来的。所以顺着源码读下来,区块头是绕不开的一环。
更重要的是:区块头是比特币必须完整存储的部分、且每个全节点都必须逐字节一致的部分。交易数据可以剪枝、可以用紧凑块优化传输,但区块头永远要在。这是理解整个数据层的关键——区块头是共识的最小完整单元。
02 六个字段,六种职责
直接看源码 src/primitives/block.h。
src/primitives/block.h — CBlockHeader(真实源码)
int32_t nVersion;
uint256 hashPrevBlock;
uint256 hashMerkleRoot;
uint32_t nTime;
uint32_t nBits;
uint32_t nNonce;
SERIALIZE_METHODS(CBlockHeader, obj){
READWRITE(obj.nVersion, obj.hashPrevBlock, obj.hashMerkleRoot,
obj.nTime, obj.nBits, obj.nNonce);
}
boolIsNull()const{return(nBits ==0);}
六个字段,80 字节(4+32+32+4+4+4)。逐个看:nVersion 是版本号,主要用于软分叉 signalling;hashPrevBlock 把区块串成一条链;hashMerkleRoot 是本区块所有交易哈希组成的默克尔树的根;nTime 是出块时间;nBits 是当前难度目标的紧凑表示;nNonce 是矿工不断尝试的随机数。
字段 | 类型 | 作用 |
nVersion | int32_t | 版本号,主要用于软分叉 signalling(BIP9 用它编码投票信号) |
hashPrevBlock | uint256 | 前一个区块的哈希,把区块串成一条链 |
hashMerkleRoot | uint256 | 本区块所有交易哈希组成的默克尔树的根 |
nTime | uint32_t | 出块时间(Unix 时间戳) |
nBits | uint32_t | 当前难度目标的紧凑表示 |
nNonce | uint32_t | 矿工不断尝试的随机数 |
把这六个字段按「谁在什么时候需要它」重新排一遍,会发现一个精妙的设计:hashPrevBlock 负责把区块串成链,节点验证时用它检查「我接的是不是当前主链的末端」。hashMerkleRoot 负责把区块头和区块体绑定起来——矿工改了交易但忘了改默克尔根,节点一算就发现对不上,直接拒收。
这是区块头与交易列表之间那个绑定关系,也是轻节点信任陌生人的技术基础。剩下三个 nTime、nBits、nNonce 是挖矿的核心变量。矿工不断改变 nNonce(4 字节,可穷举约 43 亿次),如果运气不好还改 nTime,直到区块头的双 SHA256 小于目标值。
这里有个值得停下来想的点:为什么区块头里只放一个默克尔根,而不是放所有交易的哈希列表?80 字节的代价决定了它只能放一个哈希——而这一个哈希必须是能代表「全部交易」的那个值。默克尔树正是为这个需求量身定做的数据结构:它能把任意长度列表压缩成固定 32 字节,且验证任意单个元素只需 O(log n)。
换一个角度看,区块头和区块体是一个「承诺-内容」的关系:默克尔根是承诺(commitment),交易列表是内容(content)。这个结构在密码学里很常见——它让你不需要读全部内容就能验证「某样东西确实在里面」。这个思路后面在轻节点、乃至一些隐私方案里都会反复出现。
为什么 nBits 既是"难度"又能当"是否空块"判据上面 IsNull() 直接返回 (nBits == 0)。因为一个有效区块的 nBits 永远不为 0(难度目标不可能是零),所以拿它当"空区块"哨兵值既省一个字段判断又不会误判。这种复用字段表达状态的小技巧,是长生命周期代码里省字节的常见思路。
03 一个容易被忽略的细节:mutable 缓存标志
注意 CBlock(不是 CBlockHeader)里的这几个成员。
src/primitives/block.h — CBlock 的内存态标记
class CBlock :public CBlockHeader
{
public:
std::vector<CTransactionRef> vtx;// network and disk
// Memory-only flags for caching expensive checks
mutablebool fChecked;// CheckBlock()
mutablebool m_checked_witness_commitment{false};
mutablebool m_checked_merkle_root{false};
这里的关键是 mutable 关键字和那行注释 「Memory-only flags」——这些是纯内存态,不参与序列化,不进区块哈希,也不是共识规则的一部分。它们只是缓存「这个区块的某项昂贵检查已经做过了没有」。
mutable 在 C++ 里通常被诟病为破坏 const 语义,但这里是恰当的:一个 const CBlock& 传给验证函数时,函数可能想缓存「我验过了」这个状态,但不想修改块的真实数据。用 mutable 让这类逻辑上的缓存写入和物理上的数据修改区分开,是清晰的意图表达。
三个标志对应三项检查,而且都是一次性的:CheckBlock()(基础结构合法性)、CheckWitnessCommitment()(SegWit 承诺值)、CheckMerkleRoot()(默克尔根匹配)。这些检查要遍历区块里所有交易,是CPU 密集操作。缓存它们能显著加速同步和重组流程。
一个通用工程模式对"昂贵且只跟不可变数据相关"的计算,缓存结果。把缓存字段和真实数据字段在结构体里物理分开并加注释,避免后来人误以为这些标志参与共识。mutable 用在这个场景下,恰恰是在明确表达"这是有意的例外"。
04 默克尔树:让一个哈希代表一万笔交易
hashMerkleRoot 是怎么算出来的?看 src/consensus/merkle.cpp。
src/consensus/merkle.cpp — 默克尔根真实实现
uint256ComputeMerkleRoot(std::vector<uint256> hashes, bool* mutated){
bool mutation =false;
while(hashes.size()>1){
if(mutated){
for(size_t pos =0; pos +1< hashes.size(); pos +=2){
if(hashes[pos]== hashes[pos +1]) mutation =true;
}
}
if(hashes.size()&1){
hashes.push_back(hashes.back());// 奇数个,复制最后一个
}
SHA256D64(hashes[0].begin(), hashes[0].begin(), hashes.size()/2);
hashes.resize(hashes.size()/2);
}
if(mutated)*mutated = mutation;
if(hashes.size()==0)returnuint256();
return hashes[0];
}
整个默克尔树压缩过程就这十几行,三个要点。
第一,两两配对,就地压缩。SHA256D64 这一行同时完成了两件事:把数组里每相邻两个哈希拼接后做双 SHA256,结果写回数组前部。然后 resize 把长度砍半。每一轮循环让数组长度减半,所以总共需要 log₂(n) 轮哈希——一万笔交易只需 14 轮。
第二,奇数个怎么办——复制最后一个。如果当前层有奇数个哈希(比如 3 个),就把最后一个再复制一份,凑成 4个再配对。这条规则必须写死在共识里,因为所有节点必须用完全相同的方式算出默克尔根,任何一方用不同的补位方式都会算出不同的根,直接导致链分叉。
[1..8]= 9
/\
[1..4][5..8]
/\/\
[1,2][3,4][5,6][7,8]
||| || || |
123 45 67 8
第三,那个 mutated 检查和一段血泪史。代码里那段长注释在解释为什么要检查「相邻两个哈希是否相同」。这牵涉到 2012 年的 CVE-2012-2459——一个著名的默克尔树碰撞漏洞。
攻击者可以构造两个不同的交易,它们在默克尔树里产生相同的哈希(利用了奇数补位时「复制自己」这个规则的歧义)。后果是:交易 ID 可以被伪造。攻击者可以构造一笔和已有交易完全不同的交易,却让它的 txid 与原交易相同。
修复方式值得学Bitcoin Core 的处理是——在 ComputeMerkleRoot 里检测到这种重复配对时,设置 mutated = true,然后整个区块直接判为无效、拒绝传播。这是一种"发现问题就废掉整块"的保守策略,而不是试图修补。注释还解释了为什么检测到之后还要继续把整棵树算完(而不是立即 return):因为变异区块本来就不能在网络里传播,而对同样长度的正常输入做完整比较,总比较次数是一样的,所以早退没有任何性能提升。这种对"性能优化是否真的有价值"的较真,在核心代码里不常见。
05 默克尔证明:轻节点凭什么相信陌生人
有了默克尔树,轻节点(SPV 钱包)终于可以不用下载整个区块,就能确认「我的交易确实在某个区块里」了。
要证明「交易 3 在这个区块里」,轻节点只需要向矿工索取一条 merkle path——一条从叶子到根的旁路。具体来说,3 和 4 配对得到 [3,4](节点自己算);[3,4] 和 [1,2] 配对得到 [1..4](矿工提供);[1..4] 和 [5..8] 配对得到根 9(矿工提供)。
轻节点拿到矿工给的两个旁路哈希后,自己动手重算一遍,如果算出的根等于区块头里的 hashMerkleRoot,就说明这条路径上的所有哈希都是真的,交易 3 确实在这个区块里。
关键在于:轻节点不需要信任矿工给的任何东西,只需要验证自己算出的根是否匹配区块头。区块头是全网共同验证过、且经过 PoW 付出真实算力成本产出的东西。矿工给错旁路只会导致重算的根对不上,不影响安全。
src/consensus/merkle.h — 默克尔证明接口
/**
* Compute merkle path to the specified transaction
*@param[in] position transaction for which to calculate
*the merkle path (0 is the coinbase)
*@return merkle path ordered from the deepest
*/
std::vector<uint256>TransactionMerklePath(const CBlock& block, uint32_t position);
顺带注意 position 那个参数后面的注释——位置 0 永远是 coinbase 交易,这是共识规则,不只是习惯。
06 区块头 80 字节的分量
现在可以回答开头那个问题了:为什么区块头要逐字节全网一致,而且不能剪枝?因为它承载了三重职责,缺一不可。
· 链的连续性:hashPrevBlock 把所有区块串成一条不可篡改的历史。任何一处历史被改,后续所有区块的哈希都会变化。
· 内容的完整性:hashMerkleRoot 把区块体绑定到头部。改任何一笔交易,根就变,区块头的哈希就变。
· 产出的证明:nBits、nNonce、nTime 证明出块者付出了规定的工作量,且难度是网络共识的。
80 字节里没有一个字段是多余的,也没有一个字段能随意改动。而挖矿,就是把这 80 字节反复哈希,直到结果小于目标值——下一期我们进 CheckProofOfWork,看这个判断在源码里是怎么写的,以及那个「紧凑难度表示」(nBits)到底是怎么把一个巨大数字塞进 4 个字节的。
本文代码说明代码取自 Bitcoin Core master 分支,文件路径见各节标注。
本文由「区块链编程」原创出品
未经授权,禁止转载
如有转载需求,请联系作者
👉 关注「区块链编程」
关注我,解锁更多可能















































































