查看原文
其他

实用算法解读之RT-Thread链表堆管理器

逸珺 嵌入式客栈 2021-01-31
关注、星标嵌入式客栈,干货及时送达

[导读] 前文描述了栈的基本概念,本文来聊聊堆是怎么会事儿。RT-Thread 在社区广受欢迎,阅读了其内核代码,实现了堆的管理,代码设计很清晰,可读性很好。故一方面了解RT-Thread内核实现,一方面可以弄清楚其堆的内部实现。将学习体会记录分享,希望对于堆的理解及实现有一个更深入的认知。

注,文中代码分析基于rt-thread-v4.0.2 版本。

什么是堆?

C语言堆是由malloc(),calloc(),realloc()等函数动态获取内存的一种机制。使用完成后,由程序员调用free()等函数进行释放。使用时,需要包含stdlib.h头文件。

C++预言的堆管理则是使用new操作符向堆管理器申请动态内存分配,使用delete操作符将使用完毕内存的释放给堆管理器。

注:本文只描述C的堆管理器实现相关内容。

以C语言为例,将上面的描述,翻译成一个图:

要动态管理一片内存,且需要动态分配释放,这样一个需求。很显然C语言需要将动态内存区抽象描述起来并实现动态管理。事实上,C语言中堆管理器其本质是利用数据结构将堆区抽象描述,所需要描述的方面:

  • 可用于分配的内存
  • 正在使用的内存块
  • 释放掉的内存块

再利用相应算法对于这类数据结构对象进行动态管理而实现的堆管理器。

经常看到各种算法书很多只讲算法原理,而不讲应用实例,往往体会不深。私以为可以做些改善。学而不能致用,何必费力去学。所以不是晦涩难懂的算法无用,而是没有去真正结合应用。可以再进一步想,如果算法没有应用场景,也一定会在技术发展的历程中逐渐被世人遗忘。所以建议学习阅读算法书籍时,找些实例来看看,一定会加深对算法的理解领悟。这是比较重要的题外话,送给大家以共勉。

所以从本质上讲,堆管理器就是数据结构+算法实现的动态内存管理器,管理内存的动态分配以及释放。

为什么要堆?

C编程语言对内存管理方式有静态,自动或动态三种方式。静态内存分配的变量通常与程序的可执行代码一起分配在主存储器中,并在程序的整个生命周期内有效。自动分配内存的变量在栈上分配,并随着函数的调用和返回而申请或释放。对于静态分配内存和自动分配内存的生命周期,分配的大小必须是编译时常量(可变长度自动数组[5]除外)。如果所需的内存大小直到运行时才知道(例如,如果要从用户或磁盘文件中读取任意大小的数据),则使用固定大小的数据对象则满足不了要求了。试想,即便假定都知道要多大内存,如在windows/Linux下有那么多应用程序,每个应用程序加载时都将运行中所需的内存采样静态分配策略,则如多个程序运行内存将很快耗尽。

分配的内存的生命周期也可能引起关注。静态或自动分配都不能满足所有情况。自动分配内存不能在多个函数调用之间保留,而静态数据在程序的整个生命周期中必然保留,无论是否真正需要(所以都采用这样的策略必然造成浪费)。在许多情况下,程序员在管理分配的内存的生命周期具有更多的灵活性。

通过使用动态内存分配则避免了这些限制/缺点,在动态内存分配中,更明确(但更灵活)地管理内存,通常是通过从免费存储区(非正式地称为“堆”)中分配内存(为此目的而构造的内存区域)进行分配的。在C语言中,库函数malloc用于在堆上分配一个内存块。程序通过malloc返回的指针访问该内存块。当不再需要内存时,会将指针传递给free,从而释放内存,以便可以将其用于其他目的。

谁实现堆

如果一问到这个问题,马上会说C编译器。不错C编译器实现了堆管理器,而事实上并非编译器在编译的过程中实现动态内存管理器,而是C编译器所实现的C库实现了堆管理器,比如ANSI C,VC, IAR C编译器,GNU C等其实都需要一些C库的支持,那么这些库的内部就隐藏了这么一个堆管理器。眼见为实吧,还是以IAR ARM 8.40.1 为例,其堆管理器就实现在:

.\IAR Systems\Embedded Workbench 8.3\arm\src\lib\dlib\heap

一看有这么多的源码,那么对于应用开发而言,有哪些选项需要进行配置呢?

支持四个选项:

  • Automatic:
    • 如果您的应用程序中有对堆内存分配例程的调用,但没有对堆释放例程的调用,则链接程序将自动选择无空闲堆。
    • 如果您的应用程序中有对堆内存分配例程的调用,则链接程序会自动选择高级堆。
    • 例如,如果在库中调用了堆内存分配例程,则链接程序会自动选择基本堆。
  • Advanced heap:高级堆(--advanced_heap)为广泛使用该堆的应用程序提供有效的内存管理。特别是,重复分配和释放内存的应用程序可能会在空间和时间上获得较少的开销。高级堆的代码明显大于基本堆的代码。
  • Basic heap: 基本堆(--basic_heap)是一个简单的堆分配器,适用于不经常使用堆的应用程序。特别是,它可以用于仅分配堆内存而从不释放堆内存的应用程序中。基本堆并不是特别快,并且在反复释放内存的应用程序中使用它很可能导致不必要的堆碎片化。基本堆的代码远小于高级堆的大小。
  • No-free heap:无可用堆(--no_free_heap)使用此选项可以使用最小的堆实现。因为此堆不支持释放或重新分配,所以它仅适用于在启动阶段为各种缓冲区分配堆内存的应用程序,以及永不释放内存的应用程序。

但是如果认为仅仅标准C库负责实现堆管理器,则这种理解并不全面。回到事物的本质,堆管理器是利用数据结构及算法动态管理一片内存的分配与释放。那么有这样需求的地方,都可能需要实现一个堆管理器。

堆管理器的实现很大程度取决于操作系统以及硬件体系架构。大体上需要实现堆内存管理器的有两大类:

  • 应用程序,应用程序需要堆内存管理器,是显而易见的。比如常见的windows/Linux下的应用程序,都需要堆内存管理器。而上述的cortex M或者其他单片机程序使用C/C++编程时都需要堆内存管理器。
  • 操作系统内核,操作系统内核需要像应用程序一样分配内存。但是,内核中malloc的实现通常与C库使用的实现有很大不同。例如,内存缓冲区可能需要符合DMA施加的特殊限制,或者可能从中断上下文中调用内存分配功能。这需要与操作系统内核的虚拟内存子系统紧密集成的malloc实现。比如Linux内核就需要实现内核版本的堆管理器,对外提供kmalloc/vmalloc申请内存,kfree/vfree用于释放内存。

怎么实现堆

对于RT-Thread的内核而言,也实现了一个内核堆管理器,这里就来梳理一下RT-Thread内核版本的小堆管理器的实现,同时来了解一下链表数据结构及算法操作的实例应用。

其堆管理器实现位于.\rt-thread-v4.0.2\rt-thread\src下mem.c,memheap.c以及mempool.c。

关键数据结构

其堆管理器主要的数据结构为heap_mem。

  • heap_mem

堆管理器初始化

堆管理器的初始化入口在mem.c,函数为:

void rt_system_heap_init(void *begin_addr, void *end_addr)
{
    struct heap_mem *mem;
    /*按4字节对齐转换地址*/
    /*如0x2000 0001~0x2000 0003,转后为0x2000 0004*/
    rt_ubase_t begin_align = RT_ALIGN((rt_ubase_t)begin_addr, RT_ALIGN_SIZE);
    /*如0x3000 0001~0x3000 0003,转后为0x3000 0000*/
    rt_ubase_t end_align   = RT_ALIGN_DOWN((rt_ubase_t)end_addr, RT_ALIGN_SIZE);
    
    /*调试信息,函数不可用于中断内部*/
    RT_DEBUG_NOT_IN_INTERRUPT;

    /* 分配地址范围至少能存储两个heap_mem */
    if ((end_align > (2 * SIZEOF_STRUCT_MEM)) &&
        ((end_align - 2 * SIZEOF_STRUCT_MEM) >= begin_align))
    {
        /* 计算可用堆区,4字节对齐 */
        mem_size_aligned = end_align - begin_align - 2 * SIZEOF_STRUCT_MEM;
    }
    else
    {
        rt_kprintf("mem init, error begin address 0x%x, and end address 0x%x\n",
                   (rt_ubase_t)begin_addr, (rt_ubase_t)end_addr);

        return;
    }

    /* heap_ptr指向堆区起始地址 */
    heap_ptr = (rt_uint8_t *)begin_align;

    RT_DEBUG_LOG(RT_DEBUG_MEM, ("mem init, heap begin address 0x%x, size %d\n",
                                (rt_ubase_t)heap_ptr, mem_size_aligned));

    /* 初始化堆起始描述符 */
    mem        = (struct heap_mem *)heap_ptr;
    mem->magic = HEAP_MAGIC;
    mem->next  = mem_size_aligned + SIZEOF_STRUCT_MEM;
    mem->prev  = 0;
    mem->used  = 0;
#ifdef RT_USING_MEMTRACE
    rt_mem_setname(mem, "INIT");
#endif

    /* 初始化堆结束描述符 */
    heap_end        = (struct heap_mem *)&heap_ptr[mem->next];
    heap_end->magic = HEAP_MAGIC;
    heap_end->used  = 1;
    heap_end->next  = mem_size_aligned + SIZEOF_STRUCT_MEM;
    heap_end->prev  = mem_size_aligned + SIZEOF_STRUCT_MEM;
#ifdef RT_USING_MEMTRACE
    rt_mem_setname(heap_end, "INIT");
#endif

    rt_sem_init(&heap_sem, "heap"1, RT_IPC_FLAG_FIFO);

    /* 初始化释放指针指向堆的开始 */
    lfree = (struct heap_mem *)heap_ptr;
}

传入链接堆区的内存起始地址,以及结束地址。以STM32为例,传入0x20000000--0x20018000,96k字节

上述rt_system_heap_init( 0x20000000,0x20018000),主要做了下图这么一件事情。

将堆管理头尾描述符进行了初始化,并指向对应的内存地址。用图翻译一下:

技巧点:

  • 利用类型强制转换将内存数据转换为struct heap_mem *。实现了静态双链表的创建
mem      = (struct heap_mem *)heap_ptr;
heap_end = (struct heap_mem *)&heap_ptr[mem->next];
  • 定义heap_mem没有定义使用多少字节为该块的用户数据字节数,节约了内存。是一个比较好的处理方式。
  • 对齐方式可配置,RT_ALIGN_SIZE默认为4字节。

向堆申请内存

用户调用rt_malloc 用于申请分配动态内存。

void *rt_malloc(rt_size_t size)
{
    rt_size_t ptr, ptr2;
    struct heap_mem *mem, *mem2;

    if (size == 0)
        return RT_NULL;

    RT_DEBUG_NOT_IN_INTERRUPT;
    /*按四字节对齐申请,如申请5字节,则实际按8字节申请*/
    if (size != RT_ALIGN(size, RT_ALIGN_SIZE))
        RT_DEBUG_LOG(RT_DEBUG_MEM, ("malloc size %d, but align to %d\n",
                                    size, RT_ALIGN(size, RT_ALIGN_SIZE)));
    else
        RT_DEBUG_LOG(RT_DEBUG_MEM, ("malloc size %d\n", size));

    /* 按四字节对齐申请,如申请5字节,则实际按8字节申请 */
    size = RT_ALIGN(size, RT_ALIGN_SIZE);

    if (size > mem_size_aligned)
    {
        RT_DEBUG_LOG(RT_DEBUG_MEM, ("no memory\n"));
        return RT_NULL;
    }

    /* 每块的长度必须至少为MIN_SIZE_ALIGNED=12 STM32*/
    if (size < MIN_SIZE_ALIGNED)
        size = MIN_SIZE_ALIGNED;

    /* 获取堆保护信号量 */
    rt_sem_take(&heap_sem, RT_WAITING_FOREVER);

    for (ptr = (rt_uint8_t *)lfree - heap_ptr;
         ptr < mem_size_aligned - size;
         ptr = ((struct heap_mem *)&heap_ptr[ptr])->next)
    {
        mem = (struct heap_mem *)&heap_ptr[ptr];

        /*如果该块未使用,且满足大小要求*/
        if ((!mem->used) && (mem->next - (ptr + SIZEOF_STRUCT_MEM)) >= size)
        {
            /* mem没有被使用,至少完美的配合是可能的:
             * mem->next - (ptr + SIZEOF_STRUCT_MEM) 计算出mem的“用户数据大小” */

            if (mem->next - (ptr + SIZEOF_STRUCT_MEM) >=
                (size + SIZEOF_STRUCT_MEM + MIN_SIZE_ALIGNED))
            {
                /* (除了上面的,我们测试另一个结构heap_mem (SIZEOF_STRUCT_MEM)
                 * 是否包含至少MIN_SIZE_ALIGNED的数据也适合'mem'的'用户数据空间')
                 * -> 分割大的块,创建空的余数,
                 * 余数必须足够大,以包含MIN_SIZE_ALIGNED大小数据:
                 * 如果mem->next - (ptr + (2*SIZEOF_STRUCT_MEM)) == size,
                 * struct heap_mem 会适合,在mem2及mem2->next没有使用
                 */

                ptr2 = ptr + SIZEOF_STRUCT_MEM + size;

                /* create mem2 struct */
                mem2       = (struct heap_mem *)&heap_ptr[ptr2];
                mem2->magic = HEAP_MAGIC;
                mem2->used = 0;
                mem2->next = mem->next;
                mem2->prev = ptr;
#ifdef RT_USING_MEMTRACE
                rt_mem_setname(mem2, "    ");
#endif
                /*将ptr2插入mem及mem->next之间 */
                mem->next = ptr2;
                mem->used = 1;

                if (mem2->next != mem_size_aligned + SIZEOF_STRUCT_MEM)
                {
                    ((struct heap_mem *)&heap_ptr[mem2->next])->prev = ptr2;
                }
#ifdef RT_MEM_STATS
                used_mem += (size + SIZEOF_STRUCT_MEM);
                if (max_mem < used_mem)
                    max_mem = used_mem;
#endif
            }
            else
            {
                mem->used = 1;
#ifdef RT_MEM_STATS
                used_mem += mem->next - ((rt_uint8_t *)mem - heap_ptr);
                if (max_mem < used_mem)
                    max_mem = used_mem;
#endif
            }
            /* 设置块幻数 */
            mem->magic = HEAP_MAGIC;
#ifdef RT_USING_MEMTRACE
            if (rt_thread_self())
                rt_mem_setname(mem, rt_thread_self()->name);
            else
                rt_mem_setname(mem, "NONE");
#endif

            if (mem == lfree)
            {
                /* 寻找下一个空闲块并更新lfree指针*/
                while (lfree->used && lfree != heap_end)
                    lfree = (struct heap_mem *)&heap_ptr[lfree->next];

                RT_ASSERT(((lfree == heap_end) || (!lfree->used)));
            }

            rt_sem_release(&heap_sem);
            RT_ASSERT((rt_ubase_t)mem + SIZEOF_STRUCT_MEM + size <= (rt_ubase_t)heap_end);
            RT_ASSERT((rt_ubase_t)((rt_uint8_t *)mem + SIZEOF_STRUCT_MEM) % RT_ALIGN_SIZE == 0);
            RT_ASSERT((((rt_ubase_t)mem) & (RT_ALIGN_SIZE - 1)) == 0);

            RT_DEBUG_LOG(RT_DEBUG_MEM,
                         ("allocate memory at 0x%x, size: %d\n",
                          (rt_ubase_t)((rt_uint8_t *)mem + SIZEOF_STRUCT_MEM),
                          (rt_ubase_t)(mem->next - ((rt_uint8_t *)mem - heap_ptr))));

            RT_OBJECT_HOOK_CALL(rt_malloc_hook,
                                (((void *)((rt_uint8_t *)mem + SIZEOF_STRUCT_MEM)), size));

            /* 返回除mem结构之外的内存地址 */
            return (rt_uint8_t *)mem + SIZEOF_STRUCT_MEM;
        }
    }
    /* 释放堆保护信号量 */
    rt_sem_release(&heap_sem);

    return RT_NULL;
}

其基本思路,从空闲块链表开始检索内存块,如检索到某块空闲且满足申请大小且其剩余空间至少能存储描述符,则满足了申请要求,则将后续内存头部生成描述,更新前后指针,标记幻数以及块已被使用标记,将该块插入链表。返回申请成功的内存地址。如果检索不到,则返回空指针,表示申请失败,堆目前没有满足要求的内存可供使用。实际上,上述代码在运行时将堆内存区按照下述示意图进行动态维护。

概括一下:

  • heap_ptr总是指向堆起始地址,heap_end总是指向最后一个块,两者配合可以实现边界保护,在释放内存时使用。
  • lfree 总是指向最地址最小的空闲块,因此在动态申请内存时,总是从该块进行检索是否有满足申请要求的内存块可供使用。
  • used=1表示该块被占用,非空闲。used=0表示该块空闲。
  • magic 字段幻数,起始就是一个特殊标记字,与used=0配合,用于检测异常,试想一下如果仅仅用used=0判断块是空闲,则易出错,或者需要加其他的辅助代码,才能保证代码的健壮性。
  • 动态内存管理申请比较慢,需要检索链表,以及额外的内存开销。
  • rt_realloc 及rt_calloc 不做分析了

释放内存

释放内存由rt_free实现:

void rt_free(void *rmem)
{
    struct heap_mem *mem;

    if (rmem == RT_NULL)
        return;

    RT_DEBUG_NOT_IN_INTERRUPT;

    RT_ASSERT((((rt_ubase_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;
    }

    /* 获取块描述符 */
    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_ubase_t)rmem,
                  (rt_ubase_t)(mem->next - ((rt_uint8_t *)mem - heap_ptr))));


    /* 获取堆保护信号量 */
    rt_sem_take(&heap_sem, RT_WAITING_FOREVER);

    /* 待释放的内存,其块描述符需是使用状态 */
    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);
    /* 清除使用标志 */
    mem->used  = 0;
    mem->magic = HEAP_MAGIC;
#ifdef RT_USING_MEMTRACE
    rt_mem_setname(mem, "    ");
#endif

    if (mem < lfree)
    {
        /* 更新空闲块lfree指针 */
        lfree = mem;
    }

#ifdef RT_MEM_STATS
    used_mem -= (mem->next - ((rt_uint8_t *)mem - heap_ptr));
#endif

    /* 如临近块也处于空闲态,则合并整理成一个更大的块 */
    plug_holes(mem);
    rt_sem_release(&heap_sem);
}
RTM_EXPORT(rt_free);

合并空闲块plug_holes

static void plug_holes(struct heap_mem *mem)
{
    struct heap_mem *nmem;
    struct heap_mem *pmem;

    RT_ASSERT((rt_uint8_t *)mem >= heap_ptr);
    RT_ASSERT((rt_uint8_t *)mem < (rt_uint8_t *)heap_end);
    RT_ASSERT(mem->used == 0);

    /* 前向整理 */
    nmem = (struct heap_mem *)&heap_ptr[mem->next];
    if (mem != nmem &&
        nmem->used == 0 &&
        (rt_uint8_t *)nmem != (rt_uint8_t *)heap_end)
    {
        /*如果mem->next是空闲,且非尾节点,则合并*/
        if (lfree == nmem)
        {
            lfree = mem;
        }
        mem->next = nmem->next;
        ((struct heap_mem *)&heap_ptr[nmem->next])->prev = (rt_uint8_t *)mem - heap_ptr;
    }

    /* 后向整理 */
    pmem = (struct heap_mem *)&heap_ptr[mem->prev];
    if (pmem != mem && pmem->used == 0)
    {
        /* 如mem->prev空闲,将mem与mem->prev合并 */
        if (lfree == mem)
        {
            lfree = pmem;
        }
        pmem->next = mem->next;
        ((struct heap_mem *)&heap_ptr[mem->next])->prev = (rt_uint8_t *)pmem - heap_ptr;
    }
}

动态内存的释放相对比较简单,其思路主要是判断传入地址是否在堆区,如是堆内存,则判断其块信息是否合法。如果合法,则将使用标志清除。同时如果临近块如果是空闲态,则利用plug_holes将空闲块进行合并,合并成一个大的空闲块。

内存泄漏

使用free释放内存失败会导致不可重用内存的累积,程序不再使用这些内存。这将浪费内存资源,并可能在耗尽这些资源时导致分配失败。

怎么使用堆

堆区的配置

对于STM32而言,位于board.h

/ * 配置堆区大小,可根据实际使用进行修改 */
#define HEAP_BEGIN STM32_SRAM1_START
#define HEAP_END STM32_SRAM1_END

/* 用于板级初始化堆区 */
void rt_system_heap_init(void *begin_addr, void *end_addr)

堆的使用接口

用于动态申请内存
void *rt_malloc(rt_size_t size)
/*追加申请内存,此函数将更改先前分配的内存块。*/
void *rt_realloc(void *rmem, rt_size_t newsize)
/* 申请的内存被初始化为0 */
void *rt_calloc(rt_size_t count, rt_size_t size)

内存分配不能保证成功,而是可能返回一个空指针。使用返回的值,而不检查分配是否成功,将调用未定义的行为。这通常会导致崩溃,但不能保证会发生崩溃,因此依赖于它也会导致问题。

对于申请的内存,使用前必须进行返回值判断,否则申请失败,且任继续使用。将会出现意想不到的错误!!

总结一下

通过对RT-Thread的小堆管理器实现的梳理,层层递进更深入理解以下一些要点:

  • 为什么需要堆,为什么堆是C/C++运行时的基础之一。堆可实现动态内存管理的多样性,在牺牲一定开销情况下(申请/释放开销,以及内存开销),可以提供内存的利用率,在一定程度上解决内存不足的需求。
  • 可以更深入的理解链表实用价值,理解静态实现方法的一些技巧。
  • 通过更深入的理解堆的实现,可以更好的使用堆。
  • 理解堆管理器究竟在哪里实现的,C/C++标准库,以及操作系统内核都可能实现堆管理器。
  • RT-Thread的小堆实现是一个比较简单和比较好的学习堆管理的例子,事实上堆的实现还有更复杂的场景,比如基于SLAB堆管理器实现,以及IAR中库的堆实现还需要使用树这个数据结构。

堆使用常见错误

  • 使用前没有检查分配失败:内存分配不能保证成功,而是可能返回一个空指针。使用返回的值,而不检查分配是否成功,将调用未定义的行为。这通常会导致崩溃,但不能保证会发生崩溃,因此依赖于它而不检查结果也会导致问题。
  • 内存泄露:使用free释放内存失败会导致不可重用内存的累积,程序不再使用这些内存。这将浪费内存资源,并可能在耗尽这些资源时导致分配失败。
    逻辑错误:所有的分配必须遵循相同的模式:使用malloc分配,使用free释放,重新分配。如果不能坚持这种模式,例如在调用free(悬空指针)之后或在调用malloc(野指针)之前使用内存、调用free两次(“double free”)等,通常会导致分段错误并导致程序崩溃。这些错误可能是偶发的,而且很难调试


END

果喜欢右下点个在看,也会让我倍感鼓舞

往期精彩推荐,点击即可阅读




▲抽象思想解读Linux进程描述符
▲读U-Boot源码-C语言编程大法总结篇一
▲读U-Boot源码-C语言编程技巧总结篇二
▲基于Buildroot的Linux构建之根文件系统
▲手把手教系列之移动平均滤波器C实现
▲手把手教系列之IIR数字滤波器设计实现
▲手把手教系列之梳状滤波器设计实现
▲Linux 内核架构分析

关注置顶:扫描左下二维码关注公众号加星

加群交流:扫描右下二维码添加,发送“加群”

关注

加群

Modified on

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存