简介:面向无线通信系统设计的高年级本科生与研究者,这套MATLAB代码用于D2D(设备直连)信道仿真与资源分配研究,重点展示启发式优化方法在解决频谱共享、功率控制与同频干扰问题时的实现思路。压缩包共22个文件,核心是20个.m脚本,另含2个.asv自动保存版本,整体16KB,体量小但模块划分明确,适合精读与改造。脚本从main主函数开始,依次完成路径损耗与瑞利衰落建模、用户及D2D对生成、平均干扰度量、随机分配与启发式分配、最优算法对比,以及误码率与吞吐量统计等环节,基本覆盖信道仿真到性能评估的完整链路。包内各函数以模块化方式组织,便于单独提取复用,也可作为后续扩展对比的基线;对理解D2D信道特性与资源分配策略很有帮助。目前已有1214人学习下载,值得需要物理层算法仿真的MATLAB用户收藏参考。
1. D2D信道仿真:真正耗时间的不是信道,是干扰
D2D信道仿真,很多人以为是套几个路径损耗公式进MATLAB、跑一跑就能出图,实际把代码跑通才发现,信道建模只占三成工作量,剩下七成都耗在干扰管理和资源分配上。这套MATLAB仿真工程做的就是这个完整链路:从3GPP TR 36.843的近距离信道模型起步,把大尺度衰落、瑞利快衰落、蜂窝用户同频干扰、D2D对之间的互干扰,以及贪心资源分配和功率控制全部串成一条可跑的蒙特卡洛仿真线。它解决的是新手最头疼的问题:信道算完了SINR怎么算、资源块怎么分、仿真结果为什么每次都不一样。适合两类人,一类是毕设或课程论文里要跑D2D仿真的同学,另一类是准备在MATLAB里验证新调度算法、但不想从零搭信道层的开发者。读完之后你手里会有一条能跑的仿真链路,也知道每个参数该改哪里、算出来的数字怎么解释。
2. 工程目录与技术选型:一套配置驱动的仿真骨架
2.1 目录结构:从入口到可视化,每一层在管什么
拿到源码第一步不是急着运行,而是先看目录。这套工程的模块划分遵循“信道层、算法层、场景层”三分离的原则,所有仿真参数不散落在各个脚本里,而是集中在配置文件里,改动任何场景设定都不需要翻主循环代码。
D2D_Channel_Sim/ ├── main.m # 总入口, 组装各个模块并执行蒙特卡洛循环 ├── config_default.m # 参数源, 区域大小/用户数量/功率/频段/种子 ├── channel_model/ │ ├── large_scale_ch.m # 大尺度衰落: 路径损耗 + 阴影衰落 │ ├── small_scale_ch.m # 小尺度衰落: 瑞利/莱斯快衰落 │ └── channel_gain.m # 合成函数: 把两种衰落合并成信道增益 ├── resource_alloc/ │ ├── greedy_alloc.m # 贪心RB分配, 核心调度算法 │ └── calc_power.m # 功率控制, 按目标SINR反推发射功率 ├── scenario/ │ └── gen_scenario.m # 生成一个快照: D2D对位置/蜂窝用户位置 ├── utils/ │ ├── dbm2w.m # dBm转瓦特 │ └── w2dbm.m # 瓦特转dBm ├── visualization/ │ └── plot_cdf.m # 绘制SINR/吞吐量的CDF对比曲线 └── results/ └── run_history.mat # 每次运行的完整结果存档main.m只做三件事:读配置、循环跑快照、汇总画图。channel_model是整个仿真的地基,所有链路增益都在这里算。resource_alloc是算法层,greedy_alloc负责给D2D对分配资源块,calc_power负责在满足SINR约束的前提下压低发射功率,减少对蜂窝用户的干扰。utils里的两个单位转换函数看起来不起眼,但后面会有无数地方用到,相信我,用惯了你会谢它的。visualization下的plot_cdf专门用来对比不同参数或不同算法下的性能分布,论文里的那条CDF曲线就是从它这里出来的。
2.2 技术选型:为什么是MATLAB而不是NS-3或Python
做D2D信道仿真可选的平台不少,但不同阶段适合的工具完全不一样。NS-3适合协议级仿真,跑完整的三层协议栈很在行,但为了验证一个资源分配算法去搭一套NS-3环境,学习成本就高了。Python配上SciPy也能做,但信道增益矩阵的批量运算不如MATLAB原生顺手,绘图倒是各有千秋。把三者的差异摊开看,选型逻辑就很清楚了。
| 特性 | MATLAB | NS-3 | Python + SciPy |
|---|---|---|---|
| 信道建模上手速度 | 快 | 慢, 要学模块结构 | 中等 |
| 矩阵批量运算 | 原生 | 弱 | 中等 |
| 蒙特卡洛循环性能 | 中 | 快 | 慢 |
| CDF/热力图绘制 | 好 | 弱 | 好 |
| 典型用途 | 链路级算法验证 | 全协议栈系统仿真 | 原型验证 |
这套工程瞄准的是“算法验证”,所以MATLAB是正解。实际开发过程中我的习惯是先把所有功能写成独立函数,输入输出全部用结构体传递,不搞全局变量。结构体传参的好处是接口清晰,后面想替换算法模块时,只要新函数的输入输出结构体一致,主循环一行都不用改。等到算法稳定了,再考虑把结构体升级成classdef对象,但一开始就用类是给自己找不痛快,调试起来到处都是约束,脚本阶段先把逻辑跑通才是正道。信道层、算法层、场景层各自独立还有一个额外的好处——任何一层出问题,只需要单独测那一层的输入输出,不用把整套仿真从头跑一遍。
3. 核心模块拆解:信道从大尺度衰减到快衰落的完整链路
3.1 大尺度衰落:3GPP TR 36.843近距离模型的MATLAB实现
D2D通信的典型场景是几十米到一两百米距离,这个范围用传统的COST231-Hata宏蜂窝模型并不合适,Hata模型是从1km以上距离的场景拟合出来的,硬套到50m的D2D链路上,算出的路径损耗会偏离实际物理环境。常见做法是参考3GPP TR 36.843里定义的近距离模型,分视距和非视距两套公式。
function pl_db = large_scale_ch(dist_m, fc_hz, link_type) % 大尺度衰落: 路径损耗 + 对数正态阴影衰落 % 输入: % dist_m - 收发距离(米), 标量或行向量 % fc_hz - 载频(Hz), 例如 3.5e9 % link_type - 'los' 表示视距, 'nlos' 表示非视距 % 输出: % pl_db - 路径损耗(dB), 不含阴影衰落 fc_ghz = fc_hz / 1e9; if strcmp(link_type, 'los') % 视距场景: 对应广场、空旷楼道、视线无遮挡 pl_db = 16.7 * log10(dist_m) + 45.3 + 20 * log10(fc_ghz); else % 非视距场景: 对应隔墙、遮挡街道、拐角 pl_db = 43.3 * log10(dist_m) + 11.5 + 20 * log10(fc_ghz); end end两个模型在1GHz载频、20m距离下会有一个明显差异:LOS模型算出的损耗远低于NLOS,这符合直觉——近距离直射时波的衰减本来就不大,一旦进入非视距,多径折反射带来的损耗立刻上去。注意1800字后继续写。20*log10(fc_ghz)这一项是不能省的,它把载频对传播损耗的影响折算进去,载频翻一倍,损耗大约增加6dB。仿真是3.5GHz频段和2.4GHz频段的结果直接差出好几dB,不要拿一个频段的参数去套另一个场景。
阴影衰落通常在外面再叠一层对数正态分布,标准差按8dB处理。我会在gen_scenario里对每一条链路先用距离判定LOS概率,常见的判定方式是距离小于20m直接判LOS,大于100m判NLOS,中间按线性概率过渡。这个判定阈值我习惯放在config_default里做成参数,因为不同场景的遮挡情况差异很大,写死会让后面换场景时很被动。还要注意一点,大尺度衰落的变化速度远低于快衰落,在单个快照内不应该每算一次SINR就重抽一次阴影衰落,否则整个仿真就变成纯随机数生产器了。
3.2 小尺度衰落:瑞利信道生成与功率归一化
小尺度衰落建模是另一个容易被忽视的黑匣子。D2D链路没有基站那类高架天线,收发都在用户手里,周围散射体复杂,典型的衰落是瑞利分布。实现时每一条径的I、Q分量各自服从独立高斯分布,再把所有径叠加,幅度的包络自然呈现瑞利特性。
function h_small = small_scale_ch(num_paths, num_samples) % 生成瑞利快衰落信道系数 % 输入: % num_paths - 多径数目, 典型值8~12 % num_samples - 需要生成的采样点数 % 输出: % h_small - 1 x num_samples 复向量, 平均功率归一化为1 % 每条径的每个分量功率为 1/(2*num_paths) % 叠加后总功率 = num_paths * (1/num_paths) = 1 h_small = (1 / sqrt(2 * num_paths)) * ... (randn(num_paths, num_samples) + 1i * randn(num_paths, num_samples)); h_small = sum(h_small, 1); end这里的功率归一化是关键。很多第一次做仿真的人忘了除以sqrt(2*num_paths),结果信道增益整体偏大,算出来的SINR虚高,整套仿真结论全部偏离。归一化之后|h_small|^2的平均值等于1,它只刻画衰落波动,不改变大尺度衰落确定的平均功率水平。如果需要莱斯信道,就在复高斯变量上叠加一个确定性的直射分量,K因子控制直射分量和散射分量的功率比,改动量非常小。
快衰落的使用方式也有讲究。我在这套仿真里按块衰落模型处理:一个资源块内信道系数保持不变,不同资源块之间重新生成。这对应现实里一个时频资源块的持续时间远小于信道相干时间,但块与块之间的信道已经独立变化。如果你把快衰落放在最内层的SINR循环里每次重新生成,你会得到一堆统计上毫无意义的噪声数据。
3.3 SINR计算:把信道增益翻译成系统级指标
信道增益本身不是最终产出,它要被折算成SINR才有业务意义。这一步的公式很简单,但工程上的坑全在单位和干扰项的构成上。我的实现是先用合成函数把大尺度和小尺度衰落合并成线性域的信道增益,再喂给SINR计算函数。
function gain_lin = channel_gain(dist_m, fc_hz, link_type) % 合成信道增益(线性域): 大尺度路径损耗取反 + 快衰落 % 返回线性功率增益, 可直接用于SINR分母/分子的计算 % 先取路径损耗(dB) pl_db = large_scale_ch(dist_m, fc_hz, link_type); % 生成一个独立快衰落系数(块衰落模型) h_small = small_scale_ch(1, 1); % 线性域: 10^(-pl/10) 是平均功率, 再乘以 |h|^2 得到瞬时功率 gain_lin = 10.^(-pl_db / 10) .* abs(h_small).^2; endfunction sinr_db = compute_sinr(p_tx_w, gain_lin, noise_w, i_w) % 计算单条链路的SINR(dB) % 输入: % p_tx_w - 发射功率(瓦特), 线性域 % gain_lin - 信道增益(线性), 由channel_gain得到 % noise_w - 热噪声功率(瓦特) % i_w - 同频干扰总功率(瓦特) % 输出: % sinr_db - SINR(dB) sinr_db = 10 * log10((p_tx_w * gain_lin) / (noise_w + i_w)); end热噪声功率按N = kTB计算,k是玻尔兹曼常数,T取290开尔文,B是资源块带宽,再叠加接收机噪声系数。初学者常犯的错误是把噪声系数直接当成dB数加在噪声功率上,正确的做法是把噪声系数经10*log10折算成线性倍数再乘进噪声功率。SINR计算函数本身纯粹是个公式翻译,真正的复杂度在干扰功率i_w怎么聚合出来,这就是资源分配模块要解决的事了。注意所有输入都在线性域,不在函数内部再做单位换算,单位转换统一由utils层的函数负责,这样能避免一半以上的低级错误。
4. 资源分配与功率控制:干扰约束下的贪心调度
4.1 RB复用与干扰矩阵预计算
D2D仿真的核心矛盾在于复用:蜂窝用户占用的资源块,D2D对也可以用它来发送数据,代价是互相干扰。一个D2D对收到的干扰来自两部分,一部分是同频蜂窝用户的发射,另一部分是复用了同一个资源块的其他D2D对的发射。建模的第一步是把这两类干扰链路全部预计算成矩阵,这样算法层只需要做矩阵运算,不必反复调用信道函数。
% run_snapshot 中的信道预计算片段 % 输入: sc(场景结构体), params(仿真参数), fc(载频) for i = 1:n_d2d % D2D本链路增益: 发射端到自己的接收端 d_self = norm(sc.d2d(i).rx_pos - sc.d2d(i).tx_pos); G_d2d(i, 1) = channel_gain(d_self, fc, 'los'); % 蜂窝用户j对D2D对i接收端的干扰链路增益 for j = 1:n_cue d_cue = norm(sc.cue(j).pos - sc.d2d(i).rx_pos); G_c2d(i, j) = channel_gain(d_cue, fc, 'nlos'); end end % D2D发射端k对D2D对i接收端的干扰链路增益 for k = 1:n_d2d for i = 1:n_d2d if k ~= i d_inter = norm(sc.d2d(k).tx_pos - sc.d2d(i).rx_pos); G_d2d_inter(i, k) = channel_gain(d_inter, fc, 'nlos'); end end end干扰链路用的是非视距模型,因为D2D发射端到别的D2D接收端通常不存在直射路径,这个细节决定了干扰功率的量级。预计算的好处很明显:greedy_alloc里不需要再调channel_gain,所有干扰关系都已经变成数值矩阵,算法迭代快得多,也方便后续加新的分配策略。这套缓存的思路移植到其他仿真场景同样成立,信道相关计算放在场景生成阶段一次完成,后续所有算法共享同一份信道实现,能保证不同算法对比时基站条件完全一致,统计上更公平。
4.2 贪心资源分配:按基准SINR排序,再逐个填坑
贪心分配算法的逻辑不复杂,但执行顺序非常有讲究。我先计算每个D2D对在只有蜂窝干扰、没有D2D间干扰时的基准SINR,然后按基准SINR从低到高排序——信道条件差的对优先选资源块,之后每分配一个对,都更新后续对的干扰环境。核心代码如下。
function alloc = greedy_alloc(result, rb_num) % 贪心RB分配 % 输入: % result - run_snapshot产出, 含G_d2d/G_c2d等预计算矩阵 % rb_num - 资源块数量 % 输出: % alloc - n_d2d x rb_num 逻辑矩阵, true表示该对占用该RB n_d2d = length(result.G_d2d); % 蜂窝用户干扰: 简化为全频段满功率干扰 interf_cue = sum(result.G_c2d, 2) * result.cue_power_w; % 基准SINR: 只考虑蜂窝干扰, 不算D2D间干扰 sinr_base = (result.power_w * result.G_d2d) ./ ... (result.noise_w + interf_cue); % 排序: 基准SINR低的优先分配, 保证公平性 [~, order] = sort(sinr_base); alloc = false(n_d2d, rb_num); for i = order' % 遍历每个RB, 计算该RB上已占用D2D对带来的额外干扰 for rb = 1:rb_num occupied = find(alloc(:, rb)); extra_i = 0; for u = occupied' extra_i = extra_i + result.power_w * result.G_d2d_inter(i, u); end % 候选SINR = 基准SINR衰减(额外干扰占比) sinr_cand(rb) = sinr_base(i) / ... (1 + extra_i / (result.noise_w + interf_cue(i))); end % 选额外干扰最小的RB [~, best_rb] = max(sinr_cand); alloc(i, best_rb) = true; end end排序选“最差优先”的直觉在于:信道好的对本身SINR高,即使分配待干扰大的RB也能凑合满足业务需求,而信道差的对如果还被强干扰压制,整条链路就完全不可用。这其实是一套非常朴素的公平调度思想,但它给后续算法留了比较基准。选RB时的策略也简单粗暴——遍历所有RB,计算当前已占用该RB的D2D对会给新来者带来多少干扰,挑干扰最小的那个。这里没考虑蜂窝用户在不同RB上的功率差异。如果要细化,可以把每个RB绑定的蜂窝用户单独建模,将interf_cue从向量扩展为矩阵,sinr_base也变成n_d2d x rb_num,算法的骨架不用动。
4.3 开环功率控制:用最小功率满足目标SINR
资源分配定下来之后,第二步就是给每个D2D对核定发射功率。这套工程里我用的是目标SINR反推功率的方式:给定一个业务要求的目标SINR,根据当前信道增益和干扰水平,计算刚好满足需求的最小发射功率。
function pw = calc_power(target_sinr_db, gain_lin, noise_w, interf_w, p_max_w) % 功率控制: 用最小功率满足目标SINR % 输入: % target_sinr_db - 目标SINR(dB) % gain_lin - 本链路信道增益(线性) % noise_w - 热噪声功率(W) % interf_w - 本链路受到的干扰功率(W) % p_max_w - 最大发射功率约束(W) % 输出: % pw - 实际发射功率(W) target_lin = 10^(target_sinr_db / 10); p_req = target_lin * (noise_w + interf_w) / gain_lin; pw = min(p_max_w, p_req); end这个公式的前提是SINR和发射功率线性相关,所以目标SINR转成线性后一乘就可以反推功率。interf_w要从资源分配的结果里聚合出来:当前D2D对占用了哪些RB,在每个RB上受到的蜂窝干扰和D2D间干扰之和是多少,再对RB取平均。如果目标SINR达不到,函数会返回p_max_w,此时这条链路进入“尽力而为”模式,实际SINR低于目标值,业务可能中断,这种链路在统计时要单独打标。
提示:固定功率发射是很多初版仿真的默认选择,但它的代价是高载频下干扰被过度放大。功率控制加进来之后,系统总吞吐量常常不升反降,但单位功耗吞吐量会明显改善,这在实际系统里才是有意义的优化方向。
功率控制还有个迭代版本:先固定发射功率算干扰,再根据SINR调整功率,反复迭代直到收敛。真实的蜂窝系统里确实有类似的控制环,但仿真里做迭代会遇到收敛慢、震荡等问题,做了三次以上的同学应该都见过功率在两个值之间反复横跳的情况,这套工程暂时不做迭代,一句话记住就行:迭代功率控制需要加收敛保护,否则就是无尽的震荡。
5. 避坑指南:D2D仿真中四个高频翻车现场
5.1 随机数种子不固定,结果无法复现
现象是同样的参数跑两次,SINR的CDF曲线对不上,论文里画的图和二次验证时的数据总差一段。原因是每循环一次randn重新生成随机场景和信道,整个仿真成了不可复现的黑匣子。
解决方方法很简单,main.m里第一行固定种子,并且给每个快照派生独立子种子。我的习惯是主种子用2024,第i个快照用rng(main_seed + i)重置一遍,这样不同快照之间统计独立,而整次运行可以完整复现。结果存档时把种子也写进run_history.mat,后面任何一次回放都拿得到现场。
5.2 dBm、dB与线性域混用,单位一塌糊涂
现象是最常见也最隐蔽的:SINR算出来数值离谱地高或低,比如发射功率设成23,但线性计算时直接把23当瓦特用,干扰被放大了几个数量级。原因是dBm和W是两种不同的功率表示,23dBm只等于0.2W左右,不是23瓦特。还有个更隐蔽的坑是dB是比值单位,dBm是绝对功率单位,两者做加减合法,但相乘除没有任何物理意义。
我的工程里所有物理计算统一到线性域,用户输入统一到dBm,靠utils层做转换。转换关系并不复杂:W = 10^((dBm - 30)/10),随手写容易算错,所以工具函数里封装好,禁止在业务代码里直接写10^(x/10)这种裸计算。单位换算这件事属于典型的血泪教训,我在初版仿真里因为把dBm当W用,整套资源分配的结论都失效了,从那以后每个新进项目的同学写的第一行工具函数必然是单位转换。
5.3 隐式广播引发矩阵维度车祸
现象是报错Matrix dimensions must agree,或者更隐蔽的是不报错但速度突然变慢,因为一个本应是向量的操作被广播成了大矩阵。MATLAB从2016b开始默认隐式扩展,原来的维度不匹配报错变成自动广播。我之前排查过一次性能诡异下降,最后发现是while循环里一个距离向量和自身转置相乘,生成了n x n矩阵,内存占用直接爆掉。
解决的办法是在关键函数入口加防御性断言,比如assert(isvector(dist_m) && isscalar(fc_hz)),并且约定所有向量的方向统一为行向量。这样即使调用方传错了,错误会在第一时间暴露,而不是在距离出错几层函数之后才浮现。
5.4 平均SINR与平均容量的错位
现象是平均SINR有18dB,但用香农公式手算的吞吐量远低于仿真值,或者换了调度算法之后平均SINR上升但总吞吐量反而下降。原因是E[log2(1+SINR)]不等于log2(1+E[SINR]),对数函数是凹函数,均值穿越和穿越均值是两回事。用dB平均再转线性的操作在处理容量统计时统计意义完全错乱。
正确的统计口径是先对每个样本在线性域算容量,再对所有样本求平均,绝不能在dB域平均完再换算容量。我在plot_cdf之前的结果汇总模块里统一处理这层换算,rate_slots变量存的永远是线性域的bps,绘图之前才转成需要的单位。
6. 二次开发:把单场景脚本改造成可配置实验平台
单跑一条蒙特卡洛仿真线只是起步,真正有价值的是把它变成能批量做实验的平台。我的做法是先把所有参数收敛进一个配置文件,main.m只做参数读取和结果归档,不包含任何硬编码的常数。
% config_default.m 的核心字段 params = struct(); params.area = [200, 200]; % 仿真区域尺寸(米) params.n_d2d = 20; % D2D对数量 params.n_cue = 10; % 蜂窝用户数量 params.rb_num = 6; % 资源块数量 params.p_d2d_max_dbm = 23; % D2D最大发射功率(dBm) params.p_cue_dbm = 23; % 蜂窝用户发射功率(dBm) params.fc_hz = 3.5e9; % 载频(Hz), 可切换到2.4e9 params.noise_fig_db = 9; % 接收机噪声系数(dB) params.target_sinr_db = 12; % 目标SINR(dB) params.seed = 2024; % 随机种子主循环按“读配置、跑快照、聚合结果”三段式组织,每个快照是一次独立的gen_scenario加一次greedy_alloc,串起来就是整套蒙特卡洛实验。替换算法时不需要改主循环,把新调度函数包装成和greedy_alloc相同签名的接口即可。信道模型层同理,channel_model目录下新增一个同签名的mmwave_ch.m,切换时只需要改channel_gain里的一行函数句柄。
验证方法是每次改动前先跑一组baseline存档,改动后重新运行,用plot_cdf对比改动前后非目标模块的曲线是否完全重合。如果一次改动让信道模块的曲线也飘了,说明改动污染了不该碰的部分,需要回查。这套回归习惯帮我挡掉过至少三次“看似优化实则引入隐性bug”的改动。从那以后我每次动核心逻辑之前都强制先跑一遍baseline,确认无关曲线不漂移才敢继续。希望帮到你。
本文还有配套的精品资源,点击获取