程序死机,崩溃这个应该是程序员调试代码中经常遇到的问题,同时也是最难调试的一个问题。那么什么样的现象是程序死机与崩溃呢?window系统的蓝屏就是一种,指操作系统运行遇到了致命的错误,无法运行,只能关机重新上电。对于嵌入式软件系统中,程序死机,崩溃也是程序运行遇到致命错误,无法运行。有的shell接口或命令行接口的系统,软件中如果提前编写了故障信息打印代码,在发生死机时,会看到相关的打印信息,能够根据打印信息来分析解决死机问题。本篇文章就是在一次实际调试程序死机时故障的记录,通过本文,你将了解了怎么通过一个打印信息来顺藤摸瓜打到引起程序死机,崩溃的代码,解决软件调试中最经常遇到,也最难调试的一个故障。
对于嵌入式处理器,导致死机的软件操作一般有,(1)、除法除数为0;(2)、访问非法内存(比如程序中对flash直接进行写操作,就是把flahs的存储空间当成了内存空间,直接进行了写操作);(3)、各种外设的寄存器操作不正确导致的硬件故障;当出现这些故障时,处理器都会进入一个特殊的硬件错误中断,在cortex-m3内核中就是hard fault中断,这个中断是一个死循环,为的是捕获这种致命的错误,使用户可以发现程序发生了严重的故障。对于这种故障中断发生,如果你详细了解cortex-m3内核的架构与原理,有一个快速的方法找到发生故障的代码,那就是在故障中断中查看堆栈寄存器的值,通过mem窗口查看堆栈寄存器中保存的内存地址开始的第7个字,就是发生故障的代码。从反汇编窗口中输入这第7个字的对应的代码地址,查看此处的代码是什么,引发故障的代码,仔细分析一下代码就找到了问题。
内存泄漏这个词起的很高大上,用白话解释一下就是,老王家的地和老赵家的地是邻居,老王种地(土地比喻为内存,种子比较为写入操作)时超过了自己家的地范围,种到了老赵家的地里,把老赵已经种好的地给破坏了。老王的种子泄漏到老赵家的土地里,造成了内存泄漏。内存泄漏也是上段提到的内存非法操作一种。只不过是内存泄漏有时在刚刚发生泄漏时,并不会引起软件的严重故障,软件还能运行,当软件运行到再次读写使用这段被改写的内存才可能引发死机。这时如果只是使用上段中所说的方法,相当时只是找到了作案现象,并不到抓到作案凶手。这种内存泄漏导致的死机是最最难解决的一种软件死机,本文就从最难处理的问题入手,带你一步步抓到作案凶手,给你提供一种软件死机的解题思路。
这次介绍的软件死机发生了rt thread操作系统中,操作系统软件中故障信息打印,当出现故障时,只是一个断言信息出现了 ,rt_free函数释放一个内存出错,并且打印出来断言,程序停止在断言中,捕捉到了故障发生的点。如下图。
从打印信息可知,rt_free释放内存出错误,即释放了一个非法的内存,这种现象只是在特殊的网络通信情况下出现,平时运行并没有出现,可以推断代码中rt_free()函数输入的释放内存值是合法的,如果是因为程序代码编写错误,释放内存只要一运行就会出现。出现错误的原因就是释放的这段内存被其他程序段给非法改写,导致rt_free释放时,检查出了内存被改写,打印出了断言。
通过程序的打印信息可以知道,内存堆中的0x2000b478内存被改写了,这个内存是哪个程序释放的呢?首先我们要找到rt_free释放的内存,因为打印出来的这个内存并不是被应用程序释放的内存。请看rt_free代码。
/**
* This function will release the previously allocated memory block by
* rt_malloc. The released memory block is taken back to system heap.
*
* @param rmem the address of memory which will be released
*/
void rt_free(void *rmem)
{
struct heap_mem *mem;
if (rmem == RT_NULL)
return;
RT_DEBUG_NOT_IN_INTERRUPT;
RT_ASSERT((((rt_uint32_t)rmem) & (RT_ALIGN_SIZE - 1)) == 0);
RT_ASSERT((rt_uint8_t *)rmem >= (rt_uint8_t *)heap_ptr &&
(rt_uint8_t *)rmem < (rt_uint8_t *)heap_end);
RT_OBJECT_HOOK_CALL(rt_free_hook, (rmem));
if ((rt_uint8_t *)rmem < (rt_uint8_t *)heap_ptr ||
(rt_uint8_t *)rmem >= (rt_uint8_t *)heap_end)
{
RT_DEBUG_LOG(RT_DEBUG_MEM, ("illegal memory\n"));
return;
}
/* Get the corresponding struct heap_mem ... */
mem = (struct heap_mem *)((rt_uint8_t *)rmem - SIZEOF_STRUCT_MEM);
RT_DEBUG_LOG(RT_DEBUG_MEM,
("release memory 0x%x, size: %d\n",
(rt_uint32_t)rmem,
(rt_uint32_t)(mem->next - ((rt_uint8_t *)mem - heap_ptr))));
/* protect the heap from concurrent access */
rt_sem_take(&heap_sem, RT_WAITING_FOREVER);
/* ... which has to be in a used state ... */
if (!mem->used || mem->magic != HEAP_MAGIC)
{
rt_kprintf("to free a bad data block:\n");
rt_kprintf("mem: 0x%08x, used flag: %d, magic code: 0x%04x\n", mem, mem->used, mem->magic);
}
RT_ASSERT(mem->used);
RT_ASSERT(mem->magic == HEAP_MAGIC);
/* ... and is now unused. */
mem->used = 0;
mem->magic = HEAP_MAGIC;
#ifdef RT_USING_MEMTRACE
rt_mem_setname(mem, " ");
#endif
if (mem < lfree)
{
/* the newly freed struct is now the lowest */
lfree = mem;
}
#ifdef RT_MEM_STATS
used_mem -= (mem->next - ((rt_uint8_t *)mem - heap_ptr));
#endif
/* finally, see if prev or next are free also */
plug_holes(mem);
rt_sem_release(&heap_sem);
}
从上面代码“mem = (struct heap_mem *)((rt_uint8_t *)rmem - SIZEOF_STRUCT_MEM);”以看出释放的内存rmem和打印出来的内存控制块mem之间是相差SIZEOF_STRUCT_MEM(12个字节),如下代码,就是一个结构体的长度。那么rmem的值就应该是mem+12,也就是说用户申请到的内存mem的值和内存控制块rmem之间相差12字节的。应用程序使用的内存地址应该是0x2000b478+0x0c(12) = 0x2000b484。
#define SIZEOF_STRUCT_MEM RT_ALIGN(sizeof(struct heap_mem), RT_ALIGN_SIZE)
#define HEAP_MAGIC 0x1ea0
struct heap_mem
{
/* magic and used flag */
rt_uint16_t magic;
rt_uint16_t used;
rt_size_t next, prev;
#ifdef RT_USING_MEMTRACE
rt_uint8_t thread[4]; /* thread name */
#endif
};
上面程序的打印信息显示rmem“0x2000b478”开始的第一个半字即结构体成员magic被修改了,magic正确的值应该为HEAP_MAGIC(0x1EA0),现在是0x1E00,即rmem地址开始的第一个字节被其他程序修改了。
根据代码结构分析,上述打印信息运行的在一个tcpcleinet的线程中,线程实现和服务器进行TCP双向异步通信的功能。被改写的内存很大可能是在这个线程中使用的动态分配内存或是静态数组的。首先确定一下程序中内存堆的占用的地址空间,确认是动态内存还是静态数组。
board.c文件中关于内存堆的初始化代码如下,通过代码可以看出内存堆使用的RAM空间范围是HEAP_BEGIN(Image$$RW_IRAM1$$ZI$$Limit)--HEAP_END(STM32 RAM的结束地址,即0x2001 0000)。
/**
* This function will initial STM32 board.
*/
void rt_hw_board_init(void)
{
HAL_Init();
SystemClock_Config();
#ifdef RT_USING_HEAP
rt_system_heap_init((void *)HEAP_BEGIN, (void *)HEAP_END);
#endif
#ifdef RT_USING_COMPONENTS_INIT
rt_components_board_init();
#endif
#ifdef RT_USING_CONSOLE
rt_console_set_device(RT_CONSOLE_DEVICE_NAME);
#endif
}
相关的宏定义为如下:
//
// Internal SRAM memory size[Kbytes] <8-64>
// Default: 64
#define STM32_SRAM_END (0x20000000 + STM32_SRAM_SIZE * 1024)
#ifdef __CC_ARM
extern int Image$$RW_IRAM1$$ZI$$Limit;
#define HEAP_BEGIN ((void *)&Image$$RW_IRAM1$$ZI$$Limit)
#elif __ICCARM__
#pragma section="HEAP"
#define HEAP_BEGIN (__segment_end("HEAP"))
#else
extern int __bss_end;
#define HEAP_BEGIN ((void *)&__bss_end)
#endif
#define HEAP_END STM32_SRAM_END
那么Image$$RW_IRAM1$$ZI$$Limit这个值代表的是什么呢?先查看权威的解释,来自mdk的帮助文件
帮助文件的解释为,Image$$RW_IRAM1$$ZI$$Limit是IRAM ZI段的结尾,在ARM处理器的编译映象占用的内存中分配为,RAM中先放置为RW段(即读写段),再放置ZI(初始化全部为0)。 ZI段后面的空间就是RAM的未用空间。这个 Image$$RW_IRAM1$$ZI$$Limit的意思就表示内部RAM中ZI段的超出结束位置地址,实际就是未使用的RAM空间的开始地址。这个地址的值实际应该是多少呢?只有在编译,链接完成后才能看到,查看map文件即可找到,如下图,即0x20002FB8。
到此内存堆的使用的空间就是0x20002fb8-0x20010000,被异常改写的内存地址0x2000B478正好在内存堆中,并且不在内存堆的边界,这里得出一个信息就是,这个被改写的内存中由于用户申请到的其他动态内存写错误导致的。 代码中申请动态内存的地方有几千处,应该从哪里入手查找呢?实现最有可能就是处于同一线程中的应用代码中申请的内存。此程序在tcpclient线程中,查看代码,通过仿真器在线查看所有用户申请的内存地址和长度或者通过串口打印出来内存地址。
pipe_buff:0x2000B2DC, 长度200字节,即占用内存堆范围为0x2000B2DC-0x2000B3A3,包括使用的内存控制块的12字节,占用的空间为0x2000B2D0-0x2000B3A3
sock_buff:0x2000B3B0, 长度200字节,即占用内存堆范围为0x2000B3B0-0x2000B477,包括使用的内存控制块的12字节,占用的空间为0x2000B3A4-0x2000B477
可以看出来,sock_buf的占用的内存范围和被改写的地址0x2000B478,紧挨着,如果对sock_buff内存进行写入操作发生一个字节的越界就是会改写了0x2000B478这个地址,至此就找到原因,就是因为对sock_buf的在某些情况下的写入操作导致出现。
问题的范围现在已经被大大缩小到对一个变量内存的读写操作,剩下的工作就是查看所有操作sock_buf的代码。关于此处代码不多,仅有2处。如下。
static void select_handle(rt_tcpclient_t *thiz, char *pipe_buff, char *sock_buff)
{
fd_set fds;
rt_int32_t max_fd = 0, res = 0;
max_fd = MAX_VAL(thiz->sock_fd, thiz->pipe_read_fd) + 1;
FD_ZERO(&fds);
while (1)
{
FD_SET(thiz->sock_fd, &fds);
FD_SET(thiz->pipe_read_fd, &fds);
res = select(max_fd, &fds, RT_NULL, RT_NULL, RT_NULL);
/* exception handling: exit */
EXCEPTION_HANDLE(res, "select handle", "error", "timeout");
/* socket is ready */
if (FD_ISSET(thiz->sock_fd, &fds))
{
res = recv(thiz->sock_fd, sock_buff, BUFF_SIZE, 0);
/* exception handling: exit */
EXCEPTION_HANDLE(res, "socket recv handle", "error", "TCP disconnected");
/* have received data, clear the end */
/*颐景园项目地信号不好,同时服务器针对这个设备的开关指令发送频率过高,sock_buff(0x2000b3b0)导致会收到BUFF_SIZE(200)长度的数据,
下面的操作就会意外的修改了其他的内存0x20000B478的magic_head,另外一个内存0x20000B478在free时发生断言 zhaoshimin 20191110*/
/*sock_buff[res] = '\0'; */
RX_CB_HANDLE(sock_buff, res);
}
/* pipe is read */
if (FD_ISSET(thiz->pipe_read_fd, &fds))
{
/* read pipe */
res = read(thiz->pipe_read_fd, pipe_buff, BUFF_SIZE);
/* exception handling: exit */
EXCEPTION_HANDLE(res, "pipe recv handle", "error", "");
/* have received data, clear the end */
/*修改原因同上line 352行 zhaoshimin 20191110*/
/*pipe_buff[res] = '\0';*/
/* write socket */
res = send(thiz->sock_fd, pipe_buff, res, 0);
/* exception handling: warning */
EXCEPTION_HANDLE(res, "socket write handle", "error", "warning");
}
}
exit:
rt_free(pipe_buff);
rt_free(sock_buff);
/*关闭连接,释放资源*/
rt_tcpclient_close(thiz);
}
static void tcpclient_thread_entry(void *param)
{
rt_tcpclient_t *temp = param;
char *pipe_buff = RT_NULL, *sock_buff = RT_NULL;
pipe_buff = rt_malloc(BUFF_SIZE);
if (pipe_buff == RT_NULL)
{
LOG_E("thread entry malloc pipe buff error\n");
return;
}
sock_buff = rt_malloc(BUFF_SIZE);
if (sock_buff == RT_NULL)
{
rt_free(pipe_buff);
LOG_E("thread entry malloc sock buff error\n");
return;
}
memset(sock_buff, 0, BUFF_SIZE);
memset(pipe_buff, 0, BUFF_SIZE);
select_handle(temp, pipe_buff, sock_buff);
}
从以上代码(代码已经改正错误)可以看出,res = recv(thiz->sock_fd, sock_buff, BUFF_SIZE, 0);用于读取最多BUFF_SIZE(200)个字节到sock_buf内存中,这句没有问题,下面的sock_buff[res] = '\0';这句就有问题,当读取到的数据长度为200,即res=200,再执行一次 sock_buff[200] = '\0',就发生了内存操作越界,改写了其他内存。
同理,pipe_buf的操作也存在同样的问题,但是实际使用中对pipe_buff[res] = '\0';的操作不会出现res=200的情况,所有这句代码就从来没有发现过内存操作越界的,但是代码有问题也一同更改。
找到问题代码,修改起来就很容易了,就是注释掉这两句内存操作越界的代码。
本文由表及里,深入浅出的完整描绘了一次实际项目经验中遇到的重大软件bug,这个软件bug是在时间很紧,压力很大的情况下解决的。整个bug的解决思路融入着12年嵌入式软件开发调试经验,一次很宝贵经验分享。如果读者想通过此篇文章开阔一下思路,提高一下调试技能,要多看几遍,才能理解这种思路。