A. DPDK ACL算法介绍
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的官方文档即可。
ACL规则主要面向的是IP流量中的五元组信息,即IP/PORT/PROTO,算法在这个基础上进行了抽象,提供了三种类型的匹配区域:
熟悉这三种类型的使用后,完全可以用它们去匹配网络报文的其它区域,甚至将其应用到其它场景中。
具体来说,rte_acl_field_def有5个成员:type、size、field_index、input_index、offset。
如果要深入理解算法,可以思考这几个字段的意义,或者换个角度来看:
对于规则的定义,要注意如下两点:
比如定义了5个field,那么请给出每一个的具体定义:
像field[1]中IP和mask都为0,表示匹配所有的IP地址;field[3]中range由0到65535,表示匹配所有。类似这样的全匹配一定要显示的定义出来,因为如果不明确定义,这些字段的值取决于编译器的,最后编译的ACL规则很可能与原有设想存在偏差。
如果在规则中,对于某个field不进行限制,对于不同type的field,规则书写时有一定差异:
对于BITMASK和MASK类型,全0代表匹配所有,如上例中的field[0]、field[1];
对于RANGE,则按照上述field[3]中的形式定义。
规则定义好后,会转换为trie树并最终合并到一起。
实际处理过程中,build_trie函数会自底向上的将rule中的每个field转换为node,然后将这些node合并生成这条rule的trie,最后将这个trie与已有的trie进行merge,最终生成整个rule set的trie。
tire由node组成,其主要数据成员如下:
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)。
num_ptrs用于描述出边数目,ptrs即为实际的出边,它记录了其匹配值values和匹配后的节点指针。
match_flag和mrt则用于记录匹配结果,trie树中叶子节点一定是记录匹配结果的节点。
trie树其详细结构比较复杂,这里将其结构进行简化,如下所示:
上图的trie树有4个node,通过ptrs进行指向,values字段为匹配值的bitmap表示,为了表述的简洁,后续会采用simple的方式进行描述。
在trie simple中,实心节点表示匹配节点,边上的数字代表匹配值(为便于阅读,采用实际值而不再是bitmap形式),…代表其它匹配值。
不同type的field,转换为node的方式会有所不同。
目前提供的3种类型:BITMASK描述一个byte的匹配,支持mask模式;MASK用于描述4个byte的匹配,支持mask模式;RANGE描述2个byte的匹配,此时mask表示上限。
field到node的转换,见build_trie中的for循环,具体转换函数则参考:
对于BITMASK,如{.value.u8 = 6, .mask_range.u8 = 0xff,},它最后的转换形式如下:
构造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。
对于多个rule,每次构造完成后会merge到整体的trie中。
这里详细介绍下merge算法原理,其实仔细阅读acl_merge_trie函数的注释即可。
对于node A和node B的merge, acl_merge_trie函数返回一个节点,这个节点指向它们路径的交集。
这里给出三个例子用于展示merge前后的变化。为了减少状态点,构造rte_acl_field_def如下:
示例1:
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类别相同,但优先级不同:
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类别不同,优先级相同:
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的概率相对较低。
一些说明:
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状态跳转。
DPDK对边的压缩方式与上述类似,不过它优化的粒度更细,不同type的node有不同的压缩方式:
比如在示例三中,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函数,其原理是:
比如对于示例3中的d2节点:
fanout计算完成后,若其值为1则为SINGLE节点,(1, 5]为QRANGE节点,(5, 256]为DFA节点。
注意:对于trie树的root节点,不论fanout值为多少,会强制将其构造为DFA节点,且其fanout值会重新计算。
type和fanout计算完成后,会统计各类节点数目,信息保存在acl_calc_counts_indices传入的counts参数中,随后rte_acl_gen依据这些信息将整块的node array内存分配出来,其布局大致如下:
Data indexes中用于保存在rte_acl_field_def中定义的offset;
Results对应match node,用于保存匹配结果。
Trans table包含整个匹配过程中的跳转点:
静态将整块node array分配完成后,就需要依据trie 树的node信息填充Trans table和Results了,具体过程见acl_gen_node函数;Data indexes的填充则在acl_set_data_indexes中完成。
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位对于不同类型的节点有不同的解释:
低32位:
在实际处理过程中,通过高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函数完成:
匹配的过程与跳转表的构建其实是互为一体的,如何构建跳转表就决定了如何进行匹配。
在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,这里构造规则如下:
trie树如下所述:
对应的node array如下图所示:
假设输入数据为:proto 16, ip 192.12.8.8,则transition跳转方式如上图红线所示:
注意:node array中indexes、DFA0和idle省略了。
关于trie树相关的理论知识参考 这里 。
本文主要介绍了DPDK的ACL算法,详细描述了如何由规则生成trie,并将trie转换为node array的过程,在文末通过示例介绍了具体的匹配过程。文章旨在介绍ACL算法的基本思路,希望对大家能有所帮助。
B. 图解KMP字符串匹配算法
kmp算法跟之前讲的bm算法思想有一定的相似性。之前提到过,bm算法中有个好后缀的概念,而在kmp中有个好前缀的概念,什么是好前缀,我们先来看下面这个例子。
观察上面这个例子,已经匹配的abcde称为好前缀,a与之后的bcde都不匹配,所以没有必要再比一次,直接滑动到e之后即可。
那如果前缀中有互相匹配的字符呢?
观察上面这个例子,这个时候如果我们直接滑到好前缀之后,则会过度滑动,错失匹配子串。那我们如何根据好前缀来进行合理滑动?
其实就是看当前的好前缀的前缀和后缀是否有匹配的,找到最长匹配长度,直接滑动。鉴于不止一次找最长匹配长度,我们完全可以先初始化一个数组,保存在当前好前缀情况下,最长匹配长度是多少,这时候我们的next数组就出来了。
我们定义一个next数组,表示在当前好前缀下,好前缀的前缀和后缀的最长匹配子串长度,这个最长匹配长度表示这个子串之前已经匹配过匹配了,不需要再次进行匹配,直接从子串的下一个字符开始匹配。
我们是否每次算next[i]时都需要每一个字符进行匹配,是否可以根据next[i - 1]进行推导以便减少不必要的比较。
带着这个思路我们来看看下面的步骤:
假设next[i - 1] = k - 1;
如果modelStr[k] = modelStr[i] 则next[i]=k
如果modelStr[k] != modelStr[i],我们是否可以直接认定next[i] = next[i - 1]?
通过上面这个例子,我们可以很清晰地看到,next[i]!=next[i-1],那当modelStr[k]!=modelStr[i]时候,我们已知next[0],next[1]…next[i-1],如何推导出next[i]呢?
假设modelStr[x…i]是前缀后缀能匹配的最长后缀子串,那么最长匹配前缀子串为modelStr[0…i-x]
我们在求这个最长匹配串的时候,他的前面的次长匹配串(不包含当前i的),也就是modelStr[x…i-1]在之前应该是已经求解出来了的,因此我们只需要找到这个某一个已经求解的匹配串,假设前缀子串为modelStr[0…i-x-1],后缀子串为modelStr[x…i-1],且modelStr[i-x] == modelStr[i],这个前缀后缀子串即为次前缀子串,加上当前字符即为最长匹配前缀后缀子串。
代码实现
首先在kmp算法中最主要的next数组,这个数组标志着截止到当前下标的最长前缀后缀匹配子串字符个数,kmp算法里面,如果某个前缀是好前缀,即与模式串前缀匹配,我们就可以利用一定的技巧不止向前滑动一个字符,具体看前面的讲解。我们提前不知道哪些是好前缀,并且匹配过程不止一次,因此我们在最开始调用一个初始化方法,初始化next数组。
1.如果上一个字符的最长前缀子串的下一个字符==当前字符,上一个字符的最长前缀子串直接加上当前字符即可
2.如果不等于,需要找到之前存在的最长前缀子串的下一个字符等于当前子串的,然后设置当前字符子串的最长前缀后缀子串
然后开始利用next数组进行匹配,从第一个字符开始匹配进行匹配,找到第一个不匹配的字符,这时候之前的都是匹配的,接下来先判断是否已经是完全匹配,是直接返回,不是,判断是否第一个就不匹配,是直接往后面匹配。如果有好前缀,这时候就利用到了next数组,通过next数组知道当前可以从哪个开始匹配,之前的都不用进行匹配。
C. 【算法笔记】字符串匹配
BF 算法中的 BF 是 Brute Force 的缩写,中文叫作暴力匹配算法,也叫朴素匹配算法:
主串和模式串:
在字符串 A 中查找字符串 B,那字符串 A 就是主串,字符串 B 就是模式串。我们把主串的长度记作 n,模式串的长度记作 m
我们在主串中,检查起始位置分别是 0、1、2…n-m 且长度为 m 的 n-m+1 个子串,看有没有跟模式串匹配的。
BF 算法的时间复杂度是 O(n*m)
等价于
比如匹配Google 和Goo 是最好时间复杂度,匹配Google 和ble是匹配失败的最好时间复杂度。
KMP算法是一种改进的字符串匹配算法,由D.E.Knuth与J.H.Morris和V.R.Pratt同时发现,因此人们称它为克努特—莫里斯—普拉特算法。KMP算法主要分为两个步骤:字符串的自我匹配,目标串和模式串之间的匹配。
看来网上很多的文章,感觉很多的都没有说清楚,这里直接复制阮一峰的内容,讲的很清晰
内容来自 http://www.ruanyifeng.com/blog/
首先,字符串"BBC ABCDAB ABCDABCDABDE"的第一个字符与搜索词"ABCDABD"的第一个字符,进行比较。因为B与A不匹配,所以搜索词后移一位。
因为B与A不匹配,搜索词再往后移。
就这样,直到字符串有一个字符,与搜索词的第一个字符相同为止。
接着比较字符串和搜索词的下一个字符,还是相同。
直到字符串有一个字符,与搜索词对应的字符不相同为止。
这时,最自然的反应是,将搜索词整个后移一位,再从头逐个比较。这样做虽然可行,但是效率很差,因为你要把"搜索位置"移到已经比较过的位置,重比一遍。
一个基本事实是,当空格与D不匹配时,你其实知道前面六个字符是"ABCDAB"。KMP算法的想法是,设法利用这个已知信息,不要把"搜索位置"移回已经比较过的位置,继续把它向后移,这样就提高了效率。
怎么做到这一点呢?可以针对搜索词,算出一张《部分匹配表》(Partial Match Table)。这张表是如何产生的,后面再介绍,这里只要会用就可以了。
已知空格与D不匹配时,前面六个字符"ABCDAB"是匹配的。查表可知,最后一个匹配字符B对应的"部分匹配值"为2,因此按照下面的公式算出向后移动的位数:
因为 6 - 2 等于4,所以将搜索词向后移动4位。
因为空格与C不匹配,搜索词还要继续往后移。这时,已匹配的字符数为2("AB"),对应的"部分匹配值"为0。所以,移动位数 = 2 - 0,结果为 2,于是将搜索词向后移2位。
因为空格与A不匹配,继续后移一位。
逐位比较,直到发现C与D不匹配。于是,移动位数 = 6 - 2,继续将搜索词向后移动4位。
逐位比较,直到搜索词的最后一位,发现完全匹配,于是搜索完成。如果还要继续搜索(即找出全部匹配),移动位数 = 7 - 0,再将搜索词向后移动7位,这里就不再重复了。
下面介绍《部分匹配表》是如何产生的。
首先,要了解两个概念:"前缀"和"后缀"。 "前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。
"部分匹配值"就是"前缀"和"后缀"的最长的共有元素的长度。以"ABCDABD"为例,
"部分匹配"的实质是,有时候,字符串头部和尾部会有重复。比如,"ABCDAB"之中有两个"AB",那么它的"部分匹配值"就是2("AB"的长度)。搜索词移动的时候,第一个"AB"向后移动4位(字符串长度-部分匹配值),就可以来到第二个"AB"的位置。
BM(Boyer-Moore)算法。它是一种非常高效的字符串匹配算法,有实验统计,它的性能是着名的KMP 算法的 3 到 4 倍。
BM 算法包含两部分,分别是坏字符规则(bad character rule)和好后缀规则(good suffix shift)
未完待续
参考文章:
字符串匹配的Boyer-Moore算法
D. kmp算法什么意思
KMP算法之所以叫做KMP算法是因为这个算法是由三个人共同提出来的,就取三个人名字的首字母作为该算法的名字。其实KMP算法与BF算法的区别就在于KMP算法巧妙的消除了指针i的回溯问题,只需确定下次匹配j的位置即可,使得问题的复杂度由O(mn)下降到O(m+n)。
在KMP算法中,为了确定在匹配不成功时,下次匹配时j的位置,引入了next[]数组,next[j]的值表示P[0...j-1]中最长后缀的长度等于相同字符序列的前缀。
对于next[]数组的定义如下:
1) next[j] = -1 j = 0
2) next[j] = max(k): 0<k<j P[0...k-1]=P[j-k,j-1]
3) next[j] = 0 其他
如:
P a b a b a
j 0 1 2 3 4
next -1 0 0 1 2
即next[j]=k>0时,表示P[0...k-1]=P[j-k,j-1]
因此KMP算法的思想就是:在匹配过程称,若发生不匹配的情况,如果next[j]>=0,则目标串的指针i不变,将模式串的指针j移动到next[j]的位置继续进行匹配;若next[j]=-1,则将i右移1位,并将j置0,继续进行比较。
E. 指出BM算法与KMP算法的区别
KMP算法和BM算法,它们分别是前缀匹配和后缀匹配的经典算法。
1、因为路由表中的每个表项都指定了一个网络,所以一个目的地址可能与多个表项匹配。最明确的一个表项,即子网掩码最长的一个,就叫做最长前缀匹配。
2、之所以这样称呼它,是因为这个表项也是路由表中,与目的地址的高位匹配得最多的表项。