嵌入式系统的开发,软件的运行稳定可靠是非常重要的。在芯片中,软件是没有质量的,但软件的质量可以决定一颗芯片的成败。芯片设计中,性能能否满足设计要求,除了硬件设计、软硬件配合的设计技巧,对于软件来说,编程的一些技术和技巧同样重要。
本文讲述我在芯片固件开发过程中使用的一些编程调试技巧。针对在嵌入式系统开发中常见的问题,如实时系统下的同步问题,动态内存分配的内存泄漏问题,如何在编程阶段预防BUG出现,调试阶段如何及时发现问题和定位问题。总结下经验,目的是开发一个稳定运行的固件,提高开发效率,提高运行性能。
一 编程调试技巧
(一) 动态内存分配还是静态内存分配?
嵌入式系统开发中,动态内存分配和静态内存分配各有利弊,动态内存分配灵活方便,但占用额外内存资源,调用时性能有损失,存在内存碎片问题。
在无线芯片固件开发中,在两者的使用上主要考虑一下几点:
- CPU性能
- 可用内存大小
- 需要使用内存分配的调用频率
- 性能影响
在实际设计中:
- 考虑malloc函数调用的开销。所有的收发通路上对数据帧的处理、管理使用的数据结构都用静态内存分配的方式。目的是为了减少处理时间。
- l站点管理,密钥管理,SDIO接口传递的外部命令,向上传送的SDIO事件管理采用动态内存分配的方式。这些调用和数据帧收发处理次数相比不是一个数量级,有的是偶尔调用。
- 一次性分配,存在于软件整个生命周期的数据结构变量采用静态内存分配。
(二) 使用ARM C库还是自己写一个?
嵌入式系统开发中经常会使用到C库的一些函数,如malloc,free,memcpy,memset,printf,是自己写一个呢还是利用ARM开发工具提供的C库呢?
我的习惯和建议是最好使用ARM的C库。优势就是
- 使用方便,减少开发周期
- ARM的C库性能会更好。
我以memcpy为例,网上有篇分析arm memcpy汇编代码的文章,ARM公司写的代码为什么更优化,性能更好呢?它主要考虑了以下几点:
- 源地址和目的地址的首地址的字节对齐问题
- 拷贝字节长度
- 末尾字节的对齐问题。
- 尽量word拷贝
- 尽量利用arm的批量拷贝汇编指令。
所以我不会去另外写个memcpy函数。使用memcpy时只要注意,4-8个长的采用赋值方式效率会高些。或者不影响性能的情况下怎么用都无所谓。
ARMC库有两种库:标准库和MicroC库。后者是非线程安全,在裸系统下使用。前者是线程安全的,可以在实时系统下使用。对malloc的使用用户需要实现一个保护和释放保护的函数,供C库使用。防止多线程调用malloc函数出现的同步问题。
(三) 如何预防和发现内存泄漏
使用动态内存分配,系统就可能出现内存泄漏,内存使用溢出,内存重复释放等问题,如果直接使用malloc和free,很难发现这样的BUG。在编程阶段,重定义malloc和free函数可以及时发现和定位这些问题。让程序去发现问题,而不是自己去找问题或者根本不知道有问题。
重定义的malloc采用双向链表管理所有动态分配的内存。下面是管理内存使用的数据结构:
每次内存分配分配如上大小的内存,包括三个部分,黄色部分为MEMORY_BLOCK数据结构,灰色部分为实际使用内存区,红色部分(4个字节)为尾部标记。
MEMORY_BLOCK保存了双向链表,分配的文件名指针和文件行号,分配的长度和头部标记。
这样在内存释放的时候就可以通过判断标记释放破坏来打印出错信息。重定义之后的malloc和free可以发现的内存问题和提示信息包括:
- 检查尾部标记是否破坏,很可能本块内存使用溢出了,或者被其他地方非法写了。
- 检查头部标记是否破坏,很可能被地址上方的其他内存破坏,或者被其他地方非法写了。
- 在一块分配的内存第一次释放时,会把头部标记该为其他值,这样如果有重复释放的情况,检查会发现头部标记和分配内存时设置的不一致而发现问题。
- 在系统退出时,应该所有内存都释放了。这时候检查内存管理链表,如果还有节点存在,说明有内存泄漏问题。
- 对于未分配的内存,释放指针指向的内存区时,因在内存管理链表中找不到匹配的指针值,可以发现有非法释放的问题。
- 出现内存释放问题时,可以打印调用分配函数的文件名和行号,分配的大小。如果MEMORY_BLOCK区破坏了,则可以查看该数据块的被填写的内容作为进一步判断问题的参考。
重定义的malloc和free函数能够发现和定位绝大多数的内存使用的bug。可以杜绝内存泄漏问题。如果破坏了MEMORY_BLOCK区,则可以发现有问题,但定位需要自己再判断。
(四) 注意大小端
在嵌入式系统编程中,注意大小端问题是基本要求。
- 注意访问的字段是大端还是小端格式的
- 注意访问的字段在不同体系结构CPU(大小端不同)的访问问题。考虑代码的可移植性问题。
- 慎重使用位域定义和操作,容易出现大小端问题。而且位域操作是需要较多指令才能实现。可以反汇编比较一下位域操作和位操作编译结果的不同。在有些嵌入式C语言规范中禁止使用位域也是这样的考虑。
- 也不要有这样的想法:我的代码只会在小端CPU上运行。
如果不想在小端CPU上执行的代码移植到大端CPU时,修改代码中大量读写操作。在刚开始写代码时注意这个问题可以避免以后吃大苦头。
所以编写大小端访问的宏是必须的。代码如下:
(五) 注意字节对齐问题
因现在的嵌入式开发平台大部分是32位CPU,对51以及64位 CPU另外考虑。
通常动态内存分配内存地址是word对齐的,编译器编译的结构体变量首地址也是word对齐的,对于结构体中变量定义,以及访问,对齐问题就需要编程者自己注意了。
变量定义基本原则:
- 对结构体中变量是half-word(2个字节长),必须是2的倍数边界对齐
- 对结构体中是变量word型(4个字节长),必须是4的倍数边界对齐
- 对结构体中变量是char型的(1个字节),可以任意边界对齐。如果是数组类型根据长度考虑。
对于下面的数据结构(左边)定义,
虽然编译器编译时会进行字节填充,建议使用显式填充的方式定义(如上右数据结构)。
对于变量读写操作变量,在变量定义时考虑对齐可以避免读写出现问题,比如代码中有可能跨word边界读写一个word。有些CPU体系结构会出现访问异常,有些CPU体系结构则读(或写)了一个错误值,但不会异常。对Intel的桌面平台的CPU,跨word边界读写不会有问题,因为CPU已经帮你解决这个问题,但影响是代码执行效率变差,这样的代码在windows平台是正常的,但到了嵌入式平台就会出现问题。所以根本的解决方式是在编程阶段注意这个问题。
比如在无线网卡固件中,需要处理数据包中的字段,有些字段的起始地址是随机的,有可能是word对齐的,也有可能不是,访问这样的word变量时,加入__packed关键字。代码如下:
u32data = *((u32 __packed *)da);
这样编译器在编译时会编译为按字节读取再合并为word的汇编代码,不会出现读取数据问题。
(六) 时刻关注同步访问问题
在实时系统应用开发过程中,同步bug是经常碰到且比较难定位的bug。所以在编程阶段就进行考虑能避免后面调试时的痛苦。时刻关注同步访问问题,在编程过程中时刻自问下,这个变量的操作是否会出现同步问题,是否有多个线程进行写操作,释放时是否还有其他线程在用着呢?下面对开发过程中使用的保护技术进行下介绍。
1 寄存器(变量)写的同步问题
在嵌入式实时系统中,对寄存器或者内存中的变量的读写是很普遍的事情。以寄存器为例,如果是裸系统(不采用实时操作系统),只要把寄存器定义为volatile类型,就可以避免硬件会异步修改导致的软件编程编译之后的访问执行问题。
在多任务的实时系统中,还需要注意多个任务会对寄存器进行写操作。这时候就需要保护操作。比如采用关中断,信号量或者互斥保护的办法。
比如task1(低优先级)和task2(高优先级)都会读写MAC地址寄存器(两个word长的寄存器MAC_LOW_REG和MAC_HI_REG)。当task1刚写完MAC地址低四字节寄存器时,task2开始执行,然后向MAC地址寄存器写了不同的内容。task2执行完之后再切换回task1执行,task1继续写MAC地址寄存器(MAC_HI_REG)。这样的后果就是MAC地址寄存器中写人了非法的内容。
在无线网卡固件中对寄存器操作的基本原则是:
- 系统初始化读写的寄存器一般不需要保护,执行一遍就可以了。
- 对任务(或线程)中对寄存器写操作采用信号量的保护方式,并对寄存器的访问按功能进行分类。把整个功能端进行保护,防止具体功能执行一半操作时被打断。同时尽量让功能端的代码不要太长。
- 对只在某个线程中访问的寄存器可以不用保护,但原则上还是采用上面一条。
- 对uart输出,因只是debug时调用,release的代码不包含这部分,所以不进行保护。实际使用也没发现影响系统。
2 动态管理的结构体变量原子操作同步访问
无线网卡固件会有一些动态分配和释放的结构体变量,比如站点管理,牵涉到多个线程的访问和释放。对这样的结构体同样需要保护。防止结构体释放之后,还有线程会对该结构体进行访问操作。对已释放的空间读写数据的bug在调试阶段比较难发现和定位,所以编程阶段就需要预防出现这样的问题。
编程中保护的方法是采用原子操作的方式。具体的代码和操作步骤如下:
在实现站点管理时,
- 在加入一个站点时则调用kref_init(sta->kref),初始化原子变量为1
- 需要访问这个站点是则调用kref_get(sta->kref),则原子变量为2.
- 访问结束之后调用kref_put(sta->kref, release),则原子变量减为1.
- 当释放时则再次调用kref_put(sta->kref, release), 原子变量减为0,调用release函数释放站点内存。
- 对于多个线程的访问,因为都是采用kref_get,kref_put对,不会有问题。
- 对于task2调用释放站点的函数,如果这时候有task1线程刚获取了kref_get,则task2释放站点的函数不会调用release函数,只有task1线程调用kref_put之后,才会真正释放站点内存。这样就实现了不会对已经释放内存的站点空间进行操作了。
上面的代码参考了linux内核源代码,对于原子操作需要自己实现,ucos没有原子操作的函数,对于ARM7和ARM9可以采用开关中断的方式实现,对于Cortex-M3可以采用ARM的原子操作汇编指令实现。
3 双向链表的同步访问
网卡固件很多地方都采用了双向链表进行管理,比如站点管理,收发数据管理。对双向链表的操作包括加入一个节点,删除一个节点。除非该链表只在一个线程中使用,否则都采用信号量进行保护访问的方式。
(七) 增加些打印统计信息
1 输出统计信息辅助查找BUG。
在项目的Debug版本中,利用实时系统创建的第一个任务start task周期性的打印这些统计信息,为了不影响功能和性能,间隔时间设为30秒。
可以打印输出CPU的占用率,上下文切换次数,收发统计,动态内存分配次数的总的分配大小。
在早期系统调试的时候因问题比较多,这些打印信息能很快帮助定位问题,比如receive frame count总是固定不变,而且数字为某个特定的值,基本可以判断接收停止了,而且为什么停止。
而且通过接收的总次数和释放的总次数,以及接收之后的数据流向的个数,判断是否有未释放的,是那个模块处理的时候未释放。
通过动态内存分配的打印信息可以判断是否有内存泄漏,系统需要的堆大概需要多大。
ucos有CPU占用率的函数,直接调用就可以获取了,有些实时系统没有这样的函数,自己可以实现一个,原理就是实时系统有个系统时钟,每次触发,总的tick计数加1,系统空闲则,空闲(idle)任务会把自己的tick次数加1。(1-空闲任务tick数/总的tick数)就是cpu占用率。
2 输出实时系统的任务相关信息。
实时系统调试时,需要关注各个任务(线程)任务栈的使用情况。是否存在任务栈分配过大和过小的情况。过大浪费内存,过小会栈溢出。了解任务栈的使用情况,分配一个合适大小的栈。
ucos有计数栈使用大小的函数,编译配置时一般不使能。 因为计数会影响性能。原理就是把某个任务栈初始化全0,任务栈使用之后,这块内存区使用的地方就变为非0的值了。因为是栈,所以计算时,从栈顶向下,计数为0的个数直到碰到非0的内存地址。栈大小减去剩下的0的字节数就是栈的使用大小了。
(八) 按模块控制打印输出
无线网卡固件在设计时分多个模块(线程,接口),每个模块使用专门的打印输出宏。并定义一个全局变量wl_debug_components用来控制那个模块需要打印输出。
比如初始化设置为:
#if DEBUG
u32 wl_debug_components = COMP_INFO | COMP_TX | COMP_RX;
#endif
则代码运行是对调用COMP_INFO,COMP_TX, COMP_RX级别的打印语句输出打印信息。而且可以通过外部控制,如通过外部接口发送命令,修改wl_debug_components值,让程序输出打印和不打印某个模块的打印信息。
打印输出的设计要求:
- 按模块打印
- 外部可以控制打印输出
- debug版本把打印代码编译进去,release版本不编译进去。
(九) 巧用开发环境和调试工具
1. ARM的semihost机制
semihost机制是ARM的特点之一。可以利用JTAG在没有串口的情况下和调试环境的Command窗口收发相关信息,如写个菜单程序,在command窗口输出菜单,用户选择之后让程序做相应的操作。利用semihost好处是使用方便,在做一些功能性的测试时很有用,但printf的代码执行性能比用串口更差。
2. ARM调试器断点设置工具。
Realview是个很强力的调试工具,不仅仅是设置断点,在代码执行到设置断点处停下来,还可以通过设置断点表达式,让CPU在指定条件下停下来。
1)在对指定内存地址读写数据时触发断点。在调试过程中,经常会碰到某个区域被写了非法内容,想知道哪行代码在执行时进行了这样的操作吗?
该断点触发的条件是对地址0x0E001800写操作的时候,CPU停止执行,这样就能看到代码执行到哪儿了。command还可以让CPU在停止时再执行某些命令,比如向控制台打印些消息啊,执行某个函数,等等。
2)对某个函数执行n次,或者某行代码执行n次后停下。
3)条件执行停止。可以设置断点,让全局变量setchar = 10时停止。这样就可以在设定某个指定条件是触发断点,让程序停止执行。
(十) 利用CPU的特性来定位BUG
1 利用ARM CPU的异常模式定位bug
通常可以ARM CPU的指令异常:预取指异常,数据访问异常,未定义指令异常。比如执行一条非法指令(要么程序飞了,要么代码区破坏了)。非法向只读区写数据,预取指异常指向未获得正确访问权限的地方取指。一旦出现这样的问题,可以通过查看正常模式(user模式或者管理模式)的R14连接寄存器的值确定执行代码返回地址,结合编译器生成的.map函数映射文件,确定代码在执行什么函数,大概什么位置出现异常的。这种方法有很大的机会定位发生问题的地方。
2 利用ARM9 CPU的MPU或者MMU结合上面一条定位BUG。
ARM946E CPU是带有MPU的,编程时,可以把代码段和RO字段放在一个区,设置为只读属性,RW字段和ZI字段放在另外一个区,设置为读写属性。
这样在代码中出现的向只读区写数据的问题都可以捕捉到。很多bug出现问题时会向0x0地址区写数据,比如使用设置初值为0的指针型变量,进行写操作时就会产生数据访问异常。
(十一) 调试的软硬件配合
- 串口是个很好的打印输出辅助调试设备。
- 可以考虑IO口的输入输出,比如按钮,LED亮灯
- 利用系统的定时器。对于实时系统,可以使用os的系统定时器,输出精度为1ms或者10ms。对于us精度要求的,可以直接使用CPU上的定时器,比如需要计算某段代码的执行时间,或者看看错误出现的时间,出现的间隔时间。
- 可以配合示波器或者逻辑分析仪输出执行操作的时刻和间隔。
二 结论
对于一个嵌入式开发者来说,不断学习和经验积累,拓宽知识面对提高开发效率帮助很大。
熟悉自己使用的工具,熟悉CPU的体系结构。仔细阅读开发工具的帮助手册,仔细阅读ARM公司免费的CPU体系结构文档,免费的ARM公司的编译工具文档。这些文档比书店卖的ARM开发的书有价值的多。
阅读优秀的代码,积累编程技巧和调试手段。无论是内核开发,windows驱动开发,linux驱动开发还是嵌入式固件开发,很多技巧和技术是相通的。
阅读芯片手册,包括芯片开发手册,积累软硬件配合的设计技巧,结合芯片代码了解其实现机制。
调试时关注现象、细节,你的知识面可以帮助你从现象中很容易定位问题。
以上是我嵌入式开发调试的一些经验和体会。