Suricata 源码阅读·番外:读懂 Suricata 需要哪些 C 语言知识

00 篇说,这个系列适合”有 C 基础”的读者。可”C 基础”这个说法太宽了:会写 for 循环、懂指针和结构体,翻开 Suricata 的源码,还是会被一些写法卡住。这篇番外把读 Suricata 时会遇到的、超出入门教材的 C 语言知识挑出来,每一项都配上源码里的真实例子和位置(基于 8.0.7),并说明前面哪几篇用到了它。

先给一个总览。读 Suricata,最常卡住人的是这几类:

  1. **函数指针和”接口表”**:整个系列反复出现的骨架。
  2. 宏:预处理器在 Suricata 里几乎是一门小语言。
  3. 结构体的内存布局:柔性数组、union、位域、packed。
  4. 把字节当结构体看的指针技巧:解码层的日常。
  5. 侵入式数据结构:链表和红黑树的节点长在业务结构体里。
  6. 并发:锁、条件变量、线程局部变量、原子操作。
  7. 编译器提示、错误处理、条件编译:每个文件都有,但很少有人解释。
  8. 和 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
2
typedef AppLayerResult (*AppLayerParserFPtr)(Flow *f, void *protocol_state,
AppLayerParserState *pstate, StreamSlice stream_slice, void *local_storage);

读法是: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
2
3
4
5
6
7
#define ENGINE_SET_EVENT(p, e) do { \
SCLogDebug("p %p event %d", (p), e); \
if ((p)->events.cnt < PACKET_ENGINE_EVENT_MAX) { \
(p)->events.events[(p)->events.cnt] = e; \
(p)->events.cnt++; \
} \
} while(0)

外面这层 do { } while (0) 不是循环,而是为了让多行语句的宏,用起来和一个普通语句完全一样:后面可以安全地跟分号,放在没有花括号的 if 里也不会出错。

语句表达式 ({ ... })。 紧挨着的 ENGINE_ISSET_EVENT(src/decode.h:1208)长这样:

1
2
3
4
5
#define ENGINE_ISSET_EVENT(p, e) ({ \
int r = 0; \
... \
r; \
})

这是 GCC/Clang 的扩展,不是标准 C:花括号里可以写多条语句、定义局部变量,最后一个表达式的值,就是整个宏的值。它让宏可以像函数一样”返回”结果。

可变参数和内置宏。 日志宏(src/util-debug.h:225):

1
#define SCLogInfo(...) SCLog(SC_LOG_INFO, __FILE__, __FUNCTION__, __LINE__, _sc_module, __VA_ARGS__)

... 和 __VA_ARGS__ 接收任意个参数;__FILE__、__FUNCTION__、__LINE__ 由编译器展开成当前的文件名、函数名、行号。所以每一条日志都能自动带上出处。

记号拼接 ##。 原子变量的声明(src/util-atomic.h:58):

1
2
#define SC_ATOMIC_DECLARE(type, name) \
_Atomic(type) name ## _sc_atomic__

## 把两段记号拼成一个标识符: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
2
3
4
5
6
7
8
9
10
11
typedef struct FlowHashKey4_ {
union {
struct {
uint32_t addrs[2];
...
uint16_t vlan_id[VLAN_MAX_LAYERS];
uint16_t pad[1];
};
const uint32_t u32[6];
};
} FlowHashKey4;

填的时候按字段一个个填,算哈希时把同一块内存当成 6 个 uint32_t 交给 hashword()。那个 pad 字段是为了把总长度补齐到 4 字节的整数倍,第 5 篇算哈希时用的就是它。

位域。 TcpSession 的状态(src/stream-tcp-private.h:285):

1
2
uint8_t state:4;     /* TCP 状态 */
uint8_t pstate:4; /* 上一个状态 */

冒号后面的数字是位数:两个 4 位的字段挤在一个字节里。TCP 状态不超过 16 种,用 4 位就够了。第 9 篇里 PrefilterNonPFDataSig 的 uint32_t sid : 30;(src/detect-engine-prefilter.c:537)也是同样的道理。每条流、每个包都要用到这些结构,省下的每个字节乘以几十万,就很可观了。

__attribute__((packed))。 以太网头(src/decode-ethernet.h:54):

1
2
3
4
5
typedef struct EthernetHdr_ {
uint8_t eth_dst[6];
uint8_t eth_src[6];
uint16_t eth_type;
} __attribute__((__packed__)) EthernetHdr;

编译器默认会在字段之间插填充字节来对齐,而网络协议头的布局是协议规定死的,不能有一个多余的字节。packed 告诉编译器:一个字节都不要填。只有这样,才能把收到的字节直接当成这个结构体来读,这就是下一节的内容。

4. 把字节当结构体看

解码层(第 4 篇)几乎就是由这一类技巧组成的。

强制类型转换 + 指针运算。 PacketSetEthernet()(src/decode.h:782)直接把原始数据的地址转换成头部结构体的指针:

1
p->l2.hdrs.ethh = (EthernetHdr *)buf;

接着跳过这一层,把剩下的数据交给下一层:

1
2
DecodeNetworkLayer(tv, dtv, SCNtohs(ethh->eth_type), p,
pkt + ETHERNET_HEADER_LEN, len - ETHERNET_HEADER_LEN);

pkt + ETHERNET_HEADER_LEN 是指针运算:对 uint8_t * 加 14,就是往后挪 14 个字节。没有拷贝,只是换了一个起点。

这种写法有两个前提:结构体必须是 packed 的(上一节);访问之前必须先确认长度够,否则就读出界了。第 4 篇说每一层解码都先检查长度,原因就在这里。

字节序。 网络上的数据是大端序,而 x86 这类常见的机器是小端序,直接读出来的多字节整数是反的。所以读端口要经过转换(src/decode-tcp.h:75):

1
#define TCP_GET_RAW_SRC_PORT(tcph)  SCNtohs((tcph)->th_sport)

SCNtohs()(src/suricata-common.h:431)就是标准库 ntohs() 的包装:network to host short。

位运算取字段。 IPv4 头的第一个字节里,高 4 位是版本号,低 4 位是头部长度(以 4 字节为单位)(src/decode-ipv4.h:95):

1
2
#define IPV4_GET_RAW_VER(ip4h)   (((ip4h)->ip_verhl & 0xf0) >> 4)
#define IPV4_GET_RAW_HLEN(ip4h) (uint8_t)(((ip4h)->ip_verhl & (uint8_t)0x0f) << (uint8_t)2)

& 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
2
3
4
typedef struct RootLogger_ {
...
TAILQ_ENTRY(RootLogger_) entries;
} RootLogger;

TAILQ_ENTRY 展开后是(src/queue.h:239):

1
2
3
4
struct {
struct type *tqe_next; /* next element */
struct type **tqe_prev; /* address of previous next element */
}

注意 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
2
#define FBLOCK_LOCK(fb) SCSpinLock(&(fb)->s)    /* FBLOCK_SPIN */
#define FBLOCK_LOCK(fb) SCMutexLock(&(fb)->m) /* FBLOCK_MUTEX */

自旋锁拿不到时原地空转,适合持有时间极短的场景;互斥锁拿不到时让出 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
2
#define SC_ATOMIC_GET(name) \
atomic_load(&(name ## _sc_atomic__))

第 5 篇判断是否进入紧急模式的 SC_ATOMIC_GET(flow_flags) & FLOW_EMERGENCY,就是一次原子读。原子操作只保证单个变量的读写不被打断,不能代替锁去保护多个变量之间的一致性。

7. 编译器提示、错误处理、条件编译

这三样在几乎每个文件里都有,单独看都不难,但不知道的话会被反复打断。

likely() / unlikely()。 src/util-optimize.h:32 起:

1
2
#define likely(expr)   __builtin_expect(!!(expr), 1)
#define unlikely(expr) __builtin_expect(!!(expr), 0)

告诉编译器”这个条件大概率成立 / 不成立”,让它把常走的分支排在一起,提高指令缓存和分支预测的命中率。代码里的 if (unlikely(len < ETHERNET_HEADER_LEN)),读的时候直接当成普通的 if,同时知道”这是异常情况”就行。!! 是把任意值规整成 0 或 1。

头文件里的 static inline。 PacketSetEthernet()、DecodeLinkLayer() 这些都定义在 decode.h 里,前面带 static inline。这样每个包含它的 .c 文件都有一份可以内联的副本,省掉函数调用的开销。热路径上的小函数大多是这样写的。

goto 做错误处理。 C 没有异常,也没有析构函数。Suricata 的惯用法是:出错就跳到函数末尾的统一出口,在那里释放资源、返回错误码。第 7 篇的 AppLayerParserParse() 就是这样:

1
2
3
4
5
6
7
8
9
10
11
   if (res.status < 0) {
AppLayerIncParserErrorCounter(tv, f);
goto error;
}
...
error:
if (f->proto == IPPROTO_TCP) {
StreamTcpDisableAppLayer(f);
}
AppLayerParserSetEOF(pstate);
SCReturnInt(-1);

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
2
3
4
5
6
7
8
9
union {
#ifdef NFQ
NFQPacketVars nfq_v;
#endif /* NFQ */
#ifdef AF_PACKET
AFPPacketVars afp_v;
#endif
...
};

编译时没打开 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 里同时出现、互相叠加。回到主线的任何一篇,带着这张清单再读一遍,会顺畅不少。