【ARM Linux 内存管理入门及渐进 4 - 常用接口实现(memcpy/copy_to_user)】

文章目录

    • 1.1 memcpy 实现
      • 1.1.1 memcpy 简单实现
      • 1.1.2 memcpy 简单优化
      • 1.1.3 memcpy 展开循环
      • 1.1.4 memcpy neon指令使用
      • 1.1.5 memcpy 汇编指令实现
    • 1.2 memset 实现
      • 1.2.1 STP/LDP指令
      • 1.2.2 memset 汇编实现
    • 1.2 copy_{to/from}_user实现
      • 1.2.1 ARM32 场景
      • 1.2.2 ARM64 场景

1.1 memcpy 实现

Linux 内核用到了许多方式来加强性能以及稳定性,本节探讨的 memcpy 的汇编实现方式就是其中的一种,memcpy 的性能是否强大,拷贝延迟是否足够低都直接影响着整个系统性能。通过对拷贝函数的理解可以加深对整个系统设计的一个理解,同时提升自身技术实力。

Linux 内核的拷贝函数也不是一开始就是这么好的性能,在 内核3.14 之前 Linux 尚且没有完善对 ARM64 架构的支持,系统的内存拷贝函数就是一个简单的 c 语言版本,也就是目前内核中的通用拷贝函数。

1.1.1 memcpy 简单实现

如下代码实现:在没有定义 __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,那么是否可以做一个简单的优化?

1.1.2 memcpy 简单优化

当前主流的架构都是(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 字节的拷贝之后就进行一个跳转,那么是不是可以减少一些跳转呢?

1.1.3 memcpy 展开循环

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跳转。

1.1.4 memcpy neon指令使用

循环展开也做了,有没有其他的方式可以继续优化呢?
尽管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;
}

1.1.5 memcpy 汇编指令实现

上节的代码通过 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 中也被包含。
【ARM Linux 内存管理入门及渐进 4 - 常用接口实现(memcpy/copy_to_user)】_第1张图片
从上图可以看出,拷贝算法将数据分为 3 个大的部分:

  • 第一个部分就是不对齐部分,通过对传入的 src 地址进行分析,首先处理掉不能被 16 整除的前面不对齐数据,然后处理对齐的数据。
  • 对齐的数据以 128 为一个界限,每一个 128 字节数据都能通过大块拷贝直接计算完毕,一直循环到最后剩余的尾部 128 以下的字节。
  • 剩余小于128字节的数据处理

整体设计逻辑流程图如下:
【ARM Linux 内存管理入门及渐进 4 - 常用接口实现(memcpy/copy_to_user)】_第2张图片

1.2 memset 实现

1.2.1 STP/LDP指令

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多字节加载和存储指令。

1.2.2 memset 汇编实现

由于汇编内容比较长,这里主要介绍一些关键部分:
arch/arm64/lib/memset.S

  • 获取memset的三个参数 buf/c/n:
#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
  • 判断写长度,这里只介绍大于64的情况,
/*
* 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执行
  • 循环写pattern
.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多字节写入指令来实现高性能执行的。

1.2 copy_{to/from}_user实现

linux 内核中,将用户态数据拷贝到内核或者将用户态数据拷贝到内核,使用的是copy_from_usercopy_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的区别又是什么呢?

1.2.1 ARM32 场景

在ARM32架构上,将用户态数据拷贝到内核时,首先区分用户态数据的地址是否有效(也就是属于申请的虚拟地址范围)

  • 当用户态虚拟地址有效时,那么在内核中使用memcpycopy_{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。

1.2.2 ARM64 场景

在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

你可能感兴趣的:(ARM,BSP,系列,arm,linux)