Android分区挂载原理介绍(上)

一、 device-mapper基本原理介绍       

1.1 dm工作原理       

1.2 dm实现动态卷(逻辑分区)功能介绍(dm-linear)       

1.3 dm 实现完整性校验功能介绍(dm-verity)       

1.4 元数据加密(default-key)       

1.5 dm实现快照功能介绍(snapshot,snapshot-origin,dm-snapshot-merge,bow)       

二、vold介绍       

2.1 vold结构总览       

2.2 encryptFstab(元数据加解密)     

2.3 mountFstab(挂载分区)  

2.4 fbeEnable(使能fbe加密)   

2.5 initUser0      

三、分区挂载流程介绍    

3.1 分区挂载顺序总览   

3.2 metadata分区挂载流程   

3.3 system分区挂载流程    

3.4 userdata分区挂载流程

四、 常见问题汇总     

4.1 set_policy_failed问题  

4.2 init_user0_failed问题    

4.3 enablefilecrypto_failed问题   

4.4 userdata挂载失败问题   

4.5 关机时ServiceManager crash导致vold shutdown超时死机重启      

前言:

还没开始深入了解分区挂载原理时,觉得分区挂载涉及的内容应该不会很多。就是在分区表中找到对应的物理块,然后直接mount就完成了,最多就是需要了解一些流程顺序上的内容。

当真正开始走读分区挂载流程代码时,才发现之前的想法太天真了。如果要深入了解分区挂载原理,需要了解的内容很多。

在远古android时期,挂载真的如上面描述的,仅仅是一个挂载(一个物理分区由文件系统管理起来)

但是随着对分区使用效率,安全等提出了越来越多的要求,分区挂载涉及的内容也越来越多。

例如:为了解决system和vender等分区size不能动态调整的问题引入动态分区概念(逻辑分区),将system,vendor等分区打包放在了super分区下;system等关键分区为了防止篡改,在挂载或使用时,需要这些分区进行完整性校验;为了用户数据的安全,对userdata分区中的数据需要加密,加密方式也有较早的FDE( Full-disk encryption全盘加密),发展到现在更精细化的FBE(File-based Encryption文件级加密),并且针对userdata还增加了  metadata(元数据)级别的加密;系统分区也有之前的单分区进recovery下升级,到A/B分区到recovery下升级,再到现在的VAB分区架构,借用snapshot(快照)技术完成升级及回滚等等。

如果要对分区挂载有一个完整系统的了解,上面提到的那些技术都是绕不开的。但是每一项技术深入到代码细节介绍的话是不现实的,而且也不是我们这篇文章的重点。    

所以下面我们先对用到的技术做一些原理介绍,了解他们是怎么工作的,不深入代码。对这些技术有基本的了解后,我们对整个分区挂载逻辑深入到代码级别去分析。

一、device-mapper基本原理介绍

1.1 dm工作原理

device-mapper是linux块设备映射技术框架。我们挂载用到的动态卷(逻辑分区),完整性校验(dm-verity),vab升级用到snapshot(快照)技术都离不开它。

它是在块设备上的一层映射,对外来看,它也是一个块设备(虚拟块设备)。它有三个重要概念,映射设备(mapped device),映射表(map table),目标设备(traget device)。这样说可能比较抽象。

我们可以类别成图书馆场景来了解一下它的工作原理。

图书馆中摆着一排排书,每一本书就像是我们块设备中的物理块。一排书架可以看做是一个块设备(由一个个物理块组成)。我们在存放书时,按照书的特性进行分类存放,例如经济类的书存放在一个书架,人文历史的书存放在另一个书架。这样的好处就是我们可以根据我们要拿书的种类去快速找到我们需要的书。    

9c0535a7801665dc6cd34c2a4ee4baa7.png

但是某一天图书馆的领导换了,他觉得根据书种类搜索的方法太单一了,还希望能通过书名首字母方式快速找到需要的书。这应该怎么解决呢?有一个办法就是再建一个图书馆,买一模一样的书。然后书架上按照首字母的方式排列。这样你想按首字母找也行,想按类型找也行,显然是不可能的。

这时候有一种解决方案就是建立了一个虚拟书架(虚拟块设备),这个虚拟书架对外来看,和其他书架没有区别,但是实际上面摆放的只是一张张小卡片,这些小卡片上面写的书名以及这本书真正摆放的物理位置(映射表),并且是按照拼音首字母排序的。当我们去这个书架上拿书的时候,图书管理员(device mapper驱动)会根据卡片上的内容,快速到这本书真正摆放的位置(目标设备),拿到真正的书,并给到我们。对我们来说,我们也可以在这个虚拟书架上拿到真正的书,所以用起来和其他实际的物理书架也没有什么区别。这个就是device-mapper的工作原理。

所以我们看到device-mapper工作只是增加了一层映射,对外来看它也是一个块设备,在实际访问时,device-mapper的区别会根据映射表,去真正块设备(目标设备)上的物理块上帮我们拿到需要的数据返回给我们。    

961a0392489df9a815f12ef8b23fddb3.png

Device-mapper使用起来也比较灵活

一个虚拟书架的目标设备也可以是另一个虚拟书架,这也很好理解,因为对外来看,虚拟书架和物理书架使用起来并没有什么区别。

也可以只映射一个物理分区的部分块,同样也可以将两个物理分区中的内容映射到同一个虚拟块设备中。后面的章节会介绍这种特性的使用。

dmctl list targets

可以查看dm设备支持的不同类型,后面章节介绍的一些功能其实都是不同dm设备类型的应用。dmctl list targets可以看到手机中支持的不同targets    

cdc6b7f0f8d4e7608f85310166ec99a0.png

手机中的dm设备及targets列表如下

dm设备名

target

my_engineering_a

linear

my_heytap_a

linear

system_ext_a

linear

userdata

default-key

vendor_dlkm-verity

verity

odm_a

linear

system_a

linear

vendor-verity

verity

vendor_dlkm_a

linear

vendor_a

linear

system_ext-verity

verity

my_region_a

linear

my_preload_a

linear

my_bigball_a        

linear

my_carrier_a

linear

odm_dlkm_a

linear

my_product_a

linear

product-verity

verity

system-verity

verity

product_a

linear

my_manifest_a

linear

my_company_a

linear

my_stock_a

linear

1.2 dm实现动态卷(逻辑分区)功能介绍(dm-linear)

上面一节介绍了device-mapper的基本原理,了解到device-mapper虚拟设备是通过映射表映射到目标设备的,那在映射物理设备时,是否可以只映射某个物理设备的部分块呢?答案是可以的,逻辑分区就是借助这个功能实现的

物理分区和逻辑分区有什么区别呢?简单理解如下

物理分区是SMT刷机的时候就规划好的,也就是我们常说的分区表里面划分的分区,物理分区大小是固定的,轻易不会改变

逻辑分区是一个动态卷的概念,一个物理分区可以划分为多个逻辑分区,并且这些逻辑分区的大小是随时可以改变的。    

/dev/block/bootdevice路径下面,sd*开头的都是一个个物理分区

/dev/block/by-name是对应的分区名称

b157b178b909be5ebb121cab22656d6c.png

我们在/dev/block/by-name下并没有看到system_ext,vendor之类的分区。是因为Android将system_ext,vendor这些分区看做逻辑分区,实际存放位置都存放在了super这个物理分区中。在机器开机时,借助device-mapper的技术,通过映射表将super分区中的system_ext.img,vendor.img映射成dm设备,之后再去挂载对应的dm设备(其实还有一个verity过程,之后我们再介绍,为了更好理解本解内容,我们先通过adb disable-verity跳过这个过程,重启手机)。

我们通过如下命令查看一下现在system_ext 和vendor的挂载结构

a.查看挂载点

mount | grep -e  “/system_ext” -e “/vendor ”

f409ba8d79343f35939c74569c0e23c4.png

可以看到/system_ext /vendor实际挂载的是dm这个虚拟设备,类似如下图    

4b5719a835acc6df36c4513d649890ae.png

b.查看dm devices

dmctl list devices 命令可以查看现在活动的dm设备

015c6b805ae94e36cc71f2d5a88dd38f.png

Dmctl getpath可以获取对应dm设备的路径

c323b00d62fb43d9cbed868e250eaf11.png

c.查看对应设备的映射表

dmctl table 命令可以查看现在dm设备的映射表内容

ddf7729433602b76d9ee74098aa3b55c.png    

以system_ext_a为例

输出内容含义如下

35e6f5f868f179dc68d350c911ff0ab5.png

super分区的设备号为8:6

6b944c20d8c73cfcadf2549f335e64c4.png

所以根据上面信息,可以看到system_ext_a为dm映射出来的虚拟设备,这个虚拟设备的LBA 0到2048144 线性映射了super这个物理分区,起始LBA地址为1347584的块(super物理分区)

可以得出如挂载图    

bdbdc22961f0b9732d1322893f47eb19.png

以及如下映射图

e4344454e0b5917af8ccc89b971a7137.png

LBA为逻辑地址,单位应该为512字节,我们来验证一下    

super分区的LBA:1347584开始的内容是否和映射出来的虚拟dm-1设备完全一样

取super分区LBA:1347584 开始的1024*512字节的内容与dm-1 LBA:0 开始的1024*512字节的内容对比

dd if=/dev/block/by-name/super of=/sdcard/super_1347584.bin bs=512 count=1024 skip=1347584

dd if=/dev/block/dm-1 of=/sdcard/dm_1.bin bs=512 count=1024

可以看到内容是完全一样的

ce3ee9b310a6098518313e7d514c3a9d.png

1.3 dm 实现完整性校验功能介绍(dm-verity)

在之前一节介绍动态卷功能时,我们执行了一条命令adb disable-verity。这条命令的作用是关闭dm-verity 功能。那这个功能又是做什么的呢?

dm-verity是Device mapper架构下的一种目标设备类型,该功能提供对块设备的透明完整性检查。

他工作原理如下

dm设备会把它自己按照4K单位进行分割,之后计算每个4K数据的hash值并保存起来,生成第0层,之后再把第0层的hash值放在一起,依旧按照4K大小继续计算hash值。生成第1层,以此类推,之后计算成一个root hash值。

Hash tree是保存在对应的分区中的(system,vendor等)。root hash保存到另一个有签名认证的分区中(vbmeta_system,vbmeta_vendor分区)。在开机引导的时候,会对root hash只的vbmeta分区进行签名校验,保证root hash是没有被篡改的。进一步保证整个用于校验的hash tree没有被篡改。

在运行时,当访问到某个块时,会计算这个块的hash值,之后与hash tree中的hash值对比,以此保证镜像的完整性。

0722cf22bae6dcf16dca3b08d84e31cb.png

下面我们依旧以vendor分区为例,看看整个挂载的结构是什么样的(没有执行adb disable-verity)

先看看vendor分区挂载的是哪个块设备

mount | grep "/vendor "

f628ec0dc9306123e85b4eefca0777d7.png

Vendor分区挂载的是dm-20设备,对应的dm名称为vendor-verity

dmctl getpath vendor-verity    

ce6e8f3c023d1d1792ac53c46258a187.png

dm-20的目标设备是另一个dm设备,这个也很好理解,dm设备对外来看,也是一个块设备,使用起来和其他块设备没有什么区别,当然在dm映射的时候,目标设备也可以是另一个dm设备

dmctl list devices

dmctl getpath vendor-verity

dmctl getpath vendor_a

dmctl table vendor-verity

dmctl table vendor_a

c6090ecd85666b40e0f2a3564a8e654a.png

按照上面我们查看的结果,可以得出如下的vendor分区挂载结构。

a6f1cb6aac32c787d100fc3136851619.png

1.4 元数据加密(default-key)

在第一节中,我们看到了userdata dm设备的target类型为default-key。这个又是什么功能呢?    

7e3af2697f3ffe22c086759f76787491.png

default-key 为元数据加密功能。

Userdata存放的是用户的敏感数据,需要进行加密保护。例如我们知道的之前android使用的FDE(全盘加密),到现在使用的FBE(文件系统级加密,后面章节会介绍)。FBE从字面意思也可以看得出,加密的内容是文件内容和文件名,但是其他信息(例如目录布局、文件大小、权限和创建/修改时间)不会被加密。这些其他信息统称为“文件系统元数据”。

借助dm-default-key,就可以对这些”元数据进行加密”。在元数据密钥可用前 ,userdata分区中的所有内容均是无法读取的。所以元数据密钥不能存放在userdata分区,而存放在metadata这个分区中。

在开机过程中,会先挂载metadata分区,之后使用metadata分区中保存的key对userdata先进行元数据解密。然后就可以进行挂载了。(userdata分区元数据加密只能在分区首次进行格式化时设置)

1.5 dm实现快照功能介绍(snapshot,snapshot-origin,dm-snapshot-merge,bow)

dm框架的使用十分灵活,除了上面提到的将一个物理设备的部分区域,映射成一个dm设备。同样也可以将两个物理分区中的内容映射到同一个虚拟块设备中。

类似如下结构    

7be810bcfbdd6084a386e1931d0c0036.png

这样对外来看,是一个独立的块设备,但实际这个块设备中存放的内容,可能是分散在多个不同的物理分区中。

VAB(虚拟A/B分区)升级时,就借助的这一特性。

在A/B分区刚出来时,A/B分区是实实在在的两个物理分区。例如system分区需要2GB,那A/B架构就需要预留两个system分区作为互为备份升级。这种对空间的利用率是极低的。

vab架构就很好的解决了这个问题。

Vab应用了dm-snapshot技术,下面是google文档中的一些说明

使用 dm-snapshot 时,会用到以下四个设备

  • 基础设备是被捕获快照的设备。在此页面上,基础设备始终是动态分区,例如 system 或 vendor。

  • 写入时复制 (COW) 设备,用于向基础设备记录更改。 该设备的大小没有限制,只要足够容纳对基础设备的所有更改即可。    

  • 快照设备,这是使用 snapshot 目标创建的设备。需向快照设备写入的内容将写入 COW 设备。需从快照设备读取的内容将从基础设备或 COW 设备读取,具体取决于所访问的数据是否经过快照更改。

  • 源设备,这是使用 snapshot-origin 目标创建的设备。需从源设备读取的内容将直接从基础设备读取。需向源设备写入的内容将直接写入基础设备,但原始数据将通过写入 COW 设备进行备份。

e8fa1ce4bf6c71daf3f0bf9c77dd39e6.png

二、vold介绍

2.1 vold结构总览

Vold(volume Daemon),既Volume守护进程,这个守护进程作为kernel 和framework之间的桥梁,主要处理如下内容

  • 开关机过程中各分区的挂载/卸载

  • 外部T卡/OTG设备的文件系统挂载/卸载

在mount过程中,涉及的文件系统级加解密(FBE),元数据(metadata)加解密,文件节点创建等也由Vold的进行控制    

Vold架构在整个系统中的位置如下

f91333d8650d29c7750e7e9d57a9a70e.png

Vold服务是通过rc文件的方式启动的

system/vold/vold.rc文件中定义了vold service

f8587cc04fb21adf2ef9af9628575b45.png

之后在init.rc中,early-fs阶段,start vold启动

c9134edb6aa4d73caae5d47d71e191f8.png

Vold相关代码在如下路径下

system/vold/main.cpp

VoldNative service主要处理那些内容呢?

a.StorageManagerService/vdc等通过IVoldListener下发的各种操作命令,从VoldNativeService.h 文件中,我们可以找到VoldNativeService可以处理的操作

80804edb10a486957fe5041cec6c61e5.png

之后的章节,我们会选取一些日常工作中常用到的处理命令,对这些命令提供的能力(机制)进行深入分析,具体这些能力如何使用,在哪里使用等”策略”在后面的章节中讨论

VoldNativeService处理的命令

主要功能总结说明

encryptFstab

生成元数据加密的key,然后对指定分区进行元数据加密,生成对应的dm设备(default-key),之后挂载这个dm设备

mountFstab

encryptFstab类似,不过不需要重新生成key,去指定的路径读取存放的元数据解密key,根据key生成dm设备(default-key),之后挂载这个设备        

fbeEnable

如果是首次开机

创建 System DE Master Key 和生成 System DE Encryption Policy

后续每次开机

加载System DE Master Key,准备System DE Master storage

每次开机还会创建并加载一个新的per_boot Master Key,准备per_boot Master storage。

initUser0

如果是首次开机(根据user DE key存放路径判断),

创建 User 0 DE Master Key 和生成 User 0 DE Encryption Policy;

创建 User 0 CE  Master Key 和生成 User 0 CE Encryption Policy;

创建 User 0 DE Storage,并为这些文件夹设置 User 0 DE Encryption Policy

后续开机

加载 User 0 DE Master Key

准备 User 0 DE Storage,并校验文件夹 Encryption Policy

2.2 encryptFstab(元数据加解密)

011bc07fc57d03358ca160a64d383169.png

f6336f416d45690ba3834f5e83e9256a.png

主要处理函数为

fscrypt_mount_metadata_encrypted。这个函数重点流程如下

重点流程一:

从fstab中读取传入的mount_point,获取挂载点的相关配置参数。然后解析成用于加密的options

主要函数

auto data_rec = GetEntryForMountPoint(&fstab_default, mount_point);

if (!parse_options(data_rec->metadata_encryption_options, &options)) return 

false;         

重点流程二:

在dm设备介绍时,我们了解到了target类型为default-key的dm设备。这类型设备是基于原数据加密的,在metadata解密前,被加密的分区是无法被访问的。所以用于加解密的key就不能存放在被加密的分区。所以encryptFstab进行元数据加密的时候,需要根据指定存解密key的文件夹路径,也就是metadata_key_dir。
这里的重点流程就是根据(needs_encrypt 参数)传入的needs_encrypt参数,判断是新创建一个key还是使用已经存在的key。
read_key函数会处理这两者的区别
情况一,需要进行元数据加密(needs_encrypt=true),那就创建一个KeyGeneration,然后存放到metadata_key_dir下。encryptFstab流程中,needs_encrypt=true,就是这种情况
情况二,不需要进行元数据加密,就会去指定目录读取对应的key,并返回
关键处理函数如下
auto gen = needs_encrypt ? makeGen(options) : neverGen();
KeyBuffer key;
if (!read_key(data_rec->metadata_key_dir, gen, &key))

重点流程三:

前面流程走完以后,我们已经获取到用于元数据加解密的key了(无论是新生成的还是已存在的)。下一步就是创建default-key类型的dm设备

主要处理函数    

if (!create_crypto_blk_dev(kDmNameUserdata, blk_device, key, options, &crypto_blkdev,
&nr_sec)) {
这个函数主要的作用就是

重点流程四:

此时我们已经获得了一个已经完成元数据解密的dm设备了。如果这个dm设备是新创建的(needs_encrypt=true),那就需要根据should_format参数决定是否要格式化这个dm设备了。为什么要格式化它的?

原因是如果一个分区刚刚完成needs_encrypt加密,那这个分区之前保存的任何数据也是无意义的。所以我们可以选择在完整元数据加解密后的第一时间,针对已解密的dm设备进行格式化。

之前dm设备(default-key)介绍时也提到过,数据加密只能在分区首次进行格式化时设置。原因也是一样的。加密会让目标设备中保存的数据无效。

主要代码也比较简单,就直接针对解密后的dm设备进行格式化就可以    

217a4bd23469880da5c072b894c275f0.png

重点流程五:

历经前面的步骤,我们获得了一个解密后的dm设备,并且用于解密的key也保存到了指定的metadata_key_dir目录下。下一步就针对dm设备进行挂载了

主要的处理函数是:

mount_via_fs_mgr(mount_point.c_str(), crypto_blkdev.c_str());

 这个和其他普通分区挂载一样,主要是判断一些挂载参数,最后通过mount进行挂载。就不在这里展开了

2.3 mountFstab(挂载分区)

MountFstab处理函数与encryptFstab 基本一样,只是传入的参数有一些区别。流程和encryptFstab是类似的,只是不去重新创建元数据加密的key,而是直接去指定路径去读取用于元数据解密的key,之后同样是生成dm设备,然后挂载。这里就不再赘述。重点流程可以看encryptFstab 一章    

3b05cd6a80c6f0396591099afa9b2b26.png

2.4 fbeEnable(使能fbe加密)

d30b728af1f9d4aaae2e38b9e06258c3.png

主要处理函数为

fscrypt_initialize_systemwide_keys。这个函数重点流程如下

重点流程一:

设备第一次启动时:

创建 System DE Master Key 和生成 System DE Encryption Policy;

把 System DE Encryption Policy 保存到文件  /data/unencrypted/ref;

后续每次启动时:

加载 System DE Master Key;

主要的函数为

rtn = retrieveOrGenerateKey(device_key_path, device_key_temp, kEmptyAuthentication,
makeGen(options), &device_key);

重点流程二:    

拿到System DE master Key之后,就会使用加载起来的System DE Master Key,安装到对应的/data目录,注意此时并不是真正的解密,解密是在对文件进行I/O操作的时候才会去解密。安装可以理解为我们把账key交给将来负责解密的管理员了。之后如果有用户要对 System DE Storage路径下的文件进行操作,就可以使用安装好的key来完成真正的解密

主要的函数为

install_storage_key(DATA_MNT_POINT, options, device_key, &device_policy);

之后会将key的参考信息及加密策略都存放到/data/unencrypted/ref下,供后续需要设置加密策略的流程中快速读取使用        

重点流程三:

上面提到的System DE加密是在首次开机时完成的加密,只要不恢复出厂设置这个key就一直不会改变。

除了System DE加密,fscrypt_initialize_systemwide_keys还会安装一个每次开机使用的key

per_boot_key。

主要处理函数为:

rtn = generateStorageKey(makeGen(options), &per_boot_key);

rtn = install_storage_key(DATA_MNT_POINT, options, per_boot_key, &per_

boot_policy);

同样会把per_boot_key的参考信息及加密策略都存放到/data/unencrypted/per_boot_ref下,供后续需要设置加密策略的流程中快速读取使用        

2.5 initUser0

381b72fa71ede34228705fb347f7c2f8.png

这个函数的主要作用就是针对user0用户,进行fbe加解密初始化。主要处理函数为fscrypt_init_user0。下面就分析一下这个函数的重点流程

重点流程一:

准备好存放User.0 DE Storage和User.0 CE Storage key的路径

“/data/misc/vold/user_keys/de”

“/data/misc/vold/user_keys/ce”

之后根据/data/misc/vold/user_keys/de 是否存在,来判断机器是否是首次开机

/data/misc/vold这个路径System DE加密的,在之前提到的流程中已经对这个目录安装好所需的解密keyring,此时已经可以正常访问这个目录下的路径了。在后续的挂载流程分析中,会详细分析这些流程。此时可以认为/data/misc/vold已经完成解密就可以了

情况一:/data/misc/vold/user_keys/de 路径不存在(首次开机)

调用create_and_install_user_keys

通过generateStorageKey生成两个key(de_key,ce_key)    

storeKeyAtomically将两个key(de_key,ce_key)存放到对应路径下()/data/misc/vold/user_keys/de,/data/misc/vold/user_keys/ce

之后调用install_storage_key 给指定目录安装对应的storage Encryption Policy

情况二:/data/misc/vold/user_keys/de存在(非首次开机)

正面de_key和ce_key之前已经生成了,就不需要生成了,直接走下面的流程就可以

重点流程二:

至此,两个key已经正常存在了,下一步就是load_all_de_keys,安装de_key

主要处理函数:

if (!load_all_de_keys()) return false;

重点流程三:

fscrypt_prepare_user_storage("", 0, 0, android::os::IVold::STORAGE_FLAG_DE)

准备user 0的DE 目录,并给对应的目录指定Encryption Policy(加密策略)    

d23f3a03182700bff91f941d1150dff2.png

2.6 unlockUserKey

d9016af7765236d308ea8b1fe85ed550.png


 由于篇幅过长,第三、四章节将在下周发布~

Linux内核并发与同步机制解读(arm64)上

Linux内核并发与同步机制解读(arm64)下

crash实战:手把手教你使用crash分析内核dump

89cb654c3892a1dce0e14325c0e77b90.gif

长按关注内核工匠微信

Linux内核黑科技| 技术文章| 精选教程

  • 28
    点赞
  • 52
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

内核工匠

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值