news 2026/9/10 20:17:25

用C++17从零实现教学级区块链:核心原理与代码实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用C++17从零实现教学级区块链:核心原理与代码实践

花了一个周末时间,用C++17从零写了个教学级区块链,取名叫 mini-chain。这个项目不是我的生产级基础设施,也不是什么性能怪兽,它的全部意义在于:把区块链那些云里雾里的概念——区块、工作量证明、交易验证、防篡改——用最朴素的方式落到代码里。我始终觉得,一个人是不是真搞懂区块链,不看概念背得多熟,要看能不能让一条链在自己手里跑起来。所以这篇博文就把整个实现过程拆开揉碎写清楚,包括每段代码为什么这么写、哪些地方最容易踩坑,希望给正在学C++、或者对区块链底层逻辑有兴趣的朋友一条可以复现的路径。

1. 项目概述:用大白话理解要做什么

1.1 区块链本质上是一个只能追加的账本

先解决一个问题:区块链到底是什么?我习惯用一个类比。想象你有一本账本,每一页都记着之前的页码、时间、和一批交易。任何人拿到一本新账本,都可以通过核对页码是不是连贯、每页的指纹对不对,来判断这本账没被人改过。这就是区块链的雏形。

拆成技术点来看,区块链就是一个链表——每个区块(Block)的结构体里保存着前一个区块的哈希值(previousHash),这个前哈希把整条链串了起来。如果某个历史区块被篡改,哪怕只改了一个字节,它的哈希就会变化,而后面的所有区块因为保存着它的旧哈希,立刻会对不上。最后再配合工作量证明(Proof of Work),让每个区块的生成都必须付出真实的计算成本,篡改整个历史的代价就变得极其高昂。

在很多资料里,区块链被包装成“去中心化信任机器”,这个说法没问题,但对一个刚从C++语法转向系统设计的人来说太虚了。实际去动手做的时候,你会发现核心问题其实只有三个:数据结构怎么设计、哈希怎么算、验证逻辑怎么写。

1.2 为什么选择C++而不是Python或JavaScript

网上关于区块链的教程一大半是Python写的,几十行代码就能跑通一个“区块链Demo”。但用Python实现,很多关键细节都被隐藏了:字典的序列化顺序、整型到字符串的转换、哈希拼接时的字节序问题,这些在动态语言里几乎不会暴露,而恰恰是这些细节决定了实际系统里链能不能跨节点保持一致。C++不会替你做任何决定,你必须自己处理数据的拼接、类型、内存和格式。虽然代码总量会多不少,但每次踩坑都是在补底层认知,这正是我选择C++做这个项目的核心理由。

另一个原因是性能。虽然教学项目体量不大,但工作量证明需要反复计算SHA-256哈希,Python的循环性能跑起来会很磨人,C++在同等难度下几乎不用等待。尤其当你把难度值调高以后,能不能在几秒内出块,直接影响调试体验。

1.3 项目的功能边界

开始写代码之前,我给自己划了一条明确的边界:只做单机版,不碰P2P网络。具体来说,这个项目覆盖以下功能:

  • 区块与链的数据结构设计
  • 交易构造和余额查询
  • 工作量证明挖矿
  • 链的完整性与交易合法性验证
  • 区块链数据的JSON序列化与文件持久化
  • 命令行交互界面

不做的部分也很多:不搞点对点节点通信,不做复杂的UTXO模型(简化成全局余额表),不做加密签名,不搞智能合约。原因很简单,把上面这些核心机制吃透之后,其余部分都只是锦上添花。第一次复现时如果盲目贪大,很容易陷入网络编程和密码学细节里出不来。这个取舍,对一个学习项目来说非常值得。

2. 工程搭建:让环境先跑起来

2.1 准备工具链

这个项目使用的C++标准是C++17,代码本身没有依赖复杂的系统API,所以基本任何主流的编译器都能搞定。在Windows上,我推荐直接安装Visual Studio 2022或者用Visual Studio Code加MinGW-w64的组合,在VS Code里配置好C++编译插件就行。macOS和Linux就更简单了,系统自带的clang或者g++都行,安装个CMake会方便很多。

我用CMake做构建管理,这里是顶层CMakeLists.txt的结构:

cmake_minimum_required(VERSION 3.16) project(mini-chain) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) file(GLOB_RECURSE SOURCES "${CMAKE_SOURCE_DIR}/src/*.cpp" ) add_executable(mini_chain ${SOURCES}) target_include_directories(mini_chain PRIVATE include) target_include_directories(mini_chain PRIVATE third_party)

命令行里依次执行cmake -B buildcmake --build build就能得到可执行文件。如果你不用CMake,一条g++命令也可以搞定:

g++ -std=c++17 src/main.cpp src/block.cpp src/blockchain.cpp -I include -I third_party -o mini_chain

2.2 选两个开箱即用的依赖

哈希计算和JSON处理是区块链的两大基础,标准库里没有直接可用的实现。我选了两个单头文件库,特别适合教学项目:

  • PicoSHA2:一个轻量级的SHA-256实现,只有一个头文件,接口和STL容器天然配合,避免了引入OpenSSL这类重型依赖。
  • nlohmann/json:C++领域最流行的JSON库,同样是单头文件,语法简洁。我用的还是英文全称nlohmann/json.hpp,不过在实际代码里写法和JSON标准对标。

下载这两个头文件后,放进third_party目录。我始终觉得,教学项目就该用最小依赖,把时间花在区块链本身的逻辑上,而不是去折腾OpenSSL的编译链和依赖库。这两个库背后都是主流的开源项目,如果你的网络环境允许,可以去它们的仓库下载源码。

2.3 项目目录结构

整个项目分成四个目录,结构非常清晰:

mini-chain/ ├── CMakeLists.txt ├── include/ │ ├── block.h │ ├── blockchain.h │ └── transaction.h ├── src/ │ ├── block.cpp │ ├── blockchain.cpp │ └── main.cpp ├── third_party/ │ ├── picosha2.h │ └── json.hpp └── data/ └── chain.json

includesrc分离是最基础的工程习惯,third_party存放外部依赖,data目录用于持久化层。作为一个三四个源文件的小项目,这个结构可能看起来有点“小题大做”,但如果你后续想在这个基础上加网络层、升级加密库或做其他扩展,会发现这个基础结构非常牢靠。

3. 核心数据结构:区块、交易与链

3.1 区块的设计思路

区块是整条链的“一页”,它的职责很纯粹:保存一批交易,同时保存自己的身份信息。C++的体现方式就是一个结构体加一个计算哈希的方法:

struct Block { uint64_t index; uint64_t timestamp; std::vector<Transaction> transactions; std::string previousHash; std::string merkleRoot; uint64_t difficulty; uint64_t nonce; std::string hash; std::string computeHash() const; std::string computeMerkleRoot() const; };

每个字段的存在都不是随意的:index记录区块高度,timestamp记录打包时间,transactions是区块里承载的业务数据,previousHash是链接前后区块的关键,difficulty记录当前区块的挖矿难度,nonce是工作量证明的计数器,hash是当前区块的完整指纹。

这里有个值得注意的小细节:merkleRoot(默克尔根)和hash被我分成两个字段。计算区块哈希时,拼进去的是merkleRoot,而不是直接把所有交易序列化后拼进去。原因很简单,交易列表可能会很大,直接序列化会拖慢哈希计算速度;而默克尔根把整个交易列表压缩成一个固定长度的字符串,既能代表交易集合的完整性,又能让哈希拼装的格式稳定。

3.2 哈希计算:顺序即是契约

区块哈希的计算方式是区块链源码里最容易出错的地方之一,因为字段的拼接顺序就是一份隐形的“协议”。同一套数据,字段拼接顺序不同,算出来的哈希就完全不同。在单机版里这无所谓,但一旦上网络,所有节点必须用完全相同的顺序。

我实现的计算逻辑如下:

std::string Block::computeHash() const { std::stringstream buffer; buffer << index << timestamp << merkleRoot << previousHash << difficulty << nonce; return picosha2::hash256_hex_string(buffer.str()); }

为了简洁,这个实现是把所有数据拼成一个字符串,再计算SHA-256 hex值。如果更严谨,可以引入二进制序列化来消除歧义,比如indextimestamp用固定字节序的大端编码。但在教学版里,用字符串拼接已经足够说明问题,只需要保证每个字段的分隔明确。以防万一,我建议把字段用|这样的分隔符隔开,避免“12345”到底是12345还是12345这类歧义。

3.3 交易模型的简化实现

为了让示例代码不至于失控,我把交易简化成四个字段:

struct Transaction { std::string sender; std::string recipient; uint64_t amount; uint64_t fee; };

真实区块链会使用UTXO(未花费交易输出)和椭圆曲线签名来确保资产的所有权,但这里把签名步骤省略了,sender就是一个普通的字符串。然后在链上用一个std::unordered_map<std::string, uint64_t> balances来缓存每个地址的余额,打包区块时扫描区块里的每一笔交易,对余额表做加减。

这种“账户余额模型”在工程上有很多好处(处理简单、查询直观),但它容易遇到双花攻击的问题——同一笔钱被同时花给两个人。目前的安全处理方式是:每次打包前检查发送方余额是否充足,并且只在确认区块时累加一笔挖矿奖励,这个思路为教学项目已经足够了。

3.4 链的骨架和创世区块

有了区块,就需要一个类来管理整条链:

class Blockchain { public: Blockchain(); void addTransaction(const Transaction& tx); bool minePendingTransactions(const std::string& rewardAddress); bool isChainValid() const; uint64_t getBalance(const std::string& address) const; void saveToFile(const std::string& filename) const; bool loadFromFile(const std::string& filename); private: std::vector<Block> chain_; std::unordered_map<std::string, uint64_t> balances_; uint64_t difficulty_; std::string createGenesisBlock(); void rebuildBalances(); };

构造函数里调用createGenesisBlock()创建创世区块。创世区块的特殊之处在于它的previousHash是固定的字符串,通常填"0"或者留空。从第二个区块开始,每个新区块都必须继承前一个区块的哈希:

Block newBlock(chain_.back().index + 1, pendingTransactions); newBlock.previousHash = chain_.back().hash;

这个过程读起来像废话,但它恰恰是“链”这个概念的代码具象:每一个新节点都通过previousHash和之前所有历史绑定在一起。

4. 工作量证明:挖矿机制的实现

4.1 工作量证明背后到底在做什么

工作量证明经常被讲得神乎其神,但它背后的机制非常朴素:找到一个nonce值,使得区块的哈希满足一定条件。这里选的条件是哈希值的前N位都是0,N就是这个区块的difficulty

举个例子,假设难度是5,那么有效的哈希看起来就是00000a3f8d...。因为SHA-256是一个不可预知的哈希函数,你无法从输入直接推导出输出,只能一个一个试nonce。这个“暴力搜索”的过程就是挖矿,找到满足条件的nonce的概率大约是1/16^difficulty,难度越大,平均尝试次数越多。

在比特币里,这个难度的调整周期是整条链的区块时间,用来维持平均出块时间。我们的教学版没有全网算力统计数据,所以直接在常量里配置:

constexpr uint64_t DIFFICULTY = 4;

我喜欢在开发阶段把难度调小一点,比如2或者3,这样出块时间控制在几秒以内,方便测试。到了演示阶段再调到4或者5,能感受到明显的挖矿延迟。

4.2 挖矿的代码实现

挖矿的主体代码就是在一个循环里试nonce

bool Blockchain::minePendingTransactions(const std::string& rewardAddress) { std::vector<Transaction> pendingTxs = pendingTransactions_; pendingTxs.push_back(Transaction{ "system", rewardAddress, COINBASE_REWARD, 0 }); Block newBlock(chain_.size(), std::time(nullptr), pendingTxs); newBlock.previousHash = chain_.back().hash; newBlock.difficulty = difficulty_; newBlock.setMerkleRoot(newBlock.computeMerkleRoot()); std::string target(difficulty_, '0'); do { newBlock.nonce++; newBlock.hash = newBlock.computeHash(); } while (newBlock.hash.substr(0, difficulty_) != target); chain_.push_back(newBlock); pendingTransactions_.clear(); applyBlockToBalances(newBlock); return true; }

这里值得注意的细节包括:每次循环都要调用一次computeHash(),而这个函数内部的字符串拼接和哈希计算开销虽然不大,但循环次数可能上万甚至上百万次,所以把target字符串的计算放在循环外面,避免无意义的重复构造。另外我用的是do-while,先nonce++再检查,保证每次尝试的题目都不同。

4.3 为什么工作量证明能防篡改

工作量证明最大的价值不是阻止修改,而是让修改成本高到不划算。假设有人想改掉第2个区块里的某笔交易,那么这个区块的哈希立刻会变。为了让它和后面的区块重新衔接上,他必须把第3个、第4个以及之后所有区块的nonce全部重新挖一遍。这些区块的难度之和就是篡改的成本。

我们链上校验逻辑会重新计算每个区块的哈希并检查难度条件,如果发现某个区块哈希不满足前导零条件,整条链直接判无效。任何人想伪造一条合法的链,都必须从创世区块连续挖到最新区块,这个计算量在难度稍微调大之后会非常惊人。

5. 交易与验证:让链上的数据可信

5.1 记账和打包是两回事

一开始我犯过一个理解偏差,以为每产生一笔交易就要立刻写进区块。实际上,交易被提交后先进入一个“待处理池”(pending pool),只有等矿工调用minePendingTransactions时,池子里的交易才会被打包进新区块。这个流程跟比特币的设计是一致的,它把“交易广播”和“区块确认”两个概念解耦了。

在我的实现里,addTransaction只做两件事:检查发送方余额是否足够;如果够,就把它放进pendingTransactions_。直到挖矿那一刻,交易才真正生效。为了让逻辑更接近真实世界,我们会在打包时给矿工一笔固定奖励。

bool Blockchain::addTransaction(const Transaction& tx) { if (getBalance(tx.sender) < tx.amount + tx.fee) { std::cerr << "Insufficient balance.\n"; return false; } pendingTransactions_.push_back(tx); return true; }

简单归简单,这个入口已经能拦住大部分非法交易。

5.2 链上验证:三层检查

isChainValid是我认为整个项目含金量最高的函数,它把安全性变成一个可运维的检查流程:

bool Blockchain::isChainValid() const { if (chain_.empty()) return false; if (chain_[0].previousHash != "0") return false; for (size_t i = 1; i < chain_.size(); i++) { const Block& current = chain_[i]; const Block& previous = chain_[i - 1]; // 1. 当前区块的哈希必须是自己算出来的 if (current.hash != current.computeHash()) return false; // 2. 当前区块必须正确指向前一个区块 if (current.previousHash != previous.hash) return false; // 3. 当前区块必须满足工作证明 std::string target(current.difficulty, '0'); if (current.hash.substr(0, current.difficulty) != target) return false; } rebuildBalances(); return true; }

这三个检查分别对应三种攻击手段:第一层防止直接修改hash字段装作合法,第二层防止切断或重排链的链接,第三层防止降低难度低成本的批量造假。每次启动程序时我都会执行一遍isChainValid,再继续之后的交易和挖矿操作,确保磁盘上的历史数据没有被任何人改动过。

5.3 余额重建:从零推导信任

rebuildBalances是一个很有用的函数,它清空当前余额表,然后从创世区块开始逐笔交易重新计算每个地址的余额。这样做的好处是:不管磁盘上的状态是否一致,最终的数据都以区块历史为准。本质上,这是把“状态”从“历史”里推导出来的过程,非常符合区块链“一切以链上数据为准”的设计哲学。

如果有一天你在这个项目里加入了更复杂的交易,比如多输入多输出或者是智能合约,这个函数仍然可以作为“世界状态重建”的基线逻辑。

6. 持久化与命令行交互

6.1 把链存到文件里

程序一关,内存里的链就没了,这显然不够“区块链”。我实现了一个最基本的持久化策略:把整个链转成JSON数组,写进data/chain.json

void Blockchain::saveToFile(const std::string& filename) const { nlohmann::json j; for (const auto& block : chain_) { j.push_back(block.toJson()); } std::ofstream out(filename); out << j.dump(4); }

loadFromFile则执行反向操作,把JSON重新装回Block对象。这个方案有个明显的设计问题:每次保存都是全量覆盖。链只有几百个区块时还好,一旦数据量上来了,性能会急剧下降。工程上一个常见的改进是改成append-only日志,新增一个区块只追加一段记录,而不是重写整个文件。教学版的全量覆盖能帮助理解数据的序列化格式,同时对调试更友好——打开JSON文件你就能看到整条链的结构。

6.2 设计一个简单的CLI

为了能方便地操控这条链,我在main.cpp里写了一个最朴素的命令行循环,支持五个指令:

  • add <sender> <recipient> <amount>:发起一笔交易
  • mine <miner>:为指定地址挖矿,把当前待处理池打包进区块
  • balance <address>:查询余额
  • print:打印整条链的摘要
  • store/load:保存/加载链数据
  • exit:退出程序

代码本身不复杂,就是cin加字符串解析。不要在这里引入任何参数解析库,用if/else反而更直观。

6.3 实测运行效果

当一个新人第一次把程序跑起来,最理想的状态是能看到一个这样的画面:

[demo] add alice bob 50 [ok] transaction added (sender=alice, recipient=bob, amount=50) [demo] mine bob [mining] target: 0000 [success] mined block #1 in 8423 attempts, hash=000042f1d9... [demo] balance bob bob: 159

这里bob的余额来自50的转账加挖矿奖励100,还有几笔手续费,具体数字取决于你的交易频率。通过这个画面,一个人能最直观地看到交易是如何产生、打包、落账的,比只读概念要牢固得多。

7. 常见问题与排查技巧实录

项目写完以后,我在调试过程中踩了不少坑,把最典型的几个问题整理成一张速查表,这些都是搜索引擎不太会告诉你的东西。

7.1 哈希对不上,链验证一直失败

最常见的原因是把字段拼错的类型或顺序,尤其容易把index拼成字符串,把difficulty漏掉,或者忽略了merkleRoot还没初始化。我的排查步骤是:加一个调试函数,把computeHash的输入字符串打印出来,然后用一个已知的哈希计算工具验证同样的输入能否得到相同结果。如果结果对不上,就从上到下逐字段检查类型和拼接顺序。

7.2 挖矿很慢或很快

挖矿速度的核心是difficulty。难度参数每增加1,平均尝试次数就变成原来的16倍。我实测,当难度为3时基本瞬间出块,难度为4时约几秒,难度为5时可能要几十秒甚至几分钟。如果发现挖矿耗时异常,先怀疑难度配置,再检查nonce循环里是否有cout打印——调试输出在挖矿循环里是灾难性的,会拖慢整个程序。

7.3 JSON反序列化时余额表的类型陷阱

nlohmann::json在处理大整数时,有把数字解析成浮点的风险。尤其当区块高度、时间戳都很大的时候,精度会悄悄丢失,导致链验证时哈希突然对不上。我的解决办法是:序列化时把所有整型字段用std::to_string转成字符串存储,反序列化时再std::stoull转回来。虽然JSON里看起来不美观,但绝对安全。

7.4 链加载后验证总在最后一个区块失败

症状是程序一重启,链就“损坏”了。大部分原因是没有把hash字段序列化进JSON。区块的哈希是计算出来的,很多新手序列化时只保留原始字段,忽略了hash本身。加载后重新计算哈希时,跟原来对不上,导致前哈希链接失败。解决办法:确保saveToFile里同时输出hash,或者加载后重新对所有区块依次计算哈希并更新。

7.5 多线程挖矿的竞争状态

单机版用单线程就够了,但如果你按网上教程改成多线程挖矿——让多个线程同时从非零nonce开始尝试——一定要给共享变量(比如chain_pendingTransactions_)加互斥锁。我第一次写多线程时,因为多个线程同时执行chain_.push_back(...)导致迭代器失效,程序直接崩溃。用std::atomic管理nonce或者用互斥锁包住写入操作,这个问题就会消失。

7.6 细节陷阱速查

我把一些零碎的小坑汇总一下,方便以后查阅:

现象可能原因建议
所有区块哈希全是0nonce没有在计算哈希前初始化为0构造函数里给nonce赋值
创世区块校验失败创世区块的前哈希被写成空串统一写成"0"
交易记录进块前就影响余额在确认区块前调用了余额表只有applyBlockToBalances后才允许查询余额
JSON文件里数值乱码或精度丢失整型字段被double解析改用字符串存储再转换
程序退出后链数据丢失忘记调用storeCLI退出前自动save
哈希计算每次结果不同拼接字符串时包含了未初始化的内存或指针地址检查所有字段的初始化顺序

8. 后续可以做的扩展方向

真正把这个迷你版跑熟以后,如果你想更进一步,我建议沿着这几个方向逐步迭代,每个方向都是独立的C++实战练习。

第一个方向是加P2P网络。给节点加一个简单的TCP服务器,支持节点之间互相广播新交易和新区块。这样你就可以在同一台电脑上启动两个节点,观察数据如何通过网络保持一致。这一步能把“分布式”这个原本模糊的概念变成可见的代码行为,挑战在于并发处理和消息格式设计。

第二个方向是完善UTXO模型和签名。把余额表改成真正的未花费输出,用OpenSSL或libsodium库给交易加上签名,这样别人就不能凭空花你的钱了。这个方向会涉及密码学、序列化和数据结构的综合训练,做完之后你对“加密货币的安全性到底建立在什么上面”会有本质的理解。

第三个方向是做SPV轻节点。让程序只保留区块头,不保存完整交易,通过默克尔路径验证某笔交易是否存在于某个区块中。这里能真正用到computeMerkleRoot时留下的数据结构基础,也是一道很好的算法题。

如果你只是想把C++练得更熟,这个项目同样是个宝库:重构时引入智能指针管理区块对象,给挖矿加上OpenMP并行,用std::stop_token控制后台挖矿线程,甚至给CLI接上一套像样的GUI。每一次改动都会迫使你接触新的C++特性,而它们最终的归宿都是为了让这条链更稳定、更好用——这大概就是做项目最迷人的地方。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 20:17:22

JavaWeb项目打包部署全流程:war包制作、Tomcat配置与踩坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 20:17:18

基于SSM框架的校园零食商店系统设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 20:16:57

GPT-6编码成本真相:从token计费到任务价值定价

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 20:13:50

浏览器Ctrl+C失效问题解析与解决方案

1. 浏览器中CtrlC失效现象解析最近在技术社区频繁看到开发者讨论一个奇怪现象&#xff1a;在部分浏览器环境中&#xff0c;CtrlC快捷键突然失效。作为一名长期与剪贴板打交道的全栈工程师&#xff0c;我决定深入探究这个看似简单却暗藏玄机的问题。2. 现象特征与复现条件2.1 典…

作者头像 李华
网站建设 2026/9/10 20:13:10

CANN/ge图引擎动态输入API

GetDynamicInputDesc 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、Tenso…

作者头像 李华