背景介绍
因为近些年,CPU 行业的摩尔定律生效了,很多厂商都纷纷从指令集架构层面寻找代替解决方案。在生产产品畛域,苹果推出了 ARM 指令集的 Apple Silicon M1,大获好评;在云服务行业,华为云和 Amazon 前些年就曾经在自研并上线了 ARM CPU 服务器,在老本和性能方面颇有建树。
而对于国产 CPU 行业而言,除了北公众志、海光、兆芯等少数几家手上领有 x86_64 指令集受权外,其余厂家根本都专一于非 x86_64 指令集。如:华为和飞腾在研发 ARM CPU,龙芯长年专一于 MIPS CPU;近些年衰亡的 RISC-V 也吸引了泛滥厂家的眼光。
对于各种非 x86_64 CPU,行业软件的移植和适配次要会波及嵌入式端、手机端、桌面端和服务器。嵌入式端思考到功耗,个别逻辑较为简单,代码移植和适配复杂程度不高。手机端个别都是 Android ARM,不波及太多适配问题。
桌面端分为三种状况:
如果利用基于浏览器即可满足所有性能,国产化零碎发型版本,个别内置了 Firefox 浏览器,利用对 Firefox 浏览器进行适配即可。
如果利用是一个轻度的桌面利用,能够思考应用 Electron 的计划。Electron(原名为 Atom Shell)是 GitHub 开发的一个开源框架。它通过应用 Node.js(作为后端)和 Chromium 的渲染引擎(作为前端)实现跨平台的桌面 GUI 应用程序的开发。这种状况下,首先能够看下国产化零碎的软件源是否有对应的 Electron 依赖(个别都有);如果没有,须要进行编译。
如果利用是一个重度的 Native 利用,则须要把代码在对应的指令集和零碎依赖上进行编译,工作量较大。
服务器,也分为三种状况:
如果应用的是面向虚拟机的语言,比方 Java 或基于 JVM 的各种语言(Kotlin、Scala 等),则服务不须要进行非凡的适配。个别国产化零碎的软件源中个别都会自带已实现好的 OpenJDK;如果没有,参见的指令集个别也都能找到对应的 OpenJDK 开源实现,能够自行装置。
近些年呈现的一些对 C 库无强依赖的语言,如 Go 等。编译体系在设计之初就思考了多种指标零碎和指令集架构,只须要在编译时指定指标零碎和架构即可,如 GOOS=linux GOARCH=arm64 go build,如果应用了 CGO 还须要指定 C/C++ 的编译器。
如果服务应用的是 C/C++ 等 Native 语言,且对系统 C 库有强依赖,则须要把代码在对应的指令集和零碎依赖上进行编译,工作量较大。
而下面能够看出,服务器和桌面端在 Native C/C++ 的适配上相似,而服务器对性能的要求会更为严苛。本文分享的内容次要是服务器 Native C/C++ 如何在多种指令集 CPU 上进行适配,特地是宏大代码量时如何进步工程效率,大部分内容桌面端也同样能够参考。
编译运行漫谈
既然咱们要解决的是 Native C/C++ 程序在多种指令集 CPU 的适配,咱们须要先理解下程序是如何编译和运行的,能力在各个环节借助各种工具,进步适配的效率。
大家在上计算机课时个别都有理解,C/C++ 源代码通过预处理、编译和链接,会生成指标文件。而后计算机将程序从磁盘加载到内存中,即可运行。而这两头其实暗藏了十分多的细节,让咱们一一来看。
首先,源码在编译过程中,先通过编译器前端,进行词法剖析、语法分析、类型查看、两头代码生成,生成与指标平台无关的两头示意代码。而后再交给编译器后端,进行代码优化、指标代码生成、指标代码优化,生成对应指令集的指标 .o 文件。
GCC 在这个过程中是前后端都一起解决了,而 Clang/LLVM 则别离对应了前端和后端。由此咱们也能看出,常见的穿插编译是如何实现的,即编译器后端对接到不同的指令集和体系架构上。
实践上,所有的 C/C++ 程序都应该能通过本地和穿插编译工具链编译到所有的指标平台。然而理论工程的时候,还须要思考到理论应用的编译工具,如 make、cmake、bazel、ninja 是否曾经能反对各种状况。比方,在本文公布的时候,Chromium 和 WebRTC 就因为 ninjia 和 gn 工具链的问题,是没有方法在 Mac ARM64 上编译本身架构的。
而后,链接器将指标 .o 文件和各种依赖库,链接到一起,生成可执行可执行文件。
链接过程中,会依据环境变量,查找对应的库文件。通过 ldd 命令就能够看到可执行文件,依赖的库列表。在适配雷同指令集不同零碎环境的时候,能够思考将所有的库依赖和二进制可执行文件一起拷贝进去作为编译输入。
而最终生成的可执行文件,无论是 Windows 还是 Linux 平台,都是 COFF(Common File Format)格局的变种,Windows 下是 PE(Portable Executable),Linux 下是 ELF(Executable Linkable Format)。
事实上,除了可执行文件外,动态链接库(DDL,Dynamic Linking Library)、动态链接库(Static Linking Library)均采纳可执行文件格局存储。它们在 Window 下均依照 PE-COFF 格局存储;Linux 下均依照 ELF 格局存储,只是文件名后缀不同而已。
最初,二进制可执行程序在被启动的时候,零碎会加载到一个新的地址空间。这也就意味着,零碎会从指标文件读取头信息并将程序读入到地址空间段中,用链接器和加载器加载库和进行地址空间转换。而后设置过程各种环境信息和程序参数,最终将程序运行起来,执行程序对应的每条机器指令。
而每个零碎环境的库和依赖都不尽相同,能够在通过设置 LD_LIBRARY_PATH 环境变量指定读取的库目录,或者通过 docker 等计划,残缺指定一个运行环境。
而计算机在读取每一条机器指令再执行的过程中,其实还能够通过虚拟机的形式进行机器指令的转译进行模仿,比方 qemu 能反对多种指令集,Mac rosetta 2 能将 x86_64 高效翻译为 arm64 并执行。
适配与工程效率
通过编译和运行的整个流程剖析,咱们能够在业界找到很多工具,晋升适配的效率。
因为谋求 CI/CD 疾速搭建并且对系统无依赖,咱们会采纳 docker 的形式进行编译。
通过在 Dockerfile 中从零开始装置所有工具和依赖库,能够严格保障每次编译的环境是统一的。
在编译阶段,如果依赖较为清晰,能够应用穿插编译的形式,在 x86_64 机器上间接编译对应的程序。
如果零碎依赖库比较复杂然而代码量比拟小的状况下,还能够思考应用 qemu 模仿对应的指令集进行本地编译,其实就是用 qemu 把 gcc/clang 的指令间接翻译一遍而环境都不须要批改。docker 的 buildx 就是基于这个思路实现的。
然而须要留神的是,qemu 是通过指令集翻译的形式来执行的,效率不高,代码量大点的状况下根本不必思考这个计划了。docker buildx 也还不太稳固,自己不止一次应用 buildx 编译把 docker service 搞挂。
代码量大且编译工具依赖较深的状况下,gcc/clang 穿插编译可能不好革新,能够间接在对应的指令集上进行本地编译。
具体情况须要看工程实际,代码仓库微小且革新艰难的状况下,甚至能够不同模块一部分应用穿插编译一部分应用模仿或者指标机器本地编译,最初再链接到一起,只有保障工程效率最高即可。
特定 CPU 效率优化
不同的 CPU,即便是同一个体系结构,反对的具体机器指令也有不同,这些都会影响到执行效率,比方是否能应用到一些长指令。失常的优化流程是,各 CPU 厂家把本人的个性推到 gcc/clang/llvm,作为开发者在编译时就能够应用到了。然而这个过程须要工夫,并且对编译器的版本还有要求,所以各 CPU 厂家也会在文档中阐明,在编译时可能须要留神 gcc 具体版本,甚至在执行 gcc 命令时减少非凡的参数。
咱们 RTC 服务应用了 kubernetes 进行服务编排,所以编译产出物其实是 docker images。在面对多指令集架构的时候,抉择根底镜像须要更加审慎。
docker 根底镜像通常大家会从 scratch、alpine、debian、debian-slim、ubuntu、centos 外面进行抉择。
除非特殊要求,否则大家都不会抉择 scratch 空镜像从头构建。
而 alpine 体积只有 5M,看起来很美妙,然而零碎 C 库是基于 musl 而不是桌面零碎或服务器常见的 glic,重度 C/C++ 利用,尽量不要应用这个版本,否则可能会导致工作量大增。
debian-slim 相比于 debian,次要是删除了一些不罕用的文件和文档,个别服务能够抉择 slim。
而 ubuntu 和 centos 都短少 mips 架构的官网反对,如果工作中要思考龙芯等 mips CPU 的状况,则能够思考 debian-slim。
另外一点留神的是,很多开源软件的编译验证零碎抉择的是 ubuntu,而在编译时须要留神的是,ubuntu 是基于 debian unstable 或者 testing 分支的,应用的 C 库版本与 debian 会有差别。
CI 编译完,能够应用 qemu + docker 启动服务,在一个架构上对多指令集进行简略验证,而不须要依赖与个性的机器和环境。
docker 反对将聚合多种架构的 image 聚合到一个 tag,即在不同的机器上,执行 docker pull 会依据以后零碎的指令集和架构,获取对应的镜像。然而这样的设计,在一个零碎上,生成和存储多架构,应用和验证时非凡指定一个架构,会较为繁琐。所以咱们在工程实际中,间接在 image tag 上标识出了不同的架构,这样生成、获取、验证镜像都非常简单间接。
如果最终程序须要在 Native 而非 Docker 环境运行,面对不同的零碎依赖,能够通过批改以后过程的 LD_LIBRARY_PATH 环境变量指定动静库加载门路。
在编译生成可执行二进制文件的时候,能够通过执行 ldd 命令,将所有的依赖库拷贝进去,通过 LD_LIBRARY_PATH 指定到对应的门路,能够断绝对系统库的依赖。有些状况下,因为零碎根底 C 库版本不统一,可能会导致可执行二进制文件在链接的状况下就会出问题。这时候能够思考 patchelf 对 ELF 进行批改,只用指令的 C 库和链接器,断绝各种环境依赖。
结语
融云始终专一于 IM 和 RTC 畛域,无论在私有云或者公有云市场,咱们都感触到了市场上对多种 CPU 指令集架构的需要。目前咱们针对私有云 AWS/ 华为 ARM CPU 和信创市场所有的 ARM/MIPS CPU 都进行了全功能的适配和优化,对于信创市场各种操作系统、数据库和中间件也进行了针对性的适配。本文对其中编译适配工程中用到的技术和工具进行了剖析,欢送大家多多交换。
参考链接
qemu: https://www.qemu.org/
docker buildx:
https://docs.docker.com/build…
patchelf: https://github.com/NixOS/patc…