usb驱动是linux内核中比较复杂的驱动之一,因此,大多数usb教程建议从usb-skeleton开始学习usb驱动。个人认为这是相当正确的,usb-sekelton提供了一个usb驱动开发的模板,而且代码量较少,很适合初学者的学习。

记住,对于c语言的程序设计说,数据结构是整个程序的灵魂。因此,分析别人编写的代码的简洁的入口点就是高清代码中主要数据结构之间的关系。分析以usb-skeleton为例的完整的usb驱动框架,我们就从主要的几个数据结构入手。

一、usb驱动框架的主要数据结构

usb驱动框架主要包括设备,配置,接口和端点几个组成部分,相对应的数据结构为:

struct usb_device;                //usb设备

struct usb_host_config;//usb配置

struct usb_host_interface;//usb接口

struct usb_host_endpoit;          //usb端口

它们之间的关系可以形象的表示为图1。

上图描述的usb各组成部分的关系可以描述为:

(1)一个usb设备可以有多个usb配置,多个配置之间可以相互切换,某个时刻只能对应一个配置;

(2)一个usb配置可以有多个usb接口,一个usb接口代表了一个基本功能,对以一个usb客户端驱动程序;

(3)一个usb接口可以有多个usb端点;

就像我们平时程序设计经常使用的方法一样,一个对象由一个结构体来表示,但还会再用来一个结构体来描述这个对象的一些属性。usb驱动框架也采用了这样的设计思想,usb框架中每一个组成部分都用两个结构体来描述:一个结构体表示成员组成,另一个结构体表示属性组成。Linux-USB核心定义了4个usb描述符。

struct usb_device_descriptor; struct usb_device;

struct usb_host_config; struct usb_config_descriptor;

struct usb_host_interface; struct usb_interface_descriptor;

struct usb_host_endpoint; struct usb_endpoint_descriptor;

另外,还有一个比较特殊的数据结构是URB(USB Request Block),被USB协议栈使用,是USB数据传输机制的核心数据结构,具体的URB的类型,URB的使用步骤等读者可以参考《Linux设备驱动程序》一书,本文不做详细介绍。

二、USB设备的枚举过程

USB设备是一种典型的热插拔设备,与PCI设备类似,当总线检测到有设备插入的时候,总线驱动程序就会去遍历总线上已经挂载的所有的设备驱动程序,查看有没有驱动程序与刚插入的设备匹配,如果匹配成功,则去执行驱动程序中的probe函数。这是Linux内核中经典的驱动和设备挂钩的方式之一。

三、分析代码的执行过程

要理解代码的含义,最关键的是理清代码的执行路径,即代码中函数的调用关系。光靠source insight或eclipse代码分析工具有时候略显不够,对于那些没有直接调用关系的函数这些静态代码分析工具爱莫能助。最好方法是能看到代码一步一步的执行流程,那么,单步调试就是比较好的选择了。本文采用KVM+GDB的方式对usb-skeleton模块进行了单步调试。Linux内核调试环境的搭建参考文章《qemu+eclipse内核调试》,这里仅需要创建一个Makefile文件,编译usb-skeleton.c即可。KVM中usb设备的使用参考文章《KVM中使用usb设备》。需要注意的是,Linux内中默认的usb存储设备的驱动模块名称为usb-storage,在调试前,先卸载该模块。

四、usb-skeleton主要代码分析(Linux-2.6.35)

1 skel_probe函数

static int skel_probe(struct usb_interface *interface,

const struct usb_device_id *id)

{

struct usb_skel *dev;

struct usb_host_interface *iface_desc;

struct usb_endpoint_descriptor *endpoint;

size_t buffer_size;

int i;

int retval = -ENOMEM;

/* allocate memory for our device state and initialize it */

dev = kzalloc(sizeof(*dev), GFP_KERNEL);

if (!dev) {

err("Out of memory");

goto error;

}

kref_init(&dev->kref);

sema_init(&dev->limit_sem, WRITES_IN_FLIGHT);

mutex_init(&dev->io_mutex);/IO操作互斥锁,在进行IO操作时,不允许进行其他操作,如数据拷贝,后面会提到/

spin_lock_init(&dev->err_lock);

init_usb_anchor(&dev->submitted);

init_completion(&dev->bulk_in_completion);/*comletion同步机制,即IO操作和数据拷贝的同步*/

/*数据结构之间的转换,这里是usb_skel和usb_host_interface两个结构体之间的转换,相当于container_of宏*/

dev->udev = usb_get_dev(interface_to_usbdev(interface));

dev->interface = interface;

/* set up the endpoint information */

/* use only the first bulk-in and bulk-out endpoints */

/* 初始化dev设备的bulk-in和bulk-out相关的数据成员,两种数据类型只初始化一个*/

iface_desc = interface->cur_altsetting;

for (i = 0; i < iface_desc->desc.bNumEndpoints; ++i) {

endpoint = &iface_desc->endpoint[i].desc;

if (!dev->bulk_in_endpointAddr &&

usb_endpoint_is_bulk_in(endpoint)) {

/* we found a bulk in endpoint */

buffer_size = le16_to_cpu(endpoint->wMaxPacketSize);

dev->bulk_in_size = buffer_size;

dev->bulk_in_endpointAddr = endpoint->bEndpointAddress;

dev->bulk_in_buffer = kmalloc(buffer_size, GFP_KERNEL);

if (!dev->bulk_in_buffer) {

err("Could not allocate bulk_in_buffer");

goto error;

}

dev->bulk_in_urb = usb_alloc_urb(0, GFP_KERNEL);

if (!dev->bulk_in_urb) {

err("Could not allocate bulk_in_urb");

goto error;

}

}

if (!dev->bulk_out_endpointAddr &&

usb_endpoint_is_bulk_out(endpoint)) {

/* we found a bulk out endpoint */

dev->bulk_out_endpointAddr = endpoint->bEndpointAddress;

}

}

if (!(dev->bulk_in_endpointAddr && dev->bulk_out_endpointAddr)) {

err("Could not find both bulk-in and bulk-out endpoints");

goto error;

}

/* save our data pointer in this interface device */

/*不同上下文之间结构体传递的一种方式,在probe和open等其他函数之间通过usb_set_intfdata和usb_get_intfdata两个函数来保存和获取局部变量de*/

usb_set_intfdata(interface, dev);

/* we can register the device now, as it is ready */

/*执行完usb_register_dev之后,接口interface就会对应一个此设备号,至此与该接口对应的驱动注册完成,其他一些操作将会等到open时再进行*/

retval = usb_register_dev(interface, &skel_class);

if (retval) {

/* something prevented us from registering this driver */

err("Not able to get a minor for this device.");

usb_set_intfdata(interface, NULL);

goto error;

}

/* let the user know what node this device is now attached to */

dev_info(&interface->dev,

"USB Skeleton device now attached to USBSkel-%d",

interface->minor);

return 0;

error:

if (dev)

/* this frees allocated memory */

kref_put(&dev->kref, skel_delete);

return retval;

}

此函数主要完成usb用户态驱动的注册即usb_class_driver的注册,即skel_class结构体的注册。另外,需要注意的是在驱动程序设计中probe()和open()两个函数的区别,即它们各自应该实现什么功能?个人认为主要理解以下几点:

(1)对每个驱动来讲probe函数只会执行一次,执行时机为驱动加载或设备枚举的时候,用来实现设备和驱动的匹配。从这方面来讲,probe函数的执行函数应该尽可能的短,因此,操作越少越好。

(2)open()函数是每次打开某设备时都要执行的函数,如果系统中有多个进程都在使用某个设备,那么,open()函数就有可能执行多次,从这个角度来讲,open()函数主要应该坐与可冲入相关的操作,即让每个进程看来都是像第一次打开设备一样,其他进程对设备的某些值的修改不应该被其他进程看到。即相当于每次都虚拟了一个实际的硬件设备。

从这两方面将,如果不考虑probe的执行时间,如果不会存在多个使用同一设备的进程,完全可以将probe和open合并。

2 skel_open()函数

static int skel_open(struct inode *inode, struct file *file)

{

struct usb_skel *dev;

struct usb_interface *interface;

int subminor;

int retval = 0;

subminor = iminor(inode);

/*通过驱动和此设备号去查找对应的设备,类此pci设备,根据驱动结构体,在总线的device链表上查找这个驱动对应的设备,在usb驱动架构中即指对应的借口。*/

interface = usb_find_interface(&skel_driver, subminor);

if (!interface) {

err("%s - error, can't find device for minor %d",

__func__, subminor);

retval = -ENODEV;

goto exit;

}

/*获得在probe函数中保存的局部变量usb_skel dev*/

dev = usb_get_intfdata(interface);

if (!dev) {

retval = -ENODEV;

goto exit;

}

/* increment our usage count for the device */

kref_get(&dev->kref);

/* lock the device to allow correctly handling errors

* in resumption */

mutex_lock(&dev->io_mutex);

if (!dev->open_count++) {/*与电源管理相关的代码,可以暂时不去分析*/

retval = usb_autopm_get_interface(interface);

if (retval) {

dev->open_count--;

mutex_unlock(&dev->io_mutex);

kref_put(&dev->kref, skel_delete);

goto exit;

}

} /* else { //uncomment this block if you want exclusive open

retval = -EBUSY;

dev->open_count--;

mutex_unlock(&dev->io_mutex);

kref_put(&dev->kref, skel_delete);

goto exit;

} */

/* prevent the device from being autosuspended */

/* save our object in the file's private structure */

/*这里需要注意,前面讲过在probe和open等函数之间传递私有数据用的是两个函数usb_set_intfdata()和usb_get_intfdata, 那么,在open函数和其他函数如read/write之间传递私有数据就是通过file->private_data变量来实现的*/

file->private_data = dev;

mutex_unlock(&dev->io_mutex);

exit:

return retval;

}

从上面的代码我们可以发现,open函数归根结底其实只做了一件事情:保存了私有变量usb_skel dev,是的其他文件操作函数可用。

3.skel_read()函数

static ssize_t skel_read(struct file *file, char *buffer, size_t count,

loff_t *ppos)

{

struct usb_skel *dev;

int rv;

bool ongoing_io;

dev = (struct usb_skel *)file->private_data;

/* if we cannot read at all, return EOF */

if (!dev->bulk_in_urb || !count)

return 0;

/* no concurrent readers */

/*I/O操作的互斥锁,每次读操作前先去获得此锁,防止读操作和IO之间的并发进行。即如果读操作获得了此锁,IO操作就不能进行,同样的,如果该锁已经被IO操作获得,则当前执行流程睡眠,直到IO操作完成,释放此锁*/

rv = mutex_lock_interruptible(&dev->io_mutex);

if (rv < 0)

return rv;

if (!dev->interface) { /* disconnect() was called */

rv = -ENODEV;

goto exit;

}

/* if IO is under way, we must not touch things */

retry:

spin_lock_irq(&dev->err_lock);

ongoing_io = dev->ongoing_read;/*获得当前的IO状态*/

spin_unlock_irq(&dev->err_lock);

if (ongoing_io) {/*如果usb的I/O操作正在进行中,要等待I/O操作执行完成*/

/* nonblocking IO shall not wait */

if (file->f_flags & O_NONBLOCK) {

rv = -EAGAIN;

goto exit;

}

/*

* IO may take forever

* hence wait in an interruptible state

*/

/*睡眠等待IO操作完成*/

rv = wait_for_completion_interruptible(&dev->bulk_in_completion);

if (rv < 0)

goto exit;

/*

* by waiting we also semiprocessed the urb

* we must finish now

*/

dev->bulk_in_copied = 0;/*如果正在进行IO操作,说明当前URB对应的缓冲区没有数据可用了,所以copied=0*/

dev->processed_urb = 1;/*z执行到这的时候IO操作已经完成了,这里要标记下开始处理URB了*/

}

if (!dev->processed_urb) {/*等于0时,说明这个URB还没被处理过,即第一次读取这个URB*/

/*

* the URB hasn't been processed

* do it now

*/

/*这里为什么还要等待,什么情况下需要等待?????*/

wait_for_completion(&dev->bulk_in_completion);

dev->bulk_in_copied = 0;

dev->processed_urb = 1;

}

/* errors must be reported */

rv = dev->errors;

if (rv < 0) {

/* any error is reported once */

dev->errors = 0;

/* to preserve notifications about reset */

rv = (rv == -EPIPE) ? rv : -EIO;

/* no data to deliver */

dev->bulk_in_filled = 0;

/* report it */

goto exit;

}

/*

* if the buffer is filled we may satisfy the read

* else we need to start IO

*/

if (dev->bulk_in_filled) {/*不是第一次读,当前缓冲区中的数据的字节数*/

/* we had read data */

size_t available = dev->bulk_in_filled - dev->bulk_in_copied;/*当前URB还有多少数据没有拷贝*/

size_t chunk = min(available, count);

if (!available) {/*当前URB对应的缓冲区中没有数据了,要执行IO操作读入*/

/*

* all data has been used

* actual IO needs to be done

*/

/*执行IO操作,主要包括初始化一个URB和向usb core提交这个URB两个操作,真正的IO操作是由usb core驱动代码来完成的*/

rv = skel_do_read_io(dev, count);

if (rv < 0)

goto exit;

else

goto retry;

}

/*

* data is available

* chunk tells us how much shall be copied

*/

/*从当前URB对应的缓冲区中拷贝chunk字节数据到用户空间*/

if (copy_to_user(buffer,

dev->bulk_in_buffer + dev->bulk_in_copied,

chunk))

rv = -EFAULT;

else

rv = chunk;

dev->bulk_in_copied += chunk; /*增加已拷贝数据的字节数*/

/*

* if we are asked for more than we have,

* we start IO but don't wait

*/

/*当前缓冲区拷贝完成后,还没有完成用户指定的数据拷贝量,要继续执行I/O操作,填充URB*/

if (available < count)

skel_do_read_io(dev, count - chunk);

} else {

/* no data in the buffer */

/*当前缓冲区已经没有数据了,要执行I/O操作来填充URB对应的缓冲区*/

rv = skel_do_read_io(dev, count);

if (rv < 0)

goto exit;

else if (!(file->f_flags & O_NONBLOCK))

goto retry;

rv = -EAGAIN;

}

exit:

mutex_unlock(&dev->io_mutex);

return rv;

}/*执行完I/O操作后,要去唤醒usb用户驱动中正在睡眠等待的读拷贝操作过程*/

static void skel_read_bulk_callback(struct urb *urb)

{

struct usb_skel *dev;

dev = urb->context;

spin_lock(&dev->err_lock);

/* sync/async unlink faults aren't errors */

if (urb->status) {

if (!(urb->status == -ENOENT ||

urb->status == -ECONNRESET ||

urb->status == -ESHUTDOWN))

err("%s - nonzero write bulk status received: %d",

__func__, urb->status);

dev->errors = urb->status;

} else {

dev->bulk_in_filled = urb->actual_length;

}

dev->ongoing_read = 0; /*执行完IO操作通知usb客户端驱动*/

spin_unlock(&dev->err_lock);

/*执行唤醒操作*/

complete(&dev->bulk_in_completion);

}

static int skel_do_read_io(struct usb_skel *dev, size_t count)

{

int rv;

/* prepare a read */

/*在执行I/O操作前,先准备号一个URB*/

usb_fill_bulk_urb(dev->bulk_in_urb,

dev->udev,

usb_rcvbulkpipe(dev->udev,

dev->bulk_in_endpointAddr),

dev->bulk_in_buffer,

min(dev->bulk_in_size, count),

skel_read_bulk_callback,

dev);

/* tell everybody to leave the URB alone */

spin_lock_irq(&dev->err_lock);

dev->ongoing_read = 1; /*标记现在要进行I/O操作了*/

spin_unlock_irq(&dev->err_lock);

/* do it */

/*交由usb core模块去执行真正的IO操作*/

rv = usb_submit_urb(dev->bulk_in_urb, GFP_KERNEL);

if (rv < 0) {

err("%s - failed submitting read urb, error %d",

__func__, rv);

dev->bulk_in_filled = 0;

rv = (rv == -ENOMEM) ? rv : -EIO;

spin_lock_irq(&dev->err_lock);

dev->ongoing_read = 0;

spin_unlock_irq(&dev->err_lock);

}

return rv;

}

该函数比较容易理解,就是一个从usb设备读数据到用户程序的过程,这里涉及到一个主要的数据结构URB,对于usb客户驱动来讲,只需调用usb core提供的API即可,因此,usb驱动开发者只需了解这个API的功能和如何使用即可。详见代码注释。

4 skel_write()操作

static ssize_t skel_write(struct file *file, const char *user_buffer,

size_t count, loff_t *ppos)

{

struct usb_skel *dev;

int retval = 0;

struct urb *urb = NULL;

char *buf = NULL;

/*确定每次写操作可以写的数据量,最大值为PAGE_SIZE - 512 字节*/

size_t writesize = min(count, (size_t)MAX_TRANSFER);

dev = (struct usb_skel *)file->private_data;

/* verify that we actually have some data to write */

if (count == 0)

goto exit;

/*

* limit the number of URBs in flight to stop a user from using up all

* RAM

*/

if (!(file->f_flags & O_NONBLOCK)) {

if (down_interruptible(&dev->limit_sem)) {

retval = -ERESTARTSYS;

goto exit;

}

} else {

if (down_trylock(&dev->limit_sem)) {

retval = -EAGAIN;

goto exit;

}

}

spin_lock_irq(&dev->err_lock);

retval = dev->errors;

if (retval < 0) {

/* any error is reported once */

dev->errors = 0;

/* to preserve notifications about reset */

retval = (retval == -EPIPE) ? retval : -EIO;

}

spin_unlock_irq(&dev->err_lock);

if (retval < 0)

goto error;

/* create a urb, and a buffer for it, and copy the data to the urb */

/*在usb用户驱动的读程序中,并没有URB的分配,因为在usb_skel中包含了一个URB数据成员*/

urb = usb_alloc_urb(0, GFP_KERNEL);

if (!urb) {

retval = -ENOMEM;

goto error;

}

/*因为这里是由usb用户驱动负责分配的URB,因此,也应负责分配URB的成员指向的内存*/

buf = usb_alloc_coherent(dev->udev, writesize, GFP_KERNEL,

&urb->transfer_dma);

if (!buf) {

retval = -ENOMEM;

goto error;

}

/*把用户空间的数据拷贝到URB对应的DMA内存中*/

if (copy_from_user(buf, user_buffer, writesize)) {

retval = -EFAULT;

goto error;

}

/* this lock makes sure we don't submit URBs to gone devices */

/*在URB没有准备好之前,不允许进行实际的I/O操作*/

mutex_lock(&dev->io_mutex);

if (!dev->interface) { /* disconnect() was called */

mutex_unlock(&dev->io_mutex);

retval = -ENODEV;

goto error;

}

/* initialize the urb properly */

usb_fill_bulk_urb(urb, dev->udev,

usb_sndbulkpipe(dev->udev, dev->bulk_out_endpointAddr),

buf, writesize, skel_write_bulk_callback, dev);

urb->transfer_flags |= URB_NO_TRANSFER_DMA_MAP;

/*把urb添加到另外一条链表上,以便在其他地方对其进行访问*/

usb_anchor_urb(urb, &dev->submitted);

/* send the data out the bulk port */

retval = usb_submit_urb(urb, GFP_KERNEL);

mutex_unlock(&dev->io_mutex);

if (retval) {

err("%s - failed submitting write urb, error %d", __func__,

retval);

goto error_unanchor;

}

/*

* release our reference to this urb, the USB core will eventually free

* it entirely

*/

usb_free_urb(urb);

return writesize;

}

在函数与skel_read非常类似,唯一不同的是在写操作过程中需要usb客户驱动程序来显示的分配和初始化用于写操作的URB,之所以在读操作过程中看不到这个过程,是因为,在struct usb_skel dev中已经为写操作内嵌了一个数据成员:

/* Structure to hold all of our device specific stuff */

struct usb_skel {

struct usb_device    *udev;            /* the usb device for this device */

struct usb_interface    *interface;        /* the interface for this device */

struct semaphore    limit_sem;        /* limiting the number of writes in progress */

struct usb_anchor    submitted;        /* in case we need to retract our submissions */

struct urb        *bulk_in_urb;       /* the urb to read data with */

unsigned char *bulk_in_buffer;    /* the buffer to receive data */

size_t            bulk_in_size;        /* the size of the receive buffer */

size_t            bulk_in_filled;        /* number of bytes in the buffer */

size_t            bulk_in_copied;        /* already copied to user space */

__u8            bulk_in_endpointAddr;    /* the address of the bulk in endpoint */

__u8            bulk_out_endpointAddr;    /* the address of the bulk out endpoint */

int            errors;            /* the last request tanked */

int            open_count;        /* count the number of openers */

bool            ongoing_read;        /* a read is going on */

bool            processed_urb;        /* indicates we haven't processed the urb */

spinlock_t        err_lock;        /* lock for errors */

struct kref        kref;

struct mutex        io_mutex;        /* synchronize I/O with disconnect */

struct completion    bulk_in_completion;    /* to wait for an ongoing read */

};

而且这个结构体的很多数据成员都与读操作有关,具体为什么这么设计,暂时还没有搞明白,希望大家指教!

总结:

1. usb客户驱动还是比较简单的,主要是因为很多功能都由usb core驱动程序事先完成了,usb客户端驱动程序仅仅是调用一些API即可。

2. usb客户驱动程序的开发比较固定,直接套用usb-skeleton的代码基本就可以完成用户自定义驱动的编写

c语言+usb驱动开发,usb驱动程序分析相关推荐

  1. Linux USB 驱动开发(五)—— USB驱动程序开发过程简单总结

    http://blog.csdn.net/zqixiao_09/article/details/51057086 设备驱动程序是操作系统内核和机器硬件之间的接口,由一组函数和一些私有数据组成,是应用程 ...

  2. Linux USB 驱动开发实例(七)—— 基于USB 总线的无线网卡浅析

    回顾一下USB的相关知识 USB(Universal Serial Bus)总线又叫通用串行外部总线, 它是20世纪90年代发展起来的.USB接口现在得到了广泛的应用和普及,现在的PC机中都带有大量的 ...

  3. USB驱动及其源码分析

    一.USB理论部分 1.USB概述 USB1.0版本速度1.5Mbps(低速USB). USB1.1版本速度12Mbps(全速USB). USB2.0版本速度480Mbps(高速USB).USB3.0 ...

  4. Linux基础入门--驱动开发--USB

    Linux基础入门--驱动开发--USB 1.基本概念 2.组成结构 2.1 设备描述符 2.2 配置描述符 2.3 接口描述符 2.4 端点描述符 2.5 字符串描述符 3.管道 4.端点分类 4. ...

  5. 2008年12月13日上海USB驱动开发深度解析讲座PPT

    讲座PPT:宋宝华2008年12月13日上海USB驱动开发深度解析讲座PPT [url]http://www.linuxdriver.cn/200812/20081213172619_836.rar[ ...

  6. MF Porting之USB驱动开发

    花费了近三个礼拜的时间,终于完成了TI开发板的USB驱动开发,现在回头想一想,其实也没有什么,具体硬件方面的通信由DM355实现了,软件层面的数据交互由MF Porting实现了,所做的也就是熟悉了解 ...

  7. 【.Net Micro Framework PortingKit(补) – 1】USB驱动开发

    在前段时间我连续写了15篇关于[.Net Micro Framework PortingKit–?]的系列文章,初步介绍了.Net Micro Framework在Cortex-M3平台上的移植过程, ...

  8. 【.Net Micro Framework PortingKit(补) – 1】USB驱动开发 1

    前段时间我连续写了15篇关于[.Net Micro Framework PortingKit–?]的系列文章,初步介绍了.Net Micro Framework在Cortex-M3平台上的移植过程,最 ...

  9. STM32MP157驱动开发——USB设备驱动

    STM32MP157驱动开发--USB设备驱动 一.简介 1.电气属性 2.USB OTG 3.STM32MP1 USB 接口简介 4.Type-C 电气属性 二.USB HOST 驱动开发 1.US ...

最新文章

  1. 如何编译 Linux 内核
  2. AngularJS 国际化——Angular-translate
  3. oracle中or的替函数,Oracle常用内置Or自定义函数-SQL宝典
  4. 计算智能-群智能算法-蚁群算法matlab实现
  5. wojilu系统的ORM介绍
  6. java+session+存在哪_JAVA中Session
  7. Delphi 打印杨辉三角
  8. 浅析HTML文档结构对DivCSS布局的意义
  9. 引用JavaScript的三种方式第二课
  10. 基于机器学习的回归拟合、详细总结
  11. 使用HbuilderX 的UNIapp开发app, 打包后定位不能使用的解决方式
  12. 聊聊追求测试技术导致过度测试
  13. win7——win server 2012 iis中使用asp程序出现Microsoft OLE DB Provider for ODBC Drivers 错误 '80004005'
  14. PHP 7.1 新特性
  15. 从12个球任取8个球
  16. MATLAB-三维柱状图
  17. springboot 微信太阳码_springboot教务管理系统+微信小程序云开发
  18. 解析B2C电子商务网站系统架构
  19. 指针数组与数组指针详解
  20. 关于真机调试DDMS中的data文件夹打不开的解决方法

热门文章

  1. 如何设置HTML select下拉框的默认值?
  2. 使用go语言编写一个播放器
  3. 成长不可或缺的財富——肥皂剧和八卦节目带给我的成长
  4. pycharm中pyinstaller封装打包多个py文件的过程
  5. 基于Vue框架的预览组件xh-image-preview
  6. 松下FP XH六轴标准程序,程序控制六个伺服,轴的点动控制
  7. 浅笑云计算机,才太吉:浅笑云舒
  8. free的含义究竟是什么?
  9. win10重置mysql密码_Win10系统MySQL5.7密码忘记怎么重置?
  10. Scrapy+redis+mongodb分布式爬虫抓取小说《冰与火之歌1-5》