如何拯救一个内存不够的系统
存储介质大致分为ROM、RAM、Flash。
ROM(Read Only Memory)只读不可写,且断电后数据仍然保存,用于存放代码.
RAM(Random Access Memmory)就是本文指的内存,可读可写,断电后数据丢失,用于程序运行暂时存储代码、运算数据,程序是拷到到RAM运行,程序运行时暂时产生的运算数据也是存在RAM中。
Flash结合了前两者的优点,可快速读写断电后又能保存。
举个例子,内存就像是一个快递公司的快递员,平时业务量不大,每天会收到一些快件,这几个快递员分区派送及接收不同区的快递(内存耗用),送完这批快递回来(内存回收)接着送下一批(内存耗用),每天接收的快件当天可以送完或送到下一个中转站,可以良好的运转。双十一期间来了一大批快件要派送,每个快递员分到的快件都是一大批,几天才能送完,快递员累了一天没送完,老板催客户投诉,快递员崩溃第二天不上班了公司没法运转下去(系统崩溃,内存无法回收)!
系统运行时如果一个线程里函数调过程中嵌套调用层数太多,且每一层都用到很多临时变量(特别是很大的数组),那么就会造成不断压栈的数据把栈空间耗尽,程序没法再往下跑。那临时变量用malloc申请内存可以吗?此时耗的是堆的空间,如果还没free掉又不断malloc,也会出现同样的情况,堆和栈总之都是内存。(关于程序调用过程其实很复杂,可搜索“栈帧”了解)。
那么在内存只有这么多的情况下怎样精简程序?
本文针对自己做过的项目总结几条经验,不一定适用所有情况,有的时候也许算法复杂数据量大内存确实不够,此时应该换芯片方案。
1、 指针的灵活运用
我们的wifi模组用于智能家电设备上,wifi板的主要作用是对云端发下来的网络消息进行解析转成串口协议发到设备端,并把设备的运行数据报到云端,网络协议是xmpp协议。
图 1 代码简单结构框图1
图 2 代码简单结构框图2
代码的基本结构是sdk代码+app代码,如图1,sdk所有品类产品共有,实现网络协议,包括登陆注册、网络数据收发、xmpp协议处理、数据上报时消息打包等,app处理具体不同产品的业务。图2是代码中的几个主要的线程,com线程在SDK和APP中均有代码,而app和sdk代码是分开的,那这是怎么实现的?com线程的主循环在sdk中,在APP代码部分是通过调用回调函数实现,com线程初始化在APP代码中,回调函数的注册也是初始化的时候完成。
什么是回调函数?其本质是一个函数指针,函数指针代表一个函数的首地址,先把函数指针传给另一个函数(我也不知道他什么时候会被调用),另一个函数在特定条件下跳转到该指针(即执行函数指针所指的函数)。
引用一个网友的神解释:你饿了,想吃饭,就一会回去问你妈一声:“开饭没有啊?”,这是正常的函数调用,但是今天你妈包饺子,花的时间比较长,你跑啊跑啊,就烦了,于是你给你妈说,我先出去玩会,开饭的时候发我手机。等过一阵你妈给你打电话说:“开饭啦,快回来吃饭吧!”其中,你告诉你妈打手机找你,就是你把回调函数句柄注册到你妈的动作,你妈打电话叫你,就是回调过程。
分析代码发现耗用内存最多的代码路径是数据上报时(图1中绿色箭头),此时由于嵌套调用层数太多,不断用临时的数组变量导致内存不够了。
当app上报设备数据时先把消息body组装好,再调用sdk提供的接口,把数据上报到云端,接口中先对body进行封装,封装成xmpp协议消息再发送到云端。
一条完整的网络消息结构如下:
当有数据上报时,串口线程发一条消息给com线程,com线程回调函数中处理该消息,上报数据到云端。处理步骤如下:
图3 数据打包发送过程
大致代码如下:
上述过程在tcl_protocol_statusReport函数系统就挂掉了,报出栈溢出bug。原因是函数嵌套调用而且每层都申请了大的数组,在第二次用1500byte大小的临时数据变量时,还没有退出上一层调用他的函数,上一层申请的临时数组变量的内存还没有释放,导致线程的栈溢出了;而组装数据的顺序是先组出中间的body再在外面封上msg头和尾,最后再在最外层封上xmpp头和尾,导致每次封装数据时无法避免要用一块新的临时缓存腾挪数据。
那么该如何解决?能否在步骤1.1的时候就退出,好释放临时缓存空间?呵呵释放了那么刚刚组好的body数据也不见了啊。
如果把数据组装的顺序换一下,在前面的先组装,在后面的后组装,不就行了?一开始就直接用sdk中的xmpp发数据的缓存区,不再去用临时的数组,顺序如下:
(1)组xmpp头(sdk程序中)
(2)组msg头(sdk程序中)
(3)组body数据(sdk程序中,调用app传进来的函数指针和和该函数指针需要的形参组装body)
(4)组msg尾(sdk程序中)
(5)组xmpp尾(sdk程序中)
按照上面的顺序,可以一开始就使用已经定义的xmpp发送数据的缓存,不用再定义临时数组,修改sdk代码中的tcl_protocol_statusReport函数:先组装xmpp头,再组装msg头,再组装body,接着再组装msg尾和xmpp尾便可以,最后调用socket发送函数发送数据。那么怎么组装body?组装body的函数在app中,sdk的函数无法调用app中的函数,怎样在sdk的tcl_protocol_statusReport函数中组装出body?只需要把组装body的函数指针和其需要的参数传给tcl_protocol_statusReport函数就行了。
由图3可知,组装body是protocol_statusUppdate函数做的事情,需要的参数是一个T_MSG类型的参数msg,原来protocol_statusUppdate函数除了组装body数据还做了其他事情所以需要修改(此时函数名改为protocol_getDataStatus),因此tcl_protocol_statusReport函数需要新增两个参数:
①指向组装body的函数的指针(其类型和protocol_getDataStatus函数一致);
②组装body的函数需要的形参。
另外由于局域网和广域网通信都要调用tcl_protocol_statusReport函数,他们用到的发送数据的缓存区不同,所以com线程(广域网)sdk里主循环调用回调函数时,把组装数据用的缓存区也传出去了。
各函数定义如下:
修改后com线程中的回调函数comsysHandler对uart线程发来消息的处理过程大致代码如下:
修改前:
总结一下,上面用到指针的地方主要有三个:(1)把组装数据的缓存区从sdk传到APP函数中;(2)把组装body的函数的函数指针从app传到sdk代码中;(3)把组装body需要的形参
通过指针从app传到sdk中。这样就避免了中途再申请临时数组。
2、尽量不用malloc
频繁使用malloc会使系统中内存碎片越来越多,能使用的连续的大片内存越来越少,特别是频繁申请不定长的内存。由于内存字节对齐以及页边界对齐,比如申请5个byte的内存,最终分配物理内存时,假设这次是地址0~4分配出去,那么下次在申请8个内存,会从地址5开始吗?显然不是,而是从地址8开始,等原来申请的5个字节内存释放后,那么地址0~7的内存如果再也不会申请8个字节以下内存,那这就是个内存碎片没法再用。
不同系统内存管理方案不同,有些能支持内存碎片回收(比如freeRTOS),有些不支持;有些有内存池或内存块机制, 简言之,为避免内存碎片,专门分配固定大小的内存块专用(比如ThreadX系统)。
3、简化线程间通信消息(共用体与结构体位域结合的妙用)
本系统线程之间定义了消息队列,用于线程之间的通信,通信消息格式定义如下:
from表示发出消息的线程,to表示消息发往的线程,code是消息的ID号,len表示消息总长度,data是消息数据的指针,可看出一个消息占用5个int,即20 byte的空间;在freeRTOS中创建队列时输入参数中队列消息大小可以取任意值,队列大小可以是任意个消息;而在threadX系统中,创建队列时,消息的大小只能有4种取值:1个word、4个word、8个word、16个word,1个word即4byte,这里一个消息20byte即 5个word创建队列时消息大小就只能选8个word,每个线程创建一个容纳10个消息的队列,有一定的内存浪费。
最值得注意的是,原来定义的消息中有data指针一项,这里仅仅是与uart线程有关的有些消息会有另外的数据带上,而且数据的长度是不定的,因此用malloc开辟缓存来存数据,此处data即是缓存的指针,比如家电的运行数据有一项变了,此时uart需要发消息给com线程上报告知云端,此消息包括两个方面:
①是哪项数据变化了;
②这一项数据变成了什么值。
而其他线程发的消息data一项都是NULL。用malloc的坏处前文已经说了,因此考虑把消息中data去掉,那么数据的值怎么去获取?可以用函数接口去获取,不再在消息中直接带上,消息中告诉对方有哪些数据项有变化,对方线程再通过本线程的函数接口去获取就好了。
另外,消息中定义的form和to实际应用中并没有用上,每个消息的code是独一无二的,知道消息的code就知道这个消息from和to是哪个线程了,因此消息中from和to也可以直接去掉。修改后的消息格式如下:
消息类型定义由结构体改成了共用体,只占1个int,对于非uart线程的线程,消息中只有消息code一项,而对于uart线程,消息涉及到消息ID和数据,因此另外定义了一个占32bit的结构体,其中用到8位来表示消息code,其它下面占一位的表示某项数据是否有变化,比如当setTemp=1表示温度有变化,为0表示无变化,当acSwitch=1表示开关一项数据有变化,所有数据项是否有变化,分别需要1位来表示。
共用体的特点是其成员所占用的内存是共有的,一个共用体占用内存取决于共用体里占用内存最长的成员,这里之所以用共用体是因为对于uart线程的消息组成情况不同于其他线程消息,需要另外定义,且其占用内存1个int就够了,若想不另外耗用内存(像原来定义的消息data指针就另外耗用了1个int),用共用体是最合适的。
当有数据需要上报时,比如空调的风速变了,uart线程发送数据上报的消息给com线程,msgCode=数据上报消息ID,且数据相关项中blowMode = 1, 伪代码如下:
Com线程收到消息后,通过uart线程的接口去获取blowMode的值:
4、 线程合并
当两个线程业务量都不是很多时,不会相互阻塞影响功能时,可以考虑合并共用一个栈空间,节省内存。