ns-3 入门 5:追踪

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

大家都知道仿真的目的是得到有价值的输出以进行分析,但对特定的分析目的来说,不同的输出方式可能分析效率不同。比如使用日志输出,其粒度仅为源代码文件和日志等级,对于更细的控制则略显不足,且日志输出的稳定性不作保证,可能随着更新而更改;而输出 pcap 可以记录每个数据包的细节,使用 Wireshark 筛选也更加直观简易,但不能体现仿真过程中的逻辑控制,如之前提到的 Wi-Fi STA 节点的移动轨迹。如果遇到并非自己代码中想要输出的内容,情况就更复杂了。比如开发者在教程中举了一个例子:为 TCP socket 添加一点输出,而这需要更改 ns-3 本身(在 tcp-socket-base.cc 中)。虽然加输出确实很简单,但 1) 更新 ns-3 版本时很麻烦,2) 仍需进行进一步日志筛选,3) 要重新编译 ns-3。

所以,使用 ns-3 的追踪工具是更好的选择,因为它可以只输出感兴趣的数据源,免去筛选的麻烦,并且直接从 ns-3 内部获取数据(而不用改 ns-3 的代码)。

基本上,追踪接收器就是一个回调,是一个函数指针。将追踪源和接收器相连的操作,就是把接收器对应的回调传入对应追踪源的回调列表中。当追踪源执行的时候,它就依次执行每个接收器对应的回调。另外,被追踪的值采用值语义2传递,这使得它触发的时候是什么样,追踪出来就是什么样。

在不使用回调的情况下,若 A 要通过 B 的函数进行通信,A 就必须依赖 B。由于追踪接收器有很多,又不能逐个添加依赖,所以通过依赖的办法缺乏灵活性。

简单的示例代码

开发者给出了 fourth.cc1 作为入门代码示例。略过头文件部分,代码首先定义了一个 Object 的子类,添加了一个追踪源数据,并实现了 GetTypeId 方法:

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class MyObject : public Object
{
  public:
    /**
     * Register this type.
     * \return The TypeId.
     */
    static TypeId GetTypeId()
    {
        static TypeId tid = TypeId("MyObject")
                                .SetParent<Object>()
                                .SetGroupName("Tutorial")
                                .AddConstructor<MyObject>()
                                .AddTraceSource("MyInteger",
                                                "An integer value to trace.",
                                                MakeTraceSourceAccessor(&MyObject::m_myInt),
                                                "ns3::TracedValueCallback::Int32");
        return tid;
    }
    MyObject()
    {
    }
    TracedValue<int32_t> m_myInt; //!< The traced value.
};

如果真的按照官方教程去走,一定会晕头转向,因为官方教程的路线根本没提到 ns-3 的对象模型。

ns-3 提供了三个基类,ObjectObjectBaseSimpleRefCountSimpleRefCount 具有 ns-3 智能指针(Ptr)的引用计数,ObjectBase 具有类型(即 GetTypeId 这一堆)和属性信息以及对象聚合(目前没用到)。Object 具有以上两者的所有特性,如 NetDeviceTcpSocketState 全部是它的子类。

TypeIdObject 的子类可选包含的一个属性,用于存放该类的元数据,如:

  • 标识这个类的唯一字符串(MyObject),用于进行运行时类型推导
  • 父类(Object),用于进行向上/向下类型转换
  • 可用的构造函数,用于在不知道对象具体类型的情况下创建对象
  • 可公开访问属性(attribute)列表,需要同时提供访问方法和边界检查

除了以上四条之外,可以看到 AddTraceSource 方法,用于添加一个追踪源。它的用法有点像 AddAttribute,不同的是 1) 由于是内部编辑,所以不需要像 attribute 一样做边界检查; 2) 有一个回调函数,用于在触发时执行。

m_myint 就是被追踪的数据,而 TracedValue 类型用于除了包装 int32_t 之外,还具备在值变化时触发回调的功能,并要求回调函数有且仅有两个 int32_t 参数,分别是旧值和新值。至于 MakeTraceSourceAccessor,则用于连接追踪源和追踪接收器,反正加上就行了。

对应的,IntTrace 函数中就是 MyInteger 追踪源对应的接收回调,没有返回值,有且仅有两个 int32_t 参数,代码简单易懂:

cpp
1
2
3
4
5
void
IntTrace(int32_t oldValue, int32_t newValue)
{
    std::cout << "Traced " << oldValue << " to " << newValue << std::endl;
}

最后在 main 函数中,就是创建对象和连接的过程了。

cpp
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
int
main(int argc, char* argv[])
{
    Ptr<MyObject> myObject = CreateObject<MyObject>();
    myObject->TraceConnectWithoutContext("MyInteger", MakeCallback(&IntTrace));

    myObject->m_myInt = 1234;

    return 0;
}

对于 Object 的子类,应该使用 CreateObject() 函数创建对象实例,对应的也会返回一个 Ptr。而对于 SimpleRefCount 的子类,则使用 Create()

此处连接使用的并不是示例代码 3 (third.cc)中使用的 Config::Connect() 搭配绝对路径识别符,而是直接使用 MyObjectTraceConnectWithoutContext() 成员函数,这样在第一个参数中就只需要写该类下追踪源的名字而非完整路径了。MakeCallback 的作用这里不再赘述。

此处与示例代码 3 中另一个显著的不同是后者使用带 context 的追踪,TraceConnect() 成员函数中第二个参数为 std::string 类型,用来描述上下文(如对象路径),其会作为第一个参数传入回调,而此处使用的 TraceConnectWithoutContext() 就没有。也就是说,如果使用带 context 的追踪,那么回调函数应该改为 void IntTrace(std::string context, int32_t oldValue, int32_t newValue)Config::Connect()Config::ConnectWithoutContext() 则都不需要额外传入上下文信息。

使用 Config 子系统

虽然这种 TraceConnect() 很实用,但据开发者声称,一般来说都会使用所谓的配置路径选择追踪源,就像之前在示例代码 3 中一样。也就是说,以下两个部分的代码是等效的:

cpp
1
2
3
4
5
6
7
8
9
// 使用 Config::Connect
std::ostringstream oss;
oss << "/NodeList/" << wifiStaNodes.Get(nWifi - 1)->GetId()
    << "/$ns3::MobilityModel/CourseChange";
Config::Connect(oss.str(), MakeCallback(&CourseChange););

// 使用 TraceConnect
wifiStaNodes.Get(nWifi - 1)->GetObject<MobilityModel>()
    ->TraceConnect("CourseChange", MakeCallback(&CourseChange));