Android mmap+Binder

1.Binder机制

Binder机制是Android系统提供的一种跨进程通信机制,它使用代理对象、共享内存和序列化等技术实现了进程间通信和远程调用的功能。它允许在不同进程之间进行数据传输和方法调用,实现了进程间的解耦。

在Android系统中,Binder被广泛应用于各种组件之间的通信,例如Activity与Service、Service与Service、应用与系统服务等。

几个概念:

①驱动层:Binder作为一个设备驱动存在于Linux内核中。当应用程序与Binder对象进行通信时,实际上是在与Binder驱动进行交互。Binder驱动负责管理Binder对象、传输数据以及进程间的上下文切换等工作。设备文件节点通常是 /dev/binder。

②用户空间库:Binder提供了一套C++库和Java库,以方便用户空间的应用程序使用。这些库包括序列化/反序列化数据的工具、用于管理Binder对象的引用计数的工具以及在进程间调用方法时的代理(proxy)和存根对象(stub)等。在Java层通常使用AIDL来定义跨进程接口。

③代理与存根:Binder使用了代理(Proxy)和存根(Stub)对象来实现进程间的方法调用。当一个进程要调用另一个进程中的方法时,它会通过代理对象将方法调用转换为一个Binder事务。然后这个事务会被发送到接收进程,由存根对象解析并执行对应的方法。最后存根对象将结果返回给代理对象,完成整个跨进程调用。

④序列化与反序列化:在进程间传输数据时,需要将数据序列化为字节流,以便在不同进程之间进行传输。在接收进程中,数据会被反序列化为原始格式。Binder提供了一套序列化和反序列化的工具(如Parcel类)用于在进程间传输数据。

⑤引用计数与死亡通知:Binder机制通过引用计数来管理Binder对象的生命周期。当一个进程获得了另一个进程的Binder对象引用时,引用计数会增加。当引用计数减少到零时Binder对象会被销毁。另外Binder还支持死亡通知机制,允许一个进程监听另一个进程的Binder对象死亡事件。

 

2.物理内存和虚拟内存

①物理内存

物理内存是系统硬件提供的内存,即真正的内存。系统的物理内存被划分为许多相同大小的部分,也称作内存页。内存页的大小取决于CPU的架构和操作系统的配置,一般为4KB。

物理内存的使用主要分为以下几方面:

1)内核使用

操作系统启动时,位于/boot目录下的压缩内核文件会被加载到内存中并解压。这部分内容在系统允许期间都会常驻在内存的起始位置。

2)进程使用        

除去内核使用的部分,所有的进程都需要分配物理内存页给它们的代码、数据和堆栈。进程消耗的这些物理内存被称为“驻留内存”,RSS。

3)页缓存page cache       

除去内核和进程使用的部分,物理内存剩下的部分被称为页缓存page cache。引入页缓存的目的是为了提高Linux操作系统对磁盘访问的性能。因为磁盘IO的速度远远低于内存的访问速度,所以Cache层在内存中缓存了磁盘上的部分数据。当数据请求到达时,如果在Cache中存在该数据则直接将数据传递给用户程序,避免了对底层磁盘的操作,提高了性能。

页缓存的大小是一直动态变化的。当系统内存充足时,页缓存会一直增大;当系统内存不足时,这时如果有进程申请内存,操作系统会从页缓存中回收内存页进行分配,如果页缓存也已不足,那么系统会将当期驻留在内存中的数据置换到事先配置在磁盘上的swap空间中,然后空出来的这部分内存就可以用来分配了,这就是swap交换。swap交换往往会带来磁盘IO的大量消耗,严重影响到系统正常的磁盘io。出现大量的swap交换说明系统已经快要不行了,需要重点关注。

页缓存实际上是内核中的物理内存,在磁盘和用户空间之间多了一层缓存,由内核负责管理控制。

②虚拟内存

虚拟内存实际上并不存在,它只是存在于这套巧妙的内存管理机制中。虚拟内存用于解决物理内存不足的情况。当一个进程启动时,内核会给新启动的进程建立一个虚拟地址空间,这个虚拟地址空间代表了该进程可能使用到的所有内存,当然它是可以动态变化的。

虚拟地址结构从下往上地址增大,主要包括:

1)代码段:该部分只读,用于存放加载的代码。 

2)数据段:用于存放全局变量和静态变量。   

3)堆:动态内存,当malloc/free申请释放内存小于某个阈值(一般操作系统设定为128K,可以修改)时,通过brk/sbrk系统调用控制堆顶指针向高地址偏移(malloc)或低地址偏移(free)。 

4)文件映射区:动态内存,当malloc/free申请释放内存大于128K时,通过mmap系统调用分配一块虚拟地址空间。       

5)栈:用于存放局部变量和进程上下文。

00ccef712eb6440881f9d911441377b5.jpg

由于成本的限制,物理内存往往无法做的很大,但是进程运行阶段所需申请的内存可能远远超过物理内存,并且系统不可能只跑一个进程,会有多个进程一起申请使用内存,如果都直接向物理内存进行申请使用肯定无法满足。通过引入虚拟内存,每个进程都有自己独立的虚拟地址空间,这个空间理论上可以无限大,因为它并不要钱。

由于程序的局部性原则,代码在某一时间并不是全部执行的,即一个进程同一时刻不可能所有变量数据都会访问到,只需要在访问某部分数据时,把这一块虚拟内存映射到物理内存,其它没有实际访问的虚拟地址空间并不会占用到物理内存,这样对物理内存的消耗就大大减少了。

比如在一个类中有1000个方法,某一时间可能只有1个方法被执行。 所以只有当部分代码被CPU执行的时候才会将代码加载到物理内存,剩下的大部分代码会存储在磁盘中,因此即使是128M的物理内存也可以加载10G的程序代码。

现在大多数的程序都是运行在虚拟内存,而且在应用层是绝对不可能取到物理内存的。

③虚拟内存 -> 物理内存的映射机制

系统内核为每个进程都维护了一份从虚拟内存到物理内存的映射表,称为页表。页表根据虚拟地址查找出所映射的物理页位置和数据在物理页中的偏移量,便得到了实际需要访问的物理地址。

ecd3e59d36a84bb080be9969af0ad982.webp

这里还要提到一个概念,驻留内存,驻留内存指虚拟内存中实际映射到物理内存的那部分,也就是进程实际占用的物理内存大小。所以判断一个进程使用的内存大小,主要是看占用的物理内存,也就是驻留内存的大小,即RSS。

上图中的A4和B3部分实际都映射到同一块物理内存,这就是共享内存。

 

3.用户空间与内核空间

Linux的进程是相互独立的,也叫做沙盒模式,一个进程是不能直接操作或访问其他进程空间的。每个进程都只能运行在自己进程所拥有的虚拟地址空间。

进程空间分为用户空间(User Space)和内核空间(Kernel Space),例如一个4GB的虚拟地址空间,其中3GB是用户空间,1GB是内核空间,内核空间的大小是可以通过参数配置调整的。

用户空间访问内核空间的唯一方式就是系统调用ioctl。通过系统调用接口,所有的资源访问都在内核的控制下执行,避免了用户程序对系统资源的越权访问,从而保障系统的安全和稳定。

这里有两个隔离,一个进程间是相互隔离的,二是进程内用户和内核的隔离。进程间的交互叫进程间通信(IPC,或称跨进程通信),而进程内的用户和内核的交互就是系统调用。

bf27daee646446d18835c6e74a93a9b1.jpg

注意:只有系统调用才能操作内核空间。

两个系统调用的函数:

copy_from_user() //将数据从用户空间拷贝到内核空间

copy_to_user() //将数据从内核空间拷贝到用户空间

不同进程之间的用户空间是不能共享的,而内核空间是可共享的,所有进程共用1个内核空间。因此一个进程向另一个进程通信,就要利用进程间可共享的内核空间来完成底层通信工作。

 

4.传统的IPC通信方式

由于有Cache Page的存在,read/write系统调用的流程:

e2d9cf3418f147f6ba95dca251f6645b.png

 ①用户进程向内核发起读取文件的请求,这涉及到用户态到内核态的转换。

②内核访问文件系统。

③如果有cache直接返回数据,没有就读取磁盘。

④读取成功就将page1读取到cache中完成第一次 拷贝。

⑤通知内核读取完毕(不同IO模型实现不同)。

⑥将数据从位于内核空间的cache拷贝到进程空间,完成第二次拷贝。

由于Page Cache处在内核空间,不能被用户进程直接寻址 ,所以需要从Page Cache中拷贝数据到用户进程的堆空间中。因此这里涉及到了两次拷贝:第一次拷贝是从磁盘到Page Cache,第二次拷贝是从Page Cache到用户内存。

最后物理内存的内容是这样的:同一个文件内容存在了两份拷贝,一份是页缓存,一份是用户进程的内存空间。

243bf1f7af5746fa87361821e1eb8a7f.png

所以传统的IPC通信方式是发送方进程通过系统调用将需要发送的数据从用户空间copy_from_user拷贝到Linux进程的内核空间中(Page Cache);然后内核服务程序唤醒接收方进程的接收线程,通过系统调用将数据从内核空间中copy_to_user发送到用户空间中;接收方进程获得用户空间中的数据,通过两次拷贝完成了数据的跨进程通信。

传统的通信方式的缺点:

①效率低下,需要2次数据拷贝: 用户空间>>内核空间>>用户空间;

②接收器的数据缓存需要接收方来提供,但是接收方却不知道到底要多大的缓存才能满足需求(尽量开辟大的空间,这样容易浪费空间;先调用API接收消息头获得信息体大小然后确定缓存大小,该方法浪费时间)。

 

5.内存映射mmap(memory mapping)

Linux将虚拟内存磁盘上的一个对象关联起来,从而初始化这个虚拟内存区域的内容,这个过程就是内存映射。

对文件进行mmap后,会在进程的虚拟内存分配地址空间,创建映射关系。这样就可以采用指针的方式来读写操作这一段内存,而系统会自动回写到对应的文件磁盘上。

06602e5da7f04fa49b1625cd8dfd4d00.png

mmap是一种内存映射文件的方法,它将一个文件或其他对象映射到进程的地址空间中,实现文件磁盘地址和进程虚拟地址空间中一段虚拟地址的一一对映关系。实现这样的映射关系后,进程就可以采用指针的方式读写操作这一块内存,而系统会自动回写脏数据到对应的文件磁盘上,直接完成对文件的操作而不必再调用read/write等系统调用函数。同样,如果内核堆中这块区域的的修改也会直接反映到用户空间,从而可以实现不同进程间的文件共享。

mmap在完成了read、write相同效果的同时不仅省去了内核到进程的内存拷贝过程,而且还可以实现数据的共享操作:一个文件可以同时被多个进程、内核映射,如果映射的文件被内核或其他进程修改,那么最终的结果也会反映到映射当中。

mmap的底层原理其实就是虚拟内存。即不同的虚拟内存指向相同的物理内存,从而实现共享内存和共享文件。不管是父子进程还是无亲缘关系的进程,都可以将自身用户空间映射到同一个文件或匿名映射到同一片区域,从而通过各自对映射区域的改动,达到进程间通信和进程间共享的目的。

mmap内存映射之后,可以通过直接操作内存来读写文件,减少了对数据的拷贝次数,相对于sp的key valye读写,大大提高了IO读写的效率。

注:mmap映射区域的大小必须是物理页大小(page_size)的整倍数(在Linux中内存页通常是4k)。因为内存的最小粒度是页,而进程虚拟地址空间和内存的映射也是以页为单位。为了匹配内存的操作,mmap从磁盘到虚拟地址空间的映射也必须是页。例如,有一个文件的大小是5K,mmap函数从文件的起始位置映射5K到虚拟内存中,由于内存物理页是4K,虽然映射的文件只有5K,但是实际上映射到内存区域的内存是8K,以便满足物理页大小的整数倍。映射后对5~8K的内存区域用零填充,对这部分的操作不会报错也不会写入到原文件中。

映射建立之后,即使文件关闭,映射依然存在。因为映射的是磁盘的地址,不是文件本身,和文件句柄无关。同时可用于进程间通信的有效地址空间不完全受限于被映射文件的大小,因为是按页映射。

 

mmap内存映射具体流程:

①用户进程调用内存映射函数库mmap,当前进程在虚拟地址空间中寻找一段空闲的满足要求的虚拟地址。

②此时内核收到相关请求后会调用内核的mmap函数(不同于用户空间mmap库函数)。内核mmap函数通过虚拟文件系统定位到文件磁盘物理地址,即实现了文件地址和虚拟地址区域的映射关系。 此时,这片虚拟地址并没有任何数据关联到主存(内核中的page cache)中。

注意,前两个阶段仅在于创建虚拟区间并完成地址映射,但是并没有将任何文件数据拷贝至主存。真正的文件读取是当进程发起读或写操作时。

③进程的读写操作访问虚拟地址空间这一段映射地址,现这一段地址并不在物理页面上。因为目前只建立了地址映射,真正的硬盘数据还没有拷贝到内存中,因此引发缺页中断。

④由于引发了缺页中断,内核则调用nopage函数把所缺的页从磁盘装入到主存中。

⑤之后用户进程即可对这片主存进行读写操作,如果写操作改变了其内容,一定时间后系统会自动回写脏页面到对应磁盘地址,也即完成了写入到文件的过程。

注意:这里拷贝磁盘内容到主存,这里的主存是指处于内核空间的Page Cache,而不是用户空间的内存。用户地址要访问内核空间中的数据,需使用MMU把虚拟地址映射到内核的内存地址中,即可对数据进行操作。

6a9cff72f222437eb261ab01edbc48ea.png

mmap数据读写的性能提升就在于对数据的读写拷贝次数,mmap只需要一次系统调用(一次拷贝),后续操作不需要系统调用。并且访问的数据不需要在page cache和用户缓冲区之间拷贝。

特点:

用户空间与内核空间磁盘块通过映射直接交互,不再间接通过页缓存。文件读写操作跨过了页缓存,数据拷贝次数减少为只需一次。

②实现了用户空间和内核空间的高效交互方式。两空间的各自修改操作可以直接反映在映射的区域内,从而被对方空间及时捕捉。

③借助硬盘的大空间,对于大规模数据的读写避免对页内存空间大小的依赖,提高操作效率。

④可用于实现高效的大规模数据传输。内存空间不足,是制约大数据操作的一个方面,解决方案往往是借助硬盘空间协助操作,补充内存的不足。但是进一步会造成大量的文件I/O操作,极大影响效率。凡是需要用磁盘空间代替内存的时候mmap都可以发挥其功效。

 

Android应用在进程启动之初会创建一个单例的ProcessState对象,其构造函数执行时会同时完成binder mmap,为进程分配一块内存,专门用于Binder通信:

ProcessState::ProcessState(const char *driver) {

    if (mDriverFD >= 0) {

        mVMStart = mmap(0, BINDER_VM_SIZE, PROT_READ, MAP_PRIVATE | MAP_NORESERVE, mDriverFD, 0);

        ...

    }

}

第一个参数是分配地址,为0意味着让系统自动分配,流程是先在用户空间找到一块合适的虚拟内存,之后在内核空间也找到一块合适的虚拟内存,修改两个控件的页表,使得两者映射到同一块物理内存。

Linux的内存分用户空间和内核空间,同时页表也分两类,用户空间页表跟内核空间页表,每个进程有一个用户空间页表,但是系统只有一个内核空间页表。而Binder mmap的关键是:更新用户空间对应的页表的同时也同步映射内核页表,让两个页表都指向同一块地址,这样数据只需要从A进程的用户空间直接拷贝到B所对应的内核空间,而B所对应的内核空间在B进程的用户空间也有相应的映射,这样就无需从内核拷贝到用户空间了。

 

Binder在内存管理方面使用了mmap函数。达到了文件只拷贝一次的特点:

①Binder初始化时会在物理内存当中申请大小为一页的空间,然后分别映射到内核空间vm_struct和Server进程的用户空间vm_area_struct;

②当Client通过Binder跨进程与Server进行交互时,会将Client携带的数据写入vm_struct内核空间,完成第一次拷贝;

③这样数据将会存储于vm_struct所映射的物理内存Binder Map中;

④由于vm_area_struct也建立了与binder_map的映射关系,这样一来Server的用户空间在不拷贝vm_struct的情况下也能获取到Client发过来的数据。

480e4a980938475a9769967bcfcd1e79.jpg

 

5.Binder实现IPC

Binder机制是Android系统中进程间通信(IPC:Interprocess Communication)的一种常用方式,Android中的ContentProvider、Intent、aidl都是基于Binder。Binder就像Android中的血管。

IPC除了Binder,还有共享内存、消息队列、管道、Socket等方式。相比于其他的跨进程通信方式,Binder有自身的优势:

①Binder使用mmap机制,只拷贝一次,性能上仅次于共享内存。而管道、消息队列、Socket都需要2次,共享内存方式一次内存拷贝都不需要,但实现方式比较复杂。

②采用Client/Server架构,实现面向对象的调用方式,调用如同调用Java对象,使用简单。

③为每个app分配了UID/PID来鉴别身份标识,通信时检测UID/PID进行有效性检测,提高了安全性。

跨进程通信是需要内核空间做支持的。传统的IPC 机制如管道、Socket都是内核的一部分。但Binder并不是Linux系统内核的一部分,于是有了Linux的动态内核可加载模块(LKM:Loadable Kernel Module)机制:

1)模块是具有独立功能的程序,它可以被单独编译,但是不能独立运行。它在运行时被链接到内核作为内核的一部分运行;

2)Android系统可以通过动态添加一个内核模块运行在内核空间,用户进程之间通过这个内核模块作为桥梁来实现通信。在Android系统中,这个运行在内核空间、负责各个用户进程通过Binder实现通信的内核模块就叫Binder驱动。

Binder只需要一次拷贝,就是用了mmap的方式。由于所有的读写操作都是在内核空间完成的,那么mmap就是开辟一块物理内存,与内核空间完成映射,并且所有的进程内存空间与这块物理内存也存在映射关系。

e27cbc9d17c14e9d8dd91bcfa0012197.png

当进程1拿到这块物理内存的地址之后,便可以将数据拷贝到这块物理内存,因为进程2和这块内存存在映射关系,因此进程2便可以拿到进程1的数据,腾讯的MMKV便是基于mmap实现的。

一次完整的Binder IPC通信过程:

4659ae370bed406b9c2df14b4b0ba07b.jpg

 ①Binder驱动为跨进程通信做准备:Server端在启动之后,通过/dev/binder设备调用mmap()系统函数实现内存映射。

②内核中的binder_mmap函数进行对应的处理:申请一块物理内存,然后在Server端的用户空间和内核空间同时进行映射。

③Client发送请求,这个请求将先到内核中(Binder驱动)中,同时需要将数据从Client进程的用户空间拷贝(copy_from_user)到内核空间(一次拷贝)。此时Client发起请求的线程会被挂起。由于在①中构建了映射关系,此时相当于也将数据发送到了Server端的用户空间中。之后Binder驱动通知Server端进程执行解包。

④Binder驱动通过请求通知Server端有人发出请求,Server进行处理。由于内核空间和Server端进程的用户空间存在内存映射,因此Server进程的代码可以直接访问。这样便完成了一次进程间的通信。

⑤ Server进程将目标方法处理结果返回给Client进程:Server将处理结果放回自己的共享空间(即①中映射的Binder驱动缓存区中)。Binder驱动通知Client进程获取返回结果,此时③中被挂起的线程会被重新唤醒。Client进程通过系统调用copy_to_user()从内核缓存区拷贝Server进程返回的结果。

 

6.Binder使用

ddde81ff79284231bef4e24f2f68c1a9.jpg

Binder通信采用C/S架构,包含Client、Server、ServiceManager以及Binder驱动。在framework层进行了封装,通过JNI技术调用Native层的Binder架构,在Native层以ioctl的方式与Binder驱动通讯。

1f2e0d064f8b4c569feef56664a0630b.png

Binder通信的四个角色:

1)Client进程:使用服务的进程。

2)Server进程:提供服务的进程。

3)ServiceManager进程:Android系统整个服务的管理程序。在binder通信中ServiceManager的作用是将字符形式的Binder名字转化成Client中对该Binder的引用,使得Client能够通过Binder名字获得对Server中Binder实体的引用。

注:任何服务在被使用之前,都要向ServiceManager中注册。当客户端访问某个服务时,首先向ServiceManager中查询是否存在该服务。如果ServiceManager中存在该服务,就会将该服务的handle返回给客户端(handle是每个服务的唯一标识符)。

4)Binder驱动:驱动负责进程之间Binder通信的建立、Binder在进程之间的传递、Binder引用计数管理、数据包在进程之间的传递和交互等一系列底层支持。

Client、Server、ServiceManage之间的相互通信都是基于Binder机制,所以同样也是C/S架构,也就是图中的3大步骤都有相应的Client端与Server端。

1)注册服务(addService):Server进程要先通过Binder驱动向ServiceManager进程注册服务。该过程中Server是客户端,ServiceManager是服务端。

2)获取服务(getService):Client进程使用某个服务前,须先通过Binder驱动向ServiceManager进程中获取相应的Service。该过程中Client是客户端,ServiceManager是服务端。

3)使用服务:Client根据得到的服务信息建立与服务所在的Server进程通信的通路,然后就可以直接与Service交互。该过程中Client是客户端,Server是服务端。

图中的Client、Server、Service Manager之间交互都是虚线表示,是由于它们彼此之间不是直接交互的,而是通过与Binder驱动进行交互从而实现IPC通信的。其中Binder驱动位于内核空间,Client、Server、ServiceManager位于用户空间。

Binder驱动和Service Manager可以看做是Android平台的基础架构,而Client和Server是Android的应用层,开发人员只需自定义实现Client、Server端,借助Android的基本平台架构便可以直接进行IPC通信。

ServierManager是一个进程,Server是另一个进程,Server向ServiceManager中注册Binder必然涉及到进程间通信。当前实现进程间通信又要用到进程间通信,这就好像蛋可以孵出鸡的前提却是要先找只鸡下蛋。Binder的实现比较巧妙,就是预先创造一只鸡来下蛋。ServiceManager和其他进程同样采用Bidner通信,ServiceManager是 Server端,有自己的Binder实体,其他进程都是Client,需要通过这个Binder的引用来实现Binder 的注册、查询和获取。ServiceManager提供的Binder比较特殊,它没有名字也不需要注册,当一个进程使用BINDERSETCONTEXT_MGR命令将自己注册成ServiceManager时,Binder驱动会自动为它创建Binder实体(这就是那只预先造好的那只鸡)。而且这个Binder实体的引用在所有Client中都固定为0而无需通过其它手段获得。也就是说,一个Server想要向ServiceManager注册自己的Binder就必须通过这个0号引用和ServiceManager的Binder通信。注意,这里说的Client是相对于ServiceManager而言的,一个进程或者应用程序可能是提供服务的Server,但对于ServiceManager来说它仍然是个Client。

Server向ServiceManager中注册了Binder以后, Client就能通过名字获得Binder的引用了,Client也利用保留的0号引用向ServiceManager请求访问某个Binder,ServiceManager收到这个请求后从请求数据包中取出Binder名称,在查找表里找到对应的条目,取出对应的Binder引用作为回复发送给发起请求的Client。从面向对象的角度看,Server中的Binder实体现在有两个引用:一个位于ServiceManager中,一个位于发起请求的Client中,如果接下来有更多的Client请求该Binder,系统中就会有更多的引用指向该Binder ,就像Java中一个对象有多个引用一样。

Binder通信过程:

7651f19425f94210a6b46a949b04d2f9.jpg

 1)首先一个进程使用BINDERSETCONTEXT_MGR命令通过Binder驱动将自己注册成为ServiceManager;

2)Server通过驱动向ServiceManager中注册Binder(Server中的Binder实体),表明可以对外提供服务。驱动为这个Binder创建位于内核中的实体节点以及ServiceManager对实体的引用,将名字以及新建的引用打包传给ServiceManager,ServiceManger将其填入查找表。

3)Client通过名字在Binder驱动的帮助下从ServiceManager中获取到对Binder实体的引用,通过这个引用就能实现和Server进程的通信。

b5381725780c447681e85dd0ab709a88.png

 

为什么要用多进程?

虚拟机给每一个进程分配的内存是有限的,LMK会优先回收对系统资源占用多的进程。因此,使用多进程的好处是:

1)为了突破内存限制,防止占用内存过多被杀;

2)功能稳定性,一个进程崩溃对另外进程不造成影响:将不稳定功能放入独立进程;

3)规避内存泄漏,独立的WebView进程阻隔内存泄漏导致问题。

  • 3
    点赞
  • 9
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值