Suricata 源码阅读·番外:读懂 Suricata 需要哪些 C 语言知识
00 篇说,这个系列适合”有 C 基础”的读者。可”C 基础”这个说法太宽了:会写
for循环、懂指针和结构体,翻开 Suricata 的源码,还是会被一些写法卡住。这篇番外把读 Suricata 时会遇到的、超出入门教材的 C 语言知识挑出来,每一项都配上源码里的真实例子和位置(基于 8.0.7),并说明前面哪几篇用到了它。
先给一个总览。读 Suricata,最常卡住人的是这几类:
- **函数指针和”接口表”**:整个系列反复出现的骨架。
- 宏:预处理器在 Suricata 里几乎是一门小语言。
- 结构体的内存布局:柔性数组、union、位域、
packed。 - 把字节当结构体看的指针技巧:解码层的日常。
- 侵入式数据结构:链表和红黑树的节点长在业务结构体里。
- 并发:锁、条件变量、线程局部变量、原子操作。
- 编译器提示、错误处理、条件编译:每个文件都有,但很少有人解释。
- 和 Rust 打交道:8.0 里越来越多的代码是 Rust 写的。
下面逐个看。
1. 函数指针和”接口表”
如果只能挑一项最重要的,就是这个。系列里出现过的好几个核心结构,本质上都是一张函数指针表:
TmModule(第 2 篇,src/tm-modules.h:47):一道工位怎么初始化、怎么处理包、怎么收尾。AppLayerParserProtoCtx(第 7 篇):一个应用层协议怎么建状态、怎么解析、怎么取事务。SigTableElmt(第 8 篇,src/detect.h:1468):一个规则关键字怎么解析参数、怎么匹配。- 日志模块(第 10 篇):每个模块带一个条件函数和一个写函数。
先读懂声明。 函数指针的声明是 C 里出了名难读的语法。比如应用层解析函数的类型(src/app-layer-parser.h:131):
1 | typedef AppLayerResult (*AppLayerParserFPtr)(Flow *f, void *protocol_state, |
读法是:AppLayerParserFPtr 是一种指针,指向”接收这五个参数、返回 AppLayerResult“的函数。有了这个 typedef,结构体里写 AppLayerParserFPtr Parser[2]; 就清楚多了:两个函数指针,一个方向一个。
为什么这么用。 C 没有类和虚函数,函数指针表就是 C 里的”接口”。框架只认表,不认具体实现,所以加一个协议、一个关键字、一种日志,只要多注册一项。第 10 篇结尾把这一点列为贯穿整个系列的设计。
读代码的技巧。 看到 s->SlotFunc(tv, p, ...)、p->Parser[direction](...) 这种调用,跳转到定义是跳不到具体函数的,因为它们是运行时才确定的。这时要反过来找赋值的地方:搜 .Func =、.Setup =、RegisterParser( 这类注册代码,就知道某个指针实际指向谁。
2. 宏:预处理器是一门小语言
Suricata 里宏用得极多,而且不只是定义常量。下面几种写法,入门教材很少讲。
do { ... } while (0)。 解码事件的宏(src/decode.h:1195):
1 |
外面这层 do { } while (0) 不是循环,而是为了让多行语句的宏,用起来和一个普通语句完全一样:后面可以安全地跟分号,放在没有花括号的 if 里也不会出错。
语句表达式 ({ ... })。 紧挨着的 ENGINE_ISSET_EVENT(src/decode.h:1208)长这样:
1 |
这是 GCC/Clang 的扩展,不是标准 C:花括号里可以写多条语句、定义局部变量,最后一个表达式的值,就是整个宏的值。它让宏可以像函数一样”返回”结果。
可变参数和内置宏。 日志宏(src/util-debug.h:225):
1 |
... 和 __VA_ARGS__ 接收任意个参数;__FILE__、__FUNCTION__、__LINE__ 由编译器展开成当前的文件名、函数名、行号。所以每一条日志都能自动带上出处。
记号拼接 ##。 原子变量的声明(src/util-atomic.h:58):
1 |
## 把两段记号拼成一个标识符:SC_ATOMIC_DECLARE(uint32_t, flow_flags) 声明出来的变量,真名叫 flow_flags_sc_atomic__。所以在源码里直接搜 flow_flags 是搜不到定义的,要么搜这个宏的用法,要么用 IDE 跳转。
用宏生成代码。 BSD 的 tree.h(src/tree.h:423)用 RB_GENERATE 这一个宏,为指定的类型生成一整套红黑树的插入、删除、查找函数:
1 | RB_GENERATE(TCPSEG, TcpSegment, rb, TcpSegmentCompare); /* src/stream-tcp-list.c:49 */ |
这一行展开后是上百行代码。C 没有泛型,这是 C 里实现”类型安全的泛型容器”的传统办法。第 6 篇的 TCP 段红黑树就是这么来的。
在编译期”消失”的宏。 发布版本里,SCLogDebug(...) 被定义成 do { } while (0)(src/util-debug.h:275),SCEnter() 定义为空、SCReturnInt(x) 就是 return x(src/util-debug.h:277、281)。看源码时到处都是这些调试宏,正式运行时它们的开销是零。
读宏的技巧。 实在看不懂一个宏,让编译器展开给你看:gcc -E 只做预处理,输出宏全部展开后的代码;clangd 这类工具在鼠标悬停时也能显示展开结果。
3. 结构体的内存布局
Suricata 很在意内存怎么排,下面四种写法在系列里都出现过。
柔性数组成员。 Packet 的最后一个字段(src/decode.h:698):
1 | uint8_t pkt_data[]; |
结构体最后一个成员可以是不写长度的数组。分配时多申请一段空间,这段空间就紧跟在结构体后面,用 pkt_data 访问。一次 malloc 就同时拿到了结构体和数据缓冲区,内存还是连续的。第 3 篇讲 Packet 时说过它;Flow 最后的 Storage storage[](src/flow.h:503)也是同一个写法。
union。 Packet 里各抓包方式的私有数据(src/decode.h:572 附近)放在一个 union 里:同一时间只会用到一种,所以共用同一块空间。union 还有另一种用法,是”同一块内存换个角度看”。流哈希的键(src/flow-hash.c:87):
1 | typedef struct FlowHashKey4_ { |
填的时候按字段一个个填,算哈希时把同一块内存当成 6 个 uint32_t 交给 hashword()。那个 pad 字段是为了把总长度补齐到 4 字节的整数倍,第 5 篇算哈希时用的就是它。
位域。 TcpSession 的状态(src/stream-tcp-private.h:285):
1 | uint8_t state:4; /* TCP 状态 */ |
冒号后面的数字是位数:两个 4 位的字段挤在一个字节里。TCP 状态不超过 16 种,用 4 位就够了。第 9 篇里 PrefilterNonPFDataSig 的 uint32_t sid : 30;(src/detect-engine-prefilter.c:537)也是同样的道理。每条流、每个包都要用到这些结构,省下的每个字节乘以几十万,就很可观了。
__attribute__((packed))。 以太网头(src/decode-ethernet.h:54):
1 | typedef struct EthernetHdr_ { |
编译器默认会在字段之间插填充字节来对齐,而网络协议头的布局是协议规定死的,不能有一个多余的字节。packed 告诉编译器:一个字节都不要填。只有这样,才能把收到的字节直接当成这个结构体来读,这就是下一节的内容。
4. 把字节当结构体看
解码层(第 4 篇)几乎就是由这一类技巧组成的。
强制类型转换 + 指针运算。 PacketSetEthernet()(src/decode.h:782)直接把原始数据的地址转换成头部结构体的指针:
1 | p->l2.hdrs.ethh = (EthernetHdr *)buf; |
接着跳过这一层,把剩下的数据交给下一层:
1 | DecodeNetworkLayer(tv, dtv, SCNtohs(ethh->eth_type), p, |
pkt + ETHERNET_HEADER_LEN 是指针运算:对 uint8_t * 加 14,就是往后挪 14 个字节。没有拷贝,只是换了一个起点。
这种写法有两个前提:结构体必须是 packed 的(上一节);访问之前必须先确认长度够,否则就读出界了。第 4 篇说每一层解码都先检查长度,原因就在这里。
字节序。 网络上的数据是大端序,而 x86 这类常见的机器是小端序,直接读出来的多字节整数是反的。所以读端口要经过转换(src/decode-tcp.h:75):
1 |
SCNtohs()(src/suricata-common.h:431)就是标准库 ntohs() 的包装:network to host short。
位运算取字段。 IPv4 头的第一个字节里,高 4 位是版本号,低 4 位是头部长度(以 4 字节为单位)(src/decode-ipv4.h:95):
1 |
& 0xf0 取高 4 位再右移 4 位,得到版本号;& 0x0f 取低 4 位再左移 2 位(乘以 4),得到字节数。协议头里这种挤在一起的字段非常多。
指针的指针。 第 5 篇的流表查找:
1 | Flow *FlowGetFlowFromHash(ThreadVars *tv, FlowLookupStruct *fls, Packet *p, Flow **dest); |
Flow **dest 是”指向一个 Flow * 的指针”,函数通过它把找到的流写回调用方(这里实际传的是 &p->flow)。C 没有引用参数,想在函数里修改调用方的指针变量,就得传指针的指针。
void * 和”上下文”。 SigMatch 里的 SigMatchCtx *ctx(第 8 篇)、各模块的 void *thread_data、解析器的 void *alstate,都是”框架不知道里面是什么,原样交还给具体实现”的数据。具体实现拿到之后,再强制转换回自己的类型,比如 DetectContentData *cd = (DetectContentData *)sm->ctx;。这和第 1 节的函数指针是一对搭档:函数指针决定调用谁,void * 负责把数据带过去。
5. 侵入式数据结构
教科书上的链表,一般是”节点里存一个指向数据的指针”。Suricata 用的是另一种写法:把链表、红黑树的指针,直接嵌在业务结构体里。
日志模块的根日志器(第 10 篇)是这样串起来的(src/output.c:94):
1 | typedef struct RootLogger_ { |
TAILQ_ENTRY 展开后是(src/queue.h:239):
1 | struct { |
注意 tqe_prev 是指针的指针:它指向的不是前一个元素,而是”前一个元素的 tqe_next 字段”。这样删除一个元素时,不用区分它是不是第一个,直接 *tqe_prev = tqe_next 就行。
TCP 段的红黑树(第 6 篇)也一样,节点指针 RB_ENTRY(TcpSegment) rb; 长在 TcpSegment 里(src/stream-tcp-private.h:76)。
这种写法的好处是:插入链表或树时不用另外分配节点,一个对象也可以同时挂在好几个容器上。代价是容器和数据类型绑在了一起,所以要靠宏(第 2 节)为每种类型生成一套代码。
6. 并发
Suricata 是多线程程序(第 2 篇),读源码时要随时注意”这段代码在哪个线程跑、哪些数据是共享的”。
锁。 SCMutex 就是 pthread_mutex_t(src/threads.h:116),SCMutexLock() 就是 pthread_mutex_lock()。流表的桶锁(第 5 篇)可以编译成自旋锁,也可以编译成互斥锁(src/flow-hash.h:67、73):
1 |
自旋锁拿不到时原地空转,适合持有时间极短的场景;互斥锁拿不到时让出 CPU。桶锁只在查找、插入的一小段时间里持有,所以可以选自旋锁。
读带锁的代码,最重要的是搞清楚”锁的约定”:谁加锁、谁解锁、函数返回时锁是否还持有。第 5 篇的 FlowGetFlowFromHash() 返回的流是带着锁的,调用方用完要自己解锁。这种约定一般写在函数的注释里,读到这类函数时一定要留意。
条件变量。 第 3 篇包池空了的时候,PacketPoolWait() 用 SCCondWait()(src/threads.h:140,即 pthread_cond_wait())睡下去,等别的线程把包还回来时唤醒它。条件变量总是和一把互斥锁配合使用,等待时要放在一个 while 循环里反复检查条件,因为醒来时条件不一定满足。
线程局部变量。 第 3 篇的包池是每个线程一份:
1 | thread_local PktPool thread_pkt_pool; /* src/tmqh-packetpool.c:44 */ |
thread_local 在 src/threads.h:37 起定义:支持 C11 的编译器用 _Thread_local,否则退回 GCC 的 __thread。每个线程看到的是自己的那一份,读写都不用加锁。
原子操作。 不想为一个计数器、一个标志位加锁时,用原子操作。Suricata 用的是 C11 的 <stdatomic.h>(src/util-atomic.h:38):
1 |
第 5 篇判断是否进入紧急模式的 SC_ATOMIC_GET(flow_flags) & FLOW_EMERGENCY,就是一次原子读。原子操作只保证单个变量的读写不被打断,不能代替锁去保护多个变量之间的一致性。
7. 编译器提示、错误处理、条件编译
这三样在几乎每个文件里都有,单独看都不难,但不知道的话会被反复打断。
likely() / unlikely()。 src/util-optimize.h:32 起:
1 |
告诉编译器”这个条件大概率成立 / 不成立”,让它把常走的分支排在一起,提高指令缓存和分支预测的命中率。代码里的 if (unlikely(len < ETHERNET_HEADER_LEN)),读的时候直接当成普通的 if,同时知道”这是异常情况”就行。!! 是把任意值规整成 0 或 1。
头文件里的 static inline。 PacketSetEthernet()、DecodeLinkLayer() 这些都定义在 decode.h 里,前面带 static inline。这样每个包含它的 .c 文件都有一份可以内联的副本,省掉函数调用的开销。热路径上的小函数大多是这样写的。
goto 做错误处理。 C 没有异常,也没有析构函数。Suricata 的惯用法是:出错就跳到函数末尾的统一出口,在那里释放资源、返回错误码。第 7 篇的 AppLayerParserParse() 就是这样:
1 | if (res.status < 0) { |
src/ 下 goto error、goto end、goto fail 这几种写法加起来有四千五百多处。看到 goto,先跳到标签那里看看收尾做了什么,再回来读主体。
断言。 BUG_ON(x)(src/suricata-common.h:311 起)在条件成立时直接让程序退出,用来标记”绝不应该发生”的情况。DEBUG_VALIDATE_BUG_ON() 只在打开调试校验的编译选项时才生效。它们同时也是很好的文档:断言里写的,就是作者认为必须成立的前提。
条件编译。 src/ 下的 .c 和 .h 文件里,#if、#ifdef、#ifndef 加起来有两千七百多行。Packet 里的抓包方式 union 就是一个典型:
1 | union { |
编译时没打开 NFQ 支持,这个字段就根本不存在。第 9 篇的 MPM 默认算法也是这样,取决于编译时有没有 BUILD_HYPERSCAN。读源码时要清楚”我关心的这段代码,在我的编译配置里到底存不存在”。00 篇”怎么跟着读”里建议用 clangd 配 compile_commands.json,一个重要原因就是:它知道你的编译选项,会把没编译进来的分支标成灰色。
8. 和 Rust 打交道
8.0 里,HTTP 解析(libhtp)、DNS 等大量协议解析器、EVE 的 JsonBuilder 都是 Rust 写的(第 7、10 篇)。读源码时会频繁跨越 C 和 Rust 的边界,要知道几样东西:
- Rust 这边用
#[repr(C)]和extern "C"。RustParser(rust/src/applayer.rs:385)前面的#[repr(C)],让这个结构体按 C 的规则排列内存,C 代码才能直接读写它;pub unsafe extern "C" fn声明的函数,用 C 的调用约定导出,C 代码可以像调用普通 C 函数一样调用它。 - C 这边的声明是生成的。 编译时,cbindgen(配置在
rust/cbindgen.toml)扫描 Rust 代码,自动生成一个 C 头文件rust-bindings.h,src/rust.h:24包含了它。所以在 C 代码里看到htp_connp_request_data()、SCJbSetString()这类函数,在src/下是搜不到实现的,要去rust/目录找。 - 所有权要看约定。 Rust 有所有权检查,C 没有。跨边界传指针时,谁负责释放、能活多久,全靠约定:一般是谁分配谁释放,比如 Rust 分配的 JsonBuilder,由 C 调用 Rust 导出的释放函数来释放。读这类代码时,要留意配套的
*_free函数。
读源码时的几条建议
- 工具先配好。 用 clangd 或 CLion 这类能理解编译选项的工具,跳转定义、查找引用、悬停看宏展开,比纯文本搜索可靠得多。
- 函数指针看注册,宏看展开。 跳转不到实现时,去找赋值和注册的地方;看不懂宏时,用
gcc -E或 IDE 展开。 - 先认结构体,再读函数。
Packet、Flow、TcpSession、Signature这几个结构体弄清楚了,很多函数只是在填它们的字段。 - 读到锁就停一下。 先问清楚”这里锁的是什么、谁会同时访问”,再往下读。
这些知识单独拿出来都不算难,难的是它们在 Suricata 里同时出现、互相叠加。回到主线的任何一篇,带着这张清单再读一遍,会顺畅不少。