ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MATLAB OFDM仿真程序:模块化链路、信道估计与BER曲线

MATLAB OFDM仿真程序:模块化链路、信道估计与BER曲线

简介:OFDM完整的仿真程序MATLAB面向通信工程专业学生、研究人员与工程师,围绕正交频分复用的调制解调流程提供一套可运行的仿真代码,用于理解多载波传输的实现机制并验证其抗多径衰落性能。程序覆盖符号生成与QAM、QPSK映射,IFFT子载波复用,循环前缀添加,频率选择性信道建模,接收端FFT,基于训练序列的信道估计与ZF、MMSE均衡,解调判决以及误码率统计等环节;读者可调整子载波数量、调制阶数、CP长度与信道参数,观察这些参数对系统性能的影响。压缩包共26个文件,以19个txt数据与说明文本、4个dat波形数据、2个m脚本及1份doc流程说明为主,体积约125KB,结构轻便,便于按模块阅读与复现。已有790人学习,适合课程设计、毕业设计及入门级系统仿真练习。

1. 一段几十行的主脚本,藏着 OFDM 的全部门道

第一次接触 OFDM 仿真的人,通常先被正交性积分、循环前缀推导和信道估计矩阵弄得头皮发麻,然后打开一份完整仿真程序,发现主脚本不过几十行——发射端从比特流到基带波形只走四步:星座映射、子载波摆位、IFFT、加循环前缀,接收端倒着走一遍,中间插一个信道模型和一套均衡,最后统计误码率画曲线。这份 OFDM 完整仿真程序就是照这个骨架搭的,模块拆得比较开,发射、信道、接收、估计、绘图各占一个文件夹。它适合两类人:一类是要交课程设计、要一张能对得上理论值的 BER 曲线的人;另一类是准备在这套骨架上验证自己算法的人,比如换一种信道估计、加个降低 PAPR 的预编码、试试不同的均衡准则——替换其中一块不用动整条链路。真正耗时间的从来不是公式,而是子载波索引与导频位置的对齐,这点后面会反复提。

2. 工程结构与技术栈:从主脚本到模块函数的分层

2.1 目录树与各文件职责

拿到压缩包先别急着点运行,把目录扫一遍,能省掉后面一半的调试时间。这套程序的组织方式是最朴素的 MATLAB 工程结构:一个入口脚本,参数集中在一个函数里,收发和信道各自成目录,工具函数单独放。没有用 classdef,也没上 App Designer,纯函数加结构体传参,好处是每一步都能在命令行里单独打断点看中间变量,坏处是没有类型约束,字段名写错要到运行到那一行才报错。

ofdm_sim/ ├── run_sim.m # 唯一入口:参数装载 -> 循环 SNR -> 画 BER 曲线 ├── config/ │ └── ofdm_config.m # 全部参数集中在这里,改配置只动这一个文件 ├── tx/ │ ├── ofdm_tx.m # 发射链路总成:映射 -> 摆位 -> IFFT -> 加 CP │ ├── mod_map.m # 比特到星座点,含格雷映射查表 │ └── add_pilot.m # 导频、DC、保护子载波的位置规划 ├── channel/ │ ├── awgn_ch.m # 纯高斯信道,按目标 SNR 加复噪声 │ ├── multipath_ch.m # 多径瑞利衰落,抽头时延 + 指数衰减功率 │ └── add_cfo.m # 可选:载波频偏与定时偏移,做同步算法时用 ├── rx/ │ ├── ofdm_rx.m # 接收链路总成:去 CP -> FFT -> 均衡 -> 判决 │ ├── ch_est_ls.m # 导频处最小二乘估计 + 频域插值 │ ├── ch_est_mmse.m # MMSE 估计,接口与 LS 一致,方便对比 │ ├── eq_zf.m # 单抽头迫零均衡 │ └── demod_demap.m # 最小欧氏距离判决 + 十进制回转比特 ├── utils/ │ ├── qam_const.m # 生成归一化星座表(QPSK/16QAM/64QAM 通用) │ ├── ber_count.m # 误比特统计,返回 BER 与错误比特数 │ └── plot_ber.m # 半对数坐标绘图 + 理论曲线叠加 └── results/ # .mat 结果与 .png 曲线落盘,git 里一般忽略

这个划分有个明显好处:想做算法对比时,只需要在rx/下新增一个ch_est_dft.m,然后在run_sim.m里把函数句柄换掉,其余链路一行不动。我见过不少同学把所有逻辑塞进一个.m文件里,改个估计器要注释掉半屏旧代码,最后自己也分不清哪段在跑。

2.2 参数集中配置:一个结构体管全部

仿真程序最容易失控的地方是参数散落。发射端写死N = 64,接收端为了省事写64,改一次 FFT 点数要全局搜索替换,漏一处就是维度不匹配。这套程序的做法是把所有可调项收进ofdm_config.m,返回一个 struct,逐层往下传。

function cfg = ofdm_config() %OFDM_CONFIG 仿真参数的唯一来源,任何模块都不允许写死数字 % ---- 物理层基本参数 ---- cfg.fftLen = 64; % FFT 点数,决定子载波间隔与采样率 cfg.cpLen = 16; % 循环前缀长度(样点数),须大于最大时延扩展 cfg.modOrder = 16; % 4=QPSK 16=16QAM 64=64QAM % ---- 子载波规划(索引均为 ifft 原生的 1..N 顺序,DC 落在 1)---- cfg.occupiedIdx = 9:56; % 实际占用的 48 个子载波 cfg.pilotIdx = [12 26 39 53]; % 其中 4 个放块状导频 cfg.dataIdx = setdiff(cfg.occupiedIdx, cfg.pilotIdx); % 余下 44 个载数据 cfg.pilotSym = 1 + 0j; % 导频复符号,已知量 % ---- 帧结构与蒙特卡洛 ---- cfg.nOfdmSym = 100; % 每个 SNR 点发多少个 OFDM 符号 cfg.snrDb = 0:2:20; % 扫描的 SNR 范围 cfg.seed = 20240501;% 随机种子,保证曲线可复现 end

参数说明上有个坑值得提前讲:pilotIdx用的是 ifft 原生索引,也就是索引 1 对应频率 0(直流),索引 34 之后对应负频率。很多教科书画频谱时习惯把 DC 摆到正中间(第 33 个点),如果照着图去写pilotIdx,代码跑出来频谱是镜像的,均衡结果自然一塌糊涂。这套程序统一采用原生索引,只有在plot频谱的时候才做一次fftshift,规则单一,不容易翻车。

cfg.dataIdx用setdiff生成而不是手写 44 个数字,是为了改导频位置时不用同步维护两张表。modOrder改了之后,每符号承载的比特数由log2(modOrder)现算,不写死。

2.3 运行入口:一个循环跑完整个 SNR 扫描

入口脚本承担三件事:设种子、按 SNR 点循环调用收发链路、把误码率投到半对数坐标上并叠加理论值。写得好不好,直接决定你调参时愿不愿意反复跑。

% run_sim.m clear; clc; cfg = ofdm_config(); rng(cfg.seed); % 固定种子,保证每次跑出同一条曲线 berSim = zeros(size(cfg.snrDb)); for k = 1:numel(cfg.snrDb) cfg.snrDbNow = cfg.snrDb(k); bits = randi([0 1], cfg.nOfdmSym * numel(cfg.dataIdx) * log2(cfg.modOrder), 1); [txFrame, meta] = ofdm_tx(cfg, bits); % 发射 rxFrame = awgn_ch(txFrame, cfg); % 信道:先跑纯 AWGN [bitsHat, ~] = ofdm_rx(cfg, rxFrame, meta); % 接收与均衡 berSim(k) = ber_count(bits, bitsHat); % 误比特统计 end plot_ber(cfg.snrDb, berSim); % 画图并叠加 qfunc 理论曲线 save(fullfile('results', 'ber_awgn.mat'), 'cfg', 'berSim');

循环体里bits的长度由「符号数 × 数据子载波数 × 每符号比特数」现算,改任何一个参数都不用动这行。meta是发射端回传的元信息,至少包含本次发了多少个 OFDM 符号和原始星座点索引,接收端解映射时要靠它对齐比特顺序——少了这个,ber_count十有八九会报长度不一致。

实际跑的时候,一个 SNR 点一百个符号、16QAM,单次耗时在零点几秒,扫 11 个点几秒钟出图,这个体量足够做课程设计,也足够做蒙特卡洛统计的入门验证。想跑更平滑的曲线,把nOfdmSym加到 1000,代价是耗时线性增长,低 SNR 段尤其明显。

3. 发射链路拆解:比特流到带 CP 时域波形的四步

3.1 比特分组与星座映射:别再用 bi2de 了

发射链路第一件事是把一维比特流切成每log2(M)个一组,转成十进制符号索引,再去星座表里查复数点。听上去简单,实际写起来有两个细节:一是比特顺序,二是新旧版本 MATLAB 的函数兼容。

function [dSym, nSym] = mod_map(bits, cfg) %MOD_MAP 比特流 -> 归一化复数星座点 % bits : 列向量,长度必须是 bps*numel(cfg.dataIdx)*nSym 的整数倍 bps = log2(cfg.modOrder); nData = numel(cfg.dataIdx); nSym = numel(bits) / (nData * bps); bMat = reshape(bits, bps, []).'; % 每行一个符号的 bps 个比特 w = 2.^(bps-1:-1:0); % [8 4 2 1],第一位是最高位 dIdx = bMat * w.'; % 矩阵乘代替 bi2de,跨版本通用 table = qam_const(cfg.modOrder); % 1 x M 的归一化星座表 dSym = reshape(table(dIdx + 1), [], 1);% +1 是因为 MATLAB 从 1 开始索引 end

这里用矩阵乘bMat * w.'完成二进制到十进制的转换,是为了绕开bi2de在新版本被标记为不推荐、后续版本可能移除的问题。写法上reshape(bits, bps, [])是列优先填装,所以第一列正好是最前面bps个比特,转置之后每行就是一个符号,权重向量[8 4 2 1]保证第一位当最高位用。如果想改成最低位在前,把w反过来即可,但收发两端必须一致,这一点在做交织或加扰时尤其容易出错。

qam_const返回的星座表做了功率归一化,16QAM 的平均符号能量为 1,这样后面算噪声方差时不用再除一个平均功率因子。格雷映射在 16QAM 上能把相邻星座点之间的误比特数压到 1,理论上比自然映射好一截,qam_const里用一张固定的比特到符号映射表实现,改调制阶数时只需扩表。

3.2 子载波摆位与 IFFT:索引对齐是第一道坎

拿到一串复数符号后,要把它们按顺序塞进频域格子里对应的数据子载波位置,导频位置填已知符号,保护带和直流留零,然后做 IFFT 变到时域。这一步是所有 OFDM 仿真里最容易出维度错误的地方。

function [X, meta] = ofdm_frame(dSym, cfg) %OFDM_FRAME 把串行星座点摆到频域格子上,返回 Nfft x nSym 的频域矩阵 nData = numel(cfg.dataIdx); nSym = numel(dSym) / nData; X = zeros(cfg.fftLen, nSym); % 含保护带的全零矩阵 X(cfg.dataIdx, :) = reshape(dSym, nData, nSym); % 数据按列排,一列一个符号 X(cfg.pilotIdx, :) = repmat(cfg.pilotSym, 1, nSym); % 块状导频,每符号同位置 % 保护带与 DC 保持 0,不需要显式赋值 meta.nSym = nSym; meta.X = X; end

reshape(dSym, nData, nSym)按列填充,意味着dSym的前nData个点是第一个 OFDM 符号的内容,顺序与cfg.dataIdx从小到大一致。收发两端只要都遵守「按dataIdx升序摆、按dataIdx升序取」这一条约定,就不会错位。我见过为了省事在接收端用Y(cfg.dataIdx)取值、却在发射端用X(sort(cfg.dataIdx,'descend'))摆位的写法,星座图看着还挺像,误码率却永远是 0.5,查了两天才发现是顺序反了。

IFFT 本身一行就够,关键是理解 MATLAB 的归一化约定:

x = ifft(X, cfg.fftLen, 1); % 沿列做 Nfft 点 IFFT,等价于把频域符号搬到正交子载波上

MATLAB 的ifft带1/N归一化因子,所以频域每个子载波功率为 1 时,时域样点平均功率约为「占用子载波数 / FFT 点数」。这个关系后面算噪声方差要用到。fftLen参数显式写出来,是为了防止某天X的行数因为加保护带被动变化,导致自动按行数推断出错。

3.3 加循环前缀与帧拼接:把符号连成一条波形

IFFT 出来的是Nfft × nSym的矩阵,每一列是一个 OFDM 符号。循环前缀的作用是把每个符号末尾cpLen个样点复制到开头,这样在 multipath 信道下,只要时延扩展不超过 CP 长度,子载波之间的正交性就能保住,接收端一个单抽头均衡就能把信道抹平。

function txFrame = add_cp(x, cfg) %ADD_CP 逐符号添加循环前缀并串行化成一列时域波形 Ncp = cfg.cpLen; xcp = [x(end-Ncp+1:end, :); x]; % 尾部 Ncp 个样点搬到最前面 txFrame = xcp(:); % 按列串行化,得到一个长列向量 end

xcp(:)的按列串行化方式决定了接收端必须用同样的顺序还原:reshape(rxFrame, Nfft+Ncp, nSym)。如果发射端用了reshape(xcp.', [], 1)这种按行展开的写法,接收端的reshape就会把一个符号的尾巴接到另一个符号的头上,去 CP 之后得到的频域数据全是垃圾。这类错误的典型表现是「星座图是一团云,但 SNR 拉高也不见好转」,因为它根本不是噪声问题,而是符号边界错位。

一个 64 点 FFT、16 点 CP 的配置,CP 开销是 25%,这意味着一部分能量花在了冗余上,有效 SNR 相比标称值要低10*log10(80/64) ≈ 0.97 dB。写论文对比理论曲线时,这个损失要么在噪声方差里补进去,要么在结论里说清楚,否则仿真点会系统性地偏在理论线右侧,看起来像实现有 bug。

4. 接收链路与信道:多径、LS 估计到 BER 曲线的闭环

4.1 信道模型:从纯 AWGN 到多径瑞利

程序里给了两个信道函数,建议先跑 AWGN 把链路本身验证通,再加多径。AWGN 的写法简单,但噪声方差的计算方式直接决定横轴到底是 SNR 还是别的什么。

function rx = awgn_ch(tx, cfg) %AWGN_CH 按目标 SNR 叠加复高斯白噪声 sigPow = mean(abs(tx).^2); % 时域平均功率(已含 CP 冗余) sigma2 = sigPow / 10^(cfg.snrDbNow/10); % 复噪声总方差 noise = sqrt(sigma2/2) * (randn(size(tx)) + 1j*randn(size(tx))); rx = tx + noise; end

sigma2/2分别给实部和虚部,保证复噪声总方差等于sigma2。这是复数基带仿真的标准做法,写成sqrt(sigma2)会让噪声功率翻倍,曲线整体左移 3 dB,是个非常隐蔽的错。sigPow用实测的发射波形功率而不是理论值,好处是即使星座表归一化没做干净,信噪比定义依然准确。

多径信道按抽头时延和功率分配构造 FIR 滤波器:

function [rx, h] = multipath_ch(tx, cfg) %MULTIPATH_CH 多径瑞利衰落信道,抽头功率按 dB 给定后归一化 delays = cfg.pathDelay; % 例如 [0 2 5 9],单位是样点数 gainsDb = cfg.pathGainDb; % 例如 [0 -3 -6 -9],相对功率 p = 10.^(gainsDb(:).'/10); p = p / sum(p); % 归一化,保证平均总功率为 1 h = zeros(max(delays)+1, 1); h(delays+1) = sqrt(p/2) .* (randn(1,numel(delays)) + 1j*randn(1,numel(delays))); rxClean = filter(h, 1, tx); % FIR 卷积,模拟各径叠加 rx = awgn_ch(rxClean, cfg); % 再叠噪声,噪声功率按衰落前的信号功率算 end

抽头功率归一化这一步不能省。如果不归一化,sum(p) > 1会让接收信号整体放大,等效 SNR 变高,曲线无故变好;sum(p) < 1则相反。另外衰落信道下噪声应该按「发射信号功率」来定,而不是按衰落后的信号功率,否则每个 SNR 点的实际噪声水平都在变,曲线没有可比性。filter(h,1,tx)引入的群时延是max(delays)/2量级,做定时同步算法时要注意补上,做纯 BER 统计可以忽略,因为符号边界是整体平移的。

4.2 去 CP、FFT 与信道估计均衡

接收端的前三步是发射端的逆运算,写起来短,但每一个索引都要和发射端严丝合缝。

function [Y, rcp] = ofdm_deframe(rxFrame, cfg) %OFDM_DEFRAME 串并转换 -> 去 CP -> FFT 回频域 Nf = cfg.fftLen; Ncp = cfg.cpLen; nSym = numel(rxFrame) / (Nf + Ncp); r = reshape(rxFrame, Nf + Ncp, nSym); % 与发射端 xcp(:) 的顺序对应 rcp = r(Ncp+1:end, :); % 丢掉前 Ncp 个样点 Y = fft(rcp, Nf, 1); % 每个符号独立做 FFT end

rcp = r(Ncp+1:end, :)就是去 CP。注意这里能直接丢掉前Ncp个样点而不做任何相关运算,前提是定时完全对齐。一旦加了定时偏移或者多径首径不在 0 时刻,就必须先做定时同步,找到 CP 的起始位置,否则丢掉的是有效数据,后面全错。程序里预留了add_cfo.m做频偏和定时偏移注入,做同步算法验证时才会用到。

信道估计是这套程序里最能体现「模块可替换」的部分。LS 估计一句话:导频位置接收值除以已知导频值。

function H = ch_est_ls(Y, cfg) %CH_EST_LS 导频处最小二乘估计 + 频域线性插值 Hp = Y(cfg.pilotIdx, :) ./ cfg.pilotSym; % 每列一个符号的导频处信道响应 xi = (1:cfg.fftLen).'; H = zeros(cfg.fftLen, size(Y,2)); % interp1 不支持复数,实部虚部分别插值后再合成 for s = 1:size(Y,2) H(:,s) = interp1(cfg.pilotIdx(:), real(Hp(:,s)), xi, 'linear', 'extrap') + ... 1j * interp1(cfg.pilotIdx(:), imag(Hp(:,s)), xi, 'linear', 'extrap'); end % 保护带上的外推值噪声极大,直接置零,避免均衡时放大 H(setdiff(1:cfg.fftLen, cfg.occupiedIdx), :) = 0; end

interp1对复数会直接报错,这是 MATLAB 里比较经典的坑,必须拆实部虚部。'extrap'让保护带也有值,但保护带上没有导频约束,外推出来的信道估计基本是噪声,所以最后统一置零。均衡只在数据子载波上做,保护带置不置零对结果没影响,但置零能防止画均衡后星座图时出现一圈离谱的散点,调试时看着清爽。

均衡器给出两种,迫零和 MMSE:

function Yeq = eq_zf_eq_mmse(Y, H, cfg, mode) %EQ_ZF_EQ_MMSE 单抽头均衡,mode 取 'zf' 或 'mmse' Hd = H(cfg.dataIdx, :); Yd = Y(cfg.dataIdx, :); switch lower(mode) case 'zf' % 迫零:直接除以信道,低 SNR 下会放大噪声 Yeq = Yd ./ Hd; case 'mmse' % MMSE:带噪声方差的正则项 % 注意:时域噪声方差要乘 Nfft 才是频域每点的噪声方差 W = conj(Hd) ./ (abs(Hd).^2 + cfg.sigma2 * cfg.fftLen); Yeq = Yd .* W; end end

MMSE 里的cfg.sigma2 * cfg.fftLen这一项很多人会漏。时域加噪方差是sigma2,MATLAB 的fft没有归一化因子,所以频域每个子载波上的噪声方差被放大成Nfft * sigma2。漏掉这个Nfft,MMSE 实际上就退化成了一个正则项偏小的迫零,深衰落子载波上噪声照样被放大,曲线会出现误码率地板,看起来像 CP 长度不够,其实完全不是。

4.3 判决、解映射与 BER 统计

均衡后的复数点要判到最近的星座点上,再转回比特。最小欧氏距离判决在星座点少的时候直接算距离矩阵就行,不用上维特比。

function bitsHat = demod_demap(Yeq, cfg) %DEMOD_DEMAP 最小距离判决 + 索引转比特 table = qam_const(cfg.modOrder); % 1 x M bps = log2(cfg.modOrder); % 每个接收点到所有星座点的距离,取最小者 D = abs(Yeq(:) - table).^2; % 自动广播成 (nPts x M) [~, idx] = min(D, [], 2); % idx 从 1 开始 dIdx = idx - 1; % 换回 0 基 % 十进制转二进制,直接按位取,避免 de2bi 的版本问题 bitsMat = zeros(numel(dIdx), bps); for b = 1:bps bitsMat(:, b) = bitget(dIdx, bps - b + 1); % 高位在前,与发射端一致 end bitsHat = reshape(bitsMat.', [], 1); % 按符号顺序串回一列 end

bitget(dIdx, bps-b+1)保证输出的第一位对应发射端bMat * w.'里的最高位。如果这里顺序反了,误码率不会变成 0.5,而是会稳定在「一个符号内比特全错」的水平,曲线形状还是随 SNR 下降,很容易被误判成「实现有损耗」,实际上是映射表反了。这类问题查起来靠对比:把 SNR 设到 30 dB,如果 BER 还下不去 1e-2,就不是噪声问题。

统计部分把仿真点和理论值叠在一张图上,理论曲线用qfunc现算:

function plot_ber(snrDb, berSim) %PLOT_BER 半对数坐标绘制仿真 BER 并叠加 AWGN 理论值 figure('Color','w'); semilogy(snrDb, berSim, 'o-', 'LineWidth', 1.2); hold on; semilogy(snrDb, qfunc(sqrt(2*10.^(snrDb/10))), 'k--'); % BPSK/QPSK 理论 grid on; xlabel('SNR (dB)'); ylabel('BER'); legend('仿真','理论','Location','southwest'); end

qfunc(sqrt(2*Eb/N0))是 BPSK 和 QPSK 在 AWGN 下的闭式解,16QAM 需要换成近似式(3/8)*erfc(sqrt(0.4*Eb/N0))之类。把仿真点和理论线摆在一起看,是最快的自检手段:QPSK + AWGN 且不做信道估计时,两条线应该几乎重合,差 1 dB 以内算正常(CP 开销导致的);如果仿真线整体右移 3 dB,多半是噪声方差算错;如果低 SNR 段贴合、高 SNR 段翘起来形成地板,那就是多径、CP 或估计误差的问题,不是同一类毛病。

5. 避坑与排查:五处让曲线跑飞的典型故障

5.1 星座图与解调异常

现象一:星座图看着正常,BER 恒定在 0.5 附近,加多少 SNR 都不动。

原因通常是比特顺序或子载波顺序在收发两端不一致。最常见的是发射端按dataIdx升序摆数据,接收端却用sort(dataIdx,'descend')取值,或者reshape的方向反了,导致解出来的比特和原始比特完全对不上。另一种情况是mod_map里的权重向量写成2.^(0:bps-1)、demod_demap里却按高位在前取,两边刚好互为镜像。

解决办法是把链路切成两半验证:先不经过信道,直接ofdm_rx(cfg, ofdm_tx(cfg, bits), meta)做回环,看 BER 是不是严格的 0。回环通了再叠 AWGN,这是最省时间的排查顺序,别一上来就跑完整曲线。

现象二:回环 BER 为 0,加 AWGN 后误码率下降很慢,比理论差 6 dB 以上。

先检查星座表的归一化。qam_const如果返回的是未归一化的整数坐标(比如 16QAM 用 ±1、±3),平均符号能量是 10 而不是 1,噪声方差按 1 算就会偏小,等效 SNR 白白低了 10 dB。其次是检查噪声功率里有没有把 CP 开销算进去,sigPow用加了 CP 之后的波形算是对的,如果用的是 IFFT 输出直接算,就会漏掉 25% 的能量摊薄。

还有一个隐蔽项:awgn_ch里randn的维度。写成randn(1,numel(tx))得到的是行向量,加到一个列向量上 MATLAB 会自动广播成矩阵,结果维度暴涨,后面的reshape虽然不报错但数据已经乱了。统一用size(tx)最稳。

现象三:多径信道下 BER 曲线出现明显的误码率地板,SNR 再高也压不下去。

第一个要查的是 CP 长度和最大时延扩展的关系。cfg.pathDelay最大值是 9 个样点,CP 长度 16 够用;如果哪天把时延改成[0 5 12 20],CP 还是 16,第 4 条径就跑到 CP 窗口外面去了,符号间干扰(ISI)直接出现,地板是必然的。判断方法很简单:把cpLen临时改成 32 再跑一次,地板消失就说明是 CP 不够。

第二个要查的是信道估计的插值方式。4 个导频做线性插值,在频率选择性强的信道下误差本来就大,深衰落子载波上的估计值可能偏出去一大截,迫零均衡把噪声放大得很厉害。换成 MMSE 或者把导频加密到 8 个,地板会明显下降。这不是程序 bug,是算法本身的局限,写报告时该说清楚。

5.2 统计与版本兼容问题

现象四:同一套参数跑两次,曲线不一样,甚至差出好几 dB。

run_sim.m里没有rng(cfg.seed),或者虽然设了但每次循环内部又调用了rand、randn之外还依赖了randperm之类没被种子控制住的函数。MATLAB 的随机数发生器状态是全局的,只要中间某个函数多调了一次randn(比如你在调试时加了一行打印),后续所有噪声序列全部错位。

解决办法是在run_sim.m开头统一设一次种子,并且不要在各个子函数内部再调rng。另外,蒙特卡洛点数太少也会让曲线抖动,nOfdmSym = 10的时候,1e-3 量级的误码率基本靠运气,至少要到 100 以上才有统计意义,低 SNR 段可以少发,高 SNR 段要多发。

现象五:换了一台机器或者升级了 MATLAB 版本,程序直接报「函数不存在」或者结果变了。

最典型的是bi2de和de2bi。这两个函数在新的版本里已经不建议使用,部分环境里需要额外工具箱支持,直接报未定义。本文给出的mod_map和demod_demap用矩阵乘和bitget手写转换,就是为了绕开这个依赖,无论在哪个版本上都能跑。

其次是interp1对复数的处理,老版本在某些情况下会静默地只取实部而不报错,结果就是信道估计的虚部全丢,相位完全错乱。显式拆实部虚部是最稳妥的写法。第三是qfunc依赖 Communications Toolbox,如果没有装,用0.5*erfc(x/sqrt(2))替代即可,erfc是 MATLAB 基础函数,不存在兼容问题。

提示:遇到结果和预期不符,先做回环测试(不加信道),再逐步加噪、加多径、加估计,每加一步验证一次。一次性把整条链路跑通再查错,效率会低很多。

6. 二次开发改造:把仿真脚本变成算法验证骨架

这套程序最值得利用的地方在于模块边界清晰,把「跑出一条曲线」改造成「验证一个想法」的成本很低。几个我实际改过的方向,按投入从低到高排。

第一个是把信道估计做成可插拔的。ch_est_ls.m和ch_est_mmse.m的函数签名一致,都是(Y, cfg) -> H,所以在ofdm_rx.m里加一个cfg.estMode分支就能切换。要验证自己写的 DFT 降噪估计,新建一个ch_est_dft.m,对 LS 结果做一次 IDFT、把 CP 长度以外的时域抽头置零、再 DFT 回频域,然后在switch里加一个case即可,其余链路一行不动。我一般会顺手把几种估计器放在同一个 SNR 循环里跑,用cell存各自的 BER,最后画在一张图上,对比一目了然。

第二个是加 PAPR 统计。发射端add_cp之前拿到的是Nfft × nSym的时域矩阵,对每一列算max(|x|).^2 / mean(|x|.^2),再统计超过某个门限的概率,就是 CCDF 曲线。想做预编码降低 PAPR,在 IFFT 之前乘一个变换矩阵(比如 DFT 扩频、SLM 的相位旋转),改的是ofdm_frame里摆完位之后那几行,接收端对应做逆变换。这套扩展不需要动信道和估计,改动面很小。

第三个是把同步补上。add_cfo.m已经预留了频偏注入,接收端需要自己做 CP 相关定时和基于 CP 的频偏估计。这部分我建议单独写一个sync.m,输入是接收时域波形,输出是定时偏移和频偏估计值,先离线验证估计精度(画 MSE 随 SNR 的曲线),确认没问题再接回主链路。同步算法一旦和主链路搅在一起,出了错很难判断是同步偏了还是估计偏了。

改造过程中有个习惯值得坚持:每改一个模块,先在snrDb = [10 20 30]这三个点上跑一遍小规模测试,回环比对误码数,确认没有引入新的错误,再去跑完整的 0:2:20 曲线。早年我图省事,直接改完就扔进完整循环里,结果曲线形状变了,分不清是新算法有效还是某处索引错位,白白花了一天时间逐个函数加打印。

改造验证的准则是:如果新算法的理论收益是 2 dB,那么回环和 AWGN 下的曲线必须先对得上,再看多径下的增益,否则任何增益都不可信。把每个模块的输入输出存成.mat快照,出问题时能直接对比改前改后,比盯着代码猜快得多。

提示:所有随机过程共用一套种子,改算法时保留同一个种子,这样两次运行的噪声序列完全一致,增益差异才能归因到算法本身,而不是随机波动。

这份 OFDM 仿真程序的脚本、参数配置和绘图函数都在压缩包里,目录结构就是上面那棵树的组织方式,解压后把当前目录切到根目录,直接跑run_sim.m就能出第一条曲线。建议第一遍先不要改任何参数,看清楚 BER 曲线的形状,再动手换调制阶数、加多径、换估计器。

从那以后我每次接手一份新的仿真代码,都先把它砍成「发射→回环→接收」的最小闭环跑通,再逐段把信道和估计加回去,绝不一次性跑完整链路。这个习惯让我少了很多对着星座图发呆的夜晚。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进