Linux 驱动基础
在一些情况下,我们要动态的改变驱动中某个变量的值,那么就可以在注册时给驱动模块传递参数。
给驱动模块中传递参数,需要定义好接受参数值的全局变量,并调用 来引用它,具体示例如下:
传递一个int 型参数
在加载模块的时候,传递参数:
传递string 参数
在加载模块的时候,传递参数:
(1)什么是符号?
这里的符号主要指的是全局变量和函数。
(2)为什么要导出符号?
Linux内核采用的是以模块化形式管理内核代码。内核中的每个模块相互之间是相互独立的,也就是说A模块的全局变量和函数,B模块是无法访问的。
如果一个模块已经以静态的方式编译进的内核,那么它导出的符号就会出现在全局的内核符号表中(内核源码根目录下的Module.symvers 文件)。
当我们想在A 模块中引用动态插入(obj-m = xxx.o) 的B 模块定义的全局变量或者函数时就需要导出。
如下图,内核中已经定义了函数 irq_set_irqchip_state,而且我的代码中已经包含了它的头文件,但还是报错未定义,这就是因为这个符号未导出,在动态加载的.ko 中不认识它。
解决方法:EXPORT_SYMBOL(irq_set_irqchip_state);

(3)如何导出符号?
Linux内核给我们提供了两个宏:
(4)模块编译时,如何寻找使用的符号?
a.在本模块中符号表中,寻找符号(函数或变量实现)
b.在内核全局符号表中寻找
c.在模块目录下的Module.symvers文件中寻找
调试流程:在B模块中声明全局变量和函数,然后用EXPORT_SYMBOL 导出,编译完成后会在当前模块目录下生成Module.symvers,将其拷贝到A 模块目录,然后在A 模块中调用extern 修饰导出的变量和函数;
模块B 代码
模块A 代码
在Linux中,无论是应用程序或是驱动程序,访问的都是虚拟地址,所以在访问一个物理寄存器地址时,首先要进行映射。
将一个寄存器物理地址映射为虚拟地址可以使用 函数,反映射 。
映射完成后可以用以下函数访问寄存器的值:
注释:iomem 映射与普通内存映射的区别,iomem 主要用于寄存器,寄存器每次值的改变都有独特的意义,比如写0,写1 可能是让某个gpio输出高低。而普通内存一般只用来存放数据。
定义指向寄存器虚拟地址的指针时可以用volatile 来修饰
volatile的作用:volatile
1.防止编译器的优化
2.每次直接从内存中读取
代码示例:
当应用程序必须等待某个事件发生,比如必须等待按键被按下时, 可以使用“休眠-唤醒”机制。 效果类似于阻塞IO。
等待队列
使用宏DECLARE_WAIT_QUEUE_HEAD 定义一个等待队列:
相关函数定义于:
休眠函数
用下列函数可以让一个线程挂起:

唤醒函数
使用下列函数可以唤醒线程:

使用休眠-唤醒的方式等待某个事件发生时,有一个缺点: 等待的时间可能很久。我们可以加上一个超时时间,这时就可以使用 poll 机制。
poll机制: 应用层调用poll 函数查询是否有数据可读 或 有空间可写。如果可读写,直接返回可读写状态;没有数据可读或不可写,那么进入休眠状态,直到可读写或超时后返回。
驱动编程 使用 poll 机制时,驱动程序的核心就是提供对应的 drv_poll (file_operations->poll)函数。应用层调用 poll -> sys_poll -> drv_poll;
在 drv_poll 函数中要做 2 件事:
① 把当前线程挂入队列 wq: poll_wait
我们需要在drv_poll 函数中调用poll_wait(),把线程挂入等待队列。
应用程序调用一次 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 把它们一起返回。
wait_address:等待队列。
驱动中唤醒线程依旧使用 wake_up_interruptible 等函数。
应用编程 可以使用 命令查看poll 函数使用方法。
fds:
fd:open 返回的句柄。
envents:需要查询的事件,读时 envents = POLLIN,写时 envents = POLLOUT。
nfds:传入poll 的fds 数量。
timeout:超时时间,ms。
返回值:0,不可读写;其余参考下表。

使用休眠唤醒或poll 时都有一个缺点,应用程序需要陷入休眠状态等待数据的到来;如果app 在没有数据的期间还想做其它事情怎么办?可以使用异步通知。
什么是异步通知?
你在旁边等着, 眼睛盯着店员, 生怕别人插队, 他一做好你就知道: 你是主动等待他做好, 这叫“ 同步”。
你付钱后就去玩手机了, 店员做好后他会打电话告诉你: 你是被动获得结果, 这叫“ 异步”。
当前要实现的场景是:
app 在处理其他事,比如不停的打印:printf(“1111111111
”);
按键按下时,驱动中的中断获取到键值,并使用异步通知告诉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关系详解
程序流程:

app 需要调用的函数:
signal、fcntl
用法使用: 查询
驱动编程
使用异步通知时,驱动程序的核心有 2:
① 提供对应的 drv_fasync 函数;
② 并在合适的时机发信号。
drv_fasync 函数很简单,调用 fasync_helper 函数就可以,如下:
调用 faync_helper,它会根据 FAYSNC 的值决定是否设置 button_async->fa_file=驱动文件 filp:
驱动文件 filp 结构体里面含有之前设置的 PID。
fasync_helper 函数会分配、构造一个 fasync_struct 结构体 button_async:
① 驱动文件的 flag 被设置为 FAYNC 时:
② 驱动文件被设置为非 FASYNC 时:
以后想发送信号时,使用 button_async 作为参数就可以,它里面“可能”含有 PID。
什么时候发信号呢?在本例中,在 GPIO 中断服务程序中发信号。
怎么发信号呢?代码如下:
第 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 设置的数。
在内核中使用定时器很简单,涉及这些函数(参考内核源码 includelinux imer.h):
Linux 内核使用一个struct timer_list 来描述一个定时器:
① 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 之前,直接修改:
② 在 add_timer 之后,使用 mod_timer 修改:
利用内核定时器来处理按键抖动 在实际的按键操作中,可能会有机械抖动:

按下或松开一个按键,它的 GPIO 电平会反复变化,最后才稳定。一般是几十毫秒才会稳定。
如果不处理抖动的话,用户只操作一次按键,中断程序可能会上报多个数据。
怎么处理?
① 在按键中断程序中,可以循环判断几十亳秒,发现电平稳定之后再上报
② 使用定时器
显然第 1 种方法太耗时,违背“中断要尽快处理”的原则,你的系统会很卡。
怎么使用定时器?看下图:

核心在于:在 GPIO 中断中并不立刻记录按键值,而是修改定时器超时时间, 10ms 后再处理。
如果 10ms 内又发生了 GPIO 中断,那就认为是抖动,这时再次修改超时时间为 10ms。
只有 10ms 之内再无 GPIO 中断发生,那么定时器的函数才会被调用。
在定时器函数中记录按键值。
在前面我们介绍过中断上半部、下半部。中断的处理有几个原则:
① 不能嵌套;
② 越快越好。
在处理当前中断时,即使发生了其他中断,其他中断也不会得到处理,所以中断的处理要越快越好。但
是某些中断要做的事情稍微耗时,这时可以把中断拆分为上半部、下半部。
在上半部处理紧急的事情,在上半部的处理过程中,中断是被禁止的;
在下半部处理耗时的事情,在下半部的处理过程中,中断是使能的。
内核函数 定义 tasklet
中断下半部使用结构体 tasklet_struct 来表示,它在内核源码 includelinuxinterrupt.h 中定义:
其中的 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 个宏来定义结构体:
使用 DECLARE_TASKLET 定义的 tasklet 结构体,它是使能的;
使 用 DECLARE_TASKLET_DISABLED 定 义 的 tasklet 结 构 体 , 它 是 禁 止 的 ; 使 用 之 前 要 先 调 用tasklet_enable 使能它。
也可以使用函数来初始化 tasklet 结构体:
使能/禁止 tasklet
tasklet_enable 把 count 增加 1; tasklet_disable 把 count 减 1。
调度 tasklet
把 tasklet 放入链表,并且设置它的 TASKLET_STATE_SCHED 状态为 1。它需要放在中断上半部处理函数中调用。
** kill tasklet**
如果一个 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 命令来查看系统上创建的工作线程。
工作队列的应用场合: 要做的事情比较耗时, 甚至可能需要休眠, 那么可以使用工作队列。
缺点: 多个工作(函数)是在某个内核线程中依序执行的, 前面函数执行很慢, 就会影响到后面的函数。在多 CPU 的系统下, 一个工作队列可以有多个内核线程, 可以在一定程度上缓解这个问题。
Linux 内核用work_struct 来描述一个工作,用workqueue_struct 描述一个工作队列,kworker 是工作线程。
工作队列:相当于一个缓冲区、一条流水线,用来存放待处理的工作。
kworker 线程:kworker 线程就是处理工作的人,有工作是它会被唤醒,没有工作时它会陷入休眠。
内核函数 内核线程、 工作队列(workqueue)都由内核创建了, 我们只是使用。 使用的核心是一个 结构体, 定义如下:

使用工作队列时, 步骤如下:
① 构造一个 work_struct 结构体, 里面有函数;
② 把这个 work_struct 结构体放入工作队列, 内核线程就会运行 work 中的函数。
定义 work 第 1 个宏是用来定义一个 work_struct 结构体, 要指定它的函数。
第 2 个宏用来定义一个 delayed_work 结构体, 也要指定它的函数。 所以“ delayed”, 意思就是说要让它运行时, 可以指定: 某段时间之后你再执行。
如果要在代码中初始化 work_struct 结构体, 可以使用下面的宏:
他们的区别在于DECLARE_WORK 帮我们定义好了struct work_struct 并绑定func,而INIT_WORK 需要我们自己定义struct work_struct。
使用 work: schedule_work 调用 schedule_work 时, 就会把 work_struct 结构体放入队列中, 并唤醒对应的内核工作线程。 工作线程从队列里把 work_struct 结构体取出来, 执行里面的函数。
延时工作队列使用:mod_delayed_work 延时工作队列与定时器有一些类似,调用mod_delayed_work 可以在一段事件后执行work->func 函数中的内容。
内核的按键驱动(gpio-keys)就利用这个原理来消除按键抖动,在中断函数中调用。 第一次触发中断激活延时工作,如果在10ms 内再次发生中断则重新修改延时时间,依次类推直到最后一次中断10ms 后执行延时工作任务。
phy 状态机利用这个机制在work->func 中调用mod_delayed_work 来不停的循环工作任务,读取phy 的寄存器状态。

先用一个简单的应用程序来测试一个现象:
先后执行两次程序,打印变量a的地址和a的值,执行第二个程序时第一个还在睡眠中。
应用进程的地址空间结构
发现一个问题两个进程中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 并注册。
