Xv6 手册:陷阱与系统调用

Chapter 4: Traps and system calls

有三种事件会导致 CPU 暂停普通指令的执行,并将控制权转移到处理该事件的特殊代码。

  • 系统调用(system call) :当用户程序执行 ecall 指令请求内核为其做某事时。
  • 异常(exception) :用户或内核指令做了一些非法的事情,比如除以零或使用无效的虚拟地址。
  • 设备中断(interrupt) :当设备发出信号表示它需要关注时,例如磁盘硬件完成读写请求时。

本书使用 陷阱(trap) 作为这些情况的通用术语。

当陷阱发生,执行中的代码需要在稍后能够恢复,且不需要知道具体发生了什么。所以,这种陷阱处理过程需要是透明的;这一点对于设备中断来说尤为重要,因为被中断的代码一般不会预料到会有中断发生。这样确保了程序可以在不被告知具体干扰的情况下继续平稳运行。

通常的顺序是:

  1. 陷阱强制将控制权转移到内核;
  2. 内核保存寄存器和其他状态,以便可以恢复执行原代码;
  3. 内核执行适当的处理代码;
  4. 内核恢复保存的状态并从陷阱中返回;
  5. 原代码从中断的地方继续执行。

在 xv6 中,所有陷阱都在内核进行处理。

  • 对于系统调用,非常自然,因为响应和管理这些调用是内核的职责。
  • 对于中断来说,这样的处理方式也有意义,一方面是因为设备的使用需要被隔离,只有内核有权访问设备;另一方面,内核提供了一个便捷的机制来在多个进程之间共享设备资源。
  • 对于异常,这一做法同样适用,因为 xv6 对来自用户空间的所有异常反应,都是终止出现问题的程序,这样做可以确保系统稳定安全。

通过在内核中统一处理陷阱、中断和异常, xv6 能够有效地维护系统操作的顺畅与安全。

Xv6 的陷阱处理分为四个阶段:

  • RISC-V CPU 采取的硬件操作,
  • 一些为内核 C 代码做准备的汇编指令,
  • 一个决定如何处理陷阱的 C 函数,
  • 系统调用或设备驱动程序服务例程。

尽管三种陷阱类型(中断、异常和系统调用)的共性意味着内核可以使用单一代码路径来处理所有陷阱,但在实践中发现,为来自用户空间的陷阱和来自内核空间的陷阱分别设置独立的处理路径更为方便。

内核中负责处理这些陷阱的代码,无论是用汇编语言还是 C 编写的,通常被称为处理程序。处理程序的第一条指令经常使用汇编语言编写,这部分代码有时被称作向量。

4.1 RISC-V 陷阱机制

每个 RISC-V CPU 都有一组 控制寄存器(control registers) ,内核可以写入这些寄存器来告诉 CPU 如何处理陷阱,也可以读取这些寄存器来了解已发生的陷阱。

riscv.h(kernel/riscv.h)包含了 xv6 使用的定义。

最重要的寄存器的概述:

  • stvec :内核在这里写入其陷阱处理程序的地址;RISC-V 跳转到 stvec 中的地址来处理陷阱。
  • sepc :当陷阱发生时,RISC-V 将 pc 保存在这里(因为 pc 随后会被 stvec 中的值覆盖)。 sret(从陷阱返回)指令将 sepc 复制到 pc 。内核可以写入 sepc 来控制 sret 的去向。
  • scause:RISC-V 使用这个寄存器存储一个数字,描述触发陷阱的原因。
  • sscratch:此寄存器被陷阱处理代码用来辅助操作,在保存用户寄存器之前避免覆盖它们。
  • sstatus :在 sstatus 寄存器中,SIE 位控制是否启用设备中断。如果内核清除了 SIE 位,RISC-V 将延迟设备中断的处理,直到内核再次设置 SIE 位为止。SPP 位则指示陷阱是发生在用户模式还是监督模式,并决定执行 sret 指令时返回到哪种模式。

上述寄存器与在监督模式下处理的陷阱有关,不能在用户模式下被读取或写入。多核芯片上的每个 CPU 都有自己的寄存器集,并且可能有多个 CPU 同时处理一个陷阱。

当 RISC-V 硬件触发一个陷阱时,执行以下操作:

  1. 如果陷阱是设备中断,并且 sstatus 的 SIE 位被清除,则不执行以下任何操作。
  2. 通过清除 sstatus 中的 SIE 位来禁用中断。
  3. 将 pc 复制到 sepc。
  4. 在 sstatus 的 SPP 位中保存当前模式(用户或监督)。
  5. 设置 scause 以反映陷阱的原因。
  6. 将模式设置为监督。
  7. 将 stvec 复制到 pc。
  8. 在新的 pc 处开始执行。

请注意,当陷阱发生时, CPU 不会自动切换到内核页表,不会切换到内核的堆栈,也不会保存除 程序计数器(PC) 之外的任何寄存器。这些任务需要由内核软件来执行。 CPU 在陷阱处理过程中尽量减少工作量的一个原因是为软件提供灵活性。例如,某些操作系统在特定情况下会省略页表的切换以提高陷阱处理的速度。

考虑到这一点,我们可以思考,是否可以省略上述步骤中的任何一个,以便实现更快的陷阱处理。虽然在特定情境下,简化流程可行,但通常,跳过步骤可能带来风险。

  • 例如,假设 CPU 没有切换 pc ,那么来自用户空间的陷阱可能在未停止用户指令执行的情况下进入监督模式。这可能导致用户指令继续运行并破坏用户与内核间的隔离,比如通过修改 satp寄存器 指向一个允许访问所有物理内存的页表,从而造成安全隐患。

因此,至关重要的是, CPU 必须切换到由内核指定的指令地址,即 stvec ,确保正确且安全地处理陷阱。

4.2 来自用户空间的陷阱

Xv6 根据陷阱发生在内核执行还是用户代码执行,采取不同的处理方式。以下是针对用户代码中发生的陷阱的具体处理过程:

当用户程序调用系统调用(通过 ecall 指令)、进行非法操作或遇到设备中断时,可能会在用户空间触发陷阱。从用户空间来的陷阱会先经过高级路径 uservec ,然后进入 usertrap。而在返回时,则通过 usertrapretuserret 来完成。

Xv6 在处理陷阱时面临一个主要的设计限制: RISC-V 硬件在触发陷阱时不会自动切换页表。

这意味着, stvec寄存器 中指定的陷阱处理程序,其地址必须在用户页表中有一个有效的映射,因为陷阱发生时,用户页表当前生效。为了能够继续执行, xv6 的陷阱处理代码之后需要切换到内核页表,内核页表也需要对 stvec 所指向的处理程序的地址有相应映射。

Xv6 引入了一个称为 跳板页(trampoline page) 的特殊页面。这个页面包含了 xv6 的陷阱处理代码 uservecstvec寄存器 指向该代码。跳板页被映射到每个进程的页表中的 TRAMPOLINE 地址,位于虚拟地址空间的顶部,确保它位于程序自身使用的内存区域之上。同时,跳板页也被映射到内核页表中的相同 TRAMPOLINE 地址。

由于跳板页在用户页表中存在,因此可以在监督模式下开始执行陷阱处理。且因跳板页在内核地址空间中也映射在同一地址,陷阱处理程序能够在切换到内核页表后继续运行。

uservec 陷阱处理程序的代码位于 trampoline.S 文件中。当 uservec 开始执行时,所有 32 个寄存器都包含了被中断的用户代码的值。为了在内核返回用户空间之前能够恢复它们,这 32 个值需要被保存到内存中。但将数据存储到内存,需要一个寄存器保存地址,而此时没有通用寄存器可用。

RISC-V 提供了一个名为 sscratch 的特殊寄存器来解决这个问题。

uservec 的开头,使用 csrw 指令将 a0寄存器 的值保存到 sscratch寄存器 中。这样, uservec 就有了一个可以自由使用的寄存器 a0 ,可以用来执行后续的内存操作。

uservec 的任务之一是保存 32 个用户寄存器。

内核为每个进程分配了一页内存来保存一个 trapframe 结构,这个结构中包含了保存这 32 个用户寄存器的空间。由于 satp寄存器 仍然指向用户页表,因此 uservec 需要将 trapframe 映射到用户地址空间中。Xv6 将每个进程的 trapframe 映射到该进程用户页表中的虚拟地址 TRAPFRAME ,这个地址位于 TRAMPOLINE 下方。每个进程的 p->trapframe 指针也指向 trapframe ,但使用的是物理地址,这样内核就可以通过内核页表来访问它。

因此,uservec 将地址 TRAPFRAME 加载到 a0 ,在那里保存所有用户寄存器,包括从 sscratch 读取回来的用户 a0

trapframe 包含了当前进程的内核栈地址、当前CPU的 hartidusertrap 函数的地址和内核页表的地址。 uservec 检索这些值,将 satp 切换到内核页表,并跳转到 usertrap (代码如下)。

usertrap 函数的职责是识别陷阱的起因,进行相应的处理,并执行返回操作。它会修改 stvec 寄存器的值,这样如果陷阱在内核中发生,就会由 kernelvec 而不是 uservec 来处理。接着,usertrap 会保存 sepc 寄存器的值,这个寄存器存储了用户程序的计数器,因为 usertrap 可能会调用 yield 函数,从而切换到另一个进程的内核线程,在这个过程中,sepc 可能会被修改。

接下来,usertrap 会根据陷阱的类型采取不同的行动:如果是系统调用,它会调用 syscall 函数来处理;如果是设备中断,则调用 devintr 函数;如果既不是系统调用也不是设备中断,那么它就是一个异常,此时内核会终止出错的进程。在处理系统调用时,usertrap 会在保存的用户程序计数器上加 4 ,因为 RISC-V 在系统调用时会将程序指针指向 ecall 指令,但用户代码需要从紧随其后的指令处继续执行。

在退出前,usertrap 还会检查进程是否已经被终止或者是否需要释放 CPU(例如,如果陷阱是由定时器中断引起的)。

返回用户空间的第一步是调用 usertrapret。这个函数设置 RISC-V 控制寄存器,为将来从用户空间来的陷阱做准备:将 stvec 设置为 uservec 并准备 uservec 所依赖的 trapframe 字段。 usertrapretsepc 设置为先前保存的用户程序计数器。

最后, usertrapret 在映射在用户和内核页表中的跳板页上调用 userret ;原因是 userret 中的汇编代码将切换页表。

usertrapret 函数在调用 userret 时,通过 a0 寄存器传递了当前进程用户页表的指针。接着,userret 函数会将 satp寄存器 的值更新为指向该用户页表,从而切换到用户地址空间。需要记住的是,用户页表负责映射 跳板页(trampoline page)TRAPFRAME,但不包括内核的其他部分。由于跳板页在用户页表和内核页表中都映射到了相同的虚拟地址,这使得 userret 在修改 satp 之后还能够继续执行。

从切换到用户页表这一刻起,userret 只能访问寄存器中的数据和 trapframe 里的内容。userret 首先将 TRAPFRAME 的地址加载到 a0 寄存器中,然后通过 a0 寄存器从 trapframe 中恢复之前保存的用户寄存器的值,包括之前保存的 a0 寄存器的值。完成这些恢复操作后,userret 执行 sret 指令,从而返回到用户空间继续执行。

4.3 代码:调用系统调用

第二章以 initcode.S 调用 exec 系统调用结束。让我们看看用户调用如何到达内核中 exec 系统调用的实现。

initcode.Sexec 的参数放入寄存器 a0a1 中,并将系统调用号放入 a7 。系统调用号与 syscalls 数组中的条目匹配, syscalls 是一个函数指针表。 ecall 指令将程序捕获到内核中,并导致 uservecusertrap ,然后是 syscall 执行,如上所述。

syscall 从捕获帧中保存的 a7 中检索系统调用号,并使用它来索引 syscalls 。对于第一个系统调用, a7 包含 SYS_exec ,这导致调用系统调用实现函数 sys_exec

sys_exec 返回时, syscall 将其返回值记录在 p->trapframe->a0 中。这将导致原始用户空间调用 exec() 返回该值,因为 RISC-V 上的 C 调用约定将返回值放在 a0 中。系统调用通常返回负数表示错误,零或正数表示成功。如果系统调用号无效, syscall 会打印错误并返回 -1 。

4.4 代码:系统调用参数

在内核中实现系统调用时,需要获取用户代码传递的参数。由于这些参数按照 RISC-V 的 C 调用约定,最初放置于寄存器中,内核的陷阱处理代码会将这些用户寄存器保存到当前进程的陷阱帧里,以便后续访问。

内核提供的函数 argintargaddrargfd 分别用于从陷阱帧中提取第n个系统调用参数作为整数、指针或文件描述符。这三个函数都通过调用 argraw 来获取相应的保存用户寄存器值。这样,内核能够有效地访问并处理来自用户空间的系统调用参数。

一些系统调用通过指针访问用户内存,用于读取或写入数据。

  • 例如,exec 系统调用接收一个指向用户空间字符串参数的指针数组。
    • 这类指针带来了两个主要挑战:
      1. 首先,由于用户程序可能存在缺陷或恶意行为,传递给内核的指针可能是无效的,或是设计用来误导内核访问其自身的内存而非用户内存。
      2. 其次, xv6 内核的页表映射与用户程序的不同,这意味着内核不能直接使用常规指令从用户提供的地址加载或存储数据。因此,必须采取额外措施来安全、准确地处理这些指针。

内核提供了能够安全地在用户提供的地址间传输数据的函数。

  • 例如,fetchstr 函数就是用来从用户空间安全获取字符串参数的,像 exec 这样的系统调用在处理文件名等字符串参数时就会使用 fetchstr。具体来说,fetchstr 通过调用 copyinstr 来完成实际的数据复制工作。

copyinstr 从用户页表 pagetable 中的虚拟地址 srcva 复制最多 max 字节到目标地址 dst。由于 pagetable 并非当前正在使用的页表,copyinstr 会调用 walkaddr(该函数进一步调用 walk),在 pagetable 中查找 srcva 来获取物理地址 pa0。内核页表将物理 RAM 映射到与 RAM 的物理地址相同的虚拟地址上,这使得 copyinstr 能够直接将字符串字节从 pa0 复制到 dst

walkaddr负责检查,用户提供的虚拟地址是否属于进程用户地址空间的一部分,防止程序通过欺骗手段让内核读取其他不应访问的内存区域。还有一个名为 copyout 的函数用于将数据从内核复制到用户提供的地址,确保数据传输的安全准确。

4.5 来自内核空间的陷阱

当内核代码触发陷阱时,usertrap 会将 stvec 指向专为这种情况设置的 kernelvec 。由于 kernelvec 只在系统已经处于内核态下执行时才会被调用,它假设当前使用的是内核页表(通过 satp寄存器 设定),并且栈指针指向有效的内核栈。kernelvec 负责保存所有32个寄存器的状态到栈中,以便之后能恢复这些状态,确保被中断的内核代码可以顺利且不受影响地继续执行。

kernelvec 在被中断的内核线程的栈上保存寄存器,因为这些寄存器值是该线程状态的一部分。尤其当陷阱导致线程切换时,这一点尤为重要。在此情况下,陷阱处理完成后将从新线程的栈返回执行,而被中断线程的寄存器值则安全地保存在其自己的栈中。

保存寄存器后,kernelvec 转向 kerneltrap 进行后续处理。kerneltrap 准备处理两种类型的陷阱:设备中断和异常。

它首先通过调用 devintr 来检测并处理设备中断。如果确认不是设备中断,则认定为异常。在 xv6 内核中遇到异常被视为致命错误,此时内核将调用 panic 函数并终止执行。

如果 kerneltrap 是由定时器中断触发,并且此时有进程的内核线程正在运行(而非调度线程),kerneltrap 会调用 yield 函数,让当前线程主动放弃处理器,使得其他就绪线程有机会执行。

kerneltrap 的工作完成后,它需要返回到被陷阱中断的代码。由于 yield 可能已经扰乱了 sepcsstatus ,但 kerneltrap 开始时保存了它们。现在恢复这些控制寄存器,并返回到 kernelveckernelvec 从栈中弹出保存的寄存器并执行 sret ,该指令将 sepc 复制到 pc 并恢复中断的内核代码。

kerneltrap 因定时器中断调用 yield 时,理解陷阱返回的机制变得尤为重要。

在 Xv6 中,当 CPU 从用户空间进入内核时,会将 CPU 的 stvec设置为 kernelvec,这一设置确保了任何后续的陷阱都会被导向到 kernelvec 进行处理。

usertrap 函数中,可以看到这个向 kernelvec 的转换过程。重要的是,在内核开始执行但 stvec 仍然指向 uservec(即尚未切换到 kernelvec)的时间窗口期间,设备中断是不能发生的。这是因为如果在这个关键时刻允许中断发生,可能会导致系统不稳定或出现不可预测的行为。

幸运的是, RISC-V 在开始处理陷阱时自动禁用中断,这避免了上述风险。直到 usertrap 完成必要的设置并正确地将 stvec 设置为 kernelvec 后,才会再次启用中断。

因此,当中断通过 kerneltrap 被处理,并且如果需要调用 yield 来让出 CPU 给其他线程时,整个系统的设计保证了只有在安全的时候才会重新启用中断,确保每个线程能够在适当的时候得到执行机会,同时维护系统的整体响应性和稳定性。

4.6 页面错误异常

Xv6对异常的响应相当直接:

  • 如果用户空间发生了异常,内核会终止发生故障的进程。
  • 如果在内核中发生了异常,内核则会触发 panic 。

实际的操作系统往往会有更为复杂的响应机制。

作为示例,许多操作系统内核利用页面错误机制来实现写时复制(Copy-On-Write, COW)的 fork 操作。为了理解这一概念,我们可以参考xv6的 fork 方法,它使得子进程在创建时拥有与父进程相同的初始内存内容。

在 xv6 中,fork 操作通过 uvmcopy 为子进程分配物理内存,并将父进程的内存内容复制到子进程中。然而,如果能够直接让子进程和父进程共享父进程原有的物理内存,将会更加高效。

但是,这种简单的共享方法存在一个问题:由于父子进程可能同时对共享的栈或堆进行写操作,这将导致相互干扰,破坏彼此的数据一致性。

通过适当使用页表权限和页面错误机制,父进程和子进程可以安全地共享物理内存。当访问未在页表中映射的虚拟地址,或者映射的 PTE_V 被清除,又或者是映射的权限位(如 PTE_R、PTE_W、PTE_X、PTE_U)禁止当前尝试的操作时, CPU 会触发页面错误异常。

RISC-V 架构区分了三种类型的页面错误:

  1. 加载页面错误:由试图从内存加载数据到寄存器的指令引起。
  2. 存储页面错误:由试图将数据从寄存器存储到内存的指令引发。
  3. 指令页面错误:由试图从内存中获取指令以执行而引发。

这些页面错误的具体类型可以通过检查 scause 寄存器来确定,该寄存器包含了导致页面错误的原因。同时,stval 寄存器保存了导致页面错误的虚拟地址,这对于处理页面错误非常有用,比如在实现COW机制时,操作系统可以根据此地址识别出需要复制的页面,并调整页表,从而确保父子进程之间既能共享内存又能独立操作而不相互干扰。

写时复制(Copy-On-Write, COW)的 fork 操作的基本计划是让父进程和子进程最初共享所有物理页面,但将这些页面映射为只读(即清除 PTE_W 标志)。

  • 这意味着父进程和子进程都可以从共享的物理内存中读取数据,但如果任一进程尝试写入某个共享页面, CPU 会触发一个页面错误异常。

当发生页面错误时,内核的陷阱处理程序介入,执行以下步骤:

  1. 分配新的物理内存页面。
  2. 将故障地址所指向的原始物理页面内容复制到新分配的页面中。
  3. 修改故障进程页表中的相关 PTE ,使其指向新的副本,并重新设置 PTE_W 标志以允许写入操作。
  4. 最后,内核在引发故障的指令处恢复故障进程的执行。由于现在该页面允许写入,重新执行的指令不会再次触发错误。

这种机制确保了只有在首次写入时才会发生数据复制,从而减少了不必要的初始开销,并提高了效率。

为了支持 COW ,系统需要记录每个页面的引用情况,因为这有助于决定何时可以安全地释放物理页面。

  • 例如,如果一个页面仅被单个进程的页表引用,那么这个页面可以直接用于写操作而无需创建副本。

这样的优化基于对 fork、页面错误、exec 和退出等历史事件的跟踪,使得系统能够高效管理内存使用,减少资源浪费。

COW 使 fork 更快,因为它避免了在 fork 时立即复制父进程的内存。实际上,只有当子进程或父进程尝试写入某个共享页面时,才会发生实际的数据复制。

这种策略特别有效,因为在许多情况下,比如 fork 后紧接着是 exec,子进程并不会对从父进程继承来的大部分内存进行写操作,而是直接替换其地址空间。因此,使用 COW fork 消除了不必要的内存复制步骤。

此外,COW fork 对应用程序是透明的,这意味着无需修改现有应用程序,即可享受此优化带来的性能提升。

页表和页面错误机制在现代操作系统中扮演着关键角色,支持多种高级内存管理技术。以下是几种关键技术:

  1. 延迟分配(Lazy Allocation)

    • 请求时记录 :当应用程序通过如 sbrk 请求更多内存时,内核仅记录所需大小的增加而不立即分配物理内存或创建新的虚拟地址范围对应的 PTE。
    • 按需分配:只有当应用程序试图访问这些新分配但尚未实际分配物理内存的虚拟地址时(例如执行读取或写入操作),导致页面错误,内核才介入分配相应的物理内存页,并将其映射到进程的页表中。
    • 优点:
      • 减少了不必要的内存分配,特别是对于那些请求了大量内存但实际上并未使用的部分。
      • 分散了内存分配的成本,避免了一次性大额内存分配带来的性能瓶颈。
  2. 需求分页(Demand Paging)

    • 在程序启动时,不是将整个可执行文件加载到内存中,而是仅创建所有页表项标记为无效的用户页表。
    • 每次程序首次使用一个页面时,发生页面错误,内核响应从磁盘读取该页面的内容并将其映射到用户地址空间。
    • 优点:
      • 减少了启动时间,不需要一次性加载整个程序到内存中。
      • 提高了内存使用效率,仅加载必要的部分。
  3. 磁盘分页(Disk Paging)

    • 内存页面的一部分存储在 RAM 中,其余部分存储在磁盘上的分页区域。如果应用程序尝试访问已分页到磁盘的页面,会发生页面错误,内核将相应页面重新加载到内存中。
    • 当需要释放物理 RAM 时,内核会逐出一些页面到磁盘上,标记相关 PTE 为无效。
    • 优点:
      • 支持运行比物理RAM更大的程序,通过有效的页面调度算法提高系统性能。
      • 对应用程序透明,无需修改即可享受虚拟内存带来的好处。

优化与挑战:

  • 局部性原理:当应用程序只使用其内存的一个子集且这些子集适合于 RAM 时,分页效果最佳。良好的局部性可以显著减少分页次数,提高性能。
  • 逐出成本:逐出现有页面以腾出空间是昂贵的操作,因此尽量减少分页频率对保持系统高效运行至关重要。
  • 延迟分配与需求分页的优势:在空闲物理内存稀缺的情况下,这两种技术特别有利,能够避免浪费资源在未被实际使用的页面上。

结合分页和页面错误异常的其他特性,包括自动扩展栈和内存映射文件。内存映射文件是指:程序使用 mmap 系统调用将其映射到自己的地址空间中,以便程序可以使用加载和存储指令对其进行读写操作的文件。


以上内容引用、理解与翻译自《xv6 book》,版权归属 Russ Cox, Frans Kaashoek, 和 Robert Morris,以及 Massachusetts Institute of Technology 所有。

相关推荐
唐僧洗头爱飘柔95271 分钟前
(云计算HCIP)HCIP全笔记(十三)本篇介绍虚拟化技术,内容包含:虚拟化资源、虚拟化过程、I/O虚拟化、虚拟化架构KVM和Xen介绍、主流虚拟化技术介绍
笔记·架构·云计算·hcip·kvm·xen·i/o虚拟化
brzhang11 分钟前
面试官:讲讲 gRPC?别慌,老码小张带你从原理到实践彻底搞懂它!
前端·后端·架构
CodeFox23 分钟前
线上 nacos 挂了 !cp 模式下,naming server down 掉问题深度解析!
java·后端·架构
brzhang23 分钟前
流量大了就加机器?太 Low 了!负载均衡的这些高级玩法,让你部署、测试、安全一步到位!
前端·后端·架构
火星思想30 分钟前
Webpack热更新后模块生效的完整过程
前端·webpack·架构
JustLorain30 分钟前
如何实现事务的可串行化快照隔离
数据库·后端·架构
brzhang39 分钟前
实时通信的那些事儿:短轮询、长轮询、SSE 和 WebSocket 到底怎么选?
前端·后端·架构
Goboy1 小时前
开发者必读的日志管理技巧
后端·面试·架构
小霸王_300378633 小时前
《系统架构 - Java 企业应用架构中的完整层级划分》
java·架构·系统架构
白总Server9 小时前
多智能体系统的中间件架构
linux·运维·服务器·中间件·ribbon·架构·github