Linux中DMA驱动空间映射(二)


在这里插入图片描述

__get_free_pages函数

此函数用于分配一页或多页的物理内存,并返回一个指向这段内存的指针。在Linux内核中的物理内存是以页的形式组织的,每一页内存通常是4KB。_get_free_pages 函数可以用来分配任意数量的页,只需要传入一个整数参数来指定需要分配的页数。这个函数在内部首先会调用get_order 函数来计算需要分配的页的数量,然后调用alloc_pages函数来真正分配内存。最后函数会返回第一个页框的起始地址。

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order)

注意:使用*_get_free_pages* 函数分配的内存是物理内存,不是虚拟内存。这段内存是直接映射到物理内存中的,而不是通过虚拟内存映射实现的。因此在使用这个函数分配内存时,需要确保不会出现内存泄漏或者内存错误,以免造成系统崩溃或者数据丢失。
_get_free_pages 函数只能在Linux内核空间中使用,不能在用户空间调用。因为在用户空间无法直接操作物理内存,一般需要通过系统调用或者其他接口来分配内存。
alloc_pages函数会分配长度为1<<order的 连续页框块order参数的最大值由include/Linux/Mmzone.h文 件中的MAX_ORDER宏决定,在默认的2.6.18内核版本中,该宏定义为10。也就是说在理论上__get_free_pages函数一次最多能申请1<<10 * 4KB也就是4MB的内存大小

kmalloc函数

在内存块中按照2的order次方字节来创建多个slab描述符,如16字节、32字节、64字节、128字节等大小,系统会 分别创建kmalloc-16、kmalloc-32、kmalloc-64等slab描述符,在系统启动时这在create_kmalloc_caches()函数中完成。例如,要分配30字节的一个小内存块,可以用“kmalloc(30,GFP_KERNEL)’’ 实现,之后系统会从kmalloc-32 slab描述符中分配一个对象。

kmalloc:分配物理连续的内存地址(则虚拟地址自然连续,基于 slab)。
kfree:配套,释放 kmalloc 分配的内存地址。

vmalloc函数:

vmalloc:分配不连续的物理地址空间,但虚拟内存地址是连续的。
vfree:配套,释放 vmalloc 分配的内存地址。

一致性DMA映射

不需要手动刷cache,硬件来保证cache一致性
缓冲区的分配和映射
void *dma_alloc_coherent(struct device *dev,size_t size,dma_addr_t *dma_handle,int flag);
该函数处理了缓冲区的分配和映射。前两个参数是device结构和所需缓冲区的大小。

  1. 函数的返回值时缓冲区的内核虚拟地址,可以被驱动程序使用。
  2. 相关的总线地址则保存在dma_handle中。

释放缓冲区
void dma_free_coherent(struct device *dev,size_t size,
void *vaddr,dma_addr_t dma_handle);

DMA池

DMA池是一个生成小型、一致性DMA映射的机制。调用dma_alloc_coherent函数获得的映射,可能其最小大小为单页。如果设备需要的DMA区域比这还小,就要用DMA池。
头文件 <linux/dmapool.h>
缓冲区的分配和映射
struct dma_pool *dma_pool_create(const char *name,struct device *dev,
size_t size,size_t align,
size_t allocation);
name是DMA池的名字,dev是device结构,size是从该池中分配的缓冲区大小,
align是该池分配操作所必须遵守的硬件对齐原则。
销毁DMA池
void dma_pool_destroy(struct dma_pool *pool);

DMA池分配内存
void *dma_pool_alloc(struct dma_pool *pool,int mem_flags,
dma_addr_t *handle)
释放内存
void dma_pool_free(struct dma_pool *pool,void *vaddr,dma_addr_t addr);

流式DMA映射

流式映射希望能与已经由驱动程序分配的缓冲区协同工作,因而不得不处理那些不是它们选择的地址。缓冲区来自内核的较上层,上层很可能用的是普通的kmalloc() _get_free_pages()等方法,这时候就要使用流式DMA映射
需要手动刷cache。

当建立流式映射时,必须告诉内核数据流动的方向。

  1. DMA_BIDIRECTIONAL:不清楚传输方向则可用该类型,一致性内存映射隐性的设置为DMA_BIDIRECTIONAL
  2. DMA_TO_DEVICE:数据从内存传输到设备(clean)
  3. DMA_FROM_DEVICE: 数据从设备传输到内存(invalid)
  4. DMA_NONE 调试用途,传输方向初始化时可以设为此值

流式DMA有2种映射,一种是映射单个内存区域,一种是分散/聚集映射(映射一个scatterlist)。
映射单个内存区域的接口有:

  1. dma_{map,unmap}_single:不能映射高端地址,基于 va 映射

     dma_map_single(dev, addr, size, dir)
     dev:设备
     addr:虚拟地址
     size:大小
     dir:方向
     返回值:返回io地址
     dma_map_single(dev, dma, size, direction)
    
  2. dma_{map,unmap}_page:单页流式映射,解决上面不能映射高端地址的缺点,基于page映射

       dma_map_page(dev, page, offset, size, direction)
       参数基本同 dma_map_single,只是基于page映射
       dma_unmap_page(dev, dma, size, direction);
    

映射scatterlist的接口有:映射分散表的第一步是建立并填充一个描述被传输缓冲区的scatterlist结构的数组。
头文件<linux/scatterlist.h>

 scatterlist结构的成员:
 struct page *page;
 unsigned int length;
 unsigned int offset;

dma_{map,unmap}_sg:将几个连续的sglist条目合并成一个

  dma_map_sg(dev, sglist, nents, direction);
  dma_unmap_sg(dev, sglist, nents, direction);
    dev:设备
    sglist:散列表指针
    nents为sglist的条目数量。
    direction:方向
    返回值:返回真正映射的sg条目数量。返回零表示失败。
    获取对应的物理地址,可以使用sg_dma_address和
    sg_dma_len来获取sg的物理地址和长度

同步接口
dma_sync_{single, sg}_for_cpu:cpu收到DMA传输完的数据后,执行

dma_map_single(dev, buffer, len, DMA_FROM_DEVICE);
//读取数据前
dma_sync_single_for_cpu(dev, buffer, len, DMA_FROM_DEVICE);

dma_sync_{single, sg}_for_device:cpu发送完数据后,同步到设备

dma_map_single(dev, buffer, len, DMA_TO_DEVICE);
//发送数据前
dma_sync_single_for_cpu(dev, buffer, len, DMA_TO_DEVICE);

CMA连续内存分配

在内存初始化时预留一块连续内存,可以在内存碎片化严重时通过调用dma_alloc_contiguous接口并且gfp指定为__GFP_DIRECT_RECLAIM从预留的那块连续内存中分配大块连续内存。
在这里插入图片描述

CMA是内存管理子系统中的一个模块,负责物理地址连续的内存分配。一般系统会在启动过程中,从整个memory中配置一段连续内存用于CMA,然后内核其他的模块可以通过CMA的接口API进行连续内存的分配。

其底层还是依赖内核伙伴系统的内存管理机制,或者说CMA是处于需要连续内存块的其他内核模块(例如DMA mapping framework)和内存管理模块之间的一个中间层模块,主要功能包括:

  1. 解析DTS或者命令行中的参数,确定CMA内存的区域,这样的区域我们定义为CMA area。
  2. 提供cma_alloc和cma_release两个接口函数用于分配和释放CMA pages。
  3. 记录和跟踪CMA area中各个pages的状态。
  4. 调用伙伴系统接口,进行真正的内存分配。

优势:由操作系统来管理的,当一个驱动模块想要申请大块连续内存时,通过内存管理子系统把CMA区域的内存进行迁移,空出连续内存给驱动使用;而当驱动模块释放这块连续内存后,它又被归还给操作系统管理,可以给其他申请者分配使用。
5. 开机时,系统预留出 CMA 区域。
6. 在 CMA 业务不使用时,允许其他业务有条件地使用 CMA 区域,条件是申请页面的属性必须是可迁移的。
7. 当 CMA 业务使用时,把其他业务的页面迁移出 CMA 区域,以满足 CMA 业务的需求。

CMA数据结构

struct cma结构,用于管理一个CMA区域,此外还定义了全局的cma数组:

//头文件mm/cma.h
struct cma {
	unsigned long   base_pfn;
	unsigned long   count;
	unsigned long   *bitmap;
	unsigned int order_per_bit; /* Order of pages represented by one bit */
	struct mutex    lock;
#ifdef CONFIG_CMA_DEBUGFS
	struct hlist_head mem_head;
	spinlock_t mem_head_lock;
#endif
	const char *name;
};
 
extern struct cma cma_areas[MAX_CMA_AREAS];
extern unsigned cma_area_count;

base_pfn:CMA区域物理地址的起始页帧号;
count:CMA区域总体的页数。count成员说明了该cma area内存有多少个page。它和order_per_bit一起决定了bitmap指针指向内存的大小。
*bitmap:位图,用于描述页的分配情况。0表示free,1表示已经分配。
order_per_bit:位图中每个bit描述的物理页面的order值,其中页面数为2^order值。具体内存管理的单位和struct cma中的order_per_bit成员相关,如果order_per_bit等于0,表示按照一个一个page来分配和释放,如果order_per_bit等于1,表示按照2个page组成的block来分配和释放,以此类推。
在这里插入图片描述

CMA区域创建

1. 命令行或者内核参数配置

通过命令行参数也可以建立cma area。使用CMA,还需要在内核启动阶段预留CMA内存。在Linux内核在启动时,会根据启动参数预留CMA内存,cma内存预留的参数的格式如下:

cma=nn[MG]@[start[MG][-end[MG]]] [ARM,X86,KNL]
        Sets the size of kernel global memory area for
        contiguous memory allocations and optionally the
        placement constraint by the physical address range of
        memory allocations. A value of 0 disables CMA
        altogether. For more information, see
        include/linux/dma-contiguous.h

这样命令行参数来指明Global CMA area在整个物理内存中的位置。在初始化过程中,内核会解析这些命令行参数,获取CMA area的位置(起始地址,大小),并调用cma_declare_contiguous接口函数向CMA模块进行注册(当然,和device tree传参类似,最终也是调用cma_init_reserved_mem接口函数)。
使用CMA功能,需要在内核编译时开启DMA_CMA选项,确认运行内核是否支持该选项可以使用下面的命令。
# cat /boot/config-$(uname -r) | grep DMA_CMA
如果有如下输出,则表示运行内核支持DMA_CMA选项。否则表示不支持,需要重新配置内核并编译内核。

CONFIG_DMA_CMA=y

检查CMA是否预留成功,可以执行下面的命令:

root@tronlong-virtual-machine: cat /proc/meminfo | grep Cma
CmaTotal:              1048576  kB
CmaFree:               1048576  kB

除了命令行参数,通过内核配置(CMA_SIZE_MBYTES和CMA_SIZE_PERCENTAGE)也可以确定CMA area的参数。

通过内核参数或配置宏,来进行CMA区域的创建,最终会调用到cma_declare_contiguous函数:
在这里插入图片描述
缺点:CMA area的概念是全局的,通过内核配置参数和命令行参数,内核可以定位到Global CMA area在内存中的起始地址和大小,并在初始化的时候,调用dma_contiguous_reserve函数,将指定的memory region保留给Global CMA area使用,有些驱动不愿意和其他驱动共享CMA,因此出现两种CMA area:Global CMA area给大家共享,而Per Device CMA可以给指定的一个或者几个驱动使用。这时候,命令行参数不是那么合适了,因此引入了device tree中的reserved memory node的概念。

2. 从dts来配置

物理内存的描述放置在dts中,最终会在系统启动过程中,对dtb文件进行解析,从而完成内存信息注册。
① device_tree中建立通用global cma area

// 源码:arch/arm/boot/dts/sun4i-a10.dtsi
reserved-memory {
    #address-cells = <1>;
    #size-cells = <1>;
    ranges;

    /* Address must be kept in the lower 256 MiBs of DRAM for VE. */
    default-pool {
        compatible = "shared-dma-pool";
        size = <0x6000000>;
        alloc-ranges = <0x40000000 0x10000000>;
        reusable;
        linux,cma-default;
    };
};

对于CMA区域的dts配置来说,有三个关键点:

  1. 一定要包含有reusable,表示当前的内存区域除了被dma使用之外,还可以被内存管理子系统reuse。
  2. 不能包含有no-map属性,该属性表示是否需要创建页表映射,对于通用的内存,必须要创建映射才可以使用,而CMA是可以作为通用内存进行分配使用的,因此必须要创建页表映射。
  3. 对于共享的CMA区域,需要配置上linux,cma-default属性,标志着它是共享的CMA。

② device_tree建立per device area

//源码:arch/arm/boot/dts/stm32mp157a-dk1.dts
reserved-memory {
		#address-cells = <1>;
		#size-cells = <1>;
		ranges;
                 ....... 
		gpu_reserved: gpu@d4000000 {
			reg = <0xd4000000 0x4000000>;
			no-map;
		};
};

先在reserved memory中定义专用的CMA区域,注意这里和上面共享的主要区别就是在专用CMA区域中是不包含 linux,cma-default; 属性的。
外设使用专用cma域示例:

//源码:arch/arm/boot/dts/stm32mp157a-dk1.dts
&gpu {
	contiguous-area = <&gpu_reserved>;
	status = "okay";
};

CMA内存初始化流程

确定整个系统的的内存布局,简单说就是了解整个memory的分布情况,哪些是memory block是memory type,哪些memory block是reserved type。毫无疑问,CMA area对应的当然是reserved type。

1. memory type内存块的建立
start_kernel
    ------>setup_arch
        ------>setup_machine_fdt
            ------>early_init_dt_scan_nodes
                ------>of_scan_flat_dt
                    ------>early_init_dt_scan_memory
                        ------>early_init_dt_add_memory_arch
                            ------>memblock_add
2. 建立reserved type的memory block
start_kernel
    ------>setup_arch
        ------>arm_memblock_init
            ------>early_init_fdt_scan_reserved_mem
                ------>of_scan_flat_dt
                     ------> __fdt_scan_reserved_mem
                        ------> fdt_init_reserved_mem
                            ------> memblock_add
3. 初始化reserved memory
start_kernel
    ------>setup_arch
        ------>arm_memblock_init
            ------>early_init_fdt_scan_reserved_mem
                ------>fdt_init_reserved_mem
                    ------>__reserved_mem_init_node

为 DTS 中的预留区分配内存。 DTS 中预留区分做两类,一类是 DTB 本身需要预留的区域,另一类是 “/reserved-memory” 节点中描述的预留区。在后者中,预留区分配需要的内存之后,还会将这些预留区加入到 CMA 或 DMA 中。
__reserved_mem_init_node函数中会调用 rmem_cma_setup() 函数,该函数用于将全局 reserved-mem[] 数组的区域加入到 CMA 分配器中,即添加一块新的 CMA 区域。在该函数内,涉及从 MEMBLOCK 分配物理内存和加入新的CMA 区域,也包含了设置 CMA 分配器使用的默认分配区。rmem_cma_setup函数:

在这里插入图片描述

4. reserved-memory添加到cma子系统
rmem_cma_setup
    |------>cma_init_reserved_mem       // 将reserved-memory 添加到cma_areas数组中
    |------>dma_contiguous_early_fixup// dma remap
    |------>dma_contiguous_set_default// set_default cma area

页表与物理页初始化

构建完 CMA 区域之后,CMA 需要将每个 CMA 区域的页表进行映射,以及将 CMA 区域内的物理页进行初始化。该阶段初始化完毕之后还不能使用 CMA 分配器。dma_contiguous_remap该函数用于创建映射关系。

start_kernel
    ------>setup_arch
        ------>paging_init
            ------>dma_contiguous_remap

CMA分配器的激活

对 CMA 进行激活初始化,激活之后 CMA 就可用供其他模块、设备和子系统使用。cma_activate_area函数用于将CMA区域内的预留页全部释放添加到Buddy管理器内,然后激活CMA区域供系统使用。此函数将cma结构体剩余成员的初始化。

//源码:mm/cma.c
static int __init cma_init_reserved_areas(void)
{
	int i;

	for (i = 0; i < cma_area_count; i++)
		cma_activate_area(&cma_areas[i]);

	return 0;
}
core_initcall(cma_init_reserved_areas);

core_initcall宏将cma_init_reserved_areas函数放置到特定的段中,在系统启动的时候会调用到该函数。
在这里插入图片描述
cma默认是从reserved memory中分配的,通常情况这块内存是直接分配并预留不做任何使用,无形之中造成了浪费。所以在不用的时候放入伙伴系统,作为普通内存使用。

分配器使用

CMA 激活之后,内核可以使用 CMA API 就可以使用连续物理内存。
分配 CMA 里面的连续物理内存,可以使用dma_alloc_from_contiguous

// 源码:kernel/dma/contiguous.c
/**
 * dma_alloc_from_contiguous() - allocate pages from contiguous area
 * @dev:   Pointer to device for which the allocation is performed.
 * @count: Requested number of pages.
 * @align: Requested alignment of pages (in PAGE_SIZE order).
 * @no_warn: Avoid printing message about failed allocation.
 *
 * This function allocates memory buffer for specified device. It uses
 * device specific contiguous memory area if available or the default
 * global one. Requires architecture specific dev_get_cma_area() helper
 * function.
 */
 /*
  * 指针dev 指向需要分配CMA的设备,
  * 参数 count 指明需要分配的page数,
  * align 参数 指明对齐的方式,align = CONFIG_CMA_ALIGNMENT;
  * no_warn 控制警告消息的打印。
  */
struct page *dma_alloc_from_contiguous(struct device *dev, size_t count,
				       unsigned int align, bool no_warn)
{
	if (align > CONFIG_CMA_ALIGNMENT)
		align = CONFIG_CMA_ALIGNMENT;

	return cma_alloc(dev_get_cma_area(dev), count, align, no_warn);
}

cma_alloc函数解析:在这里插入图片描述
当使用完 CMA 连续物理内存之后,可以通过dma_release_from_contiguous将物理内存归还给 CMA 内存管理器:

 //源码:kernel/dma/contiguous.c
/**
 * dma_release_from_contiguous() - release allocated pages
 * @dev:   Pointer to device for which the pages were allocated.
 * @pages: Allocated pages.
 * @count: Number of allocated pages.
 *
 * This function releases memory allocated by dma_alloc_from_contiguous().
 * It returns false when provided pages do not belong to contiguous area 
 * and true otherwise.
 */
 /*
  * 参数 dev 指向一个设备,
  * pages 指向连续物理内存的起始页,
  * 参数 count 表示分配的page数
  */
bool dma_release_from_contiguous(struct device *dev, 
struct page *pages,int count)
{
	return cma_release(dev_get_cma_area(dev), pages, count);
}

其他CMA操作API:

struct page *dma_alloc_contiguous(struct device *dev, size_t size,
 gfp_t gfp)void dma_free_contiguous(struct device *dev, struct page *page, 
size_t size)

实例:

驱动代码

驱动模块中申请一片512M的连续内存,一个page是4k字节,要申请512M内存,count就是0x20000:

#include <linux/dma-contiguous.h>
#include <linux/cma.h>

struct page *p = NULL;

    p = cma_alloc(dma_contiguous_default_area, 0x20000, (1<<PAGE_SHIFT));
    //align参数表示申请的内存以多大块对齐。
    

得到的是连续内存的第一个page的实例指针,如果要使用这个内存就将page转换成虚拟地址:

    unsigned char *buf = NULL;
    buf = page_to_virt(p);

释放内存:

    cma_release(dma_contiguous_default_area, p, (1<<PAGE_SHIFT));
编译加载模块

使用make进行模块编译,成功编译出内核模块ko文件,但在编译中提示了Warning:

# make
WARNING: "cma_release" [/root/cma_test/cmatest.ko] undefined!
WARNING: "dma_contiguous_default_area" [/root/cma_test/cmatest.ko] undefined!
WARNING: "cma_alloc" [/root/cma_test/cmatest.ko] undefined!

三个警告就是我们使用的CMA的接口,没有定义是会影响模块加载:

# insmod cmatest.ko
insmod: ERROR: could not insert module cmatest.ko: Unknown symbol in module

发现CMA的接口和dma_contiguous_default_area指针,都没有做符号导出(EXPORT_SYMBOL),是不能在内核之外被使用的,只有编译到内核中的代码可以调用。那么是否表示单独的内核模块ko无法使用CMA内存呢?当然我们可以修改内核,将这三个符号导出再使用,但是这样使用并不灵活。我们采用直接引用内核符号表的方式来解决。

内核符号表

CMA功能默认只提供给内核中的函数调用,CMA的相关接口没有做符号导出(EXPORT_SYMBOL),启动后加载的内核模块要使用CMA功能需要获取对应的接口的符号地址。
cma测试模块需要如下三个内核符号的地址:

  1. dma_contiguous_default_area: DMA_CMA管理结构体指针;
  2. cma_alloc: cma内存分配函数地址;
  3. cma_release: cma内存释放函数地址;
    可以在/proc/kallsyms中获取这三个符号地址。
# cat /proc/kallsyms | grep dma_contiguous_default_area

得到运行系统中的dma_contiguous_default_area符号地址:

ffffffff9445ba58 B dma_contiguous_default_area

前面的数值既是符号的地址,同样的方法可以获取:cma_alloc和cma_release的符号地址。
注意: 每次系统重启,这些符号的地址可能会发生变化。

调整后的驱动代码
  1. 去掉内核头文件
- #include <linux/dma-contiguous.h>
- #include <linux/cma.h>

struct page *p = NULL;
  1. 添加模块参数,用于接收符号地址
static unsigned long area_base = 0;
module_param(area_base, ulong, 0600);
static unsigned long alloc_fn = 0;
module_param(alloc_fn, ulong, 0600);
static unsigned long free_fn = 0;
module_param(free_fn, ulong, 0600);
  1. 添加函数指针和cma实例指针
typedef struct page *(*cma_alloc_t)(struct cma *, size_t, unsigned int);
typedef bool (*cma_release_t)(struct cma *, const struct page *, unsigned int);

cma_alloc_t cma_alloc=NULL;
cma_release_t cma_release=NULL;
struct cma * dma_cma_p = NULL;
  1. 用模块参数初始化函数指针和cma实例指针
    cma_alloc = (cma_alloc_t)alloc_fn;
    cma_release = (cma_release_t)free_fn;
    dma_cma_p = (struct cma *)(*(unsigned long *)area_base);
  1. 修改cma申请和释放调用
    p = cma_alloc(dma_cma_p, 0x20000, (1<<PAGE_SHIFT));

    cma_release(dma_cma_p, p, 0x20000);
重新加载模块

修改cma测试模块后编译,重新带参数的加载模块:

# insmod cmatest.ko area_base=0xffffffff9445ba58 alloc_fn=0xffffffff9a255140 free_fn=0xffffffff9a255350

CMA部署

获得系统物理内存的范围
cat /proc/iomem

root@tronlong-virtual-machine:/home/tronlong# cat /proc/iomem 
00000000-00000fff : reserved
00001000-0009e7ff : System RAM
0009e800-0009ffff : reserved
000a0000-000bffff : PCI Bus 0000:00
000c0000-000c7fff : Video ROM
000ca000-000cafff : Adapter ROM
000cb000-000ccfff : Adapter ROM
000d0000-000d3fff : PCI Bus 0000:00
000d4000-000d7fff : PCI Bus 0000:00
000d8000-000dbfff : PCI Bus 0000:00
000dc000-000fffff : reserved
  000f0000-000fffff : System ROM
00100000-bfecffff : System RAM
  01000000-017bbc4b : Kernel code
  017bbc4c-01d2537f : Kernel data

“System RAM”代表系统物理内存的起始物理地址和终止物理地址,分配的cma区域不能超过这段范围。

查看当前系统的预留区
cat /sys/kernel/debug/memblock/reserved
通过这个命令可以知道系统已预留的内存信息,这些已预留的内存信息不可使用。但排除这些预留区域,再在RAM范围内找出可用内存,再满足对其需求就可以自己手动找出可用于CMA的区域。

DTS中说明cma信息
dts方式部署cma的好处是既可以指定起始地址和长度,还可以命名该cma。

// 源码:arch/arm/boot/dts/sun4i-a10.dtsi
reserved-memory {
    #address-cells = <1>;
    #size-cells = <1>;
    ranges;

    /* Address must be kept in the lower 256 MiBs of DRAM for VE. */
    default-pool {
        compatible = "shared-dma-pool";
        size = <0x6000000>;
        alloc-ranges = <0x40000000 0x10000000>;
        reusable;
        linux,cma-default;
    };
};
  • 29
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值