拓冰建站拓冰建站
首页 / 资讯中心 / 正文

ns-3自定义应用层协议:从Header设计到Application实现

ns-3 的应用层仿真写到第四篇前面几篇基本把环境搭建、Node/Channel/NetDevice 的关系、Socket 的基础收发都顺过了一遍。但这个专题再往下走我猜很多人会跟我当时一样卡住UdpEcho 跑通了OnOff 也会用了然后想模拟一个“带业务逻辑”的端到端场景比如传感器周期性上报、采集端解析不同节点的数据、按序列号识别丢包——翻开 ns-3 自带的 Application 列表发现要么太教学、要么只是个流量发生器并没有一个能直接套进业务里的标准件。这篇文章想解决的就是这个问题。我会从协议报文设计开始完整走一遍 ns-3 自定义应用层协议的过程定义自己的 Header、写两个 Application 子类发送端 接收端、在星型拓扑里跑通端到端上报最后把我在这个过程中实际踩过的几个坑原原本本列出来。适合已经跑过 ns-3 基础示例、现在准备做应用层仿真但还没想清楚“自定义协议该往哪里下手”的读者。1. 这一篇不炒冷饭UdpEcho 讲完以后为什么还得写自己的 Application先把一个很容易误解的问题说清楚。很多新手以为 ns-3 里的“应用层”就等于 Applications 模块里那十几个现成类业务需求来了优先去那里面找找不到就觉得 ns-3 不支持。实际上 ns-3 对应用层的定位很开放Application只是一个挂载在 Node 上的对象壳子真正的行为由你在StartApplication()里写的代码决定。你完全可以写一个自己的Application子类在仿真里跑你自己的业务协议。1.1 自带 Application 的边界在哪里先把 ns-3 自带的那几个常用类盘一下看看它们适合什么场景Application它擅长的事它不擅长的事UdpEchoClient / UdpEchoServer验证链路和协议栈是否通承载任何真实业务语义OnOffApplication按开关模型生成恒定/突发流量根据响应做状态机流转、携带结构化数据BulkSendApplication尽可能快地灌满链路小包低频上报、解析对端反馈PacketSink把收到的所有包统计掉解析包内容、区分不同源节点这四类覆盖了“连通性测试”和“背景流量生成”两大需求但实际仿真中我们要解决的问题通常是第三种几个终端节点周期性上报数据汇聚节点需要知道是谁发的、序号是多少、载荷是什么可能还要做应答、重传、超时。这些需求已经超出了自带类的设计目标。不是说不能拿 OnOff PacketSink 硬凑而是硬凑出来的模型抓不住协议本质后面分析结果时你根本不知道丢包是链路拥塞引起的还是应用层协议自己状态错乱引起的。1.2 真实业务对应用层仿真的三个要求我自己的体会是凡是需要自定义应用层基本都逃不开下面这三条第一报文要有语义。发的不是一坨随机字节而是能解析出“节点编号、序列号、载荷值”这些字段。没有语义接收端只能统计包个数做不了协议正确性验证。第二行为要有状态。发送端不是一直发同一个包而是发完一个调度下一个还要知道自己发到哪一帧了接收端收到包之后要做解析而不是简单 discard。这就是为什么 Application 里既要有发送调度代码又要有接收回调代码。第三交互要有方向。UdpEcho 是最简单的请求应答模型实际系统里更多是生产者—消费者模型、定期快照模型、注册—上报模型。方向不同Socket 的 Bind/Connect/RecvFrom 用法就不同。2. 报文先行把 SensorReportHeader 定义成一个干净利落的 12 字节头部设计自定义协议的第一步往往不是写代码而是先把报文格式定下来。很多初学者一上来就写 Application 类写到一半才发现不知道序列号塞在哪个字段里、怎么从收到的字节流里把控制信息抠出来。所以这篇文章我先把报文格式单独拿出来讲这是整个自定义应用层最容易被忽视、又最影响后期排查的一部分。2.1 字段取舍仿真不是越复杂越好我在这篇 Demo 里设计的是一个传感器周期上报报文叫SensorReportHeader。为了让例子足够简单又能说明问题我只放了三个uint32_t字段总共 12 字节字段名长度字节含义sensorId4发送节点编号接收端用来区分数据来源seq4发送端的序列号从 0 开始递增用来做丢包分析value4一个模拟的业务载荷值比如温度刻度值这里我没有加魔数magic number也没有加消息类型字段。为什么不加因为在端到端仿真里通道上只有我们自己的业务报文不存在第三方干扰校验和与消息类型可以省略加了反而让 Header 变大对理解核心收发流程没有帮助。如果你是在做混合流量仿真需要考虑通道里同时存在多种协议报文那时候再增加一个type字段并且用第一个字节做区分逻辑是一样的。序列号用 4 字节也是考虑过的。跑长仿真时如果 seq 只有 1 字节256 次上报后就会翻绕接收端做丢包分析时分不清新包和老包4 字节在绝大多数仿真场景下够用。sensorId 用节点编号填充好处是输出日志时能一眼看出数据是从哪个 Node 发出来的和 ns-3 的 node id 对得上排查拓扑问题时非常方便。2.2 Header 子类的六个关键方法在 ns-3 里自定义头部需要继承Header类并实现一组约定好的方法。下面是我这个SensorReportHeader的完整定义// sensor-report-header.h #ifndef SENSOR_REPORT_HEADER_H #define SENSOR_REPORT_HEADER_H #include ns3/header.h #include ns3/type-id.h namespace ns3 { class SensorReportHeader : public Header { public: SensorReportHeader (); virtual ~SensorReportHeader (); // TypeId 相关 static TypeId GetTypeId (); virtual TypeId GetInstanceTypeId () const; // Header 纯虚函数必须实现 virtual uint32_t GetSerializedSize () const; virtual void Serialize (Buffer::Iterator start) const; virtual uint32_t Deserialize (Buffer::Iterator start); virtual void Print (std::ostream os) const; // 业务字段的读写 void SetSensorId (uint32_t sensorId); uint32_t GetSensorId () const; void SetSeq (uint32_t seq); uint32_t GetSeq () const; void SetValue (uint32_t value); uint32_t GetValue () const; private: uint32_t m_sensorId; uint32_t m_seq; uint32_t m_value; }; } // namespace ns3 #endif对应的实现文件// sensor-report-header.cc #include sensor-report-header.h namespace ns3 { NS_OBJECT_ENSURE_REGISTERED (SensorReportHeader); SensorReportHeader::SensorReportHeader () : m_sensorId (0), m_seq (0), m_value (0) { } SensorReportHeader::~SensorReportHeader () { } TypeId SensorReportHeader::GetTypeId () { static TypeId tid TypeId (ns3::SensorReportHeader) .SetParentHeader () .SetGroupName (Applications) .AddConstructorSensorReportHeader (); return tid; } TypeId SensorReportHeader::GetInstanceTypeId () const { return GetTypeId (); } uint32_t SensorReportHeader::GetSerializedSize () const { return 3 * sizeof (uint32_t); } void SensorReportHeader::Serialize (Buffer::Iterator start) const { start.WriteHtonU32 (m_sensorId); start.WriteHtonU32 (m_seq); start.WriteHtonU32 (m_value); } uint32_t SensorReportHeader::Deserialize (Buffer::Iterator start) { m_sensorId start.ReadNtohU32 (); m_seq start.ReadNtohU32 (); m_value start.ReadNtohU32 (); return GetSerializedSize (); } void SensorReportHeader::Print (std::ostream os) const { os SensorReportHeader sensorId m_sensorId seq m_seq value m_value; } } // namespace ns3这里面我挑两个容易写错的地方强调一下。第一个是GetSerializedSize()必须和Serialize()里实际写入的字节数严格一致。ns-3 的Buffer::Iterator在序列化时是连续前进的如果GetSerializedSize()返回 12但Serialize()里写了 8 字节接收端反序列化时读到的就是错位数据反过来也不行。我见过不少刚上手的朋友在这里偷懒直接返回一个写死的常量后面往 Header 里加字段时忘了同步改导致定位问题花了很长时间。第二个是Deserialize()的返回值。这个方法返回的是“本次反序列化消耗了多少字节”不是返回字段个数更不是返回0。我刚写自定义 Header 的时候也踩过Deserialize里读完了字段但返回值仍然是默认的 0导致上层框架认为头部没有被消费掉后续解析全部错乱。正确写法就是读取结束后return GetSerializedSize ();。2.3 字节序处理与 Print 的调试价值Header 序列化还有一个容易被忽略的细节网络字节序。在 ns-3 里官方推荐用WriteHtonU32/ReadNtohU32这一组方法Hton 表示主机字节序转网络字节序Ntoh 表示反方向。这样你的报文在同一个仿真进程里收发没有问题即使以后把 pcap 抓下来放到 Wireshark 里看字段也是按标准大端顺序排列的方便对照。Print()方法在自定义 Header 里属于典型的“平时用不上、排查时救命”的方法。ns-3 在 pcap 导出时不一定直接打印你的 Header 内容但如果你把 pcap 数据导入 Wireshark或是在日志里手动调用header.Print(os)它能帮你快速确认序列化是否按预期发生。我在调试本文 Demo 时每次拿到未知报文都会先打印 Header 内容确认 sensorId、seq、value 三个字段能对上再去看时序。3. 把协议装进 Application发送端与接收端的完整骨架Header 定义好之后接下来就是把协议跑起来。ns-3 自定义 Application 说穿了就是两件事在StartApplication()里创建 Socket 并挂好回调在调度函数里做周期性发送在接收回调里读 Socket。下面这一段是整个自定义应用层最简单的跑通路径。3.1 发送端 SensorSender定时器就是应用层的“心跳”发送端的设计目标很明确从某个 Node 上周期性地向汇聚节点发送SensorReportHeader。这里最关键的设计决定是“用Simulator::Schedule实现周期发送”而不是把多个包提前一次性注入。两者的区别在于一次性注入虽然也能在仿真里形成多个包但无法模拟“节点真的在按固定频率工作”的状态而且你想动态改变发送间隔时会非常别扭。void SensorSender::StartApplication () { m_running true; m_seq 0; m_socket Socket::CreateSocket (GetNode (), UdpSocketFactory::GetTypeId ()); if (m_socket-Bind () ! 0) { NS_LOG_ERROR (SensorSender bind failed); return; } m_socket-Connect (InetSocketAddress (m_remote, m_port)); // 第一次上报放在 0 时刻真正的运行起始时间由 SetStartTime 控制 m_sendEvent Simulator::Schedule (Seconds (0), SensorSender::SendReport, this); } void SensorSender::SendReport () { if (!m_running) { return; } SensorReportHeader header; header.SetSensorId (GetNode ()-GetId ()); header.SetSeq (m_seq); header.SetValue (200 (m_seq % 50)); // 模拟一个随时间变化的值 PtrPacket packet CreatePacket (); packet-AddHeader (header); m_socket-Send (packet); // 关键下一次上报仍然由这里调度 m_sendEvent Simulator::Schedule (m_interval, SensorSender::SendReport, this); }注意发送端的 Socket 创建不能放到构造函数里。原因很实际Application 对象在被AddApplication(Node)关联到节点之前GetNode()返回的是空指针。你在构造函数里创建 Socket如果此时节点还没挂好Socket 不知道该绑定到哪个 Node 的协议栈上。标准做法是放在StartApplication()里这个方法由 ns-3 自动调用保证此时 Application 已经绑定到 Node。至于为什么要Bind()之后再Connect()这两个动作在 UDP 场景下含义和 TCP 不太一样。Bind()是给本地 Socket 分配一个可用的端口Connect()在这里仅仅是给 Socket 设置一个默认目的地址并不会真的建立连接。之后每次Send(packet)都会自动发到预设的m_remote:m_port省去每次调用SendTo时再传地址的麻烦。如果你的发送端需要用不同目的地址做测试可以不Connect()改用SendTo(packet, dst)两种方式 ns-3 都支持。3.2 接收端 SensorCollector收包后的第一件事是取源地址接收端和发送端最大的不同在于它不主动发数据而是通过回调等包。接收端在StartApplication()里创建 Socket 后要绑定到一个固定端口并注册接收回调void SensorCollector::StartApplication () { m_socket Socket::CreateSocket (GetNode (), UdpSocketFactory::GetTypeId ()); InetSocketAddress local InetSocketAddress (Ipv4Address::GetAny (), m_port); if (m_socket-Bind (local) ! 0) { NS_LOG_ERROR (SensorCollector bind failed); return; } m_socket-SetRecvCallback (MakeCallback (SensorCollector::HandleRead, this)); }接收回调的实现里有一个很容易被忽略的关键动作用RecvFrom而不是Recv。void SensorCollector::HandleRead (PtrSocket socket) { Address from; PtrPacket packet; while ((packet socket-RecvFrom (from))) { if (InetSocketAddress::IsMatchingType (from)) { InetSocketAddress remote InetSocketAddress::ConvertFrom (from); NS_LOG_UNCOND ([collector] receive from remote.GetIpv4 () : remote.GetPort ()); } SensorReportHeader header; packet-RemoveHeader (header); NS_LOG_UNCOND ([collector] sensorId header.GetSensorId () seq header.GetSeq () value header.GetValue ()); } }为什么这里不能用Recv()直接读因为Recv()只能从 Socket 里把包取出来它不负责告诉你这个包是谁发来的。UDP 在 ns-3 里是无连接的接收端在没有Connect()的情况下同一个 Socket 可能收到来自多个节点的数据。如果丢失了源地址后面想做“按节点分别统计丢包”就无从下手。UDP Socket 的源地址信息在底层收到包时已经被保存下来了只是必须通过RecvFrom()的第二个参数Address from把它取出来。这是我反复强调的一个细节后文还会再讲踩坑案例。3.3 生命周期管理StopApplication 不是摆设Application
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门