DPDK提供了三种classify算法:最长匹配LPM、精确匹配(Exact Match)和通配符匹配(ACL)。
其中的ACL算法,本质是步长为8的Multi-Bit Trie,即每次可匹配一个字节。一般来说步长为n时,Trie中每个节点的出边为2^n,但DPDK在生成run-time structures时,采用DFA/QRANGE/SINGLE这几种不同的方式进行数据结构的压缩,有效去除了冗余的出边。本文将为大家介绍ACL算法的基本原理,主要内容包括:trie树的构造、运行时的node array生成和匹配原理。对于ACL接口的使用,参考DPDK的官方文档即可。
0.规则的构造
0.1 定义匹配区域 rte_acl_field_def
ACL规则主要面向的是IP流量中的五元组信息,即IP/PORT/PROTO,算法在这个基础上进行了抽象,提供了三种类型的匹配区域:
RTE_ACL_FIELD_TYPE_BITMASK:单字节区域如ip头部一个字节的proto字段;
RTE_ACL_FIELD_TYPE_MASK:采用MASK方式描述,一般对应4字节的源/目的地址;
RTE_ACL_FIELD_TYPE_RANGE:一般对应TCP或UDP头部2字节的PORT区域。
熟悉这三种类型的使用后,完全可以用它们去匹配网络报文的其它区域,甚至将其应用到其它场景中。
具体来说,rte_acl_field_def有5个成员:type、size、field_index、input_index、offset。
如果要深入理解算法,可以思考这几个字段的意义,或者换个角度来看:
前面提到的三种type,往往对应3种size,那么size字段是多余的吗,什么时候size与一般情况下的使用不同,为什么?
field_index,它一般采用enum类型定义,在 rte_acl_field_def结构中也基本是连续的,是否可以去掉?
offset用来指定匹配时data的偏移,那么是不是意味着匹配时不是逐字节匹配?
有了offset指名偏移,为什么还要input_index字段呢?
0.2 定义具体规则 acl_ipv4_rule
对于规则的定义,要注意如下两点:
a)对于每一个field给出明确的定义
比如定义了5个field,那么请给出每一个的具体定义:
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0x0,},
.field[1] = {.value.u32 = IPv4(0, 0, 0, 0), .mask_range.u32 = 0,},
.field[2] = {.value.u32 = IPv4(192, 168, 2, 4), .mask_range.u32 = 32,},
.field[3] = {.value.u16 = 0, .mask_range.u16 = 0xffff,},
.field[4] = {.value.u16 = 1024, .mask_range.u16 = 0xffff,},
像field[1]中IP和mask都为0,表示匹配所有的IP地址;field[3]中range由0到65535,表示匹配所有。类似这样的全匹配一定要显示的定义出来,因为如果不明确定义,这些字段的值取决于编译器的,最后编译的ACL规则很可能与原有设想存在偏差。
b)field的全匹配方式
如果在规则中,对于某个field不进行限制,对于不同type的field,规则书写时有一定差异:
对于BITMASK和MASK类型,全0代表匹配所有,如上例中的field[0]、field[1];
对于RANGE,则按照上述field[3]中的形式定义。
1.trie树的构造
规则定义好后,会转换为trie树并最终合并到一起。
实际处理过程中,build_trie函数会自底向上的将rule中的每个field转换为node,然后将这些node合并生成这条rule的trie,最后将这个trie与已有的trie进行merge,最终生成整个rule set的trie。
1.1 node数据结构
tire由node组成,其主要数据成员如下:
struct rte_acl_node {
struct rte_acl_bitset values;
uint32_t num_ptrs; /* number of ptr_set in use */
struct rte_acl_ptr_set *ptrs; /* transitions array for this node */
int32_t match_flag;
struct rte_acl_match_results *mrt; /* only valid when match_flag != 0 */
uint64_t node_index; /* index for this node */
int32_t match_index; /* index to match data */
uint32_t node_type;
int32_t fanout;
union {
char transitions[RTE_ACL_QUAD_SIZE];
/* boundaries for ranged node */
uint8_t dfa_gr64[RTE_ACL_DFA_GR64_NUM];
};
…
};
其中values/num_ptrs/ptrs/match_flag/mrt用于描述trie树。
node_index/match_index/node_type/fanout/union用于记录构造node array时的中间数据。
node中values成员用于记录匹配信息,ptrs则用于描述node的出边,用于指向转换后的node。
values采用bitmap进行压缩,其数据结构为struct rte_acl_bitset values; 一个byte取值范围是[0,255],可通过256个bit位来进行对应,并实现byte值的快速查找:即通过第x位的bit值是否为1来判断是否包含数值x(0 <= x < 256)。
比如bitmap 0x040 = 1 << 6,可匹配6(IPPROTO_TCP)
num_ptrs用于描述出边数目,ptrs即为实际的出边,它记录了其匹配值values和匹配后的节点指针。
match_flag和mrt则用于记录匹配结果,trie树中叶子节点一定是记录匹配结果的节点。
trie树其详细结构比较复杂,这里将其结构进行简化,如下所示:
上图的trie树有4个node,通过ptrs进行指向,values字段为匹配值的bitmap表示,为了表述的简洁,后续会采用simple的方式进行描述。
在trie simple中,实心节点表示匹配节点,边上的数字代表匹配值(为便于阅读,采用实际值而不再是bitmap形式),…代表其它匹配值。
1.1 由field到node
不同type的field,转换为node的方式会有所不同。
目前提供的3种类型:BITMASK描述一个byte的匹配,支持mask模式;MASK用于描述4个byte的匹配,支持mask模式;RANGE描述2个byte的匹配,此时mask表示上限。
field到node的转换,见build_trie中的for循环,具体转换函数则参考:
acl_gen_mask_trie和acl_gen_range_trie
对于BITMASK,如{.value.u8 = 6, .mask_range.u8 = 0xff,},它最后的转换形式如下:
对于MASK,如{.value.u32 = IPv4(192, 168, 2, 4), .mask_range.u32 = 32,},它会转换为如下形式:
对于RANGE,如{.value.u16 = 1024, .mask_range.u16 = 0xffff,},它会转换为如下形式:
上述RANGE采用的bitmap值而不是匹配值,这里对bitmap的计算进行简要说明:RANGE表示两个字节,1024 = 0x400,范围是0x400 ~ 0xFFFF,对于高字节,匹配的值为4255,对于低字节匹配的值为0255,所以最终的bitmap形式为0xFF::FFF0(高位字节)与0xFF::FF(低位字节)。
1.2 由field到rule trie
构造field的node时,总会在结尾添加一个空的end节点,最后一个field除外(它是match node)。在for循环中每完成了一个field的解析后,会将其合并到root中,从而生成这个rule的trie。
合并前,也会先构造一个空的end node(见build_trie函数中,while循环下的root创建),让它与field构成的node头合并,因为不相交,所以merge时会将匹配信息合并到end node并释放原有的头,并将field链的end节点返回(保存到end_prev中),下次合并时,就用此end节点与新的node头合并。
循环遍历完所有的field后,这些node就串联起来了,构成这个rule的trie。
root = acl_alloc_node(context, 0);
root->ref_count = 1;
end = root;
for (n = 0; n < rule->config->num_fields; n++) {
field_index = rule->config->defs[n].field_index;
fld = rule->f->field + field_index;
end_prev = end;
...
/* merge this field on to the end of the rule */
if (acl_merge_trie(context, end_prev, merge, 0,
NULL) != 0) {
return NULL;
}
}
1.3 rule trie的合并
对于多个rule,每次构造完成后会merge到整体的trie中。
这里详细介绍下merge算法原理,其实仔细阅读acl_merge_trie函数的注释即可。
对于node A和node B的merge, acl_merge_trie函数返回一个节点,这个节点指向它们路径的交集。
这里给出三个例子用于展示merge前后的变化。为了减少状态点,构造rte_acl_field_def如下:
struct rte_acl_field_def ipv4_defs[NUM_FIELDS_IPV4] = {
{
.type = RTE_ACL_FIELD_TYPE_BITMASK,
.size = sizeof(uint8_t),
.field_index = PROTO_FIELD_IPV4,
.input_index = RTE_ACL_IPV4_PROTO,
.offset = 0,
},
{
.type = RTE_ACL_FIELD_TYPE_MASK,
.size = sizeof(uint32_t),
.field_index = SRC_FIELD_IPV4,
.input_index = RTE_ACL_IPV4_SRC,
.offset = offsetof(struct ipv4_hdr, src_addr) -
offsetof(struct ipv4_hdr, next_proto_id),
},
};
示例1:
struct acl_ipv4_rule acl_rules[] = {
{
.data = {.userdata = 102, .category_mask = 1, .priority = 1},
.field[0] = {.value.u8 = IPPROTO_TCP, .mask_range.u8 = 0xff,},
.field[1] = {.value.u32 = IPv4(192, 168, 1, 2), . mask_range.u32 = 32,},
},
{
.data = {.userdata = 104, .category_mask = 1, .priority = 2},
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0x0,},
.field[1] = {.value.u32 = IPv4(192, 168, 4, 0), .mask_range.u32 = 24,},
},
};
acl_rules[1]为trie A,acl_rules[0]对应trie B,最终trie B合并到trie A上,具体如下:
1和1’合并时,因为level为0,所以1’直接合并到1中;
4和4’合并时,因为节点无交集,所以创建新节点c1(node 4的拷贝),并将4'上的边拷贝到c1中。
示例2,rule类别相同,但优先级不同:
struct acl_ipv4_rule acl_rules[] = {
{
.data = {.userdata = 102, .category_mask = 1, .priority = 1},
.field[0] = {.value.u8 = IPPROTO_TCP, .mask_range.u8 = 0xff,},
.field[1] = {.value.u32 = IPv4(192, 168, 1, 2), . mask_range.u32 = 32,},
},
{
.data = {.userdata = 104, .category_mask = 1, .priority = 2},
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0x0,},
.field[1] = {.value.u32 = IPv4(192, 168, 0, 0), .mask_range.u32 = 16,},
},
};
acl_rules[1]为trie A,acl_rules[0]对应trie B,最终trie B合并到trie A上,具体如下:
6和6’是match node,类别相同,且6的优先级为2大于6’的优先级。
6和6’合并时,直接返回6。而前面创建的新节点,如d1,已包含5’的所有边(非ACL_INTERSECT_B),所以最终返回5,free d1。
同理依次往上回溯,a4,b3,c2,也依次被释放,最终merge的trie即为原来的trie A。
示例3,rule类别不同,优先级相同:
struct acl_ipv4_rule acl_rules[] = {
{
.data = {.userdata = 102, .category_mask = 1, .priority = 1},
.field[0] = {.value.u8 = IPPROTO_TCP, .mask_range.u8 = 0xff,},
.field[1] = {.value.u32 = IPv4(192, 168, 1, 2), . mask_range.u32 = 32,},
},
{
.data = {.userdata = 104, .category_mask = 2, .priority = 1},
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0x0,},
.field[1] = {.value.u32 = IPv4(192, 168, 0, 0), .mask_range.u32 = 16,},
},
};
acl_rules[1]为trie A,acl_rules[0]对应trie B,最终trie B合并到trie A上,具体如下:
6和6’是match node,因为类别不同,所以最终创建了新node e1,这也导致示例3和示例2最终merge结果的不同。
合并是一个递归的过程,逆向思考构造过程会有助于理解算法。另外,在build_trie之前会sort_rule,匹配范围更大的rule会放到前面优先构造trie,个人为这样node A包含node B的概率更大,这可能也是merge时创建的node C是A的拷贝而不是B的拷贝的原因,因为这样出现ACL_INTERSECT_B的概率相对较低。
一些说明:
1.merge完后,对于有分支的边,”…” 表示匹配除了其它分支以外的所有值,图中表述不准确(不太好画);
2.对于虚线的节点,表示merge完后会被释放;红色节点表示新建的node;红色的边,表示有修改或新增的边;实心节点表示match节点。
3.node创建的顺序是a\b\c\…,但决定node是否free,则是1\2\3\…,见红色节点的名称。
2.node array的构造
trie树构造完成后,会将其由指针跳转的形式转换为等效的数组索引形式,即node array,既可让匹配数据更紧凑,也可提高匹配算法的效率。
采用node array的方式进行状态点的压缩是很常见的优化方式,比如snort里面的ac算法(acsmx.c):
笔者也曾经做过类似的优化,通过将出边由指针方式修改为索引方式,整个匹配tree的内存占用只需要原来的1/5。
将指针方式转换为node array形式是优化的第一步,对于Next[256]出边又可以采用多种压缩方式,比如snort中新的ac算法(acsmx2.c),就采用了Sparse rows和Banded rows等多种压缩方式,但其原理是将出边进行映射转换,本质上还是做DFA状态跳转。
2.1 node type
DPDK对边的压缩方式与上述类似,不过它优化的粒度更细,不同type的node有不同的压缩方式:
对于出边比较多的节点它会被标记为RTE_ACL_NODE_DFA;
对于出边比较少的节点则使用RTE_ACL_NODE_QRANGE;
如果节点出边仅一条则为RTE_ACL_NODE_SINGLE;
对于Match节点会被标记为RTE_ACL_NODE_MATCH。
比如在示例三中,node 1为DFA节点(根节点强制使用DFA方式),2、3、a5、b4、c3、d2为QRANGE,4、5为SINGLE,6、e1为MATCH。
2、3、a5、b4虽然在图上仅有一条有效边,但它不为SINGLE,因为对于无效的匹配其实也会有出边,所以它的真实出边数目并不唯一,只有像4、5这类全匹配节点才是真正的SINGLE节点。
在构造node array前,会调用acl_calc_counts_indices函数更新node的node type,fanout等信息。
node type依据其fanout值决定,fanout计算见acl_count_fanout函数,其原理是:
1.计算node的values(bitmap)中连续为0的区间数;
2.计算node的各个出边values(bitmap)中连续为1的区间数;
3.将上述结果相加即为fanout值;
比如对于示例3中的d2节点:
1.node的values为:0xFFFF::FFFF,全1,bit为0的区间不存在
2.对于指向node 6的出边,其values为:0xFFFF::FFFB,连续1的区间数为2(1111...1011)
3.对于指向node e1的出边,其values为:0x0000::0004,连续1的区间数为1(0000...0100)
4.故计算得到的fanout值为3
fanout计算完成后,若其值为1则为SINGLE节点,(1, 5]为QRANGE节点,(5, 256]为DFA节点。
注意:对于trie树的root节点,不论fanout值为多少,会强制将其构造为DFA节点,且其fanout值会重新计算。
2.2 内存布局
type和fanout计算完成后,会统计各类节点数目,信息保存在acl_calc_counts_indices传入的counts参数中,随后rte_acl_gen依据这些信息将整块的node array内存分配出来,其布局大致如下:
Data indexes中用于保存在rte_acl_field_def中定义的offset;
Results对应match node,用于保存匹配结果。
Trans table包含整个匹配过程中的跳转点:
1.DFA0和idle节点用于构造失效节点,某一次匹配失效后会一直处于idle状态,直到匹配结束;
2.DFAs、QUADs、SINGLEs即为trie树中依据node的不同type对应的array模式;
静态将整块node array分配完成后,就需要依据trie 树的node信息填充Trans table和Results了,具体过程见acl_gen_node函数;Data indexes的填充则在acl_set_data_indexes中完成。
2.3 跳转表的构造
2.2中的内存布局大致描绘了各种类型节点的分布情况,DFAs内部由一个一个的DFA节点组成,QUADs和SINGLEs也一样,都是由相同类型的节点构成。
对于每一个节点,其结构则类似如下形式:
DFA节点的fanout一般为4,出边数为fanout*RTE_ACL_DFA_GR64_SIZE;(图中画的为fanout为4的情况,256条出边)
QUAD节点的fanout不超过5,即为节点的出边数不超过5;(图中画的为fanout为4的情况)
SINGLE节点只有一个出边;
图中的trans即为这个节点的出边,它本质是一个uint64的数据结构,通过trans和input信息即可计算得到下一个节点的index,从而实现匹配跳转。trans中不同bit位包含着丰富的信息,具体见acl.h中的说明即可。
高32位对于不同类型的节点有不同的解释:
1.对于MATCH节点,高32位没有意义,match节点一定是叶子节点(这与AC算法或其它匹配算法不一样,它没有贪婪模式);
2.对于QRANGE即QUAD节点,通过高32位和input_byte可以计算得到index;
3.SINGLE节点,高32位其实意义不大,因为通过低32位的node_addr即可知道下一跳节点的位置,不过转换过程中还是采用与QRANGE一样的计算方式获取index,如scalar_transition函数中的处理;
4.对于DFA节点,也是通过高32位与input_byte得到index,只是计算方式略有区别。
低32位:
1.高三位用于指定node type,是这条边指向节点的类型,而不是这个节点的类型;
2.低29位用于指定node addr,即指向节点的addr。
在实际处理过程中,通过高32位与input_byte计算得到index,与低32位中的addr,即可快速定位到下一个trans:trans_table + (addr+index)。
这里的处理其实与传统的DFA跳转差别很大,传统处理时,next = node[‘input’],跳转到下一个节点,然后采用next[‘input’]进行跳转和匹配,即使有数据结构的压缩,跳转目标仍是状态点。但DPDK中,跳转时直接采用trans_table + (addr+index),直接找到了状态点的边(trans),而不是到状态点。
跳转表具体构建时,采用acl_gen_node函数完成:
首先通过采用广度优先的形式遍历所有的node,node的adds通过index进行记录;
对于QRANGE,高32位的transition计算比较复杂,在acl_add_ptrs中完成。第一次acl_add_ptrs中resolved为0,将自身64bit的trans构造完成记录在node_index中,第二次acl_add_ptrs时resolved为1,因为child的node_index已构造完成,此时将child的node_index赋值给这个node的trans(见对node_a的赋值),即完成了出边的构造。(这里代码较复杂,建议多读几遍)
DFA与QRANGE类似,对于MATCH、SINGLE的trans处理则相对简单。
3.匹配原理
匹配的过程与跳转表的构建其实是互为一体的,如何构建跳转表就决定了如何进行匹配。
在2.3节,对于跳转的形式已进行了说明,具体可阅读rte_acl_classify_scalar函数:跳转时直接采用trans_table + (addr+index),直接找到了状态点的边(trans),而不是到状态点。
对于具体的匹配过程,还有一点需要注意,即GET_NEXT_4BYTES的使用,每次匹配时候都会去4BTYTES进行匹配,这也是为什么定义input fields时要求4字节连续。比如我在dpdk-dev邮件组中问的这个问题。
解决4字节连续,可以通过定义相同的input_index来解决,比如像邮件中提到的设置sport/dport的input_index相同,这是因为data indexes的构造取决于input_index,见acl_build_index函数;同时field_index不同、input_index相同时可避免对field区间的优化(如果优化,将某个field去掉了,这时4字节匹配会失效)。邮件中的问题,正是因为field[3]被优化掉后,4字节连续匹配出现问题。
在特定的场合还必须通过指定.size为32来解决,即使type类型为BITMASK,见DPDK的ACL文档中关于tos示例的说明。
另外再说下field_index,前面提出一个问题:field_index是否多余?
答案是不多余,因为算法中会对field进行优化,如果不指定field_index字段,这个优化就无法进行了,具体的优化处理见acl_rule_stats函数。
优化过程中要进行input_index的判断,这是因为相同的input_index可以有多个field,但其中只有某个field是completely wild时应避免进行优化。只有相同input_index的所有field都是completely wild时,才应该将这个field优化掉。
上面的一系列说明,都是针对GET_NEXT_4BYTES每次匹配四个字节的匹配进行的补充说明。
匹配的具体过程,这里用图形的方式进行简要说明,为了能有多种类型的node,这里构造规则如下:
struct acl_ipv4_rule acl_rules[] = {
{
.data = {.userdata = 102, .category_mask = 1, .priority = 1},
/* proto */
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0,},
/* source IPv4 */
.field[1] = {.value.u32 = IPv4(192, 168, 0, 0), . mask_range.u32 = 16,},
},
{
.data = {.userdata = 102, .category_mask = 1, .priority = 2},
/* proto */
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0,},
/* source IPv4 */
.field[1] = {.value.u32 = IPv4(192, 200, 0, 0), . mask_range.u32 = 16,},
},
{
.data = {.userdata = 102, .category_mask = 1, .priority = 3},
/* proto */
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0,},
/* source IPv4 */
.field[1] = {.value.u32 = IPv4(192, 12, 0, 0), . mask_range.u32 = 16,},
},
{
.data = {.userdata = 102, .category_mask = 1, .priority = 4},
/* proto */
.field[0] = {.value.u8 = 0, .mask_range.u8 = 0,},
/* source IPv4 */
.field[1] = {.value.u32 = IPv4(192, 80, 0, 0), . mask_range.u32 = 16,},
},
};
trie树如下所述:
trie树进行转换时,node0作为根节点,会转换成为DFA1;
node1是唯一的一个quad节点,且其出边数目为3;
node2出边数目为9,所以被转换为DFA2;
node3、node4、node6、node7、node9、node10、node12、node13为singles节点,每个节点一个出边,对应粉红色区域的t0~t7;
node5、node8、node11、node14为match节点。
对应的node array如下图所示:
假设输入数据为:proto 16, ip 192.12.8.8,则transition跳转方式如上图红线所示:
1.通过acl_start_next_trie,第一个transition对应DFA1的边t16;
2.t16指向quad1,type为QUAD,GET_NEXT_4BYTES获取到了接下来的4个字节:192、12、8、8;
3.scalar_transition中通过t16与192的计算,得到下一个transition为QUAD1的t1;
4.t1指向DFA2,type为DFA,通过t1与12计算,得到下一个transition为DFA2的t12;
5.t12指向single节点,不论输入什么都会跳转到singles的t2;
6.t2同样为single节点,不论输入什么都会跳转到t3;
7.t3指向一个Match节点,type为match,通过低32位的addr即可获取到匹配结果在matches中的索引2;
8.读取m2,获取到匹配结果。
注意:node array中indexes、DFA0和idle省略了。
4.其它
关于trie树相关的理论知识参考这里。
5.总结
本文主要介绍了DPDK的ACL算法,详细描述了如何由规则生成trie,并将trie转换为node array的过程,在文末通过示例介绍了具体的匹配过程。文章旨在介绍ACL算法的基本思路,希望对大家能有所帮助。