写这个系列之前,我一直有个感觉:ns-3网上教程不少,但大部分都停留在“怎么跑通一个example”这个层面,真正讲清楚应用层该怎么设计、怎么继承、怎么调度的内容少之又少。所以这篇“应用层(三)”我干脆把前面两篇没展开的东西集中补齐,从Application基类的底层设计讲起,到自定义协议、多节点场景、数据统计,再到我实际调试时踩过的坑,完整走一遍应用层开发流程。不管你是刚被导师丢到仿真项目里开始啃ns-3,还是想给自己的网络协议验证搭一套更可控的仿真环境,这篇都能帮你省下不少瞎折腾的时间。
1. 应用层在ns-3里的位置与作用
1.1 一个仿真场景的“导演层”:应用层负责什么
用拍电影来打比方。网络仿真里的节点是演员,物理层、MAC层、路由层是后勤团队,它们负责把数据从一个节点搬运到另一个节点。但整场戏到底怎么演——谁在什么时间点发多少数据、发给谁、收到后怎么回应——这些都得由应用层来定。没有应用层的仿真,就像拍了一堆演员站在片场里不动,设备、信道、协议栈全都空转,没有任何实际流量产生,也就谈不上性能分析。
ns-3里的Application基类就是给用户写“剧本”的入口。每一个自定义应用,本质上就是在回答三个问题:什么时候开始运作(StartApplication)、什么时候停止(StopApplication)、在运作期间做什么(通过事件回调实现)。这个设计思路和真实操作系统里的应用进程很像,只不过ns-3里没有真实的进程调度,所有“运行”都是靠仿真事件驱动的。
很多初学者容易把应用层和Socket层搞混,以为写个UDP的发送接收就叫“应用层编程”了。实际上在ns-3里这两层是分开的:应用层决定业务逻辑(什么时候发、发什么内容、怎么处理收到的包),Socket层负责把数据交给传输层真正送出去。这个分离做得相当干净,你在应用层里只需要持有Socket对象,调用它的Send()方法,至于底层走UDP还是TCP、IP地址怎么封装,全都不需要关心。
1.2 从协议栈视角看应用层的独立性
ns-3的架构里,每一层都是独立模块,应用层更是被设计成完全坐在协议栈“上面”的存在。它不关心底层用的是PointToPoint、Wi-Fi还是LTE,也不关心包经过了几个路由器、是走IPv4还是IPv6。只要传输层能提供一个Socket接口,应用层就能正常工作。
这种独立性的直接好处是:你可以在完全一样的应用代码下,只更换底层网络设备、信道模型和协议配置,就能对比不同网络条件下的应用性能。比如同一套请求-响应应用,跑在10Mbps的点对点链路上是一种结果,跑在802.11ax的无线链路上又是另一种结果。应用的业务逻辑完全不用改,这是做协议对比的基础。
从三层建模的完整路径来看,ns-3的正常工作流是:先建节点,再装网络设备,然后给节点配置协议栈,最后在各个节点上安装应用。前三步都是给应用层“铺路”,应用层是最后一个环节,也是真正产生数据流量的环节。理解了这条链路,你就明白为什么很多仿真跑完啥数据都没有——不是网络的问题,而是应用层压根没装对位置。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 应用层核心API与类设计
2.1 Application基类:四个关键的虚函数
ns-3里所有应用组件的基类是ns3::Application,它定义了四个最核心的生命周期方法:
- void Setup()
- void StartApplication()
- void StopApplication()
- void DoDispose()
StartApplication和StopApplication是你在写自定义应用时最常覆盖的两个方法。StartApplication在仿真时间到达应用设置的StartTime时被调用,StopApplication则对应StopTime。这两个时间点由应用容器的Start()和Stop()方法决定,具体到代码里就像这样:
cpp复制ApplicationContainer apps = appHelper.Install(nodes);
apps.Start(Seconds(1.0));
apps.Stop(Seconds(10.0));
这样配置后,应用的StartApplication会在仿真进行到第1秒时被调用,StopApplication会在第10秒被调用。中间这段时间,应用可以通过自己注册的事件来持续工作。注意这里的时间是仿真时间,不是墙钟时间。如果仿真事件不够多,可能墙钟过去的只有几毫秒,但仿真时间已经过了10秒。
DoDispose是清理方法,仿真正式结束、节点被销毁时调用。很多初学写应用时只关注Start和Stop,忘了重写DoDispose去释放Socket、清空缓冲队列,结果在长仿真跑多轮的场景下内存暴涨。这个坑后面我会专门讲。
还有一点要知道的:Setup方法和StartApplication是有区别的。Setup一般用于在应用安装后、仿真开始前进行参数配置,比如设置对端地址、包大小、发送间隔。它适合做一次性初始化。StartApplication则是在仿真真正跑到应用启动时刻才调用,适合做运行时准备工作,比如创建Socket、绑定端口。前面讲的Demo场景,如果把Socket创建放在Setup里,而仿真时间还没到就开始调用,反而容易出现绑定顺序问题。
2.2 Socket:连接应用层与传输层的桥梁
在ns-3里,应用层和传输层之间的接口就是Socket。它是仿照BSD Socket API设计的,写过Linux网络编程的人会非常熟悉。创建Socket并不是直接new出来,而是通过SocketFactory来创建:
cpp复制Ptr<Socket> socket = Socket::CreateSocket(GetNode(), UdpSocketFactory::GetTypeId());
这里的UdpSocketFactory表示我们要创建的是UDP套接字。如果要用TCP,就换成TcpSocketFactory::GetTypeId()。这种工厂方式的好处是,底层协议栈实现可以灵活替换,比如你可以自定义一个传输层协议,然后在应用层用对应的Factory创建Socket。
Socket的基本操作流程是这样的:
- Bind():绑定本地的IP地址和端口
- Connect():对于TCP来说,发起连接建立;对于UDP,它只是设置默认对端地址
- Send():发送一个数据包
- SetRecvCallback():设置收到数据时的回调函数
- ShutdownSend()/ShutdownRecv():关闭发送或接收方向
一个容易踩坑的地方是:UDP的Connect和TCP的Connect语义不一样。很多人从TCP编程转过来,以为UDP也必须Connect才能发数据。实际上ns-3里UDP不Connect也能Send,只是你需要在Send之前设置好对端地址;如果Connect了,就相当于给Socket指定了一个默认对端,后续Send时不用再带地址参数。我个人习惯UDP也会先Connect,这样代码统一,也方便后面加流量控制逻辑。
2.3 Packet与数据封装的细节
在应用层,你操作的数据单元是Packet对象。它本身是可变大小的二进制数据容器,你可以往里填充任意字节:
cpp复制Ptr<Packet> packet = Create<Packet>(1024); // 创建一个1024字节的空包
packet->AddHeader(myHeader); // 在包头插入自定义头部
socket->Send(packet);
Packet的序列化和反序列化是ns-3设计的精华。它支持AddHeader和RemoveHeader操作,可以在包的头部插入自定义的结构体。对于应用层来说,最常见的用途就是在包里加时间戳或序列号,用于延迟和丢包统计:
cpp复制class MyAppHeader : public Header {
public:
void SetSeq(uint32_t seq) { m_seq = seq; }
uint32_t GetSeq() const { return m_seq; }
void SetTimestamp(uint64_t ts) { m_timestamp = ts; }
uint64_t GetTimestamp() const { return m_timestamp; }
// 必须实现的序列化和反序列化方法
virtual TypeId GetInstanceTypeId() const override;
virtual uint32_t GetSerializedSize() const override;
virtual void Serialize(Buffer::Iterator start) const override;
virtual uint32_t Deserialize(Buffer::Iterator start) override;
private:
uint32_t m_seq;
uint64_t m_timestamp;
};
在发送端,构造好包头然后AddHeader;在接收端,先RemoveHeader再把包头信息读出来。这个方法可以用来测量单跳或多跳的端到端延迟:发送端写入Simulator::Now()的时间戳,接收端收到后计算当前时间和时间戳的差,就是这一跳的延迟。
3. 实操:从零构建一个自定义应用层协议
3.1 场景设计:一个UDP请求-响应服务
这里我实现一个具体场景:节点A是客户端,节点B是服务器。客户端每隔500毫秒发送一个64字节的请求包,服务器收到请求后立刻回一个128字节的响应包。仿真时长10秒,点对点链路带宽5Mbps,延迟2毫秒。最终统计请求发送数、响应接收数和平均响应延迟。
这个场景本身很简单,但它覆盖了应用层开发的所有关键步骤:继承基类、创建Socket、事件调度、数据收发、统计输出。你只要把它跑通,基本就掌握了应用层仿真的核心套路。
为什么选UDP而不选TCP?因为UDP更简单、更贴近“应用层自己在掌控发送节奏”这个需求。TCP自带拥塞控制和可靠传输,会把你的发送节奏打乱,让应用层的调度显得没那么直观。如果做延迟敏感型业务仿真,UDP也是更常用的底座。等基础跑通了,想测TCP行为,把SocketFactory换成Tcp即可。
3.2 继承Application,实现客户端与服务器
先定义客户端类:
cpp复制class RequestClient : public Application {
public:
RequestClient() : m_packetSize(64), m_interval(0.5),
m_running(false), m_requestsSent(0),
m_responsesReceived(0), m_totalDelayUs(0) {}
virtual ~RequestClient() {}
// 安装后、仿真开始前调用,把外部参数传进来
void Setup(Address serverAddress, uint32_t packetSize, double interval) {
m_serverAddress = serverAddress;
m_packetSize = packetSize;
m_interval = interval;
}
protected:
virtual void StartApplication() override {
// 创建UDP套接字并绑定
m_socket = Socket::CreateSocket(GetNode(), UdpSocketFactory::GetTypeId());
m_socket->Bind();
m_socket->Connect(m_serverAddress);
// 注册接收回调
m_socket->SetRecvCallback(MakeCallback(&RequestClient::HandleResponse, this));
m_running = true;
m_requestsSent = 0;
m_responsesReceived = 0;
m_totalDelayUs = 0;
// 发送第一个请求
SendRequest();
}
virtual void StopApplication() override {
m_running = false;
if (m_socket) {
m_socket->Close();
m_socket = nullptr;
}
// 打印统计
NS_LOG_UNCOND("Client << Request sent: " << m_requestsSent
<< ", Response received: " << m_responsesReceived
<< ", Avg delay(us): "
<< (m_responsesReceived ? m_totalDelayUs / m_responsesReceived : 0));
}
private:
void SendRequest() {
if (!m_running) return;
Ptr<Packet> packet = Create<Packet>(m_packetSize);
MyAppHeader header;
header.SetSeq(m_requestsSent);
header.SetTimestamp(Simulator::Now().GetMicroSeconds());
packet->AddHeader(header);
m_socket->Send(packet);
m_requestsSent++;
// 安排下一次发送
m_sendEvent = Simulator::Schedule(Seconds(m_interval),
&RequestClient::SendRequest, this);
}
void HandleResponse(Ptr<Socket> socket) {
Ptr<Packet> packet = socket->Recv();
if (!packet) return;
MyAppHeader header;
packet->RemoveHeader(header);
uint64_t sendTime = header.GetTimestamp();
uint64_t now = Simulator::Now().GetMicroSeconds();
m_totalDelayUs += (now - sendTime);
m_responsesReceived++;
}
Ptr<Socket> m_socket;
Address m_serverAddress;
uint32_t m_packetSize;
double m_interval;
EventId m_sendEvent;
bool m_running;
uint32_t m_requestsSent;
uint32_t m_responsesReceived;
uint64_t m_totalDelayUs;
};
服务器类代码思路基本对称:在StartApplication里创建Socket,Bind到指定端口(比如9000),然后SetRecvCallback。收到请求包后,把请求内容解析出来,再把一个带同样序列号的响应包Send回去。
关键点有四个。
第一,SendRequest末尾一定要用Simulator::Schedule再安排下一次发送,这个是应用层定时循环的核心。如果你漏了这行,应用只会发送一个包就再也不动了。我见过太多人卡在这里。
第二,HandleResponse里要调用socket->Recv()把数据取出来。如果设置了RecvCallback但不调Recv,数据会一直堆积在Socket缓冲里,虽然不影响收到新包,但内存占用会持续增长,也会对延迟计算产生干扰。
第三,Simulator::Now().GetMicroSeconds()返回的是仿真时间,不是真实时间。在仿真里它就是精确的“时钟”,不会受CPU负载影响。利用这个时间戳来做延迟测量,是应用层做性能统计最常用的手段。
第四,StopApplication里一定要做状态清理和最终统计输出。如果你的统计都放在析构函数里,那可能永远看不到结果,因为对象销毁的时机在仿真结束之后,可视化界面上看不到输出。
3.3 编写仿真脚本:拓扑、地址与运行窗口
有了应用类,接着就是搭脚本。完整的仿真脚本分四步:
第一步,创建节点:
cpp复制NodeContainer nodes;
nodes.Create(2);
第二步,配置点对点链路:
cpp复制PointToPointHelper pointToPoint;
pointToPoint.SetDeviceAttribute("DataRate", StringValue("5Mbps"));
pointToPoint.SetChannelAttribute("Delay", StringValue("2ms"));
NetDeviceContainer devices;
devices = pointToPoint.Install(nodes);
第三步,配置协议栈和IP地址:
cpp复制InternetStackHelper stack;
stack.Install(nodes);
Ipv4AddressHelper address;
address.SetBase("10.1.1.0", "255.255.255.0");
Ipv4InterfaceContainer interfaces = address.Assign(devices);
第四步,安装应用:
cpp复制uint16_t port = 9000;
Address serverAddress(InetSocketAddress(interfaces.GetAddress(1), port));
RequestClientHelper clientHelper(serverAddress, 64, 0.5);
ApplicationContainer clientApps = clientHelper.Install(nodes.Get(0));
clientApps.Start(Seconds(1.0));
clientApps.Stop(Seconds(10.0));
RequestServerHelper serverHelper(port);
ApplicationContainer serverApps = serverHelper.Install(nodes.Get(1));
serverApps.Start(Seconds(1.0));
serverApps.Stop(Seconds(10.0));
这里有个细节你要注意:客户端和服务器都设置了Start(Seconds(1.0))。在实际场景中,服务器一般先启动,客户端后启动,但差距很小就无所谓。如果序列上服务器比客户端启动晚,客户端先发送的前几个包就丢了,虽然UDP本来可能丢包,但这些“启动顺序导致的丢包”会影响你的统计数据。稳妥的做法是服务器提前0.1秒启动,保证它已经处于接收状态。
运行脚本用:
bash复制./ns3 run scratch/request-response
如果一切正常,你会看到客户端打印出类似这样的输出:
text复制Client << Request sent: 18, Response received: 18, Avg delay(us): 4036
发送了18个请求(1秒到10秒,每0.5秒一个),18个响应全收到,平均延迟40微秒左右。注意这个延迟比链路传播延迟2毫秒小得多,因为它计算的是应用层的虚拟延迟,不包含排队和传输时间,在轻负载下就很小。
3.4 扩展:多节点场景与流量控制
上面的例子只有两个节点,实际项目里通常要跑多节点拓扑。比如想测试一个服务器同时服务10个客户端的场景,你只需要把客户端应用安装到10个节点上:
cpp复制NodeContainer clientNodes;
clientNodes.Create(10);
// 每个客户端都连接到同一个服务器节点
// 需要给每个客户端和服务器之间建立链路
// 这里用CSMA或交换机设备替代PointToPoint会更灵活
多客户端场景下,最值得关注的是服务器端的Socket处理方式。UDP服务器一个Socket就能服务多个客户端,因为每个收到的包都自带源地址信息;但TCP服务器需要为每个连接创建新的Socket。在ns-3里用TCP做多客户端仿真,要自己管理Accept回调,实现起来会比UDP复杂得多。如果只是测多客户端并发场景,建议先用UDP把逻辑跑通,再切换到TCP做可靠性测试。
多节点场景还有一个好处:可以验证应用层对高并发压力的响应。比如增加客户端的发送频率,或者增大包大小,观察平均延迟的变化。这在真实网络里对应“业务量增加后,服务质量如何变化”的问题。
4. 常见问题与排查技巧
4.1 应用不启动、不发送数据
这是应用层仿真最常遇到的问题。现象是仿真正常跑完,但应用啥都没干。排查思路按顺序走:
第一,确认Start和Stop时间设置合理。如果仿真总时长只有5秒,应用设置成6秒启动,那当然永远不会执行。可以用日志打印确认应用是否进入StartApplication:
cpp复制NS_LOG_UNCOND("MyApp StartApplication called at " << Simulator::Now().GetSeconds());
在启动和停止方法里各打印一行,马上就能看出来应用有没有到时间启动。这个土办法比任何调试器都直观。
第二,确认事件调度是循环的。前面讲过,如果SendRequest里没有在末尾Schedule下一次发送,那应用只会发送一次。检查一下发送事件的调用链,确保它是一个闭环。
第三,确认节点编号和IP地址对应正确。应用装在节点0上,但Connect的地址写成了节点1,或者根本写了一个不存在的IP,都会导致数据发不出去。在仿真里没有“报错”,只有“静默失败”,这正是排查困难的原因。
4.2 Socket绑定失败或端口冲突
当一个节点上有多个应用时,端口冲突是常见问题。比如你给同一节点装了10个客户端应用,如果没有显式指定端口,UDP的Bind会随机分配一个空闲端口,一般不会冲突。但如果你手动指定固定端口,又没做冲突检查,第二个应用Bind同一个端口就会失败。
排查方法:用日志打印每个应用绑定到的端口:
cpp复制NS_LOG_UNCOND("Bind successful. Local address: " << m_socket->GetLocalAddress());
如果确认冲突,解决方案很简单:给每个应用传入不同的起始端口,或者干脆不显式Bind,让系统自动分配。
4.3 丢包异常:到底是网络丢包还是应用问题
做应用层仿真时,最容易混淆的就是两类丢包:一类是网络层丢包(队列溢出、无线信道误码),另一类是应用层“丢包”(应用没启动完就发数据、Socket缓冲满了丢弃、接收回调节奏太慢导致缓冲积压)。
区分办法是开Packet Capture。在脚本里加一行:
cpp复制pointToPoint.EnablePcapAll("request-response");
然后打开生成的pcap文件,对比应用发送的包和网络上实际传输的包。如果应用发出去了但链路上没有,说明丢在了应用和传输层交界处;如果链路上有但接收端没处理,问题就在接收回调逻辑。这个排查方法比单纯看统计数字准确得多。
pcap追踪对理解ns-3分层模型也很有帮助。你会看到Packet在每一层加头部和去头部的过程,这样就能更直观地理解为什么应用层说到传输层那段距离里会发生那么多“看不见”的操作。
4.4 仿真卡死或无响应
如果程序的执行时间异常长,或者看起来像死循环,大概率是事件调度的节奏出问题了。最常见的原因是SendRequest里的Schedule下一次发送时,间隔设置得过小,比如小于1微秒,产生了一个超高频率的事件循环。这种情况下仿真看起来像卡住了,因为仿真时间推进极慢。
另一种情况是在事件处理函数里做了大量耗时操作,比如在接收回调里写文件、格式化字符串。虽然仿真时间不受真实时间影响,但处理事件的数量会影响墙钟时间。如果每收到一个包就写一行日志,仿真跑完可能要等很久。
处理办法是控制日志输出级别和数量。调试阶段可以用NS_LOG_UNCOND打印关键事件,但跑大批量仿真前一定要关掉,或者改用数据累加的方式,最后统一输出。
4.5 常见问题速查表
| 现象 | 可能原因 | 排查方式 |
|---|---|---|
| 应用没有启动 | Start/Stop时间设置错误 | StartApplication里加日志 |
| 只发一次包 | 忘记重新Schedule | 检查发送函数尾部 |
| 收不到任何响应 | IP地址/端口配置错误 | 开启pcap抓包 |
| 部分请求没有响应 | 网络丢包或启动顺序问题 | 分析pcap,调整启动时间 |
| 仿真执行极慢 | 事件调度间隔过小 | 检查Simulator::Schedule的次数 |
| 内存不下降 | 忘记释放Socket/Packet引用 | 完善DoDispose和StopApplication |
| 统计结果全是0 | 统计代码放错了位置 | 检查统计逻辑是否写在StopApplication之后 |
5. 应用层建模的扩展思路:从流量模型到真实系统移植
5.1 内置应用与流量模型的选取
ns-3内置了几个非常常用的应用,在正式开始自定义应用之前,建议先把它们摸透:
- UdpEchoServer / UdpEchoClient:最基础的请求-回显应用,适合验证网络连通性
- OnOffApplication:模拟“开-关”流量,适合做突发性数据流(如视频通话、遥测数据)
- BulkSendApplication:持续高吞吐发送,适合压测链路容量和队列管理算法
- PacketSink:接收端应用,只管收包并统计数据,常用于测量吞吐
不同业务场景对应不同流量模型。举例来说,如果你要仿真一个数据中心里周期性心跳包的场景,UdpEchoClient稍作修改就能用;如果是Web服务器的流量特征,OnOffApplication的On/Off时间比例可以模拟请求的突发性;如果做拥塞控制实验,BulkSend配合PacketSink是标准配置。
流量模型的选择直接决定你的仿真结论是否可信。选错了业务模型,即便网络配置、协议栈参数全部正确,测试出的延迟、吞吐、丢包数据也不具备参考意义。
5.2 用Trace和Flow Monitor做更精细的统计
应用层自带的统计变量往往不够用,比如你想统计“应用层收到的第一个包时间”和“最后一个包时间”,或者想导出每个包的到达间隔分布。这时候就需要接Trace回调。Socket和Application都支持Trace机制,比如:
cpp复制socket->TraceConnectWithoutContext("Rx", MakeCallback(&MyApp::TraceRx, this));
TraceCallback可以拿到发包大小、接收间隔等底层信息,配合Simulator::Now()就能重建完整的时间序列。在应用层做Trace统计,还有一个额外好处:它能帮你定位数据在“应用层到传输层”之间是否有额外延迟。
如果要统计网络层面的端到端延迟和丢包,推荐直接使用FlowMonitor模块。它会对每个Flow的包做跟踪,输出端到端延迟、抖动、丢包率。不过FlowMonitor的统计粒度是网络层,不包含应用层的排队和处理延迟,和应用层自己的统计结果会有差异。对比两个结果,能分析出延迟主要产生在哪一层。
5.3 把仿真应用迁移到真实系统的注意点
仿真应用和真实应用最大的区别在于:仿真里没有真正的系统调用、没有进程调度延迟、没有内存分配抖动。因此你在ns-3的UDP应用里用Simulator::Schedule实现的定时发送,到了真实Linux环境下,可能因为系统调度而产生微秒甚至毫秒级别的抖动。
如果最终目标是移植到真实系统,建议在仿真阶段就要模拟这种抖动。方法很简单:发送间隔不是固定值,而是加一个随机偏移。用ns-3自带随机变量:
cpp复制Ptr<UniformRandomVariable> jitter = CreateObject<UniformRandomVariable>();
jitter->SetAttribute("Min", DoubleValue(0.0));
jitter->SetAttribute("Max", DoubleValue(0.1));
SendInterval = Simulator::Schedule(Seconds(m_interval + jitter->GetValue()),
&MyApp::SendRequest, this);
加了这个抖动之后,仿真结果会更接近真实系统,数据的可信度会提升一个档次。做网络仿真的人常说“仿真结果永远是对的”,意思是仿真器只会忠实反映你输入的模型,但对不对应真实世界,取决于模型的真实性。应用层定时抖动就是模型真实性很大的一块。
5.4 一个值得投入的工作方向:统一的应用层测试框架
如果你做的仿真项目周期长、场景多,强烈建议投入时间搭一个统一的应用层测试框架。基本思路是:把客户端、服务器、流量模型、统计数据输出全部参数化,用配置文件驱动不同的测试场景,而不是每新开一个场景就复制一份脚本。
我实际操作下来的做法是:
- 用JSON或INI文件配置网络拓扑、链路参数、应用参数、仿真时长
- 写一个通用的脚本加载配置文件并启动仿真
- 应用层直接读取配置里的发送间隔、包大小、对端地址
- 所有结果统一输出到CSV文件,方便后续绘图
这个框架初期投入两三天,但后续每一轮实验都能省下大量机械操作时间。到项目后期,当你在多个场景间对比数据时,这套框架的价值会特别明显——它保证每个场景除了你想变的参数之外,其他设置完全一样,对比结论才站得住脚。
我个人在实际操作中的体会是,ns-3应用层看似简单,但真正跑起来后,80%的时间都花在调试数据收发和统计输出上。多花点时间把Socket的收、发、回调搞清楚,比盲目堆功能更有效率。另外建议在写自定义应用之前,先细致读一遍ns-3官方自带的应用代码,比如UdpEchoClient和PacketSink的实现,这些代码量不大,但结构非常标准,是很好的参考模板。把基础吃透,后续再做多线程仿真、协议交互仿真,都会顺手很多。
