免费注册 查看新帖 |

Chinaunix

  平台 论坛 博客 文库
12下一页
最近访问板块 发新帖
查看: 11675 | 回复: 15
打印 上一主题 下一主题

字符设备驱动模型浅析 [复制链接]

论坛徽章:
0
跳转到指定楼层
1 [收藏(0)] [报告]
发表于 2010-07-19 18:26 |只看该作者 |倒序浏览
本帖最后由 zhiqiang0071 于 2010-07-26 09:17 编辑

本文属本人原创,欢迎转载,转载请注明出处。由于个人的见识和能力有限,不可能面面俱到,也可能存在谬误,敬请网友指出,本人的邮箱是yzq.seen@gmail.com,博客是http://zhiqiang0071.cublog.cn

      在linux系统中,很多驱动是字符型驱动,有些是直接编译集成在内核中,另一些是单独编译成“.ko”动态加载的。其实字符驱动只是个外壳,用于内核与应用程序间通信,无非是调用open,release,read,write和ioctl等例程。所以根据应用不同,字符驱动能会调用其他驱动模块,如i2c、spi和v4l2等,于是字符驱动还可分WDT驱动、RTC驱动和MTD驱动等。所以在分析其他驱动模块之前有必要好好分析下字符设备驱动模型。本篇文章要讲的就是字符设备驱动模型,也就是字符设备驱动是怎么注册和注销的,怎么生成设备节点的,怎么和应用程序关联的,例程调用具体如何实现的等等。

一、字符设备驱动的注册和注销
对于写过linux-2.6内核(本文采用linux-2.6.18内核)字符驱动的程序员来说,对下面这段程序的形式肯定不陌生。
  1. int result;
  2.         /*
  3.          * Register the driver in the kernel
  4.          * Dynmically get the major number for the driver using
  5.          * alloc_chrdev_region function
  6.          */
  7.         result = alloc_chrdev_region(&dev, 0, 1, “testchar”);

  8.         /* if it fails return error */
  9.         if (result < 0) {
  10.                 printk("Error registering test character device\n");
  11.                 return -ENODEV;
  12.         }

  13.         printk(KERN_INFO " test major#: %d, minor# %d\n", MAJOR(dev), MINOR(dev));

  14.         /* initialize cdev with file operations */
  15.         cdev_init(&cdev, & test _fops);

  16.         cdev.owner = THIS_MODULE;
  17.         cdev.ops = &test_fops;

  18.         /* add cdev to the kernel */
  19.         result = cdev_add(&cdev, dev, 1);

  20.         if (result) {
  21.                 unregister_chrdev_region(dev, 1);
  22.                 printk("Error adding test char device: error no:%d\n", result);
  23.                 return -EINVAL;
  24.         }
  25. testchar _class = class_create(THIS_MODULE, "testchar");
  26.         if (!testchar _class) {
  27.                 printk("Error creating test device class\n");
  28.                 unregister_chrdev_region(dev, 1);
  29.                 unregister_chrdev(MAJOR(dev), "testchar");
  30.                 cdev_del(&cdev);
  31.                 return -EINVAL;
  32.         }
  33. class_device_create(testchar _class, NULL, dev, NULL, "testchar");
复制代码
通常这段程序会放在一个模块初始化加载函数里,形式是这样的,
  1. int __init testchar_init(void)
  2. {
  3. }
  4. module_init(testchar_init);
复制代码
既然有注册的函数,那必然有注销的函数,这叫有进必有出,有公必有母…,总而言之,这是大自然的神奇被人类所利用。废话少说,形式是这样的,
  1. void __exit testchar _cleanup(void)
  2. {
  3.         /* remove major number allocated to this driver */
  4.         unregister_chrdev_region(dev, 1);

  5.         /* remove simple class device */
  6.         class_device_destroy(testchar_class, dev);

  7.         /* destroy simple class */
  8.         class_destroy(testchar class);

  9.         cdev_del(&cdev);

  10.         /* unregistering the driver from the kernel */
  11.         unregister_chrdev(MAJOR(dev), "testchar");
  12. }
  13. module_exit(testchar_cleanup);
复制代码
这些注册字符驱动的例程调用大都集中在文件fs/char_dev.c中。所以先来看看这个文件中都有些啥,这叫直捣黄龙。
这有个初始化函数,在模块加载过程中会被调用到(动态insmod加载或在内核中加载),如下,
  1. void __init chrdev_init(void)
  2. {
  3.         cdev_map = kobj_map_init(base_probe, &chrdevs_lock);
  4. }。
复制代码
kobj_map_init()函数传进去了两个参数,base_probe函数和chrdevs_lock互斥变量,返回一个struct kobj_map类型的指针。
base_probe调用了request_module()函数,用于加载与字符驱动相关的驱动程序,被加载的驱动命名方式是char-major-主设备号-次设备号。request_module()函数,你可以看看该函数上头的英文注释,它最终会调用应用层空间的modprobe命令来加载驱动程序。实际上,没有使用该项功能。
chrdevs_lock是一个全局的互斥变量,用于整个设备驱动模块的关键区域保护,后面你会看到。
返回的是struct kobj_map类型指针,保存到cdev_map中,该结构体干吗用的呢,顾名思义,用来做映射连通的,后面会慢慢说明。先来看看该结构体的定义,
  1. struct kobj_map {
  2.         struct probe {
  3.                 struct probe *next;         /* 被255整除后相同的设备号链成一个单向链表 */
  4.                 dev_t dev;  /* 字符设备驱动的设备号,包含主设备号(高12位)和次设备号(低20位) */
  5.                 unsigned long range;       /* 次设备号范围 */
  6.                 struct module *owner;     /* 表明模块的归属,是THIS_MODULE */
  7.                 kobj_probe_t *get;         /* 这里可以保存传进来的base_probe函数指针,可回调 */
  8.                 int (*lock)(dev_t, void *);  /* 保存回调函数,具体是啥,后续会说到 */
  9.                 void *data;
  10. } *probes[255];  /* 虽然大小只有255,但采用了链表的形式,可以支持到4096个主设 */
  11.         struct mutex *lock;   /* 保存全局互斥锁,用于关键区域的保护 */
  12. };
复制代码
我们再来看看kobj_map_init()函数里头做了什么,该函数是这样的,
  1. struct kobj_map *kobj_map_init(kobj_probe_t *base_probe, struct mutex *lock)
  2. {
  3.         struct kobj_map *p = kmalloc(sizeof(struct kobj_map), GFP_KERNEL);
  4.         struct probe *base = kzalloc(sizeof(*base), GFP_KERNEL);
  5.         int i;

  6.         if ((p == NULL) || (base == NULL)) {
  7.                 kfree(p);
  8.                 kfree(base);
  9.                 return NULL;
  10.         }

  11.         base->dev = 1;
  12.         base->range = ~0;                 /* 初始的范围很大 */
  13.         base->get = base_probe;        /* 保存函数指针 */
  14.         for (i = 0; i < 255; i++)
  15.                 p->probes[i] = base;       /* 所有指针都指向同一个base */
  16.         p->lock = lock;
  17.         return p;
  18. }。
复制代码
该函数只是分配了一个结构体struct kobj_map,并做了初始化,保存了函数指针base_probe和全局锁lock。

下面就按照驱动注册流程一个个解析这些例程调用吧。首先是alloc_chrdev_region()函数,解析它之前,先看看结构体(定义了255个结构体指针),
  1. static struct char_device_struct {
  2.         /*被255整除后相同的设备号链成一个单向链表*/
  3.         struct char_device_struct *next;  
  4.         unsigned int major;                /* 主设备号 */
  5.         unsigned int baseminor;          /* 次设备起始号 */
  6.         int minorct;                /* 次设备号范围 */
  7.         char name[64];        /* 驱动的名字 */
  8.         struct file_operations *fops;   /* 保存文件操作指针,目前没有使用 */
  9.         struct cdev *cdev;                /* will die */   /*目前没有使用*/
  10. } *chrdevs[CHRDEV_MAJOR_HASH_SIZE]; /* CHRDEV_MAJOR_HASH_SIZE = 255 */
复制代码
它的作用仅仅是用于注册字符设备驱动,保存已经注册字符驱动的一些信息,如主次设备号,次设备号的数量,驱动的名字等,便于字符设备驱动注册时索引查找。alloc_chrdev_region()函数很简单,通过调用__register_chrdev_region()来实现,通过英语注释你也可以明白,这个函数有两个作用,一是,如果主设备号为0,则分配一个最近的主设备号,返回给调用者;二是,如果主设备号不为0,则占用好该主设备号对应的位置,返回给调用者。如下,
  1. static struct char_device_struct *
  2. __register_chrdev_region(unsigned int major, unsigned int baseminor,
  3.                            int minorct, const char *name)
  4. {
  5.         struct char_device_struct *cd, **cp;
  6.         int ret = 0;
  7.         int i;

  8.         cd = kzalloc(sizeof(struct char_device_struct), GFP_KERNEL);
  9.         if (cd == NULL)
  10.                 return ERR_PTR(-ENOMEM);

  11.         mutex_lock(&chrdevs_lock);  /* 这下看到了吧,加锁,就允许你一个人进来 */

  12.         /* temporary */
  13.         if (major == 0) {         /* 如果主设备号为零,则找一个最近空闲的号码分配 */
  14.                 for (i = ARRAY_SIZE(chrdevs)-1; i > 0; i--) {
  15.                         if (chrdevs[i] == NULL)
  16.                                 break;
  17.                 }

  18.                 if (i == 0) {
  19.                         ret = -EBUSY;
  20.                         goto out;
  21.                 }
  22.                 major = i;
  23.                 ret = major;
  24.         }

  25.         /* 这些不用说你懂的 */
  26.         cd->major = major;
  27.         cd->baseminor = baseminor;
  28.         cd->minorct = minorct;
  29.         strncpy(cd->name,name, 64);

  30.         i = major_to_index(major);

  31.         /* 如果主设备号不为0,则占用好该主设备号对应的位置 */
  32.         for (cp = &chrdevs[i]; *cp; cp = &(*cp)->next)
  33.                 if ((*cp)->major > major ||
  34.                     ((*cp)->major == major && (*cp)->baseminor >= baseminor))
  35.                         break;
  36.         if (*cp && (*cp)->major == major &&
  37.             (*cp)->baseminor < baseminor + minorct) {
  38.                 ret = -EBUSY;
  39.                 goto out;
  40.         }
  41.         cd->next = *cp;
  42.         *cp = cd;
  43.         mutex_unlock(&chrdevs_lock);   /* 开锁,队列里的下一个人可以进来了 */
  44.         return cd;
  45. out:
  46.         mutex_unlock(&chrdevs_lock);
  47.         kfree(cd);
  48.         return ERR_PTR(ret);
  49. }
复制代码
接着是cdev_init()函数,先说说cdev的结构体,
  1. struct cdev {
  2.         struct kobject kobj; /* 不多解释了,看看鄙人前面写的文章吧 */
  3.         struct module *owner; /* 模块锁定和加载时用得着 */
  4.         const struct file_operations *ops; /* 保存文件操作例程结构体 */
  5.         struct list_head list; /* open时,会将其inode加到该链表中,方便判别是否空闲 */
  6.         dev_t dev; /* 设备号 */
  7.         unsigned int count;
  8. };
复制代码
cdev结构体把字符设备驱动和文件系统相关联,后面解析字符设备驱动怎样运行的时候会详谈。
cdev_init()函数如下,
  1. void cdev_init(struct cdev *cdev, const struct file_operations *fops)
  2. {
  3.          memset(cdev, 0, sizeof *cdev);
  4.          INIT_LIST_HEAD(&cdev->list);
  5.          cdev->kobj.ktype = &ktype_cdev_default; /* 卸载驱动时会用到,别急,后面详讲 */
  6.          kobject_init(&cdev->kobj);
  7.          cdev->ops = fops; /* 用户写的字符设备驱动fops就保存在这了 */
  8. }。
复制代码
你也看到了,该函数就是对变量做了初始化,关于kobject的解析,建议你看看鄙人博客上写的《Linux设备模型浅析之设备篇》和《Linux设备模型浅析之驱动篇》两篇文章,这里就不详谈了。
用户的fops,在本文中是test_fops,一般形式是这样的,
  1. static const struct file_operations test_fops = {
  2.         .owner  = THIS_MODULE,
  3.         .open   = test_fops_open,
  4.         .release  = test_fops_release,
  5.         .ioctl    = test_fops_ioctl,
  6.         .read   = test_fops_read,
  7.         .write  = test_fops_write,
  8. };
复制代码


接着又调用了函数cdev_add(),这个函数又调用了kobj_map()函数,其作用就是分配一个struct probe结构体,填充该结构体中的变量并将其加入到全局的cdev_map中,说白了,就是分个一亩三分田给该字符设备驱动,并做好标记,放到主设备号对应的地方,等主人下次来找的时候能找到(使用kobj_lookup()函数,后面会讲到)。该函数是这样的,
  1. int kobj_map(struct kobj_map *domain, dev_t dev, unsigned long range,
  2.              struct module *module, kobj_probe_t *probe,
  3.              int (*lock)(dev_t, void *), void *data)
  4. {
  5.         unsigned n = MAJOR(dev + range - 1) - MAJOR(dev) + 1;
  6.         unsigned index = MAJOR(dev);
  7.         unsigned i;
  8.         struct probe *p;

  9.         if (n > 255)
  10.                 n = 255;
  11.         /* 分配了一亩三分田 */
  12.         p = kmalloc(sizeof(struct probe) * n, GFP_KERNEL);

  13.         if (p == NULL)
  14.                 return -ENOMEM;

  15.         /* 填充些私有的东西 */
  16.         for (i = 0; i < n; i++, p++) {
  17.                 p->owner = module;
  18.                 p->get = probe;  /* 是exact_match ()函数,获取cdev结构体的kobject指针 */
  19.                 p->lock = lock;   /* 是exact_lock()函数,增加引用*/
  20.                 p->dev = dev;
  21.                 p->range = range;
  22.                 p->data = data;      /* cdev保存到p->data中 */
  23.         }
  24.         mutex_lock(domain->lock);
  25.         /* 将这一亩三分田加到主设备号对应的位置上去 */
  26.         for (i = 0, p -= n; i < n; i++, p++, index++) {
  27.                 struct probe **s = &domain->probes[index % 255];
  28.                 while (*s && (*s)->range < range)
  29.                         s = &(*s)->next;
  30.                 p->next = *s;
  31.                 *s = p;
  32.         }
  33.         mutex_unlock(domain->lock);
  34.         return 0;
  35. }
复制代码
接下来有class_create()函数和class_device_create()函数,前者生成一个名字为"testchar"的class,后者作用就是在/dev目录下生成设备节点,当然,需要uevent和UDEVD的支持,具体可见鄙人博客上的文章《Linux设备模型浅析之uevent篇》。
     顺带说下register_chrdev()函数,其也是注册字符设备驱动,只不过是封装好的,包含了所有前面讲的注册步骤——分配一个设备号,由一个主设备号和255个次设备号组成。如下,
  1. int register_chrdev(unsigned int major, const char *name,
  2.                     const struct file_operations *fops)
  3. {
  4.         struct char_device_struct *cd;
  5.         struct cdev *cdev;
  6.         char *s;
  7.         int err = -ENOMEM;

  8.         cd = __register_chrdev_region(major, 0, 256, name);
  9.         if (IS_ERR(cd))
  10.                 return PTR_ERR(cd);
  11.         
  12.         cdev = cdev_alloc();   /* 这个有点不一样,动态分配的,不是调用者提供 */
  13.         if (!cdev)
  14.                 goto out2;

  15.         cdev->owner = fops->owner;
  16.         cdev->ops = fops;
  17.         kobject_set_name(&cdev->kobj, "%s", name);
  18.         for (s = strchr(kobject_name(&cdev->kobj),'/'); s; s = strchr(s, '/'))
  19.                 *s = '!';
  20.                
  21.         err = cdev_add(cdev, MKDEV(cd->major, 0), 256);
  22.         if (err)
  23.                 goto out;

  24.         cd->cdev = cdev;

  25.         return major ? 0 : cd->major;
  26. out:
  27.         kobject_put(&cdev->kobj);
  28. out2:
  29.         kfree(__unregister_chrdev_region(cd->major, 0, 256));
  30.         return err;
  31. }
复制代码

论坛徽章:
0
2 [报告]
发表于 2010-07-19 19:15 |只看该作者
本帖最后由 zhiqiang0071 于 2010-07-26 09:18 编辑

二、字符设备驱动的调用机制
都注册好了,应用程序可以打开该设备驱动文件了。都是通过它来实现的,
  1. const struct file_operations def_chr_fops = {
  2.         .open = chrdev_open,
  3. };
复制代码
在char_dev.c文件中并没有哪个例程使用def_chr_fops,在隔壁的fs/inode.c中被init_special_inode()函数调用了,如下
  1. void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
  2. {
  3.         inode->i_mode = mode;
  4.         if (S_ISCHR(mode)) {         /* 判断是不是字符类型的文件 */
  5.                 inode->i_fop = &def_chr_fops;   /* 这就是我们要找的 */
  6.                 inode->i_rdev = rdev;
  7.         } else if (S_ISBLK(mode)) {
  8.                 inode->i_fop = &def_blk_fops;
  9.                 inode->i_rdev = rdev;
  10.         } else if (S_ISFIFO(mode))
  11.                 inode->i_fop = &def_fifo_fops;
  12.         else if (S_ISSOCK(mode))
  13.                 inode->i_fop = &bad_sock_fops;
  14.         else
  15.                 printk(KERN_DEBUG "init_special_inode: bogus i_mode (%o)\n",
  16.                        mode);
  17. }
复制代码
init_special_inode()函数又被特定文件系统调用,比如cramfs,在fs/cramfs/inode.c的cramfs_iget5_set()函数中被调用。所以对任何文件系统操作时都能调用到。要知道linux采用了VFS虚拟文件系统层,也就是可以同时支持很多不同种类的文件系统,比如cramfs, ext2, ext3等,但这些文件系统都要注册到VFS层才行,注册后,就有机会调用到cramfs_iget5_set()了,具体不详谈了(其实我知道的也就这么多,呵呵)。很显然,inode在linux系统中代表一个文件或目录,inode->i_fop用来对文件或目录进行操作的,所以这里inode->i_fop = &def_chr_fops后,应用程序再去调用open、close、read、write和ioctl等操作时,就映射到了def_chr_fops,但是,def_chr_fops中只有一个.open = chrdev_open,并没有read, wirte等的实现,那么现在就来说说为什么了。
当应用程序使用open()打开字符设备驱动文件时,最终调用到了chrdev_open()函数,我们来看看其中发生了什么,
  1. int chrdev_open(struct inode * inode, struct file * filp)
  2. {
  3.         struct cdev *p;
  4.         struct cdev *new = NULL;
  5.         int ret = 0;

  6.         spin_lock(&cdev_lock);
  7.         p = inode->i_cdev;
  8.         if (!p) {        /* 很显然,第一次打开的时候是NULL */
  9.                 struct kobject *kobj;
  10.                 int idx;
  11.                 spin_unlock(&cdev_lock);
  12.               /* 找到和设备号i_rdev对应的kobj,其实就是cdev了,因为cdev中包含kobj;idx保存的是次设备号,后面会分析kobj_lookup()函数 */
  13.                 kobj = kobj_lookup(cdev_map, inode->i_rdev, &idx);
  14.                 if (!kobj)
  15.                         return -ENXIO;
  16.                 new = container_of(kobj, struct cdev, kobj);  /* 说的没错吧,得到cdev了 */
  17.                 spin_lock(&cdev_lock);
  18.                 p = inode->i_cdev;
  19.                 if (!p) {
  20.                         inode->i_cdev = p = new;  /* 把找到的cdev保存到inode的icdev中 */
  21.                         inode->i_cindex = idx;       /* 次设备号保存到inode中,用户驱动可能需要 */
  22.                         list_add(&inode->i_devices, &p->list);  /* inode加入到cdev的链表中 */
  23.                         new = NULL;
  24.                 } else if (!cdev_get(p))
  25.                         ret = -ENXIO;
  26.         } else if (!cdev_get(p))
  27.                 ret = -ENXIO;
  28.         spin_unlock(&cdev_lock);
  29.         cdev_put(new);
  30.         if (ret)
  31.                 return ret;
  32. /*
  33. 保存用户的fops,以后你再调用read, write, ioctl系统调用的时候就直接使用了,你懂的
  34. */
  35.         filp->f_op = fops_get(p->ops);
  36.         if (!filp->f_op) {         /* 如果你没有注册fops,那你的路还得慢慢走 */
  37.                 cdev_put(p);
  38.                 return -ENXIO;
  39.         }
  40.         /* 判断open函数是否存在,没有,那么对不起了,不合格 */
  41.         if (filp->f_op->open) {
  42.                 lock_kernel();
  43.                 /* 调用用户的open函数,你写过驱动,你懂的 */
  44.                 ret = filp->f_op->open(inode,filp);
  45.         }
  46.         if (ret)
  47.                 cdev_put(p);
  48.         return ret;
  49. }
复制代码
看到了吧,这个chrdev_open()函数起到承上启下的作用,把用户注册的字符驱动程序的各个函数调用与应用程序调用连接了起来。
再来看一下kobj_lookup()函数吧,
  1. struct kobject *kobj_lookup(struct kobj_map *domain, dev_t dev, int *index)
  2. {
  3.         struct kobject *kobj;
  4.         struct probe *p;
  5.         unsigned long best = ~0UL;

  6. retry:
  7.         mutex_lock(domain->lock);
  8.        /* 根据主设备号和设备号查找它的一亩三分地。因为要支持2^12次方也就是4096个主设备号,但只使用了前255个主设备号索引,所以这255个索引对应的probe结构都有一个单向      链表保存着大于255的主设备号(被255整除后的索引相等)  */
  9.         for (p = domain->probes[MAJOR(dev) % 255]; p; p = p->next) {
  10.                 struct kobject *(*probe)(dev_t, int *, void *);
  11.                 struct module *owner;
  12.                 void *data;
  13.                 /* 比较,看是否真找到了,因为有链表存在,所以不一定就是哦… */
  14.                 if (p->dev > dev || p->dev + p->range - 1 < dev)
  15.                         continue;         /* 不是就继续找呗 */
  16.                 if (p->range - 1 >= best)  /* 不太可能吧… */
  17.                         break;
  18.                 if (!try_module_get(p->owner))
  19.                         continue;
  20.                 owner = p->owner;
  21.                 data = p->data;  /* 你如果从头看到现在,那么你了解,data就是cdev */
  22.                 probe = p->get;
  23.                 best = p->range - 1;
  24.                 *index = dev - p->dev;  /* 得到了次设备号,为什么?你懂的… */
  25.                 /* 调用的lock就是exact_lock()函数,增加对该字符设备驱动的引用,防止被卸载什么的 */
  26.                 if (p->lock && p->lock(dev, data) < 0) {
  27.                         module_put(owner);
  28.                         continue;
  29.                 }
  30.                 mutex_unlock(domain->lock);
  31.                 /*调用的probe就是exact_match()函数,获取cdev的kobj指针 */
  32.                 kobj = probe(dev, index, data);
  33.                 /* Currently ->owner protects _only_ ->probe() itself. */
  34.                 module_put(owner);
  35.                 if (kobj)
  36.                         return kobj;
  37.                 goto retry;
  38.         }
  39.         mutex_unlock(domain->lock);
  40.         return NULL;
  41. }。
复制代码
open调用的流程图如下(ARM平台),


三、字符设备驱动的注销
当你想换个花样,不想使用这个字符设备驱动的时候,你可能会叫“rmmod 帝”帮下忙,要不然也没啥别的办法。rmmod命令使用后,会调用到文章开头里说的一个注销函数testchar _cleanup(),
  1. void __exit testchar _cleanup(void)
  2. {
  3.         /* remove major number allocated to this driver */
  4.         unregister_chrdev_region(dev, 1);

  5.         /* remove simple class device */
  6.         class_device_destroy(testchar_class, dev);

  7.         /* destroy simple class */
  8.         class_destroy(testchar class);

  9.         cdev_del(&cdev);

  10.         /* unregistering the driver from the kernel */
  11.         unregister_chrdev(MAJOR(dev), "testchar");
  12. }
复制代码
具体就不深入去分析了,你有空,可以跟进去看看。都很简单,概括起来主要是三个方面,一是把已分配的一亩三分地(probe和char_device_struct)重置为空闲的地;二是把已经分配的内存注销掉;三是kobj清理,切断与文件系统inode的藕断丝连。

四、总结

没啥说的了,就这样吧。


字符设备驱动模型浅析.pdf (197.56 KB, 下载次数: 367)

论坛徽章:
0
3 [报告]
发表于 2010-07-19 19:56 |只看该作者
不错{:3_185:}

论坛徽章:
1
天蝎座
日期:2013-10-23 21:11:03
4 [报告]
发表于 2010-07-19 20:07 |只看该作者
写得不错

论坛徽章:
0
5 [报告]
发表于 2010-07-19 22:43 |只看该作者
不错,顶!很适合像我这样的新手。感谢lz

论坛徽章:
0
6 [报告]
发表于 2010-07-20 11:25 |只看该作者
lz其他的博文也很强啊,是不是考虑出本书了

论坛徽章:
0
7 [报告]
发表于 2010-07-20 20:37 |只看该作者
好东西,看看

论坛徽章:
0
8 [报告]
发表于 2010-07-22 18:26 |只看该作者
奇怪,dreamice兄,这篇文章咋没评上原创文章呢?

论坛徽章:
0
9 [报告]
发表于 2010-07-23 18:36 |只看该作者
好东西`~哥大大的支持你多写写原创的。。有什么好的资料就给我发过来啊~{:3_181:}

论坛徽章:
0
10 [报告]
发表于 2010-09-03 14:27 |只看该作者
狠狠的MARK
您需要登录后才可以回帖 登录 | 注册

本版积分规则 发表回复

  

北京盛拓优讯信息技术有限公司. 版权所有 京ICP备16024965号-6 北京市公安局海淀分局网监中心备案编号:11010802020122 niuxiaotong@pcpop.com 17352615567
未成年举报专区
中国互联网协会会员  联系我们:huangweiwei@itpub.net
感谢所有关心和支持过ChinaUnix的朋友们 转载本站内容请注明原作者名及出处

清除 Cookies - ChinaUnix - Archiver - WAP - TOP