ns-3 入门 4:构建拓扑

根据官方教程 Building Topologies 一章写成。原示例代码12和文中代码均以 GPLv2 发布。

总线拓扑

第二篇示例代码1描述了一个简单的例子,默认共有 5 个节点,$n_0$ 和 $n_1$ 通过点对点链路相连,网段为 10.1.1.0/24;$n_1$ 到 $n_{i+1}$ 共 ${i+1}$ 个节点($i$ 为 nCsma 参数的值,默认为 3)通过共享介质的 CSMA 网络连接(有点类似 Ethernet),使用 10.1.2.0/24 网段。其中 $n_1$ 同时连接了两个网络。之后在 $n_0$ 上设置示例代码 1 一样的 echo client,在 $n_{i+1}$ 上设置对应的 echo server。

代码大多跟之前的第一个示例没什么不同,只是有了更多的节点和设备。然而还是有一些需要注意的地方:

  • 一个节点可以同时属于多个 NodeContainer,如 $n_1$,这样就方便在同一个节点上设置多个设备了。
  • NodeContainer.Create 函数会在当前 container 的基础上增加指定数量的节点,而非用指定数量初始化。
  • 由于不在同一个网段内,两个网段自然是不互通的,于是需要使用 Ipv4StaticRoutingHelper::PopulateRoutingTables() 让所有节点都能充当路由器的功能,并填充路由表。类似于 Linux 的 sysctl -w net.ipv4.ip_forward=1
  • 对于 CsmaChannel 之类可以同时连接多个设备的信道,可以在某一节点上启用混杂(promiscuous)模式(如代码 113 行),这样就可以监听到所有经过该信道的数据包了。

程序一共录制了三个 pcap 文件。可以看到,在 second-0-0.pcapsecond-1-0.pcap 中,只有基于点对点协议的 UDP 包,但在 second-2-0.pcap 中,不光变成了网段不同、基于 Ethernet 的 UDP 包,还出现了 ARP 包,以进行 IP 到 MAC 地址的查找。

为了更高的可自定义度,EnablePcap 不仅可以接受网络设备指针(nd: Ptr<NetDevice>),也可以接受节点 IDnodeid: uint32_t和设备 IDdeviceid: uint32_t)。换而言之,以下两行代码是等效的:

cpp
1
2
csma.EnablePcap("second",csmaDevices.Get(1),true);
csma.EnablePcap("second",csmaNodes.Get(1)->GetId(),0,true);

Get 中输入的参数为节点在该容器中的索引,而非节点 ID。节点 ID 是全局共享、递增的。

模型、属性和现实

在官方教程的对应章节中,主要是想提醒使用者须清楚认识到建模并不一定能涵盖真实情况的所有方面。比如上一节中 CsmaChannel 相对于真实 Ethernet3 来说并没有碰撞检测特性,而这是很容易被使用者忽略的。

另外作者还提到了不同网络标准中的不同属性。比如 Ethernet 的常见最大包大小为 1518 字节,而由于 Ethernet II (DIX)标准的封装方式比 IEEE 802.2 (LLC/SNAP) 的协议开销少 8 个字节4,所以前者允许的最大 MTU 可以高一点。在 ns-3 的 CsmaNetDevice 中,MTU 和封装方式是两个单独的属性,若采用了后者的封装方式而忘了更改默认为 1500 的 MTU,虽然模拟能够正常运行,但可能会偏离实际情况。

这里不是说 MTU=1500 同时采用 LLC/SNAP 一定是错的,现代部分网络甚至允许 MTU=9000 的 Jumbo Frame,最终一切还是要看具体情况。

构建无线拓扑

这一段的示例代码2在第二篇代码中增加了一个无线网络,并连接到 $n_0$,Wi-Fi 使用 802.11a 标准(有点远古),网段为 10.1.3.0/24。随附示例代码中描述拓扑的字符画:

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// Default Network Topology
//
//   Wifi 10.1.3.0
//                 AP
//  *    *    *    *
//  |    |    |    |    10.1.1.0
// n5   n6   n7   n0 -------------- n1   n2   n3   n4
//                   point-to-point  |    |    |    |
//                                   ================
//                                     LAN 10.1.2.0

在上面总线拓扑的基础上,又添加了 YansWifiChannel,相对来说更复杂一点,但从始至终都是围着链路层和物理层工作。

  • $n_0$ 作为 AP,其他节点作为 STA,分到两个 NodeContainer 中。
  • 使用 YansWifiPhyHelper 来设置物理层属性,如频率、速率等。它承担了类似 CsmaHelper 的功能,只不过不能直接包办建立起信道和安装网络设备了。
  • 通过 WifiMacHelper 区分 AP 和 STA,并设置同一个 SSID 来确保连接同一个网络。
  • AP 和 STA 的网络设备也有所不同,所以也包含在两个 NetDeviceContainer 中,但可以使用同一个 WifiHelper(用于安装网络设备)。
  • 通过 MobilityHelper 模拟 STA 节点走来走去,而 AP 节点则固定在原地。
  • 用同一个 Ipv4AddressHelper 在同一次 Setbase 之后多次 Assign 来保证 AP 和 STA 在同一个网段内(相当于使用固定 IP,不考虑复杂的 DHCP)。

在 STA 节点的链路层设置中(WifiMacHelper),可以看到设置了 ActiveProbing 属性,这使得 STA 节点会一直搜寻 AP,导致仿真永远不会结束。因此,还需要在开始前手动设置 10s 结束仿真。

并不需要对 Wi-Fi 网络启用混杂模式,因为 Wi-Fi 网络的数据包都是广播的,所以所有节点都能收到。换而言之,混杂模式是自动开启的。

如果加上 --trace 参数,会记录下来 4 个文件,其中 third-0-1.pcap 对应的是 Wi-Fi 的流量。使用工具读取,可以看到相比 Ethernet,Wi-Fi 的通信更加复杂,还有 beacon frame 之类的控制信息。

记录 STA 移动轨迹

之前在介绍跟踪时,并未明确跟踪接收器的设计办法,使用的也是 helper 自带的连接方式。而在这个示例代码中,为了追踪某个 STA 的移动轨迹并打印到日志中,需要自行设计跟踪接收器(其实就是一个函数),并设置回调。这里的跟踪接收器也只是个函数而已:

cpp
1
2
3
4
5
6
7
void
CourseChange(std::string context, Ptr<const MobilityModel> model)
{
  Vector position = model->GetPosition();
  NS_LOG_UNCOND(context <<
              " x = " << position.x << ", y = " << position.y);
}

其实功能非常简单,就是当调用这个函数的时候,打印出对应的事件名(context),以及 STA 节点的位置。为了让节点位置变化时调用这个函数,需要在 Simulator::Run() 之前设置回调:

cpp
1
2
3
4
5
std::ostringstream oss;
oss << "/NodeList/" << wifiStaNodes.Get(nWifi - 1)->GetId()
    << "/$ns3::MobilityModel/CourseChange";
auto cb = MakeCallback(&CourseChange);
Config::Connect(oss.str(), cb);

Config::Connect() 的第一个参数就是事件名,也就是跟踪源的路径,这里仅指定了一个 STA。第二个参数就是一个回调对象。

MakeCallback 的作用是将函数指针包装成一个 Callback 对象。Callback 类是一个模板类,有一个强制参数(对应返回值,void 也算一个)和至多五个可选参数(对应参数列表)。MakeCallback 会根据函数指针的类型自动推断参数列表。这样,就可以直接使用类似 ret = cb(arg1, arg2, ...) 的方法来调用回调了。如果需要设置对象的成员函数回调,或者希望预先设定某几个参数,可以参考 手册的对应部分

这样,当 STA 位置变化时,就会在日志中打印当前位置,如:

plaintext
1
2
3
4
5
6
7
/NodeList/7/$ns3::MobilityModel/CourseChange x = 10, y = 0
/NodeList/7/$ns3::MobilityModel/CourseChange x = 9.36083, y = -0.769065
/NodeList/7/$ns3::MobilityModel/CourseChange x = 9.62346, y = 0.195831
/NodeList/7/$ns3::MobilityModel/CourseChange x = 9.42533, y = 1.17601
/NodeList/7/$ns3::MobilityModel/CourseChange x = 8.4854, y = 0.834616
/NodeList/7/$ns3::MobilityModel/CourseChange x = 7.79244, y = 1.55559
/NodeList/7/$ns3::MobilityModel/CourseChange x = 7.85546, y = 2.55361

ns-3 中的队列

和很多人理解的不同,数据包并不是排一个队就能直接发出去了,而在实际情况中,有多层队列,排完这个再排下一个,用来处理不同的事情。在 ns-3 中虽然并没有实际操作系统中那么复杂,但也“将 IP 层或流量控制层与设备层分开”,前者,即所谓的流量控制层,处理 QoS 和 AQM;而后者在 NetDevice 中,处理设备层的队列,与连接类型有关(Ethernet,Wi-Fi 等)。如果设备层的队列并没有被填满,那么流量控制层队列基本等效于透明,毕竟这时并没有控制流量的必要。

官方教程中有 [不同队列类型详解]。在分配 IP 地址时,流量控制层默认启用 pfifo_fast 队列(容量为 1000 个包)并可以自行指定,而设备层队列由设备自身决定,不同的网络设备有不同的队列类型。

若要自行指定流量控制层的队列,有两种办法。如果还未安装网络设备,可以借助网络设备的 helper 实现:

cpp
1
2
3
PointToPointHelper p2p;
p2p.SetQueue("ns3::DropTailQueue", "MaxSize", StringValue("50p"));
NetDeviceContainer devices = p2p.Install(nodes);

如果已经安装了网络设备,可以使用 TrafficControlHelper 来设置根队列:

cpp
1
2
3
TrafficControlHelper tch;
tch.SetRootQueueDisc("ns3::CoDelQueueDisc", "MaxSize", StringValue("1000p"));
tch.Install(devices);