date: 2014-11-26 09:53
翻译自: http://community.arm.com/groups/processors/blog/2010/02/17/caches-and-self-modifying-code
Cache处在CPU核心与内存存储器之间,它给我们的感觉是,它具有“使之运行得更快”的魔力。当然,不同体系结构,其Cache也是千差万别。在编写代码时,常见的建议是,大脑中有一个通用的Cache的概念就可以了,这使得我们能编写出高效率的代码。比如内核代码中,某些数据结构其成员位置的“精心安排”,使得同时会被访问的成员尽量按cache line对齐。但在某些情况,为了保证我们想要的结果,我们必须考虑到cache的具体实现细节,自修改(Self-Modifying)代码就是一种典型的情况。
ARM架构有相互独立的数据cache和指令cache,分别称之为D-cache和I-cache。正因如此,ARM架构经常被当做Modified Harvard Architecture(意即有各自独立的数据总线和指令总线,可以在这两条总线上同时进行存取。与之相对的是von Neumann architecture,这种架构只有一条总线,无论是数据传输还是指令传输都要走这条总线,因此取指令和读(写)数据不能同时进行)。Modified Harvard Architecture架构有很多优点,为了便于后面的讨论,这里只强调一点:因为有两条总线的存在,CPU可以同时进行取指令和取数据的操作。
使用 Harvard-style memory interface自有它的优点,比如效率提升;但它也有自己的缺点。对纯 Harvard架构来说,一个典型的问题是:内存中的指令区(比如代码段)不能被当做数据来直接访问(这句话翻译的可能有问题,不过不影响后面的讨论。原话是:The typical drawback of a pure Harvard architecture is that instruction memory is not directly accessible from the same address space as data memory)。不过这种限制并没有实施到ARM架构上。在ARM架构下,你可以改写指令(比如当前指令之后的某条指令)并将新的指令(指令其实是一种特殊的数据)写到内存中,但是因为D-cache和I-cache不同步,新写的指令会被标记成“已经在I-cache中存在了(而不再从内存中读取)”,导致CPU最终执行的还是老的指令。(这段话比较难懂吧,看可问题描述你就明白了)。
1.问题描述
假定有这样一段“自修改代码”:其中包含及时编译器(JIT)在运行时要动态生成本地指令的“字节码”(不一定是java的字节码),该“字节码”要执行的操作是,将目标函数的地址加载到某个寄存中然后跳转过去。及时编译器(JIT compiler)已经将目标函数移到别处,因此需要更新指向它的指针(因此要修改“加载目标函数地址到寄存器”的指令)。这对及时编译器来说,是再平常不过的操作了,一来目标函数的地址在编译时不确定,二来为了对目标函数实施某些优化而可能将其重编译至别处。 在修改指令之前,CPU看到的指令和数据是这样的:
译者注:movw和movt指令的用法如下:
指令 | 作用 |
---|---|
MOVW | 把16 位立即数放到寄存器的低16 位,高16位清0 |
MOVT | 把16 位立即数放到寄存器的高16 位,低16位不影响 |
上图中,I-Cache一开始就装载了旧版指令。这并不总是正确,如果指令不曾执行那它存在I-cache中的可能性比较低,但不排除这种可能,比如指令预取。为了方便讨论,我们假定I-cache已经装载旧版指令。
处理器只能从I-cache中执行指令,同时只能从D-cache中“看到”数据(内存存储器对它就是透明的),通常处理器不能直接访问内存。对我们而言,我们需要记住:处理器不能直接执行存在于D-cache的“指令”并且不能被安排来读写I-cache中的“数据”。因为CPU不能直接往I-Cache(或内存)中写(指令),因此,当我们改写指令后,CPU看到的指令和数据是这样的:
如果现在尝试去执行修改后的代码,处理器将会忽略它而简单的执行旧的版本,因为对处理器来说,(旧版本)代码仍然在I-cache中并且CPU不知道代码已经做了改动(没人通知CPU说I-cache已经失效)。这对使用自修改代码的Applications (such as JIT compilers)来说,的确是件讨厌的事。
2.问题解决
很明显,我们需要将数据(其实是指令)从D-cache中“转移”到I-Cache中。从上图我们知道,这只有一条路:将D-Cache中数据写到内存中,然后从内存中将指令装载到I-Cache中。 在将来的某个时间点,CPU可能会将D-cache中的数据写到内存中,并从内存中重写装载指令到I-Cache中,但具体在何时我们不得而知,因此无法将希望寄托在CPU不确定的行为身上,我们要立刻、现在就解决它。现在,D-cache中的数据为新的,与内存中的内容已经不一致了,因而是脏数据。毫无疑问,为了将数据写到内存中,我们只需clean它,并等待回写完成。此时,结果如下:
为了执行修改后的代码,我们需要通知处理器,I-cache中的指令已经“过时”,需要从内存中重现装载。我们通过使I-cache失效(invalidating)来达到此目的。此时结果如下:
现在,如果我们再去尝试执行修改后的指令,取指操作将遭遇I-cache miss(未命中),于是就从内存中重新装载,正如我们所料,这次执行的将是修改后的代码。 然而,这并不是事实的全部,还有一些其他的事情需要我们去做。如果处理器自带分支预测(branch prediction),我们还得清除跳转目标缓冲器(branch target buffer,BTB)。通常,处理器会将写内存的操作放在一个缓冲队列中缓冲起来。所以在清(clean)D-cache前,必须完成这些写内存的操作。当然,这些操作是与具体处理器架构相关的。你也可以用一个库函数来干这些“琐事”。如果你只是为了写自修改代码,那么理解你的库函数都干了些啥以及为啥要这样干就可以了。至于具体CPU架构的底层细节,就无需关注了。
最后,你可能想过利用PLI指令来给处理器一个提示,让他重新装载指令到I-Cache中。这可能会给你带来可观的效率提升, as it will not have to stall on memory when you eventually branch to it(这句不懂)。当然,既然是提示,处理器可能会忽视它而不起作用,但在某些实现上它还是有益的。
译者注:PLI 预取指令,这是服务于cache 系统的一条 hint 指令。
3.代码
通常,执行这些任务的相关指令为CP15 (System Control Coprocessor) 操作,不能在非特权模式下执行。这意味着必须借助操作系统(内核)来完成这些操作(系统调用陷入内核后,CPU即处在特权模式)。
在linxu系统中,如果用gcc编译,可以调用 __clear_cache()函数,而在Windwos CE系统中可以调用FlushInstructionCache()函数。
对Android操作系统来说,libc库提供了cacheflush()函数,我们来看看该函数的实现(这部分为译者添加,如果不想了解细节可以跳过)。
原型为:
/* A special syscall that is only available on the ARM, not x86 function. */
int cacheflush(long start, long end, long flags);
其对应的实现在cacheflush.s中
ENTRY(cacheflush)
.save {r4, r7}
stmfd sp!, {r4, r7}
ldr r7, =__NR_ARM_cacheflush
swi #0
ldmfd sp!, {r4, r7}
movs r0, r0
bxpl lr
b __set_syscall_errno
END(cacheflush)
cacheflush通过swi #0陷入内核,其系统调用号为__NR_ARM_cacheflush。
在内核端,__NR_ARM_cacheflush的定义在<kernel/arch/arm/include/asm/unistd.h>中:
#define __NR_SYSCALL_BASE 0
/*
* The following SWIs are ARM private.
*/
#define __ARM_NR_BASE (__NR_SYSCALL_BASE+0x0f0000)
#define __ARM_NR_cacheflush (__ARM_NR_BASE+2)
可见系统调用号__ARM_NR_cacheflush为0x0f0002。
再来看内核的实现(定义在<kernel/arch/arm/kernel/traps.c>文件中):
#define NR(x) ((__ARM_NR_##x) - __ARM_NR_BASE)
asmlinkage int arm_syscall(int no, struct pt_regs *regs)
{
...
/*
* Flush a region from virtual address 'r0' to virtual address 'r1'
* _exclusive_. There is no alignment requirement on either address;
* user space does not need to know the hardware cache layout.
*
* r2 contains flags. It should ALWAYS be passed as ZERO until it
* is defined to be something else. For now we ignore it, but may
* the fires of hell burn in your belly if you break this rule. ;)
*
* (at a later date, we may want to allow this call to not flush
* various aspects of the cache. Passing '0' will guarantee that
* everything necessary gets flushed to maintain consistency in
* the specified region).
*/
case NR(cacheflush):
do_cache_op(regs->ARM_r0, regs->ARM_r1, regs->ARM_r2);
return 0;
...
}
可见,最终调用do_cache_op(),该函数的实现也在本文件中:
static inline void
do_cache_op(unsigned long start, unsigned long end, int flags)
{
struct mm_struct *mm = current->active_mm;
struct vm_area_struct *vma;
if (end < start || flags)
return;
down_read(&mm->mmap_sem);
vma = find_vma(mm, start);
if (vma && vma->vm_start < end) {
if (start < vma->vm_start)
start = vma->vm_start;
if (end > vma->vm_end)
end = vma->vm_end;
up_read(&mm->mmap_sem);
flush_cache_user_range(start, end);
return;
}
up_read(&mm->mmap_sem);
vma即是给定地址区间[start, end)(前闭后开区间)对应的虚存区间,内核用vm_area_struct 结构来管理虚存空间,cacheflush()传进来的地址区间必须是有效的。进行必要的检查后,do_cache_op()调用 flush_cache_user_range() 执行核心操作。
flush_cache_user_range 是一个宏,其定义在<kernel/arch/arm/include/asm/cacheflush.h>:
/*
* flush_cache_user_range is used when we want to ensure that the
* Harvard caches are synchronised for the user space address range.
* This is used for the ARM private sys_cacheflush system call.
*/
#define flush_cache_user_range(start,end) \
__cpuc_coherent_user_range((start) & PAGE_MASK, PAGE_ALIGN(end))
__cpuc_coherent_user_range()是一个与CPU相关的函数,对ARMv7来说,其定义在<kernel/arch/arm/mm/cache-v7.s>中,要读懂这些代码需要了解ARM的技术手册。这里我们只关注'@'符号引导的注释,正如前文所说,这里干了三件事:
- clean D-cache
- invalidate I-cache
- invalidate BTB
代码如下:
/*
* v7_coherent_user_range(start,end)
*
* Ensure that the I and D caches are coherent within specified
* region. This is typically used when code has been written to
* a memory region, and will be executed.
*
* - start - virtual start address of region
* - end - virtual end address of region
*
* It is assumed that:
* - the Icache does not read data from the write buffer
*/
ENTRY(v7_coherent_user_range)
UNWIND(.fnstart )
dcache_line_size r2, r3
sub r3, r2, #1
bic r12, r0, r3
#ifdef CONFIG_ARM_ERRATA_764369
ALT_SMP(W(dsb))
ALT_UP(W(nop))
#endif
1:
USER( mcr p15, 0, r12, c7, c11, 1 ) @ clean D line to the point of unification
add r12, r12, r2
cmp r12, r1
blo 1b
dsb
icache_line_size r2, r3
sub r3, r2, #1
bic r12, r0, r3
2:
USER( mcr p15, 0, r12, c7, c5, 1 ) @ invalidate I line
add r12, r12, r2
cmp r12, r1
blo 2b
3:
mov r0, #0
ALT_SMP(mcr p15, 0, r0, c7, c1, 6) @ invalidate BTB Inner Shareable
ALT_UP(mcr p15, 0, r0, c7, c5, 6) @ invalidate BTB
dsb
isb
mov pc, lr
/*
* Fault handling for the cache operation above. If the virtual address in r0
* isn't mapped, just try the next page.
*/
9001:
mov r12, r12, lsr #12
mov r12, r12, lsl #12
add r12, r12, #4096
b 3b
UNWIND(.fnend )
ENDPROC(v7_coherent_user_range)