前言
着重分析linux系统装载程序相关,涉及部分操作系统其它部分
android下与linux内核的版本对应:
Android Version | API Level | Linux Kernel in AOSP |
---|---|---|
1.5 Cupcake | 3 | 2.6.27 |
1.6 Donut | 4 | 2.6.29 |
2.0/1 Eclair | 5-7 | 2.6.29 |
2.2.x Froyo | 8 | 2.6.32 |
2.3.x Gingerbread | 9, 10 | 2.6.35 |
3.x.x Honeycomb | 11-13 | 2.6.36 |
4.0.x Ice Cream San | 14, 15 | 3.0.1 |
4.1.x Jelly Bean | 16 | 3.0.31 |
4.2.x Jelly Bean | 17 | 3.4.0 |
4.3 Jelly Bean | 18 | 3.4.39 |
4.4 Kit Kat | 19, 20 | 3.10 |
5.x Lollipop | 21, 22 | 3.16.1 |
6.0 Marshmallow | 23 | 3.18.10 |
7.0 Nougat | 24 | 4.4.1 |
7.1 Nougat | 25 | 4.4.1 |
8.0 Oreo | 26 | 4.10 |
8.1 Oreo | 27 | 4.10 |
9.0 Pie | 28 | 4.4, 4.9 and 4.14 |
来自:https://en.wikipedia.org/wiki/Android_version_history
分析的系统虽然是8.0的…手头的android源码不含内核…只能分析网上的4.4版本了:https://www.androidos.net.cn/kernel/4.4/xref
前置知识
Syscall位于用户与内核空间之间,使得:
- 提高内核安全性,将内核操作完全独立,内核决定开放什么接口,谁能调用。
- 便于移植
因为各操作系统、系统版本、各架构的系统调用都不一样,因此为了形成编码级别的移植增加了一层运行库。支持编码移植的基础正是各个语言的运行库,比如c语言的read,在各系统下的运行库实现都不同,但代码调用时只需要引入它的声明并使用,连接时找到该平台下的库就可以连接到该实现。这一过程仅与运行库文件与编译器连接器有关,因此可以交叉。
思路就是把各个操作系统的系统调用交集在各个平台下都实现成库文件,上层编码时只需要使用语言提供的库就可以生成不同平台的可执行代码去运行。
其实触发中断到运行库间还有一层,是外壳函数,是各个操作系统为了方便系统调用(汇编)提供的c函数实现(相同系统使用外壳函数的源码可以移植),外壳函数屏蔽了架构差异。
运行库(屏蔽系统与架构)>外壳函数(屏蔽架构)>系统调用(架构汇编与系统规定实现)
自己透过c运行库与外壳函数手动实现系统调用可以十分有效的混淆ida的分析。
内核空间是高地址1G空间,余下的就是应用空间。应用可以通过系统调用陷入内核空间。64位下用户与内核各有64TB左右的空间。中间有空洞
用户空间对应进程,所以每当进程切换,用户空间就会跟着变化;而内核空间是由内核负责映射,它并不会跟着进程变化,是固定的。内核空间地址有自己对应的页表,用户进程各自有不同的页表。
因为用户与内核间要数据交互所以这样分每个虚拟内存交互可以使其交互方便点。
图:x86段页式的寻址方式
相对X86,ARM的寻址方式更像是实模式,至于多任务下的数据保护,由OS来完成。
关于ARM、X86下地址映射运行模式的区别、cpu提供的功能与操作系统实现的部分的划分还比较模糊….先暂时以与x86相同来考虑
内核的虚拟内存是固定的,每个进程虚拟空间的内核空间部分是物理相同的。该部分虚拟内存可以访问全部的物理内存,32位下用高端内存机制,64位下虚拟地址足够大了就不用了。
让我们忽略Linux对段式内存映射的支持。 在保护模式下,我们知道无论CPU运行于用户态还是核心态,CPU执行程序所访问的地址都是虚拟地址,只是页表不同。
那为何用户模式下进程不能自己切换到内核态呢?正是因为中断的存在。使得切换到内核态必须进行限制级的申请,只有中断存在的系统调用号(系统选择开放的功能)才能获得运行。这样一来内核态的切换与执行操作绑定,没有要系统黑盒执行的操作就不能切换内核态。
接下来详细分析从运行库到中断表,到内核执行的全过程
map
execv
android下的运行库是google自己实现的bionic,为c语言运行库,实现直接调用的系统调用。(突然好奇图形渲染怎么实现的..等以后闲下来再分析)
c库的system和popen都是最后调用的exec外壳函数,exec系列只有execve是真正意义上的系统调用,其它都是在此基础上经过包装的库函数。
函数声明:
int execve(const char filename, char const argv[ ], char *const envp[ ]);
如果执行成功则函数不会返回,执行失败则直接返回-1,失败原因存于errno 中。功能为在当前进程执行新程序。
man:http://man7.org/linux/man-pages/man2/execve.2.html
壳函数
声明:
bionic\libc\include\unistd.h
一般的壳函数会在\bionic\libc\bionic下有其c语言实现,之后通过c的形式调用其符号。符号的实现在各平台的.S文件中,这个比较特殊直接用的.S
实现,外壳函数在不同的架构下实现不同:
bionic\libc\arch-arm64\syscalls\execve.S
bionic\libc\arch-x86_64\syscalls\execve.S
一些宏定义:
各平台的时间基本都是放入系统调用号,触发中断,检测是否成功。注意这些都是微机原理课上看到过的.S汇编格式,大意为手动分段(.xxxx),定义好符号名(f:)
中断过程
这里省略中断向量表的过程,基本概述就是查询中断向量表,跳到中断处理程序,其中系统调用按编号处理。
省略内核态切换、堆栈切换过程,需要时再分析
系统调用
sys-execve
kernel/arch/arm/kernel/calls.S
此处为系统调用表,调用符号sys_execve
符号声明:
/kernel/include/linux/syscalls.h
asmlinkage是gcc标签,代表函数读取的参数来自于栈中,而非寄存器。
/kernel/include/uapi/asm-generic/unistd.h
发现221号的NR正对应sys_execve。
其表明的定义位置可能并没有其实现,这说明其实现可能是系统统一的。比如按着它的路径找到:
/arch/arm/kernel/sys_arm.c
添加系统调用只需添加call、与实现即可
还有就是拷一份内核代码到本地全局搜索:
系统调用的定义是用宏写的
fs/exec.c
这个宏定义有些复杂,这里就不展开了
do-execve
fs/exec.c
指向程序参数argv和环境变量envp两个数组的指针以及数组中所有的指针都位于虚拟地址空间的用户空间部分。因此内核在当问用户空间内存时, 需要多加小心, 而user注释则允许自动化工具来检测时候所有相关事宜都处理得当
do-execveat-common
fs/exec.c
注释即可,主要就是填充bprm结构
binprm
include/linux/binfmts.h
该结构体统一保存各可执行文件的信息
prepare-binprm
|
|
设置即将运行的uid与gid。读到buf的不是文件的全部内容BINPRM-BUF-SIZE只是128个字节
exec-binprm
|
|
调用search-binary-handler()函数对linux_binprm的formats链表进行扫描,并尝试每个load-binary函数,如果成功加载了文件的执行格式,对formats的扫描终止。
search-binary-handler
|
|
search-binary-handler函数遍历format格式,对于linux下的elf格式的可执行文件而言,会找到elf-format,调用其load-binary函数,最终其实调用的是load-elf-binary函数
注意这些人非常喜欢用宏定义:
linux-binfmt
include/linux/binfmts.h
这个结构体每个代表一种可执行文件格式,linux中存于formats链表中
其存贮的加载函数指针指向的函数不同
load-elf-binary
这部分是重点,elf文件的加载
load_elf_binary
fs/binfmt_elf.c
通过几部分代码可见内核设计者的意图,内存等资源不到用的时候不分配,即lazy加载。这样可以有效回收,并防止资源浪费
都做了如下事:
- 检查elf文件,读取头
- 读取段头表
- 查找解释/连接器,打开该文件并读取头
- 读取解释器的段
- flush-old-exec进行新进程地址空间的替换,setup-new-exec函数对刚刚替换的地址空间进行简单的初始化,堆栈执行性
- 遍历加载段头,load-addr为基址,load_bias为偏移,基址与偏移只定一次
- 将解释器的数据映射到虚拟内存中
- 添加环变、参数等内容到栈底,设置current的mm-struct
- 最后调用start-thread函数将执行权交给解释器或者应用程序
start-code:最小的段va
end-code:最大的可执行段va+filesz
start-data:最大的段va
end-data:最大的段va+filesize
elf-bss:最大的段va+filesize
elf-brk:最大的段va+memsz
因此内存分配为:代码区,初始化数据区(有文件直接映射),未初始化数据区bss,brk表示镜像所占内存末尾,堆,栈
load-el-phdrs
读取段头的函数:
flush-old-exec
fs/exec.c
setup-new-exec
fs/exec.c
elf-map
fs/binfmt_elf.c
传入的参数filep是文件指针,addr是即将映射的内存中的虚拟地址,size是文件映像的大小,off是映像在文件中的偏移。elf-map函数主要通过vm-mmap为文件申请虚拟空间并进行相应的映射,然后返回虚拟空间的起始地址map-addr
load-elf-interp
/fs/binfmt_elf.c
与主体装载不同的是没有随机地址了,依旧是分段映射
start-thread
arm下的没找到….先看熟悉的x86好了
start-thread
arch/x86/kernel/process_64.c
设置各寄存器,force-iret强制返回,跳转到new-ip指向的地址处开始执行,要设置的就是ip与sp。注意这里的regs实际上是保存系统调用返回地址的位置,通过修改返回地址使系统调用返回时直接执行新程序
参考
https://vvl.me/2017/03/20/how-does-the-Linux-kernel-run-a-program/