前言
最近看了自己搞一个MemoryGraph工具行不行?。文中提到,可以在运行时手动去控制malloc stack logging工具的开关,以及可以对日志文件的进行获取和分析。
本着学习心态,去简单实现一下malloc stack logging日志的离线分析,同时记录一下本次遇到的问题。个人水平有限,代码仅供参考。
代码
除了项目代码以外,MallocStackLogging文件是我从自己的越狱iPhone7提取出来的系统MallocStackLogging动态库,可以拖进Hopper分析。libmalloc-166.251.2是我加了注释的源码。
目标
离线解析日志文件,
需要知道日志文件在哪里,
日志文件的数据是以什么格式保存的
日志文件在哪里?
其实文中有提到,日志文件是放在沙盒的tmp目录下的。不过,还是要自己探究一下。
经过测试,当选择为Live Allocations Only时是不生成日志的。(原因是libmalloc源码中就是这么写的)。
当选择为 All Allocation and Free History 的时候,
日志文件地址就这么直接给出了吗?
日志文件是如何存储的?
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里瞅瞅。
整个库其实并不大,符号也不多。我看到了一些感兴趣的符号,比如_create_log_file。
那下一步呢?不会要真的分析汇编吧?
遇事不决,github搜一波
随便搜了个函数,看看我发现了啥
在低版本的libmalloc库中,是有源码的。
以下的分析都是基于libmalloc-166.251.2版本的。之后libmalloc-283就不存在这些代码了。
所以,我有一个大胆的想法:
你们什么都没干,只是把代码移到了MallocStackLogging.framework中对吧?(其实是改了的)
libmalloc-166.251.2 源码分析
我把加了注释的libmalloc-166.251.2源码也放入了代码文件中,里面有我的很多注释和蹩脚的翻译。如果对源码感兴趣的朋友,可以翻阅。希望对你有帮助。
比如这样的注释。
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
他们在哈希表中的存放是这样的:上才艺!
为了方便理解,我把图画成这样了。其实parent存放的是父节点的hash值也就是下标。 更多详细资料可以参考函数enter_frames_in_table。我在其中加了很多注释。
其实我们只需要知道一个事情,哈希表backtrace_uniquing_table存放了堆栈信息。最后返回的stack_id就是最上层调用指令(比如funcC funcD)的hash值也是下标。有一个stack_id,我就可以一连串地拿到funcC funcB funcA。
一个stack_id 对应 一个堆栈列表信息。
在__prepare_to_log_stacks函数中,我还看到了
这也解释了上面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 就有些复杂了。经过一堆宏的位运算。
(16个0_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);
}
代码很简单,读日志文件,解析。
解析出来的数据,怎么看都不对劲。
我们来看看用系统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);
}
结果是
这个数据,看起来就正常多了。
问题出在哪里呢?我试图去找私有库的头文件,了解真正的结构,未果。
这个时候,我想起《自己搞一个MemoryGraph工具行不行?》文中提到,日志文件的内容是4个数一组的。这个跟我看源码的结果是不一致。不过不妨一试。
其实第一次解析时,有一个细节。就是第一条数据看上去像是一点点正常。
修改一下结构体:
//❌ 第二次错误尝试
typedef struct {
uint64_t argument;
uint64_t address;
uint64_t offset_and_flags;
uint64_t what;
} wrong_stack_logging_index_event64;
结果:
看起来address 和 size 是对了。stack_id和type_flags不对。
这里,我也思考一下🤔,什么情况下会需要修改结构体?
1.增加新功能了
2.成员的类型不够存了,需要改变结构
......
所以,会不会offset_and_flags被拆出来了?
那就不需要那么多位运算了。
验证一下:
图中左边是我自己解析的第一条数据。右边是使用系统api解析的数据。
注意观察左边的offset_and_flags和what的值
所以猜想是正确的。不知道有朋友会不会迷惑高位的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对象的分配堆栈:
到此,解析日志文件,查询哈希表的逻辑就通了。
至于离线解析,系统api中已经提供哈希表的序列化和反序列化方法。
(苹果:我们预判了你那点小心思)
我们只需要写入到文件中就可以了。
下次就可以直接拿着一个哈希表文件,一个日志文件,对照解析就可以了。
项目中有相关代码。为了简化逻辑(懒),写入和读取文件的代码写得非常粗糙。仅供参考。
个人的一些思考
其实在看源码的过程中,我一直在思考这个功能的使用场景。
利用系统开放的两个勾子,来记录内存分配释放的堆栈信息的功能。其实在matrix-iOS和OOMDetector库中都已经见识过了。这两个库也许也是参考了官方的实现方式。
甚至于,在matrix-iOS的wiki中也写到__syscall_logger勾子是有审核风险的。
我真的能通过这个功能,收集用户手机上的日志信息吗?
就算可以。这么巨量的日志文件,又如何上传到我们的服务器呢?
用户的手机磁盘空间是非常宝贵的。不然iOS系统也不会用内存压缩的方式替换内存交换了。
那是不是意味着,只能在开发阶段使用?
libmalloc-317.140.5的stack_logging.h中的大量哈希表的操作方法也被标记为了过期。
最后
本次记录主要是看libmalloc-166.251.2源码的过程。与我们真正在使用的MallocStackLogging.framework有所差异。但是,源码中依然有很多值得学习的地方。
最后,感谢大佬的文章、感恩源码。