Zeek源码分析(1)
Zeek的主循环
IO循环
查阅资料得知,在Zeek 3.1之前,Zeek的主循环使用轮询忙等待来检查是否有Ready的IO源,来处理的数据、事件
在3.1之后,Zeek 转而依赖于操作系统的内置的IO多路复用来fd上的更改,所以引入了 libkqueue
libkqueue是个跨平台的IO多路复用实现。它在 macOS 和 FreeBSD 下使用 kqueue 实现,在 Linux 系统下实际使用 epoll 实现(epoll不可用时使用poll实现)
在创建新的IO源时,会向IO源管理器注册fd。当源有要处理的数据时,此fd用于通知 kqueue,将新数据的处理推入 IO 源。而不是我们问源是否有事情要做
Zeek主循环干了哪些事?
- 遍历处理就绪的 IO 源(如拿到NIC中的包、触发定时器等)
- 更新全局的network_time并触发定时器
- 派发事件队列中的所有事件
- 判断是否有中断、终结信号
- 更新统计数据
Zeek脚本中Function、Event、Hook的区别
匿名 | 多实现和优先级 | 立即调用 | 可调度 | 默认参数 | 引用类型参数可变性 | 备用声明 | 返回值 | |
---|---|---|---|---|---|---|---|---|
Function | √ | × | √ | × | √ | √ | × | √ |
Hook | × | √ | √ | × | √ | √ | √ | √ |
Event | × | √ | × | √ | √ | √ | √ | × |
侵入式指针
一种智能指针。和std::shared_ptr
语义一致
std::shared_ptr
的引用计数与Object无关,由std::shared_ptr
存储
而侵入式指针的引用计数由Object内部存储
这样的设计可以避免出现两个Control Block管理一个Object的情况
看一下以下错误代码片段: 1
2
3
4
5
6
7
8
9
10
11
12struct A {
int data;
};
int main()
{
A* raw_p = new A;
std::shared_ptr<A> p(raw_p);
std::shared_ptr<A> p2(raw_p);
return 250;
}
很明显可以看出编写者对std::shared_ptr
理解不够深刻。这是一段等着死的代码。因为p和p2各自维护了自己的引用计数
创建时都加1,销毁时都减1,结果把A delete了两次
所以安全的使用std::shared_ptr
得充分理解make_shared
和enable_shared_from_this
而使用侵入式指针就不需要考虑这些事情
优点:
- 优于
std::shared_ptr
的性能。使用make_shared
时会有两次new操作,一次是指针本身,一次是引用计数 - 避免同一个Object被多个引用计数管理导致double free。
缺点:
- 所有类都要继承基类。耦合性过高
实际上如果观察基于引用计数来GC的语言,比如Python,就会发现基类PyObject就是侵入式引用计数
Zeek运行时的数据流
TFTP数据包到达Analyzer
的过程
调用栈: 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30evnet tftp_read_request()
.... // 此处省略事件管理器分发事件的过程...
/*------------- Zeek脚本事件开始 -------------*/
analyzer::tftp::TFTP_Analyzer::DeliverPacket() // 分析出TFTP的操作码、文件名等信息
/*------------- 应用层协议分析开始 -------------*/
analyzer::pia::PIA::ReplayPacketBuffer() // 将之前构造的Buffer里的负载全部转给协议分析器(包括匹配之前所有同会话的负载)
analyzer::pia::PIA_UDP::ActivateAnalyzer() // 实例化TFTP_Analyzer,并放入UDPSessionAdapter子分析器列表
detail::RuleActionEnable::DoAction() // 启动dpd.sig里指定的协议分析器(enable tftp)
detail::RuleMatcher::ExecRuleActions() // 执行匹配到的dpd.sig里的Action和Dependency Conditions
detail::RuleMatcher::Match() // 将负载输入所有相关匹配器中,检查Conditions(Header、Content)
detail::RuleMatcherState::Match() // 调用rule_matcher->Match
analyzer::pia::PIA::DoMatch() // 检查RuleEndpointState是否初始化,否则初始化。转发给基类处理
analyzer::pia::PIA::PIA_DeliverPacket() // 添加到Buffer中(详见细节11)
analyzer::pia::PIA_UDP::DeliverPacket() // 转发给基类PIA处理
analyzer::Analyzer::NextPacket() // 先给SupportAnalyzer(如果存在),然后将数据转发到DeliverPacket
analyzer::Analyzer::ForwardPacket() // 转发给当前会话的所有协议分析器(和当前端口绑定的协议分析器、PIA、ConnSize)
/*------------- 匹配dpd.sig开始 -------------*/
packet_analysis::UDP::UDPAnalyzer::DeliverPacket() // 分析出源端口、目标端口、负载长度等
packet_analysis::IP::IPBasedAnalyzer::AnalyzePacket() // 创建Connection并放到session_mgr里,构造UDPSessionAdapter树(详见细节9)
packet_analysis::Analyzer::ForwardPacket() // 转发给对应的传输层包分析器
packet_analysis::IP::IPAnalyzer::AnalyzePacket() // IP包分析器,分析出IP地址、传输层协议类型等
packet_analysis::Analyzer::ForwardPacket() // 转发给对应的网络层包分析器
packet_analysis::Ethernet::EthernetAnalyzer::AnalyzePacket() // 以太网包分析器,分析出Mac地址、网络层协议类型等
packet_analysis::Analyzer::ForwardPacket() // root_analyzer送到对应的数据链路层包分析器中
packet_analysis::Manager::ProcessPacket() // packet_mgr开始处理包
/*------------- 包分析开始 -------------*/
run_state::detail::dispatch_packet() // 将包交给packet_mgr处理
iosource::PktSrc::Process() // 发现有数据包到来并处理
run_state::detail::run_loop() // 主循环
main()
- IO源循环中,发现有数据包到来。执行
IOSource::Process()
,调用派生类PcapSource
实现的纯虚函数ExtractNextPacket
取到包,再调用dispatch_packet
dispatch_packet
中调用packet_mgr->ProcessPacket(pkt)
开始处理包- 调用
root_analyzer->ForwardPacket
将包送到对应的二层包分析器的AnalyzePacket
函数里 - 先进入
EthernetAnalyzer
类,分析出源Mac地址和目标Mac地址,以及上层协议是IP,然后将包转发给IPAnalyzer
类【数据链路层】 IPAnalyzer
类分析出IP协议版本、源IP地址、是否分片、上层协议等等信息。并验证checksum等,最后将包转发给UDPAnalyzer
类【网络层】UDPAnalyzer
类先由基类IPBasedAnalyzer
创建Connection(并触发new_connection事件)并放到session_mgr里、构造UDPSessionAdapter
树;然后解析源端口、目标端口、负载长度等。最后将包转发给UDPSessionAdapter
处理【传输层】UDPSessionAdapter
通过PIA
分析器的Match
函数匹配dpd.sig,匹配到后调用ExecRuleActions
函数执行enable tftp
enable tftp
,会先将TFTP_Analyzer
添加到UDPSessionAdapter
子分析器列表中,随后调用analyzer_mgr->InstantiateAnalyzer
函数实例化一个TFTP_Analyzer
对象- 将UDP的负载通过
PIA
分析器的ReplayPacketBuffer
函数DeliverPacket
传递给TFTP_Analyzer
对象 TFTP_Analyzer
分析出TFTP的操作码、文件名等信息【应用层】- 最后通过自定义实现的事件通知到Zeek脚本
一些细节:
dispatch_packet
函数在第一个包到来前会触发network_time_init
事件root_analyzer->ForwardPacket
会触发触发raw_packet
事件IPAnalyzer
类会分析IPv6的包有没有扩展标头,比如有ESP的话就触发esp_packet事件。且说明负载被加密,不执行后面的操作TCPAnalyzer
、UDPAnalyzer
都继承自IPBasedAnalyzer
,因为基于IP的数据包,分析器的很多代码是一致的IPBasedAnalyzer
会检查Connection是否reused,如果是会触发connection_reused事件,并重新将Connection放到session_mgr里IPBasedAnalyzer
会触发new_packet事件UDPAnalyzer
类会触发udp_request或udp_reply事件- 所有的协议分析器都是树形结构的,根节点都是其传输层协议的会话适配器(
UDPSessionAdapter
、TCPSessionAdapter
等) UDPSessionAdapter
树构造时,会检查并添加与端口绑定的协议分析器;以及增加PIA
分析器、ConnSize
分析器PIA
分析器为TCP和UDP提供通用功能,可用于确定使用哪个协议分析器对其进行解析,也就是匹配dpd.sig;也提供reassembles包的功能PIA
分析器里的Buffer
用双向链表将包负载连起来,用于保存数据包有效载荷(包括 TCP 的序列号)和reassembles
源码中各种全局Manager的用途
1 | /* |
从Zeek源码中的回调函数引发的对面向对象的思考
回调函数
回调函数是编码中常用的一种手段,用于实现异步操作、事件响应等
在C/C++中,实现回调的方法很多,比如最常用、简单的函数指针
在Zeek源码中的回调函数(如Timer的回调)几乎都是使用多态实现的
其实不难看出为什么使用多态实现,因为Zeek的代码几乎全部是OOP的,而函数指针指向成员函数比较的麻烦(并不是不能,需要写丑陋的using/typedef,而且调用时还需要传递this指针,破坏了OOP。或者直接干脆用static成员函数)
使用多态实现的回调既不会破坏OOP、也提供不错的回调性能(比函数指针多一次对虚表的内存寻址)
但也有一定的缺点,比如:
- 由于继承是强耦合的,如果新需求到来需要再加一个回调的业务实现,那么就需要一个新的派生类
- 代码可读性的下降
借此我们可以思考一下,有没有更好的写法
其实Modern C++指导思想下的写法就是使用std::function
+ 弱回调实现
好处如下:
std::function
可以接受一个C函数、C++成员函数、仿函数或lambda,更灵活- 使用
std::bind
可以顺便保存响应函数的参数,将回调函数和回调上下文绑定起来,实现闭包 - 弱回调可以保证不影响(延长)对象生命周期,且对象销毁后触发回调也不会导致段错误
- 良好的代码可读性和良好的回调性能
弱回调的实现方法:利用
std::weak_ptr
,在回调的时候先尝试提升为std::shared_ptr
,如果提升成功,说明接受回调的对象还在,执行回调;如果提升失败就忽略
对面向对象的思考
其实这样的思想就是函数式编程,把数据和计算放到闭包里,而OOP是把数据和操作放到对象里
可以看出Zeek源码的编写者是非常愿意拥抱现代C++的(C++17标准、std::string_view
等的使用)。但他们很明显还在使用过时的设计思想。他们尝试把一切都使用OOP编写,也就是“纯”面向对象
计算机科学界有很多声音批判“纯”面向对象的声音
王垠的 解密“设计模式” 批判了(面向对象)设计模式的 “历史局限性”:
(设计模式)变成了一种教条,带来了公司里程序的严重复杂化以及效率低下 ... 什么都得放进 class 里 ... 代码弯了几道弯,让人难以理解。
孟岩的 function/bind的救赎(上) 也提到 “面向类编程” 脱离了 “对象的本质”:
C++ 静态消息机制 还引起了更深严重的问题 —— 扭曲了人们对面向对象的理解 ... “面向对象编程” 变成了 “面向类编程”,“面向类编程” 变成了 “构造类继承树”。
如何进行代码设计?
假设我们需要实现一个“异步下载”的功能,我们看如何进行实现
面向过程——朴素设计
下载完成后打印结果可以实现为:
1 | void DownloadAsyncAndPrint() { |
下载完成后写数据库可以实现为:
1 | void DownloadAsyncAndWriteToDB() { |
我们发现异步下载这部分功能是公共逻辑,所以可以通过抽取函数(extract function)手法来重构出异步下载的核心逻辑
1 | std::future<Result> DownloadAsyncImpl(); |
存在的问题: - 不可能针对所有需求提供上述接口(有人需要打印结果,有人需要写数据库,还有人需要...) - 需要提供不涉及实现细节的接口(比如 DownloadAsyncImpl
基于 C++ 20 的协程,可以改用多线程实现,但调用者并不关心)
本质上,面向过程的结构化设计,导致数据 result 生产和消费的逻辑耦合在了一起,不易于扩展
面向对象——解耦发送者和接收者
为了解决这个问题,需要引入控制反转(IoC),从纯面向对象的视角看:
- 一个数据:result
- 两个角色:发送者(
ownloadAsyncImpl
)和 接收者(Print/WriteToDB
)
而目的是解耦送者和接收者,可以通过以下两种方法实现
模板模式
通过继承,在发送者(虚基类)上重载接收者(纯虚方法)逻辑:
1 | // interface |
策略模式
通过组合,向发送者(类/函数)传递接收者(派生类)逻辑:
1 | // interface |
在实际的代码编写中,如果能写出以上两种设计的代码,其实已经足够好了。但依然有以下缺点: - 模板模式在运行时不能动态更换接收者 - 策略模式要为每种类型定义一个接收者的接口
在Zeek的源码中,各种分析器(Analyzer
)就是基于模板模式设计的
而策略模式也有应用,比如RuleCondition
、Timer
等
本质上,面向对象的封装把数据和对数据的操作(方法)捆绑在类里,引入了复杂的类层次结构(class hierarchy),最后沦为面向类编程

回调闭包
其实,可以使用回调闭包(callback closure) 实现等效的依赖注入 (DI) 功能:
1 | // interface |
上述代码去掉了class,把 handler 对象改为 callback 闭包,把 虚函数调用 改为 回调闭包的调用,不再需要接口和继承
脱离了 “类” 的束缚,是不是 清晰多了
泛型编程
实际上,也可以使用泛型编程(generic programming) 进一步化简
1 | // interface |
总结
其实各种编程范式的争论,在计算机科学领域一直喋喋不休
比如在C++标准库中,各种容器就是泛型编程的思想
而在Java标准库中,各种容器的迭代器基于Iterable
接口、对象的比较基于Comparable
接口
在本质上并没有谁更好谁更坏
能够随着项目入乡随俗,不单单是写出命名风格、缩进风格一致的代码,更要写出设计一致、默认正确的代码
这是一个优秀程序员的基本功
函数模板DownloadAsync<>
只关心 callback 能处理 result,而不需要关心它的实际类型是什么