一、如何编写Linux PCI驱动程序

PCI的世界是广阔的,充满了(大部分令人不快的)惊喜。由于每个CPU体系结构实现不同的芯片集,并且PCI设备有不同的需求(“特性”),因此Linux内核中的PCI支持并不像人们希望的那么简单。这篇简短的文章介绍用于PCI设备驱动程序的Linux APIs。

一个更完整的资源是由Jonathan Corbet、Alessandro Rubini和Greg Kroah-Hartman编写的“Linux设备驱动程序”的第三版。LDD3可以从https://lwn.net/Kernel/LDD3/免费获得(根据知识共享许可协议)。

1.1 PCI驱动程序结构

PCI驱动程序通过pci_register_driver()在系统中"发现"PCI设备。事实上,恰恰相反。当PCI通用代码发现一个新设备时,具有匹配“描述”的驱动程序将被通知。详情如下。

pci_register_driver()将设备的大部分探测留给PCI层,并支持在线插入/删除设备[因此在单个驱动程序中支持热插拔PCI、CardBus和Express-Card]。pci_register_driver()调用需要传入一个函数指针表,从而指示驱动程序的更高一级结构体。

一旦驱动程序知道了一个PCI设备并获得了所有权,驱动程序通常需要执行以下初始化:

  • 启用设备
  • 请求MMIO / IOP资源
  • 设置DMA掩码大小(用于一致性DMA和流式DMA)
  • 分配和初始化共享控制数据(pci_allocate_coherent())
  • 访问设备配置空间(如果需要)
  • 注册IRQ处理程序(request_irq())
  • 初始化non-PCI(即LAN/SCSI/等芯片部分)
  • 启用DMA /处理引擎

当使用设备完成时,可能需要卸载模块,驱动程序需要采取以下步骤:

  • 禁止设备产生irq
  • 释放IRQ (free_irq())
  • 停止所有DMA活动
  • 释放DMA缓冲区(包括流式DMA和一致性DMA)
  • 从其他子系统注销(例如scsi或netdev)
  • 释放MMIO / IOP资源
  • 禁用该设备

下面几节将介绍这些主题中的大部分。其余部分请查看LDD3或<linux/pci.h>。

如果PCI子系统没有配置(没有设置CONFIG_PCI),下面描述的大多数PCI函数都被定义为内联函数,要么完全空,要么只是返回一个适当的错误代码,以避免在驱动程序中出现大量ifdefs。

 1.2 pci_register_driver()调用

PCI设备驱动程序在初始化过程中调用pci_register_driver(),使用一个指向描述该驱动程序结构(struct pci_driver)的指针:

struct pci_driver

 定义

struct pci_driver {
  struct list_head        node;
  const char              *name;
  const struct pci_device_id *id_table;
  int (*probe)(struct pci_dev *dev, const struct pci_device_id *id);
  void (*remove)(struct pci_dev *dev);
  int (*suspend)(struct pci_dev *dev, pm_message_t state);
  int (*resume)(struct pci_dev *dev);
  void (*shutdown)(struct pci_dev *dev);
  int (*sriov_configure)(struct pci_dev *dev, int num_vfs);
  int (*sriov_set_msix_vec_count)(struct pci_dev *vf, int msix_vec_count);
  u32 (*sriov_get_vf_total_msix)(struct pci_dev *pf);
  const struct pci_error_handlers *err_handler;
  const struct attribute_group **groups;
  const struct attribute_group **dev_groups;
  struct device_driver    driver;
  struct pci_dynids       dynids;
};

成员

node

  驱动结构链表节点。

name

  驱动名称

id_table

  驱动程序感兴趣的设备id表的指针。大多数驱动程序应该使用MODULE_DEVICE_TABLE(pci,…)导出这个表。

probe

  对于所有匹配ID表且还没有被其他驱动“拥有”的PCI设备,这个探测函数会被调用(在pci_register_driver()对已经存在的设备执行期间,如果插入了新设备,则执行该方法)。对于ID表中条目与设备匹配的每个设备,这个函数被传递一个“struct pci_dev *”。当驱动程序选择获得设备的“所有权”时,探针函数返回0,否则返回一个错误代码(负数)。探测函数总是从进程上下文中被调用,因此它可以休眠。

remove

  每当这个驱动程序处理的设备被移除时(在驱动程序注销或手动从热插拔槽中拔出时),remove()函数就会被调用。remove函数总是从进程上下文中被调用,因此它可以休眠。

suspend

  将设备置于低功耗状态。

resume

  唤醒低功率状态下的设备。

shutdown

  连接到reboot_notifier_list (kernel/sys.c)。意在停止任何空闲的DMA操作。用于启用lan上唤醒(NIC)或在重新启动之前改变设备的电源状态。如drivers/net/e100.c。

sriov_configure

  可选驱动回调,允许配置的VFs数量,通过sysfs “sriov_numvfs”文件。

sriov_set_msix_vec_count

  PF驱动回调来改变VF上MSI-X向量的数量。通过sysfs " sriov_vf_msix_count "触发。这将改变VF Message Control寄存器中的MSI-X表大小。

sriov_get_vf_total_msix

  PF驱动回调以获得用于分配到VFs的MSI-X向量的总数。

err_handler

  错误处理结构

groups

  Sysfs属性组。

dev_groups

  附加到设备上的属性,一旦设备绑定到驱动程序,就会创建这些属性。

driver

  驱动程序模型结构。

dynids

  动态添加的设备id列表。

ID表是一个以全零结尾的struct pci_device_id条目数组。通常首选使用静态const的定义。

struct pci_device_id

定义

struct pci_device_id {
  __u32 vendor, device;
  __u32 subvendor, subdevice;
  __u32 class, class_mask;
  kernel_ulong_t driver_data;
  __u32 override_only;
};

成员

vendor

  要匹配的供应商ID(或PCI_ANY_ID)

device

  要匹配的设备ID(或PCI_ANY_ID)

subvendor

  要匹配的子系统供应商ID(或PCI_ANY_ID)

subdevice

  要匹配的子系统设备ID(或PCI_ANY_ID)

class

  设备类、子类和“接口”要匹配。请参阅PCI Local Bus Spec的附录D或包含/linux/pci_ids.h以获得完整的类列表。大多数驱动程序不需要指定class/class_mask,因为vendor/device通常就足够了。

class_mask

  限制比较类字段的子字段。例如:drivers/scsi/sym53c8xx_2/。

driver_data

  驱动程序私有的数据。大多数驱动不需要使用driver_data字段。最佳实践是使用driver_data作为等效设备类型的静态列表的索引,而不是使用它作为指针。

override_only

  只有当dev->driver_override是这个驱动时才匹配。

大多数驱动只需要PCI_DEVICE()或PCI_DEVICE_CLASS()来建立pci_device_id表。

新的PCI id可以在运行时添加到设备驱动程序pci_ids表中,如下所示:

echo "vendor device subvendor subdevice class class_mask driver_data" > /sys/bus/pci/drivers/{driver}/new_id

所有字段都以十六进制值传入(没有前导0x)。vendor和device字段是必须的,其他字段是可选的。用户只需要通过必要的可选字段:

  • subvendor和subdevice字段默认为PCI_ANY_ID (FFFFFFFF)
  • class 和 classmask字段默认为0
  • driver_data默认为0UL。
  • override_only字段默认为0。

注意,driver_data必须与驱动中定义的任何pci_device_id项所使用的值匹配。如果所有的pci_device_id条目都有一个非零的driver_data值,则driver_data字段是必须的。

一旦添加,驱动程序探测例程将对其(最新更新的)pci_ids列表中列出的任何未声明的PCI设备调用。

当驱动程序退出时,它只调用pci_unregister_driver(), PCI层自动调用驱动程序处理的所有设备的remove回调。

1.2.1 驱动函数/数据的“属性”

请在适当的地方标记初始化和清理函数(相应的宏在<linux/init.h>中定义):

__init 初始化代码。在驱动程序初始化后丢弃。
__exit 退出代码。对于非模块化驱动程序忽略。

关于何时/何处使用上述属性的提示:

  • module_init()/module_exit()函数(以及所有从这些函数中调用_only_的初始化函数)应该标记为__init/__exit。
  • 不要标记结构体pci_driver。
  • 如果不确定要使用哪个标记,则不要标记函数。与其把函数标记错,不如不标记函数。

1.3 如何手动查找PCI设备

PCI驱动程序应该有一个很好的理由不使用pci_register_driver()接口来搜索PCI设备。PCI设备被多个驱动程序控制的主要原因是一个PCI设备实现了几个不同的HW服务。例如组合串行/并行端口/软盘控制器。

手动搜索可以使用以下构造来执行:

按供应商和设备ID搜索:

struct pci_dev *dev = NULL;
while (dev = pci_get_device(VENDOR_ID, DEVICE_ID, dev))
        configure_device(dev);

按class ID搜索(以类似的方式进行迭代):

pci_get_class(CLASS_ID, dev)

通过供应商/设备和子系统供应商/设备ID进行搜索:

pci_get_subsys(VENDOR_ID,DEVICE_ID, SUBSYS_VENDOR_ID, SUBSYS_DEVICE_ID, dev)

可以使用常量PCI_ANY_ID作为VENDOR_ID或DEVICE_ID的通配符。例如,这允许搜索来自特定供应商的任何设备。

这些功能是热插拔安全的。它们增加返回的pci_dev上的引用计数。最终(可能在模块卸载时)必须通过调用pci_dev_put()来减少这些设备上的引用计数。

1.4 设备初始化步骤

正如引言中提到的,大多数PCI驱动程序需要以下步骤进行设备初始化:

  • 启用设备
  • 请求MMIO / IOP资源
  • 设置DMA掩码大小(用于一致性DMA和流式DMA)
  • 分配和初始化共享控制数据(pci_allocate_coherent())
  • 访问设备配置空间(如果需要)
  • 注册IRQ处理程序(request_irq())
  • 初始化non-PCI(即LAN/SCSI/等芯片部分)
  • 启用DMA /处理引擎

驱动程序可以在任何时候访问PCI配置空间寄存器。(好吧,几乎。当运行BIST时,配置空间可以消失…但这只会导致PCI总线Master中止和配置读取将返回垃圾)。

1.4.1 启用PCI设备

在接触任何设备寄存器之前,驱动程序需要通过调用pci_enable_device()来启用PCI设备。这将:

  • 如果设备处于挂起状态,请唤醒它
  • 分配设备的I/O和内存区域(如果BIOS没有做)
  • 分配一个IRQ(如果BIOS没有做)

pci_enable_device()可能会失败!检查返回值。

操作系统BUG:在启用资源之前,我们不会检查资源分配。如果在调用pci_enable_device()之前调用pci_request_resources(),这个序列会更有意义。目前,当两个设备被分配了相同的范围时,设备驱动程序无法检测到这个bug。这不是一个常见的问题,不太可能很快得到解决。

之前已经讨论过这个问题,但在2.6.19中没有改变:https://lore.kernel.org/r/20060302180025.GC28895@flint.arm.linux.org.uk/

pci_set_master() 将通过在PCI_COMMAND寄存器中设置总线主位来启用DMA。它还修复了延迟计时器的值,如果它被BIOS设置为虚假的值。

pci_clear_master() 将通过清除总线master bit位禁用DMA。

如果PCI设备可以使用PCI Memory-Write-Invalidate事务,则调用pci_set_mwi()。这将为Mem-Wr-Inval启用PCI_COMMAND位,并确保正确设置了缓存行大小寄存器。检查pci_set_mwi()的返回值,因为不是所有的架构或芯片集都支持Memory-Write-Invalidate。另外,如果使用memw - inval很好,但不是必需的,则调用pci_try_set_mwi()让系统尽最大努力启用memw - inval。

1.4.2 请求MMIO / IOP资源

内存(MMIO)和I/O端口地址不应该直接从PCI设备配置空间读取。使用pci_dev结构中的值,因为PCI“总线地址”可能已经被特定于arch/芯片集的内核支持重新映射到“主机物理”地址。

关于如何访问设备寄存器或设备内存,请参见io_mapping函数。

设备驱动程序需要调用pci_request_region()来验证没有其他设备已经在使用相同的地址资源。相反,驱动程序应该在调用pci_disable_device()之后调用pci_release_region()。这个想法是为了防止两个设备在相同的地址范围内发生冲突。

参见上面的OS BUG注释。目前(2.6.19),驱动程序只能在调用pci_enable_device()之后确定MMIO和IO端口资源可用性。

一般的pci_request_region()是request_mem_region()(对于MMIO范围)和request_region()(对于IO端口范围)。将这些用于“普通”PCI bar所没有描述的地址资源。

int pci_request_region(struct pci_dev *pdev, int bar, const char *res_name)

  预留PCI I/O和内存资源

参数

struct pci_dev *pdev
  需要预留资源的PCI设备
int bar
  BAR待定
const char *res_name
  要与资源关联的名称

描述

将与PCI设备pdev BAR关联的PCI区域标记为由所有者res_name保留的。不要访问PCI区域内的任何地址,除非这个调用成功返回。

成功返回0,错误返回EBUSY。当出现故障时,还会打印一条警告消息。
 

还请参阅下面的pci_request_selected_regions()。

int pci_request_selected_regions(struct pci_dev *pdev, int bars, const char *res_name)

预留所选PCI I/O和内存资源

参数

struct pci_dev *pdev
  需要预留资源的PCI设备
int bars
  请求的bar的位掩码
const char *res_name
  要与资源关联的名称

1.4.3 设置DMA掩码大小

如果下面的内容没有意义,请参考使用通用设备的动态DMA映射。本节只是提醒您,驱动程序需要指示设备的DMA功能,并不是DMA接口的权威来源。

虽然所有驱动程序都应该显式地指明PCI总线主设备的DMA能力(例如32位或64位),但对于流数据具有32位以上总线主设备能力的设备需要驱动程序通过调用pci_set_dma_mask()与适当的参数来“注册”该能力。一般来说,在系统RAM大于4G _physical_地址的系统上,这允许更有效的DMA。

所有PCI-X和PCIe兼容设备的驱动程序必须调用pci_set_dma_mask(),因为它们是64位DMA设备。

类似地,如果设备可以通过调用 pci_set_consistent_dma_mask() 在系统RAM中直接寻址4G物理地址以上的“一致内存”,驱动程序也必须“注册”这个能力。同样,这包括所有PCI-X和PCIe兼容设备的驱动程序。许多64位“PCI”设备(在PCI- x之前)和一些PCI- x设备都是64位DMA,能够有效负载(“流”)数据,但不能控制(“一致”)数据。

1.4.4 设置共享控制数据

一旦设置了DMA掩码,驱动程序就可以分配“一致的”(也就是共享的)内存。有关DMA api的完整描述,请参阅使用通用设备的动态DMA映射。本节只是提醒您,在设备上启用DMA之前需要进行此操作。

1.4.5 初始化设备寄存器

一些驱动程序将需要特定的“能力”字段编程或其他“供应商特定的”寄存器初始化或重置。例如,清除悬而未决的中断。

1.4.6 注册IRQ处理程序

虽然调用request_irq()是这里描述的最后一步,但这通常只是初始化设备的另一个中间步骤。这个步骤通常可以延迟到设备打开使用。

所有IRQ线路的中断处理程序都应该注册到IRQF_SHARED,并使用devid将IRQs映射到设备(记住所有PCI IRQ线路都可以共享)。

request_irq() 将把一个中断处理程序和设备句柄与一个中断号关联起来。历史上,中断号代表从PCI设备运行到中断控制器的IRQ线。对于 MSI 和 MSI-X(下面更详细),中断数是一个CPU“向量”。

request_irq() 也使能中断。在注册中断处理程序之前,确保设备处于静默状态,并且没有任何中断挂起。

MSI 和 MSI-X 是PCI功能。两者都是“消息通知中断”,它通过DMA写入本地APIC将中断交付给CPU。MSI 和 MSI-X 之间的根本区别在于如何分配多个“向量”。MSI需要连续的向量块,而 MSI-X 可以分配几个单独的向量块。

MSI功能可以通过在调用request_irq()之前使用 PCI_IRQ_MSI 和/或 PCI_IRQ_MSIX 标志调用 pci_alloc_irq_vectors() 来启用。这导致PCI支持将CPU向量数据编程到PCI设备能力寄存器中。许多架构、芯片集或BIOS不支持 MSI 或 MSI-X,只使用PCI_IRQ_MSI和PCI_IRQ_MSIX标志调用pci_alloc_irq_vectors将会失败,因此尝试始终指定PCI_IRQ_LEGACY。

对于MSI/MSI-X和遗留INTx有不同中断处理程序的驱动程序,在调用pci_alloc_irq_vectors之后,应该根据pci_dev结构中的 msi_enabled 和 msix_enabled 标志选择正确的中断处理程序。

使用MSI有(至少)两个很好的理由:

  1. 根据定义,MSI是一个互斥中断向量。这意味着中断处理程序不必验证引起中断的设备。
  2. MSI避免了DMA/IRQ竞争条件。当MSI被交付时,主机内存的DMA保证对主机CPU是可见的。这对于数据一致性和避免陈旧的控制数据都很重要。这种保证允许驱动程序省略MMIO读取以刷新DMA流。

请参阅drivers/infiniband/hw/mthca/或drivers/net/tg3.c以了解MSI/MSI- x的用法。

1.5  PCI设备关闭

在卸载PCI设备驱动程序时,需要执行以下大部分步骤:

  • 禁止设备产生irq
  • 释放IRQ (free_irq())
  • 停止所有DMA活动
  • 释放DMA缓冲区(包括流式DMA和一致性DMA)
  • 从其他子系统注销(例如scsi或netdev)
  • 禁止设备响应MMIO/IO端口地址
  • 释放MMIO/IO端口资源

1.5.1 停止设备上的IRQs

如何做到这一点是芯片/设备特定的。如果没有这样做,当(且仅当)IRQ与另一个设备共享时,就有可能出现“screaming interrupt”。

当共享IRQ处理程序被“解除钩”时,使用相同IRQ线路的其余设备仍然需要启用IRQ。因此,如果“断开”的设备断言了IRQ线路,系统将响应假设它是断言了IRQ线路的其余设备之一。由于没有其他设备将处理IRQ,系统将“挂起”,直到它决定IRQ不会被处理并掩盖IRQ(100,000次迭代后)。一旦共享IRQ被屏蔽,其余的设备将停止正常工作。情况不妙。

这是另一个使用MSI或MSI-X的原因。MSI和MSI-X被定义为排他中断,因此不容易受到“screaming interrupt”问题的影响。

1.5.2 释放IRQ

一旦设备处于静止状态(不再有irq),就可以调用free_irq()。这个函数将在任何待处理的IRQ被处理后返回控制,从那个IRQ“解除”驱动程序的IRQ处理程序,如果没有其他人在使用它,最后释放这个IRQ。

1.5.3 停止所有DMA活动

在试图释放DMA控制数据之前,停止所有DMA操作是非常重要的。如果不这样做,可能会导致内存损坏、挂起,并在某些芯片集上导致硬崩溃。

在停止IRQ之后停止DMA可以避免IRQ处理程序可能重新启动DMA引擎的竞争。

虽然这一步听起来很明显也很琐碎,但是一些“成熟的”驱动程序在过去并没有完成这一步。

1.5.4 释放DMA缓冲区

一旦DMA停止,首先清理流式DMA。例如,unmap数据缓冲区和返回缓冲区的“上游”所有者,如果有一个。

然后清理包含控制数据的“一致”缓冲区。

有关取消映射接口的详细信息,请参阅使用通用设备的动态DMA映射。

1.5.5 从其他子系统注销注册

大多数低级别PCI设备驱动程序支持USB、ALSA、SCSI、NetDev、Infiniband等其他子系统。确保您的驱动程序没有丢失来自其他子系统的资源。如果发生这种情况,通常的症状是当子系统试图调用已卸载的驱动程序时出现Oops (panic)。

1.5.6 禁用设备响应MMIO/IO端口地址

io_unmap() MMIO或IO端口资源,然后调用 pci_disable_device()。这与 pci_enable_device() 对称相反。不要在调用 pci_disable_device() 后访问设备寄存器。

1.5.7 释放MMIO/IO端口资源

调用pci_release_region()来标记MMIO或IO端口范围为可用的。如果不这样做,通常会导致无法重新加载驱动程序。

1.6 如何访问PCI配置空间

您可以使用pci_(read|write)_config_(byte|word|dword)访问由struct pci_dev *表示的设备的配置空间。所有这些函数在成功或错误代码(PCIBIOS_…)时返回0,该错误代码可以通过pcibios_strerror转换为文本字符串。大多数驱动程序都希望对有效PCI设备的访问不会失败。

如果你没有一个结构体pci_dev可用,你可以调用pci_bus_(read|write)_config_(byte|word|dword)来访问总线上的给定设备和函数。

如果您访问配置头的标准部分的字段,请使用<linux/pci.h>中声明的位置和位的符号名。

如果您需要访问Extended PCI Capability寄存器,只需为特定的功能调用 pci_find_capability(),它将为您找到相应的寄存器块。

1.7 其他有关的函数

pci_get_domain_bus_and_slot()

找到对应于给定域、总线、槽位和编号的pci_dev。如果找到该设备,则增加其引用计数。

pci_set_power_state()

设置PCI电源管理状态(0=D0…3=D3)

pci_find_capability()

在设备的功能列表中找到指定的功能。

pci_resource_start()

返回给定PCI区域的总线起始地址

pci_resource_end()

返回给定PCI区域的总线端地址

pci_resource_len()

返回PCI区域的字节长度

pci_set_drvdata()

为pci_dev设置私有驱动数据指针

pci_get_drvdata()

返回pci_dev的私有驱动程序数据指针

pci_set_mwi()

使能Memory-Write-Invalidate事务

pci_clear_mwi()

禁用Memory-Write-Invalidate事务

1.8 各种提示

当向用户显示PCI设备名称时(例如,当驱动程序想告诉用户它找到了什么卡时),请使用pci_name(pci_dev)。

总是通过指向pci_dev结构的指针来引用PCI设备。所有PCI层函数都使用这个标识,而且它是唯一合理的标识。不要使用总线/插槽/函数号,除非是非常特殊的用途——在有多个主总线的系统上,它们的语义可能相当复杂。

不要试图打开快速背靠背写在你的驱动程序。总线上的所有设备都需要能够做到这一点,所以这需要由平台和通用代码来处理,而不是单个驱动程序。

1.9 供应商和设备标识

不要添加新的设备或厂商id来包含/linux/pci_ids.h,除非它们在多个驱动程序之间共享。如果有用的话,你可以在驱动中添加私有定义,或者使用普通的十六进制常量。

设备id是任意的十六进制数字(供应商控制),通常只在pci_device_id表中使用。

请提交新的供应商/设备IDs到https://pci-ids.ucw.cz/。这是一个pci.ids文件的镜像在 https://github.com/pciutils/pciids。

1.10 已停用的函数

在尝试将旧驱动程序移植到新的PCI接口时,可能会遇到几个函数。它们不再出现在内核中,因为它们与热插拔或PCI域不兼容,或者没有正常的锁定。

pci_find_device()

被 pci_get_device() 取代

pci_find_subsys()

被 pci_get_subsys() 取代

pci_find_slot()

被 pci_get_domain_bus_and_slot() 取代

pci_get_slot()

被  pci_get_domain_bus_and_slot() 取代

另一种选择是传统的PCI设备驱动程序,它遍历PCI设备列表。这仍然是可能的,但令人沮丧。

1.11 MMIO空间与“Write Posting”

将驱动程序从使用I/O端口空间转换为使用MMIO空间通常需要一些额外的更改。具体来说,“write posting”需要处理。许多驱动程序(如tg3, acenic, sym53c8xx_2)已经这样做了。I/O端口空间保证在CPU继续之前写事务到达PCI设备。对MMIO空间的写操作允许CPU在事务到达PCI设备之前继续。HW weenies称之为“Write Posting”,因为在事务到达目的地之前,写完成被“post”到CPU。

因此,对时间敏感的代码应该添加readl(),以便CPU在执行其他工作之前等待。经典的“bit banging”序列适用于I/O端口空间:

for (i = 8; --i; val >>= 1) {
        outb(val & 1, ioport_reg);      /* write bit */
        udelay(10);
}

MMIO空间的相同序列应为:

for (i = 8; --i; val >>= 1) {
        writeb(val & 1, mmio_reg);      /* write bit */
        readb(safe_mmio_reg);           /* flush posted write */
        udelay(10);
}

重要的是,“safe_mmio_reg”不会有任何影响设备正确操作的副作用。

另一个需要注意的情况是重置PCI设备。使用PCI Configuration空间读取来刷新writel()。如果PCI设备不响应readl(),这将优雅地处理所有平台上的PCI Master中止。大多数x86平台将允许MMIO读取master中止(也称为“Soft Fail”)并返回垃圾(例如~0)。但是许多RISC平台会崩溃(也就是“Hard Fail”)。

本文来自博客园,作者:王楼小子,转载请注明原文链接:https://www.cnblogs.com/wanglouxiaozi/p/15525726.html

原文地址:https://www.cnblogs.com/wanglouxiaozi/p/15525726.html