iOS 写一个MallocStackLogging日志离线分析

4,431 阅读9分钟

1623398094_528517.jpeg

前言

最近看了自己搞一个MemoryGraph工具行不行?。文中提到,可以在运行时手动去控制malloc stack logging工具的开关,以及可以对日志文件的进行获取和分析。

本着学习心态,去简单实现一下malloc stack logging日志的离线分析,同时记录一下本次遇到的问题。个人水平有限,代码仅供参考。

代码

代码地址

除了项目代码以外,MallocStackLogging文件是我从自己的越狱iPhone7提取出来的系统MallocStackLogging动态库,可以拖进Hopper分析。libmalloc-166.251.2是我加了注释的源码。

目标

离线解析日志文件,
需要知道日志文件在哪里,
日志文件的数据是以什么格式保存的

日志文件在哪里?

其实文中有提到,日志文件是放在沙盒的tmp目录下的。不过,还是要自己探究一下。

8E61309D-2FAB-4D2B-B7F8-EA8EE0ADC706.png

经过测试,当选择为Live Allocations Only时是不生成日志的。(原因是libmalloc源码中就是这么写的)。
当选择为 All Allocation and Free History 的时候,

F7C88A37-AB08-4C93-8DF3-B231F2032FD4.png

EE750ED1D46F419727CD8F3E423E532F.jpg 日志文件地址就这么直接给出了吗?

日志文件是如何存储的?

extern boolean_t turn_on_stack_logging(stack_logging_mode_type mode);
extern void turn_off_stack_logging(void);

我决定从两个已知的开关方法入手。
翻阅源码 libmalloc-317.140.5 得知 libmalloc 都是调用的MallocStackLogging.framework的方法

Load the MallocStackLogging library and register it with libmalloc boolean_t malloc_register_stack_logger(void);

MALLOC_EXPORT
boolean_t
turn_on_stack_logging(stack_logging_mode_type mode)
{
	malloc_register_stack_logger();
	if (!msl.dylib) {
		return false;
	}
	boolean_t (*msl_turn_on_stack_logging) (stack_logging_mode_type mode);
	msl_turn_on_stack_logging = _dlsym(msl.dylib, "msl_turn_on_stack_logging");
	if (!msl_turn_on_stack_logging) {
		return false;
	}
	return msl_turn_on_stack_logging(mode);
}
MALLOC_EXPORT
boolean_t
malloc_register_stack_logger(void)
{
	if (msl.dylib != NULL) {
		return true;
	}
	void *dylib = _dlopen("/System/Library/PrivateFrameworks/MallocStackLogging.framework/MallocStackLogging", RTLD_GLOBAL);
	if (dylib == NULL) {
		return false;	
	}
	os_once(&_register_msl_dylib_pred, dylib, register_msl_dylib);
	if (!msl.dylib) {
		malloc_report(ASL_LEVEL_WARNING, "failed to load MallocStackLogging.framework\n");
	}
	return msl.dylib == dylib;
}

不管怎么样,先拿到MallocStackLogging.framework再说。
由于MallocStackLogging.framework是私有库,所以需要从越狱手机里的动态库缓存(dyld_shared_cache_arm64)里解析出来。这里越狱、解析的步骤不复赘述,网上有很多这方面的资料,拿到的MallocStackLogging.framework在代码文件夹中也有一份,有兴趣的朋友,可以拿去看看。

扔到Hopper里瞅瞅。

797B5640-5C1F-4611-AAF1-59651A574D46.png

整个库其实并不大,符号也不多。我看到了一些感兴趣的符号,比如_create_log_file。

A6BE41503A921F548710D312BC4630A9.png

那下一步呢?不会要真的分析汇编吧?

遇事不决,github搜一波

6AC1020D-55B8-4C3B-B2EB-A1E426241538.png

34ABEECE2DEA494E22351331621F669E.gif随便搜了个函数,看看我发现了啥

在低版本的libmalloc库中,是有源码的。

以下的分析都是基于libmalloc-166.251.2版本的。之后libmalloc-283就不存在这些代码了。

所以,我有一个大胆的想法:

你们什么都没干,只是把代码移到了MallocStackLogging.framework中对吧?(其实是改了的)

libmalloc-166.251.2 源码分析

我把加了注释的libmalloc-166.251.2源码也放入了代码文件中,里面有我的很多注释和蹩脚的翻译。如果对源码感兴趣的朋友,可以翻阅。希望对你有帮助。

4B216E22-C82A-41FD-8AFC-27E674DAED1F.png

比如这样的注释。

turn_on_stack_logging函数

turn_on_stack_logging
	__prepare_to_log_stacks(false);//初始化pre_write_buffers 
			__create_uniquing_table// 创建用于保存内存分配堆栈的哈希表uniquing_table
			create_log_file//创建日志文件
				index_file_descriptor//静态变量  日志文件句柄
				__stack_log_file_path__//全局变量 日志文件地址
	malloc_logger = __disk_stack_logging_log_stack;
	__syscall_logger = __disk_stack_logging_log_stack;

缩进表示调用关系。
可以看到MallocStackLogging.framework也是利用两个勾子函数malloc_logger和__syscall_logger来实现内存分配日志信息的记录的。核心是__create_uniquing_table函数,
这个函数会用来创建存放分配堆栈信息哈希表backtrace_uniquing_table。

假如我们有这样两个函数调用堆栈:

funcC  funcD
funcB  funcB
funcA  funcA

他们在哈希表中的存放是这样的:上才艺!

72B9ECD8-8D2E-47B0-808E-1F3248137B61.png

为了方便理解,我把图画成这样了。其实parent存放的是父节点的hash值也就是下标。 更多详细资料可以参考函数enter_frames_in_table。我在其中加了很多注释。

193DC892-41AD-4747-B08A-38B031A16EE0.png

其实我们只需要知道一个事情,哈希表backtrace_uniquing_table存放了堆栈信息。最后返回的stack_id就是最上层调用指令(比如funcC funcD)的hash值也是下标。有一个stack_id,我就可以一连串地拿到funcC funcB funcA。

一个stack_id 对应 一个堆栈列表信息。

在__prepare_to_log_stacks函数中,我还看到了

3C6ACF9C-6AEE-47C4-AD6D-77CEA4A27D64.png 这也解释了上面Live Allocations Only时,没有生成日志文件的问题。

turn_off_stack_logging函数

turn_off_stack_logging
	malloc_logger = NULL;
	__syscall_logger = NULL;
	stack_logging_enable_logging = 0;

这个函数就没什么好讲了,把两个勾子置空。

__disk_stack_logging_log_stack函数

__disk_stack_logging_log_stack
		__prepare_to_log_stacks(false); // 如果turn_on_stack_logging中初始化完成,这里什么都不会做了
		__prepare_to_log_stacks_stage2//主要在删除不需要的日志文件
			reap_orphaned_log_files(getpid(), NULL)//主要在删除不需要的日志文件
				reap_orphaned_log_files_in_hierarchy//主要在删除不需要的日志文件
					open_log_file_at_path(pathname, streams);//分析进程会进入这里
					delete_logging_file 删除日志
		__enter_stack_into_table_while_locked
			thread_stack_pcs   获取当前线程的堆栈指令列表
			enter_frames_in_table    当前分配的堆栈指令数组保存到哈希表uniquing_table
			__expand_uniquing_table   堆栈入表失败时,尝试扩容哈希表uniquing_table
		//精简模式下vm_allocate额外逻辑——start
		radix_tree_create  分配一页虚拟内存
			radix_tree_init
		radix_tree_insert
		//精简模式下vm_allocate额外逻辑——end
		flush_data(); // 写入日志文件

两个勾子函数都会走到__disk_stack_logging_log_stack中,堆栈信息入表和写文件的核心逻辑都在这里了。
首先是堆栈信息保存到哈希表中,enter_frames_in_table会在入表成功后,返回stack_id。 《自己搞一个MemoryGraph工具行不行?》文中提到:

看了下相关源码,发现原来系统并不是仅仅对stackid和frames做了一个map,而是用一个树来存储栈。并且是放在内存中的。这样一来是大大地减少了内存的占用,二来是通过stackid和树之间建立一种映射关系,查找速度也非常快!简直绝了!

但是,根据我看源码的结果。树的操作只会在精简模式下进行,而且入树的是stack_id和分配的内存地址。不知道是不是因为我看的是libmalloc库老版本的缘故。

if (stack_logging_mode_lite_or_vmlite && (type_flags & stack_logging_type_vm_allocate)) {
		if (pre_write_buffers) {
			//精简模式
			if (!pre_write_buffers->vm_stackid_table) {
				pre_write_buffers->vm_stackid_table	= radix_tree_create();
				pre_write_buffers->vm_stackid_table_size = radix_tree_size(pre_write_buffers->vm_stackid_table);
			}
			if (pre_write_buffers->vm_stackid_table) {
				uint64_t address = return_val;
				radix_tree_insert(&pre_write_buffers->vm_stackid_table,
								  trunc_page(address), round_page(address+size) - trunc_page(address),
								  uniqueStackIdentifier);
				pre_write_buffers->vm_stackid_table_size = radix_tree_size(pre_write_buffers->vm_stackid_table);
			}
		}
		goto out;
	}

之后,flush_data函数中,会把数据写入到日志文件中。

什么数据? stack_logging_index_event

typedef struct {
	uintptr_t argument;
	uintptr_t address;
	uint64_t offset_and_flags; // top 8 bits are actually the flags!
} stack_logging_index_event;

address存放是分配或者释放的内存地址(经过伪装处理)
argument存放是内存的大小
offset_and_flags 就有些复杂了。经过一堆宏的位运算。

(160_stack_id的低48位) | (type_flags的低8位_56个0 ) | (type_flags的24-32位_48个0) = type_flags的低8位_type_flags的24-32位_stack_id的低48位 = 共64

offset_and_flags的64位存放了stack_id 和 type_flags 和user_tag

源码总结

那么目前为止,我知道了日志文件中存放的是一个个stack_logging_index_event结构体。
那么通过解析日志文件,拿到stack_logging_index_event后,我就能知道address对应的stack_id, 再拿stack_id去查堆栈哈希表,就能拿到分配或释放这块内存的堆栈信息。

解析日志文件

接下来就是写代码了。

NSLog(@"获取到刚刚的日志文件:%@",filePath);
    const char *path = [filePath cStringUsingEncoding:4];
    FILE *fp = fopen(path,  "r");
    
    
    //❌ 错误1
    char bufferSpace[4096];
    size_t read_count = 0;
    size_t read_size = sizeof(my_stack_logging_index_event);
    size_t number_slots = (size_t)(4096 / read_size);

    if (fp != NULL) {
        do {
            read_count = fread(bufferSpace, read_size, number_slots, fp);
            if (read_count > 0) {
                my_stack_logging_index_event *target_64_index = (my_stack_logging_index_event *)bufferSpace;
                for (int i = 0; i < read_count; i++) {
                    my_stack_logging_index_event index_event = target_64_index[i];
                    my_mach_stack_logging_record_t pass_record;
                    pass_record.address = STACK_LOGGING_DISGUISE(index_event.address);
                    pass_record.argument = target_64_index[i].argument;
                    pass_record.stack_identifier = STACK_LOGGING_OFFSET(index_event.offset_and_flags);
                    pass_record.type_flags = STACK_LOGGING_FLAGS_AND_USER_TAG(index_event.offset_and_flags);

                    NSString *type = typeString(pass_record.type_flags);
                    NSLog(@"%@ size:%llu stackid:0x%llx address:%p",type,pass_record.argument,pass_record.stack_identifier,(void *)pass_record.address);
                }
            }
        } while (read_count > 0);
        fclose(fp);
    }

代码很简单,读日志文件,解析。

错误1.png

解析出来的数据,怎么看都不对劲。

我们来看看用系统api解析出来的数据是什么样子的。

extern kern_return_t __mach_stack_logging_enumerate_records(task_t task, mach_vm_address_t address, void enumerator(my_mach_stack_logging_record_t, void *), void *context);
    
void enumerate_records_hander(my_mach_stack_logging_record_t record, void * context) {
    NSString *type = typeString(record.type_flags);
    NSLog(@"%@ size:%llu stackid:0x%llx address:%p",type,record.argument,record.stack_identifier,(void *)record.address);
}

- (void)test_enumerate_records {
    if (!_isOpen) {
        NSLog(@"❎ 还没有打开日志开关呢");
        return;
    }
    __mach_stack_logging_enumerate_records(mach_task_self(), NULL, enumerate_records_hander, NULL);
}

结果是

06B847C1-0ADA-4137-AFDC-99A694B630F2.png

这个数据,看起来就正常多了。

问题出在哪里呢?我试图去找私有库的头文件,了解真正的结构,未果。
这个时候,我想起《自己搞一个MemoryGraph工具行不行?》文中提到,日志文件的内容是4个数一组的。这个跟我看源码的结果是不一致。不过不妨一试。
其实第一次解析时,有一个细节。就是第一条数据看上去像是一点点正常。

修改一下结构体:

//❌ 第二次错误尝试
typedef struct {
    uint64_t argument;
    uint64_t address;
    uint64_t offset_and_flags;
    uint64_t what;
} wrong_stack_logging_index_event64;

结果:

错误2.png

看起来address 和 size 是对了。stack_id和type_flags不对。

这里,我也思考一下🤔,什么情况下会需要修改结构体?
1.增加新功能了
2.成员的类型不够存了,需要改变结构
......

所以,会不会offset_and_flags被拆出来了?
那就不需要那么多位运算了。

验证一下:

2B8F3F04-FA3A-4107-A037-7C50E058FB87.png
图中左边是我自己解析的第一条数据。右边是使用系统api解析的数据。
注意观察左边的offset_and_flags和what的值

4C1405BC-9F94-4548-94E0-AB450ECA3C76.png

D4356FBA-B5C1-478D-949A-8861D585F901.png

所以猜想是正确的。不知道有朋友会不会迷惑高位的2。因为stack_logging_type和user_tag是一起存的。可以参考<mach/vm_statistics.h>中的VM_GET_FLAGS_ALIAS宏等等(这部分是我的理解)。

好了,再次修改结构体。

//✅ 这是现在的版本了
typedef struct {
    uint64_t argument;
    uint64_t address;
    uint64_t offset;
    uint64_t flags;
} test_stack_logging_index_event64;

这一次尝试就不贴图了,结果和系统api解析出来的一致。

获取到stack_id。我就可以查哈希表了。

接下来,用到了很多系统库的api。都可以在libmalloc-317.140.5库中的stack_logging.h文件中找到。

//用系统api拷贝一份哈希表出来
        struct backtrace_uniquing_table *table = __mach_stack_logging_copy_uniquing_table(mach_task_self());
        if (table != NULL) {
            //用系统api 使用stack_id 查找 堆栈信息
            mach_vm_address_t frames[MAX_FRAMES];
            uint32_t frames_count;
            // 从表中查询 堆栈
            kern_return_t ret = __mach_stack_logging_uniquing_table_read_stack(table, last_person_record.stack_identifier, frames, &frames_count, MAX_FRAMES);
            if (ret == KERN_SUCCESS) {
                if (frames_count > 0) {
                    NSLog(@"number of frames returned from __mach_stack_logging_get_frames = %u\n", frames_count);
                    NSLog(@"刚刚的person对象的分配堆栈如下:");
                    for (int i = 0; i < frames_count; i++) {
                        vm_address_t addr = frames[i];
                        Dl_info info;
                        dladdr((void *)addr, &info);
                        NSLog(@"---  %s",info.dli_sname);
                    }
                }
            }else {
                NSLog(@"__mach_stack_logging_uniquing_table_read_stack 调用失败❎");
            }
            
            //释放哈希表
            __mach_stack_logging_uniquing_table_release(table);
        }

我尝试去哈希表查找一个我观察的person对象的分配堆栈:

AC21865A-3DED-4D03-B7C2-32F856DFB6B4.png

到此,解析日志文件,查询哈希表的逻辑就通了。

至于离线解析,系统api中已经提供哈希表的序列化和反序列化方法。

EE750ED1D46F419727CD8F3E423E532F.jpg(苹果:我们预判了你那点小心思)

我们只需要写入到文件中就可以了。
下次就可以直接拿着一个哈希表文件,一个日志文件,对照解析就可以了。
项目中有相关代码。为了简化逻辑(懒),写入和读取文件的代码写得非常粗糙。仅供参考。

个人的一些思考

其实在看源码的过程中,我一直在思考这个功能的使用场景。
利用系统开放的两个勾子,来记录内存分配释放的堆栈信息的功能。其实在matrix-iOS和OOMDetector库中都已经见识过了。这两个库也许也是参考了官方的实现方式。
甚至于,在matrix-iOS的wiki中也写到__syscall_logger勾子是有审核风险的。

我真的能通过这个功能,收集用户手机上的日志信息吗?

就算可以。这么巨量的日志文件,又如何上传到我们的服务器呢?
用户的手机磁盘空间是非常宝贵的。不然iOS系统也不会用内存压缩的方式替换内存交换了。 那是不是意味着,只能在开发阶段使用?

libmalloc-317.140.5的stack_logging.h中的大量哈希表的操作方法也被标记为了过期。

最后

本次记录主要是看libmalloc-166.251.2源码的过程。与我们真正在使用的MallocStackLogging.framework有所差异。但是,源码中依然有很多值得学习的地方。
最后,感谢大佬的文章、感恩源码。