OPNET 802.11 MAC仿真:定时等待、退避与信道建模
简介:面向无线局域网协议研究与 OPNET 仿真实践者的 802.11-MAC 协议源代码包,适合用于理解 MAC 层信道接入、冲突避免与分布式协调功能的具体实现,并可作为网络建模、性能分析和课程实验的参考。压缩包共 356 个文件,以 ov、os、m、obj、lib、exp、dll、c 等类型为主,涵盖 OPNET 模型、仿真对象库、编译中间件与 C 源码,整体约 1.31MB。已有 876 人学习,说明其在无线协议仿真方向具有一定参考价值。读者可据此梳理 802.11 帧结构、CSMA/CA 与退避机制、DCF 公平共享信道等关键流程,并观察 QoS、安全机制与物理层调制技术的交互方式,从而加深对 OPNET 工程组织和协议代码实现细节的理解。
1. 仿真跑通了,结果却不可信:802.11 MAC 的每一次“等待”
很多人拿到一份 OPNET 802.11 MAC 源代码,第一件事就是编译、跑通、出吞吐曲线,然后盯着结果发愣:曲线要么平得像一条直线,要么随负载上升反而断崖下跌。翻代码也找不出错——帧格式对、状态机对、地址字段也对。真正的问题几乎从不在帧长什么样,而在“什么时候决定不发”。802.11 的性能几乎全部押在那几个等待上:DIFS 等多久、退避计数器什么时候冻结、NAV 什么时候被别人的 Duration 字段推后。这套源代码值得逐行读的原因也在这里——它把“等”这件很玄学的事,拆成了可读的定时器、可打印的状态变量和可复现的事件序列。搞懂这几个等待怎么被建模,你才能判断仿真结果到底是协议的真实行为,还是模型参数配错了。
2. 事件驱动内核:为什么 MAC 状态机不能写成 while 循环
2.1 仿真时间里没有“流逝”,只有“下一个事件”
离散事件仿真的世界观和真实世界完全不同。真实世界里时间连续流淌,仿真内核里时间是一串离散的时间戳,每个时间戳上发生一个事件,事件之间整个世界完全静止——没有任何变量会变,也没有任何代码在跑。内核维护一张未来事件表(FEL),每次取出时间戳最小的那条,把仿真时钟拨到那个时刻,调用对应进程处理,然后取下一条。
这个机制带来一个直接推论:op_sim_time()返回的不是“现在几点”,而是“当前正在处理的那个事件的时间戳”。它在一个事件的处理过程中恒定不变。新手最容易翻车的地方就是把这个函数当成墙钟用,写出这样的代码:
/* 错误示范:用墙钟式思维等信道空闲 */ while (wlan_mac_channel_busy()) { /* 什么都不做,等它空下来 */ }这段代码在真实网卡固件里可能勉强能跑(虽然也很糟糕),在仿真里会直接把整个网络冻死。因为信道什么时候空下来,取决于别的节点在未来的某个时刻发出的那个事件;而未来事件只有在当前事件返回、内核去取 FEL 下一条时才会被处理。你在循环里空转,仿真时间不推进,那个“让信道变空”的事件永远排不到你前面。整个仿真就卡在这个 while 里,一直转到你手动终止。
正确的写法只有一个方向:把“等”注册成未来事件,然后立刻返回。
/* 正确做法:告诉内核"20 微秒后叫醒我",然后退出当前事件处理 */ Evhandle evh = op_intrpt_schedule_self(op_sim_time() + WLAN_SLOT_TIME, WLAN_INTRPT_BACKOFF_SLOT);这里的关键认知是:20 微秒的等待在仿真里不消耗任何东西。它不是 sleep,不是忙等,只是在 FEL 里插了一条记录。二十万个节点同时各排一个 20 微秒后的定时器,内核照样能在常数级时间内找到下一个要处理的事件。正因为如此,一份 802.11 MAC 源代码才敢把时隙建模到 20 微秒、把 SIFS 建模到 10 微秒,同时仿真一小时的真实业务——如果按固定步长推进,这个时间尺度根本算不动。
2.2 自中断:SIFS、DIFS、退避时隙在代码里的统一形态
OPNET 的进程模型里,一个进程收到的输入分几类,靠op_intrpt_type()区分。最常打交道的两种:OPC_INTRPT_STRM是流中断,表示某个模块通过包流给你送来了一个包(高层的发送请求,或者收信机解出来的一帧);OPC_INTRPT_SELF是自中断,就是你自己排的那个定时器到点了。
协议里的每一种“等”,最终都翻译成一条自中断,靠op_intrpt_code()里的自定义编码区分是哪一种:
switch (op_intrpt_type()) { case OPC_INTRPT_STRM: /* 两类来源要用 op_intrpt_strm() 分开:高层下发的帧 vs 空口收到的帧 */ if (op_intrpt_strm() == WLAN_STRM_FROM_HL) { wlan_mac_frame_arrival_from_hl(); } else { wlan_mac_frame_arrival_from_radio(); } break; case OPC_INTRPT_SELF: switch (op_intrpt_code()) { case WLAN_INTRPT_DIFS_EXPIRE: /* DIFS 到点,可以启动/恢复退避 */ wlan_mac_difs_expired(); break; case WLAN_INTRPT_BACKOFF_SLOT: /* 一个时隙边界到点,退避计数器减一 */ wlan_mac_backoff_slot_tick(); break; case WLAN_INTRPT_ACK_TIMEOUT: /* 等 ACK 超时,判定这一帧失败 */ wlan_mac_ack_timeout(); break; case WLAN_INTRPT_NAV_EXPIRE: /* 虚拟载波侦听结束 */ wlan_mac_nav_expired(); break; } break; }这段分派是整个 MAC 模型的主干。看懂它的价值在于:协议文本里那些“等待 SIFS 之后发送”“退避计数器在每个空闲时隙减一”,在实现层面统统退化为“排一个自中断,到点了再判断一次条件”。没有任何地方真的在“等”,每一次唤醒都是一次重新决策的机会——这也是为什么 802.11 的 MAC 能优雅地处理“退避到一半信道被别人占了”这种情形,因为每个时隙边界本来就是一次检查点。
op_intrpt_schedule_self会返回一个事件句柄。这个句柄不要随手丢掉,因为定时器需要能被取消。典型场景:你在等 ACK 超时(排了一个WLAN_INTRPT_ACK_TIMEOUT),结果 ACK 提前到了,这时必须把那个超时事件从 FEL 里摘掉,否则它到点还会触发一次“超时”逻辑,把已经成功的一帧判成失败。取消的写法是把句柄存进状态变量,收到 ACK 时调op_intrpt_remove(evh)。
2.3 进程模型的状态机与状态变量放在哪儿
802.11 MAC 进程模型的宏观状态不多,但在实际代码里会被拆得比较细,比较典型的划分是:初始化之后进IDLE,有帧要发且信道空闲就直接进TRANSMIT,信道忙则进DEFER等一个 DIFS,DIFS 到点后进BACKOFF逐时隙递减,退避归零进TRANSMIT,发完进WAIT_FOR_ACK,收到 ACK 回IDLE,超时则回BACKOFF并翻倍窗口。
状态之间的迁移条件全都是op_sim_time()与某个时间戳的比较,而不是任何循环条件。这套状态机真正承载信息的不是状态本身,而是挂在进程上的那组状态变量:
typedef struct { int backoff_slots; /* 剩余退避时隙数,0 表示可以立刻发 */ int cw; /* 当前竞争窗口上界,初值 aCWmin */ int retry_count; /* 当前这一帧已经重传了几次 */ double nav_end_time; /* 虚拟载波侦听结束时刻,0 表示无 NAV */ double ack_deadline; /* ACK 期望到达的最晚时刻 */ Evhandle ack_timeout_evh; /* ACK 超时事件句柄,用于提前取消 */ WlanT_Mac_State state; /* IDLE / DEFER / BACKOFF / TRANSMIT / WAIT_ACK */ } WlanT_Mac_State_Info;这组变量必须挂在进程实例上(通过进程的 state variable 块或op_pro_argmem_access()拿到的内存),绝不能声明成 C 的static全局变量。原因很直接:同一个进程模型会被实例化很多次,每个无线站点一份。static全局在所有实例之间共享,于是节点 A 的退避计数器会被节点 B 覆盖——现象是仿真跑起来所有节点像被同一个幽灵操纵,同时发、同时退避,冲突率高得离谱。这个坑后面第 5 章还会展开,它是这类模型里最难查的一类错误,因为代码本身完全合法,编译器不会给你任何提示。
参数的来源同样值得一说。WLAN_SLOT_TIME、WLAN_SIFS、WLAN_DIFS这些量在不同 PHY 下取值不同(DSSS 与 FHSS 差着一个数量级),合格的实现会把它们做成进程模型的属性或者由 PHY 类型推导出来,而不是在 C 文件里写死一串魔数。这不只是风格问题:当你把仿真从 1 Mbps 的 DSSS 换到高速率的 PHY 配置时,帧间间隔不变但传输时间缩短,如果这些常量写死,退避窗口和实际传输时间的比例就错了,吞吐曲线会以一种很难解释的方式偏离。
3. 帧交换的时序账本:CSMA/CA 在代码里被拆成哪几个定时器
3.1 用“等多久”编码优先级:SIFS、PIFS、DIFS 与 EIFS
802.11 最漂亮的设计之一,是把优先级编码进了时间间隔本身,而不是额外的控制帧或令牌。想让 ACK 拥有最高优先级?让它只等 SIFS。想让数据帧排在 ACK 后面?让它等 DIFS。中间再插一个 PIFS 给点协调功能用。任何人想插队,物理上做不到,因为它的等待时间就是比人家长。
以 DSSS 为例,这几个量的典型取值是:
| 帧间间隔 | 典型取值 | 谁在用 | 作用 |
|---|---|---|---|
| SIFS | 10 us | ACK、CTS、分片后续帧 | 最高优先级,一次交换的原子性靠它保证 |
| PIFS | 30 us | 点协调功能的轮询 | 介于两者之间,可抢占竞争期 |
| DIFS | 50 us | 竞争期的数据帧、管理帧 | 普通优先级入口 |
| EIFS | SIFS + ACK 时长 + DIFS | 收到无法正确解码的帧之后 | 惩罚性间隔,防止误帧引发连锁冲突 |
| 时隙 | 20 us | 退避计数器的计量单位 | 决定竞争粒度 |
这套数值本身体现了两条约束。第一,SIFS 必须小于“对方从收到帧到准备好回复”的最小物理时间,否则 ACK 还没发出来,别的站点的 DIFS 就已经到期了,一次交换就断掉。第二,时隙必须大于“一个站在信道变空的那一刻能否可靠判断出空闲”的不确定窗口,也就是传播延迟加上收发机切换时间。这两个约束同时决定了 PHY 和 MAC 的边界,也解释了为什么换 PHY 时这些值必须跟着变——它们不是随便定的数,是被物理条件卡出来的。
代码里的体现就是前面说的那几条自中断。进入DEFER状态时排一个 DIFS 定时器;DIFS 到点时再检查一次信道是否真的还空闲(因为可能刚好有人在这段时间开始发),空闲才进BACKOFF。这个“到点了再检查一次”的动作,是把协议文本里“侦听信道空闲达 DIFS”翻译成可执行逻辑的关键——没有这一步,你只是知道 DIFS 过去了,并不知道这段时间里信道是不是一直空闲。
3.2 退避计数器:为什么信道变忙时必须冻结而不是清零
这是 802.11 与以太网 CSMA/CD 最本质的差别,也是很多仿真结果失真的源头。
以太网的规则简单粗暴:碰撞了就重新掷骰子,从头退避。802.11 的规则是:退避计数器只在一个个空闲时隙里递减,一旦检测到信道忙,计数器冻结,保留当前值,等信道再次空闲 DIFS 之后从冻结的值继续往下减。
case WLAN_INTRPT_BACKOFF_SLOT: if (wlan_mac_channel_idle(p_state)) { if (p_state->backoff_slots > 0) { p_state->backoff_slots--; /* 一个空闲时隙,扣一格 */ op_intrpt_schedule_self(op_sim_time() + WLAN_SLOT_TIME, WLAN_INTRPT_BACKOFF_SLOT); } else { wlan_mac_transmit_frame(p_state); /* 归零,立刻抢占信道 */ } } else { /* 信道被别人占了:不清零,保持当前值,回到 DEFER 等下一个 DIFS */ wlan_mac_enter_defer(p_state); } break;冻结而非清零的设计动机,可以从两个角度理解。从公平性看,冻结保留了“我已经等了多久”的信用。如果每次信道变忙就清零,那么在高负载下,一个已经退避到只剩 1 格的站点会被不断打断、不断重置,它几乎永远抢不到信道,尾部延迟会爆炸。从稳定性看,冻结让退避过程等价于一个“剩余工作量”而不是“重新抽签”,多个站点的剩余量天然分散,冲突概率随竞争者数量增长的速度被压下来了。
这里还有个实现细节常被忽略:递减的触发点必须绑在时隙边界上,而不是“信道空闲了就马上减”。因为多个站点的时隙边界必须对齐到同一个时间网格,否则它们会在错开的时间点各自递减,冻结与恢复的语义就乱了。这也是为什么每条自中断都精确地排op_sim_time() + WLAN_SLOT_TIME,而不是排一个“大概这么久之后”。
3.3 CW 翻倍与重传上限:失败是被这样记账的
竞争窗口的管理逻辑集中在一个失败处理函数里,逻辑本身很短,但每一步都容易写错:
/* 一次传输被判失败:等 ACK 超时,或者 RTS 之后等 CTS 超时 */ static void wlan_mac_tx_failed(WlanT_Mac_State_Info *p_state) { p_state->retry_count++; if (p_state->retry_count > WLAN_SHORT_RETRY_LIMIT) { /* 短帧重传超过上限,整帧丢弃并上报高层 */ wlan_mac_discard_frame(p_state); p_state->retry_count = 0; p_state->cw = WLAN_CW_MIN; /* 归零重来 */ } else { p_state->cw = 2 * p_state->cw + 1; /* CW 翻倍:31 -> 63 -> 127 -> ... */ if (p_state->cw > WLAN_CW_MAX) { p_state->cw = WLAN_CW_MAX; /* 封顶 1023 */ } } /* 在新的窗口里重新抽一个退避值;注意均匀分布函数返回 [0, n),故传 cw+1 */ p_state->backoff_slots = op_dist_uniform(p_state->cw + 1); wlan_mac_enter_defer(p_state); }几个必须对齐的细节。CW 的翻倍公式是2*cw + 1而不是2*cw,因为 802.11 规定的最小窗口是 31(即 2^5 - 1),翻倍后是 63、127,都是 2^n - 1 的形式,用2*cw + 1才能保持这个形式。抽随机数的上界是包含 CW 的(0 到 CW 共 CW+1 个值),所以传给均匀分布函数的是cw+1,这一点如果搞错,退避窗口会整体偏小一格,高负载下的冲突率会系统性地偏高——查起来极其费劲,因为偏差不大,曲线看起来“有点像对的”。
重传上限分两档是协议本身的规定:短帧(小于 RTS 门限的帧)用较短的重试上限,长帧用较长的上限。原因是长帧传输时间长、失败代价大,值得多试几次;短帧如果连着失败,多半是信道真的挤,继续试只是浪费。搞混这两档的表现是丢包率在重负载下异常高,但冲突次数统计又对不上。
3.4 RTS/CTS 与 NAV:把隐藏终端问题变成“听得见的等待”
RTS/CTS 的价值不在于握手本身,而在于它把“信道将被占用多久”这个信息广播出去了——用的是一个 2 字节的 Duration 字段,接收方把它翻译成一个本地时间戳 NAV。
/* 收到任何一帧(哪怕不是发给自己的),只要 Duration 字段有效就更新 NAV */ double duration = 0.0; op_pk_nfd_get(pkptr, "Duration", &duration); if (duration > 0.0) { double nav_candidate = op_sim_time() + duration; if (nav_candidate > p_state->nav_end_time) { p_state->nav_end_time = nav_candidate; /* NAV 只能被推后,不能被提前 */ } }更新规则里有两个容易写错的点。第一,NAV 取最大值而不是覆盖——如果一个站先听到一个 RTS 把 NAV 推到 t+500us,随后又听到另一个更短的 RTS 说只到 t+300us,它不能把 NAV 缩回去,因为第一个传输还没结束。第二,判断信道是否忙时,op_sim_time() < nav_end_time与物理层载波侦听是“或”的关系,NAV 是虚拟侦听,物理层是真实侦听,两者任一说忙就是忙。
NAV 真正解决的问题是隐藏终端。A 和 C 互相听不见,但都能听见 B。A 给 B 发数据时,C 察觉不到任何物理信号,会以为信道空闲直接开火,在 B 那里把 A 的帧撞掉。加上 RTS/CTS 之后,B 回给 A 的 CTS 被 C 听见了,C 从 CTS 的 Duration 里读到“接下来这段时间别说话”,于是乖乖闭嘴。整个过程不需要 C 能听见 A,只需要 C 能听见 B 就够了。
代价是四个帧的开销。所以实际实现里有一个 RTS 门限:只有帧长超过这个门限才走四路握手,短帧直接两路(数据 + ACK),因为短帧本身占用信道时间短,被撞的概率和损失都小,不值得为它付四次握手的成本。
| 对比项 | 两路握手(Data + ACK) | 四路握手(RTS + CTS + Data + ACK) |
|---|---|---|
| 额外开销 | 1 个 SIFS + 1 个 ACK | 3 个 SIFS + RTS + CTS + ACK |
| 抗隐藏终端 | 无 | 有(靠 CTS 的 NAV 广播) |
| 碰撞代价 | 整帧长 | 通常只是 RTS 的长度 |
| 适用 | 短帧、低负载、无隐藏终端 | 长帧、高负载、拓扑稀疏 |
仿真里调整这个门限,吞吐曲线会有明显的形状变化:门限设得过大,隐藏终端引发的碰撞在图上表现为大帧的丢包率飙升;设得过小,短帧也要四次握手,有效吞吐被开销吃掉一大块。能找到那个“曲线拐点最靠右”的门限值,基本说明这份 MAC 模型的时序逻辑是自洽的。
4. 信道建模:碰撞在仿真里不是“同时发”,而是一次 SINR 计算
4.1 pipeline stage 链:一个包从发信机到收信机要过几道关
无线仿真和有线仿真最大的区别在于接收判定不能靠“有没有第二个包同时到达”这种布尔判断。真实的无线收信机并不知道“有没有别人在发”,它只知道天线上的合成波形长什么样,然后试图从里面解出比特。所以 OPNET 的无线收信机用一条 pipeline stage 链来建模这个过程,每一级只负责一件事:
| stage | 职责 | 典型参数 |
|---|---|---|
| rx_group | 找出本时刻所有对本收信机有能量辐射的传输,构成干扰集合 | 频率、带宽是否重叠 |
| propdel | 按传播模型算功率衰减与到达时刻 | 距离、路径损耗指数、阴影衰落 |
| power | 与收信机门限比较,判断是否“听得见” | 收信机灵敏度、最大接收范围 |
| rx_compute | 算 SINR,映射成 BER,再算成误包率 | 数据率、调制方式、编码 |
| ecc | 前向纠错修正误包率 | 纠错编码类型 |
一个关键认知:干扰集合是在 rx_group 里算出来的,不是靠“包到达时间相同”猜的。两个包可能一前一后错开 5 微秒到达,在布尔模型下算“没碰撞”,但在真实物理层里,先到的那个正在被解调的时段内,后到的那个就是实打实的干扰能量。这条链的存在就是为了让仿真能表现出“部分重叠的碰撞”——长帧被短帧撞掉一段尾巴,如果纠错编码足够强,这帧可能还能被正确接收。这种细节在布尔碰撞模型里完全看不到。
4.2 干扰累加与捕获效应:碰撞了但没丢,不一定是 bug
rx_compute的核心就几行,但它决定了很多反直觉的仿真现象:
/* 把功率换算成误包率:查表,不实时做数值积分 */ double sinr = pkt_signal_power / (interference_power + noise_power); double ber = wlan_ber_lookup(rx_data_rate, modulation, sinr, processing_gain); double per = 1.0 - pow(1.0 - ber, 8.0 * frame_bits); op_pk_nfd_set(pkptr, "PER", per);ber用查表而不是公式,是性能上的必然选择:一次仿真里这个函数会被调用几百万次,实时算 BER 曲线没人受得了,所以实现上一般预先把不同速率、不同调制方式下 SINR 到 BER 的曲线做成表,运行时插值。
per的换算假设比特错误独立,这在仿真里是常规近似。注意这个公式里frame_bits是整帧的比特数——帧越长,同样的 BER 下误包率越高,这是长帧在差信道下吃亏的数学根源。
有了 SINR,捕获效应就自然浮现了:即使两个帧在时间上重叠,只要信号比干扰强足够多,SINR 依然可以高到让 BER 可接受,这帧就被正确解出来了。所以在仿真日志里看到“发生了碰撞但没有丢包”,先别急着怀疑代码——那可能是捕获效应在起作用,尤其是 RTS 这种很短的帧,它穿透正在进行的长时间数据传输的能力,本来就是这套机制设计时要利用的。
反过来说,如果你的模型里干扰功率根本没有被累加(interference_power恒为 0),那么不管多少节点同时发,收信机都能收到,吞吐曲线会一路贴着理论峰值上升,直到高层排队才开始拐弯。这是无线仿真里最容易出、也最难自己发现的一类错——因为曲线看起来“很漂亮”。
4.3 速率、调制与误包率:为什么调一个参数能改变整条曲线的形状
802.11 的物理层支持多档速率。速率越高,同样的帧占用的信道时间越短,这看起来是纯赚,但实际上有个反向作用:高速率对应的调制方式对 SINR 的要求更高,在同样的干扰水平下 BER 更大,误包率更高,重传更多;而每一次重传又是一次完整的 DIFS + 退避 + 传输,占用信道的时间可能比一开始就用低速率发一遍还长。
/* 传输时间不只等于帧长/速率,前导码和 PLCP 头是固定的,低速时占比很大 */ double tx_time = WLAN_PREAMBLE_TIME + WLAN_PLCP_HEADER_TIME + (8.0 * frame_bytes) / (double)data_rate_bps;这个式子在仿真里非常重要,因为它是“占用信道多久”的唯一来源,而占用时间又反过来决定了别人要等多久。低速率下前导码那几十微秒的固定开销占比很高,于是同样一个短帧,用 1 Mbps 发和用 11 Mbps 发,占用信道的时间可能差三倍以上。这会导致一个很有意思的现象:在一个干扰很重的场景里,把所有节点的速率都调高,吞吐不一定上升,反而可能因为误包率和重传上升而下降。这个结论没法靠拍脑袋得出,只能靠在模型里同时把速率、BER 曲线和传输时间三者的耦合关系建对,才能跑出来。
5. 排查清单:五类跑偏现象背后各自坏在哪
5.1 吞吐曲线形状不对时,先怀疑退避,再怀疑信道
现象一:负载升高后吞吐量不降反升到接近理论峰值,且冲突统计几乎为零。
原因通常有两个层次。浅层是接收判定里没算干扰功率,interference_power恒为零,于是任何时刻任何帧都能被正确解出,信道相当于一条永远不会堵的管道。深层是rx_group这个 stage 根本没被调用——有些简化模型为了跑得快会把它裁掉,这在只测单跳单流的场景下不会暴露,一旦铺开多节点就全错了。
解决方式是从最小验证做起:搭两个节点的场景,让两者在 MAC 层用固定的时间偏移同时发送,然后打印rx_compute里的 SINR 和 PER。正确的模型应该看到 SINR 因为相互干扰而明显下降、PER 显著上升。如果 SINR 从头到尾等于单节点时的值,问题就锁定在干扰没有累加这一步。
现象二:吞吐曲线在节点数增加到某个值之后突然垂直掉下去,而不是平滑饱和。
这种“断崖”几乎总是退避相关的。最常见的是退避计数器在信道变忙时被清零而不是冻结——代码里一句p_state->backoff_slots = new_value写在错误的分支里就够了。清零的后果是所有被打断的节点都回到同一起跑线重新抽签,抽出来的值分布相同,于是下一轮它们的剩余时隙高度相关,一拥而上的概率大幅上升,冲突率呈现雪崩式增长。
排查办法是在WLAN_INTRPT_BACKOFF_SLOT分支里加打印,记录每个时刻各节点的backoff_slots。正常运行时,这个值应该呈现出“整体缓慢递减,个别人被冻结”的模式;如果看到大面积的“跳回大值”,那就是清零了。
现象三:长帧丢包率异常高,短帧几乎不丢,且关掉 RTS/CTS 也没改善。
先看 RTS 门限和重传上限是不是配错了对。如果长帧使用了短帧的重试上限,它在挤的时候很快就被丢掉;如果 RTS 门限设得比最大帧还大,那么长帧永远走两路握手,隐藏终端全部暴露出来,被撞一次就是整帧的代价。这两项在配置里离得很近,改一个忘了改另一个是很常见的翻车点。另外要确认 CTS 的 Duration 字段算的是不是“剩余整个交换”的时间——如果只算了 CTS 自己的长度,NAV 保护形同虚设,隐藏终端依然会撞上来。
5.2 跑得慢和不复现,往往和模型逻辑无关
现象四:仿真在 20 个节点时还能跑,加到 50 个节点后一晚上没跑完。
第一嫌疑是收信机 pipeline 里的包拷贝。每来一个包就整包深拷贝一份用于统计,或者每个 stage 里都op_pk_copy一遍保留原始包,开销会随节点数和负载呈乘性增长。正确的做法是只在真正需要留存证据时才拷贝,统计只取需要的那几个字段。第二嫌疑是统计量收集:把每个包的时延都写进一个统计向量里,跑一小时仿真能写出上千万条记录,光 I/O 就压垮了。改成收集均值、分位数,或者用带桶的直方图,速度差别是数量级的。
还有一个隐蔽的开销来自自中断的密度。如果退避时隙排得比物理上需要的更细(比如本该 20 微秒排一次,代码里排成 2 微秒),事件数直接翻十倍,而宏观经济指标并不会因此更准。检查方法是打印仿真结束时的事件总数,与“节点数 × 平均帧数 × 每帧事件数”估算的值对比,差一个数量级就说明排得太密了。
现象五:同一个随机种子跑两次,结果不一样。
理论上一份合格的离散事件模型在种子固定时必须严格可复现。不可复现通常来自三个地方。一是同时刻多个流中断的处理顺序不稳定——同一时间戳有两条流中断,内核按某种次序处理,如果你的代码里有“先处理谁”的隐含依赖,结果就会飘。二是用了系统时间或者未初始化的内存做随机源,比如某个状态变量忘了在初始化状态里赋值,第一次读到的就是垃圾值。三是有并行或后台线程参与,某些版本允许把统计收集放到别的线程里做,这在极少数实现里会引入非确定性。
排查从最简单的一步开始:把仿真跑两次,逐事件对比事件日志的前 N 条。分歧出现的第一个事件,就是问题源头。这一步能省掉大量盲目翻代码的时间。
5.3 代码改了不生效,先确认你改的是不是正在跑的那份
现象六:明明改了进程模型里的时序逻辑,重新仿真结果一模一样。
三个层次的原因,按发生概率排序。第一,只改了 C 源文件但没有重新生成仿真可执行文件——模型里的进程代码是编译进仿真内核的,改完源文件不重编,跑的还是旧版本,这个坑几乎人人都踩过。第二,改了模型但仿真配置(scenario)里引用的是另一份模型副本,工程里同时存在两个同名进程模型时特别容易发生。第三,把状态变量写成了 C 的static全局,多个节点实例共享同一份内存,于是节点的行为互相污染,表现出来就是“改了参数但整体统计没变化”——因为改动被实例间的覆盖抹平了。
确认办法很土但有效:在初始化状态里打印一行带版本标记的日志。每次改完代码重跑,第一件事就是看这行日志有没有变。同时把状态变量定义在进程的 state variable 块里,而不是文件作用域的static,这条规则值得当成硬性纪律执行。
6. 设计取舍:802.11 把复杂度押在“等待”上,值不值
把整份模型读完,会发现一个反复出现的模式:802.11 几乎所有的协调机制,最终都归结为“谁等得久一点”。SIFS 比 DIFS 短,所以 ACK 能压过数据帧;退避计数器在信道忙时冻结,所以被打断的人不用从头再来;NAV 用一个 2 字节字段把未来占用时间广播出去,所以听不见发送方的人也能安静下来。整套协议的复杂度不在帧格式,不在状态数量,而在时间轴上的精细分层。
这个取舍有明确的代价。用时间编码优先级,意味着优先级的分辨率受制于物理层能分辨的最小时间,你不能造出比 SIFS 更细的优先级层级;用随机退避做分布式协调,意味着延迟天生带方差,无法给出确定性上界,这对实时业务是很不友好的;用 NAV 做虚拟侦听,意味着保护范围取决于控制帧能不能被听见,一旦控制帧本身撞了,保护就失效。
| 机制 | 协调方式 | 延迟特性 | 主要弱点 |
|---|---|---|---|
| CSMA/CD(有线) | 边发边听,碰撞后立即退避 | 低负载下极低 | 无线无法边发边听 |
| CSMA/CA(802.11 DCF) | 先听后发,时间间隔分层 + 随机退避 | 随机,随负载上升快 | 隐藏终端、无时延保证 |
| TDMA | 中心分配时隙 | 确定,但需要同步 | 低负载时浪费,灵活性差 |
| 轮询 | 中心点名 | 可预测,有优先级 | 依赖中心节点存活 |
从仿真工程师的角度看,这套设计的价值恰恰在于它的“不完美”是可以被量化的。你可以把节点数从 5 加到 50,看吞吐曲线在哪里饱和;可以把 RTS 门限从 0 扫到 2000 字节,看隐藏终端的影响被压到什么程度;可以把重传上限从 7 降到 2,看丢包率怎么随负载变。这些曲线不是用来证明协议好或坏的,是用来验证你的模型有没有把该建的机制建对。如果扫描出来的曲线形状和协议分析里的预期对不上,八成不是协议错了,是某个定时器排错了位置。
我自己的血泪经验是:每次动这类模型之前,先跑一个只有两个节点、固定发包间隔、关掉所有随机性的最小场景,把每一次 SIFS、DIFS、ACK 的触发时刻都打印出来,对着时序图一格一格核。核完这一遍再去跑大规模场景,能省掉后面几天的“结果为什么不复现”。从那以后我每次改 MAC 的定时逻辑,都强制走一遍这个最小场景的时序核对——它比任何统计量都诚实。希望帮到你。
本文还有配套的精品资源,点击获取