深入Android系统(二)Bionic库

Bionic库是Android的基础库之一,也是连接Android和Linux的桥梁。Bionic库中包含了很多基本系统功能接口,这些功能大部分来自 Linux,但是和标准的 Linux 之间有很多细微差别。同时Bionic库中增加了一些新的模块(像loglinker),服务于Android的上层代码。

导读

为甚有导读呢,因为Bionic库这部分学起来太枯燥了,在实际开发过程中几乎没主动使用过,不过并不代表它不存在哈,这哥们无处不在。。。

通过Bionic库的学习呢,至少可以:

  • 了解Android在一些系统库的实现与Linux实现的区别
  • 了解Android是怎么执行系统调用
  • 了解内存管理函数并再一次认识Doug Lea老爷子(猜猜为什么要说
  • 了解管道这种比较原始的进程间通信手段
  • 了解Android在线程管理上和Linux的区别
  • 了解一个叫Futex的同步机制(YY:感觉jvm的同步和这个很像)
  • 了解我们常用的Log.d()的具体实现和Log系统的结构
  • 了解.so.o都是什么类型的文件(可执行文件)以及通用结构是啥样子的
  • 了解动态链接的核心模块linker是怎样工作的
  • 了解一个叫ptrace的系统调用,为以后Hook API做准备
  • 了解到一些常用的开源协议
  • 了解部分 Android 的变更和新特性

咳咳,有木有发现这么多的了解字眼?因为从这几天本人大脑的表现来看,这种不常用的姿势大脑会习惯性的忘记,只能以了解来安慰自己了。。。。。

-------导读到此结束-------

Bionic库到底是干啥用的呢?看下简介先。

Bionic简介

Bionic包含了系统中最基本的 lib 库,包括libclibmlibdllibstdc++libthread,以及Android特有的链接器linker

其实当时已经有成熟开源的GNU Libc库了,不过GNU Libc库遵守的是GPL开源协议。GPL有个特点就是传染性:一旦系统中有软件使用了GPL授权协议,那么该系统相关代码必须开源。

网上找到一篇讲解Google和Linux 内核的GPL约束的文章,可以了解下历史。而关于开源协议,文章最后罗列了一些常见的开源协议。

我们继续了解Bionic

Bionic是 Google 在BSD开源协议的C库基础上加入 Linux 的特性而生成的。Bionic名字就是BSDLinux
的混合。BSD协议是一种几乎不受限制的开源方式,比较受商业公司的喜爱。(普遍爱加密混淆不是)

除了版权问题,性能也是Google重新开发libc库的原因。Bionic针对移动设备上有限的CPU周期和可用内存进行了裁剪(去掉了很多和进程、线程、同步相关的高级功能),在减少库大小的同时也提高了工作效率。

Bionic中的模块简介

我们再看下Bionic的目录结构(简化版本):

├── Android.mk
├── libc
├── libdl
├── libfdtrack  #5.0对比新增的,而且libthread_db不见了
├── libm
├── libstdc++
├── linker

对比9.0和5.0,这部分的变化挺大的,差异部分不先深究了(主要是跟Linux不怎么熟。。。),所以Bionic部分暂时以了解为主吧,迫不及待要看Binder了哈哈哈

Libc 库

Libc 库是C语言最基础的库文件,它提供了所有系统的基本功能,这些功能主要来源于它对系统调用的封装,Libc 库是应用和Linux内核交互的桥梁。

Libc 库提供的主要功能如下:

  • 进程管理:包括进程的创建、调度策略的设置、优先级的调整等
  • 线程管理:包括线程的创建和退出、线程的同步和互斥、线程本地储存等。
  • 内存管理:包括内存的分配和释放
  • 时间管理:包括获取和保存系统时间,获取当前系统运行时长等
  • 时区管理:包括时区的设置和调整。
  • 定时器管理:提供系统的定时服务。
  • 文件系统管理:提供文件系统的挂载和移除功能。
  • 文件管理:包括文件或者目录的创建、删除、属性修改等。
  • Socket:创建、监听Socket;发送接收数据。
  • DNS解析:帮助解析网络地址。
  • 信号:用于进程间通信。
  • 环境变量:设置和获取系统的环境变量。
  • Android Log:提供和Android Log驱动进行交互的功能。
  • Android 属性:管理一个共享区域来设置和读取Android的属性。
  • 标准IO:提供格式化的输入输出功能
  • 字符串:提供字符串的移动、复制、比较等功能
  • 宽字符:提供对宽字符(应该就是是UNICODE字符)的支持

Libm 库

数学函数库,提供了常见的数学函数和浮点运算功能。不过Android中的浮点运算是通过软件实现的,相比硬件支持的浮点运算运行速度慢,最好避免使用。

libdl 库

libdl 库原本是用于动态库的装载,但是Android的libdl 库中dlopendlclosedlsym等函数的实现只是一个空壳。应用进程使用的dlopen等函数实际上是在linker模块中实现的。

libstdc++ 库

libstdc++ 是标准的c++库,但是在Android中的实现非常简单,只是newdelete等少数几个操作符的实现。

Linker

首先linker不是编译程序时用的链接器,编译程序用到的链接器是arm-elf-ld。(此乃编译期)

linker的功能是装载动态库以及用于库的重定位,相当于Linux中的ld.so。(此乃运行时)

Android没有使用Linux的ld.so,而是自己开发了这个linker程序

Bionic C 库中的系统调用

有点偏底层了,忍住、忍住,继续学习

系统调用简介

Linux 的系统调用就是 Linux 内核提供给用户进程使用的一套接口。用户进程可以通过这些接口来获得Linux内核提供的服务,例如:打开和关闭文件、启动退出一个进程、分配和释放内存等。

但是Linux的系统调用并不等同于普通的API调用,api是函数的定义,规定了这个函数的功能,跟内核无直接关系。而系统调用是通过中断向内核发请求,实现内核提供的某些服务。

现代CPU一般都实现了特权等级(x86 CPU)或工作模式(arm CPU)

x86 CPU包含4个特权级别Ring0~Ring3

  • Ring0的权限最高,可以使用任何CPU指令,Linux的内核代码就是运行在这个级别下。
  • Ring3的权限最低,很多CPU指令都被限制使用,普通用户进程的代码就运行在Ring3下。

arm CPU则有7中工作模式:

  • 用户模式(usr):普通用户进程工作的模式,权限比较受限
  • 快速中断模式(fiq):用于高速数据传输或通道处理
  • 外部中断模式(irq):用于通用的中断处理
  • 管理模式(svc):操作系统使用的保护模式(高权限),复位和软件中断进入
  • 数据访问终止模式(abt):当数据或指令预取终止时进入该模式,可用于虚拟内存及存储保护
  • 系统模式(sys):运行均有特权的操作系统任务
  • 定义指令终止模式(und):用于支持硬件协处理器的软件仿真(浮点、微量运算)

无论是x86的特权等级还是arm的工作模式,目的都是将系统内核和用户进程分开,防止用户进程对系统内核进行破坏。同时,系统内核也能必须为用户进程提供服务。

应用程序如何使用系统调用呢?

首先,我们要了解的是,操作系统一般是通过中断来从用户态切换到内核态的。

那我们下面了解下中断的相关姿势

中断的相关知识

中断一般分为三类:

  • 由计算机硬件异常或故障引起的中断,称为内部异常中断
  • 由程序中执行了引起中断的指令而造成的中断,称为软中断(这也是和我们将要说明的系统调用相关的中断,软中断通常是一条指令,使用这条指令用户可以手动触发某个中断。);
  • 由外部设备请求引起的中断,称为外部中断

简单来说,中断的理解就是中止当前运行去处理一些特殊事情。

中断一般有两个属性,中断号中断处理程序

不同的中断有不同的中断号,每个中断号都对应了一个中断处理程序

内核中有一个叫中断向量表的数组来映射这个关系。当中断到来时,cpu会暂停正在执行的代码,根据中断号中断向量表找出对应的中断处理程序并调用执行。执行完成后,会继续执行之前的代码。

中断号是有限的,所有不会用一个中断来对应一个系统调用(系统调用有很多)。Linux下用int 0x80触发所有的系统调用

那如何区分不同的调用呢?对于每个系统调用都有一个系统调用号,在触发中断之前,会将系统调用号放入到一个固定的寄存器,0x80对应的中断处理程序会读取该寄存器的值,然后决定执行哪个系统调用的代码。

不同平台的系统调用

在x86平台下,应用程序使用软中断0x80来调用系统功能;arm平台则使用swi软中断。同时Linux为每个系统调用都进行了编号(0~NR_syscall),并在内核中保存了一张系统调用表,这张表保存了系统调用编号和它对应的服务程序。

在x86上,通过eax寄存器来传递系统调用号。例如:

movl    $__NR_brk, %eax
 int    $0x80,     120

除了使用eax传递系统调用号外,许多系统调用还需要传递一些参数到内核。x86平台按顺序使用寄存器ebxecxedxesiedi来传递参数。例如:

mov    16(%esp),    %ebx
mov    20(%esp),    %ecx
mov    24(%esp),    %edx
movl   $__NR_write,  %eax
int    $0x80

在arm平台中,Android中的系统调用是通过swi的0号软中断来实现的,例如:

mov    ip,  r7
ldr    r7,  =__NR_brk
swi    0

好的,简单了解了中断的内容,不过指令示例看的有点晕,下面单独对这部分说明一下

中断相关的指令说明

寄存器分类(详情请看这位大神的文章)

  • 4个数据寄存器:EAXEBXECXEDX
  • 2个变址寄存器:ESIEDI
  • 2个指针寄存器:ESPEBP
  • 6个段寄存器:ESCSSSDSFSGS
  • 1个指令指针寄存器:EIP
  • 1个标志寄存器:EFlags

中断指令:

  • swi:arm 软中断指令(Software Interrupt, SWI)

    指令格式如下:swi immed_24

    • 其中:immed_24 24位立即数,值为从0――16777215之间的整数。内核程序通过该软中断立即数来区分用户不同操作,执行不同内核函数。
  • int:x86 软中断指令

    指令格式如下:int op_num

    • 其中:op_num表示对应的中断号

上面用到的相关ARM指令:

  • mov :寄存器间数据移动指令
  • ldr:内存和CPU间数据移动指令

上面用到x86架构指令:

  • x86中没有ldr指令,因为x86的mov指令可以将数据从内存中移动到寄存器中。
  • movx:其中 x 可以是下面的字符:
    • l用于32位的长字值
    • w用于16位的字值
    • b用于8位的字节值

有了这部分上面的指令就比较好理解了。不管怎样到这里先暂停了百度了,越查资料问题越多。。。。

系统调用的实现方法

在路径bionic/libc/arch-x86/syscalls下存放的是系统调用的汇编代码(arm、mips的实现代码在arch-armarch-mips目录下)。

每个系统调用放在一个文件中,每个文件中只有一小段的汇编代码实现,称为syscall stub,以mount.S文件为例,我们看下文件内容:

/* Generated by gensyscalls.py. Do not edit. */
#include 
ENTRY(mount)
    pushl   %ebx
    .cfi_def_cfa_offset 8
    .cfi_rel_offset ebx, 0
    pushl   %ecx
    .cfi_adjust_cfa_offset 4
    .cfi_rel_offset ecx, 0
    pushl   %edx
    .cfi_adjust_cfa_offset 4
    .cfi_rel_offset edx, 0
    pushl   %esi
    .cfi_adjust_cfa_offset 4
    .cfi_rel_offset esi, 0
    pushl   %edi
    .cfi_adjust_cfa_offset 4
    .cfi_rel_offset edi, 0

    call    __kernel_syscall
    pushl   %eax
    .cfi_adjust_cfa_offset 4
    .cfi_rel_offset eax, 0

    mov     28(%esp), %ebx
    mov     32(%esp), %ecx
    mov     36(%esp), %edx
    mov     40(%esp), %esi
    mov     44(%esp), %edi
    movl    $__NR_mount, %eax
    call    *(%esp)
    addl    $4, %esp

    cmpl    $-MAX_ERRNO, %eax
    jb      1f
    negl    %eax
    pushl   %eax
    call    __set_errno_internal
    addl    $4, %esp
1:
    popl    %edi
    popl    %esi
    popl    %edx
    popl    %ecx
    popl    %ebx
    ret
END(mount)

全是一些汇编指令,指令的具体内容就不详解了,可以参照中断相关的指令说明

还有一点就是这些代码不是人工手写的,是通过gensyscalls.py脚本根据bionic/libc/SYSCALLS.TXT文件生成的。

我们看下SYSCALLS.TXT文件部分内容格式:

# signals
int     __sigaction:sigaction(int, const struct sigaction*, struct sigaction*)  arm,mips,x86
int     __rt_sigaction:rt_sigaction(int, const struct sigaction*, struct sigaction*, size_t)  all

标准的格式(这部分9.0和5.0还是不太一样的,列出的是9.0格式):

return_type    func_name[:syscall_name[:call_id]]([parameter_list])    platform

分为三部分:

  • 第一部分是函数的返回类型。
  • 第二部分是函数名和参数。
    • func_name指的是函数名称,也就是C程序调用时的名称。
    • syscall_name指的是系统调用的名称,这是一个内部名称,主要用来生成系统编号的宏定义。生成宏定义的方法是在syscall_name前面加上字符串__NR_。以_exit函数为例,它在SYSCALLS.TXT文件中的定义为
      void    _exit:exit_group(int)    all
      
      对应生成的系统编号的宏定义为__NR_exit_group。这些宏是在 Linux kernel 代码中定义的。
    • call_id指的是系统调用号,一般不用列出。通过syscall_name生成的宏定义可以得到系统调用编号。
    • parameter_list 指的是参数列表
  • 第三部分是用来指定目标平台,包括:allarmmipsx86

Bionic中的内存管理函数

暂不细谈哈,再细谈要偏离我在本书的学习路线了。。

关于内存的一些说明

对于传统32位处理器来说,寻址空间最大为4GB。其中0~3 GB地址空间分配给用户进程使用,3~4 GB由内核使用。

用户进程并不是在启动时就获得了对所有0~3 GB地址空间的访问权限,而是要事先向内核申请对某块地址的读写权限。而且申请的只是地址空间而已,并没有分配实际的物理内存

只有当进程访问某个内存地址时,如果该地址对应的物理页面不存在,则由内核产生缺页中断,在中断中才会分配物理内存并建立页表。如果用户进程不需要某块地址空间了,可以通过内核释放掉他们,对应的物理内存也同时被释放掉。

由于缺页中断运行缓慢,如果频繁的由内核来分配释放内存会降低整个系统的性能。因此,一般操作系统都会在用户进程提供地址空间的分配和回收机制,也就是内存管理器:

  • 内存管理器会预先向内核申请一块大的地址空间,称为
  • 当用户进程需要分配内存时,由内存管理器中寻找一块空闲的内存分配给用户进程使用。
  • 当用户进程释放某块内存时,内存管理器并不会立即交给内核释放,而是放到空闲列表中,留待下次分配使用。

内存管理器也会动态调整堆的大小:

  • 当堆空间使用完了会继续向内核申请
  • 当堆中内存空闲太多也会返还一部分给内核

Linux 的内存管理方法

Linux 有两种方式来申请和释放内存空间,一种是使用系统调用brk;另一种是使用系统调用mmapmunmap

Linux 内核通常会将用户地址空间划分为一些大的区域,如代码区数据区等。

先看个示意图:
深入Android系统(二)Bionic库_第1张图片

brk

系统调用brk的作用是调整高地址边界。分配内存时把边界推高,释放内存时把边界拉低。

brk的优点是分配内存快,缺点是可能分配不到大块的内存空间。因为堆区栈区之间的区域不是完全的空白区域,可能有部分内存已经被分配出去了。

堆区高地址边界如果遇到了已分配的区域就会导致分配失败。因此brk通常用来分配比较小的内存空间,比如小于256KB的内存块。

mmap

系统调用mmap用来分配大块的内存空间。mmap会在堆区栈区之间寻找一块合适的空间分配给用户进程使用。

  • 如果内存大小不合适,还可以通过系统调用mremap来改变大小。
  • 使用完成后可以通过munmap释放掉内存空间。

Bionic 的内存管理器

Android 7.0 以前是可以指定dlmalloc或者jemalloc来作为内存管理器的;后来 7.0 增加了一个Project Svelte;再后来,我在9.0的项目中找不到dlmalloc的独立源码了。。

jemalloc的知乎传送门

而关于dlmalloc,这部分来自百度百科哈。

dlmalloc 是目前一个十分流行的内存分配器,其由Doug Lea从 1987 年开始编写,到目前为止,最新版本为2.8.3,由于其高效率等特点被广泛的使用和研究。
dlmalloc的实现只有一个源文件(还有一个头文件),大概5000行,其内注释占了大量篇幅,由于有这么多注释存在的情况下,表面上看上去很容易懂,的确如此,在不追求细节的情况,对其大致思想的确很容易了解(没错,就只是了解而已),但是dlmalloc作为一个高品质的佳作,实现上使用了非常多的技巧,在实现细节上不花费一定的精力是没有办法深入理解其为什么这么做,这么做的好处在哪,只有当真正读懂后回味起来才发现它是如此美妙。

这是继Java 多线程后又一次听说Doug Lea了,多线程核心几乎是老爷子一个人手撸出来的(尤其是AQS),真滴大神哇

尽管时至今日,dlmalloc中的技术在一些地方已然落后于时代,已经很多优秀的allocator:像 google的tcmalloc, freeBSD的jemalloc等在某些情况下性能可以达到dlmalloc的数十甚至上百倍。但dlmalloc的很多思想和基本算法对后来者产生了深远的影响。

dlmalloc 的简单了解

dlmalloc源码下载链接

dlmalloc以链表的方式将堆区的空闲空间根据尺寸组合在一起。分配内存时通过这些链表能快速找到合适大小的内存。如果不能找到满足要求的空闲空间,dlmalloc会使用系统调用扩大空间

先看下分块示意图:
深入Android系统(二)Bionic库_第2张图片

dlmalloc的内存块被称为trunk,每块大小要求按地址对齐(默认为8字节),因此trunk块的大小必须是8的倍数。

dlmalloc使用三种不同的链表结构来组织不同大小的空闲内存块:

  • 尺寸小于 256 Byte的块使用结构malloc_chunk按尺寸组织在一起。由于空间小于256字节,因此,最多使用32个malloc_chunk结构的环形链表来组织小于256字节的块
  • 大于 256 Byte的块由结构malloc_tree_chunk组成的链表管理,这些块根据尺寸组织成二叉树
  • 当超过某个更大的尺寸(DEFAULT_MMAP_THRESHOLD指定的默认阈值为256 KB),则由系统通过mmap的方式单独分配一块内存空间,并通过结构malloc_segment组成的链表进行管理
dlmalloc分配内存流程

通过查找这些链表来快速找到一块和要求尺寸最匹配的空闲内存块(这样可以尽可能的避免内存碎片)。如果没有合适大小的内存块,则将一块大的分成2部分,一块分配出去,另一部分根据大小再加入对应的空闲列表中。

dlmalloc释放内存流程

会将相邻的空闲块合并成一个大块来减少内存碎片。如果空闲块过多,超过了dlmalloc内部设定的一个阈值,dlmalloc就开始向系统返回内存(应该就是向内核释放)。

dlmalloc的函数的简单说明

书中还介绍了一些具体函数暂不扩展哈

dlmalloc除了能管理进程的空间外,还可以提供私有堆管理。所谓的私有堆,是指在外单独分配的一块地址空间,由dlmalloc按同样的方式进行管理。
可以通过以下特征区分是否为私有堆函数:

  • dlmalloc中用来管理进程空间的函数都带有dl前缀,如dlmallocdlfree
  • 私有堆的管理函数则带有mspace_前缀,如mspace_mallocmspace_free

管道

管道是从Unix系统出现的一种进程间通信的手段,分为匿名管道(PIPE)命名管道(FIFO)两种。

历史上管道是半双工的,即数据同一时刻只能在一个方向上流动。现在一些系统提供全双工的管道。但是为了可移植性,我们假定系统无此功能。

如果需要IPC,最佳做法是使用Binder;如果只需要线程间通信,可以使用匿名管道

匿名管道一些知识

匿名管道主要用于父子进程间的通信。Android 支持

创建匿名管道会在内核建立一个内存文件(这个文件在文件系统中不可见)和两个文件描述符。管道使用者通过这两个文件描述符来读写内存文件中的数据,从而达到信息交换的目的。

通常情况下,文件描述符不能在进程间传递,只有父子进程兄弟进程间可以通过继承的方式来共享文件描述符。因此,匿名管道主要用于父子进程间的通信。

匿名管道属于半双工的数据只能从一端到另一端。示意图如下:
深入Android系统(二)Bionic库_第3张图片
管道两端的进程将管道看成同一个文件,一个进程负责向管道中写,另一个进程则从管道中读取。如果进程间需要双向通信,则不需建立起两条管道。

我们看下匿名管道的特征:

  • 只提供单向通信,也就是说,两个进程都能访问这个文件,假设进程1往文件内写东西,那么进程2 就只能读取文件的内容。
  • 只能用于具有血缘关系的进程间通信,通常用于父子进程建通信
  • 管道是基于字节流来通信的
  • 依赖于文件系统,它的生命周期随进程的结束结束(随进程)
  • 其本身自带同步互斥效果

匿名管道最大的好处是简单、灵活,但是只能用于父子进程间通信限制了它的使用。因此,后来出现了命名管道

命名管道的特点

命名管道又被称为先进先出队列(FIFO),是一种特殊的管道,通过建立一个inode节点存在于文件系统中。Android 不支持(貌似与mkfifoFAT32文件系统格式有关)

命名管道匿名管道非常类似,但是又有自身的显著特征:

  • 命名管道可以用于任何两个进程间的通信,而不限于同源的两个进程。
  • 命名管道作为一种特殊的文件存放在文件系统中,而不是像匿名管道那样存放在内核中。当进程对命名管道的使用结束后,命名管道依然存在于文件系统中,除非对其进行删除操作,否则该命名管道不会自行消失。

匿名管道一样,命名管道也只能用于数据的单向传输,如果要用命名管道实现两个进程间数据的双向传输,建议使用两个单向的命名管道。

Bionic中的线程管理函数

Bionic中的线程管理函数和统一 Linux 版本的实现有很多差异,Android 根据自己的需要做了很多裁剪工作。

Bionic线程函数的特性

Android 线程管理pthread相关的源码实现位于bionic/libc/bionic/pthread*

Android中的pthread基于Futex实现,并同时使用更简短的代码来实现通用操作,简单记录下部分特性:

  • pthread_mutex_t,pthread_cond_t定义的类型只有4个字节

  • 支持normalrecursiveerror-check互斥量属性。

    • PTHREAD_MUTEX_NORMAL:这种类型的互斥锁不会自动检测死锁。如果一个线程试图对一个互斥锁重复锁定,将会引起这个线程的死锁。如果试图解锁一个由别的线程锁定的互斥锁会引发不可预料的结果。如果一个线程试图解锁已经被解锁的互斥锁也会引发不可预料的结果。
    • PTHREAD_MUTEX_ERRORCHECK:这种类型的互斥锁会自动检测死锁。 如果一个线程试图对一个互斥锁重复锁定,将会返回一个错误代码。 如果试图解锁一个由别的线程锁定的互斥锁将会返回一个错误代码。如果一个线程试图解锁已经被解锁的互斥锁也将会返回一个错误代码。
    • PTHREAD_MUTEX_RECURSIVE:如果一个线程对这种类型的互斥锁重复上锁,不会引起死锁。一个线程对这类互斥锁的多次重复上锁必须由这个线程来重复相同数量的解锁,这样才能解开这个互斥锁,别的线程才能得到这个互斥锁。如果试图解锁一个由别的线程锁定的互斥锁将会返回一个错误代码。如果一个线程试图解锁已经被解锁的互斥锁也将会返回一个错误代码
  • 不支持pthread_cancel函数。与之替代的是pthread_cleanup_pushpthread_cleanup_pop以及pthread_exit函数。

这部分其实还包括pthread的线程操作函数、TLS线程本地储存、互斥量Mutex、条件量Condition的介绍,感觉有些深入。所以先简单了解到这里吧,等需要的时候再来细看。(PS:偷个懒)

Futex同步机制

Futexfast userspace mutex的缩写。Futex是Linux的一个基础组件,可以用来构建各种更高级别的同步机制,比如锁或者信号量等等。Android中不但线程函数使用了Futex,甚至一些模块中也直接使用了Futex作为进程间同步的手段。

Linux 从 2.5.7 开始支持Futex。在类Unix系统中,传统的进程间同步机制都是通过对内核对象进行操作来完成的,这个内核对象在需要同步的进程中都是可见的。这种方式因为涉及到内核态与用户态的切换,效率比较低。

Futex的解决思路是:在无竞争的情况下操作完全在user space进行,不需要系统调用,仅在发生竞争的时候进入内核去完成相应的处理(wait 或者 wake up)。所以说,futex是一种user modekernel mode混合的同步机制,需要两种模式合作才能完成,Futex变量必须位于user space,而不是内核对象Futex的代码也分为user modekernel mode两部分,无竞争的情况下在user mode,发生竞争时则通过sys_futex系统调用进入kernel mode进行处理。

Futex的系统调用

在Linux中,Futex的系统调用如下:

#define __NR_futex      240

对应的Futex系统调用的原型是:

#include 
#include 
int futex (int *uaddr, int op, int val, const struct timespec *timeout,int *uaddr2, int val3);

其中:

  • *uaddr:就是用户态下共享内存的地址,里面存放的是一个对齐的整型计数器
  • op:表示操作类型,有五种预定义值,在Bionic中只使用了下面两种:
    • FUTEX_WAIT: 原子性的检查uaddr中计数器的值是否为val,如果是则让进程休眠,直到FUTEX_WAKE或者超时(timeout)。也就是把进程挂到uaddr相对应的等待队列上去。
    • FUTEX_WAKE: 最多唤醒val个等待在uaddr上进程。

Bionic中,提供了两个函数来包装Futex系统调用(位于bionic/libc/private/bionic_futex.h):

static inline int __futex_wait(volatile void* ftx, int value, const timespec* timeout) {
  return __futex(ftx, FUTEX_WAIT, value, timeout, 0);
}
static inline int __futex_wake(volatile void* ftx, int count) {
  return __futex(ftx, FUTEX_WAKE, count, NULL, 0);
}

还有两个类似的函数:

static inline int __futex_wake_ex(volatile void* ftx, bool shared, int count) {
  return __futex(ftx, shared ? FUTEX_WAKE : FUTEX_WAKE_PRIVATE, count, NULL, 0);
}
static inline int __futex_wait_ex(volatile void* ftx, bool shared, int value) {
  return __futex(ftx, (shared ? FUTEX_WAIT_BITSET : FUTEX_WAIT_BITSET_PRIVATE), value, nullptr,
                 FUTEX_BITSET_MATCH_ANY);
}

_ex后缀的函数对比前两个函数多了一个shared参数:

  • shared的值为true时,表示waitwake操作是用于进程间的挂起和唤醒
  • shared的值为false时,表示waitwake操作是用于进程内线程间的挂起和唤醒

Futex的同步逻辑

首先明确下Futex变量值的状态:

  • 0 表示无锁状态;
  • 1 表示有锁无竞争状态;
  • 2 表示有竞争状态。

流程如下:

  • 创建一个全局的整型变量作为Futex变量(一个整型计数器),初始值为0。如果用于进程间同步,这个变量必须位于共享内存。
  • 当进程或线程持有锁的时候,检查Futex变量是否为 0。
    • 如果为0,将Futex变量设置为 1 然后继续执行。
    • 如果不为0,将Futex变量设置为 2 以后,执行FUTEX_WAIT系统调用进入挂起等待状态。
  • 当进程或线程释放锁的时候
    • 如果Futex变量的值为 1,说明没有其他线程在等待锁,直接将Futex变量设置为 0 就结束了。
    • 如果Futex变量的值为 2,说明还有线程在等待锁,此时将Futex变量设置为 0,同时执行FUTEX_WAKE系统调用来唤醒等待的进程。

对于Futex变量的操作,需要保证比较赋值操作是原子的。

Mutex

Glibc库中实现有pthread_mutex_lock()/pthread_mutex_unlock()等用户态锁接口,以提供快速的futex机制。

Bionicpthread实现中也提供了标准的pthread_mutex_lock()/pthread_mutex_unlock()接口。

Mutex类封装了pthread_mutex_lock()pthread_mutex_unlock()接口。

不过书中的源码路径已经不好使了,在9.0上的路径是:system/core/libutils/include/utils/Mutex.h,可以参考下。

Android中的Log模块

由于Android的开发是Host-Target模式,解决问题的主要手段就是分析log,

我们看下Log系统的架构:
深入Android系统(二)Bionic库_第4张图片

Log系统的输出分为5级:

  • ERROR:用来输出错误信息
  • WARN:用来输出警告信息
  • INFO:用来输出一般性的提示信息
  • DEBUG:用来输出调试信息
  • VERBOSE:用来输出价值比较低的信息

Log分级不是强制的,但是正确使用能让调试更加方便

Android 的Log输出量巨大,特别是通信系统的Log很多,因此Android把Log输出到了不同的缓冲区。目前定义的缓冲区包括:

public static final int LOG_ID_MAIN = 0; //Java层的log
public static final int LOG_ID_RADIO = 1; //通信系统的log
public static final int LOG_ID_EVENTS = 2; //event模块的log
public static final int LOG_ID_SYSTEM = 3; //系统组件的log
public static final int LOG_ID_CRASH = 4; //crash信息

缓冲区的定义主要是给系统组件用的。Java层的Log.*打印都会输出到LOG_ID_MAIN中。

Log 系统的接口和用法

Java 层的接口调用

android.util.Log类中常用的Log.*(String tag, String msg)就不介绍了,很常用的方法。我们看下几个特殊的:

  • Log.*(String tag, String msg, Throwable tr):增加的Throwable tr是为了在出现异常时更方便的打印堆栈信息。
  • Log.wtf()系列:配合setWtfHandler(TerribleFailureHandler handler)使用,通过setWtfHandler设置带有回调函数的handler,这样在使用Log.wtf()时,可以统一处理这种严重情况。

我们再看下Log.java类中Log.*(String tag, String msg)具体的调用:

public static int d(String tag, String msg) {
    return println_native(LOG_ID_MAIN, DEBUG, tag, msg);
}

println_native是一个JNI调用,位于frameworks/base/core/jni/android_util_Log.cpp,我们看下部分内容:

static const JNINativeMethod gMethods[] = {
    /* name, signature, funcPtr */
    { "isLoggable",      "(Ljava/lang/String;I)Z", (void*) android_util_Log_isLoggable },
    { "println_native",  "(IILjava/lang/String;Ljava/lang/String;)I", (void*) android_util_Log_println_native },
    { "logger_entry_max_payload_native",  "()I", (void*) android_util_Log_logger_entry_max_payload_native },
};

static jint android_util_Log_println_native(JNIEnv* env, jobject clazz,
        jint bufID, jint priority, jstring tagObj, jstring msgObj)
{
    // 部分省略
    
    int res = __android_log_buf_write(bufID, (android_LogPriority)priority, tag, msg);
    
    // 部分省略
}

println_native最后调用到了位于system/core/liblog模块定义的__android_log_buf_write函数。

native 层的调用

native 层使用的其实是宏定义,常用的形式包括:

  • ALOGV:相当于Log.v()
  • ALOGD:相当于Log.d()
  • ALOGW:相当于Log.w()
  • ALOGI:相当于Log.i()
  • ALOGE:相当于Log.e()

上面这几个指令都会输出到LOG_ID_MAIN的缓冲区。以ALOGD为例,我们看下定义文件(system/core/liblog/include/log/log_main.h)的部分内容:

#ifndef ALOGD
#define ALOGD(...) ((void)ALOG(LOG_DEBUG, LOG_TAG, __VA_ARGS__))
#endif

#ifndef ALOG
#define ALOG(priority, tag, ...) LOG_PRI(ANDROID_##priority, tag, __VA_ARGS__)
#endif

#ifndef LOG_PRI
#define LOG_PRI(priority, tag, ...) android_printLog(priority, tag, __VA_ARGS__)
#endif

#define android_printLog(prio, tag, ...) \
  __android_log_print(prio, tag, __VA_ARGS__)

最后其实是调用的__android_log_print函数,我们再来看下函数的实现system/core/liblog/logger_write.c文件:

LIBLOG_ABI_PUBLIC int __android_log_print(int prio, const char* tag,
                                          const char* fmt, ...) {
  //省略部分内容
  return __android_log_write(prio, tag, buf);
}
LIBLOG_ABI_PUBLIC int __android_log_write(int prio, const char* tag,
                                          const char* msg) {
  return __android_log_buf_write(LOG_ID_MAIN, prio, tag, msg);
}

最后也指向了__android_log_buf_write方法,等下我们仔细看下这个方法

此外,还有两组指令分别是:

  • SLOG*:输出到LOG_ID_SYSTEM的缓冲区,定义文件为system/core/liblog/include/log_system.h
  • RLOG*:输出到LOG_ID_RADIO的缓冲区,定义文件为system/core/liblog/include/log_radio.h

Javanative 调用跟进

跟进上面的分析,我们发现native层和Java层最后调用到了__android_log_buf_write,我们看下内容:

LIBLOG_ABI_PUBLIC int __android_log_buf_write(int bufID, int prio,
                                              const char* tag, const char* msg) {
  //省略部分内容
  return write_to_log(bufID, vec, 3);
}
static int __write_to_log_init(log_id_t, struct iovec* vec, size_t nr);
static int (*write_to_log)(log_id_t, struct iovec* vec,
                           size_t nr) = __write_to_log_init;

__android_log_buf_write调用了write_to_log(bufID, vec, 3),而write_to_log是一个指针,指向了函数__write_to_log_init(源码中充分利用了write_to_log函数指针,变换指针指向的函数来完成log的输出工作)。

根据书中的资料显示,最后会指向系统调用write()通过kernel层的log驱动来打印输出,这部分在9.0上并不是很清晰,跟踪源码只找到了如下部分信息:

static int __write_to_log_daemon(log_id_t log_id, struct iovec* vec, size_t nr) {
 //省略部分内容
  write_transport_for_each(node, &__android_log_transport_write) {
    if (node->logMask & i) {
      ssize_t retval;
      retval = (*node->write)(log_id, &ts, vec, nr);
      if (ret >= 0) {
        ret = retval;
      }
    }
  }
//省略部分内容
  write_transport_for_each(node, &__android_log_persist_write) {
    if (node->logMask & i) {
      (void)(*node->write)(log_id, &ts, vec, nr);
    }
  }

}

感觉也就是此处的write()最后会执行到系统调用write()那里吧。

就先到这里吧,先往下进行了,在Bionic花费的时间有点长了

可执行文件格式分析

在分析Bioniclinker之前,先介绍下Android的可执行文件格式。linker本身不是很复杂,但是对可执行文件不了解的话,就不太容易理解程序的逻辑。

Android 的可执行文件和动态库就是Linux的 ELF 文件格式,但是,由于Android使用了自己的linker,因此和普通的Linux系统不完全兼容。

ELF 文件格式简介

ELFExecutable and Linkable Format的缩写,最初由Unix实验室发布,它是ABI的一部分。ELF标准的目的是为软件开发人员提供一组二进制接口定义,这些接口可以在多种操作系统环境下生效,从而减少二次开发的工作量

ELF文件以节(section)的方式组织在一起的,节(section)描述了文件的各项信息,例如:代码、数据、符号表、重定位表、全局编译表等。

可执行文件被装载进内存时,并不是被完整的映射进内存,而是根据ELF文件中格式的定义,一段一段的装载进去,因此,可执行文件的格式和内存的映像并不完全相同,文件装载进内存后是以段(segment)的方式来组织,如:代码段、数据段、动态段等。

ELF 格式的文件结构和内存结构对比图:
深入Android系统(二)Bionic库_第5张图片

ELF 格式的文件有三种:

  • 可执行文件
  • 动态库文件(.so文件)
  • 重定位文件(.o文件)

这三种都有一个ELF文件头,描述了整个可执行文件的基本信息,如目标代码的格式、体系结构、各种段或节的偏移和大小等。

可执行文件和动态库会有程序头部表(Program Header Table),但是重定位文件中没有。

ELF文件中还有一个节区头部表(Section Header Table),描述文件中各个节区的内容。这个表和程序头部表的内容有些重复,这是因为这两张表的用途不一样:

  • 在编译和链接阶段,也就是ELF文件的生成阶段,需要节区头部表(Section Header Table)
  • 程序头部表(Program Header Table)是在在ELF文件的装载阶段

分析ELF文件格式的目的,是为了了解可执行文件的装载过程,因此会重点学习程序头部表

ELF文件头

书中ELF文件格式的定义和Android 9 中定义位置有些不同,9.0源码文件位于bionic/libc/kernel/uapi/linux/elf.h

ELF文件头的定义如下:

typedef struct elf32_hdr {
  unsigned char e_ident[EI_NIDENT];     //目标文件标识
  Elf32_Half e_type;                    //目标文件类型
  Elf32_Half e_machine;                 //目标运行平台的体系结构
  Elf32_Word e_version;                 //目标文件版本
  Elf32_Addr e_entry;                   //程序的入口地址
  Elf32_Off e_phoff;                    //程序头部表的偏移量
  Elf32_Off e_shoff;                    //节区头部表的偏移量
  Elf32_Word e_flags;                   //文件相关的,特定于处理器的标志
  Elf32_Half e_ehsize;                  //ELF 头部字节的大小
  Elf32_Half e_phentsize;               //程序头部表的表项的字节大小
  Elf32_Half e_phnum;                   //程序头部表的表项数目
  Elf32_Half e_shentsize;               //节区头部表的表项的字节大小
  Elf32_Half e_shnum;                   //节区头部表的表项数目
  Elf32_Half e_shstrndx;                //节区头部表中字符串的索引表
} Elf32_Ehdr;

在程序头部表里,最重要的是记录程序头部表节区头部表的位置,表示表项数目和表项大小的字段。可以通过readelfobjdump指令查看。

readelf -h linker为例,我们看下头部信息:

ELF 头:
  Magic:   7f 45 4c 46 01 01 01 00 00 00 00 00 00 00 00 00 
  类别:                              ELF32
  数据:                              2 补码,小端序 (little endian)
  版本:                              1 (current)
  OS/ABI:                            UNIX - System V
  ABI 版本:                          0
  类型:                              DYN (共享目标文件)
  系统架构:                          ARM
  版本:                              0x1
  入口点地址:               0x1a640
  程序头起点:          52 (bytes into file)
  Start of section headers:          1162348 (bytes into file)
  标志:             0x5000200, Version5 EABI, soft-float ABI
  本头的大小:       52 (字节)
  程序头大小:       32 (字节)
  Number of program headers:         9
  节头大小:         40 (字节)
  节头数量:         25
  字符串表索引节头: 22

程序头部表 (Program Header Table)

程序头部表 (Program Header Table)的作用是记录文件中各种段的地址、大小等信息,在程序装载、链接时都需要它。

程序头部表是一个结构Elf32_Phdr的数组,每个结构中记录了装入内存中的各个的信息,包括类型、地址、大小等。

结构Elf32_Phdr定义如下:

typedef struct elf32_phdr {
  Elf32_Word p_type;        //段的类型
  Elf32_Off p_offset;       //段在文件中的偏移
  Elf32_Addr p_vaddr;       //段装入内存后的虚拟地址
  Elf32_Addr p_paddr;       //段装入内存后的物理地址
  Elf32_Word p_filesz;      //段在文件中的大小
  Elf32_Word p_memsz;       //段装入内存后的大小
  Elf32_Word p_flags;       //段的标志
  Elf32_Word p_align;       //内存对齐方式
} Elf32_Phdr;

我们看下p_type字段定义的段类型:

名字 数值 说明
NULL 0 表示此数组项未使用
LOAD 1 表示此数组项描述了一个可加载的的大小由p_memszp_filesz指定。一个可执行文件中可以有多个LOAD
DYNAMIC 2 表示此数组项描述了动态链接信息。关于动态链接的所有区都在此段中描述。数组的长度并没有明确指定,而是将数组的最后一项值为NULL来表示数组的结束。
INTERP 3 表示此数组项描述的动态装载器的信息,在Android中就是Linker。此类型仅对可执行文件有意义,在一个文件中只能有一个。如果必须存在该字段,其必须位于LOAD字段前
NOTE 4 表示此数组项描述了附加信息的位置和大小
SHLIB 5 语义未指定。包含此种类型段的程序与ABI不符
PHDR 6 表示了此数组项描述了程序头部表自身在文件中及内存中的大小和位置。此类型的在文件中只能有一个。如果存在此类型的,则必须在所有可加载段项目的前面,包括INTERP
TLS 7 表示此数组项描述了线程局部存储模板信息

我们通过readelf -l linker查看头部表的相关信息:

Elf 文件类型为 DYN (共享目标文件)
入口点 0x1a640
共有 9 个程序头,开始于偏移量 52

程序头:
  Type           Offset   VirtAddr   PhysAddr   FileSiz MemSiz  Flg Align
  PHDR           0x000034 0x00000034 0x00000034 0x00120 0x00120 R   0x4
  LOAD           0x000000 0x00000000 0x00000000 0xbeaf8 0xbeaf8 R E 0x1000
  LOAD           0x0bf730 0x000c0730 0x000c0730 0x05e48 0x0f494 RW  0x1000
  DYNAMIC        0x0c4b28 0x000c5b28 0x000c5b28 0x000b0 0x000b0 RW  0x4
  NOTE           0x000154 0x00000154 0x00000154 0x00020 0x00020 R   0x4
  GNU_EH_FRAME   0x0be814 0x000be814 0x000be814 0x002e4 0x002e4 R   0x4
  GNU_STACK      0x000000 0x00000000 0x00000000 0x00000 0x00000 RW  0x10
  EXIDX          0x0a20e8 0x000a20e8 0x000a20e8 0x03b78 0x03b78 R   0x4
  GNU_RELRO      0x0bf730 0x000c0730 0x000c0730 0x058d0 0x058d0 RW  0x10

 Section to Segment mapping:
  段节...
   00     
   01     .note.gnu.build-id .dynsym .dynstr .gnu.hash .rel.dyn .text .ARM.exidx .rodata .ARM.extab .eh_frame .eh_frame_hdr 
   02     .data.rel.ro .init_array .dynamic .got .data .bss 
   03     .dynamic 
   04     .note.gnu.build-id 
   05     .eh_frame_hdr 
   06     
   07     .ARM.exidx 
   08     .data.rel.ro .init_array .dynamic .got

虽然程序头部表包含很多个段,但是只有类型LOAD才会从文件映射到内存中。其余类型的如果有实际的节区,这些节区也会出现在LOAD类型的中。

从上面的打印信息分析:

  • linker程序头有9个,其中包含2个LOAD类型,在装载这个文件时,实际mmap进内存的也只有这两个,它们也就是所谓的代码段数据段。从属性上也可以分辨一个是只读的R-代码段,一个是可读写的RW-数据段
  • 程序头下面是这9个分别包含的节区
    • 代码段数据段分别对应了节区的0102项。
    • DYNAMIC(03项)只包含了一个.dynamic节区,这个.dynamic节区和02项中的.dynamic节区是同一个。只不过.dynamic节区的起始位置和大小等数据保存在DYNAMIC(03项)中。所以只能通过DYNAMIC段找到.dynamic节区。

所以,虽然LOAD段的地址空间覆盖了.dynamic节区,但是无法通过它来找到.dynamic节区,必须通过DYNAMIC段。这样设计的目的是,当系统装载可执行文件时只需要将LOAD类型的完整的映射进内存就完成了,而访问各个节区还是要通过相应的所记录的地址来完成

.dynamic节区一般定义了下列节区的起始地址、大小等内容。

  • .plt节区:包含过程链接表
  • .got节区:包含全局偏移表
  • rel.plt节区:包含函数符号的重定位表
  • rel.dyn节区:包含非函数符号的重定位表
  • .dynsym节区:包含符号表
  • .dynstr节区:包含字符串表
  • .hash节区:包含符号的hash表

我们可以通过readelf -d libjni_projector.so 来查看.dynamic节区的结构。

Dynamic section at offset 0x5d60 contains 33 entries:
  标记        类型                         名称/值
 0x00000003 (PLTGOT)                     0x6eb4
 0x00000002 (PLTRELSZ)                   640 (bytes)
 0x00000017 (JMPREL)                     0x1660
 0x00000014 (PLTREL)                     REL
 0x00000011 (REL)                        0x1330
 0x00000012 (RELSZ)                      816 (bytes)
 0x00000013 (RELENT)                     8 (bytes)
 0x6ffffffa (RELCOUNT)                   68
 0x00000006 (SYMTAB)                     0x16c
 0x0000000b (SYMENT)                     16 (bytes)
 0x00000005 (STRTAB)                     0x74c
 0x0000000a (STRSZ)                      2489 (bytes)
 0x6ffffef5 (GNU_HASH)                   0x1108
 0x00000001 (NEEDED)                     共享库:[liblog.so]
 0x00000001 (NEEDED)                     共享库:[libandroid_runtime.so]
 0x00000001 (NEEDED)                     共享库:[libcutils.so]
 0x00000001 (NEEDED)                     共享库:[libnativehelper.so]
 0x00000001 (NEEDED)                     共享库:[libutils.so]
 0x00000001 (NEEDED)                     共享库:[libc++.so]
 0x00000001 (NEEDED)                     共享库:[libc.so]
 0x00000001 (NEEDED)                     共享库:[libm.so]
 0x00000001 (NEEDED)                     共享库:[libdl.so]
 0x0000000e (SONAME)                     Library soname: [libjni_projector.so]
 0x0000001a (FINI_ARRAY)                 0x6c30
 0x0000001c (FINI_ARRAYSZ)               4 (bytes)
 0x0000001e (FLAGS)                      BIND_NOW
 0x6ffffffb (FLAGS_1)                    标志: NOW
 0x6ffffff0 (VERSYM)                     0x1208
 0x6ffffffc (VERDEF)                     0x12c4
 0x6ffffffd (VERDEFNUM)                  1
 0x6ffffffe (VERNEED)                    0x12e0
 0x6fffffff (VERNEEDNUM)                 2
 0x00000000 (NULL)                       0x0

NEEDED项标明的为需要动态装载的库。

我们看下linker节区的对应关系:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bTGFnsLD-1596637306386)(http://cdn.hualee.top/ELF-header-segement.png)]

函数重定位的一些姿势

Linux 为了解决外部引用的问题,特地设定了一个全局变量偏移表.got,表中每一项储存的都是外部引用函数的地址。这样在程序代码中只需要间接引用全局表表项的地址就可以了。

当Linux需要对一个引用符号重定位时,首先要装载这个库,然后在库中查表寻找函数的相对地址,最后在库装载的基地址上加上函数的相对地址得到函数的虚拟地址。

Bionic中的Linker模块

可执行文件的创建

我们先简单了解下可执行文件的创建流程:

  • 首先,C代码(.c) 经过编译器预处理,编译成汇编代码(.asm
  • 然后,经汇编器处理,生成目标代码(.o
  • 然后,通过链接器,链接相应的库生成可执行文件(.out
  • 最后OS将可执行文件加载到内存里执行。

如图:
深入Android系统(二)Bionic库_第6张图片

可执行文件的装载

Linux 系统上有两种并不完全相同的可执行程序:

  • 一种是静态链接的可执行程序:包含了运行所需要的所有函数,可以不依赖任何外部库来运行。
  • 一种是动态链接的可执行程序:不会包含所依赖的库文件,文件大小相对会小很多。

静态可执行程序用在一写特殊的场合,例如系统初始化时,这是整个系统还未准备好,动态链接程序还无法使用。系统的启动程序Init就是一个静态可执行程序

在Android中,生成一个静态可执行程序的方法是在编译脚本中增加如下配置:

LOCAL_FORCE_STATIC_EXECUTABLE := true

Linux 执行一个可执行文件的过程是:

父进程执行fork后,在fork出的子进程中执行execve函数,这个函数会将可执行文件装载进内存,准备好运行环境后就跳到可执行文件入口开始执行。通常可执行程序的入口是_start_main()函数。

静态链接

在静态链接时,Android会给程序自动加上2个.o重定位文件:crtbegin_static.ocrtend_android.o这部分从9.0的编译输出out中并没有找到对应的文件,但是找到了crtbegin_so.ocrtend_so.o,不清楚这部分是否有所变更)。与之对应的源文件位置在bionic/libc/arch-common/bionic目录下:crtbegin.ccrtend.S_start_main()函数就位于crtbegin.c中:

__used static void _start_main(void* raw_args) {
  structors_array_t array;
  array.preinit_array = &__PREINIT_ARRAY__;
  array.init_array = &__INIT_ARRAY__;
  array.fini_array = &__FINI_ARRAY__;

  __libc_init(raw_args, NULL, &main, &array);
}

最后调用了__libc_init函数,其中第一个参数是Linux内核加载器传递过来的raw类型数据,第三个参数是main的函数指针,第四个参数是几个段数组的地址。__libc_init执行完libc库的初始化后,就会调用main函数。

发现一本比较好的书,叫程序员的自我修养:链接、装载与库,对这部分的内容讲解比较有意思,值得一看。咳咳,想要电子版评论我吧

动态链接

在动态链接时,execve系统调用会分析可执行文件文件头来寻找链接器。Linux 文件中是ld.so,而 Android 则是linker

execve会将linker装载进可执行文件的空间,然后执行linker_start函数。linker完成动态库的装载和符号的重定位后再去运行真正的可执行文件的代码。

linker使用的_start函数位于bionic/linker/arch/arm/begin.S(每个架构实现目录下都有)。

#include 
ENTRY(_start)
  // Force unwinds to end in this function.
  .cfi_undefined r14

  mov r0, sp
  bl __linker_init      //执行__linker_init函数

  /* linker init returns the _entry address in the main image */
  bx r0                 //__linker_init函数返回可执行程序的入口地址
END(_start)

这部分内容与书中稍有区别,不过流程都是一样的:_start函数跳转到__linker_init函数去执行,__linker_init执行完程序的初始化后会返回可执行文件的入口到r0寄存器,然后通过bx r0跳转到应用入口函数。

可执行程序的初始化

可执行程序的初始化是通过__linker_init函数完成的,9.0的具体实现在bionic/linker/linker_main.cpp

__linker_init中有一个soinfo的结构。在Android中soinfo是一个非常重要的数据结构,这部分定义在bionic/linker/linker_soinfo.h。不管是可执行文件还是动态库,Android都会为其构造一个soinfo的结构,soinfo中保存了程序所有的节区信息。

我们看下__linker_init源码片段,严重删减并添加注释的那种:

extern "C" ElfW(Addr) __linker_init(void* raw_args) {

  soinfo linker_so(nullptr, nullptr, nullptr, 0, 0);

  //1 对linker_so的部分属性进行初始化,有木有发现soinfo就是在可执行文件的结构
  linker_so.base = linker_addr;
  linker_so.size = phdr_table_get_load_size(phdr, elf_hdr->e_phnum);
  linker_so.load_bias = get_elf_exec_load_bias(elf_hdr);
  linker_so.dynamic = nullptr;
  linker_so.phdr = phdr;
  linker_so.phnum = elf_hdr->e_phnum;
  linker_so.set_linker_flag();

  //2 预链接,此方法执行完,soinfo的信息基本上就被填充完了。如果失败则退出
  if (!linker_so.prelink_image()) __linker_cannot_link(args.argv[0]);

  //3 装载linker所有的依赖库并进行重定位,如果失则败退出
  if (!linker_so.link_image(g_empty_list, g_empty_list, nullptr)) __linker_cannot_link(args.argv[0]);

  //4 初始化主线程 (包括 TLS 表).
  __libc_init_main_thread(args);

  //5 初始化linker的静态libc库的全局变量
  __libc_init_globals(args);

  //6 初始化linker自身的全局变量
  linker_so.call_constructors();
  
  //7 获取libdl对应的soinfo并添加到列表中
  solist = get_libdl_info(kLinkerPath, linker_so, linker_link_map);
  g_default_namespace.add_soinfo(solist);

  //8 可执行程序重定位未出现异常,此时再去初始化(安全地引用外部数据和其他非本地数据),并得到跳转地址
  ElfW(Addr) start_address = __linker_init_post_relocation(args);

  return start_address;
}

9.0在实现上和书中的差距有些大了,不过整体流程还是没变,只是细节上更丰富了下。

Bionic学习到这里内心已经有些抗拒了。。。。。不过再坚持一下下啦

linker如何替换掉libdl.so

Linux 装载一个动态库时需要使用dlopen函数。dlopen原本位于libdl.so中,前面说过,libdl.so中的函数,如dlopen,dlclose,dlsys等Google并没有直接实现,真正的实现在linker中,那么linker是如何实现替换的呢?

在可执行文件的装载过程中,所有装载进来的动态库对应的soinfo结构都会放到一个链表中,当新装载一个动态库时,会首先检查它是否已经存在于链表中,如果不存在才会继续装载。

linker伪造了一个libdl.so的soinfo结构,并放在了链表第一个元素的位置,因此程序中链接的libdl.so并不会真正的装载。

请留意上面__linker_init源码片段中的第7步就是在做这个事情了。不过9.0和书中的源码已经变化很大了,增加了namespace的逻辑。具体可以参考下Android Linker简介

调试器-ptraceHook API

ptrace系统调用通常用在调试器软件中,调试器利用ptrace函数达到控制目标进程运行的目的。一些Android的安全管家就是通过ptrace函数把自带的动态库插入到系统或者别的进程中,从而达到监控系统运行的目的。

ptrace系统调用简介

看下Bionicptrace的定义:

long ptrace(int __request, ...);
//具体实现中调用的是__ptrace
long __ptrace(int req, pid_t pid, void* addr, void* data);

__ptrace的参数:

  • req:请求执行的操作类型
  • pid:目标进程ID
  • addr:目标进程的地址
  • data:操作相关的数据根据请求的操作不同而变化。如果写入操作,data存放的是需要写入的数据;如果是读取操作,data将存放返回的数据

我们看下req的操作类型:

  • PTRACE_TRACEME:指示父进程跟踪某个子进程的执行。任何传给子进程的信号将导致其停止执行,同时父进程调用wait()时会得到通告。之后,子进程调用exec()时,核心会给它传送SIGTRAP信号,在新程序开始执行前,给予父进程控制的机会。pid, addr, 和 data参数被忽略。

    这是唯一由子进程使用的请求,剩下部分将由父进程使用的请求。

  • PTRACE_PEEKTEXT:从目标进程的代码段中读取一个长整型,内存地址由参数addr决定

  • PTRACE_PEEKDATA:从目标进程的数据段中读取一个长整型,内存地址由参数addr决定。

  • PTRACE_PEEKUSR : 从子进程的用户区addr指向的位置读取一个long int,并作为调用的结果返回。

  • PTRACE_POKETEXT,PTRACE_POKEDATA : 将data指向的long int拷贝到子进程内存空间由addr指向的位置。

  • PTRACE_POKEUSR : 将data指向的long int拷贝到子进程用户区由addr指向的位置。

  • PTRACE_GETREGS, PTRACE_GETFPREGS: 将子进程通用和浮点寄存器的值拷贝到父进程内由data指向的位置。addr参数被忽略。

  • PTRACE_SETREGS, PTRACE_SETFPREGS: 从父进程内将data指向的数据拷贝到子进程的通用和浮点寄存器。addr参数被忽略。

  • PTRACE_SETSIGINFO:将父进程内由data指向的数据作为siginfo_t结构体拷贝到子进程。addr参数被忽略。

  • PTRACE_SETOPTIONS: 将父进程内由data指向的值设定为ptrace选项,data作为位掩码来解释,由下面的标志指定

    • PTRACE_O_TRACESYSGOOD : 当转发syscall陷阱(traps)时,在信号编码中设置位7,即第一个字节的最高位。例如:SIGTRAP | 0x80。这有利于追踪者识别一般的陷阱和那些由syscall引起的陷阱。
    • PTRACE_O_TRACEFORK : 通过 (SIGTRAP | PTRACE_EVENT_FORK << 8) 使子进程下次调用fork()时停止其执行,并自动跟踪开始执行时就已设置SIGSTOP信号的新进程。新进程的PID可以通过PTRACE_GETEVENTMSG获取。
    • PTRACE_O_TRACEVFORK : 通过 (SIGTRAP | PTRACE_EVENT_VFORK << 8) 使子进程下次调用vfork()时停止其执行,并自动跟踪开始执行时就已设置SIGSTOP信号的新进程。新进程的PID可以通过PTRACE_GETEVENTMSG获取。
    • PTRACE_O_TRACECLONE : 通过 (SIGTRAP | PTRACE_EVENT_CLONE << 8) 使子进程下次调用clone()时停止其执行,并自动跟踪开始执行时就已设置SIGSTOP信号的新进程。新进程的PID可以通过PTRACE_GETEVENTMSG获取。
    • PTRACE_O_TRACEEXEC : 通过 (IGTRAP | PTRACE_EVENT_EXEC << 8) 使子进程下次调用exec()时停止其执行。
    • PTRACE_O_TRACEVFORKDONE : 通过 (SIGTRAP | PTRACE_EVENT_VFORK_DONE << 8) 使子进程下次调用exec()并完成时停止其执行。
    • PTRACE_O_TRACEEXIT : 通过 (SIGTRAP | PTRACE_EVENT_EXIT << 8) 使子进程退出时停止其执行。子进程的退出状态可通过PTRACE_GETEVENTMSG
  • PTRACE_GETEVENTMSG : 获取刚发生的ptrace事件消息,并存放在父进程内由data指向的位置。addr参数被忽略。

  • PTRACE_CONT :重启动已停止的进程。如果data指向的数据并非0,同时也不是SIGSTOP信号,将会作为传递给子进程的信号来解释。那样,父进程可以控制是否将一个信号发送给子进程。 addr参数被忽略。

  • PTRACE_SYSCALL, PTRACE_SINGLESTEP : 如同PTRACE_CONT一样重启子进程的执行,但指定子进程在下个入口或从系统调用退出时,或者执行单个指令后停止执行,这可用于实现单步调试。addr参数被忽略。

  • PTRACE_SYSEMU, PTRACE_SYSEMU_SINGLESTEP : 用于用户模式的程序仿真子进程的所有系统调用。

  • PTRACE_KILL : 给子进程发送SIGKILL信号,从而终止其执行。dataaddr参数被忽略。

  • PTRACE_ATTACH : 衔接到pid指定的进程,从而使其成为当前进程的追踪目标。

  • PTRACE_DETACH : PTRACE_ATTACH的反向操作。

Hook API 的一些内容

Hook API技术由来已久,在操作系统未能提供所需功能的情况下,利用Hook API手段来实现某种有用的功能也算是一种不得已的方法。

书中讲到最早的Hook API是为了实现Windows上电子词典的光标取词功能,把系统的字符串输出函数换成电子词典中的函数,从而能得到屏幕上任何位置的字符串。厉害了~~~~

Linux由于安全性高,通常是采用ptrace函数来实现Hook API 的目的。不过调用ptrace函数需要root权限。

Hook API的原理是利用ptrace函数把一小段代码注入目标程序中,这一小段代码的任务是:装载自己开发的动态库到目标进程中,然后查找目标进程中特定函数在全局偏移表中的位置,替换成自己动态库的函数地址。

随着 Android 安全性的提高,这部分的实现越来越有难度了。不过搞破坏是人的天性,值得好好研究。亲切的附上知乎大神文章:Android Native Hook知多少供大家品尝

开源协议

开源协议规定了你在使用开源软件时的权利和责任,也就是规定了你可以做什么,不可以做什么。

开源协议虽然不一定具备法律效力,但是当涉及软件版权纠纷时,开源协议也是非常重要的证据之一。

我们简单介绍下比较常见的几种

GNU GPL(General Public License)

遵循 GPL 协议的开源软件数量极其庞大,包括 Linux 系统在内的大多数的开源软件都是基于这个协议的。

特点是:只要软件中包含了遵循 GPL 协议的产品或代码,该软件就必须也遵循 GPL 许可协议,也就是必须开源免费,不能闭源收费,因此这个协议并不适合商用软件。

BSD(Berkeley Software Distribution)

GPL的出发点是代码的开源/免费使用和引用/修改/衍生代码的开源/免费使用,不允许修改后和衍生的代码做为闭源的商业软件发布和销售。

这也就是为什么我们能用免费的各种linux,包括商业公司的linux和linux上各种各样的由个人,组织,以及商业软件公司开发的免费软件了。

GPL协议的主要内容是只要在一个软件中使用(”使用”指类库引用,修改后的代码或者衍生代码)GPL协议的产品,则该软件产品必须也采用GPL协议,既必须也是开源和免费。这就是所谓的传染性

GPL协议的产品作为一个单独的产品使用没有任何问题,还可以享受免费的优势。

由于GPL严格要求使用了GPL类库的软件产品必须使用GPL协议,对于使用GPL协议的开源代码,商业软件或者对代码有保密要求的部门就不适合集成/采用作为类库和二次开发的基础。

Apache License Version

Apache Licence是著名的非盈利开源组织Apache采用的协议。该协议和BSD类似,同样鼓励代码共享和尊重原作者的著作权,同样允许代码修改,再发布(作为开源或商业软件)。

需要满足的条件也和BSD类似:

  • 需要给代码的用户一份Apache Licence
  • 如果你修改了代码,需要在被修改的文件中说明。
  • 在延伸的代码中(修改和有源代码衍生的代码中)需要带有原来代码中的协议,商标,专利声明和其他原来作者规定需要包含的说明。
  • 如果再发布的产品中包含一个Notice文件,则在Notice文件中需要带有Apache Licence。你可以在Notice中增加自己的许可,但不可以表现为对Apache Licence构成更改。

Apache Licence也是对商业应用友好的许可。使用者也可以在需要的时候修改代码来满足需要并作为开源或商业产品发布/销售。

开源协议概述图

来自百度百科

image

结语

Bionic章节到这里算是结束了,学起来真滴困啊。最近赶上出差再加上这部分章节的知识真滴陌生,在完成时间上有所推迟,不过收益匪浅,哈哈哈!

下一篇到了梦寐以求的章节了《进程间通信-Android的Binder

你可能感兴趣的:(深入Android系统,bionic,Android,Log系统,linker,系统调用)