- 嵌入式驱动开发
- 第二章 内核模块
- 2.1第一个内核程序
- 2.2内核模块的相关工具
- 2.3内核模块的一般形式
- 一个模块的程序代码
- 2.6内核模块依赖
- 2.8习题
- 第三章 字符设备驱动
- 3.0设备驱动三大类
- 3.1字符设备驱动基础
- 3.2字符设备驱动框架
- 3.3虚拟串口设备
- 3.4虚拟串口设备驱动
- 3.6习题
- 第四章高级I/O操作
- 4.1ioctl设备操作
- 4.4阻塞型I/O
- 4.5I/O多路复用
- 4.6异步I/O
- 4.7几种I/O模型总结
- 4.8异步通知
- 4.9mmap设备文件操作
- 第五章 中断和时间管理
- 5.2驱动中的中断处理
- 5.3中断下半部
- 5.3.1软中断
- 5.3.2tasklet
- 5.3.3工作队列
- 5.4延时控制
- 5.5定时操作
- 5.5.1低分辨率定时器
- 5.5.2高分辨率定时器
- 5.6习题
- 第六章互斥和同步
- 6.2内核中的并发
- 6.3中断屏蔽
- 6.4原子变量
- 6.5自旋锁
- 6.6读写锁
- 6.7顺序锁
- 6.8信号量
- 6.10互斥量
- 6.11RCU机制
- 6.12在虚拟串口驱动加入互斥
- 6.13完成量
- 6.14习题
- 第七章内存和DMA
- 7.2按页分配内存
- 7.3slab分配器
- 7.4不连续内存页分配
- 7.7I/O内存
- 7.10习题
- 第八章Linux设备模型
- 8.2总线、设备和驱动
- 8.3平台设备及驱动
- 第八章Linux设备模型
- 8.2总线、设备和驱动
- 8.3平台设备及驱动
-
内核模块:内核模块就是被单独编译的一段内核代码,它可以在需要的时候动态地加载到内核,从而动态地增加内核的功能。在不需要的时候可以动态地卸载,从而减少内核的功能,并节约一部分内存(这要求内核配置了模块可卸载选项才行)
-
#include
#include #include int init_module(void) { printk("module initn"); return 0; } void cleanup_module(void) { printfk("cleanup modulen"); }
- 模块加载:
- insmod:加载指定目录下的一个.ko文件到内核。
- modprobe:自动加载模块到内核,相对于insmod更加智能。
- 模块信息:
- modinfo:查看模块的信息。
- 模块卸载:
- rmmod:卸载模块。
- MODULE_LICENSE是一个宏,里面的参数是一个字符串,代表相应的许可证协议。可以是:GPL、GPLv2、GPL and additional rights、Dual BSD/GPL、Dual MIT/GPL、Dual MPL/GPL等
#include2.6内核模块依赖#include #include static int_init vser_init(void) { printk("vser_initn"); return 0; } static void_exit vser_exit(void) { printk("vser_exitn"); } module_init(vser_init); module_exit(vser_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Kevin Jiang "); MODULE_DESCRIPTION("A simple module"); MODULE_ALIAS("virtual-serial");
#include#include #include extern int expval; extern void expfun(void); static int _init vser_init(void) { printk("vser_initn"); printk("expval: %dn",expval); expfun(); return 0; } static void _exit vser_exit(void) { printk("vser_exitn"); } module_init(vser_init); module_exit(vser_exit); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Kevin Jiang "); MODULE_DESCRIPTION("A simple module"); MODULE_ALIAS("virtual-serial");
#include#include static int expval = 5; EXPORT_SYMBOL(expval); static void expfun(void) { printk("expfun"); } EXPORT_SYMBOL_GPL(expfun); MODULE_LICENSE("GPL"); MODULE_AUTHOR("Kevin Jiang ")
- 上面的代码中,dep.c里定义了一个全局变量expval,定义了一个函数expfun,并分别使用EXPORT_SYMBOL和EXPORT_SYMBOL_GPL导出。在vser.c首先用extern声明了这个变量和函数,并打印了该变量的值和调用了该函数。
-
在默认情况下,模块初始化函数的名字是(A),模块清除函数是(B)
A init_module B.cleanup_module C.mod_init D.mod_exit
-
加载模块可以用哪个命令(A、D)
A.insmod B.rmmod C.depmod D.modprobe
-
查看模块信息可以用哪个命令(C)
A.insmod B.rrmod C.modinfo D.modprobe
-
内核模块参数的类型不包括(D)
A.布尔 B.字符串指针 C.数组 D.结构
-
内核模块导出用哪个宏(C)
A.MODULE_EXPROT B.MODULE_PARAM C.EXPORT_SYMBOL D.MODULE_LICENSE
-
内核模块能否能调用C库的函数(B)
A.能 B.不能
-
在内核模块的代码中,我们能否定义任意大小的局部变量(B)
A.能 B.不能
- 字符设备驱动
- 块设备驱动
- 网络设备驱动
- mknod是make node的缩写,顾名思义就是创建一个节点。
#include#include #include #include #define VSER_MAJOR 256 #define VSER_MINOR 0 #define VSER_DEV_CNT 1 #defnie VSER_DEV_NAME "vser" static int _init vser_init(void) { int ret; dev_t dev; dev = MKDEV(VSER_MAJOR,VSER_MINOR); ret = register_chrdev_region(dev,VSER_DEV_CNT,VSER_DEV_NAME); if(ret) goto reg_err; return 0; reg_err: return ret; } static void_exit vser_exit(void) { dev_t dev; dev = MKDEV(VSER_MAJOR,VSER_MINOR); unregister_chrdev_region(dev,VSER_DEV_CNT); } module_init(vser_init); module_exit(vser_exit); MODULE_LICENST("GPL"); MODULE_AUTHOR("Kevin Jiang "); MODULE_DESCRIPTION("A simple character device driver"); MODULE_ALIAS("virtual-serial");
-
使用MKDEV宏将主设备号和次设备号合成一个设备号。在当前内核版本中dev_t是一个无符号的32位整数,很自然的,主设备号占12位,次设备号占20位。另外还有两个宏为****MAJOR和MINOR,它们分别是从设备号中取出的主设备号和次设备号的两个宏。
-
cdev_init的函数原型如下,第一个参数是要初始化的cdev地址,第二个参数是设备操作方法集合的结构地址。
void cdev_init(struct cdev*cdev, const struct file_operations *fops);
- cdev对象初始化以后,就应该添加到内核中的cdev_map散列表中,调用的函数是cdev_add,其函数原型如下
3.3虚拟串口设备int cdev_add(struct cdev *p, dev_t dev, unsigned count);
- 内核中已经有了一个关于FIFO的数据结构struct kfifo,相关的操作宏或函数的声明、定义都在"include/linux/kfifo.h"头文件中,下面将最常用的宏罗列如下
DEFINE_KFIFO(fifo, type, size)
kfifo_from_user(fifo, from, len, copied)
kfifo_to_user(fifo, to, len, copid)
- kfifo_from_user是将用户空间的数据(from)放入FIFO中,元素的个数由len来指定,实际放入的元素个数由cpied返回。kfifo_to_user则是将FIFO中的数据取出,复制到用户空间(to)。
3.6习题
-
字符设备和块设备的区别不包括(C)
A.字符设备按字节流进行访问,块设备按块大小进行访问
B.字符设备智能处理可打印字符,块设备可以处理二进制数据
C.多数字符设备不能随机访问,而块设备一定能随机访问
D.字符设备通常没有页高速缓存,而块设备有
-
在3.14.25版本的内核中,主设备号占(C)位,次次设备号占(D)位
A.8 B.16 C.12 D.20
-
用于分配主次设备号的函数是(C)
A.register_chrdev_region B.MKDEV C.alloc_chrdev_region D.MAJOR
-
在字符设备驱动中,struct file_operations结构中的函数指针成员不包括(B)
A.open B.close C.read D.show_fdinfo
- ioctl的命令需要遵从一种编码规则,
比特位 含义 31-30 00 - 命令不带参数 10 - 命令需要从驱动中获取数据,读方向 01 - 命令需要把数据写入驱动,写方向 11 - 命令既需要写入数据又要获取数据,读写方向 29-16 如果命令带参数,则指定参数所占用的内存空间大小 15-8 每个驱动全局唯一的幻数(魔术) 7-0 命令码
- 在帧格式的设置和获取上使用了copy_to_user和copy_from_user两个函数,它们的函数原型如下
unsigned long __must_check copy_from_user(void *to, const void __user *from, unsigned long n); unsigned long __must_check copy_to_user(void __user *to, const void *from, unsigned long n);4.4阻塞型I/O
- 要实现阻塞操作,最重要的数据结构就是等待队列。
-
这里以poll为例来进行说明,poll系统调用的原型及相关的数据结构类型如下:
int poll(struct pollfd *fds, nfds_t nfds, int timeout); struct pollfd{ int fd; short events; short revents; }; POLLIN There is data to read. POLLOUT Writting now will not block. POLLRDNORM Equivalent to POLLIN. POLLWRNORM Equivalent to POLLOUT.
- 异步I/O是POSIX定义的一组标准接口,Linux也支持。相对于前面的几种I/O模型,异步I/O在提交完I/O操作请求后就立即返回,程序不需要等到I/O操作完成后再去做别的事情,具有非阻塞的特性。当底层把I/O操作完成后,可以给提交者发送信号,或者调用注册的回调函数,告知请求提交者I/O操作已完成。
- 阻塞I/O:在资源不可用时,进程阻塞,阻塞发生在驱动中,资源可用后进程被唤醒,在阻塞期间不占用CPU,时最常用的一种方式。
- 非阻塞I/O:调用立即返回,即便是在资源不可用的情况下,通过返回值来确定I/O操作是否成功,如果不成功,程序将在之后继续进行尝试。
- I/O多路复用:可以同时监听多个设备的状态,如果被监听的所有设备都没有关心的事件产生,那么系统调用会阻塞。当被监听的任何一个设备有对应的关心的事件发生,将会唤醒系统调用,系统调用将再次遍历所有监听设备,获取其事件信息,然后系统调用返会。之后可以对设备发起非阻塞的读或写操作。
- 异步I/O:调用者只是发起I/O操作的请求,然后立即返回,程序可以去做别的事情。具体的I/O操作在驱动中完成,驱动中可能会被阻塞,也可能不会被阻塞。当驱动的I/O操作完成后,调用者将会得到通知,通常是内核向调用者发送信号,或者自动调用调用者注册的回调函数,通知操作是由内核完成的,而不是驱动本身。
- 异步通知的实现步骤:
- 注册信号处理函数,这相当于注册中断处理函数
- 打开设备驱动文件,设置文件属性。目的是使驱动根据打开文件的file结构,找到对应的进程,从而向该进程发送信号。
- 设置设备资源可用时驱动向进程发送的信号,这一过程并不是必需的,当正如我们下面看到的,如果要使用sigaction的高级特性,该步骤是必不可少的。
- 设置文件的FASYNC 标志,使能异步通知机制,这相当于打开中断使能位。
- 驱动代码要完成以下几个操作:
- 构造struct fasync _struct链表的头
- 实现fasync接口函数,调用fasync_helper函数来构造struct fasync _struct节点,并加入到链表。
- 在资源可用时,调用kill_fasync发送信号,并设置资源的可用类型时可读还是可写。
- 在文件最后一次关闭时,即在release接口中,需要显式调用驱动实现的fasync接口函数,将节点从链表中删除,这样驱动进程就不会再收到信号。
- 字符设备驱动提供了一个mmap接口,可以把内核空间中的那片内存所对应的物理地址空间再次映射到用户空间,这样一个物理内存就有了两份映射,或者说有两个虚拟地址,一个在内核空间,一个在用户空间。
-
中断处理函数原型如下
int request_irq(unsigned int irq, irq_handler_t handler, unsigned long flags, const char *name, void *dev);
-
flags:与中断相关的标志,用于初始化struct irqaction对象中的flags成员,常用的标志如下,这些标志可以用位或的方式来设置多个
- IRQF_TRIGGER_RISING:上升沿触发。
- IRQF_TRIGGER_FALLING:下降沿触发。
- IRQF_TRIGGER_HIGHT:高电平触发。
- IRQF_TRIGGER_LOW:低电平触发。
-
关于中断使能和禁止的函数或宏,这些函数罗列如下:
- local_irq_save(flags):使能本地CPU的中断,并将之前的中断使能状态保存在flags中。
- local_irq_restore(flags):用flags中的中断使能状态恢复中断使能标志。
- Linux将中断分成了两个部分:上半部和下半部,上半部完成紧急但能很快完成的事情,下半部完成不紧急但比较耗时的操作。
- 最后需要注意的是,软中断也处于中断上下文中,因此对中断处理函数的限制同样适5与软中断,只是没有时间上的严格限定。
- tasklet的主要特性总结:
- tasklet是一个特定的软中断,处于中断的上下文。
- tasklet_schedule函数被调后,对应的下半部会保证被至少执行一次。
- 如果一个tasklet已经被调度,但是还没有被执行,那么新的调度将会别忽略。
- 它的实现思想也比较简单,就是内核在启动的时候创建一个或多个内核工作线程,工作线程取出工作队列中的每一个工作,然后执行,当队列中没有工作时,工作线程休眠。当驱动程序想要延迟执行某一个工作是,构造一个工作队列节点对象,然后加入到相应的工作队列,并唤醒工作线程,工作线程又取出队列上的节点来完成工作,所有工作完成后又休眠。
- 工作队列特性总结:
- 工作队列的工作函数运行在进程上下文,可以调度调度器。
- 如果上一个工作还没有完成,又重新调度下一个工作,那么新的工作将不会被调度。
-
靠循环来延时的宏或函数:
void ndelay(unsigned long x); udelay(n) mdelay(n)
- ndelay、udelay、mdelay分别表示纳秒级延时、微秒级延时和毫秒级延时。
-
如果延时时间较长,有没有特殊要求,那么可以使用休眠延时
void msleep(unsigned int msecs); long msleep_interruptible(unsigned int msecs); void ssleep(unsigned int second);
- msleep_interruptible表示休眠可以被信号打断,反过来说,msleep和ssleep都不能被信号打断,只能等到休眠时间到了才会返回。
- 要在驱动中实现一个定时器,需要经过以下几个步骤:
- 构造一个定时器对象,调用init_timer来初始化这个对象,并对expires、function和data成员赋值。
- 使用add_timer将定时器对象添加到内核的定时器链表中。
- 定时时间到了之后,定时器函数自动被调用,如果需要周期定时,那么可以在定时函数中使用mod_timer来修改expires。
- 在不需要定时器的时候,用del_timer来删除定时器。
- 高分辨率定时器是以ktime_t来定义时间。
-
关于中断处理例程说法错误的是(C)
A.需要尽快完成
B.不能调用可能会引起进程休眠的函数
C.如果中断时共享的,内核会决定具体调用哪一个驱动的中断服务例程
D.工作在中断上下文
-
中断的下半部机制包括(A、B、C)
A.软中断 B.tasklet C.工作队列
-
关于软中断下半部机制的说法正确的是(B)
A.可以使处理的总时间减少
B.可以提高CPU的利用率
C.每种下半部机制都不运行在中断上下文中
D.在下半部中可以响应新的硬件中断
-
下面哪种下半部机制工作在进程上下文中(C)
A.软中断 B.tasklet C.工作队列
-
修改低分辨率定时器的expires成员使用(C)函数
A.init_timer B.add_timer C.mod_timer D.del_timer
-
关于低分辨率定时器说法错误的是(D) A.分辨率受HZ的影响
B.function函数指针指向的函数运行在中断上下文中
C.使用mod_timer可以实现循环定时
D.所有定时器都放在一个组中,遍历整个链表非常耗时
-
高分辨率定时器是用(C)来定义时间的
A.HZ B.jiffies C.ktime_t
- 内核中有哪些并发情况
- 硬件中断:当处理器允许中断的时候,一个内核执行路径可能在任何一个时间都会被一个外部中断打断。
- 软中断和tasklet:通过前面的知识我们知道,内阁可以在任意硬件中断快要返回之前执行软中断以及tasklet,也有可能唤醒软中断线程,并执行tasklet。
- 抢占内核的多进程环境:如果一个进程在执行时发生系统调用,进入到内核,由内核代替该进程完成相应的操作,此时如有一个更高优先级的进程准备就绪,内核判断在可抢占的条件的成立的情况下可以抢占当前进程,然后去执行更高优先级的进程。
- 普通的多进程环境:当一个进程因为等待的资源暂时不可用时,就会主动放弃CPU,内核会调度另外一个进程来执行。
- 多处理器或多核CPU。在同一时刻,可以在多个处理器上并发执行多个程序,这是真正意义上的并发。
- 使用中断屏蔽来做互斥时的注意事项总结如下:
- 对解决中断引起的并发而带来的竞态简单高效。
- 应该尽量使用local_irq_save和local_irq_restore来屏蔽和使能中断。
- 中断屏蔽的时间不宜过长。
- 只能屏蔽本地CPU的中断,对多CPU系统,中断也可能会在其他CPU上产生。
- 内核专门提供了一种数据类型atomic_t,用它来定义的变量为原子变量。
- 自旋锁是一种忙等锁。
- 关于自旋锁的一些重要特性和使用注意事项总结如下:
- 获得自旋锁的临界代码段执行时间不宜过长,因为是忙等锁,这会影响工作效率。
- 在获得锁期间,不能够调用可能会引起进程切换的函数。
- 自旋锁是不可递归的。
- 自旋锁可以用于中断上下文中,因为它是忙等锁,所以并不会引起进程的切换。
- 如果中断中也要访问共享资源,则在非中断处理代码中访问共享资源之前应先禁止中断再获取自旋锁。
- 虽然一直都在说自旋锁是忙等锁,但是在单处理器的无抢占内核中,单纯的自旋锁获取操作其实是一个空操作而在单处理器的可抢占内核中也仅仅是禁止抢占而已。
- 在并发的方式中有读——读并发、读——写并发、写——写并发三种。
- 内核提供了一种允许读和读并发的锁,叫读写锁,其数据类型为rwlock_t.
- 自旋锁不允许读和读之间的并发,读写锁则更进一步,允许读和读之间并发,顺序锁又更进一步,允许读和写之间的并发。
- 顺序锁的数据类型是seqlock_t.
- 信号量最常用的API接口如下
- down:获取信号量(信号量的值减1,P操作)
- down_interruptible:同down,但是能够被信号唤醒。
- up:释放信号量(信号量的值加1,V操作)
- 互斥量的限制和特性:
- 要在同一上下文对互斥量上锁和解锁,比如不能在读进程中上锁,也不能在写进程中解锁。
- 和自旋锁一样,互斥量的上锁是不能递归的。
- 当持有互斥量时,不能推迟进程。
- 不能用于中断上下文,即使mutex_teylock也不行。
- 持有互斥量期间,可以调用可能会引起进程切换的函数。
- 在不违背自旋锁的使用规则时,应该优先使用自旋锁。
- 在不能使用自旋锁但不违背互斥量的使用规则时,应该优先使用互斥量,而不是信号量。
- RCU机制都是通过指针来进行的,写者发起写访问操作时,不使用以前的共享内存,复制以前的数据到新开辟的内存空间,然后在修改新空间的内容。适用于读访问多,写访问少的情况,它尽可能地减少了对锁的使用。
- 归纳一下在驱动中如何解决竞态问题:
- 找出驱动中的共享资源,比如available和接收FIFO.。
- 考虑在驱动中的什么地方有并发的可能、是何种并发,以及由此引起的竟态。
- 使用何种手段互斥。
- complete只唤醒一个进程
- complete _all唤醒所有休眠的进程。
-
内核中的并发情况有(A、B、C、D、E)
A.硬件中断 B.软中断和tasklet C.抢占内核的多进程环境 D.普通的多进程环境
E.多处理器或多核CPU
-
local_irq_save的作用是(C)
A.禁止全局中断 B.禁止本CPU中断 C.禁止本CPU中断并将之前的中断使能状态保存下来
-
可以对原子变量进行的操作有(A、B、C、D)
A.自减并测试结果是否为0 B.加上一个整数值并返回结果
C.进行位清除 D.变量中指定的比特位交换
-
关于自旋锁的使用说法错误的是(C)
A.获得自旋锁的临界代码不宜过长
B.在获得锁的期间不能够调用可能会引起进程切换的函数
C.自旋锁可以用于中断上下文中
D.在所有系统中,即不管是否抢占、是否多核,自旋锁都是忙等锁。
-
关于信号量的使用说法错误的是(D)
A.如果不能获得信号量,则进程休眠
B.在中断上下文不能调用down函数来获取信号量
C.在获得信号量期间,进程可以休眠
D.相比于自旋锁,优先使用信号量
-
关于RCU说法错误的是(A)
A.写者完成写后立即更新指针
B.适合于读访问多、写访问少的情况
C.对共享资源的访问都是通过指针来实现的
D.尽可能地减少了对锁地使用
-
完成量地complete函数可以唤醒(A)进程
A.一个 B.所有
-
page_address:只用于非高端内存地虚拟地址的获取。
-
kmap:用于返回分配地高端或非高端内存地虚拟地址,如果不是高端内存,则内部调用地其实是page_address,也叫永久映射。
-
kmap_atomic:和kmap功能类似,但操作时原子性地,也叫作临时映射。
-
kunmap:用于解除前面的映射。
- kmalloc:类似于malloc。
- kzalloc:同kmalloc,只是分配地内存预先被清零。
- kfree:释放有kmalloc分配地内存。
- vmalloc和vzalloc用于分配内存,vzalloc将分配地内存预先清理,vfree释放内存。
void *vmalloc(unsigned long size); void *vzalloc(unsigned long size); void vfree(const void *addr);7.7I/O内存
- ioremap:映射从offset开始地size字节I/O内存,返回值为对应地虚拟地址,NULL表示映射失败。
- iounmap:解除之前地I/O内存映射。
-
Linux地内存区域有(A、B、C)
A.ZONE_DMA B.ZONE_NORMAL C.ZONE_HIGHMEM
-
alloc_page函数地order参数表示(B)
A.分配地页数为order
B.分配地页数为2地order次方
-
如果指定了__GFP_HIGHMEM,表示可以在那些区域分配内存(C)
A.ZONE_DMA B.ZONE_NORMAL C.CONE_HIGHMEM
-
用于永久映射地函数是(A)
A.kamp B.kamp_atomic
-
用于临时映射地函数是(B)
A.kamp B.kamp_atomic
-
在内核中如果要分配128个字节,使用下面哪个函数比较合适(D)
A.alloc_page B.__get_free_pages
C.malloc D.kmalloc
-
能分配大块内存,但物理地址不一定连续地函数是(A)
A.vmalloc B.__get_free_pages C.malloc D.kmalloc
-
per-CPU变量指的是(A)
A.每一个CPU有一个变量地副本
B.多个CPU公用一个变量
-
映射I/O内存地函数是(B)
A.kmap B.ioremap
- Linux设备模型为这三种对象各自定义了对应地类:structbus_type代表总线、struct_device代表设备、struct device_driver代表驱动。
-
平台驱动是用struct platform_driver结构来表示de
- probe:总线发现有匹配的平台设备时调用。
- remov:所驱动地平台设备被移除时或平台驱动注销时调用。
- shutdown、suspend和resume:电源管理函数,在要求设备掉电、挂起和恢复时被调用。
- id_table:平台驱动可以驱动地平台设备ID列表,可用于和平台设备匹配。 于临时映射地函数是(B)
A.kamp B.kamp_atomic
-
在内核中如果要分配128个字节,使用下面哪个函数比较合适(D)
A.alloc_page B.__get_free_pages
C.malloc D.kmalloc
-
能分配大块内存,但物理地址不一定连续地函数是(A)
A.vmalloc B.__get_free_pages C.malloc D.kmalloc
-
per-CPU变量指的是(A)
A.每一个CPU有一个变量地副本
B.多个CPU公用一个变量
-
映射I/O内存地函数是(B)
A.kmap B.ioremap
- Linux设备模型为这三种对象各自定义了对应地类:structbus_type代表总线、struct_device代表设备、struct device_driver代表驱动。
- 平台驱动是用struct platform_driver结构来表示de
- probe:总线发现有匹配的平台设备时调用。
- remov:所驱动地平台设备被移除时或平台驱动注销时调用。
- shutdown、suspend和resume:电源管理函数,在要求设备掉电、挂起和恢复时被调用。
- id_table:平台驱动可以驱动地平台设备ID列表,可用于和平台设备匹配。
1.《嵌入式驱动开发》援引自互联网,旨在传递更多网络信息知识,仅代表作者本人观点,与本网站无关,侵删请联系页脚下方联系方式。
2.《嵌入式驱动开发》仅供读者参考,本网站未对该内容进行证实,对其原创性、真实性、完整性、及时性不作任何保证。
3.文章转载时请保留本站内容来源地址,https://www.lu-xu.com/jiaoyu/2372076.html