Linux 驱动基础

注册驱动模块时给模块传递参数

在一些情况下,我们要动态的改变驱动中某个变量的值,那么就可以在注册时给驱动模块传递参数。
给驱动模块中传递参数,需要定义好接受参数值的全局变量,并调用module_param 来引用它,具体示例如下:

include\linux\ Moduleparam.h
module_param(name,type,perm)
参数:
  @name用来接收参数的变量名
  @type参数的数据类型
    bool   :布尔类型
    invbool:颠倒了值的bool类型;
    charp  :字符指针类型,内存为用户提供的字符串分配;
    int    :整型
    long   :长整型
    short  :短整型
    uint   :无符号整型
    ulong  :无符号长整型
    ushort :无符号短整型
  @perm 指定参数访问权限。

传递一个int 型参数

//驱动代码
static unsigned int var=0;
module_param(var,uint,0400);

在加载模块的时候,传递参数:

insmod test.ko var=100

传递string 参数

static char *string;
module_param(string,charp,0400);

在加载模块的时候,传递参数:

insmod test.ko string="abcdefg";

驱动符号导出

(1)什么是符号?
这里的符号主要指的是全局变量和函数。

(2)为什么要导出符号?
Linux内核采用的是以模块化形式管理内核代码。内核中的每个模块相互之间是相互独立的,也就是说A模块的全局变量和函数,B模块是无法访问的。如果需要在其它模块中调用本模块的函数或全局变量就需要导出符号。(不导出的话编译模块时就不认识该符号)

(静态编译入内核的模块之间引用符号,是不需要导出的)

当我们想在动态插入的A 模块中引用B模块 (B模块可以编译成.ko 也可以编译进内核) 定义的全局变量或者函数时,就需要B模块导出符号,并且在插入A模块前确保B模块已经插入内核,或编译进内核的情况下A模块才会认识符号。

有两种情况:
1、在编译时如果没有查找到符号的话,就会报warning “xxx” undefined!
2、编译完成后,如果被引用的B模块也编译成ko,但是系统中没有插入B模块,直接插入引用符号的A模块的话,就会报Unknown symbol xxx (err 0)

(对于情况2,B模块被编译进内核的话,因为已经在内核里,直接插入A模块即可)

如下图,内核中已经定义了函数 irq_set_irqchip_state,而且我的代码中已经包含了它的头文件,但还是报错未定义,这就是因为这个符号未导出,在动态加载的.ko 中不认识它。
解决方法:EXPORT_SYMBOL(irq_set_irqchip_state);
在这里插入图片描述
(3)如何导出符号?
Linux内核给我们提供了两个宏:

EXPORT_SYMBOL(name);
EXPORT_SYMBOL_GPL(name);

(4)符号导出后的结果
模块编译进内核时符号导出的情况
如果一个模块已经以静态的方式编译进的内核,并且它调用EXPORT_SYMBOL(xxx)导出符号的话,被到处的符号就会出现在内核的全局符号表中(内核源码根目录下的Module.symvers 文件)。(不导出的话就不会出现在任何符号表)
例如:
我在编译进内核的fec_main.c (obj-y += fec_main.o) 驱动模块中,导出了fec_enet_txq_submit_skb 函数符号;
在这里插入图片描述
Image 编译完成后,我们就可以在内核根目录下的Module.symvers 文件中找到 fec_enet_txq_submit_skb 符号。
在这里插入图片描述
编译成ko 时符号导出情况
如果我们在一个编译成ko (obj-m = xxx.o) 的代码中调用EXPORT_SYMBOL(xxx) 导出符号的话,那么被导出的符号就会出现在该模块目录下的Module.symvers 文件下。(不导出的话就不会出现在任何符号表)
例如:
我对~/zjh/driver/12_virt_spi 下的驱动代码spi_virt_transfer 函数执行符号导出,它的符号就会出现在~/zjh/driver/12_virt_spi/Module.symvers 文件中。
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

(5)模块编译时,如何寻找使用的符号?
a.在本模块中符号表中,寻找符号(函数或变量实现)
b.在内核全局符号表中寻找
c.在模块目录下的Module.symvers文件中寻找

(6)调试案例
现在需要实现在A模块中调用B模块定义的函数,具体的操作如下:
a. 在B模块中声明全局变量和函数,然后用EXPORT_SYMBOL 导出,B编译完成后会在当前模块目录下生成 Module.symvers 文件。(在文件中记录着被导出的符号)
在这里插入图片描述
在这里插入图片描述
b. 将B模块编译生成的Module.symvers 拷贝到A 模块目录下,然后在A 模块中用extern 修饰被导出的变量和函数。调用该函数。
调用
在这里插入图片描述
c. 编译A 模块,将A、B模块都拷贝到开发板rootfs 中。
如果直插入A 模块的话就会出现如下错误:
在这里插入图片描述
成功插入B模块,再插入A模块时,函数就可以成功被调用。
在这里插入图片描述

驱动中访问寄存器

在Linux中,无论是应用程序或是驱动程序,访问的都是虚拟地址,所以在访问一个物理寄存器地址时,首先要进行映射。
将一个寄存器物理地址映射为虚拟地址可以使用ioremap 函数,反映射 ioumap

//包含头文件#include <asm/io.h>  对于arm其实包含的就是arch/arm/include/asm/io.h
#define ioremap(cookie,size) __arm_ioremap((cookie), (size),MT_DEVICE)
void __iomem * __arm_ioremap(phys_addr_t phys_addr, size_t size,
unsigned int mtype)
{
	return arch_ioremap_caller(phys_addr, size, mtype,__builtin_return_address(0));
}
/*
cookie: 要映射的物理地址
size:  要映射的地址长度
返回值:映射好的虚拟地址 (void __iomem * 类型)
*/
void iounmap (volatile void __iomem *addr)

映射完成后可以用以下函数访问寄存器的值:

//小端读写:如果要读的寄存器和cpu 都是小端,那么可以用以下的小端读写函数
u8 readb(const volatile void __iomem *addr)
u16 readw(const volatile void __iomem *addr)
u32 readl(const volatile void __iomem *addr)

void writeb(u8 value, volatile void __iomem *addr)
void writew(u16 value, volatile void __iomem *addr)
void writel(u32 value, volatile void __iomem *addr)

//大端读写:如果cpu 是小端,寄存器是大端,可以用以下大端函数读写
#define ioread16be(p)		({ __u16 __v = be16_to_cpu((__force __be16)__raw_readw(p)); __iormb(); __v; })
#define ioread32be(p)		({ __u32 __v = be32_to_cpu((__force __be32)__raw_readl(p)); __iormb(); __v; })

#define iowrite16be(v,p)	({ __iowmb(); __raw_writew((__force __u16)cpu_to_be16(v), p); })
#define iowrite32be(v,p)	({ __iowmb(); __raw_writel((__force __u32)cpu_to_be32(v), p); })

注释:iomem 映射与普通内存映射的区别,iomem 主要用于寄存器,寄存器每次值的改变都有独特的意义,比如写0,写1 可能是让某个gpio输出高低。而普通内存一般只用来存放数据。

定义指向寄存器虚拟地址的指针时可以用volatile 来修饰
volatile的作用:volatile
1.防止编译器的优化
2.每次直接从内存中读取

代码示例:

/* 寄存器物理地址 */
#define CCM_CCGR1_BASE (0X020C406C)
#define SW_MUX_GPIO1_IO03_BASE (0X020E0068)
#define SW_PAD_GPIO1_IO03_BASE (0X020E02F4)
#define GPIO1_DR_BASE (0X0209C000)
#define GPIO1_GDIR_BASE (0X0209C004

/* 映射后的寄存器虚拟地址指针 */
static void __iomem *IMX6U_CCM_CCGR1;			//void iomem* 类型中可能已经包含了volatile 修饰
static void __iomem *SW_MUX_GPIO1_IO03;
static void __iomem *SW_PAD_GPIO1_IO03;
static void __iomem *GPIO1_DR;
static void __iomem *GPIO1_GDIR;

/* 寄存器地址映射 */
IMX6U_CCM_CCGR1 = ioremap(CCM_CCGR1_BASE, 4);
SW_MUX_GPIO1_IO03 = ioremap(SW_MUX_GPIO1_IO03_BASE, 4);
SW_PAD_GPIO1_IO03 = ioremap(SW_PAD_GPIO1_IO03_BASE, 4);
GPIO1_DR = ioremap(GPIO1_DR_BASE, 4);
GPIO1_GDIR = ioremap(GPIO1_GDIR_BASE, 4);

/* 读写寄存器地址*/
val = readl(IMX6U_CCM_CCGR1);
writel(val, IMX6U_CCM_CCGR1);

/* 取消映射 */
iounmap(IMX6U_CCM_CCGR1);	//在自己编写的驱动中反映射时经常出现异常(卡住或者oops),待研究
iounmap(SW_MUX_GPIO1_IO03);
iounmap(SW_PAD_GPIO1_IO03);
iounmap(GPIO1_DR);
iounmap(GPIO1_GDIR);

休眠与唤醒

当应用程序必须等待某个事件发生,比如必须等待按键被按下时, 可以使用“休眠-唤醒”机制。 效果类似于阻塞IO。

等待队列
使用宏DECLARE_WAIT_QUEUE_HEAD 定义一个等待队列:
相关函数定义于:include/linux/wait.h

static DECLARE_WAIT_QUEUE_HEAD(gpio_key_wq);
#define DECLARE_WAIT_QUEUE_HEAD(name) \
        wait_queue_head_t name = __WAIT_QUEUE_HEAD_INITIALIZER(name)

休眠函数
用下列函数可以让一个线程挂起:
在这里插入图片描述
唤醒函数
使用下列函数可以唤醒线程:
在这里插入图片描述

completion 完成机制

在内核中有许多场景需要一个线程,告诉另一个线程工作是否完成。
比如spi 的传输过程,如果你选择同步传输(调用函数后传输完成才返回)——spi_sync,在spi_sync 中就用到了完成机制。

struct completion {
	unsigned int done;
	wait_queue_head_t wait;
};

定义completion
你可以自己手动定义一个completion 结构体,也可以调用内核的宏来定义。
如果你自己手动定义的话要调用init_completion 函数来初始化它。

struct completion done
init_completion(&done)

调用内核宏定义:

#define COMPLETION_INITIALIZER(work) \
    { 0, __WAIT_QUEUE_HEAD_INITIALIZER((work).wait) }

#define DECLARE_COMPLETION(work) \
    struct completion work = COMPLETION_INITIALIZER(work)

等待完成函数
可以调用wait_for_completion 来等待某个事情完成。
void __sched wait_for_completion(struct completion *x)

完成函数
调用complete 来通知某个事情完成。
void complete(struct completion *x)

重新初始化函数
重新初始化 (当你在一个for 循环中,需要反复的只用一个completion 时就可以调用reinit_completion 重新初始化它)
static inline void reinit_completion(struct completion *x)

原理:
completion 它其实是利用等待队列来完成的,在其中有一个等待队列和done。(done 用来计数)
初始化时会将done 设置为0,初始化等待队列wait。

static inline void __init_completion(struct completion *x)
{
	x->done = 0;
	init_waitqueue_head(&x->wait);
}

当调用wait_for_completion 时会判断 done 的值,如果为0,就会进入休眠等待;调用complete 时会执行done++,并唤醒休眠线程。
如果已经提前调用过complete,再调用wait_for_completion 此时done值非0,不会陷入休眠等待。

重新初始化时又会将done 设置为0.

static inline void reinit_completion(struct completion *x)
{
	x->done = 0;
}

POLL 机制

使用休眠-唤醒的方式等待某个事件发生时,有一个缺点: 等待的时间可能很久。我们可以加上一个超时时间,这时就可以使用 poll 机制。
poll机制: 应用层可以调用poll 函数查询是否有数据可读 或 有空间可写。如果可读写,直接返回可读写状态;没有数据可读或不可写,那么进入休眠状态,直到可读写被唤醒 或超时后返回。

驱动编程

使用 poll 机制时,驱动程序的核心就是提供对应的 drv_poll (file_operations->poll)函数。应用层调用 poll -> sys_poll -> drv_poll;
在 drv_poll 函数中要做 2 件事:
把当前线程挂入队列 wq: poll_wait
我们需要在drv_poll 函数中把线程挂入等待队列。
应用程序调用一次 poll,可能导致 drv_poll 被调用 2 次,但是我们并不需要把当前线程挂入队列 2 次。
可以使用内核的函数 poll_wait 把线程挂入队列,如果线程已经在队列里了,它就不会再次挂入。
调用poll_wait 并不会直接挂起线程,只是把线程放入等待队列。

② 返回设备状态:
APP 调用 poll 函数时,有可能是查询“有没有数据可以读”: POLLIN,也有可能是查询“你有没有空间给我写数据”: POLLOUT。
所以 drv_poll 要返回自己的当前状态: (POLLIN | POLLRDNORM) 或 (POLLOUT | POLLWRNORM)。
POLLRDNORM 等同于 POLLIN,为了兼容某些 APP 把它们一起返回。
POLLWRNORM 等同于 POLLOUT ,为了兼容某些 APP 把它们一起返回。

//include/linux/poll.h
static inline void poll_wait(struct file * filp, wait_queue_head_t * wait_address, poll_table *p)

typedef struct poll_table_struct {
	poll_queue_proc _qproc;
	unsigned long _key;
} poll_table;

filp:file 结构体。
wait_address:等待队列。
p:
驱动中唤醒线程依旧使用 wake_up_interruptible 等函数。

drv_poll 示例代码:

//unsigned int (*poll) (struct file *, struct poll_table_struct *);
static unsigned int key_poll(struct file *file,struct poll_table_struct *polltable)
{
        printk("%s\n",__func__);
        //将线程挂入等待队列,但是并没有进入睡眠态,而是返回数据状态,sys_poll根据返回的结果决定是否进入睡眠
        poll_wait(file,&gpio_key_wq,polltable);
        return buff_is_empty() ? 0 : POLLIN | POLLRDNORM;
}

在中断中唤醒线程:

static irqreturn_t key_irq_handler(int irq,void* dev_id)
{
        int value,data = 66,ret;
        struct gpio_keys* key_p = dev_id;

        struct gpio_desc* gpiod =  gpio_to_desc(key_p->gpio);
        //value = gpio_get_value(key_p->gpio);  //gpio_get_value 获取到的值是硬件值
        value = gpiod_get_value(gpiod);         //gpiod_get_value 获取到的值是软件值
        if(value < 0)
                printk("%s gpio_get_value\n",__func__);
        //printk("%s,key %d val %d\n",__func__,key_p->gpio,value);
        if(value == 1)
        {
                ret = w_buff(data);
                if(ret)
                        return IRQ_HANDLED;
                wake_up_interruptible(&gpio_key_wq);	//唤醒线程
        }
        return IRQ_HANDLED;
};
应用编程

可以使用 man poll 命令查看poll 函数使用方法。
可以利用poll 检测多个文件,每一个文件都需要提供一个pollfd。

#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

struct pollfd {
               int   fd;         /* file descriptor */
               short events;     /* requested events */
               short revents;    /* returned events */
           };

fds:
fd:open 返回的句柄。
envents:需要查询的事件,读时 envents = POLLIN,写时 envents = POLLOUT。
revents:返回的事件状态,POLLIN或POLLRDNORM 表示可读,POLLOUT 或POLLWRNORM 表示可写。
nfds:传入poll 的fds 数量。
timeout:超时时间,ms。
返回值:0,不可读写;其余参考下表。
在这里插入图片描述
应用编程示例:

#include<stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <poll.h>

int main(int argc,char* argv[])
{
    int fd = 0;
    unsigned int val = 0;
    int nread = 0,ret;
    struct pollfd fds;


    fd = open(argv[1],O_RDWR);
    if(fd<0)
    {
        perror("open:");
        return -1;
    }

    fds.fd = fd;
    fds.events = POLLIN;

    //int poll(struct pollfd *fds, nfds_t nfds, int timeout); nfds: 指定传入poll的fds 数量
    ret = poll(&fds,1,2000);
    if(!(ret & (POLLIN | POLLRDNORM)))
    {
            printf("not have valid data\n");
            return -1;
    }

    nread = read(fd,&val,sizeof(val));
    if(nread < 0)
        perror("read:");

    printf("val: %d\n",val);

    if(close(fd) < 0)
        perror("close");

    return 0;
}

异步通知

使用休眠唤醒或poll 时都有一个缺点,应用程序需要陷入休眠状态等待数据的到来;如果app 在没有数据的期间还想做其它事情怎么办?可以使用异步通知。

什么是异步通知?
你在旁边等着, 眼睛盯着店员, 生怕别人插队, 他一做好你就知道: 你是主动等待他做好, 这叫“ 同步”。
你付钱后就去玩手机了, 店员做好后他会打电话告诉你: 你是被动获得结果, 这叫“ 异步”。

当前要实现的场景是:
app 在处理其他事,比如不停的打印:printf(“1111111111\n”);
按键按下时,驱动中的中断获取到键值,并使用异步通知告诉app 有数据来了;app 调用read 读取键值。

那么就需要思考以下问题:
APP 要做什么:接收信号。
① 发什么信号
Linux提供很多信号,SIGIO 是Linux驱动比较常用的信号,表示有IO数据。
② 内核里有那么多驱动, 你想让哪一个驱动给你发信号?
APP 要打开驱动程序的设备节点。 //open() 打开/dev/xxx
③ 驱动程序怎么知道要发信号给你而不是别人?
APP 要把自己的进程 ID 告诉驱动程序。 //fcntl(fd,F_SETOWN,getpid());
④ 使能驱动可以使用异步通知。
获取驱动文件 flag //oflags = fcntl(fd,F_GETFL);
使能驱动异步通知 //fcntl(fd,F_SETFL,oflags | FASYNC);
④ APP 有时候想收到信号, 有时候又不想收到信号:
应该可以把 APP 的意愿告诉驱动。
⑤ Linux中有许多信号,app 需要接收哪个信号,收到信号后需要做什么事?
注册信号处理函数,绑定信号与信号处理函数。使用signal 函数注册。
在这里插入图片描述
驱动需要做哪些事情:发信号
① 记录app 进程ID。
app 设置pid 时,首先会被保存到file 结构体;
② APP 还要使能驱动程序的异步通知功能, 驱动中有对应的函数:
判断flag 的FASYNC 变化后,会调用drv_fasync,在drv_fasync() 中调用fasync_helper(),它会根据 FAYSNC 的值决定是否设置 button_async->fa_file=驱动文件 file;
③ 发生中断时, 有数据时, 驱动程序调用内核辅助函数发信号。
这个辅助函数名为 kill_fasync。

file 结构体: 应用程序打开设备节点时,会在内核VFS 层建立一个struct file 来描述一个文件的动态信息。
关于更多的file 结构体内容可以通过以下博文了解
字符设备的应用程序到驱动的调用流程
手把手教Linux驱动4-进程、文件描述符、file、inode关系详解

struct file {
	union {
		struct llist_node	fu_llist;
		struct rcu_head 	fu_rcuhead;
	} f_u;
	struct path		f_path;
	struct inode		*f_inode;	/* cached value */
	const struct file_operations	*f_op;

	/*
	 * Protects f_ep_links, f_flags.
	 * Must not be taken from IRQ context.
	 */
	spinlock_t		f_lock;
	atomic_long_t		f_count;
	unsigned int 		f_flags;
	fmode_t			f_mode;
	struct mutex		f_pos_lock;
	loff_t			f_pos;
	struct fown_struct	f_owner;
	const struct cred	*f_cred;
	struct file_ra_state	f_ra;

	u64			f_version;
#ifdef CONFIG_SECURITY
	void			*f_security;
#endif
	/* needed for tty driver, and maybe others */
	void			*private_data;

#ifdef CONFIG_EPOLL
	/* Used by fs/eventpoll.c to link all the hooks to this file */
	struct list_head	f_ep_links;
	struct list_head	f_tfile_llink;
#endif /* #ifdef CONFIG_EPOLL */
	struct address_space	*f_mapping;
}

程序流程:
在这里插入图片描述
app 需要调用的函数:
signal、fcntl
用法使用:man signal 查询

驱动编程
使用异步通知时,驱动程序的核心有 2:
① 提供对应的 drv_fasync 函数;
② 并在合适的时机发信号。

drv_fasync 函数很简单,调用 fasync_helper 函数就可以,如下:

static struct fasync_struct *button_async;
static int drv_fasync (int fd, struct file *filp, int on)
{
	return fasync_helper (fd, filp, on, &button_async);
}

调用 faync_helper,它会根据 FAYSNC 的值决定是否设置 button_async->fa_file=驱动文件 filp:
驱动文件 filp 结构体里面含有之前设置的 PID。
fasync_helper 函数会分配、构造一个 fasync_struct 结构体 button_async:
① 驱动文件的 flag 被设置为 FAYNC 时:
button_async->fa_file = filp; // filp 表示驱动程序文件,里面含有之前设置的 PID
② 驱动文件被设置为非 FASYNC 时:
button_async->fa_file = NULL;
以后想发送信号时,使用 button_async 作为参数就可以,它里面“可能”含有 PID。
什么时候发信号呢?在本例中,在 GPIO 中断服务程序中发信号。
怎么发信号呢?代码如下:
kill_fasync (&button_async, SIGIO, POLL_IN);
第 1 个参数: button_async->fa_file 非空时,可以从中得到 PID,表示发给哪一个 APP;
第 2 个参数表示发什么信号: SIGIO;
第 3 个参数表示为什么发信号: POLL_IN,有数据可以读了。 (APP 用不到这个参数)

内核定时器

在配置内核 make menuconfig 时,可以搜索选项 CONFIG_HZ 来查看内核的心跳。
在这里插入图片描述
默认 CONFIG_HZ=100,这意味着内核1s 内会发生一百次系统中断,也就是每隔10ms 产生一次系统中断。
10ms 一次心跳,暂时称之为一个滴答。
内核定时器就是依据Linux 系统中断实现的,在CONFIG_HZ=100 的情况下内核定时器的最小精度就是10ms。
在内核中有一个全局变量 jiffies 来统计系统启动至今总的滴答数。
另外内核中有个全局的宏 HZ 它的值等于CONFIG_HZ 设置的数。

在内核中使用定时器很简单,涉及这些函数(参考内核源码 include\linux\timer.h):
Linux 内核使用一个struct timer_list 来描述一个定时器:

struct timer_list {
	struct list_head entry;
	unsigned long expires; /* 定时器超时时间,单位是节拍数 */
	struct tvec_base *base;
	void (*function)(unsigned long); /* 定时处理函数 */
	unsigned long data; /* 要传递给 function 函数的参数 */
	int slack;
};

① setup_timer(timer, fn, data):
设置定时器,主要是初始化 timer_list 结构体,设置其中的函数、参数。
② void add_timer(struct timer_list *timer):
向内核添加定时器。 timer->expires 表示超时时间。
当超时时间到达,内核就会调用这个函数: timer->function(timer->data)。
③ int mod_timer(struct timer_list *timer, unsigned long expires):
修改定时器的超时时间,
它等同于: del_timer(timer); timer->expires = expires; add_timer(timer);
但是更加高效。
④ int del_timer(struct timer_list *timer):
删除定时器。

① 在 add_timer 之前,直接修改:
timer.expires = jiffies + xxx; // xxx 表示多少个滴答后超时,也就是 xxx*10ms
timer.expires = jiffies + 2*HZ; // HZ 等于 CONFIG_HZ, 2*HZ 就相当于 2 秒
② 在 add_timer 之后,使用 mod_timer 修改:
mod_timer(&timer, jiffies + xxx); // xxx 表示多少个滴答后超时,也就是 xxx*10ms
mod_timer(&timer, jiffies + 2*HZ); // HZ 等于 CONFIG_HZ, 2*HZ 就相当于 2 秒

利用内核定时器来处理按键抖动

在实际的按键操作中,可能会有机械抖动:
在这里插入图片描述
按下或松开一个按键,它的 GPIO 电平会反复变化,最后才稳定。一般是几十毫秒才会稳定。
如果不处理抖动的话,用户只操作一次按键,中断程序可能会上报多个数据。
怎么处理?
① 在按键中断程序中,可以循环判断几十亳秒,发现电平稳定之后再上报
② 使用定时器
显然第 1 种方法太耗时,违背“中断要尽快处理”的原则,你的系统会很卡。
怎么使用定时器?看下图:
在这里插入图片描述
核心在于:在 GPIO 中断中并不立刻记录按键值,而是修改定时器超时时间, 10ms 后再处理。
如果 10ms 内又发生了 GPIO 中断,那就认为是抖动,这时再次修改超时时间为 10ms。
只有 10ms 之内再无 GPIO 中断发生,那么定时器的函数才会被调用。
在定时器函数中记录按键值。

中断下半部 tasklet

在前面我们介绍过中断上半部、下半部。中断的处理有几个原则:
① 不能嵌套;
② 越快越好。
在处理当前中断时,即使发生了其他中断,其他中断也不会得到处理,所以中断的处理要越快越好。但
是某些中断要做的事情稍微耗时,这时可以把中断拆分为上半部、下半部。
在上半部处理紧急的事情,在上半部的处理过程中,中断是被禁止的;
在下半部处理耗时的事情,在下半部的处理过程中,中断是使能的。

内核函数

定义 tasklet
中断下半部使用结构体 tasklet_struct 来表示,它在内核源码 include\linux\interrupt.h 中定义:

struct tasklet_struct
{
	struct tasklet_struct *next;
	unsigned long state;
	atomic_t count;
	void (*func)(unsigned long);
	unsigned long data;
};

其中的 state 有 2 位:
① bit0 表示 TASKLET_STATE_SCHED
等于 1 时表示已经执行了 tasklet_schedule 把该 tasklet 放入队列了; tasklet_schedule 会判断该位,如果已经等于 1 那么它就不会再次把 tasklet 放入队列。
② bit1 表示 TASKLET_STATE_RUN
等于 1 时,表示正在运行 tasklet 中的 func 函数;函数执行完后内核会把该位清 0。

其中的 count 表示该 tasklet 是否使能:等于 0 表示使能了,非 0 表示被禁止了。对于 count 非 0 的tasklet,里面的 func 函数不会被执行。

使用中断下半部之前,要先实现一个 tasklet_struct 结构体,这可以用这 2 个宏来定义结构体:

#define DECLARE_TASKLET(name, func, data) \
			struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(0), func, data }
#define DECLARE_TASKLET_DISABLED(name, func, data) \
			struct tasklet_struct name = { NULL, 0, ATOMIC_INIT(1), func, data }

使用 DECLARE_TASKLET 定义的 tasklet 结构体,它是使能的;
使 用 DECLARE_TASKLET_DISABLED 定 义 的 tasklet 结 构 体 , 它 是 禁 止 的 ; 使 用 之 前 要 先 调 用tasklet_enable 使能它。
也可以使用函数来初始化 tasklet 结构体:

extern void tasklet_init(struct tasklet_struct *t,
						void (*func)(unsigned long), unsigned long data);

使能/禁止 tasklet

static inline void tasklet_enable(struct tasklet_struct *t);
static inline void tasklet_disable(struct tasklet_struct *t);

tasklet_enable 把 count 增加 1; tasklet_disable 把 count 减 1。

调度 tasklet

static inline void tasklet_schedule(struct tasklet_struct *t);

把 tasklet 放入链表,并且设置它的 TASKLET_STATE_SCHED 状态为 1。它需要放在中断上半部处理函数中调用。

** kill tasklet**

extern void tasklet_kill(struct tasklet_struct *t);

如果一个 tasklet 未被调度, tasklet_kill 会把它的 TASKLET_STATE_SCHED 状态清 0;
如果一个 tasklet 已被调度, tasklet_kill 会等待它执行完华,再把它的 TASKLET_STATE_SCHED 状态清 0。

通常在卸载驱动程序时调用 tasklet_kill。

tasklet 使用方法
先定义 tasklet,需要使用时(硬件中断处理函数)调用 tasklet_schedule,驱动卸载前调用 tasklet_kill。
tasklet_schedule 只是把 tasklet 放入内核队列,它的 func 函数会在软件中断的执行过程中被调用。

如下图,tasklet 在硬件中断处理函数执行完成后才执行,并不会直接在tasklet_schedule 函数中执行。
在这里插入图片描述
在这里插入图片描述

工作队列

前面讲的定时器、 下半部 tasklet, 它们都是在中断上下文中执行, 它们无法休眠。 当要处理更复杂的事情时, 往往更耗时。 这些更耗时的工作放在定时器或是tasklet中, 会使得系统很卡; 并且循环等待某件事情完成也太浪费 CPU 资源了。
如果使用线程来处理这些耗时的工作, 那就可以解决系统卡顿的问题: 因为线程可以休眠。

在内核中, 我们并不需要自己去创建线程, 可以使用“ 工作队列” (workqueue)。 内核初始化工作队列时, 就为它创建了内核线程。 以后我们要使用“ 工作队列”, 只需要把“ 工作” 放入“ 工作队列中”, 对应的内核线程就会取出“ 工作”, 执行里面的函数。
如下,可以使用ps 命令来查看系统上创建的工作线程。

root@OpenWrt:~# ps |grep "kworker"
    7 root         0 IW   [kworker/u8:0-ev]
   32 root         0 IW   [kworker/2:1-mm_]
   33 root         0 IW   [kworker/3:1-eve]
   85 root         0 IW<  [kworker/u9:0-hc]
  119 root         0 IW   [kworker/0:1-mm_]
  120 root         0 IW   [kworker/1:1-mm_]
  130 root         0 IW   [kworker/u8:2-ev]
  147 root         0 IW   [kworker/3:2-mm_]
  148 root         0 IW   [kworker/0:2-eve]

工作队列的应用场合: 要做的事情比较耗时, 甚至可能需要休眠, 那么可以使用工作队列。
缺点: 多个工作(函数)是在某个内核线程中依序执行的, 前面函数执行很慢, 就会影响到后面的函数。在多 CPU 的系统下, 一个工作队列可以有多个内核线程, 可以在一定程度上缓解这个问题。

Linux 内核用work_struct 来描述一个工作,用workqueue_struct 描述一个工作队列,kworker 是工作线程。
工作队列:相当于一个缓冲区、一条流水线,用来存放待处理的工作。
kworker 线程:kworker 线程就是处理工作的人,有工作是它会被唤醒,没有工作时它会陷入休眠。

内核函数

内核线程、 工作队列(workqueue)都由内核创建了, 我们只是使用。 使用的核心是一个 struct work_struct 结构体, 定义如下:

在这里插入图片描述
使用工作队列时, 步骤如下:
① 构造一个 work_struct 结构体, 里面有函数;
② 把这个 work_struct 结构体放入工作队列, 内核线程就会运行 work 中的函数。

工作队列相关的结构体、函数等定义在 include\linux\workqueue.h,在编写驱动时可以参考头文件来加深对内核代码的印象。

定义 work
#define DECLARE_WORK(n, f) \
		struct work_struct n = __WORK_INITIALIZER(n, f)
		
#define DECLARE_DELAYED_WORK(n, f) \
		struct delayed_work n = __DELAYED_WORK_INITIALIZER(n, f, 0)

第 1 个宏是用来定义一个 work_struct 结构体, 要指定它的函数。
第 2 个宏用来定义一个 delayed_work 结构体, 也要指定它的函数。 所以“ delayed”, 意思就是说要让它运行时, 可以指定: 某段时间之后你再执行。

如果要在代码中初始化 work_struct 结构体, 可以使用下面的宏:#define INIT_WORK(_work, _func)
他们的区别在于DECLARE_WORK 帮我们定义好了struct work_struct 并绑定func,而INIT_WORK 需要我们自己定义struct work_struct。

#define INIT_WORK(w, f) \
	do { \
		(w)->data = 0; \
		(w)->func = (f); \
	} while (0)
使用 work: schedule_work

调用 schedule_work 时, 就会把 work_struct 结构体放入队列中, 并唤醒对应的内核工作线程。 工作线程从队列里把 work_struct 结构体取出来, 执行里面的函数。

inline bool schedule_work(struct work_struct *work)
延时工作队列使用:mod_delayed_work

延时工作队列与定时器有一些类似,调用mod_delayed_work 可以在一段事件后执行work->func 函数中的内容。
内核的按键驱动(gpio-keys)就利用这个原理来消除按键抖动,在中断函数中调用mod_delayed_work。 第一次触发中断激活延时工作,如果在10ms 内再次发生中断则重新修改延时时间,依次类推直到最后一次中断10ms 后执行延时工作任务。

phy 状态机利用这个机制在work->func 中调用mod_delayed_work 来不停的循环工作任务,读取phy 的寄存器状态。

static inline bool mod_delayed_work(struct workqueue_struct *wq,	//workqueue_struct 可以指定系统工作队列systemd_wq
				    struct delayed_work *dwork,
				    unsigned long delay)

在这里插入图片描述

内存管理

#include <stdio.h>
#include <stdlib.h>

int a;

void main(int argc,char **argv)
{
    a = atoi(argv[1]);
    printf("ptr %x ,value %d\n",&a,a);

    sleep(20);
}

先用一个简单的应用程序来测试一个现象:
先后执行两次程序,打印变量a的地址和a的值,执行第二个程序时第一个还在睡眠中。

yz@yanzhi-13:~/zjh/test_source$ ./hello 12 &
[1] 15933
yz@yanzhi-13:~/zjh/test_source$ ptr 601054 ,value 12
yz@yanzhi-13:~/zjh/test_source$
yz@yanzhi-13:~/zjh/test_source$ ./hello 123 &
[2] 15950
yz@yanzhi-13:~/zjh/test_source$ ptr 601054 ,value 123
yz@yanzhi-13:~/zjh/test_source$
yz@yanzhi-13:~/zjh/test_source$ ps |grep hello
15933 pts/14   00:00:00 hello
15950 pts/14   00:00:00 hello

应用进程的地址空间结构
发现一个问题两个进程中a 的地址是一样的,但是它们的值却不同,按道理来说为了保存不同的值 变量的地址必然是不一样的,这是怎么回事?
这里要引入虚拟地址的概念: CPU 发出的地址是虚拟地址, 它经过 MMU(Memory Manage Unit, 内存管理单元)映射到物理地址上, 对于不同进程的同一个虚拟地址, MMU 会把它们映射到不同的物理地址。
如下图:
在这里插入图片描述

每一个 APP 在内核里都有一个 task_struct, 这个结构体中保存有内存信息: mm_struct 。 而虚拟地址、物理地址的映射关系保存在页目录表中, 如下图所示:
在这里插入图片描述
解析如下:
① 每个 APP 在内核中都有一个 task_struct 结构体, 它用来描述一个进程;
② 每个 APP 都要占据内存, 在 task_struct 中用 mm_struct 来管理进程占用的内存;
内存有虚拟地址、 物理地址, mm_struct 中用 mmap 来描述虚拟地址, 用 pgd 来描述虚拟地址与物理地址之间的映射关系。
注意: pgd, Page Global Directory, 页目录。

③ 每个 APP 都有一系列的 VMA: virtual memory
比如 APP 含有代码段、 数据段、 BSS 段、 栈等等, 还有共享库。 这些单元会保存在内存里, 它们的地址空间不同, 权限不同(代码段是只读的可运行的、 数据段可读可写), 内核用一系列的 vm_area_struct 来描述它们。
vm_area_struct 中的 vm_start、 vm_end 是虚拟地址。
④ vm_area_struct 中虚拟地址如何映射到物理地址去?
每一个 APP 的虚拟地址可能相同, 物理地址不相同, 这些对应关系保存在 pgd 中。

ARM 架构内存映射简介

ARM 架构支持一级页表映射, 也就是说 MMU 根据 CPU 发来的虚拟地址可以找到第 1 个页表, 从第 1 个页表里就可以知道这个虚拟地址对应的物理地址。 一级页表里地址映射的最小单位是 1M。

ARM 架构还支持二级页表映射, 也就是说 MMU 根据 CPU 发来的虚拟地址先找到第 1 个页表, 从第 1 个页表里就可以知道第 2 级页表在哪里; 再取出第 2 级页表, 从第 2 个页表里才能确定这个虚拟地址对应的物理地址。 二级页表地址映射的最小单位有 4K、 1K, Linux 使用 4K。

一级页表项里的内容, 决定了它是指向一块物理内存, 还是指问二级页表, 如下图:
在这里插入图片描述

一级页表映射过程

一线页表中每一个表项用来设置 1M 的空间, 对于 32 位的系统, 虚拟地址空间有 4G, 4G/1M=4096。 所以一级页表要映射整个 4G 空间的话, 需要 4096 个页表项。

第 0 个页表项用来表示虚拟地址第 0 个 1M(虚拟地址为 0~0xFFFFF)对应哪一块物理内存, 并且有一些权限设置;
第 1 个页表项用来表示虚拟地址第 1 个 1M(虚拟地址为 0x100000~0x1FFFFF)对应哪一块物理内存, 并且有一些权限设置;
依次类推。
使用一级页表时, 先在内存里设置好各个页表项, 然后把页表基地址告诉 MMU, 就可以启动 MMU 了。
以下图为例介绍地址映射过程:
① CPU 发出虚拟地址 vaddr, 假设为 0x12345678
② MMU 根据 vaddr[31:20]找到一级页表项:
虚拟地址 0x12345678 是虚拟地址空间里第 0x123 个 1M, 所以找到页表里第 0x123 项, 根据此项内容知道它是一个段页表项。
段内偏移是 0x45678。
③ 从这个表项里取出物理基地址: Section Base Address, 假设是 0x81000000
④ 物理基地址加上段内偏移得到: 0x81045678
所以 CPU 要访问虚拟地址 0x12345678 时, 实际上访问的是 0x81045678 的物理地址。
在这里插入图片描述

二级页表映射过程

首先设置好一级页表、 二级页表, 并且把一级页表的首地址告诉 MMU。
以下图为例介绍地址映射过程:
① CPU 发出虚拟地址 vaddr, 假设为 0x12345678
② MMU 根据 vaddr[31:20]找到一级页表项:
虚拟地址 0x12345678 是虚拟地址空间里第 0x123 个 1M, 所以找到页表里第 0x123 项。 根据此项内容知道它是一个二级页表项。
③ 从这个表项里取出地址, 假设是 address, 这表示的是二级页表项的物理地址;
④ vaddr[19:12]表示的是二级页表项中的索引 index 即 0x45, 在二级页表项中找到第 0x45 项;
⑤ 二级页表项格式如下:
在这里插入图片描述
里面含有这 4K 或 1K 物理空间的基地址 page base addr, 假设是 0x81889000:
它跟 vaddr[11:0]组合得到物理地址: 0x81889000 + 0x678 = 0x81889678。
所以 CPU 要访问虚拟地址 0x12345678 时, 实际上访问的是 0x81889678 的物理地址。
在这里插入图片描述

多次设备的驱动编写

在Linux 系统上我们经常看到有一些设备它有许多次设备,比如串口,它们的设备名是相同的 设备名+序号,并且拥有相同的主设备号,递增的次设备号。
在这里插入图片描述
那么这些在驱动中是如何设置的:
设备名很简单,就是个名字,这些串口都是属于同一个驱动的,我们也可以在驱动中自定义一个设备的名字。
序号怎么得到呢?
在设备树中有这样别名的节点,把某个串口的名字定义成 serialx,网口的名字定义成ethernetx,然后再驱动中我们可以调用如下的函数来获取到别名后面的序号:
ret 就是获取到的序号;下方代码来自imx6ull 的串口驱动imx.c
在这里插入图片描述
在这里插入图片描述
设备号怎么定义:主设备号都是一样的,像普通驱动一样定义一个主设备号;然后指定一个基础的次设备号,加上获取到的序号就可以得到每个次设备唯一递增的次设备号。
然后我们需要创建一个类和为每一个串口创建一个设备节点,设备节点与struct device 有关,需要为每个串口创建一个device 并与设备号绑定,将前面的名字与序号组合起来赋值给device->name,最后注册device。
在这里插入图片描述
最后为了能通过文件IO 访问设备,要为每个设备创建cdev 并注册。
在这里插入图片描述

dma api使用

以下内容来自 Linux设备驱动开发详解_宋宝华

dma 掩码设置

设备并不一定能在所有的内存地址上执行 DMA 操作,在这种情况下应该通过下列函数执行 DMA 地址掩码:
int dma_set_mask(struct device *dev, u64 mask);
例如,对于只能在 24 位地址上执行 DMA 操作的设备而言,就应该调用 dma_set_mask (dev, 0xffffff)。

一致性dma 缓冲区申请

DMA 映射包括两个方面的工作:分配一片 DMA 缓冲区为这片缓冲区产生设备可访问的地址
同时,DMA 映射也必须考虑 Cache 一致性问题。内核中提供了以下函数用于分配一个 DMA 一致性的内存区域:
void * dma_alloc_coherent(struct device *dev, size_t size, dma_addr_t *handle, gfp_t gfp);
返回值:申请到的内存缓冲区虚拟地址。(cpu 使用虚拟地址、dma 设备使用总线地址)
handle:保存着缓冲区设备可用的dma总线地址。

dma_alloc_coherent()申请一片 DMA 缓冲区,进行地址映射并保证该缓冲区的Cache 一致性。
与 dma_alloc_coherent()对应的释放函数为:
void dma_free_coherent(struct device *dev, size_t size, void *cpu_addr, dma_addr_t handle);

此外,Linux 内核还提供了 PCI 设备申请 DMA 缓冲区的函数 pci_alloc_consistent(),
其原型为:
void * pci_alloc_consistent(struct pci_dev *pdev, size_t size,dma_addr_t *dma_addrp);
对应的释放函数为 pci_free_consistent(),其原型为:
void pci_free_consistent(struct pci_dev *pdev, size_t size, void*cpu_addr, dma_addr_t dma_addr);

dma 流式映射与使用

相对于一致性 DMA 映射而言,流式 DMA 映射的接口较为复杂。
对于单个已经分配的缓冲区而言,使用 dma_map_single()可实现流式 DMA 映射,该函数原型为:
dma_addr_t dma_map_single(struct device *dev, void *buffer, size_t size, enum dma_data_direction direction);
dma_map_single 用于将一段已分配的内存映射为dma 设备可用的dma 内存,如果映射成功,返回的是总线地址,否则,返回 NULL。
返回值:缓冲区对应的dma 总线地址。
buffer:需要映射缓冲区的虚拟地址。
size:缓冲区长度。
direction:方向。可能的值包括 DMA_TO_DEVICE 、 DMA_FROM_DEVICE 、DMA_BIDIRECTIONAL 和 DMA_NONE。

dma_map_single()的反函数为 dma_unmap_single(),原型是:
void dma_unmap_single(struct device *dev, dma_addr_t dma_addr, size_t size, enum dma_data_direction direction);

通常情况下,设备驱动不应该访问 unmap 的流式 DMA 缓冲区,如果一定要这么做,可先使用如下函数获得 DMA 缓冲区的拥有权:
void dma_sync_single_for_cpu(struct device *dev, dma_handle_t bus_addr, size_t size, enum dma_data_direction direction);
在驱动访问完 DMA 缓冲区后,应该将其所有权返还给设备,通过如下函数完成:
void dma_sync_single_for_device(struct device *dev, dma_handle_t bus_addr, size_t size, enum dma_data_direction direction);

  • 3
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Linux驱动是连接硬件和操作系统之间的重要桥梁,负责将硬件设备的功能映射为操作系统所能识别和使用的接口。学习和掌握Linux驱动基础知识对于深入理解和应用Linux操作系统具有重要意义。 首先,了解Linux设备模型是学习Linux驱动基础Linux将硬件设备抽象为设备结构体,并将其组织成设备树形结构。理解设备树的组织方式可以帮助我们理解设备的层级关系,加深对设备驱动的认识。 其次,掌握设备驱动的注册和初始化过程是理解Linux驱动基础的关键。驱动的注册需要通过构造设备结构体并调用相应的函数实现,这样操作系统才能识别和加载驱动。在驱动注册之后,需要进行设备的初始化,包括设置设备所需的资源、注册中断处理函数等。 理解设备操作方法和中断处理是考察Linux驱动基础的重点。设备操作方法包括打开设备、关闭设备、读取设备和写入设备等功能。通过掌握设备操作方法的实现,我们可以对设备进行控制和读写。中断处理是设备与操作系统交互的重要方式,学习中断处理可以帮助我们理解设备与操作系统之间的异步通信机制。 最后,理解设备的文件系统接口也是考察Linux驱动基础的重要方面。Linux将设备抽象成文件,并通过文件系统接口来进行设备的读写操作。掌握设备文件的创建和管理,了解如何通过文件系统接口进行设备的读写,对于理解驱动的应用场景以及与用户空间的交互具有关键作用。 总之,考察Linux驱动基础需要理解Linux设备模型、设备驱动的注册和初始化过程、设备操作方法和中断处理、设备的文件系统接口等多个方面的知识。只有全面掌握这些基础知识,才能深入理解和应用Linux驱动的原理和技术。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值