使用 Prelink 加速程序启动

一、What is Prelink?

1.1 Prelink 简介

Prelink 是 Red Hat 开发者 Jakub Jelinek 所设计的工具。正如其名字所示,Prelink 利用事先链接代替运行时链接的方法来加速共享库的加载。它不仅可以加快起动速度,还可以减少部分内存开销,是各种 Linux 架构上用于减少程序加载时间、缩短系统启动时间和加快应用程序启动的很受欢迎的一个工具。

Linux 系统运行时的动态链接尤其是重定位 (Relocation) 的开销,对于大型系统来说是很大的。相比之下,早期 UNIX 下的 a.out 格式的老式链接方法在速度和占用内存方面有明显的优势(但不如ELF格式更灵活,能方便的构建动态共享库)。Prelink 工具是试图在保持一部分灵活性的基础上,借鉴 a.out 格式在速度和占用内存方面的优点,对 ELF 文件进行一些改进。

Prelink 工具的原理主要基于这样一个事实:动态链接和加载的过程开销很大,并且在大多数的系统上,函数库并不会常常被更动,每次程序被执行时所进行的链接动作都是完全相同的,对于嵌入式系统来说尤其如此。因此,这一过程可以改在运行时之前就可以预先处理好,即花一些时间利用 Prelink 工具对动态共享库和可执行文件进行处理,修改这些二进制文件并加入相应的重定位等信息,节约了本来在程序启动时的比较耗时的查询函数地址等工作,这样可以减少程序启动的时间,同时也减少了内存的耗用。

Prelink 的这种做法当然也有代价:每次更新动态共享库时,相关的可执行文件都需要重新执行一遍 Prelink 才能保证有效,因为新的共享库中的符号信息、地址等很可能与原来的已经不同了。这种代价对于嵌入式系统的开发者来说可能稍微带来一些复杂度,不过好在对用户来说几乎是可以忽略的。

很多 Linux 发行版上已经预装了或者已经使用了 Prelink 工具,不过我们需要适用于嵌入式平台,比如 ARM 的版本,这样我们需要到下载 Prelink 的源代码并重新编译。

1.2 Prelink 机理

从我们最熟悉的 hello world 程序开始分析:

我们知道,printf 是在 c语言运行库 libc 中定义的。如果不使用动态库,也就是使用glibc 的静态库版本,链接到 a.out 中的话,那么 printf 函数的地址在运行之前就是已知的,很简单的一句地址转移就可以完成了。

可是使用动态库的话,在程序编译阶段,我们是无法得知 printf 的函数地址,因为动态库的加载的内存地址是随机的。那么对于动态库的情况,针对 printf 是如何寻址的呢?

在程序启动时,当调用 printf 的时候,程序会将处理权交给 loader,由其负责在进程以及其链接的动态库中查找 printf 的函数地址。由于 loader 不知道 printf 是在哪个动态库,所以它将在整个进程和动态库的范围内查找。更糟糕的是在 C++ 程序中,符号的命名是类名+函数名,这导致在做字符串比较时,往往直到字符串的结尾才能获得结果。

这就导致了,在进程启动过程中,符号查找往往占据了大部分时间。据统计,在 Linux 的 KDE 进程中启动过程中,符号查找表竟占据了进程启动 80% 的时间。有没有办法来改进呢?

如果进程在运行前,就能获知动态库的加载地址,那么函数调用的地址就应该是已知的,我们就可以通过修改执行程序,来避免符号的查找。从而节省进程启动的时间。

实际上 Prelink 正是这么做的。Prelink 最早是在 Redhat 中引用的,用来加速 KDE 的启动速度。那时侯 Prelink 作为系统的一个进程,不定期的启动,对系统中的进程和动态库进行优化,这在系统中进程和动态库不怎么变化的情况下非常有用。

在做 Prelink 时,需要为其指定需要做 Prelink 的进程和动态库的目录。Prelink 需要做以下几件事情:

  • 分析所有的进程和动态库,为每个动态库指定一块唯一的(虚拟)内存地址;
  • 分析进程和动态库中,所有需要重定位的函数、全局变量等,用 loader 进行符号查找,对齐地址进行解析;
  • 修改进程中和动态库的二进制文件;

众所周知,在 32 位 Linux 操作系统上有 4G 的地址空间,3G 以上为操作系统使用,0000000~4000000 归进程的代码段、数据段和堆段使用,从 3G 往下归栈段使用。基本上我们可以认为从 1G~3G 的地址空间可以用来指定动态库的加载地址,地址空间还是很丰富的。

凡事总有万一,如果地址空间不够怎么办呢?Prelink 关于这个问题,做了两个约定:

  • 总是一同出现的动态库,其动态库的加载地址一定不能重叠;
  • 总是不同时间段出现的动态库,其动态库的加载地址可以重叠;

有了这两个约定之后,基本上就可以保证,为每个动态库指定加载地址,从而在运行前就能获知函数和全局变量等符号的地址。

更多有关 Prelink 的具体做法和细节可以参考 Prelink 的开发者 Jakub Jelinek 的专文介绍。以下两份文档提供了有关 prelink 的原理简介和性能评估,极具参考价值:

二、How to prelink?

2.1. Prelink 的交叉编译

2.1.1 获取源码

原版的 prelink 不适用于嵌入式平台;需要使用 Yocto Project 下的 prelink-cross 版本:

也可以通过 git 获取最新的源码:

注意,需要切换到 cross 分支。

2.1.2.交叉编译

prelink 工具类似于 gcc 等工具链,如果处理的 ELF 文件所属系统架构不同于宿主系统架构(也就是当前的操作系统),则需要指定交叉编译参数。例如,如果目标软件运行的平台为 arm,需要将 -target 参数指定为 arm-linux。

此外,还需要加上 –without-sysroot 参数,使得我们编译出来的 prelink 工具可以在运行时指定 sysroot 路径。

2.2. Prelink 的使用详解

针对目标程序 target_bin 的 prelink 过程如下:

对于上述 prelink 过程所用到的重要参数解释如下:

  • –root 选项指定包含目标程序和系统共享库的 sysroot 路径,也就是嵌入式系统的根目录拷贝到宿主操作系统上的路径;后续所有路径都可指定为 sysroot 的相对路径。上述操作中,–root 参数指定的目录,其层级结构应与板子上根目录层级结构一致:

  • –cache-file 参数用于指定prelink 建立索引过程中的 cache 文件;
  • –config_file 参数用于指定 prelink 的配置文件,默认为 /etc/prelink.conf 。里面是所有需要进行 prelink 的 ELF 文件路径;如果 prelink 后加 -a 选项,则会处理此文件中所有的目录或文件;
  • –ld-library-path 参数用于指定目标可执行文件运行时的共享库搜索路径。由于我们需要从 target_bin 开始进行 prelink,因此指定其运行时的 LD_LIBRARY_PATH。
  • -h 和 -l 参数用于指定对目录为目录的软连接的不同处理方式;加 -h 参数时,会处理软链接文件指向的目标目录;加 -l 参数时,如果目标目录跨文件系统,则会忽略。
  • -b 参数用于添加黑名单,所有用 –b 参数指定的目标都不会处理。
  • -a 参数表示会处理配置文件中所有添加的路径或文件。
  • -m 节省虚拟定址分配;如果有大量的共享库需要 prelink 就会需要这个选项。
  • -R 参数会为共享库选择随机的基址;这个是为安全考虑。
  • -f 强制重新 prelink 已经做过 prelink 的 ELF 文件。prelink 默认会忽略之前已经被 prelink 的 ELF 文件。
  • -v 参数表示会输出中间的详细处理过程。

其他参数的解释请参考 man 手册。

单个可执行文件的 prelink 处理时间在秒级,如果对整个系统进行 prelink,可能要花几分钟或者十几分钟。

这里需要注意,使用 prelink 处理多个可执行文件时,如果每个文件运行时的动态库搜索路径不同,建议通过指定 LD_LIBRARY_PATH 来分别处理,而非通过 -a 参数一次性处理,否则可能会 prelink 错误的共享库,导致运行时 prelink 机制并没能发挥作用。

如果需要取消已经做过 Prelink 的 ELF 文件的,也非常简单:

警告:在对本机的 ELF 文件进行 prelink 处理过程中,如果被强制中断,可能会将整个系统弄崩掉。

三、Prelink 注意事项

3.1. 地址无关代码

需要被 Prelink 的 ELF 文件,无论是共享库还是可执行文件,编译时必须加 -fpic/-fPIC 参数,生成目标无关地址代码。对于可执行文件,不能使用 -fpie/-fPIE 加 –pie 生成地址无关可执行文件,否则无法被 prelink。

这个结论是根据上述测试程序得出的,其中的详细机理有待进一步研究。

3.2. 检查Prelink 状态

可以使用 readelf 和 objdump 工具来检查一个 ELF 文件是否已经被 prelink。例如:

注意观察到 6~14 行,对比没有被 prelink 之前的状态,INIT、FINI、STRTAB、SYMTAB 等 section 的地址已经修改为运行时进程空间的虚拟内存地址。第 30 行,RELACOUNT 表示已经预先进行重定位的符号的数量;第 31 行是 prelink 根据 ELF 所直接依赖的共享库计算的 MD5 值,该值用于判断该 ELF 所以来的共享库是否被修改过;从第 32 行可以看出该 ELF 已被加上 PRELINKED 标记和时间戳。

但是,并非所有被成功 prelink 的 ELF 文件都会加上 PRELINKED 的标记和时间戳。在用 prelink 处理完我们的SDK的后,发现 target_bin 所有的依赖项都有 PRELINKED 标记,target_bin 自身并没有此标记。但是通过测试其启动速度,确有巨大的提升,证明 prelink 在 target_bin 上确实发挥了作用。

至于为什么没有这个标记,暂时还没有调查清楚,仍待进一步研究。

对于上述情况,通过 objdump 等工具查看ELF文件的 section header,我们仍然可以发现 prelink 处理后留下的蛛丝马迹。

Prelink 之前,查看 target_bin 的节头:

Prelink 之后,再次查看节头:

对比 prelink 前后的节头信息,我们发现 prelink 后每个节的地址都有了调整,增加了.gnu.liblist , .gnu.conflict 和 .gnu.prelink_undo 这三个节。同时 .dynstr 节的 size 由 0xa3cb1 增加到了 0xa3e8c。这些都是 prelink 之后 ELF 的 size 有所增大的原因。

3.3. 查看ELF依赖树

Prelink 的处理过程是从目标 ELF 文件开始,检查其依赖树。从叶子节点开始处理,自底向上,直至根节点。若中间任何节点处理异常,则目标文件都无法被 prelink。同理,如果已经被 prelink 处理的 ELF 文件,如果其依赖树的中任何节点对应的 ELF 文件有更改,则需要从根开始重新 prelink。如果被更改的 ELF 所处的层级较低,被很多可执行文件依赖,则可能整个系统的 ELF 都需要重新进行 Prelink 处理。

可以使用 lddtree 查看 ELF 文件的依赖树。但是这个工具比较鸡肋,只适用于处理本机的 ELF 文件,无法像 prelink 一样可以在运行时指定 sysroot 和 LD_LIBRARY_PATH。

下面提供了一个 shell 脚本,可以指定 sysroot 和 LD_LIBRARY_PATH,输出 ELF 文件依赖图,仅供参考:

使用方法如下:

此脚本可以指定 sysroot 和 LD_LIBRARY_PATH 输出任意 ELF 文件的依赖图,并同时检查各节点的 prelink 状态,支持 graphviz / dot 和 csv 格式。

3.4. 不必要的依赖项

如果 prelink 在处理某个 ELF 文件(记为 A)的过程中,发现 A 并没有使用其直接依赖的另一个 ELF 文件(记为 B),而 A 又通过 C 间接依赖到 B,并且 B 已经被 prelink 处理。此时,A 将无法被 prelink。

3.5. 动态加载的共享库

Prelink 对于通过 dlopen 方式打开的共享库没有效果。

Linux C 获取系统及进程内存状态

一、系统内存

Linux 提供了诸多的方式来获取系统内存状态。在 shell 下,我们可以通过 free 或者 top 等命令来获取。但是在 C/C++ 程序中又该如何获取呢?

这就不得不提到 Linux 提供的 proc 虚拟文件系统了,它提供了一个在 linux 内核空间和用户间之间进行通信的窗口。通过这个窗口,内核可以告诉我们很多系统信息。其中涉及到系统内存的,主要是 /proc/meminfo 文件:

该文件告诉我们关于系统内存的全方位的信息。于是通过读取并解析该文件,就可以到达获取内存状态的目的:

如果我们只是想获取一些常见的内存状态,比如 RAM Free,不需要知道太多细节,那么使用 GNU C 提供 sysinfo 函数是一个不错的方案。

头文件 <sys/sysinfo.h> 中声明了 sysinfo 函数,并定义了 sysinfo 结构体:

通过 sysinfo 函数,我们可以获取上述系统状态:

二、进程内存

进程的内存状态就相对比较复杂了,后续待补充。。。

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 的行为做出调整。

参考文档: