阿里linux内核月报201702

The future of the page cache
持久化内存用得越来越多, 促使了内核的一系列变更, 内核是否还真的需要页面缓存呢? 在2017 linux.conf.au会上, Matthew Wilcox先是纠正了数年前的一个错误,然后表示, 我们不仅需要页面缓存,还要将他的作用将进一步得到提升。

他从他作为微软员工的时候开始讲起,以前他以为不会提及这个。 然后进入主题,内容如下, 计算机就是缓存的世界。只要缓存都命中,他的新电脑每秒可以执行100亿条指令。但是内存每秒只能跑5亿3千万条cache line,因此缓存未命中就会严重影响性能。如果数据没有缓存到主存,需要从存储设备读, 即使是快速的SSD,也会变得很慢。

计算机就是这样,PDP-11也会因缓存未命中而显著变慢。更严重的是,CPU的发展速度比内存快,而同时内存的发展速度相比存储也更快。缓存未命中的带来的性能损失也会越来越严重。

页面缓存
很久以来,Unix系统都有缓冲区缓存,位于文件系统与磁盘之间,目的是为了缓存磁盘块到内存中。为了准备这次演讲,他回过头查阅了1975年发行的Unix第六版,并在那里找到了使用缓冲区缓存的例子。Linux从一开始就有个缓冲区缓存。在1995年发行的1.3.50版本中,Linus Torval做了一个牛逼创新, 页面缓存。页面缓存与缓冲区缓存的区别在于,它是位于虚拟文件系统(VFS)与文件系统本身之间。有了页面缓存,如果所需的页面已经存在,则根本无需调用文件系统的代码。起初,页面缓存和缓冲区缓存是完全独立的,在1999年,Ingo Molnar统一了它们。现在,缓冲区缓存仍然存在,但是内容是指向页面缓存。

页面缓存有非常多的内置功能。明显的如通过给定的索引查找页面;如果页面不存在,则创建并选择从磁盘填充。脏页可以刷回磁盘,页面可以被锁定,解锁,以及从缓存中删除。线程可以等待页面状态的变更,同时有以给定状态搜索页面的接口。页面缓存也能够追踪与持久化内存相关的错误。

页面缓存内部处理锁机制。在内核社区,应该在哪层之上处理锁存在分歧,但是内部实现锁是肯定的。当页面缓存被修改时,有个自旋锁以控制其访问;而查找则通过无锁的读-拷贝-更新(RCU)机制来处理。

缓存是预测未来的艺术,他说。当缓存增长太大时,各种启发算法开始决策哪些页面应当被移除。仅使用了一次的页面很可能不会被再使用,因此它们将保留在“不活跃”链表并相对较快的换出。第二次使用将会把页面从不活跃链表提升到活跃链表。活跃链表的页也会因为超时而被移到不活跃列表。 有一个例外,“影子”条目用于追踪已脱离不活跃链表并已回收的页面,这些条目可以延长相对遥远的过去使用过的页的生命周期。

一段时间以来,大页一直作为页面缓存的一个挑战。内核的透明大页(THP)特性最初只用于匿名(非文件后端)内存,尽管在页面缓存中使用大页有很多优点。该领域的最开始的工作就是简单地往页面缓存中增加了大量单页条目,以对应于单个大页。Wilcox认为这种方法是“愚蠢的”,他增强了用于追踪页面缓存中页面的基数树代码,以能够直接处理大页条目。待提交的patch将使得页面缓存可使用单个条目对应大页。

我们是否仍然需要页面缓存?
近期,Dave Chinner预测将不再需要页面缓存。他指出,最初由Wilcox创建的DAX系统,支持直接访问持久化内存,完全绕过页面缓存。Wilcox说,“没有什么比你的同事怀疑你的整个动机更糟糕”。但也有其他人不同意Chinner,包括Torvalds。Torvalds在一个单独的论坛指出页面缓存很重要,因为在数据访问的关键路径上,好的东西不是来自低层的文件系统代码。
在最后,Wilcox深入讲了IO请求在DAX系统上如何工作。他在设计DAX原始代码的时候, 没有使用缓存。但是这个决定是错误的。

在当前的内核中,当一个应用使用类似于read()的系统调用从存储在持久化内存中的文件读取数据时,DAX介入。由于请求的数据不存在于页面缓存中,VFS层调用文件系统特定的read_iter()函数。这反过来调用到DAX代码,它将回调到文件系统将文件偏移转换为块号,然后查询块层以获取持久化内存块的位置(如果需要,将其映射到内核地址空间),最终使得块内容可以被拷贝回应用。

这“不可怕”,但理应以另外一种方式工作,他说。初始步骤应当相同,因为read_iter()函数仍将被调用,同时它将调用到DAX代码。但是,DAX不是回调到文件系统,而是应当调用到页面缓存以获取文件中所需偏移关联的物理地址,然后数据从该地址拷贝到用户空间。虽然这一切都假定信息已经存在页面缓存中,但在这种情况下,低层文件系统代码完全无需介入。文件系统已经完成了这项工作,同时页面缓存也缓存了结果。

当Torvalds写了上述关于页面缓存的帖子时,他说:

从锁定角度来看,这也是一个重大的灾难:相信我,如果你认为你的文件系统可以进行细粒度的锁定,当诸如并发路径查找的事情到来时,你会生活在一个梦幻世界。

这是“如此正确”,Wilcox说。DAX中的锁定的确是灾难性的。他最初认为可能用相对简单的锁定来解决,但复杂性在每个新发现的边缘场景蔓延。DAX锁定现在“实在丑陋”,他很抱歉他犯了一个错误,认为可以绕过页面缓存。现在,他说他必须去解决。

未来的工作
他想重新考虑文件系统块大小大于系统页面大小的想法,这是人们多年想要的东西。现在页面缓存可以处理多个页面大小,应该是可行的。“一个简单的编码问题”,他说。他正在找寻其他感兴趣的开发人员一起来做这个项目。

巨大的交换条目也是感兴趣的领域。我们在内存中有大量的匿名页面,但当换出时,他们被分解成正常页面。“这可能是错误的答案”。目前有提升交换性能的工作,但需要重新调整以保持大页在一起。这可能有助于交换到持久化内存的关联想法。持久化内存交换空间中的数据仍然可以被访问,因此将其留在那可能是有意义的,尤其是没有被大量修改。

The Machine: Controlling storage with a filesystem
HPE的新机器:The Machine HPE即HP Enterprise。之前的LinuxConf上,来自HPE的Keith Packard向大家展示过他们正在开发中的名为The Machine的新架构机器。而本次2017的北美LinuxConf上,Keith Packard着重展示了这个新机器中存储系统管理方面的一些特点,和The Machine本身一样,这一类的系统往往是几个新主意加上若干早已存在的成熟技术拼接而成。

简单介绍一下The Machine(详细的介绍可以直接看https://lwn.net/Articles/655437/, A look at The Machine)。The Machine差不多是一种针对传统服务器的Rethink。基础想法是传统服务器是以cpu为中心的,把数据从各种外存零散地传送到分散在处的cpu上加以处理,而The Machine则是要以数据为中心—-在拓扑结构的中央放置一个非常高速的庞大持久化内存(计划中一台机器最终会达到300TB内存,目前的实现全是DRAM,未来要切换到真的persistent memory上)。庞大内存的内部靠光通信同步,因此严格从物理意义上看它显然还是NUMA的,但不同node之间的速度差异已经足够小,程序员可以认为主存速度是均一的,即UMA。庞大的主存区域外围环绕着大量cpu,目前用的是ARM64,一台The Machine计划装80个ARM64。

由于每一个cpu都具有通过Load/Store指令直接访问持久化主存的能力,传统的文件系统和磁盘、块设备抽象就不再需要了,每一个cpu都直接面对自己要处理的数据的唯一拷贝。Packard把这种设计叫做”memory-drivencomputing“,内存驱动计算。

The Machine上的众多cpu们对于这片大家共享的主存区域有各种管理需求,其中比较重要的一点是The Machine的各个cpu不被认为是可信的,因此它们每个人能访问到的主存区域都各有限制。Packard承认最初他完全从头设计了一套API,但很快就意识到这个API和传统的POSIX式文件接口非常相似。索性就直接用文件系统抽象来管理这个庞大的主存区域了。

于是Packard用FUSE实现了一个使用POSIX文件式风格管理内存的系统,称为LFS(LIbrary Filesystem),它把主存分成若干8GB片来管理,目前每台The Machine有300TB内存,所以一共也就是四万左右的分片需要照顾,元数据量 并不大。谈到分片,Packard也承认可以用LVM2来管理它们,但是从头设计一个LFS有额外的好处。比如各种接口比起用lvm tools来要简洁得多:touch文件意味着建一个新的volume set、fallocate意味着要做实际的空间分配了,等等。

LFS直接面对主存,下边不再有块设备等等的抽象。这使得它不能使用一些传统的组件,比如软RAID。Packard认为这可以接受:软RAID在直接读写持久化内存的场景下没有需求。使用文件系统抽象的一大挑战是:你要如何表示那些传统上通过NUMA API来控制的东西,例如程序员知道这个文件是放在主存上的,也知道主存是NUMA的,他想指定这个文件一定要放在某几个结点上。POSIX API没有这样的语义,Packard选择了使用XATTR来表达这些需求。

https://github.com/FabricAttachedMemory 提供了一个The Machine模拟器供有兴趣的读者进一步深入研究。

kvmalloc()
内核提供有两种基础的内存分配机制,一种是slab分配器,用于在内核自己的地址空间分配物理地址连续的内存,使用这种分配器的典型代表是kmalloc。另一种是vmalloc,用于在一个独立的地址空间分配虚拟地址连续但物理地址可能不连续的内存。

slab分配器几乎是大部分内存分配首选,在没有内存压力的时slab分配器不用修改地址空间从而更加快速。slab分配器最适合的是小于一个物理页大小内存的分配;当内存碎片化之后,物理地址连续的内存页会变得很难查找,系统性能也会由于分配器要不断的合并出物理地址连续的页而变差。

vmalloc分配的内存不要求物理地址连续,因此在内存紧张时更容易成功。但多方面原因也导致过度使用vmalloc会有很多阻碍:1)每次vmalloc分配完成需要更新页表,并刷新tlb; 2)vmalloc只能够分配整个物理页,因此也不适合小内存分配;3)在32位系统上,vmalloc分配的地址范围是有限的(但是在64位系统上目前已经没有这个限制)。

内核里面有很多地方必须要求分配物理地址连续的大内存,但更大部分其实并不没有这个要求。对于不要求物理地址连续的分配请求,其实并不关心是使用kmalloc或者vmalloc分配,只要能够分配就ok。对这一类的请求,可以先尝试从slab分配器分配,如果失败再回退到使用vmalloc。内核里面也确实有很多不一样的代码在做这同样一件事。

然而,就像Hocko指出的一样,这其中一些代码很有”创意”,但很多代码并不如他们期望的一样工作。考虑如下代码:

memory = kmalloc(allocation_size, GFP_KERNEL);
if (!memory)
memory = vmalloc(allocation_size);
这段代码的问题在于,对于小内存(小于等于8个物理页)的分配,kmalloc会一直重试,而不是返回失败。这种情况下,回退到vmalloc的这段代码并不会执行。更糟糕的是,kmalloc为了满足分配请求甚至可能调用oom killer杀死一些未预料的线程。kmalloc的这种机制在某些情况下确实是有必要的,但是上面这段代码,这个并不在这些情况里面。

这就需要有一组接口能够使用这种回退机制,又同时能够最小化这种回退机制带来的影响。最终Hocko的一组patch增加了以下几个新接口函数:

void *kvmalloc(size_t size, gfp_t flags);
void *kvzalloc(size_t size, gfp_t flags);
void *kvmalloc_node(size_t size, gfp_t flags, int node);
void *kvzalloc_node(size_t size, gfp_t flags, int node);
正如大家期望的,kvmalloc首先尝试从slab分配器分配内存,通过使用__GFP_NOWARN和__GFP_NORETRY标志尽量减小在不能立即分配到内存情况下的影响(也避免调用oom killer)。如果从slab分配器尝试分配内存失败,kvmalloc会回退到用vmalloc分配。kvzalloc会在分配到的内存返回之前进行清零操作;_node结尾的接口用于从指定NUMA节点分配内存。和其他内存分配函数一样,这些函数也依旧可能失败。

这组函数使用上有以下两点需要注意:1) 使用这组函数分配小于一个页的内存没有任何意义,回退路径里面的vmalloc并不支持小于一个页内存的分配,因此回退路径在这种情况下会失效;2) 这组函数在原子上下文中工作会有问 题,原因是由于vmalloc不能在原子上下文中调用。
历史上Changli Gao在2010年的时候也提交过一个版本的patch尝试在内核里面增加kvmalloc,但是由于没有考虑到这些不可预期的负面影响,并没有被合并。Hocko似乎找到了让这组patch进入主线的方法,最终让这组实现进入了内核

Last-minute control-group BPF ABI concerns
主线4.10中一个称之为cgroup-BPF的特性被合并,这个新的特性可以将一个BPF(Berkeley Packet Filter)程序附加(attach)到cgroups中,该BPF程序可以通过cgroups中的进程对其接受和发送的包进行过滤。该特性就本身而言并没有太大的争议,并且直到最近,其接口和语义仍然没有较大争议。但自从这个特性被合并,出现了一些不同的声音。开发社区可能不得不决定是否对其进行调整,或者在4.10发布之前临时关闭这个功能。

相关问题的讨论最早是由Andy Lutomirski发起的,第一个问题是:bpf()系统调用被用于附加(attach)一个程序到cgroups,他认为这个系统调用从根本上来说是个cgroups操作而非BPF操作,所以应该通过cgroups的接口进行处理。如果将来其它开发者引入其它类型的程序(非BPF程序),那么仍然沿用bpf()接口将会失去其本来的意义。不管怎么说,他认为bpf()并不是一个足够灵活的系统调用。

这个异议并没有引起广泛关注,看起来并没有多少开发者对添加其它的包过滤机制感兴趣。BPF的开发者Alexei Starovoitov认为其他的机制也可以很容易的基于BPF实现。网络的maintainer David Miller在这个问题上完全同意Starovoitov,所以对于这一点来说,不会有太大的改变。

下一个问题牵扯的更深一些。Cgroups是天然的层级结构的,对于version 2的cgroups接口,用户期待控制器的行为是层级管理的。控制器规则一般来说会下沉到层级的下一级,比如:如果某个cgroup被配置为CPU占用率为10%,那么其子组被配置为占用50%,这意味着其50%是基于其父亲组的10%的一半,也就是说系统绝对资源的5%。BPF过滤器机制并非一个完全的控制器,但他的层级管理行为也会受到关注。

如果程序运行在一个两层cgroups层级中,并且上下两层均有过滤程序被附加(attach),通常认为这两个过滤器都是可以运行的,即两个过滤器的约束条件都是有效的。但事实并非如此,取而代之的是只有低层级过滤器程序在运行,而高层级对应的过滤器则被忽略。上层过滤器打算阻止某些特定类型的通信,但底层的过滤器却重载了这些限制,使其上层的限制无法生效。如果所有层级的过滤器设置都是一个管理员完成的,这个语义可能并不会带来问题,但如果系统管理员希望设置整个容器和用户名字空间,而容器可以添加自己的过滤程序,那么这个行为将使得系统级的设置被旁路。

Starovoitov承认这个问题,最差情况也就是针对给定的层级结构,采用一个组合所有过滤规则的程序。但是他同时认为“当前的语义与设计是一致的”,并且说不同的行为可以在未来实现。问题是如果将来更改语义,会引入ABI的更改,这类更改会打破目前在4.10上的语义,是的系统出现问题,这种更改是不允许的。如何以兼容的方式添加新的语义,目前没有相应的计划,因此我们不得不假设:如果4.10按照当前的行为发布,未来将没有人能够改变它。

其他的开发者(Peter Zijlstra and Michal Hocko)也表达了对这个行为的顾虑。Zijlstra询问cgroups的maintainer Tejun Heo对这个问题的想法,但并没有获得更多信息。Starovoitov确信当前语义没有任何问题,并且可以在未来不打破兼容的情况下进行调整。

Lutomirski的顾虑则更加模糊。直到现在为止,cgroups都是用于资源控制,附带的BPF过滤器的引入则改变了这个规则。这些程序可以作为攻击者运行一些恶意代码。例如:他们可以在输入的协助下介入到一个setUID程序,产生潜在的权限问题。有些程序隐藏的有用信息也会被攻击者发现。

对于现在来说,捆绑一个网络过滤器程序是一个特权操作,所以暂时并不是个大问题。但一些人试图使过滤程序工作在用户命名空间,这将带来更多问题。Lutomirski提出了“未完成提案”,该提案可以阻止创建“危险的”cgroups,除非将来相应的问题得到解决。
再次重申,降低未来系统的风险,需要在最开始就要施加相应限制,这将暗示这个特性需要在4.10发布的时候被关闭。但是Starovoitov之前同意在安全领域展开工作,他再次重申这些问题会在未来某个时间点完成。

这是到目前为止的所有讨论。如果这些讨论没有结论,4.10将要如期发布这个新特性,即便关于ABI和安全的顾虑仍然存在。对于新的API发布时仍然有类似的未回答的问题,历史上有一些教训的。这里给出的结论,仅仅是希望BPF的开发者可以发现和定位语义和安全问题并且不会产生ABI兼容问题。

Making sense of GFP_TEMPORARY
本文主要试图描述 linux 内核内存分配标记 GFP_TEMPORARY 语义的合理性,以及现状。

本文翻译到此也许可以结束了,因为,几乎没有人看到 GFP_TEMPORARY 会联想到背后一丝实际的语义。老实说我以前没关注过这个 flag,也从来没用过,居然还被 mainline 了,不过这就是 linux 的世界。

我们都知道,linux 内核在分配内存的时候一般会通过指定 flags (linux/gfp.h) 来告诉分配器如何处理不同情况(比如:是否分配器可以阻塞),那么像之前的文章里提到的有些 flags 是 common 的有些是 by design 的。

相比 GFP_KERNEL,GFP_TEMPORARY 仅仅增加了__GFP_RECLAIMABLE,也就是说增加了内存页可以回收的标记 (顾名思义了),而这之前__GFP_RECLAIMABLE 主要是被 slab/slub 间接标记的 (SLAB_RECLAIM_ACCOUNT)。

为啥仅仅增加 __GFP_RECLAIMABLE 就变成了 GFP_TEMPORARY?Who knows?
讨论中有些声音是赞成 GFP_TEMPORARY 的,我觉得其实不是赞成 GFP_TEMPORARY 而是觉得 __GFP_RECLAIMABLE 会产生更多潜在的可回收的内存分配,在系统整体上有利于满足高阶内存分配。即使这样,起码换个名?

最后,GFP_TEMPORARY 即将被 Michal Hocko 在后续的 patch 中拿掉,这下真的呼应了 “TEMPORARY” 。

BTW,大家在实际的开发中,本着对代码负责,定义变量也是最好能合理的体现它的语义,关于变量名定义的语义表达,也许够给很多程序员开个专题了。

A pair of GCC plugins
这些年来,很多gresecurity/PaX内核的加固特性由于内核自保护项目的贡献进入了内核主线中。其中之一是4.8内核引入的GCC插件基础架构,该特性在内核代码编译时引入各种类型的保护。还有其他很多特性引入到4.9内核代码中,比较重要的是latent_entropy插件。此外最近两个引入的插件是kernexec来组织内核执行用户空间代码和清理从用户空间拷贝数据结构的structleak插件。

kernexec
攻击者经常会通过引诱内核执行一些用户态代码来攻击系统。通过这一方式,攻击者可以以内核权限来执行他自己的代码。为了防止这个问题的发生,英特尔和ARM的CPU上分别实现SMEP和PAN技术来保护系统。

但是对于那些没有SMEP的英特尔CPU来说,kernexec可以提供相同的保护功能。一月中旬,Kees Cook提交了第一版kernexec插件的代码。该插件通过将内核代码所在地址的最高比特位置位来实现保护功能。所有内核地址空间中的函数地址的最高比特位被置位后,系统在调用函数前会检查最高比特位是否置位。因为用户态函数内存地址的最高比特位没有置位,因此系统就可以发现内核将要执行用户态代码,并生成一个通用保护错误。

添加内核加固特性后的性能开销总是被特别的关注。为了优化性能,插件会尝试优化函数调用和返回指令。在函数调用过程中,使用一个寄存器并进行或运算来实现最高比特位的检查。在函数返回时,使用btsq指令来检查返回地址的最高比特位。

Cook特别注明当前的插件还不能支持内核汇编代码的部分。也就是说汇编代码仍然可以调用和返回到用户态内存地址上。

structleak
内核结构(或包含在其中的字段)经常被复制到用户空间。如果这些结构不进行初始化,它们可能包含一些“interesting”的值,而这些值是存在于内核内存中。如果攻击者可以安排这些值与内核结构对齐,并将它们复制到用户空间,最终就会导致内核信息泄漏。cve-2013-2141就是这种类型的信息泄露;这也就促使“PaX Team”(Pax补丁集的作者)创建structleak插件。

Cook还在1月13日发布了该插件的端口到内核邮件列表。它会在函数里的局部变量结构中查找__ser属性(这是一个注释,用来表示用户空间指针)。如果这些变量没有被初始化(因此也可能会包含堆栈“垃圾”),插件就会清理它们。这样的话,如果这些值在某个时候被复制到用户空间,也不会有内核内存内容的暴露。

PaX Team在补丁发布中也进行了评论,不过大多是建议调整一些插件的文本说明。特别是,Cook已经改变了在Kconfig描述的插件描述。然而,Cook对于那些变化有合理的理由。

此外,一个Kconfig选项用来打开structleak详细模式的(gcc_plugin_structleak_verbose)不符合PaX Team的标准。需要指出的是,可能会发生误报,这是因为“不是所有现有的初始化由插件检测“,但PaX Team对此表示反对:“一个变量要么有一个构造函数要么没有”。但Cook不这么认为:

正如指出的那样,在[插件]报告需要初始化变量时有大量的误报。它并没有报告说,缺少一个构造函数。 这是对正在发生的事情进行一个务实的描述,因为插件有时在不需要的地方确实没有必要初始化,那对我来说真的是一个假阳性。

除了选项的问题,正如Mark Rutland指出的,这__user注释并不是一个真正的迹象用于表明有问题:

对我来说,似乎__user注释只能是发生偶然问题的一个指标。我们有__user指针结构,而这些结构永远将不会被复制到用户空间,相反我们有这样的结构,它们不含__user注释,但将被复制到用户空间。

他建议,分析 copy_to_user() 的调用可能会有更好的检测。PaX Team也表示同意,但同时也说,最初的想法是要找到一个简单的模式匹配来消除cve-2013-2141和其他类似的错误。既然错误已经消除了,插件是否还有问题还不清楚,但没有理由不保持它,PaX Team说:“我把这个插件放在这里是因为维护无需成本,并且替代它(更好的)解决方案还不存在。”

这些都是相当简单的功能,可以防止内核错误被攻击者使用。从这点来讲,structleak可能并不真正被需要,但新的代码可能引进一个相似的问题,而没有特定的插件用于解决这些问题。另一方面,Kernexec有潜力去阻止那些依赖于内核执行用户空间代码的攻击。现在这两个插件已经存在了一段时间,让它们进入upstream,这样就能使发布者开始建立他们的内核,从而使他们手中有更多Linux用户,这会是一件好事。希望我们会看到有些人使它们很快进入主线。

Unscheduled maintenance for sched.h
在内核的发展过程中,对头文件的维护远没有像C文件那般重视。4.10内核包含18,407个头文件,只有不到10,000个头文件会在特定子系统外部被引用。然而,在内核0.01版本,总共才31个头文件。以为例,4.10中该文件达到3,674行,还直接引用50个头文件,而这其中的许多头文件又会进一步引用其它头文件。

臃肿的头文件会降低内核编译的速度。假如sched.h冗余1000行代码,被2500个文件引用,则内核编译阶段需要多处理250万行代码,严重影响编译速度。同时,臃肿的头文件也难以维护。
Ingo Molnar, CPU调度器的主要维护者,决定以sched.h为切入点,开启精简头文件的工作。主要方法是将sched.h中的数据结构和函数接口分类,拆分成多个更小的头文件。这是一个繁琐的工作,许多引用sched.h的文件都需要重新引用新的更细粒度的头文件。整个工作下来,涉及将近1200个文件的修改。

工作虽辛苦,但效果显著,重新整理sched.h后, all-yes-config kernel build节省了30秒。目前这部分工作由于patch数量多,review不便,还没有决定在哪个窗口期进入主干分支。可以肯定的是,将来内核头文件将会变得更清爽,后续还会对其他臃肿的头文件进行改造,如

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
YOLO高分设计资源源码,详情请查看资源内容中使用说明 YOLO高分设计资源源码,详情请查看资源内容中使用说明 YOLO高分设计资源源码,详情请查看资源内容中使用说明 YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明YOLO高分设计资源源码,详情请查看资源内容中使用说明

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值