标签:else sign 操作 click block eve head cli signed
在读者学习本章之前,最好了解Nand Flash读写过程和操作,可以参考:Nand Flash裸机操作。
一开始想在本章写eMMC框架和设备驱动,但是没有找到关于eMMC设备驱动具体写法,所以本章仍继续完成Nand Flash设备驱动,eMMC这个坑留在以后填。如果读者开发板为eMMC,本节驱动可能无法正常执行。
在裸机操作中,读者应了解Nand Flash时序图、Nand Flash片选、读写和擦除等操作,在此不再赘述。
Nand Flash设备驱动放在drivers/mtd/nand目录下,mtd(memory technology device,存储技术设备)是用于访问存储设备(ROM、flash)的子系统。mtd的主要目的是为了使新的存储设备的驱动更加简单,因此它在硬件和顶层之间提供了一个抽象的接口。
读者可在此目录下任意选择一个单板驱动文件进行分析,我选择的是davinci_nand.c文件。
Nand Flash和Nor Flash文件链接:
https://pan.baidu.com/s/1t82QF5_IEP8T9psn5C1IVw
提取码为:5kqv
首先来看它的入口函数:
1 static int __init nand_davinci_init(void) 2 { 3 return platform_driver_probe(&nand_davinci_driver, nand_davinci_probe); 4 }
我们进入platform_driver的probe函数中,看看它是如何初始化
1 static int __init nand_davinci_probe(struct platform_device *pdev) 2 { 3 struct davinci_nand_pdata *pdata = pdev->dev.platform_data; 4 struct davinci_nand_info *info; 5 struct resource *res1; 6 struct resource *res2; 7 void __iomem *vaddr; 8 void __iomem *base; 9 int ret; 10 uint32_t val; 11 nand_ecc_modes_t ecc_mode; 12 struct mtd_partition *mtd_parts = NULL; 13 int mtd_parts_nb = 0; 14 15 ... 16 /* 初始化硬件,如设置TACLS、TWRPH0、TWRPH1等 */ 17 platform_set_drvdata(pdev, info); 18 ... 19 /* 配置mtd_info结构体,它是nand_chip的抽象 */ 20 info->mtd.priv = &info->chip; 21 info->mtd.name = dev_name(&pdev->dev); 22 info->mtd.owner = THIS_MODULE; 23 24 info->mtd.dev.parent = &pdev->dev; 25 26 /* 配置nand_chip结构体 */ 27 info->chip.IO_ADDR_R = vaddr; 28 info->chip.IO_ADDR_W = vaddr; 29 info->chip.chip_delay = 0; 30 info->chip.select_chip = nand_davinci_select_chip; 31 ... 32 /* Set address of hardware control function */ 33 info->chip.cmd_ctrl = nand_davinci_hwcontrol; 34 info->chip.dev_ready = nand_davinci_dev_ready; 35 36 /* Speed up buffer I/O */ 37 info->chip.read_buf = nand_davinci_read_buf; 38 info->chip.write_buf = nand_davinci_write_buf; 39 40 /* Use board-specific ECC config */ 41 ecc_mode = pdata->ecc_mode; 42 43 ret = -EINVAL; 44 switch (ecc_mode) { 45 case NAND_ECC_NONE: 46 case NAND_ECC_SOFT: /* 启动软件ECC */ 47 pdata->ecc_bits = 0; 48 break; 49 case NAND_ECC_HW: /* 启动硬件ECC */ 50 ... 51 break; 52 default: 53 ret = -EINVAL; 54 goto err_ecc; 55 } 56 info->chip.ecc.mode = ecc_mode; 57 58 /* 使能nand clk */ 59 info->clk = clk_get(&pdev->dev, "aemif"); 60 ... 61 ret = clk_enable(info->clk); 62 ... 63 val = davinci_nand_readl(info, A1CR_OFFSET + info->core_chipsel * 4); 64 ... 65 /* 扫描Nand Flash */ 66 ret = nand_scan_ident(&info->mtd, pdata->mask_chipsel ? 2 : 1, NULL); 67 ... 68 /* second phase scan */ 69 ret = nand_scan_tail(&info->mtd); 70 /* 以上nand_scan_ident()和nand_scan_tail()两步可以使用nand_scan()代替 */ 71 72 if (mtd_has_cmdlinepart()) { 73 static const char *probes[] __initconst = { 74 "cmdlinepart", NULL 75 }; 76 /* 设置分区 */ 77 mtd_parts_nb = parse_mtd_partitions(&info->mtd, probes, 78 &mtd_parts, 0); 79 } 80 81 if (mtd_parts_nb <= 0) { 82 mtd_parts = pdata->parts; 83 mtd_parts_nb = pdata->nr_parts; 84 } 85 86 /* Register any partitions */ 87 if (mtd_parts_nb > 0) { 88 ret = mtd_device_register(&info->mtd, mtd_parts, 89 mtd_parts_nb); 90 if (ret == 0) 91 info->partitioned = true; 92 } 93 94 /* If there‘s no partition info, just package the whole chip 95 * as a single MTD device. 96 */ 97 if (!info->partitioned) 98 ret = mtd_device_register(&info->mtd, NULL, 0) ? -ENODEV : 0; 99 ... 100 return ret; 101 }
probe()函数所做的有以下几点:
1. 初始化硬件,如设置TACLS、TWRPH0、TWRPH1等
2. 配置mtd_info结构体,它是nand_chip等底层Flash结构体的抽象,用于描述MTD设备,定义了MTD数据和操作函数
3. 配置nand_chip结构体
4. 启动软件ECC
5. 使用clk_get()和clk_enable()获取并使能Nand Flash时钟
6. 使用nand_scan()扫描Nand Flash
7. 使用parse_mtd_partitions()解析命令行中设置的分区。若命令行中没有设置mtdparts返回0;若设置了并且解析没问题,那么返回分区的个数,否则返回小于0的数
8. 使用mtd_device_register()注册Nand Flash分区
其中,
1. nand_scan()函数调用关系如下:
nand_scan(&info->mtd, pdata->mask_chipsel ? 2 : 1); -> nand_scan_ident(mtd, maxchips, NULL); /* 获取Nand Flash存储器类型 */ -> nand_get_flash_type(mtd, chip, busw, &nand_maf_id, &nand_dev_id, table); -> nand_scan_tail(mtd); /* 设置Nand Flash底层读写擦除等函数 */
nand_get_flash_type()函数定义如下:
1 static struct nand_flash_dev *nand_get_flash_type(struct mtd_info *mtd, struct nand_chip *chip, int busw, int *maf_id, int *dev_id, struct nand_flash_dev *type) 2 { 3 int i, maf_idx; 4 u8 id_data[8]; 5 int ret; 6 7 /* Select the device */ 8 chip->select_chip(mtd, 0); /* nand_chip的片选函数 */ 9 10 /* 11 * Reset the chip, required by some chips (e.g. Micron MT29FxGxxxxx) 12 * after power-up 13 */ 14 chip->cmdfunc(mtd, NAND_CMD_RESET, -1, -1); 15 16 /* Send the command for reading device ID */ 17 chip->cmdfunc(mtd, NAND_CMD_READID, 0x00, -1); 18 19 /* Read manufacturer and device IDs */ 20 *maf_id = chip->read_byte(mtd); /* 调用read_byte函数读取厂家ID */ 21 *dev_id = chip->read_byte(mtd); /* 设备ID */ 22 23 /* Try again to make sure, as some systems the bus-hold or other 24 * interface concerns can cause random data which looks like a 25 * possibly credible NAND flash to appear. If the two results do 26 * not match, ignore the device completely. 27 */ 28 29 chip->cmdfunc(mtd, NAND_CMD_READID, 0x00, -1); 30 31 for (i = 0; i < 2; i++) 32 id_data[i] = chip->read_byte(mtd); 33 34 /* 打印参数信息 */ 35 if (id_data[0] != *maf_id || id_data[1] != *dev_id) { 36 printk(KERN_INFO "%s: second ID read did not match " 37 "%02x,%02x against %02x,%02x\n", __func__, 38 *maf_id, *dev_id, id_data[0], id_data[1]); 39 return ERR_PTR(-ENODEV); 40 } 41 ... 42 /* 校验产品ID */ 43 if (!type) 44 type = nand_flash_ids; 45 46 for (; type->name != NULL; type++) 47 if (*dev_id == type->id) 48 break; 49 ... 50 }
代码中第46行可看出,nand_flash_ids[]数组是个全局变量,通过循环匹配设备ID,确定Nand Flash的大小、位数等规格。其定义如下:
struct nand_flash_dev nand_flash_ids[] = { #ifdef CONFIG_MTD_NAND_MUSEUM_IDS {"NAND 1MiB 5V 8-bit", 0x6e, 256, 1, 0x1000, 0}, {"NAND 2MiB 5V 8-bit", 0x64, 256, 2, 0x1000, 0}, {"NAND 4MiB 5V 8-bit", 0x6b, 512, 4, 0x2000, 0}, {"NAND 1MiB 3,3V 8-bit", 0xe8, 256, 1, 0x1000, 0}, {"NAND 1MiB 3,3V 8-bit", 0xec, 256, 1, 0x1000, 0}, {"NAND 2MiB 3,3V 8-bit", 0xea, 256, 2, 0x1000, 0}, {"NAND 4MiB 3,3V 8-bit", 0xd5, 512, 4, 0x2000, 0}, {"NAND 4MiB 3,3V 8-bit", 0xe3, 512, 4, 0x2000, 0}, {"NAND 4MiB 3,3V 8-bit", 0xe5, 512, 4, 0x2000, 0}, {"NAND 8MiB 3,3V 8-bit", 0xd6, 512, 8, 0x2000, 0}, ... };
2. 我们如果不传入命令行参数,parse_mtd_partitions()函数没有作用,就需要自己构建分区表。mtd_device_register()函数传入参数中应有分区表
int mtd_device_register(struct mtd_info *master, const struct mtd_partition *parts, int nr_parts) { return parts ? add_mtd_partitions(master, parts, nr_parts) : add_mtd_device(master); }
参数struct mtd_partition *parts即为分区表,其定义和示例如下:
/* 定义 */ struct mtd_partition { char *name; /* 分区名,如bootloader、params、kernel和root */ uint64_t size; /* 分区大小*/ uint64_t offset; /* 分区所在的偏移值 */ uint32_t mask_flags; /* 掩码标识 */ struct nand_ecclayout *ecclayout; /* oob布局 */ }; /* 示例 */ static const struct mtd_partition partition_info[] = { { .name = "NAND FS 0", .offset = 0, .size = 8 * 1024 * 1024 }, { .name = "NAND FS 1", .offset = MTDPART_OFS_APPEND, /* 接着上一个 */ .size = MTDPART_SIZ_FULL /* 余下的所有空间 */ } };
简单分析完了Nand Flash设备驱动,接下来我们来分析MTD子系统框架。
在开发板中ls /dev/mtd*,我们可以看到MTD设备既有块设备也有字符设备,块设备(mtdblockx)针对文件系统,字符设备(mtdx)针对格式化等操作。
在上一节中,我们知道了mtd_info是nand_chip等底层Flash结构体的抽象,因此我们可以得到如下框架。
在上一节中,我们知道了mtd_device_register()函数最终调用add_mtd_device(master)函数添加MTD设备。根据上图可以确定添加的是MTD原始设备。
add_mtd_device()函数定义如下:
1 int add_mtd_device(struct mtd_info *mtd) 2 { 3 struct mtd_notifier *not; /* MTD通知结构体,用于添加删除mtd_info */ 4 int i, error; 5 ... 6 /* 配置mtd_info */ 7 mtd->index = i; 8 mtd->usecount = 0; 9 ... 10 mtd->erasesize_mask = (1 << mtd->erasesize_shift) - 1; 11 mtd->writesize_mask = (1 << mtd->writesize_shift) - 1; 12 ... 13 mtd->dev.type = &mtd_devtype; 14 mtd->dev.class = &mtd_class; 15 mtd->dev.devt = MTD_DEVT(i); 16 dev_set_name(&mtd->dev, "mtd%d", i); 17 dev_set_drvdata(&mtd->dev, mtd); 18 device_register(&mtd->dev); /* 创建device */ 19 ... 20 list_for_each_entry(not, &mtd_notifiers, list) 21 not->add(mtd); /* 调用mtd_notifier的add函数 */ 22 23 return 0; 24 }
其中,struct mtd_notifier定义如下:
struct mtd_notifier { void (*add)(struct mtd_info *mtd); void (*remove)(struct mtd_info *mtd); struct list_head list; };
struct mtd_notifier的注册注销函数定义如下:
/* 注册函数 */ void register_mtd_user (struct mtd_notifier *new) { struct mtd_info *mtd; mutex_lock(&mtd_table_mutex); list_add(&new->list, &mtd_notifiers); __module_get(THIS_MODULE); mtd_for_each_device(mtd) new->add(mtd); mutex_unlock(&mtd_table_mutex); } /* 注销函数 */ int unregister_mtd_user (struct mtd_notifier *old) { struct mtd_info *mtd; mutex_lock(&mtd_table_mutex); module_put(THIS_MODULE); mtd_for_each_device(mtd) old->remove(mtd); list_del(&old->list); mutex_unlock(&mtd_table_mutex); return 0; }
至此,各个结构体层次已经出来了,如下图所示:
既然mtd_info是nand_chip等底层Flash结构体的抽象,那么用于表示Nor Flash的结构体是什么呢,第三节我们就来分析这个问题。
进入drivers/mtd/目录中,Nor Flash和Nand Flash一样,必然会有自己的目录。根据排除法确定Nor Flash设备驱动文件所在的目录为maps。
读者可在此目录下任意选择一个单板驱动文件进行分析,我选择的是dc21285.c文件。
首先来看它的入口函数:
1 static int __init init_dc21285(void) 2 { 3 int nrparts; 4 5 /* Determine bankwidth */ 6 switch (*CSR_SA110_CNTL & (3<<14)) { 7 ... 8 case SA110_CNTL_ROMWIDTH_32: 9 dc21285_map.bankwidth = 4; 10 dc21285_map.read = dc21285_read32; 11 dc21285_map.write = dc21285_write32; 12 dc21285_map.copy_to = dc21285_copy_to_32; 13 break; 14 ... 15 } 16 ... 17 /* 根据Nor Flash物理地址映射Nor Flash空间 */ 18 dc21285_map.virt = ioremap(DC21285_FLASH, 16*1024*1024); /* DC21285_FLASH为物理起始地址,16*1024*1024为FLASH大小 */ 19 ... 20 /* NOR有两种规范 21 * 1. jedec:内核中定义有jedec_table结构体,里面存放有NOR Flash的大小、名字等信息。如果内核中没有定义我们使用的NOR Flash,就必须手动添加 22 * 2. cfi:common flash interface,是新的NOR Flash规范,Flash本身包含有属性,和Nand Flash相同 23 */ 24 if (machine_is_ebsa285()) { 25 dc21285_mtd = do_map_probe("cfi_probe", &dc21285_map); 26 } else { 27 dc21285_mtd = do_map_probe("jedec_probe", &dc21285_map); 28 } 29 30 if (!dc21285_mtd) { 31 iounmap(dc21285_map.virt); 32 return -ENXIO; 33 } 34 35 dc21285_mtd->owner = THIS_MODULE; 36 37 nrparts = parse_mtd_partitions(dc21285_mtd, probes, &dc21285_parts, 0); 38 mtd_device_register(dc21285_mtd, dc21285_parts, nrparts); 39 ... 40 return 0; 41 }
通过此函数,我们可以知道表示Nor Flash的结构体为struct map_info dc21285_map。
init()函数所做的有以下几点:
1. 申请mtd_info结构体内存空间
2. 申请并配置map_info结构体
3. 映射与map_info->phys物理地址对应的map_info->virt虚拟内存,其大小为Flash真实大小,它放在map_info->size
4. 使用do_map_probe()设置map_info结构体
5. 使用parse_mtd_partitions()解析命令行中设置的分区。若命令行中没有设置mtdparts返回0;若设置了并且解析没问题,那么返回分区的个数,否则返回小于0的数
6. 使用mtd_device_register()注册Nor Flash分区
do_map_probe()函数调用关系如下:
1 dc21285_mtd = do_map_probe("cfi_probe", &dc21285_map); 2 -> struct mtd_chip_driver *drv = get_mtd_chip_driver(name); 3 -> list_for_each(pos, &chip_drvs_list) 4 if (!strcmp(this->name, name)) /* 匹配驱动 */ 5 return this; /* 返回的是mtd_chip_driver *drv */ 6 -> ret = drv->probe(map); /* 调用驱动的probe()函数返回mtd_info */
Nand Flash驱动源代码:
1 #include <linux/module.h> 2 #include <linux/types.h> 3 #include <linux/init.h> 4 #include <linux/kernel.h> 5 #include <linux/string.h> 6 #include <linux/ioport.h> 7 #include <linux/platform_device.h> 8 #include <linux/delay.h> 9 #include <linux/err.h> 10 #include <linux/slab.h> 11 #include <linux/clk.h> 12 #include <linux/mtd/mtd.h> 13 #include <linux/mtd/nand.h> 14 #include <linux/mtd/nand_ecc.h> 15 #include <linux/mtd/partitions.h> 16 17 #include <asm/io.h> 18 //#include <asm/arch/regs-nand.h> 19 //#include <asm/arch/nand.h> 20 21 struct itop_nand_regs 22 { 23 unsigned long nfconf ; 24 unsigned long nfcont ; 25 unsigned long nfcmd ; 26 unsigned long nfaddr ; 27 unsigned long nfdata ; 28 unsigned long nfeccd0 ; 29 unsigned long nfeccd1 ; 30 unsigned long nfeccd ; 31 unsigned long nfstat ; 32 unsigned long nfestat0; 33 unsigned long nfestat1; 34 unsigned long nfmecc0 ; 35 unsigned long nfmecc1 ; 36 unsigned long nfsecc ; 37 unsigned long nfsblk ; 38 unsigned long nfeblk ; 39 }; 40 41 static struct nand_chip *itop_nand; 42 static struct mtd_info *itop_mtd; 43 static struct itop_nand_regs *nand_regs; 44 static struct clk *clk; 45 46 static struct mtd_partition itop_nand_part[] = { 47 [0] = { 48 .name = "bootloader", 49 .size = 0x00080000, 50 .offset = 0, 51 }, 52 [1] = { 53 .name = "params", 54 .offset = MTDPART_OFS_APPEND, 55 .size = 0x00020000, 56 }, 57 [2] = { 58 .name = "kernel", 59 .offset = MTDPART_OFS_APPEND, 60 .size = 0x00400000, 61 }, 62 [3] = { 63 .name = "root", 64 .offset = MTDPART_OFS_APPEND, 65 .size = MTDPART_SIZ_FULL, 66 } 67 }; 68 69 static void itop_nand_select_chip(struct mtd_info *mtd, int chipnr) 70 { 71 if (chipnr == -1) 72 { 73 /* 取消选中: NFCONT[1]设为1 */ 74 nand_regs->nfcont |= (1 << 1); 75 } 76 else 77 { 78 /* 选中: NFCONT[1]设为0 */ 79 nand_regs->nfcont &= ~(1 << 1); 80 } 81 } 82 83 static void itop_nand_cmd_ctrl(struct mtd_info *mtd, int cmd, unsigned int ctrl) 84 { 85 if (ctrl & NAND_CLE) 86 { 87 /* MFDCMMD = cmd */ 88 nand_regs->nfcmd = cmd; 89 } 90 else 91 { 92 /* NFDADDR = cmd */ 93 nand_regs->nfaddr = cmd; 94 } 95 } 96 97 static int itop_nand_device_ready(struct mtd_info *mtd) 98 { 99 /* 判断NFSTAT[0]: 1 表示ready */ 100 return (nand_regs->nfstat & (1 << 0)); 101 } 102 103 static int itop_nand_init(void) 104 { 105 /* 1. 分配nand_chip */ 106 itop_nand = kzalloc(sizeof(struct nand_chip), GFP_KERNEL); 107 108 /* 2. 设置nand_chip */ 109 itop_mtd = kzalloc(sizeof(struct mtd_info), GFP_KERNEL); 110 itop_mtd->owner = THIS_MODULE; 111 itop_mtd->priv = itop_nand; 112 113 nand_regs = ioremap(0x4E000000, sizeof(struct itop_nand_regs)); 114 115 itop_nand->select_chip = itop_nand_select_chip; 116 itop_nand->cmd_ctrl = itop_nand_cmd_ctrl; /* 命令最后调用它 */ 117 itop_nand->IO_ADDR_R = &nand_regs->nfdata; /* 读数据最后调用它 */ 118 itop_nand->IO_ADDR_W = &nand_regs->nfdata; /* 写数据 */ 119 itop_nand->dev_ready = itop_nand_device_ready; /* 状态位 */ 120 itop_nand->ecc.mode = NAND_ECC_SOFT; /* 开启ECC */ 121 122 /* 3. 硬件相关的操作 */ 123 /* 注意使能时钟 */ 124 clk = clk_get(NULL, "nand"); 125 clk_enable(clk); 126 nand_regs->nfconf = ((0 << 12) | (1 << 8) | (0 << 4)); 127 nand_regs->nfcont = ((1 << 1) | (1 << 0)); 128 129 /* 4. nand_scan() */ 130 nand_scan(itop_mtd, 1); 131 132 /* 5. mtd_device_register() */ 133 mtd_device_register(itop_mtd, itop_nand_part, 4); 134 135 return 0; 136 } 137 138 static void itop_nand_exit(void) 139 { 140 kfree(itop_nand); 141 kfree(itop_mtd); 142 iounmap(nand_regs); 143 } 144 145 module_init(itop_nand_init); 146 module_exit(itop_nand_exit); 147 148 MODULE_LICENSE("GPL");
Nor Flash驱动源代码(有可能部分开发板中没有Nor Flash):
1 #include <linux/module.h> 2 #include <linux/types.h> 3 #include <linux/init.h> 4 #include <linux/kernel.h> 5 #include <linux/string.h> 6 #include <linux/ioport.h> 7 #include <linux/platform_device.h> 8 #include <linux/delay.h> 9 #include <linux/err.h> 10 #include <linux/slab.h> 11 #include <linux/clk.h> 12 #include <linux/mtd/mtd.h> 13 #include <linux/mtd/map.h> 14 #include <linux/mtd/partitions.h> 15 16 #include <asm/io.h> 17 18 static struct map_info *nor_map; 19 static struct mtd_info *nor_mtd; 20 21 static struct mtd_partition nor_part[] = { 22 [0] = { 23 .name = "bootloader", 24 .size = 0x00080000, 25 .offset = 0, 26 }, 27 28 [1] = { 29 /* 没有那么大内存 */ 30 #if 0 31 .name = "params", 32 .offset = MTDPART_OFS_APPEND, 33 .size = 0x00020000, 34 }, 35 [2] = { 36 .name = "kernel", 37 .offset = MTDPART_OFS_APPEND, 38 .size = 0x00400000, 39 }, 40 [3] = { 41 #endif 42 .name = "root", 43 .offset = MTDPART_OFS_APPEND, 44 .size = MTDPART_SIZ_FULL, 45 } 46 }; 47 48 static int itop_nor_init(void) 49 { 50 /* 分配空间 */ 51 nor_map = kzalloc(sizeof(struct map_info), GFP_KERNEL); 52 nor_mtd = kzalloc(sizeof(struct mtd_info), GFP_KERNEL); 53 54 nor_map->bankwidth = 2; 55 nor_map->name = "itop_nor"; 56 nor_map->phys = 0; 57 nor_map->size = 0x100000; 58 nor_map->virt = ioremap(nor_map->phys, nor_map->size); 59 60 nor_mtd = do_map_probe("cfi_probe", nor_map); 61 62 if (!nor_mtd) 63 nor_mtd = do_map_probe("jedec_probe", nor_map); 64 65 if (!nor_mtd) { 66 iounmap(nor_map->virt); 67 kfree(nor_mtd); 68 kfree(nor_map); 69 return -ENXIO; 70 } 71 72 nor_mtd->owner = THIS_MODULE; 73 74 /* 2表示分区个数 */ 75 mtd_device_register(nor_mtd, nor_part, 2); 76 77 return 0; 78 } 79 80 static void itop_nor_exit(void) 81 { 82 if (nor_mtd) { 83 kfree(nor_map); 84 kfree(nor_mtd); 85 iounmap(nor_map->virt); 86 } 87 } 88 89 module_init(itop_nor_init); 90 module_exit(itop_nor_exit); 91 92 MODULE_LICENSE("GPL");
Makefile:
1 KERN_DIR = /work/itop4412/tools/linux-3.5 2 3 all: 4 make -C $(KERN_DIR) M=`pwd` modules 5 6 clean: 7 make -C $(KERN_DIR) M=`pwd` modules clean 8 rm -rf modules.order 9 10 obj-m += nand.o nor.o
标签:else sign 操作 click block eve head cli signed
原文地址:https://www.cnblogs.com/Lioker/p/11254902.html