Linux 内核用到了许多方式来加强性能以及稳定性,本节探讨的 memcpy 的汇编实现方式就是其中的一种,memcpy 的性能是否强大,拷贝延迟是否足够低都直接影响着整个系统性能。通过对拷贝函数的理解可以加深对整个系统设计的一个理解,同时提升自身技术实力。
Linux 内核的拷贝函数也不是一开始就是这么好的性能,在 内核3.14 之前 Linux 尚且没有完善对 ARM64 架构的支持,系统的内存拷贝函数就是一个简单的 c 语言版本,也就是目前内核中的通用拷贝函数。
如下代码实现:在没有定义 __HAVE_ARCH_MEMCPY 之前,内核就会采用最简单的逐字节拷贝,完全不需要考虑对齐,不需要考虑性能…
#ifndef __HAVE_ARCH_MEMCPY
/**
* memcpy - Copy one area of memory to another
* @dest: Where to copy to
* @src: Where to copy from
* @count: The size of the area.
*
* You should not use this function to access IO space,
* use memcpy_toio()
* or memcpy_fromio() instead.
*/
void *memcpy(void *dest, const void *src, size_t count)
{
char *tmp = dest;
const char *s = src;
while (count--)
*tmp++ = *s++;
return dest;
}
EXPORT_SYMBOL(memcpy);
#endif
通常不会采用这样的代码来运行OS,那么是否可以做一个简单的优化?
当前主流的架构都是(x86/arm64)64位,32 位机器正在逐渐汰掉,所以可以使用地址总线64bits这一特性来进一步优化:
void *memcpy(void *d, void *s, size_t count)
{
int i;
for (i = 0; i < count / sizeof(int64_t); i++) {
(int64_t *)d++ = (int64_t *)s++;
}
return d;
}
一条指令下去就可以完成 8 个字节的拷贝,这样整个循环体直接缩减为原来的 1/8,效率是上一版本的 8 倍之多。是否还可以优化?
我们知道CPU的跳转指令代价很高,因为它会更新整个pipline,所以软件应该尽可能的减少 代码的跳转,上面的代码做完一次 8 字节的拷贝之后就进行一个跳转,那么是不是可以减少一些跳转呢?
void *memcpy(void *d, void *s, size_t count)
{
int i;
for (i = 0; i < count / sizeof(int) / 4; i++) {
(int *)d++ = (int *)s++;
(int *)d++ = (int *)s++;
(int *)d++ = (int *)s++;
(int *)d++ = (int *)s++;
}
return d;
}
通过展开循环的配置从而减少了cpu跳转。
循环展开也做了,有没有其他的方式可以继续优化呢?
尽管ARM arch64 最多一次能存储 8 个字节,但是它还有更为高级的寄存器,那就是向量寄存器,通过 NEON 指令处理,可以一次性搬移 128 位数据,也就是 16个字节,这样效率又提升一倍:
#include
void *memcpy_128(void *dest, void *src, size_t count)
{
int i;
unsigned long *s = (unsigned long *)src;
unsigned long *d = (unsigned long *)dest;
for (i = 0; i < count / 64; i++) {
vst1q_u64(&d[0], vld1q_u64(&s[0]));
vst1q_u64(&d[2], vld1q_u64(&s[2]));
vst1q_u64(&d[4], vld1q_u64(&s[4]));
vst1q_u64(&d[6], vld1q_u64(&s[6]));
d += 8; s += 8;
}
return dest;
}
上节的代码通过 NEON 指令优化之后,一次循环可以处理 64 字节的数据,大大的加快了拷贝效率。还有没有更好的优化方式?
当然是有的,那就是用汇编来写,结合上面提到的所有的优化方式,以汇编的形式实现,可以获得最佳性能。我们看下 Linux 内核下的 ARM64 架构 memcpy 的实现方式。
arch/arm64/lib/memcpy.S
ENTRY(__memcpy)
ENTRY(memcpy)
#include "copy_template.S"
ret
ENDPIPROC(memcpy)
ENDPROC(__memcpy)
memcpy.S 直接 include 了一个 copy_template.S 的文件,这个 copy_template.S 不仅仅只是在 memcpy.S 中用到,在其他的类似 copy_to_user.S 和copy_from_user.S 中也被包含。
从上图可以看出,拷贝算法将数据分为 3 个大的部分:
LDP/STP指令
相比于LDR和STR指令(8 bytes),LDP和STP指令用于多字节(16 bytes)操作,
[释义]:
LDP :LDP x3, x7, [x0] -> 从x0的值为基地址,加载地址到X3寄存器,存储x0+8到x7寄存器。
STP :STP x1, x2, [x4]-> 以x4的值为基地址,存储x1地址的值到x4,存储x2地址的值到x4 + 8。
memset 和 memcpy 都使用LDP和STP多字节加载和存储指令。
由于汇编内容比较长,这里主要介绍一些关键部分:
arch/arm64/lib/memset.S
#include
#include
#include
/*
* Fill in the buffer with character c (alignment handled by the
* hardware)
*
* Parameters:
* x0 - buf
* x1 - c
* x2 - n
* Returns:
* x0 - buf
*/
dstin .req x0 //目的地址
val .req w1 //pattern
count .req x2 //长度 length
tmp1 .req x3
tmp1w .req w3
tmp2 .req x4
tmp2w .req w4
zva_len_x .req x5
zva_len .req w5
zva_bits_x .req x6
A_l .req x7
A_lw .req w7
dst .req x8
tmp3w .req w9
tmp3 .req x9
/*
* The count is not less than 16, we can use stp to store the start 16
* bytes, then adjust the dst aligned with 16.This process will make
* the current memory address at alignment boundary.
*/
stp A_l, A_l, [dst] /*non-aligned store..*/
/*make the dst aligned..*/
sub count, count, tmp2
add dst, dst, tmp2
.Laligned:
cbz A_l, .Lzero_mem
.Ltail_maybe_long:
cmp count, #64
b.ge .Lnot_short //大于等于64则跳转到 Lnot_short执行
.Lnot_short:
sub dst, dst, #16/* Pre-bias. */
sub count, count, #64
1: //这里是展开写入64bytes
stp A_l, A_l, [dst, #16]
stp A_l, A_l, [dst, #32]
stp A_l, A_l, [dst, #48]
stp A_l, A_l, [dst, #64]!
subs count, count, #64
b.ge 1b//判断count减去64之后是否大于等于0,等于则继续跳转到标号1
tst count, #0x3f //判断是否等于63
add dst, dst, #16
b.ne .Ltail63 //小于63则跳转到Ltail63标号
从上面实现代码来看,memset
也是通过stp多字节写入指令来实现高性能执行的。
linux 内核中,将用户态数据拷贝到内核或者将用户态数据拷贝到内核,使用的是copy_from_user
和copy_to_user
。
static __always_inline unsigned long __must_check
copy_to_user(void __user *to, const void *from, unsigned long n);
static __always_inline unsigned long __must_check
copy_from_user(void *to, const void __user *from, unsigned long n);
void *memcpy(void *dest, const void *src, size_t len);
但是在有些情况下,直接使用memcpy也不会出现错误,可以正常的将数据从内核态拷贝到用户态以及将数据从用户态拷贝到内核态。那么什么时候使用memcpy会发生错误呢?memcpy和copy_{to/from}_user的区别又是什么呢?
在ARM32架构上,将用户态数据拷贝到内核时,首先区分用户态数据的地址是否有效(也就是属于申请的虚拟地址范围):
当用户态虚拟地址有效时,那么在内核中使用memcpy
和copy_{to/from}_user
的过程是一样的,不会出现任何问题。即使虚拟地址没有映射到物理内存,memcpy在内核态发生缺页后会由do_page_fault
申请物理内存,然后建立虚拟地址和物理地址的映射,这个过程和copy_{to/from}_user
一样。
当用户态虚拟地址无效时,内核态使用memcpy
会导致缺页,然后调用do_page_fault
申请物理内存。但是,由于虚拟地址是无效的,因此do_page_fault
不能处理这种异常,也就不能建立虚拟地址和物理地址的映射关系,最终将导致kernel oops。
当用户态虚拟地址无效时,内核使用copy_{to/from}_user
进行用户空间的数据拷贝,并且copy_{to/from}_user
对所有内存操作的指令建立异常处理指令,也就是在对应的内存操作指令发生错误时,do_page_fault
会跳转到异常处理处执行,处理后给用户空间返回错误提示,而不是直接报kernel oops。具体的过程如下:
copy_{to/from}_user
操作用户态地址,此时虚拟地址无效(无效地址一定是没有和物理地址建立映射关系的),因此发生缺页异常。do_page_fault
发生异常的虚拟地址查找物理内存,此时发现虚拟地址无效,因此就会查找异常表(查找异常指令地址对应的异常处理地址),如果在异常处理表中查找到相应的处理项,就do_page_fault
就返回到异常指令处理的地方执行,该异常处理指令最终给用户态返回一个错误,而不是kernel oops。在64位arm架构下,linux可以开启内核不能访问用户空间地址的选项,此时,我们内核就不能直接访问用户空间地址了,否则就会报错。既然不能访问用户空间地址,那在内核当中就不能使用memcpy
来操作用户空间的数据,且只能使用copy_{to/from}_user
。
Q:copy_{to/from}_user操作的也是用户空间地址,为什么他们不会有问题,而memcpy会有问题呢?
A: 在arm64下,有两个页表:
当我们开启了内核空间不能访问用户空间地址的选项时,进程在从用户空间切换到内核空间时,linux会将用户空间的页表设置为一个无效的页表。因此在kernel使用用户空间地址时,用户空间的页表是无效的,也就不能使用memcpy
来交换用户空间和内核空间的数据了。而copy_{to/from}_user
在交换用户空间和内核空间的数据时,会先将用户空间的页表设置回那个有效的页表,然后再执行数据的操作,所以
copy_{to/from}_user
可以放问用户空间地址,而其他函数(例如memcpy
)不能。
同样,copy_{to/from}_user
对用户空间地址操作的指令都在异常处理表中建立了一个映射,当操作的用户空间地址异常时,发生缺页异常后do_page_fault
不能建立虚拟地址和物理地址的映射管理,do_page_fault
会查找异常指令对应的异常处理函数,然后跳转到异常处理指令处执行,最后给用户态空间返回一个错误,而不是kernel oops。
推荐阅读:
https://www.byteisland.com/arm64-%E7%9A%84-memcpy
https://blog.csdn.net/forever_2015/article/details/50286009
https://blog.csdn.net/u012787604/article/details/121964272
http://www.wowotech.net/memory_management/454.html