嵌入式系统的开发,软件的执行稳定可靠是很重要的。在芯片中,软件是没有质量的,但软件的质量能够决定一颗芯片的成败。芯片设计中,性能是否能满足设计要求,除了硬件设计、软硬件配合的设计技巧,对于软件来说。编程的一些技术和技巧相同重要。
本文讲述我在芯片固件开发过程中使用的一些编程调试技巧。
针对在嵌入式系统开发中常见的问题,如实时系统下的同步问题,动态内存分配的内存泄漏问题,怎样在编程阶段预防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(大小端不同)的訪问问题。
考虑代码的可移植性问题。
- 谨慎使用位域定义和操作,easy出现大小端问题。并且位域操作是须要较多指令才干实现。能够反汇编比較一下位域操作和位操作编译结果的不同。在有些嵌入式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变量时,增加__packedkeyword。
代码例如以下:
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口的输入输出。比方button,LED亮灯
- 利用系统的定时器。对于实时系统,能够使用os的系统定时器。输出精度为1ms或者10ms。对于us精度要求的,能够直接使用CPU上的定时器。比方须要计算某段代码的运行时间,或者看看错误出现的时间,出现的间隔时间。
- 能够配合示波器或者逻辑分析仪输出运行操作的时刻和间隔。
二 结论
对于一个嵌入式开发人员来说,不断学习和经验积累。拓宽知识面对提高开发效率帮助非常大。
熟悉自己使用的工具,熟悉CPU的体系结构。细致阅读开发工具的帮助手冊,细致阅读ARM公司免费的CPU体系结构文档。免费的ARM公司的编译工具文档。这些文档比书店卖的ARM开发的书有价值的多。
阅读优秀的代码,积累编程技巧和调试手段。
不管是内核开发。windows驱动开发,linux驱动开发还是嵌入式固件开发。非常多技巧和技术是相通的。
阅读芯片手冊,包含芯片开发手冊。积累软硬件配合的设计技巧,结合芯片代码了解事实上现机制。
调试时关注现象、细节,你的知识面能够帮助你从现象中非常easy定位问题。
以上是我嵌入式开发调试的一些经验和体会。