安卓支持 RISC-V 架构的技术剖析
关键词:安卓、玄铁 C910、虚拟机、RISC-V 架构,RISC-V 操作系统,IoT 芯片,嵌入式操作系统,AOSP
引言
本文主要以 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 解析库、编解码库等等。本文后续将从开发顺序为叙事线索,对上述指令架构需要支持的模块进行介绍。
如何支持
以下将分五个阶段对安卓的 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 工程可以通过以下命令拉取:
该工程中需要为 RISC-V 添加工具前缀、架构配置、运行时库相关的支持;并通过 build.py 脚本进行构建:
按照以下流程完成工具的生成:
玄铁 910 特有的扩展指令优化则可以通过在 toolchain/llvm-project/llvm/lib/Target/RISCV 下额外添加指令和寄存器 tablegen 相关描述,使安卓整体工程能得到玄铁 910 扩展指令的加速。 GCC 工具链则可以通过 RISC-V 官方开源的构建工程生成:
NDK/VNDK
在拥有两套预编译工具链之后即可着手 NDK, VNDK 两套开发套件的生成。NDK 是一套包含了众多平台库用于 C/C++程序开发工具套件,包含 C/C++源码、mk 描述的原生工程;安卓内部的许多模块都依赖 NDK,如 system、frameworks 路径下各类的原生程序;可以通过 ndk-build 或 gradle 编译出适配各个平台兼容 API 版本的原生执行程序:
NDK 构建工程可以通过以下命令拉取:
其中 rxx 为版本号需要与系统版本要求的最小 API 相适应。该工程中需要为 RISC-V 添加工具主要需要添加版本、位宽、路径相关的配置支持;并通过 checkbuild.py 脚本进行构建:
VNDK 则是用于让供应商实现其 HAL 对接的一套原生库的集合,包括框架共享库和 SP-HAL 两个部分。可以让系统在升级的时候其供应商分区保持不变,避免 API/ABI 变化所引发的问题;VNDK 可以基于安卓的主工程使用以下命令生成:
Linux 内核
Linux 内核在安卓中通常也通过预编译方式存放,通用模拟器使用的内核镜像存放于 prebuilts/qemu-kernel 而设备板级通常存放于 device 目录;一般来说开发板会使用 Image 二进制或 gz 文件,模拟器可以使用 elf 文件以方便调试 :
Linux 内核工程可以通过以下命令拉取:
RISC-V 的 Linux 内核在 4.15 版本就被 upstream 至 kernel.org 主枝,因此只需要添加相关配置文件就可以使用脚本构建安卓的内核镜像:
模拟器相关的驱动通常以 goldfish 开头,在内核中已经有实现;其中 goldfish_pipe 则较为泛用,会被用于主机通信、adb、网络、opengl 渲染等等功能,其他驱动都对应一个专门的外设功能。模拟器中的虚拟平台的设备树要配置与内核 compatible 一致的,否则会导致设备缺失、协议不兼容等相关问题。
RVB-ICE 开发板板级则需要将所有未 upstream 至内核官方源码树的第三方设备驱动与内核代码整合或使用 ko 方式进行模块编译。这些驱动通常包括存储、显示、触控、传感器、USB、蓝牙、摄像、定位、音频、硬件 codec 等等,需要在后续服务调试过程中与 HAL 进行对接,向上层应用提供基础服务。此外开发过程中也会发现一些模块缺失或者兼容性问题,需要在后续调试过程中不断的进行调整和适配。
模拟器
安卓的模拟器基于 QEMU 实现,通过一个中间 glue 层进行对接,最外层实现了 emulator 封装;提供了虚拟设备管理、镜像指定、snapst 缓存、gpu 加速、摄像头模拟、网络映射等功能。还可以针对需要编译成 TV、电话、穿戴、平板、车载等不同配置。以通过以下命令拉取:
emulator 提供的大部分特性都与架构无关,而 external/qemu/target/riscv 目录下已经支持了 RISC-V 相关的 tcg 指令反应和 C 的 help 函数,因此 RISC-V 相关支持只需要添加 cmake 编译支持、emulator 的架构字串、和 external/qemu/hw/riscv/下的 goldfish 虚拟设备即可。虚拟设备文件主要包括内存配置、设备树创建、中断控制器初始化、虚拟设备创建、固件加载几个部分;其中需要注意设备数和设备创建和内核配置的一致性,不然很容易出现设备匹配问题:
之后使用脚本构建模拟器包:
其他
其他预编译工程还包括 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 命令就能根据规则完成各个模块的编译。
除了编译框架之外,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 添加虚拟机平台主要需要添加以下文件:
RVB-ICE 开发板的板级平台支持与通用模拟器配置略有不同,存放于 device 下的对应板级目录内,主要包括:
其他部分主要为一些特性支持和 HAL 对接会随设备的复杂度增加而增加:
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 部分主要对常用内存操作函数、内核提供的系统调用、数据结构等内容进行了封装向用户态程序提供了标准化的接口;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 想匹配的代码。其过程可以用下图描述。
从上图 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 字节码编译成可执行二进制码。
从上图可以看到,编译器经过优化后,得到 ART HInstruction,再由体系结构相关的后端处理,生成对应体系结构的指令。上图中标注为黄色部分为 RISC-V 体系结构主要完成工作:
优化遍:指令简化(Instruction Simplifier), Intrinsic,Vector
RISC-V 后端:指令生成(Code Gen),寄存器分配
Step 3. 原生小系统启动
在 mksh 命令行和 toybox 小工具集能够正常基于安卓生成之后即可开始进行原生程序相关的调试。本阶段需要在完成系统分区和镜像烧写,boot 的引导, 内核的启动,文件系统的加载,运行各类初始化 rc 脚本,启用 selinux 相关环境,启动 rc 脚本注册的各种服务,初始化命令行,最后进入循环等待各类事务的处理。
内核启动
在系统启动调试初始阶段通常会使用 gdb 加载 linux 内核(打包自制的 ramdisk)方式调试基础的 c 程序运行。内核在 kernel_init 中调用 prepare_namespace 完成 ramdisk 挂载后就会通过 ramdisk_execute_command 运行 init 进程,init 进程一般使用静态编译,因此可以直接使用 gdb 调试从 load_elf_binary 接口向下通过异常处理函数进入用户态,观察程序出错点,此时出现的问题通常只涉及 crtbegin 入口的参数和跳转处理,系统调用的传参和返回,以及 init-array 是否被正常调用。当走通静态程序执行后即可在 init 中调用动态编译的程序,主要调试动态库加载,符号重定位等相关内容。当以上过程都顺利走通,就表示原生程序运行已经基本走通,可以开始进一步正规的 init 启动过程调试了。
系统初始化
安卓的初始化函数位于 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 的队列维护、命令行的启动和原生服务的启动。至此系统的初始化已经完成大半,原生部分也仅剩余服务部分需要进行调试了。
在第二阶段的 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 环境。
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 服务调试过程中相关的手段,就能解决大部分问题了。此外使用系统单元测试用例保证每个模块工作逻辑正常也会对整体系统调试提供很大的帮助。
本文转自平头哥芯片开放社区(occ),更多详情请点击https://occ.t-head.cn/development/software?channelName=1。
评论