• Linux设备驱动中的阻塞和非阻塞I/O <转载>


    Green

     

    Linux设备驱动中的阻塞和非阻塞I/O

     

    【基本概念】

    1、阻塞

      阻塞操作是指在执行设备操作时,托不能获得资源,则挂起进程直到满足操作所需的条件后再进行操作。被挂起的进程进入休眠状态(不占用cpu资源),从调度器的运行队列转移到等待队列,直到条件满足。

    2、非阻塞

      非阻塞操作是指在进行设备操作是,若操作条件不满足并不会挂起,而是直接返回或重新查询(一直占用CPU资源)直到操作条件满足为止。

      当用户空间的应用程序调用read(),write()等方法时,若设备的资源不能被获取,而用户又希望以阻塞的方式来访问设备,驱动程序应当在设备驱动层的对应 read(),write()操作中,将该进程阻塞直到资源可以获取为止;若用户是以非阻塞方式获取资源,当资源不能获取时设备驱动的read()、write()应当立即返回,用户空间的read()、write()也相应的立即返回。

       阻塞从字面上听起来似乎意味着效率低,其实不是这样。如果以非阻塞方式,用户想获取某一资源只能不停地查询,这样会占用CPU大量资源。而阻塞访问,若不能获取资源就会进入休眠从而节省CPU资源给其他进程使用。

      很显然阻塞的进程会进入到休眠状态,因此必须保证有一个地方能够唤醒休眠的进程。唤醒进程的地方一般都在中断里面,意味硬件资源的获得同时往往伴随着一个中断。

      在设备驱动中,阻塞的实现通常是通过等待队列。

    一、阻塞IO实现(等待队列)

      在Linux驱动程序中,可以使用等待队列(wait queue)来实现阻塞进程的唤醒。wait queue在很早就作为一个基本的功能出现在Linux内核里了,它是一种以队列为基础的数据结构,与进程调度机制紧密结合,能够用于实现内核中的异步事件通知机制。等待队列可以用来同步对系统资源的访问,信号量在内核中也依赖等待队列来实现。

      在Linux设备驱动中等待队列实现阻塞一般方式是:

    1. 定义一个等待队列头 wait_queue_head_t  wq_h;
    2. 初始化等待队列头
    3. 当有操作要以阻塞方式访问资源时,调用 wait_event()加入到等待队列中即可
    4. 在条件满足时调用wake_up()唤醒等待队列 

    涉及到两个比较重要的数据结构:__wait_queue_head,该结构描述了等待队列的链头,其包含一个链表和一个原子锁,结构定义如下:     

    struct __wait_queue_head {
      spinlock_t    lock;  /* 保护等待队列资源的一个自旋锁 */
      struct list_head    task_list;   /* 等待队列 */
    };
    typedef struct __wait_queue_head wait_queue_head_t;

     __wait_queue,该结构是对一个等待任务的抽象。每个等待任务都会抽象成一个wait_queue,并且挂载到wait_queue_head上。该结构定义如下:

    复制代码
    struct __wait_queue 
    {
        unsigned int flags;
        void *private;                       /* 通常指向当前任务控制块 */
    
        /* 任务唤醒操作方法,该方法在内核中提供,通常为autoremove_wake_function */
        wait_queue_func_t func;             
        struct list_head task_list;              /* 挂入wait_queue_head的挂载点 */
    };
    复制代码

     Linux中等待队列的实现思想如下图所示,当一个任务需要在某个wait_queue_head上睡眠时,将自己的进程控制块信息封装到wait_queue中,然后挂载到wait_queue的链表中,执行调度睡眠。当某些事件发生后,另一个任务(进程)会唤醒wait_queue_head上的某个或者所有任务,唤醒工作也就是将等待队列中的任务设置为可调度的状态,并且从队列中删除。 

     

           使用等待队列时首先需要定义一个wait_queue_head,这可以通过DECLARE_WAIT_QUEUE_HEAD宏来完成,这是静态定义的方法。该宏会定义一个wait_queue_head,并且初始化结构中的锁以及等待队列。当然,动态初始化的方法也很简单,初始化一下锁及队列就可以了。

           一个任务需要等待某一事件的发生时,通常调用wait_event,该函数会定义一个wait_queue,描述等待任务,并且用当前的进程描述块初始化wait_queue,然后将wait_queue加入到wait_queue_head中。

    函数实现流程说明如下:

    a -- 用当前的进程描述块(PCB)初始化一个wait_queue描述的等待任务。

    b -- 在等待队列锁资源的保护下,将等待任务加入等待队列。

    c -- 判断等待条件是否满足,如果满足,那么将等待任务从队列中移出,退出函数。

    d --  如果条件不满足,那么任务调度,将CPU资源交与其它任务。

    e -- 当睡眠任务被唤醒之后,需要重复b、c 步骤,如果确认条件满足,退出等待事件函数。

    等待队列的操作接口函数

    1、定义和初始化“等待队列头”

    wait_queue_head_t my_queue;  /* 定义等待队列头 */
    init_waitqueue_head(&my_queue);  /* 初始化等待队列头 */

     也可以使用Linux内核中的宏来同时完成“等待队列头”的定义和初始化

    DECLARE_WAIT_QUEUE_HEAD(my_queue);

    2、定义等待队列:

    DECLARE_WAITQUEUE(name,tsk);  /* 定义并初始化一个名为name的等待队列。*/

    3、(从等待队列头中)添加/移出等待队列: 

    /* add_wait_queue()函数,设置等待的进程为非互斥进程,并将其添加进等待队列头(q)的队头中*/
    void add_wait_queue(wait_queue_head_t *q, wait_queue_t *wait);
    /* 该函数也和add_wait_queue()函数功能基本一样,只不过它是将等待的进程(wait)设置为互斥进程。*/
    void add_wait_queue_exclusive(wait_queue_head_t *q, wait_queue_t *wait);

    4、等待事件:

    (1)wait_event(queue, condition)宏:

    复制代码
    /** 
     * wait_event - sleep until a condition gets true 
     * @wq: the waitqueue to wait on 
     * @condition: a C expression for the event to wait for 
     * 
     * The process is put to sleep (TASK_UNINTERRUPTIBLE) until the 
     * @condition evaluates to true. The @condition is checked each time 
     * the waitqueue @wq is woken up. 
     * 
     * wake_up() has to be called after changing any variable that could 
     * change the result of the wait condition. 
     */  
    #define wait_event(wq, condition)                     
    do {                                      
      if (condition)                            
      break;                            
      __wait_event(wq, condition);                      
    } while (0)  
    复制代码

        在等待会列中睡眠直到condition为真。在等待的期间,进程会被置为TASK_UNINTERRUPTIBLE进入睡眠,直到condition变量变为真。每次进程被唤醒的时候都会检查condition的值.

    (2)wait_event_interruptible(queue, condition)函数:

       和wait_event()的区别是调用该宏在等待的过程中当前进程会被设置为TASK_INTERRUPTIBLE状态.在每次被唤醒的时候,首先检查condition是否为真,如果为真则返回,否则检查如果进程是被信号唤醒,会返回-ERESTARTSYS错误码.如果是condition为真,则返回0.

    (3)wait_event_timeout(queue, condition, timeout)宏:

       也与wait_event()类似.不过如果所给的睡眠时间为负数则立即返回.如果在睡眠期间被唤醒,且condition为真则返回剩余的睡眠时间,否则继续睡眠直到到达或超过给定的睡眠时间,然后返回0
    (4)wait_event_interruptible_timeout(wq, condition, timeout)宏:
       与wait_event_timeout()类似,不过如果在睡眠期间被信号打断则返回ERESTARTSYS错误码.
    (5) wait_event_interruptible_exclusive(wq, condition)宏

    5、唤醒队列

    (1)wake_up(x)函数 

    复制代码
    #define wake_up(x)          __wake_up(x, TASK_NORMAL, 1, NULL)  
    /** 
     * __wake_up - wake up threads blocked on a waitqueue. 
     * @q: the waitqueue 
     * @mode: which threads 
     * @nr_exclusive: how many wake-one or wake-many threads to wake up 
     * @key: is directly passed to the wakeup function 
     */  
    void __wake_up(wait_queue_head_t *q, unsigned int mode,  
                int nr_exclusive, void *key)  
    {  
        unsigned long flags;  
       
        spin_lock_irqsave(&q->lock, flags);  
        __wake_up_common(q, mode, nr_exclusive, 0, key);  
        spin_unlock_irqrestore(&q->lock, flags);  
    }  
    EXPORT_SYMBOL(__wake_up);  
    复制代码
    唤醒等待队列.可唤醒处于TASK_INTERRUPTIBLE和TASK_UNINTERUPTIBLE状态的进程,和wait_event/wait_event_timeout成对使用.(2)wake_up_interruptible()函数:
    #define wake_up_interruptible(x)    __wake_up(x, TASK_INTERRUPTIBLE, 1, NULL)

    和wake_up()唯一的区别是它只能唤醒TASK_INTERRUPTIBLE状态的进程.,与wait_event_interruptible/wait_event_interruptible_timeout/ wait_event_interruptible_exclusive成对使用。

    下面看一个实例:

     工作队列使用范例

    注意两个概念:

    a --  疯狂兽群

          wake_up的时候,所有阻塞在队列的进程都会被唤醒,但是因为condition的限制,只有一个进程得到资源,其他进程又会再次休眠,如果数量很大,称为 疯狂兽群

    b -- 独占等待

          等待队列的入口设置一个WQ_FLAG_EXCLUSIVE标志,就会添加到等待队列的尾部,没有设置设置的添加到头部,wake up的时候遇到第一个具有WQ_FLAG_EXCLUSIVE这个标志的进程就停止唤醒其他进程。

    二、非阻塞I/O实现方式 —— 多路复用

    1、轮询的概念和作用

          在用户程序中,select() 和 poll() 也是设备阻塞和非阻塞访问息息相关的论题。使用非阻塞I/O的应用程序通常会使用select() 和 poll() 系统调用查询是否可对设备进行无阻塞的访问。select() 和 poll() 系统调用最终会引发设备驱动中的 poll()函数被执行。 

    2、应用程序中的轮询编程

          在用户程序中,select()和poll()本质上是一样的, 不同只是引入的方式不同,前者是在BSD UNIX中引入的,后者是在System V中引入的。用的比较广泛的是select系统调用。原型如下 

    int select(int numfds, fd_set *readfds, fd_set *writefds, fd_set *exceptionfds, struct timeval *timeout);

        其中readfs,writefds,exceptfds分别是select()监视的读,写和异常处理的文件描述符集合,numfds的值是需要检查的号码最高的文件描述符加1,timeout则是一个时间上限值,超过该值后,即使仍没有描述符准备好也会返回。

     struct timeval
    {
        int tv_sec;    //秒
        int tv_usec;   //微秒
    }

    涉及到文件描述符集合的操作主要有以下几种:

    1)清除一个文件描述符集   FD_ZERO(fd_set *set);

    2)将一个文件描述符加入文件描述符集中    FD_SET(int fd,fd_set *set);

    3)将一个文件描述符从文件描述符集中清除  FD_CLR(int fd,fd_set *set);

    4)判断文件描述符是否被置位    FD_ISSET(int fd,fd_set *set);

    最后我们利用上面的文件描述符集的相关来写个验证添加了设备轮询的驱动,把上边两块联系起来 

    3、设备驱动中的轮询编程

           设备驱动中的poll() 函数原型如下

    unsigned int(*poll)(struct file *filp, struct poll_table * wait);

    第一个参数是file结构体指针,第二个参数是轮询表指针,poll设备方法完成两件事:

    a -- 对可能引起设备文件状态变化的等待队列调用poll_wait()函数,将对应的等待队列头添加到poll_table,如果没有文件描述符可用来执行 I/O, 则内核使进程在传递到该系统调用的所有文件描述符对应的等待队列上等待。

    b -- 返回表示是否能对设备进行无阻塞读、写访问的掩码。

    位掩码:POLLRDNORM, POLLIN,POLLOUT,POLLWRNORM

    设备可读,通常返回:(POLLIN | POLLRDNORM)

    设备可写,通常返回:(POLLOUT | POLLWRNORM)       

    poll_wait()函数:用于向 poll_table注册等待队列

     void poll_wait(struct file *filp, wait_queue_head_t *queue,poll_table *wait)  

          poll_wait()函数不会引起阻塞,它所做的工作是把当前进程添加到wait 参数指定的等待列表(poll_table)中。

         真正的阻塞动作是上层的select/poll函数中完成的。select/poll会在一个循环中对每个需要监听的设备调用它们自己的poll支持函数以使得当前进程被加入各个设备的等待列表。若当前没有任何被监听的设备就绪,则内核进行调度(调用schedule)让出cpu进入阻塞状态,schedule返回时将再次循环检测是否有操作可以进行,如此反复;否则,若有任意一个设备就绪,select/poll都立即返回。

    具体过程如下:

    a -- 用户程序第一次调用select或者poll,驱动调用poll_wait并使两条队列都加入poll_table结构中作为下次调用驱动函数poll的条件,一个mask返回值指示设备是否可操作,0为未准备状态,如果文件描述符未准备好可读或可写,用户进程被会加入到写或读等待队列中进入睡眠状态。

    b -- 当驱动执行了某些操作,例如,写缓冲或读缓冲,写缓冲使读队列被唤醒,读缓冲使写队列被唤醒,于是select或者poll系统调用在将要返回给用户进程时再次调用驱动函数poll,驱动依然调用poll_wait 并使两条队列都加入poll_table结构中,并判断可写或可读条件是否满足,如果mask返回POLLIN | POLLRDNORM或POLLOUT | POLLWRNORM则指示可读或可写,这时select或poll真正返回给用户进程,如果mask还是返回0,则系统调用select或poll继续不返回     

    下面是一个典型模板: 

    复制代码
    static unsigned int XXX_poll(struct file *filp, poll_table *wait)  
    {  
        unsigned int mask = 0;  
        struct XXX_dev *dev = filp->private_data;     //获得设备结构指针 
        ...  
        poll_wait(filp, &dev->r_wait, wait);    //加读等待对列头  
        poll_wait(filp ,&dev->w_wait, wait);    //加写等待队列头  
        if(...)//可读
        { 
           POLLIN | POLLRDNORM;    //标识数据可获得  
        }  
        if(...)//可写  
        {
              mask |= POLLOUT | POLLWRNORM;    //标识数据可写入  
        }  
        ..  
        return mask;  
    }          
    复制代码

    4、调用过程:

    Linux下select调用的过程:

    1、用户层应用程序调用select(),底层调用poll())
    2、核心层调用sys_select() ------> do_select()

      最终调用文件描述符fd对应的struct file类型变量的struct file_operations *f_op的poll函数。
      poll指向的函数返回当前可否读写的信息。
      1)如果当前可读写,返回读写信息。
      2)如果当前不可读写,则阻塞进程,并等待驱动程序唤醒,重新调用poll函数,或超时返回。

    3、驱动需要实现poll函数
    当驱动发现有数据可以读写时,通知核心层,核心层重新调用poll指向的函数查询信息。

    poll_wait(filp,&wait_q,wait) // 此处将当前进程加入到等待队列中,但并不阻塞

    在中断中使用wake_up_interruptible(&wait_q)唤醒等待队列。
    4、实例分析

    1、memdev.h 

    复制代码
    /*mem设备描述结构体*/
    struct mem_dev                                     
    {                                                        
      char *data;                      
      unsigned long size; 
      wait_queue_head_t inq;  
    };
    
    #endif /* _MEMDEV_H_ */
    复制代码

    2、驱动程序 memdev.c

    复制代码
    #include <linux/module.h>  
    #include <linux/types.h>  
    #include <linux/fs.h>  
    #include <linux/errno.h>  
    #include <linux/mm.h>  
    #include <linux/sched.h>  
    #include <linux/init.h>  
    #include <linux/cdev.h>  
    #include <asm/io.h>  
    #include <asm/system.h>  
    #include <asm/uaccess.h>  
      
    #include <linux/poll.h>  
    #include "memdev.h"  
      
    static mem_major = MEMDEV_MAJOR;  
    bool have_data = false; /*表明设备有足够数据可供读*/  
      
    module_param(mem_major, int, S_IRUGO);  
      
    struct mem_dev *mem_devp; /*设备结构体指针*/  
      
    struct cdev cdev;   
      
    /*文件打开函数*/  
    int mem_open(struct inode *inode, struct file *filp)  
    {  
        struct mem_dev *dev;  
          
        /*获取次设备号*/  
        int num = MINOR(inode->i_rdev);  
      
        if (num >= MEMDEV_NR_DEVS)   
                return -ENODEV;  
        dev = &mem_devp[num];  
          
        /*将设备描述结构指针赋值给文件私有数据指针*/  
        filp->private_data = dev;  
          
        return 0;   
    }  
      
    /*文件释放函数*/  
    int mem_release(struct inode *inode, struct file *filp)  
    {  
      return 0;  
    }  
      
    /*读函数*/  
    static ssize_t mem_read(struct file *filp, char __user *buf, size_t size, loff_t *ppos)  
    {  
      unsigned long p =  *ppos;  
      unsigned int count = size;  
      int ret = 0;  
      struct mem_dev *dev = filp->private_data; /*获得设备结构体指针*/  
      
      /*判断读位置是否有效*/  
      if (p >= MEMDEV_SIZE)  
        return 0;  
      if (count > MEMDEV_SIZE - p)  
        count = MEMDEV_SIZE - p;  
          
      while (!have_data) /* 没有数据可读,考虑为什么不用if,而用while */  
      {  
            if (filp->f_flags & O_NONBLOCK)  
                return -EAGAIN;  
          
        wait_event_interruptible(dev->inq,have_data);  
      }  
      
      /*读数据到用户空间*/  
      if (copy_to_user(buf, (void*)(dev->data + p), count))  
      {  
        ret =  - EFAULT;  
      }  
      else  
      {  
        *ppos += count;  
        ret = count;  
         
        printk(KERN_INFO "read %d bytes(s) from %d
    ", count, p);  
      }  
        
      have_data = false; /* 表明不再有数据可读 */  
      /* 唤醒写进程 */  
      return ret;  
    }  
      
    /*写函数*/  
    static ssize_t mem_write(struct file *filp, const char __user *buf, size_t size, loff_t *ppos)  
    {  
      unsigned long p =  *ppos;  
      unsigned int count = size;  
      int ret = 0;  
      struct mem_dev *dev = filp->private_data; /*获得设备结构体指针*/  
        
      /*分析和获取有效的写长度*/  
      if (p >= MEMDEV_SIZE)  
        return 0;  
      if (count > MEMDEV_SIZE - p)  
        count = MEMDEV_SIZE - p;  
      
      /*从用户空间写入数据*/  
      if (copy_from_user(dev->data + p, buf, count))  
        ret =  - EFAULT;  
      else  
      {  
        *ppos += count;  
        ret = count;  
          
        printk(KERN_INFO "written %d bytes(s) from %d
    ", count, p);  
      }  
        
      have_data = true; /* 有新的数据可读 */  
          
        /* 唤醒读进程 */  
        wake_up(&(dev->inq));  
      
      return ret;  
    }  
      
    /* seek文件定位函数 */  
    static loff_t mem_llseek(struct file *filp, loff_t offset, int whence)  
    {   
        loff_t newpos;  
      
        switch(whence) {  
          case 0: /* SEEK_SET */  
            newpos = offset;  
            break;  
      
          case 1: /* SEEK_CUR */  
            newpos = filp->f_pos + offset;  
            break;  
      
          case 2: /* SEEK_END */  
            newpos = MEMDEV_SIZE -1 + offset;  
            break;  
      
          default: /* can't happen */  
            return -EINVAL;  
        }  
        if ((newpos<0) || (newpos>MEMDEV_SIZE))  
            return -EINVAL;  
              
        filp->f_pos = newpos;  
        return newpos;  
      
    }  
    unsigned int mem_poll(struct file *filp, poll_table *wait)  
    {  
        struct mem_dev  *dev = filp->private_data;   
        unsigned int mask = 0;  
          
       /*将等待队列添加到poll_table */  
        poll_wait(filp, &dev->inq,  wait);  
       
          
        if (have_data)         mask |= POLLIN | POLLRDNORM;  /* readable */  
      
        return mask;  
    }  
      
      
    /*文件操作结构体*/  
    static const struct file_operations mem_fops =  
    {  
      .owner = THIS_MODULE,  
      .llseek = mem_llseek,  
      .read = mem_read,  
      .write = mem_write,  
      .open = mem_open,  
      .release = mem_release,  
      .poll = mem_poll,  
    };  
      
    /*设备驱动模块加载函数*/  
    static int memdev_init(void)  
    {  
      int result;  
      int i;  
      
      dev_t devno = MKDEV(mem_major, 0);  
      
      /* 静态申请设备号*/  
      if (mem_major)  
        result = register_chrdev_region(devno, 2, "memdev");  
      else  /* 动态分配设备号 */  
      {  
        result = alloc_chrdev_region(&devno, 0, 2, "memdev");  
        mem_major = MAJOR(devno);  
      }    
        
      if (result < 0)  
        return result;  
      
      /*初始化cdev结构*/  
      cdev_init(&cdev, &mem_fops);  
      cdev.owner = THIS_MODULE;  
      cdev.ops = &mem_fops;  
        
      /* 注册字符设备 */  
      cdev_add(&cdev, MKDEV(mem_major, 0), MEMDEV_NR_DEVS);  
         
      /* 为设备描述结构分配内存*/  
      mem_devp = kmalloc(MEMDEV_NR_DEVS * sizeof(struct mem_dev), GFP_KERNEL);  
      if (!mem_devp)    /*申请失败*/  
      {  
        result =  - ENOMEM;  
        goto fail_malloc;  
      }  
      memset(mem_devp, 0, sizeof(struct mem_dev));  
        
      /*为设备分配内存*/  
      for (i=0; i < MEMDEV_NR_DEVS; i++)   
      {  
            mem_devp[i].size = MEMDEV_SIZE;  
            mem_devp[i].data = kmalloc(MEMDEV_SIZE, GFP_KERNEL);  
            memset(mem_devp[i].data, 0, MEMDEV_SIZE);  
        
          /*初始化等待队列*/  
         init_waitqueue_head(&(mem_devp[i].inq));  
         //init_waitqueue_head(&(mem_devp[i].outq));  
      }  
         
      return 0;  
      
      fail_malloc:   
      unregister_chrdev_region(devno, 1);  
        
      return result;  
    }  
      
    /*模块卸载函数*/  
    static void memdev_exit(void)  
    {  
      cdev_del(&cdev);   /*注销设备*/  
      kfree(mem_devp);     /*释放设备结构体内存*/  
      unregister_chrdev_region(MKDEV(mem_major, 0), 2); /*释放设备号*/  
    }  
      
    MODULE_AUTHOR("David Xie");  
    MODULE_LICENSE("GPL");  
      
    module_init(memdev_init);  
    module_exit(memdev_exit);  
    复制代码

    3、应用程序 app-write.c

    复制代码
    #include <stdio.h>  
      
    int main()  
    {  
        FILE *fp = NULL;  
        char Buf[128];  
          
          
        /*打开设备文件*/  
        fp = fopen("/dev/memdev0","r+");  
        if (fp == NULL)  
        {  
            printf("Open Dev memdev Error!
    ");  
            return -1;  
        }  
          
        /*写入设备*/  
        strcpy(Buf,"memdev is char dev!");  
        printf("Write BUF: %s
    ",Buf);  
        fwrite(Buf, sizeof(Buf), 1, fp);  
          
        sleep(5);  
        fclose(fp);  
          
        return 0;      
      
    }  
    复制代码

    4、应用程序 app-read.c 

    复制代码
    #include <stdio.h>  
    #include <stdlib.h>  
    #include <unistd.h>  
    #include <sys/ioctl.h>  
    #include <sys/types.h>  
    #include <sys/stat.h>  
    #include <fcntl.h>  
    #include <sys/select.h>  
    #include <sys/time.h>  
    #include <errno.h>  
      
    int main()  
    {  
        int fd;  
        fd_set rds;  
        int ret;  
        char Buf[128];  
          
        /*初始化Buf*/  
        strcpy(Buf,"memdev is char dev!");  
        printf("BUF: %s
    ",Buf);  
          
        /*打开设备文件*/  
        fd = open("/dev/memdev0",O_RDWR);  
          
        FD_ZERO(&rds);  
        FD_SET(fd, &rds);  
      
        /*清除Buf*/  
        strcpy(Buf,"Buf is NULL!");  
        printf("Read BUF1: %s
    ",Buf);  
      
        ret = select(fd + 1, &rds, NULL, NULL, NULL);  
        if (ret < 0)   
        {  
            printf("select error!
    ");  
            exit(1);  
        }  
        if (FD_ISSET(fd, &rds))   
            read(fd, Buf, sizeof(Buf));              
          
        /*检测结果*/  
        printf("Read BUF2: %s
    ",Buf);  
          
        close(fd);  
          
        return 0;      
    }  
    复制代码




     
  • 相关阅读:
    蜕变过程中的思考
    Django template for 循环用法
    Django 发送html邮件
    Django F对象的使用
    在Django中使用Q()对象
    ubuntu中管理用户和用户组
    Django settings.py 的media路径设置
    Git版本控制 备忘录
    Git .gitignore文件的使用
    将git关联到pycharm
  • 原文地址:https://www.cnblogs.com/yangdh/p/13504248.html
Copyright © 2020-2023  润新知