第二十四章 IO 模型引入
24.1 IO 的概念
IO是计算机系统内外数据交换过程,冯·诺依曼架构下各部件协同工作。用户输入,CPU处理,结果输出。磁盘IO是内存与磁盘数据交换的核心,对信息交互至关重要。
24.2 IO 执行过程
Linux操作系统管理资源,调度进程,应用程序在用户空间运行,通过API与硬件交互。IO操作需从用户空间切换到内核空间,包括IO调用(应用程序请求)和IO执行(内核完成)。
完整IO过程分三步:用户空间应用发起系统调用,内核准备数据到缓冲区,最后将内核缓冲区数据拷贝到用户进程缓冲区。
24.3 IO 模型的分类
在数据密集型任务中,如从磁盘读取100M数据并处理,传统同步模式效率低下。为提高效率,可使用IO编程模型,包括阻塞IO、非阻塞IO、信号驱动IO、IO多路复用和异步IO。前四种属同步IO,需等待操作完成才返回;异步IO则通过回调等方式在完成后通知。
24.3.1 阻塞 IO
在阻塞IO中,进程发起IO操作(如read)时,会进入等待状态,直到内核空间的数据准备就绪。此期间,进程无法执行其他任务,只能等待。一旦数据就绪,内核将数据从内核空间拷贝到用户空间,然后进程继续执行后续操作。
在C语言中,scanf()函数是阻塞IO的一个典型例子。当调用scanf()等待用户输入时,程序会暂停执行,直到用户输入数据并按下回车键。
#include <stdio.h>
int main(void) {
int i;
scanf("%d", &i); // 阻塞等待用户输入
printf("i = %d\n", i); // 输入后继续执行
return 0;
}
阻塞IO在数据未就绪时阻塞进程,虽然能确保及时响应数据,但在等待期间无法处理其他任务,效率低下。
24.3.2 非阻塞 IO
与阻塞IO不同,非阻塞IO在发起IO操作时,如果内核中的数据未准备好,内核会立即返回一个错误(如EAGAIN或EWOULDBLOCK),而不是让进程进入阻塞状态。
这样,进程可以继续执行其他任务,而无需等待IO操作完成。当内核中的数据准备就绪时,后续的IO操作会立即返回结果。
在C语言中,要实现非阻塞IO,通常需要设置文件描述符为非阻塞模式。这可以通过使用fcntl()函数或ioctl()函数来完成。
然而,标准C库中的 scanf()函数并不直接支持非阻塞IO。为了演示非阻塞IO的概念,我们可以考虑使用套接字(socket)编程中的非阻塞模式,但这里为了简化,我将仅描述如何设置文件描述符为非阻塞,并给出一个简化的示例思路。
#include <fcntl.h>
#include <unistd.h>
#include <errno.h>
#include <stdio.h>
// 假设fd是一个已经打开的文件描述符
int fd = ...; // 这里应该是通过某种方式(如open)获得的文件描述符
// 将文件描述符设置为非阻塞模式
int flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) {
perror("fcntl");
// 错误处理
}
flags |= O_NONBLOCK; // 设置非阻塞标志
if (fcntl(fd, F_SETFL, flags) == -1) {
perror("fcntl");
// 错误处理
}
// 接下来,你可以尝试进行非阻塞的read操作
// 注意:这里需要处理EAGAIN或EWOULDBLOCK错误
ssize_t bytesRead;
char buffer[1024];
while (1) {
bytesRead = read(fd, buffer, sizeof(buffer));
if (bytesRead == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// 数据未就绪,继续执行其他任务或稍后再试
continue;
}
// 其他错误处理
perror("read");
break;
}
// 处理读取到的数据
// ...
}
24.3.3 IO 多路复用
IO多路复用技术,以 select()函数为例,允许单个进程或线程同时监视多个文件描述符(如网络连接、文件等),以等待这些描述符上的IO事件(如可读、可写)。
select()函数通过一次系统调用实现了对多个IO源的等待,提高了程序的效率和响应速度。
select()函数接收一组文件描述符,并设置超时时间。内核会检查这些描述符,等待至少一个描述符上的IO事件发生或超时。一旦有事件发生或超时,select()返回,进程/线程随后遍历所有描述符以确定哪些描述符就绪。
select()能监视的文件描述符数量有限(通常是1024),限制在大规模并发连接中的应用。在文件描述符数量较多时,需要遍历所有描述符来检查哪些就绪,这可能导致性能下降。此外,每次调用select()时,都需要将文件描述符集合从用户空间拷贝到内核空间,增加了开销。
替代方案:
poll():
与select()类似,但去除了文件描述符数量的限制(尽管实际上仍受系统资源限制),但性能问题依旧存在。
epoll()(仅Linux):
针对select()和poll()的缺点进行了优化,提供了更高的性能和效率,支持边缘触发和水平触发两种模式,并且避免了不必要的文件描述符遍历。
epoll在内核中为每个关注的文件描述符(FD)维护了一个callback函数。这些callback函数会在对应的FD变得“活跃”(即有I/O事件发生时)被自动调用。
这种机制确保了epoll只关注那些真正有事件发生的FD,从而避免了像select或poll那样需要遍历所有FD集合的低效做法。
epoll通过内核与用户空间共享一个事件表来减少系统调用的次数,当文件描述符的状态发生变化时,内核将这个事件通知给用户空间,用户空间再根据事件类型进行相应的处理。
边缘触发:
当状态从未就绪变为就绪时,会触发一个事件。这意味着如果数据没有一次性读取完毕,并且没有更多的数据到达,则不会再次触发事件,直到有新的数据到达。
水平触发:
只要状态保持就绪,就会持续触发事件。这意味着每次调用 epoll_wait() 时,只要文件描述符处于就绪状态,就会返回该描述符,直到数据被完全处理。
24.3.4 信号驱动
信号驱动IO 模型通过信号机制来管理I/O操作。在这种模型中,进程会针对某个文件描述符启用信号驱动模式,并指定一个信号处理函数。
当该文件描述符上的I/O事件发生时,内核会向该进程发送一个SIGIO信号。这个信号会触发指定的信号处理函数,从而在该函数中处理I/O事件。
简单来说,信号驱动IO的工作流程如下:
启用信号驱动:
进程通过设置文件描述符的特定选项(如使用fcntl()系统调用和F_SETOWN、F_SETSIG或F_SETFL等命令),为该文件描述符启用信号驱动IO,并指定信号处理函数。
等待信号:
进程继续执行其他任务,同时等待SIGIO信号的到来。这个等待过程是隐式的,因为进程在执行过程中可能会被内核打断以处理信号。
信号处理:
当文件描述符上有I/O事件发生时,内核向进程发送SIGIO信号。该信号被捕获,并调用之前注册的信号处理函数。在信号处理函数中,进程可以安全地读取或写入数据,因为信号处理函数是在安全的上下文中执行的。
读取数据:
在信号处理函数中,进程可以通过正常的I/O系统调用来读取或处理数据。完成处理后,进程可以继续其正常流程。
24.3.5 异步 IO
aio_read 函数常常用于异步 IO,当进程使用 aio_read 读取数据时,如果数据尚未准备就绪就立即返回,不会阻塞。若数据准备就绪就会把数据从内核空间拷贝到用户空间的缓冲区中,然后执行定义好的回调函数对接收到的数据进行处理。
第二十五章 阻塞 IO
25.1 什么是等待队列
在Linux内核中,等待队列是实现进程阻塞和唤醒的一种机制,它基于双向循环链表构建。
等待队列主要由两部分组成:等待队列头和等待队列项。
等待队列头:
表示一个等待队列的头部,用于管理该队列中的所有等待项。
它包含一个自旋锁用于保护队列的并发访问,以及一个链表头用于链接队列中所有等待项。
/*等待队列头*/
typedef struct {
spinlock_t lock; // 自旋锁,保护队列的并发访问
struct list_head task_list; // 链表头,链接所有等待项
} wait_queue_head_t;
等待队列项:
表示等待队列中的一个元素,即一个具体的等待项。
它包含一个标志、一个私有数据指针、一个指向等待队列函数的指针,以及一个链表项。
/*等待队列项*/
typedef struct {
unsigned int flags; // 等待项的标志
void *private; // 私有数据指针
wait_queue_func_t func; // 指向等待队列函数的指针(通常用于唤醒时的回调)
struct list_head task_list; // 链表项,链接到等待队列中
} wait_queue_t;
25.2 等待队列 API 函数
25.2.1. 等待队列头的初始化
静态初始化:
使用 DECLARE_WAIT_QUEUE_HEAD 宏定义声明并初始化等待队列头,全局变量。
DECLARE_WAIT_QUEUE_HEAD(my_wait_queue);
动态初始化:
使用 init_waitqueue_head 宏初始化一个已经声明的等待队列头变量。
wait_queue_head_t my_wait_queue;
init_waitqueue_head(&my_wait_queue);
25.2.2. 创建等待队列项
使用 DECLARE_WAITQUEUE 宏为当前进程创建一个等待队列项。
DECLARE_WAITQUEUE(my_wait, current);
25.2.3. 添加/删除队列项
添加队列项:
使用 add_wait_queue() 函数将等待队列项添加到等待队列中。
add_wait_queue(&my_wait_queue, &my_wait);
25.2.4. 等待事件
不可中断等待:使用 wait_event 宏让进程进入不可中断的睡眠状态,直到条件满足。
wait_event(my_wait_queue, condition);
可中断等待:使用 wait_event_interruptible 宏让进程进入可中断的睡眠状态,直到条件满足或被信号唤醒。
wait_event_interruptible(my_wait_queue, condition);
带超时的等待:
wait_event_timeout 宏 和 wait_event_interruptible_timeout 宏允许设置超时时间。
long ret = wait_event_interruptible_timeout(my_wait_queue, condition, timeout);
25.2.5. 唤醒等待队列
唤醒所有进程:
使用 wake_up() 唤醒等待队列中的所有进程。
wake_up(&my_wait_queue);
唤醒可中断进程:
使用 wake_up_interruptible() 只唤醒等待队列中处于可中断睡眠状态的进程。
wake_up_interruptible(&my_wait_queue);
26.3 等待队列使用方法
步骤一:
初始化等待队列头,并将条件置成假(condition=0)。
DECLARE_WAIT_QUEUE_HEAD(read_wq); //定义并初始化等待队列头
步骤二:
在需要阻塞的地方调用 wait_event(),使进程进入休眠状态。
/**从设备读取数据*/
static ssize_t cdev_test_read(struct file *file,//文件描述符
char __user *buf, //用户缓冲区
size_t size, //读取的字节
、 loff_t *off) //偏移量
{
struct device_test *test_dev=(struct device_test *)file->private_data;
//可中断的阻塞等待,使进程进入休眠态
wait_event_interruptible(read_wq,test_dev->flag);
// copy_to_user:内核空间向用户空间传数据
if (copy_to_user(buf, test_dev->kbuf, strlen( test_dev->kbuf)) != 0)
{
printk("copy_to_user error\r\n");
return -1;
}
return 0;
}
步骤三:
当条件满足时,需要解除休眠,先将条件(condition=1),然后调用 wake_up 函数唤醒等待队列中的休眠进程。
/*向设备写入数据函数*/
static ssize_t cdev_test_write(struct file *file, //文件描述符
const char __user *buf, //用户缓冲区
size_t size, //字节大小
loff_t *off) //偏移量
{
struct device_test *test_dev=(struct device_test *)file->private_data;
if (copy_from_user(test_dev->kbuf, buf, size) != 0) // copy_from_user:用户空间向内核空间传数据
{
printk("copy_from_user error\r\n");
return -1;
}
test_dev->flag=1;//将条件置 1
//并使用 wake_up_interruptible 唤醒等待队列中的休眠进程
wake_up_interruptible(&read_wq);
return 0;
}
第二十六章 非阻塞 IO
默认情况下,通过 open函数以读写模式(O_RDWR)打开设备文件(如/dev/xxx_dev)进行读取时,操作是阻塞的,即如果设备没有准备好数据,调用线程会等待直到数据准备好。
若要以非阻塞方式读取数据,可以在 open函数的标志中添加O_NONBLOCK。这样,如果设备没有立即准备好数据,read调用将不会阻塞,而是返回一个错误(通常是-1),并设置 errno为EAGAIN或EWOULDBLOCK,表示当前没有数据可读,但后续可能会有数据。
int fd;
fd = open("/dev/test", O_RDWR|O_NONBLOCK); //打开/dev/test 设备
第二十七章 IO 多路复用
IO 多路复用是一种同步的 IO 模型,允许单个进程或线程监视多个文件描述符。当其中一个文件描述符就绪(如可读、可写或发生错误)时,它会通知应用程序进行相应的读写操作。这种方式能有效利用 CPU 资源,因为当没有文件描述符就绪时,进程可以释放 CPU 去执行其他任务。
select、poll、epoll 区别
select:轮询监视多个文件描述符,但存在文件描述符数量限制(通常为1024),并且每次调用时都需要将监视的文件描述符集合从用户空间复制到内核空间,效率较低。
poll:功能与 select 类似,但没有文件描述符数量的限制,但同样存在每次调用时从用户空间到内核空间的复制开销。
/*linux应用程序poll函数原型*/
int poll(struct pollfd *fds,//pollfd结构体集
nfds_t nfds, //要监视的文件描述符数量
int timeout); //等待的事件,ms,-1无限等待
/*pollfd的结构体参数*/
struct pollfd {
int fd; // 文件描述符
short events; // 感兴趣的事件
short revents; // 实际发生的事件
};
/*
可监视的事件类型
POLLIN 有数据可以读取
POLLPRI 有紧急的数据需要读取
POLLOUT 可以写数据
POLLERR 指定的文件描述符发生错误
POLLHUP 指定的文件描述符挂起
POLLNVAL 无效的请求
POLLRDNORM 等同于 POLLIN
*/
当应用程序使用 select 或者 poll 函数对驱动程序进行非阻塞访问时,
驱动程序中 file_operations 操作集的 poll 函数会执行。所以需要完善驱动中的 poll 函数。驱动中的 poll 函数原型如下所示:
/*file_operations函数集的poll函数*/
unsigned int (*poll)(struct file *filp, //要打开的文件描述符
struct poll_table_struct *wait); //结构体 poll_table_struct 类型指针
//上面这俩参数都是应用程序自动传递的
/*
返回值:
向应用程序返回资源状态,可以返回的资源状态如下:
POLLIN 有数据可以读取
POLLPRI 有紧急的数据需要读取
POLLOUT 可以写数据
POLLERR 指定的文件描述符发生错误
POLLHUP 指定的文件描述符挂起
POLLNVAL 无效的请求
POLLRDNORM 等同于 POLLIN,普通数据可读。
*/
驱动程序的 poll 函数中隐式调用 poll_wait 函数,注意!poll_wait 函数是不会引起阻塞的。
poll_wait 函数原型如下所示:
/*驱动程序中的poll_wait,由epoll调用*/
void poll_wait(struct file *filp, //要打开的文件描述符
wait_queue_head_t *queue, //等待队列头
poll_table *wait); //等待队列
/*epoll_wait 函数在指定的 epoll 实例上等待事件的到来,
如果在指定的超时时间内有事件发生,则
返回并告知应用程序这些事件的发生。*/
epoll:将主动轮询变为被动通知,当事件发生时,内核会主动通知应用程序(活跃文件主动调用回调函数),大大提高了效率。epoll 还支持边缘触发和水平触发两种模式。
//poll在linux应用程序中的使用
#include <poll.h>
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
struct pollfd {
int fd; // 文件描述符
short events; // 感兴趣的事件
short revents; // 实际发生的事件
};
// 使用示例
struct pollfd fdset[1];
fdset[0].fd = fd; // fd 是之前通过 open() 打开的文件描述符
fdset[0].events = POLLIN; // 监视可读事件
int ret = poll(fdset, 1, -1); // 无限期等待
if (ret > 0 && (fdset[0].revents & POLLIN)) {
// 处理可读事件
}
/*poll在驱动程序中的使用*/
unsigned int my_poll(struct file *filp, struct poll_table_struct *wait)
{
unsigned int mask = 0;
//获取私有数据,这是驱动的write函数写入的
struct device_test *test_dev=(struct device_test *)file->private_data;
// 假设我们有一个等待队列
wait_queue_head_t *my_wait_queue = &my_device.wait_queue;
// 将等待队列添加到 poll_table 中
poll_wait(filp, my_wait_queue, wait);
// 检查是否有数据可读
if (test_dev->flag==1) {
mask |= POLLIN; //设置文件状态为有数据可读
}
// 如果有其他事件类型也支持,可以在这里设置
return mask;
}
// 在 file_operations 中设置 poll 函数
static struct file_operations my_fops = {
.owner = THIS_MODULE,
.open = my_open,
.release = my_release,
.read = my_read,
.write = my_write,
.poll = my_poll, // 设置 poll 函数
// ... 其他操作 ...
};
第二十八章 信号驱动 IO
信号驱动 IO 不需要应用程序查询设备的状态,一旦设备准备就绪,会触发 SIGIO 信号,进而调用注册的信号处理函数。
应用程序部分
注册信号处理函数:
应用程序使用 signal()或更安全的 sigaction()来注册 SIGIO信号的处理函数。
void sigio_handler(int signum) {
// 处理信号,例如读取数据
printf("Caught SIGIO, data is ready\n");
// 读取数据等逻辑
}
int main() {
signal(SIGIO, sigio_handler); // 或者使用 sigaction 更安全
// 其他设置和逻辑
}
设置文件描述符以接收SIGIO信号:
通过 fcntl() 函数设置文件描述符以接收SIGIO信号,并启用 FASYNC标志。
int fd = open("/dev/mydevice", O_RDONLY | O_NONBLOCK);
if (fd < 0) {
perror("open");
exit(EXIT_FAILURE);
}
fcntl(fd, F_SETOWN, getpid()); // 设置进程ID接收SIGIO
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | FASYNC); // 开启FASYNC
驱动程序部分
实现fasync方法:
在 file_operations结构体中实现 fasync()方法,用于处理文件描述符的 FASYNC标志变化。
static int mydevice_fasync(int fd, struct file *filp, int on) {
//私有数据,包含了fasync_struct **fasync_queue;
struct mydevice_data *device = filp->private_data;
return fasync_helper(fd, filp, on, &device->fasync_queue); //更新异步通知队列
}
/*被fasync()调用,用来初始化或更新异步通知队列*/
int fasync_helper(int fd, //打开的文件描述符
struct file *filp, //file结构体指针
int on, //标志位,非零则添加异步通知,为零则删除异步通知
struct fasync_struct **fa);//指向fasync_struct结构体的指针,
//用于存储或更新异步通知的入口
设备就绪时通知应用程序:
当设备检测到数据准备好时,驱动程序应调用 kill_fasync()来发送 SIGIO信号。
void mydevice_notify_ready(struct mydevice_data *device) {
if (device->fasync_queue) {
kill_fasync(&device->fasync_queue, SIGIO, POLLIN);
}
}
fasync_helper 函数通常在字符设备驱动的 fasync方法中被调用。当应用程序通过 fcntl系统调用设置文件的 FASYNC标志时,驱动会接收到一个fasync方法的调用,此时驱动就可以使用fasync_helper函数来初始化或更新异步通知队列。
第二十九章 定时器
29.1 Linux定时器
定时器基本概念
系统定时器:硬件提供的一个周期性中断源,用于内核的时间管理和任务调度。
节拍(jiffies):系统定时器每中断一次,jiffies变量就增加一,表示时间流逝。节拍频率可配置,常见的有100Hz、250Hz、300Hz、1000Hz等。jiffies可在构建内核时设置。
timer_list结构体:用于定义定时器,包含到期时间(expires)、处理函数(function)等。
定时器操作
定义定时器:
使用DEFINE_TIMER宏定义定时器及其处理函数。
//定义定时器,和处理函数
DEFINE_TIMER(timer_test, function_test);
设置定时器到期时间:
通过修改定时器的 expires字段设置,通常使用 jiffies加上通过转换函数,如msecs_to_jiffies(),得到的未来时间差。
//设置定时器到期事件
timer_test.expires = jiffies + msecs_to_jiffies(3000); // 3秒后到期
int jiffies_to_msecs(const unsigned long j); //将 jiffies 类型的参数 j 分别转换为对应的毫秒
int jiffies_to_usecs(const unsigned long j); //将 jiffies 类型的参数 j 分别转换为对应的微秒
u64 jiffies_to_nsecs(const unsigned long j); //将 jiffies 类型的参数 j 分别转换为对应的纳秒
long msecs_to_jiffies(const unsigned int m); //将毫秒转换为 jiffies 类型
long usecs_to_jiffies(const unsigned int u); //将微秒转换为 jiffies 类型
unsigned long nsecs_to_jiffies(u64 n); //将纳秒转换为 jiffies 类型
注册定时器:
使用 add_timer()将定时器添加到内核定时器列表中,启动定时器。
add_timer(&timer_test);
修改定时器:
mod_timer()用于更改定时器的到期时间或重新激活已停止的定时器,可以使用 。
删除定时器:
使用 del_timer()从内核定时器列表中删除定时器,停止其运行。
29.2 驱动程序编写
#include <linux/timer.h>
//定义 function_test 定时功能函数
static void function_test(struct timer_list *t);
//定义一个定时器
DEFINE_TIMER(timer_test,function_test);
static void function_test(struct timer_list *t)
{
printk("this is function test \n");
//使用 mod_timer 函数将定时时间设置为五秒后
mod_timer(&timer_test,jiffies_64 + msecs_to_jiffies(5000));
}
/*在驱动入口函数 add_timer()添加定时器*/
static int __init timer_mod_init(void)
{
//将定时时间设置为五秒后
timer_test.expires = jiffies_64 + msecs_to_jiffies(5000);
add_timer(&timer_test); //添加一个定时器
return 0;
}
/*在驱动出口函数 del_timer()删除定时器*/
....
第三十章 Linux 内核打印
由于 buildroot 系统已经设置了相应的打印等级,所以驱动的相关打印都能正常显示在串口终端上,如果将实验系统换成了 ubuntu,然后加载同样的驱动,会发现打印信息不见了,这一现象的基本原因就是内核打印等级不同。
30.1 方法一:dmseg 命令
kernel 会将打印信息存储在 ring buffer(环形缓冲区) 中,
在终端使用 dmseg 命令可以获取内核打印信息。
dmsg -C //清除内核环形缓冲区
dmsg -c //读取并清除所有消息
dmsg -T //显示时间戳
dmseg | grep usb //查找包含usb关键字的打印信息
30.2 方法二:查看 kmsg 文件
内核所有的打印信息都会输出到循环缓冲区 'log_buf',为了能够方便的在用户空间读取内核打印信息,Linux 内核驱动将该循环缓冲区映射到了 /proc/kmsg文件节点。
使用 cat /proc/kmsg 读取 kmsg 文件,在没有新的内核打印信息时会阻塞。
cat /proc/kmsg
然后在该设备的其他终端 insmode加载任意有打印信息的驱动文件。
在串口终端中可以看到对应驱动的打印信息就被打印了出来。
30.3 方法三:调整内核打印等级
内核日志打印由相应的打印等级来控制,可通过调整内核打印等级来控制打印日志的输出。
#查看内核默认打印等级
cat /proc/sys/kernel/printk
可以看到内核打印等级由四个数字所决定。
上面的 7 4 1 7 代表着只有优先级高于 console_loglevel的打印消息才能输出到终端,“内核源码/include/linux/kern_levels.h”文件中对于文件打印等级进行了如下打印等级定义:
#define KERN_EMERG KERN_SOH "0" // 系统不可用
#define KERN_ALERT KERN_SOH "1" // 必须立即采取行动
#define KERN_CRIT KERN_SOH "2" // 临界条件
#define KERN_ERR KERN_SOH "3" // 错误条件
#define KERN_WARNING KERN_SOH "4" // 警告条件
#define KERN_NOTICE KERN_SOH "5" // 正常但重要的条件
#define KERN_INFO KERN_SOH "6" // 信息性
#define KERN_DEBUG KERN_SOH "7" // 调试级别的消息
printk 在打印信息前,可以加入相应的打印等级宏定义。
printk(打印等级 "打印信息")
/*驱动入口函数*/
static int __init helloworld_init(void)
{
printk(KERN_EMERG " 0000 KERN_EMERG\n");
printk(KERN_ALERT " 1111 KERN_ALERT\n");
printk(KERN_CRIT " 2222 KERN_CRIT\n");
printk(KERN_ERR " 3333 KERN_ERR\n");
printk(KERN_WARNING " 4444 KERN_WARNING\n");
printk(KERN_NOTICE " 5555 KERN_NOTICE\n");
printk(KERN_INFO " 6666 KERN_INFO\n");
printk(KERN_DEBUG " 7777 KERN_DEBUG\n"); // 该行和 console_loglevel等级相同,所以不会打印
printk(" 8888 no_fix\n"); //默认访问等级,4
return 0;
}
第三十一章 llseek 定位设备驱动
假如现在有这样一个场景,
将两个字符串依次进行写入,并对写入完成的字符串进行读取,如果仍采用之前的方式,第二次的写入值会覆盖第一次写入值,那要如何来实现上述功能呢?这就要轮到 llseek 出场了。
31.1 lseek 函数的使用
lseek() 函数用于在打开的文件中移动读写位置指针。
/*移动文件的读写位置指针*/
off_t lseek(int fd, //文件描述符
off_t offset, //偏移量
int whence); //相对位置 .首、当前、尾
lseek() 会调用 file_operation 结构体中的 llseek() 接口,所以需要对驱动中的 llseek 函数进行填充。
31.2 llseek相关接口的完善
/*llseek接口的完善*/
#include <linux/fs.h>
#include <linux/errno.h>
// 假设 BUFSIZE 已经在某个地方定义,表示设备或缓冲区的最大大小
#define BUFSIZE 1024 // 示例值,实际应根据需要定义
static loff_t cdev_test_llseek(struct file *file, loff_t offset, int whence)
{
loff_t new_offset; // 定义 loff_t 类型的新的偏移值
switch (whence) { // 对 lseek 函数传递的 whence 参数进行判断
case SEEK_SET:
if (offset < 0) {
return -EINVAL; // 偏移量不能小于0
}
if (offset > BUFSIZE) {
return -EINVAL; // 偏移量不能大于 BUFSIZE
}
new_offset = offset; // 如果 whence 参数为 SEEK_SET,则新偏移值为 offset
break;
case SEEK_CUR:
// 注意:这里应该是从当前位置加上偏移量,而不是 file->f_pos + offset > BUFSIZE
if (file->f_pos + offset > BUFSIZE) {
return -EINVAL; // 新位置不能超出 BUFSIZE
}
if (file->f_pos + offset < 0) {
return -EINVAL; // 新位置不能小于0
}
new_offset = file->f_pos + offset; // 如果 whence 参数为 SEEK_CUR,则新偏移值为当前位置加上偏移量
break;
case SEEK_END:
// 注意:对于 SEEK_END,应该是 BUFSIZE - offset,因为我们是从文件末尾往回数
// 如果 offset 是正数,表示从文件末尾往前移动 offset 个字节
// 如果 offset 是负数,则表示从文件末尾往后(即扩大文件)移动 |offset| 个字节,但这通常需要额外的处理(如文件扩展)
if (offset > 0 && BUFSIZE < offset) {
return -EINVAL; // 如果偏移量大于 BUFSIZE,则无效
}
// 这里简单处理为只允许往前移动,不允许扩展文件
new_offset = BUFSIZE - offset; // 如果 whence 参数为 SEEK_END,则新偏移值为 BUFSIZE 减去偏移量
if (new_offset < 0) {
return -EINVAL; // 新位置不能小于0
}
break;
default:
return -EINVAL; // 不支持的 whence 值
}
file->f_pos = new_offset; // 更新 file->f_pos 偏移值
return new_offset; // 返回新的偏移值
}
/*read接口的完善*/
#include <linux/fs.h>
#include <linux/uaccess.h> // 包含 copy_to_user 和相关函数
#include <linux/printk.h> // 包含 printk
// 假设 BUFSIZE 和 mem 已经在某处定义,且 mem 是一个足够大的缓冲区
#define BUFSIZE 1024
char mem[BUFSIZE]; // 示例缓冲区
static ssize_t cdev_test_read(struct file *file, char __user *buf, size_t size, loff_t *off)
{
loff_t p = *off; // 将读取数据的偏移量赋值给 loff_t 类型变量 p
size_t count;
int i;
// 检查偏移量是否超出了缓冲区范围
if (p >= BUFSIZE) {
return 0; // 通常返回 0 表示到达文件末尾
}
// 计算实际可以读取的字节数
count = min_t(size_t, size, BUFSIZE - p);
// 将 mem 中的数据复制到用户空间
if (copy_to_user(buf, mem + p, count)) {
printk("copy_to_user error\n");
return -EFAULT; // 正确的错误码应该是 -EFAULT
}
// 注意:这里打印 mem 的前 20 个字节可能并不是用户实际读取的,这只是为了调试
for (i = 0; i < min_t(int, 20, (int)BUFSIZE); i++) {
printk("mem[%d] is %c\n", i, (mem[i] >= 32 && mem[i] <= 126) ? mem[i] : '.');
}
// 注意:这里的打印信息可能不是很有用,因为它没有直接反映用户读取的内容
// 并且,尝试打印 mem+p 作为字符串是不正确的,因为 mem+p 是一个字符指针,不是字符串
// 我们可以打印出偏移量和读取的字节数
printk("Read from mem+%llu, count=%zu\n", p, count);
// 更新偏移值
*off += count;
// 返回实际读取的字节数
return count;
}
/*write接口完善*/
#include <linux/fs.h>
#include <linux/uaccess.h>
#include <linux/printk.h>
#define BUFSIZE 1024
char mem[BUFSIZE]; // 假设这是全局缓冲区
static ssize_t cdev_test_write(struct file *file, const char __user *buf, size_t size, loff_t *off)
{
loff_t p = *off; // 将写入数据的偏移量赋值给 loff_t 类型变量 p
size_t count;
// 检查偏移量是否超出了缓冲区范围
if (p >= BUFSIZE) {
// 通常这里返回 -EINVAL 表示无效参数,但返回 0(表示无更多数据可写)也可以
// 这里我们选择返回 0,因为偏移量已经超出了缓冲区
return 0;
}
// 计算实际可以写入的字节数
count = min_t(size_t, size, BUFSIZE - p);
// 将用户空间的数据复制到内核空间
if (copy_from_user(mem + p, buf, count)) {
// copy_from_user 失败,可能是因为用户空间地址无效
printk("copy_from_user error\n");
return -EFAULT; // 返回错误码 -EFAULT
}
// 注意:这里打印 mem+p 是不安全的,因为它可能不是以 null 结尾的字符串
// 如果确实需要打印写入的数据,应该只打印已知安全的部分
// 例如,只打印我们刚刚写入的那些字节
for (size_t i = 0; i < count; i++) {
printk("mem[%llu+%zu]=%c\n", p, i, ((mem[p + i] >= 32 && mem[p + i] <= 126) ? mem[p + i] : '.'));
}
// 或者,如果你只是想看到写入的字节(不考虑可打印性),可以这样:
// printk("Wrote %zu bytes to mem+%llu\n", count, p);
// 更新偏移值
*off += count;
// 返回实际写入的字节数
return count;
}
第三十二章 IOCTL 驱动传参
应用层 ioctl简述
在应用层,ioctl()用于向设备发送控制和配置命令。
/*ioctl 函数用于向设备发送控制和配置命令*/
#include <sys/ioctl.h>
int ioctl(int fd, //文件描述符
unsigned int cmd, //控制指令
unsigned long args);//参数
/*
cmd 参数,为 unsigned int 类型,
一个 unsigned int cmd 被拆分为了 4 段,
位域拆分如下:
cmd[31:30] 数据(args)的传输方向(读写)
cmd[29:16] 数据(args)的大小
cmd[15:8] 命令的类型,可以理解成命令的密钥,一般为 ASCII 码
cmd[7:0] 命令的序号,是一个 8bits 的数字
*/
cmd 参数一般都是自己定义命令宏,来使用合成宏得到。
四个合成宏定义如下所示:
定义命令,无需参数
#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)))
//定义一个命令,不需要参数
#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)))
/*
type: 命令的类型,一般为一个 ASCII 码值,一个驱动程序一般使用一个 type
nr: 该命令下序号。一个驱动有多个命令,一般他们的 type,序号不同
size: 参数的类型
*/
/*ioctl应用层示例*/
#include <stdio.h>
#include <fcntl.h>
#include <sys/ioctl.h>
#include <unistd.h>
/*自己定义命令宏*/
#define CMD_TEST1 _IOW('L', //命令类型
1, //命令序号
int)//参数类型
int main() {
int fd = open("/dev/mydevice", O_RDWR);
if (fd < 0) {
perror("open failed");
return 1;
}
int value = 123;
//调用应用层ioctl,传入命令层
if (ioctl(fd, CMD_TEST1, &value) < 0) {
perror("ioctl failed");
close(fd);
return 1;
}
printf("ioctl success, value = %d\n", value);
close(fd);
return 0;
}
驱动层ioctl简述
应用程序中 ioctl()会调用 file_operation 结构体中的 unlocked_ioctl() 接口。
/*unlock_ioctl接口*/
long (*unlocked_ioctl) (struct file *file, //文件描述符
unsigned int cmd, //命令
unsigned long arg); //参数
/*驱动层unlock_ioctl()*/
#include <linux/fs.h>
#include <linux/uaccess.h>
#define CMD_TEST1 _IOW('L', 1, int)
/*完善unlock_ioctl*/
static long mydevice_ioctl(struct file *file, //文件描述符
unsigned int cmd, //命令
unsigned long arg) //参数
{
//判断命令
switch (cmd) {
case CMD_TEST1: {
int value;
if (copy_from_user(&value, (int __user *)arg, sizeof(int)))
return -EFAULT;
// 处理 value,例如打印或修改
printk(KERN_INFO "Received value: %d\n", value);
// 假设不需要修改value并返回
break;
}
default:
return -EINVAL;
}
return 0;
}
第三十三章 封装驱动API
驱动代码不搞驱动的可能看不懂,需要封装好了让应用层直接拿过去用。
以定时器驱动的封装举例。
1、编写整体库文件 timerlib.h
2、接下来将创建每个功能函数的 c 文件,最后编译为单独的库。
3、编译时将.c编译成.o,然后将.o编译成 .a静态库文件。注意库文件以 lib开头。
gnu-gcc -c xxx.c //将xxx.c直接编译成.o gnu-ar rcs xxx.o //将xxx.o归档成.a静态库 /* ar: 调用归档工具。 r: 向归档文件中插入文件(如果归档文件不存在,则创建归档文件)。 c: 创建一个新的归档文件。 注意,当与 r 一起使用时,这个选项是多余的, 因为 r 已经隐含了创建归档文件的行为(如果归档文件不存在的话)。 s: 为归档文件创建索引,这有助于更快地访问归档中的文件。 */
archive,归档
首先编写 dev_open.c 文件。
然后编写定时器打开函数 timeropen.c 文件。
编写定时器打开函数 timerclose.c 文件。
编写定时器打开函数 timerset.c 文件。(给各个功能函数单独编写C文件)
然后 调用 gnu-ar rcs 进行归档创建静态库。
#ifndef _TIMELIB_H_
#define _TIMELIB_H_
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/ioctl.h>
#define TIMER_OPEN _IO('L',0)
#define TIMER_CLOSE _IO('L',1)
#define TIMER_SET _IOW('L',2,int)
int dev_open();//定义设备打开函数
int timer_open(int fd);//定义定时器打开函数
int timer_close(int fd);//定义定时器关闭函数
int timer_set(int fd,int arg);//定义设置计时时间函数
#endif
第三十四章 优化驱动稳定性和效率
34.1 方法一:检测 ioctl 命令
ioctl()的 cmd 参数是通过一个合成宏构造的,它包含了数据方向、大小、类型、序号等信息。为了解析这些信息,存在四个对应的分解宏:
_IOC_DIR(cmd) //分解cmd命令,得到命令方向
_IOC_SIZE(cmd) //分解cmd命令,得到命令大小
_IOC_TYPE(cmd) //分解cmd命令,得到命令类型
_IOC_NR(cmd) //分解cmd命令,得到命令序号
在Linux内核驱动中,可以使用这些分解宏来验证传入的 ioctl 命令的合法性。
包括检查命令的类型、确保数据传输方向符合预期、验证命令序号是否匹配特定的操作,以及检查数据大小是否适合处理。
/*检查命令的类型*/
if(_IOC_TYPE(cmd) != 'L'){
printk("cmd type error \n");
return -1;
}
34.2 方法二:检测传递地址
在Linux内核开发中,access_ok()函数用于检查用户空间内存块是否可用。这通常是在内核准备从用户空间读取或写入数据之前的一个安全步骤。
/*检查用户空间地址是否可用*/
access_ok(addr, //地址指针
size);//内存大小
struct args test;
int len;
//判断命令
switch(cmd){
case CMD_TEST0:
len = sizeof(struct args);
if(!access_ok(arg,len)){ //判断地址是否可用
return -1;
}
if(copy_from_user(&test,(int *)arg,sizeof(test)) != 0){
printk("copy_from_user error\n");
}
break;
34.3 方法三:分支预测优化
在Linux内核开发中,CPU的 ICache和流水线机制能够预取并执行后续指令以提高效率。然而,在条件分支中,如果跳转到的指令并非预期,则预取指令将浪费资源。
为了优化这种情况,内核提供了 likely()宏和 unlikely()宏,
likely()宏 和 unlikely()宏,会让编译器总是将大概率执行的代码放在靠前的位置,从而提高驱动的效率。
这两个宏通过 __builtin_expect()函数告诉编译器某个条件为真或为假的可能性更大,从而帮助编译器优化代码布局。
ICache,即指令缓存,用于缓存最近使用过的指令。
流水线机制:取指、译码、执行、访存、回写,多任务并行。
#include <linux/compiler.h>
#define likely(x) __builtin_expect(!!(x), 1) //表示x为真的可能性较大.
#define unlikely(x) __builtin_expect(!!(x), 0) //表示x为假的可能性较大.
/*__builtin_expect 的作用是告知编译器预期表达式 exp 等于 c 的可能性更大,
编译器可以根据该因素更好的对代码进行优化,*/
/*分支预测优化*/
struct args test;
int len;
switch(cmd){
case CMD_TEST0:
len = sizeof(struct args);
if(!access_ok(arg,len)){
return -1;
}
//对copy_from_user添加分支预函数 unlikely()
if(unlikely(copy_from_user(&test,(int *)arg,sizeof(test)) != 0)){
printk("copy_from_user error\n");
}
break;
...
}
第三十五章 驱动调试方法
35.1 方法 1:dump_stack 函数
dump_stack()可以抛出调用它的函数的栈回溯,打印调用关系。
dump_stack();
static int __init helloworld_init(void)
{
printk(KERN_EMERG "helloworld_init\r\n");
dump_stack(); //栈信息打印
return 0;
}
35.2 方法 2:WARN_ON(condition)函数
WARN_ON (condition)函数在参数的条件成立时,内核会抛出栈回溯,打印函数的调用关系。通常用于内核抛出一个警告,暗示某种不太合理的事情发生了。
底层其实也是调用dump_stack(),只是多了condition参数判断条件是否成立。
/*参数条件成立则抛出栈回溯,否则略过*/
WARN_ON(1);
static int __init helloworld_init(void)
{
printk(KERN_EMERG "helloworld_init\r\n");
//条件成立则抛出栈回溯,否则略过
WARN_ON(1);
return 0;
}
35.3 方法 3:BUG_ON (condition)函数
内核中有许多地方调用类似 BUG_ON()的语句,它像一个内核运行时的断言,意味着本来不该执行到 BUG_ON()这条语句,一旦 BUG_ON()执行内核就会立刻抛出 oops,导致栈回溯和错误信息的打印。
oops 也常被称为"kernel panic"。
/*条件成立则抛出oops错误,引发栈回溯打印信息*/
BUG_ON (1)
35.4 方法 4:panic (fmt...)函数
panic (fmt...)函数:输出打印会造成系统死机并将函数的调用关系以及寄存器值都打印出来。
static int __init helloworld_init(void)
{
printk(KERN_EMERG "helloworld_init\r\n");
panic("!!!!!!!!!!!!!!!!!!!!!!!!!!!!"); //会死机,然后抛出栈回溯
return 0;
}