声明:文本是看完韦东山老师的视频和看了一些文章后,所写的总结。我会尽力将自己所了解的知识写出来,但由于自己感觉并没有学的很好,所以文中可能有错的地方敬请指出,谢谢。

在介绍本文之前,我想先对前面的知识做一下总结,我们知道Linux系统的设备分为字符设备(char device),块设备(block device),以及网络设备(network device)。字符设备是指存取时没有缓存的设备。块设备的读写都有缓存来支持,并且块设备必须能够随机存取(random access),字符设备则没有这个要求。典型的字符设备包括鼠标,键盘,串行口等。块设备主要包括硬盘软盘设备,CD-ROM等。一个文件系统要安装进入操作系统必须在块设备上。 
        网络设备在Linux里做专门的处理。Linux的网络系统主要是基于BSD unix的socket机制。在系统和驱动程序之间定义有专门的数据结构(sk_buff)进行数据的传递。系统里支持对发送数据和接收数据的缓存,提供流量控制机制,提供对多协议的支持。

而本文主要对网卡驱动进行讲解,同时会分为两部分,第一部分介绍网卡驱动程序的框架,而另一部分我将以一个老师课上用的例子来完成一个虚拟的网卡驱动程序的编写。

下面开始介绍网卡驱动程序的框架:

而要说到网卡驱动我们就要说到网络协议的分层了,下面是一个网络协议的分层图:

在上面这幅图中我们可以看到有两种分层方式,一种是OSI七层网络模型,而另一种是LinuxTCP/IP四层概念模型。而我们要写的就是LinuxTCP/IP四层概念模型中的网络接口层。而对应到OSI七层网络模型中我们主要是写数据链路层。通过链路层从上面接收数据包将其传输到下面物理层,或者下面物理层有数据传来而触发中断来接收数据,然后将其在传到上面的网络层。而我们将这个过程细分一下可以得到下面的分层图:

而上图中每一层的含义为:

1)网络协议接口层:

实现统一的数据包收发的协议,该层主要负责调用dev_queue_xmit()函数发送数据包到下层或者调用 netif_rx()函数接收数据包

2)网络设备接口层:

通过net_device结构体来描述一个具体的网络设备的信息,实现不同的硬件的统一

3)设备驱动功能层:

用来负责驱动网络设备硬件来完成各个功能, 它通过hard_start_xmit() 函数启动发送操作, 并通过网络设备上的中断触发接收操作,

4)网络设备与媒介层:

用来负责完成数据包发送和接收的物理实体, 设备驱动功能层的函数都在这物理上驱动的

通过上面的描述我们知道,net_device结构体描述了网络设备的信息,并实现了不同硬件的统一,所以要写驱动程序要先看net_device中有什么参数,然后看哪些参数是要我们去完成的,而那些是上面协议层已经帮我们写好的。下面是net_device:

/**  The DEVICE structure. 设备框架*/
struct net_device
{char           name[IFNAMSIZ];         /* 设备名 *//*I/O specific fields   IO特有的域*/unsigned long      mem_end;    /* 内存结束地址 */unsigned long       mem_start;  /* 内存开始地址*/unsigned long        base_addr;  /* 内存基地址 */unsigned int     irq;        /* 设备中断号 */unsigned char        if_port;    /* 多端口设备使用的端口类型 */unsigned char     dma;        /* DMA通道 */unsigned long        state;          /* 设备状态信息 */      int           (*init)(struct net_device *dev); /* 设备的初始化函数,只调用一次 *//* 网络设备特征 */unsigned long       features;                        /* 接口特征 */           /* 获取流量的统计信息,通过运行ifconfig便可以调用该成员函数,并返回一个net_device_stats结构体获取信息 */struct net_device_stats* (*get_stats)(struct net_device *dev);struct net_device_stats    stats; /* 用来保存统计信息的net_device_stats结构体 */unsigned int   flags;  /*flags指网络接口标志,以IFF_(Interface Flags)开头*//*当flags =IFF_UP( 当设备被激活并可以开始发送数据包时, 内核设置该标志)、 *IFF_AUTOMEDIA(设置设备可在多种媒介间切换)、IFF_BROADCAST( 允许广播)、*IFF_DEBUG( 调试模式, 可用于控制printk调用的详细程度) 、 IFF_LOOPBACK( 回环)、*IFF_MULTICAST( 允许组播) 、 IFF_NOARP( 接口不能执行ARP,点对点接口就不需要运行 ARP)* 和IFF_POINTOPOINT( 接口连接到点到点链路) 等。*/ unsigned short priv_flags; /* 和flags相似,但是用户空间不可见 */unsigned short  padded; /* 通过alloc_netdev()填充多少 */unsigned  mtu;    /* 最大传输单元,也叫最大数据包 */unsigned short   type;   /* 接口硬件类型 */    unsigned short  hard_header_len;    /* 硬件帧头长度,一般被赋为ETH_HLEN,即14   */  /* 接口地址信息 */unsigned char perm_addr[MAX_ADDR_LEN]; /* 不变的物理地址 */unsigned char   addr_len;   /* 物理地址长度   */unsigned short dev_id;    /* for shared network cards */struct dev_mc_list    *mc_list;   /* Mac地址    */int   mc_count;   /* mcasts个数 */unsigned char dev_addr[MAX_ADDR_LEN]; /* 存放设备的MAC地址 */int  (*hard_start_xmit) (struct sk_buff *skb, struct net_device *dev); //数据包发送函数, sk_buff就是用来收发数据包的结构体void  (*tx_timeout) (struct net_device *dev);//发包超时处理函数

介绍完net_device结构体,我想介绍一下他的操作函数,其中包括他的分配函数alloc_netdev()函数或者alloc_etherdev()函数,以及其注销函数free_netdev(vnet_dev)。

/*** alloc_netdev - 分配网络设备*  @sizeof_priv:  私有数据空间大小,在本程序中设为0,即不需要私有数据*   @name:     设备名*    @setup:        初始化设备的回调函数,这里写回调函数:ether_setup*/
struct net_device *alloc_netdev(int sizeof_priv, const char *name,void (*setup)(struct net_device *))

而我们再看alloc_etherdev()函数:

/*** alloc_etherdev - 分配设置一个以太网设备* @sizeof_priv:    私有数据的大小*/
struct net_device *alloc_etherdev(int sizeof_priv)
{return alloc_netdev(sizeof_priv, "eth%d", ether_setup);
}

通过观察上面两个函数我们发现,其实alloc_etherdev()函数就是调用alloc_netdev()函数,只是给他设置了通用的值。

/*** free_netdev - 释放设备* @dev:        网络设备*/
void free_netdev(struct net_device *dev)

我们下面在介绍两个重要的结构体:net_device_stats结构体和sk_buff结构体。我们知道我们所写的网络设备驱动,其主要的功能就是完成收发数据。而net_device_stats结构体就是统计收发的信息,而sk_buff就是用于收发的数据包。

下面我们下说net_device_stats结构体:

struct net_device_stats
{unsigned long  rx_packets;     /* 接收的数据包总数 */unsigned long tx_packets;     /* 发送的数据包总数 */unsigned long rx_bytes;       /* 接收的总字节数 */unsigned long  tx_bytes;       /* 传输的总字节数 */unsigned long  rx_errors;      /* 接收的错误包数 */unsigned long  tx_errors;      /* 传输的错误包数 */unsigned long  rx_dropped;     /* Linux缓冲区没有空间 */unsigned long tx_dropped;     /* 在Linux中没有空间可用    */};

下面说另一个结构体:sk_buff

/** *    struct sk_buff - socket 缓冲区*/
struct sk_buff {/* 这两个参数一定要放在最前面 */struct sk_buff       *next;        /* 列表中的下一个缓存区 */struct sk_buff        *prev;        /* 列表中的上一个缓存区 */struct sock       *sk;          /* 我们所属的socket */ struct net_device   *dev;         /* 我们要到的或者要离开的设备 */unsigned int       len,          /* 数据包的总长度 */data_len,     /* 数据包中真实数据的长度 */mac_len;      /* Mac包头长度 */__u32          priority;     /* 包序列优先级 */__be16            protocol;     /* 存放上层的协议类型,可以通过eth_type_trans()来获取 */sk_buff_data_t     transport_header; /* 传输层头偏移量 */sk_buff_data_t       network_header;   /* 网络层头偏移量 */sk_buff_data_t       mac_header;       /* 链路层头偏移量 *//* These elements must be at the end, see alloc_skb() for details.  */sk_buff_data_t     tail;      /* 缓存区数据包末尾指针 */sk_buff_data_t       end;       /* 缓存区末尾指针 */unsigned char       *head,     /* 缓存区协议头指针 */ *data;     /* 缓存区数据包开始位置指针 */
};

我们用下图对其空间说明:

而sk_buff中的data又可以细分为:MAC头,IP头,type和真正的数据。而下图是其空间排布:

而对sk_buff操作的函数有:

struct sk_buff *alloc_skb(unsigned int len, int priority)  /* 分配一个sk_buff结构,供协议栈代码使用 */struct sk_buff *dev_alloc_skb(unsigned int len)  /* 分配一个sk_buff结构,供驱动代码使用 */unsigned char *skb_push(struct sk_buff *skb, int len)  /* 向后移动skb的tail指针,并返回tail移动之前的值。 */unsigned char *skb_put(structsk_buff *skb, int len)  /* 向前移动skb的head指针,并返回head移动之后的值。 */kfree_skb(struct sk_buff *skb)  /* 释放一个sk_buff结构,供协议栈代码使用。 */dev_kfree_skb(struct sk_buff *skb)  /* 释放一个sk_buff结构,供驱动代码使用 */

而说到sk_buff就要介绍两个运用他的函数,一个是发送包函数:hard_start_xmit,以及接收包函数:netif_rx();这是一个网络设备最基本的功能。一块网卡所做的无非就是收发工作。所以驱动程序里要告诉系统你的发送函数在哪里,系统在有数据要发送时就会调用你的发 送程序。还有驱动程序由于是直接操纵硬件的,所以网络硬件有数据收到最先能得到这个数据的也就是驱动程序,它负责把这些原始数据进行必要的处理然后送给系统。这里,操作系统必须要提供两个机制,一个是找到驱动程序的发送函数,一个是驱动程序把收到的数据送给系统。

我们先讲解发包函数hard_start_xmit,对于真实的网卡,就是把skb中的数据通过网卡发送出去:

1.停止该网卡的队列(禁止再向网卡发送数据,而其他的数据要等待):netif_stop_queue(dev);

2.把skb的数据写入到网卡中

3. 写入完成后释放skb :dev_kfree_skb(skb);

4.更新统计信息:dev->stats.tx_packets++;
                  dev->stats.tx_bytes += skb->l;  /* 这里就用到了上面讲的net_device_stats中的数据 */

5.数据全部发送完后,唤醒网卡的队列 :netif_wake_queue(dev);

而对于接受数据包函数netif_rx(),我并不是很了解,这里引用一个网友的说法(本文的结尾有该篇文章的连接,我认为这是一篇很好的文章):

而接收数据包主要是通过中断函数处理,来判断中断类型,如果等于ISQ_RECEIVER_EVENT,表示为接收中断,然后进入接收数据函数,通过netif_rx()将数据上交给上层

例如下图所示,参考的内核中自带的网卡驱动:/drivers/net/cs89x0.c

如上图所示,通过获取的status标志来判断是什么中断,如果是接收中断,就进入net_rx()

其中net_rx()收包函数处理步骤如下所示:

  • 1)使用dev_alloc_skb()来构造一个新的sk_buff
  • 2)使用skb_reserve(rx_skb, 2); 将sk_buff缓冲区里的数据包先后位移2字节,来腾出sk_buff缓冲区里的头部空间
  • 3)读取网络设备硬件上接收到的数据
  • 4)使用memcpy()将数据复制到新的sk_buff里的data成员指向的地址处,可以使用skb_put()来动态扩大sk_buff结构体里中的数据区
  • 5)使用eth_type_trans()来获取上层协议,将返回值赋给sk_buff的protocol成员里
  • 6)然后更新统计信息,最后使用netif_rx( )来将sk_fuffer传递给上层协议中

其中skb_put()函数原型如下所示:

static inline unsigned char *skb_put(struct sk_buff *skb, unsigned int len);
//len:将数据区向下扩大len字节

使用skb_put()函数后,其中sk_buff缓冲区变化如下图:

讲解完上面这些基础的部分,那么下面我们以老师在课上讲的写一个虚拟的网卡例子来讲解网卡驱动程序的编写步骤:

在该例子中我们会构造一个假的sk_buff上报函数,而在这个函数中我们会将从接收函数hard_start_xmit接收到的数据包,用netif_rx(rx_skb)函数发送回到上层的网络层中,而不去接触物理层。这里我们要在sk_buff->data中做一些修改来完成这个功能。而具体的修改办法为:

也就是:

1.对调“源/目的”的MAC地址

2.对调“源/目的”的IP地址

3.修改类型,将0x8改为0

4.使用ip_fast_csum重新获得IP的校验码

5.构造一个sk_buff结构体

6. 将修改好的data复制到原来的data中

7.更新统计信息

8.向上层提交sk_buff

通过下面这幅图:

我们已经对网卡驱动有了大致的了解,而且我们知道在内核中,都会以面向对象的思想去设置一个结构体,在这个结构体中有这个模块或者这个层中所用到的参数,方法或者接口信息,正是这些有统一接口的方法,掩蔽了硬件的具体细节,让系统对各种网络设备的访问都采用统一的形式,做到硬件无关性。而我们编写驱动程序时所要做的就是去填充这个结构体。而在网卡驱动中这个结构体就是net_device结构体,而我们编写驱动的步骤也就清楚了:

1.分配一个net_device结构体

2.设置net_device结构体

    2.1 提供发包函数:hard_start_xmit

    2.2 收到数据时(在中断处理函数中)用netif_rx函数上报数据

    2.3 其他的设置

3.注册net_device结构体:register_netdev()。

那么我们根据上面的介绍,就可以写自己的网卡驱动程序了,下面是我写的驱动程序:

#include <linux/errno.h>
#include <linux/netdevice.h>
#include <linux/etherdevice.h>
#include <linux/kernel.h>
#include <linux/types.h>
#include <linux/fcntl.h>
#include <linux/interrupt.h>
#include <linux/ioport.h>
#include <linux/in.h>
#include <linux/skbuff.h>
#include <linux/slab.h>
#include <linux/spinlock.h>
#include <linux/string.h>
#include <linux/init.h>
#include <linux/bitops.h>
#include <linux/delay.h>
#include <linux/ip.h>#include <asm/system.h>
#include <asm/io.h>
#include <asm/irq.h>
#include <asm/dma.h>static struct net_device *vnet_dev;static void emulator_rx_packet(struct sk_buff *skb,struct net_device *dev)
{/* 参考LDD3 */unsigned char *type;struct iphdr  *ih;__be32 *saddr,*daddr,tmp;unsigned char tmp_dev_addr[ETH_ALEN];struct ethhdr *ethhdr;struct sk_buff *rx_skb;//从硬件读出/保存数据/* 对调“源/目的”的MAC地址 */ethhdr = (struct ethhdr *)skb->data;memcpy(tmp_dev_addr,ethhdr->h_dest,ETH_ALEN);memcpy(ethhdr->h_dest,ethhdr->h_source,ETH_ALEN);memcpy(ethhdr->h_source,tmp_dev_addr,ETH_ALEN);/* 对调“源/目的”的IP地址 */ih = (struct iphdr *)(skb->data + sizeof(struct ethhdr));saddr = &ih->saddr;daddr = &ih->daddr;tmp = *saddr; *saddr = *daddr;*daddr = tmp;type = skb->data + sizeof(struct ethhdr) + sizeof(struct iphdr);//修改类型,原来0x8表示ping*type = 0;        /* 0表示reply */ih->check = 0;     /* and rebuild the checksum (ip need it) */ih->check = ip_fast_csum((unsigned char *)ih,ih->ihl);//构造一个sk_buffrx_skb = dev_alloc_skb(skb->len + 2);  skb_reserve(rx_skb,2);       /* align IP on 16B boundary *//*使用skb_reserve()来腾出2字节头部空间  */memcpy(skb_put(rx_skb,skb->len),skb->data,skb->len);/*使用memcpy()将之前修改好的sk_buff->data复制到新的sk_buff里*/// skb_put():来动态扩大sk_buff结构体里中的数据区,避免溢出/* write metadata,and then pass to the receive level */rx_skb->dev = dev;rx_skb->protocol = eth_type_trans(rx_skb,dev);rx_skb->ip_summed = CHECKSUM_UNNECESSARY;        /* don't check it *//* 更新接收统计信息,并使用netif_rx( )来 传递sk_fuffer收包 */dev->stats.rx_packets++;                     dev->stats.rx_bytes += skb->len;dev->last_rx= jiffies;                       //收包时间戳netif_rx(rx_skb);}static int virt_net_sendpacket(struct sk_buff *skb,struct net_device *dev)
{   static int cnt = 0;printk(" virt_net_sendpacket cnt = %d \n",++cnt);/* 对于真实的网卡,把skb里的数据通过网卡发送出去 */netif_stop_queue(dev);       /* 停止该网卡的队列 *//*                   */      /* 把skb的数据写入网卡 *//* 构造一个假的sk_buff上报 */emulator_rx_packet(skb,dev);dev_kfree_skb(skb);          /* 释放skb *//* 更新统计信息 */dev->stats.tx_packets++;dev->stats.tx_bytes += skb->l;netif_wake_queue(dev);       /* 数据全部发送出去后,唤醒网卡的队列 */return 0;
}static int s3c_vnet_init(void)
{/* 1. 分配一个net_device结构体 */vnet_dev = alloc_netdev(0,"vnet%d",ether_setup);   /* 也可以使用alloc_etherdev函数来分配 *//* 2. 设置net_device结构体 */vnet_dev->hard_start_xmit = virt_net_sendpacket;       /* 发包函数 *//* 2.1 设置MAC地址 */vnet_dev->dev_addr[0] = 0x08;vnet_dev->dev_addr[1] = 0x89;vnet_dev->dev_addr[2] = 0x89;vnet_dev->dev_addr[3] = 0x89;vnet_dev->dev_addr[4] = 0x89;vnet_dev->dev_addr[5] = 0x11;/* 2.2 设置下面两项才能ping通 *//* keep the default flags, just add NOARP */vnet_dev->flags    |= IFF_NOARP;vnet_dev->features |= NETIF_F_NO_CSUM;/* 3. 注册net_device结构体:register_netdev */register_netdev(vnet_dev);return 0;
}static void s3c_vnet_exit(void)
{unregister_netdev(vnet_dev);free_netdev(vnet_dev);
}module_init(s3c_vnet_init);
module_exit(s3c_vnet_exit);
MODULE_LICENSE("GPL");

编写完程序我们就应该对其进行测试了:

1.insmod virt_net.ko          /*在本文中我生成的是名为virt_net.ko 的文件,你的可能不一样 */

2.ifconfig                    /* 查看系统中已有的网络设备 */

3. ifconfig    vnet0    3.3.3.3     /* 设置虚拟网卡为3.3.3.3,注意,这里的vnet0是使用alloc_netdev函数设置的名字 */

4. ifconfig                    /* 再次查看系统中的网络设备 */

5.ping  3.3.3.3             /* ping 自己看是否可以ping通 */

    5.1 ifconfig                    /* 查看设备的统计信息 */

6.ping  3.3.3.4             /* ping其他的服务器看是否可以ping通  */

    6.1 ifconfig                    /* 再次查看设备的统计信息 */

而下面是两篇介绍网卡信息的文章,我在写文章时,对他们有所借鉴:

26.Linux-网卡驱动介绍以及制作虚拟网卡驱动(详解)

Linux网卡驱动程序编写

嵌入式Linux——网卡驱动(1):网卡驱动框架介绍相关推荐

  1. linux 3g拨号 option.c 脚本,嵌入式Linux系统实现3G网卡拨号

    嵌入式Linux系统实现3G网卡拨号 http://blog.chinaunix.net/uid-9525959-id-3998519.htmlhttp://hi.baidu.com/backtrac ...

  2. linux 3g拨号,嵌入式Linux系统实现3G网卡拨号

    嵌入式Linux系统实现3G网卡拨号 本文介绍在嵌入式Linux中,实现3G联网的基本方法.包括驱动配置,和联网的过程.也对在PC上实现3G的过程进行了介绍. 硬件:3g usb模块(华为ce189的 ...

  3. 南京邮电大学嵌入式系统开发实验5:嵌入式Linux下LED报警灯驱动设计及编程

    实验5  嵌入式Linux下LED报警灯驱动设计及编程 一.实验目的 理解驱动本质,掌握嵌入式Linux系统下驱动开发相关知识,包括端口寄存器访问.接口函数编写.和文件系统挂接.注册及相关应用编程等知 ...

  4. 嵌入式Linux系统实现3G网卡拨号

    本文介绍在嵌入式Linux中,实现3G联网的基本方法.包括驱动配置,和联网的过程.也对在PC上实现3G的过程进行了介绍. 硬件:3g usb模块(华为ce189的3g网卡)+一张sim卡(电信cdma ...

  5. linux内置usb3.0驱动,基于嵌入式Linux的USB3.0视频驱动的改进

    作 者:孙红[1,2] 秦守文[1] Sun Hong , Qin Shouwen (1. School of Optical--Electrical and Computer Engineering ...

  6. linux卸载cf卡命令,嵌入式Linux 中CF卡的驱动和管理技术研究

    在嵌入式Linux系统中,为了在没有PCMCIA控制器的情况下仍然要利用CompactFlash存储卡(简称CF卡)作为存储设备,作者从CF卡的硬件特性入手,在系统层基于CF卡的memory寻址访问方 ...

  7. 嵌入式linux/鸿蒙开发板(IMX6ULL)开发(一) 嵌入式Linux开发基本概念以及开发流程介绍

    文章目录 1.linux开发初了解 1.1 嵌入式Linux开发的基本概念 1.1.1关于Git的背景介绍 1.1.2关于repo的背景介绍 1.1 3 一些关于此背景知识的介绍 1.1.4关于Lin ...

  8. 利用Yocto构建嵌入式Linux教程02--Yocto的一些基本概念介绍

    本教程选用的Yocto版本为3.0.4,使用的Linux发行版为Ubuntu 18.04 (LTS),图中所有示例为实际测试截图,有问题请给我留言.微信公众号:嵌入式Linux那些事儿 在Yocto项 ...

  9. 嵌入式linux pcie网卡配置,嵌入式Linux下PCIE数据采集卡驱动开发

    目录 5.4 中断 (34) 5.4.1 Linux中断处理架构 (34) 5.4.2 Linux中断编程 (34) 5.5 本章小结 (35) 第六章PCIE高速数据采集卡驱动程序开发 (36) 6 ...

  10. 嵌入式Linux开发板_WIFI无线网卡驱动移植

    在线课堂:https://www.100ask.net/index(课程观看) 论  坛:http://bbs.100ask.net/(学术答疑) 开 发 板:https://100ask.taoba ...

最新文章

  1. 建立索引常用的规则如下
  2. 系统架构师学习笔记_第二章_连载
  3. 开源——需要分享共享的无私精神
  4. 把A表中的a字段和b字段数据 复制到B表中的aa字段和bb字段
  5. 小程序如何将wx.request里的数据传出去
  6. c语言中的printf函数_C语言中的printf()函数与示例
  7. 利用Python实现定时发送邮件,实现一款营销工具
  8. 13.相机和图像——聚焦于对象实战,不断变化的焦距,景深_3
  9. java面试请你谈谈mysql_Java面试题之MySQL
  10. ESP32 ESP-IDF开发环境搭建,Windows下基于ESP-IDF | Cmake | VScode插件的 ESP32 开发环境搭建
  11. MFC框架类、文档类、视图类相互访问的方法
  12. Vue数据更新视图不更新的几种解决方案
  13. sas 服务器版安装文件,SAS软件各个版本,包括服务器版本的切磋了解
  14. 【已解决】SVN设置为中文 最全面
  15. 51单片机—LED小灯的点亮及其流水灯程序
  16. 【kubernetes/k8s源码分析】calico node felix源码分析之一
  17. 中国水泥工业节能减排行业投资效益及未来发展战略规划报告2021-2027年
  18. 安装SQL Server 2017遇到“以前的某个安装需要重新引导计算机以便使更改生效”的问题
  19. Dockers的安装卸载
  20. 第六章网络应用技术(比较简单)

热门文章

  1. vue点击头像上传图片
  2. 连接器产业深度分析报告,国产化替代如何突出重围?(附厂商名录)
  3. Zabbix——通过API接口管理Zabbix所监控主机
  4. 儿童安全座椅入法,名悦集团细说儿童乘车安全指南
  5. Java Annotation手册
  6. 如何使用有效的客户体验管理方法,提升产品用户体验?
  7. C++ vector动态数组
  8. 使用node搭建后台管理系统(2)
  9. 三分构图与井字构图在风景摄影中的应用
  10. 一只笈博士智能学习宝,勇敢开口说英语。