交叉编译 Mozilla NSS 库

最近在做嵌入式平台上的 Chromium 移植工作,由于 Chromium 在 Linux 平台下需要依赖系统的 NSS 库,但是目标平台并没有这个库,只好自己移植一下。

获取最新的 Mozilla NSS 库源码包,这里我们下载 NSS NSPR 二合一包:

由于 NSS 依赖 NSPR,我们需要首先编译 NSPR 库:

由于 NSS 库的编译脚本默认使用 GYP 编译,需要先配置一下环境:

先修改一下 coreconf/shlibsign.py 这个脚本,将下面一行注释,不然报错:

然后就可以开始编译 NSS 库了:

注:nss-3.43/dist 为编译 out 目录,编译完后 include/lib 都在里面了。

移植 Chromium 到嵌入式 Linux 平台

出于工作需要,历时三个月,将最新的 Chromium 移植到了某 SoC 厂商的嵌入式芯片平台。其间辛酸挫折不可胜数,择其关键步骤记之,以飨后来者。

一、版本选型

Chromium 的版本几乎一到两月一次更新,速度之快令人目眩。笔者在移植之初,选定了当时的 stable 66 版本,待移植完之后已经升到 69 版本了。于是,花费了一两日又成功移植到了最新的 69 版本,这也算是一个意外的收获。

版本选型,务必要选用 Chromium 的 Stable 版本,也是 Chrome 正式发布的版本。笔者最初就因选用了非 Stable 版本,结果在移植过程中出现了许多奇奇怪怪的问题,浪费了大量的时间。另外,请尽量避开那些有重大的架构调整及其前后的版本,否则你会陷入无尽的找 patch 和打 patch 循环里,耗费光阴。

维基百科上可以查找到 Chroium 的主版本更新历史,可以参考相应的版本号。

二、源码获取

首先,来到 Chromium 的开发者网站,阅读必要的文档,开始我们的移植工作:

这里列出几个你可能会频繁用到的 Chromium 相关网站:

万事开头难,获取 Chromium 的源码就是一个大坑。Chromium 的完整源码部署完在 20G 以上,中间传输的文档大概在 10G 左右,你需要足够的带宽保障。由于功夫网的存在,你需要一个可靠的梯子。这里的可靠是指要保证拉取 10G 的资源中途不会断开连接,否则你可能需要重头开始。

在获取源码前,需要获取“获取 Chromium” 所需要的 depot_tools,这个仓库中包含了拉取 Chromium 源码以及编译 Chromium 所需要必备工具。当然,这一步也是需要梯子的。

接着将 depot_tools 目录加入你的 PATH 环境变量中,这里假定你拉取的 depot_tools 在你的 Home 目录下:

注意这里的路径中不要使用~符号。你可以将上述命令加入你的 shell 的配置文件中,如 .bashrc 或者 .zshrc。

接下来便可以拉取 Chromium 源码了。创建一个装载 chromium 源码的目录,然后切换到该目录下;

然后使用 depot_tools 里面的 fetch 工具来获取完整的 Chromium 源码。fetch 有几项很用的参数,可以 fetch –help 来查看。如果你不想获取完整的仓库历史记录,可以使用 –no-history 参数, 这会将你的下载时间缩减一半。

接下来,请准备好咖啡和喜剧片,伴你度过无聊又漫长的下载时光。这里顺带吐糟一下 Chromium 的源码维护工具 fetch 和 gclient,比较难用,新手很容易弄糊涂。

如果你是第一次获取源码,需要使用到 fetch 这个工具:

这里会创建一个 .gclient 文件,并自动开始下载代码,如果中断,可运行如下命令:

如过当前目录下已经拥有一份源码,只是想 sync 到最新,则使用 gclient:

注意:fetch 工具会在当前目录下创建两个隐藏文件 .gclient 和 .gclient_entries。前者记录着 Chromium 主仓库的 URL 等信息,后者记录着 Chromium 中众多子仓库的信息。如果没有这两个文件,gclient 将不能正常工作,后续的编译也会遇到各种问题。

源码终于拉取完了,你会看到当前目录下多了一个 src 目录,里面就是 Chromium 的完整源码。但是别急,开始编译之前,你还需要做一些准备工作:

这里的 runhooks 主要是拉取 Chromium 编译所需要的一些资源文件,比如各个目标平台的 sysroot,toolchain 等等。这些资源都存放在 Google Cloud Storage 上,通过 download_from_google_storage.py 这个脚本拉取。

注意,如果是通过 export 环境变量的方式来设置代理的话,在 runbooks 阶段会出现如下警告:

这里的解决之道是新建一个 .mobo 文件,里面记录代理设置:

然后将该文件的位置赋值给 NO_AUTH_BOTO_CONFIG 这个环境变量:

再运行 fetch chromium 或者 gclient sync 就不会有警告了。

通过上述方式获取到的是当前最新的 Chromium 源码,默认处于 master 分支。如果要切换到某个特定版本,比如当前的 stable 版本 70.0.3538.102,就要用到 chromium 的 release tag 了。

通过 fetch chromium 或者 gclient sync 方式获取的源码,默认已经带有 release tag,可通过 git tag 查看,通过 git checkout TAG 就可以切换到相应的版本:

然后再次运行 gclient sync,将其他的 git 仓库的状态按 src 仓库的 DPES 对齐:

注意:checkout 到 release tag,再次 sync 的时候,需要附加 –with_branch_heads 参数。否则可能会遇到以下错误:

三、编译入门

终于可以开始编译了。这里我们先编译当前工作平台(Linux X64)的 Chromium,熟悉一下编译流程,检验是否能编译通过,为后续交叉编译移植到其他平台做准备。

Chromium 使用了 Ninja 作为主要的构建工具,通过 GN(在低版本中,与之相对应的是 GYP)来生成 .ninja 文件。如果想玩转 Chromium 的编译和开发,熟悉 GN 的语法是必须的。这里附上一份关于 GN 使用的 Slides: Using CN Build

我们使用 GN 来生成指定的编译目录和默认的 ninjia 文件:

接着就可以使用 ninjia 开始编译了,需要指定你的编译目录(out/Default)和编译目标(chrome):

注:上面命令中的 autoninja 也可以替换为 ninjia。autoninjia 是 ninjia 的一个 wrapper,会自动提供最佳的参数值传递给 ninja。因此最好使用 autoninjia 来编译。

如果你的编译机器不是高配的服务器或者工作站,而是性能一般的个人 PC 的话,请耐心得等待四五个小时吧,甚至更多的时间。

在这四五个小时的时间里,你可以探索 Chromium 里面为数众多的编译配置选项了。了解这些,将会大大加快你的编译时间,提升你的工作效率。下面介绍几个最重要也是最常用的配置选项:

  • target_cpu。此选项值为字符串。编译出的程序的运行平台。例如编译 x64 版本时设置 target_cpu=”x64″。如果没有显式指定 target_cpu,默认值为宿主系统的 cpu 类型。通常编译 x86 版本会比 x64 版本速度更快。
  • is_clang。布尔值,默认值为 true。控制是否启用 clang 进行编译。clang 编译会比 gcc 快,并且编出来的二进制文件体积也要小一些。
  • is_debug。布尔值。值为 true 时编译 debug 版本,为 false 时编译 release 版本。
  • symbol_level。整数值。当值为 0 时,不生成调试符号,可加快代码编译链接速度。当值为 1 时,生成的调试符号中不包含源码信息,无法进行源代码级调试,但是可以加快代码编译链接速度。当值为 2 时,生成完整的调试符号,编译链接时间比较长。
  • use_jumbo_build。布尔值。控制是否启用 jumbo 编译。Jumbo 会显著提高代码编译的速度,编译时间会减少到之前的 1/10。同时,jumbo 编译也会显著增加 RAM 及 SWAP 的占用,内存较小的设备慎用,否则可能会适得其反。
  • is_component_build。布尔值。值为 true 时将 Chromium 中的诸多单元编译成小的共享库,为 false 时编译成静态库。一般编译 debug 版本时设置为 true,这样每次改动编译链接花费的时间就会减少很多。编译 release 版本时设置为 false,这样就可以把所有代码编译到一个可执行文件里面。
  • enable_nacl。布尔值。控制是否启用 Native Client。通常并不需要,可以将其值设置成 false,这样会大大加快编译速度。
  • is_official_build。布尔值。控制是否启用 official 编译模式。official 编译模式会进行代码编译优化,非常耗时。因此我们一般置为 false,仅编译 release 版本时置为 true 以开启优化。
  • remove_webcore_debug_symbols。布尔值。控制编译 blink 源码的调试符号中是否去掉源码信息。置为 true 会加快编译速度,但不能从源码级调试 blink 相关代码。
  • cc_wrapper。字符串值。可设置 cc_wrapper=”ccache” 来使用 ccache 加快编译速度。

在移植的初始阶段,我们首先要解决的是编译,编译通过是第一目标。我们一般使用下面的选项组合来加快编译进度:

更多编译选项的介绍,可以参考代码工程中 build/config 目录下的 gn 和 gni 文件的注释。使用 gn 可以查看当前目标(out/Default)的所有编译选项:

如何在编译时传递编译选项呢?有两种方式,一种是直接命令行参数显示指定,一种是手动编辑。

  • 显示指定编译选项:

通过 –args 参数传递的参数会写入到 out/Default/args.gn,然后生成 ninja 文件。

  • 手动编辑编译选项:

该命令会调用默认的编辑器打开 out/Default/args.gn 这个文件,编辑保存后,gn 会调用 gen 子命令生成 ninjia 文件。

四、交叉编译

通过以上步骤,我们成功编译了 x64 平台的 chromium,但这是万里征途第一步,我们的目标是交叉编译嵌入式(AArch64)Linux 平台下的版本。

Chromium 原生支持一些主流嵌入式平台的交叉编译,包括 ARM、AArch64、MIPS 等,但是默认的操作系统都是 Debian Linux。因此,你可以从源码直接构建适用于搭载着最新的 Raspbian 系统的树莓派的 Chromium。而对于一些 SoC 厂商深度定制的、非 Debian 的嵌入式 Linux 系统,要想运行 Chromium,则需要自己去移植了,这也是此篇文章的核心主题与目标。

针对树莓派 Raspbian 系统的交叉编译

首先,先编译一下试用于树莓派 Raspbian 的 Chromium 来熟悉交叉编译过程,在此基础上,再去进行嵌入式平台的移植。

处理交叉编译,首先需要获取目标平台的交叉编译工具链(toolchain)和包含必要头文件及运行库的系统逻辑根目录(sysroot)。适用于 ARM Linux 平台的交叉编译工具链可以直接到 ARM 官网下载,或者通过到包管理器来安装,比如:

对于 sysroot,Chromium 提供了一个脚本去获取各主流平台(ARM、AArch64、MIPS 等)Debian Linux 系统下的 sysroot。注意 Chromium 官方只提供 Debian Linux 发行版的,如果你的目前平台运行的是其他 Linux 发行版,可能最终 Chromium 将无法正常运行。

在这里,我们假定你的树莓派运行的是最新的 Raspbian 发行版。获取 sysroot:

然后就可以直接编译 arm 平台的 Chromium 了:

编译完成后,将 out/Default 目录下生成的可执行文件 chrome、natives_blob.bin、snapshot_blob.bin,共享库 lib*.so,以及一些资源文件 *.pak 等拷贝到树莓派上,正常就可以直接运行了。

针对嵌入式 Linux 系统的交叉编译

对于嵌入式 Linux 系统,一般 SoC 厂商会提供目标平台的 toolchain 和 sysroot,我们需要在通过 GN 构建系统来配置,这样最终才能成功编译出目标平台上能够正常运行的二进制文件。

其中,sysroot 的配置较为简单,直接将 target_sysroot 这个 GN 参数指定为目标平台的 sysroot 路径即可:

此部分的处理逻辑在 build/config/sysroot.gni 这个文件中:

toolchain 的配置有两种方式。一种是通过环境变量,指定 toolchain 中各个子命令的具体路径,比如 CC、CXX、AS、NM、LD 等等,最终 gn 会读取这些环境变量,写入生成的 ninja 文件中,这与 autotools、Cmake 等构件系统的做法类似。此部分的处理逻辑在 build/toolchain/linux/unbundle/BUILD.gn 这个文件中:

只需要保证所配置的环境变量在使用 gn 命令生成 ninja 文件时能被正确读到即可:

另一种配置 toolchain 的方式,则是通过 custom_toolchain 这个 GN 参数来指定。

注意这里的 custom_toolchain 参数并非是一个路径,而是通过 toolchain 模板实例化的一个配置对象,参考 build/toolchain/gcc_toolchain.gni 中的模板 gcc_toolchain 和 clang_toolchain。

对于这两种 toolchian 的配置方式,如果需要移植的目标平台比较少,建议使用环境变量的方式,这样最简单;倘若移植的目标平台比较多,像笔者所在的公司,一套软件需要移植到多个 SoC 厂商数十个不同的芯片平台上,这样还是通过 GN 构建系统来搞最合适,结合 GN 模板的强大功能,实现配置复用和定制。

以树莓派举例,我们写一个简单的 GN 构建文件,使用 gcc_toolchian 模板来实例化其 toolchain 配置项:

运行 gn 命令时,将 custome_toolchain 参数赋值为该配置实例的路径即可:

搞定了 toolchain 和 sysroot,我们的交叉编译任务便成功了一半。剩下的就是根据需要来定制 GN 配置项和编译参数,这是一个不断试错的过程。

嵌入式 Linux 系统 Chromium 移植的策略及方法

嵌入式 Linux 系统,都是高度定制裁剪后过的。可以简单理解为在标准 Linux 发行版的基础上,移除不需要的包,剥离到只剩下基本的必需的组件,而剩下的这些组件通常处于比较底层的位置,并不直接被 Chromium 所依赖。以图形系统为例,嵌入式 Linux 肯定没有 GTK/KDE 这样高级的图形化界面,通常也没有 X11/Wayland 这样的图形服务器,只有诸如 DirectFB/OpenGL 这样基础的图形库。这意味着要将 Chromium 移植到嵌入式 Linux, 一般要对接系统的图形系统、音视频编解码系统、输入设备、输入法系统等模块。

单纯移植一个可以浏览基本网页的 Chromium,一般要对接平台的图形系统;好在笔者移植的目标平台有 Wayland 这样的图形服务器,直接被 Chromium 支持,因此可以省下这部分的工作。

如果目标平台没有 X11/Wayland 这类图形服务器,可以考虑先移植一个 Headless 模式content_shell,也就是使用 Content API 所实现的一个简易版的、没有图形输出、而使用命令行输出的 Browser。这样可以最快的速度完成浏览器核心部分的移植,在此基础上再行对接图形系统、音视频、输入等其他模块。

编译一个 Headless 模式的 content_shell, 可以通过以下的参数组合:

Chromium 项目庞大无比,模块众多。很多模块会依赖系统的 library,某些 library 通常只在一些桌面 Linux 发行版中才有,而在嵌入式 Linux 系统上往往是被裁剪掉的,这样就会因为缺库而编译不通过。

幸运的是,有很多功能模块,可以通过 GN 配置项来开启和关闭,比如 use_libpci、use_cups、use_system_libdrm 等等;另外有一些 library,Chromium 项目中有其源码,一般在 src/third_party 目录下,并提供 GN 开关供你选择使用 Chromium 源码中的版本,还是系统自带的版本。

后一类开关一般以 “use_system_” 为前缀,比如 use_system_freetype 等等。结合这些开关,我们可以绕过大部分缺库的问题。如果碰到实在绕不过的的缺库问题,我们可能只有先往目标平台上移植所缺失的库了。比如笔者遇到目标平台上缺失 NSS 库,最后找来 SoC 厂商的 SDK,补上缺失的 NSS 包,重新编译并烧写系统镜像才解决。

五、系统对接

结合上一章节所述,移植 Chromium 到嵌入式平台,一般要对接系统的图形系统、音视频编解码系统、输入设备、输入法系统等模块。由于嵌入式平台的复杂性和差异性,实际情况千差万别,笔者只从实际经历出发,做一些概要性的描述。

图形系统的对接

音视频系统的对接

输入设备的对接

输入法的对接

六、定制裁剪

七、性能优化

Chromium 内存优化总结

一、概述

Chromium 在系统资源有限的嵌入式平台,常常遇到内存吃紧、性能恶化的问题,尤其体现在加载超大页面的时候。针对内存占用进行优化,是性能优化的关键。

二、Chromium 中的 Memory Reducing 方式

Chromium 对内存的处理主要集中在 RenderThreadImpl::OnMemoryPressure() 函数中;RenderThread 对象初始化的时候,会将 this 指针与该成员函数绑定,作为参数实例化一个 MemoryPressureListener 对象。

我们可以设计一个 MemoryMonitor 的类,不断地查询内存状态。当内存吃紧时,经由 RenderViewObserver 通知到 MemoryPressureListener 对象,便会回调 OnMemoryPressure() 这个函数,并传入内存压力等级:

1. 对于 DiscardableMemory 的清理

DiscardableMemory(以下简称 DM)是 Chromium 中用来缓存大对象(LOB)的内存管理类型,主要用于缺少内存交换空间(Swap)的移动终端或嵌入式设备、以及没有使用空闲内存来提升用户体验的桌面设备上。

DM 提供了一种较为简单的方式,能够更好地响应系统内存压力信号并及时释放内存。DM 有两种状态:锁定状态和解锁状态。处于锁定状态的 DM 不能被回收,解锁 DM 能够让操作系统按需回收。

DM 的实现中,定义了使用等级(usage level)去量化实例被使用的频率。随着某个实例被频繁地使用,其使用等级会相应增加。DM 以页为最小分配单位,由于内存对齐,所分配的总内存通常会比所申请的要大。因此对于小对象的内存分配,DM 并不是很有效率。需要注意的是,DM 的实例是线程不安全的,在使用时要确保没有资源竞争。


注意:由于 DM 的架构在 47 及以后的版本中有较大改动,以下的两种清理方式仅适用于 39 及以前的版本。

ReduceMemoryUsage 方法将每个 DM 实例的使用等级降低到基础等级及以下。当 DM 的平均使用等级较高时,过于频繁地调用这个方法会降低性能。但是如果调用的频率太低,又会导致内存膨胀,因此需要选择合适的频率来达到平衡。

ReduceMemoryUsageUntilWithinLimit 这个静态方法可以用来减少内存占用直到减少到给定的字节额度,让内存压力降低到正常水平。

Image Cache 的清理会直接影响到用户的使用体验。当滚动页面或切换 Tab 时会重新 decode 页面图片,造成视觉上的卡顿感。因此只在内存严重不足时,才会去清理 Image Cache,并且这个频率不能过高。

3. V8 中的 Garbage Collection

GC 是控制内存占用最后的手段,也是最关键的杀招,需要尽可能地去压榨出更多的内存。下面这个方法可以去通知 V8 去做一次 GC:

注意到 CollectAllAvailableGarbage 这个方法没有返回值,也没有指定释放的内存大小,它不会去做全面的 GC(全面的 GC 会潜在地导致 V8 的任务线程卡住)。关于 V8 的内存回收机制有很多文章分析得很透彻,在此不做详述。

三、MemoryPressureListener 的工作原理

MemoryPressureListener 提供了一个静态方法来响应平台上的内存压力信号。启动一个 Listener 只需要创建一个新的 MemoryPressureListener 实例,并将一个处理函数作为参数传入。

MemoryPressureListener 中定义了三个内存压力等级:None 等级表示内存足够使用,通常不会用来通知;Mederate 等级会尝试释放非急需且方便释放的内存;Critical 等级代表内存状态极度危险。

在 Linux 平台,如果没有足够的内存被归还给系统,导致系统可用内存不足,最终会触发 Kernel 里的 OOM (Out of Memory) killer。OOM killer 会杀掉最占用内存的进程,以腾出内存给系统使用,不致于让系统立刻崩溃。因此在内存状态为 Critical 的时候需要尽可能释放掉所有可能的内存。

通知 MemoryPresssureListener 内存状况:

值得注意的是,即便在同一个线程里,当收到系统内存吃紧的通知后,回调函数也不能保证会被同步执行。

四、Memory Monitor 的实现方式和策略

1. 关于 meminfo 的介绍

在 Linux 系统中,内核通过 /proc/meminfo 提供系统当前的内存详细信息:

随着时间的推移,系统的可用内存会越来越少,直观的体现为 MemFree 值不断减小,Cached 值会先增长后减少。对于启用了 Swap 的平台,系统会将内非活动内存换页到 Swap,以提高可用内存。

2. 对 RAM Cache 的清理

当系统内存不足时,有两种方式进行内存释放,一种是系统自己触发的内存回收,另一种是手工的方式。在某些嵌入式平台上,系统的内存回收不能及时有效地被触发,导致 cached 持续增长,MemFree 降到很低都没有释放,这时我们可以手工释放内存:

如果 page cache 中有脏数据时,操作 drop_caches 是不能释放的,必须通过 sync 命令将脏数据刷新到磁盘,才能通过操作 drop_caches 释放 page cache。

注:/proc 目录是 Linux 上的一个虚拟文件系统,我们可以通过对它的读写来与 kernel 实体间进行通信,从而对当前 kernel 的行为做出调整。

参考文档: