• 页错误异常处理(page fault)的实现


    主要参考了《深入linux内核》和《Linux内核深度解析》,另外简单浅析了一下相关内容

    页错误异常处理

    在取指令或数据的时候,处理器的内存管理单元需要把虚拟地址转换成物理地址。

    如果虚拟页没有映射到物理页,或者没有访问权限,处理器将生成页错误异常(page fault)。

    1. 虚拟页没有映射到物理页,这种情况通常称为缺页异常,有以下几种情况。

    (1)访问用户栈的时候,超出了当前用户栈的范围,需要扩大用户栈。
    (2)当进程申请虚拟内存区域的时候,通常没有分配物理页,进程第一次访问的时候触发页错误异常。
    (3)内存不足的时候,内核把进程的匿名页换出到交换区。
    (4)一个文件页被映射到进程的虚拟地址空间,内存不足的时候,内核回收这个文件页,在进程的页表中删除这个文件页的映射。
    (5)程序错误,访问没有分配给进程的虚拟内存区域。

    前面四种情况,如果页错误异常处理程序成功地把虚拟页映射到物理页,处理程序返回后,处理器重新执行触发异常的指令。

    第五种情况,页错误异常处理程序将会发送段违法(SIGSEGV)信号以杀死进程。

    1. 没有访问权限,有以下两种情况。

    (1)可能是软件有意造成的,典型的例子是写时复制(Copy on Write,CoW):进程分叉生成子进程的时候,为了避免复制物理页,子进程和父进程以只读方式共享所有私有的匿名页和文件页。当其中一个进程试图写只读页时,触发页错误异常,页错误异常处理程序分配新的物理页,把旧的物理页的数据复制到新的物理页,然后把虚拟页映射到新的物理页。
    (2)程序错误,例如试图写只读的代码段所在的物理页。

    第一种情况,如果页错误异常处理程序成功地把虚拟页映射到物理页,处理程序返回后,处理器重新执行触发异常的指令。
    第二种情况,页错误异常处理程序将会发送段违法(SIGSEGV)信号以杀死进程。

    不同处理器架构实现的页错误异常不同,页错误异常处理程序的前面一部分是各种处理器架构自定义的部分,后面从函数handle_mm_fault开始的部分是所有处理器架构共用的部分。

    特定于架构部分(x86)的异常处理流程

    page fault基本流程:
    从cr2中获取发生异常的地址
    缺页地址位于内核态
    位于vmalloc区?->从主内核页表同步数据到进程页表
    非vmalloc区 ->不应该产生page fault->oops
    缺页地址位于用户态
    缺页上下文发生在内核态
    exception table中有相应的处理项? ->进行修正
    没有 ->oops
    查找vma
    找到?-> 是否expand stack?->堆栈扩展
    不是->正常的缺页处理:handle_mm_fault
    没找到->bad_area

    arch\x86\mm\fault.c (用的是3.10版本和4.x版本差不多)

    /*
      *缺页异常主处理函数。
      *regs:异常时的寄存器信息;
      *error_code-当异常发生时,硬件压入栈中的错误代码。
      *             当第0位被清0时,则异常是由一个不存在的页所引起的。否则是由无效的访问权限引起的。
      *             如果第1位被清0,则异常由读访问或者执行访问所引起,如果被设置,则异常由写访问引起。
      *             如果第2位被清0,则异常发生在内核态,否则异常发生在用户态。
      */
    static void __kprobes
    __do_page_fault(struct pt_regs *regs, unsigned long error_code)
    {
        struct vm_area_struct *vma;
        struct task_struct *tsk;
        unsigned long address;
        struct mm_struct *mm;
        int fault;
        int write = error_code & PF_WRITE;
        unsigned int flags = FAULT_FLAG_ALLOW_RETRY | FAULT_FLAG_KILLABLE |
                        (write ? FAULT_FLAG_WRITE : 0);
     
        tsk = current;
        mm = tsk->mm;
     
        /* Get the faulting address: */
        //缺页异常的地址默认存放于CR2寄存器中,x86硬件决定
        address = read_cr2();
     
        /*
         * Detect and handle instructions that would cause a page fault for
         * both a tracked kernel page and a userspace page.
         */
        if (kmemcheck_active(regs))
            kmemcheck_hide(regs);
        prefetchw(&mm->mmap_sem);
     
        // mmio不应该发生缺页,通常都会ioremap到vmalloc区,然后进行访问
        if (unlikely(kmmio_fault(regs, address)))
            return;
     
        /*
         * We fault-in kernel-space virtual memory on-demand. The
         * 'reference' page table is init_mm.pgd.
         *
         * We MUST NOT take any locks for this case. We may
         * be in an interrupt or a critical region, and should
         * only copy the information from the master page table,
         * nothing more.
         *
         * This verifies that the fault happens in kernel space
         * (error_code & 4) == 0, and that the fault was not a
         * protection error (error_code & 9) == 0.
         */
         /*
         * 缺页地址位于内核空间。并不代表异常发生于内核空间,有可能是用户
         * 态访问了内核空间的地址。
         */
        if (unlikely(fault_in_kernel_space(address))) {
            if (!(error_code & (PF_RSVD | PF_USER | PF_PROT))) {
                /*
                 * 检查发生缺页的地址是否在vmalloc区,是则进行相应的处理
                 * 主要是从内核主页表向进程页表同步数据
                 */
                if (vmalloc_fault(address) >= 0)
                    return;
     
                if (kmemcheck_fault(regs, address, error_code))
                    return;
            }
     
            /* Can handle a stale RO->RW TLB: */
            /*
             * 检查是否是由于陈旧的TLB导致的假的pagefault(由于TLB的延迟flush导致,
             * 因为提前flush会有比较大的性能代价)。
             */
            if (spurious_fault(error_code, address))
                return;
     
            /* kprobes don't want to hook the spurious faults: */
            if (notify_page_fault(regs))
                return;
            /*
             * Don't take the mm semaphore here. If we fixup a prefetch
             * fault we could otherwise deadlock:
             */
            /*
             * 有问题了: 由于异常地址位于内核态,触发内核异常,因为vmalloc
             * 区的缺页异常前面已经处理过了,内核态的缺页异常只能发生在
             * vmalloc区,如果不是,那就是内核异常了。
             */
            bad_area_nosemaphore(regs, error_code, address);
     
            return;
        }
     
        // 进入到这里,说明异常地址位于用户态
        /* kprobes don't want to hook the spurious faults: */
        if (unlikely(notify_page_fault(regs)))
            return;
        /*
         * It's safe to allow irq's after cr2 has been saved and the
         * vmalloc fault has been handled.
         *
         * User-mode registers count as a user access even for any
         * potential system fault or CPU buglet:
         */
        /*
         * 开中断,这种情况下,是安全的,可以缩短因缺页异常导致的关中断时长。
         * 老内核版本中(2.6.11)没有这样的操作
         */
        if (user_mode_vm(regs)) {
            local_irq_enable();
            error_code |= PF_USER;
        } else {
            if (regs->flags & X86_EFLAGS_IF)
                local_irq_enable();
        }
     
        if (unlikely(error_code & PF_RSVD))
            pgtable_bad(regs, error_code, address);
     
        if (static_cpu_has(X86_FEATURE_SMAP)) {
            if (unlikely(smap_violation(error_code, regs))) {
                bad_area_nosemaphore(regs, error_code, address);
                return;
            }
        }
     
        perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS, 1, regs, address);
     
        /*
         * If we're in an interrupt, have no user context or are running
         * in an atomic region then we must not take the fault:
         */
        /*
         * 当缺页异常发生于中断或其它atomic上下文中时,则产生异常。
         * 这种情况下,不应该再产生page fault
         */
        if (unlikely(in_atomic() || !mm)) {
            bad_area_nosemaphore(regs, error_code, address);
            return;
        }
     
        /*
         * When running in the kernel we expect faults to occur only to
         * addresses in user space. All other faults represent errors in
         * the kernel and should generate an OOPS. Unfortunately, in the
         * case of an erroneous fault occurring in a code path which already
         * holds mmap_sem we will deadlock attempting to validate the fault
         * against the address space. Luckily the kernel only validly
         * references user space from well defined areas of code, which are
         * listed in the exceptions table.
         *
         * As the vast majority of faults will be valid we will only perform
         * the source reference check when there is a possibility of a
         * deadlock. Attempt to lock the address space, if we cannot we then
         * validate the source. If this is invalid we can skip the address
         * space check, thus avoiding the deadlock:
         */
        if (unlikely(!down_read_trylock(&mm->mmap_sem))) {
            /*
             * 缺页发生在内核上下文,这种情况发生缺页的地址只能位于用户态地址空间
             * 这种情况下,也只能为exceptions table中预先定义好的异常,如果exceptions
             * table中没有预先定义的处理,或者缺页的地址位于内核态地址空间,则表示
             * 错误,进入oops流程。
             */
            if ((error_code & PF_USER) == 0 &&
             !search_exception_tables(regs->ip)) {
                bad_area_nosemaphore(regs, error_code, address);
                return;
            }
    retry:
            // 如果发生在用户态或者有exception table,说明不是内核异常
            down_read(&mm->mmap_sem);
        } else {
            /*
             * The above down_read_trylock() might have succeeded in
             * which case we'll have missed the might_sleep() from
             * down_read():
             */
            might_sleep();
        }
     
        // 在当前进程的地址空间中寻找发生异常的地址对应的VMA。
        vma = find_vma(mm, address);
        // 如果没找到VMA,则释放mem_sem信号量后,进入__bad_area_nosemaphore处理。
        if (unlikely(!vma)) {
            bad_area(regs, error_code, address);
            return;
        }
        /* 找到VMA,且发生异常的虚拟地址位于vma的有效范围内,则为正常的缺页
         * 异常,请求调页,分配物理内存 */
        if (likely(vma->vm_start <= address))
            goto good_area;
        /* 如果异常地址不是位于紧挨着堆栈区的那个区域,同时又没有相应VMA,则
         * 进程访问了非法地址,进入bad_area处理
         */
        if (unlikely(!(vma->vm_flags & VM_GROWSDOWN))) {
            bad_area(regs, error_code, address);
            return;
        }
        if (error_code & PF_USER) {
            /*
             * Accessing the stack below %sp is always a bug.
             * The large cushion allows instructions like enter
             * and pusha to work. ("enter $65535, $31" pushes
             * 32 pointers and then decrements %sp by 65535.)
             */
            /*
             * 压栈操作时,操作的地址最大的偏移为65536+32*sizeof(unsigned long),
             * 该操作由pusha命令触发(老版本中,pusha命令最大只能操作32字节,即
             * 同时压栈8个寄存器)。如果访问的地址距栈顶的距离超过了,则肯定是非法
             * 地址访问了。
             */
            if (unlikely(address + 65536 + 32 * sizeof(unsigned long) < regs->sp)) {
                bad_area(regs, error_code, address);
                return;
            }
        }
        
        /*
        * 运行到这里,说明设置了VM_GROWSDOWN标记,表示缺页异常地址位于堆栈区
        * 需要扩展堆栈。说明: 堆栈区的虚拟地址空间也是动态分配和扩展的,不是
        * 一开始就分配好的。
        */
        if (unlikely(expand_stack(vma, address))) {
            bad_area(regs, error_code, address);
            return;
        }
     
        /*
         * Ok, we have a good vm_area for this memory access, so
         * we can handle it..
         */
        /*
         * 运行到这里,说明是正常的缺页异常,则进行请求调页,分配物理内存
         */
    good_area:
        if (unlikely(access_error(error_code, vma))) {
            bad_area_access_error(regs, error_code, address);
            return;
        }
     
        /*
         * If for any reason at all we couldn't handle the fault,
         * make sure we exit gracefully rather than endlessly redo
         * the fault:
         */
        /*
         * 分配物理内存,缺页异常的正常处理主函数
         * 可能的情况有:1、请求调页/按需分配;2、COW;3、缺的页位于交换分区,
         * 需要换入。
         */
        fault = handle_mm_fault(mm, vma, address, flags);
     
        if (unlikely(fault & (VM_FAULT_RETRY|VM_FAULT_ERROR))) {
            if (mm_fault_error(regs, error_code, address, fault))
                return;
        }
     
        /*
         * Major/minor page fault accounting is only done on the
         * initial attempt. If we go through a retry, it is extremely
         * likely that the page will be found in page cache at that point.
         */
        if (flags & FAULT_FLAG_ALLOW_RETRY) {
            if (fault & VM_FAULT_MAJOR) {
                tsk->maj_flt++;
                perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MAJ, 1,
                     regs, address);
            } else {
                tsk->min_flt++;
                perf_sw_event(PERF_COUNT_SW_PAGE_FAULTS_MIN, 1,
                     regs, address);
            }
            if (fault & VM_FAULT_RETRY) {
                /* Clear FAULT_FLAG_ALLOW_RETRY to avoid any risk
                 * of starvation. */
                flags &= ~FAULT_FLAG_ALLOW_RETRY;
                flags |= FAULT_FLAG_TRIED;
                goto retry;
            }
        }
     
        // VM86模式(兼容老环境)相关检查
        check_v8086_mode(regs, address, tsk);
     
        up_read(&mm->mmap_sem);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124
    • 125
    • 126
    • 127
    • 128
    • 129
    • 130
    • 131
    • 132
    • 133
    • 134
    • 135
    • 136
    • 137
    • 138
    • 139
    • 140
    • 141
    • 142
    • 143
    • 144
    • 145
    • 146
    • 147
    • 148
    • 149
    • 150
    • 151
    • 152
    • 153
    • 154
    • 155
    • 156
    • 157
    • 158
    • 159
    • 160
    • 161
    • 162
    • 163
    • 164
    • 165
    • 166
    • 167
    • 168
    • 169
    • 170
    • 171
    • 172
    • 173
    • 174
    • 175
    • 176
    • 177
    • 178
    • 179
    • 180
    • 181
    • 182
    • 183
    • 184
    • 185
    • 186
    • 187
    • 188
    • 189
    • 190
    • 191
    • 192
    • 193
    • 194
    • 195
    • 196
    • 197
    • 198
    • 199
    • 200
    • 201
    • 202
    • 203
    • 204
    • 205
    • 206
    • 207
    • 208
    • 209
    • 210
    • 211
    • 212
    • 213
    • 214
    • 215
    • 216
    • 217
    • 218
    • 219
    • 220
    • 221
    • 222
    • 223
    • 224
    • 225
    • 226
    • 227
    • 228
    • 229
    • 230
    • 231
    • 232
    • 233
    • 234
    • 235
    • 236
    • 237
    • 238
    • 239
    • 240
    • 241
    • 242
    • 243
    • 244
    • 245
    • 246
    • 247
    • 248
    • 249
    • 250
    • 251
    • 252
    • 253
    • 254
    • 255
    • 256
    • 257
    • 258
    • 259
    • 260
    • 261
    • 262
    • 263
    • 264
    • 265
    • 266
    • 267
    • 268
    • 269
    • 270
    • 271
    • 272
    • 273
    • 274
    • 275
    • 276
    • 277
    • 278
    • 279
    • 280
    • 281
    • 282
    • 283
    • 284
    • 285
    • 286
    • 287
    • 288

    用户空间页错误异常

    **在结束对缺页异常的特定于体系结构的分析之后,确认异常是在允许的地址触发,内核必须确定将所需数据读取到物理内存的适当方法。该任务委托给handle_mm_fault,它不依赖于底层体系结构,而是在内存管理的框架下、独立于系统而实现。**该函数确认在各级页目录中,通向对应于异常地址的页表项的各个页目录项都存在。

    用户空间页错误异常是指进程访问用户虚拟地址生成的页错误异常,可以分为两种情况:

    • 进程在用户模式下访问用户虚拟地址。生成页错误异常。
    • 进程在内核模式下访问用户虚拟地址,生成页错误异常。
      进程通过系统调用进入内核模式,系统调用传入用户空间的缓冲区,进程在内核模式下访问用户空间的缓冲区。

    如果虚拟内存区域使用标准巨型页,则调用函数hugetlb_fault处理标准巨型页的页错误异常。
    如果虚拟内存区域使用普通页,则调用__handle_mm_fault处理普通页的页错误异常。

    如果页错误异常处理程序确认虚拟地址属于分配给进程的虚拟内存区域,并且虚拟内存区域授予触发页错误异常的访问权限,就会运行到函数handle_mm_fault,执行流程如下:

    image-20220806060341991

    /*
     * By the time we get here, we already hold the mm semaphore
     *
     * The mmap_sem may have been released depending on flags and our
     * return value.  See filemap_fault() and __lock_page_or_retry().
     */
    static int __handle_mm_fault(struct vm_area_struct *vma, unsigned long address,
    		unsigned int flags)
    {
    	struct vm_fault vmf = {
    		.vma = vma,
    		.address = address & PAGE_MASK,
    		.flags = flags,
    		.pgoff = linear_page_index(vma, address),
    		.gfp_mask = __get_fault_gfp_mask(vma),
    	};
    	struct mm_struct *mm = vma->vm_mm;
    	pgd_t *pgd;
    	p4d_t *p4d;
    	int ret;
    
        // 在页全局目录中查找虚拟地址对应的表项
    	pgd = pgd_offset(mm, address);
        
        // 在页四级目录中查找虚拟地址对应的表现,如果页四级目录不在,那么先创建四级目录
    	p4d = p4d_alloc(mm, pgd, address);
    	if (!p4d)
    		return VM_FAULT_OOM;
    
        // 在页上层目录中查找虚拟地址对应的表项,如果页上层目录不存在,那么先创建页上层目录
    	vmf.pud = pud_alloc(mm, p4d, address);
    	...
    
        // 在页中间目录中查找虚拟地址对应的表项,如果页中间目录不存在,那么先创建页中间目录。
    	vmf.pmd = pmd_alloc(mm, vmf.pud, address);
    	...
    
        // 处理页表项
    	return handle_pte_fault(&vmf);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40

    handle_pte_fault

    image-20220806060833770

    static int handle_pte_fault(struct vm_fault *vmf)
    {
    	pte_t entry;
    
        // 直接在页表中查找虚拟地址对应的表项
    	if (unlikely(pmd_none(*vmf->pmd))) {
    		// 如果页中间目录表项是空表项,说明直接页表不存在,则把vmf->pte设置为空
    		vmf->pte = NULL;
    	} else {
    		/* 
                如果页中间目录表项不是空表项,说明直接页表存在,那么在直接页表中查找虚拟地址对应的表项,
                vmf->pte存放表项的地址,vmf->orig_pte存放页表项的值,如果页表项是空表项,
                vmf->pte没必要存放表项的地址,设置成空指针
    		*/
    		if (pmd_devmap_trans_unstable(vmf->pmd))
    			return 0;
    		/*
    		 * A regular pmd is established and it can't morph into a huge
    		 * pmd from under us anymore at this point because we hold the
    		 * mmap_sem read mode and khugepaged takes it in write mode.
    		 * So now it's safe to run pte_offset_map().
    		 */
    		vmf->pte = pte_offset_map(vmf->pmd, vmf->address);
    		vmf->orig_pte = *vmf->pte;
    
    		/*
    		 * some architectures can have larger ptes than wordsize,
    		 * e.g.ppc44x-defconfig has CONFIG_PTE_64BIT=y and
    		 * CONFIG_32BIT=y, so READ_ONCE or ACCESS_ONCE cannot guarantee
    		 * atomic accesses.  The code below just needs a consistent
    		 * view for the ifs and we later double check anyway with the
    		 * ptl lock held. So here a barrier will do.
    		 */
    		barrier();
    		if (pte_none(vmf->orig_pte)) {
    			pte_unmap(vmf->pte);
    			vmf->pte = NULL;
    		}
    	}
    
        //	如果页表项不存在(直接页表不存在或者页表项是空表项
    	if (!vmf->pte) {
            // 如果是私有匿名映射,则调用函数do_anonymous_page处理匿名页的缺页异常
    		if (vma_is_anonymous(vmf->vma))
    			return do_anonymous_page(vmf);
    		else
                /* 如果是文件映射或者共享匿名映射,调用函数do_fault处理文件页的缺页异常 */
    			return do_fault(vmf);
    	}
    
        // 如果页表项存在,但是页不在物理内存中,说明页被换出交换区到内存中
    	if (!pte_present(vmf->orig_pte))
    		return do_swap_page(vmf);
    
    	if (pte_protnone(vmf->orig_pte) && vma_is_accessible(vmf->vma))
    		return do_numa_page(vmf);
    
        // 开始处理"页表项存在,并且页在物理内存中”这种情况,页错误异常是由访问权限触发的
    	vmf->ptl = pte_lockptr(vmf->vma->vm_mm, vmf->pmd);
        
        /*
            获取页表锁的地址,页表锁有两种实现方式:精粒度的锁(一个进程一个页表锁),
            细粒度的锁(每个直接页表一个锁)。
        */
    	spin_lock(vmf->ptl); // 锁住页表
    	entry = vmf->orig_pte;
        
        // 重新读取页表项的值
    	if (unlikely(!pte_same(*vmf->pte, entry)))
    		goto unlock;
        
        // 如果页错误异常是由写操作触发
    	if (vmf->flags & FAULT_FLAG_WRITE) {
            // 如果页表项没有写权限,调用函数do_wp_page执行写时复制
    		if (!pte_write(entry))
    			return do_wp_page(vmf);
            
            // 如果页表项有写权限,那么设置页表项的脏标志位,表示页的数据被修改
    		entry = pte_mkdirty(entry);
    	}
        
        // 设置页表项的访问标志位,表示页刚刚被访问过
    	entry = pte_mkyoung(entry);
        
        // 设置页表项
    	if (ptep_set_access_flags(vmf->vma, vmf->address, vmf->pte, entry,
    				vmf->flags & FAULT_FLAG_WRITE)) {
            
            // 如果页表项发生变化,调用函数update_mmu cache以更新处理器的内存管理单位的页表缓存
    		update_mmu_cache(vmf->vma, vmf->address, vmf->pte);
    	} else {
    		/*
    		 * This is needed only for protection faults but the arch code
    		 * is not yet telling us if this is a protection fault or not.
    		 * This still avoids useless tlb flushes for .text page faults
    		 * with threads.
    		 */
            // 如果页表项没有变化,并且页错误异常是由写操作触发的,说明页错误异常可能是TLB表项和页表项不一致导致的,那么使TLB表项失效。
    		if (vmf->flags & FAULT_FLAG_WRITE)
    			flush_tlb_fix_spurious_fault(vmf->vma, vmf->address);
    	}
    unlock: // 释放页表锁
    	pte_unmap_unlock(vmf->pte, vmf->ptl);
    	return 0;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106

    私有匿名页的缺页异常

    什么情况下会发生匿名页缺页异常呢?

    • 函数的局部变量比较大,或者函数调用的层次比较深,导致当前栈不够用,需要扩大栈;
    • 进程调用malloc,从堆申请了内存块,只分配虚拟内存区域,还没有映射到物理页,第一次访问时触发缺页异常。
    • 进程直接调用mmap,创建匿名的内存映射,只分配了虚拟内存区域,还没有映射到物理页,第一次访问时触发缺页异常。

    函数do_anonymous_page处理私有匿名页的缺页异常,执行流程及源码分析如下:

    image-20220806064425794

    mm\memory.c

    static int do_anonymous_page(struct vm_fault *vmf)
    {
    	struct vm_area_struct *vma = vmf->vma;
    	struct mem_cgroup *memcg;
    	struct page *page;
    	pte_t entry;
    
    	// 如果是共享的匿名映射,但是虚拟内存区域没有提供虚拟内存操作集合(vm_area_struct.vm_ops),那么返回错误号VM_FAULT_SIGBUS。
        // 判断vma_is_anonymous 是根据 !vma->vm_ops
    	if (vma->vm_flags & VM_SHARED)
    		return VM_FAULT_SIGBUS;
    
    	/*
    	 * Use pte_alloc() instead of pte_alloc_map().  We can't run
    	 * pte_offset_map() on pmds where a huge pmd might be created
    	 * from a different thread.
    	 *
    	 * pte_alloc_map() is safe to use under down_write(mmap_sem) or when
    	 * parallel threads are excluded by other means.
    	 *
    	 * Here we only have down_read(mmap_sem).
    	 */
        
        // 如果直接页表不存在,分配页表
    	if (pte_alloc(vma->vm_mm, vmf->pmd, vmf->address))
    		return VM_FAULT_OOM;
    
    	/* See the comment in pte_alloc_one_map() */
    	if (unlikely(pmd_trans_unstable(vmf->pmd)))
    		return 0;
    
    	// 如果缺页异常是由读操作触发的,并且进程允许使用零页,那么我们就把虚拟页映射到一个专用的零页
    	if (!(vmf->flags & FAULT_FLAG_WRITE) &&
    			!mm_forbids_zeropage(vma->vm_mm)) {
    		// 生成特殊的页表项,映射到专用的零页
            entry = pte_mkspecial(pfn_pte(my_zero_pfn(vmf->address),
    						vma->vm_page_prot));
            // 在直接页表中查找虚拟地址对应的表项,并且锁住页表
    		vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd,
    				vmf->address, &vmf->ptl);
            
            // 如果页表项不是空表项,说明其他处理器可能正在修改同一个页表项
    		if (!pte_none(*vmf->pte))
    			goto unlock;
    		/* Deliver the page fault to userland, check inside PT lock */
    		if (userfaultfd_missing(vma)) {
    			pte_unmap_unlock(vmf->pte, vmf->ptl);
    			return handle_userfault(vmf, VM_UFFD_MISSING);
    		}
            
            // 跳转道标号setpte区设置页表项
    		goto setpte;
    	}
    
    	// 分配自己的私有页
    	if (unlikely(anon_vma_prepare(vma)))
    		goto oom;
    	
        // 分配物理页,优先从高端内存区域分配,并且用零初始化
        page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
    	if (!page)
    		goto oom;
    
    	if (mem_cgroup_try_charge(page, vma->vm_mm, GFP_KERNEL, &memcg, false))
    		goto oom_free_page;
    
    	/*
    	 * The memory barrier inside __SetPageUptodate makes sure that
    	 * preceeding stores to the page contents become visible before
    	 * the set_pte_at() write.
    	 */
        
        // 设置页描述符的标志位,表示物理页包含有效的数据
    	__SetPageUptodate(page);
    
        // 使用页帧号和访问权限生成相应的页表项
    	entry = mk_pte(page, vma->vm_page_prot);
        
        // 如果虚拟内存区域又写权限,设置页表项的脏标志位和写权限
    	if (vma->vm_flags & VM_WRITE)
    		entry = pte_mkwrite(pte_mkdirty(entry));
    
    	vmf->pte = pte_offset_map_lock(vma->vm_mm, vmf->pmd, vmf->address,
    			&vmf->ptl);
        
        // 如果页表项不是空表项,说明其他处理器可能正在修改同一个页表项
    	if (!pte_none(*vmf->pte))
    		goto release;
    
    	/* Deliver the page fault to userland, check inside PT lock */
    	if (userfaultfd_missing(vma)) {
    		pte_unmap_unlock(vmf->pte, vmf->ptl);
    		mem_cgroup_cancel_charge(page, memcg, false);
    		put_page(page);
    		return handle_userfault(vmf, VM_UFFD_MISSING);
    	}
    
    	inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
        // 建立物理页到虚拟页的反向映射
    	page_add_new_anon_rmap(page, vma, vmf->address, false);
    	mem_cgroup_commit_charge(page, memcg, false, false);
        
        // 把物理页添加到活动LRU或不可回收LRU链表,页回收算法需要从LRU链表选择需要回收物理页
    	lru_cache_add_active_or_unevictable(page, vma);
    setpte:
        // 设置页表项
    	set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
    
    	// 不需要从页表缓存删除页表项,因为从前虚拟页未映射到物理页
    	update_mmu_cache(vma, vmf->address, vmf->pte);
    unlock:
        
        // 释放页表的锁
    	pte_unmap_unlock(vmf->pte, vmf->ptl);
    	return 0;
    release:
    	mem_cgroup_cancel_charge(page, memcg, false);
    	put_page(page);
    	goto unlock;
    oom_free_page:
    	put_page(page);
    oom:
    	return VM_FAULT_OOM;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68
    • 69
    • 70
    • 71
    • 72
    • 73
    • 74
    • 75
    • 76
    • 77
    • 78
    • 79
    • 80
    • 81
    • 82
    • 83
    • 84
    • 85
    • 86
    • 87
    • 88
    • 89
    • 90
    • 91
    • 92
    • 93
    • 94
    • 95
    • 96
    • 97
    • 98
    • 99
    • 100
    • 101
    • 102
    • 103
    • 104
    • 105
    • 106
    • 107
    • 108
    • 109
    • 110
    • 111
    • 112
    • 113
    • 114
    • 115
    • 116
    • 117
    • 118
    • 119
    • 120
    • 121
    • 122
    • 123
    • 124

    文件页的缺页异常

    共享匿名页某种程度也算是文件页,用的是特殊的fd

    何时会触发文件页的缺页异常呢?

    1. 启动程序的时候,内核为程序的代码段和数据段创建私有的文件映射,映射到进程的虚拟地址空间,第一次访问的时候触发文件页的缺页异常。
    2. 进程使用mmap创建文件映射,把文件的一个区间映射到进程的虚拟地址空间,第一次访问的时候触发文件页的缺页异常。

    函数__do_fault处理文件页和共享匿名页的缺页异常,执行流程及源码分析如下:

    mm\memory.c

    static int do_fault(struct vm_fault *vmf)
    {
    	struct vm_area_struct *vma = vmf->vma;
    	int ret;
    
        // 如果虚拟内存区域没有提供页错误异常方法vma->vm_ops->fault,返回错误号VM_FAULT_SIGBUS
    	if (!vma->vm_ops->fault)
    		ret = VM_FAULT_SIGBUS;
        // 如果缺页异常是由读文件页触发的
    	else if (!(vmf->flags & FAULT_FLAG_WRITE))
    		ret = do_read_fault(vmf);
        // 如果缺页异常是由写私有文件页触发的,调用函数do_cow_fault以处理写私有文件页错误,执行写时复制
    	else if (!(vma->vm_flags & VM_SHARED))
    		ret = do_cow_fault(vmf);
    	else
           	// 如果缺页异常是由写共享文件页触的发的,调用函数do_shared_fault
    		ret = do_shared_fault(vmf);
    
    	/* preallocated pagetable is unused: free it */
    	if (vmf->prealloc_pte) {
    		pte_free(vma->vm_mm, vmf->prealloc_pte);
    		vmf->prealloc_pte = NULL;
    	}
    	return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25

    处理读文件页错误

    具体处理读文件页错误的方法如下:

    • 把文件页从存储设备上的文件系统读到文件的缓存 (每个文件有一个缓存,因为以页为单位,所以称为页缓存)中。
    • 设置进程的页表项,把虚拟页映射到文件的页缓存的物理页。

    函数do_read_fault处理读文件页错误,执行流程及源码分析如下:

    image-20220806072605718

    static int do_read_fault(struct vm_fault *vmf)
    {
    	struct vm_area_struct *vma = vmf->vma;
    	int ret = 0;
    
    	/*
    	为了减少页错误异常的次数,如果正在访问的文件页后面的几个文件页也被映射到进程的虚拟地址空间,
    	那么预先读取到页缓存中,全局变量fault_around_bytes控制总长度,默认值是64kb,如果页长度是4kb,就一次读取16页
    	 */
    	if (vma->vm_ops->map_pages && fault_around_bytes >> PAGE_SHIFT > 1) {
    		ret = do_fault_around(vmf);
    		if (ret)
    			return ret;
    	}
    	
        // 把文件页读到文件的页缓存中
    	ret = __do_fault(vmf);
    	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
    		return ret;
    
        // 设置页表项,把虚拟页映射到文件的页缓存中的物理页
    	ret |= finish_fault(vmf);
    	unlock_page(vmf->page);
    	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
    		put_page(vmf->page);
    	return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    函数__do_fault需要使用虚拟内存区域的虚拟内存操作集合中的fault方法(vm_area_struct.vm_ops->fault)来把文件页读到内存中。

    **进程调用mmap创建文件映射的时候,文件所属的文件系统会注册虚拟内存区域的虚拟内存操作集合,fault方法负责处理文件页的缺页异常。**例如,EXT4文件系统注册的虚拟内存操作集合是ext4_file_vm_ops,fault方法是函数ext4_filemap_fault。许多文件系统注册的fault方法是通用的函数filemap_fault。

    给定一个虚拟内存区域vma,函数filemap_fault读文件页的方法如下:

    • 根据vma->vm_file得到文件的打开实例file;
    • 根据file->f_mapping得到文件的地址空间mapping;
    • 使用地址空间操作集合中的readpage方法(mapping->a_ops->readpage)把文件页读到内存中。

    函数finish_fault负责设备项表项,把主要工作委托给函数alloc_set_pte,执行流程及源码分析如下:

    image-20220806072938063
    int alloc_set_pte(struct vm_fault *vmf, struct mem_cgroup *memcg,
    		struct page *page)
    {
    	struct vm_area_struct *vma = vmf->vma;
    	bool write = vmf->flags & FAULT_FLAG_WRITE;
    	pte_t entry;
    	int ret;
    
    	if (pmd_none(*vmf->pmd) && PageTransCompound(page) &&
    			IS_ENABLED(CONFIG_TRANSPARENT_HUGE_PAGECACHE)) {
    		/* THP on COW? */
    		VM_BUG_ON_PAGE(memcg, page);
    
    		ret = do_set_pmd(vmf, page);
    		if (ret != VM_FAULT_FALLBACK)
    			return ret;
    	}
    
        // 如果直接页表不存在,那么分配直接页表,根据虚拟地址在直接页表中查找页表项,并且锁住页表
    	if (!vmf->pte) {
    		ret = pte_alloc_one_map(vmf);
    		if (ret)
    			return ret;
    	}
    
    	// 锁住页表后重新检查
    	if (unlikely(!pte_none(*vmf->pte)))
    		return VM_FAULT_NOPAGE;
    
        // 如果锁住页表以后发现页表项不是空表项,说明其他处理器修改同一页表,那么当前处理器放弃处理
    	flush_icache_page(vma, page);
        
        // 使用页帧号和访问权限生成对应的页表项
    	entry = mk_pte(page, vma->vm_page_prot);
    	if (write)
    		entry = maybe_mkwrite(pte_mkdirty(entry), vma);
    	
        /* copy-on-write page */  // 写时复制的页
    	if (write && !(vma->vm_flags & VM_SHARED)) {
    		inc_mm_counter_fast(vma->vm_mm, MM_ANONPAGES);
    		page_add_new_anon_rmap(page, vma, vmf->address, false);
    		mem_cgroup_commit_charge(page, memcg, false, false);
            // 把物理页添加到活动的lru链表或者不可回收lru链表
    		lru_cache_add_active_or_unevictable(page, vma);
    	} else {
    		inc_mm_counter_fast(vma->vm_mm, mm_counter_file(page));
    		page_add_file_rmap(page, false);
    	}
        // 设置页表项
    	set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
    
    	/* no need to invalidate: a not-present page won't be cached */
        // 一个不存在的页不会被缓存,更新处理器的页表缓存
    	update_mmu_cache(vma, vmf->address, vmf->pte);
    
    	return 0;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57

    处理写私有文件页错误的方法

    • 把文件页从存储设备上的文件系统读到文件的页缓存中;
    • 执行写时复制,为文件的页缓存中的物理页创建一个副本,这个副本是进程的私有匿名页,和文件脱离系统,修改副本不会导致文件变化;
    • 设备进程的页表项,把虚拟页映射到副本

    函数do_cow_fault处理写私有文件页错误,执行流程及源码分析如下:

    static int do_cow_fault(struct vm_fault *vmf)
    {
    	struct vm_area_struct *vma = vmf->vma;
    	int ret;
    	// 关联一个实例到虚拟内存区域
    	if (unlikely(anon_vma_prepare(vma)))
    		return VM_FAULT_OOM;
    
        // 因为后面我们会用到写时复制,所以预先为副本分配一个物理页
    	vmf->cow_page = alloc_page_vma(GFP_HIGHUSER_MOVABLE, vma, vmf->address);
    	if (!vmf->cow_page)
    		return VM_FAULT_OOM;
    
    	if (mem_cgroup_try_charge(vmf->cow_page, vma->vm_mm, GFP_KERNEL,
    				&vmf->memcg, false)) {
    		put_page(vmf->cow_page);
    		return VM_FAULT_OOM;
    	}
    
        // 把文件页读取到文件的页缓存中
    	ret = __do_fault(vmf);
    	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
    		goto uncharge_out;
    	if (ret & VM_FAULT_DONE_COW)
    		return ret;
        // 把文件的页缓存中物理页的数据复制到副本物理页
    	copy_user_highpage(vmf->cow_page, vmf->page, vmf->address, vma);
    	// 设置副本页描述符的标志位,表示物理页包含有效的数据
        __SetPageUptodate(vmf->cow_page);
    
        // 设置页表项,把虚拟页映射到副本的物理页。
    	ret |= finish_fault(vmf);
    	unlock_page(vmf->page);
    	put_page(vmf->page);
    	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
    		goto uncharge_out;
    	return ret;
    uncharge_out:
    	mem_cgroup_cancel_charge(vmf->cow_page, vmf->memcg, false);
    	put_page(vmf->cow_page);
    	return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    处理写共享文件页错误的方法

    • 把文件页从存储设备上的文件系统读到文件的页缓存中;
    • 设备进程的页表项,把虚拟页映射到文件的页缓存中的物理页。

    函数do_shared_fault处理写共享文件页错误,执行流程及源码分析如下:

    image-20220806073315966

    static int do_shared_fault(struct vm_fault *vmf)
    {
    	struct vm_area_struct *vma = vmf->vma;
    	int ret, tmp;
    
        // 把文件页读取到文件的页缓存中
    	ret = __do_fault(vmf);
        
        // 如果创建内存映射的时候文件所属的文件系统注册了虚拟内存操作集合中的page-write方法,
        // 调用这个方法通知文件系统“页即将变成可写的”。
    
    	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE | VM_FAULT_RETRY)))
    		return ret;
    
    	/*
    	 * Check if the backing address space wants to know that the page is
    	 * about to become writable
    	 */
    	if (vma->vm_ops->page_mkwrite) {
    		unlock_page(vmf->page);
    		tmp = do_page_mkwrite(vmf);
    		if (unlikely(!tmp ||
    				(tmp & (VM_FAULT_ERROR | VM_FAULT_NOPAGE)))) {
    			put_page(vmf->page);
    			return tmp;
    		}
    	}
    
        // 设置页表项,把虚拟页映射到文件的页缓存中的物理页
    	ret |= finish_fault(vmf);
    	if (unlikely(ret & (VM_FAULT_ERROR | VM_FAULT_NOPAGE |
    					VM_FAULT_RETRY))) {
    		unlock_page(vmf->page);
    		put_page(vmf->page);
    		return ret;
    	}
    
        // 设置页的脏标志位,表示页的数据被修改。如果文件所属文件系统没有注册虚拟内存操作集合方法
        // 那么更新方法的修改时间
    	fault_dirty_shared_page(vma, vmf->page);
    	return ret;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42

    写时复制

    有两种情况会执行写时复制(Copy on Write,CoW)。

    (1)进程分叉生成子进程的时候,为了避免复制物理页,子进程和父进程以只读方式共享所有私有的匿名页和文件页。当其中一个进程试图写只读页时,触发页错误异常,页错误异常处理程序分配新的物理页,把旧的物理页的数据复制到新的物理页,然后把虚拟页映射到新的物理页。

    **(2)进程创建私有的文件映射,然后读访问,触发页错误异常,异常处理程序把文件读到页缓存,然后以只读模式把虚拟页映射到文件的页缓存中的物理页。**接着执行写访问,触发页错误异常,异常处理程序执行写时复制,为文件的页缓存中的物理页创建一个副本,把虚拟页映射到副本。这个副本是进程的私有匿名页,和文件脱离关系,修改副本不会导致文件变化。

    函数do_wp_page处理写时复制(第一种情况),执行流程如图3.94所示,执行过程如下。

    image-20220806073713374

    函数wp_page_copy执行写时复制(第二种情况),执行流程如图3.95所示,其代码如下:

    image-20220806073845724

    内核模式页错误异常

    内核访问内核虚拟地址,正常情况下不会出现虚拟页没有映射到物理页的状况,内核使用线性映射区域的虚拟地址,在内存管理子系统初始化的时候就会把虚拟地址映射到物理地址,运行过程中可能使用vmalloc()函数从vmalloc区域分配虚拟内存区域,vmalloc()函数会分配并且映射到物理页(x86不确定)。如果出现虚拟页没有映射到物理页的情况,一定是程序错误,内核将会崩溃。

    内核可能访问用户虚拟地址,进程通过系统调用进入内核模式,有些系统调用会传入用户空间的缓冲区,内核必须使用头文件“uaccess.h”定义的专用函数访问用户空间的缓冲区,这些专用函数在异常表中添加了可能触发异常的指令地址和异常修正程序的地址。

    如果访问用户空间的缓冲区时生成页错误异常,页错误异常处理程序发现用户虚拟地址没有被分配给进程,就在异常表中查找指令地址对应的异常修正程序,如果找到了,使用异常修正程序修正异常,避免内核崩溃。

  • 相关阅读:
    Spring Boot3 系列:Spring Boot3 跨域配置 Cors
    【CLR C#】面向面试的.Net的GC(垃圾回收)机制及其整体流程
    vue 改变路由(URL)参数不刷新页面
    PR BeatEdit 节奏卡点神器 的报错 beat detection error: IBT failed 和解决路径
    c++11~c++20 -05-thread_local
    ADRC/Matlab一步步实现跟踪微分器TD(附完整PLC测试代码链接)
    轻量级的Linux发行版:4MLinux稳定版发布
    Vue中启动提示polyfill缺少-webpack v5版本导致
    JAVA坝上长尾鸡养殖管理系统计算机毕业设计Mybatis+系统+数据库+调试部署
    2022CCPC预选赛C Guess(博弈)
  • 原文地址:https://blog.csdn.net/qq_53111905/article/details/126188911