最近在回看某高校区块链课程的比特币部分正好整理到“比特币脚本”这一节。以前翻比特币代码的时候经常看到一堆OP_DUP、OP_HASH160、OP_EQUALVERIFY这样的符号当时只觉得是转账时随意拼出来的“魔法代码”直到我把脚本的执行规则彻底搞清楚之后才意识到比特币之所以能成为一条不需要信任第三方就能完成价值转移的链脚本系统起了决定性作用。这篇文章就把这节课堂笔记展开成一份完整的实操解读从脚本的运行机制到用命令行工具验证真实交易把容易踩的坑和容易搞混的细节都过一遍希望对同样在啃区块链底层的朋友有帮助。我会尽量用“能动手就不空谈”的方式写不会只复述幻灯片上的定义。毕竟脚本这东西光看概念是记不住的把它当成一台极简的自动售货机来理解反而轻松得多。1. 为什么读懂脚本是理解比特币的关键1.1 脚本在比特币里扮演什么角色先退一步看比特币的交易模型。比特币并不是像银行账户那样维护“张三余额是多少”而是维护一堆未消费的输出也就是所谓的 UTXO。每一笔交易的输出本质上是一段“锁定条件”声明“谁能花这笔钱”而输入则是“解锁证明”证明“我有资格花这笔钱”。那这个锁定和解锁是靠什么实现的就是脚本。所以说白了比特币的账户体系不是数据库里的 userId 字段而是一段可执行的脚本。转账时用户不是把币发给某个账号而是把币发给一段脚本花费时用户要提供能让这段脚本执行结果为真的数据。这个设计在当年非常超前——它意味着你在比特币上能做的不只是“A 转给 B”而是可以定义任意满足条件的支付方式。比如要求提供某个密钥、要求两个密钥同时签名、甚至要求未来某个时间之后才能花。这些条件全部用脚本表达交易验证的规则也因此变得统一而简洁。这也是为什么我一直建议刚入门的朋友不要跳过脚本去学钱包和交易所因为钱包地址、交易签名、多重签名等等背后全部是脚本在起作用。你如果不理解脚本那你对比特币的认知就停留在“记账本”这个浅层完全没有触达它真正的算法内核。1.2 为什么设计者故意把脚本限制成“不够聪明”第一次接触比特币脚本的人多少都会觉得这门语言“残缺”没有循环没有递归没有复杂的数据结构甚至连图灵完备都做不到。很多人会问既然都做成可编程了为什么不直接做成万能编程语言其实这个“残缺”是刻意为之的。脚本要运行在每一个参与验证的节点上如果脚本里允许无限循环那么一个恶意用户构造的脚本就可能导致全网节点陷入死循环这比网络攻击还致命。所以比特币脚本在设计上只保留顺序执行、条件判断、堆栈操作这些最基本的元素它把计算能力限制在一个可以预见的范围里换来了安全性和确定性的底线。打个比方脚本不是一个人人可写程序的通用计算机而是超市门口的自动门。自动门只识别“手里有没有小票”这类有限几件事它不会因为一句天气之类的废话而自己写程序改逻辑。这种“笨”是刻意控制的复杂度恰好让每个节点都能用极低的成本完成验证也让挖矿节点之间对交易有效性的判断能够迅速达成一致。另外脚本还有一个很重要的特征它是“无状态”的。执行一次脚本不会修改任何全局变量不会影响其他交易的状态每个脚本只基于输入数据和它自己那段锁定逻辑跑一遍跑完就销毁。这种无状态特性极大地简化了并行验证和分叉处理也让整个系统更容易判断“这一笔交易结果是否合法”而不是陷入复杂的关联依赖。理解了脚本的定位之后再去看那些操作码就会觉得顺理成章了。2. 脚本语言的工作方式2.1 基于堆栈的字节码比特币脚本本质上是一个基于堆栈的、非图灵完备的字节码语言。什么叫基于堆栈就是所有操作的数据都放在一个“后进先出”的栈里指令只对这个栈顶数据进行操作。你把数字推到栈顶然后由操作码决定是弹出、复制、拼接还是比较。最常见的几个操作码OP_DUP把栈顶元素复制一份压入栈顶。OP_HASH160将栈顶元素做两次哈希得到 20 字节的地址指纹。OP_EQUALVERIFY比较栈顶两个元素是否相等如果相等则继续执行如果不相等则整个脚本直接验证失败。OP_CHECKSIG弹出栈顶的签名和公钥验证签名是否匹配对应公钥并把验证结果true/false压回栈顶。OP_RETURN直接让脚本失败常用于在交易里附带链上备注信息。这些操作码单个看都很简单但通过不同的组合就能构造出多样的支付逻辑。有点像乐高积木每块都很简单拼在一起却能满足很多真实需求。不过要注意并不是所有操作码都能随意使用比特币只接受标准脚本模板其他非标准脚本在交易传播时经常会被节点拒绝。这也是为什么实际能用到的脚本形式其实有限但又没有完全限制死。2.2 解锁脚本与锁定脚本如何“合体”前面说输出里放的锁定脚本叫scriptPubKey输入里放的解锁脚本叫scriptSig。那验证时具体怎么执行呢不是两条脚本分开跑而是把scriptSig和scriptPubKey按顺序拼接成一条完整脚本然后统一执行。也就是说先执行解锁脚本把签名、公钥、或者别的数据放到栈上然后接着执行锁定脚本让锁定条件去消费这些数据。这一步是整个验证的核心流程也是新手最容易理解错的地方。很多人以为各跑各的其实不是验证节点是先把两段脚本拼接再按顺序执行。拼接顺序不能错解锁脚本在前锁定脚本在后一旦拼接顺序被恶意调换签名验证就会出问题这正是签名数据要包含当时交易细节的原因也是隔离见证SegWit出现前一些小毛病的根源。举个例子。标准转账脚本 P2PKHPay to Public Key Hash可以拆成这样看待解锁脚本scriptSig通常包含两个部分数字签名sig和公钥pubkey锁定脚本scriptPubKey通常是OP_DUP OP_HASH160 20字节公钥哈希 OP_EQUALVERIFY OP_CHECKSIG合体执行时栈的变化是sig入栈栈顶为签名pubkey入栈栈顶为公钥OP_DUP复制栈顶公钥出现两份公钥OP_HASH160对栈顶公钥做哈希得到公钥哈希20字节公钥哈希入栈栈里出现两个公钥哈希OP_EQUALVERIFY比较两个公钥哈希是否一致一致则通过并弹出两者OP_CHECKSIG验证公钥和签名验证通过则压入 true所以整条脚本执行成功的标志就是最后栈顶为 true并且整个脚本执行过程中没有触发任何验证失败。这个过程在交易验证时会被每个节点重复执行以此保证对状态转换的共识。2.3 一条标准P2PKH脚本的手工推演为了让这个流程更清楚我编一个简化示例并把每一步栈的状态列出来。假设签名用S表示公钥用K表示期望的公钥哈希用H表示。执行到哪一步栈内元素左侧为栈底说明初始空开始执行S入栈S签名压栈K入栈S, K公钥压栈栈顶是 KOP_DUPS, K, K复制栈顶公钥OP_HASH160S, K, hash(K)对公钥取哈希H入栈S, K, hash(K), H期望公钥哈希压栈OP_EQUALVERIFYS, K比较 hash(K) 与 H一致则继续并弹出OP_CHECKSIGtrue验证签名成功留下 true如果公钥哈希对不上OP_EQUALVERIFY会让脚本直接失败后面的OP_CHECKSIG根本没机会执行。如果签名与公钥不匹配OP_CHECKSIG会留下 false脚本同样失败。这个推演虽然简单但能让人看清楚脚本的“机械感”它不关心你钱包里的币有多少只关心你能不能提供一段逻辑上自洽的证明。也正是这种机械性让脚本可以脱离人的干预在网络里被任何节点自动执行和验证。3. 常见脚本与真实交易3.1 P2PKH最基础也最常见的转账路径P2PKH 就是普通地址转账的脚本形式。地址本身其实是公钥哈希的编码表示平时我们在钱包里看到的1abc...开头地址本质上就是这段脚本里的 20 字节哈希再经过 Base58Check 编码后的结果。你向一个地址转账写进交易输出的就是OP_DUP OP_HASH160 公钥哈希 OP_EQUALVERIFY OP_CHECKSIG而不是“发送给某个人”这个抽象动作。这带来一个很酷的效果收款人可以不公开自己的公钥因为锁定脚本里只有公钥哈希直到他要花钱时才把公钥暴露出来。这在密码学上相当于给收款人增加了一层保护也降低了提前被针对的风险。不过要注意一旦某笔交易花费了这个输出公钥就被公开了所以隐私保护并不是永久的。实际操作中P2PKH 的解锁脚本在签名时也很有讲究。签名并不是简单地对脚本内容签个字或者对交易整体签个名而是对交易中涉及“被花掉的输出金额、输出的锁定脚本、交易的其他输入输出信息”的序列化数据进行签名签名时还需要指定一个哈希标志SIGHASH默认是SIGHASH_ALL表示签名覆盖全部交易内容。如果这里设置成其他标志就可能出现可以被别人改动的情况也是很多智能合约类应用需要非常小心的点。3.2 P2SH把复杂条件打包成地址P2SHPay to Script Hash是一种更高级的脚本形式它的出现让普通用户不需要关心复杂的脚本内容只需要使用一个“脚本哈希”即可。P2SH 的锁定脚本是OP_HASH160 20字节赎金脚本哈希 OP_EQUAL看起来和 P2PKH 很相似但它锁定的对象是一段赎回脚本的哈希而不是公钥哈希。这种情况下付款方只需要知道一个脚本哈希地址就能向一个“接收方”转账而这个地址背后到底要求提供多少个签名、由谁来签名都由收款方在花费时提交的赎回脚本redeemScript来决定。这个过程有点像是商家给你一个保险箱但不告诉你开锁要转几圈密码只有开锁的时候才把完整密码卡刷给银行看银行核对该密码卡哈希正确后再执行卡上的开锁指令。这个设计对复杂脚本的普及影响非常大。比如多重签名场景以前可能需要贴上三个公钥和一堆操作码现在只需要一个脚本哈希地址就搞定。同时它也降低了手续费和地址长度让用户体验更接近普通转账。不过要提醒一句P2SH 只是让复杂逻辑的“外观”变简单执行时节点仍然会把完整赎回脚本拼接到锁定脚本中运行所以赎回脚本本身还是不能被无限复杂化。3.3 多签名脚本把控制权交给多把钥匙多签名脚本最典型的锁定逻辑是要求 m 个公钥中至少 n 个签名有效即 m-of-n 多重签名。一条常见的 2-of-3 多签锁定脚本大致是OP_2 pubkey1 pubkey2 pubkey3 OP_3 OP_CHECKMULTISIG执行过程中脚本要求栈里摆放 2 个签名和 3 个公钥按顺序比对并最终验证签名数量是否满足阈值。这个场景常常用于公司资金共同管理、第三方托管、或者个人钱包里提高安全性。我最早看多签总觉得和单签无非是多个OP_CHECKSIG的组合实操之后才发现OP_CHECKMULTISIG有个历史悠久的小陷阱执行时即使验证通过也会多弹出一个无用元素。所以标准实现里解锁脚本通常要在签名之前额外塞入一个空字节。这个问题在很早之前的客户端版本中就已经作为兼容规则固定下来了不是 bug但很容易让初学的人编写测试脚本时错误判断栈的状态。在实际使用中多签名脚本尤其适合用来分散风险。比如一个项目团队的资金可以让三个负责人各自掌握一把私钥任何两把签了名才可以动用资金。这样即使一个人的私钥泄露也无法单独取走资金。结合时间锁以后还能设计出更灵活的“保险柜”策略比如超过一定时间后单签也可以提款避免团队失联导致资金被永久冻结。4. 动手验证把一个脚本跑起来4.1 快速用节点工具解析真实交易理论知识聊再多不如直接抓一笔真实交易来看。最理想的实验环境是本地运行一个比特币全节点或者至少同步好区块头然后用命令行工具bitcoin-cli操作。先通过getrawtransaction拿到交易的原始数据再用decoderawtransaction解析出每个输入输出的脚本。举例来说一次典型调用是bitcoin-cli getrawtransaction 交易哈希 true如果节点同步完全可以直接用true参数返回结构化解码结果如果不带参数则只返回一个序列化字符串需要再传给decoderawtransaction。实际输出里你会看到类似这样的字段{ vin: [ { txid: ..., vout: 0, scriptSig: { asm: 3045022100...0103 02a8..., hex: ... } } ], vout: [ { value: 0.00100000, scriptPubKey: { asm: OP_DUP OP_HASH160 4040... OP_EQUALVERIFY OP_CHECKSIG, type: pubkeyhash } } ] }看到这里你会直观理解锁定脚本和解锁脚本的长相。如果输出里的scriptPubKey显示为pubkeyhash说明这是一笔标准 P2PKH 转账如果显示为scripthash说明是 P2SH 类型的交易。多练习几次你读链上数据的能力会明显提升。我在这个阶段踩过一个坑直接把decoderawtransaction的结果当成“钱包可识别地址”来用忽略了里面其实是哈希值这个事实。后来才明白地址不是链条上原生的东西而是为了让人类方便阅读由脚本哈希或公钥哈希加校验字节编码而成的。理解了这层再看钱包地址就没那么神秘了。4.2 自己构造一条带脚本身份的交易流程如果想更深一层地掌握脚本建议你手动构造一个 P2PKH 交易。我一般是在测试网络上做不会花真钱也敢随便折腾。构造流程大致是准备一个测试网络节点同步到最新区块并创建几个测试地址。通过挖矿或者水龙头获取一些测试币让其中一个地址持有可花费输出。查看那个输出的锁定脚本确认它是标准 P2PKH。构造一个花费该输出的交易在输入里填写txid/vout在输出里填写目标地址的锁定脚本。使用钱包私钥对交易进行签名生成对应scriptSig。将完整交易广播到测试网络等它被打包确认。签名步骤是很多新手会迷糊的地方。你必须保证在签名时使用的交易哈希和最终广播的交易哈希一致其中任意一个输出金额、锁定脚本、或者输入顺序发生改变签名都会失效。这就是为什么很多离线签名工具要严格规范签名前的“预交易”字段顺序。你可以这么理解签名相当于你给一个文件加封条封条上写了文件每一页的内容任何一页被偷改封条都会提醒后人“此文件不可靠”这就是防篡改的直观体现。如果你希望看到更底层的脚本执行过程还可以用开源的全节点客户端内置脚本解释器或者在浏览器里找支持脚本模拟的在线沙盒。通过可视化地把每一步栈状态展示出来你会比对着文档死记硬背更快地建立直觉。4.3 常见报错与排查思路这里整理几个我在折腾脚本时遇到比较多的问题以及定位问题的思路。现象可能原因怎么排查广播交易提示non-mandatory-script-verify-flag锁仓脚本或解锁脚本不符合标准模板比如操作码组合不受认可用decoderawtransaction看脚本类型尽量改写成标准 P2SH 或 P2PKH签名验证失败报Signature must be zero for failed check签名数据格式错误最常见是 DER 编码的签名没有正确封装检查签名长度和尾部哈希类型字节比特币签名要求严格的 DER 编码解锁脚本能过能跑但总是被打回签名哈希类型SIGHASH设置不当或签名覆盖的交易字段与广播内容不一致在签名时明确SIGHASH_ALL确保第二次序列化时不遗漏字段多签脚本执行时结果和预期不符没有理解OP_CHECKMULTISIG的弹栈特性或公钥顺序和签名顺序不匹配先在离线脚本模拟器里按栈规则走一遍确认执行到OP_CHECKMULTISIG时栈内排列正确输出锁定脚本看起来正常但节点拒绝接收交易输出金额低于 dust 阈值或脚本体积超出限制检查-minrelaytxfee和 dust 限额适当增加输出金额这些排查经验我都是真金白银地亏损过才记牢的。第一次在测试网络上广播交易时就因为签名时把公钥顺序写反导致OP_CHECKMULTISIG一直验证失败折腾了一整天。后来静下心画了一遍堆栈图才发现问题出在公钥与签名排序的对应关系上。符号层面的东西画图真的比硬记代码可靠。5. 除了转账脚本还能做什么5.1 时间锁与条件支付脚本不仅可以验证“谁有资格花”还能验证“什么时候可以花”。比特币里有OP_CHECKLOCKTIMEVERIFY和OP_CHECKSEQUENCEVERIFY两个操作码它们可以把时间条件直接写进脚本逻辑。比如你可以创建一条这样的交易当前输出的锁定脚本里规定只有在某个区块高度之后才能解锁或者只有在某个相对时间过去之后才能解锁。这个能力被广泛应用在支付通道和受限资金场景中。比如两个人想要频繁交易可以先各自把资金锁进一个双签地址然后链下更新余额最后链上统一清算。清算时如果某一方不配合另一方可以通过时间锁等待期满后再取走资金避免资金被永久卡住。这种设计后来成为很多进阶扩展协议的基础不管是通道网络还是各类脚本化金融玩法底层都离不开时间锁条件。我在笔记里把时间锁归类为“条件表达”的一类因为本质上是把“时间”当作一个输入条件写进脚本而不是让脚本自己运行一个定时器。比特币脚本不具备主动触发能力它只能够在被花费时被动验证条件是否满足这一点理解到位了后面再看闪电网络这类项目时思路会清晰很多。5.2 脚本语言边界带来的启示很多玩合约的人会对比以太坊的智能合约说比特币脚本太弱了。但如果从系统设计的角度看这种“弱”反而是一种很强的工程取舍。脚本的确定性保证每一个节点都能用同样的结果达成共识同时也让语言层面的攻击面大幅缩小。它不会因为某个合约引入一个死循环导致整个链卡住也不会因为状态机复杂度太高让节点验证成本失控。我不是说图灵完备的智能合约不好而是说不同的链有不同的定位。比特币脚本适合做好核心资产的拥有权和转移规则越简单越可靠越好而其他链可以承载更复杂的业务逻辑各自都在自己的位置上有存在的意义。读完这节笔记我最深的感觉是任何一个系统设计安全性优先级永远应该排在“看起来能力更多”之前所谓做不到某些事其实往往是刻意保护了更重要的事。以后如果你打算深入研究更为复杂的链上应用我建议先把比特币脚本这套栈式执行模型吃透因为很多底层概念比如锁定、解锁、哈希承诺、多重签名、时间锁像积木一样被复用到各种项目里。你在这里打下的底子后面会反复用到。最后分享一个我在实操中的小经验不要只看文档里脚本的“标准写法”一定要亲手把脚本放到测试网络里跑一次哪怕只是转账 0.0001 个测试币。只有你亲眼看到签名、公钥、哈希、验证一步步落栈再从内存池看到交易被打包你才会真正理解“脚本是一段可执行程序”这句话的味道。很多看似抽象的概念一旦在真实的交易数据里被看见就再也不会忘了。