Android 动态分区详解(一) 5 张图让你搞懂动态分区原理

36 篇文章 124 订阅 ¥69.90 ¥99.00

Android 动态分区详解

0. 导读

本文主要包含动态分区的物理数据布局,内存核心数据结构和动态分区映射示例 3 个部分。

  • 如果你对动态分区没啥概念,建议直接从头阅读
  • 如果只关心动态分区在设备上是如何存储的,请跳转到第 3 节
  • 如果只关心动态分区在内存中的数据结构,请跳转到第 4 节
  • 如果想看动态分区生成和映射的大致过程,请跳转到第 5 节

1. 动态分区详解的背景

1.1 背景

之前一直没有去了解动态分区,但随着最近重新研究 Android OTA 升级,发现动态分区是一个绕不过去的坎。

从 Android Q 引入动态分区,到 Android R/S 在动态分区之上增加虚拟分区管理, OTA 升级时需要对分区变更进行处理,不了解动态分区就无法深入 Android OTA 升级。

因此最近花了些时间阅读代码,学习 Android 动态分区。学习在 linux device mapper 机制之上,Android 系统对动态分区的各种操作处理。因此,这一系列没有包含 linux 底层 device mapper 驱动解读,后面看情况会考虑深入。

目前这几篇文章的大致规划如下,后续可能会有变动:

  1. Android 动态分区详解(一): 5 张图让你搞懂动态分区原理
  2. Android 动态分区详解(二): 相关工具介绍
  3. Android 动态分区详解(三): 动态分区配置和 super.img 的生成
  4. Android 动态分区详解(四): 动态分区的加载
  5. Android 动态分区详解(五): OTA 中对动态分区的处理

1.2 动态分区的本质

请原谅我总是忍不住想说一些空洞的废话。

动态分区管理的本质是什么?

分区数据是对磁盘上数据分布的描述,系统挂载磁盘时读取数据,在内存中建立相应数据结构,用于后续对磁盘进行管理。可见这里需要先提前生成分区数据,然后读取数据,一旦分区数据写入后就固定了。

动态分区,顾名思义就是分区是动态的,不是一成不变的,可以根据需要改变。动态分区管理的本质就是对分区数据的增删改查操作,操作的对象就是动态分区描述数据 metadata。

围绕 metadata 数据进行增删改查,就是 Android 动态分区管理的核心内容:

  • 增: metadata 是如何生成的?
  • 删: 没有删除 metadata 的需求,不需要删除操作
  • 改: OTA 升级时是如何修改 metadata 的?
  • 查: 系统如何读取(查询) metadata 数据并进行解析,并基于解析结果创建分区?

2. Linux device mapper 驱动

Android 动态分区最底层基于 Linux 的 device mapper 机制,将 super 分区的各个不同部分映射成 device mapper 的 linear 设备。因此,肯定有人会问,学习动态分区前需要先学习 device mapper 驱动吗?

答案是不需要,我就深入没有学习过 device mapper 驱动,但并不妨碍我对 Android 动态分区管理的理解。

注意:

我强调的是对Android 动态分区管理的理解,而不是动态分区底层机制的理解,如果你想知道动态分区最底层实现的细节,还是应该去读一读 device mapper 驱动。

Linux device mapper 相关驱动位于 drivers/md 目录下。

当然,在理解动态分区管理之前,能知道一些 device mapper 的基本原理就更好,我这里所谓的基本原理,是你能看懂下面这张图:

Device mapper 内核中各对象的层次关系

图 1. Device mapper 内核中各对象的层次关系

说下图 1 的重点:

  • 虚拟设备 Mapped Device 基于驱动 Target Driver和内部的映射表 Mapping Table 来实现
  • 一个虚拟设备 Mapped Device 可以由一个或多个 Target Device 映射组成,参与映射的 Target Device 本身也可能是虚拟设备

所以,一个设备可能是真实的,也可能是虚拟的。对虚拟设备的访问会被 Target Driver 拦截,然后通过 Mapping Table 转发给另外一个设备。例如,在 Android Q 上,对 system_avendor_a 分区的访问被驱动拦截并转发为对 super 分区某个区域的访问。而这里的拦截以及转发对用户是透明的,用户根本不知道,也不需要知道他最终访问的到底是哪个设备。

如果不打算读 device mapper 驱动代码,但又希望在总体上对 device mapper 有比较全面的认识,推荐阅读《(转载)device mapper原理》这篇文章。

这篇文章源自 IBM 《Linux 内核中的 Device Mapper 机制》: https://www.ibm.com/developerworks/cn/linux/l-devmapper/,但该文的原始内容已经不可访问。

3. Android 动态分区布局

3.1 动态分区布局

Android 从 Q 10 开始引入动态分区 super,将原来的 system_a, system_b, vendor_a, vendor_b 等打包到到这个分区中。下面是 Android 官方的分区转换布局图:

这个图全网随处可见,好吧,我承认我绕不开这张图了~

传统分区转换为动态分区布局

来源: https://source.android.google.cn/devices/tech/ota/dynamic_partitions/implement

图 2. 转换为动态分区时的新物理分区表布局

既然新的 super 分区内部包含了多个原来的分区,那要如何才能识别这些内部的分区呢?答案就是引入数据来描述 super 分区的布局。

于是,在 super 分区开头存储用于描述分区布局的 metadata,和磁盘开头存储用于描述磁盘分区布局的 gpt 数据的道理一样。系统加载动态分区时读取 metadata,对其进行解析,在内存中建立 super 分区布局描述的 LpMetadata 结构体,就知道内部的各个分区都位于哪个地方。

换句话说,metadata 就是 LpMetadata 结构在 super 分区上的物理存储数据,而 LpMetadatametadata 在内存中的数据结构。LpMetadata中的信息会被转换成图 1 中的映射表Mappting Table, 基于这个映射表,super 分区对应设备/dev/block/by-name/super 的不同部分被映射成多个虚拟设备,如/dev/block/mapper/system_a, /dev/block/mapper/vendor_a 等。

3.2 metadata 数据布局

在开始研究内存数据结构 LpMetadata 之前,先从宏观上看下物理存储的 metadata 是怎样的。

下图是我基于 Android 官方的布局图 (图 2. 转换为动态分区时的新物理分区表布局),对 metadata 部分细化后的的结构示意图。

安卓动态分区布局及其 metadata

图 3. 安卓动态分区布局及其 metadata

对 metadata,可以分层 3 个层次,见图 2 最右侧的 3 列:

第 1 层,super 分区(宏观)

  • super 分区头部存放描述分区内部布局的 metadata 数据
  • metadata 数据之后 1MB 对齐开始的地方依次存放两组槽位(slot)的分区数据

第 2 层,metadata 数据(中观)

  • metadata 数据开始前预留了 4K 的空间,所以 metadata 数据从 4K 的偏移位置开始

  • metadata 数据由 Geometry 和 Metadata 两部分组成

    • Geometry 大小为 4K,数据除自身外,还有个一个备份,紧挨着 Geometry 存放
    • Metadata 大小 64K,按槽位(slot)存放,每个槽位有一份 Metadata 数据,所有槽位的 Metadata 数据结束后开始存放其备份数据
    • 分区加载时,根据分区对应槽位,读取相应槽位的 Metadata 和其备份数据

    为什么槽位有两个,但是这里的 Metadata 有 3 份呢?

    我一开始也有这个疑惑,经过核对传递给 lpmake 的参数后发现确实是 3 份。

    在 Android 编译生成 super.img 时,传递给 metadata 生成工具 lpmake 的参数 “--metadata-slots 3”,所以 Metadata 有 3 份。lpmake 工具只不过是一个干活的苦逼,上层的编译系统下指令说生成几份 Metadata 就生成几份。

    至于为什么要传递 “--metadata-slots 3” 而不是 “--metadata-slots 2” 给 lpmake ?那就是 builder_super_image.py 脚本的事情了,相关代码如下:

    def BuildSuperImageFromDict(info_dict, output):
      cmd = [info_dict["lpmake"],
             "--metadata-size", "65536",
             "--super-name", info_dict["super_metadata_device"]]
      ...
      if ab_update and retrofit:
        cmd += ["--metadata-slots", "2"]
      elif ab_update:
        cmd += ["--metadata-slots", "3"]
      else:
        cmd += ["--metadata-slots", "2"]
    

    只有当 retrofit 为 true 时才为 2,为什么要这么做,去问谷大爷吧~

  • 第 3 层,Geometry 和 Metadata(微观)

    • Geometry 内部是大小为 58 字节的 LpMetadataGeometry 结构数据,填充到 4K 大小
    • Metadata 内部包含 LpMetadataHeader, LpMetadataPartition, LpMetadataExtent, LpMetadataPartitionGroup, LpMetadataBlockDevice 等数据结构,填充到 64K 大小

    至于 Geometry 和 Metadata 内部每个数据结构的具体字段,请参考下一节的介绍。

3.3 metadata 数据小结

  • super 分区前 4K 为保留数据,随后是 metadata 数据,从 1M 偏移开始的地方存储内部分区的 image 数据,每个内部分区的 image 存放位置都按照 1MB 对齐,因此 metadata 位于 super 分区 4KB ~ 1MB 的范围内

  • metadata 包括 Geometry 和 Metadata 两个部分,每个部分都有自己的 Primary 和 Bakcup 两组相同的数据,存放顺序是 Geometry(Primary), Geometry(Backup), Metadata(Primary), Metadata(Backup)。

  • 对于 Metadata,其内部又有 3 份数据,分别对应于 Slot 0, Slot 1, Slot 2。当 Slot 0 启动时,读取 Slot 0 对应的 Metadata, 当 Slot 1 启动时,读取 Slot 1 对应的 Metadata,至于多出来的 Slot 2 什么时候会使用,目前没有系统研究。

    所以,通常情况下,包括 Primary 和 Backup 数据一起,磁盘上可能有 6 份 Metadata。

  • OTA 升级时,如果当前在 Slot 0 上运行,要更新 Slot 1 分区,则在更新之前 Slot 1 前会更新 Slot 1 对应的 Metadata 以反应 Slot 1 的实际布局,反之亦然。

4. Android 动态分区的核心数据结构

上一节说完 Android 动态分区描述数据在 super 分区的分布情况,这一节深入研究 metadata 具体的数据结构,不清楚的请转到 “3.3 metadata 数据小结” 回顾一下。

动态分区的核心数据结构主要定义在以下文件中:

  • system/core/fs_mgr/liblp/include/liblp/metadata_format.h
  • system/core/fs_mgr/liblp/include/liblp/liblp.h

4.1 LpMetadata 结构

启动加载动态分区前,会读取动态分区的 metadata,解析 Geometry 数据,读取当前 Slot 对应的 Metadata,在内存中建立 LpMetadata 结构。

LpMetadata: Logical Partition Metadata

LpMetadata 的定义如下:

/* file: system/core/fs_mgr/liblp/include/liblp/liblp.h */
struct LpMetadata {
    LpMetadataGeometry geometry;
    LpMetadataHeader header;
    std::vector<LpMetadataPartition> partitions;
    std::vector<LpMetadataExtent> extents;
    std::vector<LpMetadataPartitionGroup> groups;
    std::vector<LpMetadataBlockDevice> block_devices;
};

为了方便查看,我花了些时间画了个 LpMetadata 的结构示意框图:

动态分区核心数据结构 LpMetadata

图 4. 动态分区核心数据结构 LpMetadata

LpMetadata 主要分成两个部分,描述 metadata 整体结构的 geometry 和随后描述 metadata 细节部分,包括 header, partitions, extents, groups, block_devices。

接下来详细分析每一部分的结构。

4.2 LpMetadataGeometry 结构

LpMetadataGeometry 结构中最主要的数据有 3 个:

  • metadata_max_size: 单个 Slot 对应的 Metadata 数据大小,默认是 64K
  • metadata_slot_count: 单组 Metadata 包含的 Metadata 数量,加上备份的 Metadata,所以分区一共有 metadata_slot_count * 2 个 Metdata 数据
  • logical_block_size: 逻辑分区大小,确保各分区映射的原始数据按照 logical_block_size 大小对齐

4.2 LpMetadataHeader 结构

LpMetadataHeader 结构的 major_versionminor_version 字段定义了 metadata 数据结构的版本信息,目前 Android Q 版本为 10.0, Android R 和 Android S 的版本为 10.2,新版本在 header 的结尾添加了 flags 字段,并将 header 数据填充到 256 字节,两个版本之间的差别如下:

  • 版本 v10.0, 系统 Android Q, header 大小为 128 字节
  • 版本 v10.2, 系统 Android R/S, header 大小为 256 字节
    • 新增 32 位的 flags 字段,另新增填充 124 字节

除了版本信息之外,LpMetadataHeader 最主要的还通过 LpMetadataTableDescriptor 子结构指出了随后的 partitions, extents, groups, block_devices 等数据所在的偏移位置(offset), 数量(num_entries) 以及单个数据的大小(entry_size) 等信息。

4.3 LpMetadataPartition 结构

LpMetadataPartition 结构存储了动态分区内部的分区信息,可以理解为动态分区的内部分区表。

除了 nameattributes 字段分别表示分区名字和属性外,group_index用于表示分区所属的组别。

first_extent_indexnum_extents 分别用于表示该分区第一个映射的 extents 的索引值,以及随后需要映射的 extents 数量,不过 Android super 分区内单个 image 连续存放,对应于 1 个 extent,所以相应分区的 num_extents 值为 1。

如果 system_a 的 image 在 super 分区内分两段存放,则此时针对这一单一的 image 就会有两个 extents, LpMetadataPartition 结构中的 num_extents 为 2。如果一下理解不了这两个字段到底是如何使用的,参考第 “5. Android 动态分区映射示例” 节关于具体映射示例的说明。

4.4 LpMetadataExtent 结构

LpMetadataExtent 结构中保存了 super 分区内部单个分区(如 system_a) 在 super 内的信息。如果 super 分区中只包含了 syteam_avendor_a 的 image,则会有相应的两个 extent 数据;如果分区中 system_a, system_b, vendor_a, vendor_b 的 image 都存在,则会有四个 extent 数据。

在 Android 动态分区中,单个 extent 的长度按照 512 byte 的 sector 为单位进行计算。

LpMetadataExtent 结构的成员有:

  • num_sectors: 当前 extent 对应 image 包含的 sector 数量
  • target_type: 当前 extent 对应 image 映射的虚拟设备的类型,对于 Android 动态分区,其值为 0 (类型为 linear),即线性映射。
  • target_data: 当前 extent 对应 image 的在动态分区内的偏移地址(按 sector 数量计算)
  • target_source: 当前 extent 对应的 image 位于哪个动态分区设备中, 对应于 block_devices 数组的索引,Android 系统中默认只有一个设备 super,所以 block_devices 数组只有 1 个元素, 因此 target_source 取值为 0。如果你的动态分区中不只有 super 一个设备,则这里的 target_source 可能会是其他值。

4.5 LpMetadataPartitionGroup 结构

LpMetadataPartitionGroup 结构记录了动态分区内部的 group 信息,包括 group 的名称(name),最大长度(maximum_size) 和一些标识(flags) 信息。

4.6 LpMetadataBlockDevice 结构

LpMetadataBlockDevice 结构记录了动态分区内的 device 信息,包括:

  • 动态分区内数据 image 的第一个 sector 的起始信息(first_logical_sector)
  • 动态分区内每个数据 image 的对齐信息 alignmentalignment_offset
  • 动态分区名称 partition_name 和属性 flags

对于默认的 super 动态分区,则 :

  • first_logical_sector = 2048, 即 1MB offset

  • alignment = 1024576, alignment_offset = 0,每个 image 按照 1MB 的边界进行对齐

  • size, super 分区大小,按照 byte 进行计算

  • partition_name = “super”

  • flags, super 分区的设备属性信息,参考各种 LP_BLOCK_DEVICE_* 标识

5. Android 动态分区映射示例

这里以 Broadcom 平台某个产品在 Android Q 下 super 分区的生成和加载作为示例演示动态分区是如何生成,又是如何映射为虚拟设备的。

总体而言,该流程如下面的 图 5 所示,后面会对这个流程的各个部分进行解释:

安卓动态分区映射转换示例

图 5. 安卓动态分区映射转换示例

5.1 super.img 的编译和生成

在 Android 的编译过程中,系统通过脚本 build/tools/releasetools/build_super_image.py 内部去调用 lpmake 工具生成 super.img 文件。

所以,在编译的 log 中查找 lpmake 就直接看到系统是如何去生成 super.img 的。

$ source build/envsetup.sh
$ lunch inuvik-userdebug
$ make PRODUCT-inuvik-userdebug dist -j40 2>&1 | tee make.log
$ grep -ni "lpmake" make.log

实际上系统中会多次调用 lpmake 去生成相应的 super.img,这几个操作间有什么区别留给你去发现了~这里不再赘述,只选取其中一个生成 super.img 的命令:

$ grep -ni lpmake make.log 
979:2022-02-28 12:52:57 - common.py - INFO    :   Running: "lpmake --metadata-size 65536 --super-name super --metadata-slots 3 --device super:3028287488 --group bcm_ref_a:1509949440 --group bcm_ref_b:1509949440 --partition system_a:readonly:1077702656:bcm_ref_a --image system_a=out/target/product/inuvik/system.img --partition system_b:readonly:0:bcm_ref_b --partition vendor_a:readonly:104992768:bcm_ref_a --image vendor_a=out/target/product/inuvik/vendor.img --partition vendor_b:readonly:0:bcm_ref_b --sparse --output out/target/product/inuvik/super.img"

整理一下这里对 lpmake 的调用,如下:

lpmake --metadata-size 65536 --super-name super --metadata-slots 3 \
    --device super:3028287488 \
	--group bcm_ref_a:1509949440 --group bcm_ref_b:1509949440 \
	--partition system_a:readonly:1077702656:bcm_ref_a \
	--image system_a=out/target/product/inuvik/system.img \
	--partition system_b:readonly:0:bcm_ref_b \
	--partition vendor_a:readonly:104992768:bcm_ref_a \
	--image vendor_a=out/target/product/inuvik/vendor.img \
	--partition vendor_b:readonly:0:bcm_ref_b \
	--sparse --output out/target/product/inuvik/super.img

所以,完全可以不用管安卓下的各种动态分区配置,直接用这样的命令调用 lpmake 生成 super.img。

5.2 super.img 的解析

由于上一节生成 super.img 的命令中带有 --sparse 选项,所以生成的 super.img 为 sparse image 格式,在解析前需要使用工具 simg2img 将其转换成 raw 格式。

$ simg2img out/target/product/inuvik/super.img out/super_raw.img

拿到 super_raw.img 以后就可以使用各种工具甚至手动进行解析了,这里我直接使用 lpdump 进行解析,结果如下:

$ lpdump out/super_raw.img 
Metadata version: 10.0
Metadata size: 592 bytes
Metadata max size: 65536 bytes
Metadata slot count: 3
Partition table:
------------------------
  Name: system_a
  Group: bcm_ref_a
  Attributes: readonly
  Extents:
    0 .. 2104887 linear super 2048
------------------------
  Name: system_b
  Group: bcm_ref_b
  Attributes: readonly
  Extents:
------------------------
  Name: vendor_a
  Group: bcm_ref_a
  Attributes: readonly
  Extents:
    0 .. 205063 linear super 2107392
------------------------
  Name: vendor_b
  Group: bcm_ref_b
  Attributes: readonly
  Extents:
------------------------
Block device table:
------------------------
  Partition name: super
  First sector: 2048
  Size: 3028287488 bytes
  Flags: none
------------------------
Group table:
------------------------
  Name: default
  Maximum size: 0 bytes
  Flags: none
------------------------
  Name: bcm_ref_a
  Maximum size: 1509949440 bytes
  Flags: none
------------------------
  Name: bcm_ref_b
  Maximum size: 1509949440 bytes
  Flags: none
------------------------

对 super.img 进行简单的解析,lpdump 工具已经足够了。如果不能满足要求,也可以自己使用其它方式解析。

重点说下解析的结果:

  • 1 个 device: super
  • 3 个 group: “default”, “bcm_ref_a” 和 “bcm_ref_b”
  • 4 个 partition: “system_a”, “system_b”, “vendor_a”, “vendor_b”, 其中 “system_b” 和 “vendor_b” 在 super.img 中没有镜像文件,因为没有将这两个分区的 “image” 参数传递给 lpmake 命令
  • 2 个 extent: 分别对应于 “system_a” 和 “vendor_a” 的镜像文件,lpdump 的结果直接将 extents 结果附加到对应分区了,所以没有单独列出 extents

解析结果我已经详细列出来放到前面的 图 5 中了。

Android代码中,这部分操作通过调用库 liblp 内的函数完成,结束后在内存中生成 LpMetadata 结构数据。

5.3 super.img 的映射

在解析 super.img 生成 LpMetadata 结构以后,libdm 库内的函数会基于分区 partitions 和 条带 extents 内的信息创建映射表。

对于 system_avendor_a 分区,分别有以下映射:

/* system_a */
{
    0, 2104888,
    "/dev/block/by-id/super",
    2048
},

/* vendor_a */
{
    0, 205064,
    "/dev/block/by-id/super",
    2107392
}

换个表述方式就是:

  • 将 “/dev/block/by-id/super” 分区的 2048 开始的 2104888 个 sector 映射到 “/dev/block/mapper/system_a” 设备的 0 位置。
  • 将 “/dev/block/by-id/super” 分区的 2107392 开始的 205064 个 sector 映射到 /dev/block/mapper/vendor_a 设备的 0 位置。

然后 linux 系统的 device mapper 驱动基于每个设备的映射表生成相应的虚拟设备,这样就可以和虚拟出来的 “system_a”, “vendor_a” 分区尽情的玩耍了,而不用管这些设备到底是真实的还是虚拟的。

6. 总结

画图和组织文字前后花了几天时间,终于快写完了,到总结时还真没想好怎么用几句话把动态分区给总结出来,这里就列举一些重点吧。

6.1 关于 super 分区的布局

  • super 分区前 4K 为保留数据,随后是 metadata 数据,从 1M 偏移开始的地方存储内部分区的 image 数据,每个内部分区的 image 存放位置都按照 1MB 对齐,因此 metadata 位于 super 分区 4KB ~ 1MB 的范围内

  • metadata 包括 Geometry 和 Metadata 两个部分,每个部分都有自己的 Primary 和 Bakcup 两组相同的数据,存放顺序是 Geometry(Primary), Geometry(Backup), Metadata(Primary), Metadata(Backup)。

  • 对于 Metadata,其内部又有 3 份数据,分别对应于 Slot 0, Slot 1, Slot 2。当 Slot 0 启动时,读取 Slot 0 对应的 Metadata, 当 Slot 1 启动时,读取 Slot 1 对应的 Metadata,至于多出来的 Slot 2 什么时候会使用,目前没有系统研究。

    所以,通常情况下,包括 Primary 和 Backup 数据一起,磁盘上可能有 6 份 Metadata。

  • OTA 升级时,如果当前在 Slot 0 上运行,要更新 Slot 1 分区,则在更新之前 Slot 1 前会更新 Slot 1 对应的 Metadata 以反应 Slot 1 的实际布局,反之亦然。

不清楚的地方去看本文图 3。

6.2 关于 LpMetadata 数据结构

详细的数据结构,我也不知道说点啥好,请参考本文图 4。

6.3 动态分区生成,解析和映射流程

  • 编译阶段 build_super_image.py 内部调用 lpmake 工具生成 super.img 文件
  • 安卓启动时系统通过 liblp 库函数解析 super.img 头部的metadata,在内存中建立 LpMetadata 数据结构
  • fs_mgr 基于 LpMetadata 内的分区信息,得到映射表,然后通过 libdm 库调用 linux 的 device mapper 驱动映射设备

详细的示例分析参考本文图 5。

好了,5 张图搞懂动态分区原理就到这里了,剩下几篇将会分模块对安卓动态分区进行分析。

7. 其它

洛奇工作中常常会遇到自己不熟悉的问题,这些问题可能并不难,但因为不了解,找不到人帮忙而瞎折腾,往往导致浪费几天甚至更久的时间。

所以我组建了几个微信讨论群(记得微信我说加哪个群,如何加微信见后面),欢迎一起讨论:

  • 一个密码编码学讨论组,主要讨论各种加解密,签名校验等算法,请说明加密码学讨论群。
  • 一个Android OTA的讨论组,请说明加Android OTA群。
  • 一个git和repo的讨论组,请说明加git和repo群。

在工作之余,洛奇尽量写一些对大家有用的东西,如果洛奇的这篇文章让您有所收获,解决了您一直以来未能解决的问题,不妨赞赏一下洛奇,这也是对洛奇付出的最大鼓励。扫下面的二维码赞赏洛奇,金额随意:

收钱码

洛奇自己维护了一个公众号“洛奇看世界”,一个很佛系的公众号,不定期瞎逼逼。公号也提供个人联系方式,一些资源,说不定会有意外的收获,详细内容见公号提示。扫下方二维码关注公众号:

公众号

  • 32
    点赞
  • 107
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

洛奇看世界

一分也是爱~

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

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

打赏作者

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

抵扣说明:

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

余额充值