购买
下载掌阅APP,畅读海量书库
立即打开
畅读海量书库
扫码下载掌阅APP

第3章
操作系统基本原理

通常情况下,我们想要进行逆向工程的程序不会在裸机环境中运行。相反,这些程序通常在操作系统(如Linux、Windows或macOS)中运行。因此,有必要了解操作系统向这些程序提供服务、管理系统内存和提供硬件隔离的基础知识,以便正确理解程序最终运行时的行为。

3.1 操作系统架构概述

不同操作系统的运行方式通常截然不同,但是普通程序的执行环境却有很多相似之处。例如,内核模式和用户模式的区分,以及对内存的访问、调度和系统服务调用机制等方面的差异通常相对较小,即使在不同平台上底层实现和语义略有不同。

在本节中,我们将简要介绍几个操作系统的基本概念。虽然重点主要放在Linux上,但在进行逆向工程时,许多基本概念也适用于可能遇到的其他操作系统。

3.1.1 用户模式与内核模式

在对程序二进制文件进行逆向工程之前,了解程序在Linux操作系统中的运行环境非常重要。Armv8-A CPU为操作系统提供了至少两种执行模式:操作系统内核使用的特权模式称为内核模式,而用户程序使用的非特权模式则称为用户模式。在Armv8-A架构中,硬件会强制区分内核模式代码和用户模式代码。在用户模式下运行的程序通常以非特权的EL0(Exception Level 0)权限级别运行,而操作系统内核模式则以特权的EL1(Exception Level 1)权限级别运行。

内核模式代码可以完全访问系统上的所有内容,包括外设、系统内存和任何正在运行的程序的内存。但这种灵活性是有代价的:在内核模式下运行的程序出现错误时可能会导致整个系统崩溃,并且内核中的安全漏洞会威胁整个系统的安全。为了保护内核代码和内存中的数据免受恶意或故障程序的破坏,会在内核地址空间中将它们与系统上的用户模式进程隔离开来。在第4章中,我们将讨论更高的权限级别,具体指Armv8-A架构中的EL2和EL3。

与内核模式相比,用户模式进程只能通过间接的方式访问操作系统上的资源,并且只能在它们自己的隔离地址空间内运行。当用户模式进程需要访问设备或其他进程时,它会通过操作系统提供的API向内核发出请求(这些API以所谓的系统调用的形式提供)。然而,操作系统内核可以限制某些危险的API,只允许特权进程使用,或者提供对系统设备的抽象接口而不是原始访问方式。例如,操作系统通常允许程序访问文件系统上自己的逻辑文件,但禁止非特权程序访问硬盘驱动器上的各个数据扇区。

3.1.2 进程

绝大多数的应用程序都在用户模式下运行。每个用户模式进程都被封装在自己的虚拟内存地址空间,其中包含程序的所有代码和数据。

每个进程在创建时都会被分配一个唯一的进程标识符(Process Identifier,PID)。在Linux系统中,有许多命令可以显示进程信息,其中最常用的是 ps 命令 。你可以使用 ps aux 命令查看系统上的所有进程。如果要显示完整的进程树,则可以使用 ps axjf 命令。以下是该命令的一些简化输出:

虽然 ps 命令可以帮助我们显示系统上正在运行的进程的当前状态,但有时需要实时监控系统上进程的状态。例如,图3.1展示了如何使用 htop 命令来动态查看进程的CPU和内存使用情况。

如果想要查看更详细的性能信息,也可以使用交互式进程监视器 atop 。该程序可以显示整个系统的性能信息以及各个进程的CPU和内存使用情况。图3.2展示了 atop 的一些示例输出。

图3.1 htop命令

图3.2 atop输出

3.1.3 系统调用

当用户模式进程运行时,它会和系统上的其他代码进行隔离,不能直接访问其他进程或操作系统内核的代码和数据。除非经过操作系统内核授权,否则用户模式进程无法直接访问设备硬件。如果用户模式程序需要访问文件、系统资源或与硬件交互等,就必须使用操作系统提供的API,即所谓的系统调用(system call)。

在Armv8-A中,可以使用supervisor调用( SVC )指令来请求内核提供服务,以实现用户模式进程与内核之间的通信。当进程使用 SVC 指令发起一个系统调用时,处理器会引发一个SVC异常,并使进程停止执行,将控制权转移到内核模式下注册的 SVC 处理程序中。内核会解码被请求的系统调用,并调用相应的内核模式例程来提供服务。一旦系统调用例程结束,结果就会返回到进程中,并继续执行用户模式进程。

我们可以使用 strace 命令来动态跟踪特定进程调用了哪些系统调用。该命令会记录并显示程序所接收到的信号,通过执行 strace -p<PID> 命令,我们可以附加到指定进程ID(PID)的进程上并跟踪其使用的系统调用。如果添加 -c 选项,则可以将输出限制为每个系统调用的计数,并提供每个系统调用执行次数的摘要和每个系统调用在内核中运行的平均时间。以下是一个示例,它将 strace 附加到进程ID为1的进程上,并返回一个摘要:

在C和C++开发中,程序员通常会通过调用系统库来间接地执行系统调用。例如,如果程序想要将数据写入文件、套接字或管道,它会在 libc 库内部调用 write 函数,从而触发一个系统调用来处理这个请求。图3.3展示了这一过程。

图3.3 在libc库中调用函数

libc 库中 write 函数的反汇编代码如下所示:

该函数首先将常量数值64(0x40)移入 x8 寄存器中,然后使用 SVC 指令触发内核的系统调用处理程序。在64位AArch64架构下, x8 寄存器用于告知操作系统正在调用哪个系统调用。在Linux中,系统调用号定义在头文件 unistd.h 中,不同架构下确切位置和名称可能会有所变化。例如,在AArch64架构的Linux中,可以在路径 /usr/include/asm-generic/unistd.h 下找到该头文件。如果我们在这个文件中搜索 write 字符串,就可以获得所有名称中带有 write 的系统调用号。在AArch64架构下, write 系统调用号为64,如下所示:

然而,系统调用号因计算机架构的不同而有所不同。例如在AArch32架构下,不仅头文件的文件路径和名称不同,而且系统调用号也不同,这里,在 /usr/include/arm-linux-gnueabihf/asm/unistd-common.h 中可以找到 write 系统调用号4,而不是64。

在我们的例子中,在用系统调用号填充 x8 寄存器之后,下一条指令是 SVC 。这个指令会导致处理器产生一个supervisor调用异常,此时处理器会暂时切换到内核模式并在内核空间中执行已注册的 SVC 处理程序 。该处理程序保存当前正在执行的程序的状态,并确定请求的系统调用号,然后调用相应的内核函数或子例程(例如,在Linux内核的 fs/read_write.c 中定义的 write )来处理请求。由于这些内核函数在内核模式下运行,因此它们可以访问连接的硬件并执行底层磁盘写操作。完成系统调用例程后,内核将结果返回给用户模式下的程序并恢复程序执行,从触发系统调用请求的 SVC 指令之后继续执行。

为了保护系统稳定性和安全性,系统调用例程在操作系统内核中运行,并且必须实现权限检查,以避免非特权用户模式进程请求执行可能破坏系统稳定的特权操作。例如,即使对内核覆盖磁盘上的关键文件没有限制,也应该拒绝非特权程序这样做。

Armv8-A架构提供了多种机制来保护系统的稳定性和安全性。例如,指令集提供了未授权的加载和存储指令,如LDTR或STTR。这些指令允许在EL1下执行的特权代码以EL0的权限访问内存。这使得操作系统能够检查请求是访问特权数据还是非特权数据,以及应用程序是否可以访问该数据,从而使操作系统能够对提供给系统调用的指针进行解除引用。换句话说,当操作系统需要代表非特权应用程序访问内存时,这些指令的行为就像它们在EL0下执行一样,以防止非请求应用程序的特权数据访问。

在Linux和类UNIX系统中,系统以用户的概念来抽象安全检查。每个进程都会以特定用户的权限运行。当进行系统调用时,内核会检查当前进程的用户是否有执行所需操作的权限。如果权限检查失败,则系统调用被拒绝。

除了 atop ,查看特定用户创建的进程的另一种方法是使用带有命令行选项 -u<user> htop 。例如,命令 htop -u root 将列出所有以 root 用户运行的进程(见图3.4)。

我们还可以使用 ps 命令来显示系统中某个用户的进程(见图3.5)。

在Linux和类UNIX操作系统中, root 用户拥有最高权限。如果一个程序以 root 用户身份运行,大部分内核模式的权限检查都将隐式地成功,使其在系统内具有特殊特权。

虽然有 root 权限的进程具有极大的特权,但它们仍然在用户模式下运行。内核可能允许它们执行特权操作,但它们仍然需要请求。这与运行在内核中的程序不同,后者可以直接访问内存或设备,无须通过操作系统API转发请求。

图3.4 htop -u root命令

图3.5 ps命令

对象和句柄

对于一些系统调用API,比如涉及网络和文件访问的API,它们期望参数或返回值能指向之前分配的内核模式下的资源(比如文件或套接字)的句柄。句柄通常是用整数来表达的,并且它是唯一标识进程中的内核资源的。

在内核模式下,当我们进行系统调用(如打开一个文件进行读写)时,操作系统会分配一个文件对象,将其加入进程的句柄表中,并返回一个整数句柄给用户。之后,进程就可以使用这个句柄,比如在之后的读或写操作中,来告诉内核应该读或写哪个具体的文件。内核会通过进程的句柄表来维护用户模式下的句柄和实际内核模式对象之间的映射。这个句柄表存储在内核中。

图3.6展示了句柄在内核中是如何解析的。在这个例子中,一个32位的用户模式调用 read 系统调用。这个系统调用的第一个参数是引用之前打开的文件的句柄,这个例子中是指8号句柄(数值为8)。当程序发出 SVC 指令时,CPU跳转到内核中已注册的SVC处理程序,在那里最终执行 ksys_read 函数中的系统调用逻辑

图3.6 解析句柄

为了完成该请求, ksys_read 系统调用需要确定程序想要读取的文件。在本例中,它会在当前进程的句柄表中查找编号为8的句柄,以确定访问哪个文件。在此过程中,句柄到对象的查找由 fdget_pos 完成。该对象描述了如何完成文件读取请求,这通常通过内核中的文件系统驱动程序实现。该驱动程序也在内核模式下运行,可以直接向连接的硬盘设备发出请求,将文件读入内存。最后,当读取请求完成时,控制权就会返回用户模式进程,从 SVC 指令之后的指令处恢复执行。

大多数进程会在运行时创建自己的句柄,但有些句柄是在进程创建过程中隐式创建的,例如标准输入(stdin)、标准输出(stdout)和标准错误(stderr)伪文件句柄。按照惯例,这些伪句柄分别用0、1和2表示。这些伪文件允许程序之间进行数据管道传输或通过控制台与用户交互。

当进程完成对内核模式资源的使用时,可以使用类似 close 的系统调用来关闭对象 。这会通知内核,程序不再使用该资源。一旦所有对内核对象的引用都被关闭,内核就可以开始释放相应的资源。如果进程在关闭打开的句柄之前退出或中止,则内核将在进程退出过程中隐式地关闭它,以确保对象不会“泄漏”。

3.1.4 线程

当程序首次启动时,会创建一个新进程并分配一个线程给该程序。这个初始线程负责初始化进程并最终调用程序中的 main 函数。多线程程序可以请求添加其他线程来处理后台工作 。例如,多线程Web应用服务器可以为每个传入的请求使用一个线程,以防止长时间运行的请求阻塞其他用户对站点的访问。

进程始终至少有一个线程。当进程中的最后一个线程完成时,该进程退出。使用 top 程序可以查看程序内部线程,其语法为 top -H -p<pid> 。例如,图3.7显示了在 rsyslogd 程序中运行的线程。

图3.7 正在运行的线程

每个线程都可以独立于其他线程运行代码,就像一个独立的处理器核心一样。每个线程都有自己的处理器寄存器和状态,包括程序计数器、栈指针、算术标志以及自己内部管理的局部变量和调用栈。但需要注意的是,与进程不同,线程之间并不是相互隔离的。每个线程的代码和数据都加载到同一个进程中。虽然编程约定通常规定一个线程不应直接干扰另一个线程的私有数据,但这只是通过约定而非硬件强制实施的。图3.8展示了一个简化的进程地址空间,其中有三个用户模式线程正在运行。

图3.8 三个正在运行的用户模式线程

3.2 进程内存管理

每个进程都有自己独特的虚拟地址空间。处理器中的内存管理单元(Memory Management Unit,MMU)会把虚拟地址转换成物理地址,并确定数据在系统内存中的位置。操作系统为每个进程编写一个特定的MMU程序,使用页表来描述进程中每个可访问内存区域的布局和转换方式,以及相应数据在内存中的存储位置和每个区域的内存权限。页表的详细布局和MMU的编程不在本书的讨论范围,详见Arm架构参考手册(Armv8.6 Beta版本,2020年)

在Linux中,我们可以通过伪文件 /proc/<pid>/maps 查看给定进程ID的地址空间布局,或者使用 /proc/self/maps 伪文件来查看当前执行程序的内存布局。例如,运行 cat/proc/self/maps 命令,就可以查看正在执行的 cat 程序的虚拟内存映射,并将其输出到屏幕上。下面是一些输出示例(已添加列名):

每个内存区域都是一个独立的地址范围,同时包含了该内存区域保护机制和类型的信息。例如,第一个内存区域涵盖了0x00400000到0x00410000的虚拟内存地址范围,并且被赋予了 r-xp 内存保护机制,它的保护机制来自文件 /usr/bin/cat

未在进程的地址映射中描述的区域称为未映射内存。当尝试在这个未映射空间的内存中进行读取/写入或执行操作时,MMU会向CPU发出异常信号,让CPU挂起程序并跳转到内核中已注册的异常处理程序。通常情况下,内核会提示任何已连接的调试器或通过分段错误来异常中止该程序。

3.2.1 内存页

在之前的地址映射中,你可能会发现内存区域总是以0x1000的倍数对齐,也就是说,地址总是以零结尾。这是因为MMU在页上执行地址转换和内存保护,而不是针对单个字节进行操作,所以每个内存区域的大小和位置都是页对齐的。在Armv8-A架构中,页面大小 (称为转换粒度)始终为4 KB(0x1000)、16 KB(0x4000)或64 KB(0x10000) 。基于Linux的操作系统通常使用4 KB的页面大小 ,但也可以编译为使用64 KB的页面大小 。其他操作系统(例如64位iOS使用的内核)使用16 KB的页面大小 。有些操作系统出于性能或其他原因,甚至会使用大于架构指定的页面大小,如所谓的巨大页(从2 MB到1 GB),它们常用于服务器和高性能计算(High-Performance Computing,HPC)负载。

在Linux系统中,我们可以使用命令 getconf PAGESIZE 来确定当前系统使用的页面大小。这将以字节为单位输出当前系统的页面大小。例如,AArch64架构上的Red Hat Enterprise Linux服务器可能会使用64 KB的页面,我们可以通过以下输出查看:

相比之下,即使在相同的处理器上运行,在Debian Linux Armv8-A系统上执行同样的命令,也会被编译为使用4 KB的页面:

3.2.2 内存保护

每个内存区域都有一组内存保护机制,其中最基本的是可读、可写和可执行权限。在进程映射中,区域保护的前三个字母RW X表示区域的权限,其中“-”表示特定权限未被授权,而不是缺少该权限。表3.1详细描述了这些权限。

表3.1 内存保护权限

AArch64内存模型中的访问权限由访问权限(Access Permission,AP)属性控制 。EL0与EL1/2/3的访问权限差异如表3.2所示。

表3.2 访问权限属性

如果程序尝试以不符合内存区域权限的方式使用该区域中的数据,例如尝试向只读内存区域中写入数据或者执行被标记为不可执行的内存区域中的代码,则会导致MMU产生权限错误。此时会将控制权转移给内核中已注册的异常处理程序。如果内核确认这是程序错误引起的故障,通常会使用分段错误来异常中止该程序。

在程序运行过程中,操作系统会为每个程序分配一个独立的地址空间。这个地址空间由进程专用的页表定义,并加载到MMU中。由于操作系统管理着页表,因此如果进程希望改变自己地址空间中内存区域的大小或访问权限,就必须向操作系统发出请求并等待其批准。

3.2.3 匿名内存和内存映射

在进程的地址空间中,最基本的内存区域类型是称为“页面”的空白内存块,它们可以用来存储程序代码或数据。这些区域通常由操作系统填充为0,并在程序运行时动态地写入需要的数据。大多数操作系统允许自由分配此类内存,并赋予其任意可执行、可读或可写内存保护标志。但由于一些操作系统实行严格的代码签名政策,因此禁止在运行时创建可执行内存。

匿名内存除经常被用于在多个进程之间共享内存外,还有其他用途。例如,程序的堆管理器会使用匿名内存来分配和管理内存,以便向程序添加新的可寻址内存范围,从而通过 malloc new 函数为动态内存分配提供服务。堆管理器会定期使用 brk mmap 系统调用在内核中分配大块页对齐的内存,并通过传递 MAP_ANONYMOUS 标志来请求匿名内存。然后,堆管理器根据需求将这些大的“块”分割成单独的内存块,从而允许程序在运行时快速分配动态内存,而无须让每次分配都按页对齐或调用系统调用。

内存映射文件和模块

除了使用页面文件支持的内存,操作系统还提供了一种称为内存映射文件的机制。该机制可以将磁盘上的逻辑文件映射到内存区域中。Linux程序通常使用 mmap 系统调用来执行此操作,并将文件的内存映射视图创建到它们自己的进程地址空间中。

从程序的角度来看,内存映射区域和普通的“匿名”内存很相似。不同之处在于,内存映射区域会用磁盘中的数据预填充而非最初的零填充,这避免了通过额外的 read 调用手动从磁盘读取数据的需要。一旦映射完成,内存映射文件就可以像其他内存区域一样使用普通的加载和存储指令进行访问。内存映射区域跟其他内存区域一样,也可以设置为可读、可写或可执行的某种组合,甚至可以在进程之间被共享。

内存映射区域提供了各种性能优势。它们是按需从磁盘加载的,因此操作系统可以使用内存映射区域来降低整个系统的内存压力。即使这些区域是私有映射的,操作系统也可以通过隐式共享只读映射文件未被修改的部分来实现多个进程之间的数据共享。从内存映射视图读取数据的概念很简单:如果文件被映射到地址 0x100000 ,那么 0x100100 处的字节就是文件的 0x100 字节,以此类推。

内存映射区域的创建方式决定了内存写入区域的行为。默认情况下,在内存映射中写入内存会将更改带回到底层文件。例如,如果文件被映射到地址 0x100000 ,程序将字节 2 写入地址 0x100100 ,则该文件的 0x100 字节会被设置为2。但是,如果文件使用 mmap 映射并传递 MAP_PRIVATE 参数,则对内存区域的写入只会保留在内存中,而不会更改磁盘上的文件。这种行为允许程序对其已读取但无写入权限的文件进行内存映射。

我们可以查看进程地址空间映射来了解哪些内存区域是内存映射文件,以及它们映射的是哪个文件。例如,运行 cat /proc/self/maps 命令可以查看自己的地址映射,以便看到文件 /usr/lib/locale/locale-archive 被映射到程序地址空间的只读地址 0xffff7b510000 ,并且堆被分配为可读、可写、不可执行和私有的。

除了将普通文件映射到内存中,程序还经常使用内存映射区域来映射它们的库和程序文件。在Linux系统中,这些程序和库通常以ELF文件格式 存储在磁盘上。不同的操作系统使用不同的文件格式,例如,macOS和iOS使用Mach-O文件格式 ,而Windows则使用PE(Portable Executable)文件格式 来存储库和可执行文件。

尽管这些文件格式在不同的操作系统中的具体实现有所不同,但它们的核心功能是相似的:这些二进制文件包含程序的代码和常量数据、定义全局变量的位置和初始值,以及告诉操作系统和用户模式链接器如何将这些数据映射到内存中并为模块的执行做好准备。

具体的模块加载机制比较复杂,不在本文的讨论范围。但简单来说,每个文件都自我描述了一系列节,每个节将文件中的数据直接映射到内存中,并描述应该应用于该内存的内存保护机制。在ELF文件中,这通常通过 LOAD (加载)节执行,对应的数据通过模块加载器LD 使用 mmap 从文件映射到内存中。

我们可以使用 readelf 命令来查看ELF文件中各节的内容。例如,运行 readelf -lW /usr/bin/cat 命令来查看 cat 程序的程序头,将返回以下结果:

在这个文件中,有两个 LOAD (加载)区域。第一个加载区域的作用是将程序映射为可读和可执行的,并加载到内存的地址 0x400000 处。它的长度为 0xa80c 字节,包含了文件中从 0 0x00a80c 字节的内容。由于Arm需要进行内存对齐,因此加载器将这些值四舍五入到当前页对齐的系统。

第二个加载区域也描述了一个内存区域,该区域将被加载到内存的地址 0x41fbe8 处。这个内存区域是可读和可写的,长度为 0x001060 字节,其中,前面的 0x0006e8 字节将从文件偏移量 0x00fbe8 开始提取,剩余的部分将填充为零。

cat 程序被加载到内存中时,最初只有两个内存映射区域会被加载:第一个可读/可执行,第二个可读/可写。但是,随着程序的运行,它可以请求更改内存映射区域的权限。如果更改了内存映射文件区域(或其他类型的内存区域)的一部分的权限,则会将其“分解”为子区域。重新查看 cat /proc/self/maps 的输出,我们可以看到这种情况的确发生了。在这种情况下,映射的读/写节的第一部分被标记为只读,导致它看起来像 /usr/bin/cat 被映射了三次:

这三个相邻的区域组成了加载到内存中的 cat 程序。在这个例子中,我们说 cat 程序加载在地址 0x400000 处,这是第一个映射区域的地址。

3.2.4 地址空间布局随机化

在过去,程序二进制文件会描述它们应该加载到内存中的哪个位置。加载器会尽力将模块加载到这个地址,从而使程序在执行期间内存中加载的内容保持一致。然而,在现代系统中,我们引入了地址空间布局随机化(ASLR)机制,它会将库、程序二进制文件和其他内存数据加载到故意随机化的地址上。

ASLR的作用是增加攻击者利用应用程序中的漏洞(如缓冲区溢出)的难度,防止远程攻击者知道受害进程中的代码和数据加载地址 。尽管ASLR不能完全防御所有内存损坏漏洞利用,但通常情况下,攻击者需要使用其他技术或漏洞来避过ASLR。由于ASLR的性能表现良好并且相对简单,C和C++程序员无须对其应用程序进行源代码级更改即可启用。因此,在现代操作系统中,通常会默认启用ASLR来保护进程免受内存攻击。有关漏洞利用技术和ASLR绕过的详细解释超出了本书的范围,但在我下一本从攻击和防御视角介绍漏洞利用缓解的书中会有更详细的介绍。

需要注意的是,不同操作系统对ASLR的实现可能存在差异。根据一篇2017年发表的论文 ,不同操作系统中ASLR实现的熵存在差异,多数32位操作系统的熵比64位操作系统的低。表3.3列出了每个操作系统中ASLR提供的随机化位数(用1来表示)以及总熵,每种操作系统都提供32位和64位版本。

表3.3 ASLR实现的熵比较

在逆向工程中,理解ASLR的实现和机制通常并不重要,但了解其存在却非常重要。这是因为,在每次运行程序时,内存中的符号和代码片段的地址都会随机化。举例来说,假如我们在调试程序时发现某个关键的函数位于内存地址 0x0000ffffabcd1234 处,那么在下一次运行同一程序时,同样的函数可能出现在完全不同的地址 0x0000ffffbe7d1234 处。

我们可以使用Linux命令 ldd 来演示ASLR的作用。该命令可以输出指定二进制文件所需的共享库,在本例中我们以 /bin/bash 程序为例。启用ASLR后,每次运行 bash 时, ldd 命令都会显示程序使用的共享库,并且发现每个库都映射到不同的随机地址上。

为了在程序分析过程中应对这种不确定性,逆向工程师有两种选择。第一种选择是将伪文件 /proc/sys/kernel/randomize_va_space 中的数值设为0,以暂时禁用系统上的ASLR。这样做会一直禁用ASLR,直到下一次系统重新启动。要重新启用ASLR,可以将此值设置为1(表示部分ASLR)或2(表示完全ASLR)。

第二种选择是在调试会话期间,在调试器中禁用ASLR。实际上,某些版本的GNU Project Debugger(GDB)在默认情况下就会禁用加载的二进制文件上的ASLR。可以通过在GDB中使用 disable-randomization 选项 来控制此选项。

另一种方法是记录地址的偏移量。例如,如果 libc 库被加载到地址 0x0000ffffbe7d0000 ,而一个重要的符号位于地址 0x0000ffffbe7d1234 ,则该符号在该库中的偏移量为 0x1234 。由于ASLR只会改变程序二进制文件和库的基址,不会改变二进制文件中代码和数据的位置,因此,这种偏移形式可以用来引用库或程序中的关键点,而不依赖于库的加载地址。

3.2.5 栈的实现

在执行各自任务的过程中,线程需要跟踪局部变量和控制流信息,如当前的调用栈。这些信息对于线程的执行状态来说是私有的,但由于太大,无法完全存储在寄存器中。为了解决这个问题,每个线程都有一个专门的线程本地“临时”内存区域——称为线程栈。当线程被分配时,线程栈被分配到程序地址空间中,并在线程退出时被取消分配。线程使用一个称为栈指针(Stack Pointer,SP)的专用寄存器来跟踪各自栈的位置。

Arm架构支持四种不同的栈实现方式 :

●满递增栈(Full Ascending)。

●满递减栈(Full Descending)。

●空递增栈(Empty Ascending)。

●空递减栈(Empty Descending)。

区分满栈和空栈实现的方法是记住SP指向的位置:

●满栈:SP指向压入栈的最后一个元素。

●空栈:SP指向栈上的下一个可用空闲位置。

栈增长方向和栈顶部项的位置取决于它是递增实现还是递减实现:

●递增实现:栈向更高的内存地址增长(推送元素时SP递增)。

●递减实现:栈向更低的内存地址增长(推送元素时SP递减)。

对于递增栈而言,进行推送( PUSH )操作时会增加SP的值;而对于递减栈而言,则是递减SP的值。图3.9展示了这四种栈实现方式。需要注意的是,栈的顶部对应的是低地址,而底部对应的是高地址。这是因为大多数调试器在显示栈视图时都采用这种方向。

图3.9 栈的实现方式

在Armv7-A架构的A32指令集中,可以使用 PUSH 指令将数值存储在栈中,并使用 POP 指令将其加载回寄存器。栈指针告诉程序可加载或存储的内存位置。不过,这两个指令都是伪指令,也就是说,它们只是其他指令的别名。在AArch32上, PUSH POP 指令实际上是特定 STM (STore Multiple)和 LDM (LoaD Multiple)指令的别名 。在反汇编时,看到的是底层指令而不是它们的别名。 PUSH POP 指令背后的 STM/LDM 指令助记符表明涉及哪种栈实现。Arm架构的程序调用标准(AAPCS) 始终使用满递减栈。有关内存访问指令的详细信息,请参见第6章。

3.2.6 共享内存

默认情况下,内存地址空间旨在确保进程之间的内存完全隔离。内核通过确保每个进程的地址空间使用不重叠的物理内存来实现这一点,以便每个内存读取/写入操作或指令获取操作都将使用系统内存的不同部分,不会干扰其他进程或内核。然而,有一个例外:共享内存

共享内存是两个或多个进程之间使用同一块物理内存的一块内存区域。这意味着一个进程在共享内存写入的数据可以被其他进程查看。以下示例展示了不同程序的截断地址空间:

在这个示例中,这两个区域都被映射为只读权限。权限后面跟着一个字母: p 或者 s p 表示内存是私有的, s 表示内存是共享的。

当两个进程共享内存时,内核只需要标记两个地址空间中的页表条目(Page Table Entry,PTE),即可使用相同的底层物理内存。当一个进程对其共享内存区域进行写入操作时,写入的内容会被复制到相应的物理内存中,因此另一个进程可以通过对共享内存区域进行读取操作看到写入的内容,因为两个区域都引用相同的物理内存。

需要注意的是,虽然共享内存必须使用相同的底层物理地址,但共享内容的两个进程可以在各自的地址空间中映射数据,并具有不同的内存权限。例如,在一个多进程应用程序中,一个进程可以向可执行和可读的共享内存写入数据,而另一个进程则不能写入。这种情况在启用了安全加固的Web浏览器中进行进程间即时编译时会出现。

在基于TrustZone的可信执行环境(Trusted Execution Environment,TEE)的系统中,共享内存可用于可信应用程序和普通应用程序之间的通信,这些应用程序分别运行在硬件隔离的TrustZone环境和普通操作系统中。在这种情况下,普通环境中的代码会将某些物理内存映射到其地址空间中,而安全环境中的代码则将相同的物理内存区域映射到自己的地址空间中。从任一环境向此共享内存缓冲区写入的数据都对两个进程可见。共享内存是一种高效的通信形式,因为它能够在TrustZone环境和普通操作系统之间快速传输数据,而无须进行上下文切换。更多关于Arm TrustZone的信息可以在第4章中了解。 1UJx8UP0nABAr24dSe8tRzNDcupJvCc7epHeqeoERzM8LNkqX5ixlssY+PXMmPLz

点击中间区域
呼出菜单
上一章
目录
下一章
×

打开