夜雨聆风学习资料网

ARTICLE · 1179086

比特币源码精读——工作量证明与难度调整

比特币源码精读——工作量证明与难度调整

比特币难度调整源码解析:4 倍限幅与那条底线

拆开 PoW 校验与难度调整,看比特币如何让"出块速度"稳定在设计值附近

比特币源码精读 · 第 04 期

💡 你将学到:CheckProofOfWork 的完整校验链、nBits 紧凑编码、4倍限幅规则为什么必须存在

⏱ 阅读时间:约 11 分钟

01 校验一个区块的工作量,逻辑短得出奇

先看最核心的判断。矿工说「我算出符合条件的块了」,节点要怎么验证?src/pow.cpp 里的答案只有几行。

src/pow.cpp — 工作量校验(真实源码)

boolCheckProofOfWork(uint256 hash, unsignedint nBits, const Consensus::Params& params)

{

if(EnableFuzzDeterminism())return(hash.data()[31]&0x80)==0;

returnCheckProofOfWorkImpl(hash, nBits, params);

}

boolCheckProofOfWorkImpl(uint256 hash, unsignedint nBits, const Consensus::Params& params)

{

auto bnTarget{DeriveTarget(nBits, params.powLimit)};

if(!bnTarget)returnfalse;

// Check proof of work matches claimed amount

if(UintToArith256(hash)> bnTarget)

returnfalse;

returntrue;

}

逻辑就一句话:把 nBits 解码成一个目标值,如果区块哈希大于它,就不合格。但这两行代码里藏着三个值得单独拆开的设计。

为什么有两个函数

外层 CheckProofOfWork 做了件有点奇怪的事:检查模糊测试确定性开关,如果开启,就只看哈希最高字节的最高位是否为 0,完全跳过真实校验。

这是为模糊测试(fuzzing)准备的。模糊测试的核心是「用随机输入把程序跑崩」,如果每轮都要做真实的 256 位比较,测试会慢到没法跑;而且随机输入几乎不可能命中合法难度,校验分支永远走不到,等于没测。改成检查一个字节的最高位,命中概率是 1/2,既能覆盖「合格/不合格」两条分支,又快了几十个数量级。

一个为测试而存在的分支,代价是生产代码里多了一次条件判断。Bitcoin Core 愿意付这个代价,因为共识代码的可测试性本身就是安全属性——一段永远走不到的分支,等于一段没人验证过的代码。

为什么用 optional 而不是直接返回

src/pow.cpp — DeriveTarget 的边界检查

std::optional<arith_uint256>DeriveTarget(unsignedint nBits, constuint256 pow_limit)

{

bool fNegative;bool fOverflow;

arith_uint256 bnTarget;

bnTarget.SetCompact(nBits, &fNegative, &fOverflow);

// Check range

if(fNegative || bnTarget ==0|| fOverflow

|| bnTarget >UintToArith256(pow_limit))

return{};

return bnTarget;

}

这里检查了四个非法情况:目标为负、目标为零、编码溢出、目标比全局上限还宽松。任何一个命中就返回空值,让调用方拒绝。

用 optional 而非「返回一个 bool + 通过输出参数传值」,是因为「解码出的目标本身不合法」和「哈希没达标」是两种完全不同的失败:前者意味着区块头里的 nBits 字段本身就是伪造的或损坏的,属于数据非法;后者只是「这个块挖得不够努力」,是完全正常的业务失败。把两种失败区分开,错误信息才能准确。

为什么目标不能比 powLimit 更大powLimit 是这条链允许的最宽松难度(对应共识规则写死的初始难度)。如果一个区块声明的目标比它还宽松,那就意味着几乎不用做任何工作就能出块。攻击者可以据此廉价地制造大量无效区块来干扰网络——这就是 2010 年那几次区块填充攻击的防御核心。换句话说,nBits 字段是矿工自己填的,但它的有效区间由协议锁死。矿工可以把难度调难(自己找罪受),但不能调得比协议底线更简单。

02 4 个字节,怎么装下 256 位的难度

上面反复出现的 nBits 只有 4 字节(32 位),但难度目标是个 256 位的大数。它是怎么塞进去的?答案在 SetCompact/GetCompact 这对函数里。

这是一种科学计数法的变体:nBits 把 256 位数表示成「尾数 + 指数」。所以一个 4 字节字段能表达从极小到极大的整个难度范围。

这个设计的妙处在于:校验时不需要还原出完整的目标值再做比较,可以直接在紧凑表示上做简化判断。但为了清晰和稳妥,Bitcoin Core 选择了老实的做法——完整解码成 256 位数再比较,就是我们上面看到的代码。

为什么不全用紧凑表示直接比?精度。紧凑表示只有约 8 位有效尾数,紧凑相等不代表真实值相等。共识代码不能有「差不多等于」,只能是精确等于或不等于。

03 难度调整:一个必须被限幅的除法

现在到最核心的部分。难度不是固定的,而是根据出块速度动态调整。看真实实现。

src/pow.cpp — CalculateNextWorkRequired(核心片段)

// Limit adjustment step

int64_t nActualTimespan = pindexLast->GetBlockTime()- nFirstBlockTime;

if(nActualTimespan < params.nPowTargetTimespan/4)

nActualTimespan= params.nPowTargetTimespan/4;// 下限:1/4

if(nActualTimespan > params.nPowTargetTimespan*4)

nActualTimespan= params.nPowTargetTimespan*4;// 上限:4 倍

arith_uint256 bnNew;

bnNew.SetCompact(pindexLast->nBits);

bnNew*= nActualTimespan;// 按实际耗时缩放

bnNew/= params.nPowTargetTimespan;

if(bnNew > bnPowLimit) bnNew = bnPowLimit;// 封顶

return bnNew.GetCompact();

把这段代码翻译成人话,一共四步。第一步,算实际耗时:nActualTimespan 等于本周期最后一个块的时间减去周期第一个块的时间。设计目标是每 2016 个块约 14 天。第二步,限幅——这是整段代码的灵魂。如果实际耗时不到目标的 1/4,就按 1/4 算;超过 4 倍,就按 4 倍算。无论实际出块多快或多慢,单次调整幅度不超过 4 倍。

第三步,按比例缩放难度:出块快(实际耗时小于目标)就降低难度,出块慢就提高难度。第四步,封顶:难度不能无限降低,同样受 powLimit 约束。

为什么必须有 4 倍限幅

这是本篇最值得想清楚的问题。假设没有限幅,会发生什么?

设想一个极端情况:全网算力突然暴增 100 倍。实际耗时变成目标的 1/100,按比例调整会把难度一口气降低 100 倍。然后——出块更快了,算力增长会被进一步放大,难度再降,再更快。这是一个正反馈过程,理论上几轮调整后难度会崩到接近 powLimit。

反过来更危险:如果算力突然消失(比如大规模矿工下线),耗时变成 10 倍,没有限幅就会一步把难度提高 10 倍。等矿工回来时,难度高到需要等很久才能出第一个块,可能长达几个月。网络的出块能力被锁死了。

4倍限幅相当于给这个反馈回路装了一个阻尼器。它带来的是「缓慢但稳定」:即使算力发生几十倍的变化,难度每 2016 个块(两周)只调整最多 4 倍,永远存在一条平滑的收敛路径。

换一个角度理解这个限幅:它承认了一件事——难度调整本身是一个滞后的、离散的、每两周才动一次的控制回路。控制回路有延迟、有采样间隔,就必然可能震荡。限幅就是把震荡幅度强行压住,让系统即使在最坏的输入下也只会在目标值附近摆动,而不是一路狂奔到失控。

这也解释了为什么比特币选择 2016 个块(约两周)作为调整周期,而不是每出一个块就调整。周期太短,控制回路的采样噪声会主导决策——某个块碰巧出得快了就调难度,下一个块慢了就调回来,难度会剧烈震荡,反而破坏了「稳定出块」这个基本目标。两周是一个折中:足够长以平滑噪声,足够短以能对算力变化做出反应。

这个设计的普适价值任何根据测量值动态调整系统参数的地方,都应该问一句"如果测量值被恶意或意外放大,调整会不会失控?"。限幅(clamp)是控制论里最基础也最有效的稳定手段。Bitcoin Core 在这里还额外加了硬上限 powLimit——软限幅防止震荡,硬上限防止失控,双层防护。

Testnet4 那个分支在防什么

代码里有个 enforce_BIP94 分支,注释写得很清楚:测试网有一个「最小难度例外」规则(矿工连续找不到块时可以临时降低难度快速出块)。但这个例外会污染难度基准——如果用周期最后一个块的高度当基准,而那个块正好享受了例外,难度就会算错。

所以 Testnet4 改成用周期的第一个块当基准,注释解释得很直接:这个块不允许使用最小难度例外,所以它的 nBits 一定是真实难度。这个细节很有启发:当计算基准的某个输入可能不可信时,要挑那个必然可信的输入。

04 双重校验:防止难度突变攻击

还记得 CheckProofOfWork 只校验「这个块是否符合它自己声明的难度」吗?如果攻击者能随意填 nBits,他就能声明一个极低的难度来快速出块。

防御分两层:DeriveTarget 里的 bnTarget > pow_limit 检查拦住了「比全局底线还宽松」的情况。但攻击者仍可以声明一个比当前难度低很多、但不低于 powLimit 的目标,快速造出一批低难度区块。

src/pow.h — 难度突变检查的接口契约

/**

* Return falseif the proof-of-work requirement specified by new_nbits at a

* given height is not possible, given the proof-of-work on the prior block as

* specified by old_nbits.

*

* This function only checks that the new value is within a factor of 4 of

* the old value for blocks at the difficulty adjustment interval, and

* otherwise requires the values to be the same.

*/

规则很明确:只有在难度调整的那个特定高度,难度才允许变动;其他任何高度,nBits 必须和上一个区块完全相同。而一旦到了调整高度,变动幅度也不能超过 4 倍。

两层防护的分工很清楚:DeriveTarget 管硬底线(难度不能低到失去 PoW 意义,对应早期区块填充攻击);难度突变检查管相对连续性(难度只能按协议规定的时间和幅度变化,对应难度篡改攻击)。

这个双层结构值得单独强调一下。很多系统做安全设计时习惯「单点防御」——找一个关键位置设一道强校验。但共识系统不一样:这里的每一道校验都是全网所有节点独立执行的,任何一个节点漏掉都可能导致分叉。所以更稳妥的做法是让多道校验互相独立、各管一段。

想象一下如果只有 powLimit 这道防线会怎样:攻击者声明一个「比 powLimit 稍难一点」的难度,powLimit 检查通过了,但这个难度依然比当前网络难度低得多,攻击者可以快速批量出块。如果只有「难度连续性」检查:攻击者在每个调整周期都合法地把难度降一点(因为没到调整高度就不允许变,但到了就可以变4 倍),几轮下来难度会低到失去实际算力约束。

两道防线各自堵住一种绕过方式,且互不依赖——这才是「防御纵深」的实际含义。

05 写在最后

PoW 这一块代码,体现的是一种和 UTXO 完全不同的问题解决思路。UTXO 关心的是怎么高效表示账本,PoW 关心的是怎么让一个开放网络在没有中心权威的情况下达成一致。

而这部分的工程智慧,一半在算法,一半在防御性设计的取舍:限幅——不让任何单一测量值把系统参数推到失控边缘;硬上限——除了限幅,还有一条写死的硬底线 powLimit,任何绕过限幅的路径都会被它拦下;基准选择——当某些输入可能被污染时,挑必然可信的那个当基准;可测试性——为模糊测试专门开一个快捷路径,只为了保证那段分支能被测到。

下一期我们进交易签名验证:从 CheckECDSASignature 开始,看 secp256k1 椭圆曲线、DER 编码、以及「交易哈希是怎么算出来的」这个决定了签名防篡改能力的关键问题。

本文代码说明代码取自 Bitcoin Core master 分支,文件路径见各节标注。

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

未经授权,禁止转载

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

👉 关注「区块链编程」

关注我,解锁更多可能

相关学习资料