• Linux驱动开发——PCI设备驱动


    目录

    一、 PCI协议简介

    二、PCI和PCI-e

    三、Linux PCI驱动

    四、 PCI设备驱动实例

    五、 总线类设备驱动开发习题



    一、 PCI协议简介


            PCI (Peripheral Component Interconnect,外设部件互联) 局部总线是由Intel 公司联合其他几家公司一起开发的一种总线标准,最初是为了替代 ISA 之类的总线,用于解决当时的图形化界面显示器的带宽问题。相比于 ISA 总线,它最大的特点是高带宽、突发传输和即插即用(热插拔)。在 PCI 3.0 的规范中,PCI 局部总线的时钟速率有 33MHZ、66MHz 和133MHz 三种标准速率,支持的数据位宽有 32 位和 64 位两种。所以最低的数据传输率为33MHz x 32bit = 132MB/s,即每秒 132M 字节,这完全满足当时的图形显卡的要求。突发传输是指其地址总线和数据总线复用,在传输开始时先发地址,然后再连续传输若干个字节的数据,这样做的好处是可以减少芯片的管脚,并且一个传输周期可以完成若干个字节的传输。即插即用和前面谈到的 USB 类似,总线上的设备存放有配置信息,在初始化的过程中,主机会主动获取这些信息,从而分配其所需要的资源,这会在后面做更详细的介绍。随着 PCI 局部总线的发展,其应用的领域也越来越广泛,现在 PC 中独立的网卡、声卡、数据采集卡等使用的都是 PCI 局部总线。后来又推出了串行的标准,PCI-Express,其传输速率相当高,在 PCI-Express 3.0 规范中,其传输率可以达到 8GT/s即每秒 8G 次传输。因为使用的广泛性,在某些嵌入式系统中也使用了 PCI或 PCI-Express局部总线。下面简单介绍一下 PCI3.0 规范中驱动开发者需要关心的内容,下图是 PCI系统的连接框图(引自 PCI3.0 规范)。


            处理器(Processor)通过 Host/PCI桥(Bridge)连接到了 0号 PCI局部总线(PCI LocalBus #0),在这条局部总线上,有声卡 (Audio)、动态视频(Motion Video)、图形显卡(Graphics)、网卡(LAN)和 SCSI控制器等。通过 PCI-to-PCI Bridge,又扩展出了1号PCI局部总线(PCILocal Bus #1),在这条总线上又接入了其他 PCI功能设备。另外,还有PCI-ISA 桥,可以将 PCI 总线转换为传统的ISA 总线。

            PCI局部总线也是主从结构,在 PCI的规范中主设备叫发起者(Initiator),从设备叫目标(Target),传输由主设备发起,从设备进行响应。一个 PCI 设备都要实现目标的功能,但也可以实现发起者的功能,也就是说,一个设备既可以在某一时刻做主设备,也可以在另一个时刻做从设备。并且一条总线上允许有多个主设备,由仲裁器来决定哪个主设备可以获得总线的控制权。下面我们仅讨论 PCI的从设备。
            PCI 定义了三个物理地址空间,包括内存地址空间、I/O 地址空间和配置地址空间。

            其中配置地址空间是必需的,这个地址空间用于对设备的硬件进行配置。为了更好地理解这三个地址空间的访问,我们先来看看 PCI 的典型写传输时序图,如下图所示(引自PCI3.0 规范)。


            当发起者要对目标进行写操作时,会先将 FRAME 拉低,在之后的第一个时钟周期AD 总线上是发送地址,C/BE 是总线的命令,用于确定一个更具体的写操作,DEVSEI是被选中的目标发出的确认信号。在之后的若干个周期,AD 总线上是要写入的数据,C/BE 上是字节使能,用于确定哪个字节是有效的。IRDY 和 TRDY 分别是发起者和目标的准备信号,当任一个无效时,都会自动插入等待周期。在最后一个数据周期,FRAME无效,但传输最终完成是在 FRAME 无效后 IRDY 也无效的时刻。PCI 的读传输操作和写基本类似,只是数据的方向相反。上面涉及的总线命令如下图所示(引自 PCI3.0 规范)。


            I/O 读、I/O 写、内存读、内存写、配置读和配置写即我们前面提到的三个物理地址空间的读写。我们首先来看配置空间是如何寻址的,地址结构如下图所示。


            PCI规范定义了两种类型的配置空间地址,Type 0 用于选择总线上的一个设备,Type1 用于将请求传递给另一条总线。地址中的各个字段的含义如下。
            Bus Number:8 位总线地址,在 256条PCI 局部总线中选择一条。
            Device Number:5 位设备地址,在一条总线上的 32 个物理设备中选择一个
            Function Number:3 位功能地址,在一个物理设备上的8个功能中选择一个功能,也就是说,PCI 设备和 USB 设备类似,一个物理设备可以有多个功能,从而实现多个逻辑设备。
            Register Number:用于选择配置空间中的一个 32 位寄存器。

            在 PCI规范中,对配置空间的各寄存器都有具体的定义,整个配置空间有 64 个字节我们并不需要关心配置空间中每个寄存器的含义,下面列出最主要的一些寄存器(其他寄存器的定义及地址请参见 PCI规范)。


            Vendor ID:16 位,硬件厂商ID。
            Device ID:16 位,设备 ID。
            Class Code: 24 位,外设所属的类别,如大容量存储设备控制器类、网络控制器类显示控制器类等。为 0表示不属于某一具体的类。
            Subsystem Vendor ID: 16 位,子系统厂商 ID。
            Subsystem ID:16 位,子系统ID。

            Base Address Registers: 32 位,在计算机启动的过程中,会检查所有的 PCI 设备,其中一个重要的操作就是要获取其使用的内存空间和 I/O 空间的大小,然后给每一个空间分配一个基址,这个基址就是存放在基址寄存器中的。配置空间中共有 6 个这样的基址寄存器,在 Linux 驱动中简称 bar。
            上面的 ID 和 Class 用于匹配驱动程序,基址则用于驱动进行资源获取和映射操作,后面会进行更详细的描述。有了基址寄存器后,对内存空间和 IO 空间的访问问题也就迎刃而解了,因为我们只需要发出相应的内存地址或I/O地址就可以访问对应的空间了。
     

     

    二、PCI和PCI-e

            PCI和PCIe都是计算机中用于连接设备的接口标准,但它们之间存在一些重要区别。

            PCI(Peripheral Component Interconnect)是一种早期的计算机总线标准,它被设计用于连接各种高速外围设备,如显卡、声卡、网卡等。PCI总线是一种共享总线,这意味着所有设备共享相同的带宽。因此,当多个设备同时尝试使用总线时,可能会出现性能下降或冲突。

            相比之下,PCIe(Peripheral Component Interconnect Express)是一种更现代的计算机总线标准,也被广泛应用于连接各种高速设备。与PCI不同,PCIe是一种点对点互连协议,这意味着每个设备都有自己的专用连接,不会与其他设备共享带宽。这使得PCIe在性能上大大超越PCI,特别是在高带宽应用和多设备环境中。

            总的来说,PCIe在性能、灵活性和扩展性方面都优于PCI,这也是为什么现在的计算机和设备大多采用PCIe接口的原因。但需要注意的是,由于PCIe的复杂性和成本较高,在一些低带宽和低成本的应用中,PCI仍然可能被使用。

            PCI-e在软件层面是兼容PCI的,PCI是并行传输,PCI-e是串行点对点传输。

    三、Linux PCI驱动


            下面我们还是只讨论 PCI 从设备。PCI 设备在内中用 struct pci_dev 结构来表示,该结构的成员非常多,在此就不一一列出了,可以参见内核源码中的 include/linux/pci.h 文件。在里面会发现我们前面提到的 ID、类等成员,还有设备所使用的IRQ 线。设备的 ID 还有一个 struct pci_device_id 结构,驱动中通常会定义这样一个数组,来表示可以支持的设备列表,和前面的 USB 设备列表类似。和 PCI 设备结构相关的主要函数和宏如下。

    1. int pci_enable_device(struct pci_dev *dev);
    2. void pci_disable_device(struct pci_dev *dev);
    3. pci_resource_start(dev, bar)
    4. pci_resource_end(dev, bar)
    5. pci_resource_flags(dev, bar)
    6. pci_resource_len(dev,bar)
    7. int pci_request_regions (struct pci_dev *pdev, const char *res_name);
    8. void pci_release_regions(struct pci_dev *pdev);


    pci_enable_device: 使能 PCI设备,在操作 PCI设备之前必须先使能设备

    pci_disable_device:禁止PCI设备。
    pci_resource_start: 获取 dev 中第 bar 个基址寄存器中记录的资源起始地址.

    pci_resource_end:获取 dev 中第 bar 个基址寄存器中记录的资源结束地址。
    pci_resource_flags:取 dev 中第 bar 个基址寄存器中记录的资源标志,是内存资源还是 IO 资源。
    pci_resource_len:获取 dev 中第 bar 个基址寄存器中记录的资源大小。
    pci_request_regions: 申请 PCI 设备 pdev 内的内存资源和I/0 资源,取名为res_name.

    pci_release_regions: 释放 PCI设备 pdev 内的内存资源和 IO 资源。

            内核中用 struct pci_driver 结构来表示 PCI 设备驱动,相关的主要函数宏如下


     

    1. void pci_unregister_driver(struct pci_driver *dev);
    2. pci_register_driver(driver)
    3. void pci_set_drvdata(struct pci_dev *pdev, void *data);
    4. void *pci_get_drvdata(struct pci_dev *pdev);


    pci_register_driver: 注册 PCI 设备驱动 driver
    pci_unregister_driver: 注销 PCI 设备驱动 dev。
    pci_set_drvdata:保存 data 指针到PCI设备pdev 中。
    pci_get_drvdata: 从 PCI 设备 pdev 中获取保存的指针

    PCI设备的配置空间访问的主要函数如下。

    1. int pci_read_config_byte(const struct pci_dev *dev, int where, u8 *val);
    2. int pci_read_config_word(const struct pci_dev *dev, int where, u16 *val);
    3. int pei_read_config_dword(const struct pci_dev *dev, int where, u32 *val);
    4. int pci_write_config_byte(const struct pci_dev *dev, int where, u8 val);
    5. int pci_write_config_word(const struct pci_dev *dev, int where, u16 val);
    6. int pei_write_config_dword(const struct pci_dev *dev, int where, u32 val);


    上面的函数分别实现了对配置空间的字节、字 (16 位) 和双字(32 位) 的读写操作。


    四、 PCI设备驱动实例


    这里使用的 PCI 设备是南京沁恒公司的 CH368EVT 评估板,该评估板使用了一片该公司设计的 CH368 PCI-Express 接口芯片,虽然是 PCI-Express 协议,但是在驱动上两者可以兼容,只是 PCI-Express 速率更高,能够支持更多的功能。选用该评估板的原因是其价格低廉,完全国产,也能够全面验证三个空间的读写操作。
            使用L1~L4 这 4个 LED 显示 I/O 数据端口 D3~DO 位的状态。灯亮代表 1,灯灭代表0。
    CH368 的配置空间定义如下图所示。


             厂商 ID 和设备 ID 是我们比较关心的内容,驱动的设备列表中的 ID 要和这里的致。第一个基址寄存器是 I/O 地址空间的基址,有 232 个字节,定义如下图所示。另外,CH368 的内存空间有 32KB。

    该设备的 Linux 驱动代码如下,为了尽量突出 PCI驱动的核心,并没有加入并发控制相关的代码。

    1. #include
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include
    9. #include
    10. #include
    11. #include "ch368.h"
    12. #define CH368_MAJOR 256
    13. #define CH368_MINOR 11
    14. #define CH368_DEV_NAME "ch368"
    15. struct ch368_dev {
    16. void __iomem *io_addr;
    17. void __iomem *mem_addr;
    18. unsigned long io_len;
    19. unsigned long mem_len;
    20. struct pci_dev *pdev;
    21. struct cdev cdev;
    22. dev_t dev;
    23. };
    24. static unsigned int minor = CH368_MINOR;
    25. static int ch368_open(struct inode *inode, struct file *filp)
    26. {
    27. struct ch368_dev *ch368;
    28. ch368 = container_of(inode->i_cdev, struct ch368_dev, cdev);
    29. filp->private_data = ch368;
    30. return 0;
    31. }
    32. static int ch368_release(struct inode *inode, struct file *filp)
    33. {
    34. return 0;
    35. }
    36. static ssize_t ch368_read(struct file *filp, char __user *buf, size_t count, loff_t *f_ops)
    37. {
    38. int ret;
    39. struct ch368_dev *ch368 = filp->private_data;
    40. count = count > ch368->mem_len ? ch368->mem_len : count;
    41. ret = copy_to_user(buf, ch368->mem_addr, count);
    42. return count - ret;
    43. }
    44. static ssize_t ch368_write(struct file *filp, const char __user *buf, size_t count, loff_t *f_ops)
    45. {
    46. int ret;
    47. struct ch368_dev *ch368 = filp->private_data;
    48. count = count > ch368->mem_len ? ch368->mem_len : count;
    49. ret = copy_from_user(ch368->mem_addr, buf, count);
    50. return count - ret;
    51. }
    52. static long ch368_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
    53. {
    54. union addr_data ad;
    55. struct ch368_dev *ch368 = filp->private_data;
    56. if (_IOC_TYPE(cmd) != CH368_MAGIC)
    57. return -ENOTTY;
    58. if (copy_from_user(&ad, (union addr_data __user *)arg, sizeof(union addr_data)))
    59. return -EFAULT;
    60. switch (cmd) {
    61. case CH368_RD_CFG:
    62. if (ad.addr > 0x3F)
    63. return -ENOTTY;
    64. pci_read_config_byte(ch368->pdev, ad.addr, &ad.data);
    65. if (copy_to_user((union addr_data __user *)arg, &ad, sizeof(union addr_data)))
    66. return -EFAULT;
    67. break;
    68. case CH368_WR_CFG:
    69. if (ad.addr > 0x3F)
    70. return -ENOTTY;
    71. pci_write_config_byte(ch368->pdev, ad.addr, ad.data);
    72. break;
    73. case CH368_RD_IO:
    74. ad.data = ioread8(ch368->io_addr + ad.addr);
    75. if (copy_to_user((union addr_data __user *)arg, &ad, sizeof(union addr_data)))
    76. return -EFAULT;
    77. break;
    78. case CH368_WR_IO:
    79. iowrite8(ad.data, ch368->io_addr + ad.addr);
    80. break;
    81. default:
    82. return -ENOTTY;
    83. }
    84. return 0;
    85. }
    86. static struct file_operations ch368_ops = {
    87. .owner = THIS_MODULE,
    88. .open = ch368_open,
    89. .release = ch368_release,
    90. .read = ch368_read,
    91. .write = ch368_write,
    92. .unlocked_ioctl = ch368_ioctl,
    93. };
    94. static int ch368_probe(struct pci_dev *pdev, const struct pci_device_id *id)
    95. {
    96. int ret;
    97. unsigned long io_start;
    98. unsigned long io_end;
    99. unsigned long io_flags;
    100. unsigned long io_len;
    101. void __iomem *io_addr = NULL;
    102. unsigned long mem_start;
    103. unsigned long mem_end;
    104. unsigned long mem_flags;
    105. unsigned long mem_len;
    106. void __iomem *mem_addr = NULL;
    107. struct ch368_dev *ch368;
    108. ret = pci_enable_device(pdev);
    109. if(ret)
    110. goto enable_err;
    111. io_start = pci_resource_start(pdev, 0);
    112. io_end = pci_resource_end(pdev, 0);
    113. io_flags = pci_resource_flags(pdev, 0);
    114. io_len = pci_resource_len(pdev, 0);
    115. mem_start = pci_resource_start(pdev, 1);
    116. mem_end = pci_resource_end(pdev, 1);
    117. mem_flags = pci_resource_flags(pdev, 1);
    118. mem_len = pci_resource_len(pdev, 1);
    119. if (!(io_flags & IORESOURCE_IO) || !(mem_flags & IORESOURCE_MEM)) {
    120. ret = -ENODEV;
    121. goto res_err;
    122. }
    123. ret = pci_request_regions(pdev, "ch368");
    124. if (ret)
    125. goto res_err;
    126. io_addr = ioport_map(io_start, io_len);
    127. if (io_addr == NULL) {
    128. ret = -EIO;
    129. goto ioport_map_err;
    130. }
    131. mem_addr = ioremap(mem_start, mem_len);
    132. if (mem_addr == NULL) {
    133. ret = -EIO;
    134. goto ioremap_err;
    135. }
    136. ch368 = kzalloc(sizeof(struct ch368_dev), GFP_KERNEL);
    137. if (!ch368) {
    138. ret = -ENOMEM;
    139. goto mem_err;
    140. }
    141. pci_set_drvdata(pdev, ch368);
    142. ch368->io_addr = io_addr;
    143. ch368->mem_addr = mem_addr;
    144. ch368->io_len = io_len;
    145. ch368->mem_len = mem_len;
    146. ch368->pdev = pdev;
    147. ch368->dev = MKDEV(CH368_MAJOR, minor++);
    148. ret = register_chrdev_region (ch368->dev, 1, CH368_DEV_NAME);
    149. if (ret < 0)
    150. goto region_err;
    151. cdev_init(&ch368->cdev, &ch368_ops);
    152. ch368->cdev.owner = THIS_MODULE;
    153. ret = cdev_add(&ch368->cdev, ch368->dev, 1);
    154. if (ret)
    155. goto add_err;
    156. return 0;
    157. add_err:
    158. unregister_chrdev_region(ch368->dev, 1);
    159. region_err:
    160. kfree(ch368);
    161. mem_err:
    162. iounmap(mem_addr);
    163. ioremap_err:
    164. ioport_unmap(io_addr);
    165. ioport_map_err:
    166. pci_release_regions(pdev);
    167. res_err:
    168. pci_disable_device(pdev);
    169. enable_err:
    170. return ret;
    171. }
    172. static void ch368_remove(struct pci_dev *pdev)
    173. {
    174. struct ch368_dev *ch368 = pci_get_drvdata(pdev);
    175. cdev_del(&ch368->cdev);
    176. unregister_chrdev_region(ch368->dev, 1);
    177. iounmap(ch368->mem_addr);
    178. ioport_unmap(ch368->io_addr);
    179. kfree(ch368);
    180. pci_release_regions(pdev);
    181. pci_disable_device(pdev);
    182. }
    183. static struct pci_device_id ch368_id_table[] =
    184. {
    185. {0x1C00, 0x5834, 0x1C00, 0x5834, 0, 0, 0},
    186. {0,}
    187. };
    188. MODULE_DEVICE_TABLE(pci, ch368_id_table);
    189. static struct pci_driver ch368_driver = {
    190. .name = "ch368",
    191. .id_table = ch368_id_table,
    192. .probe = ch368_probe,
    193. .remove = ch368_remove,
    194. };
    195. module_pci_driver(ch368_driver);
    196. MODULE_LICENSE("GPL");
    197. MODULE_AUTHOR("name ");
    198. MODULE_DESCRIPTION("CH368 driver");
    1. #ifndef _CH368_H
    2. #define _CH368_H
    3. union addr_data {
    4. unsigned char addr;
    5. unsigned char data;
    6. };
    7. #define CH368_MAGIC 'c'
    8. #define CH368_RD_CFG _IOWR(CH368_MAGIC, 0, union addr_data)
    9. #define CH368_WR_CFG _IOWR(CH368_MAGIC, 1, union addr_data)
    10. #define CH368_RD_IO _IOWR(CH368_MAGIC, 2, union addr_data)
    11. #define CH368_WR_IO _IOWR(CH368_MAGIC, 3, union addr_data)
    12. #endif


            代码第 19 行至第 27 行是设备结构的定义,包含了保存映射之后的 IO 地址和内存地址的 io_addr 和 mem_addr 指针成员、保存 IO 地址空间大小和内存地址空间大小的io_len 和 mem_len 成员、保存 PCI 设备结构的 pdev 指针成员。该 PCI设备实现为一个字符设备,所以有 cdev 和 dev 成员。
            代码第 224 行至第 242 行是 PCI驱动结构的定义、注册和注销。ch368_id_table 是该驱动支持的设备列表,其中的 ID 号要和上图中的 ID 号一致。
            当有匹配的 PCI设备被检测到后,ch368_probe 函数自动被调用。代码第 134 行首先使能了 PCI 设备,代码第 138 行至第 146 行分别获取了 IO 和内存的物理地址、标志和长度信息。代码第 148 行至第 151 行判断了获取的标志内的资源类型信息,如果不和预期的相同,则设备探测失败。代码第 153 行至第 167 行申请了 PCI 设备所声明的资源,然后进行了映射,获得了对应的虚拟地址。代码第 169 行至第 180 行分配了 struct ch368_dev 结构的内存空间,并对各成员进行了相应的初始化,还使用 pci_set_drvdata函
     

            总线类设备驱动微将该结构地址保存在了 PCI 设备结构之中,方便之后从 PCI 设备结构中获得该地址该函数之后的代码是字符设备相关的注册操作。ch368 remove 做的工作和 h368 probe第亿函数相反。


            ch368_open 和 ch368_release 没有做太多的工作,ch368_read 和 ch368_write 则是针对内存空间的读和写,因为在这片内存空间没有对应外接的设备,所以没有实际意义。比较实际的操作是在 ch368_ioctl 中,CH368_RD_CFG 命今用来读取配置空间的数据,CH368_WR_CFG 命令用于向配置空间写入数据。CH368_RD_IO和CH368_WR_IO则分别是对 I/0 空间进行读和写。union addr_data 用于传送地址和返回数据,这和ADC 驱动的例子是类似的。
            应用层的测试代码如下
     

    1. #include
    2. #include
    3. #include
    4. #include
    5. #include
    6. #include
    7. #include
    8. #include "ch368.h"
    9. int main(int argc, char *argv[])
    10. {
    11. int i;
    12. int fd;
    13. int ret;
    14. union addr_data ad;
    15. unsigned char id[4];
    16. fd = open("/dev/ch368", O_RDWR);
    17. if (fd == -1)
    18. goto fail;
    19. for (i = 0; i < sizeof(id); i++) {
    20. ad.addr = i;
    21. if (ioctl(fd, CH368_RD_CFG, &ad))
    22. goto fail;
    23. id[i] = ad.data;
    24. }
    25. printf("VID: 0x%02x%02x, DID: 0x%02x%02x\n", id[1], id[0], id[3], id[2]);
    26. i = 0;
    27. ad.addr = 0;
    28. while (1) {
    29. ad.data = i++;
    30. if (ioctl(fd, CH368_WR_IO, &ad))
    31. goto fail;
    32. i %= 15;
    33. sleep(1);
    34. }
    35. fail:
    36. perror("pci test");
    37. exit(EXIT_FAILURE);
    38. }


            上面的代码在打开设备后先读取了配置空间的前 4 个字节,根据 PCI 规范,这4字节刚好是厂商ID 和设备ID。接下来在 while 循环中对 I/0 空间的第一个字节依次写入了0~15,这样 PCI 设备上的 4个 LED 灯就会按照此规律被点亮。前面说过,4个LED反映了写入 I/ O空间的数据的低 4 位的状态,数据位为 1 对应的灯被点亮,数据位为0对应的灯熄灭。
            使用下面的命令进行编译和测试。需要说明的是,需要有一台安装了 Linux 系统的物理机,并且物理机上要有对应的 PCIE 插槽才能插入该设备并进行测试。


    五、 总线类设备驱动开发习题


    1.I2C 总线协议规定是由 ( ) 来进行应答的。
    [A] 数据发送者
    [B] 数据接收者


    2.I2C 总线协议规定所有访问都是由 ( )来发起的
    [B] 从设备
    [A] 主设备


    3.SPI是 ( )总线。
    [B] 异步
    [A] 同步


    4.SPI总线是 ( )的

    [B] 半双工
    [A] 单工

    [C] 全双功


    5.SPI 总线是 ( )的。

    [A] 单主
    [B] 多主


    6.USB 的传输类型分为 (
    [B] 等时传输
    [A] 控制传输
    [D] 块传输
    [C] 中断传输
     


    7.USB 的接口是由多个 ( ) 组成的。
    [A] 配置
    [B] 管道

    [C] 端点


    8.PCI的配置空间包括 ( ) 信息。
    [B] 设备ID
    [A] 厂商ID
    [D] 地址空间大小
    [C] 基地址

     

    答案:B         A         A         A         C        ABCD        C        ABCD

  • 相关阅读:
    数据筛选 query 函数介绍(Pandas)
    kubernetes资源对象介绍及常用命令(五)-(ConfigMap&Secret)
    【bug】记一个若依的部门树修改报错的bug
    云数据库架构思维升级,看这篇就够了
    Web APIs——BOM
    嵌入式系统【硬件层、STM32芯片】
    [nodemon] app crashed - waiting for file changes before starting...解决方法
    利用DP-SSL对少量的标记样本进行有效的半监督学习
    雪花算法的使用
    Maven笔记
  • 原文地址:https://blog.csdn.net/qq_52479948/article/details/134304611