夜雨聆风学习资料网

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 分支,文件路径见各节标注。

本文由「区块链编程」原创出品

未经授权,禁止转载

如有转载需求,请联系作者

👉 关注「区块链编程」

关注我,解锁更多可能

相关学习资料