设备树学习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 里换个引脚编号即可。

相关推荐
星间都市山脉2 小时前
Android STS(Security Test Suite)完整介绍与测试流程
android·java·linux·windows·ubuntu·android studio·androidx
qq_163135752 小时前
Linux 【02-tac命令超详细教程】
linux
Jurio.2 小时前
tmux 安装与使用教程:SSH 断开后任务继续运行,终端分屏与多窗口管理
linux·经验分享·ssh·tmux
YJlio2 小时前
《Sysinternals实战指南》16.5 Ctrl2Cap 工具详解:把 Caps Lock 变成 Ctrl 的键盘改造与回退方法
linux·运维·服务器·网络·python·学习·计算机外设
l'm coming2 小时前
[linux]内核启动加载驱动文件的流程
linux·arm开发·驱动开发·嵌入式
一拳一个娘娘腔3 小时前
CVE-2026-31431 — “Copy Fail“ 深度拆解
linux·安全
麦麦麦当劳大王3 小时前
Linux SSH服务端配置指南
linux·运维·服务器·ssh
Yiyaoshujuku4 小时前
化学谱图数据API接口,数据字段一览!
linux·服务器·前端
__Witheart__4 小时前
make menuconfig 使用全流程
linux·ubuntu·rockchip