linux内存管理笔记(十三)----页表映射

Linux内核中一般采用的是3级映射模型,第一层是页面目录(PDG),第二层是中间目录(PMD),页表(PTE),其三级映射的框图如下:
在这里插入图片描述

对于IMX6UL架构中,可以采用按段来映射,这时候采用的是一级页表,内存中有一个映射段,表中有4096个表项,每个表项大小为4Byte,所以这个映射表的大小为16KB,而且其位置必须是16KB边界对齐,每个段表项可以寻址1MB的大小的地址空间。当CPU访问内存时,32位的虚拟地址的高12位(bit[31:20])用作访问段映射表的索引,从表中找到对应的表项,每个表项提供一个12Bit的物理短地址,以及相应的标志位,如可读,可写等标志位。将这个12bit的物理地址和虚拟地址的低20bit拼凑在一起,就得到32bit的物理地址。但是在ARM32系统中只用到了两层映射,所以软件上就会跳过PMD表,其映射框图如下图

在这里插入图片描述
32位的虚拟地址的高12位(bit[31:20])作为访问一级页表的索引值,通过TTBRx找到PGD页表项的基地址,然后加上索引值,就可以找到二级页表的基地址。以虚拟地址的次8位(bit[19:12])作为二级页表的索引值,得到相应的页表项,从这个页表项中找到20位的物理页面地址,最后将这个20位的物理页面地址和虚拟地址的低12Bit拼在一起,最终就得到了32位物理地址。整个过程由MMU硬件完成,软件不需要接入。我们从ARM Linux内核建立具体内存区间的页面映射过程来看页表的映射是如何实现的。
在map_lowmem()使用create_mapping()创建页表映射,这个函数的参数结构是struct map_desc,下面来研究它的相关结构体,有助于理解内核是如何处理页表映射的。

struct map_desc {
	unsigned long virtual;
	unsigned long pfn;
	unsigned long length;
	unsigned int type;
};
结构变量含义
virtual表示这个区间的虚拟地址的起始地址
pfn物理地址开始地址的页帧号
length内存区间长度
type内存区间的属性

而对于内存区间的属性type指向类型位struct mem_type的mem_types数组

struct mem_type {
	pteval_t prot_pte;
	pteval_t prot_pte_s2;
	pmdval_t prot_l1;
	pmdval_t prot_sect;
	unsigned int domain;
};
结构变量含义
prot_ptePTE的属性
prot_pte_s2定义CONFIG_ARM_LPAE才有效
prot_pl1PMD属性
prot_sectsection类型映射
domain定义ARM不同的域

对于domain成员用于ARM中定义的不同的域,ARM linux只是用了3个

#define DOMAIN_KERNEL	2
#define DOMAIN_USER	1
#define DOMAIN_IO	0

DOMAIN_KERNEL属于系统空间,DOMAIN_IO用于I/O地址域,实际也属于系统空间,DOMAIN_USER则属于用户空间。下面重点关注对于二级映射中的一级页表和二级页表,对于ARMV7中,下面是first-level descriptor详细说明
在这里插入图片描述
prot_pl1成员用于一级页表项的控制位和标志位,具体的定义如下:

#define PMD_TYPE_MASK         (_AT(pmdval_t, 3) << 0)
#define PMD_TYPE_FAULT        (_AT(pmdval_t, 0) << 0)
#define PMD_TYPE_TABLE        (_AT(pmdval_t, 1) << 0)
#define PMD_TYPE_SECT         (_AT(pmdval_t, 2) << 0)
#define PMD_PXNTABLE          (_AT(pmdval_t, 1) << 2)     /* v7 */
#define PMD_BIT4              (_AT(pmdval_t, 1) << 4)
#define PMD_DOMAIN(x)         (_AT(pmdval_t, (x)) << 5)
#define PMD_PROTECTION        (_AT(pmdval_t, 1) << 9)        /* v5 */

下面是second-level descriptor的详细说明:

在这里插入图片描述
prot_pte成员用于页面表项的控制位和标志位,其具体的定义如下:

/*
 * + Level 2 descriptor (PTE)
 *   - common
 */
#define PTE_TYPE_MASK		(_AT(pteval_t, 3) << 0)
#define PTE_TYPE_FAULT		(_AT(pteval_t, 0) << 0)
#define PTE_TYPE_LARGE		(_AT(pteval_t, 1) << 0)
#define PTE_TYPE_SMALL		(_AT(pteval_t, 2) << 0)
#define PTE_TYPE_EXT		(_AT(pteval_t, 3) << 0)		/* v5 */
#define PTE_BUFFERABLE		(_AT(pteval_t, 1) << 2)
#define PTE_CACHEABLE		(_AT(pteval_t, 1) << 3)

对于系统中定义了一个全局的mem_type[]数组来描述所有的内存区间类型,定义如下

static struct mem_type mem_types[] __ro_after_init = {
	[MT_DEVICE] = {		  /* Strongly ordered / ARMv6 shared device */
		.prot_pte	= PROT_PTE_DEVICE | L_PTE_MT_DEV_SHARED |
				  L_PTE_SHARED,
		.prot_pte_s2	= s2_policy(PROT_PTE_S2_DEVICE) |
				  s2_policy(L_PTE_S2_MT_DEV_SHARED) |
				  L_PTE_SHARED,
		.prot_l1	= PMD_TYPE_TABLE,
		.prot_sect	= PROT_SECT_DEVICE | PMD_SECT_S,
		.domain		= DOMAIN_IO,
	},
...
	[MT_MEMORY_DMA_READY] = {
		.prot_pte  = L_PTE_PRESENT | L_PTE_YOUNG | L_PTE_DIRTY |
				L_PTE_XN,
		.prot_l1   = PMD_TYPE_TABLE,
		.domain    = DOMAIN_KERNEL,
	},
}

create_mapping的参数是struct map_desc类型,用于描述一个虚拟地址区域线性映射到物理区域。基于这块区域创建PGD/PTE,下面我们就进入map_lowmem。

static void __init map_lowmem(void)
{
	struct memblock_region *reg;                                            --------------(1)
#ifdef CONFIG_XIP_KERNEL
	phys_addr_t kernel_x_start = round_down(__pa(_sdata), SECTION_SIZE);
#else
	phys_addr_t kernel_x_start = round_down(__pa(_stext), SECTION_SIZE);
#endif
	phys_addr_t kernel_x_end = round_up(__pa(__init_end), SECTION_SIZE);

	/* Map all the lowmem memory banks. */
	for_each_memblock(memory, reg) {                                       ----------------(2)
		phys_addr_t start = reg->base;
		phys_addr_t end = start + reg->size;
		struct map_desc map;

		if (memblock_is_nomap(reg))
			continue;

		if (end > arm_lowmem_limit)
			end = arm_lowmem_limit;
		if (start >= end)
			break;

		if (end < kernel_x_start) {
			map.pfn = __phys_to_pfn(start);
			map.virtual = __phys_to_virt(start);
			map.length = end - start;
			map.type = MT_MEMORY_RWX;

			create_mapping(&map);
		} else if (start >= kernel_x_end) {
			map.pfn = __phys_to_pfn(start);
			map.virtual = __phys_to_virt(start);
			map.length = end - start;
			map.type = MT_MEMORY_RW;

			create_mapping(&map);
		} else {
			/* This better cover the entire kernel */
			if (start < kernel_x_start) {
				map.pfn = __phys_to_pfn(start);
				map.virtual = __phys_to_virt(start);
				map.length = kernel_x_start - start;
				map.type = MT_MEMORY_RW;

				create_mapping(&map);
			}

			map.pfn = __phys_to_pfn(kernel_x_start);
			map.virtual = __phys_to_virt(kernel_x_start);
			map.length = kernel_x_end - kernel_x_start;
			map.type = MT_MEMORY_RWX;

			create_mapping(&map);

			if (kernel_x_end < end) {
				map.pfn = __phys_to_pfn(kernel_x_end);
				map.virtual = __phys_to_virt(kernel_x_end);
				map.length = end - kernel_x_end;
				map.type = MT_MEMORY_RW;

				create_mapping(&map);
			}
		}
	}
}
  • 如果memblock region的起始地址包含了kernel _stext到init_end区间,则需要调用三次create mapping()建立映射
  • 如果memblock region只包含kernel _stext到init_end区间的一部分,则需要调用三次create mapping()建立映射
  • 如果memblock region不包含kernel _stext到init_end区间,则只需要调用一次create mapping()建立映射

kernel的text段起始物理地址和init段结束的物理地址区间需要单独映射,而对于IMX6系列,kernel_x_start=80200000,kernel_x_end=81000000,而memory中定义了内存的地址空间为80000000~a0000000,采用的是直接映射的方式

  • 0x8000 0000 ~ 0x8020 0000空间第一次使用create_mapping()建立映射,对于kernel的代码段,使用MT_MEMORY_RW属性,对应的物理页面是0x80000,虚拟地址为0xc000 0000~0xc020 0000
  • 0x8020 0000 ~ 0x8100 0000空间第二次使用create_mapping()建立映射,使用MT_MEMORY_RWX属性,对应的物理页面是0x80200,虚拟地址为0xc020 0000 ~ 0xc100 0000
  • 0x8100 0000 ~ 0xa000 0000空间第三次使用create_mapping()建立映射,使用MT_MEMORY_RW属性,对应的物理页面是0x81000,虚拟地址为0xc100 0000 ~ 0xe000 0000

通过定义了3个内存区间,然后调用create_mapping时,以此数据结构指针为调用参数,那么我们来看看create_mapping

static void __init create_mapping(struct map_desc *md)
{
	if (md->virtual != vectors_base() && md->virtual < TASK_SIZE) {               --------------(1)
		pr_warn("BUG: not creating mapping for 0x%08llx at 0x%08lx in user region\n",
			(long long)__pfn_to_phys((u64)md->pfn), md->virtual);
		return;
	}

	if ((md->type == MT_DEVICE || md->type == MT_ROM) &&                          --------------(2)
	    md->virtual >= PAGE_OFFSET && md->virtual < FIXADDR_START &&
	    (md->virtual < VMALLOC_START || md->virtual >= VMALLOC_END)) {
		pr_warn("BUG: mapping for 0x%08llx at 0x%08lx out of vmalloc space\n",
			(long long)__pfn_to_phys((u64)md->pfn), md->virtual);
	}

	__create_mapping(&init_mm, md, early_alloc, false);                           ---------------(3)
}
  • 首先判断虚拟地址是否合法,判断虚拟地址在用户区域,并且不是中断向量表(中断向量表可以在虚拟地址0开始的地方)
  • 判断映射类型是否合法 ,内存类型为IO和ROM类型的不允许映射在低端内存或高于VMALLOC_END区域,只能映射在vmalloc区域
  • 参数检查后,调用__create_mapping进行实际的映射
static void __init __create_mapping(struct mm_struct *mm, struct map_desc *md,
				    void *(*alloc)(unsigned long sz),
				    bool ng)
{
	unsigned long addr, length, end;
	phys_addr_t phys;
	const struct mem_type *type;
	pgd_t *pgd;

	type = &mem_types[md->type];                                          -------------------(1)

#ifndef CONFIG_ARM_LPAE
	/*
	 * Catch 36-bit addresses
	 */
	if (md->pfn >= 0x100000) {
		create_36bit_mapping(mm, md, type, ng);
		return;
	}
#endif

	addr = md->virtual & PAGE_MASK;
	phys = __pfn_to_phys(md->pfn);
	length = PAGE_ALIGN(md->length + (md->virtual & ~PAGE_MASK));

	if (type->prot_l1 == 0 && ((addr | phys | length) & ~SECTION_MASK)) {
		pr_warn("BUG: map for 0x%08llx at 0x%08lx can not be mapped using pages, ignoring.\n",
			(long long)__pfn_to_phys(md->pfn), addr);
		return;
	}

	pgd = pgd_offset(mm, addr);                                           -------------------(2)
	end = addr + length;
	do {                                                                  -------------------(3)
		unsigned long next = pgd_addr_end(addr, end);

		alloc_init_pud(pgd, addr, next, phys, type, alloc, ng);          --------------------(4)

		phys += next - addr;
		addr = next;
	} while (pgd++, addr != end);
}

创建地址映射需要首先明确地址空间,不同的进程有不同的地址空间,而我们这里对内核虚拟地址空间而创建地址映射,因此传递的参数是init_mm。其处理流程为:

  • 1.根据type找到对应的struct mem_type,然后虚拟地址采用4K地址对其方式,通过物理页面找到对应物理地址,然后进行参数合法性检查
  • 2.根据addr找到对应虚拟地址对应的pgd地址
  • 3.(addr,length)这个虚拟地址范围可能需要占用多个PGD entry,因此采用一个循环,不断的调用alloc_init_pud函数来完成(addr,length)这个虚拟地址范围的映射。 pgd_addr_end(addr, end); 获取addr后下一个2M的虚拟起始地址,保证不超过end,如果超过end,则返end
  • 4.一是填充pgd entry,二是创建后续的pud translation table(如果需要的话)并进行下游Translation table的建立,对于ARM32 ,该PGD表项不存在,所以只会执行一次,接下来创建下一级页表。对于4级页表的处理器,这个会创建PGD的表项。

首先我们来看看,pdg_offset,入参是mm和addr,获去所属的页面目录项PGD,内核的页表存放在swapper_pg_dir 地址中,可以通过init_mm数据结构来获得。

/* to find an entry in a page-table-directory */
#define pgd_index(addr)		((addr) >> PGDIR_SHIFT)

#define pgd_offset(mm, addr)	((mm)->pgd + pgd_index(addr))

struct mm_struct init_mm = {
	.mm_rb		= RB_ROOT,
	.pgd		= swapper_pg_dir,
	.mm_users	= ATOMIC_INIT(2),
	.mm_count	= ATOMIC_INIT(1),
	.mmap_sem	= __RWSEM_INITIALIZER(init_mm.mmap_sem),
	.page_table_lock =  __SPIN_LOCK_UNLOCKED(init_mm.page_table_lock),
	.mmlist		= LIST_HEAD_INIT(init_mm.mmlist),
	.user_ns	= &init_user_ns,
	INIT_MM_CONTEXT(init_mm)
};

这个 swapper_pg_dir 是 pgd 的入口,前面章节已经介绍过,其值是0xc000 4000,内核的pgd的起始地址在0xc000 4000, 所以(init_mm)->pgd+(add >> 21) = (pgd_t) 0xc000 4000 + (addr >> 21)。如果addr的值是0xc000 0000,那么右移21位值为0x600,最后就为0xc000 4000 + 0x600 * 4 = 0xc000 7000,总之pdg_offset_k()可以从init_mm数据结构所指定的页面目录中找到地址addr所属的页面目录项指针pgd。首先通过init_mm结构得到页表的基地址,然后通过addr右移21得到pgd的索引值,最后在一级页表中找到对应的页表项pgd。

在计算得到虚拟地址的结束地点end = addr + length,这里是为了取得addr开始,PGDIR_SIZE为步长,end为结束标志位来进行while循环,所以对于while循环,此处按照2MB步长,遍历[virtual, virtual+length)空间创建PDG页表和PTE。

#define pgd_addr_end(addr, end)						\
({	unsigned long __boundary = ((addr) + PGDIR_SIZE) & PGDIR_MASK;	\
	(__boundary - 1 < (end) - 1)? __boundary: (end);		\
})

接下来,我们看看alloc_init_pud的处理流程

static void __init alloc_init_pud(pgd_t *pgd, unsigned long addr,
				  unsigned long end, phys_addr_t phys,
				  const struct mem_type *type,
				  void *(*alloc)(unsigned long sz), bool ng)
{
	pud_t *pud = pud_offset(pgd, addr);
	unsigned long next;

	do {
		next = pud_addr_end(addr, end);
		alloc_init_pmd(pud, addr, next, phys, type, alloc, ng);
		phys += next - addr;
	} while (pud++, addr = next, addr != end);
}
  • 根据pgd中找到对应的PUD项,然后计算PUD的结束地址,给PUD建立PMD,然后循环,其处理流程与PGD类似,由对于ARM32,PUD也不存在。

由于是2级映射,这里的pud=pgd,接着调用alloc_init_pmd

static void __init alloc_init_pmd(pud_t *pud, unsigned long addr,
				      unsigned long end, phys_addr_t phys,
				      const struct mem_type *type,
				      void *(*alloc)(unsigned long sz), bool ng)
{
	pmd_t *pmd = pmd_offset(pud, addr);                                        -----------------(1)
	unsigned long next;
	
	do {
		/*
		 * With LPAE, we must loop over to map
		 * all the pmds for the given range.
		 */
		next = pmd_addr_end(addr, end);

		/*
		 * Try a section mapping - addr, next and phys must all be
		 * aligned to a section boundary.
		 */
		if (type->prot_sect &&
				((addr | next | phys) & ~SECTION_MASK) == 0) {                -----------------(2)
			__map_init_section(pmd, addr, next, phys, type, ng);
		} else {
			alloc_init_pte(pmd, addr, next,
				       __phys_to_pfn(phys), type, alloc, ng);                 -----------------(3)
		}

		phys += next - addr;

	} while (pmd++, addr = next, addr != end);
}
  • 1.通过pud拿到对应的二级页表的PMD
  • 2.如果当前的物理地址,虚拟地址以及下一个将要映射的起始地址是按照2MB对齐,同时prot_sect代表主页表是以段映射方式,则可以按照段映射方式,不需要按照二级映射的方式
  • 3.对应的是二级页表的初始化,pte表的初始化

回到我们开头,在map_lowmem创建了3段映射,都是采用段映射的方式,其映射方式如下,其的段映射地址空间,起始地址为0xc000 7000,大小为0x800。

物理地址范围段表地址范围虚拟地址范围
0x8000 0000 ~ 0x8020 00000xc000 70000xc000 0000 ~ 0xc020 0000
0x8020 0000 ~ 0x8100 00000xc000 7008 ~ 0xc000 70380xc020 0000 ~ 0xc100 0000
0x8100 0000 ~ 0xa000 00000xc000 7040 ~ 0xc000 77f80xc100 0000 ~ 0xe000 0000

下面我们来看看arm-linux采用的是两级页表的映射,跳过了PUD和PMD,所以我们就直接到alloc_init_pte创建PTE表,其处理为

static void __init alloc_init_pte(pmd_t *pmd, unsigned long addr,
				  unsigned long end, unsigned long pfn,
				  const struct mem_type *type,
				  void *(*alloc)(unsigned long sz),
				  bool ng)
{
	pte_t *pte = arm_pte_alloc(pmd, addr, type->prot_l1, alloc);
	do {
		set_pte_ext(pte, pfn_pte(pfn, __pgprot(type->prot_pte)),   
			    ng ? PTE_EXT_NG : 0);
		pfn++;
	} while (pte++, addr += PAGE_SIZE, addr != end);
}
  • pmd参数传递L1页表地址
  • addr和end分别指明被映射到虚拟地址的起止地址
  • pfn是将被映射的物理地址的页框
  • type参数指明映射的类型

arm_pte_alloc函数使用port_l1作为参数,创建PGD页表目录,返回addr对应的pte地址,后面的跟之前的PMD的原理一样,遍历(addr,end)区间内存,以PAGE_SIZE为步长。

static pte_t * __init arm_pte_alloc(pmd_t *pmd, unsigned long addr,
				unsigned long prot,
				void *(*alloc)(unsigned long sz))
{
	if (pmd_none(*pmd)) {                                               ----------------(1)
		pte_t *pte = alloc(PTE_HWTABLE_OFF + PTE_HWTABLE_SIZE);
		__pmd_populate(pmd, __pa(pte), prot);
	}
	BUG_ON(pmd_bad(*pmd));
	return pte_offset_kernel(pmd, addr);                                ----------------(2)
}
  1. 判断pmd所指向的L2页表,不存在就直接通过alloc函数分配,PTE_HWTABLE_OFF(5124=2KB)+PTE_HWTABLE_SIZE(5124=2KB)总计4KB的一个物理页来存储2个linux pet 页表+2个hw pte页表。然而最开始使用 va[31:20] 一共 12 bits 来表征 1 级表项的 index_1,va[19:12] 8 bits 表征 2级表项 index_2,也就是说,1 级表项一共有 2 的 12 次幂这么多个 entry,也就是 4096 个,2 级表项有 2 的 8 次幂个 entry,也就是 256 个。这个特性是 ARM 的 MMU 硬件特性。而为什么我们alloc分配的时候,分配了2个512呢?

    我们看看 Linux 的 pgtable-2level.h 的部分代码注释

     * This leads to the page tables having the following layout:
     *
     *    pgd             pte
     * |        |
     * +--------+
     * |        |       +------------+ +0
     * +- - - - +       | Linux pt 0 |
     * |        |       +------------+ +1024
     * +--------+ +0    | Linux pt 1 |
     * |        |-----> +------------+ +2048
     * +- - - - + +4    |  h/w pt 0  |
     * |        |-----> +------------+ +3072
     * +--------+ +8    |  h/w pt 1  |
     * |        |       +------------+ +4096
     *
     * See L_PTE_xxx below for definitions of bits in the "Linux pt", and
     * PTE_xxx for definitions of bits appearing in the "h/w pt".
     *
     * PMD_xxx definitions refer to bits in the first level page table.
     *
    

    针对二级页表呢,分配了 512 + 512 个,其实真正的 ARM MMU 的二级是 256 个,他们的对应关系如上面的简要的图所示,pgd 对应到了 h/w pt 0 和 h/w pt 1,他们都是 256 的(每个 pte 是 4 个 Bytes,所以图中看到是 1K 的 Step),另外的两个是 Linux OS 对页面的一些描述信息,同他们放到一起,正好组成了 4K ,即一个页面。分配好内存后,那么就使用__pmd_populate(),生成pmd页表目录,并刷入RAM。

  2. 根据所属的页目录项的地址和address,返回相应的 PTE 表项

从 arm_pte_alloc 函数返回到 alloc_init_pte 后,继续调用 set_pte_ext,这个和结构体系相关,在 ARMv7-A架构的处理器,它的实现是在汇编函数中,其中入参如下

r0pteppointer to level 2 translation table entry
r1ptePTE value to store
r2extvalue for extended pte bits
ENTRY(cpu_v7_set_pte_ext)
#ifdef CONFIG_MMU
    str    r1, [r0]           	 @ linux version	----------将r1的值存入r0地址的内存中

    bic    r3, r1, #0x000003f0					   ----------清除r1的bit[9:4],存入r3
    bic    r3, r3, #PTE_TYPE_MASK                    ----------PTE_TYPE_MASK为0x03,记清除低2位
    orr    r3, r3, r2                                ----------r3与r2或,存入r3
    orr    r3, r3, #PTE_EXT_AP0 | 2                  ----------这里将bit1和bit4置位,所以是Small page。

    tst    r1, #1 << 4                               ----------判断r1的bit4是否为0
    orrne    r3, r3, #PTE_EXT_TEX(1)                 ----------设置TEX为1

    eor    r1, r1, #L_PTE_DIRTY
    tst    r1, #L_PTE_RDONLY | L_PTE_DIRTY
    orrne    r3, r3, #PTE_EXT_APX                    ----------设置AP[2]

    tst    r1, #L_PTE_USER
    orrne    r3, r3, #PTE_EXT_AP1                    ----------设置AP[1]

    tst    r1, #L_PTE_XN
    orrne    r3, r3, #PTE_EXT_XN                     ----------设置XN位

    tst    r1, #L_PTE_YOUNG
    tstne    r1, #L_PTE_VALID
    eorne    r1, r1, #L_PTE_NONE
    tstne    r1, #L_PTE_NONE
    moveq    r3, #0

 ARM(    str    r3, [r0, #2048]! -)                 ---------并没有写入r0,而是写入r0+2048Bytes的偏移。
 THUMB(    add    r0, r0, #2048 )
 THUMB(    str    r3, [r0] )
    ALT_SMP(W(nop))
    ALT_UP (mcr    p15, 0, r0, c7, c10, 1)        @ flush_pte
#endif
    bx    lr
ENDPROC(cpu_v7_set_pte_ext)

要理解是如何设置PTE表项,就需要参照B3.3.1 Translation table entry formants中关于Second-level descriptors的描述。
在这里插入图片描述
在这里插入图片描述

本章比较长,主要是针对的是map_lowmem进行了分析,该函数主要的是完成低端内存的映射的过程,也就是lowmem : 0xc0000000 - 0xe0000000 ( 512 MB),对于这个区域,对于不同的芯片,低端内存的空间是可以配置的。针对该函数对于内存空间通过create_mapping进行了映射,对内核整个映射的过程进行了初步的梳理,对于arm32,内核是支持4级页表映射,如图下所示

在这里插入图片描述

而对于iMx6UL,其采用的是二级映射的方式,对于PGD和PUD均为空,对于低端内存主要使用的是段映射方式,我们也大致梳理了下二级映射的一些原理,后面会详细的介绍。

  • 0
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值