<audio id="audio" title="32 | 字符设备（上）：如何建立直销模式？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/65/e1/655281c771bd57e3bef07e4377af21e1.mp3"></audio>

上一节，我们讲了输入输出设备的层次模型，还是比较复杂的，块设备尤其复杂。这一节为了让你更清晰地了解设备驱动程序的架构，我们先来讲稍微简单一点的字符设备驱动。

这一节，我找了两个比较简单的字符设备驱动来解析一下。一个是输入字符设备，鼠标。代码在drivers/input/mouse/logibm.c这里。

```
/*
 * Logitech Bus Mouse Driver for Linux
 */
module_init(logibm_init);
module_exit(logibm_exit);

```

另外一个是输出字符设备，打印机，代码drivers/char/lp.c这里。

```
/*
 * Generic parallel printer driver
 */
module_init(lp_init_module);
module_exit(lp_cleanup_module);

```

## 内核模块

上一节，我们讲过，设备驱动程序是一个内核模块，以ko的文件形式存在，可以通过insmod加载到内核中。那我们首先来看一下，怎么样才能构建一个内核模块呢？

一个内核模块应该由以下几部分组成。

**第一部分，头文件部分**。一般的内核模块，都需要include下面两个头文件：

```
#include &lt;linux/module.h&gt;
#include &lt;linux/init.h&gt;

```

如果你去看上面两个驱动程序，都能找到这两个头文件。当然如果需要的话，我们还可以引入更多的头文件。

**第二部分，定义一些函数，用于处理内核模块的主要逻辑**。例如打开、关闭、读取、写入设备的函数或者响应中断的函数。

例如，logibm.c里面就定义了logibm_open。logibm_close就是处理打开和关闭的，定义了logibm_interrupt就是用来响应中断的。再如，lp.c里面就定义了lp_read，lp_write就是处理读写的。

**第三部分，定义一个file_operations结构**。前面我们讲过，设备是可以通过文件系统的接口进行访问的。咱们讲文件系统的时候说过，对于某种文件系统的操作，都是放在file_operations里面的。例如ext4就定义了这么一个结构，里面都是ext4_xxx之类的函数。设备要想被文件系统的接口操作，也需要定义这样一个结构。

例如，lp.c里面就定义了这样一个结构。

```
static const struct file_operations lp_fops = {
	.owner		= THIS_MODULE,
	.write		= lp_write,
	.unlocked_ioctl	= lp_ioctl,
#ifdef CONFIG_COMPAT
	.compat_ioctl	= lp_compat_ioctl,
#endif
	.open		= lp_open,
	.release	= lp_release,
#ifdef CONFIG_PARPORT_1284
	.read		= lp_read,
#endif
	.llseek		= noop_llseek,
};

```

在logibm.c里面，我们找不到这样的结构，是因为它属于众多输入设备的一种，而输入设备的操作被统一定义在drivers/input/input.c里面，logibm.c只是定义了一些自己独有的操作。

```
static const struct file_operations input_devices_fileops = {
	.owner		= THIS_MODULE,
	.open		= input_proc_devices_open,
	.poll		= input_proc_devices_poll,
	.read		= seq_read,
	.llseek		= seq_lseek,
	.release	= seq_release,
};

```

**第四部分，定义整个模块的初始化函数和退出函数**，用于加载和卸载这个ko的时候调用。

例如lp.c就定义了lp_init_module和lp_cleanup_module，logibm.c就定义了logibm_init和logibm_exit。

**第五部分，调用module_init和module_exit**，分别指向上面两个初始化函数和退出函数。就像本节最开头展示的一样。

**第六部分，声明一下lisense，调用MODULE_LICENSE**。

有了这六部分，一个内核模块就基本合格了，可以工作了。

## 打开字符设备

字符设备可不是一个普通的内核模块，它有自己独特的行为。接下来，我们就沿着打开一个字符设备的过程，看看字符设备这个内核模块做了哪些特殊的事情。

<img src="https://static001.geekbang.org/resource/image/2e/e6/2e29767e84b299324ea7fc524a3dcee6.jpeg" alt="">

要使用一个字符设备，我们首先要把写好的内核模块，通过insmod加载进内核。这个时候，先调用的就是module_init调用的初始化函数。

例如，在lp.c的初始化函数lp_init对应的代码如下：

```
static int __init lp_init (void)
{
......
	if (register_chrdev (LP_MAJOR, &quot;lp&quot;, &amp;lp_fops)) {
		printk (KERN_ERR &quot;lp: unable to get major %d\n&quot;, LP_MAJOR);
		return -EIO;
	}
......
}


int __register_chrdev(unsigned int major, unsigned int baseminor,
		      unsigned int count, const char *name,
		      const struct file_operations *fops)
{
	struct char_device_struct *cd;
	struct cdev *cdev;
	int err = -ENOMEM;
......
	cd = __register_chrdev_region(major, baseminor, count, name);
	cdev = cdev_alloc();
	cdev-&gt;owner = fops-&gt;owner;
	cdev-&gt;ops = fops;
	kobject_set_name(&amp;cdev-&gt;kobj, &quot;%s&quot;, name);
	err = cdev_add(cdev, MKDEV(cd-&gt;major, baseminor), count);
	cd-&gt;cdev = cdev;
	return major ? 0 : cd-&gt;major;
}

```

在字符设备驱动的内核模块加载的时候，最重要的一件事情就是，注册这个字符设备。注册的方式是调用__register_chrdev_region，注册字符设备的主次设备号和名称，然后分配一个struct cdev结构，将cdev的ops成员变量指向这个模块声明的file_operations。然后，cdev_add会将这个字符设备添加到内核中一个叫作struct kobj_map *cdev_map的结构，来统一管理所有字符设备。

其中，MKDEV(cd-&gt;major, baseminor)表示将主设备号和次设备号生成一个dev_t的整数，然后将这个整数dev_t和cdev关联起来。

```
/**
 * cdev_add() - add a char device to the system
 * @p: the cdev structure for the device
 * @dev: the first device number for which this device is responsible
 * @count: the number of consecutive minor numbers corresponding to this
 *         device
 *
 * cdev_add() adds the device represented by @p to the system, making it
 * live immediately.  A negative error code is returned on failure.
 */
int cdev_add(struct cdev *p, dev_t dev, unsigned count)
{
	int error;


	p-&gt;dev = dev;
	p-&gt;count = count;


	error = kobj_map(cdev_map, dev, count, NULL,
			 exact_match, exact_lock, p);
	kobject_get(p-&gt;kobj.parent);


	return 0;

```

在logibm.c中，我们在logibm_init找不到注册字符设备，这是因为input.c里面的初始化函数input_init会调用register_chrdev_region，注册输入的字符设备，会在logibm_init中调用input_register_device，将logibm.c这个字符设备注册到input.c里面去，这就相当于input.c对多个输入字符设备进行统一的管理。

内核模块加载完毕后，接下来要通过mknod在/dev下面创建一个设备文件，只有有了这个设备文件，我们才能通过文件系统的接口，对这个设备文件进行操作。

mknod也是一个系统调用，定义如下：

```
SYSCALL_DEFINE3(mknod, const char __user *, filename, umode_t, mode, unsigned, dev)
{
	return sys_mknodat(AT_FDCWD, filename, mode, dev);
}


SYSCALL_DEFINE4(mknodat, int, dfd, const char __user *, filename, umode_t, mode,
		unsigned, dev)
{
	struct dentry *dentry;
	struct path path;
......
	dentry = user_path_create(dfd, filename, &amp;path, lookup_flags);
......
	switch (mode &amp; S_IFMT) {
......
		case S_IFCHR: case S_IFBLK:
			error = vfs_mknod(path.dentry-&gt;d_inode,dentry,mode,
					new_decode_dev(dev));
			break;
......
	}
}

```

我们可以在这个系统调用里看到，在文件系统上，顺着路径找到/dev/xxx所在的文件夹，然后为这个新创建的设备文件创建一个dentry。这是维护文件和inode之间的关联关系的结构。

接下来，如果是字符文件S_IFCHR或者设备文件S_IFBLK，我们就调用vfs_mknod。

```
int vfs_mknod(struct inode *dir, struct dentry *dentry, umode_t mode, dev_t dev)
{
......
	error = dir-&gt;i_op-&gt;mknod(dir, dentry, mode, dev);
......
}

```

这里需要调用对应的文件系统的inode_operations。应该调用哪个文件系统呢？

如果我们在linux下面执行mount命令，能看到下面这一行：

```
devtmpfs on /dev type devtmpfs (rw,nosuid,size=3989584k,nr_inodes=997396,mode=755)

```

也就是说，/dev下面的文件系统的名称为devtmpfs，我们可以在内核中找到它。

```
static struct dentry *dev_mount(struct file_system_type *fs_type, int flags,
		      const char *dev_name, void *data)
{
#ifdef CONFIG_TMPFS
	return mount_single(fs_type, flags, data, shmem_fill_super);
#else
	return mount_single(fs_type, flags, data, ramfs_fill_super);
#endif
}


static struct file_system_type dev_fs_type = {
	.name = &quot;devtmpfs&quot;,
	.mount = dev_mount,
	.kill_sb = kill_litter_super,
};

```

从这里可以看出，devtmpfs在挂载的时候，有两种模式，一种是ramfs，一种是shmem都是基于内存的文件系统。这里你先不用管，基于内存的文件系统具体是怎么回事儿。

```
static const struct inode_operations ramfs_dir_inode_operations = {
......
	.mknod		= ramfs_mknod,
};


static const struct inode_operations shmem_dir_inode_operations = {
#ifdef CONFIG_TMPFS
......
	.mknod		= shmem_mknod,
};

```

这两个mknod虽然实现不同，但是都会调用到同一个函数init_special_inode。

```
void init_special_inode(struct inode *inode, umode_t mode, dev_t rdev)
{
	inode-&gt;i_mode = mode;
	if (S_ISCHR(mode)) {
		inode-&gt;i_fop = &amp;def_chr_fops;
		inode-&gt;i_rdev = rdev;
	} else if (S_ISBLK(mode)) {
		inode-&gt;i_fop = &amp;def_blk_fops;
		inode-&gt;i_rdev = rdev;
	} else if (S_ISFIFO(mode))
		inode-&gt;i_fop = &amp;pipefifo_fops;
	else if (S_ISSOCK(mode))
		;	/* leave it no_open_fops */
}

```

显然这个文件是个特殊文件，inode也是特殊的。这里这个inode可以关联字符设备、块设备、FIFO文件、Socket等。我们这里只看字符设备。

这里的inode的file_operations指向一个def_chr_fops，这里面只有一个open，就等着你打开它。

另外，inode的i_rdev指向这个设备的dev_t。还记得cdev_map吗？通过这个dev_t，可以找到我们刚在加载的字符设备cdev。

```
const struct file_operations def_chr_fops = {
	.open = chrdev_open,
};

```

到目前为止，我们只是创建了/dev下面的一个文件，并且和相应的设备号关联起来。但是，我们还没有打开这个/dev下面的设备文件。

现在我们来打开它。打开一个文件的流程，我们在[文件系统](https://time.geekbang.org/column/article/97876)那一节讲过了，这里不再重复。最终就像打开字符设备的图中一样，打开文件的进程的task_struct里，有一个数组代表它打开的文件，下标就是文件描述符fd，每一个打开的文件都有一个struct file结构，会指向一个dentry项。dentry可以用来关联inode。这个dentry就是咱们上面mknod的时候创建的。

在进程里面调用open函数，最终会调用到这个特殊的inode的open函数，也就是chrdev_open。

```
static int chrdev_open(struct inode *inode, struct file *filp)
{
	const struct file_operations *fops;
	struct cdev *p;
	struct cdev *new = NULL;
	int ret = 0;


	p = inode-&gt;i_cdev;
	if (!p) {
		struct kobject *kobj;
		int idx;
		kobj = kobj_lookup(cdev_map, inode-&gt;i_rdev, &amp;idx);
		new = container_of(kobj, struct cdev, kobj);
		p = inode-&gt;i_cdev;
		if (!p) {
			inode-&gt;i_cdev = p = new;
			list_add(&amp;inode-&gt;i_devices, &amp;p-&gt;list);
			new = NULL;
		} 
	} 
......
	fops = fops_get(p-&gt;ops);
......
	replace_fops(filp, fops);
	if (filp-&gt;f_op-&gt;open) {
		ret = filp-&gt;f_op-&gt;open(inode, filp);
......
	}
......
}

```

在这个函数里面，我们首先看这个inode的i_cdev，是否已经关联到cdev。如果第一次打开，当然没有。没有没关系，inode里面有i_rdev呀，也就是有dev_t。我们可以通过它在cdev_map中找cdev。咱们上面注册过了，所以肯定能够找到。找到后我们就将inode的i_cdev，关联到找到的cdev new。

找到cdev就好办了。cdev里面有file_operations，这是设备驱动程序自己定义的。我们可以通过它来操作设备驱动程序，把它付给struct file里面的file_operations。这样以后操作文件描述符，就是直接操作设备了。

最后，我们需要调用设备驱动程序的file_operations的open函数，真正打开设备。对于打印机，调用的是lp_open。对于鼠标调用的是input_proc_devices_open，最终会调用到logibm_open。这些多和设备相关，你不必看懂它们。

## 写入字符设备

当我们像打开一个文件一样打开一个字符设备之后，接下来就是对这个设备的读写。对于文件的读写咱们在文件系统那一章详细讲述过，读写的过程是类似的，所以这里我们只解析打印机驱动写入的过程。

<img src="https://static001.geekbang.org/resource/image/9b/e2/9bd3cd8a8705dbf69f889ba3b2b5c2e2.jpeg" alt="">

写入一个字符设备，就是用文件系统的标准接口write，参数文件描述符fd，在内核里面调用的sys_write，在sys_write里面根据文件描述符fd得到struct file结构。接下来再调用vfs_write。

```
ssize_t __vfs_write(struct file *file, const char __user *p, size_t count, loff_t *pos)
{
	if (file-&gt;f_op-&gt;write)
		return file-&gt;f_op-&gt;write(file, p, count, pos);
	else if (file-&gt;f_op-&gt;write_iter)
		return new_sync_write(file, p, count, pos);
	else
		return -EINVAL;
}

```

我们可以看到，在__vfs_write里面，我们会调用struct file结构里的file_operations的write函数。上面我们打开字符设备的时候，已经将struct file结构里面的file_operations指向了设备驱动程序的file_operations结构，所以这里的write函数最终会调用到lp_write。

```
static ssize_t lp_write(struct file * file, const char __user * buf,
		        size_t count, loff_t *ppos)
{
	unsigned int minor = iminor(file_inode(file));
	struct parport *port = lp_table[minor].dev-&gt;port;
	char *kbuf = lp_table[minor].lp_buffer;
	ssize_t retv = 0;
	ssize_t written;
	size_t copy_size = count;
......
	/* Need to copy the data from user-space. */
	if (copy_size &gt; LP_BUFFER_SIZE)
		copy_size = LP_BUFFER_SIZE;
......
	if (copy_from_user (kbuf, buf, copy_size)) {
		retv = -EFAULT;
		goto out_unlock;
	}
......
	do {
		/* Write the data. */
		written = parport_write (port, kbuf, copy_size);
		if (written &gt; 0) {
			copy_size -= written;
			count -= written;
			buf  += written;
			retv += written;
		}
......
        if (need_resched())
			schedule ();


		if (count) {
			copy_size = count;
			if (copy_size &gt; LP_BUFFER_SIZE)
				copy_size = LP_BUFFER_SIZE;


			if (copy_from_user(kbuf, buf, copy_size)) {
				if (retv == 0)
					retv = -EFAULT;
				break;
			}
		}	
	} while (count &gt; 0);
......

```

这个设备驱动程序的写入函数的实现还是比较典型的。先是调用copy_from_user将数据从用户态拷贝到内核态的缓存中，然后调用parport_write写入外部设备。这里还有一个schedule函数，也即写入的过程中，给其他线程抢占CPU的机会。然后，如果count还是大于0，也就是数据还没有写完，那我们就接着copy_from_user，接着parport_write，直到写完为止。

## 使用IOCTL控制设备

对于I/O设备来讲，我们前面也说过，除了读写设备，还会调用ioctl，做一些特殊的I/O操作。

<img src="https://static001.geekbang.org/resource/image/c3/1d/c3498dad4f15712529354e0fa123c31d.jpeg" alt="">

ioctl也是一个系统调用，它在内核里面的定义如下：

```
SYSCALL_DEFINE3(ioctl, unsigned int, fd, unsigned int, cmd, unsigned long, arg)
{
	int error;
	struct fd f = fdget(fd);
......
	error = do_vfs_ioctl(f.file, fd, cmd, arg);
	fdput(f);
	return error;
}

```

其中，fd是这个设备的文件描述符，cmd是传给这个设备的命令，arg是命令的参数。其中，对于命令和命令的参数，使用ioctl系统调用的用户和驱动程序的开发人员约定好行为即可。

其实cmd看起来是一个int，其实他的组成比较复杂，它由几部分组成：

- 最低八位为NR，是命令号；
- 然后八位是TYPE，是类型；
- 然后十四位是参数的大小；
- 最高两位是DIR，是方向，表示写入、读出，还是读写。

由于组成比较复杂，有一些宏是专门用于组成这个cmd值的。

```
/*
 * Used to create numbers.
 */
#define _IO(type,nr)		_IOC(_IOC_NONE,(type),(nr),0)
#define _IOR(type,nr,size)	_IOC(_IOC_READ,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOW(type,nr,size)	_IOC(_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))
#define _IOWR(type,nr,size)	_IOC(_IOC_READ|_IOC_WRITE,(type),(nr),(_IOC_TYPECHECK(size)))


/* used to decode ioctl numbers.. */
#define _IOC_DIR(nr)		(((nr) &gt;&gt; _IOC_DIRSHIFT) &amp; _IOC_DIRMASK)
#define _IOC_TYPE(nr)		(((nr) &gt;&gt; _IOC_TYPESHIFT) &amp; _IOC_TYPEMASK)
#define _IOC_NR(nr)		(((nr) &gt;&gt; _IOC_NRSHIFT) &amp; _IOC_NRMASK)
#define _IOC_SIZE(nr)		(((nr) &gt;&gt; _IOC_SIZESHIFT) &amp; _IOC_SIZEMASK)

```

在用户程序中，可以通过上面的“Used to create numbers”这些宏，根据参数生成cmd，在驱动程序中，可以通过下面的“used to decode ioctl numbers”这些宏，解析cmd后，执行指令。

ioctl中会调用do_vfs_ioctl，这里面对于已经定义好的cmd，进行相应的处理。如果不是默认定义好的cmd，则执行默认操作。对于普通文件，调用file_ioctl；对于其他文件调用vfs_ioctl。

```
int do_vfs_ioctl(struct file *filp, unsigned int fd, unsigned int cmd,
	     unsigned long arg)
{
	int error = 0;
	int __user *argp = (int __user *)arg;
	struct inode *inode = file_inode(filp);


	switch (cmd) {
......
	case FIONBIO:
		error = ioctl_fionbio(filp, argp);
		break;


	case FIOASYNC:
		error = ioctl_fioasync(fd, filp, argp);
		break;
......
	case FICLONE:
		return ioctl_file_clone(filp, arg, 0, 0, 0);


	default:
		if (S_ISREG(inode-&gt;i_mode))
			error = file_ioctl(filp, cmd, arg);
		else
			error = vfs_ioctl(filp, cmd, arg);
		break;
	}
	return error;

```

由于咱们这里是设备驱动程序，所以调用的是vfs_ioctl。

```
/**
 * vfs_ioctl - call filesystem specific ioctl methods
 * @filp:	open file to invoke ioctl method on
 * @cmd:	ioctl command to execute
 * @arg:	command-specific argument for ioctl
 *
 * Invokes filesystem specific -&gt;unlocked_ioctl, if one exists; otherwise
 * returns -ENOTTY.
 *
 * Returns 0 on success, -errno on error.
 */
long vfs_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
	int error = -ENOTTY;


	if (!filp-&gt;f_op-&gt;unlocked_ioctl)
		goto out;


	error = filp-&gt;f_op-&gt;unlocked_ioctl(filp, cmd, arg);
	if (error == -ENOIOCTLCMD)
		error = -ENOTTY;
 out:
	return error;

```

这里面调用的是struct file里file_operations的unlocked_ioctl函数。我们前面初始化设备驱动的时候，已经将file_operations指向设备驱动的file_operations了。这里调用的是设备驱动的unlocked_ioctl。对于打印机程序来讲，调用的是lp_ioctl。可以看出来，这里面就是switch语句，它会根据不同的cmd，做不同的操作。

```
static long lp_ioctl(struct file *file, unsigned int cmd,
			unsigned long arg)
{
	unsigned int minor;
	struct timeval par_timeout;
	int ret;


	minor = iminor(file_inode(file));
	mutex_lock(&amp;lp_mutex);
	switch (cmd) {
......
	default:
		ret = lp_do_ioctl(minor, cmd, arg, (void __user *)arg);
		break;
	}
	mutex_unlock(&amp;lp_mutex);
	return ret;
}


static int lp_do_ioctl(unsigned int minor, unsigned int cmd,
	unsigned long arg, void __user *argp)
{
	int status;
	int retval = 0;


	switch ( cmd ) {
		case LPTIME:
			if (arg &gt; UINT_MAX / HZ)
				return -EINVAL;
			LP_TIME(minor) = arg * HZ/100;
			break;
		case LPCHAR:
			LP_CHAR(minor) = arg;
			break;
		case LPABORT:
			if (arg)
				LP_F(minor) |= LP_ABORT;
			else
				LP_F(minor) &amp;= ~LP_ABORT;
			break;
		case LPABORTOPEN:
			if (arg)
				LP_F(minor) |= LP_ABORTOPEN;
			else
				LP_F(minor) &amp;= ~LP_ABORTOPEN;
			break;
		case LPCAREFUL:
			if (arg)
				LP_F(minor) |= LP_CAREFUL;
			else
				LP_F(minor) &amp;= ~LP_CAREFUL;
			break;
		case LPWAIT:
			LP_WAIT(minor) = arg;
			break;
		case LPSETIRQ: 
			return -EINVAL;
			break;
		case LPGETIRQ:
			if (copy_to_user(argp, &amp;LP_IRQ(minor),
					sizeof(int)))
				return -EFAULT;
			break;
		case LPGETSTATUS:
			if (mutex_lock_interruptible(&amp;lp_table[minor].port_mutex))
				return -EINTR;
			lp_claim_parport_or_block (&amp;lp_table[minor]);
			status = r_str(minor);
			lp_release_parport (&amp;lp_table[minor]);
			mutex_unlock(&amp;lp_table[minor].port_mutex);


			if (copy_to_user(argp, &amp;status, sizeof(int)))
				return -EFAULT;
			break;
		case LPRESET:
			lp_reset(minor);
			break;
 		case LPGETFLAGS:
 			status = LP_F(minor);
			if (copy_to_user(argp, &amp;status, sizeof(int)))
				return -EFAULT;
			break;
		default:
			retval = -EINVAL;
	}
	return retval

```

## 总结时刻

这一节我们讲了字符设备的打开、写入和ioctl等最常见的操作。一个字符设备要能够工作，需要三部分配合。

第一，有一个设备驱动程序的ko模块，里面有模块初始化函数、中断处理函数、设备操作函数。这里面封装了对于外部设备的操作。加载设备驱动程序模块的时候，模块初始化函数会被调用。在内核维护所有字符设备驱动的数据结构cdev_map里面注册，我们就可以很容易根据设备号，找到相应的设备驱动程序。

第二，在/dev目录下有一个文件表示这个设备，这个文件在特殊的devtmpfs文件系统上，因而也有相应的dentry和inode。这里的inode是一个特殊的inode，里面有设备号。通过它，我们可以在cdev_map中找到设备驱动程序，里面还有针对字符设备文件的默认操作def_chr_fops。

第三，打开一个字符设备文件和打开一个普通的文件有类似的数据结构，有文件描述符、有struct file、指向字符设备文件的dentry和inode。字符设备文件的相关操作file_operations一开始指向def_chr_fops，在调用def_chr_fops里面的chrdev_open函数的时候，修改为指向设备操作函数，从而读写一个字符设备文件就会直接变成读写外部设备了。

<img src="https://static001.geekbang.org/resource/image/fb/cd/fba61fe95e0d2746235b1070eb4c18cd.jpeg" alt="">

## 课堂练习

这节我用打印机驱动程序作为例子来给你讲解字符设备，请你仔细看一下它的代码，设想一下，如果让你自己写一个字符设备驱动程序，应该实现哪些函数呢？

欢迎留言和我分享你的疑惑和见解 ，也欢迎可以收藏本节内容，反复研读。你也可以把今天的内容分享给你的朋友，和他一起学习和进步。

<img src="https://static001.geekbang.org/resource/image/8c/37/8c0a95fa07a8b9a1abfd394479bdd637.jpg" alt="">
