首页 / 资讯中心 / 文章详情

Linux 内核揭秘:中断与中断处理(一)——APIC、向量号、IDT 与中断栈的硬件基础

Linux 内核揭秘:中断与中断处理(一)——APIC、向量号、IDT 与中断栈的硬件基础 ★ FEATURED ARTICLE
文档教程操作系统【免费下载链接】linux-insides-zhLinux 内核揭秘项目地址https://gitcode.com/gh_mirrors/li/linux-insides-zh点击查看免费下载本文是《Linux 内核揭秘》中断与中断处理系列的第一部分。在前面的初始化章节中我们一路从内核解压后的首个指令跟踪到第一个init进程启动见识了大量与子系统相关的初始化步骤但始终没有深入这些子系统内部。本篇将正式开启中断这个内核中至关重要的子系统先厘清中断Interrupt、异常Exception与中断处理程序Interrupt Handler的概念再逐位剖析x86_64架构下中断描述符表IDT的硬件结构、IST中断栈表机制以及内核如何借助 per-CPU 中断栈安全地完成上下文切换。读完本文你将掌握中断向量号的分配规则、IDT 门描述符的二进制布局、IRQ_STACK_SIZE与THREAD_SIZE的计算方法并能在后续章节中看懂 Linux 内核处理真实硬件中断的完整代码路径。从初始化章节进入中断世界在初始化系列中我们从内核初始化的第一步出发一路见证了大量初始化步骤最终以第一个init程序的启动收尾。但那十篇文章的侧重点是初始化本身对于各子系统如何工作、如何实现我们几乎没有深入。本篇作为中断主题的开篇会从两个最基本的问题入手什么是中断interrupts什么是中断处理程序interrupt handlers然后逐步深入中断的细节以及 Linux 内核处理这些中断的方式。事实上中断在前面的章节中已经多次出现。比如在内核初始化第二部分中我们看到了一个向 IDT 填入前 32 个异常处理程序的循环for (i 0; i NUM_EXCEPTION_VECTORS; i) set_intr_gate(i, early_idt_handler_array[i]);这段代码位于arch/x86/kernel/head64.c它正是内核解压后、start_kernel运行前搭建早期异常处理骨架的地方。本篇的理论内容恰好就是理解这段代码为什么这样写的基础。什么是中断中断就是当软件或者硬件需要使用 CPU 时引发的事件event。例如当我们在键盘上按下一个键时操作系统和电脑接下来应该做什么一个简单的假设是每一个物理硬件都有一根连接 CPU 的中断线设备可以通过它对 CPU 发起中断信号。但中断信号并不会直接发送给 CPU。在老式机器上中断信号发送给 PICProgrammable Interrupt Controller可编程中断控制器——一个顺序处理各种设备中断请求的芯片。而在新式机器上这项工作由APICAdvanced Programmable Interrupt Controller高级可编程中断控制器完成。一个 APIC 由两个独立设备组成Local APICI/O APICLocal APIC存在于每个 CPU 核心中负责处理特定于 CPU 的中断配置常被用于管理来自 APIC 时钟APIC-timer、热敏元件以及其他与 I/O 设备相连设备的中断。I/O APIC则提供多核处理器的中断管理它被用来在所有的 CPU 核心中分发外部中断。Local APIC 与 I/O APIC 的更多细节将在本系列后续部分展开。中断可以在任何时间发生。当一个中断发生时操作系统必须立刻处理它。所谓处理一个中断意味着内核必须确保以下步骤顺序内核必须暂停执行当前进程取代当前任务内核必须搜索中断处理程序并转交控制权执行中断处理程序中断处理程序结束之后被中断的进程能够恢复执行。当然实际的中断处理过程中还交织着大量错综复杂的细节但上面三条就是这个过程的基本骨架。中断向量号与中断描述符表IDT每个中断处理程序的地址都保存在一个特殊的位置这个位置被称为中断描述符表Interrupt Descriptor Table简称IDT。处理器使用一个唯一的数字来识别中断和异常的类型这个数字被称为中断向量号vector number。向量号同时也是IDT的索引其取值范围是有限的从0到255。在 Linux 内核源码中可以找到对向量号范围的检查代码BUG_ON((unsigned)n 0xFF);这段检查出现在中断门设置的相关位置例如set_intr_gate、set_system_intr_gate等函数中见arch/x86/include/asm/desc.h。向量号的空间分配规则如下0到31共 32 个向量号被处理器保留用作处理架构定义的异常和中断。完整的异常向量表及其类型、错误码、来源的说明可以在内核初始化第二部分中找到——那里列出了#DE除零、#BP断点、#DF双重错误、#PF缺页等 0~31 号异常的定义。32到255的向量号被设计为用户定义中断不被系统保留。这些中断通常分配给外部 I/O 设备使设备可以向处理器发送中断。中断的类型笼统地讲可以把中断分为两个主要类型外部或硬件引起的中断软件引起的中断。第一种类型外部中断由Local APIC或者与Local APIC连接的处理器针脚接收。第二种类型软件引起的中断由处理器自身的特殊情况引起有时使用特殊的架构指令。一个常见的特殊情况例子就是除零另一个例子是使用系统调用syscall退出程序。中断可以在任何时间、因为超出代码和 CPU 控制的原因而发生与之相对异常与程序执行是同步synchronous的并且可以被分为 3 类类型说明故障Faults在执行一条不完善的指令可以在之后被修正之前被报告的异常。如果发生它允许被中断的程序继续执行。陷入Traps在执行了陷入指令后立刻被报告的异常。与故障一样陷入也允许被中断的程序继续执行。终止Aborts从不报告引起异常的精确指令的异常并且不允许被中断的程序继续执行。另外我们在内核启动过程的第三部分中已经知道中断可以分为可屏蔽的maskable和不可屏蔽的non-maskable。可屏蔽中断可以被阻塞使用的x86_64指令是sti和cli。在 Linux 内核源码中可以找到它们static inline void native_irq_disable(void) { asm volatile(cli: : :memory); }以及static inline void native_irq_enable(void) { asm volatile(sti: : :memory); }这两个指令修改中断寄存器RFLAGS中的IF标志位sti设置IFcli清除IF。不可屏蔽中断则总是被报告——通常任何硬件上的失败都被映射为不可屏蔽中断。中断优先级如果多个异常或中断同时发生处理器会按照事先设定好的中断优先级处理它们。下表列出了从最低到最高的优先级数字越小优先级越高---------------------------------------------------------------- | | | | Priority | Description | | | | --------------------------------------------------------------- | | Hardware Reset and Machine Checks | | 1 | - RESET | | | - Machine Check | --------------------------------------------------------------- | | Trap on Task Switch | | 2 | - T flag in TSS is set | | | | --------------------------------------------------------------- | | External Hardware Interventions | | | - FLUSH | | 3 | - STOPCLK | | | - SMI | | | - INIT | --------------------------------------------------------------- | | Traps on the Previous Instruction | | 4 | - Breakpoints | | | - Debug Trap Exceptions | --------------------------------------------------------------- | 5 | Nonmaskable Interrupts | --------------------------------------------------------------- | 6 | Maskable Hardware Interrupts | --------------------------------------------------------------- | 7 | Code Breakpoint Fault | --------------------------------------------------------------- | 8 | Faults from Fetching Next Instruction | | | Code-Segment Limit Violation | | | Code Page Fault | --------------------------------------------------------------- | | Faults from Decoding the Next Instruction | | | Instruction length 15 bytes | | 9 | Invalid Opcode | | | Coprocessor Not Available | | | | --------------------------------------------------------------- | 10 | Faults on Executing an Instruction | | | Overflow | | | Bound error | | | Invalid TSS | | | Segment Not Present | | | Stack fault | | | General Protection | | | Data Page Fault | | | Alignment Check | | | x87 FPU Floating-point exception | | | SIMD floating-point exception | | | Virtualization exception | ---------------------------------------------------------------中断描述符表IDT的内部结构了解了各类中断与异常之后就可以进入更实用的部分了。IDT 保存了中断和异常处理程序的入口指针它是一个类似于**全局描述符表Global Descriptor TableGDT**的结构——GDT 我们在内核启动过程的第二部分已经介绍过那里展示了如何通过lgdt指令把 GDT 的基址和大小装入 48 位的GDTR寄存器。IDT 与 GDT 有一个关键差异IDT 的表项被称为门gates而不是描述符descriptors。一个门可以是下面三种之一中断门Interrupt gates任务门Task gates陷阱门Trap gates在x86架构中只有 long mode长模式下中断门和陷阱门才可以在x86_64中被引用。与 GDT 类似中断描述符表在x86上是 8 字节数组门而在x86_64上是 16 字节数组门。回忆内核启动过程的第二部分全局描述符表必须包含NULL描述符作为它的第一个元素。与 GDT 不同的是IDT 的第一个元素可以是一个门这并非强制要求。例如早期章节在过渡到保护模式时只是用NULL门加载过 IDT/* * Set up the IDT */ static void setup_idt(void) { static const struct gdt_ptr null_idt {0, 0}; asm volatile(lidtl %0 : : m (null_idt)); }这段代码位于arch/x86/boot/pm.c。IDT 可以被加载到线性地址空间和基址的任何地方只要在x86上以 8 字节对齐、在x86_64上以 16 字节对齐。IDT 的基址存储在一个特殊的寄存器——IDTR中。在x86上有两条指令协同修改IDTR寄存器LIDT——加载 IDT 的基址到 IDTR 的指定操作数SIDT——在指定操作数中读取并存储 IDTR 的内容。在x86上IDTR 寄存器是 48 位的包含下面的信息--------------------------------------------------------- | | | | Base address of the IDT | Limit of the IDT | | | | --------------------------------------------------------- 47 16 15 0注意上面setup_idt中的null_idt是gdt_ptr类型其定义如下struct gdt_ptr { u16 len; u32 ptr; } __attribute__((packed));这与 IDTR 结构的示意完全吻合由 2 字节len即 Limit和 4 字节ptr即 Base address两个域组成共 48 位。这里也解释了为什么引导阶段用gdt_ptr来承载一个空的 IDT——两者在硬件寄存器层面的布局完全一致。IDT 门条目的二进制布局在x86_64中IDT 的每个入口是一个 16 字节的门结构如下127 96 ------------------------------------------------------------------------------- | | | Reserved | | | -------------------------------------------------------------------------------- 95 64 ------------------------------------------------------------------------------- | | | Offset 63..32 | | | ------------------------------------------------------------------------------- 63 48 47 46 44 42 39 34 32 ------------------------------------------------------------------------------- | | | D | | | | | | | | Offset 31..16 | P | P | 0 |Type |0 0 0 | 0 | 0 | IST | | | | L | | | | | | | ------------------------------------------------------------------------------- 31 16 15 0 ------------------------------------------------------------------------------- | | | | Segment Selector | Offset 15..0 | | | | -------------------------------------------------------------------------------处理器把异常和中断向量号作为索引去查找对应的中断描述符表条目其处理方式类似于它看到call指令时处理一个程序调用。IDT 条目的各个域含义如下0-15bits —— 段选择器偏移处理器用它作为中断处理程序的入口指针基址16-31bits —— 段选择器基址包含中断处理程序入口指针即目标代码段选择子通常是内核代码段__KERNEL_CSIST—— 在x86_64上的新机制下面会专门介绍DPL—— 描述符特权级P—— 段存在标志48-63bits —— 中断处理程序基址的第二部分Offset 31..16 之外的高位区64-95bits —— 中断处理程序基址的第三部分Offset 63..3296-127bits —— CPU 保留位。其中Type域描述了 IDT 条目的类型对应三种不同的中断处理程序中断门Interrupt gate、陷阱门Trap gate、任务门Task gate。在 Linux 内核源码中中断描述符表用gate_desc数组描述extern gate_desc idt_table[];gate_desc的定义如下x86_64分支#ifdef CONFIG_X86_64 ... ... ... typedef struct gate_struct64 gate_desc; ... ... ... #endifgate_struct64的定义为struct gate_struct64 { u16 offset_low; u16 segment; unsigned ist : 3, zero0 : 5, type : 5, dpl : 2, p : 1; u16 offset_middle; u32 offset_high; u32 zero1; } __attribute__((packed));这个结构体把上面示意图中的 16 字节门拆成了offset_low / segment / istzero0typedplp / offset_middle / offset_high / zero1用位域精确对应硬件的位布局。__attribute__((packed))确保结构体不引入任何填充字节。中断门、陷阱门与 IF 标志中断门与陷阱门都包含一个指向中断处理程序的远指针far pointer二者唯一的不同在于 CPU 处理IF标志的方式如果经由中断门进入处理程序CPU 会清除IF标志位这样在当前中断处理程序执行期间CPU 不会响应其他中断只有当前处理程序返回、iret指令执行时IF才会被重新设置。如内核初始化第二部分所述这正是set_intr_gate设置GATE_INTERRUPT类型的用意。ISTx86_64 的中断栈表机制IST即Interrupt Stack Table中断栈表是x86_64中的新机制用来代替传统的栈切换机制。旧式x86架构提供了一种在响应中断时自动切换栈帧的机制IST是这种栈切换模式的修改版——它使能之后可以无条件切换栈并且可以被任何与确定中断关联的 IDT 条目中的中断使能。因此IST并非所有中断都必须使用一些中断可以继续使用传统的栈切换模式。IST机制在任务状态段Task State SegmentTSS中提供了 7 个IST指针。TSS 是一个包含进程信息的特殊结构用来在执行中断或处理 Linux 内核异常时做栈切换每一个指针都被 IDT 中的中断门引用。当发生不可屏蔽中断NMI、双重错误Double Fault等特殊事件时IST提供切换到新栈的能力。x86_64 上可到达 7 个 IST per-CPU 入口其中一些如下DOUBLEFAULT_STACKNMI_STACKDEBUG_STACKMCE_STACK对应内核源码中的定义#define DOUBLEFAULT_STACK 1 #define NMI_STACK 2 #define DEBUG_STACK 3 #define MCE_STACK 4所有被IST切换到新栈的中断门描述符都由set_intr_gate_ist函数初始化。例如内核的默认 IDT 数据def_idts数组static const __initconst struct idt_data def_idts[] { ... INTG(X86_TRAP_NMI, nmi), ... INTG(X86_TRAP_DF, double_fault),其中nmi与double_fault的入口点在arch/x86/entry/entry_64.S中创建idtentry double_fault do_double_fault has_error_code1 paranoid2 read_cr21 ... ... ... SYM_CODE_START(nmi) ... ... ... SYM_CODE_END(nmi) SYM_CODE_END(nmi)中断处理程序的声明则在arch/x86/include/asm/traps.h中给出asmlinkage void nmi(void); asmlinkage void double_fault(void);内核栈、线程栈与 per-CPU 中断栈在x86_64架构中Linux 内核中每一个活动线程都有一个很大的栈其大小由THREAD_SIZE定义#define PAGE_SHIFT 12 #define PAGE_SIZE (_AC(1,UL) PAGE_SHIFT) ... ... ... #define THREAD_SIZE_ORDER (2 KASAN_STACK_ORDER) #define THREAD_SIZE (PAGE_SIZE THREAD_SIZE_ORDER)PAGE_SIZE是4096字节THREAD_SIZE_ORDER的值依赖于KASAN_STACK_ORDER。KASAN_STACK_ORDER又由CONFIG_KASAN内核配置参数决定#ifdef CONFIG_KASAN #define KASAN_STACK_ORDER 1 #else #define KASAN_STACK_ORDER 0 #endifKASan是一个运行时内存调试器。所以如果CONFIG_KASAN被禁用THREAD_SIZE为16384字节4096 2如果该配置选项打开THREAD_SIZE为32768字节4096 3。这块栈空间保存着有用数据只要线程处于活动状态或者僵尸状态。当线程运行在用户空间时内核栈是空的除非thread_info结构详细内容见内核初始化第四部分位于栈空间的底部。活动或僵尸线程并不是栈中唯一的住户——与每一个 CPU 关联的特殊栈也存在于这块空间。当内核在该 CPU 上执行代码时这些栈处于活动状态当执行用户空间代码时它们不包含任何有用信息。每个 CPU 都有特殊的 per-CPU 栈。首先是给外部中断使用的中断栈interrupt stack大小定义如下#define IRQ_STACK_ORDER (2 KASAN_STACK_ORDER) #define IRQ_STACK_SIZE (PAGE_SIZE IRQ_STACK_ORDER)即16384字节未开 KASAN 时。Per-CPU 的中断栈在x86_64架构中使用irq_stack_union联合描述union irq_stack_union { char irq_stack[IRQ_STACK_SIZE]; struct { char gs_base[40]; unsigned long stack_canary; }; };第一个域irq_stack是一个 16KB 的数组。这个联合还包含一个结构体结构体有两个域gs_base—— 总是指向irq_stack_union底部的gs寄存器。在x86_64中per-CPU更多 per-CPU 变量的内容可阅读Concepts/linux-cpu-1.md与 stack canary 共享gs寄存器。所有 per-CPU 标志初始值为零gs指向 per-CPU 区域的开始。虽然段内存模式早已被废除但我们仍可以通过特殊模块寄存器Model Specific RegistersMSR给fs和gs两个段寄存器设置基址使它们继续被用作地址寄存器。在内核初始化第一部分中我们就设置过gs寄存器movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs4(%rip),%edx wrmsrinitial_gs指向irq_stack_unionGLOBAL(initial_gs) .quad INIT_PER_CPU_VAR(irq_stack_union)stack_canary—— Stack canary栈金丝雀对中断栈来说是一个用来验证栈是否被修改的栈保护者stack protector。gs_base是 40 字节的数组GCC 要求 stack canary 位于被修正过的偏移量上在x86_64架构上gs的偏移必须是40在x86架构上必须是20。irq_stack_union是percpu区域的第一份数据我们可以在System.map中看到它0000000000000000 D __per_cpu_start 0000000000000000 D irq_stack_union 0000000000004000 d exception_stacks 0000000000009000 D gdt_page ... ... ...它在代码中的定义是DECLARE_PER_CPU_FIRST(union irq_stack_union, irq_stack_union) __visible;irq_stack_ptr 的初始化除了irq_stack_union在arch/x86/include/asm/processor.h中还能看到下面的 per-CPU 变量DECLARE_PER_CPU(char *, irq_stack_ptr); DECLARE_PER_CPU(unsigned int, irq_count);第一个irq_stack_ptr从名字可知是指向中断栈栈顶的指针第二个irq_count用来检查 CPU 是否已经在中断栈中。irq_stack_ptr的初始化发生在arch/x86/kernel/setup_percpu.c的setup_per_cpu_areas函数中该函数正是 per-CPU 区域的初始化入口相关背景可参考 Concepts/linux-cpu-1.mdvoid __init setup_per_cpu_areas(void) { ... ... #ifdef CONFIG_X86_64 for_each_possible_cpu(cpu) { ... ... ... per_cpu(irq_stack_ptr, cpu) per_cpu(irq_stack_union.irq_stack, cpu) IRQ_STACK_SIZE - 64; ... ... ... #endif ... ... }这里遍历所有可能的 CPU并把irq_stack_ptr设置为中断栈顶减去 64。为什么是 64这与load_percpu_segment位于arch/x86/kernel/cpu/common.c中的栈金丝雀布局有关void load_percpu_segment(int cpu) { ... ... ... __loadsegment_simple(gs, 0); wrmsrl(MSR_GS_BASE, cpu_kernelmode_gs_base(cpu)); ... load_stack_canary_segment(); }我们知道gs寄存器指向中断栈的栈底movl $MSR_GS_BASE,%ecx movl initial_gs(%rip),%eax movl initial_gs4(%rip),%edx wrmsr SYM_DATA(initial_gs, .quad INIT_PER_CPU_VAR(fixed_percpu_data))这里的wrmsr指令把edx:eax指向的数据加载到由ecx指定的 MSR 寄存器中。此处 MSR 是MSR_GS_BASE它保存gs寄存器所指向内存段的基址edx:eax指向initial_gs的地址即fixed_percpu_data的基址。中断发生时的硬件行为当一个中断或异常发生时硬件会执行一系列固定动作新的ss选择器被强制置为NULL且ss选择器的rpl域被设置为新的cpl旧的ss、rsp、寄存器标志RFLAGS、cs、rip被压入新栈在 64 位模式下中断栈帧大小固定为 8 字节因此可以得到下面的栈布局--------------- | | | SS | 40 | RSP | 32 | RFLAGS | 24 | CS | 16 | RIP | 8 | Error code | 0 | | ---------------如果在中断门中IST域不是0就把IST读到rsp中如果该中断向量关联了错误码再把错误码压入栈如果没有错误码就压入一个虚拟错误码——这一步是为了确保栈的一致性接下来从门描述符中把段选择器域加载到CS寄存器并通过验证第21位即 GDT 中L位的值来确认目标代码是 64 位代码段最后从门描述符中把偏移域加载到riprip就是中断处理函数的入口指针中断处理函数开始执行执行结束后必须通过iret指令把控制权交还给被中断的进程。iret无条件地弹出栈指针ss:rsp来恢复被中断的进程且不依赖于cpl的改变。顺带一提早期引导阶段的中断栈帧与此略有出入——由于 CPU 压栈在先在早期通用处理程序early_idt_handler_commonarch/x86/kernel/head_64.S执行前栈上自顶向下依次是错误码、%rip、%cs、%rflags这与内核初始化第二部分中对early_idt_handler_array的objdump反汇编观察完全吻合。与初始化章节的呼应早期 IDT 是怎么搭起来的本篇的硬件理论正好可以解释内核初始化第二部分中那几段关键代码的动机。set_intr_gate宏定义在arch/x86/include/asm/desc.h#define set_intr_gate(n, addr) \ do { \ BUG_ON((unsigned)n 0xFF); \ _set_gate(n, GATE_INTERRUPT, (void *)addr, 0, 0, \ __KERNEL_CS); \ _trace_set_gate(n, GATE_INTERRUPT, (void *)trace_##addr,\ 0, 0, __KERNEL_CS); \ } while (0)BUG_ON正是本篇开头那个BUG_ON((unsigned)n 0xFF)检查的真实出处。随后_set_gate调用pack_gate填充gate_desc结构把 64 位处理程序地址拆成低 16 位PTR_LOW、中间 16 位PTR_MIDDLE和高 32 位PTR_HIGH分别写入offset_low、offset_middle、offset_high并把段选择子设为内核代码段__KERNEL_CS、把ist和dpl置 0、把type设为GATE_INTERRUPT。这与本篇介绍的gate_struct64位域一一对应。早期初始化只填充前NUM_EXCEPTION_VECTORS即 32个表项是因为在初期设置阶段中断处于禁用状态每个表项都指向early_idt_handler_array中生成的通用处理程序它们先压入虚拟错误码与向量号再跳转到early_idt_handler_common。至此IDT 长什么样、为什么这样填就有了完整的答案。总结关于 Linux 内核中断和中断处理的第一部分到此结束。我们初步了解了与中断和异常相关的理论与初始化条件中断的本质与 APICLocal APIC / I/O APIC的中断投递路径向量号 0~255 的分配规则、0~31 保留异常区与 32~255 用户定义区中断与异常的完整分类外部/软件中断、故障/陷入/终止、可屏蔽/不可屏蔽及 10 级中断优先级IDT 与 GDT 的异同、IDTR 寄存器与LIDT/SIDT指令、门条目的 16 字节位布局与gate_struct64结构体IST中断栈表机制、TSS 中的 7 个 IST 指针以及 NMI、双重错误等特殊中断的入口初始化内核栈与 per-CPU 中断栈的大小计算THREAD_SIZE、IRQ_STACK_SIZE、KASAN 的影响、irq_stack_union与 stack canary 的关系以及setup_per_cpu_areas中irq_stack_ptr的初始化中断发生时硬件的完整压栈/取门/iret恢复流程。在下一部分我们将更深入地走进中断和中断处理的真实实现。如果想从结构层面先快速浏览中断描述符表的全貌可以参考本仓库 KernelStructures/linux-kernelstructure-1.md中断主题的完整章节索引见 Interrupts/README.md。赞分享文档教程操作系统【免费下载链接】linux-insides-zhLinux 内核揭秘项目地址https://gitcode.com/gh_mirrors/li/linux-insides-zh点击查看免费下载相关推荐Linux 内核揭秘中断与中断处理入门Part 1——从硬件事件到 IDT 与中断栈Linux 内核揭秘中断与中断处理入门Part 1——从硬件事件到 IDT 与中断栈 导读 中断interrupt是 Linux 内核与外部世界交互的Linux 内核中断与中断处理一从中断理论到 IDT 与 x86_64 中断栈Linux 内核中断与中断处理一从中断理论到 IDT 与 x86_64 中断栈 本文是 linux insides 仓库中「Interrupts and文档教程操作系统如何用Pyfa打造零风险的EVE舰船配置方案如何用Pyfa打造零风险的EVE舰船配置方案 在EVE Online的宇宙中每一次舰船配置错误都可能意味着数百万ISK的损失和任务失败的挫折。Pyfa作为一款文档教程操作系统上一篇图像分类模型的傅里叶频率热图分析Google Research frequency_analysis 模块实战指南下一篇tfjs-converter 夜间测试自动化Cloud Scheduler Cloud Functions Cloud Build 流水线深度解析创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
阅读完成 · 觉得有帮助?
咨询建站