ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MU-MIMO仿真:MATLAB多用户信道建模与预编码实现

MU-MIMO仿真:MATLAB多用户信道建模与预编码实现

简介:这份资源面向无线通信、信号处理方向的学生与研究人员,提供一套多用户MIMO系统的MATLAB仿真程序,用于理解MIMO信道建模、预编码与多用户调度等核心环节。包内共229个文件,以mat数据文件、m脚本、asv备份、c与mexw32/mexw64混合编译文件为主,另含少量log、h、cpp、fig与txt,压缩包约1.65MB,结构上覆盖信道建模、预编码算法、用户选择策略与性能分析等模块。已有390人学习下载,说明其在教学与科研场景中具备一定参考价值。读者可借助这些代码复现瑞利衰落、多径衰落等信道条件下的MU-MIMO链路,对比MMSE、MRT、ZF等预编码方案对用户间干扰的抑制效果,并通过吞吐量、误码率、信噪比与系统容量等指标评估不同参数配置,从而在理论与工程实现之间建立联系,提升无线通信系统设计能力。

1. 多用户MIMO仿真到底在算什么:从单用户到MU-MIMO的信道容量差异

很多人第一次接触 MU-MIMO,是在 MATLAB 里跑一个 2x2 或 4x4 的 MIMO 信道容量图,发现单用户 MIMO 的容量随天线数线性涨,就以为多用户无非是把天线分给几个人用。真动手搭一套多用户 MIMO 仿真之后才会发现,事情完全不是这么回事:单用户 MIMO 的容量增益来自空间复用,而 MU-MIMO 的增益来自「空分多址」——基站用同一时频资源同时服务多个用户,靠的是下行预编码把每个用户的信号压到其他用户的零空间里。这个标题里的 MU-MIMO_sim 就是干这件事的:用 MATLAB 把多用户 MIMO 的下行链路信道建模、预编码、容量计算串起来,跑出可复现的曲线。适合已经会写单用户 MIMO 容量脚本、想搞清楚多用户场景下信道建模和预编码怎么落地的人,也适合做毕设或论文仿真、需要一套能改参数就能出图的代码框架的人。

2. MU-MIMO 信道建模:从 i.i.d. 瑞利到相关信道怎么选

2.1 为什么多用户信道不能直接套单用户的那套

单用户 MIMO 里,信道矩阵 H 就是一个 N_r × N_t 的复高斯矩阵,容量公式 C = log2 det(I + (SNR/N_t) HH^H) 直接算。到了多用户下行,基站有 N_t 根发射天线,同时服务 K 个用户,每个用户 N_r 根接收天线,信道变成一组矩阵 {H_1, H_2, ..., H_K},每个 H_k 是 N_r × N_t。这时候容量不再是单个行列式,而是要看基站怎么把 K 路数据流预编码后叠加发射。最常见的建模假设是每个用户的信道独立同分布瑞利衰落,即 H_k 的每个元素服从 CN(0,1)。这个假设在用户间距足够大、散射环境丰富时成立,仿真里也最省事。但如果用户靠得近,或者基站天线间距不够,用户间信道就会出现空间相关,这时候必须引入相关矩阵。

常见做法是用 Kronecker 模型:H_k = R_r^{1/2} * G_k * R_t^{1/2},其中 G_k 是 i.i.d. 复高斯矩阵,R_r 和 R_t 分别是接收端和发射端的相关矩阵。R_t 通常用指数相关模型生成,相关系数 ρ 控制相邻天线的相关程度,ρ=0 退化成 i.i.d.,ρ=0.9 就是强相关。这个 ρ 是仿真里最容易被忽略又最影响结果的参数,后面避坑章节会细说。

2.2 用 MATLAB 生成多用户信道矩阵的最小代码

下面这段代码生成 K 个用户的信道矩阵,支持 i.i.d. 和相关两种模式,相关矩阵用指数模型。直接抄进脚本就能跑。

function H = generate_mu_mimo_channel(K, Nt, Nr, rho, mode) % K : 用户数 % Nt : 基站发射天线数 % Nr : 每个用户接收天线数 % rho : 发射端天线相关系数,0 表示 i.i.d. % mode : 'iid' 或 'corr' % H : K x 1 cell,每个元素是 Nr x Nt 的复信道矩阵 H = cell(K, 1); for k = 1:K G = (randn(Nr, Nt) + 1j*randn(Nr, Nt)) / sqrt(2); % CN(0,1) if strcmp(mode, 'iid') H{k} = G; else Rt = exp_corr_matrix(Nt, rho); % Nt x Nt 发射相关矩阵 Rr = eye(Nr); % 接收端暂不相关 H{k} = Rr^(1/2) * G * Rt^(1/2); end end end function R = exp_corr_matrix(N, rho) % 指数相关矩阵:R(i,j) = rho^|i-j| R = zeros(N, N); for i = 1:N for j = 1:N R(i,j) = rho^abs(i-j); end end end

逻辑说明:generate_mu_mimo_channel对每个用户独立生成信道,mode='iid'时直接返回复高斯矩阵,mode='corr'时用 Kronecker 模型做相关。exp_corr_matrix生成指数相关矩阵,rho是相邻天线相关系数。参数上,K一般取 2 到 10,Nt取 4 到 64,Nr取 1 或 2 最常见(用户端单天线场景就是 Nr=1)。rho建议从 0、0.3、0.7、0.9 各跑一遍,看容量曲线怎么随相关性退化。注意Rt^(1/2)在 MATLAB 里是矩阵平方根,不是逐元素开方,别写错。

2.3 信道容量计算:从单用户到多用户的公式切换

单用户 MIMO 容量是 C = log2 det(I + (P/Nt) H H^H)。多用户下行如果基站知道所有用户的信道(CSIT 完美),用脏纸编码(DPC)能达到的容量和是 C_sum = max log2 det(I + (P/Nt) H^H H),其中 H 是把所有用户信道堆叠成的矩阵 [H_1; H_2; ...; H_K],维度是 (K*Nr) × Nt。这个公式看着和单用户很像,但含义完全不同:它假设基站能对每个用户的数据流做非线性预编码,实际中常用线性预编码(ZF、MMSE)去逼近。仿真里一般先算 DPC 上界,再算 ZF 和 MMSE 预编码的实际和速率,对比看差距。

function [C_dpc, C_zf, C_mmse] = mu_mimo_sum_rate(H, P, noise_var) % H : K x 1 cell,每个 Nr x Nt % P : 总发射功率 % noise_var: 噪声方差 % 返回三种预编码下的和速率(bps/Hz) K = length(H); Nt = size(H{1}, 2); Nr = size(H{1}, 1); % 堆叠所有用户信道 H_stack = []; for k = 1:K H_stack = [H_stack; H{k}]; end % DPC 上界 C_dpc = real(log2(det(eye(K*Nr) + (P/Nt/noise_var) * (H_stack*H_stack')))); % ZF 预编码:W = H^H (H H^H)^(-1) W_zf = H_stack' / (H_stack * H_stack'); C_zf = 0; for k = 1:K idx = (k-1)*Nr + (1:Nr); hk = H_stack(idx, :); wk = W_zf(:, idx); sinr = (P/Nt) * abs(hk*wk)^2 / (noise_var + sum_signal_leakage(H_stack, W_zf, k, P/Nt)); C_zf = C_zf + log2(1 + sinr); end % MMSE 预编码 W_mmse = H_stack' / (H_stack*H_stack' + (noise_var*Nt/P)*eye(K*Nr)); C_mmse = 0; for k = 1:K idx = (k-1)*Nr + (1:Nr); hk = H_stack(idx, :); wk = W_mmse(:, idx); sinr = (P/Nt) * abs(hk*wk)^2 / (noise_var + sum_signal_leakage(H_stack, W_mmse, k, P/Nt)); C_mmse = C_mmse + log2(1 + sinr); end end function leak = sum_signal_leakage(H_stack, W, k, scale) % 计算第 k 个用户受到的其他用户干扰功率 K = size(W, 2) / size(H_stack, 1) * size(H_stack, 1); leak = 0; idx_k = (k-1)*size(H_stack,1)/K + (1:size(H_stack,1)/K); for j = 1:K if j == k, continue; end idx_j = (j-1)*size(H_stack,1)/K + (1:size(H_stack,1)/K); leak = leak + scale * norm(H_stack(idx_k,:) * W(:, idx_j))^2; end end

逻辑说明:H_stack把所有用户信道按行堆叠,DPC 上界直接用堆叠矩阵算行列式。ZF 预编码的伪逆H_stack' / (H_stack*H_stack')是标准写法,MMSE 在分母加了正则项(noise_var*Nt/P)*eye(...)。sum_signal_leakage算的是其他用户对当前用户的干扰功率,SINR 里分子是有用信号,分母是噪声加干扰。参数上,P是总功率,noise_var一般设 1,P/noise_var就是发射 SNR。注意 ZF 在用户数接近天线数时噪声放大严重,MMSE 的正则项能缓解,但正则系数要调,后面会讲。

3. 预编码选型与参数调优:ZF、MMSE、DPC 到底差多少

3.1 三种预编码的适用边界

ZF 预编码的优点是简单,完全消除用户间干扰,但代价是噪声放大。当 K*Nr 接近 Nt 时,H_stack*H_stack'接近奇异,伪逆的范数爆炸,SINR 反而下降。MMSE 预编码在 ZF 基础上加正则项,本质是在干扰消除和噪声放大之间做折中,正则系数noise_var*Nt/P就是折中因子。DPC 是理论上界,实际不可实现(需要非线性编码和无限长码块),但仿真里必须算,用来衡量线性预编码离上界有多远。经验上,用户数 K 小于 Nt/2 时 ZF 和 MMSE 差距不大,K 接近 Nt 时 MMSE 明显好于 ZF,K 超过 Nt 时两者都崩,只能靠用户调度。

3.2 参数扫描:用户数、天线数、SNR 怎么设

仿真里最常扫的三个参数是用户数 K、发射天线数 Nt、发射 SNR。建议的扫描范围:K 从 2 到 8,Nt 从 4 到 32,SNR 从 0 dB 到 30 dB。每次固定两个扫一个,画曲线。下面是一个扫描脚本的骨架。

SNR_dB = 0:2:30; K_list = [2, 4, 6, 8]; Nt = 16; Nr = 1; rho = 0.3; num_real = 200; % 每个点蒙特卡洛次数 for ki = 1:length(K_list) K = K_list(ki); C_dpc_avg = zeros(size(SNR_dB)); C_mmse_avg = zeros(size(SNR_dB)); for si = 1:length(SNR_dB) P = 10^(SNR_dB(si)/10); for r = 1:num_real H = generate_mu_mimo_channel(K, Nt, Nr, rho, 'corr'); [c1, ~, c2] = mu_mimo_sum_rate(H, P, 1); C_dpc_avg(si) = C_dpc_avg(si) + c1; C_mmse_avg(si) = C_mmse_avg(si) + c2; end end C_dpc_avg = C_dpc_avg / num_real; C_mmse_avg = C_mmse_avg / num_real; plot(SNR_dB, C_dpc_avg, '-o', SNR_dB, C_mmse_avg, '-s'); hold on; end legend('DPC K=2','MMSE K=2','DPC K=4','MMSE K=4','DPC K=6','MMSE K=6','DPC K=8','MMSE K=8'); xlabel('SNR (dB)'); ylabel('Sum Rate (bps/Hz)');

逻辑说明:外层循环扫 K,内层扫 SNR,每个点做num_real次蒙特卡洛平均。num_real取 200 到 1000,太少曲线会抖。rho固定 0.3 是中等相关,想看重相关影响就改成 0.9。注意P = 10^(SNR_dB/10)是把 dB 转线性,noise_var固定为 1,这样 SNR 就是发射功率。画图时用hold on叠加多条曲线,legend要对应好,别标错。

3.3 蒙特卡洛次数与收敛判断

蒙特卡洛次数不够是仿真曲线「玄学抖动」的主要原因。判断收敛的土办法:把num_real从 100 加到 200 再加到 500,看同一个 SNR 点的和速率变化是否小于 2%。如果 500 次还在抖,要么是信道生成有 bug,要么是 ZF 在 K 接近 Nt 时本身方差就大。我一般先跑 100 次看趋势,确认代码没问题再上 1000 次出正式图。另外,每次跑之前用rng(42)固定随机种子,不然每次图都不一样,没法对比。

4. 避坑与排查:MU-MIMO 仿真里最容易翻车的 5 个地方

4.1 现象:容量曲线随 SNR 升高反而下降

原因:ZF 预编码在 K*Nr 接近 Nt 时,H_stack*H_stack'接近奇异,伪逆的范数随 SNR 升高而放大噪声,导致 SINR 不升反降。这是 ZF 的固有缺陷,不是代码 bug。

解决:换 MMSE 预编码,或者降低 K/Nt 比值。如果必须用 ZF,加一个小的对角加载H_stack*H_stack' + epsilon*eye(...),epsilon 取 1e-3 到 1e-2,能缓解奇异但不根治。

4.2 现象:相关信道下容量比 i.i.d. 还高

原因:相关矩阵生成时Rt^(1/2)写成了逐元素开方,或者rho的符号搞反了。指数相关矩阵R(i,j)=rho^|i-j|在 rho 为正时是正相关,如果误用负相关或者矩阵不正定,Rt^(1/2)会出复数,容量计算出错。

解决:检查Rt是否正定,用eig(Rt)看最小特征值是否大于 0。Rt^(1/2)用sqrtm(Rt)更稳妥,别用sqrt(Rt)。另外,相关信道容量通常低于 i.i.d.,如果跑出更高,先查相关矩阵。

4.3 现象:MMSE 预编码的正则系数调不对,性能还不如 ZF

原因:正则系数noise_var*Nt/P是理论值,实际仿真里如果 SNR 定义和噪声方差设置不一致,这个系数就偏了。比如 SNR 用 dB 转线性时忘了开方,或者noise_var没归一化。

解决:把正则系数当成可调参数,在[0.001, 0.01, 0.1, 1]里扫一遍,看哪个和速率最高。理论上最优正则系数随 SNR 变化,低 SNR 时应该大一些,高 SNR 时小一些。如果懒得调,直接用noise_var*Nt/P也行,但要知道它不一定最优。

4.4 现象:用户数超过天线数时仿真直接报错或结果全零

原因:H_stack的维度是 (KNr) × Nt,当 KNr > Nt 时,H_stack*H_stack'是奇异矩阵,求逆报错。即使不报错,ZF 也无法同时服务这么多用户。

解决:K*Nr 必须小于等于 Nt,这是 MU-MIMO 的基本约束。如果用户数确实多,得加用户调度,每时隙只选 Nt 个用户里的子集。仿真里可以先随机选 Nt 个用户,或者按信道增益排序选前 Nt 个。

4.5 现象:蒙特卡洛跑得慢,1000 次要等半小时

原因:MATLAB 里循环里反复调用det和inv,或者H_stack拼接用了[H_stack; H{k}]在循环里动态增长。

解决:预分配H_stack = zeros(K*Nr, Nt),用索引赋值而不是拼接。det和inv能换成chol或\就换。另外,parfor可以并行蒙特卡洛,但要注意随机种子在每个 worker 上要独立设置,不然结果不可复现。

5. 进阶技巧:用 OOP 重构仿真框架,让参数扫描不再改代码

跑通基础脚本之后,最烦的是每次换参数都要改代码、重跑、手动存图。我后来用 MATLAB 的 OOP 把整个仿真重构成一个类,参数全放在属性里,方法只负责计算和画图。这样扫参数就是改一个属性值,循环调用方法,图自动存。下面是一个最小框架。

classdef MuMimoSim < handle properties K = 4; Nt = 16; Nr = 1; rho = 0.3; mode = 'corr'; SNR_dB = 0:2:30; num_real = 500; seed = 42; end methods function obj = MuMimoSim(varargin) for i = 1:2:length(varargin) obj.(varargin{i}) = varargin{i+1}; end end function [snr, C_dpc, C_mmse] = run(obj) rng(obj.seed); snr = obj.SNR_dB; C_dpc = zeros(size(snr)); C_mmse = zeros(size(snr)); for si = 1:length(snr) P = 10^(snr(si)/10); for r = 1:obj.num_real H = generate_mu_mimo_channel(obj.K, obj.Nt, obj.Nr, obj.rho, obj.mode); [c1, ~, c2] = mu_mimo_sum_rate(H, P, 1); C_dpc(si) = C_dpc(si) + c1; C_mmse(si) = C_mmse(si) + c2; end end C_dpc = C_dpc / obj.num_real; C_mmse = C_mmse / obj.num_real; end function plot_result(obj, snr, C_dpc, C_mmse) figure; hold on; plot(snr, C_dpc, '-o', snr, C_mmse, '-s'); xlabel('SNR (dB)'); ylabel('Sum Rate (bps/Hz)'); legend('DPC', 'MMSE'); title(sprintf('K=%d, Nt=%d, rho=%.1f', obj.K, obj.Nt, obj.rho)); saveas(gcf, sprintf('mu_mimo_K%d_Nt%d_rho%.1f.png', obj.K, obj.Nt, obj.rho)); end end end

用法就是sim = MuMimoSim('K', 6, 'rho', 0.7); [snr, c1, c2] = sim.run(); sim.plot_result(snr, c1, c2);。参数全在构造函数里传,不用改文件。handle继承让对象可以原地修改属性。rng(obj.seed)保证每次跑结果一样。saveas自动存图,文件名带参数,扫完一轮图全在文件夹里,不用手动整理。

这个框架的边界:它只适合单基站多用户下行,不支持多小区干扰,也不支持用户移动性。如果要加这些,得在generate_mu_mimo_channel里扩展,或者再包一层。另外,parfor和handle类一起用要注意每个 worker 的对象是独立的,别在并行里改共享属性。

我自己的习惯是每跑完一组参数,把sim对象存成.mat文件,文件名带日期和参数,过两周回头看还能复现。仿真这行,后悔药就是随机种子和参数记录,别省这一步。希望帮到你。

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

RELATED READING

延伸阅读

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