? [Github pages] ?
嵌入式系统也有自己的数据结构与算法,而且还不少。
本文对嵌入式系统里面所用到的数据结构与算法进行一个简要的介绍,主要是为了对嵌入式领域的数据结构与算法做一个简单的描述总结,非算法原理性描述。这里不特意区分是数据结构还是算法,两者我把它们作为一个整体进行描述,因为数据结构往往伴随着相关的算法,而算法也往往需要基础数据结构作为支撑。
由于本人处于服务于消费电子产品的SOC芯片产品线,并且比较倾向于编码终端产品(非手机类),它们通常没有非常大量的复杂数据要存储,通常是用作物理世界的信息采集传递接口,不涉及到后端服务器那大量的数据存储、查找等等操作,所以可能说的东西不够全面,也仅仅是针对终端产品开发所涉及到的那些数据结构与算法而言,难免一家之言,有失偏颇。
本文数据结构与算法不做特意区分,并且内核特指 Linux 内核,SOC 特指 ARM 平台。
像是字符串查找类的算法,常常能够接触到但是并不需要如何如何深究其实现的算法有很多,其最常见的载体函数就是 strcmp/strncmp,strstr,C++ 里面的 find 等等,最常见的命令有 grep、find、sed 等等。其中会涉及到 KMP、BM、Sundy 等字符串匹配算法,抑或是其中的混合,在这里这几种算法的基本实现不需要依赖于非常特殊的数据结构,仅使用「普通」的数组就可以搞定。
这几种算法是我们会经常在无意识当中使用过,但是嵌入式领域来讲,通常情况下我们很少去关注其内部的具体实现,不是说没有那个意识,有两个理由:一是嵌入式里面没必要去关注这种算法的实现;而是也不一定知道里面有这种算法存在。
再者,在平时的代码编译与执行过程当中,我们可能会用到一些自己实现的字符串匹配算法,比如要实时的解析一个字符串文件用作程序的配置文件,可能会用到一些 fscanf,strcmp 抑或是自己实现的字符串对比函数。
其中编译时候最常用的就是一些脚本命令如 grep 等,还有就是 Makefile 里面自带的 make 语法函数,执行的时候通常简单的例程一般就会使用自己实现的字符串匹配(最常见暴力匹配法哈),大点的程序会用到 lua 来解析字符串。
与字符串有关的数据结构与算法最常接触到的就是暴力匹配、没那么暴力的 RK 算法、KMP 系及其改进与变种,像字典树这种更加高级一点的在搜索引擎里面可以看到,但是嵌入式领域就万万是不怎么常见的,可以说根本就见不到的。
队列是目前在用户态编程当中最为常见的一种数据结构了,因为编码、解码系列产品代码里面需要大量的视频数据处理,自然就少不了视频数据的传递,而数据的传递就非常满足先进先出的特性,而且整个代码工程里面大部分数据传递的地方都满足这个特征,也就是常说的「生产者消费者」模型。
于是软件里面的视频数据传递与管理、消息处理队列、环形缓冲区、命令处理队列、事件分发模块等等都适用于队列这个数据结构,而最常用的组织方式有自行实现的单向链表、内核数据结构中的 list 双向链表。C++ 模板库中的 queue 组织方式就是一种队列结构。
队列的实现方式有不同的途径,并且队列的使用范围有一定的限定。比如我最常用的队列,在同一个队列实例下,可能会有一个或者多个生产者,但是只有一个消费者,如果要实现多个消费者的一般会创建多个队列实例,减少编码难度。
说到这里,想象一下如果要一个实例多个消费者要如何实现,消费者可以实时加入,只需要多一个引用计数即可,正常情况下,消费者应该享有完全一致的生产资料,也就是说某一个生产元素只有等到所有的消费者都取用完毕之后其引用计数才能够减为0并被丢弃,还要考虑万一有个消费者挂了,需要有一个超时机制,如果超过某个时间计数没有减为0,就得强行丢弃这个数据。综合考虑在内存没有极度紧张的前提下,还不如多点空间换取更加简单的编码,不易出错,易理解,易维护。
常见队列模型比如环形缓冲区[参考],通常就会用数组来实现,实现的方式便是不难,不过如果要编写的极致一点的话就参考内核里面的 kfifo 的实现,属于鬼斧神工一类的。其它的类型更加常用内核的 list 双向链表[参考]实现,特点是通用,如果需要使用只需要把 list_head 结构体嵌入到需要管理的结构体里面即可使用,并且双向链表也带来了数据访问的灵活性。还有就是自己实现的特定数据类型的单向链表,不过很不常用。
栈的结构特点是先进后出,那么在嵌入式系统里面会常用于参数的传递,不仅包括函数的参数传递,还包括一些自定义参数的传递,比如在模块解耦的情况下会使用迪米特法则对参数传递进行设计,在参数的 push 与 pop 过程当中就可能会用得到栈这种数据结构,当然使用队列也未尝不可。
还有用到栈的地方见于需要使用递归结构的时候,比如内核里面 V4L2 框架的 pipeline 管理中的流开启函数,里面用到了图的广度优先遍历,其中为了避免整个函数的嵌套调用,就使用了栈这种数据结构对 pipeline 节点进行保存,完全符合先进后出的特点。
其它地方比如函数的栈回溯,比如 gdb 调试时候的函数调用过程回溯,比如函数执行过程中的栈式存储,比如某些 log 系统对函数调用的跟踪也用到栈式存储结构,就比如谷歌的开源日志系统 glog,内核的 oops 也会有栈式存储结构的身影。
栈的实现更常见与数组结构,使用链表结构实现也可以,但是由于栈这种比较特殊的先进先出的结构,我们不需要对删除与添加的灵活性有太多的要求,所以为了访问的效率与存储空间的适度节省,通常使用数组结构来实现。平时需要自己实现的栈结构的模块并不多。
图结构常用于表示有着非单一性的、有着多方联系的复杂关系,比如说社交关系这种就属于相对比较复杂的图类型。在嵌入式编程里面由于不怎么涉及到这种复杂的相互关联的结构,所以图这一结构基本上不会出现在平时的编程实现当中去。
不过在内核里面会用到图结构,其中我了解的有 V4L2 的 pipeline 开启函数,这个函数里面不仅用到了图遍历,还用到了位图(bitmap),还用到了栈,这个函数可以说是很吊了,那个函数的名字叫做media_entity_pipeline_start
。这里的图结构是一个单向无权无环图,主要用来表示整个 V4L2 视频输入设备框架下属管理的视频输入硬件模块,比如 CSI、ISP、SENSOR 等等,它们的数据流向是单向的,并且是无环的,因为视频数据流的大体流向始终是从硬件到驱动到用户空间的,中间是不会有回环的操作。
位图一般用于标记,标记某个对象是否是有效的、存在与否等等,总之被标记的对象只有两种(因为 bit 只有 0 和 1 这两种取值)。而上面说到的函数里面位图是用于标记一个图的端点是否被访问到,访问到就会置 1,否则的话就置0,具体的位图操作在内核的 bitmap.h、bitmap.c 等文件里面有相关描述,也可以直接看内核的 bitmap 相关的文档。
位图在 buffer 管理的时候也用的上,就比如我最近开发的一个视频处理子模块,里面就用到了一个 unsigned long long
(ARM-32 64bit)长度的位图变量,用于存储并标记 buffer 是否正在被使用,以此为基础应用于 buffer 的 push 与 pop 标记。当然一个通用的 bitmap 结构应该是可以自定义长度的。
位图结构的特点是短小,以及精悍,只使用一个位就可以代替平常一个字节的功能,可想而知,位图这种数据结构就必须使用数组结构来实现,链表想都不要想。位图可以实现一种有序进出的数据标记管理,但是大多数时候用来实现一种无序进出的数据标记管理,常见使用哈希数据结构来实现从数据到位图的索引,升级版的还有布隆过滤器(Bloom Filter)这一类位图应用。
首先最常见的排序的目的是为了索引(筛选)与查找,这几乎是排序的大部分需求,一种是排序出来之后方便找到极值,比如淘宝购物的按时间顺序排序或者按照金额顺序排序,这是为了满足我们主观的筛选等需求。还有的是排序是为了更快的拿到想要的值,也就是方便查找,比如搜索引擎,比如数据库,比如内核里面的进程树。
需要注意的是,这里指的排序并不是限定与从大到小或者从小到大,只要是满足一定的规则,按照我们的设计去依照一定的顺序规则排列的都算是排序。拿内核里面维护的一个用于调度的进程树来讲,它是用红黑树实现的排序类结构,里面的元素是进程描述符,最左子节点是最急切需要调度的进程。每次新建一个进程或者调度完毕一个进程的时候内核会按照进程的加权执行时间把它重新插入到红黑树里面并保持从小到大的顺序,需要取出的时候就从最左子节点取出,需要删除就从树里面剔除。
上面的操作涉及到查找(插入与删除)、索取(取最左子节点)这些操作,而红黑树能够把查找的时间复杂度降低到 OlogN。还有其它的有利于查找、插入、删除的数据结构与算法有哈希表、跳表、桶排序、快排等等。其中内核的 V4L2 control 模块[参考]就用到了类似桶排序的概念.
编解码产品最核心的地方是视频的编码与解码以及相关的处理,比如ISP特效等等,而针对视频的管理来说由于内存的限制,也不可能缓存太多的帧,自然这一部分就用不到排序了,甚至大部分场景下,哈希表都可以完全的被双向链表来代替,效率上完全够用。
综合来讲,排序类的算法多存在于内核里面,以及我们常用的 sqlite 数据库内部,但是数据库内部是不需要我们自己去实现一个排序函数的,用户可能会用到的排序操作并不多。
在嵌入式系统里面显得不是非常的“通用”,这里说的通用是指的大众化,广范围,比如内核的 CFS 调度算法,它虽然广泛使用在 Linux 内核里面,但是并没有太多的人需要关注并去实现一个这样的算法,它的应用面没有那么的广,所以我定义为非通用。
在嵌入式编解码产品中,「同步」是一个非常大的类别,音视频播放时候的同步、音视频采集时候的同步,涉及到同步,那问题就麻烦了,问题麻烦的点在于两个方面:
其实这种音视频采集比解码多了一个物理采集的部分,物理采集可是硬实时,采集不等人的,你错过了这一帧就是错过了,没得后悔的,要说解码还可以有一定的延迟容忍,那么采集是丝毫不跟你讲道理的。音视频虽然涉及到的硬件以及整体的流程比较复杂,但是好歹还有 200ms 的容忍度在,除了音视频的同步之外,还有其它的同步,比如常见的基于陀螺仪的在线实时防抖算法,这个要求就更过分了,要求基本上在几十甚至几毫秒级别的同步(陀螺仪数据与视频帧数据)。
还有内存分配算法,比如 slab、虚拟内存页管理算法、畸变校正、ISP 特效等等,其中有一些是属于文件系统管理类的,有一些就属于计算机视觉类的了。非通用类的算法在以后的学习总结过程当中不是属于重点,因为我也可能、极大可能没有那么多的精力去研究这些东西,更多的是关注现在已经总结出来的,比较通用的数据结构与算法。
本文只对嵌入式涉及到的数据结构与算法做一个简单的概述,必然有其局限性,不过我觉得无伤大雅。之后的学习过程中会更多的关注不限于嵌入式领域的算法,包括计算机科学里面一些比较通用的算法以及思想,拓展思维与界限。下一篇文章不出意外应该会是从队列开始,应该不止有一篇文章来描述队列,因为这个在嵌入式领域有着广泛的应用,所以有可能会实现一些实用性的工具模块,从中去领会队列的应用精髓。
打算尽量结合一些切实可用的代码一起总结学习,其实这个之前已经有在做了。如果你看到这里了,我在 github 上面搭建了一个仓库,叫做 Newbie-C,顾名思义就是用 C 来写的,目前仅有个大体的框架,内容仅有一个环形缓冲区的模块实现,还待慢慢添加完善,所以这里就不放链接了。
如果有同学有心去找着看下,也欢迎往上提交一些自己的代码,不过我还是会尽量靠自己去完善它的,我还得把那个编译模块完善一下,不然目前编译起来还是挺麻烦的,每次都是得全局编译。下面是本文的一些参考文章,其实还是自己写的啦,进去看下吧。是时候开启一个新的篇目了。
?????
[返回]环形缓冲区可以参考这篇文章:环形缓冲区
[返回]list 双向链表可以参考这篇文章:C语言之list_head双向链表
[返回]类似桶排序的概念可以参考这篇文章:V4L2框架-control的数据结构