Linux为什么一定要copy_from_user ?

网上很多人提问为什么一定要copy_from_user,也有人解答。比如百度一下:

但是这里面很多的解答没有回答到点子上,不能真正回答这个问题。我决定写篇文章正式回答一下这个问题,消除读者的各种疑虑。

这个问题,我认为需要从2个层面回答

  • 个层次是为什么要拷贝,可不可以不拷贝?

  • 第二个层次是为什么要用copy_from_user而不是直接memcpy

为什么要拷贝

拷贝这个事情是必须的,这个事情甚至都跟Linux都没有什么关系。比如Linux有个kobject结构体,kobject结构体里面有个name指针:​​​​​​​

struct kobject {  const char    *name;  struct list_head  entry;  struct kobject    *parent;  struct kset    *kset;  struct kobj_type  *ktype;  struct kernfs_node  *sd; /* sysfs directory entry */  struct kref    kref;...};

但我们设置一个设备的名字的时候,其实就是设置device的kobject的name:​​​​​​​

int dev_set_name(struct device *dev, const char *fmt, ...){  va_list vargs;  int err;
  va_start(vargs, fmt);  err = kobject_set_name_vargs(&dev->kobj, fmt, vargs);  va_end(vargs);  return err;}

驱动里面经常要设置name,比如:​​​​​​​

  dev_set_name(&chan->dev->device, "dma%dchan%d",         device->dev_id, chan->chan_id);

但是Linux没有傻到直接把name的指针这样赋值:

struct device {  struct kobject kobj;  ...}; dev_set_name(struct device *dev, char *name){  dev->kobj.name = name_param; //假想的烂代码}

如果它这样做了的话,那么它就完蛋了,因为驱动里面完全可以这样设置name:

driver_func(){  char name[100];  ....  dev_set_name(dev, name);}

传给dev_set_name()的根本是个stack区域的临时变量,是一个匆匆过客。而device的name对于这个device来讲,必须长期存在。所以你看内核真实的代码,是给kobject的name重新申请一份内存,然后把dev_set_name()传给它的name拷贝进来:

int kobject_set_name_vargs(struct kobject *kobj, const char *fmt,          va_list vargs){  const char *s;  ..  s = kvasprintf_const(GFP_KERNEL, fmt, vargs);  ...  if (strchr(s, '/')) {    char *t;
    t = kstrdup(s, GFP_KERNEL);    kfree_const(s);    if (!t)      return -ENOMEM;    strreplace(t, '/', '!');    s = t;  }  kfree_const(kobj->name);  kobj->name = s;
  return 0;}

这个问题在用户空间和内核空间的交界点上是完全存在的。假设内核里面某个驱动的xxx_write()是这么写的:​​​​​​​

struct globalmem_dev {        struct cdev cdev;        unsigned char *mem;        struct mutex mutex;};
static ssize_t globalmem_write(struct file *filp, const char __user * buf,                               size_t size, loff_t * ppos){        struct globalmem_dev *dev = filp->private_data;
        dev->mem = buf; //假想的烂代码
        return ret;}

这样的代码是要完蛋的,因为dev->mem这个内核态的指针完全有可能被内核态的中断服务程序、被workqueue的callback函数、被内核线程,或者被用户空间的另外一个进程通过globalmem_read()去读,但是它却指向一个某个进程用户空间的buffer。

在内核里面直接使用用户态传过来的const char __user * buf指针,是灾难性的,因为buf的虚拟地址,只在这个进程空间是有效的,跨进程是的。但是调度一直在发生,中断是存在的,workqueue是存在的,内核线程是存在的,其他进程是存在的,原先的用户进程的buffer地址,切了个进程之后就不知道是个什么鬼!换个进程,页表都特码变了,你这个buf地址还能找着人?进程1的buf地址,在下面的红框里面,什么都不是!

所以内核的正确做法是,把buf拷贝到一个跨中断、跨进程、跨workqueue、跨内核线程的长期有效的内存里面:​​​​​​​

struct globalmem_dev {        struct cdev cdev;        unsigned char mem[GLOBALMEM_SIZE];//长期有效        struct mutex mutex;};
static ssize_t globalmem_write(struct file *filp, const char __user * buf,                               size_t size, loff_t * ppos){        unsigned long p = *ppos;        unsigned int count = size;        int ret = 0;        struct globalmem_dev *dev = filp->private_data;        ....
        if (copy_from_user(dev->mem + p, buf, count))//拷贝!!                ret = -EFAULT;        else {                *ppos += count;                ret = count;        ...}

记住,对于内核而言,用户态此刻传入的指针只是一个匆匆过客,只是个灿烂烟花,只是个昙花一现,瞬间即逝!它甚至都没有许诺你天长地久,随时可能劈腿!

所以,如果一定要给个需要拷贝的理由,原因就是防止劈腿!别给我扯些有的没的。

必须拷贝的第二个理由,可能与安全有关。比如用户态做类似pwritev, preadv这样的调用:​​​​​​​

ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);

用户传给内核一个iov的数组,数组每个成员描述一个buffer的基地址和长度:​​​​​​​

struct iovec{  void __user *iov_base;  /* BSD uses caddr_t (1003.1g requires void *) */  __kernel_size_t iov_len; /* Must be size_t (1003.1g) */};

用户传过来的是一个iovec的数组,里面有每个iov的len和base(base也是指向用户态的buffer的),传进内核的时候,内核会对iovec的地址进行check,保证它确实每个buffer都在用户空间,并且会把整个iovec数组拷贝到内核空间:​​​​​​​

ssize_t import_iovec(int type, const struct iovec __user * uvector,     unsigned nr_segs, unsigned fast_segs,     struct iovec **iov, struct iov_iter *i){  ssize_t n;  struct iovec *p;  n = rw_copy_check_uvector(type, uvector, nr_segs, fast_segs,          *iov, &p);  ...  iov_iter_init(i, type, p, nr_segs, n);  *iov = p == *iov ? NULL : p;  return n;}

这个过程是有严格的安全考量的,整个iov数组会被copy_from_user(),而数组里面的每个buf都要被access_ok的检查:​​​​​​​

ssize_t rw_copy_check_uvector(int type, const struct iovec __user * uvector,            unsigned long nr_segs, unsigned long fast_segs,            struct iovec *fast_pointer,            struct iovec **ret_pointer){  ...  if (copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))) {    ret = -EFAULT;    goto out;  }
  ...  ret = 0;  for (seg = 0; seg < nr_segs; seg++) {    void __user *buf = iov[seg].iov_base;    ssize_t len = (ssize_t)iov[seg].iov_len;
    ...    if (type >= 0        && unlikely(!access_ok(buf, len))) {      ret = -EFAULT;      goto out;    }    ...  }out:  *ret_pointer = iov;  return ret;}

access_ok(buf, len)是确保从buf开始的len长的区间,一定是位于用户空间的,应用程序不能传入一个内核空间的地址来传给系统调用,这样用户可以通过系统调用,让内核写坏内核本身,造成一系列内核安全漏洞。

假设内核不把整个iov数组通过如下代码拷贝进内核:

copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))

而是直接访问用户态的iov,那个这个access_ok就完全失去价值了,因为,用户完全可以在你做access_ok检查的时候,传给你的是用户态buffer,之后把iov_base的内容改成指向一个内核态的buffer去。

所以,从这个理由上来讲,开始的拷贝也是必须的。但是这个理由远远没有开始那个随时劈腿的理由充分!

为什么不直接用memcpy?

这个问题主要涉及到2个层面,一个是copy_from_user()有自带的access_ok检查,如果用户传进来的buffer不属于用户空间而是内核空间,根本不会拷贝;二是copy_from_user()有自带的page fault后exception修复机制。

先看个问题,如果代码直接用memcpy():​​​​​​​

static ssize_t globalmem_write(struct file *filp, const char __user * buf,                               size_t size, loff_t * ppos){             struct globalmem_dev *dev = filp->private_data;        ....
        memcpy(dev->mem + p, buf, count))
        return ret;}

memcpy是没有这个检查的,哪怕用户传入进来的这个buf,指向的是内核态的地址,这个拷贝也是要做的。试想,用户做系统调用的时候,随便可以把内核的指针传进来,那用户不是可以随便为所欲为?比如内核的这个commit,引起了的安全漏洞:

CVE-2017-5123

就是因为,作者把有access_ok的put_user改为了没有access_ok的unsafe_put_user。这样,用户如果把某个进程的uid地址传给内核,内核unsafe_put_user的时候,不是完全可以把它的uid改为0?

所以,你看到内核修复这个CVE的时候,是对这些地址进行了一个access_ok的:

下面我们看第二个问题,page fault的修复机制。假设用户程序随便胡乱传个用户态的地址给内核:​​​​​​​

void main(void){        int fd;
        fd = open("/dev/globalfifo", O_RDWR, S_IRUSR | S_IWUSR);        if (fd != -1) {                int ret = write(fd, 0x40000000, 10);//假想的代码                if (ret < 0)                        perror("write error\n");        }}

0x40000000这个地址是用户态的,所以access_ok是没有问题的。但是这个地址,根本什么有效的数据、heap、stack都不是。我特码就是瞎写的。

如果内核驱动用memcpy会发生什么呢?我们会看到一段内核Oops:

用户进程也会被kill掉:​​​​​​​

# ./a.out Killed

当然如果你设置了/proc/sys/kernel/panic_on_oops为1的话,内核就不是Opps这么简单了,而是直接panic了。

但是如果内核用的是copy_from_user呢?内核是不会Oops的,用户态应用程序也是不会死的,它只是收到了bad address的错误:​​​​​​​

# ./a.out write error: Bad address

内核只是友好地提示你用户闯进来的buffer地址0x40000000是个错误的地址,这个系统调用的参数是不对的,这显然更加符合系统调用的本质

内核针对copy_from_user,有exception fixup机制,而memcpy()是没有的。详细的exception修复机制见:

https://www.kernel.org/doc/Documentation/x86/exception-tables.txt

PAN

如果我们想研究地更深,硬件和软件协同做了一个更加安全的机制,这个机制叫做PAN (Privileged Access Never) 。它可以把内核对用户空间的buffer访问限制在特定的代码区间里面。PAN可以阻止kernel直接访问用户,它要求访问之前,必须在硬件上开启访问权限。根据ARM的spec文档

https://static.docs.arm.com/ddi0557/ab/DDI0557A_b_armv8_1_supplement.pdf

描述:

所以,内核每次访问用户之前,需要修改PSATE寄存器开启访问权限,完事后应该再次修改PSTATE,关闭内核对用户的访问权限。

根据补丁:

https://patchwork.kernel.org/patch/6808781/

copy_from_user这样的代码,是有这个开启和关闭的过程的。

所以,一旦你开启了内核的PAN支持,你是不能在一个随随便便的位置访问用户空间的buffer的。

引言

我们对copy_{to,from}_user()接口的使用应该是再熟悉不过吧。基本Linux书籍都会介绍它的作用。毕竟它是kernel space和user space沟通的桥梁。所有的数据交互都应该使用类似这种接口。所以,我们没有理由不知道接口的作用。但是,我也曾经有过以下疑问。

  1. 为什么需要copy_{to,from}_user(),它究竟在背后为我们做了什么?
  2. copy_{to,from}_user()和memcpy()的区别是什么,直接使用memcpy()可以吗?
  3. memcpy()替代copy_{to,from}_user()是不是一定会有问题?

一下子找回了当年困惑的自己。我所提出的每个问题,曾经我也思考过。还不止一次的思考,每一次都有不同的想法。当然是因为从一开始就我就没有完全理解。现在又重新回到这个沉重的话题,继续思考这曾经的问题。

温馨提示:文章代码分析基于Linux-4.18.0,部分架构相关代码以ARM64为代表。

百家争鸣

针对以上问题当然是先百度。百度对于该问题的博客也是很多,足以看出这个问题肯定困惑着一大批Linux的爱好者。对于我的查阅结果来说,观点主要分成以下两种:

  • copy_{to,from}_user()比memcpy()多了传入地址合法性校验。例如是否属于用户空间地址范围。理论上说,内核空间可以直接使用用户空间传过来的指针,即使要做数据拷贝的动作,也可以直接使用memcpy(),事实上在没有MMU的体系架构上,copy_{to,from}_user()最终的实现就是利用了memcpy()。但是对于大多数有MMU的平台,情况就有了些变化:用户空间传过来的指针是在虚拟地址空间上的,它所指向的虚拟地址空间很可能还没有真正映射到实际的物理页面上。但是这又能怎样呢?缺页导致的异常会很透明地被内核予以修复(为缺页的地址空间提交新的物理页面),访问到缺页的指令会继续运行仿佛什么都没有发生一样。但这只是用户空间缺页异常的行为,在内核空间这种缺页异常必须被显式地修复,这是由内核提供的缺页异常处理函数的设计模式决定的。其背后的思想是:在内核态,如果程序试图访问一个尚未被提交物理页面的用户空间地址,内核必须对此保持警惕而不能像用户空间那样毫无察觉。
  • 如果我们确保用户态传递的指针的正确性,我们完全可以用memcpy()函数替代copy_{to,from}_user()。经过一些试验测试,发现使用memcpy(),程序的运行上并没有问题。因此在确保用户态指针安全的情况下,二者可以替换。

从各家博客上,观点主要集中在第一点。看起来第一点受到大家的广泛认可。但是,注重实践的人又得出了第二种观点,毕竟是实践出真知。真理究竟是是掌握在少数人手里呢?还是群众的眼睛是雪亮的呢?当然,我不否定以上任何一种观点。也不能向你保证哪种观点正确。因为,我相信即使是曾经无懈可击的理论,随着时间的推移或者特定情况的改变理论也可能不再正确。比如,牛顿的经典力学理论(好像扯得有点远)。如果要我说人话,就是:随着时间的推移,Linux的代码在不断的变化。或许以上的观点在曾经正确。当然,也可能现在还正确。下面的分析就是我的观点了。同样,大家也是需要保持怀疑的态度。下面我就抛砖引玉。

抛砖引玉

首先我们看下memcpy()和copy_{to,from}_user()的函数定义。参数几乎没有差别,都包含目的地址,源地址和需要复制的字节size。

  1. static __always_inline unsigned long __must_check
  2. copy_to_user(void __user *to, const void *from, unsigned long n);
  3. static __always_inline unsigned long __must_check
  4. copy_from_user(void *to, const void __user *from, unsigned long n);
  5. void *memcpy(void *dest, const void *src, size_t len);

但是,有一点我们肯定是知道的。那就是memcpy()没有传入地址合法性校验。而copy_{to,from}_user()针对传入地址进行类似下面的合法性校验(简单说点,更多校验详情可以参考代码)。

  • 如果从用户空间copy数据到内核空间,用户空间地址to及to加上copy的字节长度n必须位于用户空间地址空间。
  • 如果从内核空间copy数据到用户空间,当然也需要检查地址的合法性。例如,是否越界访问或者是不是代码段的数据等等。总之一切不合法地操作都需要立刻杜绝。

经过简单的对比之后,我们再看看其他的差异以及一起探讨下上面提出的2个观点。我们先从第2个观点说起。涉及实践,我还是有点相信实践出真知。从我测试的结果来说,实现结果分成两种情况。

第一种情况的结果是:使用memcpy()测试,没有出现问题,代码正常运行。测试代码如下(仅仅展示proc文件系统下file_operations对应的read接口函数):

  1. static ssize_t test_read(struct file *file, char __user *buf,
  2. size_t len, loff_t *offset)
  3. {
  4. memcpy(buf, "test\n", 5); /* copy_to_user(buf, "test\n", 5) */
  5. return 5;
  6. }

我们使用cat命令读取文件内容,cat会通过系统调用read调用test_read,并且传递的buf大小是4k。测试很顺利,结果很喜人。成功地读到了“test”字符串。看起来,第2点观点是没毛病的。但是,我们还需要继续验证和探究下去。因为第1个观点提到,“在内核空间这种缺页异常必须被显式地修复”。因此我们还需要验证的情况是:如果buf在用户空间已经分配虚拟地址空间,但是并没有建立和物理内存的具体映射关系,这种情况下会出现内核态page fault。我们首先需要创建这种条件,找到符合的buf,然后测试。这里我当然没测啦。因为有测试结论(主要是因为我懒,构造这个条件我觉得比较麻烦)。这个测试是我的一个朋友,人称宋老师的“阿助教”阿克曼大牛。他曾经做个这个实验,并且得到的结论是:即使是没有建立和物理内存的具体映射关系的buf,代码也可以正常运行。在内核态发生page fault,并被其修复(分配具体物理内存,填充页表,建立映射关系)。同时,我从代码的角度分析,结论也是如此。

经过上面的分析,看起来好像是memcpy()也可以正常使用,鉴于安全地考虑建议使用copy_{to,from}_user()等接口。

第二种情况的结果是:以上的测试代码并没有正常运行,并且会触发kernel oops。当然本次测试和上次测试的kernel配置选项是不一样的。这个配置项是CONFIG_ARM64_SW_TTBR0_PAN或者CONFIG_ARM64_PAN(针对ARM64平台)。两个配置选项的功能都是阻止内核态直接访问用户地址空间。只不过,CONFIG_ARM64_SW_TTBR0_PAN是软件仿真实现这种功能,而CONFIG_ARM64_PAN是硬件实现功能(ARMv8.1扩展功能)。我们以CONFIG_ARM64_SW_TTBR0_PAN作为分析对象(软件仿真才有代码提供分析)。BTW,如果硬件不支持,即使配置CONFIG_ARM64_PAN也没用,只能使用软件仿真的方法。内核Kconfig部分解释如下。如果需要访问用户空间地址需要通过类似copy_{to,from}_user()的接口,否则会导致kernel oops。

  1. config ARM64_SW_TTBR0_PAN
  2. bool "Emulate Privileged Access Never using TTBR0_EL1 switching"
  3. help
  4. Enabling this option prevents the kernel from accessing
  5. user-space memory directly by pointing TTBR0_EL1 to a reserved
  6. zeroed area and reserved ASID. The user access routines
  7. restore the valid TTBR0_EL1 temporarily.

在打开CONFIG_ARM64_SW_TTBR0_PAN的选项后,测试以上代码就会导致kernel oops。原因就是内核态直接访问了用户空间地址。因此,在这种情况我们就不可以使用memcpy()。我们别无选择,只能使用copy_{to,from}_user()。当然了,我们也不是没有办法使用memcpy(),但是需要额外的操作。如何操作呢?下一节为你揭晓。

刨根问底

既然提到了CONFIG_ARM64_SW_TTBR0_PAN的配置选项。当然我也希望了解其背后设计的原理。由于ARM64的硬件特殊设计,我们使用两个页表基地址寄存器ttbr0_el1和ttbr1_el1。处理器根据64 bit地址的高16 bit判断访问的地址属于用户空间还是内核空间。如果是用户空间地址则使用ttbr0_el1,反之使用ttbr1_el1。因此,ARM64进程切换的时候,只需要改变ttbr0_el1的值即可。ttbr1_el1可以选择不需要改变,因为所有的进程共享相同的内核空间地址。

当进程切换到内核态(中断,异常,系统调用等)后,如何才能避免内核态访问用户态地址空间呢?其实不难想出,改变ttbr0_el1的值即可,指向一段非法的映射即可。因此,我们为此准备了一份特殊的页表,该页表大小4k内存,其值全是0。当进程切换到内核态后,修改ttbr0_el1的值为该页表的地址即可保证访问用户空间地址是非法访问。因为页表的值是非法的。这个特殊的页表内存通过链接脚本分配。

  1. #define RESERVED_TTBR0_SIZE (PAGE_SIZE)
  2.  
  3. SECTIONS
  4. {
  5. reserved_ttbr0 = .;
  6. . += RESERVED_TTBR0_SIZE;
  7. swapper_pg_dir = .;
  8. . += SWAPPER_DIR_SIZE;
  9. swapper_pg_end = .;
  10. }

这个特殊的页表和内核页表在一起。和swapper_pg_dir仅仅差4k大小。reserved_ttbr0地址开始的4k内存空间的内容会被清零。

当我们进入内核态后会通过__uaccess_ttbr0_disable切换ttbr0_el1以关闭用户空间地址访问,在需要访问的时候通过__uaccess_ttbr0_enable打开用户空间地址访问。这两个宏定义也不复杂,就以__uaccess_ttbr0_disable为例说明原理。其定义如下:

  1. .macro __uaccess_ttbr0_disable, tmp1
  2. mrs \tmp1, ttbr1_el1 // swapper_pg_dir (1)
  3. bic \tmp1, \tmp1, #TTBR_ASID_MASK
  4. sub \tmp1, \tmp1, #RESERVED_TTBR0_SIZE // reserved_ttbr0 just before
  5. // swapper_pg_dir (2)
  6. msr ttbr0_el1, \tmp1 // set reserved TTBR0_EL1 (3)
  7. isb
  8. add \tmp1, \tmp1, #RESERVED_TTBR0_SIZE
  9. msr ttbr1_el1, \tmp1 // set reserved ASID
  10. isb
  11. .endm
  1. ttbr1_el1存储的是内核页表基地址,因此其值就是swapper_pg_dir。
  2. swapper_pg_dir减去RESERVED_TTBR0_SIZE就是上面描述的特殊页表。
  3. 将ttbr0_el1修改指向这个特殊的页表基地址,当然可以保证后续访问用户地址都是非法的。

__uaccess_ttbr0_disable对应的C语言实现可以参考这里。如何允许内核态访问用户空间地址呢?也很简单,就是__uaccess_ttbr0_disable的反操作,给ttbr0_el1赋予合法的页表基地址。这里就不必重复了。我们现在需要知道的事实就是,在配置CONFIG_ARM64_SW_TTBR0_PAN的情况下,copy_{to,from}_user()接口会在copy之前允许内核态访问用户空间,并在copy结束之后关闭内核态访问用户空间的能力。因此,使用copy_{to,from}_user()才是正统做法。主要体现在安全性检查及安全访问处理。这里是其比memcpy()多的第一个特性,后面还会介绍另一个重要特性。

现在我们可以解答上一节中遗留的问题。怎样才能继续使用memcpy()?现在就很简单了,在memcpy()调用之前通过uaccess_enable_not_uao()允许内核态访问用户空间地址,调用memcpy(),最后通过uaccess_disable_not_uao()关闭内核态访问用户空间的能力。

未雨绸缪

以上的测试用例都是建立在用户空间传递合法地址的基础上测试的,何为合法的用户空间地址?用户空间通过系统调用申请的虚拟地址空间包含的地址范围,即是合法的地址(不论是否分配物理页面建立映射关系)。既然要写一个接口程序,当然也要考虑程序的健壮性,我们不能假设所有的用户传递的参数都是合法的。我们应该预判非法传参情况的发生,并提前做好准备,这就是未雨绸缪。

我们首先使用memcpy()的测试用例,随机传递一个非法的地址。经过测试发现:会触发kernel oops。继续使用copy_{to,from}_user()替代memcpy()测试。测试发现:read()仅仅是返回错误,但不会触发kernel oops。这才是我们想要的结果。毕竟,一个应用程序不应该触发kernel oops。这种机制的实现原理是什么呢?

我们以copy_to_user()为例分析。函数调用流程如下:

  1. copy_to_user()->_copy_to_user()->raw_copy_to_user()->__arch_copy_to_user()

__arch_copy_to_user()在ARM64平台是汇编代码实现,这部分代码很关键。

  1. end .req x5
  2. ENTRY(__arch_copy_to_user)
  3. uaccess_enable_not_uao x3, x4, x5
  4. add end, x0, x2
  5. #include "copy_template.S"
  6. uaccess_disable_not_uao x3, x4
  7. mov x0, #0
  8. ret
  9. ENDPROC(__arch_copy_to_user)
  10.  
  11. .section .fixup,"ax"
  12. .align 2
  13. 9998: sub x0, end, dst // bytes not copied
  14. ret
  15. .previous
  1. uaccess_enable_not_uao和uaccess_disable_not_uao是上面说到的内核态访问用户空间的开关。
  2. copy_template.S文件是汇编实现的memcpy()的功能,稍后看看memcpy()的实现代码就清楚了。
  3. .section .fixup,“ax”定义一个section,名为“.fixup”,权限是ax(‘a’可重定位的段,‘x’可执行段)。9998标号处的指令就是“未雨绸缪”的善后处理工作。还记得copy_{to,from}_user()返回值的意义吗?返回0代表copy成功,否则返回剩余没有copy的字节数。这行代码就是计算剩余没有copy的字节数。当我们访问非法的用户空间地址的时候,就一定会触发page fault。这种情况下,内核态发生的page fault并返回的时候并没有修复异常,所以肯定不能返回发生异常的地址继续运行。所以,系统可以有2个选择:第1个选择是kernel oops,并给当前进程发送SIGSEGV信号;第2个选择是不返回出现异常的地址运行,而是选择一个已经修复的地址返回。如果使用的是memcpy()就只有第1个选择。但是copy_{to,from}_user()可以有第2个选择。.fixup段就是为了实现这个修复功能。当copy过程中出现访问非法用户空间地址的时候,do_page_fault()返回的地址变成9998标号处,此时可以计算剩余未copy的字节长度,程序还可以继续执行。

对比前面分析的结果,其实__arch_copy_to_user()可以近似等效如下关系。

  1. uaccess_enable_not_uao();
  2. memcpy(ubuf, kbuf, size); == __arch_copy_to_user(ubuf, kbuf, size);
  3. uaccess_disable_not_uao();

先插播一条消息,解释copy_template.S为何是memcpy()。memcpy()在ARM64平台是由汇编代码实现。其定义在arch/arm64/lib/memcpy.S文件。

  1. .weak memcpy
  2. ENTRY(__memcpy)
  3. ENTRY(memcpy)
  4. #include "copy_template.S"
  5. ret
  6. ENDPIPROC(memcpy)
  7. ENDPROC(__memcpy)

所以很明显,memcpy()和__memcpy()函数定义是一样的。并且memcpy()函数声明是weak,因此可以重写memcpy()函数(扯得有点远)。再扯一点,为何使用汇编呢?为何不使用lib/string.c文件的memcpy()函数呢?当然是为了优化memcpy() 的执行速度。lib/string.c文件的memcpy()函数是按照字节为单位进行copy(再好的硬件也会被粗糙的代码毁掉)。但是现在的处理器基本都是32或者64位,完全可以4 bytes或者8 bytes甚至16 bytes copy(考虑地址对齐的情况下)。可以明显提升执行速度。所以,ARM64平台使用汇编实现。这部分知识可以参考这篇博客《ARM64 的 memcpy 优化与实现》

下面继续进入正题,再重复一遍:内核态访问用户空间地址,如果触发page fault,只要用户空间地址合法,内核态也会像什么也没有发生一样修复异常(分配物理内存,建立页表映射关系)。但是如果访问非法用户空间地址,就选择第2条路,尝试救赎自己。这条路就是利用.fixup__ex_table段。如果无力回天只能给当前进程发送SIGSEGV信号。并且,轻则kernel oops,重则panic(取决于kernel配置选项CONFIG_PANIC_ON_OOPS)。在内核态访问非法用户空间地址的情况下,do_page_fault()最终会跳转no_context标号处的__do_kernel_fault()

  1. static void __do_kernel_fault(unsigned long addr, unsigned int esr,
  2. struct pt_regs *regs)
  3. {
  4. /*
  5. * Are we prepared to handle this kernel fault?
  6. * We are almost certainly not prepared to handle instruction faults.
  7. */
  8. if (!is_el1_instruction_abort(esr) && fixup_exception(regs))
  9. return;
  10. /* ... */
  11. }

fixup_exception()继续调用search_exception_tables(),其通过查找__ex_table段。__ex_table段存储exception table,每个entry存储着异常地址及其对应修复的地址。例如上述的9998: sub x0, end, dst指令的地址就会被找到并修改do_page_fault()函数的返回地址,以达到跳转修复的功能。其实查找过程是根据出问题的地址addr,查找__ex_table段(exception table)是否有对应的exception table entry,如果有就代表可以被修复。由于32位处理器和64位处理器实现方式有差别,因此我们先从32位处理器异常表的实现原理说起。

__ex_table段的首尾地址分别是__start___ex_table__stop___ex_table(定义在include/asm-generic/vmlinux.lds.h。这段内存可以看作是一个数组,数组的每个元素都是struct exception_table_entry类型,其记录着异常发生地址及其对应的修复地址。

  1. exception tables
  2. __start___ex_table --> +---------------+
  3. | entry |
  4. +---------------+
  5. | entry |
  6. +---------------+
  7. | ... |
  8. +---------------+
  9. | entry |
  10. +---------------+
  11. | entry |
  12. __stop___ex_table --> +---------------+

在32位处理器上,struct exception_table_entry定义如下:

  1. struct exception_table_entry {
  2. unsigned long insn, fixup;
  3. };

有一点需要明确,在32位处理器上,unsigned long是4 bytes。insn和fixup分别存储异常发生地址及其对应的修复地址。根据异常地址ex_addr查找对应的修复地址(未找到返回0),其示意代码如下:

  1. unsigned long search_fixup_addr32(unsigned long ex_addr)
  2. {
  3. const struct exception_table_entry *e;
  4.  
  5. for (e = __start___ex_table; e < __stop___ex_table; e++)
  6. if (ex_addr == e->insn)
  7. return e->fixup;
  8.  
  9. return 0;
  10. }

在32位处理器上,创建exception table entry相对简单。针对copy_{to,from}_user()汇编代码中每一处用户空间地址访问的指令都会创建一个entry,并且insn存储当前指令对应的地址,fixup存储修复指令对应的地址。

当64位处理器开始发展起来,如果我们继续使用这种方式,势必需要2倍于32位处理器的内存存储exception table(因为存储一个地址需要8 bytes)。所以,kernel换用另一种方式实现。在64处理器上,struct exception_table_entry定义如下:

  1. struct exception_table_entry {
  2. int insn, fixup;
  3. };

每个exception table entry占用的内存和32位处理器情况一样,因此内存占用不变。但是insn和fixup的意义发生变化。insn和fixup分别存储着异常发生地址及修复地址相对于当前结构体成员地址的偏移(有点拗口)。例如,根据异常地址ex_addr查找对应的修复地址(未找到返回0),其示意代码如下:

  1. unsigned long search_fixup_addr64(unsigned long ex_addr)
  2. {
  3. const struct exception_table_entry *e;
  4. for (e = __start___ex_table; e < __stop___ex_table; e++)
  5. if (ex_addr == (unsigned long)&e->insn + e->insn)
  6. return (unsigned long)&e->fixup + e->fixup;
  7. return 0;
  8. }

因此,我们的关注点就是如何去构建exception_table_entry。我们针对每个用户空间地址的内存访问都需要创建一个exception table entry,并插入__ex_table段。例如下面的汇编指令(汇编指令对应的地址是随意写的,不用纠结对错。理解原理才是王道)。

  1. 0xffff000000000000: ldr x1, [x0]
  2. 0xffff000000000004: add x1, x1, #0x10
  3. 0xffff000000000008: ldr x2, [x0, #0x10]
  4. /* ... */
  5. 0xffff000040000000: mov x0, #0xfffffffffffffff2 // -14
  6. 0xffff000040000004: ret

假设x0寄存器保存着用户空间地址,因此我们需要对0xffff000000000000地址的汇编指令创建一个exception table entry,并且我们期望当x0是非法用户空间地址时,跳转返回的修复地址是0xffff000040000000。为了计算简单,假设这是创建第一个entry,__start___ex_table值是0xffff000080000000。那么第一个exception table entry的insn和fixup成员的值分别是:0x80000000和0xbffffffc(这两个值都是负数)。因此,针对copy_{to,from}_user()汇编代码中每一处用户空间地址访问的指令都会创建一个entry。所以0xffff000000000008地址处的汇编指令也需要创建一个exception table entry。

所以,如果内核态访问非法用户空间地址究竟发生了什么?上面的分析流程可以总结如下:

  1. 访问非法用户空间地址:

    0xffff000000000000: ldr x1, [x0]

  2. MMU触发异常

  3. CPU调用do_page_fault()

  4. do_page_fault()调用search_exception_table()(regs->pc == 0xffff000000000000)

  5. 查看__ex_table段,寻找0xffff000000000000 并且返回修复地址0xffff000040000000

  6. do_page_fault()修改函数返回地址(regs->pc = 0xffff000040000000)并返回

  7. 程序继续执行,处理出错情况

  8. 修改函数返回值x0 = -EFAULT (-14) 并返回(ARM64通过x0传递函数返回值)

总结

到了回顾总结的时候,copy_{to,from}_user()的思考也到此结束。我们来个总结结束此文。

  • 无论是内核态还是用户态访问合法的用户空间地址,当虚拟地址并未建立物理地址的映射关系的时候,page fault的流程几乎一样,都会帮助我们申请物理内存并创建映射关系。所以这种情况下memcpy()和copy_{to,from}_user()是类似的。
  • 当内核态访问非法用户空间地址的时候,通过.fixup__ex_table两个段的帮助尝试修复异常。这种修复异常并不是建立地址映射关系,而是修改do_page_fault()返回地址。memcpy()由于没有创建这样的段,所以memcpy()无法做到这点。
  • 在使能CONFIG_ARM64_SW_TTBR0_PAN或者CONFIG_ARM64_PAN(硬件支持的情况下才有效)的时候,我们只能使用copy_{to,from}_user()这种接口,直接使用memcpy()是不行的。

最后,我想说,即使在某些情况下memcpy()可以正常工作。但是,这也是不推荐的,不是良好的编程习惯。在用户空间和内核空间数据交互上,我们必须使用类似copy_{to,from}_user()的接口。为什么类似呢?因为还有其他的接口用于内核空间和用户空间数据交互,只是没有copy_{to,from}_user()出名。例如:{get,put}_user()。

  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值