阅读micropython源码-内存管理组件GC
苏勇,2021年8月
文章目录
相关源文件:
- ports/mimxrt/main.c
- py/gc.h
- py/gc.c
- lib/utils/gchelper.h
- lib/utils/gchelper_generic.c
- lib/utils/gchelper_m0.s
- lib/utils/gchelper_m3.s
- lib/utils/gchelper_native.c
初探micropython中的内存管理机制
阅读micropython源码,从main()函数入手。在main()函数中,除了初始化功能组件,例如board_init()、tusb_init()和led_init()外,在正式初始化micropython之前,对mp_stack和gc进行了初始化,这两个组件操作了来自链接命令文件的四个保存了内存指针的变量。我猜想mp_stack和gc分别是管理micropython内部的栈和堆内存空间,但同时C运行环境还会使用系统默认的堆栈空间,那么,包含micropython的系统到底是如何管理内存的呢?
extern uint8_t _sstack, _estack, _gc_heap_start, _gc_heap_end;
int main(void) {
board_init();
tusb_init();
led_init();
mp_stack_set_top(&_estack);
mp_stack_set_limit(&_estack - &_sstack - 1024);
for (;;) {
gc_init(&_gc_heap_start, &_gc_heap_end);
mp_init();
...
gc_sweep_all();
mp_deinit();
}
return 0;
}
分析指定的内存相关参数
原本我是以mimxrt作为具体的移植对象进行阅读,但是mimxrt的linker file描述比较复杂,涉及到片内的好几块内存和外扩内存,所以最终我选择使用samd21q18a_flash.ld文件作为参考分析对象。samd21q18a_flash.ld描述的内存分配模型非常简单,全部使用片内FLASH和片内RAM,这也是一个典型的MCU内存分配系统:
/* Memory Spaces Definitions */
MEMORY
{
rom (rx) : ORIGIN = 0x00000000, LENGTH = 0x00040000
ram (rwx) : ORIGIN = 0x20000000, LENGTH = 0x00008000
}
其中关于Stack的定义如下:
/* The stack size used by the application. NOTE: you need to adjust according to your application. */
STACK_SIZE = DEFINED(STACK_SIZE) ? STACK_SIZE : DEFINED(__stack_size__) ? __stack_size__ : 0x2000;
/* Section Definitions */
SECTIONS
{
...
/* stack section */
.stack (NOLOAD):
{
. = ALIGN(8);
_sstack = .;
. = . + STACK_SIZE;
. = ALIGN(8);
_estack = .;
} > ram
...
}
但是“_gc_heap_start”和“_gc_heap_end”是mimxrt所独有的,所以只能硬着头皮看mimxrt的linker file。
MEMORY
{
m_flash_config (RX) : ORIGIN = flash_config_start, LENGTH = flash_config_size
m_ivt (RX) : ORIGIN = ivt_start, LENGTH = ivt_size
m_interrupts (RX) : ORIGIN = interrupts_start, LENGTH = interrupts_size
m_text (RX) : ORIGIN = text_start, LENGTH = text_size
m_vfs (RX) : ORIGIN = vfs_start, LENGTH = vfs_size
/* Teensy uses the last bit of flash for recovery. */
m_reserved (RX) : ORIGIN = (vfs_start + vfs_size), LENGTH = reserved_size
m_itcm (RX) : ORIGIN = itcm_start, LENGTH = itcm_size
m_dtcm (RW) : ORIGIN = dtcm_start, LENGTH = dtcm_size
m_ocrm (RW) : ORIGIN = ocrm_start, LENGTH = ocrm_size
}
其中m_itcm、m_dtcm和m_ocrm是3个独立的片内RAM,itcm和dtcm是高速RAM,从后续的代码中可以看到,itcm专用于存放ram_func,dtcm存放了data段、bss段、heap段(系统堆)、stack段(系统栈),其中stack为dtcm的末尾。ocrm是独立的低速RAM。
__StackTop = ORIGIN(m_dtcm) + LENGTH(m_dtcm);
__StackLimit = __StackTop - STACK_SIZE;
PROVIDE(__stack = __StackTop);
而在“MIMXRT1011.ld”文件中定义了main.c引用的4个关于内存的变量:
ocrm_start = 0x20200000;
ocrm_size = 0x00010000;
/* 20kiB stack. */
__stack_size__ = 0x5000;
_estack = __StackTop;
_sstack = __StackLimit;
/* Do not use the traditional C heap. */
__heap_size__ = 0;
/* Use second OCRAM bank for GC heap. */
_gc_heap_start = ORIGIN(m_ocrm);
_gc_heap_end = ORIGIN(m_ocrm) + LENGTH(m_ocrm);
_estack和_sstack就是系统栈,_gc_heap_start和_gc_heap_end代表了整个ocrm的空间。现在看起来mp_stack就是接管了系统栈,而gc是用了系统堆之外额外的一块独立空间。此处猜测,mp_stack仅仅是观察系统栈的使用情况,不大可能直接操作系统栈,因为系统栈中还会包含micropython之外的硬件自动压栈的信息,这些内容是不能让micropython随便操作的,但可以让micropython读,以获取某些程序运行时的信息。gc可能对应micropython的动态内存分配机制,用掉了整块ocrm的64KB内存,可能对应某些malloc()和free()函数的实现。
关于mp_stack的分析,将在另一篇文章中详述,本文将重点追溯gc的实现代码。
通用Python的GC垃圾收集机制
实际上,关于GC,经过多次试探性地阅读micropython源码,我已经有了一知半解的概念。GC的全名是垃圾收集器Garbage Collector,是Python标准实现的一个概念,是Python中管理内存的一个功能组件。
参考《python 垃圾回收器_Python 垃圾回收机制》文章的介绍:
https://blog.youkuaiyun.com/weixin_35853363/article/details/112933690
Python中的垃圾回收是以引用计数为主,分代收集为辅。
在Python中,如果一个对象的引用数为0,Python虚拟机就会回收这个对象的内存。
classA:
def __init__(self):
self.t=None
print 'new obj, id is %s' %str(hex(id(self)))
def __del__(self):
print 'del obj, id id %s' %str(hex(id(self)))
if __name__ == '__main__':
while True:
a1=A()
del a1
运行如上代码,进程占用的内存基本不会变动:
new obj, id is 0x2a79d48L
del obj, id id 0x2a79d48L
a1 = A() 会创建一个对象,在0x2a79d48L内存中,a1变量指向这个内存,这时候这个内存的引用计数是1。del a1后,a1变量不再指向0x2a79d48L内存,所以这块内存的引用计数减一,等于0,所以就销毁了这个对象,然后释放内存。
导致引用计数+1的情况:
- 对象被创建,例如a=23
- 对象被引用,例如b=a
- 对象被作为参数,传入到一个函数中,例如func(a)
- 对象作为一个元素,存储在容器中,例如list1=[a,a]
导致引用计数-1的情况:
- 对象的别名被显式销毁,例如del a
- 对象的别名被赋予新的对象,例如a=24
- 一个对象离开它的作用域,例如f函数执行完毕时,func函数中的局部变量(全局变量不会)
- 对象所在的容器被销毁,或从容器中删除对象
从main.c入手分析micropython中的gc组件
在main()函数中有两处调用gc的函数:
gc_init(&_gc_heap_start, &_gc_heap_end);
gc_sweep_all();
在main.c文件中还有一个单独的gc_collect(),谁会调用这个函数?
gc_init()
先跟到gc_init()函数中,gc接管的整块gc_heap被分为了gc_alloc_table、gc_finaliser_table和gc_pool。
void gc_init(void *start, void *end) {
// calculate parameters for GC (T=total, A=alloc table, F=finaliser table, P=pool; all in bytes):
// T = A + F + P
// F = A * BLOCKS_PER_ATB / BLOCKS_PER_FTB
// P = A * BLOCKS_PER_ATB * BYTES_PER_BLOCK
// => T = A * (1 + BLOCKS_PER_ATB / BLOCKS_PER_FTB + BLOCKS_PER_ATB * BYTES_PER_BLOCK)
...
MP_STATE_MEM(gc_alloc_table_start) = (byte *)start;
...
MP_STATE_MEM(gc_finaliser_table_start) = MP_STATE_MEM(gc_alloc_table_start) + MP_STATE_MEM(gc_alloc_table_byte_len);
...
MP_STATE_MEM(gc_pool_start) = (byte *)end - gc_pool_block_len * BYTES_PER_BLOCK;
MP_STATE_MEM(gc_pool_end) = end;
...
assert(MP_STATE_MEM(gc_pool_start) >= MP_STATE_MEM(gc_finaliser_table_start) + gc_finaliser_table_byte_len);
}
gc_alloc_table和gc_finaliser_table记录内存块使用的情况,gc_pool就是内存块的物理存放空间。
再看gc.h文件,果不其然,这里还定义了典型的内存管理API:
void *gc_alloc(size_t n_bytes, unsigned int alloc_flags);
void gc_free(void *ptr); // does not call finaliser
size_t gc_nbytes(const void *ptr);
void *gc_realloc(void *ptr, size_t n_bytes, bool allow_move);
在程序中可以使用gc_alloc()和gc_free(),以及另外的内存分配函数,在gc管辖的内存区域中申请和释放内存。若gc中的某些个内存块的引用数为0时(变成了“野指针”),在系统调用gc_collect()时,即可自动回收内存,而不用显式调用gc_free()。这种case是为了应对Python中能够实现动态创建内存并在自动回收已经不再使用的内存,防止过早地出现内存溢出。
gc_collect()
至于gc_collect()函数的实现,这里也有一个trick。
// A given port must implement gc_collect by using the other collect functions.
void gc_collect(void);
void gc_collect_start(void);
void gc_collect_root(void **ptrs, size_t len);
void gc_collect_end(void);
正如代码中的注释说明,gc_collect()函数需要在移植代码中实现,并且在其中调用另外三个gc_collect_xxx()函数。gc_collect_start()和gc_collect_end()传入和传出的参数都是void,猜测是要根据具体的移植情况,通过gc_collect_root()函数传入符合不同策略的参数。例如,在mimxrt移植实现的main.c函数中,就有如下代码:
void gc_collect(void) {
gc_collect_start();
gc_helper_collect_regs_and_stack();
gc_collect_end();
}
咦,这个gc_helper_collect_regs_and_stack()是什么鬼,竟然没有参数,看起来也是一个通用实现。跟进去看一下。gc_helper_collect_regs_and_stack()函数在“lib/utils/gchelper.h”文件中声明,跟arm cortex-m架构相关的代码如下:
typedef uintptr_t gc_helper_regs_t[10];
void gc_helper_collect_regs_and_stack(void);
在lib/utils/gchelper_generic.c文件中关于arm cortex-m架构的相关代码如下:
// Fallback implementation, prefer gchelper_m0.s or gchelper_m3.s
STATIC void gc_helper_get_regs(gc_helper_regs_t arr) {
register long r4 asm ("r4");
register long r5 asm ("r5");
register long r6 asm ("r6");
register long r7 asm ("r7");
register long r8 asm ("r8");
register long r9 asm ("r9");
register long r10 asm ("r10");
register long r11 asm ("r11");
register long r12 asm ("r12");
register long r13 asm ("r13");
arr[0] = r4;
arr[1] = r5;
arr[2] = r6;
arr[3] = r7;
arr[4] = r8;
arr[5] = r9;
arr[6] = r10;
arr[7] = r11;
arr[8] = r12;
arr[9] = r13;
}
// Explicitly mark this as noinline to make sure the regs variable
// is effectively at the top of the stack: otherwise, in builds where
// LTO is enabled and a lot of inlining takes place we risk a stack
// layout where regs is lower on the stack than pointers which have
// just been allocated but not yet marked, and get incorrectly sweeped.
MP_NOINLINE void gc_helper_collect_regs_and_stack(void) {
gc_helper_regs_t regs;
gc_helper_get_regs(regs);
// GC stack (and regs because we captured them)
void **regs_ptr = (void **)(void *)®s;
gc_collect_root(regs_ptr, ((uintptr_t)MP_STATE_THREAD(stack_top) - (uintptr_t)®s) / sizeof(uintptr_t));
}
gc_helper_collect_regs_and_stack()函数内部首先定义了一个指向寄存器组的指针,然后通过汇编语言实现的gc_helper_get_regs()函数拿到当前CPU内部寄存器的值。这里注意,gchelper_m0.s和gchelper_m3.s实现的函数是gc_helper_get_regs_and_sp(),并未在此处调用。然后把这些(压入系统栈内的)寄存器一并送入到gc_collect_root()函数中。
void gc_collect_root(void **ptrs, size_t len) {
for (size_t i = 0; i < len; i++) {
void *ptr = gc_get_ptr(ptrs, i);
if (VERIFY_PTR(ptr)) {
size_t block = BLOCK_FROM_PTR(ptr);
if (ATB_GET_KIND(block) == AT_HEAD) {
// An unmarked head: mark it, and mark all its children
TRACE_MARK(block, ptr);
ATB_HEAD_TO_MARK(block);
gc_mark_subtree(block);
}
}
}
}
此时再看gc_collect_root()函数的实现,顾名思义,只是一个执行collect的入口。首先遍历栈中的每个指针,通过VERIFY_PTR()函数查验其是否为gc管理的内存资源(位于gc_pool中),若是,则找到这块内存所对应的block,若这个block被标记为“AT_HEAD”,说明目前定位到一串已经分配但不再使用的内存块链表,则在gc_alloc_table表中标记该块和挂在它后面的字块,表示它们可以被重新分配使用。
gc_sweep_all()
这里顺便还看到一个常用的函数gc_deal_with_stack_overflow(),这个函数在gc_sweep_all()中被调用了,也顺便分析一下。这个函数的功能在于,当出现内存溢出的情况下(堆溢出而不是字面上的栈溢出,gc只能管理堆不能管理栈),从头开始逐个扫描整个gc_alloc_table,看能不能找到碎片的内存(没有子块的内存块),然后试图把它们重新组织起来。之后可由调用环境再试图通过gc_alloc()申请gc_pool中的内存,由一定记录之前申请不到的(指定较大尺寸的)内存块现在就能申请到了。
STATIC void gc_deal_with_stack_overflow(void) {
while (MP_STATE_MEM(gc_stack_overflow)) {
MP_STATE_MEM(gc_stack_overflow) = 0;
// scan entire memory looking for blocks which have been marked but not their children
for (size_t block = 0; block < MP_STATE_MEM(gc_alloc_table_byte_len) * BLOCKS_PER_ATB; block++) {
// trace (again) if mark bit set
if (ATB_GET_KIND(block) == AT_MARK) {
gc_mark_subtree(block);
}
}
}
}
结论
到此,对micropython中的gc已经有了一个大体的了解:
- main()函数调用的gc_init()之后,将一大块内存器交给gc管理,实际存放用户数据的存储区在gc_pool,gc通过gc_alloc_table管理gc_pool中以block组织起来的内存块的使用情况。
- micropython内核可以通过gc_alloc()和gc_free()等函数从gc_pool中申请使用内存块
- micropython内核可以在合适的时机调用gc_collect()函数回收已分配但不再使用的内存块。至于内存如何变成已分配但未使用的状态,可参见上文中摘要的Python垃圾回收机制。
- gc_collect()函数的实现依赖于具体的CPU架构,需要根据移植平台实现,中间涉及到获取系统栈中CPU寄存器值及监管范围的操作,不同的处理器有所区别,所以需要用户实现,但大部分通用操作已经由gc_collect_xxx()的其它函数实现了,所以用户在具体的移植中实现gc_collect()时,可调用其它gc_collect_xxx()函数完成大部分功能。
在有必要的情况下,如果要了解gc中的内存分配机制,可再具体详读gc_alloc()和gc_free()函数及相关函数的实现代码。
另外,关于micropython工程的内存管理,此处也可以有一个结论。micropython工程有三块内存:系统栈、系统堆和micropython堆(gc)。系统堆是C编译器管理,micropython堆是micropython通过gc组件管理,而系统栈是由C编译器管理,但仍被micropython监视。
关于可能与系统栈有关的mp_stack组件的分析,请见下文分解。