
我最早接触区块链代码是翻比特币的 Core 源码当时最大的感受就是这玩意用 C 写是真的敢写。指针随便飞模板套模板类继承能绕三圈但核心逻辑其实不复杂。后来自己动手用 C 从零写一条最简单的玩具链才发现真正难的从来不是区块长什么样而是怎么在 C 里把区块、交易、挖矿、校验这一整条链路合理地串起来并且还能跑得稳。这篇文章就把我实际写下来的一套最简实现拆开讲从数据结构到 PoW从 Merkle 树到持久化适合正在学 C 又对区块链项目感兴趣的人。我会把每一步设计背后的理由说清楚也会把我在实测里踩过的坑一并列出照着这个思路动手你能得到一条真正能运行、能挖矿、能转账验证的最小区块链。1. C 写区块链的底气语言特性与项目边界1.1 为什么区块链底层大多是 C翻一下主流区块链项目的底层实现比特币的参考实现 Bitcoin Core 是 CEOSIO 是 CRipple 的共识节点也是 C。这不是巧合。区块链节点本质上是一个需要长时间运行、高并发处理交易、频繁做密码学运算的网络服务对内存管理、计算效率、系统资源控制的要求很苛刻。C 在这几个维度的优势很直接没有 GC 的停顿内存布局可控能直接调用 OpenSSL 这类成熟的 C 语言密码学库而且编译后的二进制性能上限远高于解释型语言。当然用 Go 或 Java 也能写区块链事实上也有不少成功的项目。但如果目标是搞懂区块链底层的机制C 反而是最原汁原味的。你会发现读源码的时候需要自己管理资源这逼着你去想清楚每个对象什么时候创建、什么时候销毁恰好把区块链里每个区块都要被持久化、被校验、被广播的流程刻进脑子里。1.2 项目目标做一个麻雀虽小五脏俱全的最简链动手之前先定边界否则很容易写着写着就跑偏。我的目标非常明确支持最基础的区块、交易、账户模型能做转账。使用 PoW 共识能调节难度能实际挖矿。交易带数字签名能验证这笔钱确实是这个地址花的。数据落盘重启后链条不丢。多个节点之间能同步区块。不做的部分我也明确划掉不做智能合约不做分片不做复杂的 P2P 网络协议不做图形界面。技术验证阶段功能边界越清晰越容易把一行行代码写扎实。1.3 为什么不用更省事的方案很多朋友劝我用 Python 或 Node.js 写理由自然是有现成的密码学库、开发速度快。但我想验证的是 C 在这类系统里的表现所以有意识地和省事方案做了几个对比对比维度CPython/Node.js内存控制手动管理可控性强语言自动管理隐蔽 GC 停顿密码学库接入直接链 OpenSSL需要绑定层运行效率高可支撑更大交易量适合原型性能上限低学习价值能深入理解资源、并发、序列化屏蔽了许多底层细节标出来不是说其他语言不行而是强调如果你想真正理解区块链的底层建造逻辑C 是成本高但回报也最高的选择。接下来的章节我会按照从核心数据结构到外围系统的顺序把整个项目拆开。2. 区块数据结构与链的骨架先打好第一层地基2.1 区块头与区块体的结构设计一条区块链说白了是一个链表但每个节点不是单纯指向前一个节点而是保存前一个节点的哈希。这个设计是整个链条不可篡改的根源只要有人改了历史任意一个区块后续所有区块的哈希都会对不上。区块我分成两个部分区块头和区块体。区块头放元信息区块体放交易列表。struct BlockHeader { uint32_t version; // 版本号 std::string prev_hash; // 上一个区块的完整哈希 std::string merkle_root;// 交易梅克尔根 uint32_t timestamp; // 出块时间 uint32_t bits; // 难度目标的压缩表示 uint32_t nonce; // 工作量证明的随机数 }; struct Block { BlockHeader header; std::vectorTransaction txs; std::string hash; // 当前区块的哈希由序列化后的区块头计算 };这个结构跟比特币主网的结构几乎一致只是做了极简处理。transaction 我们放到第 4 章详细说这里先把它当成一个能序列化成字节流的对象。2.2 区块哈希的计算区块哈希不是对整个区块做一次 SHA-256 就完事而是对序列化后的区块头做双重 SHA-256。双重哈希的意义是防止长度扩展攻击这是密码学上的细节这里只需要先形成习惯算哈希一律用两次。std::string sha256(const std::string data) { unsigned char hash[SHA256_DIGEST_LENGTH]; SHA256((unsigned char*)data.c_str(), data.size(), hash); std::string result((char*)hash, SHA256_DIGEST_LENGTH); return toHex(result); } std::string Block::calculateHash() { std::string headerBytes serializeHeader(); return sha256(hexToBytes(sha256(headerBytes))); // 双重 SHA-256 }注意这里有个细节serializeHeader()必须保证字段顺序固定否则同一区块在不同时间算出的哈希可能不一致。我建议把区块头字段按固定顺序拼接成字节流不要用std::to_string直接拼因为整数在不同平台上可能有不同的字节序最好统一小端序。我在项目里专门写了一个writeUint32LE的小工具函数。2.3 链的类设计与创世区块链本身我封装成一个Blockchain类核心接口就那几件事初始化、取最新区块、添加新区块、校验整条链。class Blockchain { public: Blockchain() { init(); } bool addBlock(const Block block); // 校验后追加 bool isValidChain() const; // 全链校验 Block getLatestBlock() const; std::vectorBlock getChain() const; private: std::vectorBlock chain_; void init(); bool isValidBlock(const Block block, const Block prevBlock) const; };创世区块是我在init()里手动构造的第一个区块它的prev_hash全部填充为 0nonce和bits可以直接写死不用参与挖矿。很多新手会忽略一个点创世区块也需要被校验否则节点从零启动时会直接拒绝整条链。我这里的做法是init()先构造创世区块然后在isValidChain()里从索引 1 开始校验后续区块创世区块单独信任。2.4 校验规则与尽力而为的边界isValidBlock里我做了四件事block.header.prev_hash必须等于前一个区块的block.hash。block.calculateHash()必须等于block.hash。区块头哈希必须满足当前难度目标也就是有足够多的前导零。时间戳不能比前一个区块早太多也不能是未来时间。前三条是硬校验第四条要留一点宽容度。我在实测时遇到过节点时钟偏差导致的链条分叉所以把未来时间窗口设置成 2 小时超过就拒绝。这个参数不能设为 0否则本机时钟稍微飘一点节点就挖不出块了。3. 工作量证明挖矿循环的朴素实现与难点3.1 PoW 的本质寻找一个合法的 nonce区块头里的nonce字段就是给矿工猜的。矿工需要不断改变 nonce让sha256(sha256(header))得出的哈希值小于等于目标值。目标值越小找到合法 nonce 的难度越高。目标值一般用一个 256 位的大整数表示而区块头的bits是它的压缩表示。我在教学项目里做了简化直接用前导零个数来控制难度。比如要求哈希的前 4 个十六进制字符必须是0000那么矿工平均要尝试 16^4 65536 次才能找到一个合法哈希。这个方案写起来直观跑起来也快非常适合教学。3.2 挖矿主循环的 C 实现std::string ProofOfWork::mineBlock(BlockHeader header, uint32_t difficulty) { std::string targetPrefix(difficulty, 0); for (uint64_t nonce 0; nonce UINT64_MAX; nonce) { header.nonce static_castuint32_t(nonce); std::string serialized serializeHeader(header); std::string hash sha256(hexToBytes(sha256(serialized))); if (hash.substr(0, difficulty) targetPrefix) { return hash; } } throw std::runtime_error(nonce 溢出未找到合法哈希); }3.3 为什么 nonce 要用 uint64_t我的第一个版本 nonce 用的是uint32_t结果在难度比较低的时候没有任何问题但把难度调到 6 个零以后经常跑几亿次都找不到合法值uint32 直接溢出循环从头开始陷入死循环。这是个非常隐蔽的坑。改成uint64_t只是第一步更好的方案是当 nonce 即将溢出时微调区块头里的timestamp或增加一个额外的extra_nonce字段手动改变区块头的字节序列。实际挖矿软件基本都是区块头 extra_nonce的组合策略因为光靠一个 32 位 nonce 在现在的矿机算力下几十毫秒就撞穿了。3.4 难度调整的平滑策略如果区块可以无限制地刷新那么区块生成速度完全取决于矿工算力而算力总是波动的。为了让出块时间维持在某个目标值附近我每隔 20 个区块计算一次实际出块时间然后按比例调整难度。if (chainHeight % 20 0) { double actualTime (latest.timestamp - lastAdjust.timestamp); double targetTime 20 * targetBlockTime; double ratio targetTime / actualTime; if (ratio 0.5) ratio 0.5; // 防止难度突变 if (ratio 2.0) ratio 2.0; newDifficulty static_castint(oldDifficulty * ratio); newDifficulty std::max(1u, std::min(32u, newDifficulty)); }限制 ratio 在 0.5 到 2.0 之间非常关键。如果不限制算力短时间抖动会导致难度剧烈震荡反而让出块时间更不稳定。我把难度上下限分别限制为整数 1 和 32也就是最多要求 32 个前导零保证教学环境下不至于挖一个区块要等好几分钟。3.5 实测中的哈希率我把难度设为 5 个零在单核机上跑出来的数据大概是每秒几万次到十几万次哈希。这在真实项目里当然慢得可笑但用于理解 PoW 流程已经完全够用。如果你的机器多核建议用 OpenMP 把试 nonce 的循环并行化注意每个线程需要独立的 nonce 空间避免线程间数据竞争。4. 交易与 Merkle 树区块里装入了真正的价值4.1 交易结构从最简单的转账记录开始我在项目里把账户模型和 UTXO 模型简化成了最简转账模型交易是这样设计的struct Transaction { std::string from; // 付款方公钥哈希 std::string to; // 收款方公钥哈希 int64_t amount; // 转账金额 uint32_t timestamp; // 交易时间 std::string signature; // 付款方对整个交易内容的签名 };这个结构简单得不能再简单但足够演示交易不可篡改from、to、amount任何一个字段被改动签名验证都会失败。注意signature是对除去签名字段后的整个交易数据做的签名序列化时必须固定字段顺序。4.2 Merkle 树的构建区块体里所有交易需要形成一个 Merkle 根放在区块头里。构建过程很朴素把每个交易取哈希然后两两配对再取哈希一直向上合并直到只剩一个根哈希。std::string buildMerkleRoot(std::vectorstd::string txHashes) { if (txHashes.empty()) return std::string(64, 0); while (txHashes.size() 1) { std::vectorstd::string nextLevel; for (size_t i 0; i txHashes.size(); i 2) { std::string left txHashes[i]; std::string right (i 1 txHashes.size()) ? txHashes[i 1] : txHashes[i]; nextLevel.push_back(sha256(hexToBytes(sha256(left right)))); } txHashes nextLevel; } return txHashes[0]; }有个细节必须注意当某一层交易数量是奇数时最后那个节点要把它自己复制一份和自己配对再哈希。这个处理方式如果不做同一条链用不同顺序的交易列表构建 Merkle 根得到的根可能不同校验就会失败。我在测试时就吃过这个亏一开始只处理了偶数情况交易条数为 3、5、7 时全部校验失败。4.3 为什么区块头只存 Merkle 根而不是所有交易哈希区块头如果存所有交易哈希那么区块头体积会随交易数量增长而且验证一笔交易是否存在必须把整个区块头都下载下来。Merkle 根只占 32 字节不管里面装多少交易。更重要的价值在轻量节点场景轻节点只需要下载区块头当有人声称某交易在某个区块里时提供一条 Merkle 证明路径轻节点就能自己验证真伪而不必同步全量交易数据。这个机制解释了为什么区块头很小但承载了整条链的完整性。4.4 交易合法性校验的完整流程一笔交易要能被区块打包至少要过三关形式校验from、to都是合法的 20 字节地址amount大于 0。签名校验用from对应的公钥验证signature是否匹配交易内容。余额校验from账户在当前链上的余额必须足够支付amount。这个教学项目里余额直接用内存字典维护每处理一个区块就更新一次。真实项目里的 UTXO 模型会复杂得多每个交易都要显式引用之前的未花费输出但核心思想一致不能凭空造钱每笔钱都得有出处。5. 签名与地址让钱真正归属于某个人5.1 地址的本质公钥的哈希在转账交易里from和to不直接放公钥而是放公钥的哈希。为什么这么设计一方面是为了缩短地址长度另一方面是增加一层安全隔离就算未来 SHA-256 被破解出碰撞也不会直接威胁到私钥本身。std::string publicKeyToAddress(const std::string publicKey) { std::string sha sha256(publicKey); std::string ripemd ripemd160(hexToBytes(sha)); return toHex(ripemd); // 这里省略了 base58 编码教学环境下直接输出 hex }真实项目会在末尾追加校验和防止手输地址时写错。我这个简化版没有做校验和所以如果读者要继续扩展建议给地址追加 4 字节校验位。5.2 用 OpenSSL 做 ECDSA 签名C 里做 ECDSA 签名直接用 OpenSSL。我选的是 secp256k1 曲线和比特币一致。核心流程如下// 生成密钥对 EVP_PKEY* pkey generateKeyPair(); // 内部调用 EC_KEY_new_by_curve_name(NID_secp256k1) // 对交易内容txBytes签名 std::string signData(const std::string txBytes, EVP_PKEY* privateKey) { EVP_MD_CTX* ctx EVP_MD_CTX_new(); EVP_DigestSignInit(ctx, nullptr, EVP_sha256(), nullptr, privateKey); EVP_DigestSignUpdate(ctx, txBytes.c_str(), txBytes.size()); size_t sigLen 0; EVP_DigestSignFinal(ctx, nullptr, sigLen); std::vectorunsigned char sig(sigLen); EVP_DigestSignFinal(ctx, sig.data(), sigLen); EVP_MD_CTX_free(ctx); return toHex(std::string(sig.begin(), sig.end())); }验证签名是另一端的事情必须用from地址对应的公钥bool verifySignature(const std::string txBytes, const std::string publicKeyHex, const std::string signatureHex) { // 加载公钥 - 创建 EVP_MD_CTX - EVP_DigestVerifyInit // EVP_DigestVerifyUpdate(ctx, txBytes.c_str(), txBytes.size()) // EVP_DigestVerifyFinal(ctx, sig.data(), sig.size()) }5.3 签名验证的顺序先验交易再更新余额我在处理一个区块的交易时严格按照先全部验证签名再统一更新余额的顺序。为什么不能边验证边更新因为同一笔交易里可能有两笔输入都花同一个地址的钱。如果边验边更新第二笔交易校验时余额已经减少了但这不一定是坏事但更严谨的做法是先确保整批交易都合法再执行状态变更这样可以避免一个区块内部出现部分生效部分作废的尴尬状态。5.4 一个容易忽略的端点公私钥类型OpenSSL 的接口在不同版本之间变化非常大。我用的是 OpenSSL 1.1.1 和 3.x 都兼容的EVP_DigestSign*系列接口尽量避免使用已经被废弃的纯ECDSA_do_sign。如果你在编译时报错找不到EVP_DigestSignInit多半是 OpenSSL 版本过旧建议升级开发库或者在 CMake 里明确链接crypto库。6. 持久化与网络同步从内存玩具变成可运行的节点6.1 区块数据的落盘方案很多人做完前面的步骤会觉得链能跑了但一重启程序所有区块全部丢光。要让区块链真正可运行必须做持久化。我用了最简单也最容易讲清楚的方案每个区块序列化后追加写入一个二进制文件并在内存里维护一个区块哈希 - 文件偏移的索引。class Storage { public: bool saveBlock(const Block block); Block loadBlock(const std::string blockHash); bool hasBlock(const std::string blockHash); private: std::fstream dataFile_; std::unordered_mapstd::string, size_t index_; };写入时先把区块长度用 4 字节小端整数写在前面再写区块内容这样读取时能准确定位每个区块的边界。这种 append-only 方式的优势是写入性能高且天然只追加不修改符合区块链不可变的特点。劣势是磁盘占用会越来越大真实项目会配合剪枝机制删除无用的中间状态但教学阶段完全没必要引入。6.2 崩溃恢复与索引重建如果程序在写文件的中途崩溃文件里会留下一个不完整的区块。我处理的办法是启动时逐条读取文件每次读长度时校验长度是否合理比如不能超过 10MB读数据时校验哈希值是否匹配。一旦发现损坏记录就丢弃该记录并把文件截断到上一个完整区块的位置。这个逻辑不复杂但能避免很多莫名其妙的启动崩溃。6.3 简单 P2P 同步TCP 节点发现与区块下载P2P 是我这版实现里最能用但简陋的部分。我做了三件事每个节点启动时指定一个种子节点地址。种子节点维护一份已知节点列表新节点连上来后把自己的地址广播出去。节点之间通过自定义协议交换当前链高度然后高度低的节点向高的节点请求缺失区块。// 简易协议命令以 4 字节长度前缀 JSON 字符串 // 例如 {cmd:get_blocks,from_height:10,to_height:20} std::string Node::handleRequest(const std::string request) { auto json parseJson(request); if (json[cmd] get_blocks) { int from json[from_height].toInt(); int to json[to_height].toInt(); return blocksToJson(chain_.getBlocks(from, to)); } }这个实现当然撑不住大规模网络但对于 3-5 个本地节点联调、模拟区块广播和链同步已经足够。真实项目的 P2P 层要考虑节点发现、防女巫攻击、交易广播、区块广播的优先级那又是另一个量级的复杂度了。6.4 同步时的链分叉处理两个节点几乎同时挖出不同区块是完全可能发生的。处理规则遵循最长链优先哪个分支累积的 PoW 工作量最多就认可哪条链。我的实现简化成只看链长度谁长用谁。真实比特币里由于难度可能不同比的是累计难度但教学项目里简化成链长度已经能演示分叉合并的场景。7. 实测表现与踩坑记录这些坑你可能也会遇到7.1 性能实测数据我的测试环境是一台普通 4 核 Intel 笔记本单线程挖矿难度设为 5 个前导零。数据如下测试项数值平均出块时间约 1.5 秒单线程哈希率约 8 万次/秒区块最大交易数演示100 笔100 笔交易的 Merkle 根计算耗时小于 1 毫秒交易签名耗时约 0.5 毫秒交易验签耗时约 0.3 毫秒这个性能水平做演示完全没有问题。如果你把难度调到 1几乎瞬间出块适合快速测试整条流程调到 8 以后单线程要等很久建议这时候把难度调回 5-6。7.2 踩坑一序列化字段顺序不一致我最早写serializeHeader时为了偷懒用了std::to_string直接拼接整数导致 10 和 10 这类字符串在哈希时和真正的二进制小端字节序结果完全不一样。后来换了固定的writeUint32LE工具函数保证任何架构上序列化结果一致。这个坑的教训是区块链这种系统里序列化格式就是协议本身必须有一套明确的、跨平台一致的规则。7.3 踩坑二时间戳回拨导致校验失败某次测试时我把系统时间手动往后调了 10 分钟结果区块时间戳比上一个区块还早校验直接拒绝。后来我在isValidBlock里加了一个容错允许当前时间往前偏移 1 小时以内的时间戳存在超过 1 小时则拒绝。真实网络里也有类似的规则但容错窗口更小。这个坑提醒我时间戳不能盲目信任系统时钟节点时间同步是个系统工程。7.4 踩坑三内存泄漏与对象拷贝C 的默认拷贝构造是浅拷贝我在把Block放进std::vector时如果结构体里有指针类型就很容易出现悬垂指针。我后来把Transaction里的数据全部改成std::string和整数避免裸指针。如果你要扩展成真正的生产级项目建议为所有数据结构显式定义拷贝构造和移动构造或者直接使用std::shared_ptr管理生命周期但要注意拷贝带来的性能开销。7.5 踩坑四nonce 溢出死循环这个在第 3 章已经详细说过这里再强调一次挖矿循环的 nonce 不能用 32 位整数至少用 64 位当 nonce 接近溢出时要主动修改区块头里的时间戳或 extra_nonce 字段重新开始新一轮尝试。我见过太多新手在挖矿函数里用uint32_t跑着跑着就卡死不动了。7.6 安全提示这几个地方别偷懒教学项目的代码风格可以随意但如果你要拿这个项目去做毕业设计、竞赛作品甚至面试展示下面几个安全细节一定要补上区块大小限制给每个区块设最大体积比如 1MB防止恶意节点塞入超大区块。签名严格性验签时必须使用与地址匹配的公钥不能只验证签名格式正确。交易去重同一笔交易出现在两个不同区块里应该被识别为冲突不能让余额被重复花费。Daemon 化运行节点应支持后台运行日志要写到文件里方便排查问题。补上这些点之后这个项目就不再是玩具而是一个结构上接近真实节点的教学系统。如果你打算继续扩展我建议下一步去读 Bitcoin Core 的文档把你手写的结构和它的CTxOut、CMutableTransaction对照一遍那种原来我写的简化版对应的是这个真实组件的顿悟感比看十遍教程都有效。我在这个项目里最大的收获也正在于此只有亲手写出过一条能被签名和哈希约束住的链你才会真正明白每个字段存在的意义。