简介:面向通信工程与编码技术领域的研究者,这份基于空间数据系统咨询委员会8176标准的低密度奇偶校验编码与二进制相移键控调制联合仿真资源,将信道编码、数字调制与误码率分析整合在一起,重点解决低密度奇偶校验编解码实现复杂、仿真效率低的问题,适合学习逼近香农极限的纠错编码原理与工程化实现。压缩包共21个文件,以MATLAB源文件和参数矩阵数据文件为主,另含DLL加速解码模块、误码率曲线图与自动保存备份,包体仅1.07MB,结构清晰便于快速定位。目前已有229人学习下载,可作为通信系统仿真入门或低密度奇偶校验码性能验证的练习素材。资源内提供校验矩阵预处理、编码函数、多模式解码以及二进制相移键控调制映射等脚本,可直接运行观察不同码率与参数下的纠错效果,尤其适合在本科课程设计或科研预研中搭建原型系统并开展对比实验。
1. 从"ldpcdecode_ldpc8176"这个命名说起:8176码长背后的深空通信场景
拿到LDPC-BPSK-8176_ldpcdecode_ldpc8176_LDPC_这个项目名,最扎眼的不是ldpcdecode,而是8176。常跑 5G 的人熟悉的是 3840 码长,跑 WiFi 的是 672,而 8176 是 CCSDS 深空通信标准里 LDPC 码的典型长度。这个码长意味着你面对的不是普通信道编码仿真,而是一套面向高可靠、低信噪比、长距离链路的方案:LDPC 编码、BPSK 调制、软判决译码。标题里的ldpcdecode一般就是那个置信传播(BP)译码函数,ldpc8176则是码长标识。如果你手上有这么一段代码但跑不通,或者想自己从零搭一个可复现的 LDPC-BPSK 链路,这篇笔记就是为了解决这个问题。我会把从构造校验矩阵、调制、加噪、译码到误码率统计的完整流程拆开讲,并标出我在实际调试中踩过的坑。
2. 先把码造出来:LDPC-8176 的校验矩阵与生成矩阵,选型理由
2.1 为什么是 8176:CCSDS 标准与准循环结构
数字通信里,LDPC 码的码长往往和标准绑定。5G LDPC 使用准循环结构,基图最大支持 3840 码长;WiFi 802.11ad 用 672 码长;而 8176 是 CCSDS 131.0-B-3 标准中 LDPC 码的标称码长,常见码率有 1/2、2/3、4/5。这套码字被设计用于深空探测、卫星链路这类低信噪比场景,和 5G LDPC 相比,它的码长更长、纠错能力更强,但译码延迟也更大。你要复现ldpc8176,第一步不是写译码器,而是把校验矩阵 $H$ 弄到手。很多旧项目里不直接给 $H$,而是给一份生成矩阵 $G$ 的二进制文件,或者给你一组循环移位系数,你需要自己展开。
2.2 构造校验矩阵 H 的两种落地做法
第一种做法是最省事的:去 CCSDS 标准文档附录里找那个 16 行或 32 行的循环移位矩阵。以码率 1/2 的 (8176, 4088) 码为例,$H$ 是 4088 行、8176 列的稀疏矩阵,由若干 $511 \times 511$ 的循环子块拼成。你只需要读入每个子块的偏移量,然后用 Python 的scipy.sparse生成稀疏矩阵。第二种做法是直接用别人整理好的.alist文件或者 MATLAB 的.mat文件,这类文件在 GitHub 上搜LDPC 8176 alist能找到。我一般会选择第二种,因为手写展开循环子块很容易在索引上出错。
import numpy as np from scipy.sparse import lil_matrix # 以 (8176, 4088) 码率 1/2 为例,假设从标准读到 base 矩阵 # base 矩阵维度: 16 行 x 16 列,每个元素是 511x511 循环块的偏移量,-1 表示全零块 base = np.array([ [0, -1, -1, ...], # 这里放实际读取的偏移量 # ... 省略 ]) Z = 511 # 循环子块大小 rows, cols = base.shape H = lil_matrix((rows * Z, cols * Z), dtype=np.int8) for i in range(rows): for j in range(cols): offset = base[i, j] if offset >= 0: for r in range(Z): H[i * Z + r, j * Z + (r + offset) % Z] = 1 H = H.tocsr() print('H shape:', H.shape, '非零数:', H.nnz)逻辑说明:这段代码把基矩阵里每个偏移量展开成一个循环置换子块。lil_matrix适合边构造边赋值,最后转成csr矩阵方便后续矩阵运算。注意偏移量为-1表示该位置是零块,直接跳过。
参数说明:Z 是子块大小,CCSDS 8176 码的 Z 通常是 511,因为 8176 = 16 * 511。不同码率对应的基矩阵行数和列数不同,码率 2/3 时 H 是 8 行 24 列,没错,因为校验位长度是 8176-5450≈2726,子块数要能整除。拿到 base 矩阵后先检查维度,别套错。
2.3 从 H 得到 G:高斯消元与系统码转换
有了 H 还不够,编码时一般需要生成矩阵 G,除非你直接做基于 H 的编码。常见的做法是把 H 通过高斯消元化成系统形式 $H = [A | I]$,然后得到 $G = [I | A^T]$。这里有个血泪经验:直接用浮点高斯消元处理 4088 行稀疏矩阵会慢到让你怀疑人生,而且会破坏稀疏性。正确做法是在二元域GF(2)上做消元,用bitset或numpy的整数位运算来加速。
def gf2_rref(H): H = H.toarray().astype(np.uint8) # 小规模演示;8176 建议用分块位运算 rows, cols = H.shape pivot_cols = [] r = 0 for c in range(cols): if r >= rows: break # 找当前列非零行 pivot = np.nonzero(H[r:, c])[0] if len(pivot) == 0: continue pivot = pivot[0] + r H[[r, pivot]] = H[[pivot, r]] # 消去其他行的该列 for i in range(rows): if i != r and H[i, c]: H[i] ^= H[r] pivot_cols.append(c) r += 1 return H, pivot_cols逻辑说明:这是标准的二元域高斯消元,^=是异或操作,对应 GF(2) 上的加法。消元时把主元列变成单位列,最后左侧变成单位矩阵。
参数说明:H必须满秩才能得到完整的 G。如果秩不够,说明你拿到的基矩阵本身有线性相关的行,那就需要把冗余行去掉再消元。对于 8176 码,一次性toarray()会分配 4088x8176 ≈ 33MB 的二维数组,勉强能接受,但如果你在 MATLAB 里直接full(H)很容易把内存打满。我一般建议用分块的位运算,每 64 列打包成一个 uint64 数组,消元速度能快几十倍。
生成矩阵 G 出来后,编码就是 $c = u G$。但注意系统码编码要求 G 是行满秩,而且最后要把信息位和校验位拼接成 8176 位。实际项目中很多人不直接用 G,而是用"基于 H 的编码"(Richardson-Urbanke 算法),其中用到了 H 的稀疏下三角结构。不过对于一次性仿真,把 G 算出来简单直接,代价是 G 通常很密,编码耗时比稀疏编码高,但 8176 码长并不算大,单帧编码在毫秒级别,可以接受。
3. BPSK 调制与 AWGN 信道:从编码比特到软信息 LLR
3.1 BPSK 映射与噪声模型
编码后的比特流要映射到信道符号上。BPSK 的映射规则很简单:比特0映射为+1,比特1映射为-1,或者反过来。你可能会觉得这有什么好讲的,但真正翻车往往在符号映射与 LLR 公式的符号一致性上。假设接收端拿到的是 $y = x + n$,其中 $x \in {\pm 1}$,$n$ 是零均值方差 $\sigma^2$ 的高斯白噪声。如果你用的发射映射是0 -> +1, 1 -> -1,那么对数似然比(LLR)定义为 $\ln \frac{P(bit=0|y)}{P(bit=1|y)}$,计算出来是 $2y / \sigma^2$。如果映射反过来,LLR 就是 $-2y / \sigma^2$。这个符号搞错,误码率会直接从谷底变成天花板。
3.2 LLR 计算:两种等效写法
LLR 有两种常见写法,一种直接用噪声方差,一种用 Es/N0。设 $E_s$ 为每符号能量,BPSK 时 $E_s=1$(因为符号幅度为 ±1,平均能量为 1)。噪声方差 $\sigma^2 = N_0 / 2 = 1 / (2 \cdot E_s/N_0)$。于是 LLR $L = 2y / \sigma^2 = 4y \cdot E_s/N_0$。注意这里没有把 $E_s$ 归一化,很多人先归一化 $y = x + n$ 后直接代入 $E_s=1$ 的公式,也行,但要在代码里写清楚。
import numpy as np # 参数设置 eb_n0_db = 1.5 # Eb/N0,单位 dB rate = 0.5 # 码率,信息位/码字长度 es_n0_db = eb_n0_db + 10 * np.log10(rate) # Es/N0 = Eb/N0 + 10log10(码率) snr = 10 ** (es_n0_db / 10) # 线性值 # 发射机生成 8176 位码字 c,这里用随机比特代替 c = np.random.randint(0, 2, 8176) x = 1 - 2 * c # bit 0 -> +1, bit 1 -> -1 # 信道 noise_var = 1.0 / (2 * snr) noise = np.sqrt(noise_var) * np.random.randn(8176) y = x + noise # 解调器输出 LLR llr = 4 * y * snr # 因为 2/sigma^2 = 4*snr逻辑说明:snr是 Es/N0 的线性值。噪声方差设为1/(2*snr),这是复数基带模型中的惯例,BPSK 只有实部所以单边噪声谱密度对应到这里。LLR 公式中2*y/sigma^2代进去sigma^2 = 1/(2*snr)得到4*y*snr。
参数说明:这里rate=0.5对应码率 1/2 的 8176 码。如果你用的是码率 2/3,rate要改成 2/3。很多人误把 Eb/N0 直接当 Es/N0 用,结果误码率曲线永远对不上理论值。另外,这里假设信道没有幅度衰减,也就是单位功率信道。如果你的仿真加了衰落或功率归一化,LLR 的系数还要再除以幅度增益。
3.3 信道参数 Eb/N0 和 Es/N0 的换算
Eb/N0 是每个信息比特的能量除以噪声功率谱密度,Es/N0 是每个调制符号的能量除以 N0。对 BPSK 来说一个符号携带一个码字比特,而一个码字比特包含的信息比特数是码率 r。所以 $E_s/N_0 = E_b/N_0 \cdot r$。在 dB 域就是加10*log10(r)。由于 LDPC 码率通常小于 1,Es/N0 会比 Eb/N0 低几个 dB。画 BER 曲线时横坐标一般用 Eb/N0,这样不同码率之间可以公平比较。但在做 LLR 输入译码器时,必须使用 Es/N0 对应的噪声方差,否则译码器内部对外部信息进行缩放时会产生偏差。这个换算看似简单,但我在检查别人的项目时经常发现有人在循环里改了 Eb/N0 却忘了同步更新 noise_var,导致曲线的横坐标对、形状却不对。
4. 动手实现 ldpcdecode:BP 译码的迭代流程与参数调优
4.1 置信传播译码的四个核心步骤
ldpcdecode这个函数名对应的几乎一定是 BP 译码或最小和译码。BP 译码的核心是变量节点与校验节点之间传递概率信息。每一步迭代分四步:初始化变量节点消息为信道 LLR;校验节点更新;变量节点更新;判决并检查是否满足所有校验方程 $H c^T = 0$。你可以把变量节点看作"这个比特有多相信自己是 0/1",校验节点看作"这一组比特的奇偶校验结果推回来给每个比特的修正"。
def bp_decode(llr, H, max_iter=50): m, n = H.shape # 初始化:变量节点到校验节点的消息 v2c # 用二维列表存储,v2c[i][j] 表示第 j 个变量节点发给第 i 个校验节点的消息 v2c = [list() for _ in range(m)] c2v = [list() for _ in range(m)] # 构造边列表:每个校验节点连接哪些变量节点 edges = [[] for _ in range(m)] H_coo = H.tocoo() for i, j in zip(H_coo.row, H_coo.col): edges[i].append(j) # 初始化 v2c = llr for i in range(m): v2c[i] = [llr[j] for j in edges[i]] for it in range(max_iter): # 步骤1:校验节点更新 c2v[i][k] = 2*atanh(prod(tanh(v2c[i][k]/2))) # 用 min-sum 近似简化 for i in range(m): e = edges[i] msgs = v2c[i] prod_sign = np.prod([np.sign(m) for m in msgs]) min_val = min(np.abs(m) for m in msgs) for k in range(len(e)): # 去掉第 k 条消息 sign_k = prod_sign * np.sign(msgs[k]) min_k = min([np.abs(m) for idx, m in enumerate(msgs) if idx != k] or [0]) c2v[i].append(sign_k * min_k) # 步骤2:变量节点更新 new_v2c = [list() for _ in range(m)] for i in range(m): e = edges[i] for k in range(len(e)): total = llr[e[k]] + sum(c2v[i][idx] for idx in range(len(e)) if idx != k) new_v2c[i].append(total) v2c = new_v2c # 步骤3:硬判决 hard = llr.copy() for i in range(m): for idx, j in enumerate(e): hard[j] = llr[j] + sum(c2v[i]) # 简化:实际上每个变量节点汇总所有连边消息 # 改为更标准写法:对每个变量节点j,total = llr[j] + sum(c2v[in][pos] for in connected) # 这里不展开,最终判断 H*hard % 2 == 0 # 如果满足或达到迭代上限则退出 return hard逻辑说明:上面的c2v更新用的是最小和近似,即2*atanh(prod(tanh(x/2)))近似为最小绝对值乘以符号。真正 BP 需要计算双曲正切乘积,计算量大且数值不稳定。最小和是工程上最常用的低复杂度替代。
参数说明:max_iter是最关键的参数。对于 8176 码长,在 Eb/N0 = 1.5 dB 附近通常需要 20~30 次迭代才能收敛。设置太小(比如 5 次)误码率会明显下降;设置太大(比如 200 次)在低信噪比下会浪费大量时间,因为大部分帧其实无法收敛。我一般取 50 作为默认值,然后扫曲线时对每个 SNR 点统计平均迭代次数,看是否撞到上限,如果撞到上限就说明这个 SNR 点还需要更多迭代,或者译码器存在数值问题。
4.2 最小和近似与修正因子
最小和会比标准 BP 性能损失约 0.2~0.5 dB,具体取决于码的列重和行重。8176 码的行重大多是 32,列重有 4 或 6,行重越大,最小和近似的损失越明显。为了弥补,工程上会在校验节点输出的绝对值上乘一个小于 1 的修正因子,常见取 0.75~0.9。这就是归一化最小和(Normalized Min-Sum)。你也可以用偏移最小和(Offset Min-Sum),把绝对值减一个偏移量。修正因子的选取不是玄学,但确实需要扫一遍。我遇到的做法是先在 1~2 dB 处测误码率,把因子从 1.0 往下扫到 0.6,步长 0.05,选误码率最低的点。对于 8176 码,0.75 附近通常是最优,但不同迭代次数下最优因子会略微变化,所以你如果打算换迭代次数,修正因子最好跟着重新调。
4.3 迭代终止条件与最大迭代次数
最常见的终止条件是译码后的硬判决满足所有校验方程,即 $H \hat{c} = 0 \mod 2$。另一个是早停:如果连续几次迭代变量节点的 LLR 符号不再翻转,也可以提前停止。但要注意,早停在低信噪比下容易误判,因为可能陷入局部稳定点但仍有校验错误。我建议以校验满足为唯一终止条件,同时加一个最大迭代次数兜底。每次迭代后记录是否满足校验和能量是否下降(即硬判决的 LLR 绝对值之和),如果 LLR 一直不增长且校验一直不满足,基本可以判定为不可译,直接退出省时间。
def check_parity(H, hard_bits): syndrome = H.dot(hard_bits) % 2 return np.all(syndrome == 0)逻辑说明:H.dot(hard_bits)在 GF(2) 上相当于做奇偶校验。因为 hard_bits 是 LLR 符号硬判决的 0/1,用稀疏矩阵乘后取模,如果结果全为 0 则说明通过了校验。
参数说明:H必须是二进制矩阵,hard_bits也必须是 0/1。如果你把 LLR 直接乘进去,需要先hard_bits = (llr > 0).astype(int)。另外注意 H 的行可能线性相关,但校验方程仍然有效。
5. 把链路跑通:仿真框架、误码率曲线与避坑清单
5.1 仿真代码骨架:发送-加噪-译码-统计
一次完整的 LDPC-BPSK 仿真链路至少包含编码器、调制器、AWGN 信道、解调器和译码器,外加误码率统计。以下是一个最小可运行的骨架,用前面构造的 H 和 G 来跑。注意这里为了简洁,编码直接用 G 矩阵,实际项目里如果 G 太密,建议换成 Richardson 编码。
import numpy as np from scipy.sparse import csr_matrix def encode(u, G): c = (u @ G) % 2 return c def simulate(eb_n0_db, num_frames=100, max_iter=50): # 假设已有 H, G 全局变量 rate = 0.5 info_len = H.shape[1] - H.shape[0] # 8176 - 4088 = 4088 ber_total = 0 ber_info = 0 for _ in range(num_frames): u = np.random.randint(0, 2, info_len) c = encode(u, G) x = 1 - 2 * c es_n0_db = eb_n0_db + 10 * np.log10(rate) snr = 10 ** (es_n0_db / 10) noise_var = 1.0 / (2 * snr) noise = np.sqrt(noise_var) * np.random.randn(len(c)) y = x + noise llr = 4 * y * snr decoded = bp_decode(llr, H, max_iter) ber_total += np.sum(decoded != c) ber_info += np.sum(decoded[:info_len] != u) return ber_total / (num_frames * len(c)), ber_info / (num_frames * info_len) # 调用示例 # ber, ber_i = simulate(1.5, 100, 50)逻辑说明:编码时u@G是模 2 矩阵乘,G 是 (info_len, 8176) 的二进制矩阵。decode后的硬判决和原始码字比对,统计全部码字误码率。信息位误码率单独统计,工程上更关注信息位。
参数说明:num_frames决定统计精度。在误码率 1e-4 量级,至少需要 1e5 个比特,也就是大约 25 帧。但实际仿真中每帧 8176 比特,跑 100 帧只有 80 万比特,如果要测到 1e-6 需要更多帧,建议动态停止:没看到 100 个误码就不停。另外,bp_decode里的最小和实现要保证返回值是 0/1 而不是 LLR,否则后续比较全是错的。
5.2 常见问题与排查:从"误码率降不下来"到"内存爆炸"
这里列几条我自己调这个方案时真正翻过车的记录,现象、原因、解决一条线写清楚。
现象 1:低信噪比(比如 0 dB)时译码输出和发送码字完全不一致,误码率接近 0.5,但理论上 LDPC 应该至少有一点纠错效果。原因:LLR 符号反了,BPSK 映射
0 -> +1,但 LLR 算出来却是-2y/σ²,导致译码器把所有先验信息当成反向的。解决:在加噪声前后各打印几个符号检查,或者用校验矩阵验证:LLR 硬判决后应该有一半概率满足校验,如果几乎全不满足,就是符号反了。现象 2:高信噪比(比如 5 dB 以上)误码率反而比低信噪比高。原因:噪声方差计算错误,
snr用了 dB 值直接带入1/(2*snr),忘记取 10 的幂次。解决:统一使用线性 SNR,代码里明确写snr_linear = 10**(snr_db/10),并在设置noise_var后打印方差,对照公式手算一次。现象 3:
bp_decode速度极慢,一帧要几秒钟。原因:校验节点更新时用了 Python 的prod和对每个消息重复求最小值,复杂度是 O(行重²)。解决:对每行先算全局乘积符号和最小值的两个候选(最小的两个绝对值),然后对第 k 条消息直接用sign_k * min((min_val, second_min))避开重复扫描。同时用稀疏矩阵存储边信息,而不是用 Python 列表。现象 4:H 矩阵 GG 时内存爆炸。原因:
lil_matrix转成csr后还行,但高斯消元里用toarray()把 8176x4088 的矩阵变成普通 numpy 数组,中间变量也拷贝了很多份。解决:用scipy.sparse.csgraph或自己实现分块位运算,或者换思路:直接用已有的 H 做 BP 译码,编码改用 Richardson 算法,不显式生成 G。现象 5:在某个 Eb/N0 点误码率出现"地板",继续增大信噪比也不再下降。原因:可能是有极少数帧无法收敛,因为迭代次数不够,或最小和修正因子过大导致数值饱和。解决:对无法收敛的帧打印校验和和 LLR 分布,看是否所有校验位都满足但信息位仍有误?这种情况说明码字本身陷入一个伪码字,需要增加迭代次数并检查 H 是否构造正确。
5.3 参数速查表:迭代次数、量化位宽、修正因子
| 参数 | 建议范围 | 影响 |
|---|---|---|
| 最大迭代次数 | 30~60 | 太小性能差,太慢低 SNR 耗时高 |
| 最小和修正因子 | 0.70~0.90 | 过小欠拟合,过大性能退化 |
| LLR 量化位宽(定点) | 8~12 bit | 低于 8 位在低 SNR 损失明显 |
| 校验矩阵子块大小 Z | 511 | 固定,来自标准 |
| 译码器内 LLR 限幅 | ±20~±30 | 防止数值溢出 |
上面这个表是我常用的起点。特别注意 LLR 限幅:BP 译码迭代中变量节点消息会随置信度增长,如果无限制,在低信噪比下可能出现类似发散的现象,误码率曲线会突然抬升。所以我在每次迭代后对 LLR 做clip(-30, 30),这个限幅值不是越大约好,因为过大的限幅会削弱后续迭代对错误的修正能力。
6. 进阶:让译码器跑得更快——分层译码、归一化修正与浮点转定点
如果你要仿真上万帧来画误码率曲线,纯 Python BP 译码的帧级效率会让人崩溃。我做过一次 8176 码的 Monte Carlo 仿真,每帧 50 次迭代,单帧要跑约 20 毫秒,扫 10 个 SNR 点各 1000 帧,总共要快 3 小时。所以效率优化不是可选项,而是必须。
第一个加速手段是分层译码(Layered Belief Propagation),本质是按校验行把 H 分成若干层,每层更新后立刻更新变量节点消息并传入下一层。相比原始 BF 的"先全部更新校验,再全部更新变量",分层收敛速度几乎快一倍,相同迭代次数下误码率更低。实现时,把 H 按行分块,每块内部做校验节点更新,随后立即把增量累加到变量消息上。对于 8176 的准循环结构,分层天然对应每个 511 行的块,非常规整。
第二个手段是定点化。把浮点 LLR 转成 Q4.8 定点(12 位,4 位整数,8 位小数),加减乘法替换成整数运算,译码速度能提升 3~5 倍。定点化最关键的参数是量化位宽。我实测过 8 位量化在 Eb/N0 = 2 dB 时比浮点损失约 0.1 dB,而 10 位以上几乎无损。另一件容易忽略的事是修正因子的定点表示,0.75 可以直接写成3 >> 2(右移两位),不用乘法。如果你用归一化最小和,尽量把因子设成 2 的幂次分之一,比如 0.75 是 1 - 1/4,可以用移位加减法实现,避免整数除法。
最后一个习惯是每次改完参数后,先跑 10 帧对照浮点结果,再上大批量仿真。我调试定点化时曾经把修正因子设成 0.7,结果低位量化误差被放大,误码率曲线在高 SNR 处掉到地板。后来发现是因为 0.7 在 Q8 定点下表示为 0.699,且乘法舍入误差积累。改成 0.75 后一切正常。这给了我很深印象:最小和本来就是近似,修正因子的精度反而没有你想象得那么敏感,关键是别引入量化导致的偏置。希望这些踩坑记录能帮你少走几步弯路,把这套 LDPC-BPSK-8176 的译码器真的跑起来、跑得快。
本文还有配套的精品资源,点击获取