wpcap是windows平台下著名的数据包嗅探库,在linux下叫libpcap,数据包嗅探软件wireshark就是用来这个库,其中实现了十分高效的数据包过滤算法。很久以前就想看一下他具体的过滤机制,不过那时候没看懂。这两天看到一篇文章提到了winpcap的中间码是SSA格式的,所以又提起兴趣看了一下,发现很多地方都已经能看懂了。(好像新下的源码比以前多了很多注释?很多源码可能维护者也没看懂,注释里都打了问号。)
wpcap对过滤表达式的编译应该说是十分的简洁和清晰,而且中间码也是麻雀虽小五脏俱全,后端优化覆盖了几个基本的优化,个人感觉完全可以当做编译原理课程教科书式的范例代码(只是没有循环,所以和循环相关的算法一个都没有)。
过滤器编译的实现分为三个部分,全都在pcap_compile(gencode.c)函数中完成。
解析阶段将用户传过来的过滤表达式解析为icode(intermediate code?我也不知道叫什么)。实现上比较偷懒直接用了flex和bison,规则由根目录下的scanner.l和grammar.y提供。由规则制导翻译成一系列的block组成的cfg。
由于过滤表达式特殊的性质,这个cfg有一些不一样的性质:
因为解析主要由flex和bison实现,具体实现这里不再多做阐述。
优化在bpf_optimize中实现,其实我仔细看了下,icode应该不是SSA格式的,一个使用仍然可能会对应多个定值,而且在分支汇合处并没有phi指令的计算。
首先解释一些相关的结构体,不然后面可能会比较疑惑。首先是指令:
struct stmt {
// 指令码
int code;
// 如果是跳转指令,这里是true分支
struct slist *jt; /*only for relative jump in block*/
// 如果是跳转指令,这里是false分支
struct slist *jf; /*only for relative jump in block*/
// 操作数
bpf_u_int32 k;
};
可以看到,显式的操作数只有一个k,其余的都是隐式操作数,也就是说除了分支指令,这是一个一地址码。
边的结构:
struct edge {
// 边ID,用于边映射
int id;
// 边指令,用于分支优化
// 其实就是基本块的指令,但这里添加了正负号用于区分真分之和假分支
int code;
// 支配边比特向量
uset edom;
// 两头的基本块
struct block *succ;
struct block *pred;
// 入边兄弟结点间的链接
struct edge *next; /* link list of incoming edges for a node */
};
基本块的结构:
struct block {
// 块ID,用于块映射
int id;
// 非分支指令序列
struct slist *stmts; /* side effect stmts */
// 分支指令
struct stmt s; /* branch stmt */
int mark;
u_int longjt; /* jt branch requires long jump */
u_int longjf; /* jf branch requires long jump */
// 在dag的底基层
int level;
// 在指令序列的偏移,用于代码转换
int offset;
// 分支语义
int sense;
// 真分支
struct edge et;
// 假分支
struct edge ef;
// 用于拉链回填
struct block *head;
// 由于层级链表
struct block *link; /* link field used by optimizer */
// 支配结点比特向量
uset dom;
// 传递闭包比特向量
uset closure;
// 入边
struct edge *in_edges;
// def和kill比特向量
atomset def, kill;
// 活跃信息比特向量
atomset in_use;
atomset out_use;
// 分支的value
int oval;
// 所有寄存器的value
bpf_u_int32 val[N_ATOMS];
};
在bpf_optimize中,第一步调用了opt_init执行内存初始化。首先计算所有基本块的数量,并给每个基本块分配一个id,然后申请了一个数组,建立id到基本块的映射。
/*
* First, count the blocks, so we can malloc an array to map
* block number to block. Then, put the blocks into the array.
*/
unMarkAll(ic);
// 计算所有块的数量
n = count_blocks(ic, ic->root);
opt_state->blocks = (struct block **)calloc(n, sizeof(*opt_state->blocks));
...
unMarkAll(ic);
opt_state->n_blocks = 0;
// 给基本块分配id,并通过opt_state->blocks数组来建立id到基本块的映射。
number_blks_r(opt_state, ic, ic->root);
给基本块之间的边建立同样的映射,前面说过所有的基本块都有两个分支,所以这里基本块×2就行:
opt_state->n_edges = 2 * opt_state->n_blocks;
opt_state->edges = (struct edge **)calloc(opt_state->n_edges, sizeof(*opt_state->edges));
然后建立dag的层链表数组:
/*
* The number of levels is bounded by the number of nodes.
*/
opt_state->levels = (struct block **)calloc(opt_state->n_blocks, sizeof(*opt_state->levels));
计算边的比特向量字数和块的比特向量字数,后面分配比特向量时就依照这个值来分配。
opt_state->edgewords = opt_state->n_edges / (8 * sizeof(bpf_u_int32)) + 1;
opt_state->nodewords = opt_state->n_blocks / (8 * sizeof(bpf_u_int32)) + 1;
分配所有需要用到的比特向量:
/* XXX */
// 这里将所有后面要用到的比特向量全部分配到一个内存中
opt_state->space = (bpf_u_int32 *)malloc(2 * opt_state->n_blocks * opt_state->nodewords * sizeof(*opt_state->space)
+ opt_state->n_edges * opt_state->edgewords * sizeof(*opt_state->space));
...
p = opt_state->space;
opt_state->all_dom_sets = p;
// 支配节点比特向量
for (i = 0; i < n; ++i) {
opt_state->blocks[i]->dom = p;
p += opt_state->nodewords;
}
// 传递闭包比特向量
opt_state->all_closure_sets = p;
for (i = 0; i < n; ++i) {
opt_state->blocks[i]->closure = p;
p += opt_state->nodewords;
}
opt_state->all_edge_sets = p;
for (i = 0; i < n; ++i) {
register struct block *b = opt_state->blocks[i];
// 支配边比特向量,包括真分支边和假分支边
b->et.edom = p;
p += opt_state->edgewords;
b->ef.edom = p;
p += opt_state->edgewords;
// 建立边id到边的映射
b->et.id = i;
opt_state->edges[i] = &b->et;
b->ef.id = opt_state->n_blocks + i;
opt_state->edges[opt_state->n_blocks + i] = &b->ef;
// 填充边的前继
b->et.pred = b;
b->ef.pred = b;
}
分配值哈希表和值链表,用于value的传播。这里的value实际上就是数据流值:
max_stmts = 0;
for (i = 0; i < n; ++i)
max_stmts += slength(opt_state->blocks[i]->stmts) + 1;
/*
* We allocate at most 3 value numbers per statement,
* so this is an upper bound on the number of valnodes
* we'll need.
*/
// 每个语句假设会有三个value
opt_state->maxval = 3 * max_stmts;
opt_state->vmap = (struct vmapinfo *)calloc(opt_state->maxval, sizeof(*opt_state->vmap));
...
opt_state->vnode_base = (struct valnode *)calloc(opt_state->maxval, sizeof(*opt_state->vnode_base));
第二部就开始进行基本块的优化和过程内优化,在opt_loop函数中实现。不要被这名字迷惑了,这不是循环优化的意思,而是迭代到不动点的意思。这个函数的调用流程:
do {
opt_state->done = 1;
find_levels(opt_state, ic);
find_dom(opt_state, ic->root);
find_closure(opt_state, ic->root);
find_ud(opt_state, ic->root);
find_edom(opt_state, ic->root);
opt_blks(opt_state, ic, do_stmts);
} while (!opt_state->done);
首先调用find_levels构造层级链表。因为没有环的缘故,所以数据流信息只会向下传播。在Tarjan的《Finding Dominators in Practice》中说明了在迭代算法的实现中,BFS往往比DFS拥有更好的效率,这里的层级链表实际上是无环流图BFS树的一种表现形式。
static void
find_levels(opt_state_t *opt_state, struct icode *ic)
{
memset((char *)opt_state->levels, 0, opt_state->n_blocks * sizeof(*opt_state->levels));
unMarkAll(ic);
find_levels_r(opt_state, ic, ic->root);
}
在find_dom中会为所有结点构造支配结点信息,同样是由于没有环路的原因,所以节点x的idom(x)必然是前继结点的最近公共祖先,这里直接用最简单的对所有前继的交集运算即可算出结点的dom(x)。
/*
* Find dominator relationships.
* Assumes graph has been leveled.
*/
static void
find_dom(opt_state_t *opt_state, struct block *root)
{
int i;
struct block *b;
bpf_u_int32 *x;
/*
* Initialize sets to contain all nodes.
*/
x = opt_state->all_dom_sets;
i = opt_state->n_blocks * opt_state->nodewords;
// 因为要执行交集运算,所有节点初始化为全部位为1
while (--i >= 0)
*x++ = 0xFFFFFFFFU;
/* Root starts off empty. */
// 第一个节点初始化为0
for (i = opt_state->nodewords; --i >= 0;)
root->dom[i] = 0;
/* root->level is the highest level no found. */
// 沿着层级对前继做交集运算,向下传递支配信息
for (i = root->level; i >= 0; --i) {
for (b = opt_state->levels[i]; b; b = b->link) {
SET_INSERT(b->dom, b->id);
if (JT(b) == 0)
continue;
SET_INTERSECT(JT(b)->dom, b->dom, opt_state->nodewords);
SET_INTERSECT(JF(b)->dom, b->dom, opt_state->nodewords);
}
}
}
find_closure计算传递闭包集。由于没有循环,传递闭包其实就是前继中所有可能到达该节点路径上所有节点的集合,所以简单的对所有前继做并集运算即可得出(这个信息并没有用到???)。
/*
* Find the backwards transitive closure of the flow graph. These sets
* are backwards in the sense that we find the set of nodes that reach
* a given node, not the set of nodes that can be reached by a node.
*
* Assumes graph has been leveled.
*/
static void
find_closure(opt_state_t *opt_state, struct block *root)
{
int i;
struct block *b;
/*
* Initialize sets to contain no nodes.
*/
// 因为做并集运算,首先全部初始化成0
memset((char *)opt_state->all_closure_sets, 0,
opt_state->n_blocks * opt_state->nodewords * sizeof(*opt_state->all_closure_sets));
/* root->level is the highest level no found. */
// 对节点做并集运算,向下传递闭包信息
for (i = root->level; i >= 0; --i) {
for (b = opt_state->levels[i]; b; b = b->link) {
SET_INSERT(b->closure, b->id);
if (JT(b) == 0)
continue;
SET_UNION(JT(b)->closure, b->closure, opt_state->nodewords);
SET_UNION(JF(b)->closure, b->closure, opt_state->nodewords);
}
}
}
下一步是在find_ud中计算ud链。在第一个循环中,每遍历到一个节点,就在compute_local_ud中计算出当前基本块的use,def和kill集,其实就是逐条指令的看需要用到哪些寄存器。
这里说下pcap的虚拟寄存器。pcap中一共有18个存储单元,其中单元0-15是普通的存储寄存器,A_ATOM是计数寄存器,X_ATOM是索引寄存器。
在计算出了每个block的use,def和kill集合后,第二个循环就开始将这些信息向上传播计算每个块入口和出口处的use集。
/*
* Assume graph is already leveled.
*/
static void
find_ud(opt_state_t *opt_state, struct block *root)
{
int i, maxlevel;
struct block *p;
/*
* root->level is the highest level no found;
* count down from there.
*/
maxlevel = root->level;
// 计算每个block的use,def和kill
for (i = maxlevel; i >= 0; --i)
for (p = opt_state->levels[i]; p; p = p->link) {
compute_local_ud(p);
p->out_use = 0;
}
// 传递use,def信息
for (i = 1; i <= maxlevel; ++i) {
for (p = opt_state->levels[i]; p; p = p->link) {
p->out_use |= JT(p)->in_use | JF(p)->in_use;
p->in_use |= p->out_use &~ p->kill;
}
}
}
在find_edom中,会计算所有边的支配信息,原理和结点的支配节点计算相同,表述的含义自然也是类似的。
至此,我们已经算完了结点支配节点集,传递闭包集,ud信息和边支配边集,所有的信息都已经计算得到了,下面就开始在opt_blks函数中执行优化算法了。
opt_blks中,首先调用find_inedges来初始化所有节点的前继结点信息(之前只在语法分析时初始化了后继信息,但没有生成前继的信息)。然后逐层的通过opt_blk优化所有基本块。
opt_blk中,首先计算当前块的所有前继出口处寄存器值的交集,这样就可以得到在这个块的入口处所有寄存器的定值信息。
/*
* Inherit values from our predecessors.
*
* First, get the values from the predecessor along the
* first edge leading to this node.
*/
memcpy((char *)b->val, (char *)p->pred->val, sizeof(b->val));
/*
* Now look at all the other nodes leading to this node.
* If, for the predecessor along that edge, a register
* has a different value from the one we have (i.e.,
* control paths are merging, and the merging paths
* assign different values to that register), give the
* register the undefined value of 0.
*/
while ((p = p->next) != NULL) {
for (i = 0; i < N_ATOMS; ++i)
if (b->val[i] != p->pred->val[i])
b->val[i] = 0;
}
然后依据计算得到的这个定值信息一次有优化每个个基本块。这里可以拿两个说明一下。
case BPF_LD|BPF_ABS|BPF_W:
case BPF_LD|BPF_ABS|BPF_H:
case BPF_LD|BPF_ABS|BPF_B:
v = F(opt_state, s->code, s->k, 0L);
vstore(s, &val[A_ATOM], v, alter);
break;
这是一个load指令,从数据包的绝对地址处加载一个值到A_ATOM寄存器中。首先F函数会给这个地址处的值生成一个抽象的value,如果之前没有加载过这个值,则这个value是新建的,否则是之前已经保存在hash表中的value。在vstore中会判断val(就是上一步计算出来的每个寄存器在前继节点的定值信息数组)中的A_ATOM是否就是这个之前已经生成的value,如果是,完全可以复用上一个load指令,于是这个load指令就会被重写为NOP指令并在后面的死代码消除中被清理掉。
case BPF_ALU|BPF_ADD|BPF_K:
case BPF_ALU|BPF_SUB|BPF_K:
case BPF_ALU|BPF_MUL|BPF_K:
case BPF_ALU|BPF_DIV|BPF_K:
case BPF_ALU|BPF_MOD|BPF_K:
case BPF_ALU|BPF_AND|BPF_K:
case BPF_ALU|BPF_OR|BPF_K:
case BPF_ALU|BPF_XOR|BPF_K:
case BPF_ALU|BPF_LSH|BPF_K:
case BPF_ALU|BPF_RSH|BPF_K:
op = BPF_OP(s->code);
if (alter) {
if (s->k == 0) {
/*
* Optimize operations where the constant
* is zero.
*
* Don't optimize away "sub #0"
* as it may be needed later to
* fixup the generated math code.
*
* Fail if we're dividing by zero or taking
* a modulus by zero.
*/
if (op == BPF_ADD ||
op == BPF_LSH || op == BPF_RSH ||
op == BPF_OR || op == BPF_XOR) {
s->code = NOP;
break;
}
if (op == BPF_MUL || op == BPF_AND) {
s->code = BPF_LD|BPF_IMM;
val[A_ATOM] = K(s->k);
break;
}
if (op == BPF_DIV)
opt_error(opt_state,
"division by zero");
if (op == BPF_MOD)
opt_error(opt_state,
"modulus by zero");
}
if (opt_state->vmap[val[A_ATOM]].is_const) {
fold_op(opt_state, s, val[A_ATOM], K(s->k));
val[A_ATOM] = K(s->k);
break;
}
}
val[A_ATOM] = F(opt_state, s->code, val[A_ATOM], K(s->k));
break;
这是逻辑运算指令的优化。
在第一个if中,会判断操作数是否是0,如果是0,对于加减等运算实际上是无效的,这条指令就会被重写为NOP,而对于乘法和AND指令,则转换为一条load指令,这条指令把A_ATOM直接置位0。而对于除法和取模运算,很明显,操作数是不能为0的。
在第二个if中,会判断被操作数是否是一个常量,如果是,则直接进行算术优化,并用load重新该指令。
最后会进行窥孔优化和死代码消除。
opt_peep(opt_state, b);
opt_deadstores(opt_state, b);
对于窥孔优化是如何进行的,opt_peep里的注释十分的详尽,这里也不多阐述了。
在opt_deadstores中,调用deadstmt来找到一个块内有连续两个对同一个寄存器的def,而且中间没有use的指令对,并将第一处def改写为NOP,实际上第一个def就是一个死代码。在最后还会检查,如果一个寄存器在出口并不alive,但是在基本块最后一个def后却没有use,这种也是死代码,可以消除掉。
// 消除两个连续的对同一个寄存器的def
for (s = b->stmts; s != 0; s = s->next)
deadstmt(opt_state, &s->s, last);
deadstmt(opt_state, &b->s, last);
// 消除出口处不活跃的def
for (atom = 0; atom < N_ATOMS; ++atom)
if (last[atom] && !ATOMELEM(b->out_use, atom)) {
last[atom]->code = NOP;
opt_state->done = 0;
}
死代码消除后,会进行分支优化。前面说过,过滤器在执行过程中只关注结果,不在乎过程,所以如果分支指令是等价的,那么结果自然也是一致的,于是中间的其他流程自然也不必在去判断。
怎么算分支等价呢,有两点
基于这两个规则进行了分支优化:
// 左分支和右分支相同,则将前继的后继改为当前结点的后继
// 当前结点是没有必要执行的
if (JT(ep->succ) == JF(ep->succ)) {
/*
* Common branch targets can be eliminated, provided
* there is no data dependency.
*/
if (!use_conflict(ep->pred, ep->succ->et.succ)) {
opt_state->done = 0;
ep->succ = JT(ep->succ);
}
}
/*
* For each edge dominator that matches the successor of this
* edge, promote the edge successor to the its grandchild.
*
* XXX We violate the set abstraction here in favor a reasonably
* efficient loop.
*/
top:
for (i = 0; i < opt_state->edgewords; ++i) {
register bpf_u_int32 x = ep->edom[i];
// 从比特向量中迭代所有的支配边
while (x != 0) {
k = lowest_set_bit(x);
x &=~ ((bpf_u_int32)1 << k);
k += i * BITS_PER_WORD;
// 当前分支的走向是确定的
target = fold_edge(ep->succ, opt_state->edges[k]);
/*
* Check that there is no data dependency between
* nodes that will be violated if we move the edge.
*/
// 在确定没有数据依赖冲突后可以安全的将后继分支作为前继分支的后继
if (target != 0 && !use_conflict(ep->pred, target)) {
opt_state->done = 0;
ep->succ = target;
if (JT(target) != 0)
/*
* Start over unless we hit a leaf.
*/
goto top;
return;
}
}
}
无论哪一种优化,都会进行数据依赖的分析,实际是判断在出口处的定值是否一致,否则可能会缺少数据流信息导致执行结果不正确。
基本块优化结束后,会调用intern_blocks来消除指令序列完全相同的基本块,这里只是逐个基本块逐条指令的比较了一下,相同的基本块则直接合并。
for (i = opt_state->n_blocks - 1; --i >= 0; ) {
if (!isMarked(ic, opt_state->blocks[i]))
continue;
// 比较当前块和其他块是否相同
for (j = i + 1; j < opt_state->n_blocks; ++j) {
if (!isMarked(ic, opt_state->blocks[j]))
continue;
if (eq_blk(opt_state->blocks[i], opt_state->blocks[j])) {
opt_state->blocks[i]->link = opt_state->blocks[j]->link ?
opt_state->blocks[j]->link : opt_state->blocks[j];
break;
}
}
}
// 消除掉相同的基本块
for (i = 0; i < opt_state->n_blocks; ++i) {
p = opt_state->blocks[i];
if (JT(p) == 0)
continue;
if (JT(p)->link) {
done1 = 0;
JT(p) = JT(p)->link;
}
if (JF(p)->link) {
done1 = 0;
JF(p) = JF(p)->link;
}
}
转换节点在icode_to_fcode中实现,主要是将离散的控制流结构转换为连续的结构,最终生成连续的指令序列,有点类似于代码生成吧。转换的逻辑也很简单,主要是处理跳转,有些长跳转会被转换为一系列的段跳转。
这三个阶段完毕后,指令序列会被保存到当前会话的上下文中,后面在从网卡捕获到数据包后,便会在这个数据包上执行指令序列,并通过最终返回的true还是false决定包是被显示还是不显示。执行指令序列的虚拟机在bpf_filter.c中实现,是一个很小的执行引擎,实现也十分的简单。
(由于博主水平有限,如有讲述错误之处,请留言指正。)