• Systemcall 系统调用 Hello


    一、系统调用过程

    1. 用户在进行系统调用时,通过传递一个系统调用编号,来告知内核,它所请求的系统调用,内核通过这个编号进而找到对应的处理系统调用的C函数。这个系统编号,在 x86 架构上,是通过 eax 寄存器传递的。

    2. 系统调用的过程跟其他的异常处理流程一样,包含下面几个步骤:
    (1) 将当前的寄存器上下文保存在内核 stack 中(这部分处理都在汇编代码中)
    (2) 调用对应的C函数去处理系统调用
    (3) 从系统调用处理函数返回,恢复之前保存在 stack 中的寄存器,CPU 从内核态切换到用户态

    3. 在内核中用于处理系统调用的C函数入口名称是 sys_xxx() ,xxx() 就是对应的系统调用,实际上会有宏在xxx()前面加上一个函数头。 在 Linux 内核的代码中,这样的系统调用函数命名则是通过宏定义 SYSCALL_DEFINEx 来实现的,其中的 x 表示这个系统调用处理函数的输入参数个数。(不同的架构会复写这个宏定义,以实现不同的调用规则,其中 ARM64 的宏定义在 arch/arm64/include/asm/syscall_wrapper.h 文件中)

    4. 将系统调用编号与这些实际处理C函数联系起来的是一张系统调用表 sys_call_table 这个表具有 __NR_syscalls 个元素(目前kernel-5.10这个值是440)。表中对应的 n 号元素所存储的就是 n 号系统调用对应的处理函数指针。__NR_syscalls 这个宏只是表示这个表的大小,并不是真正的系统调用个数,如果对应序号的系统调用不存在,那么就会用 sys_ni_syscall 填充,这是一个表示没有实现的系统调用,它直接返回错误码 -ENOSYS。

    //arch/arm64/kernel/sys.c
    #undef __SYSCALL
    #define __SYSCALL(nr, sym)    asmlinkage long __arm64_##sym(const struct pt_regs *);
    #include <asm/unistd.h> //<1>
    
    #undef __SYSCALL
    #define __SYSCALL(nr, sym)    [nr] = __arm64_##sym,
    
    typedef long (*syscall_fn_t)(const struct pt_regs *regs);
    
    const syscall_fn_t sys_call_table[__NR_syscalls] = {
        [0 ... __NR_syscalls - 1] = __arm64_sys_ni_syscall, //这个函数是防止没有实现的,直接return -ENOSYS;
    #include <asm/unistd.h> //<2>
    };

    <asm/unistd.h> 最终使用的是 <uapi/asm-generic/unistd.h> 它里面定义了 NR_xxx 和 相关函数,以 getpriority 系统调用的实现为例:

    //include/uapi/asm-generic/unistd.h
    #define __NR_getpriority 141
    __SYSCALL(__NR_getpriority, sys_getpriority)

    在位置<1>,展开为:asmlinkage long __arm64_sys_getpriority(const struct pt_regs *);
    在位置<2>,展开为:[141] = __arm64_sys_getpriority,
    最终 sys_call_table[] 下标为 141 的位置指向的函数为 __arm64_sys_getpriority

    二、系统调用的进入和退出

    1. 在 x86 的架构上,支持2种方式进入和退出系统调用:

    (1) 通过 int $0x80 触发软件中断进入,iret 指令退出
    (2) 通过 sysenter 指令进入,sysexit指令退出

    2. 在 ARM 架构上,则是通过 svc 指令进入系统调用。

    ARM64 架构中,存在4个不同的运行级别,分别为 EL0、EL1、EL2、EL3,这4个级别运行的系统如下图所示:

    用户态运行在 EL0 级别,我们讨论的内核则是运行在 EL1 级别。svc 指令通过触发一个同步异常,使得从 EL0 跳转到 EL1 级别,也就是从用户态跳转到了内核态。这个同步异常的处理入口在 arch/arm64/kernel/entry.S
    文件中的 el0_sync 它是通过 kernel_ventry 这样一个宏在 ENTRY(vectors) 异常处理向量表中注册的,其实就是汇编中的一个标号。当 svc 指令执行时,CPU 就会切换到 EL1 级别,并且跳转到在异常向量表 vectors 中找到由宏 kernel_ventry 展开所在的地址。kernel_ventry 做了一个简单的溢出检测后,就跳转到真正的异常处理入口 el0_sync 。

    /*
     * EL0 mode handlers.
     */
        .align    6
    SYM_CODE_START_LOCAL_NOALIGN(el0_sync) /*宏展开为: ; ; el0_sync: */
        kernel_entry 0
        mov    x0, sp
        bl    el0_sync_handler
        b    ret_to_user
    SYM_CODE_END(el0_sync) /*宏展开为:.type el0_sync 0 ; .size el0_sync, .-el0_sync*/

    在这段汇编指令中, kernel_entry 将寄存器入栈,保存现场。然后将当前的栈指针传递给 x0,作为 el0_sync_handler 的C函数入参。异常处理完成后,则通过 ret_to_user 回到用户态。

    由于所有的同步的异常都是这个入口,所以在 el0_sync_handler 中会读取 ESR_EL1 寄存器获取真正触发同步异常的原因,然后进行对应的响应处理。此处,我们是 svc 指令触发的异常,所以调用 el0_svc 进行处理。我们看 do_el0_svc 函数的处理:

    asmlinkage void noinstr el0_sync_handler(struct pt_regs *regs) //arch/arm64/kernel/entry-common.c
    {
        unsigned long esr = read_sysreg(esr_el1);
    
        switch (ESR_ELx_EC(esr)) { //取bit26-bit32
        case ESR_ELx_EC_SVC64: //0x15
            el0_svc(regs); //系统调用
            break;
        ...
        default:
            el0_inv(regs, esr);
        }
    }
    
    static void noinstr el0_svc(struct pt_regs *regs)
    {
        enter_from_user_mode();
        do_el0_svc(regs);
    }
    
    void do_el0_svc(struct pt_regs *regs) //arch/arm64/kernel/syscall.c
    {
        sve_user_discard();
        el0_svc_common(regs, regs->regs[8], __NR_syscalls, sys_call_table); //reg[8]也就是X8寄存器存储的是系统调用号
    }
    
    static void el0_svc_common(struct pt_regs *regs, int scno, int sc_nr, const syscall_fn_t syscall_table[]) //syscall.c
    {
        unsigned long flags = current_thread_info()->flags;
    
        regs->orig_x0 = regs->regs[0];
        regs->syscallno = scno;
    
        cortex_a76_erratum_1463225_svc_handler();
        local_daif_restore(DAIF_PROCCTX);
    
        if (flags & _TIF_MTE_ASYNC_FAULT) {
            regs->regs[0] = -ERESTARTNOINTR;
            return;
        }
    
        if (has_syscall_work(flags)) {
            if (scno == NO_SYSCALL)
                regs->regs[0] = -ENOSYS;
            scno = syscall_trace_enter(regs);
            if (scno == NO_SYSCALL)
                goto trace_exit;
        }
    
        /*跳转到对应系统调用编号的处理函数中 */
        invoke_syscall(regs, scno, sc_nr, syscall_table);
    
        if (!has_syscall_work(flags) && !IS_ENABLED(CONFIG_DEBUG_RSEQ)) {
            local_daif_mask();
            flags = current_thread_info()->flags;
            if (!has_syscall_work(flags) && !(flags & _TIF_SINGLESTEP))
                return;
            local_daif_restore(DAIF_PROCCTX);
        }
    
    trace_exit:
        syscall_trace_exit(regs);
    }
    
    
    static void invoke_syscall(struct pt_regs *regs, unsigned int scno, unsigned int sc_nr, const syscall_fn_t syscall_table[]) //syscall.c
    {
        long ret;
    
        if (scno < sc_nr) {
            syscall_fn_t syscall_fn;
            syscall_fn = syscall_table[array_index_nospec(scno, sc_nr)]; //获取 sys_call_table[] 中的回调函数
            ret = __invoke_syscall(regs, syscall_fn);
        } else {
            ret = do_ni_syscall(regs, scno);
        }
    
        if (is_compat_task())
            ret = lower_32_bits(ret);
    
        regs->regs[0] = ret; //将系统调用函数返回值保存在X0寄存器
    }
    
    static long __invoke_syscall(struct pt_regs *regs, syscall_fn_t syscall_fn)
    {
        /* 
         * 调用kernel实现的系统调用函数,对于 getpriority()
         * 系统调用来说就是 __arm64_sys_getpriority()
         */
        return syscall_fn(regs);
    }

    在结束系统调用的时候,内核需要把 CPU 让给用户,不过在返回前,内核会检查是否需要进行一次 schedule,如果需要,那么这次返回到用户空间的时候,CPU 就会执行另一个进程,而不是触发之前触发系统调用的那个。返回的处理代码在汇编函数 ret_to_user 中:

    /*
     * "slow" syscall return path.
     */
    SYM_CODE_START_LOCAL(ret_to_user)
        disable_daif
        gic_prio_kentry_setup tmp=x3
    #ifdef CONFIG_TRACE_IRQFLAGS
        bl    trace_hardirqs_off
    #endif
        ldr    x19, [tsk, #TSK_TI_FLAGS]
        and    x2, x19, #_TIF_WORK_MASK
        cbnz    x2, work_pending
    finish_ret_to_user:
        user_enter_irqoff
        enable_step_tsk x19, x2
    #ifdef CONFIG_GCC_PLUGIN_STACKLEAK
        bl    stackleak_erase
    #endif
        kernel_exit 0
    
    /*
     * Ok, we need to do extra processing, enter the slow path.
     */
    work_pending:
        mov    x0, sp                // 'regs'
        mov    x1, x19
        bl    do_notify_resume
        ldr    x19, [tsk, #TSK_TI_FLAGS]    // re-check for single-step
        b    finish_ret_to_user
    SYM_CODE_END(ret_to_user)

    首先它会关闭 DAIF(D:进程D状态的 mask,A:exception mask,I:IRQ,F:FIRQ)然后根据 task 的状态,确定是否需要进入 work_pending,也就是代码注释所说的“slow” system call。在 work_pending 中,do_notify_resume 中判断任务切换的标志如果有置位,就进行一次 schedule。最后就是 kernel_exit,这一处的汇编代码比较长,不过这些剩下的事情就是为用户进程做好恢复的准备,然后打开中断之类的。所有的异常处理在返回前都是调用这个宏,此处先略过不提。

    asmlinkage void do_notify_resume(struct pt_regs *regs, unsigned long thread_flags) //arch/arm64/kernel/signal.c
    {
        do {
            /* Check valid user FS if needed */
            addr_limit_user_check();
    
            //若参数flag表示需要重新调度,就重新调度
            if (thread_flags & _TIF_NEED_RESCHED) {
                /* Unmask Debug and SError for the next task */
                local_daif_restore(DAIF_PROCCTX_NOIRQ);
    
                schedule();
            } else {
                local_daif_restore(DAIF_PROCCTX);
    
                if (thread_flags & _TIF_UPROBE)
                    uprobe_notify_resume(regs);
    
                if (thread_flags & _TIF_MTE_ASYNC_FAULT) {
                    clear_thread_flag(TIF_MTE_ASYNC_FAULT);
                    send_sig_fault(SIGSEGV, SEGV_MTEAERR, (void __user *)NULL, current);
                }
    
                if (thread_flags & _TIF_SIGPENDING)
                    do_signal(regs);
    
                if (thread_flags & _TIF_NOTIFY_RESUME) {
                    tracehook_notify_resume(regs);
                    rseq_handle_notify_resume(NULL, regs);
                }
    
                if (thread_flags & _TIF_FOREIGN_FPSTATE)
                    fpsimd_restore_current_state();
            }
    
            local_daif_mask();
            thread_flags = READ_ONCE(current_thread_info()->flags);
        } while (thread_flags & _TIF_WORK_MASK);
    }

    三、系统调用的参数传递

    1. 就像C函数一样,系统调用也需要有输入参数。在 X86 架构上,通常函数的参数是通过栈传递。不过由于系统调用,涉及到用户和内核2个栈,为了使参数的处理相对简单一些,系统调用的参数规定通过 CPU 寄存器传递。由于寄存器的数量有限,所以规定系统调用最多传递 6 个参数。如果有多的参数需要传递,那么就通过指针进行传递。

    参数传递的实现在内核部分的代码,可以看 SYSCALL_DEFINEx 宏的定义(基于ARM64架构):

    //include/linux/syscalls.h
    #define __MAP0(m,...)
    #define __MAP1(m,t,a,...) m(t,a)
    #define __MAP2(m,t,a,...) m(t,a), __MAP1(m,__VA_ARGS__)
    #define __MAP3(m,t,a,...) m(t,a), __MAP2(m,__VA_ARGS__)
    #define __MAP4(m,t,a,...) m(t,a), __MAP3(m,__VA_ARGS__)
    #define __MAP5(m,t,a,...) m(t,a), __MAP4(m,__VA_ARGS__)
    #define __MAP6(m,t,a,...) m(t,a), __MAP5(m,__VA_ARGS__)
    #define __MAP(n,...) __MAP##n(__VA_ARGS__)
    
    #define __SC_ARGS(t, a)    a
    
    /*
     * 若x=2,按上面的宏展开后就是 " regs->regs[0], regs->regs[1] ", 可以使用 gcc -E 进行测试
     */
    //arch/arm64/include/asm/syscall_wrapper.h
    #define SC_ARM64_REGS_TO_ARGS(x, ...)                \
        __MAP(x,__SC_ARGS,,regs->regs[0],,regs->regs[1],,regs->regs[2],,regs->regs[3],,regs->regs[4],,regs->regs[5])
    
    
    /*
     * __arm64_sys##name 就是填入到 sys_call_table 中的函数名,svc 同步异常就是跳转到这个入口
     * 这个入口函数将CPU寄存器中值作为函数入参传递到下一级子函数中,如此即实现了系统调用的输入
     * 参数传递.
     */
    //arch/arm64/include/asm/syscall_wrapper.h
    #define __SYSCALL_DEFINEx(x, name, ...)                        \
        asmlinkage long __arm64_sys##name(const struct pt_regs *regs);        \
        ALLOW_ERROR_INJECTION(__arm64_sys##name, ERRNO);            \
        static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__));        \
        static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__));    \
        asmlinkage long __arm64_sys##name(const struct pt_regs *regs)        \
        {                                    \
            return __se_sys##name(SC_ARM64_REGS_TO_ARGS(x,__VA_ARGS__));    \
        }                                    \
        static long __se_sys##name(__MAP(x,__SC_LONG,__VA_ARGS__))        \
        {                                    \
            long ret = __do_sys##name(__MAP(x,__SC_CAST,__VA_ARGS__));    \
            __MAP(x,__SC_TEST,__VA_ARGS__);                    \
            __PROTECT(x, ret,__MAP(x,__SC_ARGS,__VA_ARGS__));        \
            return ret;                            \
        }                                    \
        static inline long __do_sys##name(__MAP(x,__SC_DECL,__VA_ARGS__))

    注意:系统调用最大只能传入6个参数,使用X0-X5传递参数,在内核中可以全局检索到 SYSCALL_DEFINE6,但是检索不到 SYSCALL_DEFINE7。若是要多于6个参数要传递,就需要传结构体指针,可以参考 sched_setattr() 的实现。

    2. 以 int getpriority(int which, id_t who) 为例展示系统调用展开:

    //kernel/sys.c
    SYSCALL_DEFINE2(getpriority, int, which, int, who)
    {
        //函数实现
    }

    上面宏展开:

    /*
     * 就是填入到 sys_call_table 中的函数名,svc 同步异常就是跳转到这个入口
     * 这个入口函数将CPU寄存器中值作为函数入参传递到下一级子函数中
     */
    asmlinkage long __arm64_sys_getpriority(const struct pt_regs *regs); //参数是pt_regs是数组指针
    
    static struct error_injection_entry __used __section("_error_injection_whitelist") _eil_addr___arm64_sys_getpriority = {
        .addr = (unsigned long)__arm64_sys_getpriority,
        .etype = EI_ETYPE_ERRNO, 
    };;
    
    static long __se_sys_getpriority(__SC_LONG(int,which), __SC_LONG(int,who));
    
    static inline long __do_sys_getpriority(int which, int who);
    
    asmlinkage long __arm64_sys_getpriority(const struct pt_regs *regs) {
        return __se_sys_getpriority(regs->regs[0], regs->regs[1]); //这里将寄存器根据SYSCAL_DEFINEx中的x拆开传递,传参就是X0,X1寄存器
    }
    
    static long __se_sys_getpriority(__SC_LONG(int,which), __SC_LONG(int,who)) {
        long ret = __do_sys_getpriority((__force int) which, (__force int) who);
        __SC_TEST(int,which), __SC_TEST(int,who);
        return ret;
    }
    
    static inline long __do_sys_getpriority(int which, int who)
    {
        //函数实现
    }

    可见 SYSCALL_DEFINEX(...) {...} 定义的系统调用响应函数就是宏展开部分加函数实现部分的拼接。

    3. 没有参数的系统调用宏有点特殊,以 pid_t fork(void) 系统调用为例展开:

    //kernel/fork.c
    SYSCALL_DEFINE0(fork)
    {
        函数实现
    }

    使用gcc -E 宏展开后:

    asmlinkage long __arm64_sys_fork(const struct pt_regs *__unused);
    
    static struct error_injection_entry __used __section("_error_injection_whitelist") _eil_addr___arm64_sys_fork = {
        .addr = (unsigned long)__arm64_sys_fork,
        .etype = EI_ETYPE_ERRNO,
    };
        
    asmlinkage long __arm64_sys_fork(const struct pt_regs *__unused)
    {
        函数实现
    }

    通过以上的宏分析,我们可以看到在 ARM64 架构中,系统调用的参数就是通过 x0~x5 这6个寄存器进行传递的,再加上之前用于传递系统调用编号的 x8 寄存器。

    在 X86 架构中,系统调用编号是通过 eax 传递,参数则是由 ebx, ecx, edx, esi, edi, ebp 这6个寄存器实现的。系统调用函数定义的这个宏可以根据不同的架构进行重新定义,如此即可以满足不同架构的系统调用规范要求。

    系统调用的参数是用户态传递到内核的,所以对它们都需要进行安全检查。其中非常通用的是对地址的检查,内核通过 access_ok 这个函数进行一个简单的校验,这个函数的定义根据CPU架构不同而不同,下面是 ARM64 的定义:

    //arch/arm64/include/asm/uaccess.h
    #define access_ok(addr, size)    __range_ok(addr, size)
    
    //__range_ok 是使用汇编实现的函数,就是判断 (u65)addr + (u65)size <= (u65)current->addr_limit + 1

    在 ARM64 上,这个函数通过汇编指令实现的,不过看注释就它所做的检查非常地基础,也就是看当前需要访问的空间是否有超过 current->addr_limit 。这个值通常是用户空间的最大地址,可以通过 get_fs 和 set_fs 获取和配置。

    系统调用传递的参数有限,很多时候,在内核中处理系统调用的时候,需要访问进程的用户空间地址。内核中有许多用于在内核空间访问用户空间数据的宏,在下面的表格中列出它们。其中,带有双下划线的表示访问前不做地址校验。

    FunctionFunctionAction
    get_user __get_user 从用户空间读取一个整数
    put_user __put_user 写入一个整数到用户空间
    copy_from_user __copy_from_user 从用户空间拷贝一段数据
    copy_to_user __copy_to_user 拷贝一段数据到用户空间
    strncpy_from_user __strncpy_from_user 从用户空间拷贝一个字符串
    strlen_user strlen_user 获取一个用户空间字符串的长度
    clear_user __clear_user 将用户空间的一段空间全部写0

    如前面所言,access_ok 只是一个非常粗糙的检查,它能确保用户传递的参数没有染指到内核空间。除此以外,传入的参数还是可能会存在错误,如果作为地址的入参并没有在当前这个进程的地址空间中,那么就会触发一个 page fault。下面是内核中产生 page fault 的一些原因:

    (1) 内核访问的地址属于进程的地址空间,不过内存页还不存在或者我们对一个只读属性的 page 进行写操作。此时,在 page fault 中会初始化一个新的页框
    (2) 内核访问的地址属于进程的地址空间,不过对应的 PTE 还没有建立,此时会新建对应地址的 PTE
    (3) 内核函数的 bug,导致出现访问异常,此时会触发 kernel oops
    (4) 系统调用传递下来的参数,地址不属于进程的地址空间

    前2种情况都是正常的流程,也很好区分,是否属于地址空间,在进程的 VMA 中的进行查找即可知道,PTE 是否建立,查看对应地址的 PTE 是否为空即可。麻烦的是后面2中情况的区分。如果只是系统调用参数导致的错误,那么内核应该只是将这种错误反馈到用户空间即可,不必大惊小怪地进行一次 oops。

    为了把这2种情况区分开来,Linux 搞了一张 exception table。内核访问进程的用户空间都是通过前面列出的几个宏进行的,如果是第四种 page fault 的情况,那么引发 page fault的指令地址肯定就是在那几个访问用户空间地址的接口处。这样我们只需要把这些接口中会触发 page fault 的指令登记在这个 exception table 中,出现 page fault 的时候,就去这张表里找,如果能找到,那么就说明是第四种情况。

    在 do_page_fault 中,通过函数 search_exception_tables 查找 exception table。而这个 exception table 在编译阶段由编译器将它们存放在了 __ex_table 段,在加载内核的时候,这个段会被加载到内存中。指示这个段的起始地址和结束地址的符号是 __start___ex_table & __stop___ex_table。

    在 exception table 中,每个元素由2个整数构成:

    struct exception_table_entry
    {
        int insn, fixup;
    };

    第一个就是产生异常的指令地址值,而第二个则是 do_page_fault 在匹配到这个地址时,可以跳转继续执行的地址,所有又叫做 fixup 。在 fixup 中,通常会设置好错误码,以便返回给用户空间,并且 fixup 这部分的指令也存放在一个名为 .fixup 的段。下面是 ARM64 架构中 get_user 接口中的对于 exception 的处理:

    其中宏 _ASM_EXTABLE 的作用是往 __ex_table 段中添加元素,其中 from 就是异常发生时的指令地址,而 to 就是异常发生后跳转到 fixup 的地址。在 get_user 中,from 对应着标号为 1 的指令所在地址,to 则对应着标号为 3 的指令所在地址。也即是 get_user 中,只有标号为 1 处的指令可能触发 page fault,如果是它触发了异常,那么就跳转到 3 所在位置进行修补。在这里我们看到,它将 -EFAULT 传递给 w0 寄存器,并且将 0 赋值给输入参数 x。这样也就是当 get_user 在访问一个异常地址时,do_page_fault 通过 exception table 将会让它返回一个错误码 -EFAULT,并且读取到的值为0。

    在内核中进行系统调用的宏 _syscall0 在最新的内核代码中已经找不到了,这样比较好,毕竟系统调用这个东西就是用户空间与内核空间的一个交互,在内核空间触发进入系统调用流程看起来不太优雅,也没什么必要。不过在最新的代码里找到了这样一个头文件 tools/include/nolibc/nolibc.h ,这个文件比较新,是用于给那些精简到连C运行库都不想用的系统。通过一个头文件,这样程序中真正用到的系统调用才会被编译生成,其他不用的就可以不占用系统的空间了。(我想这个系统都这么扣了,那应该是不是考虑不用 Linux 系统了呢)

    四、其它

    1. 直接使用系统调用号

    #include <stdio.h>
    #include <sys/types.h>
    #include <sys/stat.h>
    #include <fcntl.h>
    #include <string.h>
    #include <errno.h>
    #define _GNU_SOURCE
    #include <unistd.h>
    #include <sys/syscall.h>
    
    void main()
    {
        int fd;
        char r_buf[64] = {0};
    
        fd = open("./tmp.txt", O_RDWR|O_CREAT, S_IRUSR|S_IWUSR);
        if (fd < 0) {
            printf("open error, errno=%d: %s\n", errno, strerror(errno));
            return;
        }
        write(fd, "Hello ", strlen("Hello "));
        syscall(SYS_write, fd, "World!", strlen("World!")); //直接使用系统调用号
        lseek(fd, 0, SEEK_SET);
        read(fd, r_buf, sizeof(r_buf));
        close(fd);
    }
    /*
    $ ./pp
    r_buf: Hello World!
    */
  • 相关阅读:
    paper 89:视频图像去模糊常用处理方法
    paper 88:人脸检测和识别的Web服务API
    paper 87:行人检测资源(下)代码数据【转载,以后使用】
    paper 86:行人检测资源(上)综述文献【转载,以后使用】
    paper 85:机器统计学习方法——CART, Bagging, Random Forest, Boosting
    paper 84:机器学习算法--随机森林
    paper 83:前景检测算法_1(codebook和平均背景法)
    paper 82:边缘检测的各种微分算子比较(Sobel,Robert,Prewitt,Laplacian,Canny)
    paper 81:HDR成像技术
    paper 80 :目标检测的图像特征提取之(一)HOG特征
  • 原文地址:https://www.cnblogs.com/hellokitty2/p/15659162.html
Copyright © 2020-2023  润新知