设备树学习2--一个DTBO实验

1 实验环境和目的

这次还是用树莓派5吧,毕竟下一步还准备继续做UEFI的实验。

环境搭建刚刚写了一篇:树莓派5开发环境搭建基本操作-CSDN博客

现在准备把设备树的加载,详细的梳理一下。因为刚刚才看了索尼的光机,所以命名就直接用 ecx335c吧,不代表实际就是这个。

2 DTBO

2.1 DTS文件

首先新建一个名为 ecx335c-overlay.dts。将这个外设动态挂载到树莓派5的spi0总线上。

python 复制代码
/dts-v1/;
/plugin/;  /* 告诉DTC编译器,这是一个插件(Overlay) */

/ {
    compatible = "brcm,bcm2712";

    /* 目标:把内容注入到 &spi0 节点中 */
    fragment@0 {
        target = <&spi0>;
        __overlay__ {
            status = "okay";
            #address-cells = <1>;
            #size-cells = <0>;

            /* 我们的测试外设 */
            my_oled_panel@0 {
                compatible = "sony,ecx335c";
                reg = <0>; /* 对应树莓派物理Pin 24 (CE0) */
                spi-max-frequency = <10000000>; /* 10 MHz */

                /* 动态绑定的测试GPIO,这里用树莓派的GPIO 6 */
                reset-gpios = <&rp1_gpio 6 1>; /* 1 代表 GPIO_ACTIVE_LOW */
            };
        };
    };
};

树莓派的 Linux 系统自带了设备树编译器 dtc,所以直接编译即可。(注:-@ 参数至关重要,它负责生成符号表,允许动态解析类似 &spi0 这样的外部指针。)

bash 复制代码
dtc -@ -I dts -O dtb -o ecx335c-overlay.dtbo ecx335c-overlay.dts

2.2 静态加载

拷贝到指定位置:

bash 复制代码
sudo cp ecx335c-overlay.dtbo /boot/firmware/overlays/

然后增加加载功能sudo nano /boot/firmware/config.txt,在最后增加:

bash 复制代码
dtoverlay=ecx335c-overlay

之后重启。

2.3 动态加载

在树莓派上,有一个单独工具dtoverlay,可以动态加载。

bash 复制代码
sudo dtoverlay ecx335c-overlay.dtbo

其实这个是对标准Linux操作的封装。

正常流程如下:

1. 如果系统没有挂载 configfs,先挂载(通常 Android/Linux 开机已挂载)

mount -t configfs none /sys/kernel/config

2. 进入内核设备树 Overlay 的控制大本营

cd /sys/kernel/config/device-tree/overlays/

3. 创建一个你专属的动态插件目录(比如调 SPI10 屏幕)

mkdir my_panel_overlay

cd my_panel_overlay

4. 【核心命令】把编译好的 dtbo 灌进内核节点

cat /path/to/your/my_panel.dtbo > dtbo

5. 如果发现灌错了,直接删除

cd /sys/kernel/config/device-tree/overlays/

rmdir my_panel_overlay

简单试了一下,基本上有这个功能。不过确实没有树莓派的工具方便。

bash 复制代码
tom@raspberrypi:/sys/kernel/config/device-tree/overlays$ sudo mkdir test
tom@raspberrypi:/sys/kernel/config/device-tree/overlays$ cd test/
tom@raspberrypi:/sys/kernel/config/device-tree/overlays/test$ ls
dtbo  path  status

2.4 加载后发生的事

此时主要由莓派 5 内部的 RP1 南桥芯片驱动(drivers/pinctrl/pinctrl-rp1.c) 和 DesignWare SPI 核心总线驱动(drivers/spi/spi-dw-core.c)进行处理。

内核解析你的dtbo,看到里面请求把GPIO 9、10、11复用。RP1的pinctrl驱动会立刻向物理引脚控制寄存器写入新的复用配置(Mux Value)。这几个脚的物理铜线在芯片内部瞬间与普通GPIO断开,直接挂接到了RP1内部的硬核SPI0控制器上。

Linux内核在系统树里动态创建出名为/sys/bus/spi/devices/spi10.0的新物理抽象节点。

内核SPI总线开始拿设备树里的compatible去匹配驱动。不过如果没匹配上,后面insmod的时候会继续匹配。

3 /sys/firmware/devicetree/base/

对于设备树来说,所有的内容都可以及时看到。不过这个文件夹是只读(Read-Only)"的,目录下的所有文件和文件夹,全都是内核只读暴露出来的。

bash 复制代码
tom@raspberrypi:~/dttest$ cd /sys/firmware/devicetree/base/
tom@raspberrypi:/sys/firmware/devicetree/base$ ls
'#address-cells'   cam0_reg        clk-108M      cpus             i2c0mux            model           pwr_button        sd-vcc-reg       system
 aliases           cam1_clk        clk-27M       dummy            interrupt-parent   name            reserved-memory   serial-number    thermal-zones
 arm-pmu           cam1_reg        clocks        firmwarekms      leds               __overrides__   rp1_firmware     '#size-cells'     timer
 axi               cam_dummy_reg   compatible    hvs@107c580000   memory@0           phy             rp1_vdd_3v3       soc@107c000000   wl-on-reg
 cam0_clk          chosen          cooling_fan   i2c0if           memreserve         psci            sd-io-1v8-reg     __symbols__

只要一个设备树节点在内核初始化或动态加载时被成功解析,它就会原封不动地在 /sys/firmware/devicetree/base/ 目录下映射出一个一模一样的目录树。

这是由内核里的 OF 核心库(Drivers/of/base.c) 管理的。对内核而言,/sys/firmware/devicetree/base/ 就是整个硬件世界最原始的"数字孪生克隆体"。

不管这个硬件有没有驱动、芯片有没有坏、总线控制器有没有激活,只要DTB里写了,这里就一定会有。

目录的名字是按照节点名@物理地址拼出来的,比如my_oled_panel@0。

此时,可以发现已经创建了节点。

bash 复制代码
tom@raspberrypi:~/dttest$ find /sys/firmware/devicetree/base/ -name "*my_oled_panel*"
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000/my_oled_panel@0
tom@raspberrypi:~/dttest$ find /sys/firmware/devicetree/base/ -name "spi@*"
/sys/firmware/devicetree/base/soc@107c000000/spi@7d004000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@60000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@58000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@68000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@4c000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@54000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@5c000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@64000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000
/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@6c000

下面是用grep -a '' *打印出来的内容(这个命令感觉有点哇塞):

bash 复制代码
tom@raspberrypi:/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000/my_oled_panel@0$ grep -a '' *
compatible:sony,ecx335c
name:my_oled_panel
reg:
reset-gpios:.
spi-max-frequency:▒▒▒

能在树莓派/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000/my_oled_panel@0里看到compatible、reg、reset-gpios 这些虚拟文件,说明 DTBO 已经成功在内核启动时把静态账本塞进 spi0里面了! 此时,struct spi_device 对象已经在内核总线中创建完毕。

为什么是/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000/my_oled_panel@0呢?是因为使用的spi0,这个在上级就已经定义了。TODO

在 BCM2712 芯片设计时,就决定芯片内部的 AXI 总线外设物理基地址从 1000000000 开始。其中专门拉了一条 PCIe 专门用来连接 RP1 南桥芯片,这个 PCIe 控制器的物理配置寄存器基地址被接在了0x1000120000

GPIO 控制器放在偏移地址 0x30000 的地方。第一个 I2C 控制器放在偏移地址 0x40000 的地方。第一个 SPI 控制器(也就是SPI0)的寄存器,做在了偏移地址0x50000的地方。

原始的SPI定义是在linux/arch/arm64/boot/dts/broadcom/rp1.dtsi at rpi-6.18.y · raspberrypi/linux · GitHub

python 复制代码
		rp1_spi8: spi@4c000 {
			reg = <0xc0 0x4004c000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI8 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI8_TX>,
			       <&rp1_dma RP1_DMA_SPI8_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		rp1_spi0: spi@50000 {
			reg = <0xc0 0x40050000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI0 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI0_TX>,
			       <&rp1_dma RP1_DMA_SPI0_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		rp1_spi1: spi@54000 {
			reg = <0xc0 0x40054000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI1 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI1_TX>,
			       <&rp1_dma RP1_DMA_SPI1_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		rp1_spi2: spi@58000 {
			reg = <0xc0 0x40058000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI2 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI2_TX>,
			       <&rp1_dma RP1_DMA_SPI2_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		rp1_spi3: spi@5c000 {
			reg = <0xc0 0x4005c000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI3 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI3_TX>,
			       <&rp1_dma RP1_DMA_SPI3_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		// SPI4 is a target/slave interface
		rp1_spi4: spi@60000 {
			reg = <0xc0 0x40060000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI4 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <0>;
			#size-cells = <0>;
			num-cs = <1>;
			spi-slave;
			dmas = <&rp1_dma RP1_DMA_SPI4_TX>,
			       <&rp1_dma RP1_DMA_SPI4_RX>;
			dma-names = "tx", "rx";
			status = "disabled";

			slave {
				compatible = "spidev";
				spi-max-frequency = <1000000>;
			};
		};

		rp1_spi5: spi@64000 {
			reg = <0xc0 0x40064000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI5 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI5_TX>,
			       <&rp1_dma RP1_DMA_SPI5_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		rp1_spi6: spi@68000 {
			reg = <0xc0 0x40068000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI6 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <1>;
			#size-cells = <0>;
			num-cs = <2>;
			dmas = <&rp1_dma RP1_DMA_SPI6_TX>,
			       <&rp1_dma RP1_DMA_SPI6_RX>;
			dma-names = "tx", "rx";
			status = "disabled";
		};

		// SPI7 is a target/slave interface
		rp1_spi7: spi@6c000 {
			reg = <0xc0 0x4006c000  0x0 0x130>;
			compatible = "snps,dw-apb-ssi";
			interrupts = <RP1_INT_SPI7 IRQ_TYPE_LEVEL_HIGH>;
			clocks = <&rp1_clocks RP1_CLK_SYS>;
			clock-names = "ssi_clk";
			#address-cells = <0>;
			#size-cells = <0>;
			num-cs = <1>;
			spi-slave;
			dmas = <&rp1_dma RP1_DMA_SPI7_TX>,
			       <&rp1_dma RP1_DMA_SPI7_RX>;
			dma-names = "tx", "rx";
			status = "disabled";

板级的是在linux/arch/arm64/boot/dts/broadcom/bcm2712-rpi.dtsi at rpi-6.18.y · raspberrypi/linux · GitHub,这边有一层映射。

python 复制代码
		serial4 = &uart4;
		spi0 = &spi0;
		spi1 = &spi1;
		spi10 = &spi10;
		spi2 = &spi2;
		spi3 = &spi3;
		spi4 = &spi4;
		spi5 = &spi5;
		uart0 = &uart0;
		uart1 = &uart1;

所以最后的地址就是/sys/firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000/my_oled_panel@0。

spi0.0 的由来:打印里是spi0.0。前一个 0 是因为在 DTBO 里把它套在了 &spi0 下面;后一个 .0 就是因为在 DTBO 里的 reg = <0>;(对应 @0)。这个就是总线命名规则。

4 编写驱动代码

文件名ecx335c_driver.c

cpp 复制代码
#include <linux/init.h>
#include <linux/module.h>
#include <linux/spi/spi.h>
#include <linux/gpio/consumer.h>
#include <linux/of.h> // 【核心修正】显式引入设备树结构体头文件

MODULE_LICENSE("GPL");
MODULE_AUTHOR("Tom");
MODULE_DESCRIPTION("Sony ECX335C Probe Tracker");

static int ecx335c_probe(struct spi_device *spi)
{
    struct gpio_desc *reset_gpio;
    
    // 打印 1:证明 Match 成功,内核已经调进了我们的 Probe
    printk(KERN_INFO "========== [Tom-Debug] ecx335c_probe STARTED ==========\n");
    printk(KERN_INFO "[Tom-Debug] Device matched on SPI bus: %s\n", dev_name(&spi->dev));
    
    // 【安全检查】确认设备树节点确实挂载上去了
    if (spi->dev.of_node) {
        printk(KERN_INFO "[Tom-Debug] DT Node Name: %s\n", spi->dev.of_node->name);
    } else {
        printk(KERN_WARNING "[Tom-Debug] Warning: No Device Tree node found for this device!\n");
    }

    // 【核心修正】移除对 spi->chip_select 的直接整型打印,改用通用的 Max Speed 打印
    printk(KERN_INFO "[Tom-Debug] Max Bus Speed configured: %d Hz\n", spi->max_speed_hz);

    // 获取 GPIO 资源 (绑定设备树中的 reset-gpios)
    reset_gpio = devm_gpiod_get(&spi->dev, "reset", GPIOD_OUT_LOW);
    if (IS_ERR(reset_gpio)) {
        printk(KERN_ERR "[Tom-Debug] Failed to acquire reset-gpios from Device Tree!\n");
        return PTR_ERR(reset_gpio);
    }

    // 打印 2:证明 GPIO 成功从设备树里解析并绑定
    printk(KERN_INFO "[Tom-Debug] Successfully mapped reset-gpios!\n");
    
    // 模拟硬件复位时序
    gpiod_set_value(reset_gpio, 1); // 在 ACTIVE_LOW 机制下,传入 1 会驱使物理引脚输出低电平
    printk(KERN_INFO "[Tom-Debug] MicroOLED hardware reset pin (XCLR) pulled LOW.\n");
    
    return 0;
}

static void ecx335c_remove(struct spi_device *spi)
{
    printk(KERN_INFO "========== [Tom-Debug] ecx335c_remove CALLED ==========\n");
}

/* 建立用来与 DTBO 匹配的兼容性字符串数组 */
static const struct of_device_id ecx335c_of_match[] = {
    { .compatible = "sony,ecx335c" },
    { /* sentinel */ }
};
MODULE_DEVICE_TABLE(of, ecx335c_of_match);

static struct spi_driver ecx335c_driver = {
    .driver = {
        .name = "ecx335c_panel_driver",
        .of_match_table = ecx335c_of_match,
    },
    .probe = ecx335c_probe,
    .remove = ecx335c_remove,
};

module_spi_driver(ecx335c_driver);

Makefile

复制代码
obj-m += ecx335c_driver.o
all:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules
clean:
	make -C /lib/modules/$(shell uname -r)/build M=$(PWD) clean

直接在当前目录输入 make,就会在当前目录下生成一个驱动模块:ecx335c_driver.ko。

bash 复制代码
tom@raspberrypi:~/dttest$ make
make -C /lib/modules/6.12.75+rpt-rpi-2712/build M=/home/tom/dttest modules
make[1]: Entering directory '/usr/src/linux-headers-6.12.75+rpt-rpi-2712'
  CC [M]  /home/tom/dttest/ecx335c_driver.o
  MODPOST /home/tom/dttest/Module.symvers
  CC [M]  /home/tom/dttest/ecx335c_driver.mod.o
  CC [M]  /home/tom/dttest/.module-common.o
  LD [M]  /home/tom/dttest/ecx335c_driver.ko
make[1]: Leaving directory '/usr/src/linux-headers-6.12.75+rpt-rpi-2712'

5 /sys/bus/spi/devices/spi0.0

可以看到,此时生成了/sys/bus/spi/devices/spi0.0。解释如下:

/sys/bus/ (总线设备大盘)是由Linux 内核设备驱动模型(Device-Driver Model)中的各总线核心层(SPI Core, Platform Core, I2C Core 等)管理。

它是功能分类。它不关心你的物理祖先是谁,这里只有"用什么协议通信"以及"归哪个驱动管"。

这里的spi0.0命名也有严格的含义:前面的0代表0号SPI 控制器(即某个 QUPv3 SE 引擎),后面的0代表片选引脚编号(Chip Select 0, 即 CS0)。

此时内容如下:

bash 复制代码
tom@raspberrypi:/sys/bus/spi/devices/spi0.0$ ls -l
total 0
lrwxrwxrwx 1 root root     0 Jun 11 11:00 driver -> ../../../../../../bus/spi/drivers/ecx335c_panel_driver
-rw-r--r-- 1 root root 16384 Jun 11 11:00 driver_override
-r--r--r-- 1 root root 16384 Jun 11 11:00 modalias
lrwxrwxrwx 1 root root     0 Jun 11 11:00 of_node -> ../../../../../../firmware/devicetree/base/axi/pcie@1000120000/rp1/spi@50000/my_oled_panel@0
drwxr-xr-x 2 root root     0 Jun 11 11:00 power
drwxr-xr-x 2 root root     0 Jun 11 11:00 statistics
lrwxrwxrwx 1 root root     0 Jun 11 10:19 subsystem -> ../../../../../../bus/spi
lrwxrwxrwx 1 root root     0 Jun 11 11:00 supplier:platform:1f000d0000.gpio -> ../../../../../virtual/devlink/platform:1f000d0000.gpio--spi:spi0.0
-rw-r--r-- 1 root root 16384 Jun 11 10:19 uevent

SPI的调试方法

bash 复制代码
# 1. 切换到 root
sudo su

# 2. 擦亮内核的追踪器镜片
echo 0 > /sys/kernel/tracing/tracing_on
echo "" > /sys/kernel/tracing/trace

# 3. 开启 SPI 模块搬运数据的事件监听(抓取发送和接收)
echo 1 > /sys/kernel/tracing/events/spi/spi_transfer_start/enable

# 4. 打开总开关
echo 1 > /sys/kernel/tracing/tracing_on

# 5. 此时去运行你的调屏测试程序(或者往 /dev/spidev10.0 灌数据)

# 6. 见证奇迹的时刻,查看内核抓到的 SPI 裸波形流
cat /sys/kernel/tracing/trace | tail -n 50

更具体的,打算单独写一篇来弄。。。TODO

6 时序观察

重启树莓派:sudo reboot

现在,我们手动塞入我们的驱动 .ko,来触发 Match 机制。

复制代码
sudo insmod ecx335c_driver.ko

直接运行以下命令查看内核环形缓冲区的 Log:

bash 复制代码
dmesg | grep "\[Tom-Debug\]"

会看到终端里以下时序Log。

复制代码
[   45.102345] ========== [Tom-Debug] ecx335c_probe STARTED ==========
[   45.102350] [Tom-Debug] Device matched on SPI bus: spi0.0
[   45.102353] [Tom-Debug] DT Node Name: my_oled_panel
[   45.102355] [Tom-Debug] Chip Select configured: 0, Max Speed: 10000000 Hz
[   45.105120] [Tom-Debug] Successfully mapped reset-gpios!
[   45.105128] [Tom-Debug] MicroOLED hardware reset pin (XCLR) pulled LOW.

7 gpios的操作

在DST中,有这么一行。

cpp 复制代码
reset-gpios = <&rp1_gpio 6 1>; /* 1 代表 GPIO_ACTIVE_LOW */

之后再代码中,可以直接使用。比如:

cpp 复制代码
gpiod_set_value(reset_gpio, 0)

这个是怎么实现的呢?

C语言肯定是不能直接调用 DTS。而是 DTS 变二进制 -> 二进制变内核内存结构体 -> 驱动通过"compatible 匹配"拿到结构体 -> 通过 gpiod_get 函数把里面的引脚信息"提取"成 C 语言指针变量。

这样的好处就是这意味着驱动以后移植到高通或者全志平台,代码一行都不用改,只需要在对应平台的 DTBO 里换个引脚编号即可。

相关推荐
通信小小昕15 小时前
Ubuntu 26.04 中文输入法安装
linux·运维·ubuntu
张小姐的猫16 小时前
【Linux】网络编程 —— HTTP协议(上)
linux·运维·服务器·网络·http·单例模式·策略模式
栩栩云生17 小时前
AI 写代码犯的错,早被写进了错题集
linux·安全·ai编程
imc.1118 小时前
linux基础IO
linux·运维·服务器
BelongPanda20 小时前
Linux Nginx 纯手动 Let‘s Encrypt 泛域名证书配置教程
linux·nginx
酷可达拉斯20 小时前
Linux操作系统-shell编程(0)
linux·运维·服务器·python·云计算
2301_7779983420 小时前
Linux中断机制:操作系统如何高效运行
linux·运维·服务器
yunwei3720 小时前
eBPF 教程:检查 exec 后真正安装的可执行镜像
linux·开源
笑锝没心没肺21 小时前
yum update 报错:GPG key retrieval failed: [Errno 14] HTTPS Error 404 - Not Found
linux·运维·服务器
AAA@峥21 小时前
CentOS Stream8 基于 Packstack 搭建 OpenStack 云平台全流程实战
linux·centos·openstack