关闭

写一个块设备驱动 7

347人阅读 评论(0) 收藏 举报

第 7章

+---------------------------------------------------+
|                 写一个块设备驱动                   |
+---------------------------------------------------+
| 作者:赵磊                                         |
| email: zhaoleidd@hotmail.com                      |
+---------------------------------------------------+
| 文章版权归原作者所有。                             |
| 大家可以自由转载这篇文章,但原版权信息必须保留。   |
| 如需用于商业用途,请务必与原作者联系,若因未取得   |
| 授权而收起的版权争议,由侵权者自行负责。           |
+---------------------------------------------------+

上一章中我们对驱动程序做了很大的修改,单独分配每一页的内存,然后使用基树来进行管理

这使得驱动程序占用的非线性映射区域大大减少,让它看起来朝优秀的代码                         接近了一些

因为优秀的代码是相似的,糟 的代码却各有各的糟 之处

本章中我们将讨论一些细枝末节的问题,算是对上一章中内容的巩固,也是为后面的章节作一些铺垫

首先聊一聊低端内存、高端内存和非线性映射区域的问题:

在 i386结构中,由于任务使用 32位寄存器表示地址,这造成每个任务的最大寻址范围是 4G

无论任务对应的是用户程序还是内核代码,都逃脱不了这个限制

让问题更糟 的是,普通的 linux内核又将4G的地址划分为 2个部分,前3G让用户空间程序使用,后
1G由内核本身使用
这又将内核实际使用的空间压缩了 4倍
不过 linux采用这样的方案倒也不是由于开发者脑瘫,因为这样一来,内核可以与用户进程共用同一个

页表,

因而在进行用户态和内核态的切换时不必刷新页表,提高了系统的效率

而带来的麻烦就是内核只有 1G的地址范围可用
其实也有一个相当出名的 4G+4G的 patch ,就是采用上述相反的方法,让内核与用户进程使用独立的地

址空间,其优缺点也正好与现在的实现相反

但这毕竟不是标准内核的情况,对大多数系统而言,我们不得不接受内核只有 1G的地址范围可用的现实

----------------------- Page 50-----------------------

然后我们再来看内核如何使用这 1G的地址范围

作为内核,当然需要有能力访问到所有的物理内存,而在保护模式下,内存需要通过页表映射到一个虚

拟地址上,再进行访问

虽然内核可以在访问任何物理内存时都采用映射->访问->取消映射的方法,但这很可能将任意一台机器
彻底变成 386的速度
因此,内核一般把尽可能多的物理内存事先映射到它的地址空间中去,这里的“尽可能多”指的是 896M
原因是内核手头只有 1G的地址空间,而其中的 128M还需要留作非线性映射空间
这样一来,内核地址空间中的 3G~3G+896M便映射了 0~896M范围的物理内存

这个映射关系在启动系统时完成,并且在系统启动后不会改变

物理内存中 0~896M的这段空间是幸运的,因为它们在内核空间中有固定的住所,
这也使它们能够方便、快速地被访问。相对896M以上的物理内存,它们地址是比较低的,

正因为此,我们通常把这部分内存区域叫做低端内存

但地址高于 896M的物理内存就没这么幸运了

由于它们没有在启动时被固定映射到内核空间的地址空间中,我们需要在访问之前对它们进行映射

但映射到哪里呢?幸好内核没有把整个 1G的地址空间都用作映射上面所说的低端内存,好歹还留下
128M
其实这 128M还是全都能用,在其开头和结尾处还有一些区域拿去干别的事情了 (希望读者去详细了解一
下) ,
所以我们可以用这剩下的接近128M的区域来映射高于 896M的物理内存

明显可以看出这时是僧多粥少,所以这部分区域最好应该节约使用

但希望读者不要把访问高于 896M的物理内存的问题想得过于严重,因为一般来说,内核会倾向于把这部

分内存分配给用户进程使用,而这是不需要占用内核空间地址的

其实非线性映射区域还有另一个作用,就是用来作连续地址的映射

内核采用伙伴系统管理内存,这使得内核程序可以一次申请 2的 n次幂个页面
但如果 n 比较大时,申请失败的风险也会随之增加               正如桑拿时遇到双胞胎的机会很少、遇到三胞胎的

机会更少一样,

获得地址连续的空闲页面的机会总是随着连续地址长度的增加而减少

另外,即使能够幸运地得到地址连续的空闲页面,可能产生的浪费问题也是不能回避的

比如我们需要申请地址连续513K的内存,从伙伴系统中申请时,由于只能选择申请 2的 n次幂个页面,
因此我们不得不去申请 1M内存

不过这两个问题倒是都能够通过使用非线性映射区域来解决

我们可以从伙伴系统中申请多个小段的内存,然后把它们映射到非线性映射区域中的连续区域中访问

内核中与此相关的函数有 vmalloc、vmap等

其实8 前的作者很羡慕8 后和 9 后的新一代,不仅因为可以在上中学时谈恋爱,
还因为随着64位系统的流行,上面这些与 32位系统如影随形的问题都将不复存在
关于 64位系统中的内存区域问题就留给有兴趣的读者去钻研了

然后我们再谈谈linux中的伙伴系统

----------------------- Page 51-----------------------

伙伴系统总是分配出 2的 n次幂个连续页面,并且首地址以其长度为单位对齐

这增大了将回收的页与其它空白页合并的可能性,也就是减少了内存碎片

我们的块设备驱动程序需要从伙伴系统中获得所需的内存

目前的做法是每次获得 1个页面,也就是分配页面时,把2的 n次幂中的 n指定为

这样做的好处是只要系统中存在空闲的页面,不管空闲的页面是否连续,分配总是能成功

但坏处是增加了造就页面碎片的几率

当系统中没有单独的空闲页面时,伙伴系统就不得不把原先连续的空闲页面拆开,再把其中的 1个页面

返回给我们的程序

同时,在伙伴系统中需要使用额外的内存来管理每一组连续的空闲页面,因此增大页面碎片也意味着需

要更多的内存来管理这些碎片

这还不算,如果系统中的空闲页面都以碎片方式存在,那么真正到了需要分配连续页面的时候,即使存

在空闲的内存,也会因为这些内存不连续而导致分配失败

除了对系统的影响以外,对我们的驱动程序本身而言,由于使用了基树来管理每一段内存,将内存段定

义得越短,意味着需要管理更多的段数,也意味着更大的基树结构和更慢的操作

因此我们打算增加单次从伙伴系统中获得连续内存的长度,比如,每次分配2个、4个、或者 8个甚至
64个页,来避免上述的问题

每次分配更大的连续页面很明显拥有不少优势,但其劣势也同样明显:

当系统中内存碎片较多时,吃亏的就是咱们的驱动程序了。原本分很多次一点一点去系统讨要,最终可

以要到足够的内存,但像现在这样子狮子大开口,却反而要不到了

还有就是如果系统中原先就存在不少碎片,原先的分配方式倒是可以把碎片都利用起来,而现在这种挑

肥捡瘦的分配会同样无视那些更小的不连续页面,反而可能企图去拆散那些更大的连续页面

折中的做法大概就是选择每次分配一块不大不小的连续的页,暂且我们选择每次分配连续的 4个页

现在开始修改代码:

为简单起见,我们了以下的4个宏:
#define SIMP_BLKDEV_DATASEGORDER        (2)
#define SIMP_BLKDEV_DATASEGSHIFT        (PAGE_SHIFT + SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGSIZE                (PAGE_SIZE << 
SIMP_BLKDEV_DATASEGORDER)
#define SIMP_BLKDEV_DATASEGMASK                (~(SIMP_BLKDEV_DATASEGSIZE-1))
SIMP_BLKDEV_DATASEGORDER表示我们从伙伴系统中申请内存时使用的 order值,把这个值设置为 2
时,每次将从伙伴系统中申请连续的 4个页面
我们暂且把这样的连续页面叫做内存段,这样一来,在 i386结构中,每个内存段的大小为 16K ,假设块
设备大小还是 16M ,那么经历了本章的修改后,
驱动程序所使用的内存段数量将从原先的 4096个减少为现在的 1024个
SIMP_BLKDEV_DATASEGSHIFT是在偏移量和内存段之间相互转换时使用的移位值,类似于页面处理中
的 PAGE_SHIFT。这里就不做更详细地介绍了,毕竟这不是 C语言教程
SIMP_BLKDEV_DATASEGSIZE是以字节为单位的内存段的长度,在i386和
SIMP_BLKDEV_DATASEGORDER=2时它的值是 16384
SIMP_BLKDEV_DATASEGMASK是内存段的屏蔽位,类似于页面处理中的 PAGE_MASK

----------------------- Page 52-----------------------

其实对于功能而言,我们只需要SIMP_BLKDEV_DATASEGORDER和 SIMP_BLKDEV_DATASEGSIZE就足够

了,其它的宏用于快速的乘除和取模等计算

如果读者对此感到有些迷茫的话,建议最好还是搞明白,因为在linux内核的世界中这一类的位操作将

随处可见

然后要改的是申请和释放内存代码

原先我们使用的是__get_free_page()和 free_page()函数,这一对函数用来申请和释放一个页面
这显然不能满足现在的要求,我们改用它们的大哥:__get_free_pages()和 free_pages()

它们的原型是:

unsigned long __get_free_pages(gfp_t gfp_mask, unsigned int order);
void free_pages(unsigned long addr, unsigned int order);
可以注意到与__get_free_page()和 free_page()函数相比,他们多了个 order参数,正是用于指定
返回 2的多少次幂个连续的页
因此原先的 free_diskmem()和 alloc_diskmem()函数将改成以下这样:
void free_diskmem(void)
{
        int i;
        void *p;

        for (i = 0; i < (SIMP_BLKDEV_BYTES + SIMP_BLKDEV_DATASEGSIZE - 1)
                >> SIMP_BLKDEV_DATASEGSHIFT; i++) {
                p = radix_tree_lookup(&simp_blkdev_data, i);
                radix_tree_delete(&simp_blkdev_data, i);
                /* free NULL is safe */
                free_pages((unsigned long)p, SIMP_BLKDEV_DATASEGORDER);
        }
}

int alloc_diskmem(void)
{
        int ret;
        int i;
        void *p;

        INIT_RADIX_TREE(&simp_blkdev_data, GFP_KERNEL);

        for (i = 0; i < (SIMP_BLKDEV_BYTES + SIMP_BLKDEV_DATASEGSIZE - 1)
                >> SIMP_BLKDEV_DATASEGSHIFT; i++) {
                p = (void *)__get_free_pages(GFP_KERNEL,
                        SIMP_BLKDEV_DATASEGORDER);
                if (!p) {
                        ret = -ENOMEM;

----------------------- Page 53-----------------------

                        goto err_alloc;
                }

                ret = radix_tree_insert(&simp_blkdev_data, i, p);
                if (IS_ERR_VALUE(ret))
                        goto err_radix_tree_insert;
        }
        return 0;

err_radix_tree_insert:
        free_pages((unsigned long)p, SIMP_BLKDEV_DATASEGORDER);
err_alloc:
        free_diskmem();
        return ret;
}
除了用__get_free_pages()和 free_pages()代替了原先的__get_free_page()和 free_page()函

数以外,

还使用刚刚定义的那几个宏代替了原先的 PAGE宏

这样一来,所需内存段数的计算方法也完成了修改

剩下的就是使用内存段的 simp_blkdev_make_request()代码
实际上,我们只要用刚才定义的 SIMP_BLKDEV_DATASEGSIZE、SIMP_BLKDEV_DATASEGMASK和 
SIMP_BLKDEV_DATASEGSHIFT替换原先代码中的 PAGE_SIZE、PAGE_MASK和 PAGE_SHIFT就大功告成

了,

当然,这个结论是作者是经过充分检查和实验后才得出的,希望不要误认为编程时可以大大咧咧地随心

所欲。作为程序员,严谨的态度永远都是需要的

现在,我们的 simp_blkdev_make_request()函数变成了这样:
static int simp_blkdev_make_request(struct request_queue *q, struct bio *bio)
{
        struct bio_vec *bvec;
        int i;
        unsigned long long dsk_offset;

        if ((bio->bi_sector << 9) + bio->bi_size > SIMP_BLKDEV_BYTES) {
                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                         ": bad request: block=%llu, count=%u\n",
                         (unsigned long long)bio->bi_sector, bio->bi_size);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
                bio_endio(bio, 0, -EIO);
#else
                bio_endio(bio, -EIO);

----------------------- Page 54-----------------------

#endif
                return 0;
        }

        dsk_offset = bio->bi_sector << 9;

        bio_for_each_segment(bvec, bio, i) {
                unsigned int count_done, count_current;
                void *iovec_mem;
                void *dsk_mem;

                iovec_mem = kmap(bvec->bv_page) + bvec->bv_offset;

                count_done = 0;
                while (count_done < bvec->bv_len) {
                        count_current = min(bvec->bv_len - count_done,
                                 (unsigned int)(SIMP_BLKDEV_DATASEGSIZE
                                - ((dsk_offset + count_done) &
                                ~SIMP_BLKDEV_DATASEGMASK)));

                        dsk_mem = radix_tree_lookup(&simp_blkdev_data,
                                 (dsk_offset + count_done)
                                >> SIMP_BLKDEV_DATASEGSHIFT);
                        if (!dsk_mem) {
                                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                         ": search memory failed: %llu\n",
                                         (dsk_offset + count_done)
                                        >> SIMP_BLKDEV_DATASEGSHIFT);
                                kunmap(bvec->bv_page);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
                                bio_endio(bio, 0, -EIO);
#else
                                bio_endio(bio, -EIO);
#endif
                                return 0;
                        }
                        dsk_mem += (dsk_offset + count_done)
                                & ~SIMP_BLKDEV_DATASEGMASK;

                        switch (bio_rw(bio)) {
                        case READ:
                        case READA:

----------------------- Page 55-----------------------

                                memcpy(iovec_mem + count_done, dsk_mem,
                                        count_current);
                                break;
                        case WRITE:
                                memcpy(dsk_mem, iovec_mem + count_done,
                                        count_current);
                                break;
                        default:
                                printk(KERN_ERR SIMP_BLKDEV_DISKNAME
                                         ": unknown value of bio_rw: %lu\n",
                                        bio_rw(bio));
                                kunmap(bvec->bv_page);
#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
                                bio_endio(bio, 0, -EIO);
#else
                                bio_endio(bio, -EIO);
#endif
                                return 0;
                        }
                        count_done += count_current;
                }

                kunmap(bvec->bv_page);
                dsk_offset += bvec->bv_len;
        }

#if LINUX_VERSION_CODE < KERNEL_VERSION(2, 6, 24)
        bio_endio(bio, bio->bi_size, 0);
#else
        bio_endio(bio, 0);
#endif

        return 0;
}

本章的到这里就完成了,接下去我们还是打算试验一下效果

其实这个实验不太好做,因为 linux本身也会随时分配和释放页面,这会影响我们看到的结果

如果读者看到的现象与预期不同,这也属于预期

不过为了降低试验受到 linux自身活动影响的可能性,建议试验开始之前尽可能关闭系统中的服务、不
要同时做其它的操作、不要在 xwindows中做

然后我们开始试验:

----------------------- Page 56-----------------------

先编译模块:

# make
make -C /lib/modules/2.6.18-53.el5/build 
SUBDIRS=/root/test/simp_blkdev/simp_blkdev_step07 modules
make[1]: Entering directory `/usr/src/kernels/2.6.18-53.el5-i686'
  CC [M]  /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.o
  Building modules, stage 2.
  MODPOST
  CC      /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.mod.o
  LD [M]  /root/test/simp_blkdev/simp_blkdev_step07/simp_blkdev.ko
make[1]: Leaving directory `/usr/src/kernels/2.6.18-53.el5-i686'
#

现在看看伙伴系统的情况:

# cat /proc/buddyinfo
Node 0, zone      DMA    288     63     34      0      0      0      0      1 
1      1      
Node 0, zone   Normal   9955   1605     24      1      0      1      1        
0      0      1
Node 0, zone  HighMem   2036    544     13      6      2      1      1        
0      0      
#

加载模块后再看看伙伴系统的情况:

# insmod simp_blkdev.ko
# cat /proc/buddyinfo
Node 0, zone      DMA    337    140      1      1      1      0      0        
1      0      
Node 0, zone   Normal  27888   8859     18      0      0      1      0        
1      0      
Node 0, zone  HighMem   1583    544     13      6      2      1      1        
0      0      
#

释放模块后再看看伙伴系统的情况:

# rmmod simp_blkdev
# cat /proc/buddyinfo
Node 0, zone      DMA    337    140     35      0      0      0      0      1 
1      1      
Node 0, zone   Normal  27888   8860    632      7      0      1      1        
0      0      1
Node 0, zone  HighMem   1583    544     13      6      2      1      1        
0      0      

----------------------- Page 57-----------------------

#

首先补充说明一下伙伴系统对每种类型的内存区域分别管理,这在伙伴系统中称之为 zone
在 i386中,常见的 zone有 DMA、Normal和 HighMem ,分别对应0~16M、16~896M和 896M以上的物

理内存

DMA zone的特点是老式ISA设备只能使用这段区域进行 DMA操作
Normal zone的特点它被固定映射在内核的地址空间中,我们可以直接使用指针访问这段内存                          (不难
看出,DMA zone也有这个性质)
HighMem zone的特点它没有以上两种 zone的特点
其实我们在上文中讲述的低端内存区域是这里的 DMA和 Normal zone ,而高端内存区域是这里的
HighMem zone

/proc/buddyinfo用于显示伙伴系统的各个 zone中剩余的各个 order的内存段个数

我们的模块目前使用低端内存来存储数据,而一般情况下系统会尽可能保留 DMA zone的空域内存不被

分配出去,

因此我们主要关注/proc/buddyinfo 中的Normal行
行中的各列中的数字表示伙伴系统的这一区域中每个 order的剩余内存数量

比如:

Node 0, zone   Normal   9955   1605     24      1      0      1      1        
0      0      1
这一行表示Normal zone 中剩余9955个独立的内存页、1605个连续2个页的内存、24连续4个页的

内存等

由于我们现在每次申请 4个页的内存,因此最关注的 Normal行的第 3列

首先看模块加载前,Normal行的第 3列数字是 24 ,表示系统中剩余24个连续4页的内存区域
然后我们看模块加载之后的情况,Normal行的第 3列从24变为了 18 ,减少了 6个连续4页的内存区域

这说明我们的程序只用掉了 6个连续4页的内存区域------明显不可能
因为作为模块编者,我们很清楚程序需要使用 1024个连续4页的内存区域

继续看这一行的后面,原先处在最末尾的 1便成了
我们可以数出来最末尾的数字对应order为 1 的连续页面,也就是连续4M的页面,原来是空闲的,而

现在被拆散用掉了

但即使它被用掉了,也不够我们的的 16M空间,数字的分析变得越来越复杂,是坚持下去还是就此停止?

这一次我们决定停止,因为真相是现在进行的模块加载前后的剩余内存对比确实产生不了什么结论

详细解释一下,其实我们可以看出在模块加载之前,Normal 区域中 order>=2的全部空闲内存加起来也

不够这个模块使用

甚至加上 DMA 区域中 order>=2的全部空闲内存也不够

----------------------- Page 58-----------------------

虽然剩余的 order<2的一大堆页面凑起来倒是足够,但谁让我们的模块挑食,只要order=2的页面呢

因此这时候系统会试图释放出空闲内存。比如:释放一些块设备缓冲页面,或者将用户进程的内存转移

到 swap中,以获得更多的空闲内存

很幸运,系统通过释放内存操作拿到了足够的空闲内存使我们的模块得以顺利加载,

但同时由于额外增加出的空闲内存使我们对比模块加载前后的内存差别失去了意义

其实细心一些的话,刚才的对比中,我们还是能够得到一些结论的,比如,

我们可以注意到模块加载后 order为           和 1的两个数字的暴增,这就是系统释放页面的证明
详细来说,系统释放出的页面既包含order<2的,也包含order>=2的,但由于其中 order>=2的页面

多半被我们的程序拿走了,

这就造成模块加载后的空闲页面中大量出现order<2的页面

既然我们没有从模块加载前后的空闲内存变化中拿到什么有意义的结论,

我们不妨换条路走,去看看模块释放前后空闲内存的变化情况:

首先还是看 Normal 区域:
order为   和 1的页面数目基本没有变化,这容易解释,因为我们释放出的都是 order=2的连续页面
order=2的连续页面从18增加到 632 ,增加了 614个。这应该是模块卸载时所释放的内存的一部分
由于这个模块在卸载时,会释放1024个 order=2的连续页面,那么我们还要继续找出模块释放的内存

中其他部分的行踪

也就是 1024-614=41 个 order=2的连续页到哪去了

回顾上文中的伙伴系统说明,伙伴系统会适时地合并连续页面,那么我们假设一部分模块释放出的页面

被合并成更大 order的连续页面了
让我们计算一下 order>2的页面的增加情况:
order=3的页面增加了 7个,order=6的页面增加了 1个,order=8的页面减少了 1个,order=1 的
页面增加了 1个
这分别相当于 order=2的页面增加 14个、增加 16、减少64个、增加 256个,综合起来就是增加 222

这就又找到了一部分,剩下的行踪不明的页面还有 410-222=188个

我们继续追查,现在 DMA zone 区域
我们的程序所使用的是低端内存,其实也包含0~16M之间的 DMA zone
刚才我们说过,系统会尽可能不把DMA 区域的内存分配出去,以保证真正到必须使用这部分内存时,能

够拿得出来

但“尽可能”不代表“绝对不”,如果出现内存不足的情况,DMA zone的空闲内存也很难幸免
但刚才我们的试验中,已经遇到了 Normal 区域内存不足情况,这时把DMA zone中的公主们拿去充当
Normal zone的军妓也是必然的了
因此我们继续计算模块释放后 DMA 区域的内存变化。在DMA 区域:
order=2的页面增加了 34个,order=3的页面减少了 1个,order=4的页面减少了 1个,order=7的
页面增加了 1个,order=9的页面增加了 1个

----------------------- Page 59-----------------------

这分别相当于 order=2的页面增加 34个、减少2、减少4个、增加 32个,增加 128个,综合起来就是
增加 188个

数字刚好吻合,我们就找到了模块释放出的全部页面的行踪

这也验证了本章中改动的功能符合预期

然后我们再一次加载和卸载模块,同时查看伙伴系统中空闲内存的变化:

# insmod simp_blkdev.ko
# cat /proc/buddyinfo
Node 0, zone      DMA    336    141      0      0      0      1      1        
1      0      
Node 0, zone   Normal  27781   8866      0      1      0      1      0        
1      0      
Node 0, zone  HighMem   1459    544     13      6      2      1      1        
0      0      
#
# rmmod simp_blkdev
# cat /proc/buddyinfo
Node 0, zone      DMA    336    141     35      0      0      0      0      1 
1      1      
Node 0, zone   Normal  27781   8867    633      7      0      1      1        
0      0      1
Node 0, zone  HighMem   1459    544     13      6      2      1      1        
0      0      
#

我们可以发现这一次模块加载前后的内存变化情况与上一轮有些不同,而分析工作就留给有兴趣的读者

本章对代码的改动量不大,主要说明一下与我们程序中出现的 linux内存管理知识

其实上一章的改动中已经涉及到了这部分知识,只是因为那时的重点不在这个方面,并且作者也不希望

在同一章中加入过多的内容,

因此在本章中做个补足

同时,本章中的说明也给后续章节中将要涉及到的内容做个准备,这样读者在将来也可以惬意一些

不过在开始写这一章时,作者曾反复考虑该不该这样组织本章,

正如我们曾经说过的,希望读者在遇到不明白的地方时主动去探索教程之外更多的知识,

而不是仅仅读完这个教程本身

本教程的目的是牵引出通过实现一个块设备驱动程序来牵引出相关的 linux的各个知识点,

让读者们以此为契机,通过寻求疑问的答案、通过学习更细节的知识来提高自己的能力

因此教程中对于不少涉及到的知识点仅仅给出简单的介绍,因为读者完全有能力通过 google了解更详细

的内容,

这也是作者建议的看书方法

----------------------- Page 60-----------------------

不过本章是个例外,因为作者最终认为对这些知识的介绍对于这部教程的整体性是有帮助的

但这里的介绍其实仍然只属于皮毛,因此还是希望读者进一步了解教程以外的更多知识

<未完,待续>


0
0

查看评论
* 以上用户言论只代表其个人观点,不代表CSDN网站的观点或立场
    个人资料
    • 访问:89108次
    • 积分:1023
    • 等级:
    • 排名:千里之外
    • 原创:0篇
    • 转载:83篇
    • 译文:0篇
    • 评论:1条
    文章分类
    最新评论