Linux下PCI设备驱动程序开发(1)

一、PCI总线系统体系结构

PCI是外围设备互连(Peripheral Component Interconnect)的简称,作为一种通用的总线接口标准,它在目前的计算机系统中得到了非常广泛的应用。PCI提供了一组完整的总线接口规范,其目的是描述如何将计算机系统中的外围设备以一种结构化和可控化的方式连接在一起,同时它还刻画了外围设备在连接时的电气特性和行为规约,并且详细定义了计算机系统中的各个不同部件之间应该如何正确地进行交互。

无论是在基于Intel芯片的PC机中,或是在基于Alpha芯片的工作站上,PCI毫无疑问都是目前使用最广泛的一种总线接口标准。同旧式的ISA总线不同,PCI将计算机系统中的总线子系统与存储子系统完全地分开,CPU通过一块称为PCI桥(PCI-Bridge)的设备来完成同总线子系统的交互,如图1所示。

图1 PCI子系统的体系结构
图1 PCI子系统的体系结构

由于使用了更高的时钟频率,因此PCI总线能够获得比ISA总线更好的整体性能。PCI总线的时钟频率一般在25MHz到33MHz范围内,有些甚至能够达到66MHz或者133MHz,而在64位系统中则最高能达到266MHz。尽管目前PCI设备大多采用32位数据总线,但PCI规范中已经给出了64位的扩展实现,从而使PCI总线能够更好地实现平台无关性,现在PCI总线已经能够用于IA-32、Alpha、PowerPC、SPARC64和IA-64等体系结构中。

PCI总线具有三个非常显著的优点,使得它能够完成最终取代ISA总线这一历史使命:

  • 在计算机和外设间传输数据时具有更好的性能;
  • 能够尽量独立于具体的平台;
  • 可以很方便地实现即插即用。

图2是一个典型的基于PCI总线的计算机系统逻辑示意图,系统的各个部分通过PCI总线和PCI-PCI桥连接在一起。从图中不难看出,CPU和RAM需要通过PCI桥连接到PCI总线0(即主PCI总线),而具有PCI接口的显卡则可以直接连接到主PCI总线上。PCI-PCI桥是一个特殊的PCI设备,它负责将PCI总线0和PCI总线1(即从PCI主线)连接在一起,通常PCI总线1称为PCI-PCI桥的下游(downstream),而PCI总线0则称为PCI-PCI桥的上游(upstream)。图中连接到从PCI总线上的是SCSI卡和以太网卡。为了兼容旧的ISA总线标准,PCI总线还可以通过PCI-ISA桥来连接ISA总线,从而能够支持以前的ISA设备。图中ISA总线上连接着一个多功能I/O控制器,用于控制键盘、鼠标和软驱。

图2 PCI系统示意图
图2 PCI系统示意图

在此我只对PCI总线系统体系结构作了概括性介绍,如果读者想进一步了解,David A Rusling在The Linux Kernel(http://tldp.org/LDP/tlk/dd/pci.html)中对Linux的PCI子系统有比较详细的介绍。

二,和PCI驱动程序相关的几个数据结构

驱动程序总是离不开数据结构,在Linux中,用数据结构来表示各色各样的设备或者其他的东西。因此,我们掌握设备驱动程序的关键之一,就是对各种数据结构的理解和运用。

1.pci_device_id

在介绍该结构之前,让我们来看看PCI的地址空间:I/O空间,存储空间,配置空间。

CPU 可以访问PCI设备上的所有地址空间,其中I/O空间和存储空间提供给设备驱动程序使用,而配置空间则由Linux内核中的PCI初始化代码使用,内核在 启动时负责对所有PCI设备进行初始化,配置好所有的PCI设备,包括中断号以及I/O基址,并在文件/proc/pci中列出所有找到的PCI设备,以 及这些我设备的参数和属性。

下图是PCI配置寄存器。

 

我们并不需要去了解配置寄存器的所有位代表了什么,有什么含义。我们只要用三个或者五个PCI寄存器去标识一个设备即可。通常,我们会选择下面三个寄存器:

vendorID:标识硬件制造商,是一个16位的寄存器。

deviceID:设备ID,由制造商选择,也是一个16位的寄存器。一般与厂商ID配对生成一个唯一的32位硬件设备标识符。

class:每个外部设备属于某个类(class),也是一个16位的寄存器。当某个驱动程序可支持多个相似的设备,每个具有不同的签名,但都属于同一个类,这时,就可以用class类对它们的外设进行识别。

讲了这么多,那我们应该怎么去设置这些值呢?不用怕,内核已经为我们都想好了,它已经将这些都归纳到一个数据结构进去了,我们要做的就是对这个数据结构进行填充,是不是很方便啊!

这个数据结构就是--pci_device_id。

    struct pci_device_id {
    __u32 vendor, device;/* Vendor and device ID or PCI_ANY_ID*/
    __u32 subvendor, subdevice;/* Subsystem ID's or PCI_ANY_ID */
    __u32 class, class_mask;/* (class,subclass,prog-if) triplet */
    kernel_ulong_t driver_data;/* Data private to the driver */
    };

那现在问题又来了,我们前面说过,一个驱动程序可以匹配一个甚至多个设备。那么,此时我们又该如何呢?可以想到数组,对吧。是的,不过这里有点地方需要注意

    staticstruct pci_device_id example_pci_tbl [] __initdata ={
    {PCI_VENDOR_ID_EXAMPLE, PCI_DEVICE_ID_EXAMPLE, PCI_ANY_ID, PCI_ANY_ID,0,0, EXAMPLE},
    {0,}
    };

注意到了吧,是的,不管你这里匹配了多少设备,记得最后一个都是{0,}。

这里还有两个关于初始化该结构体的宏,可以用来简化相关的操作。

PCI_DEVICE(vendor, device)

        创建一个仅和特定厂商及设备ID相匹配的struct pci_device_id。它把结构体的subvendor和subdevice设为PCI_ANY_ID。PCI_ANY_ID定义如下:

    #define PCI_ANY_ID (~0)

PCI_DEVICE_CLASS(device_class, device_class_mask)

        创建一个和特定PCI类相匹配的struct pci_device_id。

这里不再多说。

2.pci_driver

按照上面说的,你已经将你要匹配的设备说明了,但这仅仅只是说明,内核如何去识别它们呢?那就要用到下面的数据结构了--pci_driver。

复制代码
struct pci_driver {
    struct list_head node;
    char*name;
    conststruct pci_device_id *id_table;/* must be non-NULL for probe to be called */
    int(*probe)(struct pci_dev *dev,conststruct pci_device_id *id);/* New device inserted */
    void(*remove)(struct pci_dev *dev);/* Device removed (NULL if not a hot-plug capable driver) */
    int(*suspend)(struct pci_dev *dev,pm_message_t state);/* Device suspended */
    int(*suspend_late)(struct pci_dev *dev,pm_message_t state);
    int(*resume_early)(struct pci_dev *dev);
    int(*resume)(struct pci_dev *dev);/* Device woken up */
    void(*shutdown)(struct pci_dev *dev);
    struct pci_error_handlers *err_handler;
    struct device_driver driver;
    struct pci_dynids dynids;
};
复制代码

从上面的结构体定义可以看出,它的作用并不仅仅是识别设备的id_table结构,还包括了检测设备的 函数probe( )和卸载设备的函数remove( ):这种结构体,我们之前就已经接触过很多了,不再多说。

3.pci_dev

让我们来最后最后一个相关的数据结构--pci_dev。

复制代码
/*
* The pci_dev structure is used to describe PCI devices.
*/
struct pci_dev {
    struct list_head bus_list;/* node in per-bus list */
    struct pci_bus *bus;/* bus this device is on */
    struct pci_bus *subordinate;/* bus this device bridges to */
     
    void*sysdata;/* hook for sys-specific extension */
    struct proc_dir_entry *procent;/* device entry in /proc/bus/pci */
    struct pci_slot *slot;/* Physical slot this device is in */
     
    unsignedint devfn;/* encoded device & function index */
    unsignedshort vendor;
    unsignedshort device;
    unsignedshort subsystem_vendor;
    unsignedshort subsystem_device;
    unsignedintclass;/* 3 bytes: (base,sub,prog-if) */
    u8 revision;/* PCI revision, low byte of class word */
    u8 hdr_type;/* PCI header type (`multi' flag masked out) */
    u8 pcie_cap;/* PCI-E capability offset */
    u8 pcie_type;/* PCI-E device/port type */
    u8 rom_base_reg;/* which config register controls the ROM */
    u8 pin;/* which interrupt pin this device uses */
     
    struct pci_driver *driver;/* which driver has allocated this device */
    u64 dma_mask;/* Mask of the bits of bus address this
    device implements. Normally this is
    0xffffffff. You only need to change
    this if your device has broken DMA
    or supports 64-bit transfers. */
     
    struct device_dma_parameters dma_parms;
     
    pci_power_t current_state;/* Current operating state. In ACPI-speak,
    this is D0-D3, D0 being fully functional,
    and D3 being off. */
    int pm_cap;/* PM capability offset in the
    configuration space */
    unsignedint pme_support:5;/* Bitmask of states from which PME#
    can be generated */
    unsignedint pme_interrupt:1;
    unsignedint d1_support:1;/* Low power state D1 is supported */
    unsignedint d2_support:1;/* Low power state D2 is supported */
    unsignedint no_d1d2:1;/* Only allow D0 and D3 */
    unsignedint mmio_always_on:1;/* disallow turning off io/mem
    decoding during bar sizing */
    unsignedint wakeup_prepared:1;
    unsignedint d3_delay;/* D3->D0 transition time in ms */
     
    #ifdef CONFIG_PCIEASPM
    struct pcie_link_state *link_state;/* ASPM link state. */
    #endif
     
    pci_channel_state_t error_state;/* current connectivity state */
    struct device dev;/* Generic device interface */
     
    int cfg_size;/* Size of configuration space */
     
    /*
    * Instead of touching interrupt line and base address registers
    * directly, use the values stored here. They might be different!
    */
    unsignedint irq;
    struct resource resource[DEVICE_COUNT_RESOURCE];/* I/O and memory regions + expansion ROMs */
    resource_size_t fw_addr[DEVICE_COUNT_RESOURCE];/* FW-assigned addr */
     
    /* These fields are used by common fixups */
    unsignedint transparent:1;/* Transparent PCI bridge */
    unsignedint multifunction:1;/* Part of multi-function device */
    /* keep track of device state */
    unsignedint is_added:1;
    unsignedint is_busmaster:1;/* device is busmaster */
    unsignedint no_msi:1;/* device may not use msi */
    unsignedint block_ucfg_access:1;/* userspace config space access is blocked */
    unsignedint broken_parity_status:1;/* Device generates false positive parity */
    unsignedint irq_reroute_variant:2;/* device needs IRQ rerouting variant */
    unsignedint msi_enabled:1;
    unsignedint msix_enabled:1;
    unsignedint ari_enabled:1;/* ARI forwarding */
    unsignedint is_managed:1;
    unsignedint is_pcie:1;/* Obsolete. Will be removed.
    Use pci_is_pcie() instead */
    unsignedint needs_freset:1;/* Dev requires fundamental reset */
    unsignedint state_saved:1;
    unsignedint is_physfn:1;
    unsignedint is_virtfn:1;
    unsignedint reset_fn:1;
    unsignedint is_hotplug_bridge:1;
    unsignedint __aer_firmware_first_valid:1;
    unsignedint __aer_firmware_first:1;
    pci_dev_flags_t dev_flags;
    atomic_t enable_cnt;/* pci_enable_device has been called */
     
    u32 saved_config_space[16];/* config space saved at suspend time */
    struct hlist_head saved_cap_space;
    struct bin_attribute *rom_attr;/* attribute descriptor for sysfs ROM entry */
    int rom_attr_enabled;/* has display of the rom attribute been enabled? */
    struct bin_attribute *res_attr[DEVICE_COUNT_RESOURCE];/* sysfs file for resources */
    struct bin_attribute *res_attr_wc[DEVICE_COUNT_RESOURCE];/* sysfs file for WC mapping of resources */
    #ifdef CONFIG_PCI_MSI
    struct list_head msi_list;
    #endif
    struct pci_vpd *vpd;
    #ifdef CONFIG_PCI_IOV
    union{
    struct pci_sriov *sriov;/* SR-IOV capability related */
    struct pci_dev *physfn;/* the PF this VF is associated with */
    };
    struct pci_ats *ats;/* Address Translation Service */
    #endif
};
复制代码

由上面的定义可以知道,它详细描述了一个PCI设备几乎所有的硬件信息,包括厂商ID、设备ID、各种资源等:

三,基本框架

上面将我们要用到的一些基本信息都做了一些简单的介绍。下面,我们就来看看PCI驱动程序的一个基本的框架,如何将这些东西进行整理成一个程序。

复制代码
 1         staticstruct pci_device_id example_pci_tbl [] __initdata ={
 2          {PCI_VENDOR_ID_EXAMPLE, PCI_DEVICE_ID_EXAMPLE, PCI_ANY_ID, PCI_ANY_ID,0,0, EXAMPLE},
 3          {0,}
 4         };
 5         /* 对特定PCI设备进行描述的数据结构 */
 6         struct example_pci {
 7          unsignedint magic;
 8          /* 使用链表保存所有同类的PCI设备 */
 9          struct example_pci *next;
10         
11          /* ... */
12         }
13         /* 中断处理模块 */
14         staticvoid example_interrupt(int irq,void*dev_id,struct pt_regs *regs)
15         {
16          /* ... */
17         }
18         /* 设备文件操作接口 */
19         staticstruct file_operations example_fops ={
20          owner: THIS_MODULE,/* demo_fops所属的设备模块 */
21          read: example_read,/* 读设备操作*/
22          write: example_write,/* 写设备操作*/
23          ioctl: example_ioctl,/* 控制设备操作*/
24          open: example_open,/* 打开设备操作*/
25          release: example_release /* 释放设备操作*/
26          /* ... */
27         };
28         /* 设备模块信息 */
29         staticstruct pci_driver example_pci_driver ={
30          name: example_MODULE_NAME,/* 设备模块名称 */
31          id_table: example_pci_tbl,/* 能够驱动的设备列表 */
32          probe: example_probe,/* 查找并初始化设备 */
33          remove: example_remove /* 卸载设备模块 */
34          /* ... */
35         };
36         staticint __init example_init_module (void)
37         {
38          /* ... */
39         }
40         staticvoid __exit example_cleanup_module (void)
41         {
42          pci_unregister_driver(&demo_pci_driver);
43         }
44         /* 加载驱动程序模块入口 */
45         module_init( example_init_module);
46         /* 卸载驱动程序模块入口 */
47         module_exit( example_cleanup_module);

继续探究PCI驱动程序实现:

一,初始化设备模块

  Linux内核启动并完成对所有PCI设备进行扫描、登录和分配资源等初始化操作的同时,会建立起系统中所有PCI设备的拓扑结构,此后当PCI驱动程序需要对设备进行初始化时,一般都会调用如下的代码:

复制代码
 static int __init example_init_module (void)   
 {
    /* 注册硬件驱动程序 */
         if(!pci_register_driver(&example_pci_driver)){
         pci_unregister_driver(&example_pci_driver);
         return-ENODEV;
         }
         /* ... */
        
         return 0;
 }
复制代码

 从上面的省略号可以看出,这仅仅只是核心的一部分,其他的东西,就要看你具体的应用是在什么地方。

仅仅使用上面的init函数是不够的,因为此时你并不知道你的设备是什么样的,是不是都已经准备好了,因此,还有更重要的一步---probe。探测完成对硬件的检测工作。

我们先将相关代码进行列示:

复制代码
 static int __init example_probe(struct pci_dev *pci_dev,conststruct pci_device_id *pci_id)    
{
         struct example_pci *my_pci;
         /* 启动PCI设备 */
         if(pci_enable_device(pci_dev))
         return-EIO;
         /* 设备DMA标识 */
         if(pci_set_dma_mask(pci_dev, EXAMPLE_DMA_MASK))
         return-ENODEV;
         /* 在内核空间中动态申请内存 */
         if((my_pci = kmalloc(sizeof(struct example_pci), GFP_KERNEL))== NULL){
         printk(KERN_ERR "example_pci: out of memory\n");
         return-ENOMEM;
         }
         memset(my_pci,0,sizeof(*my_pci));
         /* 读取PCI配置信息 */
         my_pci->iobase = pci_resource_start(pci_dev,1);
         my_pci->pci_dev = pci_dev;
         my_pci->pci_id = pci_id->device;
         my_pci->irq = pci_dev->irq;
         my_pci->next= devs;
         my_pci->magic = EXAMPLE_MAGIC;
         /* 设置成总线主DMA模式 */
         pci_set_master(pci_dev);
         /* 申请I/O资源 */
         request_region(my_pci->iobase,64,my_pci_names[pci_id->driver_data]);
         return 0;
}
复制代码

 

整个程序的思路很清晰,并不需要去太多的讲解,只是对里面的一些函数需要进行一下讲解.

1.pci_enable_device

激活PCI设备,在驱动程序可以访问PCI设备的任何设备资源之前(I/O区域或者中断),驱动程序必须调用该函数:

   int pci_enable_device(struct pci_dev *dev);                       /*driver/pci/pci.c*/

 该函数实际的激活设备。它把设备唤醒,在某些情况下还指派它的中断线和I/O区域。

2.访问PCI地址空间

在驱动程序检测到设备之后,它通常需要读取或写入三个地址空间:内存,端口和配置。对驱动程序来说,对配置空间的访问至关重要,因为这是它找到设备映射到内存和I/O空间的什么位置的唯一途径。

因而,首先来看看配置空间的访问:

Linux内核为我们想的很周到,在内核中就已经提供了访问配置空间的标准接口,我们只要去直接调用就好了。

对于驱动程序而言,可通过8位,16位,32位的数据传输访问配置空间。相关函数定义在<linux/pci.h>中:

    int pci_read_config_byte(conststruct pci_dev *dev,intwhere, u8 *val);/*8位,读入一个字节*/
     
    int pci_read_config_word(conststruct pci_dev *dev,intwhere, u16 *val);/*16位,读入两个字节*/
     
    int pci_read_config_dword(conststruct pci_dev *dev,intwhere, u32 *val);/*32位,读入四个字节*/

      const struct pci_dev *dev:由dev标识的设备配置空间;

      int where:从配置空间起始位置计算的字节偏移量;

      u8/u16/u32 *val:从配置空间获得的值通过val指针返回;

      函数本身返回的值是错误码。

注意:word和dword函数会将读取到的little-endian值转换成处理器固有的字节序。我们自己无需处理字节序。

上面的是读的情况,写的情况也是类似的<linux/pci.h>:

    int pci_write_config_byte(conststruct pci_dev *dev,intwhere, u8 *val);/*8位,写入一个字节*/
     
    int pci_write_config_word(conststruct pci_dev *dev,intwhere, u16 *val);/*16位,写入两个字节*/
     
    int pci_write_config_dword(conststruct pci_dev *dev,intwhere, u32 *val);/*32位,写入四个字节*/

 因此,我们可以利用上面的函数读取和修改设备的信息。

讲完配置空间,接下来唠叨一下I/O和内存空间。

一个PCI设备可实现多达6个I/O地址区域,每个区域既可以使内存也可以是I/O地址。在内核中PCI设备的I/O区域已经被集成到通用资源管理器。因此,我们无需访问配置变量来了解设备被映射到内存或者I/O空间的何处。获得区域信息的首选接口是下面的宏定义:

  #define pci_resource_start(dev, bar)((dev)->resource[(bar)].start)

 该宏返回六个PCI I/O区域之一的首地址(内存地址或者I/O端口号).该区域由整数的bar(base address register,基地址寄存器)指定,bar取值为0到5。

    #define pci_resource_end(dev, bar)((dev)->resource[(bar)].end)

 该宏返回第bar个I/O区域的首地址。注意这是最后一个可用的地址,而不是该区域之后的第一个地址。

    #define pci_resource_flags(dev, bar)((dev)->resource[(bar)].flags)

 该宏返回和该资源相关联的标志。

资源标志用来定义单个资源的特性,对与PCI I/O区域相关的PCI资源,该信息从基地址寄存器中获得,但对于和PCI无关的资源,它可能来自其他地方。所有资源标志定义在<linux/ioport.h>。

二,打开设备模块

复制代码
static int example_open(struct inode *inode, struct file *file)    
{
         /* 申请中断,注册中断处理程序 */
         request_irq(my_pci->irq, &example_interrupt, SA_SHIRQ, my_pci_names[pci_id->driver_data], my_pci)) ;
         /* 检查读写模式 */
         if(file->f_mode & FMODE_READ) {
         /* ... */
         }
         if(file->f_mode & FMODE_WRITE) {
         /* ... */
         }
         
         /* 申请对设备的控制权 */
         down(&my_pci->open_sem);
         while(my_pci->open_mode & file->f_mode) {
             if (file->f_flags & O_NONBLOCK) {
                 /* NONBLOCK模式,返回-EBUSY */
                 up(&my_pci->open_sem);
                 return -EBUSY;
             } else {
                 /* 等待调度,获得控制权 */
                 my_pci->open_mode |= f_mode & (FMODE_READ | FMODE_WRITE);
                 up(&my_pci->open_sem);
                 /* 设备打开计数增1 */
                 MOD_INC_USE_COUNT;
                 /* ... */
             }
         }
}
复制代码

 在这个模块里主要实现申请中断、检查读写模式以及申请对设备的控制权等。在申请控制权的时候,非阻塞方式遇忙返回,否则进程主动接受调度,进入睡眠状态,等待其它进程释 放对设备的控制权。

三,数据读写和信息控制模块

复制代码
static int example_ioctl(struct inode *inode, struct file *file, unsigned int cmd, unsigned long arg)   
{
         /* ... */
         
         switch(cmd) {
         case EXAMPLE_RDATA:
             /* 从I/O端口读取4字节的数据 */
             val = inl(my_pci->iobae + 0x10);
             
            /* 将读取的数据传输到用户空间 */
             return 0;
         }
         
         /* ... */
}
复制代码

  PCI设备驱动程序可以通过example_fops结构中的函数example_ioctl( ),向应用程序提供对硬件进行控制的接口。例如,通过它可以从I/O寄存器里读取一个数据,并传送到用户空间里。

四,中断模块

复制代码
    static void example_interrupt(int irq, void *dev_id, struct pt_regs *regs)
    {
             struct example_pci *my_pci = (struct example_pci *)dev_id;
             u32 status;
             spin_lock(&my_pci->lock);
             /* 中断 */
             status = inl(my_pci->iobase + GLOB_STA);
             if(!(status & INT_MASK)) 
             {
                 spin_unlock(&my_pci->lock);
                 return; /* not for us */
             }
             /* 告诉设备已经收到中断 */
             outl(status & INT_MASK, my_pci->iobase + GLOB_STA);
             spin_unlock(&my_pci->lock);
             
             /* 其它进一步的处理 */
    }
复制代码

 PCI的中断资源比较有限,只有0~15的中断号,因此大部分外部设备都是以共享的形式申请中断号的。当中断发生的时候,中断处理程序首先负责对中断进行识别,然后再做进一步的处理。

五,释放设备模块

复制代码
static int example_release(struct inode *inode, struct file *file)    
{
     /* ... */
     
     /* 释放对设备的控制权 */
     my_pci->open_mode &= (FMODE_READ | FMODE_WRITE);
     
     /* 唤醒其它等待获取控制权的进程 */
     wake_up(&my_pci->open_wait);
     up(&my_pci->open_sem);
     
     /* 释放中断 */
     free_irq(my_pci->irq, my_pci);
     
     /* 设备打开计数增1 */
     MOD_DEC_USE_COUNT;
     
     /* ... */

    六. 卸载设备模块

    卸载设备模块与初始化设备模块是相对应的,实现起来相对比较简单,主要是调用函数pci_unregister_driver( )从Linux内核中注销设备驱动程序:

    static void __exit demo_cleanup_module (void)

    {

     pci_unregister_driver(&demo_pci_driver);

    }

小结

PCI总线不仅是目前应用广泛的计算机总线标准,而且是一种兼容性最强、功能最全的计算机总线。而Linux作为一种新的操作系统,其发展前景是无法估量的,同时也为PCI总线与各种新型设备互连成为可能。由于Linux源码开放,因此给连接到PCI总线上的任何设备编写驱动程序变得相对容易。本文介绍如何编译Linux下的PCI驱动程序,针对的内核版本是2.4。

http://www.ibm.com/developerworks/cn/linux/l-pci/index.html
  • 2
    点赞
  • 23
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值