1.内存分配器
1.1内存块大小分配
默认情况下分为53个class,每个class对应着分配不同大小的内存块
class id | size | diff |
c00 | 0 | +0 |
c01 | 16 | +16 |
cn1 | 16*n1 | +16 |
c16 | 256 | +16 |
c17 | 320 | +64 |
cn2 | t = 256 << ((n2 -16)>>2) t + (t >> 2) *((n2-16)&7) | |
c52 | 131072 | +16384 |
在申请256字节以下的内存时,粒度比较小,每16个字节都有一个对应的class,比如要申请10字节,实际会申请32(16个字节为内存块信息 + 10字节用户内存 + 6 字节对齐)字节对应class2。
而在申请256字节以上大小的内存时,粒度会适当比较大一点,计算公式如下:
t = 256 << ((n2 -16)>>2) t + (t >> 2) *((n2-16)&7)
最大可以申请131072,也就是32*页(32*4096)大小内存
如果超过该大小,该内存分配器就不适用,会选择更大的内存分配器,大内存分配器是直接使用mmap/munmap来申请释放内存。
1.2内存地址空间
内存分配器分为两个部分,第一部分内存空间(图中kSpaceSize大小对应的区域),用户会在该区域内进行内存的申请和释放。第二部分区域信息空间(图中InfoSize大小对应区域),主要记录用户分配内存的情况
内存空间的大小根据平台差异而不同,aarch64的大小为3T,总共有53个class(为了方便对齐,每一块区域kRegionSize = 3T / 64),
const uptr kAllocatorSpace = 0x10000000000ULL;
const uptr kAllocatorSize = 0x10000000000ULL; // 3T.
每个class的内存区域又被分为两部分,第一部分为用户可分配的内存大小(总大小kRegionSize/8*7),第二部分freeArray数组,数组内容为对应内存空间空闲chunk的偏移。
区域信息空间同样也同样按照class进行划分为RegionInfoSize,每个RegionInfo中记录着剩余chunks个数,用户申请内存偏移,用户映射内存偏移等信息。
1.3用户申请内存
假设用户申请10字节内存,
- 根据申请字节大小,判断使用哪种分配器,10字节大小,使用小内存分配器即可
- 申请10字节内存,选择最接近的class,为class2,其内存块大小为32字节
- 首先会每个class都有一块缓存器,先从缓存中查找是否还有空闲内存块,如果有则直接取出,并把可用块计数减1。
- 如果没有,则需要从分配器去取,这时首先就会找到对应class的regionInfo,查看num_freed_chunk,如果num_freed_chunk个数够,则取出对应的个数到缓存中,将num_freed_chunk计数减少,缓存空闲块计数增加
- 如果num_freed_chunk个数也不够了,那么则需要重新申请内存,一般申请64K大小,然后平分成对应class大小,每个class大小的地址偏移都对应着一个freeArray,总共就新增了num_freed_chunk += 64K/(classid2size),同理将一部分取出到缓存中。
1.4用户释放内存
同理用户释放该块内存
1.根据地址就能知道对应的classid,找到对应的缓存器,如果缓存器内存块个数没满,释放内存,缓存器空闲内存块加1
2.如果缓存器内存个数满了,那么就需要将一半的内存块还给内存分配器,对应的freeArray数组变大,num_freed_chunk +=c->max_count/2;
3.内存是否可以返回给OS,返回给OS最小单位为页,比如classid对应大小16字节,则一个完整的页就有256个内存块,而这些个内存块由于是同一页,所以其地址只有后12位不同,根据这个特性,遍历freeArray,记录每一页当前freeArray的个数,如果达到了256个,说明该页所有内存块都是空闲的,那么就可以回收。否则,则不能回收。
2.内存越界
2.1堆内存越界
2.1.1检测原理
asan会映射出一块额外的内存,叫做影子内存,每8个字节的实际内存都会对应在影子内存中对应的1个字节中,通过该字节中的不同标志,来反馈对应的8个字节的访问情况。
0xfa | kAsanHeapLeftRedzoneMagic | 堆区红区标志,如果访问到这块区域,表明越界 |
0xfd | kAsanHeapFreeMagic | 堆区释放标志,这块区域被释放了,如果访问到,则认为释放后又使用 |
… | … | … |
00 | 表明8个字节都可以正常访问 | |
01-07 | 表明前n字节可用正常访问,若访问到后8-n个字节,则会报内存越界 |
结合上述表,通过影子内存就可用判断访问的地址是否可寻址,影子内存的地址是访问地址右移3位再加上1ULL << 36;如下图所示
MEM_TO_SHADOW(mem) (((mem) >> SHADOW_SCALE) + (SHADOW_OFFSET))
2.1.2使用方法
2.1.2.1链接asan库
直接链接asan库,asan会对一些特定内存操作函数进行重载,如strcpy,通过优先加载asan库,先执行该函数,在函数中对内存进行检查,然后再通过dlsym(RTLD_NEXT, name)形式调用到libc中的函数。
INTERCEPTOR(char *, strcpy, char *to, const char *from) { void *ctx; ASAN_INTERCEPTOR_ENTER(ctx, strcpy); ENSURE_ASAN_INITED(); if (flags()->replace_str) { uptr from_size = REAL(strlen)(from) + 1; CHECK_RANGES_OVERLAP("strcpy", to, from_size, from, from_size); ASAN_READ_RANGE(ctx, from, from_size); //对源内存的前size字节进行检查 ASAN_WRITE_RANGE(ctx, to, from_size); //对目的内存的前size字节进行检查 } return REAL(strcpy)(to, from); //执行真正的strcpy函数 |
2.1.2.2 添加编译选项fsanitize=address
如果只链接,只会在特定几个函数的地方检测,所以可能会检测不出内存越界,比如说直接操作内存(p[11] =1),如果想要检测出这种内存越界,就需要在编译的时候,加上编译选项 -fsanitize=address,可以通过反汇编查看区别
源代码:
char *pp; int k = 11; pp = (char*)malloc(10); *(pp + k) = '1'; |
不带编译选项
bl 420e10 <malloc@plt> mov w1, #0x31 strb w1, [x0, #11] | malloc 10 字节内存 k = 11; 将值‘1’,存到对应的内存里即*(pp + k) = '1'; |
加编译选项 -fsanitize=address
mov x20, #0x1000000000 mov x0, #0xa bl 421b20 <malloc@plt> mov x1, x0 add x0, x0, #0xb and w3, w0, #0x7 lsr x2, x0, #3 ldrsb w2, [x2, x20] cmp w2, #0x0 ccmp w3, w2, #0x1, ne b.lt 429378 <main+0x738> bl 425380 <__asan_report_store1@plt> mov w0, #0x31 strb w0, [x1, #11] | malloc 10 字节内存 返回值x0 也就是内存首地址 找到pp+k(11)的地址也就是x0+0xb w0为x0低32位地址 &0x7即x0的低3位 内存地址右移3位 +0x1000000000 就能找到对应的shadow的地址,进行取值 该值为0,表明对应的8个字节都可寻址 比较 地址低3位 和 对应shodow值 如果当前地址大于shodow值-1,存在内存越界,打印错误信息 将值‘1’,存到对应的内存里即*(pp + k) = '1'; |
2.1.3检测实例
还是以上述源码为例,用户申请10字节内存,用户访问的内存首地址为0x007fa67028b0(前面还会有几个字节记录内存块信息,其对应的影子内存值为0xfa),根据公式找到对应影子内存地址为0x001ff4ce0516,第一个字节值为00,表示8个字节均可访问,第二个字节为02,表示前两个字节可访问,这就正好对应了用户申请的10个字节,如果程序中有越界访问,如UserPtr[11],其地址为0x007fa67028b0+0x0b = 0x007fa67028bb,其低三位的偏移 3,表明其是八字节对齐中的第4个内存,而0x007fa67028bb对应的影子内存0x001ff4ce0517其值为2,表示只有两个地址可以访问,所以UserPtr[11]不可访问,就会报内存越界。
2.2栈内存越界
2.2.1检测原理
栈内存越界检测原理和堆内存越界一样,通过影子内存记录内存可访问信息,不过栈的变量是在进入函数内部生成,所以开启栈内存越界检测后,进入每个函数,首先都需要将该函数栈中所有变量的影子内存及其边界赋予对应的值,这样一来如果栈中成员存在越界,就能被检测出来,具体变量分布可以参考下图。
0xf1 | kAsanStackLeftRedzoneMagic | 栈左边界越界 |
0xf2 | kAsanStackMidRedzoneMagic | 栈中间越界,也即是栈变量之间存在越界 |
0xf3 | kAsanStackRightRedzoneMagic | 栈右边界越界 |
00 | 表明8个字节都可以正常访问 | |
01-07 | 表明前n字节可用正常访问,若访问到后8-n个字节,则会报内存越界 |
2.2.2使用方法
加上编译选项 -fsanitize=address
int main(int argc, char * argv[]) { char *p1,*p2,*p3,*p4; int p5 = 20; … return 1; } |
42a258: 9101c3f4 add x20, sp, #0x70 42a284: aa1403f3 mov x19, x20 42a290: d2c00201 mov x1, #0x1000000000 42a294: d343fe98 lsr x24, x20, #3 42a298: 3204d3e2 mov w2, #0xf1f1f1f1 42a2b8: 8b540c20 add x0, x1, x20, lsr #3 42a2bc: b8216b02 str w2, [x24, x1] 42a2c0: 1144c042 add w2, w2, #0x130, lsl #12 42a2c4: b9000402 str w2, [x0, #4] 42a2c8: 529e4002 mov w2, #0xf200 42a2cc: 72be5e42 movk w2, #0xf2f2, lsl #16 42a2d0: 29010802 stp w2, w2, [x0, #8] 42a2d4: b9001002 str w2, [x0, #16] 42a2d8: 529e6002 mov w2, #0xf300 42a2dc: 72be7e62 movk w2, #0xf3f3, lsl #16 42a2e0: b9001402 str w2, [x0, #20] 每个函数开头都会有类似代码,目的就是往栈中变量的影子内存写入可访问的值以及其左右边界分别写入特定值 |
2.3重复释放或者释放后使用
释放内存后,该段内存的影子内存会被标记为0xfd,如果再次使用,或者再次释放时,都会对影子内存进行检测,发现是0xfd后就会报错.
3.内存泄漏
3.1检测原理
根据上述分配器,先从infoSize中获取每个class当前分配了多少地址,然后遍历每个chunk,每个chunk都有一个标志位,标志着是不是内存泄漏,如果是,就输出报告,这样就能找到所有内存泄漏的块并打印出来。
那么内存泄漏标志是如何标记的,asan会遍历所有全局变量,线程堆栈,以及堆区,查看是否有指针指向申请的内存,如果有,则认为该块内存在使用,不是泄露的内存,否则,则认为该块内存没有被使用,同时也没有被释放,则认为是内存泄漏
1.首先是全局变量,也就是bss、data区域
那么也就是从.data段的地址开始,直到.bss段的结束,按8字节逐一遍历(64位一个指针为8字节),用户申请的内存块(这里的地址不一定是用户申请的首地址,也可以是中间地址,可以通过下图,找到内存块首地址,所以只要申请内存块某一字节再使用,就不会被判断为内存泄漏),在用户指针之前还有额外的几个字节记录内存块信息ChunkMetadata,通过这个就能判断是否是申请的内存块,找到所有在使用的内存块,标志为kReachable。
2.其次是遍历堆栈,遍历所有线程,找到其栈顶和栈底的位置,进行遍历,遍历的过程和1一致,找到所有在使用的内存块,标志为kReachable。
3.在这些找到的kReachable的内存块中和标志为kIgnored内存块,同样执行8字节遍历,在堆中找是否有在使用的内存。如果有一块内存块存在泄漏,而其指向另一个指针,那么这两块内存都会被找出来,因为这里是kReachable的内存块进行遍历,这两个内存块都不会再遍历的范围内。如下例所示
void main(void) { int **p1; int *p2; p1 = (int**)malloc(sizeof(int*)); p2 = (int*)malloc(sizeof(int)); p2[0] = 1; p1[0] = p2; } | |
由于p1在函数结束后没有释放,所以存在泄漏,被定义为Direct leak,直接泄漏 | 由于p1指向p2,但是p1本身是一块泄漏的内存块,所以p2被定义为Indirect leak,不是直接的泄漏 |
3.2使用方法
默认情况下已经支持detect_leaks,asan调用atexit(DoLeakCheck),在用户执行exit的时候,会调用DoLeakCheck,做一次内存泄漏的检查。
遇到过,exit无法正常退出程序,无法正常执行atexit注册的函数,可以手动调用asan外部的接口__lsan_do_leak_check(),就可以触发内存泄漏检测了。
在部分情况下,内存泄漏会存在误判,所以需要自行区分
- 结构体对齐方式不是8字节,遍历的时候就可能遍历不到,导致误报
- 通过共享内存或者其他系统调用,绕过asan的内存申请,这块内存就不会在遍历堆的时候被遍历到,那么其(如果该块内存是结构体)下级的所有指针都可能
都会被误判为内存泄漏。