巧用 gcc 和 glibc 调试内存问题

工程中,我们常常会遇到内存访问越界导致的问题。一般而言,如果被踩到的内存没有被释放或者用作他途(比如恰好记录着 heap 的数据结构),是不会发生问题的。但是一旦发生 core dump,定位起来就非常麻烦。

先来看这样一段危险代码:

这里虽然发生了严重内存访问越界,但是运行时并没有什么异常:

但如果被踩到的内存后续被释放的话,就会发生问题。

我们将先前申请的内存释放掉,然后编译运行:

可以看到,在 free 的时候发生了问题。这里可以使用 gdb 找到发生内存访问越界的地方。但是如果遇到大型程序的话,类似方式往往很难定位到确切的位置。

这里提供两种方式,分别使用 C 运行库 glibc 和编译器 gcc 内置的功能,来帮忙调试类似的问题。

一、MALLOC_CHECK_

GNU 的 C 标准库(glibc)支持动态内存调试,需要通过环境变量 MALLOC_CHECK_ 来设定其行为,其取值有四个等级:

  • MALLOC_CHECK_=0 关闭所有检查
  • MALLOC_CHECK_=1 当有错误被探测到时,在标准错误输出(stderr)上打印错误信息
  • MALLOC_CHECK_=2 当有错误被探测到时,不显示错误信息,直接调用 abort 进行中断
  • MALLOC_CHECK_=3 当有错误被探测到是,同时执行 1 和 2

默认情况下,该环境变量是没有设定的,需要我们手工去设定:

再次运行上述程序,glibc 就会调用 abort 中断程序,并给出丰富的提示信息:

二、AdressSanitizer

AddressSanitizer 是 Google 出品的一款开源的内存检测工具,可以用来检测内存损坏错误,例如缓冲区溢出或访问悬空指针等一系列问题。它首先被 clang 支持,继而又被 gcc 支持,gcc 4.8 及以后的版本直接可用。

再次运行该程序,可以看到 AddressSanitizer 提示 heap-buffer-overflow 并给出了相关内存地址的申请和读写操作的 backtrace,可以快速定位问题。

当然,这么强大的工具,也是有缺陷的。AddressSanitizer 编译的程序的堆、栈占用比原生程序的大,因此对于某些嵌入式设备可能并不太适用。

关于全局变量引发的内存问题探源

 一、引子

在 C++ 中,全局和静态变量在程序中断时会被析构,无论是从 main 函数返回还是调用 exit()。然而,静态变量的构造、析构函数调用顺序是不确定的,甚至随着构建变化而变化,这样会导致许多难以预料的 bug。

笔者就曾在工作中遇到一个因静态变量引发的 core dump 问题,大费周折才调查清楚。此类问题非常有研究价值,从中能加深对编译器和链接器的理解,窥探到那些汇编程序员才会关注的隐秘细节,也能让你更加认识你自己——离一名合格的 C++ 程序员究竟还有多少距离。

闲话少说,让我们回到事故现场,细细道来。从 dump 文件获取到的 backtrace 如下:

从 backtrace 可以看出些许端倪:首先 C 运行库(libc.so.6)里面的 exit 调用表明这是在 main 函数退出之后;接着,发现一个名为 __cxa_finalize 的函数了调用一个 map 对象的析构函数,该 map 对象位于 libcr_ui.so。

Demangle 这个析构函数,可以看到该对象的类型是 std::map<std::string, int>。经过层层排查,最终在 libcr_ui.so 里面定位到了这个全局对象:

这是一个具有文件作用域的静态 std::map 型对象。查看模块依赖关系,发现这个 .cc 文件首先被编译进某静态库 libxx.a 当中,进而又被链接进 libcr_core.so 和 libcr_ui.so 这两个动态库当中。在进程退出,两个动态库分别被卸载的过程中,这个静态 map 对象先后被析构了两次,由此产生了 crash。

这是一个经典的关于编译器的坑。此问题涉及诸多有关 gcc 编译器和 ELF 共享库动态链接的细节,笔者查阅了大量的相关资料和书籍才弄清楚里面的玄机。本文旨在对此问题作一般性的阐述和总结,并通过一些演示示例来逐步加以揭示。

二、本质分析

2.1 从 C/C++ 运行库谈起

操作系统在装载程序之后,首先运行的代码并非 main 函数的第一行,而是某些别的代码。这些代码负责准备好 main 函数执行所需要的环境,并且负责调用 main 函数。在 main 返回之后,它会记录 main 函数的返回值,调用某些清理函数,然后结束进程。

这些特殊的代码称为入口函数或入口点(Entry Point),因不同的平台上而有不同的名字。程序的入口点实际上就是一个程序的初始化和结束的部分,它往往是 C/C++ 运行库的一部分。

一个典型的 C++ 程序其运行步骤大致如下:

  • 操作系统在创建进程后,把控制权交到程序入口点,即运行库中某个入口函数;
  • 入口函数对运行库和运行环境进行初始化,包括堆、I/O、线程、全局变量构造等;
  • 入口函数在完成初始化之后,调用 main 函数,开始执行程序主体部分;
  • main 函数执行完毕以后,返回到入口函数,入口函数进行清理工作,包括全局变量的析构、堆销毁、关闭 I/O 等,然后进行系统调用结束进程。

由此我们看到,运行库才是程序世界的创世者和终结者,是这个隐秘世界的真正主宰。

Windows 和 Linux 平台下主要的 C 运行库分别为 glibc (GNU C Library) 和 MSVC (Microsoft Visual Run-time),它们都是标准 C 语言运行库的超集,各自对 C 标准库进行了一些扩展。本文主要关注 Linux 平台下的 C/C++ 运行库。

2.2 关于 glibc 和 libstdc++

glibc 的发布版本主要包含两部分:一部分是头文件,比如 stdio.h,stdlib.h 等,它们往往位于 /usr/include;另外一部分则是库的二进制文件。二进制部分主要就是 C 语言的标准库,它有静态和动态两个版本。动态的标准库位于 /usr/lib/libc.so.6,而静态标准库则位于 /usr/lib/libc.a。类似的,Linux 的 C++ 运行库为 libstdc++.so / libstdc++.a。

一般而言,C++ 运行库都是依赖于 C 运行库的,它仅仅包含对 C++ 的一些语言特性方面的支持,比如 STL、new/delete、异常处理、流(stream)等。但是并不包括诸如入口函数、堆管理、基本函数操作这些特性,而这些也是 C++ 运行库所必需的,比如 C++ 的流和文件操作依赖于 C 运行库的基本文件操作,所以它必须依赖于 C 运行库。

除非在编译时显示指定静态(-static)链接 C/C++ 运行库,默认情况下运行库都是动态链接到可执行文件的:

上面示例中的 libc.so.6 就是运行时动态加载的 C 运行库。glibc 的启动过程在不同的情况下区别很大,比如静态链接的 glibc 和动态链接的 glibc,glibc 作用于可执行文件和作用于共享库。以下仅探讨静态链接的 glibc 作用于可执行文件的情况。

2.3 glibc 的入口函数

glibc 的程序入口是一个叫 _start 的函数,由汇编实现,并且与平台密切相关。_start 函数经过层层调用,最终调到用一个名为 __libc_start_main 的函数。

注:入口函数是由 ld 链接器默认的链接脚本所指定的,我们也可以通过相关参数设定自己的入口函数。

在 glibc 的源码里面,找到 __libc_start_main 函数的声明头,它是通过宏来定义的:

这个函数一共有 8 个参数。其中第一个参数传入 main 函数指针,紧接着是我们非常熟悉的 argc 和 argv。此外,还传入了三个函数指针:init 指向 main 函数调用前的初始化例程,fini 指向 main 结束后的收尾工作,rtld_fini 代表与动态库加载有关的收尾工作,rtld 是 runtime loader 的缩写。最后的 stack_end 参数代表栈底的地址,即最高的栈地址。

上面的函数参数里,init 和 fini 这两个函数指针实际分别指向函数 __libc_csu_init 和 __libc_csu_fini,csu 为 “C start up” 缩写。

紧接着,__libc_csu_init 函数又调用了 _init 函数,里面调用的是程序的 “.init” 段,它是由各个输入目标文件中的 “.init” 段拼接而来的。

通过反汇编,发现 _init 调用了一个叫作 __do_global_ctorx_aux 的函数。这个函数并不属于 glibc,而是来自 gcc 提供的一个目标文件。该函数位于 gcc/Crtstuff.c,简化后的代码如下:

这段代码中,__CTOR_LIST__ 数组的第一个元素存放着这个数组元素的个数,然后将第一个元素之后的元素都当做是函数指针,并一一调用。事实上,__CTOR_LIST__ 里面存放的是所有全局对象的构造函数的指针,所有的全局对象会在这里被构造。

注:链接器在进行最终链接时,有一部分目标文件是来自于 gcc,它们是那些与语言密切相关的支持函数,比如 C++ 的全局对象构造是与语言密切相关的,相应负责构造的函数来自于 gcc。

2.4 全局对象的构造

由于 ELF 文件的改进,出现了必须在 main 函数之前执行全局、静态对象的构造函数,以及必须在 main 函数之后执行全局、静态对象的析构函数等需求。为满足类似的需求,运行库在每个目标文件中引入了两个特殊的段:”.init” 段和 “.finit” 段,运行库会保证所有位于这两个段中代码先/后于 main 函数执行。对于全局、静态对象,又引入了两个特殊的段 “.ctors” 和 “.dtors”,分别处理构造和析构。这两个段经过编译器的层层处理,最终会分别合入到 “.init” 段和 “.finit” 段当中。

对于每个编译单元(.cpp),gcc 编译器会遍历其中所有的全局对象,生成一个特殊的函数(_GLOBAL__I_Hw),这个特殊函数负责本编译单元内所有全局、静态对象的构造和析构。一旦一个目标文件里有这样的函数,编译器会在这个编译单元产生的目标文件(.o)的 “.ctors” 段里放置一个指针,这个指针指向的便是 GLOBAL__I_Hw。

在编译器为每个编译单元生成一份特殊函数之后,链接器在链接这些目标文件时,会将同名的段合并在一起,这样每个目标文件的 .ctors 段会被合并为一个 .ctors 段,其中的内容是各个目标文件的 .ctors 段的内容拼接而成。由于每个目标文件的 .ctors 段都只存储了一个指针(指向该类里的全局构造函数),因此拼接起来的 .ctors 段就成为了一个函数指针数组,每一个元素都指向一个目标文件的全局构造函数。

把每个目标文件的全局或静态对象的构造函数地址放在一个特殊的段里,为的是能够让链接器把这些特殊的段收集起来。收集齐所有的的全局构造函数后,便可以在初始化的时候进行构造。

2.5 全局对象的析构

对于 glibc 和 GCC,在完成对象的构造之后,程序结束前,运行库还要进行对象的析构。正常的全局对象析构与前面介绍的构造是完全一样的,并且所有的函数、符号名都一一对应。

__libc_start_main 会将 __libc_csu_fini 通过 __cxa_exit() 注册到退出列表中,这样当进程退出前 exit() 里面就会调用 __libc_csu_fini,接着会调用 _fini。_fini 的原理同 _int 的原理是一样的。

编译器对每个编译单元的全局对象,都会生成一个特殊函数来调用这个编译单元的所有全局对象的析构函数,它的调用顺序与调用构造函数的顺序恰好相反。

这样做的好处是保证了全局构造和析构的顺序(先构造,后析构),链接器必须包装所有的 “.dtors” 段,其合并顺序必须是 “.ctors” 段的严格反序。但是这样增加了链接器的工作量,后来人们放弃了这种做法,采取了一种新的做法,通过 __cxa_atexit() 在 exit() 函数中注册进程退出回调函数来实现析构。

综上所述,C++ 全局构造与析构的实现是比较特殊的,它与编译器、链接器密切相关。它们的实现依赖于编译器、链接器和运行库三者的协作。全局构造的实现主要依赖于特殊的段合并后形成构造函数数组,全局析构的实现则依赖于 atexit() 函数。

注:由于全局对象的构建和析构都是由运行库来完成的,于是在程序或者共享库中有全局对象时,不能使用 “-nostartfiles” 或 “-nostdlib” 选项,否则构建与析构函数不能正常执行。

2.6 ELF 文件

为了进一步理解全局对象的构造和析构所带来的挑战,我们还需要了解共享库以及链接装载的原理。这里从 ELF 文件谈起。

ELF 的全称是可执行与可链接格式(Executeable and Linking Formate),它有三个不同的类型:可定位的、可执行的和共享目标(shared objects)。

可定位文件由编译器和汇编器创建,但在运行前需要被链接器处理。可执行文件完成了所有的重定位工作和符号解析(除了那些可能需要在运行时被解析的共享库符号)。共享目标就是共享库,既包括链接器和共享器所需的符号信息,也包括运行时可以直接执行的代码。

2.7 链接时重定位

因为编译是以源文件为单位进行的,编译器此时并没有一个全局的视野,因此对一个编译单元内的符号它是无力确定其最终地址的。而对于可执行文件来说,在现代操作系统上,程序加载运行的地址则是固定、可以预期的。在链接时,链接器可以直接计算分配该文件内各种段的绝对或相对地址。因此,对于可执行文件来说,可在链接阶段进行模块内部的符号重定位,这就是链接时重定位。

2.8 装载时重定位

如果可执行程序用到了共享库里面的函数,那么情形就不一样了。因为动态库的加载是在运行时,且加载的地址不固定,因此没法事先确定该模块的起始地址,所以对动态库的符号重定位,只能推迟。

共享库里的符号重定位是怎么解决的呢?目前来说,Linux 下 ELF 主要支持两种方式:装载时符号重定位及地址无关代码。地址无关代码接下来会讲到。对于装载载时的重定位,其原理很简单,它与链接时重定位是一致的,只是把重定位的时机放到了动态库被加载到内存之后,由动态链接器来进行。

系统在装载程序的时候,需要对程序的指令和数据中对绝对地址的引用进行重定位。因为整个程序是按照一个整体被加载的,程序中指令和数据的相对位置是不会发生改变的。比如一个程序在编译时假设被装载的目标地址为 0x1000,但是装载时操作系统发现 0x1000 这个地址已经被别的程序使用了,而从 0x4000 开始有一块足够大的空间可以容纳该程序,那么该程序就可以被装载至 0x4000,程序指令或数据中的所有绝对引用只要都加上 0x3000 的偏移量就可以了。这就是装载时重定位(Load Time Relocation)。

2.9 地址无关代码

装载时重定位是解决动态模块中有绝对地址引用的办法之一,但它有一个很大的缺点是指令部分无法在多个进程之间共享,这样就失去了动态链接节省内存的一大优势。还需要一种更好的方法解决共享对象指令中绝对地址的重定位问题。

我们希望程序模块中共享的指令部分在不要因为状态地址的改变而改变。其实现的基本想法就是把指令中那些需要被修改的部分分离出来,跟数据部分放在一起,这样指令部分就可以保持不变,而数据部分则可以在每个进程中拥有一个副本。这种方案就是目前被称为地址无关代码(PIC,Position-Independent-Code)的技术。

2.10 PIC 与 GOT

为了理解地址无关代码的原理,我们先来分析共享模块各种类型的地址引用方式。我们把共享对象模块中的地址引用按照是否为跨模块分为两类:模块内部引用和模块外部引用,按照不同的引用方式又分为指令引用和数据访问,这样就组合得到四种情况:

  • 1) 模块内部的函数调用、跳转;
  • 2) 模块内部的数据访问,比如模块中定义的全局变量、静态变量;
  • 3) 模块外部的函数调用、跳转等;
  • 4) 模块外部的数据访问,比如其他模块中定义的全局变量。

这四类情况示例如下:

对于每种类型,分别来探讨其关于地址无关代码的解决方案。

类型一:模块内部函数调用

被调用的函数与调用者同处于一个模块,它们之间的相对位置是固定的,所以这种情况是比较简单的。对于现代的体系结构来讲,模块内部的跳转、函数调用都可以是相对地址调用,或者是基于寄存器的相对调用,所以对于这种指令是不需要重定位的,也就达到了地址无关代码的目标。

类型二:模块内部数据访问

由于指令中不能包含数据的绝对地址,那么唯一的办法就是相对寻址。我们知道,一个模块前面一般是若干页的代码,后面紧跟着若干页的数据,这些页之间的相对位置是固定的。任何一条指令与它需要访问的模块内部数据之间的相对位置是固定的,那么只需要相对于当前指令加上固定的偏移量就可以访问模块内部数据了。

现代体系结构中,数据的相对寻址往往没有相对于当前指令 PC 的寻址方式。但是,ELF 用了一个巧妙的办法来得到当前的 PC 值,然后再加上一个偏移量就可以达到访问相对变量的目的。因此,这类指令也不需要重定位,可以做到地址无关代码。

类型三 模块间的数据访问

模块间的数据访问比模块内部稍微麻烦一点,因为模块间的数据访问目标地址要等到装载前才决定。比如上面例子中的变量 b,它被定义在其他模块中,并且该地址要在装载时才能确定。

要使得地址无关代码,基本的思想就是把跟地址相关的的部分放在数据段里面。ELF 的做法是在数据段里面建立一个指向这些变量的指针数组,也称为全局偏移表(Global Offset Table,GOT),当代码需要引用该全局变量时,可以通过 GOT 中相对应的表项间接引用。

链接器在装载模块的时候,会查找每个变量所在的地址,然后填充 GOT 中的各个项,以确保每个指针所指向的地址正确。由于 GOT 本身是放在数据段的,所以它可以在模块装载时被修改,并且每个进程都可以有独立的副本,相互不受影响。

当指令要访问变量 b 时,程序会先找到 GOT,然后根据 GOT 中变量所对应的表项找到变量的目标地址。

那么问题来了,那 GOT 自身又是如何做到与指令的地址无关呢?

从第二种类型的数据访问我们了解到,模块在编译时可以确定模块内部变量相对于当前指令的偏移,那么我们也可以在编译时确定 GOT 相对于当前指令的偏移。确定 GOT 的位置跟上面的访问变量 a 的方法基本一样,通过得到 PC 值然后加上一个偏移量,就可以得到 GOT 的位置。然后根据变量在 GOT 中的偏移量就可以得到变量的地址。

当然, GOT 表项中每个地址对应于哪个变量是由编译器决定的,比如第一个地址对应变量 b,第二个对应变量 c 等。

类型四 模块间的调用、跳转

对于模块间调用和跳转,我们也可以采用上面类型三的方法来解决。所不同的是,GOT 中相应的表项保存的是目标函数的地址,当函数要调用目标函数时,可以通过 GOT 中的表项进行间接跳转。

这样,我们针对每种不同的指令类型,似乎都找到了能够地址无关代码的解决方案。

2.11 全局变量的地址无关代码

定义在其他模块的全局变量的地址是跟模块装载地址有关的。当一个模块引用了一个定义在共享对象的全局变量 global,而模块中是这么引用的:

当编译器编译 module.c 时,它无法根据这个上下文判断 global 这个变量是定义在同一个模块的其他目标文件还是定义在另外一个共享对象之中,即无法判断是否为跨模块间的调用。

假设 module.c 是可执行文件的一部分,那么在这种情况下,由于程序主模块的代码并不是地址无关代码,也就是说代码不会使用这种类似于 PIC 的机制,它引用全局变量的方式跟普通数据的访问方式一样,编译器会产生这样的代码:mov1 $0x1,xxxxxxxxx;其中,XXXXXXXX 就是全局变量 global 的地址。

由于可执行文件在运行时并不进行代码重定位,所以变量的地址必须在链接过程中确定下来。为了能够使得链接过程正常进行,链接器会在创建可执行文件时,在它的 “.bss” 段创建一个 global 变量的副本。那么问题就来了,现在 global 变量定义在原先的共享对象中,而在可执行文件的 “.bss” 段还存在一个副本。如果同一个变量同时存在于多个位置,这在程序实际运行过程中肯定是不可行的。

解决的办法只有一个,那就是所有使用这个变量的指令都指向位于可执行文件中的那个副本。ELF 共享库在编译时,默认都把定义在模块内部的全局变量当作定义在其他模块的全局变量,也就是说当作前面的类型四,通过 GOT 来实现变量的访问。

当共享模块被装载时,如果某个全局变量在可执行文件中拥有副本,那么动态链接器就会把 GOT 中相应地址指向该副本,这样该变量在运行时实际上最终就只有一个实例。如果变量在共享模块中被初始化,那么动态链接器还需要将该初始化值赋值给程序主模块中的全局副本;如果该全局变量在程序主模块中没有副本,那么 GOT 中的相应地址就指向模块内部的该变量副本。

如果 module.c 是一个共享对象的一部分,那么 GCC 编译器在 -fPIC 的情况下,就会把对 global 变量的调用按照跨模块模式产生代码。原因很简单:编译器无法确定对 global 的引用是跨模块的还是模块内部的。即使是模块内部,对全局变量的引用,按照上面的结论,还是会产生跨模块代码,因为 global 可能被可执行文件引用,从而使得共享模块对 global 的引用要执行可执行文件中的 global 副本。

2.12 地址无关可执行文件

通过上面的方法,能够保证共享对象中的代码部分地址无关,但是数据部分是不是也有绝对地址引用的问题呢?让我们来看看这样一段代码:

如果某个共享对象有这样一段代码的话,那么指针 p 的地址就是一个绝对地址,它指向变量 a,而变量 a 的地址会随着共享对象的装载地址改变而改变。那么有什么方法解决这个问题呢?

当编译器在编译 pic.c 时,它实际并不能确定变量 b 和函数 ext() 是模块外部的还是模块内部的,因为他们都有可能被定义在同一个共享对象的其他目标文件中。由于没法确定,编译器只能把它们都当作模块外部的函数和变量来处理。

对于数据段来说,它在每个进程中都有一份独立的副本,所以并不担心被进程改变。从这点来看,我们可以选择装载时重定位的方法来解决数据段中绝对地址引用问题。对于共享对象来说,如果数据段中有绝对地址引用,那么编译器和链接器就会产生一个重定位。例如,Linux x86 平台,重定位表里面会包含 “R_386_RELATIVE” 类型的重定位入口,用于解决上述问题。当动态链接器装载共享对象时,如果发现该共享对象有这样的重定位入口,那么动态链接器就会对该共享对象进行重定位。

实际上,我们甚至可以让代码段也使用这种装载时重定位的方法,而不是用地址无关代码。通常,使用 gcc 编译共享对象时我们需要使用了 “-fPIC” 参数,这个参数表示产生地址无关的代码段。如果我们不使用这个参数来产生共享对象又会怎么样呢?

这样就会产生一个不使用地址无关代码而使用装载时重定位的共享对象。但如前面分析过的一样,如果代码不是地址无关的,它就不能被多个进程之间共享,于是也就失去了节省内存的优点。但是装载时重定位的共享对象的运行速度要比使用地址无关代码的共享对象快,因为它省去了地址无关代码中每次访问全局数据和函数时需要做一次计算当前地址以及间接地址寻址的过程。

对于可执行文件来说,默认情况下,如果可执行文件是动态链接的,那么 GCC 会使用 PIC 的方法来产生可执行文件的代码段部分,以便于不同的进程能够共享代码段,节省内存。所以我们可以看到,动态链接的可执行文件中存在 “.got” 这样的段。

注:在 64 位操作系统上,gcc 默认已强制使用地址无关代码来编译共享对象,即便你没有指定 -fPIC/-fPic 参数,相关讨论在这里

三、演示示例

通过以上的梳理,我们大致了解了 C 运行库和链接器器所做的工作,以及 ELF 文件实现地址无关代码的原理。下面再从演示示例来进一步理解本文所提到的问题的根源:

3.1 演示示例源码及结构

为演示需要,这里提供一个微型的项目,其结构描述如下:

test.c 是主程序,包含有两个头文件:api1.h 与 api2.h;头文件 api1.h 包含头文件 lib1/lib.h 和一功能函数 func_api1(),api2.h 包含头文件 lib2/lib.h 和一功能函数 func_api2();目录 lib1 和 lib2 下的源文件分别编译生成共享库 lib1.so 和 lib2.so。同时,头文件 lib1/lib.h 与 lib2/lib.h 链接到同一共享文件 lib.h。在文件 lib.h 中定义有一静态成员变量 static std::vector<int> vec_int。

源码目录层级结构如下:

源码文件内容如下:

通过 Makefile 可以快速理清这个示例项目的依赖关系:lib.cc 经编译生成静态库 libcommon.a;共享库 lib1.so 和 lib2.so 分别由 api1.cc 和 api2.cc 生成,均链接静态库 libcommon.a;可执行文件 a.out 由 test.cc 生成,链接共享库 lib1.so 和 lib2.so。

lib1.so 和 lib2.so 分别提供了功能函数 func_api1 与 func_api2,其实现类似,都是通过调用类 A 的静态成员函数来访问和操作其静态成员变量 vec_int。

3.2 重复析构背后的真相

与全局变量类似,静态成员变量也采用了静态存储方式。对于加了选项 -fpic 或 -fPIC的共享库,这些变量的地址都存放在该共享库的全局偏移表(GOT)中。

在上述示例中,由于链接同一个静态库 libcommon.a,导致两个共享库中都有 class A 的实现,存在着同名的符号。又由于在生成共享库时使用了 -fPIC 选项,最终同名的静态成员变量 vec_int 分别位于两个共享库的 GOT 区。

编译并运行上述程序,可以观察到有 crash,提示发生 double free,涉及到的地址正是我们在 Class A 里面定义的静态成员变量 vec_int 第一个元素的地址。

通过获取 backtrace,并 demangle 相关的符号,可以看到 core dump 正好发生在这个 std::vector 对象的析构函数里。

运行时,系统从符号表中查找并装载构造一份 vec_int。当程序结束时,对该变量进行了两次析构操作。

3.3 动态库的装载与卸载

观察上述演示示例的执行结果,main 函数开始之前,有 lib2_init 和 lib1_init 两个函数先后被执行,这是在这两个共享库分别被装载时发生的;同样,在 main 函数结束之后,lib1_fini 和 lib2_fini 先后被执行,这发生在两个共享库分别被卸载的时候。

这是因为我们使用了 __attribute__ 关键字分别把这些函数声明为 contructor 属性 和 destructor 属性,这样它们就会被编译器分别放入 ELF 文件的 “.init” 段和 “.fini” 段,从而先/后于 main 函数被执行。

此外,另一个值得注意的地方是,共享库的装载是明显有先后顺序的,并且卸载的顺序跟装载严格反序。这里,之所以共享库 lib2.so 会先于 lib1.so 被装载,是因为我们在可执行文件 a.out 的链接阶段指定了先后次序:

编译器传递给链接器的参数,其解析是按照从右往左的顺序来处理的。链接阶段,右侧的库会先于左侧的库被链接;同理,运行时右侧的库也就会先于左侧的库被装载。这也就这解释了上述示例中,lib2.so 为何最先被链接,而又最后被卸载。

由于链接器对共享库的处理是有顺序的,我们在开发过层中应该尽量按垂直关系来链接共享库,越是底层的库越是放到后面,这样也可以避免循环依赖。

四、解决方案

4.1 按 PIE 的标准生成共享库

在 2.9~2.12 节我们探讨了地址无关代码和地址无关可执行文件的机理。如果我们按地址无关可执行文件的方式来编译共享库,生成的共享库就不再会为静态成员变量或全局变量在 GOT 中创建对应的条目,从而避免了由于静态对象“构造一次,析构两次”而对同一内存区域释放两次引起的程序 core dump。

如上所示,使用 gcc 的 -fpie/-fPIE 选项来编译静态库和两个共享库,最终可以避免重复析构。选项 -fpie/-fPIE 与 -fpic/-fPIC 的用法很相似,区别在于前者总是将生成的位置无关代码看作是属于程序本身,并直接链接进该可执行程序,而非存入全局偏移表中。这样,对于同名的静态或全局对象的访问,其构造与析构将保持一一对应。

这里的关键在于静态库 libccomon.a 的编译选项必须是 -fpie/-fPIE,它直接决定了类成员变量 A::vec_int 是否存在于 GOT 中。如果只修改两个共享库的编译选项,是没有效果的。另外,-pie 参数是链接器 ld 的参数,用于生成地址无关可执行文件,它必须配合编译器参数 -fpie/-fPIE 才能发挥作用,单独使用任何一个都无效达到效果。

对于 gcc 编译器,小写的 -fpic 大写的 -fPIC 的区别在于:全局偏移表大小的上限是平台相关的,如果使用 -fpic,超出限制后链接器将会报错,这时应该使用 -fPIC。而小写的 -fpie 与大写的 -fPIE 基本没有区别。

注:经笔者测试,此方法只适用于 X86 平台,不适用于 X64 平台。大致原因是 gcc 默认强制使用地址无关代码来编译共享对象,即便使用 -fpie/-fPIE 选项,最终也还是按照 -fpic/-fPIC 的方式来编译。具体原因有待深入研究。

4.2 杜绝多个动态库链接同一静态库

正如上述示例,在工程中,如果同一静态库如果被多个动态库或者可执行文件所链接的话,说明项目的模块组织结构存在冗余,存在优化的空间。这样不仅会增加二进制文件的体积,还会引入全局变量的重复析构问题。因此在实践中,我们要尽量避免这种情况的发生。这也能从根源上解决本文提到全局变量被重复析构的问题。

举例来说,两个模块(A、B)依赖于同一个模块(C),如果模块 A 和 B 都是共享库的话,那么 C 也应该是共享库而不应该是静态库,这样就可以避免静态库被重复链接。按照这个思路,我们改造上面实例程序的模块结构,将 lib.cc 编译成一个共享库:

这样,class A 仅在 libcommon.so 里面被定义,不会发生符号的重复,其静态成员变量也只存在一份,也就不会再被重复析构。

4.3 控制共享库的符号可见性

对于 C++ 编程,性能是重要的关注点。然而,由于对其他库的依赖性以及使用特定的 C++ 特性(比如模板),编译器/链接器趋向于会使用和生成大量的符号。因此,导出所有符号会减慢程序速度,并耗用大量内存。而导出有限数量的符号可以缩短动态共享库的加载和链接时间,这也意味着会生成更有效率的代码,同时也对库的安全有益。

如何控制动态共享对象(DSO)中的符号呢,主要有如下几种方式:

4.3.1 使用 static 关键字

这种方式的局限性在于它只对本文作用域的普通变量或者函数有效。它为文件中的符号禁用了外部链接。这是一种语言级别的控制,也是最简单的一种隐藏符号的方式。这意味着带有关键字 static 的符号永远不会是可链接的,因为编译器不为链接器留下关于此符号的任何信息。

然而,对于 C++ 的类成员变量/函数,static 关键字已然是另一层含义,并不能达到隐藏符号的效果。

4.3.2 使用 -fvisibility 编译选项

GCC 提供了-fvisibility 编译选项,其可选取值为 default 或者 hidden。前者表示编译对象中对于没有显式地设置为隐藏的符号,其属性均对外可见;后者将隐藏没有进行显式设置的符号。

对于编译没有显式指定的情况,则 GCC 编译器默认使用 -fvisibility=default,表示库的符号均对外可见。

4.3.3 定义 visibility 属性

GCC 同时支持使用 visibility 属性定义符号的可见性。它为此定义 4 个类型,但是大多数情况下,最常用的只有前两个:

  • STV_DEFAULT: 用它定义的符号将被导出。换句话说,它声明符号是到处可见的。
  • STV_HIDDEN: 用它定义的符号将不被导出,并且不能从其他对象进行使用。
  • STV_PROTECTED: 符号在当前可执行文件或共享对象之外可见,但是不会被覆盖。换句话说,如果共享库中的一个受保护符号被该共享库中的另一个代码引用,那么此代码将总是引用共享库中的此符号,即便可执行文件定义了相同名称的符号。
  • STV_INTERNAL:符号在当前可执行文件或共享库之外不可访问。

要定义 visibiliy 属性,需要包含 __attribute__ 和用括号括住的内容。当符号的可见性指定为 visibility(“hidden”),这将不允许它们在库中被导出,但是可以在源文件之间共享。实际上,隐藏的符号将不会出现在动态符号表中,但是还被留在符号表中用于静态链接。这是一种良好定义的行为,完全可以达到我们的目的。它显然优于 static 关键字方法。

注:对于用 visibility 属性指定的变量,将它声明为 static 可能会让编译器感到混淆,因此编译器会显示一条警告消息。同时 attribute 机制的优先级要高于 -fvisibility 编译选项。

4.3.4 使用导出列表

符号可见性主要动态链接中涉及到,这正是链接器的需要处理的。然而上面解决方案似乎都是针对于编译器的,有没有直接针对链接器的呢?解决方案是导出列表。

导出列表常常由编译器或相关工具在创建共享库的时候自动生成,也可以由开发者手工编写。导出列表由链接器选项传入并当作链接器的输入。

导出列表的原理是,显式地告诉链接器可以通过外部文件从对象文件导出哪些符号。此类外部文件被作为“导出映射”。我们可以为本文的示例编写一个导出映射:

上面的描述告诉链接器,只有 func1 符号将被导出,其他符号(由 * 匹配)是局部的。你也可以显式地列出其他的符号为局部符号。但是很明显,使用匹配符(*)更为方便。一般来说,高度推荐使用匹配符(*)来将所有符号都标记为 local,挑出需要导出的符号指定为 global,这样更简单也更安全,可以避免一些非预期的行为。

对于上面的演示程序,我们只需要控制这个静态的 std::vector 对象的符号可见性就能达到目的。对于上面的四种方式,第一种显然不可能了。为简单起见,我们采取第二种方式,即通过 visibility 属性来控制这个成员变量的可见性。

这样一来,这个类成员变量在模块(共享库)外就不再可见了,不会因为 PIC 被复制到可执行程序中去,也就避免了重复析构的发生。

4.4 全局/静态对象的局部化

当我们需要一个全局或者静态变量时,可以考虑使用局部静态变量来代替:

这段代码构造了一个局部的静态对象,避免了指针的直接传递,保证了全局对象的单实例。由于将拷贝构造函数声明为 private,使得所有需要使用该对象的地方,都需要声明为引用。这样,即便是进程结束,其构造函数也不会被调用。

针对上面的演示示例,我们将原本定义子类中的静态成员变量,改为局部的静态变量:

这样,这个静态对象的构造函数在进程结束时便不会被调到,从根本上避免了被重复析构的可能。

4.5 阻止进程退出时的析构

某些情况下,我们希望一些重要的全局对象在程序运行的整个过程中都存在,生命周期跟进程相同。

例如 Chromium 里面的 V8 对象,就是一个跟进程生命周期相同的全局变量。任何时候都不允许它被析构掉,如果被析构掉,HTML 就无法执行 JavaScript 了。

这种情况下,其实是要防止全局对象被析构,即便是进程结束也不要调用其析构函数。除了上面提到的局部静态变量的方法可以禁止全局对象被析构以外,还有一个方法就是 quick_exit。程序结束的时候调用 quick_exit() 来替代 exit(),这样全局对象的析构函数就不会被调用了。

我们改造上面的 test.cc,使用 quick_exit() 来退出:

重新编译后运行,发现进程结束时,我们观察到共享库里显式注册的 .fini 段函数都没有被执行,也包含了那个静态成员的析构函数。因此,double free 也不再会发生了。

注:quick_exit() 与 exit() 的不同之处是前者不会执行任何析构,也不会执行 atexit() 所绑定的任何 handlers。如果想在执行 quick_exit() 中断时执行某 handler(比如刷 log),可以把它绑定到 _at_quick_exit()。如果想在 exit() 和 quick_exit() 都用上该 handler,应该都绑定上去。

五、编程建议

Google 的 C++ 编码规范中有专门指出关于全局和静态变量的注意事项,如果我们能遵循相关建议,基本上能从根源上避免此类问题。

  • 禁止使用 class 类型的静态或全局变量:它们会导致难以发现的 bug 和不确定的构造和析构函数调用顺序。constexpr 变量除外,毕竟它们不涉及动态初始化或析构。
  • 静态生存周期的对象,即包括了全局变量,静态变量,静态类成员变量和函数静态变量,都必须是原生数据类型(POD : Plain Old Data);即 int, char 和 float, 以及 POD 类型的指针、数组和结构体。
  • 不允许用函数返回值来初始化 POD 变量,除非该函数(比如 getenv() 或 getpid())不涉及任何全局变量。
  • 尽量不用全局函数和全局变量,考虑作用域和命名空间限制,尽量单独形成编译单元。
  • 多线程中的全局变量(含静态成员变量),不要使用 class 类型(含 STL 容器),避免不明确行为导致的 bug。
  • 函数作用域里的静态变量除外,毕竟它的初始化顺序是有明确定义的,而且只会在指令执行到它的声明那里才会发生。

综上,我们只允许 POD 类型的静态变量,禁用 std::vector(使用 C 数组替代)和 std::string(使用 const char []),以及其他所有的容器类型。如果确实需要一个 class 类型的静态或全局变量,可以考虑使用函数返回一个局部静态变量来代替,或者是在 main() 函数或 pthread_once() 内初始化一个指针且永不回收。注意只能用 raw 指针,别用智能指针,毕竟后者的析构函数涉及到上文指出的不定顺序问题。

六、结束语

曾有先贤踩进这个坑里,并且提交了一份 patch,用于对此类全局同名对象的问题加上编译时的警告。但是 gcc 官方好像并没有接受这个 featrue,相关讨论在这里。比较一致的看法是,这个问题的这并不是 gcc 的 bug,而因该归咎于程序员对全局变量不合理的重定义或者对编译模块不合理的组织。

因笔者水平有限,本文相当一部分是引用相关书籍及其他相关主题文章的内容,后面会附上参考文献及引用的地址链接。原作者如觉不当,请联系笔者删除。本文的不当或者错误之处,也恳请批评校正。

参考文献:

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

图形系统的对接

音视频系统的对接

输入设备的对接

输入法的对接

六、定制裁剪

七、性能优化