OPNET 802.11 MAC源码解析:CSMA/CA状态机与退避仿真
简介:面向无线网络研究与OPNET仿真学习者的802.11-MAC协议源代码包,适合需要理解WLAN信道接入机制、开展协议建模与性能分析的高校学生和开发人员。压缩包共356个文件、约1.31MB,以OPNET模型文件(ov、os)、C语言源码(c)、编译中间文件(obj、lib、dll)及实验配置与日志文件为主,保留了较完整的工程结构,便于在OPNET中加载、阅读和二次调试。已有876人学习,说明其在相关课程与科研中具有一定参考价值。通过梳理代码可具体观察帧结构定义、CSMA/CA载波侦听与退避流程、DCF分布式协调功能的实现方式,并据此复现仿真、对比不同场景下的MAC性能,为无线网络课程实验、协议改进和网络设计提供可操作的代码依据。
1. 802.11 的吞吐量算不准,是因为竞争本身就带随机性
拿 opnet 跑 802.11 的 MAC,多数人第一件事是把仿真出来的吞吐量和手算值对一下,然后发现对不上,而且差得不是一点半点。这份 opnet 802.11 MAC 协议的源代码,价值恰恰在这里:它把“为什么对不上”摊开成了一个个状态和变量。
分布式协调靠的是每个节点各自数退避、各自听信道,碰撞是概率事件,重传次数是随机变量,隐藏终端把“能不能听见”变成拓扑问题。教科书上那套饱和吞吐量公式,前提是无限负载、无限重传、所有节点互相能听见;一旦落到有限负载、多速率和部分可见的信道里,公式就失效。想搞清慢在哪一段——是退避等太久、是 ACK 超时太频繁、还是重传把窗口顶到了上限——只能读 MAC 的源代码,看状态机究竟在哪一步停下来等。
2. CSMA/CA 的状态机:为什么 MAC 逻辑必须写成有限状态机
2.1 一帧从上层到空口:ICI 是唯一的“信封”
上层协议栈递给 MAC 的是一个 packet 对象,但“怎么发”这件事——用哪个数据率、发到哪个信道、优先级多高、目的地址是谁——并不能塞进包里。包是载荷的容器,它会被排队、被分片、被重传、被销毁重造;“怎么发”这组信息只在跨层那一瞬间有效。把两者混在一起,重传时就得把已经改过的字段再改回去,是很典型的踩坑来源。
OPNET 的做法是引入 ICI(Interface Control Information):一个独立于包的属性集合,随包走一段就被销毁。常见的写法是上层创建 ICI、填属性、挂在包上,MAC 取包的同时取 ICI,把有用字段搬进自己的队列结构,然后立刻销毁 ICI。
/* 流中断:上层递交一帧。包只管载荷,怎么发全在 ICI 里 */ pk = op_pk_get (op_intrpt_strm ()); /* 取出包 */ ici = op_pk_ici_get (pk); /* 取出随包过来的接口控制信息 */ op_ici_attr_get (ici, "dest_address", &dest_addr); op_ici_attr_get (ici, "data_rate", &data_rate); op_ici_attr_get (ici, "priority", &priority); /* 入队。注意不是简单 FIFO,后面 5.5 会讲优先级映射为什么常常失效 */ wlan_mac_queue_insert (pk, priority, dest_addr); op_ici_destroy (ici); /* 信息已落到 MAC 自己的数据结构 */op_pk_ici_get拿到的是挂在这个包上的 ICI 句柄,属性名按当前模型的 ICI 格式定义,不同版本里字段名会有出入,但“目的地址、数据率、优先级、信道号”这四类基本跑不掉。关键是op_ici_destroy这一句不能省:ICI 不是包的一部分,不销毁就会一路泄漏到仿真结束,跑长仿真时内存曲线会很难看。
2.2 状态迁移不是 if-else 堆出来的:中断类型和中断码决定一切
离散事件仿真里没有“阻塞等待”这回事。任何“等 50 微秒”“等 ACK 回来”都必须翻译成“请在未来的某个时刻给我投递一个事件”,否则进程就永远醒不过来。这就是 MAC 必须写成状态机的根本原因——不是风格问题,是内核约束。
OPNET 的进程模型把这件事抽象成状态加中断。事件进来时先判op_intrpt_type(),再判op_intrpt_code(),两者合起来唯一确定“发生了什么”:
| 中断类型 | 来源 | 中断码通常是 | 典型处理 |
|---|---|---|---|
OPC_INTRPT_STRM | 上层协议 / 无线接收机 | 流号 | 收帧、入队、更新 NAV |
OPC_INTRPT_SELF | 自己排的定时器 | 自定义码 | 退避时隙、ACK 超时、NAV 归零 |
OPC_INTRPT_BEGSIM | 内核 | 无 | 读属性、注册统计量、初始化 |
OPC_INTRPT_ENDSIM | 内核 | 无 | 冲刷统计量、释放队列 |
/* 状态机主干:所有事件先分流,再落到具体状态里处理 */ switch (op_intrpt_type ()) { case OPC_INTRPT_STRM: /* 同一个流号既可能是上层递交,也可能是接收机上报,靠 code 区分 */ if (op_intrpt_code () == WlanC_FROM_UPPER) { mac_state = wlan_mac_frame_arrival (); } else { mac_state = wlan_mac_frame_reception (); } break; case OPC_INTRPT_SELF: switch (op_intrpt_code ()) { case WlanC_SLOT_INTRPT: mac_state = wlan_mac_backoff_tick (); break; case WlanC_ACK_TIMEOUT: mac_state = wlan_mac_retry (); break; case WlanC_NAV_EXPIRE: mac_state = wlan_mac_channel_idle ();break; } break; }wlan_mac_frame_arrival()这类函数返回的是“下一个状态”,而不是在里面直接跳转。这么做的好处是状态迁移集中在一处,改协议扩展(比如加上 EDCA 的四条接入类别)时只需要动返回值和分支,不用在几十个函数里找goto。血泪经验是:一旦有人在 executive 里直接改mac_state又忘了返回值,状态机就会出现“看起来进了 BACKOFF 其实还在 DEFER”的幽灵状态,仿真结果全错但一条报错都没有。
2.3 状态变量块:谁记住了退避计数器和 NAV
进程的局部变量在 executive 返回时就没了,而 MAC 需要跨中断记住的东西非常多:当前退避还剩几个时隙、竞争窗口现在多大、已经重试了几次、NAV 什么时候归零、手上这一帧是不是分片。这些必须放进状态变量块,由内核在进程整个生命周期内保留。
常见的状态变量大致是这几类:mac_state(当前状态)、backoff_slots(剩余退避时隙)、cw(当前竞争窗口上界)、short_retry_cnt/long_retry_cnt(两套重试计数)、nav_end_time(NAV 到期时刻)、channel_idle_since(信道连续空闲的起点)、current_frame(正在发或正在等的那个包句柄),再加一个按优先级分桶的发送队列。
channel_idle_since这个变量值得单独说。它记录的是“信道最近一次由忙转闲的时刻”,后面算 DIFS 时不是去排一个 50 微秒的定时器,而是拿当前时间减它,判断差值够不够。这一个设计把 IFS 判断从“每个帧排一个事件”变成“一次减法”,事件数直接降一个数量级——仿真跑得慢的人,八成问题都出在类似的地方。
3. 退避不是随机数:DIFS、时隙和计数器的代码级拆解
3.1 为什么退避计数器只在信道空闲时递减
这是 CSMA/CA 和以太网 CSMA/CD 最本质的差别。以太网是撞了再退避,Wi-Fi 是先排队再竞争:节点在 DIFS 之后从[0, CW]里抽一个数,每过一个空闲时隙减一,减到零才发。信道一忙就冻结计数器,等信道重新空闲并再等够 DIFS 才继续往下数。
冻结机制解决的是一类活锁:如果退避只在碰撞后重抽,两个节点很容易反复抽到接近的数,然后反复撞。冻结让退避计数变成一个分布式排队号——先来的计数小,先发;后来的计数大,自动让路,不需要任何中心协调。
/* 自中断:一个时隙过去了。只有信道在这段时间内全程空闲,计数器才减一 */ case WlanC_SLOT_INTRPT: if (channel_busy () || op_sim_time () < nav_end_time) { /* 冻结:计数不变。下面这句是简化写法,代价见 5.3 */ op_intrpt_schedule_self (op_sim_time () + slot_time, WlanC_SLOT_INTRPT); break; } if (backoff_slots > 0) { backoff_slots--; op_stat_write (backoff_stat, (double) backoff_slots); /* 退避深度统计 */ op_intrpt_schedule_self (op_sim_time () + slot_time, WlanC_SLOT_INTRPT); } else { mac_state = WlanC_MAC_STATE_XMT; /* 数到 0,直接发,不再等 DIFS */ } break;slot_time是物理层相关常量:DSSS 是 20 微秒,OFDM 是 9 微秒。channel_busy()的判断要同时看物理载波侦听和 NAV,只看其中一个就是典型的错模型——只看物理层会低估隐藏终端的影响,只看 NAV 会漏掉那些短到没来得及解出 Duration 的帧。op_stat_write那行不是可选项,退避深度的分布是判断模型对不对最直接的证据,没有它就只能靠吞吐量一个数字猜。
3.2 竞争窗口翻倍与重试计数:CW 的边界在哪
发送失败之后,竞争窗口翻倍。这里有个容易写错的地方:CW 的语义是“取值范围的上界”,标准里的序列是 31、63、127、255、511、1023,也就是 2^n - 1,不是 32、64、128。所以在代码里应该写(cw << 1) + 1,而不是cw * 2。
/* 超时未收到 ACK/CTS:窗口翻倍,重试到上限就丢帧 */ if (short_retry_cnt >= short_retry_limit || long_retry_cnt >= long_retry_limit) { op_pk_destroy (current_frame); /* 丢帧,向 上层 上报失败 */ cw = cw_min; /* 关键:重置,下一帧从最小窗口重来 */ short_retry_cnt = 0; long_retry_cnt = 0; mac_state = WlanC_MAC_STATE_IDLE; } else { cw = (cw << 1) + 1; /* 31 -> 63 -> 127 ... 严格连续 */ if (cw > cw_max) cw = cw_max; /* 封顶 1023,不会无限涨 */ backoff_slots = (int) op_dist_outcome (uniform_dist (0, cw)); mac_state = WlanC_MAC_STATE_BACKOFF; }为什么是两套重试计数:短帧(不超过 RTS 阈值)用短重试上限,一般 7 次;超过阈值的长帧必须走 RTS/CTS,用长重试上限,一般 4 次。逻辑是代价不对称——一个 20 字节的 RTS 撞了再试很便宜,一个 1500 字节的数据帧反复重传则会把信道占满,早点放弃、让上层重来更划算。
参数对照表,读源码时对着看比较省事:
| 参数 | DSSS(11b) | OFDM(11a/g) | 在代码里影响什么 |
|---|---|---|---|
| SlotTime | 20 μs | 9 μs | 退避自中断的间隔,直接决定仿真事件数 |
| SIFS | 10 μs | 16 μs | 数据帧和 ACK 之间的间隔 |
| DIFS | 50 μs | 34 μs | 退避启动前的强制空闲 |
| CWmin | 31 | 15 | 初始退避的抽取上界 |
| CWmax | 1023 | 1023 | 翻倍封顶值 |
| ACK Timeout | SIFS + SlotTime | SIFS + SlotTime | 等不到 ACK 就判失败的时刻 |
3.3 时隙边界与时间精度:为什么你的 DIFS 差了一个数量级
OPNET 的属性是有单位的,而单位在图形界面里常常不显示。SIFS 写成16,如果属性单位是微秒就对了;如果单位是秒,那就是 16 秒——仿真里所有节点都像被冻住一样,吞吐量趋近于零,但因为没有报错,很容易被误解成“协议实现有问题”。
我一般会这么理解这件事:凡是带物理单位的属性,在 INIT 状态里读出来之后立刻打一条调试输出,把值和量纲一起确认。op_ima_obj_attr_get读回来的是纯数值,量纲信息在属性定义里,代码看不见。
IFS 的实现方式也值得说。前面提过用op_sim_time() - channel_idle_since >= difs这种比较代替定时器,但有个前提:channel_idle_since必须在信道每一次由忙转闲时被准确更新。如果只在收到帧时更新,那么两个帧之间的空闲期就被漏算了,DIFS 会被反复重新计时,退避永远启动不了,仿真表现为“节点永远在 DEFER 状态,吞吐量为零”——这是排查时最该先看的一个中间量。
4. RTS/CTS 与 NAV:虚拟载波侦听要付出什么代价
4.1 Duration 字段:一个整数如何让所有邻居闭嘴
物理载波侦听只能听见“现在有信号”,听不见“这信号还要持续多久”。NAV 就是给后者补的:每个帧的 MAC 头里有一个 Duration 字段,声明“这轮交互还要占多久”,听到的人不管这帧是不是发给自己的,都把自己的 NAV 推到那个时刻。
/* 收到任何一帧,先解析头部拿 Duration,哪怕目的地址不是我 */ op_pk_nfd_get (pk, "MAC_Header", &hdr); duration = hdr.duration; /* 微秒 */ if (hdr.addr1 != my_addr) { if (op_sim_time () + duration > nav_end_time) { nav_end_time = op_sim_time () + duration; /* NAV 是从忙转闲的边界,必须排一个事件把自己唤醒 */ op_intrpt_schedule_self (nav_end_time, WlanC_NAV_EXPIRE); } }op_pk_nfd_get取的是包格式里的具名字段,MAC_Header是一个复合字段,展开后是帧控制、Duration、地址 1 到 4、序列控制这些。nav_end_time用绝对时刻而不是剩余时长,因为绝对时刻做比较不用每来一个事件就减一次,而且自中断里可以直接比op_sim_time() >= nav_end_time。
这里有个前提容易被忽略:MAC 必须能收到并解析目的地址不是自己的帧。如果接收逻辑里写了“不是给我的就丢”,NAV 永远是零,虚拟载波侦听等于没实现,隐藏终端场景会显得比实际更糟——不是模型夸大,是模型缺了一半。
4.2 握手失败的三种死法:超时、EIFS、以及退避没重置
第一种是 ACK 超时。数据帧发出去,等到SIFS + SlotTime还没收到 ACK,判定失败,进重传流程,CW 翻倍。这条路径最常见,也最好排查——把重传计数打成统计量,突增就说明链路质量或者干扰有问题。
第二种是 EIFS。收到一个 FCS 校验失败的帧时,MAC 解不出 Duration,不知道对方什么时候发完,只能按最保守的方式等:SIFS + DIFS + ACK 最低速率发送时间。很多实现里 EIFS 是硬编码的常量,改物理层参数时忘了改它,就会出现“换个数据率吞吐量掉得莫名其妙”的情况。去源码里搜eifs,看它是在 INIT 里算出来的还是写死的,两分钟能定位。
第三种最隐蔽:超时之后重抽了退避,但忘了把channel_idle_since一起重置。结果是新一轮退避在错误的空闲起点上启动,DIFS 被少算或者多算,表现在统计曲线上是重传数和退避深度的相关性完全不对——正常应该正相关,错了会看到两者互相独立。
4.3 隐藏终端在仿真里是“造”出来的,不是“跑”出来的
隐藏终端的定义是:A 和 C 都能和 B 通信,但 A 和 C 互相听不见。这在真实环境里靠距离和遮挡自然形成,在仿真里必须显式构造——设发射功率、设接收门限、设距离,让载波侦听范围和通信范围不重合。
具体做法是把接收门限调到比最小可解码功率还低(这样能听见别人在发),同时把发射功率调到只够覆盖目标距离(这样 A 和 C 互相低于门限)。这两个参数一个是物理层属性,一个是无线接收机属性,不同版本里名字不一样,但一定都在节点模型的无线模块上。
构造成功后的现象很好认:开启 RTS/CTS 之后吞吐量上升,关掉之后碰撞率飙升。如果开关 RTS 对结果毫无影响,说明隐藏终端根本没造出来——大概率是三个节点互相都能听见,或者干扰范围设成了零。RTS/CTS 的价值只在有隐藏终端时才为正,在全可见的网络里它是纯粹的开销,这一点在源码里体现为“RTS 阈值”那一个判断分支,改一下阈值就能观察到拐点。
5. 避坑与排查:仿真结果对不上,先看这五个地方
5.1 量纲:最常见的“差了一千倍”
现象:吞吐量比理论值低三个数量级,或者仿真时间几乎不推进。
原因:时间类属性以秒为单位,但代码里按微秒填了。SIFS 填 16,实际是 16 秒;DIFS 填 50,实际是 50 秒。整个网络在一帧上耗掉几十秒,后面的所有统计都没意义。
解决:在 INIT 状态里把所有时间常量读出来统一换算,并打印一遍确认。凡是源码里出现* 1.0e-6的地方,都去核对一遍对应的属性单位,别靠猜。
5.2 随机种子:所有节点一起退避到 0
现象:碰撞率异常高,而且碰撞都发生在仿真开始的第一个帧。
原因:每个节点的退避分布用的是同一套随机数。OPNET 的分布对象如果没单独设种子,相同参数会产生相同的抽样序列,于是所有节点在同一时刻抽到同一个退避值,同步碰撞。
解决:给每个节点的分布对象单独设置种子,种子取节点 ID 或者对象 ID 派生出来的值,避免手工写死一串数字。验证方法很直接:把每个节点的第一次退避值打成统计量,如果全都一样,就是这个问题。
5.3 事件风暴:仿真跑一晚上还没结束
现象:仿真进度条几乎不动,事件计数器在涨但仿真时间只走了一点点。
原因:退避期间每个时隙都排一次自中断,而 802.11 的时隙是 9 微秒,一个节点一秒就排十万个事件。如果信道一直忙,这些中断全是无用功——醒了发现还是忙,再睡一个时隙。
解决:信道忙的时候不要排时隙中断,改成只在信道由忙转闲时排一次,从那里开始数。这一改动通常能把事件数降一个量级,仿真时间跟着降。另一条是同类的:op_stat_write如果每收到一个包就写一次向量统计,输出文件会膨胀到几个 G,改成标量统计或者只在关键状态迁移时写。
5.4 拓扑:隐藏终端造不出来
现象:开了 RTS/CTS 吞吐量反而下降,关掉之后碰撞率也没升高。
原因:节点之间互相都能听见,隐藏终端不存在。RTS/CTS 退化成纯开销,四路握手多花两个 SIFS 加两个控制帧的时间,吞吐量当然掉。
解决:先把发射功率降下来,让通信半径缩小到拓扑里最长链路的长度;再把接收门限调低,让载波侦听半径大于通信半径。两个半径的比值决定了隐藏终端的严重程度,一般调到 1.5 到 2 倍之间比较有代表性。
5.5 优先级:EDCA 的映射没生效
现象:配了四个接入类别、配了不同的 AIFS 和 CWmin,但高优先级业务和低优先级业务的时延曲线几乎重合。
原因:上层递交的 ICI 里优先级字段没有被正确读取,或者读到了但入队时全落进了同一个桶。也可能是映射表里把 802.1p 的优先级映射成了同一个用户优先级,源头就错了。
解决:在入队函数里加一条统计,按接入类别计数,跑一小段看四个桶是不是都有值。如果只有一个桶有值,问题在映射;如果四个桶均匀但时延不分化,问题在退避参数没被真正用到抽样的那一步。
排查完这些,最后一步是拿标量输出快速扫一眼分布,别只盯着平均值:
import re rows = [] for line in open("wlan_dcf.scalar", encoding="utf-8"): m = re.match(r'^scalar\s+\S+\s+(\S+)\s+throughput\s+([\d.eE+-]+)', line) if m: rows.append((m.group(1), float(m.group(2)))) rows.sort(key=lambda r: -r[1]) total = sum(tp for _, tp in rows) for node, tp in rows: print(f"{node:24s}{tp:10.3f} kbps {tp / total * 100:5.1f}%") print(f"fairness index: {total ** 2 / (len(rows) * sum(tp ** 2 for _, tp in rows)):.4f}")这段脚本的作用不是算精确吞吐量,而是看分布:所有节点占比接近,说明竞争是公平的,退避在正常工作;某一个节点吃掉八成带宽,要么是它的退避参数不对,要么是位置太好一直抢到信道。最后那个公平性指数在 0 到 1 之间,DCF 的典型值应该在 0.95 以上,低于这个数就值得往下查。
6. 设计取舍:把这套源码当参照物,而不是当标准答案
读到这儿会发现,802.11 的 MAC 在仿真里被压成了三样东西:一个状态机、一组跨中断保留的状态变量、一个事件队列。所有看起来复杂的机制——退避冻结、CW 翻倍、NAV、EIFS、分片——都是这三样的组合。
这套结构最大的取舍是“单进程状态机”而不是分层对象。理论上 MAC 可以拆成信道评估、退避管理、帧交换、分片重组四个模块,各自维护状态;但拆分之后,一次信道由忙转闲要穿过四个模块,每个模块都得知道其他模块的状态,反而更难保证一致性。压成一个进程加一组状态变量,代价是这个进程会变得很长,好处是所有决策点都在同一个 switch 里,调试时打断点就能看到完整上下文。
第二个取舍是时间精度。仿真里所有时间都是浮点秒,而 802.11 的最小时间单位是微秒级,两者之间必然有舍入。把op_sim_time()直接拿去和SIFS + SlotTime做浮点比较,在某些边界上会判错。稳妥的做法是给所有时间比较留一个极小容差,或者把关键时间量统一换算成整数微秒再比较——这不是洁癖,是那种“一千次里错一次、但错了完全查不出来”的问题。
第三个取舍在抽象层次:PLCP 前导、物理层汇聚过程、误码率曲线,这套模型都有建模,但精度取决于 pipeline 里配了什么。如果只关心 MAC 层的竞争行为,把误码率固定成零反而更干净;如果要看速率自适应,就必须让误码率随信噪比变化。这两件事不能同时做,源码里的抽象层次已经替你选好了边界,读的时候先看清边界在哪,再判断结论能推到多远。
验证的方法其实不复杂:拿经典的二维马尔可夫链饱和吞吐量结果做对照,把负载加到饱和,看仿真曲线在 CWmin、CWmax 变化时的趋势是否一致;再关掉隐藏终端、关掉重传上限,看模型是否退化到理论上界。两次都对得上,才说明你读懂了这套源码里的每一行判断在做什么。
从那以后我每次拿到一份 MAC 层的仿真结果,都会强制先跑一遍退避深度和重传次数的分布,确认它们符合预期,再去看吞吐量这个最终数字——因为吞吐量对了但中间量不对,多半是两处错误互相抵消,换一个场景就会翻车。这套源代码里的状态机骨架、ICI 字段定义和退避实现,比任何时序图都直接,值得对着自己手上的模型读一遍。希望帮到你。
本文还有配套的精品资源,点击获取