操作系统真象还原实验记录之实验二十二:完善堆内存管理

本文详细记录了操作系统中如何实现内存申请、释放及堆内存管理的完善过程,涉及用户进程和内核线程共享内存池、内存块描述符、arena的使用,以及不同大小内存的分配策略和释放逻辑。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

操作系统真象还原实验记录之实验二十二:完善堆内存管理

在这里插入图片描述

所有内核线程共用1MB到2MB的1页页目录表和255页页表。1MB处的页目录表写好了3GB+1MB的内存映射。内核虚拟池只会产生3GB+1MB以上的虚拟地址来映射到从内核池产生的2MB以上的内存物理地址。

对于用户进程,首先在内核内存池申请一页用作PCB和一页用作用户的虚拟内存池。其次还需要申请一页页目录表,页目录表3GB以上的页目录项复制1MB处的页目录项,3GB以下的映射到从用户内存池申请的物理地址。
由于3GB以下的页目录项刚开始末位属性都是0表示页表不存在,所以会向内核池申请一页用作页表。

内存管理系统除了可以申请一页或多页内存,必须还可以释放内存,以及申请小于一页的内存即堆内存。

本章将上述功能完善
当申请内存时
做法是,所有内核线程共用一个内存块描述符数组,每一个用户进程的PCB里有自己的内存块描述符数组。
当申请多页内存时,在内核内存池或用户内存池申请多页内存首地址强制转换成arena类型。返回(arena+1)即为申请空间的首地址。
当申请小于一页内存时,若对应大小的内存块描述符的空闲块链表为空,则在内核内存池或用户内存池申请一页,强制转换成arena,将arena+i依次强制成内存块,加入对应的空闲块链表。然后再pop出一块返回地址来使用。

当释放内存时,
如果释放一页或者多页内存,直接修改对应虚拟、物理地址位图,对应页表项P置0即可。
如果释放小于一页内存的某个内存块,那么加入对应空闲块链表,再根据该内存块找到arena的首地址,++a-cnt判断是否这个arena都空闲了,如果是,那么先把这个arena所有的内存块都从对应空闲内存块链表里remove,再直接释放这一页arena。

在这里插入图片描述

/* 内存块 */
struct mem_block {
   struct list_elem free_elem;
};

/* 内存块描述符 */
struct mem_block_desc {
   uint32_t block_size;		 // 内存块大小
   uint32_t blocks_per_arena;	 // 本arena中可容纳此mem_block的数量.
   struct list free_list;	 // 目前可用的mem_block链表
};

struct arena {
   struct mem_block_desc* desc;	 // 此arena关联的mem_block_desc
/* large为ture时,cnt表示的是页框数。
 * 否则cnt表示空闲mem_block数量 */
   uint32_t cnt;
   bool large;		   
};

分配内存过程如下:
内存规格一共有7种,16、32、64、128、256、512、1024字节,对应7种内存块描述符
假设当用户进程动态申请内存,调用sys_malloc函数,申请内存大小size=63小于等于1024B内存时,检查用户PCB中相应的内存块描述符,查看free_list中是否有空闲内存块,如果没有,就在用户内存池申请一页作为64B内存规格的arena,使用arena2block(a,block_idx)函数不断获得每一个内存块,将内存块的free_elem放入该内存块描述符的free_list,最后通过pop free_list的元素,elem2entry转化成内存块地址返回。
当用户进程要释放这64B时,调用sys_free(void* ptr),
该函数先用block2arean获得该内存块所属的arean,然后发现arean内的
desc!=null且large!=true,则把该内存块的free_elem放回到相应的内存块描述符的free_list中,如果++arean->cnt == a->desc->blocks_per_arena,说明该arean所有内存块都空闲了,那么就将每个内存块都从内存块描述符的free_list中移除,再释放一整页arean。
释放page_cnt页调用mfree_page(PF, vaddr, page_cnt),有三个步骤,
第一,利用addr_v2p(vaddr)获得物理地址,修改用户内存池的位图,
第二,修改用户PCB的虚拟位图
第三,修改vaddr对应的页表项,P位置0

如果用户申请大于1024字节的内存,那么申请一页或多页作为arean,
arena->desc置null,arena->large置true,arean->size置为pg_cnt,跳过arean结构体,直接返回该大块内存地址,
释放大块内存的时候,大块内存arean不会分为多个内存块,不会被共享,所以直接调用mfree_page释放即可

1.实验代码(实现sys_malloc)

1.1 memory.h增


/* 内存块 */
struct mem_block {
   struct list_elem free_elem;
};

/* 内存块描述符 */
struct mem_block_desc {
   uint32_t block_size;		 // 内存块大小
   uint32_t blocks_per_arena;	 // 本arena中可容纳此mem_block的数量.
   struct list free_list;	 // 目前可用的mem_block链表
};

#define DESC_CNT 7

1.2 memory.c


/* 内存仓库arena元信息 */
struct arena {
   struct mem_block_desc* desc;	 // 此arena关联的mem_block_desc
/* large为ture时,cnt表示的是页框数。
 * 否则cnt表示空闲mem_block数量 */
   uint32_t cnt;
   bool large;		   
};

struct mem_block_desc k_block_descs[DESC_CNT];	// 内核内存块描述符数组


/* 为malloc做准备 */
void block_desc_init(struct mem_block_desc* desc_array) {				   
   uint16_t desc_idx, block_size = 16;

   /* 初始化每个mem_block_desc描述符 */
   for (desc_idx = 0; desc_idx < DESC_CNT; desc_idx++) {
      desc_array[desc_idx].block_size = block_size;

      /* 初始化arena中的内存块数量 */
      desc_array[desc_idx].blocks_per_arena = (PG_SIZE - sizeof(struct arena)) / block_size;	  

      list_init(&desc_array[desc_idx].free_list);

      block_size *= 2;         // 更新为下一个规格内存块
   }
}



/* 内存管理部分初始化入口 */
void mem_init() {
   put_str("mem_init start\n");
   uint32_t mem_bytes_total = (*(uint32_t*)(0xb00));
   mem_pool_init(mem_bytes_total);	  // 初始化内存池
/* 初始化mem_block_desc数组descs,为malloc做准备 */
   block_desc_init(k_block_descs);
   put_str("mem_init done\n");
}



在mem_init初始化内存的时候,顺便也初始化了内核线程的
struct mem_block_desc k_block_descs[DESC_CNT];
这个内存块描述符数组
每个描述符在一个页框内,跳过了一个struct arena大小,依次统计了剩余空间含多少个16、32、64、128、256、512、1024字节的内存块,赋值给blocks_per_arena

1.3 thread.h

struct task_struct {
   struct mem_block_desc u_block_desc[DESC_CNT];   // 用户进程内存块描述符
}

1.4 process.c

void process_execute(void* filename, char* name) { 
   /* pcb内核的数据结构,由内核来维护进程信息,因此要在内核内存池中申请 */
   thread->pgdir = create_page_dir();
   block_desc_init(thread->u_block_desc);
}

每个用户进程的PCB里增加 struct mem_block_desc u_block_desc[DESC_CNT],并在第一次调度时初始化。
只有用户进程和内核线程才能使用内存分配malloc

1.5 memory.c


/* 返回arena中第idx个内存块的地址 */
static struct mem_block* arena2block(struct arena* a, uint32_t idx) {
  return (struct mem_block*)((uint32_t)a + sizeof(struct arena) + idx * a->desc->block_size);
}

/* 返回内存块b所在的arena地址 */
static struct arena* block2arena(struct mem_block* b) {
   return (struct arena*)((uint32_t)b & 0xfffff000);
}

/* 在堆中申请size字节内存 */
void* sys_malloc(uint32_t size) {
   enum pool_flags PF;
   struct pool* mem_pool;
   uint32_t pool_size;
   struct mem_block_desc* descs;
   struct task_struct* cur_thread = running_thread();

/* 判断用哪个内存池*/
   if (cur_thread->pgdir == NULL) {     // 若为内核线程
      PF = PF_KERNEL; 
      pool_size = kernel_pool.pool_size;
      mem_pool = &kernel_pool;
      descs = k_block_descs;
   } else {				      // 用户进程pcb中的pgdir会在为其分配页表时创建
      PF = PF_USER;
      pool_size = user_pool.pool_size;
      mem_pool = &user_pool;
      descs = cur_thread->u_block_desc;
   }

   /* 若申请的内存不在内存池容量范围内则直接返回NULL */
   if (!(size > 0 && size < pool_size)) {
      return NULL;
   }
   struct arena* a;
   struct mem_block* b;	
   lock_acquire(&mem_pool->lock);
/* 超过最大内存块1024, 就分配页框 */
   if (size > 1024) {
      uint32_t page_cnt = DIV_ROUND_UP(size + sizeof(struct arena), PG_SIZE);    // 向上取整需要的页框数

      a = malloc_page(PF, page_cnt);

      if (a != NULL) {
	 memset(a, 0, page_cnt * PG_SIZE);	 // 将分配的内存清0  

      /* 对于分配的大块页框,将desc置为NULL, cnt置为页框数,large置为true */
	 a->desc = NULL;
	 a->cnt = page_cnt;
	 a->large = true;
	 lock_release(&mem_pool->lock);
	 return (void*)(a + 1);		 // 跨过arena大小,把剩下的内存返回
      } else { 
	 lock_release(&mem_pool->lock);
	 return NULL; 
      }
   } else {    // 若申请的内存小于等于1024,可在各种规格的mem_block_desc中去适配
      uint8_t desc_idx;
      
      /* 从内存块描述符中匹配合适的内存块规格 */
      for (desc_idx = 0; desc_idx < DESC_CNT; desc_idx++) {
	 if (size <= descs[desc_idx].block_size) {  // 从小往大后,找到后退出
	    break;
	 }
      }
   	       
   /* 若mem_block_desc的free_list中已经没有可用的mem_block,
    * 就创建新的arena提供mem_block */
      if (list_empty(&descs[desc_idx].free_list)) {
	 a = malloc_page(PF, 1);       // 分配1页框做为arena
	 if (a == NULL) {
	    lock_release(&mem_pool->lock);
	    return NULL;
	 }
	 memset(a, 0, PG_SIZE);

    /* 对于分配的小块内存,将desc置为相应内存块描述符, 
     * cnt置为此arena可用的内存块数,large置为false */
	 a->desc = &descs[desc_idx];
	 a->large = false;
	 a->cnt = descs[desc_idx].blocks_per_arena;
	 uint32_t block_idx;

	 enum intr_status old_status = intr_disable();

	 /* 开始将arena拆分成内存块,并添加到内存块描述符的free_list中 */
	 for (block_idx = 0; block_idx < descs[desc_idx].blocks_per_arena; block_idx++) {
	    b = arena2block(a, block_idx);
	    ASSERT(!elem_find(&a->desc->free_list, &b->free_elem));
	    list_append(&a->desc->free_list, &b->free_elem);	
	 }
	 intr_set_status(old_status);
      }    
   /* 开始分配内存块 */
      b = elem2entry(struct mem_block, free_elem, list_pop(&(descs[desc_idx].free_list)));
      memset(b, 0, descs[desc_idx].block_size);

      a = block2arena(b);  // 获取内存块b所在的arena
      a->cnt--;		   // 将此arena中的空闲内存块数减1
      lock_release(&mem_pool->lock);
      return (void*)b;
   }
}

void* sys_malloc(uint32_t size) 用于实现内存分配。
首先先判断一下当前是内核线程还是用户进程,赋值好PF

当size>1024时,内存池申请size/PG_SIZE页,赋值好arena,跳过arena返回地址。

当size<=1024字节时,在内核线程或者用户进程的内存块描述符数组找到符合size的desc_idx,查看该描述符的链表是否有内存块,没有就在内存池申请一页作为arena,用宏arena2block(a, block_idx)将该arena一块一块拆分放入内存块描述符链表中。再把该内存块描述符放到arena中
在这里插入图片描述

也就是说,假设用户进程malloc申请了16字节,那么在他自己的u_block_desc[DESC_CNT]中第0个内存块描述符的链表中,可以找到可使用的内存块地址,这些内存块属于malloc时被创建的arena。

分配内存块就是pop链表, 用宏elem2entry将成员变量地址转化成结构体mem_block首地址后返回。

1.6 main.c

 #include "interrupt.h"
#include "init.h"
#include "thread.h"
#include "print.h"
#include "process.h"
#include "console.h"
#include "syscall.h"
#include "syscall-init.h"
#include "stdio.h"

void k_thread_a(void* );
void k_thread_b(void* );
int main(void) {
   put_str("I am kernel\n");
   init_all();

   intr_enable();
	thread_start("k_thread_a", 31, k_thread_a, "argA ");
	thread_start("k_thread_b", 31, k_thread_b, "argB ");
	while(1);
   return 0;
}

void k_thread_a(void* arg){
	char* para = arg;
	void* addr = sys_malloc(33);
	console_put_str("  I am thread_a, sys_malloc(33), addr is 0x");
   console_put_int((int)addr);
   console_put_char('\n');
	while(1);
}

void k_thread_b(void* arg){
	char* para = arg;
	void* addr = sys_malloc(63);
	console_put_str("  I am thread_b, sys_malloc(63), addr is 0x");
   console_put_int((int)addr);
   console_put_char('\n');
	while(1);
}

2.实验结果

在这里插入图片描述
两个内核级线程都会使用k_block_descs[DESC_CNT]
一个申请33字节,一个申请63字节。
申请33字节时创建了一个arena并按64字节依次放入内存描述符k_block_descs[2]中的链表中,pop第一个拿来用。
申请63字节时发现k_block_descs[2]链表里就有空闲内存块,直接pop出arena第二个空闲内存块拿来用。

3.实验代码(实现sys_free)

3.1 memory.c(增)


/* 将物理地址pg_phy_addr回收到物理内存池 */
void pfree(uint32_t pg_phy_addr) {
   struct pool* mem_pool;
   uint32_t bit_idx = 0;
   if (pg_phy_addr >= user_pool.phy_addr_start) {     // 用户物理内存池
      mem_pool = &user_pool;
      bit_idx = (pg_phy_addr - user_pool.phy_addr_start) / PG_SIZE;
   } else {	  // 内核物理内存池
      mem_pool = &kernel_pool;
      bit_idx = (pg_phy_addr - kernel_pool.phy_addr_start) / PG_SIZE;
   }
   bitmap_set(&mem_pool->pool_bitmap, bit_idx, 0);	 // 将位图中该位清0
}

/* 去掉页表中虚拟地址vaddr的映射,只去掉vaddr对应的pte */
static void page_table_pte_remove(uint32_t vaddr) {
   uint32_t* pte = pte_ptr(vaddr);
   *pte &= ~PG_P_1;	// 将页表项pte的P位置0
   asm volatile ("invlpg %0"::"m" (vaddr):"memory");    //更新tlb
}

/* 在虚拟地址池中释放以_vaddr起始的连续pg_cnt个虚拟页地址 */
static void vaddr_remove(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt) {
   uint32_t bit_idx_start = 0, vaddr = (uint32_t)_vaddr, cnt = 0;

   if (pf == PF_KERNEL) {  // 内核虚拟内存池
      bit_idx_start = (vaddr - kernel_vaddr.vaddr_start) / PG_SIZE;
      while(cnt < pg_cnt) {
	 bitmap_set(&kernel_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 0);
      }
   } else {  // 用户虚拟内存池
      struct task_struct* cur_thread = running_thread();
      bit_idx_start = (vaddr - cur_thread->userprog_vaddr.vaddr_start) / PG_SIZE;
      while(cnt < pg_cnt) {
	 bitmap_set(&cur_thread->userprog_vaddr.vaddr_bitmap, bit_idx_start + cnt++, 0);
      }
   }
}

/* 释放以虚拟地址vaddr为起始的cnt个物理页框 */
void mfree_page(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt) {
   uint32_t pg_phy_addr;
   uint32_t vaddr = (int32_t)_vaddr, page_cnt = 0;
   ASSERT(pg_cnt >=1 && vaddr % PG_SIZE == 0); 
   pg_phy_addr = addr_v2p(vaddr);  // 获取虚拟地址vaddr对应的物理地址

/* 确保待释放的物理内存在低端1M+1k大小的页目录+1k大小的页表地址范围外 */
   ASSERT((pg_phy_addr % PG_SIZE) == 0 && pg_phy_addr >= 0x102000);
   
/* 判断pg_phy_addr属于用户物理内存池还是内核物理内存池 */
   if (pg_phy_addr >= user_pool.phy_addr_start) {   // 位于user_pool内存池
      vaddr -= PG_SIZE;
      while (page_cnt < pg_cnt) {
	 vaddr += PG_SIZE;
	 pg_phy_addr = addr_v2p(vaddr);

	 /* 确保物理地址属于用户物理内存池 */
	 ASSERT((pg_phy_addr % PG_SIZE) == 0 && pg_phy_addr >= user_pool.phy_addr_start);

	 /* 先将对应的物理页框归还到内存池 */
	 pfree(pg_phy_addr);

         /* 再从页表中清除此虚拟地址所在的页表项pte */
	 page_table_pte_remove(vaddr);

	 page_cnt++;
      }
   /* 清空虚拟地址的位图中的相应位 */
      vaddr_remove(pf, _vaddr, pg_cnt);

   } else {	     // 位于kernel_pool内存池
      vaddr -= PG_SIZE;	      
      while (page_cnt < pg_cnt) {
	 vaddr += PG_SIZE;
	 pg_phy_addr = addr_v2p(vaddr);
      /* 确保待释放的物理内存只属于内核物理内存池 */
	 ASSERT((pg_phy_addr % PG_SIZE) == 0 && \
	       pg_phy_addr >= kernel_pool.phy_addr_start && \
	       pg_phy_addr < user_pool.phy_addr_start);
	
	 /* 先将对应的物理页框归还到内存池 */
	 pfree(pg_phy_addr);

         /* 再从页表中清除此虚拟地址所在的页表项pte */
	 page_table_pte_remove(vaddr);

	 page_cnt++;
      }
   /* 清空虚拟地址的位图中的相应位 */
      vaddr_remove(pf, _vaddr, pg_cnt);
   }
}

/* 回收内存ptr */
void sys_free(void* ptr) {
   ASSERT(ptr != NULL);
   if (ptr != NULL) {
      enum pool_flags PF;
      struct pool* mem_pool;

   /* 判断是线程还是进程 */
      if (running_thread()->pgdir == NULL) {
	 ASSERT((uint32_t)ptr >= K_HEAP_START);
	 PF = PF_KERNEL; 
	 mem_pool = &kernel_pool;
      } else {
	 PF = PF_USER;
	 mem_pool = &user_pool;
      }

      lock_acquire(&mem_pool->lock);   
      struct mem_block* b = ptr;
      struct arena* a = block2arena(b);	     // 把mem_block转换成arena,获取元信息
      ASSERT(a->large == 0 || a->large == 1);
      if (a->desc == NULL && a->large == true) { // 大于1024的内存
	 mfree_page(PF, a, a->cnt); 
      } else {				 // 小于等于1024的内存块
	 /* 先将内存块回收到free_list */
	 list_append(&a->desc->free_list, &b->free_elem);

	 /* 再判断此arena中的内存块是否都是空闲,如果是就释放arena */
	 if (++a->cnt == a->desc->blocks_per_arena) {
	    uint32_t block_idx;
	    for (block_idx = 0; block_idx < a->desc->blocks_per_arena; block_idx++) {
	       struct mem_block*  b = arena2block(a, block_idx);
               ASSERT(elem_find(&a->desc->free_list, &b->free_elem));
	       list_remove(&b->free_elem);
	    }
	    mfree_page(PF, a, 1); 
	 } 
      }   
      lock_release(&mem_pool->lock); 
   }
}


void pfree(uint32_t pg_phy_addr);
static void page_table_pte_remove(uint32_t vaddr)
static void vaddr_remove(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt)
void mfree_page(enum pool_flags pf, void* _vaddr, uint32_t pg_cnt);

void sys_free(void* ptr);
最上四个函数用于sys_free处理大于1024字节的回收所用,
大于1024字节一定至少申请了1个页框的arena,直接调用mfree_page释放掉arena即可。
小于1024字节sys_free有自己的处理逻辑。

3.2 malloc与free

略。

3.3 main.c

#include "memory.h"
#include "interrupt.h"
#include "init.h"
#include "thread.h"
#include "print.h"
#include "process.h"
#include "console.h"
#include "syscall.h"
#include "syscall-init.h"
#include "stdio.h"

void k_thread_a(void* );
void k_thread_b(void* );
void u_prog_a (void);
void u_prog_b (void);

int main(void) {
   put_str("I am kernel\n");
   init_all();

   intr_enable();
   process_execute(u_prog_a, "u_prog_a");
   process_execute(u_prog_b, "u_prog_b");   
	thread_start("k_thread_a", 31, k_thread_a, "argA ");
	thread_start("k_thread_b", 31, k_thread_b, "argB ");
	while(1);
   return 0;
}

void k_thread_a(void* arg){

	void* addr1 = sys_malloc(256);
	void* addr2 = sys_malloc(255);
	void* addr3 = sys_malloc(254);
	console_put_str(" thread_a malloc addr:0x");
	console_put_int((int)addr1);
	console_put_char(',');
	console_put_int((int)addr2);
	console_put_char(',');
	console_put_int((int)addr3);
	console_put_char('\n');
	int cpu_delay = 100000;
	while(cpu_delay-->0);
	sys_free(addr1);
	sys_free(addr2);
	sys_free(addr3);
	while(1);
}

void k_thread_b(void* arg){
	void* addr1 = sys_malloc(256);
	void* addr2 = sys_malloc(255);
	void* addr3 = sys_malloc(254);
	console_put_str(" thread_b malloc addr:0x");
	console_put_int((int)addr1);
	console_put_char(',');
	console_put_int((int)addr2);
	console_put_char(',');
	console_put_int((int)addr3);
	console_put_char('\n');
	int cpu_delay = 100000;
	while(cpu_delay-->0);
	sys_free(addr1);
	sys_free(addr2);
	sys_free(addr3);
	while(1);
}

void u_prog_a(void) {
	void* addr1 = malloc(256);
	void* addr2 = malloc(255);
	void* addr3 = malloc(254);
	printf(" prog_a malloc addr:0x%x, 0x%x, 0x%x\n", (int)addr1,(int)addr2,(int)addr3);
	int cpu_delay = 100000;
	while(cpu_delay-->0);
	free(addr1);
	free(addr2);
	free(addr3);
	while(1);
}

void u_prog_b(void) {
	void* addr1 = malloc(256);
	void* addr2 = malloc(255);
	void* addr3 = malloc(254);
	printf(" prog_b malloc addr:0x%x, 0x%x, 0x%x\n", (int)addr1,(int)addr2,(int)addr3);
	int cpu_delay = 100000;
	while(cpu_delay-->0);
	free(addr1);
	free(addr2);
	free(addr3);
	while(1);
}

4.实验结果及分析

在这里插入图片描述
这几个地址是符合之前的设定的

用户进程的虚拟地址是USER_VADDR_START,被定义在process.h中,其值为0x8048000

图上的0x804800C 比起始地址大了C字节=12字节,书上说了arena的大小大约12字节。sys_malloc申请一页作为arena后,按256字节分配,b=arena2block(a, block_idx),显然b跳过了12字节。

用户进程中自己的PCB是独立拥有一个用户虚拟内存池的,在process_execute中会在内核内存池申请一页作为用户虚拟内存池,第一次被调度后会在start_process中将自己的用户虚拟内存池起始虚拟地址赋值为USER_VADDR_START,所以内核内存池为两个用户进程分配了两个用户虚拟内存池,所以虚拟地址可以重复,分别按照用户进程自己的页表可以映射到用户物理内存池的某物理地址。

而对于内核线程而言,PCB中的虚拟内存池可没有函数来处理。
内核线程的虚拟内存池由内核提供,在memory.c中已经处理好,在memory.c中只有一个内核虚拟内存池,一个内核物理内存池,一个用户物理内存池,所以内核线程共用一个内核虚拟内存池,虚拟地址不能重复,映射在内核物理池某物理地址。

整个堆内存管理完成的事情是:
用户进程需要动态申请内存空间,那么返回的是3GB以下的虚拟地址(PCB自己的虚拟池),同时在PCB对应的页表建立映射
内核线程需要动态申请内存空间,那么返回的是3GB以上的虚拟地址(内核的虚拟池),同时在内核页表建立映射。
支持申请小块内存,一般不需要对页表项频繁的删除。

总结就是可以给小块内存,同时申请释放内存帮你维护页表映射。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值