当前位置:主页 > 查看内容

安卓支持RISC-V架构的技术剖析

发布时间:2021-06-03 00:00| 位朋友查看

简介:引言 本文主要以RISC-V开发板上安卓的实现过程为切入点 讨论了在安卓上添加新的指令架构 ISA 和板级平台支持的各个阶段 概述了每个阶段针对架构需要添加哪些支持 涉及开发过程中一些常见的问题和注意点 可以作为安卓指令架构支持和板级开发的参考。本文内容……
引言

本文主要以RISC-V开发板上安卓的实现过程为切入点 讨论了在安卓上添加新的指令架构 ISA 和板级平台支持的各个阶段 概述了每个阶段针对架构需要添加哪些支持 涉及开发过程中一些常见的问题和注意点 可以作为安卓指令架构支持和板级开发的参考。本文内容主要作为概述 其中细节较多的部分将会在其他文章展开讨论。

为什么要做安卓的RISC-V支持

处理器指令架构在数十年前就开始百花齐放 GCC支持的指令架构数达50 个之多 registry sco.com注册的EM架构数达252个之多。这些指令架构通常由各家芯片厂商或研究机构独立发起 彼此间相对独立 都需要投入大量人力进行软硬件支撑。出于“Instruction?Sets?Want?to?be?Free”这一愿景 RISC-V作为一个开源、通用、稳定、简洁、独立的指令架构被提出 目标是让各种规模、微结构、软件栈的计算系统都能基于一种统一的指令架构有效工作。自?2011年推出以来RISC-V迅速地普及 其软件生态也逐步完善 包括GCC/Bintuils工具链、Glibc库、Linux内核等一系列基础设施得到了支持并upstream至开源主枝 Fedora、Debian、openSUSE、Gentoo等一众发行版官方支持了RISC-V架构 Go、OpenJDK、Free?Pascal、Rust、Node.js等等高级语言编译/运行环境都支持了RISC-V的后端支持。然而RISC-V在Android生态软件支持上仍有较大的空缺 基于RISC-V架构支持了Android为后续移动/智能终端产品基于RISC-V架构的处理器落地提供了一种较低成本的可能性 现有上层应用不需要较大的改动就可以平滑的移动到使用自由ISA、自由SoC、自由软件的RISC-V平台上。

RISC-V?ISA的安卓软件栈支持

安卓软件栈主要包括系统内核、硬件抽象、运行时、框架层、应用五个层次的近千个软件包。其中作为基础的Linux内核、GCC工具链、Clang/LLVM工具链已经支持了RISC-V架构 因此对的主要支持工作集中于RISC-V?ISA的编译框架支持、Bionic?C库的ISA支持、ART?JAVA运行时的ISA支持、RVB-ICE开发板板级的驱动对接、OPENGL的对接、Chromium?webview浏览器几个部分 其他依赖包括NDK、VNDK、emulator、unwind解析库、编解码库等等。本文后续将从开发顺序为叙事线索 对上述指令架构需要支持的模块进行介绍。


1.png

如何支持

以下将分五个阶段对安卓的RISC-V?ISA支持和RVB-ICE开发板上的板级支持进行概要说明

Step?1.?准备预编译工程

安卓源代码树包含一个prebuilts目录 里面存放了包括host工具、开发套件、模拟器、二进制的abi描述文件几类预编译工程。使用预编译工程在减少总体编译量、提升二进制兼容性、使模块划分更清晰的同时也带来了更多的架构支持工作量 不尽相同的编译框架、完全独立的编译脚本、单独的ISA的编译支持。以下将对各个预编译工程逐个进行介绍

工具链

安卓的最新源码已经完全使用Clang来做系统的整体构建 但仍使用libgcc相关的函数库 也会使用-fuse-ld bfd -no-integrated-as之类的选项来调用GCC的汇编器 连接器。因此添加RISC-V?ISA支持首先需要支持Clang/LLVM和GCC两套工具链。Clang/LLVM工程可以通过以下命令拉取

repo init -u https://mirrors.tuna.tsinghua.edu.cn/git/AOSP/platform/manifest -b llvm-toolchain
repo sync

该工程中需要为RISC-V添加工具前缀、架构配置、运行时库相关的支持 并通过build.py脚本进行构建

cd toolchain/llvm_android
./build.py

按照以下流程完成工具的生成

2.png

玄铁910特有的扩展指令优化则可以通过在toolchain/llvm-project/llvm/lib/Target/RISCV下额外添加指令和寄存器tablegen相关描述 使安卓整体工程能得到玄铁910扩展指令的加速。 GCC工具链则可以通过RISC-V官方开源的构建工程生成

git clone https://github.com/riscv/riscv-gnu-toolchain.git
git submodule update --init --recursive
修改target triplets
./configure --prefix /opt/riscv
make linux
NDK/VNDK

在拥有两套预编译工具链之后即可着手NDK,?VNDK两套开发套件的生成。NDK是一套包含了众多平台库用于C/C 程序开发工具套件 包含C/C 源码、mk描述的原生工程 安卓内部的许多模块都依赖NDK 如system、frameworks路径下各类的原生程序 可以通过ndk-build或gradle编译出适配各个平台兼容API版本的原生执行程序

foo bar:[hello]$ ~/android-ndk/android-ndk-r20/ndk-build
Android NDK: APP_PLATFORM not set. Defaulting to minimum supported version android-16.
[arm64-v8a] Compile : hello-android hello.c
[arm64-v8a] Executable : hello-android
[riscv64] Compile : hello-android hello.c
[riscv64] Executable : hello-android
[riscv64] Install : hello-android libs/riscv64/hello-android

NDK构建工程可以通过以下命令拉取

repo init -u https://mirrors.tuna.tsinghua.edu.cn/git/AOSP/platform/manifest -b ndk-r20
repo sync

其中rxx为版本号需要与系统版本要求的最小API相适应。该工程中需要为RISC-V添加工具主要需要添加版本、位宽、路径相关的配置支持 并通过checkbuild.py脚本进行构建

./ndk/checkbuild.py

VNDK则是用于让供应商实现其?HAL?对接的一套原生库的集合 包括框架共享库和SP-HAL两个部分。可以让系统在升级的时候其供应商分区保持不变 避免API/ABI变化所引发的问题 VNDK可以基于安卓的主工程使用以下命令生成:

make vndk dist
Linux内核

Linux内核在安卓中通常也通过预编译方式存放 通用模拟器使用的内核镜像存放于prebuilts/qemu-kernel而设备板级通常存放于device目录 一般来说开发板会使用Image二进制或gz文件 模拟器可以使用elf文件以方便调试?:

foo bar:~$ cd aosp
foo bar:[aosp]$ file prebuilts/qemu-kernel/riscv64/ranchu/kernel-qemu
prebuilts/qemu-kernel/riscv64/ranchu/kernel-qemu: ELF 64-bit LSB executable, UCB RISC-V, version 1 (SYSV), statically linked, BuildID[sha1] 60388b123f1c60053407fd899f9acceed43ac267, with debug_info, not stripped
foo bar:[aosp]$ file device/linaro/hikey-kernel/Image.gz-dtb-hikey960-4.19
device/linaro/hikey-kernel/Image.gz-dtb-hikey960-4.19: gzip compressed data, max compression, from Unix, original size 7301488

Linux内核工程可以通过以下命令拉取

repo init -u https://mirrors.tuna.tsinghua.edu.cn/git/AOSP/kernel/manifest -b common-android-5.4-stable
repo sync

RISC-V的Linux内核在4.15版本就被upstream至kernel.org主枝 因此只需要添加相关配置文件就可以使用脚本构建安卓的内核镜像:

New file: common/arch/riscv/configs/gki_defconfig
New file: common/build.config.gki.riscv64
New file: common/build.config.riscv64
构建内核:
BUILD_CONFIG common/build.config.gki.riscv64 build/build.sh

模拟器相关的驱动通常以goldfish开头 在内核中已经有实现 其中goldfish_pipe则较为泛用 会被用于主机通信、adb、网络、opengl渲染等等功能 其他驱动都对应一个专门的外设功能。模拟器中的虚拟平台的设备树要配置与内核compatible一致的 否则会导致设备缺失、协议不兼容等相关问题。

foo bar: [common]$ find drivers -name goldfish*.c
drivers/input/keyboard/goldfish_events.c #虚拟触摸输入
drivers/platform/goldfish/goldfish_pipe_base.c #主机通信接口
drivers/platform/goldfish/goldfish_pipe_v1.c #主机通信接口
drivers/platform/goldfish/goldfish_pipe_v2.c #主机通信接口
drivers/power/supply/goldfish_battery.c #虚拟电量设备
drivers/staging/goldfish/goldfish_audio.c #虚拟音频输出
drivers/staging/goldfish/goldfish_sync.c
drivers/tty/goldfish.c #虚拟终端输出
drivers/video/fbdev/goldfishfb.c #虚拟FrameBuffer显示

RVB-ICE开发板板级则需要将所有未upstream至内核官方源码树的第三方设备驱动与内核代码整合或使用ko方式进行模块编译。这些驱动通常包括存储、显示、触控、传感器、USB、蓝牙、摄像、定位、音频、硬件codec等等 需要在后续服务调试过程中与HAL进行对接 向上层应用提供基础服务。此外开发过程中也会发现一些模块缺失或者兼容性问题 需要在后续调试过程中不断的进行调整和适配。

模拟器

安卓的模拟器基于QEMU实现 通过一个中间glue层进行对接 最外层实现了emulator封装 提供了虚拟设备管理、镜像指定、snapst缓存、gpu加速、摄像头模拟、网络映射等功能。还可以针对需要编译成TV、电话、穿戴、平板、车载等不同配置。以通过以下命令拉取

repo init -u https://mirrors.tuna.tsinghua.edu.cn/git/AOSP/platform/manifest -b emu-master-dev
repo sync

emulator提供的大部分特性都与架构无关 而external/qemu/target/riscv目录下已经支持了RISC-V相关的tcg指令反应和C的help函数 因此RISC-V相关支持只需要添加cmake编译支持、emulator的架构字串、和external/qemu/hw/riscv/下的goldfish虚拟设备即可。虚拟设备文件主要包括内存配置、设备树创建、中断控制器初始化、虚拟设备创建、固件加载几个部分 其中需要注意设备数和设备创建和内核配置的一致性 不然很容易出现设备匹配问题

...
static const struct MemmapEntry { //内存分区
static void riscv_ranchu_board_init(MachineState *machine)
 /* 初始化内存分区 */
 memory_region_init_ram(main_mem, NULL, riscv_ranchu_board.ram ,
 machine- ram_size, error_fatal);
 /* 创建设备树 */
 fdt create_fdt(s, memmap, machine- ram_size, machine- kernel_cmdline);
 /* 创建goldfish设备 */
 create_device(s, fdt, RANCHU_GOLDFISH_FB);
 /* 加载opensbi */
 riscv_load_firmware (machine- firmware, memmap[RANCHU_DRAM].base, NULL);

之后使用脚本构建模拟器包

cd external/qemu/
./android/rebuild.sh
其他

其他预编译工程还包括clang-tool、gdbserver等等。clang-tool包含ABI比较、版本管理相关工具 由prebuilts/clang-tools/build-prebuilts.sh脚本生成。而gdbserver则由binutils-gdb工程静态预编译生成 可以用于原生C/C 程序和ART底层执行环境的调试。

Step?2.?编译框架和构建支持

安卓?7.0版本之后代码构建通过Blueprint和Soong实现。bp文件作用取代过去的mk文件包含编译目标模块的名称、代码、链接库、编译链接选项等参数。而Soong 会调用Blueprint相关工具 则类似于make命令的展开部分负责将bp转换为ninja文件 ninja文件主要保存于out/soong/build.ninja 通过调用ninja?-f?build.ninja命令就能根据规则完成各个模块的编译。

3.png

除了编译框架之外 RISC-V架构支持还包括原生C/C 程序、Java运行时两个部分的支持。原生C/C 程序支持主要包括Bionic、opengl、protobuf、libunwindstack等模块 还包括一些可以进行汇编优化的加解密、音视频、AI模块 JAVA运行时则集中于ART目录实现。

编译框架支持

安卓的编译框架支持主要位于build目录下 又分为make和soong两部分。soong部分针对架构主要要维护函数库路径、编译链接选项、ABI、架构名称字串等相关内容。make部分则主要包括架构名称匹配、工具链的路径和参数和通用板级相关的配置。预编译工程中的VNDK、SDK都对编译框架有依赖 因此在生成这两个预编译工程前要求编译框架和bionic都得到了相关支持。为RISC-V添加虚拟机平台主要需要添加以下文件:

1. core/combo/TARGET_linux-riscv64.mk //编译ABI toc生成规矩 链接目标配置
2. core/combo/arch/riscv64/riscv64.mk //用于指定平台相关的额外编译选项 如指令集
3. target/board/generic_riscv64/BoardConfig.mk //模拟器平台定义 引用一些通用外设或镜像配置
4. target/board/generic_riscv64/device.mk //定义JAVA虚拟机运行参数等一些设备配置
5. target/board/generic_riscv64/system.prop //定义系统运行环境的prop变量
6. target/product/aosp_riscv64.mk //定义产品名称 依赖包列表等配置
7.

RVB-ICE开发板的板级平台支持与通用模拟器配置略有不同 存放于device下的对应板级目录内 主要包括

Android.bp/Android.mk //设备顶层编译脚本
AndroidProducts.mk //产品选单配置
BoardConfigCommon.mk //板级配置 包含架构、功能开关、recovery等相关配置
common.kl //按键触摸映射
device-common.mk //定义依赖包列表等配置
[device_name].mk //定义产品名称和相关字串
[device_name]/BoardConfig.mk //板级配置 包括名称、启动参数、镜像大小等等
[device_name]/fstab.[device_name] //产品的挂载定义
[device_name]/device-[device_name].mk //定义设备文件安装规则
init.*.rc //设备特有的初始化脚本
manifest.xml //支持的hal接口定义
system.prop //定义系统运行环境的prop变量

其他部分主要为一些特性支持和HAL对接会随设备的复杂度增加而增加

audio //音频设备hal对接
bluetooth //蓝牙设备hal对接
gralloc //图形内存管理对接
gpu //gpu加速的hal对接
power //功耗管理的hal对接
overlay //源代码配置覆盖
recovery //恢复分区支持
sensorhal //传感器hal对接
sepolicy //设备的安全规则
vndk //vndk的构建工程
Bionic支持

Bionic是Android的C函数库 区别于glibc会提供ISO POSIX UNIX XOPEN XPG多套接口标准支持 被用与不同的类UNIX系统进行对接 bionic只需要实现与linux内核相关对接 主要覆盖ISO和POSIX相关接口。除了标准C库、线程库以外 还提供了浮点数学库、动态链接器和部分Android特有的接口 如systrace、scudo、property等 。 对于RISC-V?ISA支持来说 Bionic中主要实现以下几部分内容

libc/SECCOMP_*.TXT //seccomp黑白名单
libc/SYSCALLS.TXT //系统调用列表
libc/arch-common/bionic/crtbegin.c //c函数跳转入库
libc/arch-riscv64/bionic/* //汇编实现的通用系统接口
libc/arch-riscv64/generic/* //mem/str类函数汇编优化
libc/arch-riscv64/syscalls/* //系统调用接口
libc/include/bits/elf_riscv64.h //重定位类型声明
libc/include/bits/fenv_riscv64.h //浮点异常定义
libc/include/sys/ucontext.h //上下文保存结构定义
libc/kernel/uapi/asm-riscv/* //内核头文件
libc/libc.map.txt //符号输出控制文件
libc/private/bionic_asm_riscv64.h //架构相关对齐 layout定义
libc/private/*tls* //tls相关处理
libc/seccomp/* //安全接口对接声明
libm/riscv64/fenv.c //浮点控制接口实现
linker/* //重定位相关实现
tests/prebuilt-elf-files/riscv64/* //动态加载测试用例
tools/versioner/* //版本工具支持

libc部分主要对常用内存操作函数、内核提供的系统调用、数据结构等内容进行了封装向用户态程序提供了标准化的接口 Bionic的线程库也位于libc中实现 大部分线程接口如创建、等待、取消都为公共实现 架构相关的主要为tls部分 需要为RISC-V定义相关layout 各个数据结构 tp、tcb、dtv 的获取等等。 动态链接程序使用库函数依赖于链接器相关的支持 链接器负责将dso加载至任意内存地址 并对需要重定位的指令进行修改使其能正常的执行跳转集地址获取指令 Bionic中的链接器公共代码实现了8种常用重定位的定义 一般架构只需要将对应重定位序号进行对接即可。

其他原生程序支持

其他原生程序支持还包括程序堆栈回溯支持、OPENGL支持、汇编加速优化、protobuf支持等等。 安卓中为程序错误提供完善的dump和调用栈回溯功能 其中native层的堆栈回溯主要基于的libunwindstack实现。RISC-V相关支持主要需要添加ptrace调用的寄存器上下文的格式 栈帧的寄存器排布和elf信息解析相关功能。 OPENGL的支持则包括GL接口entry桩点 GPU设备对接两个部分。GL接口entry桩点 包含一段架构特有的汇编入口实现 负责加载opengl?tls数据结构和准备接口参数。GPU设备对接通常需要和IP供应商对接 使用对应sysroot的工具链生成依赖的图形库文件以保证加载时的依赖关系正确性 此外还需要对gralloc(内存管理) compositor(合成单元) drm(图形渲染框架)进行对接 使上层程序可以正常的调用GPU底层接口。 对于安卓这种业务覆盖密集运算、音视频、加解密、3d渲染、AI的系统来说 如何使用适当的软件编码来尽可能的发挥硬件性能尤为重要。如bionic的内存/字符串操作/浮点运算、boringssl的大数模乘、编解码的乘累加 NN引擎的数组向量化运算都可以针对指令架构进行优化 使用大位宽、可非对齐访问、复合功能、向量化的扩展指令进行优化往往能给热点程序提供数倍到数十倍的性能提升。

ART支持

Android应用程序是基于JAVA语言编译生成的dalvik字节码程序。由于linux系统无法直接执行dalvik字节码的应用程序 Android系统上集成了一个可以运行dalvik字节码程序的虚拟机。虚拟机的作用在于将dalvik字节码的功能通过系统提供的库、CPU的指令 完成对应字节码的功能。 从Android-5开始 DVM被ART取代 但是很多执行文件的名字还是叫做delvik ART引入了AOT技术 在应用安装或者手机充电的时候 ART会利用dex2oat工具编译应用代码 将其编译为与目标机器CPU想匹配的代码。其过程可以用下图描述。


4.png


从上图ART虚拟机的运行流程中 主要有三部分内容是移植RISC-V的关键

AOT编译器 图中.oat文件的生成工具 oat文件包含可执行二进制码的文件。AOT编译器作用是将dex字节码编译成oat文件 缺省配置下 在编译时或者安装时 会调用dex2oat来完成
JIT编译器 图中紫色部分 dex字节码运行过程中 ART记录执行的方法是否是热点方法 并生成profiling信息。JIT编译器的作用根据profiling信息对热点方法进行编译。
Interpreter dex字节码解释器 用于执行Android的dex字节码

此外 无论Interpreter还是编译器都会用到汇编器以及反汇编器。 接下来的内容 我们就从汇编器 解释器 编译器几个方面对移植工作做个简单的介绍

汇编器

汇编器的功能是将编译的指令转换成机器码 是ART中编译器部分的基础部件。在移植到RISC-V体系架构过程中 完成RISC-V所有指令集的汇编功能。

解释器

解释器的作用就是解释执行dex字节码。RISC-V指令架构支持过程中 该部分工作集中在

dex字节码翻译成RISC-V指令
c /java现场转换的context的保存和恢复
编译器

AOT编译器和JIT编译器在ART中使用的是同一套编译框架 复用同一套实现代码。两个编译器的目的都是为了将dex字节码编译成可执行二进制码。


5.png


从上图可以看到 编译器经过优化后 得到ART?HInstruction 再由体系结构相关的后端处理 生成对应体系结构的指令。上图中标注为黄色部分为RISC-V体系结构主要完成工作

优化遍 指令简化(Instruction?Simplifier),?Intrinsic Vector
RISC-V后端 指令生成(Code?Gen) 寄存器分配
Step?3.?原生小系统启动

在mksh命令行和toybox小工具集能够正常基于安卓生成之后即可开始进行原生程序相关的调试。本阶段需要在完成系统分区和镜像烧写 boot的引导,?内核的启动 文件系统的加载 运行各类初始化rc脚本 启用selinux相关环境 启动rc脚本注册的各种服务 初始化命令行 最后进入循环等待各类事务的处理。


6.png


内核启动

在系统启动调试初始阶段通常会使用gdb加载linux内核 打包自制的ramdisk 方式调试基础的c程序运行。内核在kernel_init中调用prepare_namespace完成ramdisk挂载后就会通过ramdisk_execute_command运行init进程 init进程一般使用静态编译 因此可以直接使用gdb调试从load_elf_binary接口向下通过异常处理函数进入用户态 观察程序出错点 此时出现的问题通常只涉及crtbegin入口的参数和跳转处理 系统调用的传参和返回 以及init-array是否被正常调用。当走通静态程序执行后即可在init中调用动态编译的程序 主要调试动态库加载 符号重定位等相关内容。当以上过程都顺利走通 就表示原生程序运行已经基本走通 可以开始进一步正规的init启动过程调试了。


7.png

系统初始化

安卓的初始化函数位于system/core/init下 分为first_stage_init.cpp和init.cpp两个阶段。第一阶段主要负责节点的创建和部分文件系统的挂载 要求boot system vender data等分区在开发板上已经按照分区表进行行了烧写 RVB-ICE开发板使用的是GPT log系统的初始化 一些基础环境变量的设置 之后会调用selinux_setup加载预编译的规则和上下文文件 为各个文件节点配置安全属性 在调试时selinux进行修改通常会带来不少额外的工作量 因此在开发过程中通常在系统的bootargs中添加permissive选项关闭相应校验功能 当selinux完成加载之后会调用第二阶段的初始化 本阶段主要包括property的设置、二阶段的安全上下文配置、ActionManager的初始化、Keychord的队列维护、命令行的启动和原生服务的启动。至此系统的初始化已经完成大半 原生部分也仅剩余服务部分需要进行调试了。


8.png


在第二阶段的init?loop循环中会维护包含SurfaceFlinger、netd、vold、apex、ashmem、installd、media等模块的调试。这些服务通过/root或/system/etc/init下的rc文件进行维护 在特定扳机被触发或property被设置后启动。这些服务模块大多依赖板级平台上的HAL对接 device下需要实现包括存储、wifi、gpu、音频的接口支持 并通过相应用例以保证服务执行运行。安卓启动动画也会在本阶段SurfaceFlinger和bootanimation走通后正常显示。本阶段主要使用adb gdbserver配合logcat吐出的日志信息进行调试。

Step?4.?Zygote与Java服务zygote启动

zygote进程是所有java服务的父进程 它是由init进程从配置?件中获取app_process程序的启动?式 包括参数 并启动的 会进??件?frameworks/base/core/jni/AndroidRuntime.cpp 然后进?此?件中的函数:startVm正式启动ART虚拟机 然后调用通过native方法 com.android.internal.os.ZygoteInit 启动Zygote进程 进入Zygote的JAVA环境。


9.png


JAVA服务启动

Java服务启动位于frameworks/base/services/java/com/android/server/SystemServer.java 分BootstrapServices、CoreServices、OtherServices三个阶段启动各类Java服务 服务使用高级语言编写与指令架构基本无关 但在系统层面会关心底层与HAL对接的硬件模块实现是否完全 对应系统服务deamon是否在正常的提供服务 系统运行的速度是太慢触发了TIMEOUT机制 JAVA虚拟机执行出错是否为ART实现问题等等。在开发过程中常常有许多模块由于架构支持不完全等原因被暂时绕过 后续服务启动就会发现执行出错 此时通常打开对应服务的log后通过logcat就能看到对应错误原因 再针对性的调试对应模块依赖就能解决大多数的服务问题。在模拟器调试中由于qemu执行指令较慢会出现大量的服务启动超时问题 大部分的超时问题可以通过log打印查看 再改长对应服务延时即可解决。上层java应用逻辑也可以使用JDB 单步java代码查看程序路径进行调试。

Step?5.?launcher桌面显示

在原生程序和Java服务都调试稳定的理想状况下 系统自然可以启动到桌面。然而实际的系统调试往往并没有那么顺利 常见问题现象通常有启动动画循环播放、模块缺失、系统服务执行奔溃等等。这种情况下通常会其他架构平台的运行状况进行对比 依次确认SurfaceFlinger、WindowsManager相关服务是否正常的初始化 Wallpaper、systemUI、Launcher三个app的程序逻辑是否符合预期 配合原生程序和java服务调试过程中相关的手段 就能解决大部分问题了。此外使用系统单元测试用例保证每个模块工作逻辑正常也会对整体系统调试提供很大的帮助。


10.png


本文转自网络,原文链接:https://developer.aliyun.com/article/784480
本站部分内容转载于网络,版权归原作者所有,转载之目的在于传播更多优秀技术内容,如有侵权请联系QQ/微信:153890879删除,谢谢!

推荐图文


随机推荐