量子密钥分发系统的调试现场,最容易被问到的问题就是:密钥率提上去了,后处理能不能跟上?前几篇我聊过探测器偏置、单光子源标定、时间同步,到了这一篇,重点终于从光学平台转到了数字逻辑本身——FPGA 在量子密钥分发后处理中的核心实现与优化。
关于 QKD 后处理,我见过两种极端认识。一种觉得它是“纯软件的事,写个 Python/C 程序跑一跑就行”,另一种觉得“硬件万能,把算法丢给 FPGA 就万事大吉”。实际工程里,两边都容易翻车。这个系列写到第四篇,我不打算重复后处理协议的定义,而是把我在 FPGA 上实现基矢比对、参数估计、纠错、隐私放大时踩过和填平的坑,尽量还原成可复现的思路。这篇内容没有数学推导压力,但会有很多具体到信号名、寄存器、时序约束的细节,适合正在做QKD系统集成、或者准备把后处理算法硬件化的朋友。
1. 后处理到底在解什么问题
1.1 从量子层到经典层的交接
很多人看 QKD 系统,注意力全放在单光子探测、干涉对比度、暗计数这些物理量上,觉得只要光学平台调好了,密钥就自然出来了。这是误解。探测器出来的并不是密钥,而是一堆原始比特流:某个时间窗口内有没有响应、发送方用了哪个基矢、接收方用了哪个基矢、判决结果是 0 还是 1。量子信道有损耗,探测器有暗计数,窃听者也可能在干扰,所以 Alice 和 Bob 手里的 raw key 不可能完全一致,而且其中一部分信息可能已经被第三方截获。
后处理要做的,就是通过公开经典信道做几次有限的交互,把这串“看起来有点随机、但两边还不一致、还有泄露风险”的原始数据,提炼成两把完全一致、且理论上不可被窃听者获取的最终密钥。整个流程通常拆成基矢比对、参数估计、纠错、隐私放大四个环节,顺序不能乱,每一步的结果都是下一步的输入。任何一步没做好,最终密钥要么不一致,要么不安全,要么生成率太低没法用。
这里的关键在于:前端量子层是异步事件流。探测器或者 TDC 输出的是一个个时间戳脉冲,而后端后处理需要按固定帧格式同步处理。FPGA 的第一个任务,其实不是跑那些花哨的纠错算法,而是先把异步事件转成同步数据帧,同时完成时间戳对齐。这一步做不好,后面所有模块都是空中楼阁。我见过不止一个项目,算法模型在仿真里完美无缺,一上板子就乱码,最后查出来是原始帧头对齐差了一个时钟周期。
1.2 为什么是 FPGA 而不是 DSP 或软件
这个问题几乎每次评审都会被问。软件方案当然灵活,协议升级改个函数就行,但实际 QKD 系统的数据率上去之后,软件后处理会带来很现实的麻烦:CPU 占用高、任务调度产生抖动、密钥输出的时延不确定。尤其是当误码率波动时,软件队列可能突然堆积,让整个系统的密钥缓冲策略失效。在实时通信设备里,这是很难接受的。
DSP 芯片擅长复杂数值计算,但 QKD 后处理的核心不是浮点运算密集的算法,而是大量并行位操作、比较、异或、哈希,再加上要和多路前端通道对接。FPGA 的优势恰恰在这里:多路 LVDS 直接接探测器,流水线结构天然匹配 sifting、统计、纠错、哈希这种流式任务,时延可以做到确定可控,功耗也能控制在几瓦到十几瓦量级。冷启动就能工作,不需要等待操作系统加载,这在整机产品里是压倒性优势。
但纯 FPGA 也不是万能的。安全参数管理、码率调度、系统日志、与上层密钥管理系统的交互,这些还是得靠 CPU 来做。我们项目的实际架构是 FPGA 加 CPU 异构:FPGA 跑实时数据通路和核心算法,CPU 跑控制面,通过寄存器接口下发安全参数、读取状态。这样分工之后,调试和升级都清晰很多。
2. FPGA实现后处理前,先做一轮“思想实验”
2.1 把算法先拆成数据流
很多工程师拿到后处理算法的第一反应是打开 Vivado 开始写 RTL,这通常是个错误。正确路径应该是先写软件模型,在 CPU 上用 Python 或 C 把每个模块的输入、输出、边界条件验证清楚,再开始硬件化。后处理不是单一算法,是一条完整的数据通路:原始帧进入,经过同步、sifting、参数估计、纠错、隐私放大,最后输出密钥。
这条数据通路里的每个相邻模块之间,都应该用 FIFO 解耦。FIFO 不只是缓存,它承担两个职责:一个是速率匹配,一个是背压传递。前级模块处理慢一点,后级就通过 ready 信号让数据停一停;反过来,后级出问题,前级不能无限往外丢数据。没有这种设计的后处理链路,很容易出现“偶发丢帧、密钥率忽高忽低”的诡异现象。
还有一个新手常犯的误区:把算法伪代码里的 for 循环逐行翻译成 Verilog。算法的 for 循环在硬件里是面积和并行度的权衡,展开多少、流水线怎么切、缓存多少帧,完全由数据流和时序要求决定。举个例子,参数估计只需要一段窗口内的错误计数器,完全不需要把整帧数据存下来;但基矢比对由于要等对端通过经典信道回传的信息,必须把整块原始数据缓存住。这是两种完全不同的硬件化思路,照抄算法代码肯定死。
我习惯在写 RTL 之前先画一张数据流图,把每个模块的输入输出接口、帧格式、缓冲深度、握手方式定义清楚,再考虑用几个状态机。这个阶段多花一周,后面少调一个月。
2.2 位宽、定点数和误码率预算
后处理里几乎不需要浮点运算,全部可以定点化,但位宽规划必须提前做。位宽取得太小,累加溢出、量化噪声过大,纠错性能下降;取得太大,BRAM 和寄存器资源白白浪费,时序还更难收敛。
我们当时使用的原始帧格式是 64 比特:1 bit 有效标志、2 bit 基矢信息、1 bit 判决值、60 bit 时间戳或序号。基矢比对后的数据流按 32 bit 位宽往下传。参数估计模块的统计窗口取 2^16 个有效比特,错误计数器用 20 bit 位宽,理论上最多数到 1,048,575,实际上 QBER 超过 20% 早就该判定系统异常了,所以余量非常充足。
QBER 阈值设计也建议先算一遍再定。假设物理层典型 QBER 是 3%,安全阈值设在 8%,抽样窗口取 1M bits,那么统计误差大约在 0.03% 量级,远小于安全阈值和真实误码率之间的差距。这时用固定阈值判断是可靠的。如果抽样窗口只有几千 bit,统计波动就可能虚高或虚低,干扰判断,所以窗口长度不能拍脑袋定。
LDPC 纠错模块里的 LLR 量化位宽,我个人经验是 6 bit 起步。6 bit 有符号数在 QBER 3%~5% 时还能用,但环境比较恶劣、误码率爬升时,性能损失会变得明显。换成 8 bit 后,纠错收敛性明显改善,代价只是多存一些 LLR 内存,对现代 FPGA 来说完全可接受。量化位宽要不要继续上到 10 bit?我试过,收益很小,资源和时延代价却不小,不太值。
另一个常见优化点是归一化最小和算法里的 0.75 因子。硬件实现没必要调用乘法器,0.75 等于 0.5 加 0.25,用两次右移一次加法就能完成。这类看似不起眼的定点化优化,在整个后处理链路上叠加起来,效果非常可观。
2.3 资源与吞吐的初步估算
动手写代码前,建议先按模块估算资源,避免做一个半月发现放不下。我们以 LDPC 码长 2560、信息位 2048 的典型配置为例,给出一个大致的量化感受:
| 模块 | 主要资源消耗 | 说明 |
|---|---|---|
| 基矢比对 | BRAM / FIFO | 需要缓存整帧原始数据,容量和帧长强相关 |
| 参数估计 | FF / LUT | 主要是计数器和比较器,资源占比很小 |
| LDPC 纠错 | LUT / FF / BRAM | 存储校验矩阵和 LLR,比较器链很长 |
| 隐私放大 | LUT / FF | Toeplitz 哈希以 XOR 树为主,几乎不用 DSP |
资源估算的意义不是精确预测,而是提前发现瓶颈。基矢比对要缓存多大?如果一帧是 64K 个 64 bit 数据,那就是 512 KB,光靠 FPGA BRAM 会占得很凶,这时候要么缩小帧长,要么换更大容量器件,要么用外部 DDR 配合 DMA 做缓冲。LDPC 译码器的 LUT 占用经常高到吓人,所以规划时我会要求总体资源利用率控制在 70% 左右,超过这个数,布线阶段几乎必然出现时序违例,后面改起来非常痛苦。
3. 核心模块的FPGA实现与优化细节
3.1 基矢比对:状态机、FIFO和对齐窗口
基矢比对听起来很简单:Alice 和 Bob 把基矢信息互相对一下,只留下双方基矢一致的比特。但硬件实现比想象中麻烦。因为对端的基矢信息要通过经典信道回传,可能走以太网、也可能走串口,时延往往不确定。这就意味着本地必须先把原始数据缓存起来,等对端信息到达后再逐 bit 比较。
实际工程里我们会先做帧同步。在原始帧里插入固定同步头或伪随机序列,接收端用滑动相关捕捉帧边界。一旦捕获到同步头,就锁定帧起始位置,后续数据按固定格式解帧。这个状态机的常规跳转是 IDLE 到 SEARCH,SEARCH 到 SYNC,SYNC 里持续做 CRC 校验,连续失败若干帧就退回 SEARCH 重新搜索。
基矢比对模块还要处理经典信道带来的大延迟抖动。缓存 FIFO 深度必须按最坏情况估算,不能按平均值估算,否则网络一抖动就溢出。溢出意味着丢帧,丢帧意味着这轮后处理全部作废,密钥输出直接中断。后来我们干脆在数据里打时间戳,基矢比对模块不依赖数据到达顺序,而是按时间戳对齐,从根本上规避了经典信道时延抖动的问题。
很多人在这一步会问:公开基矢信息会泄露密钥吗?答案是不会。基矢只描述测量时用的偏振态或相位基底,并不直接暴露比特值。量子保密协议本身允许公开基矢,安全性仍然建立在窃听者不确定性原理之上。FPGA 实现时不必在这个环节做过度的安全保护,但要保证交互流程不能乱,否则整个协议栈都不可信。
3.2 参数估计:误码率统计与安全阈值
参数估计模块的功能是计算 QBER,用来判断信道是否异常、是否存在窃听、系统各项参数是否需要切换。FPGA 实现里我习惯把它做成一个多级流水线:一边从 sifting 模块接收比对结果,一边累计错误计数,按固定窗口输出统计值。
窗口抽样策略可以用固定窗口,也可以用随机抽样。硬件上固定窗口最省事:连续数若干有效比特,统计其中错误的个数。统计完成后,把结果写入一组寄存器,并向上位机 CPU 发一个中断。CPU 读到 QBER 后决定继续、切换码率还是丢弃当前帧并重新同步。
阈值判断不能只看单次统计值。比如真实 QBER 是 5%,统计窗口 1M bits 时,错误数大约在 5 万左右,3σ 偏差约 1300 bit,折算成 QBER 就是大约 0.13%。安全阈值设在 8%,已经留出了足够的统计缓冲,不会因为正常波动而误报。但要注意,如果经典信道回传延迟很大,参数估计结果就有滞后,系统必须在“及时响应”和“避免误报”之间做权衡。
调试初期我强烈建议把 QBER 的分块直方图也导出来。有一次我们误码率突然飙高,阈值中断触发了,排查很久,最后发现不是攻击也不是光路问题,是基矢比对模块的对齐窗口偏了一个比特,导致所有有效比特全部错位。如果只看总误码率,这个故障就像随机噪声;但分块直方图会呈现出系统性的间隔异常。所以参数估计模块不只是安全功能,它还是定位问题的第一现场。
3.3 纠错:LDPC译码器的硬件实现思路
纠错是后处理里最复杂、也最容易成为瓶颈的模块。QKD 场景里的纠错不是搞定普通通信的白噪声,而是要把 QBER 在 2%~5% 左右的原始密钥纠成错误率极低的最终密钥。常见的协议有 Cascade 和 LDPC 两种。
Cascade 实现简单,但有个致命弱点:交互轮数多,时延随经典信道往返时间线性增长。我们早期用过 Cascade,远程站点之间的网络往返如果有几十毫秒,一轮轮交互下来,密钥生成率被拖到惨不忍睹。后来切换到 LDPC 方案,核心变化是信息单向传输:Alice 根据自己的纠错码字计算校验子发给 Bob,Bob 结合自己的数据和校验子做迭代译码。经典信道只需要传一组很短的校验子,交互次数也大幅减少。
LDPC 译码器在 FPGA 上最常用的是归一化最小和算法,英文缩写 NMS。硬件实现有几个要点。第一,初始化时把 Bob 端每个比特的软信息量化成有符号 LLR 值,位宽如前所述,6 bit 或者 8 bit。第二,变量节点更新本质是一堆加法,把边上所有校验节点的消息累加,再减去自身对应的那一条。第三,校验节点更新需要求一组数据的最小值、次小值、符号异或和最小值的索引,这个很适合用硬件表达,只需要两三个比较器加一点控制逻辑。
迭代控制上,我们先用固定迭代次数跑通,再加提前终止条件。提前终止的判据是校验子全零,硬件上做一个归约或门。校验子全零意味着纠错收敛,可以提早输出硬判决,节省时间。但如果达到最大迭代次数仍然不收敛,就把这一帧标记为失败,交给上层决定是丢弃还是降低码率重传。
LDPC 译码器的吞吐瓶颈几乎都落在校验节点更新上。想要提高吞吐,最简单的方法不是把单核内并行度拉满,而是实例化多个译码核,每个核处理独立的码块。单核并行度太高,布线拥塞和时序问题会迅速压垮整个工程。我们当时的配置是 8 个译码核,每个核 200 MHz,单核有效密钥吞吐大约 15 Mbps,8 核合计约 120 Mbps。这个数字不高,但对 QKD 系统来说已经足够支撑后续密钥管理。
码率不能固定。环境恶化时 QBER 上升,原来的高码率纠错码纠不过来;环境好的时候用低码率又白白浪费密钥。所以在 FPGA 里要预存两到三套不同码率的校验矩阵,上位机通过控制寄存器切换。这个特性在长期野外运行的系统里几乎是必须的,没有它,系统只能按最坏环境设计,密钥率白白损失一大截。
3.4 隐私放大:Toeplitz哈希的流水线实现
隐私放大是安全链条的最后一道闸门。纠错以后 Alice 和 Bob 的密钥虽然一致了,但公开信道上传输的校验子可能给了窃听者部分信息,而且物理层也可能有少量泄露。隐私放大通过一个通用哈希函数,把纠错后长度为 m 的密钥压缩成更短的最终密钥,把泄露信息量压低到可以忽略。硬件实现里我们常用 Toeplitz 哈希,因为它结构太适合 FPGA 了。
Toeplitz 矩阵的特点是对角线元素相同,整个矩阵只由 m 加 n 减 1 个随机比特种子决定,不需要存储一整块随机矩阵。具体计算可以理解为:用一个长度为 n 加 m 减 1 的移位寄存器装种子,输出端的每一位,等于输入比特流与寄存器的某一段做点积,也就是与门加 XOR 树。
这个结构的资源消耗主要在 XOR 树。输出 32 路并行时,每个输出位需要 m 个与门和 m 减 1 个异或门,32 路并行就是大约 32m 个与门和 32(m-1) 个异或门。m 取 256 时资源并不小,但可以通过分时复用让多路输出共享部分逻辑,降低峰值 LUT 占用。时序上要注意长 XOR 树必须插流水寄存器,否则关键路径会非常难看。
隐私放大最常见的 bug 是端序和矩阵方向搞反。输入比特和种子必须按同一个 bit 顺序对齐,矩阵按行展开还是按列展开也必须和软件参考模型保持一致,否则同样的随机种子会产生完全不同的错误输出。这个 bug 很难通过肉眼看出来,最好的办法是在仿真阶段用 Python 参考模型生成随机向量,和 RTL 输出做全模块一致性比对,跑上几千组随机向量再上板。
还要强调一点:随机种子必须是真随机数,不能拿固定寄存器值或简单 LFSR 生成了事。FPGA 里要实现熵源接口,并对熵源做健康检测。隐私放大模块本身不生成安全参数,它只是执行上层密钥管理系统下发的指令。这个分工边界要守住,别为了省事把安全决策逻辑嵌进 RTL 里。
4. 调试实录:时序、跨时钟域与常见问题排查
4.1 时序收敛:关键路径与流水线重定时
第一次综合,LDPC 译码器的时序违例往往惨不忍睹。我记得当时 report_timing_summary 一打开,关键路径 slack 是负 0.3 纳秒左右,集中在校验节点更新的长比较器链和后续 XOR 树上。200 MHz 系统时钟只给了 5 纳秒周期,扣掉时钟偏斜,留给逻辑的时间很紧。
定位关键路径没有捷径,多花时间读时序报告是值得的。一般问题集中在两类地方:一是组合逻辑链过长,比如多层嵌套的比较器,二是一个模块的输出直接跨过整个芯片接到另一个模块的 BRAM,布线延迟巨大。第一种情况解决方案是插流水寄存器,把组合逻辑拆成两拍甚至三拍,代价是多一个周期延迟。第二种情况可以把大块 BRAM 拆成多个小块,交叉布局,或者把相关逻辑做物理分区,缩短布线距离。
综合策略也能调。Vivado 里打开控制集优化,或者用面积换性能的策略,部分情况下能改善时序,但不要指望工具能处理所有问题。我自己的原则是,资源利用率尽量压在 70% 左右,LUT 不要堆到 85% 以上,否则后续随便加点调试逻辑,时序就会崩给你看。时序收敛不是一次做完的,每次改完 RTL 都要重新跑一遍综合,给自己留至少 10% 的时序裕量。
4.2 复位与跨时钟域:最容易翻车的两个点
QKD 系统先天就是多时钟域环境。前端探测器有自己的恢复时钟,ADC 或 TDC 有自己的采样时钟,后处理主逻辑工作在系统时钟上,这些时钟之间往往没有确定相位关系。跨时钟域处理不好,表现是模块偶发进入异常状态,重启后又正常,特别难查。
最简单的解决办法是异步 FIFO 处理数据通路,控制信号用双寄存器同步。Xilinx 器件上我推荐直接用 xpm_cdc_sync 和 xpm_fifo_async 这类原语,比自己手写靠谱得多。我们之前有一次状态机偶发跑飞,查了快两周,最后发现是双寄存器同步路径少写了 set_false_path 约束,导致工具在综合时做了不希望的优化。
复位也是重灾区。后处理模块状态机很多,复位如果处理不好,复位释放瞬间可能把状态机打进非法状态。我一直坚持用异步复位、同步释放的方式。具体代码可以这样写:
reg rst_sync1, rst_sync2; always @(posedge clk or negedge rst_n) begin if (!rst_n) begin rst_sync1 <= 1'b0; rst_sync2 <= 1'b0; end else begin rst_sync1 <= 1'b1; rst_sync2 <= rst_sync1; end end wire rst_sys_n = rst_sync2;注意复位释放的时候,rst_sync2 是经过两级同步之后的信号,可以安全地作为系统复位。千万不要在复位路径上做组合逻辑,也不要让某个模块的复位依赖另一个模块的输出。调试时用 ILA 抓一下复位释放后的状态机跳转,很多所谓“偶发故障”都能从这个环节揪出来。
4.3 实测性能参考
以我们当时一个典型的现场试验配置为例,系统时钟 200 MHz,前端通过 LVDS 接口接收 4 路探测器信号,原始数据处理速率大约 3.2 Gbps。基矢比对模块的吞吐不是瓶颈,参数估计也只要一个窗口周期就出结果,真正的瓶颈在 LDPC 纠错。8 个译码核并行时,有效密钥吞吐大约 120 Mbps,整条后处理链路纯算法时延大约 3 到 5 毫秒,不包含经典信道往返时间。
| 阶段 | 吞吐/时延参考 | 说明 |
|---|---|---|
| 基矢比对 | 约 3.2 Gbps 原始数据 | 受限于前端接口速率 |
| 参数估计 | 1M bits 窗口,统计时延亚毫秒 | 不计入经典信道回传 |
| LDPC 纠错 | 8 核约 120 Mbps 有效密钥 | 主要性能瓶颈 |
| Toeplitz 隐私放大 | 输出约 100 Mbps | 几乎不占额外时延 |
对比软件实现,同样的误码率水平下,单线程软件后处理吞吐往往不到 30 Mbps,而且时延抖动很厉害。FPGA 的价值不只是吞吐,更在于稳定:无论密钥缓冲还是上层密钥管理调度,都不需要被后处理时延的随机性折磨。
这些数字是特定工程配置下的参考,不能原样套用到所有系统。但思路是通用的:先找出吞吐瓶颈模块,再针对瓶颈做多核扩展或流水线优化,不要在非瓶颈模块上浪费时间。
4.4 常见问题排查速查表
后处理调试过程中,很多问题表面各不相同,根因却高度重复。我整理了一张速查表,项目里新同事排查问题时会先对照一遍。
| 现象 | 可能原因 | 处理方法 |
|---|---|---|
| 输出密钥始终不对 | 基矢比对失步 | 检查同步头相关峰和 CRC,增大 TRACK 窗口 |
| FIFO 溢出 | 数据速率失配或经典信道延迟抖动 | 加深 FIFO,启用背压,改用时间戳对齐 |
| QBER 统计异常偏高 | 帧对齐错误或跨时钟域问题 | 用 ILA 抓 start/end 信号,检查 CDC 约束 |
| LDPC 不收敛 | LLR 位宽不足或归一化因子不匹配 | 从 6 bit 升到 8 bit,调整 0.75 到 0.8 |
| 隐私放大结果与软件模型不一致 | 端序或矩阵方向错误 | 用随机向量做全模块比对测试 |
| 有效吞吐低于预期 | 单核 LDPC 瓶颈 | 扩展译码核数量,均衡 BRAM 布局 |
| 密钥输出偶发中断 | 参数估计超阈值触发重同步 | 查看中断状态寄存器,区分误报和真实异常 |
这张表不能替代真问题分析,但能省掉很多盲目的试错。我越发觉得,后处理里真正难的不是某一个算法,而是模块之间的握手和接口约定。写 RTL 之前,把每个模块的 valid/ready、帧头帧尾、错误标记、背压语义定义得越清楚,后面的调试就越轻松。
5. 一些踩坑后留下的体会
做 FPGA 后处理这几年,我最大的体会是:先建模,再写 RTL。后处理里的每个模块都和密码学、信息论绑定,直接硬件化很容易在量化、端序、握手上出细节错误。软件参考模型不是可有可无的文档,而是 RTL 验证的唯一基准。我们团队的规定是,每个硬件模块必须能在一组随机向量上与 Python/C 模型逐比特对齐,不允许“大概一致”。
接口先行是另一个原则。模块之间的 FIFO 深度、数据帧格式、错误标记、背压语义,必须在编码之前定义成文档,而不是边写边改。后处理链路那么长,模块间联调的痛苦程度,基本和接口文档的完善程度成反比。调试接口也要留足。内部信号能加 mark_debug 的尽量加,状态寄存器和计数寄存器能暴露的都暴露出来,不要觉得浪费资源。现场调试时,能读到的状态越多,定位问题越快。
安全属性同样不能松。隐私放大随机种子、最终密钥存储路径都不能留后门,不要做任何与密钥相关的可变分支时序,防止把密钥信息通过功耗或时序泄露出去。这个领域的安全证明再漂亮,硬件实现如果留下边信道,整个系统依然不安全。
如果回到项目最开始,我会先把整条数据结构、帧格式、握手协议画到能直接写 RTL 的程度,再让团队分头实现。很多当时觉得是运气问题的故障,其实都是设计阶段埋下的雷。希望这篇内容,能让你少踩几个我曾经踩过的坑。