精通-Linux-设备驱动开发-全-

精通 Linux 设备驱动开发(全)

原文:zh.annas-archive.org/md5/95A00CE7D8C2703D7FF8A1341D391E8B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Linux 是世界上增长最快的操作系统之一,在过去几年里,Linux 内核已经显著发展,以支持各种嵌入式设备,具有改进的子系统和许多新功能。

《精通 Linux 设备驱动开发》全面涵盖了诸如视频和音频框架等内核主题,这些通常被忽视。您将深入研究一些最复杂和有影响力的 Linux 内核框架,如 PCI、用于 SoC 的 ALSA 和 Video4Linux2,途中获得专家建议和最佳实践。除此之外,您还将学习如何利用 NVMEM 和 Watchdog 等框架。一旦本书带您开始使用 Linux 内核助手,您将逐渐进展到处理特殊设备类型,如多功能设备(MFDs),然后是视频和音频设备驱动。

通过本书,您将能够编写稳健的设备驱动程序,并将它们集成到一些最复杂的 Linux 内核框架中,包括 V4L2 和 ALSA SoC。

这本书适合谁

这本书主要面向嵌入式爱好者和开发人员、Linux 系统管理员和内核黑客。无论您是软件开发人员、系统架构师还是制造商(电子爱好者),希望深入了解 Linux 驱动程序开发,本书都适合您。

本书涵盖的内容

《第一章》,《嵌入式开发人员的 Linux 内核概念》,介绍了 Linux 内核助手,用于锁定、阻塞 I/O、推迟工作和中断管理。

《第二章》,《利用 Regmap API 简化代码》,概述了 Regmap 框架,并向您展示如何利用其 API 来简化中断管理和抽象寄存器访问。

《第三章》,《深入 MFD 子系统和 Syscon API》,专注于 Linux 内核中的 MFD 驱动程序、它们的 API 和结构,以及介绍了sysconsimple-mfd助手。

《第四章》,《冲击通用时钟框架》,解释了 Linux 内核时钟框架,并探讨了生产者和消费者设备驱动程序,以及它们的设备树绑定。

《第五章》,《ALSA SoC 框架-利用编解码器和平台类驱动程序》,讨论了编解码器和平台设备的 ALSA 驱动程序开发,并介绍了kcontrol和数字音频电源管理(DAPM)等概念。

《第六章》,《ALSA SoC 框架-深入机器类驱动程序》,深入介绍了 ALSA 机器类驱动程序的开发,并向您展示了如何将编解码器和平台绑定在一起,以及如何定义音频路由。

《第七章》,《揭秘 V4L2 和视频捕获设备驱动程序》,描述了 V4L2 的关键概念。它专注于桥接视频设备,介绍了子设备的概念,并涵盖了它们各自的设备驱动程序。

《第八章》,《与 V4L2 异步和媒体控制器框架集成》,介绍了异步探测的概念,这样您就不必关心桥接和子设备的探测顺序。最后,本章介绍了媒体控制器框架,以提供视频路由和视频管道定制。

《第九章》,《从用户空间利用 V4L2 API》,结束了我们关于 V4L2 的教学系列,并处理了来自用户空间的 V4L2。它首先教您如何编写 C 代码,以打开、配置和抓取视频设备的数据。然后,它向您展示如何通过利用用户空间的视频相关工具,如v4l2-ctlmedia-ctl,尽可能少地编写代码。

第十章 Linux Kernel Power Management 讨论了基于 Linux 的系统的电源管理,并教你如何编写具有电源感知能力的设备驱动程序。

第十一章 Writing PCI Device Drivers 处理 PCI 子系统,并向你介绍了它的 Linux 内核实现。本章还向你展示了如何编写 PCI 设备驱动程序。

第十二章 Leveraging the NVMEM Framework 描述了 Linux Non-Volatile Memory (NVEM)子系统。它首先教你如何编写提供者和消费者驱动程序以及它们的设备树绑定。然后,它向你展示如何从用户空间充分利用设备。

第十三章 Watchdog Device Drivers 提供了对 Linux 内核看门狗子系统的准确描述。它首先向你介绍了看门狗设备驱动程序,并逐渐带你深入子系统的核心,介绍了一些关键概念,如预超时和管理者。最后,本章教你如何从用户空间管理子系统。

第十四章 Linux Kernel Debugging Tips and Best Practices 突出显示了使用内核嵌入式工具(如 ftrace 和 oops 消息分析)的最常用的 Linux 内核调试和跟踪技术。

为了充分利用本书

为了充分利用本书,需要一定的 C 和系统编程知识。此外,本书的内容假定你熟悉 Linux 系统及其大部分基本命令。

在前面的表中未列出的任何必要软件包将在各自的章节中进行描述。

如果你使用本书的数字版本,我们建议你自己输入代码。这样做将帮助你避免与复制和粘贴代码相关的潜在错误。

下载彩色图片

我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。你可以在这里下载:http://www.packtpub.com/sites/default/files/downloads/9781789342048_ColorImages.pdf。

使用的约定

本书中使用了许多文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这里有一个例子:“这里没有使用 request_irq()家族方法请求父 IRQ,因为 gpiochip_set_chained_irqchip()将在幕后调用 irq_set_chained_handler_and_data()。”

一段代码设置如下:

static int fake_probe(struct i2c_client *client,                       const struct i2c_device_id *id)
{
    [...]
    mutex_init(&data->mutex);
    [...]
}

当我们希望引起你对代码块的特定部分的注意时,相关行或项目将以粗体显示:

static int __init my_init(void)
{
    pr_info('Wait queue example\n');
    INIT_WORK(&wrk, work_handler);
    schedule_work(&wrk);
    pr_info('Going to sleep %s\n', __FUNCTION__);
 wait_event_interruptible(my_wq, condition != 0);
    pr_info('woken up by the work job\n');
    return 0;}

任何命令行输入或输出都以以下方式编写:

# echo 1 >/sys/module/printk/parameters/time
# cat /sys/module/printk/parameters/time

粗体:表示新术语、重要单词或屏幕上看到的单词。这里有一个例子:“引入了 simple-mfd 助手来处理零配置/黑客子设备注册,并且引入了 syscon 来与其他设备共享设备的内存区域。”

提示或重要说明

看起来像这样。

联系我们

我们始终欢迎读者的反馈。

customercare@packtpub.com。

勘误:尽管我们已经尽一切努力确保内容的准确性,但错误是难免的。如果你在本书中发现了错误,我们将不胜感激地接受你的报告。请访问 www.packtpub.com/support/errata,选择你的书,点击勘误提交表格链接,并输入详细信息。

copyright@packt.com,附上材料的链接。

如果您有兴趣成为作者:如果您在某个专业领域有专长,并且有兴趣撰写或为一本书作出贡献,请访问 authors.packtpub.com

评论

请留下评论。在您阅读并使用了这本书之后,为什么不在购买书籍的网站上留下一条评论呢?潜在的读者可以看到并使用您的客观意见来做出购买决定,我们在 Packt 可以了解您对我们产品的看法,而我们的作者也可以看到您对他们书籍的反馈。谢谢!

有关 Packt 的更多信息,请访问 packt.com

第一部分:嵌入式设备驱动程序开发的内核核心框架

本节涉及 Linux 内核核心,介绍了 Linux 内核提供的抽象层和设施,以减少开发工作。此外,在本节中,我们将学习关于 Linux 时钟框架,借助它,系统上的大多数外围设备都能得到驱动。

本节包括以下章节:

  • 第一章,嵌入式开发人员的 Linux 内核概念

  • 第二章,利用 Regmap API 简化代码

  • 第三章,深入 MFD 子系统和 Syscon API

  • 第四章,突破通用时钟框架

第一章:嵌入式开发人员的 Linux 内核概念

作为一个独立的软件,Linux 内核实现了一组函数,有助于避免重复发明轮子,并简化设备驱动程序的开发。这些辅助程序的重要性在于,不需要使用这些代码才能被上游接受。这是驱动程序依赖的内核核心。我们将在本书中介绍这些核心功能中最受欢迎的功能,尽管还有其他功能存在。我们将首先查看内核锁定 API,然后讨论如何保护共享对象并避免竞争条件。然后,我们将看看各种可用的工作推迟机制,您将了解在哪个执行上下文中推迟代码的部分。最后,您将学习中断的工作原理以及如何从 Linux 内核内部设计中断处理程序。

本章将涵盖以下主题:

  • 内核锁定 API 和共享对象

  • 工作推迟机制

  • Linux 内核中断管理

让我们开始吧!

技术要求

要理解和遵循本章的内容,您将需要以下内容:

内核锁定 API 和共享对象

当可以被多个竞争者访问时,资源被称为共享资源,而不管它们的独占性。当它们是独占的时,访问必须同步,以便只有允许的竞争者可以拥有资源。这样的资源可能是内存位置或外围设备,而竞争者可能是处理器、进程或线程。操作系统通过原子地(即通过可以被中断的操作)修改持有资源当前状态的变量来执行互斥排除,使这一点对所有可能同时访问变量的竞争者可见。这种原子性保证修改要么成功,要么根本不成功。如今,现代操作系统依赖于硬件(应该允许原子操作)用于实现同步,尽管一个简单的系统可以通过在关键代码部分周围禁用中断(并避免调度)来确保原子性。

在本节中,我们将描述以下两种同步机制:

  • :用于互斥。当一个竞争者持有锁时,其他竞争者无法持有它(其他被排除)。内核中最知名的锁定原语是自旋锁和互斥锁。

  • 条件变量:主要用于感知或等待状态改变。这些在内核中实现方式不同,我们稍后将看到,主要在Linux 内核中的等待、感知和阻塞部分中。

当涉及到锁定时,硬件允许通过原子操作进行同步。然后内核使用这些来实现锁定设施。同步原语是用于协调对共享资源访问的数据结构。因为只有一个竞争者可以持有锁(从而访问共享资源),它可能对与锁相关的资源执行任意操作,这对其他人来说似乎是原子的。

除了处理给定共享资源的独占所有权外,还存在一些情况更好地等待资源状态改变;例如,等待列表包含至少一个对象(其状态从空变为非空)或等待任务完成(例如 DMA 事务)。Linux 内核不实现条件变量。从用户空间,我们可以考虑在这两种情况下使用条件变量,但为了实现相同或甚至更好的效果,内核提供了以下机制:

  • 等待队列:主要用于等待状态改变。它被设计为与锁协同工作。

  • 完成队列:用于等待给定计算的完成。

这两种机制都受到 Linux 内核的支持,并且由于一组较少的 API(在开发人员使用时显著简化了它们的使用),它们暴露给驱动程序。我们将在接下来的部分讨论这些。

自旋锁

自旋锁是一种基于硬件的锁原语。它依赖于手头的硬件提供原子操作的能力(例如test_and_set,在非原子实现中,将导致读取、修改和写入操作)。自旋锁基本上用于不允许睡眠或根本不需要睡眠的原子上下文中(例如在中断中,或者当您想要禁用抢占时),但也用作 CPU 间的锁原语。

这是最简单的锁原语,也是基本的锁原语。它的工作方式如下:

图 1.1 - 自旋锁争用流

图 1.1 - 自旋锁争用流

让我们通过以下场景来探讨图表:当运行任务 B 的 CPUB 想要通过自旋锁的锁定函数获取自旋锁,而该自旋锁已经被另一个 CPU(比如运行任务 A 的 CPUA,已经调用了该自旋锁的锁定函数)持有时,CPUB 将简单地在一个 while 循环中旋转,从而阻塞任务 B,直到另一个 CPU 释放锁(任务 A 调用自旋锁的释放函数)。这种旋转只会发生在多核机器上,这就是为什么先前描述的使用情况,涉及多个 CPU,因为它是在单核机器上,是不可能发生的:任务要么持有自旋锁并继续,要么在锁被释放之前不运行。我曾经说过自旋锁是由 CPU 持有的锁,这与互斥锁相反(我们将在下一节讨论),互斥锁是由任务持有的锁。自旋锁通过禁用本地 CPU 上的调度程序(即运行调用自旋锁的任务的 CPU)来运行。这也意味着当前在该 CPU 上运行的任务不能被另一个任务抢占,除非 IRQs 未被禁用(稍后会详细介绍)。换句话说,自旋锁保护只有一个 CPU 可以一次获取/访问的资源。这使得自旋锁适用于 SMP 安全和执行原子任务。

重要提示

自旋锁并不是唯一利用硬件原子功能的实现。例如,在 Linux 内核中,抢占状态取决于每个 CPU 变量,如果等于 0,则表示抢占已启用。然而,如果大于 0,则表示抢占已禁用(schedule()变得无效)。因此,禁用抢占(preempt_disable())包括向当前 CPU 变量(实际上是preempt_count)添加 1,而preempt_enable()则从变量中减去 1,并检查新值是否为 0,然后调用schedule()。这些加法/减法操作应该是原子的,因此依赖于 CPU 能够提供原子加法/减法功能。

有两种方法可以创建和初始化自旋锁:一种是静态地使用DEFINE_SPINLOCK宏,它将声明并初始化自旋锁,另一种是通过在未初始化的自旋锁上调用spin_lock_init()来动态创建。

首先,我们将介绍如何使用DEFINE_SPINLOCK宏。要理解这是如何工作的,我们必须查看include/linux/spinlock_types.h中此宏的定义,如下所示:

#define DEFINE_SPINLOCK(x) spinlock_t x = __SPIN_LOCK_UNLOCKED(x)

可以如下使用:

static DEFINE_SPINLOCK(foo_lock)

在此之后,自旋锁将通过其名称foo_lock可访问。请注意,其地址将是&foo_lock。然而,对于动态(运行时)分配,您需要将自旋锁嵌入到一个更大的结构中,为该结构分配内存,然后在自旋锁元素上调用spin_lock_init()

struct bigger_struct {    spinlock_t lock;    unsigned int foo;    [...]};
static struct bigger_struct *fake_alloc_init_function(){    struct bigger_struct *bs;    bs = kmalloc(sizeof(struct bigger_struct), GFP_KERNEL);    if (!bs)        return -ENOMEM;    spin_lock_init(&bs->lock);    return bs;}

尽可能使用DEFINE_SPINLOCK更好。它提供了编译时初始化,并且需要更少的代码行而没有真正的缺点。在这个阶段,我们可以使用spin_lock()spin_unlock()内联函数来锁定/解锁自旋锁,这两个函数都在include/linux/spinlock.h中定义:

void spin_unlock(spinlock_t *lock)
void spin_lock(spinlock_t *lock)

也就是说,使用自旋锁的这种方式存在一些限制。虽然自旋锁可以防止本地 CPU 的抢占,但它不能阻止这个 CPU 被中断占用(从而执行这个中断的处理程序)。想象一种情况,CPU 持有一个“自旋锁”来保护一个给定的资源,然后发生了一个中断。CPU 将停止当前的任务并转到这个中断处理程序。到目前为止,一切都好。现在,想象一下,这个 IRQ 处理程序需要获取相同的自旋锁(你可能已经猜到这个资源与中断处理程序共享)。它将无限地在原地旋转,试图获取一个已经被它抢占的任务锁。这种情况被称为死锁。

为了解决这个问题,Linux 内核为自旋锁提供了_irq变体函数,除了禁用/启用抢占之外,还在本地 CPU 上禁用/启用中断。这些函数是spin_lock_irq()spin_unlock_irq(),定义如下:

void spin_unlock_irq(spinlock_t *lock);
void spin_lock_irq(spinlock_t *lock);

你可能会认为这个解决方案已经足够了,但事实并非如此。_irq变体只能部分解决这个问题。想象一下,在处理器上已经禁用了中断,然后你的代码开始锁定。所以,当你调用spin_unlock_irq()时,你不仅会释放锁,还会启用中断。然而,这可能会以错误的方式发生,因为spin_unlock_irq()无法知道在锁定之前哪些中断被启用,哪些没有被启用。

以下是一个简短的示例:

  1. 假设在获取自旋锁之前,中断xy已经被禁用,而z没有。

  2. spin_lock_irq()将禁用中断(xyz现在都被禁用)并获取锁。

  3. spin_unlock_irq()将启用中断。xyz都将被启用,这在获取锁之前并不是这样。这就是问题所在。

当从关闭上下文调用时,spin_lock_irq()是不安全的,因为它的对应函数spin_unlock_irq()会天真地启用中断,有可能启用那些在调用spin_lock_irq()时没有启用的中断。只有在你知道中断被启用时才有意义使用spin_lock_irq();也就是说,你确定没有其他东西可能在本地 CPU 上禁用了中断。

现在,想象一下,在获取锁之前将中断的状态保存在一个变量中,并在释放锁时将它们恢复到获取锁时的状态。在这种情况下,就不会再有问题了。为了实现这一点,内核提供了_irqsave变体函数。这些函数的行为就像_irq函数一样,同时保存和恢复中断状态。这些函数是spin_lock_irqsave()spin_lock_irqrestore(),定义如下:

spin_lock_irqsave(spinlock_t *lock, unsigned long flags)
spin_unlock_irqrestore(spinlock_t *lock, unsigned long flags)

重要说明

spin_lock()及其所有变体会自动调用preempt_disable(),在本地 CPU 上禁用抢占。另一方面,spin_unlock()及其变体会调用preempt_enable(),尝试启用(是的,尝试!- 这取决于其他自旋锁是否被锁定,这将影响抢占计数器的值)抢占,并在启用时(取决于计数器的当前值,应该为 0)内部调用schedule()spin_unlock()然后是一个抢占点,可能重新启用抢占。

禁用中断与仅禁用抢占

尽管禁用中断可以防止内核抢占(调度程序的定时器中断将被禁用),但没有什么可以阻止受保护的部分调用调度程序(schedule()函数)。许多内核函数间接调用调度程序,例如处理自旋锁的函数。因此,即使是一个简单的printk()函数也可能调用调度程序,因为它处理保护内核消息缓冲区的自旋锁。内核通过增加或减少一个称为preempt_count的全局和每个 CPU 变量(默认为 0,表示“启用”)来禁用或启用调度程序(执行抢占)。当这个变量大于 0 时(由schedule()函数检查),调度程序简单地返回并不执行任何操作。每次调用与spin_lock*相关的帮助程序时,这个变量都会增加 1。另一方面,释放自旋锁(任何spin_unlock*系列函数)会将其减少 1,每当它达到 0 时,调度程序就会被调用,这意味着你的临界区不会是非常原子的。

因此,如果你的代码本身不触发抢占,它只能通过禁用中断来防止抢占。也就是说,锁定自旋锁的代码可能不会休眠,因为没有办法唤醒它(记住,定时器中断和调度程序在本地 CPU 上被禁用)。

现在我们已经熟悉了自旋锁及其细微差别,让我们来看看互斥锁,这是我们的第二个锁原语。

互斥锁

互斥锁是本章讨论的另一种锁原语。它的行为就像自旋锁一样,唯一的区别是你的代码可以休眠。如果你尝试锁定一个已经被另一个任务持有的互斥锁,你的任务将被挂起,只有在互斥锁被释放时才会被唤醒。这次没有自旋,这意味着 CPU 可以在你的任务等待时处理其他事情。正如我之前提到的,自旋锁是由 CPU 持有的锁,而互斥锁是由任务持有的锁

互斥锁是一个简单的数据结构,嵌入了一个等待队列(用于让竞争者休眠),而自旋锁则保护对这个等待队列的访问。以下是struct mutex的样子:

struct mutex {
    atomic_long_t owner;
    spinlock_t wait_lock;
#ifdef CONFIG_MUTEX_SPIN_ON_OWNER
    struct optimistic_spin_queue osq; /* Spinner MCS lock */
#endif
    struct list_head wait_list;
[...]
};

在上面的代码中,为了可读性,仅在调试模式下使用的元素已被删除。但是,正如你所看到的,互斥锁是建立在自旋锁之上的。owner表示实际拥有(持有)锁的进程。wait_list是互斥锁的竞争者被放置在其中休眠的列表。wait_lock是保护wait_list的自旋锁,当竞争者被插入并休眠时,它有助于保持wait_list在 SMP 系统上的一致性。

互斥锁的 API 可以在include/linux/mutex.h头文件中找到。在获取和释放互斥锁之前,必须对其进行初始化。与其他内核核心数据结构一样,可能存在静态初始化,如下所示:

static DEFINE_MUTEX(my_mutex);

以下是DEFINE_MUTEX()宏的定义:

#define DEFINE_MUTEX(mutexname) \
        struct mutex mutexname = __MUTEX_INITIALIZER(mutexname)

内核提供的第二种方法是动态初始化。这可以通过调用底层的__mutex_init()函数来实现,实际上这个函数被一个更加用户友好的宏mutex_init()所包装:

struct fake_data {
    struct i2c_client *client;
    u16 reg_conf;
    struct mutex mutex;
};
static int fake_probe(struct i2c_client *client,                       const struct i2c_device_id *id)
{
    [...]
    mutex_init(&data->mutex);
    [...]
}

获取(也称为锁定)互斥锁就像调用以下三个函数之一那样简单:

void mutex_lock(struct mutex *lock);
int mutex_lock_interruptible(struct mutex *lock);
int mutex_lock_killable(struct mutex *lock);

如果互斥锁是空闲的(未锁定),您的任务将立即获取它而不会进入睡眠状态。否则,您的任务将以取决于您使用的锁定函数的方式进入睡眠。使用mutex_lock()时,您的任务将进入不可中断的睡眠状态(TASK_UNINTERRUPTIBLE),直到等待互斥锁被释放(如果它被另一个任务持有)。mutex_lock_interruptible()将使您的任务进入可中断的睡眠状态,其中睡眠可以被任何信号中断。mutex_lock_killable()将允许您的任务的睡眠被中断,但只能被实际杀死任务的信号中断。这两个函数在成功获取锁时返回零。此外,可中断的变体在锁定尝试被信号中断时返回-EINTR

无论使用哪种锁定函数,互斥锁所有者(仅所有者)应使用mutex_unlock()释放互斥锁,其定义如下:

void mutex_unlock(struct mutex *lock);

如果您希望检查互斥锁的状态,可以使用mutex_is_locked()

static bool mutex_is_locked(struct mutex *lock)

此函数只是检查互斥锁所有者是否为NULL,如果是,则返回 true,否则返回 false。

重要说明

只有在可以保证互斥锁不会长时间持有时才建议使用mutex_lock()。通常情况下,应该使用可中断的变体。

在使用互斥锁时有特定的规则。最重要的规则在内核的互斥锁 API 头文件include/linux/mutex.h中列出。以下是其中的一部分摘录:

 * - only one task can hold the mutex at a time
 * - only the owner can unlock the mutex
 * - multiple unlocks are not permitted
 * - recursive locking is not permitted
 * - a mutex object must be initialized via the API
 * - a mutex object must not be initialized via memset or      copying
 * - task may not exit with mutex held
 * - memory areas where held locks reside must not be freed
 * - held mutexes must not be reinitialized
 * - mutexes may not be used in hardware or software interrupt
 *   contexts such as tasklets and timers

完整版本可以在同一文件中找到。

现在,让我们看一些情况,我们可以避免在持有锁时将互斥锁置于睡眠状态。这被称为尝试锁定方法。

尝试锁定方法

有时我们可能需要获取锁,如果它不是由其他地方持有的话。这样的方法尝试获取锁并立即返回一个状态值(如果我们使用自旋锁,则不会自旋,如果我们使用互斥锁,则不会休眠)。这告诉我们锁是否已成功锁定。如果我们不需要在其他线程持有锁时访问被保护的数据,可以使用它们。

自旋锁和互斥锁 API 都提供了尝试锁定方法。它们分别称为spin_trylock()mutex_trylock()。这两种方法在失败时(锁已被锁定)返回 0,在成功时(锁已获取)返回 1。因此,使用这些函数与语句是有意义的:

int mutex_trylock(struct mutex *lock)

spin_trylock()实际上是针对自旋锁的。如果自旋锁尚未被锁定,它将锁定自旋锁,方式与spin_lock()方法相同。但是,如果自旋锁已经被锁定,它将立即返回0而不会自旋:

static DEFINE_SPINLOCK(foo_lock);
[...]
static void foo(void)
{
[...]
    if (!spin_trylock(&foo_lock)) {        /* Failure! the spinlock is already locked */        [...]        return;    }
    /*     * reaching this part of the code means        that the      * spinlock has been successfully locked      */
[...]
    spin_unlock(&foo_lock);
[...]
}

另一方面,mutex_trylock()是针对互斥锁的。如果互斥锁尚未被锁定,它将以与mutex_lock()方法相同的方式锁定互斥锁。但是,如果互斥锁已经被锁定,它将立即返回0而不会休眠。以下是一个示例:

static DEFINE_MUTEX(bar_mutex);[...]
static void bar (void){
[...]    if (!mutex_trylock(&bar_mutex))        /* Failure! the mutex is already locked */        [...]        return;    }
    /*     * reaching this part of the code means that the mutex has      * been successfully locked      */
[...]    mutex_unlock(&bar_mutex);[...]
}

在上述代码中,使用了尝试锁定以及if语句,以便驱动程序可以调整其行为。

在 Linux 内核中等待、感知和阻塞

这一部分本来可以被命名为内核睡眠机制,因为我们将处理的机制涉及将涉及的进程置于睡眠状态。设备驱动程序在其生命周期中可以启动完全独立的任务,其中一些任务依赖于其他任务的完成。Linux 内核使用struct completion项来解决这种依赖关系。另一方面,可能需要等待特定条件成为真或对象状态发生变化。这时,内核提供工作队列来解决这种情况。

等待完成或状态改变

您可能不一定是在专门等待资源,而是等待给定对象(共享或非共享)的状态改变或任务完成。在内核编程实践中,通常会在当前线程之外启动一个活动,然后等待该活动完成。当您等待缓冲区被使用时,完成是 sleep() 的一个很好的替代方案,例如。它适用于传感数据,就像 DMA 传输一样。使用完成需要包括 <linux/completion.h> 头文件。其结构如下:

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

您可以使用静态的 DECLARE_COMPLETION(my_comp) 函数静态地创建结构完成的实例,也可以通过将完成结构包装到动态数据结构中(在堆上分配,将在函数/驱动程序的生命周期内保持活动状态)并调用 init_completion(&dynamic_object->my_comp) 来动态创建。当设备驱动程序执行一些工作(例如 DMA 事务)并且其他人(例如线程)需要被通知它们已经完成时,等待者必须在先前初始化的结构完成对象上调用 wait_for_completion() 以便得到通知:

void wait_for_completion(struct completion *comp);

当代码的另一部分已经决定工作已经完成(在 DMA 的情况下,事务已经完成),它可以通过调用 complete() 来唤醒任何等待的人(需要访问 DMA 缓冲区的代码),这将只唤醒一个等待的进程,或者调用 complete_all(),这将唤醒所有等待完成的人:

void complete(struct completion *comp);
void complete_all(struct completion *comp);

典型的使用场景如下(此摘录摘自内核文档):

CPU#1							CPU#2
struct completion setup_done;
init_completion(&setup_done);
initialize_work(...,&setup_done,...);
/* run non-dependent code */ 		/* do some setup */
[...]							[...]
wait_for_completion(&setup_done); 		complete(setup_done);

wait_for_completion()complete() 被调用的顺序并不重要。与信号量一样,完成 API 被设计成,即使在 complete() 被调用之前调用 wait_for_completion(),它们也能正常工作。在这种情况下,一旦所有依赖关系得到满足,等待者将立即继续执行。

请注意,wait_for_completion() 将调用 spin_lock_irq()spin_unlock_irq(),根据 自旋锁 部分的说明,不建议在中断处理程序内部或禁用 IRQs 时使用它们。这是因为它会导致启用难以检测的虚假中断。另外,默认情况下,wait_for_completion() 将任务标记为不可中断 (TASK_UNINTERRUPTIBLE),使其对任何外部信号(甚至 kill 信号)都不响应。这可能会根据它等待的活动的性质而阻塞很长时间。

您可能需要 等待 不是在不可中断状态下完成,或者至少您可能需要 等待 能够被任何信号中断,或者只能被杀死进程的信号中断。内核提供了以下 API:

  • wait_for_completion_interruptible()

  • wait_for_completion_interruptible_timeout()

  • wait_for_completion_killable()

  • wait_for_completion_killable_timeout()

_killable 变体将任务标记为 TASK_KILLABLE,因此只会对真正杀死它的信号做出响应,而 _interruptible 变体将任务标记为 TASK_INTERRUPTIBLE,允许它被任何信号中断。 _timeout 变体将最多等待指定的超时时间:

int wait_for_completion_interruptible(struct completion *done)
long wait_for_completion_interruptible_timeout(
           struct completion *done, unsigned long timeout)
long wait_for_completion_killable(struct completion *done)
long wait_for_completion_killable_timeout(           struct completion *done, unsigned long timeout)

由于wait_for_completion*()可能会休眠,因此只能在此进程上下文中使用。因为interruptiblekillabletimeout变体可能在底层作业完成之前返回,所以应该仔细检查它们的返回值,以便采取正确的行为。可杀死和可中断的变体如果被中断则返回-ERESTARTSYS,如果已完成则返回0。另一方面,超时变体将在被中断时返回-ERESTARTSYS,在超时时返回0,在超时之前完成则返回剩余的 jiffies 数(至少为 1)。有关更多信息,请参阅内核源代码中的kernel/sched/completion.c,以及本书中未涵盖的更多函数。

另一方面,complete()complete_all()永远不会休眠,并且在内部调用spin_lock_irqsave()/spin_unlock_irqrestore(),使得从 IRQ 上下文中进行完成信号完全安全。

Linux 内核等待队列

等待队列是用于处理块 I/O、等待特定条件成立、等待特定事件发生或感知数据或资源可用性的高级机制。要了解它们的工作原理,让我们来看看include/linux/wait.h中的结构:

struct wait_queue_head {
    spinlock_t lock;
    struct list_head head;
};

等待队列只是一个列表(其中进程被放入休眠,以便在满足某些条件时唤醒)的名称,其中有一个自旋锁来保护对该列表的访问。当多个进程希望休眠并且正在等待一个或多个事件发生时,可以使用等待队列。头成员是等待事件的进程列表。希望在等待事件发生时休眠的每个进程都会在进入休眠之前将自己放入此列表中。当进程在列表中时,它被称为等待队列条目。当事件发生时,列表中的一个或多个进程将被唤醒并移出列表。我们可以以两种方式声明和初始化等待队列。首先,我们可以使用DECLARE_WAIT_QUEUE_HEAD静态声明和初始化它,如下所示:

DECLARE_WAIT_QUEUE_HEAD(my_event);

我们还可以使用init_waitqueue_head()来动态执行此操作:

wait_queue_head_t my_event;
init_waitqueue_head(&my_event);

任何希望在等待my_event发生时休眠的进程都可以调用wait_event_interruptible()wait_event()。大多数情况下,事件只是资源已经可用的事实。因此,只有在检查资源的可用性之后,进程才会进入休眠状态。为了方便您,这些函数都接受一个表达式作为第二个参数,因此只有在表达式评估为假时,进程才会进入休眠状态:

wait_event(&my_event, (event_occurred == 1) );
/* or */
wait_event_interruptible(&my_event, (event_occurred == 1) );

wait_event()wait_event_interruptible()在调用时简单地评估条件。如果条件为假,则进程将被放入TASK_UNINTERRUPTIBLETASK_INTERRUPTIBLE(对于_interruptible变体)状态,并从运行队列中移除。

可能存在这样的情况,您不仅需要条件为真,而且还需要在等待一定时间后超时。您可以使用wait_event_timeout()来处理这种情况,其原型如下:

wait_event_timeout(wq_head, condition, timeout)

此函数具有两种行为,取决于超时是否已经过去:

  1. timeout已经过去:如果条件评估为假,则函数返回 0,如果评估为真,则返回 1。

  2. timeout尚未过去:如果条件评估为真,则函数返回剩余时间(以 jiffies 为单位,必须至少为 1)。

超时的时间单位是jiffies。因此,您不必担心将秒转换为jiffies,您应该使用msecs_to_jiffies()usecs_to_jiffies()辅助函数,分别将毫秒或微秒转换为jiffies

unsigned long msecs_to_jiffies(const unsigned int m)
unsigned long usecs_to_jiffies(const unsigned int u)

在更改可能破坏等待条件结果的任何变量后,必须调用适当的wake_up*系列函数。也就是说,为了唤醒在等待队列上休眠的进程,您应该调用wake_up()wake_up_all()wake_up_interruptible()wake_up_interruptible_all()中的任何一个。每当调用这些函数时,条件都会被重新评估。如果此时条件为真,则等待队列中的进程(或_all()变体的所有进程)将被唤醒,并且其状态将被设置为TASK_RUNNING;否则(条件为假),什么也不会发生。

/* wakes up only one process from the wait queue. */
wake_up(&my_event);
/* wakes up all the processes on the wait queue. */
wake_up_all(&my_event);:
/* wakes up only one process from the wait queue that is in  * interruptible sleep. 
 */ 
wake_up_interruptible(&my_event)
/* wakes up all the processes from the wait queue that
 * are in interruptible sleep.
 */
wake_up_interruptible_all(&my_event);

由于它们可以被信号中断,您应该检查_interruptible变体的返回值。非零值意味着您的睡眠已被某种信号中断,因此驱动程序应返回ERESTARTSYS

#include <linux/module.h>#include <linux/init.h>#include <linux/sched.h>#include <linux/time.h>#include <linux/delay.h>#include<linux/workqueue.h>
static DECLARE_WAIT_QUEUE_HEAD(my_wq);static int condition = 0;
/* declare a work queue*/static struct work_struct wrk;
static void work_handler(struct work_struct *work)
{
    pr_info(“Waitqueue module handler %s\n”, __FUNCTION__);
    msleep(5000);
    pr_info(“Wake up the sleeping module\n”);
    condition = 1;
    wake_up_interruptible(&my_wq);
}
static int __init my_init(void)
{
    pr_info(“Wait queue example\n”);
    INIT_WORK(&wrk, work_handler);
    schedule_work(&wrk);
    pr_info(“Going to sleep %s\n”, __FUNCTION__);
    wait_event_interruptible(my_wq, condition != 0);
    pr_info(“woken up by the work job\n”);
    return 0;}
void my_exit(void)
{
    pr_info(“waitqueue example cleanup\n”);
}
module_init(my_init);module_exit(my_exit);MODULE_AUTHOR(“John Madieu <john.madieu@labcsmart.com>”);MODULE_LICENSE(“GPL”);

在前面的示例中,当前进程(实际上,这是insmod)将在等待队列中休眠 5 秒,并由工作处理程序唤醒。dmesg的输出如下:

[342081.385491] Wait queue example
[342081.385505] Going to sleep my_init
[342081.385515] Waitqueue module handler work_handler
[342086.387017] Wake up the sleeping module
[342086.387096] woken up by the work job
[342092.912033] waitqueue example cleanup

您可能已经注意到,我没有检查wait_event_interruptible()的返回值。有时(如果不是大多数时候),这可能会导致严重问题。以下是一个真实的故事:我曾经不得不介入一家公司来修复一个 bug,即杀死(或向)用户空间任务发送信号会使他们的内核模块使系统崩溃(恐慌和重启-当然,系统被配置为在恐慌时重新启动)。发生这种情况的原因是因为这个用户进程中有一个线程在其内核模块公开的char设备上执行ioctl()。这导致内核中对给定标志的wait_event_interruptible()的调用,这意味着内核中需要处理一些数据(不能使用select()系统调用)。

那么,他们的错误是什么?发送给进程的信号使wait_event_interruptible()返回而没有设置标志(这意味着数据仍然不可用),它的代码没有检查其返回值,也没有重新检查标志或对应该可用的数据进行合理性检查。数据被访问,就好像标志已经被设置,并且实际上对一个无效的指针进行了解引用。

解决方案可能只需使用以下代码:

if (wait_event_interruptible(...)){
    pr_info(“catching a signal supposed make us crashing\n”);
    /* handle this case and do not access data */
    [….]
} else {
     /* accessing data and processing it */
    […]
}

然而,由于某种原因(与他们的设计有关),我们不得不使其不可中断,这导致我们使用了wait_event()。但是,请注意,此函数将进程置于独占等待状态(不可中断睡眠),这意味着它不能被信号中断。它应该只用于关键任务。在大多数情况下建议使用可中断函数。

现在我们熟悉了内核锁定 API,我们将看一下各种工作推迟机制,这些机制在编写 Linux 设备驱动程序时被广泛使用。

工作推迟机制

工作推迟是 Linux 内核提供的一种机制。它允许您推迟工作/任务,直到系统的工作负载允许它平稳运行或经过一定的时间。根据工作的类型,延迟任务可以在进程上下文或原子上下文中运行。通常使用工作推迟来补充中断处理程序,以弥补其中一些限制,其中一些如下:

  • 中断处理程序必须尽可能快,这意味着处理程序中只能执行关键任务,以便其余任务在系统不太忙时稍后推迟。

  • 在中断上下文中,我们不能使用阻塞调用。睡眠任务应该在进程上下文中被调度。

延迟工作机制允许我们在中断处理程序中执行尽可能少的工作(所谓的top-half),并安排一个异步操作(所谓的bottom-half),以便稍后可以运行并执行其余的操作。现在,底半部分的概念大多被吸收到在进程上下文中运行的延迟工作中,因为常见的是安排可能休眠的工作(与在中断上下文中运行的罕见工作不同,后者不会发生)。Linux 现在有三种不同的实现:softIRQstaskletswork queues。让我们来看看这些:

  • SoftIRQs:这些在原子上下文中执行。

  • Tasklets:这些也在原子上下文中执行。

  • Work queues:这些在进程上下文中运行。

我们将在接下来的几节中详细了解每一个。

SoftIRQs

顾名思义,kernel/softirq.c在内核源树中,任何希望使用此 API 的驱动程序都需要包含<linux/interrupt.h>

请注意,您不能动态注册或销毁 softIRQ。它们在编译时静态分配。此外,softIRQ 的使用受到静态编译内核代码的限制;它们不能与动态可加载模块一起使用。SoftIRQ 由<linux/interrupt.h>中定义的struct softirq_action结构表示,如下所示:

struct softirq_action {
    void (*action)(struct softirq_action *);
};

此结构嵌入了指向softirq动作被触发时要运行的函数的指针。因此,您的 softIRQ 处理程序的原型应如下所示:

void softirq_handler(struct softirq_action *h)

运行 softIRQ 处理程序会导致执行此动作函数。它只有一个参数:指向相应的softirq_action结构的指针。您可以通过open_softirq()函数在运行时注册 softIRQ 处理程序:

void open_softirq(int nr, 
                   void (*action)(struct softirq_action *))

nr表示 softIRQ 的索引,也被视为 softIRQ 的优先级(其中0最高)。action是指向 softIRQ 处理程序的指针。任何可能的索引都在以下enum中列举:

enum
{
    HI_SOFTIRQ=0,   /* High-priority tasklets */    TIMER_SOFTIRQ,  /* Timers */    NET_TX_SOFTIRQ, /* Send network packets */    NET_RX_SOFTIRQ, /* Receive network packets */    BLOCK_SOFTIRQ,  /* Block devices */    BLOCK_IOPOLL_SOFTIRQ, /* Block devices with I/O polling                            blocked on other CPUs */    TASKLET_SOFTIRQ, /* Normal Priority tasklets */    SCHED_SOFTIRQ,   /* Scheduler */    HRTIMER_SOFTIRQ, /* High-resolution timers */    RCU_SOFTIRQ,     /* RCU locking */    NR_SOFTIRQS      /* This only represent the number or                       * softirqs type, 10 actually                       */
};

具有较低索引(最高优先级)的 SoftIRQ 在具有较高索引(最低优先级)的 SoftIRQ 之前运行。内核中所有可用的 softIRQ 的名称都列在以下数组中:

const char * const softirq_to_name[NR_SOFTIRQS] = {
    “HI”, “TIMER”, “NET_TX”, “NET_RX”, “BLOCK”, “BLOCK_IOPOLL”,
        “TASKLET”, “SCHED”, “HRTIMER”, “RCU”
};

可以轻松检查/proc/softirqs虚拟文件的输出,如下所示:

~$ cat /proc/softirqs 
                    CPU0       CPU1       CPU2       CPU3       
          HI:      14026         89        491        104
       TIMER:     862910     817640     816676     808172
      NET_TX:          0          2          1          3
      NET_RX:       1249        860        939       1184
       BLOCK:        130        100        138        145
    IRQ_POLL:          0          0          0          0
     TASKLET:      55947         23        108        188
       SCHED:    1192596     967411     882492     835607
     HRTIMER:          0          0          0          0
         RCU:     314100     302251     304380     298610
~$

kernel/softirq.c中声明了一个struct softirq_actionNR_SOFTIRQS条目数组:

static struct softirq_action softirq_vec[NR_SOFTIRQS] ;

该数组中的每个条目可能只包含一个 softIRQ。因此,最多可以有NR_SOFTIRQS(在撰写本文时的最后版本为 v4.19,为 10)个已注册的 softIRQ:

void open_softirq(int nr, 
                   void (*action)(struct softirq_action *))
{
    softirq_vec[nr].action = action;
}

其中一个具体的例子是网络子系统,它在net/core/dev.c中注册所需的 softIRQ,如下所示:

open_softirq(NET_TX_SOFTIRQ, net_tx_action);
open_softirq(NET_RX_SOFTIRQ, net_rx_action);

在注册的 softIRQ 有机会运行之前,它应该被激活/安排。要做到这一点,您必须调用raise_softirq()raise_softirq_irqoff()(如果中断已关闭):

void __raise_softirq_irqoff(unsigned int nr)
void raise_softirq_irqoff(unsigned int nr)
void raise_softirq(unsigned int nr)

第一个函数只是在每个 CPU 的 softIRQ 位图中设置适当的位(kernel/softirq.c中为每个 CPU 分配的struct irq_cpustat_t数据结构中的__softirq_pending字段),如下所示:

irq_cpustat_t irq_stat[NR_CPUS] ____cacheline_aligned;
EXPORT_SYMBOL(irq_stat);

这允许在检查标志时运行。此函数已在此处描述供学习目的,并且不应直接使用。

raise_softirq_irqoff 需要在中断被禁用时调用。首先,它内部调用 __raise_softirq_irqoff(),如前所述,来激活 softIRQ。然后,它通过 in_interrupt() 宏检查是否从中断(硬件或软件)上下文中调用(该宏简单地返回 current_thread_info( )->preempt_count 的值,其中 0 表示启用了抢占。这表示我们不在中断上下文中。大于 0 的值表示我们在中断上下文中)。如果 in_interrupt() > 0,则不执行任何操作,因为我们在中断上下文中。这是因为 softIRQ 标志在任何 I/O IRQ 处理程序的退出路径上被检查(对于 ARM 是 asm_do_IRQ(),对于 x86 平台是 do_IRQ(),它调用 irq_exit())。在这里,softIRQ 在中断上下文中运行。但是,如果 in_interrupt() == 0,那么会调用 wakeup_softirqd()。这负责唤醒本地 CPU 的 ksoftirqd 线程(它调度它)以确保 softIRQ 很快运行,但这次是在进程上下文中。

raise_softirq 首先调用 local_irq_save()(在保存当前中断标志后禁用本地处理器上的中断)。然后调用 raise_softirq_irqoff(),如前所述,在本地 CPU 上调度 softIRQ(请记住,必须在本地 CPU 上禁用 IRQ 时调用此函数)。最后,它调用 local_irq_restore() 来恢复先前保存的中断标志。

关于 softIRQ,有一些需要记住的事情:

  • softIRQ 永远不会抢占另一个 softIRQ。只有硬件中断可以。SoftIRQ 以高优先级执行,禁用调度程序抢占,但启用 IRQ。这使得 softIRQ 适用于系统上最关键和重要的延迟处理。

  • 当处理程序在 CPU 上运行时,该 CPU 上的其他 softIRQ 被禁用。但是 softIRQ 可以并发运行。在 softIRQ 运行时,另一个 softIRQ(甚至是相同的 softIRQ)可以在另一个处理器上运行。这是 softIRQ 相对于 hardIRQ 的主要优势之一,也是它们被用于可能需要大量 CPU 力量的网络子系统的原因。

  • 对于 softIRQ 之间的锁定(甚至是同一个 softIRQ,因为它可能在不同的 CPU 上运行),应该使用 spin_lock()spin_unlock()

  • softIRQ 主要在硬件中断处理程序的返回路径上调度。ksoftirqd``CONFIG_SMP 已启用。更多信息请参见 timer_tick()update_process_times()run_local_timers()

  • 通过调用 local_bh_enable() 函数(主要由网络子系统调用,用于处理软 IRQ 的数据包接收/发送)。

  • 在任何 I/O IRQ 处理程序的退出路径上(参见 do_IRQ,它调用 irq_exit(),后者又调用 invoke_softirq())。

  • 当本地的 ksoftirqd 获得 CPU 时(也就是说,它被唤醒)。

负责遍历 softIRQ 挂起位图并运行它们的实际内核函数是 __do_softirq(),它在 kernel/softirq.c 中定义。该函数始终在本地 CPU 上禁用中断时调用。它执行以下任务:

  1. 一旦调用,该函数将当前每个 CPU 挂起的 softIRQ 位图保存在一个所谓的挂起变量中,并通过 __local_bh_disable_ip 本地禁用 softIRQ。

  2. 然后重置当前每个 CPU 挂起位掩码(已保存),然后重新启用中断(softIRQ 在启用中断时运行)。

  3. 之后,它进入一个 while 循环,检查保存的位图中是否有挂起的 softIRQ。如果没有 softIRQ 挂起,则什么也不会发生。否则,它将执行每个挂起 softIRQ 的处理程序,并注意增加它们的执行统计。

  4. 在执行完所有挂起的处理程序之后(我们已经退出了while循环),__do_softirq()再次读取每个 CPU 的挂起位掩码(需要禁用 IRQ 并将它们保存到相同的挂起变量中),以检查在while循环中是否安排了任何 softIRQs。如果有任何挂起的 softIRQs,整个过程将重新启动(基于goto循环),从步骤 2开始。这有助于处理例如重新安排自己的 softIRQs。

但是,如果发生以下条件之一,__do_softirq()将不会重复:

  • 它已经重复了MAX_SOFTIRQ_RESTART次,该次数在kernel/softirq.c中设置为10。这实际上是 softIRQ 处理循环的限制,而不是先前描述的while循环的上限。

  • 它已经占用了 CPU 超过MAX_SOFTIRQ_TIME,在kernel/softirq.c中设置为 2 毫秒(msecs_to_jiffies(2)),因为这会阻止调度程序被启用。

如果发生两种情况中的一种,__do_softirq()将中断其循环并调用wakeup_softirqd()来唤醒本地的ksoftirqd线程,后者将在进程上下文中执行挂起的 softIRQs。由于do_softirq在内核中的许多点被调用,很可能在ksoftirqd有机会运行之前,另一个__do_softirqs的调用将处理挂起的 softIRQs。

请注意,softIRQs 并不总是在原子上下文中运行,但在这种情况下,这是非常特定的。下一节将解释 softIRQs 可能在进程上下文中执行的方式和原因。

关于 ksoftirqd

ksoftirqd是一个每个 CPU 的内核线程,用于处理未处理的软件中断。它在内核引导过程中早期生成,如kernel/softirq.c中所述:

static __init int spawn_ksoftirqd(void)
{
  cpuhp_setup_state_nocalls(CPUHP_SOFTIRQ_DEAD,                             “softirq:dead”, NULL,
                            takeover_tasklets);
    BUG_ON(smpboot_register_percpu_thread(&softirq_threads));
    return 0;
}
early_initcall(spawn_ksoftirqd);

运行top命令后,您将能够看到一些ksoftirqd/n条目,其中n是运行ksoftirqd线程的 CPU 的逻辑 CPU 索引。由于ksoftirqds在进程上下文中运行,它们等同于经典的进程/线程,因此它们对 CPU 的竞争要求也是一样的。ksoftirqd长时间占用 CPU 可能表明系统负载很重。

现在我们已经完成了对 Linux 内核中第一个工作延迟机制的讨论,我们将讨论 tasklets,它们是一种替代(从原子上下文的角度来看)softIRQs 的机制,尽管前者是使用后者构建的。

任务

Tasklets 是基于HI_SOFTIRQTASKLET_SOFTIRQ softIRQ 构建的底半部,唯一的区别是基于HI_SOFTIRQ的 tasklets 在基于TASKLET_SOFTIRQ的 tasklets 之前运行。这意味着 tasklets 是 softIRQs,因此它们遵循相同的规则。但是,与 softIRQs 不同,两个相同的 tasklets 永远不会同时运行。tasklet API 非常基本和直观。

任务是由struct tasklet_struct结构表示,该结构在<linux/interrupt.h>中定义。该结构的每个实例表示一个唯一的任务:

struct tasklet_struct {
    struct tasklet_struct *next; /* next tasklet in the list */
    unsigned long state;         /* state of the tasklet,
                                  * TASKLET_STATE_SCHED or
                                  * TASKLET_STATE_RUN */
    atomic_t count;              /* reference counter */
    void (*func)(unsigned long); /* tasklet handler function */
    unsigned long data; /* argument to the tasklet function */
};

func成员是 tasklet 的处理程序,将由底层 softIRQ 执行。它相当于 softIRQ 的action,具有相同的原型和相同的参数含义。data将作为其唯一参数传递。

您可以使用tasklet_init()函数在运行时动态分配和初始化 tasklet。对于静态方法,您可以使用DECLARE_TASKLET宏。您选择的选项将取决于您是否需要直接或间接引用 tasklet。使用tasklet_init()需要将 tasklet 结构嵌入到一个更大的动态分配对象中。初始化的 tasklet 可以默认调度-你可以说它是启用的。DECLARE_TASKLET_DISABLED是声明默认禁用 tasklet 的替代方法,这将需要调用tasklet_enable()函数使 tasklet 可调度。tasklet 通过tasklet_schedule()tasklet_hi_schedule()函数进行调度(类似于触发 softIRQ)。您可以使用tasklet_disable()来禁用 tasklet。此函数禁用 tasklet,并且只有在 tasklet 终止执行后才会返回(假设它正在运行)。之后,tasklet 仍然可以被调度,但在再次启用之前,它不会在 CPU 上运行。也可以使用异步变体tasklet_disable_nosync(),即使终止尚未发生,也会立即返回。此外,已多次禁用的 tasklet 应该被启用相同次数(这要归功于它的count字段):

DECLARE_TASKLET(name, func, data)
DECLARE_TASKLET_DISABLED(name, func, data);
tasklet_init(t, tasklet_handler, dev);
void tasklet_enable(struct tasklet_struct*);
void tasklet_disable(struct tasklet_struct *);
void tasklet_schedule(struct tasklet_struct *t);
void tasklet_hi_schedule(struct tasklet_struct *t);

内核在两个不同的队列中维护正常优先级和高优先级的 tasklet。队列实际上是单链表,每个 CPU 都有自己的队列对(低优先级和高优先级)。每个处理器都有自己的对。tasklet_schedule()将 tasklet 添加到正常优先级列表中,从而使用TASKLET_SOFTIRQ标志调度相关的 softIRQ。使用tasklet_hi_schedule(),tasklet 将被添加到高优先级列表中,从而使用HI_SOFTIRQ标志调度相关的 softIRQ。一旦 tasklet 被调度,它的TASKLET_STATE_SCHED标志就会被设置,并且 tasklet 会被添加到队列中。在执行时,TASKLET_STATE_RUN标志被设置,TASKLET_STATE_SCHED状态被移除,从而允许 tasklet 在执行期间被重新调度,无论是由 tasklet 本身还是在中断处理程序中。

高优先级 tasklet 用于具有低延迟要求的软中断处理程序。对已经被调度但尚未开始执行的 tasklet 调用tasklet_schedule()将不会产生任何效果,导致 tasklet 只执行一次。tasklet 可以重新调度自己,这意味着你可以在 tasklet 中安全地调用tasklet_schedule()。高优先级 tasklet 总是在正常 tasklet 之前执行,应该谨慎使用;否则,可能会增加系统延迟。停止 tasklet 就像调用tasklet_kill()一样简单,这将阻止 tasklet 再次运行,或者在杀死 tasklet 之前等待它完成,如果 tasklet 当前已被调度运行。如果 tasklet 重新调度自己,你应该在调用此函数之前阻止 tasklet 再次调度自己:

void tasklet_kill(struct tasklet_struct *t);

话虽如此,让我们来看一下以下 tasklet 代码使用示例:

#include <linux/kernel.h>#include <linux/module.h>#include <linux/interrupt.h> /* for tasklets API */
char tasklet_data[] =     “We use a string; but it could be pointer to a structure”;
/* Tasklet handler, that just prints the data */void tasklet_work(unsigned long data){    printk(“%s\n”, (char *)data);}
static DECLARE_TASKLET(my_tasklet, tasklet_function,                       (unsigned long) tasklet_data);static int __init my_init(void){    tasklet_schedule(&my_tasklet);    return 0;}void my_exit(void){    tasklet_kill(&my_tasklet);
}module_init(my_init);module_exit(my_exit);MODULE_AUTHOR(“John Madieu <john.madieu@gmail.com>”);MODULE_LICENSE(“GPL”);

在前面的代码中,我们静态声明了我们的my_tasklet tasklet 以及当调度此 tasklet 时应该调用的函数,以及将作为参数传递给此函数的数据。

重要提示

因为相同的 tasklet 永远不会同时运行,所以 tasklet 和自身之间的锁定情况不需要解决。然而,任何在两个 tasklet 之间共享的数据都应该用spin_lock()spin_unlock()来保护。记住,tasklet 是在 softIRQ 的基础上实现的。

工作队列

在前一节中,我们处理了 tasklet,它们是原子延迟机制。除了原子机制,还有一些情况下我们可能希望在延迟任务中进行睡眠。工作队列允许这样做。

工作队列是一种异步工作推迟机制,在内核中被广泛使用,允许它们在进程执行上下文中异步运行专用函数。这使它们适用于长时间运行和耗时的任务,或者需要休眠的工作,从而提高用户体验。在工作队列子系统的核心,有两个数据结构可以解释这种机制背后的概念:

  • 要延迟的工作(即工作项)在内核中由 struct work_struct 的实例表示,它指示要运行的处理函数。通常,这个结构是用户结构的工作定义的第一个元素。如果需要在提交给工作队列后延迟运行工作,内核提供了 struct delayed_work。工作项是一个基本结构,它保存了要异步执行的函数的指针。总之,我们可以列举两种工作项结构:

--struct work_struct 结构,安排一个任务在稍后的时间运行(尽快在系统允许的情况下)。

--struct delayed_work 结构,安排一个任务在至少给定的时间间隔之后运行。

  • 工作队列本身由 struct workqueue_struct 表示。这是工作被放置的结构。它是一个工作项队列。

除了这些数据结构之外,还有两个通用术语你应该熟悉:

  • 工作线程,这些是专用线程,按顺序执行队列中的函数。

  • Workerpools 是一组用于管理工作线程的工作线程(线程池)。

使用工作队列的第一步是创建一个工作项,由 linux/workqueue.h 中定义的 struct work_struct 或延迟变体的 struct delayed_work 表示。内核提供了 DECLARE_WORK 宏用于静态声明和初始化工作结构,或者 INIT_WORK 宏用于动态执行相同的操作。如果需要延迟工作,可以使用 INIT_DELAYED_WORK 宏进行动态分配和初始化,或者使用 DECLARE_DELAYED_WORK 进行静态选项:

DECLARE_WORK(name, function)
DECLARE_DELAYED_WORK(name, function)
INIT_WORK(work, func);
INIT_DELAYED_WORK(work, func);

以下代码展示了我们的工作项结构是什么样子的:

struct work_struct {
    atomic_long_t data;
    struct list_head entry;
    work_func_t func;
#ifdef CONFIG_LOCKDEP
    struct lockdep_map lockdep_map;
#endif
};
struct delayed_work {
    struct work_struct work;
    struct timer_list timer;
    /* target workqueue and CPU ->timer uses to queue ->work */
    struct workqueue_struct *wq;
    int cpu;
};

func 字段是 work_func_t 类型,告诉我们有关 work 函数头的一些信息:

typedef void (*work_func_t)(struct work_struct *work);

work 是一个输入参数,对应于与你的工作相关的工作结构。如果你提交了一个延迟的工作,这将对应于 delayed_work.work 字段。在这里,需要使用 to_delayed_work() 函数来获取基础的延迟工作结构:

struct delayed_work *to_delayed_work(struct work_struct *work)

工作队列允许你的驱动程序创建一个内核线程,称为工作线程,来处理延迟的工作。可以使用以下函数创建一个新的工作队列:

struct workqueue_struct *create_workqueue(const char *name                                           name)
struct workqueue_struct
    *create_singlethread_workqueue(const char *name)

create_workqueue() 在系统的每个 CPU 上创建一个专用线程(工作线程),这可能不是一个好主意。在一个 8 核系统上,这将导致创建 8 个内核线程来运行提交到你的工作队列的工作。在大多数情况下,一个全局的内核线程应该足够了。在这种情况下,你应该使用 create_singlethread_workqueue(),它创建一个单线程工作队列;也就是说,在整个系统中只有一个工作线程。可以在同一个队列上排定普通或延迟工作。要在创建的工作队列上安排工作,可以使用 queue_work()queue_delayed_work(),具体取决于工作的性质:

bool queue_work(struct workqueue_struct *wq,
                struct work_struct *work)
bool queue_delayed_work(struct workqueue_struct *wq,
                        struct delayed_work *dwork,
                        unsigned long delay)

这些函数如果工作已经在队列中则返回 false,否则返回 true。queue_dalayed_work()可用于计划(延迟)工作以在给定延迟后执行。延迟的时间单位是 jiffies。如果您不想麻烦秒到 jiffies 的转换,可以使用msecs_to_jiffies()usecs_to_jiffies()辅助函数,分别将毫秒或微秒转换为 jiffies:

unsigned long msecs_to_jiffies(const unsigned int m)
unsigned long usecs_to_jiffies(const unsigned int u)

以下示例使用 200 毫秒作为延迟:

schedule_delayed_work(&drvdata->tx_work, usecs_to_                      jiffies(200));

提交的工作项可以通过调用cancel_delayed_work()cancel_delayed_work_sync()cancel_work_sync()来取消:

bool cancel_work_sync(struct work_struct *work)
bool cancel_delayed_work(struct delayed_work *dwork)
bool cancel_delayed_work_sync(struct delayed_work *dwork)

以下描述了这些函数的作用:

  • cancel_work_sync()同步取消给定的工作队列条目。换句话说,它取消work并等待其执行完成。内核保证当从此函数返回时,工作不会挂起或在任何 CPU 上执行,即使工作迁移到另一个工作队列或重新排队。如果work挂起,则返回true,否则返回false

  • cancel_delayed_work()异步取消挂起的工作队列条目(延迟的)。如果dwork挂起并取消,则返回true(非零值),如果没有挂起,则返回false,可能是因为它实际上正在运行,因此在cancel_delayed_work()之后可能仍在运行。为了确保工作确实运行到结束,您可能希望使用flush_workqueue(),它会刷新给定队列中的每个工作项,或者使用cancel_delayed_work_sync(),它是cancel_delayed_work()的同步版本。

要等待所有工作项完成,可以调用flush_workqueue()。完成工作队列后,应使用destroy_workqueue()销毁它。这两个选项可以在以下代码中看到:

void flush_workqueue(struct worksqueue_struct * queue);
void destroy_workqueue(structure workqueque_struct *queue);

当您等待任何挂起的工作执行时,_sync变体函数会休眠,这意味着它们只能从进程上下文中调用。

内核共享队列

在大多数情况下,您的代码不一定需要拥有自己专用的线程集的性能,因为create_workqueue()为每个 CPU 创建一个工作线程,所以在非常大的多 CPU 系统上使用它可能是一个坏主意。在这种情况下,您可能希望使用内核共享队列,它具有预先分配的一组内核线程(在引导期间通过workqueue_init_early()函数提前分配)来运行工作。

这个全局内核工作队列被称为system_wq,在kernel/workqueue.c中定义。每个 CPU 都有一个实例,每个实例都由一个名为events/n的专用线程支持,其中n是线程绑定的处理器编号。您可以使用以下函数之一将工作排队到系统的默认工作队列:

int schedule_work(struct work_struct *work);
int schedule_delayed_work(struct delayed_work *dwork,
                            unsigned long delay);
int schedule_work_on(int cpu, struct work_struct *work);
int schedule_delayed_work_on(int cpu,
                             struct delayed_work *dwork,
                             unsigned long delay);

schedule_work()立即安排工作,该工作将在当前处理器上的工作线程唤醒后尽快执行。使用schedule_delayed_work(),工作将在延迟计时器滴答后的未来放入队列中。_on变体用于在特定 CPU 上安排工作(这不需要是当前 CPU)。这些函数中的每一个都在系统的共享工作队列system_wq上排队工作,该队列在kernel/workqueue.c中定义:

struct workqueue_struct *system_wq __read_mostly;
EXPORT_SYMBOL(system_wq);

要刷新内核全局工作队列-也就是确保给定的工作批次已完成-可以使用flush_scheduled_work()

void flush_scheduled_work(void);

flush_scheduled_work()是一个包装器,它在system_wq上调用flush_workqueue()。请注意,system_wq中可能有您尚未提交且无法控制的工作。因此,完全刷新此工作队列是过度的。建议改用cancel_delayed_work_sync()cancel_work_sync()

提示

除非您有充分的理由创建专用线程,否则首选默认(内核全局)线程。

工作队列-新一代

原始(现在是传统的)工作队列实现使用了两种工作队列:一种是系统范围内单个线程,另一种是每个 CPU 一个线程。然而,由于 CPU 数量的增加,这导致了一些限制:

  • 在非常大的系统上,内核可能在启动时耗尽进程 ID(默认为 32k),甚至在 init 启动之前。

  • 多线程工作队列提供了较差的并发管理,因为它们的线程与系统上的其他线程竞争 CPU。由于有更多的 CPU 竞争者,这引入了一些开销;即比必要的更多的上下文切换。

  • 消耗了比实际需要的更多资源。

此外,需要动态或细粒度并发级别的子系统必须实现自己的线程池。因此,设计了一个新的工作队列 API,并计划删除传统的工作队列 API(create_workqueue()create_singlethread_workqueue()create_freezable_workqueue())。然而,这些实际上是新 API 的包装器,即所谓的并发管理的工作队列。这是通过每个 CPU 的工作线程池来实现的,所有工作队列共享这些线程池,以自动提供动态和灵活的并发级别,从而为 API 用户抽象出这些细节。

并发管理的工作队列

并发管理的工作队列是工作队列 API 的升级。使用这个新 API 意味着你必须在alloc_workqueue()alloc_ordered_workqueue()之间选择一个宏来创建工作队列。这些宏在成功时都分配一个工作队列并返回指针,失败时返回 NULL。返回的工作队列可以使用destroy_workqueue()函数释放。

#define alloc_workqueue(fmt, flags, max_active, args...)
#define alloc_ordered_workqueue(fmt, flags, args...)
void destroy_workqueue(struct workqueue_struct *wq)

fmt是工作队列名称的printf格式,而args...fmt的参数。destroy_workqueue()在完成工作队列后调用。内核在销毁工作队列之前会先完成所有当前挂起的工作。alloc_workqueue()基于max_active创建一个工作队列,通过限制在任何给定 CPU 上同时执行(处于可运行状态的工作线程)的工作(任务)数量来定义并发级别。例如,max_active为 5 意味着在同一时间内每个 CPU 上最多可以执行五个工作队列的工作项。另一方面,alloc_ordered_workqueue()创建一个按队列顺序依次处理每个工作项的工作队列(即 FIFO 顺序)。

flags控制工作项如何排队、分配执行资源、调度和执行。在这个新 API 中使用了各种标志。让我们来看看其中一些:

  • WQ_UNBOUND:传统的工作队列每个 CPU 都有一个工作线程,并且设计为在提交任务的 CPU 上运行任务。内核调度程序只能在定义的 CPU 上调度工作线程。采用这种方法,即使是一个工作队列也可能阻止 CPU 空闲并关闭,从而导致增加功耗或者调度策略不佳。WQ_UNBOUND关闭了这种行为。工作不再绑定到 CPU,因此被称为无绑定工作队列。不再有局部性,调度程序可以根据需要在任何 CPU 上重新调度工作线程。调度程序现在有最后的决定权,可以平衡 CPU 负载,特别是对于长时间且有时 CPU 密集的工作。

  • WQ_MEM_RECLAIM:设置此标志用于需要在内存回收路径中保证前进进度的工作队列(当空闲内存严重不足时;在这种情况下,系统处于内存压力下。在这种情况下,GFP_KERNEL分配可能会阻塞并死锁整个工作队列)。然后,工作队列将保证有一个可用的工作线程,一个所谓的拯救者线程为其保留,无论内存压力如何,以便它可以继续前进。为每个设置了此标志的工作队列分配一个拯救者线程。

让我们考虑这样一种情况:我们的工作队列W中有三个工作项(w1w2w3)。w1完成一些工作,然后等待w3完成(假设它依赖于w3的计算结果)。之后,w2(与其他工作项无关)执行一些kmalloc()分配(GFP_KERNEL)。现在,似乎没有足够的内存。虽然w2被阻塞,但它仍然占据了W的工作队列。这导致w3无法运行,尽管w2w3之间没有依赖关系。由于没有足够的内存可用,无法分配新线程来运行w3。预先分配的线程肯定会解决这个问题,不是通过为w2魔法般地分配内存,而是通过运行w3,以便w1可以继续其工作,依此类推。当有足够的可用内存时,w2将尽快继续其进展。这个预先分配的线程就是所谓的拯救者线程。如果您认为工作队列可能在内存回收路径中使用,则必须设置WQ_MEM_RECLAIM标志。此标志取代了旧的WQ_RESCUER标志,如以下提交所示:git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/commit/?id=493008a8e475771a2126e0ce95a73e35b371d277

  • WQ_FREEZABLE:此标志用于电源管理目的。设置了此标志的工作队列将在系统挂起或休眠时被冻结。在冻结路径上,将处理工作队列中所有当前工作的工作者。冻结完成后,直到系统解冻,将不会执行新的工作项。文件系统相关的工作队列可能使用此标志来确保对文件的修改被推送到磁盘或在冻结路径上创建休眠镜像,并且在创建休眠镜像后不对磁盘进行修改。在这种情况下,非可冻结项目可能会以不同的方式执行操作,这可能导致文件系统损坏。例如,XFS 的所有内部工作队列都设置了此标志(请参阅fs/xfs/xfs_super.c),以确保在冻结器基础结构冻结内核线程并创建休眠镜像后不会对磁盘进行进一步更改。如果您的工作队列可以作为系统的休眠/挂起/恢复过程的一部分运行任务,则不应设置此标志。有关此主题的更多信息,请参阅Documentation/power/freezing-of-tasks.txt,以及查看内核的内部freeze_workqueues_begin()thaw_workqueues()函数。

  • WQ_HIGHPRI:设置了此标志的任务将立即运行,并且不会等待 CPU 可用。此标志用于排队需要高优先级执行的工作项的工作队列。这样的工作队列具有具有较高优先级级别的工作者线程(较低的nice值)。

在 CMWQ 的早期,高优先级的工作项只是排队在全局正常优先级工作列表的开头,以便它们可以立即运行。如今,正常优先级和高优先级工作队列之间没有交互,因为每个工作队列都有自己的工作列表和自己的工作池。高优先级工作队列的工作项被排队到目标 CPU 的高优先级工作池。这个工作队列中的任务不应该阻塞太多。例如,加密和块子系统使用这个标志。

  • WQ_CPU_INTENSIVE:作为 CPU 密集型工作队列的一部分的工作项可能会消耗大量 CPU 周期,并且不会参与工作队列的并发管理。相反,它们的执行由系统调度程序调节,就像任何其他任务一样。这使得这个标志对可能占用 CPU 周期的绑定工作项非常有用。尽管它们的执行由系统调度程序调节,但它们的执行开始仍然由并发管理调节,并且可运行的非 CPU 密集型工作项可能会延迟 CPU 密集型工作项的执行。实际上,加密和 dm-crypt 子系统使用这样的工作队列。为了防止这样的任务延迟执行其他非 CPU 密集型工作项,当工作队列代码确定 CPU 是否可用时,它们将不被考虑在内。

为了符合旧的工作队列 API,进行以下映射以保持此 API 与原始 API 兼容:

  • create_workqueue(name)映射到alloc_workqueue(name,WQ_MEM_RECLAIM, 1)

  • create_singlethread_workqueue(name)映射到alloc_ordered_workqueue(name, WQ_MEM_RECLAIM)

  • create_freezable_workqueue(name)映射到alloc_workqueue(name,WQ_FREEZABLE | WQ_UNBOUND|WQ_MEM_RECLAIM, 1)

总之,alloc_ordered_workqueue()实际上取代了create_freezable_workqueue()create_singlethread_workqueue()(根据以下提交:git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=81dcaf6516d8)。使用alloc_ordered_workqueue()分配的工作队列是无绑定的,并且max_active设置为1

在工作队列中的计划项方面,使用queue_work_on()将被排队到特定 CPU 的工作项将在该 CPU 上执行。通过queue_work()排队的工作项将优先使用排队 CPU,尽管这种局部性不能保证。

重要提示

请注意,schedule_work()是一个包装器,它在系统工作队列(system_wq)上调用queue_work(),而schedule_work_on()queue_work_on()的包装器。另外,请记住system_wq = alloc_workqueue(“events”, 0, 0);。查看内核源代码中的kernel/workqueue.c中的workqueue_init_early()函数,以了解如何创建其他系统范围的工作队列。

内存回收是 Linux 内核在内存分配路径上的一种机制。这包括在将当前内存内容抛弃到其他地方后分配内存。

到此为止,我们已经完成了对工作队列和特别是并发管理的工作队列的研究。接下来,我们将介绍 Linux 内核中断管理,这是以前大部分机制将被征求意见的地方。

Linux 内核中断管理

除了为进程和用户请求提供服务之外,Linux 内核的另一个工作是管理和与硬件通信。这要么是从 CPU 到设备,要么是从设备到 CPU。这是通过中断实现的。中断是由外部硬件设备发送给处理器的请求立即处理的信号。在中断对 CPU 可见之前,这个中断应该由中断控制器启用,中断控制器是一个独立的设备,其主要工作是将中断路由到 CPU。

中断可能有五种状态:

  • 活动:已被处理器核心(PE)确认并正在处理的中断。在处理过程中,同一中断的另一个断言不会作为中断呈现给处理器核心,直到初始中断不再活动为止。

  • 挂起(已断言):在硬件中被识别为已断言的中断,或者由软件生成,并且正在等待目标 PE 处理。对于大多数硬件设备来说,直到其“中断挂起”位被清除之前,它们不会生成其他中断。已禁用的中断不能处于挂起状态,因为它从未被断言,并且会立即被中断控制器丢弃。

  • 活动且挂起:从中断的一个断言处于活动状态,并从随后的断言处于挂起状态。

  • 非活动:不活动或未挂起的中断。停用会清除中断的活动状态,从而允许挂起时再次被接收。

  • 已禁用/已停用:对 CPU 来说是未知的,甚至中断控制器也看不到。这将永远不会被断言。已禁用的中断会丢失。

重要提示

有中断控制器,禁用中断意味着屏蔽该中断,反之亦然。在本书的其余部分中,我们将考虑禁用与屏蔽相同,尽管这并不总是正确的。

复位时,处理器会禁用所有中断,直到它们再次由初始化代码启用(在我们的情况下是 Linux 内核的工作)。通过设置/清除处理器状态/控制寄存器中的位来启用/禁用中断。在中断断言(发生中断)时,处理器将检查中断是否被屏蔽,如果被屏蔽则不会执行任何操作。一旦取消屏蔽,处理器将选择一个挂起的中断(如果有的话,顺序无关紧要,因为它将为每个挂起的中断执行此操作,直到它们全部被服务),并将在称为向量表的特殊位置执行一个特殊用途的函数(内核中断核心代码)。在处理器开始执行此 ISR 之前,它会进行一些上下文保存(包括中断的未屏蔽状态),然后在本地 CPU 上屏蔽中断(中断可以被断言,并且一旦取消屏蔽就会被服务)。一旦 ISR 运行,我们可以说中断正在被服务。

以下是 ARM Linux 上完整的 IRQ 处理流程。当中断发生并且 PSR 中启用了中断时,以下情况会发生:

  1. ARM 核心将禁用在本地 CPU 上发生的进一步中断。

  2. 然后,ARM 核心将把当前程序状态寄存器(CPSR)放入保存的程序状态寄存器(SPSR),将当前程序计数器(PC)放入链接寄存器(LR),然后切换到 IRQ 模式。

  3. 最后,ARM 处理器将引用向量表并跳转到异常处理程序。在我们的情况下,它跳转到 IRQ 的异常处理程序,在 Linux 内核中对应于arch/arm/kernel/entry-armv.S中定义的vector_stub宏。

这三个步骤由 ARM 处理器本身完成。现在,内核开始行动:

  1. vector_stub宏检查我们用什么处理器模式到达这里 - 内核模式或用户模式 - 并相应地确定要调用的宏;要么是__irq_user,要么是__irq_svc

  2. __irq_svc()将在内核堆栈上保存寄存器(从r0r12),然后调用irq_handler()宏,如果定义了CONFIG_MULTI_IRQ_HANDLER,则调用handle_arch_irq()(存在于arch/arm/include/asm/entry-macro-multi.S中),否则调用arch_irq_handler_default(),其中handle_arch_irq是在arch/arm/kernel/setup.c中设置的指向函数的全局指针(在setup_arch()函数内)。

  3. 现在,我们需要确定硬件 IRQ 编号,这就是asm_do_IRQ()所做的。然后它调用该硬件 IRQ 上的handle_IRQ(),然后调用__handle_domain_irq(),它将硬件 irq 转换为相应的 Linux IRQ 编号(irq = irq_find_mapping(domain, hwirq))并在解码的 Linux IRQ 上调用generic_handle_irq()generic_handle_irq(irq))。

  4. generic_handle_irq()将寻找与解码的 Linux IRQ 对应的 IRQ 描述符结构(Linux 对中断的视图)(struct irq_desc *desc = irq_to_desc(irq))并在此描述符上调用generic_handle_irq_desc(),这将导致desc->handle_irq(desc)desc->handle_irq对应于在此 IRQ 映射期间使用irq_set_chip_and_handler()设置的高级 IRQ 处理程序。

  5. desc->handle_irq()可能会导致调用handle_level_irq()handle_simple_irq()handle_edge_irq()等。

  6. 高级 IRQ 处理程序调用我们的 ISR。

  7. 完成 ISR 后,irq_svc将返回并通过恢复寄存器(r0-r12)、PC 和 CSPR 来恢复处理器状态。

重要提示

回到步骤 1,在中断期间,ARM 核心会禁用本地 CPU 上的进一步 IRQ。值得一提的是,在早期的 Linux 内核中,有两类中断处理程序:一类是在禁用中断的情况下运行(即设置了旧的IRQF_DISABLED标志),另一类是在启用中断的情况下运行:它们是可中断的。前者被称为快速处理程序,而后者被称为慢处理程序。对于后者,在调用处理程序之前,内核实际上重新启用了中断。由于中断上下文的堆栈大小与进程堆栈相比非常小,所以如果我们在中断上下文中运行给定的 IRQ 处理程序,同时其他中断继续发生,甚至正在服务的中断也会发生堆栈溢出是没有意义的。这得到了提交的确认git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=e58aa3d2d0cc,该提交废弃了在启用 IRQ 的情况下运行中断处理程序的事实。从这个补丁开始,在执行 IRQ 处理程序期间,IRQ 保持禁用(在 ARM 核心在本地 CPU 上禁用它们后保持不变)。此外,上述标志已经被提交git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=d8bf368d0631完全删除,自 Linux v4.1 以来。

设计中断处理程序

现在我们熟悉了底半部和延迟机制的概念,我们需要实现中断处理程序。在本节中,我们将处理一些具体的问题。如今,中断处理程序在本地 CPU 上禁用中断的事实意味着我们需要在 ISR 设计中遵守一定的约束:

  • 执行时间:由于 IRQ 处理程序在本地 CPU 上禁用中断运行,代码必须尽可能短小和快速,以确保先前禁用的 CPU 本地中断能够快速重新启用,以便不会错过其他 IRQ。耗时的 IRQ 处理程序可能会显著改变系统的实时特性并减慢系统速度。

  • 执行上下文:由于中断处理程序在原子上下文中执行,因此禁止睡眠(或任何可能导致睡眠的机制,例如互斥、从内核到用户空间或反之的数据复制等)。任何需要或涉及睡眠的代码部分必须延迟到另一个更安全的上下文中(即进程上下文)。

IRQ 处理程序需要给出两个参数:要为其安装处理程序的中断线,以及外围设备的唯一标识符(主要用作上下文数据结构;也就是关联硬件设备的每个设备或私有结构的指针):

typedef irqreturn_t (*irq_handler_t)(int, void *);

想要启用给定中断并为其注册 ISR 的设备驱动程序应调用 <linux/interrupt.h> 中声明的 request_irq()。这必须包含在驱动程序代码中。

int request_irq(unsigned int irq,
               irq_handler_t handler,
               unsigned long flags,
               const char *name,
               void *dev)

虽然上述 API 在不再需要时需要调用者释放 IRQ(即在驱动程序分离时),但您可以使用设备管理的变体 devm_request_irq(),它包含内部逻辑,可以自动处理释放 IRQ 线。它具有以下原型:

int devm_request_irq(struct device *dev, unsigned int irq,
                     irq_handler_t handler,                      unsigned long flags,
                     const char *name, void *dev)

除了额外的 dev 参数(即需要中断的设备),devm_request_irq()request_irq() 都期望以下参数:

  • irq,即中断线(即发出设备的中断号)。在验证请求之前,内核将确保请求的中断是有效的,并且尚未分配给另一个设备,除非两个设备都要求共享此 IRQ 线(借助 flags 的帮助)。

  • handler,是指向中断处理程序的函数指针。

  • flags,表示中断标志。

  • name,一个表示生成或声明此中断的设备名称的 ASCII 字符串。

  • dev 应该对每个注册的处理程序是唯一的。对于共享的 IRQ,这不能是 NULL,因为它用于通过内核 IRQ 核心识别设备。最常用的方法是提供指向设备结构或指向任何每个设备(对处理程序有潜在用处)数据结构的指针。这是因为当发生中断时,中断线 (irq) 和此参数将传递给注册的处理程序,该处理程序可以将此数据用作上下文数据以进行进一步处理。

flags 通过以下掩码来篡改 IRQ 线或其处理程序的状态或行为,这些掩码可以进行 OR 运算,以形成最终所需的位掩码:

#define IRQF_TRIGGER_RISING    0x00000001
#define IRQF_TRIGGER_FALLING   0x00000002
#define IRQF_TRIGGER_HIGH      0x00000004
#define IRQF_TRIGGER_LOW       0x00000008
#define IRQF_SHARED            0x00000080
#define IRQF_PROBE_SHARED      0x00000100
#define IRQF_NOBALANCING       0x00000800
#define IRQF_IRQPOLL           0x00001000
#define IRQF_ONESHOT           0x00002000
#define IRQF_NO_SUSPEND        0x00004000
#define IRQF_FORCE_RESUME      0x00008000
#define IRQF_NO_THREAD         0x00010000
#define IRQF_EARLY_RESUME      0x00020000
#define IRQF_COND_SUSPEND      0x00040000

请注意,flags 也可以为零。让我们来看看一些重要的 flags。我会留下其余的供您在 include/linux/interrupt.h 中探索:

  • IRQF_TRIGGER_HIGHIRQF_TRIGGER_LOW 标志用于电平敏感中断。前者用于高电平触发的中断,后者用于低电平触发的中断。只要物理中断信号保持高电平,电平敏感中断就会被触发。如果中断源在内核中的中断处理程序结束时未被清除,操作系统将重复调用该内核中断处理程序,这可能导致平台挂起。换句话说,当处理程序服务中断并返回时,如果 IRQ 线仍处于断言状态,CPU 将立即再次发出中断。为了防止这种情况发生,中断必须在内核中断处理程序接收到时立即被确认(即清除或取消断言)。

然而,这些 flags 对于中断共享是安全的,因为如果多个设备拉低了该线路,中断将被触发(假设 IRQ 已启用,或者一旦启用)。直到所有驱动程序都为其设备提供服务。唯一的缺点是,如果驱动程序未清除其中断源,可能会导致死机。

  • IRQF_TRIGGER_RISINGIRQF_TRIGGER_FALLING 关注边沿触发中断,分别是上升沿和下降沿。当线路从非活动状态变为活动状态时,会发出此类中断信号,但只发出一次。要获得新的请求,线路必须再次变为非活动状态,然后再次变为活动状态。大多数情况下,软件不需要采取特殊措施来确认此类中断。

然而,当使用边沿触发中断时,中断可能会丢失,特别是在共享中断线路的情况下:如果一个设备将线路保持活动状态的时间太长,当另一个设备拉高线路时,不会生成边沿,处理器将看不到第二个请求,然后将被忽略。对于共享的边沿触发中断,如果硬件不取消 IRQ 线路,那么不会通知任何其他共享设备的中断。

重要提示

作为一个快速提醒,你可以记住,电平触发中断信号状态,而边沿触发中断信号事件。此外,当请求中断而没有指定IRQF_TRIGGER标志时,应该假定设置为已经配置,这可能是根据机器或固件初始化。在这种情况下,你可以参考设备树(如果在其中指定)来查看这个假定配置是什么。

  • IRQF_SHARED:这允许中断线路在多个设备之间共享。然而,需要共享给定中断线路的每个设备驱动程序都必须设置此标志;否则,注册将失败。

  • IRQF_NOBALANCING:这将中断排除在中断平衡之外,这是一个机制,用于在 CPU 之间分配/重新定位中断,以提高性能。这可以防止更改此 IRQ 的 CPU 亲和性。此标志可用于为时钟源提供灵活的设置,以防止事件被错误地归因于错误的核心。这种错误的归因可能导致中断被禁用,因为如果处理中断的 CPU 不是触发它的 CPU,则处理程序将返回IRQ_NONE。这个标志只在多核系统上有意义。

  • IRQF_IRQPOLL:此标志允许使用irqpoll机制,用于修复中断问题。这意味着当给定中断未被处理时,应该将此处理程序添加到已知中断处理程序列表中。

  • IRQF_ONESHOT:通常,在硬中断处理程序完成后,正在服务的实际中断线路将被启用,无论它是否唤醒了线程处理程序。此标志在硬件中断上保持中断线路在硬中断处理程序完成后禁用。对于必须保持中断线路禁用直到线程处理程序完成的线程化中断(稍后我们将讨论这一点),必须设置此标志。之后,它将被启用。

  • IRQF_NO_SUSPEND:这不会在系统休眠/挂起期间禁用中断。这意味着中断能够从挂起状态保存系统。这样的中断可能是定时器中断,可能在系统挂起时触发并需要处理。此标志影响整个中断线,如果中断是共享的,那么对于这个共享线路的每个注册处理程序都将被执行,而不仅仅是安装了此标志的处理程序。尽量避免同时使用IRQF_NO_SUSPENDIRQF_SHARED

  • IRQF_FORCE_RESUME:这使得在系统恢复路径中启用中断,即使设置了IRQF_NO_SUSPEND

  • IRQF_NO_THREAD:这可以防止中断处理程序被线程化。此标志覆盖了threadirqs内核(在 RT 内核上使用,例如在应用PREEMPT_RT补丁时)命令行选项,该选项强制每个中断都被线程化。此标志是为了解决某些中断(例如定时器)无法线程化的问题而引入的。

  • IRQF_TIMER:这将此处理程序标记为特定于系统定时器中断。它有助于在系统挂起期间不禁用定时器中断,以确保它正常恢复并在启用完全抢占(参见PREEMPT_RT)时不会线程化。它只是IRQF_NO_SUSPEND | IRQF_NO_THREAD的别名。

  • IRQF_EARLY_RESUME:这在系统核心(syscore)操作的恢复时间而不是设备恢复时间提前恢复 IRQ。转到lkml.org/lkml/2013/11/20/89查看引入其支持的提交。

我们还必须考虑中断处理程序的返回类型irqreturn_t,因为它们可能在处理程序返回后涉及进一步的操作:

  • IRQ_NONE:在共享中断线上,一旦中断发生,内核 irqcore 会依次遍历为此线注册的处理程序,并按照它们注册的顺序执行它们。然后,驱动程序有责任检查是否是他们的设备发出了中断。如果中断不是来自其设备,则必须返回IRQ_NONE,以指示内核调用下一个注册的中断处理程序。此返回值在共享中断线上经常使用,因为它通知内核中断不是来自我们的设备。但是,如果__report_bad_irq()函数在内核源代码树中。

  • IRQ_HANDLED:如果中断已成功处理,应返回此值。在线程化的 IRQ 上,此值会确认中断,而不会唤醒线程处理程序。

  • IRQ_WAKE_THREAD:在线程 IRQ 处理程序上,硬中断处理程序必须返回此值,以唤醒处理程序线程。在这种情况下,IRQ_HANDLED只能由先前使用request_threaded_irq()注册的线程处理程序返回。我们将在本章的线程化 IRQ 处理程序部分讨论这一点。

重要提示

在处理程序中重新启用中断时必须非常小心。实际上,您绝不能从 IRQ 处理程序中重新启用 IRQ,因为这将涉及“中断重入”的允许。在这种情况下,您有责任解决这个问题。

在驱动程序的卸载路径(或者在驱动程序运行时生命周期中不再需要 IRQ 线路时,这种情况非常罕见),您必须通过注销中断处理程序并可能禁用中断线路来释放 IRQ 资源。free_irq()接口为您执行此操作:

void free_irq(unsigned int irq, void *dev_id)

也就是说,如果使用devm_request_irq()分配的 IRQ 需要单独释放,则必须使用devm_free_irq()。它具有以下原型:

void devm_free_irq(struct device *dev,                    unsigned int irq,                    void *dev_id)

此函数有一个额外的dev参数,即要释放 IRQ 的设备。这通常与已注册 IRQ 的设备相同。除了dev之外,此函数接受相同的参数并执行与free_irq()相同的功能。但是,它应该用于手动释放使用devm_request_irq()分配的 IRQ,而不是free_irq()

devm_request_irq()free_irq()都会删除处理程序(在共享中断时由dev_id标识)并禁用该线路。如果中断线路是共享的,处理程序将从此 IRQ 的处理程序列表中删除,并且在删除最后一个处理程序时将来禁用中断线路。此外,如果可能,您的代码必须确保在调用此函数之前真正在驱动的卡上禁用中断,因为忽略这一点可能导致虚假的中断。

这里有一些关于中断的事情是值得一提的,你永远不应该忘记的:

  • 由于 Linux 中的中断处理程序在本地 CPU 上禁用 IRQ,并且当前行在所有其他核心上都被屏蔽,因此它们不需要是可重入的,因为在当前处理程序完成之前,同一个中断永远不会被接收。然而,所有其他中断(在其他核心上)保持启用(或者我们应该说是未触及),因此其他中断仍在被服务,即使当前行总是被禁用,以及本地 CPU 上的进一步中断。因此,相同的中断处理程序永远不会同时被调用来服务嵌套中断。这极大地简化了编写中断处理程序。

  • 需要在禁用中断的情况下运行的关键区域应尽量限制。要记住这一点,告诉自己,你的中断处理程序已经中断了其他代码,需要把 CPU 让出来。

  • 中断处理程序不能阻塞,因为它们不在进程上下文中运行。

  • 它们不能在用户空间传输数据,因为这可能会导致阻塞。

  • 它们不能休眠,也不能依赖可能导致休眠的代码,比如调用wait_event(),使用除GFP_ATOMIC之外的内存分配,或者使用互斥锁/信号量。线程处理程序可以处理这个问题。

  • 它们不能触发也不能调用schedule()

  • 在给定线上只能有一个待处理的中断(当其中断条件发生时,其中断标志位被设置,而不管其对应的启用位或全局启用位的状态如何)。这条线的任何进一步中断都会丢失。例如,如果在处理 RX 中断时同时接收到了五个数据包,你不应该期望会依次出现五次更多的中断。你只会被通知一次。如果处理器在服务 ISR 之前不服务,就没有办法检查以后会发生多少个 RX 中断。这意味着如果设备在处理函数返回IRQ_HANDLED之前生成另一个中断,中断控制器将被通知有待处理的中断标志,并且处理程序将再次被调用(只有一次),所以如果你不够快,可能会错过一些中断。在你处理第一个中断时,可能会发生多个中断。

重要提示

如果在禁用(或屏蔽)中发生中断,它将根本不会被处理(在流处理程序中被屏蔽),但将被识别为断言,并保持挂起状态,以便在启用(或取消屏蔽)时进行处理。

中断上下文有自己的(固定且相当低的)堆栈大小。因此,在运行 ISR 时完全禁用 IRQ 是有意义的,因为可重入性可能会导致堆栈溢出,如果发生太多的抢占。

中断的不可重入性概念意味着,如果中断已经处于活动状态,它不能再次进入,直到活动状态被清除。

顶半部和底半部的概念

外部设备向 CPU 发送中断请求,要么是为了通知特定事件,要么是为了请求服务。如前一节所述,糟糕的中断管理可能会显著增加系统的延迟,并降低其实时性。我们还指出,中断处理 - 即硬中断处理程序 - 必须非常快,不仅要保持系统的响应性,还要确保不会错过其他中断事件。

看一下下面的图表:

图 1.2 - 中断分流流程

图 1.2 - 中断分流流程

基本思想是将中断处理程序分成两部分。第一部分是一个函数,将在所谓的硬中断上下文中运行,禁用中断,并执行最少的必要工作(例如进行一些快速的健全性检查,时间敏感的任务,读/写硬件寄存器,并处理此数据并在引发中断的设备上确认中断)。这第一部分在 Linux 系统中被称为顶半部。顶半部然后调度一个(有时是线程化的)处理程序,然后运行所谓的下半部函数,重新启用中断。这是中断的第二部分。下半部然后可以执行耗时的任务(例如缓冲区处理)- 根据推迟机制可能会休眠的任务。

这种分割将显著提高系统的响应性,因为禁用 IRQ 的时间减少到最低。当下半部在内核线程中运行时,它们将与运行队列上的其他进程竞争 CPU。此外,它们可能已经设置了它们的实时属性。实际上,顶半部是使用request_irq()注册的处理程序。当使用request_threaded_irq()时,如我们将在下一节中看到的,顶半部是给定给该函数的第一个处理程序。

正如我们之前所描述的,下半部代表从中断处理程序内部调度的任何任务(或工作)。下半部是使用工作推迟机制设计的,我们之前已经看到了。根据您选择的是哪一个,它可能在(软件)中断上下文中运行,也可能在进程上下文中运行。这包括SoftIRQstaskletsworkqueuesthreaded IRQs

重要提示

Tasklets 和 SoftIRQs 实际上并不适合所谓的“线程中断”机制,因为它们在自己的特殊上下文中运行。

由于软中断处理程序以高优先级运行,并且禁用了调度程序抢占,它们在完成之前不会将 CPU 让给进程/线程,因此在将它们用于下半部委托时必须小心。如今,由于为特定进程分配的时间片可能会有所不同,因此没有严格的规定软中断处理程序应该花多长时间才能完成,以免减慢系统速度,因为内核将无法为其他进程分配 CPU 时间。我认为这个时间不应该超过半个节拍。

硬中断处理程序(顶半部)必须尽可能快,大部分时间应该只是在 I/O 内存中读写。任何其他计算都应该延迟到下半部,其主要目标是执行任何耗时且与中断相关的最小工作,这些工作不是由顶半部执行的。关于在顶半部和下半部之间重新分配工作没有明确的指导方针。以下是一些建议:

  • 与硬件相关的工作和时间敏感的工作应该在上半部分执行。

  • 如果工作不需要中断,就在上半部分执行。

  • 从我的角度来看,其他所有工作都可以推迟 - 也就是说,在下半部执行 - 以便在系统不太忙时启用中断。

  • 如果硬中断处理程序足够快,可以在几微秒内一致地处理和确认中断,那么根本没有必要使用下半部委托。

接下来,我们将看一下线程中断请求处理程序。

线程中断请求处理程序

线程化中断处理程序的引入是为了减少中断处理程序中所花费的时间,并将其余的工作(即处理)推迟到内核线程中。因此,顶半部分(硬中断处理程序)将包括快速的健全性检查,例如确保中断来自其设备,并相应地唤醒底半部分。线程化中断处理程序在自己的线程中运行,可以在其父线程中运行(如果有的话),也可以在一个单独的内核线程中运行。此外,专用内核线程可以设置其实时优先级,尽管它以正常的实时优先级运行(即MAX_USER_RT_PRIO/2,如kernel/irq/manage.c中的setup_irq_thread()函数所示)。

线程化中断背后的一般规则很简单:尽可能将硬中断处理程序保持最小,并尽可能将更多工作推迟到内核线程(最好是所有工作)。如果要请求线程化中断处理程序,应该使用request_threaded_irq()(定义在kernel/irq/manage.c中):

int 
request_threaded_irq(unsigned int irq, 
                     irq_handler_t handler,
                     irq_handler_t thread_fn, 
                     unsigned long irqflags,
                     const char *devname, 
                     void *dev_id)

这个函数接受两个特殊参数handlerthread_fn。其他参数与request_irq()的参数相同:

  • handler在中断上下文中立即运行,充当硬中断处理程序。它的工作通常包括读取中断原因(在设备的状态寄存器中)以确定如何处理中断(这在 MMIO 设备上很常见)。如果中断不是来自其设备,此函数应返回IRQ_NONE。这个返回值通常只在共享中断线上有意义。在另一种情况下,如果这个硬中断处理程序可以在足够快的时间内完成中断处理(这不是一个普遍的规则,但假设不超过半个 jiffy,也就是不超过 500 微秒,如果CONFIG_HZ,定义了 jiffy 的值,设置为 1,000),它应该在处理后返回IRQ_HANDLED以确认中断。不在这个时间范围内的中断处理应该推迟到线程化的中断处理程序。在这种情况下,硬中断处理程序应该返回IRQ_WAKE_THREAD以唤醒线程处理程序。只有在thread_fn处理程序也注册时,返回IRQ_WAKE_THREAD才有意义。

  • thread_fn是当硬中断处理程序返回IRQ_WAKE_THREAD时添加到调度程序运行队列中的线程处理程序。如果thread_fnNULL,而handler被设置并且返回IRQ_WAKE_THREAD,则在硬中断处理程序的返回路径上除了显示一个简单的警告消息外,什么也不会发生。查看内核源代码中的__irq_wake_thread()函数以获取更多信息。由于thread_fn与运行队列上的其他进程竞争 CPU,它可能会立即执行,也可能在将来系统负载较轻时执行。当它成功完成中断处理过程时,此函数应返回IRQ_HANDLED。在这个阶段,相关的 kthread 将被从运行队列中取出,并处于阻塞状态,直到再次被硬中断函数唤醒。

如果handlerNULLthread_fn != NULL,内核将安装默认的硬中断处理程序。这是默认的主处理程序。它是一个几乎空的处理程序,只是返回IRQ_WAKE_THREAD以唤醒相关的内核线程,该线程将执行thread_fn处理程序。这样可以完全将中断处理程序的执行移动到进程上下文,从而防止有错误的驱动程序(错误的中断处理程序)破坏整个系统并减少中断延迟。专用处理程序的 kthread 将在ps ax中可见。

/*
 * Default primary interrupt handler for threaded interrupts is  * assigned as primary handler when request_threaded_irq is  * called with handler == NULL. Useful for one-shot interrupts.
 */
static irqreturn_t irq_default_primary_handler(int irq,                                                void *dev_id)
{
    return IRQ_WAKE_THREAD;
}
int 
request_threaded_irq(unsigned int irq, 
                     irq_handler_t handler,
                     irq_handler_t thread_fn,
                     unsigned long irqflags,
                     const char *devname, 
                     void *dev_id)
{
    [...]
    if (!handler) {
        if (!thread_fn)
            return -EINVAL;
        handler = irq_default_primary_handler;
    }
    [...]
}
EXPORT_SYMBOL(request_threaded_irq);

重要提示

现在,request_irq()只是request_threaded_irq()的一个包装,thread_fn参数设置为NULL

请注意,当您从硬中断处理程序返回时(无论返回值是什么),中断在中断控制器级别被确认,从而允许您考虑其他中断。在这种情况下,如果中断在设备级别没有被确认,中断将一次又一次地触发,导致堆栈溢出(或者永远停留在硬中断处理程序中)对于级联触发的中断,因为发出中断的设备仍然保持中断线被断言。在线程中断出现之前,当您需要在线程中运行底半部时,您将指示顶半部在唤醒线程之前在设备级别禁用中断。这样,即使控制器准备接受另一个中断,设备也不会再次引发中断。

IRQF_ONESHOT标志解决了这个问题。在使用线程中断时(在request_threaded_irq()调用时),必须设置它;否则,请求将失败,并显示以下错误:

pr_err(
 “Threaded irq requested with handler=NULL and !ONESHOT for irq %d\n”,
 irq);

有关更多信息,请查看内核源树中的__setup_irq()函数。

以下是介绍IRQF_ONESHOT标志并解释其作用的消息摘录(完整消息可在lkml.iu.edu/hypermail/linux/kernel/0908.1/02114.html找到):

“它允许驱动程序请求在硬中断上下文处理程序执行并唤醒线程后不解除中断(在控制器级别)。线程处理程序函数执行后,中断线将被解除屏蔽。”

重要提示

如果省略IRQF_ONESHOT标志,您将需要提供一个硬中断处理程序(在其中应禁用中断线);否则,请求将失败。

线程专用中断的示例如下:

static irqreturn_t data_event_handler(int irq, void *dev_id)
{
    struct big_structure *bs = dev_id;
    process_data(bs->buffer);
    return IRQ_HANDLED;
}
static int my_probe(struct i2c_client *client,
                    const struct i2c_device_id *id)
{
    [...]
    if (client->irq > 0) {
        ret = request_threaded_irq(client->irq,
                               NULL,
                               &data_event_handler,
                               IRQF_TRIGGER_LOW | IRQF_ONESHOT,
                               id->name,
                               private);
        if (ret)
            goto error_irq;
    }
    [...]
    return 0;
error_irq:
    do_cleanup();
    return ret;
}

在前面的示例中,我们的设备位于 I2C 总线上。因此,访问可用数据可能会导致其休眠,因此不应在硬中断处理程序中执行。这就是为什么我们的处理程序参数是NULL

提示

如果需要在线程 ISR 处理之间共享的 IRQ 线(例如,某些 SoC 在其内部 ADC 和触摸屏模块之间共享相同的中断),则必须实现硬中断处理程序,该处理程序应检查中断是否由您的设备引发。如果中断确实来自您的设备,则应在设备级别禁用中断并返回IRQ_WAKE_THREAD以唤醒线程处理程序。在线程处理程序的返回路径中应重新启用设备级别的中断。如果中断不来自您的设备,则应直接从硬中断处理程序返回IRQ_NONE

此外,如果一个驱动程序在该线上设置了IRQF_SHAREDIRQF_ONESHOT标志,那么共享该线的每个其他驱动程序都必须设置相同的标志。/proc/interrupts文件列出了每个 CPU 的 IRQ 及其处理,请求步骤中给出的 IRQ 名称以及为该中断注册 ISR 的驱动程序的逗号分隔列表。

线程中断是中断处理的最佳选择,因为它们可能占用太多 CPU 周期(在大多数情况下超过一个节拍),例如大量数据处理。线程中断允许管理其关联线程的优先级和 CPU 亲和性。由于这个概念来自实时内核树(来自Thomas Gleixner),它满足了实时系统的许多要求,例如允许使用细粒度优先级模型并减少内核中断延迟。

查看/proc/irq/IRQ_NUMBER/smp_affinity,它可用于获取或设置相应的IRQ_NUMBER亲和性。该文件返回并接受一个位掩码,表示可以处理已为此 IRQ 注册的 ISR 的处理器。这样,您可以决定将硬中断的亲和性设置为一个 CPU,同时将线程处理程序的亲和性设置为另一个 CPU。

请求上下文 IRQ

请求 IRQ 的驱动程序必须预先了解中断的性质,并决定其处理程序是否可以在硬中断上下文中运行,以便相应地调用request_irq()request_threaded_irq()

当涉及到由离散和非基于 MMIO 的中断控制器提供的请求 IRQ 线时,例如 I2C/SPI gpio 扩展器,存在一个问题。由于访问这些总线可能导致它们进入睡眠状态,因此在硬中断上下文中运行这些慢速控制器的处理程序将是灾难性的。由于驱动程序不包含有关中断线/控制器性质的任何信息,因此 IRQ 核心提供了request_any_context_irq()API。此函数确定中断控制器/线是否可以休眠,并调用适当的请求函数:

int request_any_context_irq(unsigned int irq,
                            irq_handler_t handler,
                            unsigned long flags,
                            const char *name,
                            void *dev_id)

request_any_context_irq()request_irq()具有相同的接口,但具有不同的语义。根据底层上下文(硬件平台),request_any_context_irq()选择使用request_irq()的硬中断处理方法,或使用request_threaded_irq()的线程处理方法。它在失败时返回负错误值,而在成功时返回IRQC_IS_HARDIRQ(表示使用了硬中断处理)或IRQC_IS_NESTED(表示使用了线程版本)。使用此函数,中断处理程序的行为在运行时决定。有关更多信息,请查看内核中介绍它的注释,通过以下链接进行查看:git.kernel.org/pub/scm/linux/kernel/git/next/linux-next.git/commit/?id=ae731f8d0785

使用request_any_context_irq()的优点是您不需要关心在 IRQ 处理程序中可以做什么。这是因为处理程序将运行的上下文取决于提供 IRQ 线的中断控制器。例如,对于基于 gpio-IRQ 的设备驱动程序,如果 gpio 属于坐落在 I2C 或 SPI 总线上的控制器(在这种情况下,gpio 访问可能会休眠),处理程序将是线程化的。否则(gpio 访问可能不会休眠,并且作为 SoC 的一部分是内存映射的),处理程序将在硬中断上下文中运行。

在下面的示例中,设备期望一个 IRQ 线映射到一个 gpio。驱动程序不能假设给定的 gpio 线将被内存映射,因为它来自 SoC。它也可能来自离散的 I2C 或 SPI gpio 控制器。在这里使用request_any_context_irq()是一个好的做法:

static irqreturn_t packt_btn_interrupt(int irq, void *dev_id)
{
    struct btn_data *priv = dev_id;
    input_report_key(priv->i_dev,
                     BTN_0,
                     gpiod_get_value(priv->btn_gpiod) & 1);
    input_sync(priv->i_dev);
    return IRQ_HANDLED;
}
static int btn_probe(struct platform_device *pdev)
{
    struct gpio_desc *gpiod;
    int ret, irq;
    gpiod = gpiod_get(&pdev->dev, “button”, GPIOD_IN);
    if (IS_ERR(gpiod))
        return -ENODEV;
    priv->irq = gpiod_to_irq(priv->btn_gpiod);
    priv->btn_gpiod = gpiod;
    [...]
    ret = request_any_context_irq(
                  priv->irq,
                  packt_btn_interrupt,
                  (IRQF_TRIGGER_FALLING | IRQF_TRIGGER_RISING),
                  “packt-input-button”,
                  priv);
    if (ret < 0)
        goto err_btn;
return 0;
err_btn:
    do_cleanup();
    return ret;
}

前面的代码足够简单,但由于request_any_context_irq()的存在,它非常安全,这可以防止我们错误地将底层 gpio 的类型弄错。

使用工作队列来推迟底半部

由于我们已经讨论了工作队列 API,我们将在这里提供一个使用它的示例。这个示例并不是没有错误的,也没有经过测试。这只是一个演示,突出了通过工作队列推迟底半部的概念。

让我们首先定义数据结构,该数据结构将保存我们需要进行进一步开发的元素:

struct private_struct {
    int counter;
    struct work_struct my_work;
    void __iomem *reg_base;
    spinlock_t lock;
    int irq;
    /* Other fields */
    [...]
};

在前面的数据结构中,我们的工作结构由my_work元素表示。我们在这里不使用指针,因为我们需要使用container_of()宏来获取指向初始数据结构的指针。接下来,我们可以定义将在工作线程中调用的方法:

static void work_handler(struct work_struct *work)
{
    int i;
    unsigned long flags;
    struct private_data *my_data =
              container_of(work, struct private_data, my_work);
   /*
    * let’s proccessing at least half of MIN_REQUIRED_FIFO_SIZE
    * prior to re-enabling the irq at device level, and so that
    * buffer further data
    */
    for (i = 0, i < MIN_REQUIRED_FIFO_SIZE, i++) {
        device_pop_and_process_data_buffer();
        if (i == MIN_REQUIRED_FIFO_SIZE / 2)
            enable_irq_at_device_level();
    }
    spin_lock_irqsave(&my_data->lock, flags);
    my_data->buf_counter -= MIN_REQUIRED_FIFO_SIZE;
    spin_unlock_irqrestore(&my_data->lock, flags);
}

在上面的代码中,当缓冲了足够的数据时开始数据处理。现在,我们可以提供我们的 IRQ 处理程序,负责调度我们的工作,如下:

/* This is our hard-IRQ handler.*/
static irqreturn_t my_interrupt_handler(int irq, void *dev_id)
{
    u32 status;
    unsigned long flags;
    struct private_struct *my_data = dev_id;
    /* Let’s read the status register in order to determine how
     * and what to do
     */
    status = readl(my_data->reg_base + REG_STATUS_OFFSET);
    /*
     * Let’s ack this irq at device level. Even if it raises      * another irq, we are safe since this irq remain disabled      * at controller level while we are in this handler
     */
    writel(my_data->reg_base + REG_STATUS_OFFSET,
          status | MASK_IRQ_ACK);
    /*
     * Protecting the shared resource, since the worker also      * accesses this counter
     */
    spin_lock_irqsave(&my_data->lock, flags);
    my_data->buf_counter++;
    spin_unlock_irqrestore(&my_data->lock, flags);
    /*
     * Ok. Our device raised an interrupt in order to inform it      * has some new data in its fifo. But is it enough for us      * to be processed
     */
    if (my_data->buf_counter != MIN_REQUIRED_FIFO_SIZE)) {
        /* ack and re-enable this irq at controller level */
        return IRQ_HANDLED;
    } else {
        /*
         * Right. prior to schedule the worker and returning          * from this handler, we need to disable the irq at          * device level
         */
        writel(my_data->reg_base + REG_STATUS_OFFSET,
               MASK_IRQ_DISABLE);
               schedule_work(&my_work);
    }
      /* This will re-enable the irq at controller level */
      return IRQ_HANDLED;
};

IRQ 处理程序代码中的注释是有意义的。schedule_work()是调度我们的工作的函数。最后,我们可以编写我们的probe方法,该方法将请求我们的 IRQ 并注册先前的处理程序:

static int foo_probe(struct platform_device *pdev)
{
    struct resource *mem;
    struct private_struct *my_data;
    my_data = alloc_some_memory(sizeof(struct private_struct));
    mem = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    my_data->reg_base =
        ioremap(ioremap(mem->start, resource_size(mem));
    if (IS_ERR(my_data->reg_base))
        return PTR_ERR(my_data->reg_base);
     /*
      * work queue initialization. “work_handler” is the       * callback that will be executed when our work is       * scheduled.
      */
     INIT_WORK(&my_data->my_work, work_handler);
     spin_lock_init(&my_data->lock);
     my_data->irq = platform_get_irq(pdev, 0);
     if (request_irq(my_data->irq, my_interrupt_handler,
                     0, pdev->name, my_data))
         handler_this_error()
     return 0;
}

上面的probe方法的结构无疑表明我们面对的是一个平台设备驱动程序。通用的 IRQ 和工作队列 API 在这里被用来初始化我们的工作队列并注册我们的处理程序。

在中断处理程序内部进行锁定

如果一个资源在两个或更多用户上下文(kthread、work、线程化 IRQ 等)之间共享,并且只与线程化底半部(即,它们永远不会被硬中断访问)共享,则互斥锁定是正确的方式,如下例所示:

static int my_probe(struct platform_device *pdev)
{
    int irq;
    int ret;
    irq = platform_get_irq(pdev, i);
    ret = devm_request_threaded_irq(dev, irq, NULL,                                     my_threaded_irq,
                                    IRQF_ONESHOT, dev_                                    name(dev),
                                    my_data);
    [...]
    return ret;
}
static irqreturn_t my_threaded_irq(int irq, void *dev_id)
{
    struct priv_struct *my_data = dev_id;
    /* Save FIFO Underrun & Transfer Error status */
    mutex_lock(&my_data->fifo_lock);
    /* accessing the device’s buffer through i2c */
    [...]
    mutex_unlock(&ldev->fifo_lock);
    return IRQ_HANDLED;
}

在上面的代码中,用户任务(kthread、work 等)和线程化底半部在访问资源之前必须持有互斥锁。

前面的情况是最简单的例子。以下是一些规则,可以帮助你在硬中断上下文和其他上下文之间进行锁定:

  • 如果资源在用户上下文和硬中断处理程序之间共享,则应该使用禁用中断的 spinlock 变体,即简单的_irq_irqsave/_irq_restore变体。这可以确保用户上下文在访问资源时不会被此中断抢占。这可以在以下示例中看到:
static int my_probe(struct platform_device *pdev)
{
    int irq;
    int ret;
    [...]
    irq = platform_get_irq(pdev, 0);
    if (irq < 0)
        goto handle_get_irq_error;
    ret = devm_request_threaded_irq(&pdev->dev, 
                                    irq, 
                                    my_hardirq,
                                    my_threaded_irq,
                                    IRQF_ONESHOT,
                                    dev_name(dev), 
                                    my_data);
    if (ret < 0)
        goto err_cleanup_irq;
    [...]
    return 0;
}
static irqreturn_t my_hardirq(int irq, void *dev_id)
{
    struct priv_struct *my_data = dev_id;
    unsigned long flags;
    /* No need to protect the shared resource */
    my_data->status = __raw_readl(
           my_data->mmio_base + my_data->foo.reg_offset);
    /* Let us schedule the bottom-half */
    return IRQ_WAKE_THREAD;
}
static irqreturn_t my_threaded_irq(int irq, void *dev_id)
{
    struct priv_struct *my_data = dev_id;
    spin_lock_irqsave(&my_data->lock, flags);
    /* Processing the status status */
    process_status(my_data->status);
    spin_unlock_irqrestore(&my_data->lock, flags);
    [...]
    return IRQ_HANDLED;
}

在上面的代码中,硬中断处理程序不需要持有自旋锁,因为它永远不会被抢占。只有用户上下文必须持有。在硬中断和其线程化对应物之间可能不需要保护的情况下;也就是说,在请求 IRQ 线时设置了IRQF_ONESHOT标志。此标志在硬中断处理程序完成后保持中断禁用。设置了此标志后,IRQ 线保持禁用,直到线程化处理程序运行完成。这样,硬中断处理程序和其线程化对应物将永远不会竞争,并且可能不需要为两者之间共享的资源加锁。

  • 当资源在用户上下文和软中断之间共享时,有两件事需要防范:用户上下文可能会被软中断打断(记住,软中断在硬中断处理程序的返回路径上运行),以及临界区可能会从另一个 CPU 进入(记住,相同的软中断可能在另一个 CPU 上并发运行)。在这种情况下,应该使用禁用软中断的 spinlock API 变体,即spin_lock_bh()spin_unlock_bh()_bh前缀表示底半部。因为这些 API 在本章中没有详细讨论,可以使用_irq甚至_irqsave变体,这些变体也会禁用硬件中断。

  • 相同的规则适用于任务队列(因为任务队列是建立在软中断之上),唯一的区别是任务队列永远不会并发运行(它永远不会同时在多个 CPU 上运行);任务队列是通过设计独占的。

  • 在处理硬中断和软中断之间进行锁定时,有两件事需要防范:软中断可能会被硬中断打断,临界区可能会被另一个 CPU(1)硬中断(如果设计成这样),(2)相同的软中断,或者(3)另一个软中断进入。因为当硬中断处理程序运行时,软中断永远不会运行,硬中断处理程序只需要使用spin_lock()spin_unlock()API,这可以防止其他 CPU 上的硬中断处理程序的并发访问。然而,软中断需要使用实际禁用中断的锁定 API,即_irq()irqsave()变体,最好使用后者。

  • 因为 softIRQs 可能会并发运行,在两个不同的 softIRQs 之间,甚至在 softIRQ 和自身之间(在另一个 CPU 上运行)可能需要锁定。在这种情况下,应该使用spinlock()/spin_unlock()。不需要禁用硬件中断。

在这一点上,我们已经完成了对中断锁定的查看,这意味着我们已经到达了本章的结尾。

总结

本章介绍了一些核心内核功能,这些功能将在本书的接下来的几章中使用。我们涵盖的概念涉及位操作到 Linux 内核中断设计和实现,通过锁定辅助程序和工作推迟机制。到目前为止,您应该能够决定是否应该将中断处理程序分成两部分,以及了解哪种锁定原语适合您的需求。

在下一章中,我们将涵盖 Linux 内核管理的资源,这是一个用于将分配的资源管理卸载到内核核心的接口。

第二章:利用 Regmap API 并简化代码

本章介绍了 Linux 内核寄存器映射抽象层,并展示了如何简化和委托 I/O 操作给 regmap 子系统。处理设备,无论是在 SoC 中内置的(也称为 MMIO)还是位于 I2C/SPI 总线上,都包括访问(读取/修改/更新)寄存器。Regmap 是必需的,因为许多设备驱动程序在其寄存器访问例程中使用了开放编码。Regmap代表寄存器映射。它最初是为了ALSA SoCASoC)而开发的,以消除编解码器驱动程序中多余的开放编码 SPI/I2C 寄存器访问例程。最初,regmap 提供了一组用于读取/写入非内存映射 I/O(例如,I2C 和 SPI 读/写)的 API。从那时起,MMIO regmap 已经升级,以便我们可以使用 regmap 来访问 MMIO。

如今,该框架抽象了 I2C,SPI 和 MMIO 寄存器访问,不仅在必要时处理锁定,还管理寄存器缓存,以及寄存器的可读性和可写性。它还处理 IRQ 芯片和 IRQ。本章将讨论 regmap,并解释如何使用它来抽象 I2C,SPI 和 MMIO 设备的寄存器访问。我们还将描述如何使用 regmap 来管理 IRQ 和 IRQ 控制器。

本章将涵盖以下主题:

  • Regmap 和其数据结构的介绍:I2C,SPI 和 MMIO

  • Regmap 和 IRQ 管理

  • Regmap IRQ API 和数据结构

技术要求

为了在阅读本章时感到舒适,您需要以下内容:

Regmap 和其数据结构的介绍- I2C,SPI 和 MMIO

Regmap 是 Linux 内核提供的抽象寄存器访问机制,主要针对 SPI,I2C 和内存映射寄存器。

此框架中的 API 是总线不可知的,并在幕后处理底层配置。也就是说,该框架中的主要数据结构是struct regmap_config,在内核源代码树中的include/linux/regmap.h中定义如下:

struct regmap_config {
   const char *name;
   int reg_bits;
   int reg_stride;
   int pad_bits;
   int val_bits;
   bool (*writeable_reg)(struct device *dev, unsigned int reg);
   bool (*readable_reg)(struct device *dev, unsigned int reg);
   bool (*volatile_reg)(struct device *dev, unsigned int reg);
   bool (*precious_reg)(struct device *dev, unsigned int reg);
   int (*reg_read)(void *context, unsigned int reg,                   unsigned int *val);
   int (*reg_write)(void *context, unsigned int reg,                    unsigned int val);
   bool disable_locking;
   regmap_lock lock;
   regmap_unlock unlock;
   void *lock_arg;
   bool fast_io;
   unsigned int max_register;
   const struct regmap_access_table *wr_table;
   const struct regmap_access_table *rd_table;
   const struct regmap_access_table *volatile_table;
   const struct regmap_access_table *precious_table;
   const struct reg_default *reg_defaults;
   unsigned int num_reg_defaults;
   unsigned long read_flag_mask;
   unsigned long write_flag_mask;
   enum regcache_type cache_type;
   bool use_single_rw;
   bool can_multi_write;
};

为简单起见,本结构中的一些字段已被删除,在本章中不讨论。只要struct regmap_config正确完成,用户可以忽略底层总线机制。让我们介绍这个数据结构中的字段:

  • reg_bits表示寄存器的位数。换句话说,它是寄存器地址的位数。

  • reg_stride是寄存器地址的步幅。如果寄存器地址是该值的倍数,则为有效。如果设置为0,则将使用1的值,这意味着任何地址都是有效的。对不是该值的倍数的地址进行读/写将返回-EINVAL

  • pad_bits是寄存器和值之间填充位的数量。这是在格式化时将寄存器的值左移的位数。

  • val_bits:表示用于存储寄存器值的位数。这是一个强制性字段。

  • writeable_reg:如果提供,将在每次 regmap 写操作时调用此可选回调函数,以检查给定地址是否可写。如果此函数在给定给 regmap 写事务的地址上返回false,则事务将返回-EIO。以下摘录显示了如何实现此回调:

static bool foo_writeable_register(struct device *dev,                                    unsigned int reg)
{
    switch (reg) {
    case 0x30 ... 0x38:
    case 0x40 ... 0x45:
    case 0x50 ... 0x57:
    case 0x60 ... 0x6e:
    case 0xb0 ... 0xb2:
        return true;
    default:
        return false;
    }
}
  • readable_reg:这与writeable_reg相同,但用于寄存器读取操作。

  • volatile_reg: 这是一个可选的回调,如果提供,将在每次需要通过 regmap 缓存读取或写入寄存器时调用。如果寄存器是易失性的(寄存器值无法被缓存),则该函数应返回true。然后在寄存器上执行直接读/写操作。如果返回false,表示寄存器是可缓存的。在这种情况下,将使用缓存进行读取操作,并在写入操作的情况下将写入缓存。以下是一个示例,其中随机选择了虚假寄存器地址:

static bool volatile_reg(struct device *dev,                          unsigned int reg)
{
    switch (reg) {
    case 0x30:
    case 0x31:
    [...]
    case 0xb3:
        return false;
    case 0xb4:
        return true;
    default:
        if ((reg >= 0xb5) && (reg <= 0xcc))
            return false;
    [...]
        break;
    }
    return true;
}
  • reg_read: 如果您的设备需要特殊的黑客来进行读取操作,您可以提供自定义的读取回调,并使该字段指向它,以便使用回调而不是标准的 regmap 读取函数。也就是说,大多数设备不需要这样做。

  • reg_write: 这与reg_read相同,但用于写操作。

  • disable_locking: 这显示了是否应该使用lock/unlock回调。如果为false,将不使用任何锁定机制。这意味着此 regmap 要么受到外部手段的保护,要么保证不会从多个线程访问。

  • lock/unlock: 这些是可选的锁定/解锁回调,它们会覆盖 regmap 的默认锁定/解锁函数。这些基于自旋锁或互斥锁,具体取决于访问底层设备是否可能休眠。

  • lock_arg: 这是lock/unlock函数的唯一参数(如果未覆盖常规的锁定/解锁函数,则将被忽略)。

  • fast_io: 这表示寄存器的 I/O 速度很快。如果设置了,regmap 将使用自旋锁而不是互斥锁来执行锁定。如果使用自定义的锁定/解锁(这里没有讨论)函数(请参阅内核源代码中struct regmap_configlock/unlock字段),则此字段将被忽略。它应该仅用于“无总线”情况(MMIO 设备),而不是用于可能休眠的慢总线,如 I2C、SPI 或类似总线。

  • wr_table: 这是writeable_reg()回调的替代,类型为regmap_access_table,它是一个包含yes_rangeno_range字段的结构,两者都是指向struct regmap_range的指针。属于yes_range条目的任何寄存器都被视为可写,如果属于no_range或未在yes_range中指定,则被视为不可写。

  • rd_table: 这与wr_table相同,但用于任何读取操作。

  • volatile_table: 您可以提供volatile_table而不是volatile_reg。其原理与wr_tablerd_table相同,但用于缓存机制。

  • max_register: 这是可选的;它指定了不允许任何操作的最大有效寄存器地址。

  • reg_defaultsreg_default类型的元素数组,其中每个元素都是表示给定寄存器的上电复位值的{reg, value}对。这与缓存一起使用,以便读取存在于此数组中且自上电复位以来尚未写入的地址时,将返回此数组中的默认寄存器值,而无需对设备执行任何读取事务。这的一个示例是 IIO 设备驱动程序,您可以在elixir.bootlin.com/linux/v4.19/source/drivers/iio/light/apds9960.c上了解更多信息。

  • use_single_rw: 这是一个布尔值,如果设置,将指示 regmap 将设备上的任何批量写或读操作转换为一系列单个写或读操作。这对于不支持批量读取和/或写入操作的设备非常有用。

  • can_multi_write: 这仅针对写操作。如果设置,表示此设备支持批量写操作的多写模式。如果为空,多写请求将被拆分为单独的写操作。

  • num_reg_defaults: 这是reg_defaults中元素的数量。

  • read_flag_mask:这是在进行读取时要设置在寄存器的最高字节中的掩码。 通常,在 SPI 或 I2C 中,写入或读取将在顶部字节中设置最高位,以区分写入和读取操作。

  • write_flag_mask:这是在进行写入时要设置在寄存器的最高字节中的掩码。

  • cache_type:这是实际的缓存类型,可以是REGCACHE_NONEREGCACHE_RBTREEREGCACHE_COMPRESSEDREGCACHE_FLAT

初始化 regmap 就像调用以下函数之一一样简单,具体取决于我们的设备所在的总线:

struct regmap * devm_regmap_init_i2c(
                    struct i2c_client *client,
                    struct regmap_config *config)
struct regmap * devm_regmap_init_spi(
                    struct spi_device *spi,
                    const struct regmap_config);
struct regmap * devm_regmap_init_mmio(
                    struct device *dev,
                    void __iomem *regs,
                    const struct regmap_config *config)
#define devm_regmap_init_spmi_base(dev, config) \
    __regmap_lockdep_wrapper(__devm_regmap_init_spmi_base, \
                             #config, dev, config)
#define devm_regmap_init_w1(w1_dev, config) \
    __regmap_lockdep_wrapper(__devm_regmap_init_w1, #config, \
                             w1_dev, config)

在前面的原型中,返回值将是一个有效的指向struct regmap的指针,如果出现错误,则返回ERR_PTR()。 regmap 将由设备管理代码自动释放。 regs是指向内存映射 IO 区域的指针(由devm_ioremap_resource()或任何ioremap*系列函数返回)。 dev是将要交互的设备(类型为struct device)。 以下示例是内核源代码中drivers/mfd/sun4i-gpadc.c的摘录:

struct sun4i_gpadc_dev {
    struct device *dev;
    struct regmap *regmap;
    struct regmap_irq_chip_data *regmap_irqc;
    void __iomem *base;
};
static const struct regmap_config sun4i_gpadc_regmap_config = {
    .reg_bits = 32,
    .val_bits = 32,
    .reg_stride = 4,
    .fast_io = true,
};
static int sun4i_gpadc_probe(struct platform_device *pdev)
{
    struct sun4i_gpadc_dev *dev;
    struct resource *mem;
    [...]
    mem = platform_get_resource(pdev, IORESOURCE_MEM, 0);
    dev->base = devm_ioremap_resource(&pdev->dev, mem);
    if (IS_ERR(dev->base))
        return PTR_ERR(dev->base);
    dev->dev = &pdev->dev;
    dev_set_drvdata(dev->dev, dev);
    dev->regmap = devm_regmap_init_mmio(dev->dev, dev->base,
                                   &sun4i_gpadc_regmap_config);
    if (IS_ERR(dev->regmap)) {
       ret = PTR_ERR(dev->regmap);
       dev_err(&pdev->dev, "failed to init regmap: %d\n", ret);
       return ret;
    }
    [...]

这段摘录显示了如何创建 regmap。 尽管这段摘录是面向 MMIO 的,但对于其他类型,概念仍然相同。 而不是使用devm_regmap_init_MMIO(),我们将分别使用devm_regmap_init_spi()devm_regmap_init_i2c()来创建基于 SPI 或 I2C 的 regmap。

访问设备寄存器

有两个主要函数用于访问设备寄存器。 这些是regmap_write()regmap_read(),它们负责锁定和抽象底层总线:

int regmap_write(struct regmap *map,
                 unsigned int reg,
                 unsigned int val);
int regmap_read(struct regmap *map,
                unsigned int reg,
                unsigned int *val);

在前面的两个函数中,第一个参数map是初始化期间返回的 regmap 结构。 reg是要写入/读取数据的寄存器地址。 val是写操作中要写入的数据,或读操作中的读取值。以下是这些 API 的详细描述:

  • regmap_write用于向设备写入数据。 此函数执行以下步骤:
  1. 首先,它检查reg是否与regmap_config.reg_stride对齐。 如果不是,则返回-EINVAL,函数失败。

  2. 然后,根据fast_iolockunlock字段获取锁。 如果提供了lock回调,则将使用它来获取锁。 否则,regmap 核心将使用其内部默认的锁定函数,使用自旋锁或互斥锁,具体取决于是否设置了fast_io。 接下来,regmap 核心对传递的寄存器地址执行一些合理性检查,如下所示:

-如果设置了max_register,它将检查此寄存器的地址是否小于max_register。 如果地址不小于max_register,则regmap_write()失败,返回-EIO(无效的 I/O)错误代码

-然后,如果设置了writeable_reg回调,则将使用寄存器作为参数调用此回调。 如果此回调返回false,则regmap_write()失败,返回-EIO。 如果未设置writeable_reg但设置了wr_table,则 regmap 核心将检查寄存器地址是否位于no_range内。 如果是,则regmap_write()失败并返回-EIO。 如果不是,则 regmap 核心将检查寄存器地址是否位于yes_range内。 如果不在那里,则regmap_write()失败并返回-EIO

  1. 如果设置了cache_type字段,则将使用缓存。 要写入的值将被缓存以供将来参考,而不是写入硬件。

  2. 如果未设置cache_type,则立即调用写入例程将值写入硬件寄存器。 在将值写入此寄存器之前,此例程将首先将write_flag_mask应用于寄存器地址的第一个字节。

  3. 最后,使用适当的解锁函数释放锁。

  • regmap_read用于从设备中读取数据。此函数执行与regmap_write()相同的安全性和健全性检查,但用readable_regrd_table替换了writable_regwr_table。在缓存方面,如果启用了缓存,则从缓存中读取寄存器值。如果未启用缓存,则调用读取例程从硬件寄存器中读取值。该例程将在读取操作之前将read_flag_mask应用于寄存器地址的最高字节,并使用新读取的值更新*val。之后,使用适当的解锁函数释放锁。

虽然前面的访问器一次只针对一个寄存器,但其他访问器可以执行批量访问,我们将在下一节中看到。

一次性读/写多个寄存器

有时您可能希望同时对寄存器范围中的数据执行批量读/写操作。即使您在循环中使用regmap_read()regmap_write(),最好的解决方案也是使用为此类情况提供的 regmap API。这些函数是regmap_bulk_read()regmap_bulk_write()

int regmap_bulk_read(struct regmap *map, unsigned int reg,
                     void *val, size_tval_count);
int regmap_bulk_write(struct regmap *map, unsigned int reg,
                      const void *val, size_t val_count)

这些函数从/向设备读/写多个寄存器。map是用于执行操作的 regmap。对于读操作,reg是应该开始读取的第一个寄存器,val是指向应该以设备的本机寄存器大小存储读取值的缓冲区的指针(这意味着如果设备寄存器大小为 4 字节,则读取值将以 4 字节单位存储),val_count是要读取的寄存器数量。对于写操作,reg是应该从中开始写入的第一个寄存器,val是指向应该以设备的本机寄存器大小写入的数据块的指针,val_count是要写入的寄存器数量。对于这两个函数,成功时将返回0的值,如果出现错误,则将返回负的errno

提示

此框架提供了其他有趣的读/写函数。查看内核头文件以获取更多信息。一个有趣的函数是regmap_multi_reg_write(),它以任何顺序提供的{寄存器,值}对集合写入多个寄存器,可能不都在单个范围内,给定为参数的设备。

现在我们已经熟悉了寄存器访问,我们可以通过在位级别管理寄存器内容来进一步深入。

更新寄存器中的位

要更新给定寄存器中的位,我们有regmap_update_bits(),一个三合一的函数。其原型如下:

int regmap_update_bits(struct regmap *map, unsigned int reg,
                       unsigned int mask, unsigned int val)

它在寄存器映射上执行读/修改/写循环。它是_regmap_update_bits()的包装器,如下所示:

static int _regmap_update_bits(
                struct regmap *map, unsigned int reg,
                unsigned int mask, unsigned int val,
                bool *change, bool force_write)
{
    int ret;
    unsigned int tmp, orig;
    if (change)
        *change = false;
    if (regmap_volatile(map, reg) && map->reg_update_bits) {
        ret = map->reg_update_bits(map->bus_context,
                                    reg, mask, val);
        if (ret == 0 && change)
            *change = true;
    } else {
        ret = _regmap_read(map, reg, &orig);
        if (ret != 0)
            return ret;
        tmp = orig & ~mask;
        tmp |= val & mask;
        if (force_write || (tmp != orig)) {
            ret = _regmap_write(map, reg, tmp);
            if (ret == 0 && change)
                *change = true;
        }
    }
    return ret;
}

需要更新的位应该在mask中设置为1,相应的位将获得val中相同位置的位的值。例如,要将第一个(BIT(0))和第三个(BIT(2))位设置为1mask应该是0b00000101,值应该是0bxxxxx1x1。要清除第七位(BIT(6)),mask必须是0b01000000,值应该是0bx0xxxxxx,依此类推。

提示

为了调试目的,您可以使用debugfs文件系统来转储 regmap 管理的寄存器内容,如下摘录所示:

mount -t debugfs none /sys/kernel/debug

cat /sys/kernel/debug/regmap/1-0008/registers

这将以<addr:value>格式转储寄存器地址及其值。

在本节中,我们已经看到了访问硬件寄存器是多么容易。此外,我们已经学会了一些在位级别上玩耍寄存器的花哨技巧,这在状态和配置寄存器中经常使用。接下来,我们将看一下 IRQ 管理。

Regmap 和 IRQ 管理

Regmap 不仅仅是对寄存器的访问进行了抽象。在这里,我们将看到这个框架如何在更低的级别抽象 IRQ 管理,比如 IRQ 芯片处理,从而隐藏样板操作。

Linux 内核 IRQ 管理的快速回顾

通过特殊设备称为中断控制器向设备公开 IRQ。从软件角度来看,中断控制器设备驱动程序使用 Linux 内核中的 IRQ 域概念来管理和公开这些线。中断管理建立在以下结构之上:

  • struct irq_chip:这个结构体是 Linux 对 IRQ 控制器的表示,并实现了一组方法来驱动直接由核心 IRQ 代码调用的中断控制器。必要时,该结构应该由驱动程序填充,提供一组回调函数,允许我们在 IRQ 芯片上管理 IRQ,例如irq_startupirq_shutdownirq_enableirq_disableirq_ackirq_maskirq_unmaskirq_eoiirq_set_affinity。愚蠢的 IRQ 芯片设备(例如不允许 IRQ 管理的芯片)应该使用内核提供的dummy_irq_chip

  • struct irq_domain:每个中断控制器都有一个域,对于控制器来说,它就像地址空间对于进程一样。struct irq_domain结构存储了硬件 IRQ 和 Linux IRQ(即虚拟 IRQ 或 virq)之间的映射。它是硬件中断号转换对象。这个结构提供以下内容:

  • 给定中断控制器的固件节点(fwnode)的指针。

  • 将 IRQ 的固件(设备树)描述转换为中断控制器本地的 ID(硬件 IRQ 号,称为 hwirq)的方法。对于也充当 IRQ 控制器的 gpio 芯片,给定 gpio 线的硬件 IRQ 号(hwirq)大多数情况下对应于该线在芯片中的本地索引。

  • 从 hwirq 中检索 IRQ 的 Linux 视图的方法。

  • struct irq_desc:这个结构是 Linux 内核对中断的视图,包含所有核心内容,并且与 Linux 中断号一一对应。

  • struct irq_action:这是 Linux 用来描述 IRQ 处理程序的结构。

  • struct irq_data:这个结构嵌入在struct irq_desc结构中,并包含以下内容:

  • 与管理此中断的irq_chip相关的数据

  • Linux IRQ 号和 hwirq 都是

  • 指向irq_chip的指针

  • 指向中断转换域(irq_domain)的指针

始终牢记irq_domain 对于中断控制器就像地址空间对于进程一样,因为它存储了 virq 和 hwirq 之间的映射

中断控制器驱动程序通过调用irq_domain_add_<mapping_method>()函数之一来创建和注册irq_domain。这些函数实际上是irq_domain_add_linear()irq_domain_add_tree()irq_domain_add_nomap()。事实上,<mapping_method>hwirqs应该映射到virqs的方法。

irq_domain_add_linear()创建一个空的固定大小的表,由 hwirq 号索引。为每个被映射的 hwirq 分配struct irq_desc。然后将分配的 IRQ 描述符存储在表中,索引等于它被分配的 hwirq。这种线性映射适用于固定和较小数量的 hwirq(小于 256)。

虽然这种映射的主要优势是 IRQ 号查找时间是固定的,并且irq_desc仅为正在使用的 IRQ 分配,但主要缺点来自表的大小,它可能与最大可能的hwirq号一样大。大多数驱动程序应该使用线性映射。这个函数有以下原型:

struct irq_domain *irq_domain_add_linear(
                             struct device_node *of_node,
                             unsigned int size,
                             const struct irq_domain_ops *ops,
                             void *host_data)

irq_domain_add_tree()创建一个空的irq_domain,在基数树中维护 Linux IRQ 和hwirq号之间的映射。当映射 hwirq 时,会分配一个struct irq_desc,并且 hwirq 被用作基数树的查找键。如果 hwirq 号非常大,则树映射是一个很好的选择,因为它不需要分配一个与最大 hwirq 号一样大的表。缺点是hwirq-to-IRQ号查找取决于表中有多少条目。很少有驱动程序需要这种映射。它有以下原型:

struct irq_domain *irq_domain_add_tree(
                       struct device_node *of_node,
                       const struct irq_domain_ops *ops,
                       void *host_data)

irq_domain_add_nomap()是您可能永远不会使用的东西;但是,其完整描述可以在内核源树中的Documentation/IRQ-domain.txt中找到。其原型如下:

struct irq_domain *irq_domain_add_nomap(
                              struct device_node *of_node,
                              unsigned int max_irq,
                              const struct irq_domain_ops *ops,
                              void *host_data)

在所有这些原型中,of_node是指向中断控制器的 DT 节点的指针。size表示线性映射情况下域中中断的数量。ops表示 map/unmap 域回调,host_data是控制器的私有数据指针。由于这三个函数都创建了空的irq域,因此应该使用irq_create_mapping()函数,将 hwirq 和传递给它的irq域的指针一起使用,以创建映射,并将此映射插入到域中:

unsigned int irq_create_mapping(struct irq_domain *domain,
                                irq_hw_number_t hwirq)

在上述原型中,domain是此硬件中断所属的域。NULL值表示默认域。hwirq是您需要为其创建映射的硬件 IRQ 号。此函数将硬件中断映射到 Linux IRQ 空间,并返回 Linux IRQ 号。还要记住,每个硬件中断只允许一个映射。以下是创建映射的示例:

unsigned int virq = 0;
virq = irq_create_mapping(irq_domain, hwirq);
if (!virq) {
    ret = -EINVAL;
    goto err_irq;
}

在上述代码中,virq是 Linux 内核 IRQ(虚拟 IRQ 号virq)对应的映射。

重要提示

当为也是中断控制器的 GPIO 控制器编写驱动程序时,从gpio_chip.to_irq()回调中调用irq_create_mapping(),并将 virq 返回为return irq_create_mapping(gpiochip->irq_domain, hwirq),其中hwirq是从 GPIO 芯片的 GPIO 偏移量。

一些驱动程序更喜欢在probe()函数内提前创建映射并填充每个 hwirq 的域,如下所示:

for (j = 0; j < gpiochip->chip.ngpio; j++) {
    irq = irq_create_mapping(gpiochip ->irq_domain, j);
}

之后,这样的驱动程序只需在to_irq()回调函数中调用irq_find_mapping()(给定 hwirq)。如果给定的hwirq尚不存在映射,则irq_create_mapping()将分配一个新的struct irq_desc结构,将其与 hwirq 关联,并调用irq_domain_ops.map()回调(使用irq_domain_associate()函数)以便驱动程序可以执行任何所需的硬件设置。

struct irq_domain_ops

此结构公开了一些特定于 irq 域的回调。由于在给定的 irq 域中创建了映射,因此应为每个映射(实际上是每个irq_desc)提供一个 irq 配置、一些私有数据和一个转换函数(给定设备树节点和中断说明符,转换函数解码硬件 irq 号和 Linux irq 类型值)。这就是此结构中回调的作用:

struct irq_domain_ops {
    int (*map)(struct irq_domain *d, unsigned int virq,
               irq_hw_number_t hw);
    void (*unmap)(struct irq_domain *d, unsigned int virq);
   int (*xlate)(struct irq_domain *d, struct device_node *node,
                const u32 *intspec, unsigned int intsize,
                unsigned long *out_hwirq,                 unsigned int *out_type);
};

上述数据结构中 Linux 内核 IRQ 管理的元素都值得单独的部分来描述。

irq_domain_ops.map()

以下是此回调的原型:

int (*map)(struct irq_domain *d, unsigned int virq,
            irq_hw_number_t hw);

在描述此函数的功能之前,让我们描述一下它的参数:

  • d:此 IRQ 芯片使用的 IRQ 域

  • virq:此基于 GPIO 的 IRQ 芯片使用的全局 IRQ 号

  • hw:此 GPIO 芯片上的本地 IRQ/GPIO 线偏移量

.map()创建或更新 virq 和 hwirq 之间的映射。此回调设置 IRQ 配置。对于给定的映射,它只会被(由 irq 核心内部)调用一次。这是我们为给定的 irq 设置irq芯片数据的地方,可以使用irq_set_chip_data()来完成,其原型如下:

int irq_set_chip_data(unsigned int irq, void *data); 

根据 IRQ 芯片的类型(嵌套或链式),可以执行其他操作。

irq_domain_ops.xlate()

给定一个 DT 节点和一个中断指定器,这个回调函数解码硬件 IRQ 号以及它的 Linux IRQ 类型值。根据你的 DT 控制器节点中指定的#interrupt-cells属性,内核提供了一个通用的翻译函数:

  • irq_domain_xlate_twocell(): 这是一个用于直接双细胞绑定的通用翻译函数。DT IRQ 指定器与双细胞绑定一起工作,其中细胞值直接映射到hwirq号和 Linux IRQ 标志。

  • irq_domain_xlate_onecell(): 这是一个用于直接单细胞绑定的通用xlate函数。

  • irq_domain_xlate_onetwocell(): 这是一个用于单细胞或双细胞绑定的通用xlate函数。

域操作的一个示例如下:

static struct irq_domain_ops mcp23016_irq_domain_ops = {
    .map = my_irq_domain_map,
    .xlate = irq_domain_xlate_twocell,
};

前面数据结构的显著特点是分配给.xlate元素的值,即irq_domain_xlate_twocell。这意味着我们期望在设备树中有一个双细胞irq指定器,其中第一个细胞指定irq,第二个指定其标志。

链接 IRQ

当发生中断时,可以使用irq_find_mapping()辅助函数从hwirq号中找到 Linux IRQ 号。例如,这个hwirq号可能是 GPIO 控制器组中的 GPIO 偏移量。一旦找到并返回了有效的 virq,你应该在这个virq上调用handle_nested_irq()generic_handle_irq()。魔法来自于前两个函数,它们管理了irq流处理程序。这意味着有两种处理中断处理程序的方法。硬中断处理程序,或者链式中断,是原子的,运行时中断被禁用,并且可能调度线程处理程序;还有简单的线程中断处理程序,称为嵌套中断,可能会被其他中断打断。

链式中断

这种方法用于可能不休眠的控制器,比如 SoC 的内部 GPIO 控制器,它是内存映射的,其访问不休眠。链式意味着这些中断只是一系列函数调用(例如,SoC 的 GPIO 控制器中断处理程序是从 GIC 中断处理程序中调用的,就像函数调用一样)。采用这种方法,子 IRQ 处理程序在父 hwirq 处理程序内被调用。在这里必须使用generic_handle_irq()将子 IRQ 处理程序链接到父 hwirq 处理程序。即使在子中断处理程序内部,我们仍然处于原子上下文(硬件中断)。你不能调用可能会休眠的函数。

对于链式(仅链式)IRQ 芯片,irq_domain_ops.map()也是将高级irq-type流处理程序分配给给定的 irq 的正确位置,使用irq_set_chip_and_handler(),这样高级代码,根据它的内容,将在调用相应的 irq 处理程序之前执行一些操作。这里的魔法操作得益于irq_set_chip_and_handler()函数:

void irq_set_chip_and_handler(unsigned int irq,
                              struct irq_chip *chip,
                              irq_flow_handler_t handle)

在前面的原型中,irq代表 Linux IRQ(virq),作为参数传递给irq_domain_ops.map()函数;chip是你的irq_chip结构;handle是你的高级中断流处理程序。

重要提示

有些控制器非常简单,几乎不需要在它们的irq_chip结构中做任何事情。在这种情况下,你应该将dummy_irq_chip传递给irq_set_chip_and_handler()dummy_irq_chipkernel/irq/dummychip.c中定义。

以下是irq_set_chip_and_handler()的代码流程总结:

void irq_set_chip_and_handler(unsigned int irq,
                              struct irq_chip *chip,
                              irq_flow_handler_t handle)
{
    struct irq_desc *desc = irq_get_desc(irq);
    desc->irq_data.chip = chip;
    desc->handle_irq = handle;
}

这些是通用层提供的一些可能的高级 IRQ 流处理程序:

/*
 * Built-in IRQ handlers for various IRQ types,
 * callable via desc->handle_irq()
 */
void handle_level_irq(struct irq_desc *desc);
void handle_fasteoi_irq(struct irq_desc *desc);
void handle_edge_irq(struct irq_desc *desc);
void handle_edge_eoi_irq(struct irq_desc *desc);
void handle_simple_irq(struct irq_desc *desc);
void handle_untracked_irq(struct irq_desc *desc);
void handle_percpu_irq(struct irq_desc *desc);
void handle_percpu_devid_irq(struct irq_desc *desc);
void handle_bad_irq(struct irq_desc *desc);

每个函数名都很好地描述了它处理的 IRQ 类型。对于链式 IRQ 芯片,irq_domain_ops.map()可能如下所示:

static int my_chained_irq_domain_map(struct irq_domain *d,
                                     unsigned int virq,
                                     irq_hw_number_t hw)
{
    irq_set_chip_data(virq, d->host_data);
    irq_set_chip_and_handler(virq, &dummy_irq_chip,                              handle_ edge_irq);
    return 0;
}

在为链式 IRQ 芯片编写父 irq 处理程序时,代码应该在每个子 irq 上调用generic_handle_irq()。这个函数简单地调用irq_desc->handle_irq(),它指向使用irq_set_chip_and_handler()分配给给定子 IRQ 的高级中断处理程序。底层的高级irq事件处理程序(比如handle_level_irq())首先会做一些小技巧,然后会运行硬irq-handlerirq_desc->action->handler),根据返回值,如果提供的话,会运行线程处理程序(irq_desc->action->thread_fn)。

以下是链式 IRQ 芯片的父 IRQ 处理程序的示例,其原始代码位于内核源码中的drivers/pinctrl/pinctrl-at91.c中:

static void parent_hwirq_handler(struct irq_desc *desc)
{
    struct irq_chip *chip = irq_desc_get_chip(desc);
    struct gpio_chip *gpio_chip =     irq_desc_get_handler_ data(desc);
    struct at91_gpio_chip *at91_gpio = gpiochip_get_data                                       (gpio_ chip);
    void __iomem *pio = at91_gpio->regbase;
    unsigned long isr;
    int n;
    chained_irq_enter(chip, desc);
    for (;;) {
        /* Reading ISR acks pending (edge triggered) GPIO
         * interrupts. When there are none pending, we’re
         * finished unless we need to process multiple banks
         * (like ID_PIOCDE on sam9263).
         */
        isr = readl_relaxed(pio + PIO_ISR) &
                           readl_relaxed(pio + PIO_IMR);
        if (!isr) {
            if (!at91_gpio->next)
                break;
            at91_gpio = at91_gpio->next;
            pio = at91_gpio->regbase;
            gpio_chip = &at91_gpio->chip;
            continue;
        }
        for_each_set_bit(n, &isr, BITS_PER_LONG) {
            generic_handle_irq(
                   irq_find_mapping(gpio_chip->irq.domain, n));
        }
    }
    chained_irq_exit(chip, desc);
    /* now it may re-trigger */
    [...]
}

链式 IRQ 芯片驱动程序不需要使用devm_request_threaded_irq()devm_request_irq()注册父irq处理程序。当驱动程序在父 irq 上调用irq_set_chained_handler_and_data()时,此处理程序会自动注册,并提供相关的处理程序和一些私有数据:

void irq_set_chained_handler_and_data(unsigned int irq,
                                      irq_flow_handler_t                                       handle,
                                      void *data)

这个函数的参数非常容易理解。您应该在probe函数中调用这个函数,如下所示:

static int my_probe(struct platform_device *pdev)
{
    int parent_irq, i;
    struct irq_domain *my_domain;
    parent_irq = platform_get_irq(pdev, 0);
    if (!parent_irq) {
     pr_err("failed to map parent interrupt %d\n", parent_irq);
        return -EINVAL;
    }
    my_domain =
        irq_domain_add_linear(np, nr_irq, &my_irq_domain_ops,
                              my_private_data);
    if (WARN_ON(!my_domain)) {
        pr_warn("%s: irq domain init failed\n", __func__);
        return;
    }
    /* This may be done elsewhere */
    for(i = 0; i < nr_irq; i++) {
        int virqno = irq_create_mapping(my_domain, i);
         /*
          * May need to mask and clear all IRQs before           * registering a handler
          */
           [...]
          irq_set_chained_handler_and_data(parent_irq,
                                          parent_hwirq_handler,
                                          my_private_data);
          /* 
           * May need to call irq_set_chip_data() on            * the virqno too            */
        [...]
    }
    [...]
}

在前面虚假的probe方法中,使用irq_domain_add_linear()创建了一个线性域,并在该域中使用irq_create_mapping()创建了一个 irq 映射(虚拟 irq)。最后,我们为主(或父)IRQ 设置了一个高级链式流处理程序及其数据。

重要提示

请注意,irq_set_chained_handler_and_data()会自动启用中断(指定为第一个参数),分配其处理程序(也作为参数给出),并将此中断标记为IRQ_NOREQUESTIRQ_NOPROBEIRQ_NOTHREAD,这意味着此中断不能再通过request_irq()请求,不能通过自动探测进行探测,也不能线程化(它是链式的)。

嵌套中断

嵌套流方法是由可能休眠的 IRQ 芯片使用的,例如那些位于慢总线上的 IRQ 芯片,比如 I2C(例如,I2C GPIO 扩展器)。"嵌套"指的是那些不在硬件上下文中运行的中断处理程序(它们实际上不是 hwirq,并且不在原子上下文中),而是线程化的,可以被抢占。在这里,处理程序函数是在调用线程的上下文中调用的。对于嵌套(仅对嵌套)IRQ 芯片,irq_domain_ops.map()回调也是设置irq配置标志的正确位置。最重要的配置标志如下:

  • IRQ_NESTED_THREAD:这是一个标志,表示在devm_request_threaded_irq()上,不应为 irq 处理程序创建专用的中断线程,因为它在解复用中断处理程序线程的上下文中被嵌套调用(在内核源码中的kernel/irq/manage.c中实现了__setup_irq()函数中有更多关于此的信息)。您可以使用void irq_set_nested_thread(unsigned int irq, int nest)来操作此标志,其中irq对应于全局中断号,nest应为0以清除或1以设置IRQ_NESTED_THREAD标志。

  • IRQ_NOTHREAD:可以使用void irq_set_nothread(unsigned int irq)设置此标志。它用于将给定的 IRQ 标记为不可线程化。

这是嵌套 IRQ 芯片的irq_domain_ops.map()可能看起来像这样:

static int my_nested_irq_domain_map(struct irq_domain *d,
                                    unsigned int virq,
                                    irq_hw_number_t hw)
{
    irq_set_chip_data(virq, d->host_data);
    irq_set_nested_thread(virq, 1);
    irq_set_noprobe(virq);
    return 0;
}

在为嵌套 IRQ 芯片编写父 irq 处理程序时,代码应该调用handle_nested_irq()来处理子 irq 处理程序,以便它们从父 irq 线程中运行。handle_nested_irq()不关心irq_desc->action->handler,即硬 irq 处理程序。它只运行irq_desc->action->thread_fn

static irqreturn_t mcp23016_irq(int irq, void *data)
{
    struct mcp23016 *mcp = data;
    unsigned int child_irq, i;
    /* Do some stuff */
    [...]
    for (i = 0; i < mcp->chip.ngpio; i++) {
        if (gpio_value_changed_and_raised_irq(i)) {
            child_irq = irq_find_mapping(mcp->chip.irqdomain,                                         i);
            handle_nested_irq(child_irq);
        }
    }
    [...]
}

嵌套 IRQ 芯片驱动程序使用devm_request_threaded_irq(),因为对于这种类型的 IRQ 芯片没有像irq_set_chained_handler_and_data()这样的函数。对于嵌套 IRQ 芯片使用这个 API 是没有意义的。嵌套 IRQ 芯片大多数情况下是基于 GPIO 芯片的。因此,最好使用基于 GPIO 芯片的 IRQ 芯片 API,或者使用基于 regmap 的 IRQ 芯片 API,如下一节所示。然而,让我们看看这样一个例子是什么样子的:

static int my_probe(struct i2c_client *client,
                    const struct i2c_device_id *id)
{
    int parent_irq, i;
    struct irq_domain *my_domain;
    [...]
    int irq_nr = get_number_of_needed_irqs();
    /* Do we have an interrupt line ? Enable the IRQ chip */
    if (client->irq) {
        domain = irq_domain_add_linear(
                        client->dev.of_node, irq_nr,
                        &my_irq_domain_ops, my_private_data);
        if (!domain) {
            dev_err(&client->dev,
                    "could not create irq domain\n");
            return -ENODEV;
        }
        /*
         * May be creating irq mapping in this domain using
         * irq_create_mapping() or let the mfd core doing
         * this if it is an MFD chip device
         */
        [...]
        ret =
            devm_request_threaded_irq(
                &client->dev, client->irq,
                NULL, my_parent_irq_thread,
                IRQF_TRIGGER_FALLING | IRQF_ONESHOT,
                "my-parent-irq", my_private_data);
        [...]
    }
[...]
}

在上述的probe方法中,与链接流有两个主要区别:

  • 首先,主 IRQ 的注册方式:在链接的 IRQ 芯片中使用了irq_set_chained_handler_and_data(),它自动注册了处理程序,而嵌套流方法必须使用request_threaded_irq()系列方法显式注册其处理程序。

  • 其次,主 IRQ 处理程序调用底层 irq 处理程序的方式:在链接流中,主 IRQ 处理程序中调用handle_nested_irq(),它调用每个底层 irq 的处理程序作为一系列函数调用,这些函数调用在与主处理程序相同的上下文中执行,即原子地(原子性也称为hard-irq)。然而,嵌套流处理程序必须调用handle_nested_irq(),它在父级的线程上下文中执行底层 irq 的处理程序(thread_fn)。

这些是链接和嵌套流之间的主要区别。

irqchip 和 gpiolib API - 新一代

由于每个irq-gpiochip驱动程序都在其自己的irqdomain处理中进行了开放编码,这导致了大量的冗余代码。内核开发人员决定将该代码移动到 gpiolib 框架中,从而提供了GPIOLIB_IRQCHIP Kconfig 符号,使我们能够为 GPIO 芯片使用统一的 irq 域管理 API。该代码部分有助于处理 GPIO IRQ 芯片和相关irq_domain和资源分配回调的管理,以及它们的设置,使用了一组减少的辅助函数。这些函数是gpiochip_irqchip_add()gpiochip_irqchip_add_nested(),以及gpiochip_set_chained_irqchip()gpiochip_set_nested_irqchip()gpiochip_irqchip_add()gpiochip_irqchip_add_nested()都向 GPIO 芯片添加一个 IRQ 芯片。以下是它们各自的原型:

static inline int gpiochip_irqchip_add(                                    struct gpio_chip *gpiochip,
                                    struct irq_chip *irqchip,
                                    unsigned int first_irq,
                                    irq_flow_handler_t handler,
                                    unsigned int type)
static inline int gpiochip_irqchip_add_nested(
                          struct gpio_chip *gpiochip,
                          struct irq_chip *irqchip,
                          unsigned int first_irq,
                          irq_flow_handler_t handler,
                          unsigned int type)

在上述原型中,gpiochip参数是要将irqchip添加到的 GPIO 芯片。irqchip是要添加到 GPIO 芯片以扩展其功能,使其也可以充当 IRQ 控制器的 IRQ 芯片。这个 IRQ 芯片必须被正确配置,要么由驱动程序,要么由 IRQ 核心代码(如果给定dummy_irq_chip作为参数)。如果没有动态分配,first_irq将是从中分配 GPIO 芯片 IRQ 的基础(第一个)IRQ。handler是要使用的主要 IRQ 处理程序(通常是预定义的高级 IRQ 核心函数之一)。type是此IRQ 芯片上 IRQ 的默认类型;在这里传递IRQ_TYPE_NONE,并让驱动程序在请求时配置这个。

每个函数操作的摘要如下:

  • 第一个函数使用irq_domain_add_simple()函数为 GPIO 芯片分配了一个struct irq_domain。这个 IRQ 域的 ops 是使用内核 IRQ 核心域 ops 变量gpiochip_domain_ops设置的。这个域 ops 在drivers/gpio/gpiolib.c中定义,irq_domain_ops.xlate字段设置为irq_domain_xlate_twocell,这意味着这个 gpio 芯片将处理双细胞的 IRQ。

  • gpiochip.to_irq字段设置为gpiochip_to_irq,这是一个回调函数,返回irq_create_mapping(chip->irq.domain, offset),创建一个与 GPIO 偏移对应的 IRQ 映射。当我们在该 GPIO 上调用gpiod_to_irq()时执行此操作。这个函数假设gpiochip上的每个引脚都可以生成唯一的 IRQ。以下是gpiochip_domain_ops IRQ 域的定义:

static const struct irq_domain_ops gpiochip_domain_ops = {
  .map = gpiochip_irq_map,
  .unmap = gpiochip_irq_unmap,
  /* Virtually all GPIO-based IRQ chips are two-celled */
  .xlate = irq_domain_xlate_twocell,
};

gpiochip_irqchip_add_nested()gpiochip_irqchip_add()之间唯一的区别是前者向 GPIO 芯片添加了一个嵌套的 IRQ 芯片(它将gpio_chip->irq.threaded字段设置为true),而后者向 GPIO 芯片添加了一个链式的 IRQ 芯片,并将此字段设置为false。另一方面,gpiochip_set_chained_irqchip()gpiochip_set_nested_irqchip()分别将链式或嵌套的 IRQ 芯片分配/连接到 GPIO 芯片。以下是这两个函数的原型:

void gpiochip_set_chained_irqchip(                             struct gpio_chip *gpiochip,
                             struct irq_chip *irqchip,
                             unsigned int parent_irq,
                             irq_flow_handler_t parent_handler)
void gpiochip_set_nested_irqchip(struct gpio_chip *gpiochip,
                                 struct irq_chip *irqchip,
                                 unsigned int parent_irq)

在上述原型中,gpiochip是要设置irqchip链的 GPIO 芯片。irqchip代表要连接到 GPIO 芯片的 IRQ 芯片。parent_irq是与此链式 IRQ 芯片对应的父 IRQ 的 irq 编号。换句话说,它是连接到此芯片的 IRQ 编号。parent_handler是 GPIO 芯片累积的 IRQ 的父中断处理程序。实际上,这是 hwirq 处理程序。对于嵌套的 IRQ 芯片,这不会被使用,因为父处理程序是线程化的。链式变体将在parent_handler上内部调用irq_set_chained_handler_and_data()

链式 gpiochip 基于的 IRQ 芯片

gpiochip_irqchip_add()gpiochip_set_chained_irqchip()用于链式 GPIO 芯片基于的 IRQ 芯片,而gpiochip_irqchip_add_nested()gpiochip_set_nested_irqchip()仅用于嵌套 GPIO 芯片基于的 IRQ 芯片。对于链式 GPIO 芯片基于的 IRQ 芯片,gpiochip_set_chained_irqchip()将配置父 hwirq 的处理程序。不需要调用任何devm_request_* irq家族函数。但是,父 hwirq 的处理程序必须在引发的子irqs上调用generic_handle_irq(),如下面的示例(来自内核源代码中的drivers/pinctrl/pinctrl-at91.c),与标准的链式 IRQ 芯片有些相似:

static void gpio_irq_handler(struct irq_desc *desc)
{
    unsigned long isr;
    int n;
    struct irq_chip *chip = irq_desc_get_chip(desc);
    struct gpio_chip *gpio_chip =     irq_desc_get_handler_data(desc);
    struct at91_gpio_chip *at91_gpio =
                      gpiochip_get_data(gpio_chip);
    void __iomem *pio = at91_gpio->regbase;
    chained_irq_enter(chip, desc);
    for (;;) {
        isr = readl_relaxed(pio + PIO_ISR) &
                  readl_relaxed(pio + PIO_IMR);
        [...]
        for_each_set_bit(n, &isr, BITS_PER_LONG) {
            generic_handle_irq(irq_find_mapping(
                          gpio_chip->irq.domain, n));
        }
    }
    chained_irq_exit(chip, desc);
    [...]
}

在前面的代码中,首先介绍了中断处理程序。当 GPIO 芯片发出中断时,将读取整个 gpio 状态银行,以便检测其中设置的每个位,这意味着由相应的 gpio 线后面的设备触发的潜在 IRQ。

然后在域中索引与 gpio 状态银行中设置的位的索引相对应的每个 irq 描述符上调用generic_handle_irq()。这种方法将在原子上下文(hard-irq上下文)中调用在前一步中找到的每个描述符的每个处理程序,除非用于将 gpio 用作 irq 线的设备的底层驱动程序请求处理程序为线程化。

现在我们可以介绍probe方法,一个示例如下:

static int at91_gpio_probe(struct platform_device *pdev)
{
    [...]
    ret = gpiochip_irqchip_add(&at91_gpio->chip,
                                &gpio_irqchip,
                                0,
                                handle_edge_irq,
                                IRQ_TYPE_NONE);
    if (ret) {
       dev_err(
           &pdev->dev,
           "at91_gpio.%d: Couldn’t add irqchip to gpiochip.\n",
           at91_gpio->pioc_idx);
        return ret;
    }
    [...]
    /* Then register the chain on the parent IRQ */
    gpiochip_set_chained_irqchip(&at91_gpio->chip,
                                &gpio_irqchip,
                                at91_gpio->pioc_virq,
                                gpio_irq_handler);
    return 0;
}

这里没有什么特别的。这里的机制在某种程度上遵循了我们在通用 IRQ 芯片中看到的内容。父 IRQ 在这里不是使用任何request_irq()家族方法请求的,因为gpiochip_set_chained_irqchip()将在底层调用irq_set_chained_handler_and_data()

基于嵌套的 gpiochip irqchips

以下摘录显示了驱动程序如何注册其嵌套 GPIO 芯片基于的 IRQ 芯片。这在某种程度上类似于独立的嵌套 IRQ 芯片:

static irqreturn_t pcf857x_irq(int irq, void *data)
{
    struct pcf857x *gpio = data;
    unsigned long change, i, status;
    status = gpio->read(gpio->client);
    /*
     * call the interrupt handler if gpio is used as
     * interrupt source, just to avoid bad irqs
     */
    mutex_lock(&gpio->lock);
    change = (gpio->status ^ status) & gpio->irq_enabled;
    gpio->status = status;
    mutex_unlock(&gpio->lock);
    for_each_set_bit(i, &change, gpio->chip.ngpio)
        handle_nested_irq(
            irq_find_mapping(gpio->chip.irq.domain, i));
    return IRQ_HANDLED;
}

前面的代码是 IRQ 处理程序。正如我们所看到的,它使用handle_nested_irq(),这对我们来说并不新鲜。现在让我们检查probe方法:

static int pcf857x_probe(struct i2c_client *client,
                         const struct i2c_device_id *id)
{
    struct pcf857x *gpio;
    [...]
    /* Enable irqchip only if we have an interrupt line */
    if (client->irq) {
        status = gpiochip_irqchip_add_nested(&gpio->chip,
                                             &gpio->irqchip,
                                             0,                                              handle_level_irq,
                                             IRQ_TYPE_NONE);
        if (status) {
            dev_err(&client->dev, "cannot add irqchip\n");
            goto fail;
        }
        status = devm_request_threaded_irq(
                 &client->dev, client->irq,
                 NULL, pcf857x_irq,
                 IRQF_ONESHOT |IRQF_TRIGGER_FALLING |                  IRQF_SHARED,
              dev_name(&client->dev), gpio);
        if (status)
            goto fail;
        gpiochip_set_nested_irqchip(&gpio->chip,                                     &gpio->irqchip,
                                    client->irq);
    }
[...]
}

在这里,父 irq 处理程序是线程化的,并且必须使用devm_request_threaded_irq()进行注册。这解释了为什么它的 IRQ 处理程序必须在子 irq 上调用handle_nested_irq()以调用它们的处理程序。再次,这看起来像通用的嵌套irqchips,除了 gpiolib 已经包装了一些底层的嵌套irqchipAPI。要确认这一点,您可以查看gpiochip_set_nested_irqchip()gpiochip_irqchip_add_nested()方法的主体。

Regmap IRQ API 和数据结构

Regmap IRQ API 实现在 drivers/base/regmap/regmap-irq.c 中。它主要建立在两个基本函数 devm_regmap_add_irq_chip()regmap_irq_get_virq() 以及三个数据结构 struct regmap_irq_chipstruct regmap_irq_chip_datastruct regmap_irq 的基础上。

重要说明

Regmap 的 irqchip API 完全使用了线程中断。因此,只有我们在 嵌套中断 部分看到的内容才适用于这里。

Regmap IRQ 数据结构

如前所述,我们需要介绍 regmap irq api 的三个数据结构,以便了解它如何抽象中断管理。

struct regmap_irq_chipstruct regmap_irq

struct regmap_irq_chip 结构描述了一个通用的 regmap irq_chip。在讨论这个结构之前,让我们先介绍 struct regmap_irq,它存储了 regmap irq_chip 的中断的寄存器和掩码描述:

struct regmap_irq {
    unsigned int reg_offset;
    unsigned int mask;
    unsigned int type_reg_offset;
    unsigned int type_rising_mask;
    unsigned int type_falling_mask;
};

以下是前述结构中字段的描述:

  • reg_offset 是在银行内状态/掩码寄存器的偏移。该银行实际上可能是 IRQ chip{status/mask/unmask/ack/wake}_base 寄存器。

  • mask 是用于标记/控制此中断状态寄存器的掩码。在禁用中断时,掩码值将与来自 regmap 的 irq_chip.status_base 寄存器的 reg_offset 的实际内容进行 OR 运算。对于中断使能,将进行 ~mask 的 AND 运算。

  • type_reg_offset 是 IRQ 类型设置的偏移寄存器(从 irqchip 状态基地址寄存器)。

  • type_rising_mask 是用于配置 上升 类型中断的掩码位。当将中断类型设置为 IRQ_TYPE_EDGE_RISING 时,此值将与 type_reg_offset 的实际内容进行 OR 运算。

  • type_falling_mask 是用于配置 下降 类型中断的掩码位。当将中断类型设置为 IRQ_TYPE_EDGE_FALLING 时,此值将与 type_reg_offset 的实际内容进行 OR 运算。对于 IRQ_TYPE_EDGE_BOTH 类型,将使用 (type_falling_mask | irq_data->type_rising_mask) 作为掩码。

现在我们熟悉了 struct regmap_irq,让我们描述 struct regmap_irq_chip,其结构如下:

struct regmap_irq_chip {
    const char *name;
    unsigned int status_base;
    unsigned int mask_base;
    unsigned int unmask_base;
    unsigned int ack_base;
    unsigned int wake_base;
    unsigned int type_base;
    unsigned int irq_reg_stride;
    bool mask_writeonly:1;
    bool init_ack_masked:1;
    bool mask_invert:1;
    bool use_ack:1;
    bool ack_invert:1;
    bool wake_invert:1;
    bool type_invert:1;
    int num_regs;
    const struct regmap_irq *irqs;
    int num_irqs;
    int num_type_reg;
    unsigned int type_reg_stride;
    int (*handle_pre_irq)(void *irq_drv_data);
    int (*handle_post_irq)(void *irq_drv_data);
    void *irq_drv_data;
};

该结构描述了一个通用的 regmap_irq_chip,它可以处理大多数中断控制器(并非所有,我们稍后会看到)。以下列表描述了此数据结构中的字段:

  • name 是中断控制器的描述性名称。

  • status_base 是基本状态寄存器地址,regmap IRQ 核心在获取给定 regmap_irq 的最终状态寄存器之前会添加 regmap_irq.reg_offset

  • mask_writeonly 表示基本掩码寄存器是否是只写的。如果是,将使用 regmap_write_bits() 写入寄存器,否则使用 regmap_update_bits()

  • unmask_base 是基本取消屏蔽寄存器地址,对于具有单独的屏蔽和取消屏蔽寄存器的芯片必须指定。

  • ack_base 是确认基地址寄存器。使用 use_ack 位时,可以使用值 0

  • wake_basewake enable 的基地址,用于控制中断电源管理唤醒。如果值为 0,表示不支持。

  • type_base 是 IRQ 类型的基地址,regmap IRQ 核心在获取给定 regmap_irq 的最终类型寄存器之前会添加 regmap_irq.type_reg_offset。如果为 0,表示不支持。

  • irq_reg_stride 是在寄存器不连续的芯片上使用的步幅。

  • init_ack_masked 表示 regmap IRQ 核心在初始化期间是否应确认所有屏蔽中断。

  • mask_invert,如果为 true,表示掩码寄存器是反转的。这意味着清除的位索引对应于被屏蔽的中断。

  • use_ack,如果为 true,表示即使为 0,也应该使用确认寄存器。

  • ack_invert,如果为 true,表示确认寄存器是反转的:对于确认,相应的位将被清除。

  • wake_invert,如果为true,表示唤醒寄存器被反转:清除的位对应于唤醒使能。

  • type_invert,如果为true,表示使用反转类型标志。

  • num_regs是每个控制块中寄存器的数量。使用regmap_bulk_read()时要读取的寄存器数量将被给出。查看regmap_irq_thread()的定义以获取更多信息。

  • irqs是单个 IRQ 的描述符数组,num_irqs是数组中描述符的总数。中断号是基于该数组中的索引分配的。

  • num_type_reg是类型寄存器的数量,而type_reg_stride是用于非连续类型寄存器芯片的步幅。Regmap IRQ 实现了通用中断服务例程,对大多数设备都是通用的。

  • 一些设备,比如MAX77620MAX20024,在服务中断之前和之后需要特殊处理。这就是handle_pre_irqhandle_post_irq发挥作用的地方。这些是特定于驱动程序的回调函数,用于在regmap_irq_handler处理中断之前处理设备的中断。然后irq_drv_data是作为参数传递给这些前/后中断处理程序的数据。例如,MAX77620的中断服务编程指南如下所示:

--当来自 PMIC 的中断发生时,通过设置 GLBLM 来屏蔽 PMIC 中断。

--读取 IRQTOP 并相应地服务中断。

--一旦所有中断都经过检查和服务,中断服务例程通过清除 GLBLM 来取消屏蔽硬件中断线。

回到regmap_irq_chip.irqs字段,这个字段是之前介绍的regmap_irq类型。

结构体regmap_irq_chip_data

该结构是 regmap IRQ 控制器的运行时数据结构,在devm_regmap_add_irq_chip()成功返回路径上分配。它必须存储在一个大型和私有的数据结构中以供以后使用。其定义如下:

struct regmap_irq_chip_data {
    struct mutex lock;
    struct irq_chip irq_chip;
    struct regmap *map;
    const struct regmap_irq_chip *chip;
    int irq_base;
    struct irq_domain *domain;
    int irq;
    [...]
};

为简单起见,结构中的一些字段已被删除。以下是该结构中字段的描述:

  • lock是用于保护对regmap_irq_chip_data所属的irq_chip的访问的锁。由于 regmap IRQ 是完全线程化的,因此可以安全地使用互斥锁。

  • irq_chip是此 regmap 启用的irqchip的底层中断芯片描述符结构(提供 IRQ 相关操作),使用regmap_irq_chip设置,如drivers/base/regmap/regmap-irq.c中所定义:

static const struct irq_chip regmap_irq_chip = {
    .irq_bus_lock = regmap_irq_lock,
    .irq_bus_sync_unlock = regmap_irq_sync_unlock,
    .irq_disable = regmap_irq_disable,
    .irq_enable = regmap_irq_enable,
    .irq_set_type = regmap_irq_set_type,
    .irq_set_wake = regmap_irq_set_wake,
};
  • map是前述irq_chip的 regmap 结构。

  • chip是指向通用 regmap irq_chip的指针,它应该已经在驱动程序中设置好。它作为参数传递给devm_regmap_add_irq_chip()

  • base,如果大于零,是从其分配特定 IRQ 编号的基数。换句话说,IRQ 的编号从base开始。

  • domain是底层 IRQ 芯片的 IRQ 域,ops设置为regmap_domain_ops,定义如下:

static const struct irq_domain_ops regmap_domain_ops = {
    .map = regmap_irq_map,
    .xlate = irq_domain_xlate_onetwocell,
};
  • irqirq_chip的父(基本)IRQ。它对应于给定给devm_regmap_add_irq_chip()irq参数。

Regmap IRQ API

在本章的前面,我们介绍了devm_regmap_add_irq_chip()regmap_irq_get_virq()作为 regmap IRQ API 的两个基本函数。这些实际上是 regmap IRQ 管理中最重要的函数,以下是它们各自的原型:

int devm_regmap_add_irq_chip(struct device *dev,                          struct regmap *map,
                         int irq, int irq_flags,                          int irq_base,
                         const struct regmap_irq_chip *chip,
                         struct regmap_irq_chip_data **data)
int regmap_irq_get_virq(struct regmap_irq_chip_data *data,                         int irq)

在前面的代码中,devirq_chip所属的设备指针。map是设备的有效和初始化的 regmap。irq_base,如果大于零,将是第一个分配的 IRQ 的编号。chip是中断控制器的配置。在regmap_irq_get_virq()的原型中,*data是一个初始化的输入参数,必须通过devm_regmap_add_irq_chip()通过**data返回。

devm_regmap_add_irq_chip()是您应该在代码中使用以添加基于 regmap 的 irqchip 支持的函数。它的data参数是一个输出参数,表示在此函数调用成功时分配的控制器的运行时数据结构。它的irq参数是 irqchip 的父和主要 IRQ。这是设备用于发出中断信号的 IRQ,而irq_flags是用于此主要中断的IRQF_标志的掩码。如果此函数成功(即返回0),则输出数据将设置为类型为regmap_irq_chip_data的新分配和配置良好的结构。此函数在失败时返回errnodevm_regmap_add_irq_chip()是以下内容的组合:

  • 分配和初始化struct regmap_irq_chip_data

  • irq_domain_add_linear()(如果irq_base == 0),它根据域中所需的 IRQ 数量分配一个 IRQ 域。成功后,IRQ 域将分配给先前分配的 IRQ 芯片数据的.domain字段。此域的ops.map函数将配置每个 IRQ 子作为嵌套到父线程中,ops.xlate将设置为irq_domain_xlate_onetwocell。如果irq_base > 0,则使用irq_domain_add_legacy()而不是irq_domain_add_linear()

  • request_threaded_irq(),以注册父 IRQ 线程处理程序。Regmap 使用其自定义的线程处理程序regmap_irq_thread(),在调用子irqs上的handle_nested_irq()之前进行一些修改。

以下是总结前述操作的摘录:

static int regmap_irq_map(struct irq_domain *h,                           unsigned int virq,
                          irq_hw_number_t hw)
{
    struct regmap_irq_chip_data *data = h->host_data;
    irq_set_chip_data(virq, data);
    irq_set_chip(virq, &data->irq_chip);
    irq_set_nested_thread(virq, 1);
    irq_set_parent(virq, data->irq);
    irq_set_noprobe(virq);
    return 0;
}
static const struct irq_domain_ops regmap_domain_ops = {
    .map = regmap_irq_map,
    .xlate = irq_domain_xlate_onetwocell,
};
static irqreturn_t regmap_irq_thread(int irq, void *d)
{
    [...]
    for (i = 0; i < chip->num_irqs; i++) {
        if (data->status_buf[chip->irqs[i].reg_offset /
            map->reg_stride] & chip->irqs[i].mask) {
            handle_nested_irq(irq_find_mapping(data->domain,             i));
          handled = true;
        }
    }
    [...]
    if (handled)
        return IRQ_HANDLED;
    else
        return IRQ_NONE;
}
int regmap_add_irq_chip(struct regmap *map, int irq,                         int irq_ flags,
                        int irq_base,                         const struct regmap_irq_chip *chip,
                        struct regmap_irq_chip_data **data)
{
    struct regmap_irq_chip_data *d;
    [...]
    d = kzalloc(sizeof(*d), GFP_KERNEL);
    if (!d)
        return -ENOMEM;
    /* The below is just for simplicity */
    initialize_irq_chip_data(d);
    if (irq_base)
        d->domain = irq_domain_add_legacy(map->dev->of_node,
                                          chip->num_irqs,
                                          irq_base, 0,
                                          &regmap_domain_ops,                                          d);
    else
        d->domain = irq_domain_add_linear(map->dev->of_node,
                                          chip->num_irqs,
                                          &regmap_domain_ops,                                           d);
    ret = request_threaded_irq(irq, NULL, regmap_irq_thread,
                               irq_flags | IRQF_ONESHOT,
                               chip->name, d);
    [...]
    *data = d;
    return 0;
}

regmap_irq_get_virq()将芯片上的中断映射到虚拟 IRQ。它只是在给定的irq和域上返回irq_create_mapping(data->domain, irq),就像我们之前看到的那样。它的irq参数是在芯片 IRQs 中请求的中断的索引。

Regmap IRQ API 示例

让我们使用max7760 GPIO 控制器的驱动程序来看看 regmap IRQ API 背后的概念是如何应用的。此驱动程序位于内核源中的drivers/gpio/gpio-max77620.c,以下是此驱动程序使用 regmap 处理 IRQ 管理的简化摘录。

让我们首先定义将在编写代码期间使用的数据结构:

struct max77620_gpio {
    struct gpio_chip gpio_chip;
    struct regmap *rmap;
    struct device *dev;
};
struct max77620_chip {
    struct device *dev;
    struct regmap *rmap;
    int chip_irq;
    int irq_base;
    [...]
    struct regmap_irq_chip_data *top_irq_data;
    struct regmap_irq_chip_data *gpio_irq_data;
};

当您阅读代码时,上述数据结构的含义将变得清晰。接下来,让我们定义我们的 regmap IRQ 数组,如下所示:

static const struct regmap_irq max77620_gpio_irqs[] = {
    [0] = {
        .mask = MAX77620_IRQ_LVL2_GPIO_EDGE0,
        .type_rising_mask = MAX77620_CNFG_GPIO_INT_RISING,
        .type_falling_mask = MAX77620_CNFG_GPIO_INT_FALLING,
        .reg_offset = 0,
        .type_reg_offset = 0,
    },
    [1] = {
        .mask = MAX77620_IRQ_LVL2_GPIO_EDGE1,
        .type_rising_mask = MAX77620_CNFG_GPIO_INT_RISING,
        .type_falling_mask = MAX77620_CNFG_GPIO_INT_FALLING,
        .reg_offset = 0,
        .type_reg_offset = 1,
    },
    [2] = {
        .mask = MAX77620_IRQ_LVL2_GPIO_EDGE2,
        .type_rising_mask = MAX77620_CNFG_GPIO_INT_RISING,
        .type_falling_mask = MAX77620_CNFG_GPIO_INT_FALLING,
        .reg_offset = 0,
        .type_reg_offset = 2,
    },
    [...]
    [7] = {
        .mask = MAX77620_IRQ_LVL2_GPIO_EDGE7,
        .type_rising_mask = MAX77620_CNFG_GPIO_INT_RISING,
        .type_falling_mask = MAX77620_CNFG_GPIO_INT_FALLING,
        .reg_offset = 0,
        .type_reg_offset = 7,
    },
};

您可能已经注意到为了可读性而对数组进行了截断。然后,可以将此数组分配给regmap_irq_chip数据结构,如下所示:

static const struct regmap_irq_chip max77620_gpio_irq_chip = {
    .name = "max77620-gpio",
    .irqs = max77620_gpio_irqs,
    .num_irqs = ARRAY_SIZE(max77620_gpio_irqs),
    .num_regs = 1,
    .num_type_reg = 8,
    .irq_reg_stride = 1,
    .type_reg_stride = 1,
    .status_base = MAX77620_REG_IRQ_LVL2_GPIO,
    .type_base = MAX77620_REG_GPIO0,
};

总结前述摘录,驱动程序填充了一个regmap_irq数组(max77620_gpio_irqs[]),并使用它来构建一个regmap_irq_chip结构(max77620_gpio_irq_chip)。一旦regmap_irq_chip数据结构准备就绪,我们开始编写一个irqchip回调,这是内核gpiochip核心所需的:

static int max77620_gpio_to_irq(struct gpio_chip *gc,
                                unsigned int offset)
{
    struct max77620_gpio *mgpio = gpiochip_get_data(gc);
    struct max77620_chip *chip =                          dev_get_drvdata(mgpio->dev- >parent);
    return regmap_irq_get_virq(chip->gpio_irq_data, offset);
}

在上述片段中,我们只定义了将分配给 GPIO 芯片的.to_irq字段的回调。其他回调可以在原始驱动程序中找到。再次强调,此处的代码已被截断。在这个阶段,我们可以谈论probe方法,它将使用之前定义的所有函数:

static int max77620_gpio_probe(struct platform_device *pdev)
{
     struct max77620_chip *chip =      dev_get_drvdata(pdev->dev.parent);
     struct max77620_gpio *mgpio;
     int gpio_irq;
     int ret;
     gpio_irq = platform_get_irq(pdev, 0);
     [...]
     mgpio = devm_kzalloc(&pdev->dev, sizeof(*mgpio),                           GFP_KERNEL);
     if (!mgpio)
         return -ENOMEM;
     mgpio->rmap = chip->rmap;
     mgpio->dev = &pdev->dev;
     /* setting gpiochip stuffs*/
     mgpio->gpio_chip.direction_input =                                 max77620_gpio_dir_input;
     mgpio->gpio_chip.get = max77620_gpio_get;
     mgpio->gpio_chip.direction_output =                                 max77620_gpio_dir_output;
     mgpio->gpio_chip.set = max77620_gpio_set;
     mgpio->gpio_chip.set_config = max77620_gpio_set_config;
     mgpio->gpio_chip.to_irq = max77620_gpio_to_irq;
     mgpio->gpio_chip.ngpio = MAX77620_GPIO_NR;
     mgpio->gpio_chip.can_sleep = 1;
     mgpio->gpio_chip.base = -1;
     #ifdef CONFIG_OF_GPIO
     mgpio->gpio_chip.of_node = pdev->dev.parent->of_node;
     #endif
     ret = devm_gpiochip_add_data(&pdev->dev,
                                  &mgpio->gpio_chip, mgpio);
     [...]
     ret = devm_regmap_add_irq_chip(&pdev->dev,
                                    chip->rmap, gpio_irq,
                                    IRQF_ONESHOT, -1,
                                    &max77620_gpio_irq_chip,
                                    &chip->gpio_irq_data);
     [...]
     return 0;
}

在这个probe方法摘录中(没有错误检查),max77620_gpio_irq_chip最终被提供给devm_regmap_add_irq_chip,以便用 IRQ 填充 irqchip,然后将 IRQ 芯片添加到 regmap 核心。这个函数还使用一个有效的regmap_irq_chip_data结构设置了chip->gpio_irq_data,而chip是私有数据结构,允许我们存储这个 IRQ 芯片数据以供以后使用。由于这个 IRQ 控制器是建立在 GPIO 控制器(gpiochip)之上的,所以必须设置gpio_chip.to_irq字段,这里就是max77620_gpio_to_irq回调函数。这个回调函数简单地返回regmap_irq_get_virq()返回的值,后者根据给定的偏移量在regmap_irq_chip_data.domain中创建并返回一个有效的irq映射。其他函数已经被介绍过,对我们来说并不新鲜。

在本节中,我们介绍了使用 regmap 进行完整的 IRQ 管理。你已经准备好将基于 MMIO 的 IRQ 管理迁移到 regmap。

总结

这一章主要涉及 regmap 核心。我们介绍了这个框架,走过了它的 API,并描述了一些用例。除了寄存器访问,我们还学会了如何使用 regmap 进行基于 MMIO 的 IRQ 管理。下一章将涉及 MFD 设备和 syscon 框架,将大量使用本章学到的概念。在本章结束时,你应该能够开发支持 regmap 的 IRQ 控制器,并且不会发现自己在为寄存器访问重新发明轮子并利用这个框架。

第三章:深入研究 MFD 子系统和 Syscon API

设备的日益密集集成导致了一种由多个其他设备或 IP 组成的设备,可以实现专用功能。随着这种设备的出现,Linux 内核中出现了一个新的子系统。这些是MFDs,代表多功能设备。这些设备在物理上看起来是独立的设备,但从软件角度来看,它们在父子关系中表示,其中子设备是子设备。

一些基于 I2C 和 SPI 的设备/子设备可能需要一些黑客或配置才能被添加到系统中,还有一些基于 MMIO 的设备/子设备,它们不需要任何配置或黑客,只需要在子设备之间共享主设备的寄存器区域。简单的 mfd 助手被引入来处理零配置/黑客子设备的注册,syscon 被引入来与其他设备共享设备的内存区域。由于 regmap 处理 MMIO 寄存器并管理对内存的访问(也称为同步),因此将 syscon 构建在 regmap 之上是一个自然的选择。为了熟悉 MFD 子系统,在本章中,我们将首先介绍 MFD,您将了解其数据结构和 API,然后我们将研究设备树绑定,以便向内核描述这些设备。最后,我们将讨论 syscon 并介绍简单的 mfd 驱动程序,用于零配置/黑客子设备。

本章将涵盖以下主题:

  • 介绍 MFD 和 syscon API 和数据结构

  • MFD 设备的设备树绑定

  • 了解 syscon 和简单的 mfd

技术要求

为了利用本章,您需要以下内容:

介绍 MFD 子系统和 Syscon API

在深入研究 syscon 框架及其 API 之前,我们将介绍 MFD。有些外围设备或硬件块通过它们嵌入的子设备来提供多个功能,这些子设备由内核中的单独子系统处理。也就是说,子设备是所谓的多功能设备中的专用实体,负责特定任务,并通过芯片的寄存器映射中的一组减少的寄存器进行管理。ADP5520是 MFD 设备的典型示例,因为它包含背光、键盘、LED 和 GPIO 控制器。每个子设备都被视为一个子设备,正如您所看到的,每个子设备都属于不同的子系统。MFD 子系统定义在include/linux/mfd/core.h中,并在drivers/mfd/mfd-core.c中实现,用于处理这些设备,允许以下功能:

  • 在多个子系统中注册相同的设备

  • 复用总线和寄存器访问,因为可能有一些寄存器在子设备之间共享

  • 处理中断和时钟

在本节中,我们将研究来自对话半导体的da9055设备的驱动程序,位于内核源树中的drivers/mfd/da9055-core.c。该设备的数据手册可以在www.dialog-semiconductor.com/sites/default/files/da9055-00-ids3a_20120710.pdf找到。

在大多数情况下,MFD 设备驱动程序由两部分组成:

  • drivers/mfd,负责主要初始化并将每个子设备注册为系统上的平台设备(以及其平台数据)。该驱动程序应为子设备驱动程序提供通用服务。这些服务包括寄存器访问、控制和共享中断管理。当一个子系统的平台驱动程序被实例化时,核心初始化芯片(可以由平台数据指定)。单个内核映像中可以构建相同类型的多个块设备的支持。这要归功于平台数据的机制。内核中使用平台特定数据抽象机制来将配置传递给核心,并且子驱动程序使得支持多个相同类型的块设备成为可能。

  • 子设备驱动程序,负责处理核心驱动程序早期注册的特定子设备。这些驱动程序位于各自的子系统目录中。每个外围(子系统设备)对设备有一个有限的视图,隐式地缩小到外围需要为了正确运行所需的特定资源集。

重要提示

本章中的子设备概念不应与第七章中的同名概念混淆,解密 V4L2 和视频捕获设备驱动程序,稍有不同,其中子设备还代表视频管道中的实体。

在 MFD 子系统中,子设备由struct mfd_cell结构的实例表示,您可以称之为struct mfd_cell结构,您可以指定更高级的东西,例如子设备使用的资源和挂起-恢复操作(从子设备的驱动程序调用)。该结构如下所示,为简化原因删除了一些字段:

/*
 * This struct describes the MFD part ("cell").
 * After registration the copy of this structure will
 * become the platform data of the resulting platform_device
 */
struct mfd_cell {
    const char *name;
    int id;
    [...]
    int (*suspend)(struct platform_device *dev);
    int (*resume)(struct platform_device *dev);
    /* platform data passed to the sub devices drivers */
    void *platform_data;
    size_t pdata_size;
    /* Device Tree compatible string */
    const char *of_compatible;
    /* Matches ACPI */
    const struct mfd_cell_acpi_match *acpi_match;
    /*
     * These resources can be specified relative to the
     * parent device. For accessing hardware, you should
     * use resources from the platform dev
     */
    int num_resources;
    const struct resource *resources;
    [...]
};

重要提示

创建的新平台设备将具有该单元结构作为其平台数据。然后可以通过pdev->mfd_cell->platform_data访问真实的平台数据。驱动程序还可以使用mfd_get_cell()来检索与平台设备对应的 MFD 单元:const struct mfd_cell *cell = mfd_get_cell(pdev);

该结构的每个成员的功能是不言自明的。但以下内容会给您更多细节。

.resources元素是表示子设备特定资源的数组(也是平台设备),.num_resources是数组中条目的数量。这些是使用platform_data定义的,您可能希望为其命名以便轻松检索。以下是一个原始核心源文件为drivers/mfd/da9055-core.c的 MFD 驱动程序的示例:

static struct resource da9055_rtc_resource[] = {
    {
        .name = „ALM",
        .start = DA9055_IRQ_ALARM,
        .end = DA9055_IRQ_ALARM,
        .flags = IORESOURCE_IRQ,
    },
    {
        .name = "TICK",
        .start = DA9055_IRQ_TICK,
        .end = DA9055_IRQ_TICK,
        .flags = IORESOURCE_IRQ,
    },
};
static const struct mfd_cell da9055_devs[] = {
    ...
    {
        .of_compatible = "dlg,da9055-rtc",
        .name = "da9055-rtc",
        .resources = da9055_rtc_resource,
        .num_resources = ARRAY_SIZE(da9055_rtc_resource),
    },
    ...
};

以下示例显示了如何从子设备驱动程序中检索资源,本例中实现在drivers/rtc/rtc-da9055.c中:

static int da9055_rtc_probe(struct platform_device *pdev)
{
    [...]
    alm_irq = platform_get_irq_byname(pdev, "ALM");
    if (alm_irq < 0)
        return alm_irq;
    ret = devm_request_threaded_irq(&pdev->dev, alm_irq, NULL,
                                    da9055_rtc_alm_irq,
                                    IRQF_TRIGGER_HIGH |                                     IRQF_ONESHOT,
                                    "ALM", rtc);
    if (ret != 0)
        dev_err(rtc->da9055->dev,
                 "irq registration failed: %d\n", ret);
    [...]
}

实际上,您应该使用platform_get_resource()platform_get_resource_byname()platform_get_irq()platform_get_irq_byname()来检索资源。

在使用.of_compatible时,该函数必须是 MFD 的子级(参见MFD 设备的设备树绑定部分)。您应该静态填充一个包含与设备上的子设备数量相同的条目的结构数组:

static struct resource da9055_rtc_resource[] = {
    {
        .name = „ALM",
        .start = DA9055_IRQ_ALARM,
        .end = DA9055_IRQ_ALARM,
        .flags = IORESOURCE_IRQ,
    },
    [...]
};
[...]
static const struct mfd_cell da9055_devs[] = {
    {
        .of_compatible = "dlg,da9055-gpio",
        .name = "da9055-gpio",
    },
    {
        .of_compatible = "dlg,da9055-regulator",
        .name = "da9055-regulator",
        .id = 1,
    },
    [...]
    {
        .of_compatible = "dlg,da9055-rtc",
        .name = "da9055-rtc",
        .resources = da9055_rtc_resource,
        .num_resources = ARRAY_SIZE(da9055_rtc_resource),
    },
    {
        .of_compatible = "dlg,da9055-watchdog",
        .name = "da9055-watchdog",
    },
};

填充struct mfd_cell数组后,必须将其传递给devm_mfd_add_devices()函数,如下所示:

int devm_mfd_add_devices(
                struct device *dev,
                int id,
                const struct mfd_cell *cells,
                int n_devs,
                struct resource *mem_base,
                int irq_base,
                struct irq_domain *domain)

该方法的参数解释如下:

  • dev是 MFD 芯片的通用结构设备。它将用于设置子设备的父级。

  • id:由于子设备被创建为平台设备,因此应该为其分配一个 ID。对于自动 ID 分配,应将此字段设置为PLATFORM_DEVID_AUTO,在这种情况下,相应单元的mfd_cell.id将被忽略。否则,您应该使用PLATFORM_DEVID_NONE

  • cells是一个指向描述子设备的struct mfd_cell结构的列表(实际上是一个数组)的指针。

  • n_dev是要在数组中使用的struct mfd_cell条目的数量,以创建平台设备。要创建与数组中的单元数量相同的平台设备,应使用ARRAY_SIZE()宏。

  • mem_base:如果不是NULL,其.start字段将用作先前提到的数组中每个 MFD 单元的IORESOURCE_MEM类型资源的基址。以下是mfd_add_device()函数的摘录,显示了这一点:

for (r = 0; r < cell->num_resources; r++) {
    res[r].name = cell->resources[r].name;
    res[r].flags = cell->resources[r].flags;
    /* Find out base to use */
    if ((cell->resources[r].flags & IORESOURCE_MEM) &&          mem_base) {
         res[r].parent = mem_base;
         res[r].start =
             mem_base->start + cell->resources[r].start;
         res[r].end =
             mem_base->start + cell->resources[r].end;
    } else if (cell->resources[r].flags & IORESOURCE_IRQ) {
[...]
  • irq_base:如果设置了域,则此参数将被忽略。否则,它的行为类似于mem_base,但对于每个IORESOURCE_IRQ类型的资源。以下是mfd_add_device()函数的摘录,显示了这一点:
    } else if (cell->resources[r].flags & IORESOURCE_IRQ) {
        if (domain) {
          /* Unable to create mappings for IRQ ranges. */
            WARN_ON(cell->resources[r].start !=
                            cell->resources[r].end);
            res[r].start = res[r].end =
                irq_create_mapping(
                        domain,cell->resources[r].start);
        } else {
            res[r].start =
                irq_base + cell->resources[r].start;
            res[r].end =
                irq_base + cell->resources[r].end;
        }
    } else {
    [...]
  • domain:对于同时扮演子设备的 IRQ 控制器角色的 MFD 芯片,此参数将用作 IRQ 域,为这些子设备创建 IRQ 映射。它的工作方式是:对于每个单元中类型为IORESOURCE_IRQ的资源r,MFD 核心将创建一个相同类型的新资源res(实际上是一个 IRQ 资源,其res.startres.end字段设置为与此域中对应于初始资源的.start字段的 IRQ 映射:res[r].start = res[r].end = irq_create_mapping(domain, cell->resources[r].start);)。然后,新的 IRQ 资源被分配给当前单元的平台设备,并对应于其 virqs。请查看上述摘录中的前一个参数描述。请注意,此参数可以是NULL

现在让我们看看如何将这些内容与da9055 MFD 驱动程序的摘录放在一起:

#define DA9055_IRQ_NONKEY_MASK 0x01
#define DA9055_IRQ_ALM_MASK 0x02
#define DA9055_IRQ_TICK_MASK 0x04
#define DA9055_IRQ_ADC_MASK 0x08
#define DA9055_IRQ_BUCK_ILIM_MASK 0x08
/*
 * PMIC IRQ
 */
#define DA9055_IRQ_ALARM 0x01
#define DA9055_IRQ_TICK 0x02
#define DA9055_IRQ_NONKEY 0x00
#define DA9055_IRQ_REGULATOR 0x0B
#define DA9055_IRQ_HWMON 0x03
struct da9055 {
    struct regmap *regmap;
    struct regmap_irq_chip_data *irq_data;
    struct device *dev;
    struct i2c_client *i2c_client;
    int irq_base;
    int chip_irq;
};

在上述摘录中,驱动程序定义了一些常量,以及一个私有数据结构,其含义将在阅读代码时清楚。之后,为寄存器映射核心定义了 IRQ,如下:

static const struct regmap_irq da9055_irqs[] = {
    [DA9055_IRQ_NONKEY] = {
        .reg_offset = 0,
        .mask = DA9055_IRQ_NONKEY_MASK,
    },
    [DA9055_IRQ_ALARM] = {
        .reg_offset = 0,
        .mask = DA9055_IRQ_ALM_MASK,
    },
    [DA9055_IRQ_TICK] = {
        .reg_offset = 0,
        .mask = DA9055_IRQ_TICK_MASK,
    },
    [DA9055_IRQ_HWMON] = {
        .reg_offset = 0,
        .mask = DA9055_IRQ_ADC_MASK,
    },
    [DA9055_IRQ_REGULATOR] = {
        .reg_offset = 1,
        .mask = DA9055_IRQ_BUCK_ILIM_MASK,
    },
};
static const struct regmap_irq_chip da9055_regmap_irq_chip = {
    .name = "da9055_irq",
    .status_base = DA9055_REG_EVENT_A,
    .mask_base = DA9055_REG_IRQ_MASK_A,
    .ack_base = DA9055_REG_EVENT_A,
    .num_regs = 3,
    .irqs = da9055_irqs,
    .num_irqs = ARRAY_SIZE(da9055_irqs),
};

在上述摘录中,da9055_irqsregmap_irq类型的元素数组,描述了通用的 regmap IRQ。它被分配给da9055_regmap_irq_chip,它是regmap_irq_chip类型,代表了 regmap IRQ 芯片。两者都是 regmap IRQ 数据结构集的一部分。最后,probe方法被实现如下:

static int da9055_i2c_probe(struct i2c_client *client,
                            const struct i2c_device_id *id)
{
    int ret;
    struct da9055_pdata *pdata = dev_get_platdata(da9055->dev);
    uint8_t clear_events[3] = {0xFF, 0xFF, 0xFF};
    [...]
    ret =
        devm_regmap_add_irq_chip(
            &client->dev, da9055->regmap,
            da9055->chip_irq, IRQF_TRIGGER_LOW | IRQF_ONESHOT,
            da9055->irq_base, &da9055_regmap_irq_chip,
            &da9055->irq_data);
    if (ret < 0)
            return ret;
    da9055->irq_base = regmap_irq_chip_get_base(                       da9055->irq_data);
    ret = devm_mfd_add_devices(
                    da9055->dev, -1,
                    da9055_devs, ARRAY_SIZE(da9055_devs),
                    NULL, da9055->irq_base,
                    regmap_irq_get_domain(da9055->irq_data));
    if (ret)
        goto err;
    [...]
}

在上述的探测方法中,da9055_regmap_irq_chip(之前定义的)作为参数传递给regmap_add_irq_chip(),以便向 IRQ 核心添加一个有效的 regmap IRQ 控制器。该函数成功返回0。此外,它还通过最后一个参数返回一个完全配置的regmap_irq_chip_data结构,可以作为控制器的运行时数据结构后续使用。这个regmap_irq_chip_data结构将包含与先前添加的 IRQ 控制器相关联的 IRQ 域。最终,这个 IRQ 域作为参数传递给devm_mfd_add_devices(),以及 MFD 单元的数组和单元数量。

重要提示

请注意,devm_mfd_add_devices()实际上是mfd_add_devices()的资源管理版本,其具有以下函数调用序列:

mfd_add_devices()-> mfd_add_device()-> platform_device_alloc()                             -> platform_device_add_data()                             -> platform_device_add_resources()                             -> platform_device_add()

有些 I2C 芯片的芯片本身和内部子设备具有不同的 I2C 地址。这样的 I2C 子设备不能作为 I2C 客户端进行探测,因为 MFD 核心只实例化给定 MFD 单元的平台设备。这个问题通过以下方式解决:

  • 创建一个虚拟 I2C 客户端,给定子设备的 I2C 地址和 MFD 芯片的适配器。这实际上对应于管理 MFD 设备的适配器(总线)。这可以使用i2c_new_dummy()来实现。返回的 I2C 客户端应该保存以备以后使用,例如,使用i2c_unregister_device(),在模块被卸载时应调用。

  • 如果子设备需要自己的 regmap,则必须在其虚拟 I2C 客户端的基础上构建此 regmap。

  • 仅存储 I2C 客户端(以备以后删除)或将其与 regmap 一起存储在可以分配给底层平台设备的私有数据结构中。

总结前面的步骤,让我们通过一个真实 MFD 设备 max8925 的驱动程序来走一遍(主要是电源管理 IC,但也由大量子设备组成)。我们的代码是原始代码的摘要(仅处理两个子设备),函数名称经过修改以便阅读。也就是说,原始驱动程序可以在内核源树中的drivers/mfd/max8925-i2c.c中找到。

让我们从上下文数据结构定义开始,如下所示:

struct priv_chip {
    struct device *dev;
    struct regmap *regmap;
    /* chip client for the parent chip, let's say the PMIC */
    struct i2c_client *client;
    /* chip client for subdevice 1, let's say an rtc */
    struct i2c_client *subdev1_client;
    /* chip client for subdevice 2 let's say a gpio controller      */
    struct i2c_client *subdev2_client;
    struct regmap *subdev1_regmap;
    struct regmap *subdev2_regmap;
    unsigned short subdev1_addr; /* subdevice 1 I2C address */
    unsigned short subdev2_addr; /* subdevice 2 I2C address */
};
const struct regmap_config chip_regmap_config = {
    [...]
};
const struct regmap_config subdev_rtc_regmap_config = {
    [...]
};
const struct regmap_config subdev_gpiochip_regmap_config = {
    [...]
};

在前面的摘录中,驱动程序定义了上下文数据结构struct priv_chip,其中包含子设备 regmaps,然后初始化了 MFD 设备 regmap 配置以及子设备自身的配置。然后,定义了probe方法,如下所示:

static int my_mfd_probe(struct i2c_client *client,
                        const struct i2c_device_id *id)
{
    struct priv_chip *chip;
    struct regmap *map;
    chip = devm_kzalloc(&client->dev,
                        sizeof(struct priv_chip), GFP_KERNEL);
    map = devm_regmap_init_i2c(client, &chip_regmap_config);
    chip->client = client;
    chip->regmap = map;
    chip->dev = &client->dev;
    dev_set_drvdata(chip->dev, chip);
    i2c_set_clientdata(chip->client, chip);
    chip->subdev1_addr = client->addr + 1;
    chip->subdev2_addr = client->addr + 2;
    /* subdevice 1, let's say an RTC */
    chip->subdev1_client = i2c_new_dummy(client->adapter,
                                         chip->subdev1_addr);
    chip->subdev1_regmap =
         devm_regmap_init_i2c(chip->subdev1_client,
                              &subdev_rtc_regmap_config);
    i2c_set_clientdata(chip->subdev1_client, chip);
    /* subdevice 2, let's say a gpio controller */
    chip->subdev2_client = i2c_new_dummy(client->adapter,
                                           chip->subdev2_addr);
    chip->subdev2_regmap =
        devm_regmap_init_i2c(chip->subdev2_client,
                             &subdev_gpiochip_regmap_config);
    i2c_set_clientdata(chip->subdev2_client, chip);
    /* mfd_add_devices() is called somewhere */
    [...]
}

为了便于阅读,前面的摘录省略了错误检查。此外,以下代码显示了如何删除虚拟 I2C 客户端:

static int my_mfd_remove(struct i2c_client *client)
{
    struct priv_chip *chip = i2c_get_clientdata(client);
    mfd_remove_devices(chip->dev);
    i2c_unregister_device(chip->subdev1_client);
    i2c_unregister_device(chip->subdev2_client);
    return 0;
}

最后,以下简化的代码显示了子设备驱动程序如何获取设置在 MFD 驱动程序中的 regmap 数据结构的指针:

static int subdev_rtc_probe(struct platform_device *pdev)
{
    struct priv_chip *chip = dev_get_drvdata(pdev->dev.parent);
    struct regmap *rtc_regmap = chip->subdev1_regmap;
    int ret;
    [...]
    if (!rtc_regmap) {
        dev_err(&pdev->dev, "no regmap!\n");
        ret = -EINVAL;
        goto out;
    }
    [...]
}

尽管我们已经掌握了开发 MFD 设备驱动程序所需的大部分知识,但是有必要将其与设备树集成,以便更好地(即非硬编码)描述我们的 MFD 设备。这是我们将在下一节中讨论的内容。

MFD 设备的设备树绑定

尽管我们有必要的工具和输入来编写自己的 MFD 驱动程序,但是重要的是底层 MFD 设备在设备树中有其描述,因为这样可以让 MFD 核心知道我们的 MFD 设备由什么组成以及如何处理它。此外,设备树仍然是声明设备的正确位置,无论它们是否是 MFD。请记住,它的目的只是描述系统上的设备。由于子设备是构建它们的 MFD 设备的子级(存在从属关系),因此在父节点下声明这些子设备节点是一个良好的做法,就像以下示例中所示的那样。此外,子设备使用的资源有时是父设备的资源的一部分。因此,它强调了将子设备节点放在主设备节点下的想法。在每个子设备节点中,兼容属性应该与子设备的cell.of_compatible字段和子设备的platform_driver.of_match_table数组中的一个.compatible字符串条目之一匹配,或者与子设备的cell.name字段和子设备的platform_driver.name字段匹配:

重要说明

子设备的cell.of_compatiblecell.name字段是在 MFD 核心驱动程序中的子设备的mfd_cell结构中声明的。

&i2c3 {
    pinctrl-names = "default";
    pinctrl-0 = <&pinctrl_i2c3>;
    clock-frequency = <400000>;
    status = "okay";
    pmic0: da9062@58 {
        compatible = "dlg,da9062";
        reg = <0x58>;
        pinctrl-names = "default";
        pinctrl-0 = <&pinctrl_pmic>;
        interrupt-parent = <&gpio6>;
        interrupts = <11 IRQ_TYPE_LEVEL_LOW>;
        interrupt-controller;
        regulators {
            DA9062_BUCK1: buck1 {
                regulator-name = "BUCK1";
                regulator-min-microvolt = <300000>;
                regulator-max-microvolt = <1570000>;
                regulator-min-microamp = <500000>;
                regulator-max-microamp = <2000000>;
                regulator-boot-on;
            };
            DA9062_LDO1: ldo1 {
                regulator-name = "LDO_1";
                regulator-min-microvolt = <900000>;
                regulator-max-microvolt = <3600000>;
                regulator-boot-on;
            };
        };
        da9062_rtc: rtc {
            compatible = "dlg,da9062-rtc";
        };
        watchdog {
            compatible = "dlg,da9062-watchdog";
        };
        onkey {
            compatible = "dlg,da9062-onkey";
            dlg,disable-key-power;
        };
    };
};

在前面的设备树示例中,父节点(实际上是da9092da9062节点)。让我们专注于子设备的compatible属性,并以onkey为例。此节点的 MFD 单元在 MFD 核心驱动程序中声明(源文件为drivers/mfd/da9063-core.c),如下所示:

static struct resource da9063_onkey_resources[] = {
    {
        .name = "ONKEY",
        .start = DA9063_IRQ_ONKEY,
        .end = DA9063_IRQ_ONKEY,
        .flags = IORESOURCE_IRQ,d
    },
};
static const struct mfd_cell da9062_devs[] = {
    [...]
    {
        .name = "da9062-onkey",
        .num_resources = ARRAY_SIZE(da9062_onkey_resources),
        .resources = da9062_onkey_resources,
        .of_compatible = "dlg,da9062-onkey",
    },
};

现在,这个onekey平台驱动程序结构被声明(以及其.of_match_table条目)在驱动程序中(源文件为drivers/input/misc/da9063_onkey.c),如下所示:

static const struct of_device_id da9063_compatible_reg_id_table[] = {
    { .compatible = "dlg,da9063-onkey", .data = &da9063_regs },
    { .compatible = "dlg,da9062-onkey", .data = &da9062_regs },
    { },
};
MODULE_DEVICE_TABLE(of, da9063_compatible_reg_id_table);
[...]
static struct platform_driver da9063_onkey_driver = {
    .probe = da9063_onkey_probe,
    .driver = {
        .name = DA9063_DRVNAME_ONKEY,
        .of_match_table = da9063_compatible_reg_id_table,
    },
};

您可以看到两个compatible字符串与设备节点中的compatible字符串匹配。另一方面,我们可以看到同一平台驱动程序可能用于两个或更多(子)设备。然后使用名称匹配会很令人困惑。这就是为什么您会使用设备树进行声明和compatible字符串进行匹配的原因。到目前为止,我们已经了解了 MFD 子系统如何处理设备以及反之。在下一节中,我们将把这些概念扩展到 syscon 和 simple-mfd,这是两个有助于 MFD 驱动程序开发的框架。

理解 Syscon 和 simple-mfd

Syscon代表系统控制器。SoC 有时会有一组专用于与特定 IP 无关的杂项功能的 MMIO 寄存器。显然,对于这种情况,不能有功能驱动程序,因为这些寄存器既不具有代表性,也不足以代表特定类型的设备。syscon 驱动程序处理这种情况。Syscon 允许其他节点通过 regmap 机制访问此寄存器空间。实际上,它只是 regmap 的一组包装 API。当您请求访问 syscon 时,如果尚不存在,则会创建 regmap。

使用 syscon API 所需的头文件是<linux/mfd/syscon.h>。由于此 API 基于 regmap,因此还必须包括<linux/regmap.h>。syscon API 在内核源树中的drivers/mfd/syscon.c中实现。其主要数据结构是struct syscon,尽管不应直接使用此结构:

struct syscon {
    struct device_node *np;
    struct regmap *regmap;
    struct list_head list;
};

在上述结构中,np是指向充当 syscon 的节点的指针。它还用于通过设备节点进行 syscon 查找。regmap是与此 syscon 关联的 regmap,list用于实现内核链表机制,用于将系统中的所有 syscon 连接到系统范围的列表syscon_list中,该列表在drivers/mfd/syscon.c中定义。此链表机制允许遍历整个 syscon 列表,无论是通过节点匹配还是通过 regmap 匹配。

Syscon 是通过在应充当 Syscon 的设备节点的兼容字符串列表中添加"syscon"来专门声明的。在早期引导期间,具有其兼容字符串列表中的syscon的每个节点将其reg内存区域进行 IO 映射,并根据默认的 regmap 配置syscon_regmap_config绑定到 MMIO regmap,如下所示:

static const struct regmap_config syscon_regmap_config = {
    .reg_bits = 32,
    .val_bits = 32,
    .reg_stride = 4,
};

然后,创建的 syscon 将添加到 syscon 框架范围的syscon_list中,并由syscon_list_slock自旋锁保护,如下所示:

static DEFINE_SPINLOCK(syscon_list_slock);
static LIST_HEAD(syscon_list);
static struct syscon *of_syscon_register(struct device_node                                          *np)
{
    struct syscon *syscon;
    struct regmap *regmap;
    void __iomem *base;
    [...]
    if (!of_device_is_compatible(np, "syscon"))
        return ERR_PTR(-EINVAL);
    [...]
    spin_lock(&syscon_list_slock);
    list_add_tail(&syscon->list, &syscon_list);
    spin_unlock(&syscon_list_slock);
    return syscon;
}

Syscon 绑定需要以下强制属性:

  • compatible: 此属性值应为"syscon"

  • reg: 这是可以从 syscon 访问的寄存器区域。

以下是可选属性,用于篡改默认的syscon_regmap_config regmap 配置:

  • reg-io-width: 应在设备上执行的 IO 访问的大小(或宽度,以字节为单位)

  • hwlocks: 指向硬件自旋锁提供程序节点的 phandle 的引用

下面是一个示例,摘自内核文档,完整版本可在内核源代码中的Documentation/devicetree/bindings/mfd/syscon.txt中找到:

gpr: iomuxc-gpr@20e0000 {
    compatible = "fsl,imx6q-iomuxc-gpr", "syscon";
    reg = <0x020e0000 0x38>;
    hwlocks = <&hwlock1 1>;
};
hwlock1: hwspinlock@40500000 {
    ...
    reg = <0x40500000 0x1000>;
    #hwlock-cells = <1>;
};

在设备树中,可以通过三种不同的方式引用 syscon 节点:通过 phandle(在此驱动程序的设备节点中指定)、通过其路径,或者通过使用特定的兼容值进行搜索,之后驱动程序可以询问节点(或关联的此 regmap 的 OS 驱动程序)以确定寄存器的位置,最后直接访问寄存器。您可以使用以下 syscon API 之一来获取与给定 syscon 节点关联的 regmap 的指针:

struct regmap * syscon_node_to_regmap (struct device_node *np);
struct regmap * syscon_regmap_lookup_by_compatible(const char                                                    *s);
struct regmap * syscon_regmap_lookup_by_pdevname(const char                                                  *s);
struct regmap * syscon_regmap_lookup_by_phandle(
                            struct device_node *np,
                            const char *property);

上述 API 具有以下描述:

  • syscon_regmap_lookup_by_compatible(): 给定 syscon 设备节点的兼容字符串之一,此函数返回关联的 regmap,如果尚不存在,则创建一个,然后返回它。

  • syscon_node_to_regmap(): 给定一个 syscon 设备节点作为参数,此函数返回关联的 regmap,如果尚不存在,则创建一个,然后返回它。

  • syscon_regmap_lookup_by_phandle(): 给定一个包含 syscon 节点标识符的 phandle 属性,此函数返回与此 syscon 节点对应的 regmap。

在展示使用上述 API 的示例之前,让我们介绍以下平台设备节点,我们将编写probe函数。为了更好地理解syscon_node_to_regmap(),让我们将此节点声明为先前gpr节点的子节点:

gpr: iomuxc-gpr@20e0000 {
    compatible = "fsl,imx6q-iomuxc-gpr", "syscon";
    reg = <0x020e0000 0x38>;
    my_pdev: my_pdev {
        compatible = "company,regmap-sample";
        regmap-phandle = <&gpr>;
        [...]
    };
};

现在定义了设备树节点,我们可以专注于驱动程序的代码,如下所示,并使用前面列举的函数:

static struct regmap *by_node_regmap;
static struct regmap *by_compat_regmap;
static struct regmap *by_pdevname_regmap;
static struct regmap *by_phandle_regmap;
static int my_pdev_regmap_sample(struct platform_device *pdev)
{
    struct device_node *np = pdev->dev.of_node;
    struct device_node *syscon_node;
    [...]
    syscon_node = of_get_parent(np);
    if (!syscon_node)
        return -ENODEV;
    /* If we have a pointer to the syscon device node,    we use it */
    by_node_regmap = syscon_node_to_regmap(syscon_node);
    of_node_put(syscon_node);
    if (IS_ERR(by_node_regmap)) {
        pr_err("%s: could not find regmap by node\n",         __func__);
        return PTR_ERR(by_node_regmap);
    }
    /* or we have one of the compatible string of the syscon     node */
    by_compat_regmap =
        syscon_regmap_lookup_by_compatible("fsl,        imx6q-iomuxc-gpr");
    if (IS_ERR(by_compat_regmap)) {
        pr_err("%s: could not find regmap by compatible\n",         __func__);
        return PTR_ERR(by_compat_regmap);
    }
    /* Or a phandle property pointing to the syscon device node             
     */
    by_phandle_regmap =
        syscon_regmap_lookup_by_phandle(np, "fsl,tempmon");
    if (IS_ERR(map)) {
        pr_err("%s: could not find regmap by phandle\n",         __func__);
        return PTR_ERR(by_phandle_regmap);
    }
    /*
     * It is the extrem and rare case fallback
     * As of Linux kernel v4.18, there is only one driver
     * using this, drivers/tty/serial/clps711x.c
     */
    char pdev_syscon_name[9];
    int index = pdev->id;
    sprintf(syscon_name, "syscon.%i", index + 1);
    by_pdevname_regmap =
        syscon_regmap_lookup_by_pdevname(syscon_name);
    if (IS_ERR(by_pdevname_regmap)) {
        pr_err("%s: could not find regmap by pdevname\n",         __func__);
        return PTR_ERR(by_pdevname_regmap);
    }
    [...]
    return 0;
}

在前面的示例中,如果我们假设syscon_name包含gpr设备的平台设备名称,那么by_node_regmapby_compat_regmapby_pdevname_regmapby_phandle_regmap变量将指向相同的 syscon regmap。然而,这里的目的只是解释概念。my_pdev可能是gpr的兄弟(或其他关系)节点。在这里使用它作为其子节点是为了理解概念和代码,并展示根据情况使用任一 API。现在我们熟悉了 syscon 框架,让我们看看它如何与 simple-mfd 一起使用。

介绍 simple-mfd

对于基于 MMIO 的 MFD 设备,在将它们添加到系统之前可能不需要配置子设备。因为这个配置是在 MFD 核心驱动程序内部完成的,所以这个 MFD 核心驱动程序的唯一目标将是向系统中添加平台子设备。由于存在许多基于 MMIO 的 MFD 设备,将会有大量冗余代码。简单的 MFD,即简单的 DT 绑定,解决了这个问题。

当将simple-mfd字符串添加到给定设备节点的兼容字符串列表中(在这里被视为 MFD 设备),它将使for_each_child_of_node()迭代器。simple-mfd 在drivers/of/platform.c中实现为 simple-bus 的别名,其文档位于内核源树中的Documentation/devicetree/bindings/mfd/mfd.txt中。

与 syscon 一起使用以创建 regmap,有助于避免编写 MFD 驱动程序,开发人员可以将精力放在编写子设备驱动程序上。以下是一个例子:

snvs: snvs@20cc000 {
    compatible = "fsl,sec-v4.0-mon", "syscon", "simple-mfd";
    reg = <0x020cc000 0x4000>;
    snvs_rtc: snvs-rtc-lp {
        compatible = "fsl,sec-v4.0-mon-rtc-lp";
        regmap = <&snvs>;
        offset = <0x34>;
        interrupts = <GIC_SPI 19 IRQ_TYPE_LEVEL_HIGH>,
                     <GIC_SPI 20 IRQ_TYPE_LEVEL_HIGH>;
    };
    snvs_poweroff: snvs-poweroff {
        compatible = "syscon-poweroff";
        regmap = <&snvs>;
        offset = <0x38>;
        value = <0x60>;
        mask = <0x60>;
        status = "disabled";
    };
    snvs_pwrkey: snvs-powerkey {
        compatible = "fsl,sec-v4.0-pwrkey";
        regmap = <&snvs>;
        interrupts = <GIC_SPI 4 IRQ_TYPE_LEVEL_HIGH>;
        linux,keycode = <KEY_POWER>;
        wakeup-source;
    };
    [...]
};

在前面的设备树摘录中,snvs是主设备。它由一个电源控制子设备(在主设备寄存器区域中表示为一个寄存器子区域)、一个rtc子设备以及一个电源键等组成。整个定义可以在arch/arm/boot/dts/imx6qdl.dtsi中找到,这是 i.MX6 芯片系列的 SoC 供应商dtsi。相应的驱动程序可以通过在内核源代码中搜索它们的compatible属性的内容来找到。总之,对于snvs节点中的每个子节点,MFD 核心将创建一个相应的设备以及其 regmap,该 regmap 将对应于它们在主设备的内存区域中的内存区域。

本节介绍了在处理 MMIO 设备时如何轻松进入 MFD 驱动程序开发。虽然 SPI/I2C 设备不属于这一类别,但它涵盖了几乎 95%的基于 MMIO 的 MFD 设备。

总结

本章讨论了 MFD 设备以及 syscon 和 regmap API。在这里,我们讨论了 MFD 设备的工作原理以及 regmap 是如何嵌入到 syscon 中的。到达本章末尾时,我们可以假设您能够开发支持 regmap 的 IRQ 控制器,并设计和使用 syscon 在设备之间共享寄存器区域。下一章将涉及通用时钟框架以及该框架的组织结构、实现方式、使用方法以及如何添加自己的时钟。

第四章:突袭通用时钟框架

从一开始,嵌入式系统一直需要时钟信号来编排其内部工作,无论是用于同步还是用于电源管理(例如,在设备处于活动状态时启用时钟,或者根据某些标准调整时钟,如系统负载)。因此,Linux 一直有一个时钟框架。一直只有编程接口声明支持系统时钟树的软件管理,每个平台都必须实现此 API。不同的片上系统SoCs)有自己的实现。这样做一段时间还可以,但人们很快发现他们的硬件实现非常相似。代码也变得臃肿和冗余,这意味着需要使用平台相关的 API 来获取/设置时钟。

这是一个相当不舒服的情况。然后,通用时钟框架(CCF)出现了,允许软件以硬件无关的方式管理系统上可用的时钟。CCF 是一个接口,允许我们控制各种时钟设备(大多数时候,这些设备嵌入在 SoCs 中),并提供可用于控制它们的统一 API(启用/禁用,获取/设置速率,门控/解除门控等)。在本章中,时钟的概念并不是指实时时钟RTC)或时间设备,这些是内核中具有自己子系统的其他类型设备。

CCF 的主要思想是统一和抽象分散在不同 SoC 时钟驱动程序中的相似代码。这种标准化方法以以下方式引入了时钟提供者和时钟消费者的概念:

  • 提供者是 Linux 内核驱动程序,它们与框架连接并提供对硬件的访问,从而提供(使这些可用于消费者)时钟树(现在可以通过 SoC 数据表转储整个时钟树)。

  • 消费者是通过公共 API 访问框架的 Linux 内核驱动程序或子系统。

  • 也就是说,驱动程序既可以是提供者,也可以是消费者(它可以消耗它提供的一个或多个时钟,或者消耗其他人提供的一个或多个时钟)。

在本章中,我们将介绍 CCF 数据结构,然后专注于编写时钟提供者驱动程序(无论时钟类型),然后介绍消费者 API。我们将通过以下主题来实现这一点:

  • CCF 数据结构和接口

  • 编写时钟提供者设备驱动程序

  • 时钟消费者设备驱动程序和 API

技术要求

以下是本章的技术要求:

CCF 数据结构和接口

在旧的内核时代,每个平台都必须实现内核中定义的基本 API(抓取/释放时钟,设置/获取速率,启用/禁用时钟等),这些 API 可以被消费者驱动程序使用。由于这些特定 API 的实现是由每台机器的代码完成的,这导致每个机器目录中都有一个类似的文件,具有类似的逻辑来实现时钟提供者功能。其中有很多冗余代码。后来,内核将这些公共代码抽象成了时钟提供者(drivers/clk/clk.c),这就是我们现在称之为 CCF 核心的东西。

在使用 CCF 之前,需要通过CONFIG_COMMON_CLK选项将其支持引入内核。CCF 本身分为两个部分:

  • struct clk,它统一了框架级别的代码和传统的依赖于平台的实现,这些实现过去在各种平台上都是重复的。这一部分还允许我们包装消费者接口(也称为struct clk_ops),每个时钟提供者都必须提供这个接口。

  • struct clk_ops对应于用于让我们操作底层硬件的回调(这些回调由时钟的核心实现调用),以及包装和抽象时钟硬件的相应硬件特定结构。

这两部分由struct clk_hw结构绑定在一起。这个结构帮助我们实现自己的硬件时钟类型。在本章中,这被称为struct clk_foo。由于struct clk_hw也在struct clk中指向,它允许在这两个部分之间进行导航。

现在,我们可以介绍 CCF 数据结构。CCF 是建立在通用异构数据结构(在include/linux/clk-provider.h中)之上的,这些数据结构帮助尽可能地使这个框架通用化。它们如下:

  • struct clk_hw:这个结构抽象了硬件时钟线,只在提供者代码中使用。它将前面介绍的两个部分联系在一起,并允许在它们之间进行导航。此外,这个硬件时钟的基本结构允许平台定义自己的硬件特定时钟结构,以及它们自己的时钟操作回调,只要它们包装一个struct clk_hw结构的实例。

  • struct clk_ops:这个结构表示可以操作时钟线的硬件特定回调;也就是硬件。这就是为什么这个结构中的所有回调都接受一个struct clk_hw的指针作为第一个参数,尽管根据时钟类型,只有少数这些操作是强制性的。

  • struct clk_init_data:这个结构保存了所有时钟共享的init数据,这些数据在时钟提供者和通用时钟框架之间。时钟提供者负责为系统中的每个时钟准备这些静态数据,然后将其交给时钟框架的核心逻辑。

  • struct clk:这个结构是时钟的消费者表示,因为每个消费者 API 都依赖于这个结构。

  • struct clk_core:这是时钟的 CCF 表示。

重要提示

区分struct clk_hwstruct clk之间的差异使我们能够更接近消费者和提供者 clk API 之间的清晰分离。

现在我们已经列举了这个框架的数据结构,我们可以逐个学习它们是如何实现的以及它们的用途。

理解 struct clk_hw 及其依赖关系

struct clk_hw是 CCF 中每种时钟类型的基本结构。它可以被看作是从struct clk到其对应的硬件特定结构的句柄。以下是struct clk_hw的主体:

struct clk_hw {
    struct clk_core *core;
    struct clk *clk;
    const struct clk_init_data *init;
};

让我们来看看前面结构中的字段:

  • core:这个结构是框架核心的内部结构。它也在内部指向这个struct clk_hw实例。

  • clk:这是一个每个用户的struct clk实例,可以使用clk API。它由时钟框架分配和维护,并在需要时提供给时钟消费者。每当消费者通过clk_get在 CCF 中对时钟设备(即clk_core)进行访问时,它需要获取一个句柄,即clk

  • init:这是指向struct clk_init_data的指针。在初始化底层时钟提供程序驱动程序的过程中,调用clk_register()接口来注册时钟硬件。在此之前,您需要设置一些初始数据,这些初始数据被抽象为struct clk_init_data数据结构。在初始化过程中,来自clk_init_data的数据用于初始化与clk_hw对应的clk_core数据结构。初始化完成后,clk_init_data就没有意义了。

struct clk_init_data 的定义如下:

struct clk_init_data {
    const char *name;
    const struct clk_ops *ops;
    const char * const *parent_names;
    u8 num_parents;
    unsigned long flags;
};

它保存了所有时钟通用的初始化数据,并在时钟提供程序和通用时钟框架之间共享。其字段如下:

  • name,表示时钟的名称。

  • ops 是与时钟相关的一组操作函数。这将在提供时钟操作部分中进行描述。它的回调将由时钟提供程序驱动程序提供(以允许驱动硬件时钟),并将由驱动程序通过clk_*消费者 API 调用。

  • parent_names 包含时钟的所有父时钟的名称。这是一个字符串数组,保存了所有可能的父时钟。

  • num_parents 是父时钟的数量。它应该与前面数组中的条目数对应。

  • flags 代表时钟的框架级标志。我们将在提供时钟操作部分详细解释这一点,因为这些标志实际上修改了一些ops

重要提示

struct clkstruct clk_core 是私有数据结构,定义在 drivers/clk/clk.c 中。struct clk_core 结构以一种抽象的方式将时钟设备抽象到 CCF 层,以便每个实际的硬件时钟设备(struct clk_hw)对应一个struct clk_core

现在我们已经完成了 CCF 的核心struct clk_hw,我们可以学习如何向系统注册时钟提供程序。

注册/注销时钟提供程序

时钟提供程序负责以树的形式公开其提供的时钟,对其进行排序,并在系统初始化期间通过提供程序或时钟框架的核心初始化接口进行初始化。

在早期的内核版本(在 CCF 之前),时钟注册是通过clk_register()接口统一的。现在我们有基于clk_hw(提供程序)的 API,可以在注册时钟时摆脱基于struct clk的 API。由于建议时钟提供程序使用新的基于struct clk_hw的 API,因此要考虑的适当注册接口是devm_clk_hw_register(),这是clk_hw_register()的托管版本。然而,出于历史原因,旧的基于clk的 API 名称仍然保留,您可能会发现一些驱动程序在使用它。甚至还实现了一个资源托管版本,称为devm_clk_register()。我们只讨论这个旧的 API 是为了让您了解现有的代码,而不是帮助您实现新的驱动程序:

struct clk *clk_register(struct device *dev, struct clk_hw *hw)
int clk_hw_register(struct device *dev, struct clk_hw *hw)

基于这个clk_hw_register()接口,内核还提供了其他更方便的注册接口(稍后将介绍),具体取决于要注册的时钟类型。它负责将时钟注册到内核并返回表示时钟的struct clk_hw指针。

它接受一个指向struct clk_hw的指针(因为struct clk_hw是时钟提供程序侧的时钟表示),并且必须包含要注册的时钟的一些信息。内核将进一步填充这些数据。其实现逻辑如下:

  • 分配struct clk_core空间(clk_hw->core):

--根据struct clk_hw指针提供的信息,初始化clk的字段名称、opshwflagsnum_parentsparents_names

--在其上调用内核接口__clk_core_init(),执行后续的初始化操作,包括构建时钟树层次结构。

  • 通过内部内核接口clk_create_clk()分配struct clk空间(clk_hw->clk),并返回此struct clk变量。

  • 尽管clk_hw_register()包装了clk_register(),但您不应该直接使用clk_register(),因为它返回struct clk。这可能会导致混淆,并破坏提供程序和使用者接口之间的严格分离。

以下是drivers/clk/clk.cclk_hw_register的实现:

int clk_hw_register(struct device *dev, struct clk_hw *hw)
{
    return PTR_ERR_OR_ZERO(clk_register(dev, hw));
}

在执行进一步步骤之前,应该检查clk_hw_register()的返回值。由于 CCF 框架负责建立整个抽象时钟树的树结构并维护其数据,它通过在drivers/clk/clk.c中定义的两个静态链接列表来实现这一点,如下所示:

static HLIST_HEAD(clk_root_list);
static HLIST_HEAD(clk_orphan_list);

每当在时钟hw上调用clk_hw_register()(在内部调用__clk_core_init()以初始化时钟)时,如果此时钟有有效的父级,则它将最终出现在父级的children列表中。另一方面,如果num_parent0,它将放置在clk_root_list中。否则,它将挂在clk_orphan_list中,这意味着它没有有效的父级。此外,每当初始化新的clk时,CCF 将遍历clk_orphan_list(孤立时钟的列表),并重新将任何子时钟重新连接到当前正在初始化的时钟。这就是 CCF 如何保持时钟树与硬件拓扑一致。

另一方面,struct clk是时钟设备的使用者端实例。基本上,对时钟设备的所有用户访问都会创建struct clk类型的访问句柄。当不同的用户访问同一个时钟设备时,尽管在幕后使用相同的struct clk_core实例,但他们访问的句柄(struct clk)是不同的。

重要提示

您应该记住,clk_hw_register(或其祖先clk_register())在幕后使用struct clk_core,因为这是时钟的 CCF 表示。

CCF 通过在drivers/clk/clkdev.c中声明的全局链接列表来管理clk实体,以及用于保护其访问的互斥体,如下所示:

static LIST_HEAD(clocks);
static DEFINE_MUTEX(clocks_mutex);

这是来自设备树没有被大量使用的时代。那时,时钟使用者通过名称(时钟的名称)获取时钟。这用于识别时钟。知道clk_register()的目的只是注册到公共时钟框架,使用者无法知道如何定位时钟。因此,对于底层时钟提供程序驱动程序,除了调用clk_register()函数注册到公共时钟框架外,还必须立即在clk_register()之后调用clk_register_clkdev()以绑定时钟与名称(否则,时钟使用者将不知道如何定位时钟)。因此,内核使用struct clk_lookup,正如其名称所示,以查找可用的时钟,以防使用者请求时钟(当然是通过名称)。

这种机制在内核中仍然有效并得到支持。但是,为了通过基于hw的 API 强制执行提供程序和使用者代码之间的分离,应该分别在您的代码中用clk_hw_register()clk_hw_register_clkdev()替换clk_register()clk_register_clkdev()

换句话说,假设您有以下代码:

/* Not be used anymore, introduced here for studying purpose */
int clk_register_clkdev(struct clk *clk,
                        const char *con_id, const char *dev_id)

这应该替换为以下代码:

/* recommended interface */
int clk_hw_register_clkdev(struct clk_hw *hw,
                           const char *con_id,                            const char *dev_id)

回到struct clk_lookup数据结构,让我们看一下它的定义:

struct clk_lookup {
    struct list_head node;
    const char *dev_id;
    const char *con_id;
    struct clk *clk;
    struct clk_hw *clk_hw;
};

在前面的数据结构中,dev_idcon_id用于标识/查找适当的clk。这个clk是相应的底层时钟。node是将挂在全局时钟列表中的列表条目,如以下摘录中的低级__clkdev_add()函数所示:

static void __clkdev_add(struct clk_lookup *cl)
{
    mutex_lock(&clocks_mutex);
    list_add_tail(&cl->node, &clocks);
    mutex_unlock(&clocks_mutex);
}

前述的__clkdev_add()函数是间接从clk_hw_register_clkdev()内部调用的,它实际上包装了clk_register_clkdev()。现在我们介绍了设备树,事情已经改变了。基本上,每个时钟供应商都成为了 DTS 中的一个节点;也就是说,每个clk在设备树中都有一个与之对应的设备节点。在这种情况下,与其捆绑clk和一个名称,不如通过一个新的数据结构struct of_clk_provider来捆绑clk和您的设备节点。这个特定的数据结构如下:

struct of_clk_provider {
    struct list_head link;
    struct device_node *node;
    struct clk *(*get)(struct of_phandle_args *clkspec,                        void *data);
    struct clk_hw *(*get_hw)(struct of_phandle_args *clkspec,
                             void *data);
    void *data;
};

在前述结构中,发生了以下情况:

  • link挂在of_clk_providers全局列表中。

  • node表示时钟设备的 DTS 节点。

  • get_hw是解码时钟的回调函数。对于设备(消费者),通过clk_get()调用它来返回与节点关联的时钟或NULL

  • get是为了历史和兼容性原因而存在的旧的基于clk的 API。

然而,由于现在频繁和普遍使用设备树,对于底层供应商驱动程序,原始的clk_hw_register() + clk_hw_register_clkdev()(或其旧的基于clk的实现,clk_register() + clk_register_clkdev())组合变成了clk_hw_register + of_clk_add_hw_provider(以前是clk_register + of_clk_add_provider - 这可以在旧的和非clk_hw的驱动程序中找到)。此外,CCF 引入了一个新的全局链接列表of_clk_providers,以帮助管理所有 DTS 节点和时钟之间的对应关系,以及一个互斥锁来保护这个列表:

static LIST_HEAD(of_clk_providers);
static DEFINE_MUTEX(of_clk_mutex);

尽管clk_hw_register()clk_hw_register_clkdev()函数名称非常相似,但这两个函数的目标不同。通过前者,时钟供应商可以在通用时钟框架中注册时钟。另一方面,clk_hw_register_clkdev()在通用时钟框架中注册了一个struct clk_lookup,正如其名称所示。这个操作主要是为了查找时钟。如果您只有设备树平台,您不再需要所有对clk_hw_register_clkdev()的调用(除非您有充分的理由),因此您应该依赖于一次对of_clk_add_provider()的调用。

重要提示

建议时钟供应商使用新的基于struct clk_hw的 API,因为这样可以更接近清晰地分离消费者和供应商的时钟 API。

clk_hw_*接口是供应商接口,应在时钟供应商驱动程序中使用,而clk_*是消费者端。每当在供应商代码中遇到基于clk_*的 API 时,请注意应更新该驱动程序以支持新的基于硬件的接口。

一些驱动程序仍然同时使用这两个函数(clk_hw_register_clkdev()of_clk_add_hw_provider()),以支持诸如 SoC 时钟驱动程序之类的时钟查找方法,但除非有理由这样做,否则不应同时使用这两个函数。

到目前为止,我们已经花了时间讨论时钟注册。然而,注销时钟可能是必要的,要么是因为底层时钟硬件离开了系统,要么是因为在硬件初始化期间出现了问题。时钟注销 API 相当简单:

void clk_hw_unregister(struct clk_hw *hw)
void clk_unregister(struct clk *clk)

前者针对基于clk_hw的时钟,而后者针对基于clk的时钟。在管理变体方面,除非 Devres 核心处理注销,否则应使用以下 API:

void devm_clk_unregister(struct device *dev, struct clk *clk)
void devm_clk_hw_unregister(struct device *dev, struct clk_hw *hw)

在两种情况下,dev表示与时钟关联的底层设备结构。

有了这个,我们已经完成了时钟注册/注销的查看。也就是说,驱动程序的主要目的之一是向潜在的消费者公开设备资源,这也适用于时钟设备。在下一节中,我们将学习如何向消费者公开时钟线。

向其他设备公开时钟(详细说明)

一旦时钟已经在 CCF 中注册,下一步就是注册这个时钟提供者,以便其他设备可以消耗它的时钟线。在旧的内核时代(当设备树没有被大量使用时),你必须通过在每条时钟线上调用clk_hw_register_clkdev()来向消费者公开时钟,这导致为给定的时钟线注册查找结构。如今,可以通过调用of_clk_add_hw_provider()接口来使用设备树来实现这个目的,以及一定数量的参数:

int of_clk_add_hw_provider(
    struct device_node *np,
    struct clk_hw *(*get)(struct of_phandle_args *clkspec,
                          void *data),
    void *data)

让我们看看这个函数中的参数:

  • np是与时钟提供者相关的设备节点指针。

  • get是解码时钟的回调。我们将在下一节详细讨论这个回调。

  • data是给定get回调的上下文指针。这通常是一个指向需要与设备节点关联的时钟的指针。这对于解码是有用的。

这个函数在成功时返回0。它与of_clk_del_provider()相反,后者包括从全局列表中删除提供者并释放其空间:

void of_clk_del_provider(struct device_node *np)

它的资源管理版本devm_of_clk_add_hw_provider()也可以用于摆脱删除函数。

时钟提供者设备树节点及其相关机制

目前,设备树是描述系统上设备的首选方法已经有一段时间了。通用时钟框架也不例外。在这里,我们将尝试弄清楚时钟是如何在设备树和相关驱动程序代码中描述的。为了实现这一点,我们需要考虑以下设备树摘录:

clocks {
    /* Provider node */
    clk54: clk54 {
        #clock-cells = <0>;
        compatible = 'fixed-clock';
        clock-frequency = <54000000>;
        clock-output-names = 'osc';
    };
};
[...]
i2c0: i2c-master@d090000 {
    [...]
    /* Consumer node */
    cdce706: clock-synth@69 {
        compatible = 'ti,cdce706';
        #clock-cells = <1>;
        reg = <0x69>;         clocks = <&clk54>;
        clock-names = 'clk_in0';
    };
};

请记住,时钟是通过clocks属性分配给消费者的,时钟提供者也可以是消费者。在上述摘录中,clk54是一个固定时钟;我们不会在这里详细讨论。cdce706是一个时钟提供者,也消耗clk54(在clocks属性中作为phandle给出)。

时钟提供者节点需要指定的最重要的信息是#clock-cells属性,它确定时钟说明符的长度:当它为0时,这意味着只需要将此提供者的phandle属性给出给消费者。当它为1(或更大)时,这意味着phandle属性具有多个输出,并且需要提供额外的信息,例如指示需要使用哪个输出的 ID。这个 ID 直接由一个立即值表示。最好在头文件中定义系统中所有时钟的 ID。设备树可以包括这个头文件,比如clocks = <&clock CLK_SPI0>,其中CLK_SPI0是在头文件中定义的宏。

现在,让我们看看clock-output-names。这是一个可选但建议的属性,应该是一个与输出(即提供的)时钟线的名称相对应的字符串列表。

看一下以下提供者节点摘录:

osc {
    #clock-cells = <1>;
    clock-output-names = 'ckout1', 'ckout2';
};

前面的节点定义了一个提供两个时钟输出线的设备,分别命名为ckout1ckout2。消费者节点不应直接使用这些名称来引用这些时钟线。相反,他们应该使用适当的时钟说明符(相对于提供者的#clock-cells按索引引用时钟),允许他们根据设备的需求命名其输入时钟线:

device {
    clocks = <&osc 0>, <&osc 1>;
    clock-names = 'baud', 'register';
};

这个设备消耗了osc提供的两条时钟线,并根据自己的需求命名了它的输入线。我们将在本章末讨论消费者节点。

当时钟线分配给消费者设备时,当这个消费者的驱动程序调用clk_get()(或类似用于获取时钟的接口)时,这个接口调用of_clk_get_by_name(),然后调用__of_clk_get()。这里感兴趣的函数是__of_clk_get()。它在drivers/clk/clkdev.c中定义如下:

static struct clk * of_clk_get(struct device_node *np,                                int index,
                               const char *dev_id,                                const char *con_id)
{
    struct of_phandle_args clkspec;
    struct clk *clk;
    int rc;
    rc = of_parse_phandle_with_args(np, 'clocks',            
                                    '#clock-cells',
                                    index, &clkspec);
    if (rc)
        return ERR_PTR(rc);
    clk = of_clk_get_from_provider(&clkspec, dev_id, con_id); 
    of_node_put(clkspec.np);
    return clk;
}

重要提示

这个函数返回struct clk的指针而不是struct clk_hw的指针是完全正常的,因为这个接口是从消费者方面操作的。

这里的魔法来自of_parse_phandle_with_args(),它解析phandle及其参数的列表,然后调用__of_clk_get_from_provider(),我们稍后会描述。

理解of_parse_phandle_with_args()API

以下是of_parse_phandle_with_args的原型:

int of_parse_phandle_with_args(const struct device_node *np,
                               const char *list_name,
                               const char *cells_name,
                               int index,
                               struct of_phandle_args *out_args)

这个函数在成功时返回0并填充out_args;在错误时返回适当的errno值。让我们看一下它的参数:

  • np是一个指向包含列表的设备树节点的指针。在我们的情况下,它将是对应于消费者的节点。

  • list_name是包含列表的属性名称。在我们的情况下,它是clocks

  • cells_name是指定 phandle 参数计数的属性名称。在我们的情况下,它是#clock-cells。它帮助我们在指定器中的phandle属性之后抓取一个参数(其他 cells)。

  • indexphandle属性的索引,用于解析列表。

  • out_args是一个可选的输出参数,在成功路径上填充。这个参数是of_phandle_args类型,并定义如下:

#define MAX_PHANDLE_ARGS 16
struct of_phandle_args {
    struct device_node *np;
    int args_count;
    uint32_t args[MAX_PHANDLE_ARGS];
};

struct of_phandle_args中,np元素是指向与phandle属性对应的节点的指针。在时钟指定器的情况下,它将是时钟提供者的设备树节点。args_count元素对应于指定器中 phandle 之后的单元格数。它可以用来遍历args,这是一个包含相关参数的数组。

让我们看一个使用of_parse_phandle_with_args()的例子,给出以下 DTS 摘录:

phandle1: node1 {
    #gpio-cells = <2>;
};
phandle2: node2 {
    #list-cells = <1>;
};
node3 {
    list = <&phandle1 1 2 &phandle2 3>;
};
/* or */
node3 {
    list = <&phandle1 1 2>, <&phandle2 3>;
}

在这里,node3是一个消费者。要获取指向node2节点的device_node指针,你可以调用of_parse_phandle_with_args(node3, 'list', '#list-cells', 1, &args);。由于&phandle2在列表中的索引为1(从0开始),我们在index参数中指定了1

同样,要获取node1节点的关联device_node,你可以调用of_parse_phandle_with_args(node3, 'list', '#gpio-cells', 0, &args);。对于这第二种情况,如果我们查看args输出参数,我们会看到args->np对应于node3args->args_count的值为2(因为这个指定器需要2个参数),args->args[0]的值为1args->args[1]的值为2,这将对应于指定器中的第2个参数。

重要提示

要进一步了解设备树 API,请查看of_parse_phandle_with_fixed_args()和设备树核心代码中提供的其他接口,位于drivers/of/base.c中。

理解__of_clk_get_from_provider()API

__of_clk_get()中的下一个函数调用是__of_clk_get_from_provider()。我提供其原型的原因是你不应该在你的代码中使用这个。然而,这个函数只是简单地遍历时钟提供者(在of_clk_providers列表中),当找到适当的提供者时,它调用of_clk_add_provider()的第二个参数作为底层回调来解码底层时钟。在这里,由of_parse_phandle_with_args()返回的时钟指定器被作为参数给出。你可能还记得当你必须向其他设备公开一个时钟提供者时,我们必须使用of_clk_add_hw_provider()。作为第二个参数,这个接口接受一个回调,由 CCF 用于解码底层时钟,每当消费者调用clk_get()时。这个回调的结构如下:

struct clk_hw *(*get_hw)(struct of_phandle_args *clkspec, void *data)

此回调应根据其参数返回底层的clock_hwclkspec是由of_parse_phandle_with_args()返回的时钟指定符,而data是作为of_clk_add_hw_provider()的第三个参数给出的上下文数据。请记住,data通常是要与节点关联的时钟的指针。要查看此回调是如何内部调用的,我们需要查看__of_clk_get_from_provider()接口的定义,如下所示:

struct clk * of_clk_get_from_provider(struct                                       of_phandle_args *clkspec,
                                      const char *dev_id,                                       const char *con_id)
{
    struct of_clk_provider *provider;
    struct clk *clk = ERR_PTR(-EPROBE_DEFER);
    struct clk_hw *hw;
    if (!clkspec)
        return ERR_PTR(-EINVAL);
    /* Check if we have such a provider in our array */
    mutex_lock(&of_clk_mutex);
    list_for_each_entry(provider, &of_clk_providers, link) {
        if (provider->node == clkspec->np) {
          hw = of_clk_get_hw_from_provider (provider, clkspec);
            clk = clk_create_clk(hw, dev_id, con_id);
        }
        if (!IS_ERR(clk)) {
            if (! clk_get(clk)) {
                clk_free_clk(clk);
                clk = ERR_PTR(-ENOENT);
            }
            break;
        }
    }
    mutex_unlock(&of_clk_mutex);
    return clk;
}

时钟解码回调

如果我们必须总结从 CCF 获取时钟的机制,我们会说,当使用者调用clk_get()时,CCF 在内部调用__of_clk_get()。这作为此使用者的device_node属性的第一个参数,以便 CCF 可以获取时钟指定符并找到与提供程序对应的device_node属性(通过of_parse_phandle_with_args())。然后以of_phandle_args的形式返回这个。这个of_phandle_args对应于时钟指定符,并作为参数给出给__of_clk_get_from_provider(),它只是比较提供程序的of_phandle_args(即of_phandle_args->np)中的device_node属性与存在于of_clk_providers中的那些的属性,这是设备树时钟提供程序的列表。一旦找到匹配项,就会调用相应的提供程序的of_clk_provider->get()回调,并返回底层时钟。

重要说明

如果__of_clk_get()失败,这意味着找不到给定设备节点的有效时钟。这也可能意味着提供程序没有将其时钟注册到设备树接口。因此,当of_clk_get()失败时,CCF 代码调用clk_get_sys(),这是根据其名称查找时钟的后备方法,该名称不再在设备树上。这是clk_get()背后的真正逻辑。

of_clk_provider->get()回调通常依赖于作为of_clk_add_provider()参数给出的上下文数据,以便返回底层时钟。虽然可以编写自己的回调(应该遵守前一节中已经介绍的原型),但 CCF 框架提供了两个通用的解码回调,涵盖了大多数情况。这些是of_clk_src_onecell_get()of_clk_src_simple_get(),两者都具有相同的原型:

struct clk_hw *of_clk_hw_simple_get(struct                                     of_phandle_args *clkspec,
                                    void *data);
struct clk_hw *of_clk_hw_onecell_get(struct                                      of_phandle_args *clkspec,
                                     void *data);

of_clk_hw_simple_get()用于简单的时钟提供程序,除了时钟本身之外,不需要特殊的上下文数据结构,例如时钟 GPIO 驱动程序(在drivers/clk/clk-gpio.c中)。此回调只是原样返回作为上下文数据参数给出的数据,这意味着该参数应该是一个时钟。它在drivers/clk/clk.c中定义如下:

struct clk_hw *of_clk_hw_simple_get(struct                                     of_phandle_args *clkspec,
                                    void *data)
{
    return data;
}
EXPORT_SYMBOL_GPL(of_clk_hw_simple_get);

另一方面,of_clk_hw_onecell_get()要复杂一些,因为它需要一个称为struct clk_hw_onecell_data的特殊数据结构。这可以定义如下:

struct clk_hw_onecell_data {
    unsigned int num;
    struct clk_hw *hws[];
};

在前面的结构中,hws是指向struct clk_hw的指针数组,num是此数组中条目的数量。

重要说明

在旧的时钟提供程序驱动程序中,尚未实现基于 clk_hw 的 API 的,您可能会看到struct clk_onecell_dataof_clk_add_provider()of_clk_src_onecell_get()of_clk_add_provider(),而不是在本书中介绍的数据结构和接口。

也就是说,为了掌握存储在此数据结构中的时钟,建议将它们包装在您的上下文数据结构中,如drivers/clk/sunxi/clk-sun9i-mmc.c中的以下示例所示:

struct sun9i_mmc_clk_data {
    spinlock_t	lock;
    void  iomem		*membase;
    struct clk	*clk;
    struct reset_control	*reset;
    struct clk_hw_onecell_data	clk_hw_data;
    struct reset_controller_dev		rcdev;
};

然后,根据应该存储的时钟数量动态分配这些时钟的空间:

int sun9i_a80_mmc_config_clk_probe(struct                                    platform_device *pdev){    struct device_node *np = pdev->dev.of_node;
    struct sun9i_mmc_clk_data *data;
    struct clk_hw_onecell_data *clk_hw_data;
    const char *clk_name = np->name;
    const char *clk_parent;
    struct resource *r;
    [...]
    data = devm_kzalloc(&pdev->dev, sizeof(*data), GFP_KERNEL);
    if (!data)
        return -ENOMEM;
    clk_hw_data = &data->clk_hw_data;
    clk_hw_data->num = count;
    /* Allocating space for clk_hws, and 'count' is the number
     *of entries
     */
    clk_hw_data->hws =
    devm_kcalloc(&pdev->dev, count, sizeof(struct clk_hw *),
                     GFP_KERNEL);
    if (!clk_hw_data->hws)
        return -ENOMEM;
    /* A clock provider may be a consumer from another
     * provider as well      */
    data->clk = devm_clk_get(&pdev->dev, NULL);
    clk_parent = __clk_get_name(data->clk);
    for (i = 0; i < count; i++) {
        of_property_read_string_index(np, 'clock-output-names',
                                      i, &clk_name);
        /* storing each clock in its location */
        clk_hw_data->hws[i] =
        clk_hw_register_gate(&pdev->dev, clk_name,                            clk_parent, 0,
                           data->membase + SUN9I_MMC_WIDTH * i,
                           SUN9I_MMC_GATE_BIT, 0, &data->lock);        if (IS_ERR(clk_hw_data->hws[i])) {            ret = PTR_ERR(clk_hw_data->hws[i]);            goto err_clk_register;        }    }
    ret =
       of_clk_add_hw_provider(np, of_clk_hw_onecell_get,                               clk_hw_data);
    if (ret)
        goto err_clk_provider;
    [...]
    return 0;}

重要说明

在撰写本文时,前面的摘录来自 sunxi A80 SoC MMC 配置时钟/复位驱动程序,仍然使用基于 clk 的 API(以及struct clkclk_register_gate()of_clk_add_src_provider()接口),而不是clk_hw。因此,为了学习目的,我修改了这段摘录,使其使用推荐的clk_hw API。

正如您所看到的,在时钟注册期间给出的上下文数据是clk_hw_data,它是clk_hw_onecell_data类型。此外,of_clk_hw_onecell_get作为时钟解码器回调函数给出。这个辅助程序简单地返回在时钟指定器中给定的索引处的时钟(它是of_phandle_args类型)。查看其定义以更好地理解:

struct clk_hw * of_clk_hw_onecell_get(struct                                       of_phandle_args *clkspec, 
                                      void *data)
{
    struct clk_hw_onecell_data *hw_data = data;
    unsigned int idx = clkspec->args[0];
    if (idx >= hw_data->num) {
        pr_err('%s: invalid index %u\n', func , idx);
        return ERR_PTR(-EINVAL);
    }
    return hw_data->hws[idx];
}
EXPORT_SYMBOL_GPL(of_clk_hw_onecell_get);

当然,根据您的需求,可以随意实现自己的解码器回调,类似于内核源代码树中的max9485音频时钟生成器中的一个,其驱动程序是drivers/clk/clk-max9485.c

在这一部分,我们学习了时钟提供者的设备树方面。我们学习了如何公开设备的时钟源线,以及如何将这些时钟线分配给消费者。现在,是时候介绍驱动程序方面了,这也包括为其时钟提供者编写代码。

编写时钟提供者驱动程序

尽管设备树的目的是描述手头的硬件(在本例中是时钟提供者),但值得注意的是,用于管理底层硬件的代码需要编写。本节涉及编写时钟提供者的代码,以便一旦它们的时钟线被分配给消费者,它们就会按照设计的方式运行。在编写时钟设备驱动程序时,最好将完整的struct clk_hw(而不是指针)嵌入到您的私有和更大的数据结构中,因为它作为clk_ops中每个回调的第一个参数给出。这样可以定义一个自定义的to_<my-data-structure>辅助程序,使用container_of宏,它会将指针返回到您的私有数据结构,如下所示:

/* forward reference */
struct max9485_driver_data;
struct max9485_clk_hw {
    struct clk_hw hw;
    struct clk_init_data init;     u8 enable_bit;
    struct max9485_driver_data *drvdata;
;
struct max9485_driver_data {
    struct clk *xclk;
    struct i2c_client *client;
    u8 reg_value;
    struct regulator *supply;
    struct gpio_desc *reset_gpio;
    struct max9485_clk_hw hw[MAX9485_NUM_CLKS];
};
static inline struct max9485_clk_hw *to_max9485_clk(struct                                                     clk_hw *hw)
{
    return container_of(hw, struct max9485_clk_hw, hw);
}

在前面的示例中,max9485_clk_hw抽象了hw时钟(因为它包含struct clk_hw)。从驱动程序的角度来看,每个struct max9485_clk_hw代表一个 hw 时钟,允许我们定义另一个更大的结构,这次将用作驱动程序数据:max9485_driver_data结构。您会注意到在前面的结构中有一些交叉引用,特别是在struct max9485_clk_hw中,它包含指向struct max9485_driver_data的指针,以及struct max9485_driver_data,它包含一个max9485_clk_hw数组。这使我们能够从任何clk_ops回调中获取驱动程序数据,如下所示:

static unsigned long
max9485_clkout_recalc_rate(struct clk_hw *hw,
                               unsigned long parent_rate)
{
    struct max9485_clk_hw *max_clk_hw = to_max9485_clk(hw);
    struct max9485_driver_data *drvdata = max_clk_hw->drvdata;
    [...]
    return 0;
}

此外,如下摘录所示,静态声明时钟线(在本例中由max9485_clk_hw抽象),以及相关的 ops 是一个很好的做法。这是因为,与私有数据不同(可能会从一个设备更改到另一个设备),这些信息永远不会改变,无论系统上存在相同类型的时钟芯片的数量如何:

static const struct max9485_clk max9485_clks[MAX9485_NUM_CLKS] = {
    [MAX9485_MCLKOUT] = {
        .name = 'mclkout',
        .parent_index = -1,
        .enable_bit = MAX9485_MCLK_ENABLE,
        .ops = {
            .prepare		= max9485_clk_prepare,
            .unprepare	= max9485_clk_unprepare,
        },
    },
    [MAX9485_CLKOUT] = {
        .name = 'clkout',
        .parent_index = -1,
        .ops = {
             .set_rate	= max9485_clkout_set_rate,
            .round_rate	= max9485_clkout_round_rate,
            .recalc_rate	= max9485_clkout_recalc_rate,
        },
    },
    [MAX9485_CLKOUT1] = {
        .name = 'clkout1',
        .parent_index = MAX9485_CLKOUT,
        .enable_bit = MAX9485_CLKOUT1_ENABLE,
        .ops = {
            .prepare	= max9485_clk_prepare,
            .unprepare	= max9485_clk_unprepare,
        },
    },
    [MAX9485_CLKOUT2] = {
        .name = 'clkout2',
        .parent_index = MAX9485_CLKOUT,
        .enable_bit = MAX9485_CLKOUT2_ENABLE,
        .ops = {
            .prepare	= max9485_clk_prepare,
            .unprepare	= max9485_clk_unprepare,
        },
    },
};

尽管 ops 嵌入在抽象数据结构中,但它们也可以单独声明,就像内核源代码中的drivers/clk/clk-axm5516.c文件中一样。另一方面,最好动态分配驱动程序数据结构,因为这样更容易使其对驱动程序私有,从而允许每个声明的设备私有数据,如下面的摘录所示:

static int max9485_i2c_probe(struct i2c_client *client,
                             const struct i2c_device_id *id)
{
    struct max9485_driver_data *drvdata;
    struct device *dev = &client->dev;
    const char *xclk_name;
    int i, ret;
    drvdata = devm_kzalloc(dev, sizeof(*drvdata), GFP_KERNEL);
    if (!drvdata)
        return -ENOMEM;
    [...]
    for (i = 0; i < MAX9485_NUM_CLKS; i++) {
        int parent_index = max9485_clks[i].parent_index;
        const char *name;
        if (of_property_read_string_index
           (dev->of_node, 'clock-output-names', i, &name) == 0) {
            drvdata->hw[i].init.name = name;
        } else {
            drvdata->hw[i].init.name = max9485_clks[i].name;
        }
        drvdata->hw[i].init.ops = &max9485_clks[i].ops;
        drvdata->hw[i].init.num_parents = 1;
        drvdata->hw[i].init.flags = 0;
        if (parent_index > 0) {
            drvdata->hw[i].init.parent_names =
                        &drvdata->hw[parent_index].init.name;
            drvdata->hw[i].init.flags |= CLK_SET_RATE_PARENT;
        } else {
            drvdata->hw[i].init.parent_names = &xclk_name;
        }
        drvdata->hw[i].enable_bit = max9485_clks[i].enable_bit;
        drvdata->hw[i].hw.init = &drvdata->hw[i].init;
        drvdata->hw[i].drvdata = drvdata;
        ret = devm_clk_hw_register(dev, &drvdata->hw[i].hw);
        if (ret < 0)
            return ret;
    }
    return
      devm_of_clk_add_hw_provider(dev, max9485_of_clk_get,                                   drvdata);
}

在前面的摘录中,驱动程序调用clk_hw_register()(实际上是devm_clk_hw_register(),这是托管版本)以便将每个时钟注册到 CCF。现在我们已经了解了时钟提供者驱动程序的基础知识,我们将学习如何通过一组操作允许与时钟线进行交互,这些操作可以在驱动程序中公开。

提供时钟 ops

struct clk_hw是 CCF 构建其他时钟变体结构的基本硬件时钟结构。作为一个快速回调,通用时钟框架提供以下基本时钟:

  • fixed-rate: 这种类型的时钟不能改变其速率,并且始终运行。

  • gate: 这充当时钟源的门控,因为它是其父级。显然,它不能改变其速率,因为它只是一个门控。

  • mux: 这种类型的时钟不能进行门控。它有两个或更多的时钟输入:其父级。它允许我们在连接的时钟中选择一个父级。此外,它允许我们从所选的父级获取速率。

  • fixed-factor: 这种时钟类型不能进行门控,但可以通过其常数来分频和倍频父级速率。

  • divider: 这种类型的时钟不能进行门控。但是,它通过使用可以从注册时提供的各种数组中选择的分频器来分频父时钟速率。

  • composite: 这是我们之前描述的三种基本时钟的组合:mux、rate 和 gate。它允许我们重用这些基本时钟来构建单个时钟接口。

也许你会想知道内核(即 CCF)在将clk_hw作为参数传递给clk_hw_register()函数时,是如何知道给定时钟的类型的。实际上,CCF 并不知道这一点,也不必知道任何事情。这就是clk_hw->init.ops字段的目的,它是struct clk_ops类型。根据在这个结构中设置的回调函数,你可以猜测它所面对的时钟类型。以下是struct clk_ops中的一组操作函数的详细介绍:

struct clk_ops {
    int	(*prepare)(struct clk_hw *hw);
    void	(*unprepare)(struct clk_hw *hw);
    int	(*is_prepared)(struct clk_hw *hw);
    void	(*unprepare_unused)(struct clk_hw *hw);
    int	(*enable)(struct clk_hw *hw);
    void	(*disable)(struct clk_hw *hw);
    int	(*is_enabled)(struct clk_hw *hw);
    void	(*disable_unused)(struct clk_hw *hw);
    unsigned long (*recalc_rate)(struct clk_hw *hw,
                                 unsigned long parent_rate);
    long	(*round_rate)(struct clk_hw *hw, unsigned long rate,
                         unsigned long *parent_rate);
    int	(*determine_rate)(struct clk_hw *hw,
                          struct clk_rate_request *req);
    int	(*set_parent)(struct clk_hw *hw, u8 index);
    u8	(*get_parent)(struct clk_hw *hw);
    int	(*set_rate)(struct clk_hw *hw, unsigned long rate,
                       unsigned long parent_rate);
[...]
    void	(*init)(struct clk_hw *hw);
};

为了清晰起见,已删除了一些字段。

每个prepare*/unprepare*/is_prepared回调都允许休眠,因此不得从原子上下文中调用,而每个enable*/disable*/is_enabled回调可能不允许休眠,也不得休眠。让我们更详细地看一下这段代码:

  • prepareunprepare是可选的回调。在prepare中所做的工作应该在unprepare中撤消。

  • is_prepared是一个可选的回调,通过查询硬件告诉我们时钟是否准备好。如果省略,时钟框架核心将执行以下操作:

--维护一个准备计数器(当调用clk_prepare()消费者 API 时递增一个,当调用clk_unprepare()时递减一个)。

--基于这个计数器,它将确定时钟是否准备好。

  • unprepare_unused/disable_unused: 这些回调是可选的,仅在clk_disable_unused接口中使用。该接口由时钟框架核心提供,并在系统发起的延迟调用中调用(在drivers/clk/clk.c中:late_initcall_sync(clk_disable_unused)),以便取消准备/关闭未使用的时钟。该接口将调用系统上每个未使用时钟的相应.unprepare_unused.disable_unused函数。

  • enable/disable: 原子地启用/禁用时钟。这些函数必须以原子方式运行,不得休眠。例如,对于enable,它应该仅在底层时钟生成可供使用者节点使用的有效时钟信号时返回。

  • is_enabled具有与is_prepared相同的逻辑。

  • recalc_rate: 这是一个可选的回调,通过给定父级速率作为输入参数,查询硬件以重新计算底层时钟的速率。如果省略此操作,初始速率为0

  • round_rate: 此回调接受目标速率(以赫兹为单位)作为输入,并应返回底层时钟实际支持的最接近的速率。父级速率是一个输入/输出参数。

  • determine_rate: 此回调以目标时钟速率作为参数,并返回底层硬件支持的最接近的速率。

  • set_parent:这涉及具有多个输入(多个可能的父级)的时钟。此回调接受在给定父级索引(作为u8)的参数时更改输入源的选择。此索引应与时钟的clk_init_data.parent_namesclk_init_data.parents数组中有效的父级对应。此回调应在成功路径上返回0,否则返回-EERROR

  • get_parent是具有多个(至少两个)输入(多个parents)的时钟的强制性回调。它查询硬件以确定时钟的父级。返回值是与父级索引对应的u8。此索引应在clk_init_data.parent_namesclk_init_data.parents数组中有效。换句话说,此回调将从硬件中读取的父级值转换为数组索引。

  • set_rate:更改给定时钟的速率。请求的速率应该是.round_rate调用的返回值,以便有效。此回调应在成功路径上返回0,否则返回-EERROR

  • init是一个特定于平台的时钟初始化钩子,当时钟注册到内核时将调用它。目前,没有基本时钟类型实现此回调。

提示

由于.enable.disable不能休眠(它们在持有自旋锁时被调用),因此连接到可休眠总线(例如 SPI 或 I2C)的离散芯片中的时钟提供程序不能在持有自旋锁的情况下进行控制,因此应该在准备/取消准备钩子中实现其启用/禁用逻辑。通用 API 将直接调用相应的操作函数。这是为什么从消费者方面(基于时钟的 API)来看,对clk_enable的调用必须在调用clk_prepare()之前进行,并且对clock_disable()的调用应该在clock_unprepare()之后进行的原因之一。

最后但并非最不重要的是,还应注意以下差异:

重要说明

SoC 内部时钟可以被视为快速时钟(通过简单的 MMIO 寄存器写入进行控制),因此可以实现.enable.disable,而 SPI/I2C 时钟可以被视为慢时钟,应该实现.prepare.unprepare

这些函数并非对所有时钟都是强制性的。根据时钟类型,有些可能是强制性的,而有些可能不是。以下数组总结了根据其硬件功能,哪些clk_ops回调对哪种时钟类型是强制性的:

图 4.1 - 时钟类型的强制性 clk_ops 回调

图 4.1 - 时钟类型的强制性 clk_ops 回调

在前面的数组中,需要round_ratedetermine_rate

在前面的数组中,y表示强制性,而n表示相关回调无效或无关紧要。空单元格应被视为可选的,或者必须根据具体情况进行评估。

clk_hw.init.flags 中的时钟标志

由于我们已经介绍了时钟操作结构,现在我们将介绍不同的标志(在include/linux/clk-provider.h中定义),并看看它们如何影响此结构中某些回调的行为:

/*must be gated across rate change*/#define CLK_SET_RATE_GATE  BIT(0)
/*must be gated across re-parent*/#define CLK_SET_PARENT_GATE	 BIT(1)
/*propagate rate change up one level */#define CLK_SET_RATE_PARENT	 BIT(2)
/* do not gate even if unused */#define CLK_IGNORE_UNUSED	BIT(3)
/*Basic clk, can't do a to_clk_foo()*/#define CLK_IS_BASIC BIT(5)
/*do not use the cached clk rate*/#define CLK_GET_RATE_NOCACHE BIT(6)
/* don't re-parent on rate change */#define CLK_SET_RATE_NO_REPARENT BIT(7)
/* do not use the cached clk accuracy */#define CLK_GET_ACCURACY_NOCACHE BIT(8)
/* recalc rates after notifications */#define CLK_RECALC_NEW_RATES BIT(9)
/* clock needs to run to set rate */#define CLK_SET_RATE_UNGATE BIT(10)
/* do not gate, ever */#define CLK_IS_CRITICAL	BIT(11)

前面的代码显示了可以在clk_hw->init.flags字段中设置的不同框架级标志。您可以通过 OR'ing 它们来指定多个标志。让我们更详细地看一下它们:

  • CLK_SET_RATE_GATE:当您更改时钟的速率时,必须对其进行门控(禁用)。此标志还确保存在速率更改和速率故障保护;当时钟设置了CLK_SET_RATE_GATE标志并且已准备就绪时,clk_set_rate()请求将失败。

  • CLK_SET_PARENT_GATE:当您更改时钟的父级时,必须对其进行门控。

  • CLK_SET_RATE_PARENT:一旦您更改了时钟的速率,更改必须传递给上级父级。此标志有两个效果:

--当时钟使用者调用clk_round_rate()(CCF 在内部将其映射到.round_rate)以获得近似速率时,如果时钟未提供.round_rate回调,则如果未设置CLK_SET_RATE_PARENT,CCF 将立即返回时钟的缓存速率。但是,如果仍设置了此标志而未提供.round_rate,则请求将路由到时钟父级。这意味着将查询父级并调用clk_round_rate()以获取父时钟可以提供的最接近目标速率的值。

--此标志还修改了clk_set_rate()接口的行为(CCF 在内部将其映射到.set_rate)。如果设置,任何速率更改请求将被转发到上游(传递给父时钟)。也就是说,如果父时钟可以获得近似速率值,那么通过更改父时钟速率,可以获得所需的速率。此标志通常设置在时钟门和多路复用器上。请谨慎使用此标志。

  • CLK_IGNORE_UNUSED:忽略禁用未使用的调用。当有一个驱动程序未正确声明时钟但引导加载程序将它们保留在时钟上时,这是非常有用的。这相当于内核引导参数clk_ignore_unused,但适用于单个时钟。不希望在正常情况下使用,但对于启动和调试,有选择地不对仍在运行的未声明的时钟进行门控(禁用)是非常有用的。

  • CLK_IS_BASIC:不再使用。

  • CLK_GET_RATE_NOCACHE:有些芯片的时钟速率可以通过内部硬件更改,而 Linux 时钟框架根本不知道这种变化。此标志确保 Linux 时钟树中的 clk 速率始终与硬件设置匹配。换句话说,获取/设置速率不来自缓存,并且是在那个时间计算的。

重要提示

处理门钟类型时,请注意,门控时钟是禁用的时钟,而非门控时钟是启用的时钟。有关更多详细信息,请参见elixir.bootlin.com/linux/v4.19/source/drivers/clk/clk.c#L931elixir.bootlin.com/linux/v4.19/source/drivers/clk/clk.c#L862

现在我们熟悉了时钟标志,以及这些标志如何修改与时钟相关的回调的行为,我们可以逐个遍历每种时钟类型,并学习如何提供它们的相关操作。

固定速率时钟案例研究及其操作

这是最简单的时钟类型。因此,我们将使用它来建立一些必须在编写时钟驱动程序时遵守的强制性准则。这种类型的时钟的频率不能调整,因为它是固定的。此外,这种类型的时钟不能被切换,不能选择其父级,并且不需要提供clk_ops回调函数。

时钟框架使用struct clk_fixed_rate结构(如下所述)来抽象这种类型的时钟硬件:

Struct clk_fixed_rate {
    struct clk_hw hw;
    unsigned long fixed_rate;
    u8 flags; [...]
};
#define to_clk_fixed_rate(_hw) \
            container_of(_hw, struct clk_fixed_rate, hw)

在前面的结构中,hw是基础结构,并确保通用和硬件特定接口之间存在链接。一旦给定给to_clk_fixed_rate宏(基于container_of),您应该获得一个指向clk_fixed_rate的指针,它包装了这个hwfixed_rate是时钟设备的恒定(固定)速率。flags表示框架特定的标志。

让我们看一下以下摘录,它简单地注册了两条虚拟固定速率时钟线:

#include <linux/clk.h>
#include <linux/clk-provider.h>
#include <linux/init.h>
#include <linux/of_address.h>
#include <linux/platform_device.h>
#include <linux/reset-controller.h>
static struct clk_fixed_rate clk_hw_xtal = {
    .fixed_rate = 24000000,
    .hw.init = &(struct clk_init_data){
        .name = 'xtal',
        .num_parents = 0,
        .ops = &clk_fixed_rate_ops,
    },
};
static struct clk_fixed_rate clk_hw_pll = {
    .fixed_rate = 45000000,
    .hw.init = &(struct clk_init_data){
        .name = 'fixed_pll',
        .num_parents = 0,
        .ops = &clk_fixed_rate_ops,
    },
};
static struct clk_hw_onecell_data fake_fixed_hw_onecell_data = {
    .hws = {
        [CLKID_XTAL]	= &clk_hw_xtal.hw,
        [CLKID_PLL_FIXED]	= &clk_hw_pll.hw,
        [CLK_NR_CLKS]	= NULL,
    },
    .num = CLK_NR_CLKS,
};

有了这些,我们已经定义了我们的时钟。以下代码显示了如何在系统上注册这些时钟:

static int fake_fixed_clkc_probe(struct platform_device *pdev)
{
    int ret, i;
    struct device *dev = &pdev->dev;
    for (i = CLKID_XTAL; i < CLK_NR_CLKS; i++) {
        ret = devm_clk_hw_register(dev, 
                            fake_fixed_hw_onecell_data.hws[i]);
        if (ret)
            return ret;
    }
    return devm_of_clk_add_hw_provider(dev,                                   of_clk_hw_onecell_get,
                                  &fake_fixed_hw_onecell_data);
}
static const struct of_device_id fake_fixed_clkc_match_table[] = {
    { .compatible = 'l.abcsmart,fake-fixed-clkc' },
    { }
};
static struct platform_driver meson8b_driver = {
    .probe	= fake_fixed_clkc_probe,
    .driver	= {
        .name	= 'fake-fixed-clkc',
        .of_match_table = fake_fixed_clkc_match_table,
    },
};

一般简化考虑

在前面的摘录中,我们使用clk_hw_register()来注册时钟。此接口是基本注册接口,可用于注册任何类型的时钟。其主要参数是指向嵌入在基础时钟类型结构中的struct clk_hw结构的指针。

通过调用clk_hw_register()进行时钟初始化和注册需要填充struct clk_init_data(从而实现clk_ops)对象,该对象与clk_hw捆绑在一起。作为替代,您可以使用硬件特定(即,时钟类型相关)的注册函数。在这里,内核负责根据时钟类型从给定给函数的参数构建适当的init数据,然后在内部调用clk_hw_register(...)。通过这种替代方式,CCF 将根据时钟硬件类型提供适当的clk_ops

通常,时钟提供程序不需要直接使用或分配基本时钟类型,即struct clk_fixed_rate。这是因为内核时钟框架为此目的提供了专用接口。在实际情况下(存在固定时钟的情况下),这种专用接口将是clk_hw_register_fixed_rate()

struct clk_hw *
    clk_hw_register_fixed_rate(struct device *dev,                                const char *name,
                               const char *parent_name,                                unsigned long flags,
                               unsigned long fixed_rate)

clk_register_fixed_rate()接口使用时钟的nameparent_namefixed_rate作为参数来创建具有固定频率的时钟。flags表示特定于框架的标志,而dev是注册时钟的设备。时钟的clk_ops属性也由时钟框架提供,不需要提供者关心。这种时钟的内核时钟操作数据结构是clk_fixed_rate_ops。它在drivers/clk/clk-fixed-rate.c中定义如下:

static unsigned long
    clk_fixed_rate_recalc_rate(struct clk_hw *hw,
                               unsigned long parent_rate)
{
    return to_clk_fixed_rate(hw)->fixed_rate;
}
static unsigned long
    clk_fixed_rate_recalc_accuracy(struct clk_hw *hw,
                                unsigned long parent_ accuracy)
{
    return to_clk_fixed_rate(hw)->fixed_accuracy;
}
const struct clk_ops clk_fixed_rate_ops = {
    .recalc_rate = clk_fixed_rate_recalc_rate,
    .recalc_accuracy = clk_fixed_rate_recalc_accuracy,
};

clk_register_fixed_rate()返回固定速率时钟的基础clk_hw结构的指针。然后,代码可以使用to_clk_fixed_rate宏来获取原始时钟类型结构的指针。

但是,您仍然可以使用低级clk_hw_register()注册接口,并重用 CCF 提供的一些操作回调。CCF 提供适当的操作结构来处理您的时钟并不意味着您应该直接使用它。您可能不希望使用时钟类型相关的注册接口(而是使用clock_hw_register()),而是使用 CCF 提供的一个或多个单独的操作。这不仅适用于可调时钟,如下面的示例所示,还适用于本书中将讨论的所有其他时钟类型。

让我们看一个来自drivers/clk/clk-stm32f4.c的时钟分频器驱动程序的示例:

static unsigned long stm32f4_pll_div_recalc_rate(                                     struct clk_hw *hw, 
                                     unsigned long parent_rate)
{
    return clk_divider_ops.recalc_rate(hw, parent_rate);
}
static long stm32f4_pll_div_round_rate(struct clk_hw *hw,
                                       unsigned long rate,                                        unsigned long *prate)
{
    return clk_divider_ops.round_rate(hw, rate, prate);
}
static int stm32f4_pll_div_set_rate(struct clk_hw *hw,
                                    unsigned long rate,                                     unsigned long parent_rate)
{
    int pll_state, ret;
    struct clk_divider *div = to_clk_divider(hw);
    struct stm32f4_pll_div *pll_div = to_pll_div_clk(div);
    pll_state = stm32f4_pll_is_enabled(pll_div->hw_pll);
    if (pll_state)
        stm32f4_pll_disable(pll_div->hw_pll);
    ret = clk_divider_ops.set_rate(hw, rate, parent_rate);
    if (pll_state)
        stm32f4_pll_enable(pll_div->hw_pll);
    return ret;
}
static const struct clk_ops stm32f4_pll_div_ops = {
    .recalc_rate = stm32f4_pll_div_recalc_rate,
    .round_rate = stm32f4_pll_div_round_rate,
    .set_rate = stm32f4_pll_div_set_rate,
};

在上述摘录中,驱动程序只实现了.set_rate操作,并重用了 CCF 提供的时钟分频器操作的.recalc_rate.round_rate属性,称为clk_divider_ops

固定时钟设备绑定

这种类型的时钟也可以通过 DTS 配置本地直接支持,无需编写任何代码。这种基于设备树的接口通常用于提供虚拟时钟。有些设备树中的设备可能需要时钟节点来描述它们自己的时钟输入。例如,mcp2515 SPI 到 CAN 转换器需要提供一个时钟以让它知道连接的石英的频率。对于这样的虚拟时钟节点,兼容属性应该是fixed-clock。一个示例如下:

/* fixed crystal dedicated to mpc251x */
clocks {
    /* fixed crystal dedicated to mpc251x */
    clk8m: clk@1 {
        compatible = 'fixed-clock';
        reg=<0>;
        #clock-cells = <0>;
        clock-frequency = <8000000>;
        clock-output-names = 'clk8m';
    };
};
/* consumer */
can1: can@1 {
    compatible = 'microchip,mcp2515';
    reg = <0>;
    spi-max-frequency = <10000000>;
    clocks = <&clk8m>;
};

时钟框架的核心将直接提取 DTS 提供的时钟信息,并在没有任何驱动程序支持的情况下自动将其注册到内核。这里的#clock-cells为 0,因为只提供了一个固定速率线,而在这种情况下,指定器只需要是提供者的phandle

PWM 时钟替代

由于缺乏输出时钟源(时钟引脚),一些板设计师(无论对错)使用 PWM 输出引脚作为外部组件的时钟源。这种时钟只能从设备树中实例化。此外,由于 PWM 绑定需要指定 PWM 信号的周期,pwm-clock属于固定速率时钟类别。这种实例化的示例可以在以下代码中看到,这是从imx6qdl-sabrelite.dtsi中的摘录:

mipi_xclk: mipi_xclk {
    compatible = 'pwm-clock';
    #clock-cells = <0>;
    clock-frequency = <22000000>;
    clock-output-names = 'mipi_pwm3';
    pwms = <&pwm3 0 45>; /* 1 / 45 ns = 22 MHz */
    status = 'okay';
};
ov5640: camera@40 {
    compatible = 'ovti,ov5640';
    pinctrl-names = 'default';
    pinctrl-0 = <&pinctrl_ov5640>;
    reg = <0x40>;
    clocks = <&mipi_xclk>;
    clock-names = 'xclk';
    DOVDD-supply = <&reg_1p8v>;
    AVDD-supply = <&reg_2p8v>;
    DVDD-supply = <&reg_1p5v>;
    reset-gpios = <&gpio2 5 GPIO_ACTIVE_LOW>;
    powerdown-gpios = <&gpio6 9 GPIO_ACTIVE_HIGH>;
[...]
};

如你所见,compatible属性应该是pwm-clock,而#clock-cells应该是<0>。这种时钟类型的驱动程序位于drivers/clk/clk-pwm.c,关于这方面的更多信息可以在Documentation/devicetree/bindings/clock/pwm-clock.txt中找到。

固定因子时钟驱动程序及其操作

这种类型的时钟通过常数来分频和乘以父时钟的频率(因此它是一个固定因子时钟驱动程序)。这种时钟不能进行门控:

struct clk_fixed_factor {
    struct clk_hw	hw;
    unsigned int		mult;
    unsigned int		div;
};
#define to_clk_fixed_factor(_hw) \
             container_of(_hw, struct clk_fixed_factor, hw)

时钟的频率由父时钟的频率确定,乘以mult,然后除以div。实际上是一个CLK_SET_RATE_PARENT标志。由于父时钟的频率可以更改,固定因子时钟的频率也可以更改,因此还提供了.recalc_rate/.set_rate/.round_rate等回调。也就是说,由于如果设置了CLK_SET_RATE_PARENT标志,设置速率请求将向上游传播,因此这样的时钟的.set_rate回调需要返回 0,以确保其调用是有效的nop无操作):

static int clk_factor_set_rate(struct clk_hw *hw,                                unsigned long rate,
                               unsigned long parent_rate)
{
    return 0;
}

对于这样的时钟,最好使用时钟框架提供的辅助操作,称为clk_fixed_factor_ops,它在drivers/clk/clk-fixed-factor.c中定义和实现如下:

const struct clk_ops clk_fixed_factor_ops = {
    .round_rate = clk_factor_round_rate,
    .set_rate = clk_factor_set_rate,
    .recalc_rate = clk_factor_recalc_rate,
};
EXPORT_SYMBOL_GPL(clk_fixed_factor_ops);

使用这种方式的优势在于,你不再需要关心操作,因为内核已经为你设置好了一切。它的round_raterecalc_rate回调甚至会处理CLK_SET_RATE_PARENT标志,这意味着我们可以遵循我们的简化路径。此外,最好使用时钟框架辅助接口来注册这样的时钟;也就是说,clk_hw_register_fixed_factor()

struct clk_hw *
    clk_hw_register_fixed_factor(struct device *dev,                                  const char *name,
                                 const char *parent_name,                                  unsigned long flags, 
                                 unsigned int mult,                                  unsigned int div)

这个接口在内部设置了一个动态分配的struct clk_fixed_factor,然后返回指向底层struct clk_hw的指针。你可以使用to_clk_fixed_factor宏来获取指向原始固定因子时钟结构的指针。分配给时钟的操作是clk_fixed_factor_ops,如前所述。此外,这种类型的接口类似于固定速率时钟。你不需要提供驱动程序。你只需要配置设备树。

固定因子时钟的设备树绑定

你可以在内核源代码的Documentation/devicetree/bindings/clock/fixed-factor-clock.txt中找到这种简单固定因子速率时钟的绑定文档。所需的属性如下:

  • #clock-cells:根据通用时钟绑定,这将设置为 0。

  • compatible:这将是'fixed-factor-clock'

  • clock-div:固定除数。

  • clock-mult:固定乘数。

  • clocks:父时钟的phandle

这是一个例子:

clock {
    compatible = 'fixed-factor-clock';
    clocks = <&parentclk>;
    #clock-cells = <0>;
    clock-div = <2>;
    clock-mult = <1>;
};

现在固定因子时钟已经解决了,下一个合乎逻辑的步骤将是看看可开关时钟,另一种简单的时钟类型。

可开关时钟及其操作

这种类型的时钟只能进行切换,因此只提供.enable/.disable回调在这里是有意义的:

struct clk_gate {
    struct clk_hw hw;
    void  iomem	*reg;
    u8	bit_idx;
    u8	flags;
    spinlock_t	*lock;
};
#define to_clk_gate(_hw) container_of(_hw, struct clk_gate, hw)

让我们更详细地看一下前面的结构:

  • reg:这表示用于控制时钟开关的寄存器地址(虚拟地址;即 MMIO)。

  • bit_idx:这是时钟开关的控制位(可以是 1 或 0,并设置门的状态)。

  • clk_gate_flags:这表示门控时钟的特定标志。具体如下:

  • CLK_GATE_SET_TO_DISABLE:这是时钟开关的控制模式。如果设置,写入1会关闭时钟,写入0会打开时钟。

--CLK_GATE_HIWORD_MASK:一些寄存器使用“读取-修改-写入”的概念在位级别上操作,而其他寄存器只支持015),这包括改变16个低位(015)中的相应位并屏蔽16个高位(1631,因此称为高字或 High Word)以指示/验证更改。例如,如果位b1需要设置为门控,还需要通过设置高字掩码(b1 << 16)来指示更改。这意味着门控设置实际上在寄存器的低 16 位中,而门控位的掩码在同一寄存器的高 16 位中。设置此标志时,bit_idx不应大于15

  • lock:这是应该在时钟切换需要互斥时使用的自旋锁。

正如您可能已经猜到的那样,这个结构假定时钟门控寄存器是 mmio。与之前的时钟类型一样,最好使用提供的内核接口来处理这样的时钟;也就是说,clk_hw_register_gate()

struct clk_hw *
    clk_hw_register_gate(struct device *dev, const char *name,
                         const char *parent_name,                          unsigned long flags,
                         void iomem *reg, u8 bit_idx,
                         u8 clk_gate_flags, spinlock_t *lock);

此接口的一些参数与我们描述的时钟类型结构相同。此外,还有需要描述的额外参数:

  • dev是注册时钟的设备。

  • name 是时钟的名称。

  • parent_name是父时钟的名称,如果没有父时钟,则应为 NULL。

  • flags表示此时钟的框架特定标志。通常为具有父级的门控时钟设置CLK_SET_RATE_PARENT标志,以便将速率更改请求传播到上一级。

  • clk_gate_flags对应于时钟类型结构中的.flags

此接口返回时钟门控结构的底层struct clh_hw的指针。在这里,您可以使用to_clk_gate辅助宏来获取原始时钟门控结构。

在设置此时钟并在其注册之前,时钟框架将clk_gate_ops操作分配给它。这实际上是门控时钟的默认操作。它依赖于时钟通过 mmio 寄存器控制的事实:

const struct clk_ops clk_gate_ops = {
    .enable = clk_gate_enable,
    .disable = clk_gate_disable,
    .is_enabled = clk_gate_is_enabled,
};
EXPORT_SYMBOL_GPL(clk_gate_ops);

整个门控时钟 API 在drivers/clk/clk-gate.c中定义。这样的时钟驱动程序可以在drivers/clk/clk-asm9260.c中找到,而其设备树绑定可以在内核源树中的Documentation/devicetree/bindings/clock/alphascale,acc.txt中找到。

基于 I2C/SPI 的门控时钟

不仅仅是 mmio 外设可以提供门控时钟。在 I2C/SPI 总线后面也有离散芯片可以提供这样的时钟。显然,您不能依赖于我们之前介绍的结构(struct clk_gate)或接口辅助程序(clk_hw_register_gate())来开发这些芯片的驱动程序。其主要原因如下:

  • 上述接口和数据结构假定时钟门控寄存器控制是 mmio,而这在这里绝对不是这样。

  • 标准的门控时钟操作是.enable.disable。然而,这些回调不需要休眠,因为它们在持有自旋锁时调用,但我们都知道 I2C/SPI 寄存器访问可能会休眠。

这些限制都有解决方法:

  • 您可以选择使用低级别的clk_hw_register()接口来控制时钟的参数,从其标志到其操作,而不是使用特定于门控的时钟框架辅助程序。

  • 您可以在 .prepare/.unprepare 回调中实现 .enable/.disable 逻辑。请记住,.prepare/.unprepare 操作可能会休眠。这是有保证的,因为消费者在调用 clk_enable() 之前调用 clk_prepare() 是要求的,然后在调用 clk_disable() 之后调用 clk_unprepare()。通过这样做,任何对 clk_enable() 的消费者调用(映射到提供者的 .enable 回调)将立即返回。但是,由于它总是由消费者调用 clk_prepare()(映射到 .prepare 回调)先进行的,我们可以确保我们的时钟将被解锁。对于 clk_disable(映射到 .disable 回调),它保证了我们的时钟将被锁定。

这个时钟驱动程序的实现可以在 drivers/clk/clk-max9485.c 中找到,而它的设备树绑定可以在内核源树中的 Documentation/devicetree/bindings/clock/maxim,max9485.txt 中找到。

GPIO 门时钟替代

这是一个基本时钟,可以通过 gpio 输出进行启用和禁用。gpio-gate-clock 实例只能从设备树中实例化。为此,compatible 属性应该是 gpio-gate-clock#clock-cells 应该是 <0>,如下面从 imx6qdl-sr-som-ti.dtsi 中的摘录所示:

clk_ti_wifi: ti-wifi-clock {
    compatible = 'gpio-gate-clock';
    #clock-cells = <0>;
    clock-frequency = <32768>;
    pinctrl-names = 'default';
    pinctrl-0 = <&pinctrl_microsom_ti_clk>;
    enable-gpios = <&gpio5 5 GPIO_ACTIVE_HIGH>;
};
pwrseq_ti_wifi: ti-wifi-pwrseq {
    compatible = 'mmc-pwrseq-simple';
    pinctrl-names = 'default';
    pinctrl-0 = <&pinctrl_microsom_ti_wifi_en>;
    reset-gpios = <&gpio5 26 GPIO_ACTIVE_LOW>;
    post-power-on-delay-ms = <200>;
    clocks = <&clk_ti_wifi>; 
    clock-names = 'ext_clock';
};

这个时钟类型的驱动程序位于 drivers/clk/clk-gpio.c,更多的信息可以在 Documentation/devicetree/bindings/clock/gpio-gate-clock.txt 中找到。

时钟复用器及其操作

时钟复用器具有多个输入时钟信号或父级,其中只能选择一个作为输出。由于这种类型的时钟可以从多个父级中选择,因此应该实现 .get_parent/.set_parent/.recalc_rate 回调。在 CCF 中,mux 时钟由 struct clk_mux 的实例表示,如下所示:

struct clk_mux {
    struct clk_hw hw;
    void __iomem *reg;
    u32	*table;
    u32	mask;
    u8	shift;
    u8	flags;
    spinlock_t	*lock;
};
#define to_clk_mux(_hw) container_of(_hw, struct clk_mux, hw)

让我们来看看前面结构中显示的元素:

  • table 是一个对应于父索引的寄存器值数组。

  • maskshift 用于在获取适当值之前修改 reg 位字段。

  • reg 是用于父级选择的 mmio 寄存器。默认情况下,当寄存器的值为 0 时,它对应于第一个父级,依此类推。如果有例外情况,可以使用各种 flags,以及另一个接口。

  • flags 代表了时钟复用器的唯一标志,如下所示:

--CLK_MUX_INDEX_BIT:寄存器值是 2 的幂。我们很快将看到这是如何工作的。

--CLK_MUX_HIWORD_MASK:这使用了我们之前解释过的 hiword mask 的概念。

--CLK_MUX_INDEX_ONE:寄存器值不是从 0 开始,而是从 1 开始。这意味着最终值应该增加一。

--CLK_MUX_READ_ONLY:一些平台具有只读时钟复用器,这些时钟复用器在复位时预先配置,并且无法在运行时更改。

--CLK_MUX_ROUND_CLOSEST:这个标志使用最接近所需频率的父级频率。

  • lock,如果提供,用于保护对寄存器的访问。

用于注册这样一个时钟的 CCF 助手是 clk_hw_register_mux()。如下所示:

struct clk_hw *
    clk_hw_register_mux(struct device *dev, const char *name,
                        const char * const *parent_names,
                        u8 num_parents, unsigned long flags,
                        void iomem *reg, u8 shift, u8 width,
                        u8 clk_mux_flags, spinlock_t *lock)

在前面的注册接口中介绍了一些参数,当我们描述 mux 时钟结构时引入了这些参数。其余的参数如下:

  • parent_names:这是一个描述所有可能的父时钟的字符串数组。

  • num_parents:这指定了父时钟的数量。

在注册这样一个时钟时,根据是否设置了 CLK_MUX_READ_ONLY 标志,CCF 分配不同的时钟操作。如果设置了,将使用 clk_mux_ro_ops。这个时钟操作只实现了 .get_parent 操作,因为没有办法更改父级。如果没有设置,将使用 clk_mux_ops。这个操作实现了 .get_parent.set_parent.determine_rate,如下所示:

if (clk_mux_flags & CLK_MUX_READ_ONLY)
    init.ops = &clk_mux_ro_ops;
else
    init.ops = &clk_mux_ops;

这些时钟操作定义如下:

const struct clk_ops clk_mux_ops = {
    .get_parent = clk_mux_get_parent,
    .set_parent = clk_mux_set_parent,
    .determine_rate = clk_mux_determine_rate,
};
EXPORT_SYMBOL_GPL(clk_mux_ops);
const struct clk_ops clk_mux_ro_ops = {
    .get_parent = clk_mux_get_parent,
};
EXPORT_SYMBOL_GPL(clk_mux_ro_ops);

在前面的代码中,有一个.table字段。这用于根据父级索引提供一组值。然而,前面的注册接口clk_hw_register_mux()并没有提供任何方法来提供这个表格。

由于这一点,在 CCF 中还有另一种变体可用,允许我们传递表格:

struct clk *
    clk_register_mux_table(struct device *dev,                            const char *name,
                           const char **parent_names,                            u8 num_parents, 
                           unsigned long flags,                            void iomem *reg, u8 shift, u32 mask,
u8 clk_mux_flags, u32 *table, spinlock_t *lock);

该接口注册了一个多路复用器,用于通过表格控制不规则时钟。无论注册接口是什么,都会使用相同的内部操作。现在,让我们特别关注最重要的部分;即.set_parent.get_parent

  • clk_mux_set_parent:当调用此函数时,如果table不是NULL,它会从table中的索引获取寄存器值。如果tableNULL并且设置了CLK_MUX_INDEX_BIT标志,则这意味着寄存器值是根据index的幂。然后使用val = 1 << index来获取该值;如果设置了CLK_MUX_INDEX_ONE,则该值会增加一。如果tableNULL并且未设置CLK_MUX_INDEX_BIT,则index将用作默认值。在任何情况下,最终值在shift时间左移,并在我们获取实际值之前与掩码进行 OR 运算。这应该写入reg以进行父级选择:
unsigned int
   clk_mux_index_to_val(u32 *table, unsigned int flags,                         u8 index)
{
    unsigned int val = index;
    if (table) {
        val = table[index];
    } else {
        if (flags & CLK_MUX_INDEX_BIT)
            val = 1 << index;
        if (flags & CLK_MUX_INDEX_ONE) val++;
    }
    return val;
}
static int clk_mux_set_parent(struct clk_hw *hw,                               u8 index)
{
    struct clk_mux *mux = to_clk_mux(hw);
    u32 val =
        clk_mux_index_to_val(mux->table, mux->flags,                             index);
    unsigned long flags = 0; u32 reg;
    if (mux->lock)
        spin_lock_irqsave(mux->lock, flags);
    else
        __acquire(mux->lock);
    if (mux->flags & CLK_MUX_HIWORD_MASK) {
        reg = mux->mask << (mux->shift + 16);
    } else {
        reg = clk_readl(mux->reg);
        reg &= ~(mux->mask << mux->shift);
    }
    val = val << mux->shift; reg |= val;
    clk_writel(reg, mux->reg);
    if (mux->lock)
        spin_unlock_irqrestore(mux->lock, flags);
    else
        __release(mux->lock);
    return 0;
}
  • clk_mux_get_parent:这会读取reg中的值,将其向右移动shift次,并在获取实际值之前应用(AND 操作)mask。然后将该值传递给clk_mux_val_to_index()辅助函数,该函数将根据reg的值返回正确的索引。clk_mux_val_to_index()首先获取给定时钟的父级数量。如果table不是NULL,则此数字将用作循环中的上限,以遍历table。每次迭代都会检查当前位置的table值是否与val匹配。如果匹配,则返回当前迭代中的当前位置。如果找不到匹配项,则返回错误。ffs()返回字中设置的第一个(最低有效)位的位置:
int clk_mux_val_to_index(struct clk_hw *hw, u32 *table,
                         unsigned int flags,                          unsigned int val)
{
    int num_parents = clk_hw_get_num_parents(hw);
    if (table) {
        int i;
        for (i = 0; i < num_parents; i++)
            if (table[i] == val)
                return i;
        return -EINVAL;
    }
    if (val && (flags & CLK_MUX_INDEX_BIT))
        val = ffs(val) - 1;
    if (val && (flags & CLK_MUX_INDEX_ONE))
        val--;
    if (val >= num_parents)
        return -EINVAL;
    return val;
}
EXPORT_SYMBOL_GPL(clk_mux_val_to_index);
static u8 clk_mux_get_parent(struct clk_hw *hw)
{
    struct clk_mux *mux = to_clk_mux(hw);
    u32 val;
    val = clk_readl(mux->reg) >> mux->shift;
    val &= mux->mask;
    return clk_mux_val_to_index(hw, mux->table,                                 mux->flags, val);
}

此类驱动程序的示例可以在drivers/clk/microchip/clk-pic32mzda.c中找到。

基于 I2C/SPI 的时钟多路复用

用于处理时钟多路复用的上述 CCF 接口假定通过 mmio 寄存器提供控制。然而,有一些基于 I2C/SPI 的时钟多路复用芯片,您必须依赖低级的clk_hw(使用基于clk_hw_register()注册的接口)接口,并根据其属性注册每个时钟,然后提供适当的操作。

每个多路复用输入时钟应该是多路复用输出的父级,它必须至少具有.set_parent.get_parent操作。其他操作也是允许的,但不是强制性的。一个具体的例子是来自 Silicon Labs 的Si5351a/b/c可编程 I2C 时钟发生器的 Linux 驱动程序,可以在内核源代码中的drivers/clk/clk-si5351.c中找到。其设备树绑定可以在Documentation/devicetree/bindings/clock/silabs,si5351.txt中找到。

重要说明

要编写此类时钟驱动程序,您必须了解clk_hw_register_mux是如何实现的,并基于它构建您的注册函数,不包括 mmio/spinlock 部分,然后根据时钟的属性提供自己的操作。

GPIO 多路复用时钟替代方案

GPIO 多路复用时钟可以表示如下:

图 4.2 - GPIO 多路复用时钟

图 4.2 - GPIO 多路复用时钟

这是时钟多路复用的有限替代方案,只接受两个父级,如其驱动程序中所述,可以在drivers/clk/clk-gpio.c中找到。在这种情况下,父级选择取决于所使用的 gpio 的值:

struct clk_hw *clk_hw_register_gpio_mux(struct device *dev,
                                   const char *name,
                                   const char *                                    const *parent_names,
                                   u8 num_parents,
                                   struct gpio_desc *gpiod,
                                   unsigned long flags)
{
    if (num_parents != 2) {
        pr_err('mux-clock %s must have 2 parents\n', name);
        return ERR_PTR(-EINVAL);
    }
    return clk_register_gpio(dev, name, parent_names,                              num_parents,
                             gpiod, flags, &clk_gpio_mux_ops);
}
EXPORT_SYMBOL_GPL(clk_hw_register_gpio_mux);

根据其绑定,它只能在设备树中实例化。此绑定可以在内核源代码中的Documentation/devicetree/bindings/clock/gpio-mux-clock.txt中找到。以下示例显示了如何使用它:

clocks {
    /* fixed clock oscillators */
    parent1: oscillator22 {
        compatible = 'fixed-clock';
        #clock-cells = <0>;
        clock-frequency = <22579200>;
    };
    parent2: oscillator24 {
        compatible = 'fixed-clock';
        #clock-cells = <0>;
        clock-frequency = <24576000>;
    };
    /* gpio-controlled clock multiplexer */
    mux: multiplexer {
        compatible = 'gpio-mux-clock';
        clocks = <&parent1>, <&parent2>;
        /* parent clocks */
        #clock-cells = <0>;
        select-gpios = <&gpio 42 GPIO_ACTIVE_HIGH>;
    };
};

在这里,我们已经看过时钟多路复用器,它允许我们从其 API 和设备树绑定中选择时钟源。此外,我们介绍了基于 GPIO 的时钟多路复用器替代方案,它不需要我们编写任何代码。这个系列中的下一个时钟类型是分频器时钟,正如其名称所示,它通过给定的比率将父速率除以。

(可调节)分频器时钟及其操作

这种类型的时钟将父速率除以并且不能进行门控。由于可以设置分频器比率,因此必须提供.recalc_rate/.set_rate/.round_rate回调。时钟分频器在内核中表示为struct clk_divider的实例。这可以定义如下:

struct clk_divider {
    struct clk_hw  hw;
    void iomem	*reg;
    u8	shift;
    u8	width;
    u8	flags;
    const struct clk_div_table	*table;
    spinlock_t	*lock;
};
#define to_clk_divider(_hw) container_of(_hw,                                          struct clk_divider,                                          hw)

让我们来看看这个结构中的元素:

  • hw: 定义了提供方的底层clock_hw结构。

  • reg: 这是控制时钟分频比的寄存器。默认情况下,实际的分频器值是寄存器值加一。如果有其他异常,您可以参考flags字段描述进行调整。

  • shift: 这控制了寄存器中分频比的位的偏移量。

  • width: 这是分频器位字段的宽度。它控制分频比的位数。例如,如果width为 4,这意味着分频比编码在 4 位上。

  • flags: 这是时钟分频器特定标志的标志。这里可以使用各种标志,其中一些如下:

--CLK_DIVIDER_ONE_BASED: 当设置时,这意味着分频器是从寄存器中读取的原始值,因为默认的除数是从寄存器中读取的值加一。这也意味着 0 是无效的,除非设置了CLK_DIVIDER_ALLOW_ZERO标志。

--CLK_DIVIDER_ROUND_CLOSEST: 当我们希望能够将分频器四舍五入到最接近和最佳计算的值而不仅仅是向上舍入时,应该使用这个选项,这是默认行为。

--CLK_DIVIDER_POWER_OF_TWO: 实际的分频器值是寄存器值的 2 的幂。

--CLK_DIVIDER_ALLOW_ZERO: 除数值可以为 0(不变,取决于硬件支持)。

--CLK_DIVIDER_HIWORD_MASK: 有关此标志的更多详细信息,请参阅可门控时钟及其操作部分。

--CLK_DIVIDER_READ_ONLY: 这个标志表明时钟具有预配置的设置,并指示框架不要改变任何东西。这个标志也影响了分配给时钟的操作。

CLK_DIVIDER_MAX_AT_ZERO: 这允许时钟分频器在设置为零时具有最大除数。因此,如果字段值为零,则除数值应为 2 位宽度。例如,让我们考虑一个具有 2 位字段的除数时钟:

Value		divisor
0		4
1		1
2		2
3		3
  • : 这是一个值/分频器对的数组,最后一个条目应该有div = 0。这将很快描述。

  • 锁定: 与其他时钟数据结构一样,如果提供了,它将用于保护对寄存器的访问。

  • clk_hw_register_divider(): 这是最常用的此类时钟的注册接口。它定义如下:

struct clk_hw *
    clk_hw_register_divider(struct device *dev,
                            const char *name,                             const char *parent_name, 
                            unsigned long flags,                             void iomem *reg,
                            u8 shift, u8 width,                             u8 clk_divider_flags, 
                            spinlock_t *lock)

此函数在系统中注册一个分频器时钟,并返回指向底层clk_hw字段的指针。在这里,您可以使用to_clk_divider宏来获取指向包装器的clk_divider结构的指针。除了nameparent_name,它们分别表示时钟的名称和其父级的名称,此函数中的其他参数与struct clk_divider结构中描述的字段匹配。

你可能已经注意到这里没有使用.table字段。这个字段有点特殊,因为它用于那些除法比率不常见的时钟分频器。实际上,有些时钟分频器的每个单独的时钟线都有一些不相关的分频比率。有时,甚至每个比率和寄存器值之间都没有线性关系。对于这种情况,最好的解决方案是为每个时钟线提供一个表,其中每个比率对应其寄存器值。这要求我们引入一个接受这样一个表的新注册接口;即clk_hw_register_divider_table。可以定义如下:

struct clk_hw *
     clk_hw_register_divider_table(                            struct device *dev,
                            const char *name,                             const char *parent_name, 
                            unsigned long flags,                             void iomem *reg,
                            u8 shift, u8 width,                             u8 clk_divider_flags, 
                            const struct clk_div_table *table, 
                            spinlock_t *lock)

此接口用于注册具有不规则频率分频比的时钟,与前面的接口相比。不同之处在于分频器的值与寄存器的值之间的关系是由struct clk_div_table类型的表确定的。这个表结构可以定义如下:

struct clk_div_table {
    unsigned int	val;
    unsigned int	div;
};

在上述代码中,val代表寄存器值,而div代表除法比率。它们的关系也可以通过使用clk_divider_flags来改变。无论使用何种注册接口,CLK_DIVIDER_READ_ONLY标志都确定了要分配给时钟的操作,如下所示:

if (clk_divider_flags & CLK_DIVIDER_READ_ONLY)
    init.ops = &clk_divider_ro_ops;
else
    init.ops = &clk_divider_ops;

这两个时钟操作都在drivers/clk/clk-divider.c中定义,如下所示:

const struct clk_ops clk_divider_ops = {
    .recalc_rate = clk_divider_recalc_rate,
    .round_rate = clk_divider_round_rate,
    .set_rate = clk_divider_set_rate,
};
EXPORT_SYMBOL_GPL(clk_divider_ops);
const struct clk_ops clk_divider_ro_ops = {
    .recalc_rate = clk_divider_recalc_rate,
    .round_rate = clk_divider_round_rate,
};
EXPORT_SYMBOL_GPL(clk_divider_ro_ops);

前者可以设置时钟速率,而后者则不能。

重要说明

再次强调,到目前为止,使用内核提供的时钟类型相关注册接口需要你的时钟是 mmio。为非 mmio 型(基于 SPI 或 I2C 的)时钟实现这样的时钟驱动程序需要使用低级hw_clk注册接口并实现适当的操作。一个基于 I2C 的时钟驱动程序的示例,以及已实现的适当操作,可以在drivers/clk/clk-max9485.c中找到。其绑定可以在Documentation/devicetree/bindings/clock/maxim,max9485.txt中找到。这是一个比分频器更可调的时钟驱动程序。

可调时钟对我们来说已经没有秘密了。它的 API 和操作已经被描述,以及它如何处理不规则的比率。接下来,我们将看看我们迄今为止看到的所有时钟类型的最终时钟类型:复合时钟。

复合时钟及其操作

这个时钟用于使用复用器、分频器和门组件的时钟分支。这在大多数 Rockchip SoC 上都是这样。时钟框架通过struct clk_composite来抽象这样的时钟,其外观如下:

struct clk_composite {
    struct clk_hw	hw;
    struct clk_ops	ops;
    struct clk_hw	*mux_hw;
    struct clk_hw	*rate_hw;
    struct clk_hw	*gate_hw;
    const struct clk_ops	*mux_ops;
    const struct clk_ops	*rate_ops;
    const struct clk_ops	*gate_ops;
};
#define to_clk_composite(_hw) container_of(_hw,                                           struct clk_composite,                                          hw)

此数据结构中的字段相当自明,如下所示:

  • hw,就像其他时钟结构一样,是通用和硬件特定接口之间的处理。

  • mux_hw代表复用器时钟。

  • rate_hw代表分频时钟。

  • gate_hw代表门时钟。

  • mux_opsrate_opsgate_ops分别是复用器、速率和门的时钟操作。

这样的时钟可以通过以下接口进行注册:

struct clk_hw *clk_hw_register_composite(
             struct device *dev, const char *name,
             const char * const *parent_names, int num_parents,
             struct clk_hw *mux_hw,              const struct clk_ops *mux_ops,
             struct clk_hw *rate_hw,              const struct clk_ops *rate_ops,
             struct clk_hw *gate_hw,              const struct clk_ops *gate_ops,
             unsigned long flags)

这可能看起来有点复杂,但如果你已经了解了之前的时钟,这个就会对你来说更加明显。在内核源代码中查看drivers/clk/sunxi/clk-a10-hosc.c,可以找到一个复合时钟驱动程序的示例。

将所有内容汇总-全局概述

如果你还感到困惑,那么看一下下面的图表:

图 4.3 - 时钟树示例

图 4.3 - 时钟树示例

上述时钟树显示了一个振荡器时钟馈送三个 PLL - 即pll1pll2pll3 - 以及一个多路复用器。根据多路复用器(mux),hw3_clk可以从pll2pll3osc时钟派生出来。

以下设备树摘录可用于建模上述时钟树:

osc: oscillator {
    #clock-cells = <0>;
    compatible = 'fixed-clock';
    clock-frequency = <20000000>;
    clock-output-names = 'osc20M';
};
pll2: pll2 {
    #clock-cells = <0>;
    compatible = 'abc123,pll2-clock';
    clock-frequency = <23000000>; clocks = <&osc>;
    [...]
};
pll3: pll3 {
    #clock-cells = <0>;
    compatible = 'abc123,pll3-clock';
    clock-frequency = <23000000>; clocks = <&osc>;
    [...]
};
hw3_clk: hw3_clk {
    #clock-cells = <0>;
    compatible = 'abc123,hw3-clk';
    clocks = <&pll2>, <&pll3>, <&osc>;
    clock-output-names = 'hw3_clk';
};

当涉及到源代码时,以下摘录显示了如何将 hw_clk3 注册为一个复用器(时钟复用器),并指出了 pll2pll3osc 的父关系:

of_property_read_string(node, 'clock-output-names', &clk_name); 
parent_names[0] = of_clk_get_parent_name(node, 0);
parent_names[1] = of_clk_get_parent_name(node, 1);
parent_names[2] = of_clk_get_parent_name(node, 2); /* osc */
clk = clk_register_mux(NULL, clk_name, parent_names,  
                       ARRAY_SIZE(parent_names), 0, regs_base,
                       offset_bit, one_bit, 0, NULL);

下游时钟提供者应该使用 of_clk_get_parent_name() 来获取其父时钟名称。对于具有多个输出的块,of_clk_get_parent_name() 可以返回一个有效的时钟名称,但只有在存在 clock-output-names 属性时才能返回。

现在,我们可以通过 CCF sysfs接口 /sys/kernel/debug/clk/clk_summary 查看时钟树摘要。这可以在以下摘录中看到:

$ mount -t debugfs none /sys/kernel/debug
# cat /sys/kernel/debug/clk/clk_summary
[...]

有了这些,我们就完成了时钟生产者方面的工作。我们已经了解了它的 API,并讨论了它在设备树中的声明。此外,我们已经学会了如何从 sysfs 中转储它们的拓扑结构。现在,让我们来看看时钟消费者 API。

介绍时钟消费者 APIs

时钟生产者设备驱动程序如果没有在另一端利用已暴露的时钟线的消费者,就是无用的。这类驱动程序的主要目的是将它们的时钟源线分配给消费者。然后,这些时钟线被用于多种目的,Linux 内核提供了相应的 API 和辅助程序来实现所需的目标。消费者驱动程序需要在其代码中包含 <linux/clk.h>,以便使用其 API。此外,如今,时钟消费者接口完全依赖于设备树,这意味着消费者应该从设备树中分配它们需要的时钟。消费者绑定应该遵循提供者的绑定,因为消费者的指定符是由提供者的 #clock-cells 属性确定的。看一下以下 UART 节点描述,它需要两条时钟线:

uart1: serial@02020000 {
    compatible = 'fsl,imx6sx-uart', 'fsl,imx21-uart';
    reg = <0x02020000 0x4000>;
    interrupts = <GIC_SPI 26 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&clks IMX6SX_CLK_UART_IPG>,
             <&clks IMX6SX_CLK_UART_SERIAL>;
    clock-names = 'ipg', 'per';
    dmas = <&sdma 25 4 0>, <&sdma 26 4 0>;
    dma-names = 'rx', 'tx';
    status = 'disabled';
};

这代表一个具有两个时钟输入的设备。前面的节点摘录允许我们为时钟消费者引入设备树绑定,它至少应该具有以下属性:

  • clocks 属性是您应该指定设备的源时钟线的位置,以相对于提供者的 #clock-cells 属性。

  • clock-names 是用来命名时钟的属性,以与它们在 clocks 中列出的方式相同。换句话说,这个属性应该用来列出相对于消费节点的时钟的输入名称。这个名称应该反映消费者输入信号的名称,并且可以/必须在代码中使用(参见 [devm_]clk_get()),以便与相应的时钟匹配。

重要提示

时钟消费节点绝对不能直接引用提供者的 clock-output-names 属性。

消费者具有基于底层硬件时钟的简化和可移植的 API。接下来,我们将看一下消费驱动程序执行的常见操作,以及它们关联的 API。

抓取和释放时钟

以下函数允许我们根据其 id 抓取和释放时钟:

struct clk *clk_get(struct device *dev, const char *id);
void clk_put(struct clk *clk);
struct clk *C(struct device *dev, const char *id)

dev 是使用此时钟的设备,而 id 是在设备树中给出的时钟名称。成功后,clk_get 返回一个指向 struct clk 的指针。这可以提供给任何其他 clk-consumer API。clk_put 实际上释放了时钟线。前面代码中的前两个 API 在 drivers/clk/clkdev.c 中定义。然而,其他时钟消费者 API 在 drivers/clk/clk.c 中定义。devm_clk_get 简单地是 clk_get 的托管版本。

准备/取消准备时钟

要准备使用时钟,可以使用 clk_prepare(),如下所示:

void clk_prepare(struct clk *clk);
void clk_unprepare(struct clk *clk);

这些函数可能会休眠,这意味着它们不能在原子上下文中调用。在调用 clock_enable() 之前,始终调用 clk_prepare() 是值得的。如果底层时钟位于慢总线(SPI/I2C)后面,这可能很有用,因为这样的时钟驱动程序必须在准备/取消准备操作中实现它们的启用/禁用(不能休眠),而这些操作允许休眠。

启用/禁用

当涉及到对时钟进行门控/解除门控时,可以使用以下 API:

int clk_enable(struct clk *clk);
void clk_disable(struct clk *clk);

clk_enable不能休眠,实际上解除时钟门控。成功返回 0,否则返回错误。clk_disable则相反。为了在调用启用之前调用准备的事实,时钟框架提供了clk_prepare_enable API,它在内部同时调用两者。相反的操作可以使用clk_disable_unprepare

int clk_prepare_enable(struct clk *clk)
void clk_disable_unprepare(struct clk *clk)

速率函数

对于可以更改速率的时钟,我们可以使用以下函数来获取/设置时钟的速率:

unsigned long clk_get_rate(struct clk *clk);
int clk_set_rate(struct clk *clk, unsigned long rate);
long clk_round_rate(struct clk *clk, unsigned long rate);

如果clkNULLclk_get_rate()返回 0;否则,它将返回时钟的速率;也就是说,缓存速率。但是,如果设置了CLK_GET_RATE_NOCACHE标志,将通过recalc_rate()进行新的计算,以返回实际的时钟速率。另一方面,clk_set_rate()将设置时钟的速率。但是,它的速率参数不能取任何值。要查看您所针对的速率是否受时钟支持或允许,应该使用clk_round_rate(),以及时钟指针和目标速率(以赫兹为单位),如下所示。

rounded_rate = clk_round_rate(clkp, target_rate);

这是clk_round_rate()的返回值,必须提供给clk_set_rate(),如下所示:

ret = clk_set_rate(clkp, rounded_rate);

在以下情况下,更改时钟速率可能会失败:

  • 时钟从固定速率时钟源(例如OSC0OSC1XREF等)获取其源。

  • 时钟被多个模块/子级使用,这意味着usecount大于 1。

  • 时钟源被多个子级使用。

请注意,如果未实现.round_rate(),则将返回父速率。

父函数

有些时钟是其他时钟的子级,从而创建了父/子关系。要获取/设置给定时钟的父级,可以使用以下函数:

int clk_set_parent(struct clk *clk, struct clk *parent);
struct clk *clk_get_parent(struct clk *clk);

clk_set_parent()实际上设置了给定时钟的父级,而clk_get_parent()返回当前父级。

把所有东西放在一起

总结一下,看一下 i.MX 串行驱动程序(drivers/tty/serial/imx.c)的以下摘录,处理前面的设备节点:

sport->clk_per = devm_clk_get(&pdev->dev, 'per');
if (IS_ERR(sport->clk_per)) {
    ret = PTR_ERR(sport->clk_per);
    dev_err(&pdev->dev, 'failed to get per clk: %d\n', ret);
    return ret;
}
sport->port.uartclk = clk_get_rate(sport->clk_per);
/*  * For register access, we only need to enable the ipg clock.  */
ret = clk_prepare_enable(sport->clk_ipg);
if (ret)
    return ret;

在前面的代码摘录中,我们可以看到驱动程序如何获取时钟及其当前速率,然后启用它。

总结

在本章中,我们介绍了 Linux 通用时钟框架。我们介绍了提供程序和使用者双方,以及用户空间接口。然后,我们讨论了不同的时钟类型,并学习了如何为每个类型编写适当的 Linux 驱动程序。

下一章涉及 ALSA SoC,即 Linux 内核音频框架。该框架在很大程度上依赖时钟框架,例如用于对音频进行采样。

第二部分:嵌入式 Linux 系统中的多媒体和节能

本节以简单而节能的方式,通过 Linux 内核电源管理子系统,引导您了解最广泛使用的 Linux 内核多媒体子系统 V4L2 和 ALSA SoC。

本节包括以下章节:

  • 第五章,ALSA SoC 框架-利用编解码器和平台类驱动程序

  • 第六章,ALSA SoC 框架-深入了解机器类驱动程序

  • 第七章,揭秘 V4L2 和视频捕获设备驱动程序

  • 第八章,与 V4L2 异步和媒体控制器框架集成

  • 第九章,从用户空间利用 V4L2 API

  • 第十章,Linux 内核电源管理

第五章:ALSA SoC 框架 – 利用编解码器和平台类驱动程序

音频是一种可以以各种方式产生的模拟现象。自人类开始以来,语音和音频一直是通信媒体。几乎每个内核都为用户空间应用程序提供音频支持,作为计算机与人类之间的交互机制。为了实现这一点,Linux 内核提供了一组称为ALSA的 API,代表高级 Linux 音频架构

ALSA 是为台式电脑设计的,没有考虑嵌入式世界的限制。这在处理嵌入式设备时带来了很多缺点,比如:

  • 编解码器和 CPU 代码之间的紧密耦合,导致移植困难和代码重复。

  • 没有处理有关用户音频行为通知的标准方式。在移动场景中,用户的音频行为频繁,因此需要一种特殊的机制。

  • 在原始的 ALSA 架构中,没有考虑功率效率。但对于嵌入式设备(大多数情况下是由电池支持的),这是一个关键点,因此需要一种机制。

这就是 ASoC 出现的地方。ALSA 系统芯片ASoC)层的目的是为嵌入式处理器和各种编解码器提供更好的 ALSA 支持。

ASoC 是一种新的架构,旨在解决上述问题,并具有以下优点:

  • 独立的编解码器驱动程序以减少与 CPU 的耦合

  • 更方便地配置 CPU 和编解码器之间的音频数据接口动态音频功率管理DAPM),动态控制功耗(更多信息请参见:www.kernel.org/doc/html/latest/sound/soc/dapm.html

  • 减少爆音和点击声,增加与平台相关的控制

为了实现上述功能,ASoC 将嵌入式音频系统分为三个可重用的组件驱动程序,即机器类平台类编解码器类。其中,平台和编解码器类是跨平台的,而机器类是特定于板的。在本章和下一章中,我们将详细介绍这些组件驱动程序,处理它们各自的数据结构以及它们的实现方式。

在这里,我们将介绍 Linux ASoC 驱动程序架构及其不同部分的实现,特别关注以下内容:

  • ASoC 简介

  • 编写编解码器类驱动程序

  • 编写平台类驱动程序

技术要求

ASoC 简介

从架构的角度来看,ASoC 子系统的元素及其关系可以表示如下:

图 5.1 – ASoC 架构

图 5.1 – ASoC 架构

上述图表总结了新的 ASoC 架构,其中机器实体包装了平台和编解码器实体。

在内核 v4.18 之前的 ASoC 实现中,SoC 音频编解码器设备(现在由struct snd_soc_codec表示)和 SoC 平台接口(由struct snd_soc_platform表示)及其各自的数字音频接口之间有严格的分离。然而,编解码器、平台和其他组件之间的相似代码越来越多。这导致了一种新的通用方法,即struct snd_soc_component的概念(可以指代编解码器或平台)和struct snd_soc_component_driver(指代它们各自的音频接口驱动程序)。

现在我们已经介绍了 ASoC 的概念,我们可以深入讨论数字音频接口的细节。

ASoC 数字音频接口

数字音频接口DAI)是实际上从一端(例如 SoC)到另一端(编解码器)携带音频数据的总线控制器。ASoC 目前支持大多数 SoC 控制器和便携式音频编解码器上找到的 DAI,如 AC97、I2S、PCM、S/PDIF 和 TDM。

重要说明

I2S 模块支持六种不同的模式,其中最有用的是 I2S 和 TDM。

ASoC 子元素

正如我们之前所见,ASoC 系统分为三个元素,每个元素都有一个专用的驱动程序,描述如下:

  • struct snd_soc_component_driver(参见struct snd_pcm_ops元素)结构。PCM 驱动程序与平台无关,只与 SOC DMA 引擎上游 API 交互。然后 DMA 引擎与特定于平台的 DMA 驱动程序交互,以获取正确的 DMA 设置。

它负责将DMA 缓冲区中的音频数据传输到总线(或端口)Tx FIFO。这部分的逻辑更加复杂。接下来的部分将对此进行详细阐述。

  • 编解码器:编解码器字面上意味着编解码器,但芯片中有许多功能。常见的功能有 AIF、DAC、ADC、混音器、PGA、Line-in 和 Line-out。一些高端编解码器芯片还具有回声消除器、降噪等组件。编解码器负责将声源的模拟信号转换为处理器可以操作的数字信号(用于捕获操作),或者将声源(CPU)的数字信号转换为人类可以识别的模拟信号(用于播放)。必要时,它对音频信号进行相应的调整,并控制芯片中每个音频信号的路径,因为每个音频信号可能在芯片中有不同的流路径。

  • cpu_daicodec_dai)。这种链接在内核中通过struct snd_soc_dai_link的实例来抽象。配置链接后,机器驱动程序通过devm_snd_soc_register_card()注册一个struct snd_soc_card对象,这是 Linux 内核对声卡的抽象。而平台和编解码器驱动程序通常是可重用的,机器具有其特定的几乎不可重用的硬件特性。所谓的硬件特性是指 DAI 之间的链接;通过 GPIO 的开放放大器;通过 GPIO 检测插件;使用时钟(如 MCLK/外部 OSC)作为 I2S CODEC 模块的参考时钟源,等等。

从前面的描述中,我们可以得出以下 ASoC 方案及其关系:

图 5.2- Linux 音频层和关系

图 5.2- Linux 音频层和关系

上述图表是 Linux 内核音频组件之间交互的快照。现在我们熟悉了 ASoC 的概念,可以继续介绍它的第一个设备驱动程序类,即处理编解码器设备的类。

编写编解码器类驱动程序

为了进行耦合,机器、平台和编解码器实体需要专用驱动程序。编解码器类驱动程序是最基本的。它实现了应该利用编解码器设备并公开其硬件属性,以便用户空间工具(如 amixer)可以使用的代码。编解码器类驱动程序是并且应该是与平台无关的。无论平台如何,都可以使用相同的编解码器驱动程序。由于它针对特定的编解码器,因此应该包含音频控件、音频接口功能、编解码器 DAPM 定义和 I/O 函数。每个编解码器驱动程序必须满足以下规范:

  • 通过定义 DAI 和 PCM 配置为其他模块提供接口。

  • 提供编解码器控制 IO 钩子(使用 I2C 或 SPI 或两者的 API)。

  • 根据需要公开额外的kcontrols内核控件),以便用户空间实用程序动态控制模块行为。

  • 可选择地定义 DAPM 小部件并建立 DAPM 路由以进行动态电源切换,并提供 DAC 数字静音控制。

编解码器驱动程序包括编解码器设备(实际上是组件本身)和 DAI 组件,在与平台绑定期间使用。它是与平台无关的。通过 devm_snd_soc_register_component(),编解码器驱动程序注册一个 struct snd_soc_component_driver 对象(实际上是包含指向编解码器路由、小部件、控件和一组编解码器相关函数回调的编解码器驱动程序实例),以及一个或多个 struct snd_soc_dai_driver,它是编解码器 DAI 驱动程序的实例,可以包含音频流,例如:

struct snd_soc_component_driver {
    const char *name;
    /* Default control and setup, added after probe() is run */
    const struct snd_kcontrol_new *controls;
    unsigned int num_controls;
    const struct snd_soc_dapm_widget *dapm_widgets;
    unsigned int num_dapm_widgets;
    const struct snd_soc_dapm_route *dapm_routes;
    unsigned int num_dapm_routes;
    int (*probe)(struct snd_soc_component *);
    void (*remove)(struct snd_soc_component *);
    int (*suspend)(struct snd_soc_component *);
    int (*resume)(struct snd_soc_component *);
    unsigned int (*read)(struct snd_soc_component *,                          unsigned int);
    int (*write)(struct snd_soc_component *, unsigned int,
                     unsigned int);
    /* pcm creation and destruction */
    int (*pcm_new)(struct snd_soc_pcm_runtime *);
    void (*pcm_free)(struct snd_pcm *);
    /* component wide operations */
    int (*set_sysclk)(struct snd_soc_component *component,                      int clk_id, 
                     int source, unsigned int freq, int dir);
    int (*set_pll)(struct snd_soc_component *component,
                   int pll_id, int source,                    unsigned int freq_in,
                   unsigned int freq_out);
    int (*set_jack)(struct snd_soc_component *component,
                    struct snd_soc_jack *jack, void *data);
    [...]
    const struct snd_pcm_ops *ops;
    [...]
    unsigned int non_legacy_dai_naming:1;
};

该结构也必须由平台驱动程序提供。但是,在 ASoC 核心中,该结构中唯一强制的元素是 name,因为它用于匹配组件。以下是该结构中元素的含义:

  • name: 该组件的名称对于编解码器和平台都是必需的。结构中的其他元素在平台端可能不需要。

  • probe: 组件驱动程序探测函数,在机器驱动程序探测到该组件驱动程序时执行(如果需要完成组件初始化)(实际上是当机器驱动程序使用该组件制作一张卡并在 ASoC 核心中注册时执行:参见 snd_soc_instantiate_card())。

  • remove: 当组件驱动程序被注销时(当与该组件驱动程序绑定的声卡被注销时)。

  • suspendresume: 在系统挂起或恢复阶段调用的电源管理回调。

  • controls: 控制接口指针,主要用于控制音量调节、通道选择等,大多数用于编解码器。

  • set_pll: 设置锁相环的函数指针。

  • read: 读取编解码器寄存器的函数。

  • write: 写入编解码器寄存器的函数。

  • num_controls: 控制中的控件数量,即 snd_kcontrol_new 对象的数量。

  • dapm_widgets: dapm 小部件指针。

  • num_dapm_widgetsdapm 部件指针的数量。

  • dapm_routes: dapm route 指针。

  • num_dapm_routesdapm 路由指针的数量。

  • set_sysclk: 设置时钟函数指针。

  • ops: 平台 DMA 相关的回调,仅在从平台驱动程序中提供该结构时才需要(仅限 ALSA);但是,在使用通用 PCM DMA 引擎框架时,ASoC 核心会通过专用的 ASoC DMA 相关 API 为您设置该字段。

到目前为止,我们已经在编解码器类驱动程序的上下文中介绍了 struct snd_soc_component_driver 数据结构。请记住,该结构抽象了编解码器和平台设备,并且在平台驱动程序的上下文中也将进行讨论。但是,在编解码器类驱动程序的上下文中,我们需要讨论 struct snd_soc_dai_driver 数据结构,它与 struct snd_soc_component_driver 一起,抽象了编解码器或平台设备,以及它的 DAI 驱动程序。

编解码器 DAI 和 PCM(又名 DSP)配置

这一部分相当通用,可能应该被命名为struct snd_soc_dai_driver,因为编解码器上有 DAI,必须使用devm_snd_soc_register_component() API 导出。这个函数还接受一个指向struct snd_soc_component_driver的指针,这是提供的 DAI 驱动程序将绑定并导出的组件驱动程序(实际上,插入到 ASoC 全局组件列表component_list中,该列表在sound/soc/soc-core.c中定义),以便机器驱动程序在注册声卡之前将其注册到核心。该结构涵盖了每个接口的时钟、格式和 ALSA 操作,并在include/sound/soc-dai.h中定义如下:

struct snd_soc_dai_driver {
    /* DAI description */
    const char *name;
    /* DAI driver callbacks */
    int (*probe)(struct snd_soc_dai *dai);
    int (*remove)(struct snd_soc_dai *dai);
    int (*suspend)(struct snd_soc_dai *dai);
    int (*resume)(struct snd_soc_dai *dai);
[...]
    /* ops */
    const struct snd_soc_dai_ops *ops;
    /* DAI capabilities */
    struct snd_soc_pcm_stream capture;
    struct snd_soc_pcm_stream playback;
    unsigned int symmetric_rates:1;
    unsigned int symmetric_channels:1;
    unsigned int symmetric_samplebits:1;
[...]
};

在前面的块中,为了便于阅读,只列举了结构的主要元素。以下是它们的含义:

  • name: 这是 DAI 接口的名称。

  • probe: DAI 驱动程序探测函数,在机器驱动程序探测到该 DAI 驱动程序所属的组件驱动程序时执行(实际上,当机器驱动程序向 ASoC 核心注册卡时)。

  • remove: 当组件驱动程序取消注册时调用。

  • 挂起恢复: 电源管理回调。

  • ops: 指向struct snd_soc_dai_ops结构的指针,该结构提供了配置和控制 DAI 的回调。

  • capture: 指向struct snd_soc_pcm_stream结构,表示音频捕获的硬件参数。该成员描述了在音频捕获过程中支持的通道数、比特率、数据格式等。如果不需要捕获功能,则无需初始化。

  • 播放: 音频播放的硬件参数。该成员描述了在播放过程中支持的通道数、比特率、数据格式等。如果不需要音频播放功能,则无需初始化。

实际上,编解码器和平台驱动程序必须为它们拥有的每个 DAI 注册此结构。这就是使得这一部分变得通用的原因。稍后由机器驱动程序使用它来建立编解码器和 SoC 之间的链接。然而,还有其他数据结构需要一些时间来研究,以了解整个配置是如何完成的:这些是struct snd_soc_pcm_streamstruct snd_soc_dai_ops,在接下来的部分中进行了描述。

DAI 操作

这些操作由struct snd_soc_dai_ops结构的实例抽象出来。该结构包含一组关于 PCM 接口的不同事件的回调(也就是说,在音频传输开始之前,您很可能希望以某种方式准备设备,因此您会将执行此操作的代码放入prepare回调中)或关于 DAI 时钟和格式配置的回调。该结构定义如下:

struct snd_soc_dai_ops {
    int (*set_sysclk)(struct snd_soc_dai *dai, int clk_id,
                      unsigned int freq, int dir);
    int (*set_pll)(struct snd_soc_dai *dai, int pll_id,                    int source,
                   unsigned int freq_in,                    unsigned int freq_out);
    int (*set_clkdiv)(struct snd_soc_dai *dai, int div_id,                       int div);
    int (*set_bclk_ratio)(struct snd_soc_dai *dai,                           unsigned int ratio);
    int (*set_fmt)(struct snd_soc_dai *dai, unsigned int fmt);
    int (*xlate_tdm_slot_mask)(unsigned int slots,
                               unsigned int *tx_mask,                                unsigned int *rx_mask); 
    int (*set_tdm_slot)(struct snd_soc_dai *dai,
                        unsigned int tx_mask,                         unsigned int rx_mask,
                        int slots, int slot_width);
    int (*set_channel_map)(struct snd_soc_dai *dai,
                           unsigned int tx_num,                            unsigned int *tx_slot,
                           unsigned int rx_num,                            unsigned int *rx_slot);
    int (*get_channel_map)(struct snd_soc_dai *dai,
                           unsigned int *tx_num,                            unsigned int *tx_slot,
                           unsigned int *rx_num,                            unsigned int *rx_slot);
    int (*set_tristate)(struct snd_soc_dai *dai, int tristate);
    int (*set_sdw_stream)(struct snd_soc_dai *dai,                           void *stream,
                          int direction);
    int (*digital_mute)(struct snd_soc_dai *dai, int mute);
    int (*mute_stream)(struct snd_soc_dai *dai, int mute,                        int stream);
    int (*startup)(struct snd_pcm_substream *,                    struct snd_soc_dai *);
    void (*shutdown)(struct snd_pcm_substream *,                      struct snd_soc_dai *);
    int (*hw_params)(struct snd_pcm_substream *,
                     struct snd_pcm_hw_params *,                      struct snd_soc_dai *); 
    int (*hw_free)(struct snd_pcm_substream *,                    struct snd_soc_dai *);
    int (*prepare)(struct snd_pcm_substream *,                    struct snd_soc_dai *);
    int (*trigger)(struct snd_pcm_substream *, int,
                   struct snd_soc_dai *);
};

该结构中的回调函数基本上可以分为三类,驱动程序可以根据实际情况实现其中的一些。

第一类是时钟配置回调,通常由机器驱动程序调用。这些回调如下:

  • set_sysclk设置 DAI 的主时钟。如果实现了这个回调,应该从系统或主时钟派生最佳的 DAI 位和帧时钟。机器驱动程序可以在cpu_dai和/或codec_dai上使用snd_soc_dai_set_sysclk() API 来调用这个回调。

  • set_pll设置 PLL 参数。如果实现了这个回调,应该配置并启用 PLL 以根据输入时钟生成输出时钟。机器驱动程序可以在cpu_dai和/或codec_dai上使用snd_soc_dai_set_pll() API 来调用这个回调。

  • set_clkdiv设置时钟分频因子。机器驱动程序调用此回调的 API 是snd_soc_dai_set_clkdiv()

第二个回调类是 DAI 的格式配置回调,通常由机器驱动程序调用。这些回调如下:

  • set_fmt设置 DAI 的格式。机器驱动程序可以使用snd_soc_dai_set_fmt()API 调用此回调(在 CPU 或编解码器 DAI 上,或两者都有)以配置 DAI 硬件音频格式。

  • set_tdm_slot:如果 DAI 支持snd_soc_dai_set_tdm_slot(),则可以配置指定的 DAI 以进行 TDM 操作。

  • set_channel_map:通道 TDM 映射设置。机器驱动程序使用snd_soc_dai_set_channel_map()API 为指定的 DAI 调用此回调。

  • set_tristate:设置 DAI 引脚的状态,在与其他 DAI 并行使用同一引脚时需要。机器驱动程序可以使用snd_soc_dai_set_tristate()API 从机器驱动程序中调用它。

最后一个回调类是通常由 ASoC 核心调用的正常标准前端,用于收集 PCM 校正操作。相关的回调如下:

  • startup:当打开 PCM 子流(例如有人打开捕获/播放设备的设备文件)时,ALSA 会调用此函数。

  • shutdown:此回调应实现在启动期间所做的操作的撤消。

  • hw_params:在设置音频流时调用此函数。struct snd_pcm_hw_params包含音频特性。

  • hw_free:应撤消hw_params中所做的操作。

  • prepare:当 PCM 准备就绪时调用此函数。请参阅以下 PCM 常见状态更改流程,以了解何时调用此回调。根据与特定硬件平台相关的通道、buffer_bytes等设置 DMA 传输参数。

  • trigger:当 PCM 启动、停止和暂停时调用此函数。此回调中的int参数是一个命令,可以是SNDRV_PCM_TRIGGER_STARTSNDRV_PCM_TRIGGER_RESUMESNDRV_PCM_TRIGGER_PAUSE_RELEASE中的一个,根据事件。驱动程序可以使用switch...case来迭代事件。

  • (可选)digital_mute:ASoC 核心调用的防爆音。例如,当系统被挂起时,核心可能会调用它。

为了弄清前述回调如何被核心调用,让我们看一下 PCM 常见状态更改流程:

  1. 首次启动off --> standby --> prepare --> on

  2. 停止on --> prepare --> standby

  3. 恢复standby --> prepare --> on

在前述流程中的每个状态都会调用一个回调。总之,我们可以深入研究硬件配置数据结构,无论是捕获还是播放操作。

捕获和播放硬件配置

在捕获或播放操作期间,应设置 DAI 设置(如通道号)和功能,以允许配置底层 PCM 流。您可以通过为每个操作和每个 DAI 填充一个struct snd_soc_pcm_stream的实例来实现这一点,该结构在编解码器和平台驱动程序中都有定义:

struct snd_soc_pcm_stream {
    const char *stream_name;
    u64 formats;
    unsigned int rates; 
    unsigned int rate_min;
    unsigned int rate_max;
    unsigned int channels_min;
    unsigned int channels_max;
    unsigned int sig_bits;
};

该结构的主要成员可以描述如下:

  • stream_name:流的名称,可以是"Playback""Capture"

  • formats:一组支持的数据格式(有效值在include/sound/pcm.h中以SNDRV_PCM_FMTBIT_为前缀定义),例如SNDRV_PCM_FMTBIT_S16_LESNDRV_PCM_FMTBIT_S24_LE。如果支持多种格式,则可以组合每种格式,例如SNDRV_PCM_FMTBIT_S16_LE | SNDRV_PCM_FMTBIT_S20_3LE

  • rates:一组支持的采样率(以SNDRV_PCM_RATE_为前缀,所有有效值均在include/sound/pcm.h中定义),例如SNDRV_PCM_RATE_44100SNDRV_PCM_RATE_48000。如果支持多个采样率,则可以增加每个采样率,例如SNDRV_PCM_RATE_48000 | SNDRV_PCM_RATE_88200

  • rate_min:支持的最小采样率。

  • rate_max:支持的最大采样率。

  • channels_min:支持的最小通道数。

控制的概念

编解码器驱动程序通常会公开一些可以从用户空间更改的编解码器属性。这些是编解码器控件。当初始化编解码器时,所有定义的音频控件都会注册到 ALSA 核心。音频控件的结构是include/sound/control.h中定义的struct snd_kcontrol_new

除了 DAI 总线外,编解码器设备通常配备有一个控制总线,大多数情况下是 I2C 或 SPI 总线。为了不让每个编解码器驱动程序都去实现其控制访问例程,编解码器控制 I/O 已经被标准化。这就是 regmap API 的起源。您可以使用 regmap 来抽象控制接口,以便编解码器驱动程序不必担心当前的控制方法是什么。音频编解码器前端是在soc/soc-io.c中实现的。这依赖于已经讨论过的 regmap API,在第二章中,利用 Regmap API 简化代码

然后,编解码器驱动程序需要提供读取和写入接口,以便访问底层编解码器寄存器。这些回调需要在编解码器组件驱动程序的.read.write字段中设置,即struct snd_soc_component_driver。以下是可用于访问组件寄存器的高级 API:

int snd_soc_component_write(struct                             snd_soc_component *component,
                            unsigned int reg, unsigned int val)
int snd_soc_component_read(struct snd_soc_component *component,
                           unsigned int reg,                            unsigned int *val)
int snd_soc_component_update_bits(struct                                   snd_soc_component *component, 
                                  unsigned int reg,                                   unsigned int mask,                                   unsigned int val)
int snd_soc_component_test_bits(struct                                 snd_soc_component *component, 
                                unsigned int reg,   
                                unsigned int mask,       
                                unsigned int value)

前面的每个辅助程序都是自描述的。在深入控制实现之前,请注意控制框架由几种类型组成:

  • 一个简单的开关控制,即寄存器中的单个逻辑值

  • 立体声控制 - 是前述简单开关控制的立体声版本,在寄存器中同时控制两个逻辑值

  • 混音控制 - 是多个简单控制的组合,其输出是其输入的混合

  • MUX 控制 - 与前述混音控制相同,但在多个控件中选择一个

在 ALSA 中,控件通过struct snd_kcontrol_new结构进行抽象,定义如下:

struct snd_kcontrol_new {
    snd_ctl_elem_iface_t iface;
    unsigned int device;
    unsigned int subdevice;	
    const unsigned char *name;
    unsigned int index;
    unsigned int access;
    unsigned int count;
    snd_kcontrol_info_t *info;
    snd_kcontrol_get_t *get;
    snd_kcontrol_put_t *put;
    union {
        snd_kcontrol_tlv_rw_t *c;
        const unsigned int *p;
    } tlv;
    [...]
};

上述数据结构中字段的描述如下:

  • iface字段指定控制类型。它是snd_ctl_elem_iface_t类型,是SNDRV_CTL_ELEM_IFACE_XXX的枚举,其中XXX可以是MIXERPCM等。可能的值列表可以在这里找到:elixir.bootlin.com/linux/v4.19/source/include/uapi/sound/asound.h#L848。如果控制与声卡上的特定设备密切相关,可以使用HWDEPPCMRAWMIDITIMERSEQUENCER,并使用设备和子设备(即设备中的子流)字段指定设备号。

  • name是控制的名称。该字段具有重要作用,允许按名称对控件进行分类。ALSA 已经在某种程度上标准化了一些控件名称,我们将在控件命名约定部分详细讨论。

  • index字段用于保存卡上控件的数量。如果声卡上有多个编解码器,并且每个编解码器都有相同名称的控制,则可以通过index来区分这些控制。当index为 0 时,可以忽略这种区分策略。

  • access包含控制的访问权限,格式为SNDRV_CTL_ELEM_ACCESS_XXX。每个位表示一个访问类型,可以与多个 OR 操作组合。XXX可以是READWRITEVOLATILE等。可能的位掩码可以在这里找到:elixir.bootlin.com/linux/v4.19/source/include/uapi/sound/asound.h#L858

  • get是用于读取控制的当前值并将其返回到用户空间应用程序的回调函数。

  • put是用于将应用程序的控制值设置为控制的回调函数。

  • info回调函数用于获取有关控件的详细信息。

  • tlv字段为控件提供元数据。

控件命名约定

ALSA 期望以某种方式命名控件。为了实现这一点,ALSA 预定义了一些常用的源(如 Master、PCM、CD、Line 等)、方向(表示控件的数据流动,如 Playback、Capture、Bypass、Bypass Capture 等)和功能(根据控件的功能,如 Switch、Volume、Route 等)。请注意,如果没有方向的定义,这意味着控件是双向的(播放和捕获)。

您可以参考以下链接,了解有关 ALSA 控件命名的更多详细信息:www.kernel.org/doc/html/v4.19/sound/designs/control-names.html

控件元数据

有些混音控件需要在DECLARE_TLV_xxx宏中提供信息,以定义包含此信息的一些变量,然后将控件tlv.p字段指向这些变量,并最后将SNDRV_CTL_ELEM_ACCESS_TLV_READ标志添加到访问字段中。

DECLARE_TLV_DB_SCALE将定义有关混音控件的信息,其中控件值的每一步更改都会以恒定的 dB 量改变。让我们看下面的例子:

static DECLARE_TLV_DB_SCALE(db_scale_my_control, -4050, 150,                             0);

根据include/sound/tlv.h中此宏的定义,上述示例可以扩展为以下内容:

static struct snd_kcontrol_new my_control devinitdata = {
    [...]
    .access =
     SNDRV_CTL_ELEM_ACCESS_READWRITE |      SNDRV_CTL_ELEM_ACCESS_TLV_READ,
    [...]
    .tlv.p = db_scale_my_control,
};

宏的第一个参数表示要定义的变量的名称;第二个表示此控件可以接受的最小值,以0.01 dB 为单位。第三个参数是更改的步长,也是以0.01 dB 的步长。如果在控件处于最小值时执行静音操作,则需要将第四个参数设置为1。请查看include/sound/tlv.h以查看可用的宏。

在声卡注册时,将调用snd_ctl_dev_register()函数,以保存有关控制设备的相关信息,并使其对用户可用。

定义 kcontrols

kcontrols 由 ASoC 核心用于向用户空间导出音频控件(如开关、音量、*MUX…)。这意味着,例如,当用户空间应用程序(如 PulseAudio)在未插入耳机时关闭耳机或打开扬声器时,该操作由 kcontrols 在内核中处理。普通的 kcontrols 不涉及电源管理(DAPM)。它们专门用于控制非基于电源管理的元素,如音量级别、增益级别等。一旦使用适当的宏设置了控件,必须使用snd_soc_add_component_controls()方法将其注册到系统控件列表中,其原型如下:

int snd_soc_add_component_controls(
                       struct snd_soc_component *component,
                       const struct snd_kcontrol_new *controls, 
                       unsigned int num_controls);

在上述原型中,component是您为其添加控件的组件,controls是要添加的控件数组,num_controls是需要添加的数组中的条目数。

为了了解这个 API 有多简单,让我们考虑以下示例,定义一些控件:

static const DECLARE_TLV_DB_SCALE(dac_tlv, -12750, 50, 1);
static const DECLARE_TLV_DB_SCALE(out_tlv, -12100, 100, 1);
static const DECLARE_TLV_DB_SCALE(bypass_tlv, -2100, 300, 0);
static const struct snd_kcontrol_new wm8960_snd_controls[] = {
    [...]
    SOC_DOUBLE_R_TLV("Playback Volume", WM8960_LDAC,                      WM8960_RDAC, 0,
                     255, 0, dac_tlv),
    SOC_DOUBLE_R_TLV("Headphone Playback Volume", WM8960_LOUT1,
                      WM8960_ROUT1, 0, 127, 0, out_tlv),
    SOC_DOUBLE_R("Headphone Playback ZC Switch", WM8960_LOUT1,
                      WM8960_ROUT1, 7, 1, 0),
    SOC_DOUBLE_R_TLV("Speaker Playback Volume", WM8960_LOUT2, 
                      WM8960_ROUT2, 0, 127, 0, out_tlv),
    SOC_DOUBLE_R("Speaker Playback ZC Switch", WM8960_LOUT2, 
                      WM8960_ROUT2, 7, 1, 0),
    SOC_SINGLE("Speaker DC Volume", WM8960_CLASSD3, 3, 5, 0),
    SOC_SINGLE("Speaker AC Volume", WM8960_CLASSD3, 0, 5, 0),
    SOC_ENUM("DAC Polarity", wm8960_enum[1]),
    SOC_SINGLE_BOOL_EXT("DAC Deemphasis Switch", 0,                         wm8960_get_deemph, 
                        wm8960_put_deemph),
    [...]
    SOC_SINGLE("Noise Gate Threshold", WM8960_NOISEG, 3, 31, 0),
    SOC_SINGLE("Noise Gate Switch", WM8960_NOISEG, 0, 1, 0),
    SOC_DOUBLE_R_TLV("ADC PCM Capture Volume", WM8960_LADC,
                      WM8960_RADC, 0, 255, 0, adc_tlv),
    SOC_SINGLE_TLV("Left Output Mixer Boost Bypass Volume",
                      WM8960_BYPASS1, 4, 7, 1, bypass_tlv),
};

注册前述控件的相应代码如下:

snd_soc_add_component_controls(component, wm8960_snd_controls,
                              ARRAY_SIZE(wm8960_snd_controls));

以下是使用这些预设宏定义常用控件的方法。

SOC_SINGLE(xname, reg, shift, max, invert)

要设置一个简单的开关,我们可以使用SOC_SINGLE。这是最简单的控件:

#define SOC_SINGLE(xname, reg, shift, max, invert) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_volsw, .get = snd_soc_get_volsw,\
   .put = snd_soc_put_volsw, \
   .private_value = SOC_SINGLE_VALUE(reg, shift, max, invert) }

这种类型的控件只有一个设置,通常用于组件开关。由宏定义的参数描述如下:

  • xname:控制的名称。

  • reg:与控件对应的寄存器地址。

  • Shift:在寄存器reg中控制位的偏移量(从哪里应用更改)。

  • max:控件设置的值范围。一般来说,如果控制位只有1位,则max=1,因为可能的值只有01

  • invert:设置值是否被反转。

让我们来学习以下示例:

SOC_SINGLE("PCM Playback -6dB Switch", WM8960_DACCTL1, 7, 1,            0),

在前面的示例中,PCM Playback -6dB Switch是控制的名称。WM8960_DACCTL1(在wm8960.h中定义)是在编解码器(WM8960 芯片)中的寄存器的地址,允许您控制此开关:

  • 7表示DACCTL1寄存器中的第7位用于启用/禁用 6dB 衰减的 DAC。

  • 1表示只有一个启用或禁用选项。

  • 0表示您设置的值没有被反转。

SOC_SINGLE_TLV(xname,reg,shift,max,invert,tlv_array)

这个宏设置了一个带有级别的开关。它是SOC_SINGLE的扩展,用于定义具有增益控制的控件,例如音量控件,EQ 均衡器等。在这个例子中,左输入音量控制范围从 000000(-17.25 dB)到 111111(+30 dB)。每个步骤为0.75 dB,意味着总共63个步骤:

SOC_SINGLE_TLV("Input Volume of LINPUT1",
               WM8960_LINVOL, 0, 63, 0, in_tlv),

in_tlv的刻度(代表控制元数据)声明如下:

static const DECLARE_TLV_DB_SCALE(in_tlv, -1725, 75, 0);

在上述示例中,-1725表示控制刻度从-17.25dB开始。75表示每个步骤为0.75dB0表示步骤从 0 开始。对于一些音量控制情况,第一步是“静音”,步骤从1开始。因此,上述代码中的0应替换为1

SOC_DOUBLE_R(xname,reg_left,reg_right,xshift,xmax,xinvert)

SOC_DOUBLE_RSOC_SINGLE的立体声版本。不同之处在于SOC_SINGLE只控制一个变量,而SOC_DOUBLE可以同时控制一个寄存器中的两个相似变量。我们可以使用这个来同时控制左右声道。

由于有一个额外的通道,参数具有相应的移位值。以下是一个示例:

SOC_DOUBLE_R("Headphone ZC Switch", WM8960_LOUT1,
             WM8960_ROUT1, 7, 1, 0),

SOC_DOUBLE_R_TLV(xname,reg_left,reg_right,xshift,xmax,xinvert,tlv_array)

SOC_DOUBLE_R_TLVSOC_SINGLE_TLV的立体声版本。以下是其用法示例:

SOC_DOUBLE_R_TLV("PCM DAC Playback Volume", WM8960_LDAC,
                 WM8960_RDAC, 0, 255, 0, dac_tlv),

混音控制

混音控制用于路由音频通道的控制。它由多个输入和一个输出组成。多个输入可以自由混合在一起形成混合输出:

static const struct snd_kcontrol_new left_speaker_mixer[] = {
    SOC_SINGLE("Input Switch", WM8993_SPEAKER_MIXER, 7, 1, 0),
    SOC_SINGLE("IN1LP Switch", WM8993_SPEAKER_MIXER, 5, 1, 0),
    SOC_SINGLE("Output Switch", WM8993_SPEAKER_MIXER, 3, 1, 0),
    SOC_SINGLE("DAC Switch", WM8993_SPEAKER_MIXER, 6, 1, 0),
};

前述混音使用WM8993_SPEAKER_MIXER寄存器的第三、第五、第六和第七位来控制四个输入的打开和关闭。

SOC_ENUM_SINGLE(xreg,xshift,xmax,xtexts)

这个宏定义了一个单个枚举控制,其中xreg是要修改以应用设置的寄存器,xshift是寄存器中的控制位偏移,xmask是控制位大小,xtexts是指向描述每个设置的字符串数组的指针。当控制选项是一些文本时使用。

例如,我们可以设置文本数组如下:

static const char  *aif_text[] = { 
    "Left" , "Right"
};

然后定义枚举如下:

static const struct	soc_enum aifinl_enum =
    SOC_ENUM_SINGLE(WM8993_AUDIO_INTERFACE_2, 15, 2, aif_text);

现在我们已经了解了控件的概念,它们用于更改音频设备的属性,我们将学习如何利用它并玩转音频设备的功率属性。

DAPM 的概念

现代声卡由许多独立的离散组件组成。每个组件都有可以独立供电的功能单元。问题是,嵌入式系统大部分时间都是由电池供电的,并且需要最低功耗模式。手动管理电源域依赖可能会很繁琐且容易出错。动态音频功率管理DAPM)旨在在音频子系统中始终以最低功耗使用电源。DAPM 用于需要电源控制并且如果不需要电源管理则可以跳过的事物。只有当事物与电源有关时,才会进入 DAPM - 也就是说,如果它们是需要电源控制的事物,或者如果它们控制了音频通过芯片的路由(因此让核心决定哪些芯片部分需要通电)。

DAPM 位于 ASoC 核心中(这意味着电源切换是从内核内部完成的),并且在音频流/路径/设置发生变化时变得活跃,使其对所有用户空间应用程序完全透明。

在前面的部分中,我们介绍了控件的概念以及如何处理它们。然而,单独的 kcontrols 并不涉及音频电源管理。普通的 kcontrol 具有以下特征:

  • 自描述,无法描述每个 kcontrol 之间的连接关系。

  • 缺乏电源管理机制。

  • 缺乏响应音频事件的时间处理机制,例如播放、停止、开机和关机。

  • 缺乏防止啪啪声的机制,因此用户程序需要注意每个 kcontrol 的开机和关机顺序。

  • 手动,因为所有涉及音频路径的控件不能自动关闭。当音频路径不再有效时,需要用户空间干预。

DAPM 引入了小部件的概念,以解决上述问题。小部件是 DAPM 的基本单元。因此,所谓的小部件可以理解为对 kcontrols 的进一步升级和封装。

小部件是 kcontrols 和动态电源管理的组合,还具有音频路径的链接功能。它可以与相邻小部件建立动态连接关系。

DAPM 框架通过struct snd_soc_dapm_widget结构来抽象小部件,该结构在include/sound/soc-dapm.h中定义如下:

struct snd_soc_dapm_widget {
    enum snd_soc_dapm_type id;
    const char *name;
    const char *sname;
[...]
    /* dapm control */
    int reg;	/* negative reg = no direct dapm */
    unsigned char shift;
    unsigned int mask;
    unsigned int on_val;
    unsigned int off_val;
[...]
    int (*power_check)(struct snd_soc_dapm_widget *w);
    /* external events */
    unsigned short event_flags;
    int (*event)(struct snd_soc_dapm_widget*,
                 struct snd_kcontrol *, int);
    /* kcontrols that relate to this widget */
    int num_kcontrols;
    const struct snd_kcontrol_new *kcontrol_news;
    struct snd_kcontrol **kcontrols;
    struct snd_soc_dobj dobj;
    /* widget input and output edges */
    struct list_head edges[2];
    /* used during DAPM updates */
    struct list_head dirty;
[...]
}

为了便于阅读,上述片段中仅列出了相关字段,以下是它们的描述:

  • idenum snd_soc_dapm_type类型,表示小部件的类型,例如snd_soc_dapm_outputsnd_soc_dapm_mixer等。完整列表在include/sound/soc-dapm.h中定义。

  • name是小部件的名称。

  • shiftmask用于控制小部件的电源状态,对应于寄存器地址reg

  • on_valoff_val值表示用于改变小部件当前电源状态的值。它们分别对应于开启时和关闭时。

  • event表示 DAPM 事件处理回调函数指针。每个小部件都与一个 kcontrol 对象相关联,由**kcontrols指向。

  • *kcontrol_news是此 kcontrol 包含的控件数组,num_kcontrols是其中的条目数。这三个字段用于描述包含在小部件中的 kcontrol 控件,例如混音控件或 MUX 控件。

  • dirty用于在小部件状态改变时将该小部件插入脏列表中。然后扫描该脏列表以执行整个路径的更新。

定义小部件

与普通的 kcontrol 一样,DAPM 框架为我们提供了大量的辅助宏来定义各种小部件控件。这些宏定义可以根据小部件的类型和它们所在的领域分成几个字段。它们如下:

  • VREFVMID;它们提供参考电压小部件。这些小部件通常在编解码器探测/移除回调中进行控制。

  • 平台/机器领域:这些小部件通常是平台或板(实际上是机器)的输入/输出接口,需要进行物理连接,例如耳机、扬声器和麦克风。也就是说,由于这些接口在每个板上可能不同,它们通常由机器驱动程序进行配置,并响应异步事件,例如插入耳机时。它们也可以被用户空间应用程序控制以某种方式打开和关闭。

  • alsamixeramixer

  • aplayarecord

所有 DAPM 电源切换决策都是根据特定于机器的音频路由图自动进行的,该图由每个音频组件(包括内部编解码器组件)之间的互连组成。

编解码器领域定义

DAPM 框架仅为该领域提供了一个宏:

/* codec domain */
#define SND_SOC_DAPM_VMID(wname) \
    .id = snd_soc_dapm_vmid, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0}

定义平台领域小部件

平台域的小部件分别对应信号发生器、输入引脚、输出引脚、麦克风、耳机、扬声器和线路输入接口。DAPM 框架为平台域小部件提供了许多辅助定义宏。这些定义如下:

#define SND_SOC_DAPM_SIGGEN(wname) \
{   .id = snd_soc_dapm_siggen, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM }
#define SND_SOC_DAPM_SINK(wname) \
{   .id = snd_soc_dapm_sink, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM }
#define SND_SOC_DAPM_INPUT(wname) \
{   .id = snd_soc_dapm_input, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM }
#define SND_SOC_DAPM_OUTPUT(wname) \
{   .id = snd_soc_dapm_output, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM }
#define SND_SOC_DAPM_MIC(wname, wevent) \
{   .id = snd_soc_dapm_mic, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM, .event = wevent, \
    .event_flags = SND_SOC_DAPM_PRE_PMU |      SND_SOC_DAPM_POST_PMD}
#define SND_SOC_DAPM_HP(wname, wevent) \
{   .id = snd_soc_dapm_hp, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM, .event = wevent, \
    .event_flags = SND_SOC_DAPM_POST_PMU |      SND_SOC_DAPM_PRE_PMD}
#define SND_SOC_DAPM_SPK(wname, wevent) \
{   .id = snd_soc_dapm_spk, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM, .event = wevent, \
    .event_flags = SND_SOC_DAPM_POST_PMU |      SND_SOC_DAPM_PRE_PMD}
#define SND_SOC_DAPM_LINE(wname, wevent) \
{   .id = snd_soc_dapm_line, .name = wname,     .kcontrol_news = NULL, \
    .num_kcontrols = 0, .reg = SND_SOC_NOPM, .event = wevent, \
    .event_flags = SND_SOC_DAPM_POST_PMU |      SND_SOC_DAPM_PRE_PMD}
#define SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert) \
    .reg = wreg, .mask = 1, .shift = wshift, \
    .on_val = winvert ? 0 : 1, .off_val = winvert ? 1 : 0

在前面的代码中,这些宏中的大多数字段是通用的。reg字段设置为SND_SOC_NOPM(定义为-1)的事实意味着这些小部件没有寄存器控制位来控制小部件的电源状态。SND_SOC_DAPM_INPUTSND_SOC_DAPM_OUTPUT用于从编解码器驱动程序内部定义编解码器芯片的输出和输入引脚。从我们可以看到,MICHPSPKLINE小部件响应SND_SOC_DAPM_POST_PMU(小部件上电后)和SND_SOC_DAPM_PMD(小部件下电前)事件,这些小部件通常在机器驱动程序中定义。

定义音频路径域小部件

这种类型的小部件通常重新打包普通的 kcontrols,并使用音频路径和电源管理功能进行扩展。这种扩展在某种程度上使这种小部件具有 DAPM 意识。该域中的小部件将包含一个或多个不是普通 kcontrols 的 kcontrols。这些是启用了 DAPM 的 kcontrols。这些不能使用标准方法进行定义,即SOC_*-based 宏控件。它们需要使用 DAPM 框架提供的定义宏进行定义。我们将在后面的定义 DAPM kcontrols部分详细讨论它们。然而,这里是这些小部件的定义宏:

#define SND_SOC_DAPM_PGA(wname, wreg, wshift, winvert,\ 
                         wcontrols, wncontrols) \
{   .id = snd_soc_dapm_pga, .name = wname, \
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = wncontrols}
#define SND_SOC_DAPM_OUT_DRV(wname, wreg, wshift, winvert,\
                               wcontrols, wncontrols) \
{   .id = snd_soc_dapm_out_drv, .name = wname, \ 
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = wncontrols}
#define SND_SOC_DAPM_MIXER(wname, wreg, wshift, winvert, \
                              wcontrols, wncontrols)\
{   .id = snd_soc_dapm_mixer, .name = wname, \ 
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = wncontrols}
#define SND_SOC_DAPM_MIXER_NAMED_CTL(wname, wreg,                                      wshift, winvert, \
                                     wcontrols, wncontrols)\
{   .id = snd_soc_dapm_mixer_named_ctl, .name = wname, \
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = wncontrols}
#define SND_SOC_DAPM_SWITCH(wname, wreg, wshift, winvert, wcontrols) \
{   .id = snd_soc_dapm_switch, .name = wname, \ 
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = 1}
#define SND_SOC_DAPM_MUX(wname, wreg, wshift,                          winvert, wcontrols) \
{   .id = snd_soc_dapm_mux, .name = wname, \
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = 1}
#define SND_SOC_DAPM_DEMUX(wname, wreg, wshift,                            winvert, wcontrols) \
{   .id = snd_soc_dapm_demux, .name = wname, \ 
    SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
    .kcontrol_news = wcontrols, .num_kcontrols = 1}

与平台和编解码器域小部件不同,需要分配regshift字段,表明这些小部件具有相应的电源控制寄存器。DAPM 框架使用这些寄存器来在扫描和更新音频路径时控制小部件的电源状态。它们的电源状态是动态分配的,在需要时上电(在有效的音频路径上),在不需要时下电(在非活动的音频路径上)。这些小部件需要执行与前面介绍的混音器、MUX 等相同的功能。实际上,这是由它们包含的 kcontrol 控件来完成的。驱动程序代码必须在定义小部件之前定义 kcontrols,然后将wcontrolsnum_kcontrols参数传递给这些辅助定义宏。

存在另一种宏的变体,它具有指向事件处理程序的指针。这些宏具有_E后缀。它们是SND_SOC_DAPM_PGA_ESND_SOC_DAPM_OUT_DRV_ESND_SOC_DAPM_MIXER_ESND_SOC_DAPM_MIXER_NAMED_CTL_ESND_SOC_DAPM_SWITCH_ESND_SOC_DAPM_MUX_ESND_SOC_DAPM_VIRT_MUX_E。鼓励您查看内核源代码,以查看它们在elixir.bootlin.com/linux/v4.19/source/include/sound/soc-dapm.h#L136中的定义。

定义音频流域

这些小部件主要包括音频输入/输出接口、ADC/DAC 和时钟线。从音频接口小部件开始,它们如下:

#define SND_SOC_DAPM_AIF_IN(wname, stname, wslot, wreg, wshift, winvert) \
{  .id = snd_soc_dapm_aif_in, .name = wname, .sname = stname, \
   SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), }
#define SND_SOC_DAPM_AIF_IN_E(wname, stname, wslot, wreg, \
                             wshift, winvert, wevent, wflags) \
{  .id = snd_soc_dapm_aif_in, .name = wname, .sname = stname, \
   SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
   .event = wevent, .event_flags = wflags }
#define SND_SOC_DAPM_AIF_OUT(wname, stname, wslot, wreg, wshift, winvert) \
{ .id = snd_soc_dapm_aif_out, .name = wname, .sname = stname, \ 
     SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), }
#define SND_SOC_DAPM_AIF_OUT_E(wname, stname, wslot, wreg, \
                             wshift, winvert, wevent, wflags) \
{ .id = snd_soc_dapm_aif_out, .name = wname, .sname = stname, \
     SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
     .event = wevent, .event_flags = wflags }

在前面的宏定义列表中,SND_SOC_DAPM_AIF_INSND_SOC_DAPM_AIF_OUT分别是音频接口输入和输出。前者定义了连接到接收要传递到 DAC 的音频的主机的连接,后者定义了连接到从 ADC 接收的音频传输到主机的连接。SND_SOC_DAPM_AIF_IN_ESND_SOC_DAPM_AIF_OUT_E是它们各自的事件变体,允许在wflags中启用的事件发生时调用wevent

现在是 ADC/DAC 相关的小部件,以及与时钟相关的小部件,定义如下:

#define SND_SOC_DAPM_DAC(wname, stname, wreg,                          wshift, winvert) \
{    .id = snd_soc_dapm_dac, .name = wname, .sname = stname, \ 
     SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert) }
#define SND_SOC_DAPM_DAC_E(wname, stname, wreg, wshift, \
                           winvert, wevent, wflags) \
{    .id = snd_soc_dapm_dac, .name = wname, .sname = stname, \ 
     SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
     .event = wevent, .event_flags = wflags}
#define SND_SOC_DAPM_ADC(wname, stname, wreg,                          wshift, winvert) \
{    .id = snd_soc_dapm_adc, .name = wname, .sname = stname, \ 
     SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), }
#define SND_SOC_DAPM_ADC_E(wname, stname, wreg, wshift,\
                           winvert, wevent, wflags) \
{    .id = snd_soc_dapm_adc, .name = wname, .sname = stname, \ 
     SND_SOC_DAPM_INIT_REG_VAL(wreg, wshift, winvert), \
     .event = wevent, .event_flags = wflags}
#define SND_SOC_DAPM_CLOCK_SUPPLY(wname) \
{    .id = snd_soc_dapm_clock_supply, .name = wname, \
    .reg = SND_SOC_NOPM, .event = dapm_clock_event, \
    .event_flags = SND_SOC_DAPM_PRE_PMU | SND_SOC_DAPM_POST_PMD }

在前面的宏列表中,SND_SOC_DAPM_ADCSND_SOC_DAPM_DAC分别是 ADC 和 DAC 小部件。前者用于根据需要控制 ADC 的开启和关闭,而后者则针对 DAC。前者通常与设备上的捕获流相关联,例如“左捕获”或“右捕获”,而后者通常与播放流相关联,例如“左播放”或“右播放”。寄存器设置定义了一个单一的寄存器和位位置,翻转时将打开或关闭 ADC/DAC。您还应该注意它们的事件变体,分别是SND_SOC_DAPM_ADC_ESND_SOC_DAPM_DAC_ESND_SOC_DAPM_CLOCK_SUPPLY是连接到时钟框架的供应小部件变体。

还有其他小部件类型,没有提供定义宏,并且不属于我们迄今介绍的任何域。这些是snd_soc_dapm_dai_insnd_soc_dapm_dai_outsnd_soc_dapm_dai_link

这些小部件是在 DAI 注册时隐式创建的,无论是来自 CPU 还是编解码器驱动程序。换句话说,每当注册一个 DAI 时,DAPM 核心将根据注册的 DAI 流创建一个snd_soc_dapm_dai_insnd_soc_dapm_dai_out类型的小部件。通常,这两个小部件将连接到编解码器中具有相同流名称的小部件。此外,当机器驱动程序决定将编解码器和 CPU DAI 绑定在一起时,DAPM 框架将创建一个snd_soc_dapm_dai_link类型的小部件来描述连接的电源状态。

路径的概念-小部件之间的连接

小部件应该相互连接,以构建功能性的音频流路径。也就是说,需要跟踪两个小部件之间的连接,以维护音频状态。为了描述两个小部件之间的路径,DAPM 核心使用了struct snd_soc_dapm_path数据结构,定义如下:

/* dapm audio path between two widgets */
struct snd_soc_dapm_path {
    const char *name;
    /*
     * source (input) and sink (output) widgets
     * The union is for convenience,      * since it is a lot nicer to type
     * p->source, rather than p->node[SND_SOC_DAPM_DIR_IN]
     */
    union {
        struct {
            struct snd_soc_dapm_widget *source;
            struct snd_soc_dapm_widget *sink;
        };
        struct snd_soc_dapm_widget *node[2];
    };
    /* status */
    u32 connect:1; /* source and sink widgets are connected */
    u32 walking:1; /* path is in the process of being walked */
    u32 weak:1; /* path ignored for power management */
    u32 is_supply:1;  /* At least one of the connected widgets                        is a supply */
    int (*connected)(struct snd_soc_dapm_widget *source, struct  
                     snd_soc_dapm_widget *sink);
    struct list_head list_node[2];
    struct list_head list_kcontrol;
    struct list_head list;
};

这个结构抽象了两个小部件之间的连接。它的source字段指向连接的起始小部件,而sink字段指向连接的到达小部件。小部件的输入和输出(即端点)可以连接到多个路径。所有输入的snd_soc_dapm_path结构都通过list_node[SND_SOC_DAPM_DIR_IN]字段挂在小部件的源列表中,而所有输出的snd_soc_dapm_path结构都存储在小部件的接收列表中,即list_node[SND_SOC_DAPM_DIR_OUT]。连接从源到接收端,原则非常简单。只需记住连接路径是这样的:起始小部件的输出-->路径数据结构的输入路径数据结构的输出-->到达端小部件的输入

list字段将在声卡注册时出现在声卡路径列表头字段中。此列表允许声卡跟踪所有可用的路径。最后,connected字段用于让您实现自己的自定义方法来检查路径的当前连接状态。

重要说明

SND_SOC_DAPM_DIR_INSND_SOC_DAPM_DIR_OUT分别是枚举器01

您可能永远不想直接处理路径。然而,出于教学目的,这里介绍了这个概念,因为它将帮助我们理解下一节。

路由的概念-小部件之间的连接

在本章前面介绍的路径的概念是对这个概念的引入。从前面的讨论中,我们可以介绍路由的概念。路由连接至少由起始小部件、跳线路径、接收小部件组成,在 DAPM 中使用struct snd_soc_dapm_route结构来描述这样的连接:

struct snd_soc_dapm_route {
    const char *sink;
    const char *control;
    const char *source;
    /* Note: currently only supported for links where source is
     a supply */
    int (*connected)(struct snd_soc_dapm_widget *source,
                     struct snd_soc_dapm_widget *sink);
};

在前面的数据结构中,sink指向到达小部件的名称字符串,source指向起始小部件的名称字符串,control指向负责控制连接的 kcontrol 名称字符串,connected定义了自定义连接检查回调。这个结构的含义是显而易见的:source通过kcontrol连接到sink,并且可以调用connected回调函数来检查连接状态。

路由应使用以下方案定义:

{Destination Widget, Switch, Source Widget},

这意味着源小部件通过开关连接到目标小部件。这样,DAPM 核心将负责在连接需要被激活时关闭开关,并且源和目标小部件也将被打开。有时,连接可能是直接的。在这种情况下,开关应为NULL。然后,您将得到以下内容:

{end point, NULL, starting point},

您应直接使用名称字符串来描述连接关系,所有定义的路由,最后,您必须注册到 DAPM 核心。DAPM 核心将根据这些名称找到相应的小部件,并动态生成所需的snd_soc_dapm_path来描述两个小部件之间的连接。在接下来的章节中,我们将看到如何创建路由。

定义 DAPM kcontrols

如前几节所述,音频路径域中的混音器或 MUX 类型小部件由几个 kcontrols 组成,必须使用基于 DAPM 的宏进行定义。DAPM 使用这些 kcontrols 来完成音频路径。但是,对于小部件来说,这项任务不仅仅是如此。DAPM 还动态管理这些音频路径的连接关系,以便根据这些连接关系来控制这些小部件的电源状态。如果以通常的方式定义这些 kcontrols,这是不可能的,因此 DAPM 为我们提供了另一组定义宏,用于定义包含在小部件中的 kcontrols:

#define SOC_DAPM_SINGLE(xname, reg, shift, max, invert) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_volsw, \
   .get = snd_soc_dapm_get_volsw,    .put = snd_soc_dapm_put_volsw, \
   .private_value = SOC_SINGLE_VALUE(reg, shift, max, invert) } 
#define SOC_DAPM_SINGLE_TLV(xname, reg, shift, max, invert,                             tlv_array) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_volsw, \
   .access = SNDRV_CTL_ELEM_ACCESS_TLV_READ | \
             SNDRV_CTL_ELEM_ACCESS_READWRITE, \
   .tlv.p = (tlv_array), \
   .get = snd_soc_dapm_get_volsw,  
   .put = snd_soc_dapm_put_volsw, \
   .private_value = SOC_SINGLE_VALUE(reg, shift, max, invert) } 
#define SOC_DAPM_ENUM(xname, xenum) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_enum_double, \
   .get = snd_soc_dapm_get_enum_double, \
   .put = snd_soc_dapm_put_enum_double, \
   .private_value = (unsigned long)&xenum}
#define SOC_DAPM_ENUM_VIRT(xname, xenum) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_enum_double, \
   .get = snd_soc_dapm_get_enum_virt, \
   .put = snd_soc_dapm_put_enum_virt, \
   .private_value = (unsigned long)&xenum} 
#define SOC_DAPM_ENUM_EXT(xname, xenum, xget, xput) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_enum_double, \
   .get = xget, \
   .put = xput, \
   .private_value = (unsigned long)&xenum }
#define SOC_DAPM_VALUE_ENUM(xname, xenum) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER, .name = xname, \
   .info = snd_soc_info_enum_double, \
   .get = snd_soc_dapm_get_value_enum_double, \
   .put = snd_soc_dapm_put_value_enum_double, \
   .private_value = (unsigned long)&xenum }
#define SOC_DAPM_PIN_SWITCH(xname) \
{  .iface = SNDRV_CTL_ELEM_IFACE_MIXER,    .name = xname " Switch" , \
   .info = snd_soc_dapm_info_pin_switch, \
   .get = snd_soc_dapm_get_pin_switch, \
   .put = snd_soc_dapm_put_pin_switch, \
   .private_value = (unsigned long)xname }

可以看到,SOC_DAPM_SINGLE是标准控制的 DAPM 等效物,SOC_DAPM_SINGLE_TLV对应于SOC_SINGLE_TLV,依此类推。与普通的 kcontrols 相比,DAPM 的 kcontrols 只是替换了infogetput回调函数。DAPM kcontrols 提供的put回调函数不仅更新控件本身的状态,还将此更改传递给相邻的 DAPM kcontrol。相邻的 DAPM kcontrol 将此更改传递给自己的相邻 DAPM kcontrol,通过更改其中一个小部件的连接状态,知道音频路径的末端,与其关联的所有小部件都将被扫描和测试,以查看它们是否仍然处于活动音频路径中,从而动态地改变它们的电源状态。这就是 DAPM 的本质。

创建小部件和路由

前面的部分介绍了许多辅助宏。但是,这是理论性的,没有解释如何为实际系统定义所需的小部件,也没有解释如何定义小部件的连接关系。在这里,我们以 Wolfson 的编解码器芯片WM8960为例来理解这个过程:

图 5.3 - WM8960 内部音频路径和控件

图 5.3 - WM8960 内部音频路径和控件

以前面的图示为例,从 Wolfson WM8960 编解码器芯片开始,第一步是使用辅助宏来定义小部件所需的 DAPM kcontrol:

static const struct snd_kcontrol_new wm8960_loutput_mixer[] = {
    SOC_DAPM_SINGLE("PCM Playback Switch", WM8960_LOUTMIX, 8,                    1, 0),
    SOC_DAPM_SINGLE("LINPUT3 Switch", WM8960_LOUTMIX, 7, 1, 0),
    SOC_DAPM_SINGLE("Boost Bypass Switch", WM8960_BYPASS1, 7,                    1, 0),
};
static const struct snd_kcontrol_new wm8960_routput_mixer[] = { 
    SOC_DAPM_SINGLE("PCM Playback Switch", WM8960_ROUTMIX, 8,                    1, 0),
    SOC_DAPM_SINGLE("RINPUT3 Switch", WM8960_ROUTMIX, 7, 1, 0),
    SOC_DAPM_SINGLE("Boost Bypass Switch", WM8960_BYPASS2, 7,                    1, 0),
};
static const struct snd_kcontrol_new wm8960_mono_out[] = { 
    SOC_DAPM_SINGLE("Left Switch", WM8960_MONOMIX1, 7, 1, 0),
    SOC_DAPM_SINGLE("Right Switch", WM8960_MONOMIX2, 7, 1, 0),
};

在前面的部分中,我们为wm8960中的左右输出通道以及单声道输出混音器定义了混音控件:wm8960_loutput_mixerwm8960_routput_mixerwm8960_mono_out

第二步包括定义真实的小部件,包括在第一步中定义的 DAPM 控件:

static const struct snd_soc_dapm_widget wm8960_dapm_widgets[] = {
    [...]
    SND_SOC_DAPM_INPUT("LINPUT3"),
    SND_SOC_DAPM_INPUT("RINPUT3"),
    SND_SOC_DAPM_SUPPLY("MICB", WM8960_POWER1, 1, 0, NULL, 0),
    [...]
    SND_SOC_DAPM_DAC("Left DAC", "Playback", WM8960_POWER2, 8,                     0),
    SND_SOC_DAPM_DAC("Right DAC", "Playback", WM8960_POWER2, 7,                      0),
    SND_SOC_DAPM_MIXER("Left Output Mixer", WM8960_POWER3, 3,                        0,
                       &wm8960_loutput_mixer[0],                        ARRAY_SIZE(wm8960_loutput_mixer)),
    SND_SOC_DAPM_MIXER("Right Output Mixer", WM8960_POWER3, 2,                       0,
                       &wm8960_routput_mixer[0],                        ARRAY_SIZE(wm8960_routput_mixer)),
    SND_SOC_DAPM_PGA("LOUT1 PGA", WM8960_POWER2, 6, 0, NULL,                      0),
    SND_SOC_DAPM_PGA("ROUT1 PGA", WM8960_POWER2, 5, 0, NULL,                      0),
    SND_SOC_DAPM_PGA("Left Speaker PGA", WM8960_POWER2,
                     4, 0, NULL, 0),
    SND_SOC_DAPM_PGA("Right Speaker PGA", WM8960_POWER2,
                     3, 0, NULL, 0),
    SND_SOC_DAPM_PGA("Right Speaker Output", WM8960_CLASSD1,
                     7, 0, NULL, 0);
    SND_SOC_DAPM_PGA("Left Speaker Output", WM8960_CLASSD1,
                     6, 0, NULL, 0),
    SND_SOC_DAPM_OUTPUT("SPK_LP"),
    SND_SOC_DAPM_OUTPUT("SPK_LN"),
    SND_SOC_DAPM_OUTPUT("HP_L"),
    SND_SOC_DAPM_OUTPUT("HP_R"),
    SND_SOC_DAPM_OUTPUT("SPK_RP"),
    SND_SOC_DAPM_OUTPUT("SPK_RN"),
    SND_SOC_DAPM_OUTPUT("OUT3"),
};
static const struct snd_soc_dapm_widget wm8960_dapm_widgets_out3[] = {
    SND_SOC_DAPM_MIXER("Mono Output Mixer", WM8960_POWER2, 1,                       0,
                       &wm8960_mono_out[0],                        ARRAY_SIZE(wm8960_mono_out)),
};

在这一步中,为左右声道和通道选择器分别定义了 MUX 小部件:左输出混音器、右输出混音器和单声道混音器。我们还为左右扬声器分别定义了混音器小部件:SPK_LPSPK_LNHP_LHP_RSPK_RPOUT3SPK_RN。特定的混音控制由前一步中定义的wm8960_loutput_mixerwm8960_routput_mixerwm8960_mono_out完成。这三个小部件具有电源属性,因此当这些小部件中的一个(或多个)在一个有效的音频路径中时,DAPM 框架可以通过它们各自寄存器的第 7 位和/或第 8 位来控制其电源状态。

第三步是定义这些小部件的连接路径:

static const struct snd_soc_dapm_route audio_paths[] = {
   [...]
   {"Left Output Mixer", "LINPUT3 Switch", "LINPUT3"},
   {"Left Output Mixer", "Boost Bypass Switch",     "Left Boost Mixer"},
   {"Left Output Mixer", "PCM Playback Switch", "Left DAC"},
   {"Right Output Mixer", "RINPUT3 Switch", "RINPUT3"},
   {"Right Output Mixer", "Boost Bypass Switch",     "Right Boost Mixer"},
   {"Right Output Mixer", "PCM Playback Switch", "Right DAC"},
   {"LOUT1 PGA", NULL, "Left Output Mixer"},
   {"ROUT1 PGA", NULL, "Right Output Mixer"},
   {"HP_L", NULL, "LOUT1 PGA"},
   {"HP_R", NULL, "ROUT1 PGA"},
   {"Left Speaker PGA", NULL, "Left Output Mixer"},
   {"Right Speaker PGA", NULL, "Right Output Mixer"},
   {"Left Speaker Output", NULL, "Left Speaker PGA"},
   {"Right Speaker Output", NULL, "Right Speaker PGA"},
   {"SPK_LN", NULL, "Left Speaker Output"},
   {"SPK_LP", NULL, "Left Speaker Output"},
   {"SPK_RN", NULL, "Right Speaker Output"},
   {"SPK_RP", NULL, "Right Speaker Output"},
};
static const struct snd_soc_dapm_route audio_paths_out3[] = {
   {"Mono Output Mixer", "Left Switch", "Left Output Mixer"},
   {"Mono Output Mixer", "Right Switch", "Right Output Mixer"},
   {"OUT3", NULL, "Mono Output Mixer"}
};

通过第一步的定义,我们知道“左输出 Mux”和“右输出 Mux”分别有三个输入引脚,“增益旁路开关”、“LINPUT3 开关”(或“RINPUT3 开关”)和“PCM 播放开关”。“单声道混音器”只有两个输入选择引脚,分别是“左开关”和“右开关”。因此,显然,前面路径定义的意思如下:

  • “左增益混音器”通过“增益旁路开关”连接到“左输出混音器”。

  • “左 DAC”通过“PCM 播放开关”连接到“左输出混音器”。

  • "RINPUT3"通过“RINPUT3 开关”连接到“右输出混音器”。

  • “右增益混音器”通过“增益旁路开关”连接到“右输出混音器”。

  • “右 DAC”通过“PCM 播放开关”连接到“右输出混音器”。

  • “左输出混音器”连接到"LOUT1 PGA"。但是,这个连接没有开关控制。

  • “右输出混音器”连接到"ROUT1 PGA",没有开关控制这个连接。

并非所有的连接都已经描述,但思路已经存在。第四步是在编解码器驱动的探测回调中注册这些小部件和路径:

static int wm8960_add_widgets(struct                               snd_soc_component *component)
{
    [...]
    struct snd_soc_dapm_context *dapm =  
                        snd_soc_component_get_dapm(component);
    struct snd_soc_dapm_widget *w;
    snd_soc_dapm_new_controls(dapm, wm8960_dapm_widgets, 
                         ARRAY_SIZE(wm8960_dapm_widgets));
    snd_soc_dapm_add_routes(dapm, audio_paths, 
                         ARRAY_SIZE(audio_paths)); 
    [...]
    return 0;
}
static int wm8960_probe(struct snd_soc_component *component)
{
    [...]
    snd_soc_add_component_controls(component,                                    wm8960_snd_controls,
                              ARRAY_SIZE(wm8960_snd_controls));
    wm8960_add_widgets(component);
    return 0;
}
static const struct snd_soc_component_driver      soc_component_dev_wm8960 = {
    .probe	= wm8960_probe,
    .set_bias_level = wm8960_set_bias_level,
    .suspend_bias_off	= 1,
    .idle_bias_on = 1,
    .use_pmdown_time = 1,
    .endianness	= 1,
    .non_legacy_dai_naming	= 1,
};
static int wm8960_i2c_probe(struct i2c_client *i2c,
                            const struct i2c_device_id *id)
{
    [...]
    ret = devm_snd_soc_register_component(&i2c->dev, 
                                     &soc_component_dev_wm8960,                                     &wm8960_dai, 1);
    return ret;
}

在上面的示例中,控件、小部件和路径的注册被推迟到组件驱动的探测回调中。这有助于确保这些元素只有在机器驱动探测到组件时才会被创建。在机器驱动中,我们可以以相同的方式定义和注册特定于板的小部件和路径信息。

编解码器组件注册

编解码器组件设置完成后,必须将其注册到系统中,以便按照其设计进行使用。为此,应使用devm_snd_soc_register_component()。此函数将在需要时自动处理注销/清理。其原型如下:

int devm_snd_soc_register_component(struct device *dev,
                     const struct                      snd_soc_component_driver *cmpnt_drv, 
                     struct snd_soc_dai_driver *dai_drv,                      int num_dai)

以下是一个编解码器注册的示例,摘自wm8960编解码器驱动程序。组件驱动程序首先定义如下:

static const struct snd_soc_component_driver      soc_component_dev_wm8900 = {
    .probe	= wm8900_probe,
    .suspend = wm8900_suspend,
    .resume = wm8900_resume,
    [...]
    /* control, widget and route setup */
    .controls	= wm8900_snd_controls,
    .num_controls	= ARRAY_SIZE(wm8900_snd_controls),
    .dapm_widgets	= wm8900_dapm_widgets,
    .num_dapm_widgets	= ARRAY_SIZE(wm8900_dapm_widgets),
    .dapm_routes 	= wm8900_dapm_routes,
    .num_dapm_routes	= ARRAY_SIZE(wm8900_dapm_routes),
};

该组件驱动程序包含dapm路由和小部件,以及一组控件。然后,通过struct snd_soc_dai_ops提供编解码器dai回调,如下所示:

static const struct snd_soc_dai_ops wm8900_dai_ops = {
    .hw_params	= wm8900_hw_params,
    .set_clkdiv	= wm8900_set_dai_clkdiv,
    .set_pll	= wm8900_set_dai_pll,
    .set_fmt	= wm8900_set_dai_fmt,
    .digital_mute	= wm8900_digital_mute,
};

这些编解码器dai回调通过ops字段分配给编解码器dai驱动程序,以便与 ASoC 核心注册,如下所示:

#define WM8900_RATES (SNDRV_PCM_RATE_8000  |\                      SNDRV_PCM_RATE_11025 |\  
                      SNDRV_PCM_RATE_16000 |\                      SNDRV_PCM_RATE_22050 |\ 
                      SNDRV_PCM_RATE_44100 |\                      SNDRV_PCM_RATE_48000)
#define WM8900_PCM_FORMATS \
    (SNDRV_PCM_FMTBIT_S16_LE | SNDRV_PCM_FMTBIT_S20_3LE | \ 
     SNDRV_PCM_FMTBIT_S24_LE)
static struct snd_soc_dai_driver wm8900_dai = {
    .name = "wm8900-hifi",
    .playback = {
        .stream_name = "HiFi Playback",
        .channels_min = 1,
        .channels_max = 2,
        .rates = WM8900_RATES,
        .formats = WM8900_PCM_FORMATS,
    },
    .capture = {
        .stream_name = "HiFi Capture",
        .channels_min = 1,
        .channels_max = 2,
        .rates = WM8900_RATES,
        .formats = WM8900_PCM_FORMATS,
    },
    .ops = &wm8900_dai_ops,
};
static int wm8900_spi_probe(struct spi_device *spi)
{
    [...]
    ret = devm_snd_soc_register_component(&spi->dev, 
                                     &soc_component_dev_wm8900,                                      &wm8900_dai, 1);
    return ret;
}

当机器驱动程序探测到这个编解码器时,编解码器组件驱动程序的探测回调(wm8900_probe)将被调用,并且它们将完成编解码器驱动程序的初始化。这个编解码器设备驱动程序的完整版本是 Linux 内核源码中的sound/soc/codecs/wm8900.c

现在我们熟悉了编解码器类驱动程序及其架构。我们已经看到了如何导出编解码器属性,如何构建音频路径,以及如何实现 DAPM 功能。编解码器驱动程序本身是相当无用的,尽管它管理编解码器设备。它需要与平台驱动程序绑定,这是我们接下来要学习的下一个驱动程序类。

编写平台类驱动程序

平台驱动程序注册 PCM 驱动程序、CPU DAI 驱动程序及其操作函数,为 PCM 组件预分配缓冲区,并根据需要设置播放和捕获操作。换句话说,平台驱动程序包含该平台的音频 DMA 引擎和音频接口驱动程序(例如 I2S、AC97 和 PCM)。

平台驱动程序针对平台构建的 SoC。它涉及平台的 DMA,这是音频数据在 SoC 中的每个块之间传输的方式,以及 CPU DAI,这是 CPU 用于发送/携带音频数据到/从编解码器的路径。这样的驱动程序有两个重要的数据结构:struct snd_soc_component_driverstruct snd_soc_dai_driver。前者负责 DMA 数据管理,后者负责 DAI 的参数配置。然而,这两个数据结构在处理编解码器类驱动程序时已经描述过。因此,本部分将只涉及与平台代码相关的其他概念。

CPU DAI 驱动程序

自从平台代码也进行了重构,与编解码器驱动程序一样,CPU DAI 驱动程序必须导出组件驱动程序的实例,以及 DAI 驱动程序的实例,分别是struct snd_soc_component_driverstruct snd_soc_dai_driver

在平台方面,大部分工作可以由核心完成,特别是与 DMA 相关的工作。因此,CPU DAI 驱动程序通常只需在组件驱动程序结构中提供接口的名称,剩下的工作就交给核心处理。以下是 Rockchip SPDIF 驱动程序的示例,实现在sound/soc/rockchip/rockchip_spdif.c中:

static const struct snd_soc_dai_ops rk_spdif_dai_ops = {
    [...]
};
/* SPDIF has no capture channel */
static struct snd_soc_dai_driver rk_spdif_dai = {
    .probe = rk_spdif_dai_probe,
    .playback = {
        .stream_name = "Playback",
[...]
    },
    .ops = &rk_spdif_dai_ops,
};
/* fill in the name only */
static const struct snd_soc_component_driver rk_spdif_component = {
    .name = "rockchip-spdif",
};
static int rk_spdif_probe(struct platform_device *pdev)
{
    struct device_node *np = pdev->dev.of_node;
    struct rk_spdif_dev *spdif;
    int ret;
[...]
    spdif->playback_dma_data.addr = res->start + SPDIF_SMPDR;
    spdif->playback_dma_data.addr_width =     DMA_SLAVE_BUSWIDTH_4_BYTES; 
    spdif->playback_dma_data.maxburst = 4;
    ret = devm_snd_soc_register_component(&pdev->dev, 
                                          &rk_spdif_component,                                           &rk_spdif_dai, 1);
    if (ret) {
        dev_err(&pdev->dev, "Could not register DAI\n");
        goto err_pm_runtime;
     }
    ret = devm_snd_dmaengine_pcm_register(&pdev->dev, NULL, 0);
    if (ret) {
        dev_err(&pdev->dev, "Could not register PCM\n");
        goto err_pm_runtime;
    }
    return 0;
}

在上述摘录中,spdif是驱动程序状态数据结构。我们可以看到组件驱动程序中只填写了名称,并且通过devm_snd_soc_register_component()通常注册了组件和 DAI 驱动程序。struct snd_soc_dai_driver必须根据实际的 DAI 属性进行设置,如果需要,应设置dai_ops。然而,devm_snd_dmaengine_pcm_register()将完成设置的大部分工作,根据提供的dma_data设置组件驱动程序的 PCM 操作。这将在下一节详细解释。

平台 DMA 驱动程序,又称 PCM DMA 驱动程序

在声音生态系统中,我们有几种类型的设备:PCM、MIDI、混音器、序列器、定时器等。这里,PCM 确实指的是脉冲编码调制,但它是指处理基于样本的数字音频的设备,即不是 MIDI 等。PCM 层(ALSA 核心的一部分)负责进行所有数字音频工作,例如准备卡片进行捕获或播放,启动到设备的传输等。简而言之,如果您想要播放或捕获声音,您将需要一个 PCM。

PCM 驱动程序通过覆盖struct snd_pcm_ops结构公开的函数指针来执行 DMA 操作。它与平台无关,仅与 SOC DMA 引擎上游 API 交互。DMA 引擎然后与特定于平台的 DMA 驱动程序交互,以获取正确的 DMA 设置。struct snd_pcm_ops是一个包含一组回调的结构,这些回调与 PCM 接口的不同事件相关。

在处理 ASoC(而不是纯粹的 ALSA)时,只要使用通用 PCM DMA 引擎框架,就不需要实例化这个结构。ASoC 核心会为您完成这些工作。看一下以下调用堆栈:snd_soc_register_card -> snd_soc_instantiate_card -> soc_probe_link_dais -> soc_new_pcm

音频 DMA 接口

SoC 的每个音频总线驱动程序负责通过此 API 提供 DMA 接口。例如,对于基于 i.MX 的 SoC 上的音频总线,如 ESAI、SAI、SPDIF 和 SSI,其驱动程序分别位于sound/soc/fsl/sound/soc/fsl/fsl_esai.csound/soc/fsl/fsl_sai.csound/soc/fsl/fsl_spdif.csound/soc/fsl/fsl_ssi.c

音频 DMA 驱动程序通过devm_snd_dmaengine_pcm_register()进行注册。此函数为设备注册了一个struct snd_dmaengine_pcm_config。其原型如下:

int devm_snd_dmaengine_pcm_register(
                        struct device *dev,
                        const struct                         snd_dmaengine_pcm_config *config, 
                        unsigned int flags);

在上述原型中,dev是 PCM 设备的父设备,通常为&pdev->devconfig是特定于平台的 PCM 配置,类型为struct snd_dmaengine_pcm_config。这个结构需要详细描述。flags表示描述如何处理 DMA 通道的附加标志。大多数情况下为0。但是,可能的值在include/sound/dmaengine_pcm.h中定义,并且都以SND_DMAENGINE_PCM_FLAG_为前缀。经常使用的是SND_DMAENGINE_PCM_FLAG_HALF_DUPLEXSND_DMAENGINE_PCM_FLAG_NO_DTSND_DMAENGINE_PCM_FLAG_COMPAT。前者表示 PCM 是半双工,DMA 通道在捕获和播放之间共享。第二个要求核心不要尝试通过设备树请求 DMA 通道。最后一个意味着将使用自定义回调来请求 DMA 通道。注册后,通用 PCM DMA 引擎框架将构建一个合适的snd_pcm_ops并将组件驱动程序的.ops字段设置为它。

Linux 中经典的 DMA 操作流程如下:

  1. dma_request_channel:用于分配从通道。

  2. dmaengine_slave_config:用于设置从和控制器特定的参数。

  3. dma_prep_xxxx:获取事务的描述符。

  4. dma_cookie = dmaengine_submit(tx): 提交事务并获取 DMA cookie。

  5. dma_async_issue_pending(chan): 启动传输并等待回调通知。

在 ASoC 中,设备树用于将 DMA 通道映射到 PCM 设备。devm_snd_dmaengine_pcm_register()通过dmaengine_pcm_request_chan_of()请求 DMA 通道,这是一个基于设备树的接口。为了执行步骤 1步骤 3,PCM DMA 引擎核心需要提供额外的信息。这可以通过填充struct snd_dmaengine_pcm_config来完成,该结构将被提供给注册函数,或者让 PCM DMA 引擎框架从系统的 DMA 引擎核心中检索信息。步骤 4步骤 5由 PCM DMA 引擎核心透明处理。

以下是struct snd_dma_engine_pcm_config的外观:

struct snd_dmaengine_pcm_config {
    int (*prepare_slave_config)(                        struct snd_pcm_substream *substream,
                        struct snd_pcm_hw_params *params,
                        struct dma_slave_config *slave_config);
    struct dma_chan *(*compat_request_channel)(
                          struct snd_soc_pcm_runtime *rtd,
                          struct snd_pcm_substream *substream);
    [...]
    dma_filter_fn compat_filter_fn;
    struct device *dma_dev;
    const char *chan_names[SNDRV_PCM_STREAM_LAST + 1];
    const struct snd_pcm_hardware *pcm_hardware;
    unsigned int prealloc_buffer_size;
};

前面的数据结构主要处理 DMA 通道管理、缓冲区管理和通道配置:

  • prepare_slave_config:此回调用于填充 PCM 子流的 DMA slave_config(类型为struct dma_slave_config,是 DMA 从通道运行时配置)。它将从 PCM 驱动程序的hwparams回调中调用。在这里,您可以使用snd_dmaengine_pcm_prepare_slave_config,这是一个通用的prepare_slave_config回调,用于使用snd_dmaengine_dai_dma_data结构的平台。此通用回调将内部调用snd_hwparams_to_dma_slave_config,根据hw_params填充从配置,然后调用snd_dmaengine_set_config_from_dai_data,根据 DAI DMA 数据填充剩余字段。

在使用通用回调方法时,应该在 CPU DAI 驱动程序的.probe回调中调用snd_soc_dai_init_dma_data()(给定特定于 DAI 的捕获和播放 DMA 数据配置,类型为struct snd_dmaengine_dai_dma_data),这将设置cpu_dai->playback_dma_datacpu_dai->capture_dma_data字段。snd_soc_dai_init_dma_data()方法只是为给定的 DAI 设置 DMA 设置(捕获、播放或两者)。

  • compat_request_channel:这用于请求不使用设备树的平台的 DMA 通道。如果设置,将忽略.compat_filter_fn

  • compat_filter_fn:这用作在请求 DMA 通道时的过滤函数,用于不使用设备树的平台。过滤参数将是 DAI 的 DMA 数据。

  • dma_dev:这允许为除注册 PCM 驱动程序的设备之外的设备请求 DMA 通道。如果设置,DMA 通道将在此设备上请求,而不是在 DAI 设备上。

  • chan_names:这是在请求捕获/播放 DMA 通道时使用的名称数组。当默认的"tx""rx"通道名称不适用时,这是有用的,例如,如果硬件模块支持多个通道,每个通道具有不同的 DMA 通道名称。

  • pcm_hardware:这描述了 PCM 硬件的能力。如果未设置,依赖核心填写从 DMA 引擎信息派生的正确标志。该字段是struct snd_pcm_hardware类型,并将在下一节中描述。

  • prealloc_buffer_size:这是预分配音频缓冲区的大小。

PCM DMA 配置可能不会提供给注册 API(可能为NULL),注册将是ret = devm_snd_dmaengine_pcm_register(&pdev->dev, NULL, 0)。在这种情况下,应该通过snd_soc_dai_init_dma_data()提供捕获和播放 DAI DMA 通道配置,如前所述。通过使用这种方法,其他元素将从系统核心派生。例如,要请求 DMA 通道,PCM DMA 引擎核心将依赖设备树,假设捕获和播放 DMA 通道名称分别为"rx""tx",除非在flags中设置了SND_DMAENGINE_PCM_FLAG_HALF_DUPLEX标志,否则它将考虑捕获和播放使用相同的 DMA 通道,设备树节点中命名为rx-tx

DMA 通道设置也将从系统 DMA 引擎派生。以下是snd_soc_dai_init_dma_data()的样子:

static inline void snd_soc_dai_init_dma_data(                                       struct snd_soc_dai *dai,
                                       void *playback,                                        void *capture)
{
    dai->playback_dma_data = playback;
    dai->capture_dma_data = capture;
}

尽管snd_soc_dai_init_dma_data()接受捕获和播放作为void类型,但实际传递的值应该是struct snd_dmaengine_dai_dma_data类型,在include/sound/dmaengine_pcm.h中定义如下:

struct snd_dmaengine_dai_dma_data {
    dma_addr_t addr;
    enum dma_slave_buswidth addr_width;
    u32 maxburst;
    unsigned int slave_id;
    void *filter_data;
    const char *chan_name;
    unsigned int fifo_size;
    unsigned int flags;
};

该结构表示 DAI 通道的 DMA 通道数据(或配置或您喜欢的任何其他内容)。您应该参考定义它的头文件以了解其字段的含义。此外,您可以查看其他驱动程序,以获取有关如何设置此数据结构的更多详细信息。

PCM 硬件配置

当 DMA 设置不是由 PCM DMA 引擎核心自动从系统中提供时,平台 PCM 驱动程序可能需要提供 PCM 硬件设置,描述硬件如何布置 PCM 数据。这些设置通过snd_dmaengine_pcm_config.pcm_hardware字段提供,它是struct snd_pcm_hardware类型,定义如下:

struct snd_pcm_hardware {
    unsigned int info;
    u64 formats;
    unsigned int rates;
    unsigned int rate_min;
    unsigned int rate_max;
    unsigned int channels_min;
    unsigned int channels_max;
    size_t buffer_bytes_max;
    size_t period_bytes_min;
    size_t period_bytes_max;
    unsigned int periods_min;
    unsigned int periods_max;
    size_t fifo_size;
};

该结构描述了平台本身的硬件限制(或者我应该说,它设置了允许的参数),例如支持的通道数/采样率/数据格式,DMA 支持的周期大小范围,周期计数范围等。在前面的数据结构中,范围值、周期最小值和周期最大值取决于 DMA 控制器、DAI 硬件和编解码器的能力。以下是每个字段的详细含义:

  • info 包含了此 PCM 的类型和功能。可能的值是位标志,都在 include/uapi/sound/asound.h 中定义(这意味着用户代码应该包含 <sound/asound.h>),如 SNDRV_PCM_INFO_XXX。例如,SNDRV_PCM_INFO_MMAP 表示硬件支持 mmap() 系统调用。在这里,至少必须指定是否支持 mmap 系统调用以及支持哪种交错格式。当支持 mmap() 系统调用时,在这里添加 SNDRV_PCM_INFO_MMAP 标志。当硬件支持交错或非交错格式时,必须分别设置 SNDRV_PCM_INFO_INTERLEAVEDSNDRV_PCM_INFO_NONINTERLEAVED 标志。如果两者都支持,也可以同时设置。

  • formats 字段包含了支持格式的位标志(SNDRV_PCM_FMTBIT_XXX)。如果硬件支持多种格式,应该使用所有 OR 运算的位。

  • rates 字段包含了支持速率的位标志(SNDRV_PCM_RATE_XXX)。

  • rate_minrate_max 定义了最小和最大采样率。这应该与速率位相对应。

  • channel_minchannel_max 定义了通道的最小和最大数量。

  • buffer_bytes_max 定义了缓冲区的最大大小(以字节为单位)。由于可以从最小周期大小和最小周期数计算出来,因此没有 buffer_bytes_min 字段。同时,period_bytes_minperiod_bytes_max 定义了周期的最小和最大大小(以字节为单位)。

  • periods_maxperiods_min 定义了缓冲区中的最大和最小周期数。

其他字段需要引入周期的概念。周期定义了生成 PCM 中断的大小。周期的概念非常重要。周期基本上描述了一个中断。它总结了硬件以“块”大小提供数据的方式:

  • period_bytes_min 是 DMA 写入的最小传输大小,表示中断之间处理的字节数。例如,如果 DMA 可以传输最少 2,048 字节,应该写成 2048

  • period_bytes_max 是 DMA 的最大传输大小,也就是中断之间处理的最大字节数。例如,如果 DMA 可以传输最多 4,096 字节,应该写成 4096

以下是 STM32 I2S DMA 驱动程序中的 PCM 约束的示例,定义在 sound/soc/stm/stm32_i2s.c 中:

static const struct snd_pcm_hardware stm32_i2s_pcm_hw = {
    .info = SNDRV_PCM_INFO_INTERLEAVED | SNDRV_PCM_INFO_MMAP,
    .buffer_bytes_max = 8 * PAGE_SIZE,
    .period_bytes_max = 2048,
    .periods_min = 2,
    .periods_max = 8,
};

设置完成后,此结构应该最终出现在 snd_dmaengine_pcm_config.pcm_hardware 字段中,然后传递给 devm_snd_dmaengine_pcm_register()struct snd_dmaengine_pcm_config 对象。

以下是一个播放流程,显示了涉及的组件和 PCM 数据流:

图 5.4 – ASoC 音频播放流程

图 5.4 – ASoC 音频播放流程

上图显示了音频播放流程和每个步骤涉及的块。我们可以看到音频数据从用户复制到 DMA 缓冲区,然后通过 DMA 事务将数据移动到平台音频 Tx FIFO,由于其与编解码器(通过各自的 DAI)的链接,将这些数据发送到负责通过扬声器播放音频的编解码器。捕获操作是扬声器被麦克风替换的相反流程。

这就结束了处理平台类驱动程序的部分。我们已经看到了它与编解码器类驱动程序共享的数据结构和概念。请注意,编解码器和平台驱动程序都需要链接在一起,以便从系统的角度构建真正的音频路径。根据 ASoC 架构,这必须在另一个类驱动程序中完成,即所谓的机器驱动程序,这是下一章的主题。

总结

在本章中,我们分析了 ASoC 架构。在此基础上,我们处理了编解码器驱动程序和平台驱动程序。通过学习这些主题,我们经历了几个概念,比如控件和小部件。我们已经看到 ASoC 框架如何与经典的 PC ALSA 系统不同,主要是通过针对代码可重用性和实现电源管理。

最后但并非最不重要的是,我们已经看到平台和编解码器驱动程序不能独立工作。它们需要由机器驱动程序绑定在一起,负责注册最终的音频设备,这是下一章的主要主题。

第六章:ALSA SoC 框架-深入了解机器类驱动程序

在开始我们的 ALSA SoC 框架系列时,我们注意到平台和编解码器类驱动程序都不打算单独工作。ASoC 架构设计成平台和编解码器类驱动程序必须绑定在一起才能构建音频设备。这种绑定可以通过所谓的机器驱动程序或者设备树内部完成,每个都是特定于机器的。因此可以毫不夸张地说,机器驱动程序针对特定系统,可能会从一个板子变成另一个板子。在本章中,我们将重点介绍 AsoC 机器类驱动程序的不足之处,并讨论在需要编写机器类驱动程序时可能遇到的特定情况。

在本章中,我们将介绍 Linux ASoC 驱动程序架构和实现。本章将分为不同的部分,如下所示:

  • 机器类驱动程序介绍

  • 机器路由考虑

  • 时钟和格式考虑

  • 声卡注册

  • 利用 simple-card 机器驱动程序

第六章:技术要求

本章需要以下内容:

机器类驱动程序介绍

编解码器和平台驱动程序不能单独工作。机器驱动程序负责将它们绑定在一起,以完成音频信息处理。机器驱动程序类充当胶水,描述和绑定其他组件驱动程序以形成 ALSA 声卡设备。它管理任何特定于机器的控件和机器级音频事件(例如在播放开始时打开放大器)。机器驱动程序描述并绑定 CPU struct snd_soc_dai_link结构,并实例化声卡struct snd_soc_card

平台和编解码器驱动程序通常是可重用的,但机器驱动程序通常不是,因为它们具有大多数情况下不可重用的特定硬件特性。所谓的硬件特性指的是 DAI 之间的链接;通过 GPIO 打开放大器;通过 GPIO 检测插入;使用时钟如 MCLK/外部 OSC 作为 I2 的参考时钟源;编解码器模块等。一般来说,机器驱动程序的责任包括以下内容:

  • 使用适当的 CPU 和编解码器 DAI 填充struct snd_soc_dai_link结构

  • 物理编解码器时钟设置(如果有)和编解码器初始化主/从配置(如果有)

  • 定义 DAPM 小部件以通过物理编解码器内部进行路由,并根据需要完成 DAPM 路径

  • 根据需要将运行时采样频率传播到各个编解码器驱动程序

总之,我们有以下流程:

  1. 编解码器驱动程序注册了一个组件驱动程序、一个 DAI 驱动程序以及它们的操作函数。

  2. 平台驱动程序注册了一个组件驱动程序、PCM 驱动程序、CPU DAI 驱动程序以及它们的操作函数,并设置适用的播放和捕获操作。

  3. 机器层在编解码器和 CPU 之间创建 DAI 链路,并注册声卡和 PCM 设备。

既然我们已经看到了机器类驱动程序的开发流程,让我们从第一步开始,即填充 DAI 链路。

DAI 链路

DAI 链路是 CPU 和编解码器 DAI 之间的逻辑表示。它在内核中使用struct snd_soc_dai_link表示,定义如下:

struct snd_soc_dai_link {
    const char *name;
    const char *stream_name;
    const char *cpu_name;
    struct device_node *cpu_of_node;
    const char *cpu_dai_name;
    const char *codec_name;
    struct device_node *codec_of_node;
    const char *codec_dai_name;
    struct snd_soc_dai_link_component *codecs;
    unsigned int num_codecs;
    const char *platform_name;
    struct device_node *platform_of_node;
    int id;
    const struct snd_soc_pcm_stream *params;
    unsigned int num_params;
    unsigned int dai_fmt;
    enum snd_soc_dpcm_trigger trigger[2];
  /* codec/machine specific init - e.g. add machine controls */
    int (*init)(struct snd_soc_pcm_runtime *rtd);
    /* machine stream operations */
    const struct snd_soc_ops *ops;
    /* For unidirectional dai links */
    unsigned int playback_only:1;
    unsigned int capture_only:1;
    /* Keep DAI active over suspend */
    unsigned int ignore_suspend:1;
[...]
    /* DPCM capture and Playback support */
    unsigned int dpcm_capture:1;
    unsigned int dpcm_playback:1;
    struct list_head list; /* DAI link list of the soc card */
};

重要说明

完整的 snd_soc_dai_link 数据结构定义可以在 elixir.bootlin.com/linux/v4.19/source/include/sound/soc.h#L880 找到。

此链接是在机器驱动程序中设置的。它应该指定 cpu_daicodec_dai 和使用的平台。设置完成后,DAI 链接将被馈送到 struct snd_soc_card,它表示一个声卡。以下列表描述了结构中的元素:

  • name:这是任意选择的。可以是任何东西。

  • codec_dai_name:这必须与编解码器芯片驱动程序中的 snd_soc_dai_driver.name 字段匹配。编解码器可能有一个或多个 DAIs。请参考编解码器驱动程序以识别 DAI 名称。

  • cpu_dai_name:这必须与 CPU DAI 驱动程序中的 snd_soc_dai_driver.name 字段匹配。

  • stream_name:这是此链接的流名称。

  • init:这是 DAI 链接初始化回调。通常用于添加 DAI 链接特定的小部件或其他类型的一次性设置。

  • dai_fmt:这应该设置为支持的格式和时钟配置,对于 CPU 和 CODEC DAI 驱动程序应该是一致的。此字段的可能位标志稍后介绍。

  • ops:此字段是 struct snd_soc_ops 类型。它应该设置为 DAI 链接的机器级 PCM 操作:startuphw_paramspreparetriggerhw_freeshutdown。此字段稍后将详细描述。

  • codec_name:如果设置,这应该是编解码器驱动程序的名称,例如 platform_driver.driver.namei2c_driver.driver.name

  • codec_of_node:与编解码器关联的设备树节点。

  • cpu_name:如果设置,这应该是 CPU DAI 驱动程序 CPU 的名称。

  • cpu_of_node:这是与 CPU DAI 关联的设备树节点。

  • platform_nameplatform_of_node:这是提供 DMA 能力的平台节点的名称或 DT 节点引用。

  • playback_onlycapture_only 用于单向链接,例如 SPDIF。如果这是一个仅输出的链接(仅播放),那么必须将 playback_onlycapture_only 分别设置为 truefalse。对于仅输入的链接,应使用相反的值。

在大多数情况下,.cpu_of_node.platform_of_node 是相同的,因为 CPU DAI 驱动程序和 DMA PCM 驱动程序是由同一设备实现的。也就是说,您必须通过名称或 of_node 指定链接的编解码器,但不能同时使用两者。对于 CPU 和平台,您必须做同样的事情。但是,至少必须指定 CPU DAI 名称或 CPU 设备名称/节点中的一个。这可以总结如下:

if (link->platform_name && link->platform_of_node)
    ==> Error
if (link->cpu_name && link->cpu_of_node)
    ==> Eror
if (!link->cpu_dai_name && !(link->cpu_name ||                              link->cpu_of_node))
    ==> Error

这里有一个值得注意的关键点。我们如何在 DAI 链接中引用平台或 CPU 节点?我们将在后面回答这个问题。首先考虑以下两个设备节点。第一个(ssi1)是 i.mx6 SoC 的 SSI cpu-dai 节点。第二个节点(sgtl5000)代表 sgtl5000 编解码器芯片:

ssi1: ssi@2028000 {
    #sound-dai-cells = <0>;
    compatible = "fsl,imx6q-ssi", "fsl,imx51-ssi";
    reg = <0x02028000 0x4000>;
    interrupts = <0 46 IRQ_TYPE_LEVEL_HIGH>;
    clocks = <&clks IMX6QDL_CLK_SSI1_IPG>,
             <&clks IMX6QDL_CLK_SSI1>;
    clock-names = "ipg", "baud";
    dmas = <&sdma 37 1 0>, <&sdma 38 1 0>;
    dma-names = "rx", "tx";
    fsl,fifo-depth = <15>;
    status = "disabled";
};
&i2c0{
    sgtl5000: codec@0a {
        compatible = "fsl,sgtl5000";
        #sound-dai-cells = <0>;
        reg = <0x0a>;
        clocks = <&audio_clock>;
        VDDA-supply = <&reg_3p3v>;
        VDDIO-supply = <&reg_3p3v>;
        VDDD-supply = <&reg_1p5v>;
    };
};

重要提示

在 SSI 节点中,您可以看到 dma-names = "rx", "tx"; 属性,这是 pcmdmaengine 框架请求的预期 DMA 通道名称。这也可能表明 CPU DAI 和平台 PCM 由同一节点表示。

我们将考虑一个系统,其中 i.MX6 SoC 连接到 sgtl5000 音频编解码器。通常,机器驱动程序会通过引用这些节点(实际上是它们的 phandle)作为其属性来获取 CPU 或 CODEC 设备树节点。这样,您可以使用 OF 助手之一(例如 of_parse_phandle())来获取对这些节点的引用。以下是一个通过 OF 节点引用编解码器和平台的机器节点的示例:

sound {
    compatible = "fsl,imx51-babbage-sgtl5000",
                 "fsl,imx-audio-sgtl5000";
    model = "imx51-babbage-sgtl5000";
    ssi-controller = <&ssi1>;
    audio-codec = <&sgtl5000>;
    [...]
};

在前面的机器节点中,编解码器和 CPUE 通过audio-codecssi-controller属性(它们的phandle)传递引用。只要机器驱动程序是由您编写的(如果您使用simple-card机器驱动程序,这就不成立,因为它期望一些预定义的名称),这些属性名称就不是标准化的。在机器驱动程序中,你会看到类似这样的东西:

static int imx_sgtl5000_probe(struct platform_device *pdev)
{
    struct device_node *np = pdev->dev.of_node;
    struct device_node *ssi_np, *codec_np;
    struct imx_sgtl5000_data *data = NULL;
    int int_port, ext_port; int ret;
[...]
    ssi_np = of_parse_phandle(pdev->dev.of_node,                               "ssi-controller", 0);
    codec_np = of_parse_phandle(pdev->dev.of_node,                                 "audio-codec", 0);
    if (!ssi_np || !codec_np) {
        dev_err(&pdev->dev, "phandle missing or invalid\n");
        ret = -EINVAL;
        goto fail;
    }
    data = devm_kzalloc(&pdev->dev, sizeof(*data), GFP_KERNEL);
    if (!data) {
        ret = -ENOMEM;
       goto fail;
    }
    data->dai.name = "HiFi";
    data->dai.stream_name = "HiFi";
    data->dai.codec_dai_name = "sgtl5000";
    data->dai.codec_of_node = codec_np;
    data->dai.cpu_of_node = ssi_np;
    data->dai.platform_of_node = ssi_np;
    data->dai.init = &imx_sgtl5000_dai_init;
    data->card.dev = &pdev->dev;
    [...]
};

前面的摘录使用of_parse_phandle()来获取节点引用。这是来自内核源码中的imx_sgtl5000机器的摘录,它在sound/soc/fsl/imx-sgtl5000.c中。现在我们已经熟悉了应该如何处理 DAI 链路,我们可以继续从机器驱动程序中进行音频路由,以定义音频数据应该遵循的路径。

机器路由考虑

机器驱动程序可以更改(或者说追加)从编解码器内部定义的路由。它对应该使用哪些编解码器引脚有最后的决定权,例如。

编解码器引脚

编解码器引脚应该连接到板连接器。可用的编解码器引脚在编解码器驱动程序中使用SND_SOC_DAPM_INPUTSND_SOC_DAPM_OUTPUT宏来定义。这些宏可以在编解码器驱动程序中使用grep命令进行搜索,以找到可用的引脚。

例如,sgtl5000编解码器驱动程序定义了以下输出和输入:

static const struct snd_soc_dapm_widget sgtl5000_dapm_widgets[] = {
    SND_SOC_DAPM_INPUT("LINE_IN"),
    SND_SOC_DAPM_INPUT("MIC_IN"),
    SND_SOC_DAPM_OUTPUT("HP_OUT"),
    SND_SOC_DAPM_OUTPUT("LINE_OUT"),
    SND_SOC_DAPM_SUPPLY("Mic Bias", SGTL5000_CHIP_MIC_CTRL, 8,                         0,
                        mic_bias_event,
                        SND_SOC_DAPM_POST_PMU |                         SND_SOC_DAPM_PRE_PMD),
[...]
};

在接下来的章节中,我们将看到这些引脚是如何连接到板上的。

板连接器

板连接器在已注册的struct snd_soc_card的机器驱动程序中定义在struct snd_soc_dapm_widget部分。大多数情况下,这些板连接器是虚拟的。它们只是逻辑标签,与编解码器引脚连接(这次是真实的)。以下列出了imx-sgtl5000机器驱动程序中定义的连接器,sound/soc/fsl/imx-sgtl5000.c(其文档是Documentation/devicetree/bindings/sound/imx-audio-sgtl5000.txt),迄今为止已经给出了一个例子。

static const struct snd_soc_dapm_widget imx_sgtl5000_dapm_widgets[] = { 
    SND_SOC_DAPM_MIC("Mic Jack", NULL),
    SND_SOC_DAPM_LINE("Line In Jack", NULL),
    SND_SOC_DAPM_HP("Headphone Jack", NULL),
    SND_SOC_DAPM_SPK("Line Out Jack", NULL),
    SND_SOC_DAPM_SPK("Ext Spk", NULL),
};

接下来的章节将把这个连接器连接到编解码器引脚。

机器路由

最终的机器路由可以是静态的(即从机器驱动程序内部填充)或者从设备树内部填充。此外,机器驱动程序可以选择扩展编解码器电源映射,并通过连接到已在编解码器驱动程序中定义的供应小部件,使用SND_SOC_DAPM_SUPPLYSND_SOC_DAPM_REGULATOR_SUPPLY成为音频子系统的音频电源映射。

设备树路由

让我们以我们的机器节点为例,它连接了一个 i.MX6 SoC 和一个 sgtl5000 编解码器(这个摘录可以在机器文档中找到)。

sound {
    compatible = "fsl,imx51-babbage-sgtl5000",
                 "fsl,imx-audio-sgtl5000";
    model = "imx51-babbage-sgtl5000";
    ssi-controller = <&ssi1>;
    audio-codec = <&sgtl5000>;
    audio-routing = "MIC_IN", "Mic Jack",
                    "Mic Jack", "Mic Bias",
                    "Headphone Jack", "HP_OUT";
[...]
};

从设备树中的路由期望音频映射以特定格式给出。也就是说,条目被解析为字符串对,第一个是连接的接收端,第二个是连接的源端。大多数情况下,这些连接被实现为编解码器引脚和板连接器映射。源和接收端的有效名称取决于硬件绑定,如下所示:

  • 编解码器:这里应该已经定义了这里使用的引脚的名称。

  • 机器:这里应该已经定义了这里使用的连接器或插孔的名称。

在前面的摘录中,你注意到了什么?我们可以看到MIC_INHP_OUT"Mic Bias",这些是编解码器引脚(来自编解码器驱动程序),以及"Mic Jack""Headphone Jack",这些在机器驱动程序中被定义为板连接器。

为了使用在设备树中定义的路由,机器驱动程序必须调用snd_soc_of_parse_audio_routing(),它具有以下原型:

int snd_soc_of_parse_card_name(struct snd_soc_card *card,
                               const char *prop);

在前面的原型中,card代表解析路由的声卡,prop是包含设备树节点中路由的属性的名称。此函数在成功时返回0,在错误时返回负错误代码。

静态路由

静态路由包括从机器驱动程序定义 DAPM 路由映射,并直接分配给声卡,如下所示:

static const struct snd_soc_dapm_widget rk_dapm_widgets[] = {
    SND_SOC_DAPM_HP("Headphone", NULL),
    SND_SOC_DAPM_MIC("Headset Mic", NULL),
    SND_SOC_DAPM_MIC("Int Mic", NULL),
    SND_SOC_DAPM_SPK("Speaker", NULL),
};
/* Connection to the codec pin */
static const struct snd_soc_dapm_route rk_audio_map[] = {
    {"IN34", NULL, "Headset Mic"},
    {"Headset Mic", NULL, "MICBIAS"},
    {"DMICL", NULL, "Int Mic"},
    {"Headphone", NULL, "HPL"},
    {"Headphone", NULL, "HPR"},
    {"Speaker", NULL, "SPKL"},
    {"Speaker", NULL, "SPKR"},
};
static struct snd_soc_card snd_soc_card_rk = {
    .name = "ROCKCHIP-I2S",
    .owner = THIS_MODULE,
[...]
    .dapm_widgets = rk_dapm_widgets,
    .num_dapm_widgets = ARRAY_SIZE(rk_dapm_widgets),
    .dapm_routes = rk_audio_map,
    .num_dapm_routes = ARRAY_SIZE(rk_audio_map),
    .controls = rk_mc_controls,
    .num_controls = ARRAY_SIZE(rk_mc_controls),
};

上述片段摘自sound/soc/rockchip/rockchip_rt5645.c。通过这种方式使用它,就不需要使用snd_soc_of_parse_audio_routing()。然而,使用这种方法的一个缺点是,无法在不重新编译内核的情况下更改路由。接下来,我们将看一下时钟和格式考虑。

时钟和格式考虑

在深入研究本节之前,让我们花一些时间在snd_soc_dai_link->ops字段上。该字段是struct snd_soc_ops类型,定义如下:

struct snd_soc_ops {
    int (*startup)(struct snd_pcm_substream *);
    void (*shutdown)(struct snd_pcm_substream *);
    int (*hw_params)(struct snd_pcm_substream *,
                     struct snd_pcm_hw_params *);
    int (*hw_free)(struct snd_pcm_substream *);
    int (*prepare)(struct snd_pcm_substream *);
    int (*trigger)(struct snd_pcm_substream *, int);
};

这个结构中的回调字段应该让你想起了snd_soc_dai_driver->ops字段中定义的那些,它是struct snd_soc_dai_ops类型。在 DAI 链中,这些回调表示 DAI 链的机器级 PCM 操作,而在struct snd_soc_dai_driver中,它们要么是特定于编解码器 DAI 的,要么是特定于 CPU-DAI 的。

startup()在 PCM 子流打开时(当有人打开捕获/播放设备时)由 ALSA 调用,而hw_params()在设置音频流时调用。机器驱动程序可以在这两个回调中从 DAI 链配置 DAI 链数据格式。hw_params()具有接收流参数(通道数格式采样率等)的优势。

数据格式配置应该在 CPU DAI 和编解码器之间保持一致。ASoC 核心提供了帮助函数来更改这些配置。它们如下:

int snd_soc_dai_set_fmt(struct snd_soc_dai *dai,                         unsigned int fmt)
int snd_soc_dai_set_pll(struct snd_soc_dai *dai, int pll_id,
                        int source, unsigned int freq_in,
                        unsigned int freq_out)
int snd_soc_dai_set_sysclk(struct snd_soc_dai *dai, int clk_id,
                           unsigned int freq, int dir)
int snd_soc_dai_set_clkdiv(struct snd_soc_dai *dai,
                           int div_id, int div)

在上述帮助列表中,snd_soc_dai_set_fmt设置了 DAI 格式,例如时钟主/从关系、音频格式和信号反转;snd_soc_dai_set_pll配置了时钟 PLL;snd_soc_dai_set_sysclk配置了时钟源;snd_soc_dai_set_clkdiv配置了时钟分频器。这些帮助程序将调用底层 DAI 驱动程序 ops 中的适当回调。例如,使用 CPU DAI 调用snd_soc_dai_set_fmt()将调用此 CPU DAI 的dai->driver->ops->set_fmt回调。

以下是可以分配给 DAI 或dai_link.format字段的实际格式/标志列表:

  • snd_soc_dai_set_fmt()

A) SND_SOC_DAIFMT_CBM_CFM: CPU 是位时钟和帧同步的从设备。这也意味着编解码器是两者的主机。

b) SND_SOC_DAIFMT_CBS_CFS。CPU 是位时钟和帧同步的主机。这也意味着编解码器对两者都是从设备。

c) SND_SOC_DAIFMT_CBM_CFS。CPU 是位时钟的从设备,帧同步的主机。这也意味着编解码器是前者的主机,后者的从设备。

B) SND_SOC_DAIFMT_DSP_A: 帧同步为 1 位时钟宽,1 位延迟。

b) SND_SOC_DAIFMT_DSP_B: 帧同步为 1 位时钟宽,0 位延迟。此格式可用于 TDM 协议。

c) SND_SOC_DAIFMT_I2S: 帧同步为 1 个音频字宽,1 位延迟,I2S 模式。

d) SND_SOC_DAIFMT_RIGHT_J: 右对齐模式。

e) SND_SOC_DAIFMT_LEFT_J: 左对齐模式。

f) SND_SOC_DAIFMT_DSP_A: 帧同步为 1 位时钟宽,1 位延迟。

g) SND_SOC_DAIFMT_AC97: AC97 模式。

h) SND_SOC_DAIFMT_PDM: 脉冲密度调制。

i) SND_SOC_DAIFMT_DSP_B: 帧同步为 1 位时钟宽,1 位延迟。

C) SND_SOC_DAIFMT_NB_NF: 正常位时钟,正常帧同步。CPU 发射器在位时钟的下降沿上移出数据,接收器在上升沿上采样数据。CPU 帧同步发生器在帧同步的上升沿上开始帧。建议在 CPU 端使用此参数进行 I2S。

b) SND_SOC_DAIFMT_NB_IF: 正常位时钟,反转帧同步。CPU 发射器在位时钟的下降沿上移出数据,接收器在上升沿上采样数据。CPU 帧同步发生器在帧同步的下降沿上开始帧。

c) SND_SOC_DAIFMT_IB_NF:反转位时钟,正常帧同步。CPU 发射器在位时钟的上升沿上移出数据,接收器在下降沿上采样数据。CPU 帧同步生成器在帧同步的上升沿上启动帧。

d) SND_SOC_DAIFMT_IB_IF:反转位时钟,反转帧同步。CPU 发射器在位时钟的上升沿上移出数据,接收器在下降沿上采样数据。CPU 帧同步生成器在帧同步的下降沿上启动帧。此配置可用于 PCM 模式(例如蓝牙或基于调制解调器的音频芯片)。

  • snd_soc_dai_set_sysclk()。以下是方向参数,让 ALSA 知道使用的时钟:

a) SND_SOC_CLOCK_IN:这意味着 sysclock 使用内部时钟。

b) SND_SOC_CLOCK_OUT:这意味着 sysclock 使用外部时钟。

  • snd_soc_dai_set_clkdiv()

dai_link->dai_fmt字段中设置或分配给机器驱动程序的编解码器或 CPU DAIs 的可能值如上所示。以下是典型的hw_param()实现:

static int foo_hw_params(struct snd_pcm_substream *substream,
                          struct snd_pcm_hw_params *params)
{
    struct snd_soc_pcm_runtime *rtd = substream->private_data;
    struct snd_soc_dai *codec_dai = rtd->codec_dai;
    struct snd_soc_dai *cpu_dai = rtd->cpu_dai;
    unsigned int pll_out = 24000000;
    int ret = 0;
    /* set the cpu DAI configuration */
    ret = snd_soc_dai_set_fmt(cpu_dai, SND_SOC_DAIFMT_I2S |
                              SND_SOC_DAIFMT_NB_NF |                               SND_SOC_DAIFMT_CBM_CFM);
    if (ret < 0)
        return ret;
    /* set codec DAI configuration */
    ret = snd_soc_dai_set_fmt(codec_dai, SND_SOC_DAIFMT_I2S |
                              SND_SOC_DAIFMT_NB_NF |                               SND_SOC_DAIFMT_CBM_CFM);
    if (ret < 0)
        return ret;
    /* set the codec PLL */
    ret = snd_soc_dai_set_pll(codec_dai, WM8994_FLL1, 0,
                          pll_out, params_rate(params) * 256);
    if (ret < 0)
        return ret;
    /* set the codec system clock */
    ret = snd_soc_dai_set_sysclk(codec_dai, WM8994_SYSCLK_FLL1,
                  params_rate(params) * 256, SND_SOC_CLOCK_IN);
    if (ret < 0)
        return ret;
    return 0;
}

foo_hw_params()函数的上述实现中,我们可以看到编解码器和平台 DAI 是如何配置的,包括格式和时钟设置。现在我们来到机器驱动程序实现的最后一步,即注册音频声卡,这是系统上执行音频操作的设备。

声卡注册

声卡在内核中表示为struct snd_soc_card的实例,定义如下:

struct snd_soc_card {
    const char *name;
    struct module *owner;
    [...]
    /* callbacks */
    int (*set_bias_level)(struct snd_soc_card *,
                          struct snd_soc_dapm_context *dapm,
                          enum snd_soc_bias_level level);
    int (*set_bias_level_post)(struct snd_soc_card *,
                             struct snd_soc_dapm_context *dapm,
                             enum snd_soc_bias_level level);
    [...]
    /* CPU <--> Codec DAI links	*/
    struct snd_soc_dai_link *dai_link;
    int num_links;
    const struct snd_kcontrol_new *controls;
    int num_controls;
    const struct snd_soc_dapm_widget *dapm_widgets;
    int num_dapm_widgets;
    const struct snd_soc_dapm_route *dapm_routes;
    int num_dapm_routes;
    const struct snd_soc_dapm_widget *of_dapm_widgets;
    int num_of_dapm_widgets;
    const struct snd_soc_dapm_route *of_dapm_routes;
    int num_of_dapm_routes;
[...]
};

为了便于阅读,仅列出了相关字段,完整定义可以在elixir.bootlin.com/linux/v4.19/source/include/sound/soc.h#L1010找到。也就是说,以下列表描述了我们列出的字段:

  • name是声卡的名称。

  • owner是此声卡的模块所有者。

  • dai_link是构成此声卡的 DAI 链接数组,num_links指定数组中的条目数。

  • controls是一个包含由机器驱动程序静态定义和设置的控件的数组,num_controls指定数组中的条目数。

  • dapm_widgets是一个包含由机器驱动程序静态定义和设置的 DAPM 小部件的数组,num_dapm_widgets指定数组中的条目数。

  • damp_routes是一个包含由机器驱动程序静态定义和设置的 DAPM 路由的数组,num_dapm_routes指定数组中的条目数。

  • of_dapm_widgets表示从 DT(通过snd_soc_of_parse_audio_simple_widgets())提供的 DAPM 小部件,num_of_dapm_widgets是小部件条目的实际数量。

  • of_dapm_routes表示从 DT(通过snd_soc_of_parse_audio_routing())提供的 DAPM 路由,num_of_dapm_routes是路由条目的实际数量。

在设置完声卡结构之后,可以通过机器使用devm_snd_soc_register_card()方法进行注册,其原型如下:

int devm_snd_soc_register_card(struct device *dev,
                               struct snd_soc_card *card);

在上述原型中,dev表示用于管理卡的基础设备,card是先前设置的实际声卡数据结构。此函数在成功时返回0。但是,当调用此函数时,将会探测每个组件驱动程序和 DAI 驱动程序。因此,将为每个成功探测到的 DAI 链接创建一个新的 PCM 设备。

以下摘录(来自 Rockchip 机器 ASoC 驱动程序,用于使用 MAX90809 CODEC 的板子,实现在内核源码中的sound/soc/rockchip/rockchip_max98090.c中)将展示整个声卡的创建过程,从小部件到路由,再到 DAI 链接配置。让我们首先定义这个机器的小部件和控件,以及用于配置 CPU 和编解码器 DAI 的回调函数:

static const struct snd_soc_dapm_widget rk_dapm_widgets[] = { 
    [...]
};
static const struct snd_soc_dapm_route rk_audio_map[] = {
    [...]
};
static const struct snd_kcontrol_new rk_mc_controls[] = {
    SOC_DAPM_PIN_SWITCH("Headphone"),
    SOC_DAPM_PIN_SWITCH("Headset Mic"),
    SOC_DAPM_PIN_SWITCH("Int Mic"),
    SOC_DAPM_PIN_SWITCH("Speaker"),
};
static const struct snd_soc_ops rk_aif1_ops = {
    .hw_params = rk_aif1_hw_params,
};
static struct snd_soc_dai_link rk_dailink = {
    .name = "max98090",
    .stream_name = "Audio",
    .codec_dai_name = "HiFi",
    .ops = &rk_aif1_ops,
    /* set max98090 as slave */
    .dai_fmt = SND_SOC_DAIFMT_I2S | SND_SOC_DAIFMT_NB_NF |
                 SND_SOC_DAIFMT_CBS_CFS,
};

在上面的摘录中,可以看到原始代码实现文件中的rk_aif1_hw_params。现在是用于构建声卡的数据结构,定义如下:

static struct snd_soc_card snd_soc_card_rk = {
    .name = "ROCKCHIP-I2S",
    .owner = THIS_MODULE,
    .dai_link = &rk_dailink,
    .num_links = 1,
    .dapm_widgets = rk_dapm_widgets,
    .num_dapm_widgets = ARRAY_SIZE(rk_dapm_widgets),
    .dapm_routes = rk_audio_map,
    .num_dapm_routes = ARRAY_SIZE(rk_audio_map),
    .controls = rk_mc_controls,
    .num_controls = ARRAY_SIZE(rk_mc_controls),
};

这个声卡最终是在驱动程序的probe方法中创建的:

static int snd_rk_mc_probe(struct platform_device *pdev)
{
    int ret = 0;
    struct snd_soc_card *card = &snd_soc_card_rk;
    struct device_node *np = pdev->dev.of_node;
[...]
    card->dev = &pdev->dev;
    /* Assign codec, cpu and platform node */
    rk_dailink.codec_of_node = of_parse_phandle(np,
                                  "rockchip,audio-codec", 0);
    rk_dailink.cpu_of_node = of_parse_phandle(np,
                                "rockchip,i2s-controller", 0);
    rk_dailink.platform_of_node = rk_dailink.cpu_of_node;
[...]
    ret = snd_soc_of_parse_card_name(card, "rockchip,model");
    ret = devm_snd_soc_register_card(&pdev->dev, card);
[...]
}

再次强调,前面三个代码块都是从sound/soc/rockchip/rockchip_max98090.c中摘录的。到目前为止,我们已经了解了机器驱动程序的主要目的,即将 Codec 和 CPU 驱动程序绑定在一起,并定义音频路径。也就是说,有时我们可能需要更少的代码。这些情况涉及到既不需要特殊黑客的 CPU 也不需要特殊黑客的板子。在这种情况下,ASoC 框架提供了simple-card 机器驱动程序,在下一节中介绍。

利用 simple-card 机器驱动程序

有时候,您的板子不需要来自 Codec 或 CPU DAI 的任何黑客。ASoC 核心提供了simple-audio机器驱动程序,可以用来从 DT 描述整个声卡。以下是这样一个节点的摘录:

sound {
    compatible ="simple-audio-card";
    simple-audio-card,name ="VF610-Tower-Sound-Card";
    simple-audio-card,format ="left_j";
    simple-audio-card,bitclock-master = <&dailink0_master>;
    simple-audio-card,frame-master = <&dailink0_master>;
    simple-audio-card,widgets ="Microphone","Microphone Jack",
                               "Headphone","Headphone Jack",                              
                               "Speaker","External Speaker";
    simple-audio-card,routing = "MIC_IN","Microphone Jack",
                                "Headphone Jack","HP_OUT",
                                "External Speaker","LINE_OUT";
    simple-audio-card,cpu {
        sound-dai = <&sh_fsi20>;
    };
    dailink0_master: simple-audio-card,codec {
        sound-dai = <&ak4648>;
        clocks = <&osc>;
    };
};

这完全记录在Documentation/devicetree/bindings/sound/simple-card.txt中。在上面的摘录中,我们可以看到指定了机器小部件和路由映射,以及引用的编解码器和 CPU 节点。现在我们熟悉了 simple-card 机器驱动程序,我们可以利用它,并尽可能地不编写自己的机器驱动程序。话虽如此,有些情况下编解码器设备无法分离,这改变了机器应该编写的方式。这样的音频设备称为无编解码器声卡,在下一节中我们将讨论它们。

无编解码器声卡

可能会出现从外部系统采样数字音频数据的情况,例如使用 SPDIF 接口时,数据因此被预格式化。在这种情况下,声卡注册是相同的,但 ASoC 核心需要意识到这种特殊情况。

通过输出,DAI 链接对象的.capture_only字段应该是false,而.playback_only应该是true。输入应该做相反的操作。此外,机器驱动程序必须将 DAI 链接的codec_dai_namecodec_name设置为"snd-soc-dummy-dai""snd-soc-dummy"。例如,这是imx-spdif机器驱动程序(sound/soc/fsl/imx-spdif.c)的情况,其中包含以下摘录:

data->dai.name = "S/PDIF PCM";
data->dai.stream_name = "S/PDIF PCM";
data->dai.codecs->dai_name = "snd-soc-dummy-dai";
data->dai.codecs->name = "snd-soc-dummy";
data->dai.cpus->of_node = spdif_np;
data->dai.platforms->of_node = spdif_np;
data->dai.playback_only = true;
data->dai.capture_only = true;
if (of_property_read_bool(np, "spdif-out"))
    data->dai.capture_only = false;
if (of_property_read_bool(np, "spdif-in"))
    data->dai.playback_only = false;
if (data->dai.playback_only && data->dai.capture_only) {
    dev_err(&pdev->dev, "no enabled S/PDIF DAI link\n");
    goto end;
}

您可以在Documentation/devicetree/bindings/sound/imx-audio-spdif.txt中找到此驱动程序的绑定文档。在机器类驱动程序研究结束时,我们已经完成了整个 ASoC 类驱动程序的开发。在这个机器类驱动程序中,除了在代码中绑定 CPU 和 Codec 以及提供设置回调之外,我们还看到了如何通过使用 simple-card 机器驱动程序并在设备树中实现其余部分来避免编写代码。

总结

在本章中,我们已经了解了 ASoC 机器类驱动程序的架构,这代表了 ASoC 系列中的最后一个元素。我们已经学会了如何绑定平台和子设备驱动程序,以及如何为音频数据定义路由。

在下一章中,我们将介绍另一个 Linux 媒体子系统,即 V4L2,用于处理视频设备。

第七章:解密 V4L2 和视频捕获设备驱动程序

视频一直是嵌入式系统中固有的。鉴于 Linux 是这些系统中常用的内核,可以毫不夸张地说它本身就原生支持视频。这就是所谓的V4L2,代表Video 4 (for) Linux 2。是的!2是因为有第一个版本,V4L。V4L2 通过内存管理功能和其他元素增强了 V4L,使得该框架尽可能通用。通过这个框架,Linux 内核能够处理摄像头设备和它们连接的桥接器,以及相关的 DMA 引擎。这些并不是 V4L2 支持的唯一元素。我们将从框架架构的介绍开始,了解它的组织方式,并浏览它包括的主要数据结构。然后,我们将学习如何设计和编写桥接设备驱动程序,负责 DMA 操作,最后,我们将深入研究子设备驱动程序。因此,在本章中,将涵盖以下主题:

  • 框架架构和主要数据结构

  • 视频桥设备驱动程序

  • 子设备的概念

  • V4L2 控制基础设施

技术要求

本章的先决条件如下:

框架架构和主要数据结构

视频设备变得越来越复杂。在这种设备中,硬件通常包括多个集成 IP,需要以受控的方式相互合作,这导致复杂的 V4L2 驱动程序。这要求在深入代码之前弄清楚架构,这正是本节要解决的要求。

众所周知,驱动程序通常在编程中反映硬件模型。在 V4L2 上下文中,各种 IP 组件被建模为称为子设备的软件块。V4L2 子设备通常是仅内核对象。此外,如果 V4L2 驱动程序实现了媒体设备 API(我们将在下一章[第八章](B10985_08_ePub_AM.xhtml#_idTextAnchor342)中讨论,与 V4L2 异步和媒体控制器框架集成),这些子设备将自动继承自媒体实体,允许应用程序枚举子设备并使用媒体框架的实体、端口和链接相关的枚举 API 来发现硬件拓扑。

尽管使子设备可发现,驱动程序也可以决定以简单的方式使其可由应用程序配置。当子设备驱动程序和 V4L2 设备驱动程序都支持此功能时,子设备将在其上调用ioctls(输入/输出控制)的字符设备节点,以便查询、读取和写入子设备功能(包括控制),甚至在单个子设备端口上协商图像格式。

在驱动程序级别,V4L2 为驱动程序开发人员做了很多工作,因此他们只需实现与硬件相关的代码并注册相关设备。在继续之前,我们必须介绍构成 V4L2 核心的几个重要结构:

  • struct v4l2_device:硬件设备可能包含多个子设备,例如电视卡以及捕获设备,可能还有 VBI 设备或 FM 调谐器。v4l2_device是所有这些设备的根节点,负责管理所有子设备。

  • struct video_device:此结构的主要目的是提供众所周知的/dev/videoX/dev/v4l-subdevX设备节点。此结构主要抽象了捕获接口,也称为/dev/v4l-subdevX节点及其文件操作。在子设备驱动程序中,只有核心访问底层子设备中的这个结构。

  • struct vb2_queue:对我来说,这是视频驱动程序中的主要数据结构,因为它在数据流逻辑和 DMA 操作的中心部分中使用,以及struct vb2_v4l2_buffer

  • struct v4l2_subdev:这是负责实现特定功能并在 SoC 的视频系统中抽象特定功能的子设备。

struct video_device可以被视为所有设备和子设备的基类。当我们编写自己的驱动程序时,对这个数据结构的访问可能是直接的(如果我们正在处理桥接驱动程序)或间接的(如果我们正在处理子设备,因为子设备 API 抽象和隐藏了嵌入到每个子设备数据结构中的底层struct video_device)。

现在我们知道了这个框架由哪些数据结构组成。此外,我们介绍了它们的关系和各自的目的。现在是时候深入了解细节,介绍如何初始化和注册 V4L2 设备到系统中了。

初始化和注册 V4L2 设备

在被使用或成为系统的一部分之前,V4L2 设备必须被初始化和注册,这是本节的主要内容。一旦框架架构描述完成,我们就可以开始阅读代码了。在这个内核中,V4L2 设备是struct v4l2_device结构的一个实例。这是媒体框架中的最高数据结构,维护着媒体管道由哪些子设备组成,并充当桥接设备的父级。V4L2 驱动程序应该包括<media/v4l2-device.h>,这将引入struct v4l2_device的以下定义:

struct v4l2_device {
    struct device *dev;
    struct media_device *mdev;
    struct list_head subdevs;
    spinlock_t lock;
    char name[V4L2_DEVICE_NAME_SIZE];
    void (*notify)(struct v4l2_subdev *sd,
                   unsigned int notification, void *arg);
    struct v4l2_ctrl_handler *ctrl_handler;
    struct v4l2_prio_state prio;
    struct kref ref;
    void (*release)(struct v4l2_device *v4l2_dev);
};

与我们将在以下部分介绍的其他与视频相关的数据结构不同,此结构中只有少数字段。它们的含义如下:

  • dev是指向此 V4L2 设备的父struct device的指针。这将在注册时自动设置,dev->driver_data将指向这个v4l2结构。

  • mdev是指向此 V4L2 设备所属的struct media_device对象的指针。这个字段涉及媒体控制器框架,并将在相关部分介绍。如果不需要与媒体控制器框架集成,则可能为NULL

  • subdevs是此 V4L2 设备的子设备列表。

  • lock是保护对此结构的访问的锁。

  • name是此 V4L2 设备的唯一名称。默认情况下,它是从驱动程序名称加上总线 ID 派生的。

  • notify是指向通知回调的指针,由子设备调用以通知此 V4L2 设备某些事件。

  • ctrl_handler是与此设备关联的控制处理程序。它跟踪此 V4L2 设备拥有的所有控件。如果没有控件,则可能为NULL

  • prio是设备的优先级状态。

  • ref是核心用于引用计数的内部使用。

  • release是当此结构的最后一个用户退出时要调用的回调函数。

这个顶层结构通过相同的函数v4l2_device_register()初始化并注册到核心,其原型如下:

int v4l2_device_register(struct device *dev,
                         struct v4l2_device *v4l2_dev);

第一个dev参数通常是桥接总线相关设备数据结构的 struct device 指针。即pci_devusb_deviceplatform_device

如果dev->driver_data字段为NULL,此函数将使其指向正在注册的实际v4l2_dev对象。此外,如果v4l2_dev->name为空,则将设置为从dev driver name + dev device name的连接结果。

但是,如果 dev 参数为 NULL,则在调用 v4l2_device_register() 之前必须设置 v4l2_dev->name。另一方面,可以使用 v4l2_device_unregister() 注销先前注册的 V4L2 设备,如下所示:

v4l2_device_unregister(struct v4l2_device *v4l2_dev);

调用此函数时,所有子设备也将被注销。这一切都与 V4L2 设备有关。但是,您应该记住,它是顶层结构,维护媒体设备的子设备列表,并充当桥接设备的父级。

现在我们已经完成了主要的 V4L2 设备(包含其他设备相关数据结构的设备)的初始化和注册,我们可以引入特定的设备驱动程序,从桥接驱动程序开始,这是特定于平台的。

引入视频设备驱动程序 - 桥接驱动程序

桥接驱动程序控制平台 /USB/PCI/... 硬件,负责 DMA 传输。这是处理从设备进行数据流的驱动程序。桥接驱动程序直接处理的主要数据结构之一是 struct video_device。此结构嵌入了执行视频流所需的整个元素,它与用户空间的第一个交互之一是在 /dev/ 目录中创建设备文件。

struct video_device 结构在 include/media/v4l2-dev.h 中定义,这意味着驱动程序代码必须包含 #include <media/v4l2-dev.h>。以下是在定义它的头文件中看到的这个结构的样子:

struct video_device
{
#if defined(CONFIG_MEDIA_CONTROLLER)
    struct media_entity entity;
    struct media_intf_devnode *intf_devnode;
    struct media_pipeline pipe;
#endif
    const struct v4l2_file_operations *fops;
    u32 device_caps;
    struct device dev; struct cdev *cdev;
    struct v4l2_device *v4l2_dev;
    struct device *dev_parent;
    struct v4l2_ctrl_handler *ctrl_handler;
    struct vb2_queue *queue;
    struct v4l2_prio_state *prio;
    char name[32];
    enum vfl_devnode_type vfl_type;
    enum vfl_devnode_direction vfl_dir;
    int minor;
    u16 num;
    unsigned long flags; int index;
    spinlock_t fh_lock;
    struct list_head fh_list;
    void (*release)(struct video_device *vdev);
    const struct v4l2_ioctl_ops *ioctl_ops;
    DECLARE_BITMAP(valid_ioctls, BASE_VIDIOC_PRIVATE);
    struct mutex *lock;
};

不仅桥接驱动程序可以操作此结构 - 当涉及表示 V4L2 兼容设备(包括子设备)时,此结构是主要的 v4l2 结构。但是,根据驱动程序的性质(无论是桥接驱动程序还是子设备驱动程序),某些元素可能会有所不同或可能为 NULL。以下是结构中每个元素的描述:

  • entityintf_nodepipe 是与媒体框架集成的一部分,我们将在同名部分中看到。前者从媒体框架内部抽象出视频设备(成为实体),而 intf_node 表示媒体接口设备节点,pipe 表示实体所属的流水线。

  • fops 表示视频设备文件节点的文件操作。V4L2 核心通过一些子系统所需的额外逻辑覆盖虚拟设备文件操作。

  • cdev 是字符设备结构,抽象出底层的 /dev/videoX 文件节点。vdev->cdev->ops 由 V4L2 核心设置为 v4l2_fops(在 drivers/media/v4l2-core/v4l2-dev.c 中定义)。v4l2_fops 实际上是一个通用的(在实现的操作方面)和面向 V4L2 的(在这些操作所做的方面)文件操作,分配给每个 /dev/videoX 字符设备,并包装在 vdev->fops 中定义的视频设备特定操作。在它们的返回路径上,v4l2_fops 中的每个回调将调用 vdev->fops 中的对应项。v4l2_fops 回调在调用 vdev->fops 中的真实操作之前执行一些合理性检查。例如,在用户空间对 /dev/videoX 文件发出的 mmap() 系统调用上,将首先调用 v4l2_fops->mmap,这将确保在调用之前设置了 vdev->fops->mmap,并在需要时打印调试消息。

  • ctrl_handler:默认值为 vdev->v4l2_dev->ctrl_handler

  • queue 是与此设备节点关联的缓冲区管理队列。这是桥接驱动程序唯一可以操作的数据结构之一。这可能是 NULL,特别是当涉及非桥接视频驱动程序(例如子设备)时。

  • prio 是指向具有设备优先级状态的 &struct v4l2_prio_state 的指针。如果此状态为 NULL,则将使用 v4l2_dev->prio

  • name 是视频设备的名称。

  • vfl_type 是 V4L 设备类型。可能的值由 enum vfl_devnode_type 定义,包括以下内容:

  • VFL_TYPE_GRABBER:用于视频输入/输出设备

VFL_TYPE_VBI:用于垂直空白数据(未解码)

VFL_TYPE_RADIO:用于无线电卡

VFL_TYPE_SUBDEV:用于 V4L2 子设备

VFL_TYPE_SDR:软件定义无线电

VFL_TYPE_TOUCH:用于触摸传感器

  • vfl_dir 是一个 V4L 接收器、发射器或内存到内存(表示为 m2m 或 mem2mem)设备。可能的值由 enum vfl_devnode_direction 定义,包括以下内容:

VFL_DIR_RX:用于捕获设备

VFL_DIR_TX:用于输出设备

VFL_DIR_M2M:应该是 mem2mem 设备(读取内存到内存,也称为内存到内存设备)。mem2mem 设备是使用用户空间应用程序传递的内存缓冲区作为源和目的地的设备。这与当前和现有的仅使用其中一个的内存缓冲区的驱动程序不同。这样的设备在 V4L2 框架中不存在,但是存在对这种模型的需求,例如,用于 '调整器设备' 或 V4L2 回环驱动程序。

  • v4l2_dev 是此视频设备的 v4l2_device 父设备。

  • dev_parent 是此视频设备的设备父级。如果未设置,核心将使用 vdev->v4l2_dev->dev 进行设置。

  • ioctl_ops 是指向 &struct v4l2_ioctl_ops 的指针,它定义了一组 ioctl 回调。

  • release 是核心在视频设备的最后一个用户退出时调用的回调。这必须是非-NULL

  • lock 是一个互斥锁,用于串行访问此设备。这是主要的串行化锁,通过它所有的 ioctls 都被串行化。桥接驱动程序通常会使用相同的互斥锁设置此字段,就像 queue->lock 一样,这是用于串行化访问队列的锁(串行化流)。但是,如果设置了 queue->lock,那么流 ioctls 将由单独的锁串行化。

  • num 是核心分配的实际设备节点索引。它对应于 /dev/videoX 中的 X

  • flags 是视频设备的标志。您应该使用位操作来设置/清除/测试标志。它们包含一组 &enum v4l2_video_device_flags 标志。

  • fh_list 是一个 struct v4l2_fh 列表,描述了一个 V4L2 文件处理程序,可以跟踪为此视频设备打开的文件句柄的数量。fh_lock 是与此列表关联的锁。

  • class 对应于 sysfs 类。它由核心分配。此类条目对应于 /sys/video4linux/ sysfs 目录。

初始化和注册视频设备

在注册之前,视频设备可以动态分配,使用 video_device_alloc()(简单调用 kzalloc()),或者静态嵌入到动态分配的结构中,这是大多数情况下的设备状态结构。

视频设备是使用 video_device_alloc() 动态分配的,就像以下示例中一样:

struct video_device * vdev;
vdev = video_device_alloc();
if (!vdev)
    return ERR_PTR(-ENOMEM);
vdev->release = video_device_release;

在前面的摘录中,最后一行提供了视频设备的 release 方法,因为 .release 字段必须是非-NULL。内核提供了 video_device_release() 回调。它只调用 kfree() 来释放分配的内存。

当它嵌入到设备状态结构中时,代码变为如下:

struct my_struct {
    [...]
    struct video_device vdev;
};
[...]
struct my_struct *my_dev;
struct video_device *vdev;
my_dev =	kzalloc(sizeof(struct my_struct), GFP_KERNEL);
if (!my_dev)
    return ERR_PTR(-ENOMEM);
vdev = &my_vdev->vdev;
/* Now work with vdev as our video_device struct */
vdev->release = video_device_release_empty;
[...]

在这里,视频设备不能单独释放,因为它是一个更大的整体的一部分。当视频设备嵌入到另一个结构中时,就像前面的示例中一样,它不需要任何东西被释放。在这一点上,由于释放回调必须是非-NULL,我们可以分配一个空函数,例如 video_device_release_empty(),也由内核提供。

我们已经完成了分配。在这一点上,我们可以使用 video_register_device() 来注册视频设备。以下是此函数的原型:

int video_register_device(struct video_device *vdev,
                           enum vfl_devnode_type type, int nr)

在上述原型中,type 指定了要注册的桥接设备的类型。它将被分配给 vdev->vfl_type 字段。在本章的其余部分,我们将考虑将其设置为 VFL_TYPE_GRABBER,因为我们正在处理视频捕获接口。nr 是所需的设备节点号(0 == /dev/video01 == /dev/video1,...)。但是,将其值设置为 -1 将指示内核选择第一个空闲索引并使用它。指定固定索引可能对构建复杂的 udev 规则很有用,因为设备节点名称是预先知道的。为了使注册成功,必须满足以下要求:

  • 首先,必须 设置 vdev->release 函数,因为它不能是空的。如果不需要它,可以传递 V4L2 核心的空释放方法。

  • 其次,必须 设置 vdev->v4l2_dev 指针;它应该指向视频设备的 V4L2 父设备。

  • 最后,但不是强制的,您应该设置 vdev->fopsvdev->ioctl_ops

video_register_device() 在成功时返回 0。但是,如果没有空闲的次要设备,找不到设备节点号,或者设备节点的注册失败,它可能会失败。在任何错误情况下,它都会返回一个负的错误号。每个注册的视频设备都会在 /sys/class/video4linux 中创建一个目录条目,并在其中包含一些属性。

重要提示

次要号是动态分配的,除非内核使用内核选项 CONFIG_VIDEO_FIXED_MINOR_RANGES 进行编译。在这种情况下,次要号根据设备节点类型(视频、收音机等)分配在不同的范围内,总限制为 VIDEO_NUM_DEVICES,设置为 256

如果注册失败,vdev->release() 回调将永远不会被调用。在这种情况下,如果动态分配了 video_device 结构,您需要调用 video_device_release() 来释放它,或者如果 video_device 被嵌入其中,则释放您自己的结构。

在驱动程序卸载路径上,或者当不再需要视频节点时,您应该调用 video_unregister_device() 来注销视频设备,以便其节点可以被移除:

void video_unregister_device(struct video_device *vdev)

在上述调用之后,设备的 sysfs 条目将被移除,导致 udev 移除 /dev/ 中的节点。

到目前为止,我们只讨论了注册过程中最简单的部分,但是视频设备中还有一些复杂的字段需要在注册之前初始化。这些字段通过提供视频设备文件操作、一致的一组 ioctl 回调以及最重要的是媒体队列和内存管理接口来扩展驱动程序的功能。我们将在接下来的章节中讨论这些内容。

视频设备文件操作

视频设备(通过其驱动程序)旨在作为 /dev/ 目录中的特殊文件暴露给用户空间,用户空间可以使用它与底层设备进行交互:流式传输数据。为了使视频设备能够响应用户空间查询(通过系统调用),必须从驱动程序内部实现一组标准回调。这些回调形成了今天所知的 struct v4l2_file_operations 类型,定义在 include/media/v4l2-dev.h 中,如下所示:

struct v4l2_file_operations {
    struct module *owner;
    ssize_t (*read) (struct file *file, char user *buf,
                       size_t, loff_t *ppos);
    ssize_t (*write) (struct file *file, const char user *buf,
                       size_t, loff_t *ppos);
    poll_t (*poll) (struct file *file,
                      struct poll_table_struct *);
    long (*unlocked_ioctl) (struct file *file,
                          unsigned int cmd, unsigned long arg);
#ifdef CONFIG_COMPAT
     long (*compat_ioctl32) (struct file *file,
                          unsigned int cmd, unsigned long arg);
#endif
    unsigned long (*get_unmapped_area) (struct file *file,
                              unsigned long, unsigned long,
                              unsigned long, unsigned long);
    int (*mmap) (struct file *file,                  struct vm_area_struct *vma);
    int (*open) (struct file *file);
    int (*release) (struct file *file);
};

这些可以被视为顶层回调,因为它们实际上是由另一个低级设备文件操作调用的(当然,经过一些合理性检查),这次是与 vdev->cdev 字段相关联的低级设备文件操作,设置为 vdev->cdev->ops = &v4l2_fops; 在文件节点创建时。这允许内核实现额外的逻辑并强制执行合理性:

  • owner 是指向模块的指针。大多数情况下,它是 THIS_MODULE

  • open应包含实现open()系统调用所需的操作。大多数情况下,这可以设置为v4l2_fh_open,这是一个 V4L2 助手,简单地分配和初始化一个v4l2_fh结构,并将其添加到vdev->fh_list列表中。但是,如果您的设备需要一些额外的初始化,请在内部执行初始化,然后调用v4l2_fh_open(struct file * filp)。无论如何,您必须处理v4l2_fh_open

  • release应包含实现close()系统调用所需的操作。这个回调必须处理v4l2_fh_release。它可以设置为以下之一:

  • vb2_fop_release,这是一个 videobuf2-V4L2 释放助手,将清理任何正在进行的流。这个助手将调用v4l2_fh_release

  • 撤销.open中所做的工作的自定义回调,并且必须直接或间接调用v4l2_fh_release(例如,使用_vb2_fop_release()助手),以便 V4L2 核心处理任何正在进行的流的清理。

  • read应包含实现read()系统调用所需的操作。大多数情况下,videobuf2-V4L2 助手vb2_fop_read就足够了。

  • write在我们的情况下不需要,因为它是用于输出类型设备。但是,在这里使用vb2_fop_write可以完成工作。

  • 如果您使用v4l2_ioctl_ops,则必须将unlocked_ioctl设置为video_ioctl2。下一节将详细解释这一点。这个 V4L2 核心助手是__video_do_ioctl()的包装器,它处理真正的逻辑,并将每个 ioctl 路由到vdev->ioctl_ops中的适当回调,这是单独的 ioctl 处理程序定义的地方。

  • mmap应包含实现mmap()系统调用所需的操作。大多数情况下,videobuf2-V4L2 助手vb2_fop_mmap就足够了,除非在执行映射之前需要额外的元素。内核中的视频缓冲区(响应于VIDIOC_REQBUFSioctl 而分配)在被访问用户空间之前必须单独映射。这就是这个.mmap回调的目的,它只需要将一个视频缓冲区映射到用户空间。查询将缓冲区映射到用户空间所需的信息是使用VIDIOC_QUERYBUFioctl 向内核查询的。给定vma参数,您可以按如下方式获取指向相应视频缓冲区的指针:

struct vb2_queue *q = container_of_myqueue_wrapper();
unsigned long off = vma->vm_pgoff << PAGE_SHIFT;
struct vb2_buffer *vb;
unsigned int buffer = 0, plane = 0;
for (i = 0; i < q->num_buffers; i++) {
    struct vb2_buffer *buf = q->bufs[i];
    /* The below assume we are on a single-planar system,
     * else we would have loop over each plane
     */
    if (buf->planes[0].m.offset == off)
        break;
    return i;
}
videobuf_queue_unlock(myqueue);
  • poll应包含实现poll()系统调用所需的操作。大多数情况下,videobuf2-V4L2 助手vb2_fop_call就足够了。如果这个助手不知道如何锁定(queue->lockvdev->lock都没有设置),那么您不应该使用它,而应该编写自己的助手,可以依赖于不处理锁定的vb2_poll()助手。

在这两个回调中,您可以使用v4l2_fh_is_singular_file()助手来检查给定的文件是否是关联video_device的唯一文件句柄。它的替代方法是v4l2_fh_is_singular(),这次依赖于v4l2_fh

int v4l2_fh_is_singular_file(struct file *filp)
int v4l2_fh_is_singular(struct v4l2_fh *fh);

总之,捕获视频设备驱动程序的文件操作可能如下所示:

static int foo_vdev_open(struct file *file)
{
    struct mydev_state_struct *foo_dev = video_drvdata(file);
    int ret;
[...]
    if (!v4l2_fh_is_singular_file(file))
        goto fh_rel;
[...]
fh_rel:
    if (ret)
        v4l2_fh_release(file);
    return ret;
}
static int foo_vdev_release(struct file *file)
{
    struct mydev_state_struct *foo_dev = video_drvdata(file);
    bool fh_singular;
    int ret;
[...]
    fh_singular = v4l2_fh_is_singular_file(file);
    ret = _vb2_fop_release(file, NULL);
    if (fh_singular)
        /* do something */
        [...]
    return ret;
}
static const struct v4l2_file_operations foo_fops = {
    .owner = THIS_MODULE,
    .open = foo_vdev_open,
    .release = foo_vdev_release,
    .unlocked_ioctl = video_ioctl2,
    .poll = vb2_fop_poll,
    .mmap = vb2_fop_mmap,
    .read = vb2_fop_read,
};

您可以观察到,在前面的块中,我们在我们的文件操作中只使用了标准的核心助手。

重要提示

Mem2mem 设备可以使用它们相关的基于 v4l2-mem2mem 的助手。看看drivers/media/v4l2-core/v4l2-mem2mem.c

V4L2 ioctl 处理

让我们再谈谈v4l2_file_operations.unlocked_ioctl回调。正如我们在前一节中所看到的,它应该设置为video_ioctl2video_ioctl2负责在内核和用户空间之间进行参数复制,并在将每个单独的ioctl()调用分派到驱动程序之前执行一些合理性检查(例如,ioctl 命令是否有效),这最终会进入video_device->ioctl_ops字段中的回调条目,该字段是struct v4l2_ioctl_ops类型。

struct v4l2_ioctl_ops结构包含了 V4L2 框架中每个可能的 ioctl 的回调。然而,你应该根据你的设备类型和驱动程序的能力来设置这些回调。结构中的每个回调都映射一个 ioctl,结构定义如下:

struct v4l2_ioctl_ops {
    /* VIDIOC_QUERYCAP handler */
    int (*vidioc_querycap)(struct file *file, void *fh,
                            struct v4l2_capability *cap);
    /* Buffer handlers */
    int (*vidioc_reqbufs)(struct file *file, void *fh,
                           struct v4l2_requestbuffers *b);
    int (*vidioc_querybuf)(struct file *file, void *fh,
                            struct v4l2_buffer *b);
    int (*vidioc_qbuf)(struct file *file, void *fh,
                        struct v4l2_buffer *b);
    int (*vidioc_expbuf)(struct file *file, void *fh,
                          struct v4l2_exportbuffer *e);
    int (*vidioc_dqbuf)(struct file *file, void *fh,
                          struct v4l2_buffer *b);
    int (*vidioc_create_bufs)(struct file *file, void *fh,
                               struct v4l2_create_buffers *b);
    int (*vidioc_prepare_buf)(struct file *file, void *fh,
                               struct v4l2_buffer *b);
    int (*vidioc_overlay)(struct file *file, void *fh,
                           unsigned int i);
[...]
};

这个结构有超过 120 个条目,描述了每一个可能的 V4L2 ioctl 的操作,无论设备类型是什么。在前面的摘录中,只列出了我们可能感兴趣的部分。我们不会在这个结构中引入回调。然而,当你到达[第九章](B10985_09_ePub_AM.xhtml#_idTextAnchor396),从用户空间利用 V4L2 API时,我鼓励你回到这个结构,事情会更清楚。

也就是说,因为你提供了一个回调,它仍然是可访问的。有些情况下,你可能希望忽略在v4l2_ioctl_ops中指定的回调。如果基于外部因素(例如使用的卡),你希望在v4l2_ioctl_ops中关闭某些功能而不必创建新的结构,那么就需要这样做。为了让核心意识到这一点并忽略回调,你应该在调用video_register_device()之前对相关的 ioctl 命令调用v4l2_disable_ioctl()

v4l2_disable_ioctl (vdev, cmd)

以下是一个例子:v4l2_disable_ioctl(&tea->vd, VIDIOC_S_HW_FREQ_SEEK);。前面的调用将标记tea->vd视频设备上的VIDIOC_S_HW_FREQ_SEEKioctl 为被忽略。

videobuf2 接口和 API

videobuf2 框架用于连接 V4L2 驱动程序层和用户空间层,提供了一个数据交换通道,可以分配和管理视频帧数据。videobuf2 内存管理后端是完全模块化的。这允许为具有非标准内存管理要求的设备和平台插入自定义内存管理例程,而无需更改高级缓冲区管理函数和 API。该框架提供以下功能:

  • 实现流式 I/O V4L2 ioctls 和文件操作

  • 高级视频缓冲区、视频队列和状态管理功能

  • 视频缓冲区内存分配和管理

Videobuf2(或者只是 vb2)促进了驱动程序的开发,减少了驱动程序的代码大小,并有助于在驱动程序中正确和一致地实现 V4L2 API。然后 V4L2 驱动程序负责从传感器(通常通过某种 DMA 控制器)获取视频数据,并将其提供给由 vb2 框架管理的缓冲区。

这个框架实现了许多 ioctl 函数,包括缓冲区分配、入队、出队和数据流控制。然后废弃了任何特定于供应商的解决方案,大大减少了媒体框架代码的大小,并减轻了编写 V4L2 设备驱动程序所需的工作量。

重要提示

每个 videobuf2 助手、API 和数据结构都以vb2_为前缀,而版本 1(videobuf,定义在drivers/media/v4l2-core/videobuf-core.c中)的对应物使用了videobuf_前缀。

这个框架包括一些可能对你们一些人来说很熟悉的概念,但仍然需要详细讨论。

缓冲区的概念

缓冲区是在 vb2 和用户空间之间进行单次数据交换的单位。从用户空间代码的角度来看,V4L2 缓冲区代表与视频帧对应的数据(例如,在捕获设备的情况下)。流式传输涉及在内核和用户空间之间交换缓冲区。vb2 使用struct vb2_buffer数据结构来描述视频缓冲区。该结构在include/media/videobuf2-core.h中定义如下:

struct vb2_buffer {
    struct vb2_queue *vb2_queue;
    unsigned int index;
    unsigned int type;
    unsigned int memory;
    unsigned int num_planes;
    u64 timestamp;
    /* private: internal use only
     *
     * state: current buffer state; do not change
     * queued_entry: entry on the queued buffers list, which
     * holds all buffers queued from userspace
     * done_entry: entry on the list that stores all buffers
     * ready to be dequeued to userspace
     * vb2_plane: per-plane information; do not change
     */
    enum vb2_buffer_state state;
    struct vb2_plane planes[VB2_MAX_PLANES];
    struct list_head queued_entry;
    struct list_head done_entry;
[...]
};

在前面的数据结构中,我们已经删除了对我们没有兴趣的字段。剩下的字段定义如下:

  • vb2_queue是这个缓冲区所属的vb2队列。这将引导我们进入下一节,介绍根据 videobuf2 的队列概念。

  • index是这个缓冲区的 ID。

  • type是缓冲区的类型。它由vb2在分配时设置。它与其所属队列的类型匹配:vb->type = q->type

  • memory是用于使缓冲区在用户空间可见的内存模型类型。此字段的值是enum vb2_memory类型,与其 V4L2 用户空间对应项enum v4l2_memory相匹配。此字段由vb2在缓冲区分配时设置,并报告了与vIDIOC_REQBUFS给定的v4l2_requestbuffers.memory字段分配的用户空间值的 vb2 等价项。可能的值包括以下内容:

  • VB2_MEMORY_MMAP:其在用户空间分配的等价物是V4L2_MEMORY_MMAP,表示缓冲区用于内存映射 I/O。

  • VB2_MEMORY_USERPTR:其在用户空间分配的等价物是V4L2_MEMORY_USERPTR,表示用户在用户空间分配缓冲区,并通过v4l2_bufferbuf.m.userptr成员传递指针。V4L2 中USERPTR的目的是允许用户直接通过malloc()或静态方式传递在用户空间分配的缓冲区。

  • VB2_MEMORY_DMABUF。其在用户空间分配的等价物是V4L2_MEMORY_DMABUF,表示内存由驱动程序分配并导出为 DMABUF 文件处理程序。这个 DMABUF 文件处理程序可以在另一个驱动程序中导入。

  • stateenum vb2_buffer_state类型,表示此视频缓冲区的当前状态。驱动程序可以使用void vb2_buffer_done(struct vb2_buffer *vb, enum vb2_buffer_state state) API 来更改此状态。可能的状态值包括以下内容:
  • VB2_BUF_STATE_DEQUEUED 表示缓冲区在用户空间控制之下。这是由 videobuf2 核心在VIDIOC_REQBUFS ioctl 的执行路径中设置的。

  • VB2_BUF_STATE_PREPARING 表示缓冲区正在 videobuf2 中准备。这个标志是由 videobuf2 核心在支持的驱动程序的VIDIOC_PREPARE_BUF ioctl 的执行路径中设置的。

  • VB2_BUF_STATE_QUEUED 表示缓冲区在 videobuf 中排队,但尚未在驱动程序中。这是由 videobuf2 核心在VIDIOC_QBUF ioctl 的执行路径中设置的。然而,如果驱动程序无法启动流,则驱动程序必须将所有缓冲区的状态设置为VB2_BUF_STATE_QUEUED。这相当于将缓冲区返回给 videobuf2。

  • VB2_BUF_STATE_ACTIVE 表示缓冲区实际上在驱动程序中排队,并可能在硬件操作(例如 DMA)中使用。驱动程序无需设置此标志,因为在调用缓冲区.buf_queue回调之前,核心会设置此标志。

  • VB2_BUF_STATE_DONE 表示驱动程序应在此缓冲区的 DMA 操作成功路径上设置此标志,以将缓冲区传递给 vb2。这意味着 videobuf2 核心从驱动程序返回缓冲区,但尚未将其出队到用户空间。

  • VB2_BUF_STATE_ERROR 与上述相同,但是对缓冲区的操作以错误结束,当它被出队时将向用户空间报告。

如果在阅读完后,缓冲区技能的概念对您来说显得复杂,那么我鼓励您先阅读第九章从用户空间利用 V4L2 API,然后再回到这里。

平面的概念

有些设备要求每个输入或输出视频帧的数据放在不连续的内存缓冲区中。在这种情况下,一个视频帧必须使用多个内存地址来寻址,换句话说,每个“平面”有一个指针。平面是当前帧的子缓冲区(或帧的一部分)。

因此,在单平面系统中,一个平面代表整个视频帧,而在多平面系统中,一个平面只代表视频帧的一部分。由于内存是不连续的,多平面设备使用 Scatter/Gather DMA。

队列的概念

队列是流处理的中心元素,是桥接驱动程序的 DMA 引擎相关部分。实际上,它是驱动程序向 videobuf2 介绍自己的元素。它帮助我们在驱动程序中实现数据流管理模块。队列通过以下结构表示:

struct vb2_queue {
    unsigned int type;
    unsigned int io_modes;
    struct device *dev;
    struct mutex *lock;
    const struct vb2_ops *ops;
    const struct vb2_mem_ops *mem_ops;
    const struct vb2_buf_ops *buf_ops;
    u32 min_buffers_needed;
    gfp_t gfp_flags;
    void *drv_priv;
    struct vb2_buffer *bufs[VB2_MAX_FRAME];
    unsigned int num_buffers;
    /* Lots of private and debug stuff omitted */
    [...]
};

结构应该被清零,并填写前面的字段。以下是结构中每个元素的含义:

  • type 是缓冲区类型。这应该使用include/uapi/linux/videodev2.h中定义的enum v4l2_buf_type中的一个值进行设置。在我们的情况下,这必须是V4L2_BUF_TYPE_VIDEO_CAPTURE

  • io_modes是描述可以处理的缓冲区类型的位掩码。可能的值包括以下内容:

  • VB2_MMAP:在内核中分配并通过mmap()访问的缓冲区;vmalloc'ed 和连续 DMA 缓冲区通常属于这种类型。

  • VB2_USERPTR:这是为用户空间分配的缓冲区。通常,只有可以进行散射/聚集 I/O 的设备才能处理用户空间缓冲区。然而,不支持对巨大页面的连续 I/O。有趣的是,videobuf2 支持用户空间分配的连续缓冲区。不过,唯一的方法是使用某种特殊机制,比如非树 Android pmem驱动程序。

  • VB2_READ, VB2_WRITE:这些是通过read()write()系统调用提供的用户空间缓冲区。

  • lock是用于流 ioctls 的串行化锁的互斥体。通常将此锁与video_device->lock相同,这是主要的串行化锁。但是,如果一些非流 ioctls 需要很长时间才能执行,那么您可能希望在这里使用不同的锁,以防止VIDIOC_DQBUF在等待另一个操作完成时被阻塞。

  • ops代表特定于驱动程序的回调,用于设置此队列和控制流操作。它是struct vb2_ops类型。我们将在下一节详细讨论这个结构。

  • mem_ops字段是驱动程序告诉 videobuf2 它实际使用的缓冲区类型的地方;它应该设置为vb2_vmalloc_memopsvb2_dma_contig_memopsvb2_dma_sg_memops中的一个。这是 videobuf2 实现的三种基本类型的缓冲区分配:

  • 第一种是vmalloc(),因此在内核空间中是虚拟连续的,不保证在物理上是连续的。

  • 第二种是vb2_mem_ops,以满足这种需求。没有限制。

  • 您可能不关心buf_ops,因为如果未设置,它由vb2核心提供。但是,它包含了在用户空间和内核空间之间传递缓冲区信息的回调。

  • min_buffers_needed是在开始流之前需要的最小缓冲区数量。如果这个值不为零,那么只有用户空间排队了至少这么多的缓冲区,vb2_queue->ops->start_streaming才会被调用。换句话说,它表示 DMA 引擎在启动之前需要有多少可用的缓冲区。

  • bufs是此队列中缓冲区的指针数组。它的最大值是VB2_MAX_FRAME,这对应于vb2核心允许每个队列的最大缓冲区数量。它被设置为32,这已经是一个相当可观的值。

  • num_buffers是队列中已分配/已使用的缓冲区数量。

特定于驱动程序的流回调

桥接驱动程序需要公开一系列函数来管理缓冲区队列,包括队列和缓冲区初始化。这些函数将处理来自用户空间的缓冲区分配、排队和与流相关的请求。这可以通过设置struct vb2_ops的实例来完成,定义如下:

struct vb2_ops {
    int (*queue_setup)(struct vb2_queue *q,
                       unsigned int *num_buffers,                        unsigned int *num_planes,
                       unsigned int sizes[],                        struct device *alloc_devs[]);
    void (*wait_prepare)(struct vb2_queue *q);
    void (*wait_finish)(struct vb2_queue *q);
    int (*buf_init)(struct vb2_buffer *vb);
    int (*buf_prepare)(struct vb2_buffer *vb);
    void (*buf_finish)(struct vb2_buffer *vb);
    void (*buf_cleanup)(struct vb2_buffer *vb);
    int (*start_streaming)(struct vb2_queue *q,                            unsigned int count);
    void (*stop_streaming)(struct vb2_queue *q);
    void (*buf_queue)(struct vb2_buffer *vb);
};

以下是结构中每个回调的目的:

  • queue_setup:此回调函数由驱动程序的v4l2_ioctl_ops.vidioc_reqbufs()方法调用(响应VIDIOC_REQBUFSVIDIOC_CREATE_BUFS ioctls),以调整缓冲区计数和大小。此回调的目标是通知 videobuf2-core 需要多少个缓冲区和每个缓冲区的平面,以及每个平面的大小和分配器上下文。换句话说,所选的 vb2 内存分配器调用此方法与驱动程序协商在流媒体期间使用的缓冲区和每个缓冲区的平面数量。3是一个很好的选择作为最小缓冲区数量,因为大多数 DMA 引擎至少需要队列中的2个缓冲区。此回调的参数定义如下:
  • qvb2_queue指针。

  • num_buffers是应用程序请求的缓冲区数量的指针。然后,驱动程序应在此*num_buffers字段中设置分配的缓冲区数量。由于此回调在协商过程中可能会被调用两次,因此应检查queue->num_buffers以了解在设置此值之前已分配的缓冲区数量。

  • num_planes包含保存帧所需的不同视频平面的数量。这应该由驱动程序设置。

  • sizes包含每个平面的大小(以字节为单位)。对于单平面系统,只需设置size[0]

  • alloc_devs是一个可选的每平面分配器特定设备数组。将其视为分配上下文的指针。

以下是queue_setup回调的示例:

/* Setup vb_queue minimum buffer requirements */
static int rcar_drif_queue_setup(struct vb2_queue *vq,
                             unsigned int *num_buffers,                             unsigned int *num_planes,
                             unsigned int sizes[],                              struct device *alloc_devs[])
{
    struct rcar_drif_sdr *sdr = vb2_get_drv_priv(vq);
    /* Need at least 16 buffers */
    if (vq->num_buffers + *num_buffers < 16)
        *num_buffers = 16 - vq->num_buffers;
    *num_planes = 1;
    sizes[0] = PAGE_ALIGN(sdr->fmt->buffersize);
    rdrif_dbg(sdr, "num_bufs %d sizes[0] %d\n",
              *num_buffers, sizes[0]);
    return 0;
}
  • buf_init在为缓冲区分配内存后或在新的USERPTR缓冲区排队后会被调用一次。例如,可以用来固定页面,验证连续性,并设置 IOMMU 映射。

  • buf_prepareVIDIOC_QBUF ioctl 的执行路径上被调用。它应该准备好缓冲区以排队到 DMA 引擎。缓冲区被准备好,并且用户空间虚拟地址或用户地址被转换为物理地址。

  • buf_finish在每个DQBUF ioctl 上被调用。例如,可以用于缓存同步和从反弹缓冲区复制回来。

  • buf_cleanup在释放内存之前调用。可以用于取消映射内存等。

  • buf_queue:videobuf2 核心在调用此回调之前在缓冲区中设置VB2_BUF_STATE_ACTIVE标志。但是,它是代表VIDIOC_QBUF ioctl 调用的。用户空间逐个排队缓冲区,一个接一个。此外,缓冲区可能会比桥接设备从捕获设备抓取数据到缓冲区的速度更快。与此同时,在发出VIDIOC_DQBUF之前可能会多次调用VIDIOC_QBUF。建议驱动程序维护一个排队用于 DMA 的缓冲区列表,以便在任何 DMA 完成时,填充的缓冲区被移出列表,同时通过填充其时间戳并将缓冲区添加到 videobuf2 的完成缓冲区列表中,如果需要,则更新 DMA 指针。粗略地说,此回调函数应将缓冲区添加到驱动程序的 DMA 队列中,并在该缓冲区上启动 DMA。与此同时,驱动程序通常会重新实现自己的缓冲区数据结构,建立在通用的vb2_v4l2_buffer结构之上,但添加一个列表以解决我们刚才描述的排队问题。以下是这样一个自定义缓冲区数据结构的示例:

struct dcmi_buf {
   struct vb2_v4l2_buffer vb;
   dma_addr_t paddr; /* the bus address of this buffer */
   size_t size;
   struct list_head list; /* list entry for tracking    buffers */
};
  • start_streaming启动了流式传输的 DMA 引擎。在开始流式传输之前,必须首先检查是否已排队了最少数量的缓冲区。如果没有,应返回-ENOBUFSvb2框架将在下次缓冲区排队时再次调用此函数,直到有足够的缓冲区可用于实际启动 DMA 引擎。如果支持以下操作,还应在子设备上启用流式传输:v4l2_subdev_call(subdev, video, s_stream, 1)。应从缓冲区队列中获取下一帧并在其上启动 DMA。通常,在捕获新帧后会发生中断。处理程序的工作是从内部缓冲区中删除新帧(使用list_del())并将其返回给vb2框架(通过vb2_buffer_done()),同时更新序列计数字段和时间戳。

  • stop_streaming停止所有待处理的 DMA 操作,停止 DMA 引擎,并释放 DMA 通道资源。如果支持以下操作,还应在子设备上禁用流式传输:v4l2_subdev_call(subdev, video, s_stream, 0)。如有必要,禁用中断。由于驱动程序维护了排队进行 DMA 的缓冲区列表,因此必须将该列表中排队的所有缓冲区以错误状态返回给 vb2。

初始化和释放 vb2 队列

为了使驱动程序完成队列初始化,应调用vb2_queue_init()函数,给定队列作为参数。但是,vb2_queue结构应首先由驱动程序分配。此外,驱动程序必须清除其内容并为一些必需的条目设置初始值,然后才能调用此函数。这些必需的值是q->opsq->mem_opsq->typeq->io_modes。否则,队列初始化将失败,如下所示的vb2_core_queue_init()函数将会被调用,并且从vb2_queue_init()中检查其返回值:

int vb2_core_queue_init(struct vb2_queue *q)
{
    /*
     * Sanity check
     */
    if (WARN_ON(!q) || WARN_ON(!q->ops) ||          WARN_ON(!q->mem_ops) ||
         WARN_ON(!q->type) || WARN_ON(!q->io_modes) ||
         WARN_ON(!q->ops->queue_setup) ||          WARN_ON(!q->ops->buf_queue))
        return -EINVAL;
    INIT_LIST_HEAD(&q->queued_list);
    INIT_LIST_HEAD(&q->done_list);
    spin_lock_init(&q->done_lock);
    mutex_init(&q->mmap_lock);
    init_waitqueue_head(&q->done_wq);
    q->memory = VB2_MEMORY_UNKNOWN;
    if (q->buf_struct_size == 0)
        q->buf_struct_size = sizeof(struct vb2_buffer);
    if (q->bidirectional)
        q->dma_dir = DMA_BIDIRECTIONAL;
    else
        q->dma_dir = q->is_output ? DMA_TO_DEVICE :         DMA_FROM_DEVICE;
    return 0;
}

上述摘录显示了内核中vb2_core_queue_init()的主体。这个内部 API 是一个纯基本的初始化方法,它只是进行一些合理性检查并初始化基本数据结构(列表、互斥锁和自旋锁)。

子设备的概念

在 V4L2 子系统的早期,只有两个主要的数据结构:

  • struct video_device:这是/dev/<type>X出现的结构。

  • struct vb2_queue:这负责缓冲区管理。

在那个时代,这已经足够了,因为嵌入视频桥的 IP 块并不多。如今,SoC 中的图像块嵌入了许多 IP 块,每个 IP 块都通过卸载特定任务来发挥特定作用,例如图像调整、图像转换和视频去隔行功能。为了使用模块化方法来解决这种多样性,引入了子设备的概念。这为硬件的软件建模带来了模块化方法,允许将每个硬件组件抽象为软件块。

采用这种方法,处理管道中的每个 IP 块(除了桥接设备)都被视为一个子设备,甚至包括摄像头传感器本身。桥接视频设备节点采用/dev/videoX模式,而子设备则采用/dev/v4l-subdevX模式(假设它们在创建节点之前已设置了适当的标志)。

重要说明

为了更好地理解桥接设备和子设备之间的区别,可以将桥接设备视为处理管道中的最终元素,有时负责 DMA 事务。一个例子是 Atmel-drivers/media/platform/atmel/atmel-isc.cSensor-->PFE-->WB-->CFA-->CC-->GAM-->CSC-->CBC-->SUB-->RLP-->DMA。鼓励您查看此驱动程序以了解每个元素的含义。

从编码的角度来看,驱动程序应包括<media/v4l-subdev.h>,该文件定义了struct v4l2_subdev结构,该结构是用于在内核中实例化子设备的抽象数据结构。此结构定义如下:

struct v4l2_subdev {
#if defined(CONFIG_MEDIA_CONTROLLER)
    struct media_entity entity;
#endif
    struct list_head list; 
    struct module *owner;
    bool owner_v4l2_dev;
    u32 flags;
    struct v4l2_device *v4l2_dev;
    const struct v4l2_subdev_ops *ops;
[...]
    struct v4l2_ctrl_handler *ctrl_handler;
    char name[V4L2_SUBDEV_NAME_SIZE];
    u32 grp_id; void *dev_priv;
    void *host_priv;
    struct video_device *devnode;
    struct device *dev;
    struct fwnode_handle *fwnode;
    struct device_node *of_node;
    struct list_head async_list;
    struct v4l2_async_subdev *asd;
    struct v4l2_async_notifier *notifier;
    struct v4l2_async_notifier *subdev_notifier;
    struct v4l2_subdev_platform_data *pdata;
};

此结构的entity字段将在下一章第八章**,与 V4L2 异步和媒体控制器框架集成中讨论。与此同时,我们不感兴趣的字段已被删除。

但是,结构中的其他字段定义如下:

  • listlist_head类型,并由核心用于将当前子设备插入v4l2_device维护的子设备列表中。

  • owner由核心设置,表示拥有此结构的模块。

  • flags表示驱动程序可以设置的子设备标志,可以具有以下值:

  • 如果此子设备实际上是 I2C 设备,则应设置V4L2_SUBDEV_FL_IS_I2C标志。

  • 如果此子设备是 SPI 设备,则应设置V4L2_SUBDEV_FL_IS_SPI

  • 如果子设备需要设备节点(著名的/dev/v4l-subdevX条目),则应设置V4L2_SUBDEV_FL_HAS_DEVNODE。使用此标志的 API 是v4l2_device_register_subdev_nodes(),稍后将讨论并由桥接调用以创建子设备节点条目。

  • V4L2_SUBDEV_FL_HAS_EVENTS表示此子设备生成事件。

  • v4l2_dev由核心在子设备注册时设置,并指向此子设备所属的struct 4l2_device的指针。

  • ops是可选的。这是指向struct v4l2_subdev_ops的指针,由驱动程序设置以提供核心可以依赖的此子设备的回调。

  • ctrl_handler是指向struct v4l2_ctrl_handler的指针。它表示此子设备提供的控件列表,我们将在V4L2 控件基础设施部分中看到。

  • name是子设备的唯一名称。在子设备初始化后,驱动程序应设置它。对于 I2C 变体的初始化,核心分配的默认名称是("%s %d-%04x", driver->name, i2c_adapter_id(client->adapter), client->addr)。在包括媒体控制器支持时,此名称用作媒体实体名称。

  • grp_id是驱动程序特定的,在异步模式下由核心提供,并用于对类似的子设备进行分组。

  • dev_priv是设备的私有数据指针(如果有的话)。

  • host_priv是指向设备的私有数据的指针,用于连接子设备的设备。

  • devnode是此子设备的设备节点,由核心在调用v4l2_device_register_subdev_nodes()时设置,不要与基于相同结构构建的桥接设备混淆。您应该记住,每个v4l2元素(无论是子设备还是桥接)都是视频设备。

  • dev是指向物理设备的指针(如果有的话)。驱动程序可以使用void v4l2_set_subdevdata(struct v4l2_subdev *sd, void *p)设置此值,或者可以使用void *v4l2_get_subdevdata(const struct v4l2_subdev *sd)获取它。

  • fwnode是此子设备的固件节点对象句柄。在较旧的内核版本中,此成员曾经是struct device_node *of_node,并指向struct fwnode_handle,因为它允许根据在平台上使用的设备树节点/ACPI 设备进行切换。换句话说,它是dev->of_node->fwnodedev->fwnode,以非NULL的方式。

async_listasdsubdev_notifiernotifier元素是 v4l2-async 框架的一部分,我们将在下一节中看到。但是,这里提供了这些元素的简要描述:

  • async_list:当与异步核心注册时,此成员由核心用于将此子设备链接到全局subdev_list(这是一个孤立子设备的列表,不属于任何通知程序,这意味着此子设备在其父级桥之前注册)或其父级桥的notifier->done列表。我们将在下一章中详细讨论这一点,第八章**,与 V4L2 异步和媒体控制器框架集成

  • asd:此字段是struct v4l2_async_subdev类型,并在异步核心中抽象了这个子设备。

  • subdev_notifier:这是由此子设备隐式注册的通知程序,以防需要通知其他子设备的探测。它通常用于涉及多个子设备的流水线的系统,其中子设备 N 需要被通知子设备 N-1 的探测。

  • notifier:这是由异步核心设置的,并对应于其底层的.asd异步子设备匹配的通知程序。

  • pdata:这是子设备平台数据的常见部分。

子设备初始化

每个子设备驱动程序必须有一个struct v4l2_subdev结构,可以是独立的,也可以嵌入到更大和特定于设备的结构中。推荐第二种情况,因为它允许跟踪设备状态。以下是典型设备特定结构的示例:

struct mychip_struct {
    struct v4l2_subdev sd;
[...]
    /* device speific fields*/
[...]
};

在被访问之前,V4L2 子设备需要使用v4l2_subdev_init() API 进行初始化。然而,当涉及到具有基于 I2C 或 SPI 的控制接口(通常是摄像头传感器)的子设备时,内核提供了v4l2_spi_subdev_init()v4l2_i2c_subdev_init()变体:

void v4l2_subdev_init(struct v4l2_subdev *sd,
                       const struct v4l2_subdev_ops *ops)
void v4l2_i2c_subdev_init(struct v4l2_subdev *sd,
                       struct i2c_client *client,
                       const struct v4l2_subdev_ops *ops)
void v4l2_spi_subdev_init(struct v4l2_subdev *sd,
                          struct spi_device *spi,
                          const struct v4l2_subdev_ops *ops)

所有这些 API 都将struct v4l2_subdev结构的指针作为第一个参数。使用我们的设备特定数据结构注册我们的子设备将如下所示:

v4l2_i2c_subdev_init(&mychip_struct->sd, client, subdev_ops);
/*or*/
v4l2_subdev_init(&mychip_struct->sd, subdev_ops);

spi/i2c变体包装了v4l2_subdev_init()函数。此外,它们需要作为第二个参数的底层低级、特定于总线的结构。此外,这些特定于总线的变体将存储子设备对象(作为第一个参数给出)作为低级、特定于总线的设备数据,反之亦然,通过将低级、特定于总线的结构存储为子设备的私有数据。这样,i2c_client(或spi_device)和v4l2_subdev相互指向,这意味着通过拥有指向 I2C 客户端的指针,例如,您可以调用i2c_set_clientdata()(例如struct v4l2_subdev *sd = i2c_get_clientdata(client);)来获取指向我们内部子设备对象的指针,并使用container_of宏(例如struct mychip_struct *foo = container_of(sd, struct mychip_struct, sd);)来获取指向芯片特定结构的指针。另一方面,拥有指向子设备对象的指针,您可以使用v4l2_get_subdevdata()来获取底层特定于总线的结构。

最后但并非最不重要的是,这些特定于总线的变体将损坏子设备名称,就像在介绍struct v4l2_subdev数据结构时所解释的那样。v4l2_i2c_subdev_init()的摘录可以更好地理解这一点:

void v4l2_i2c_subdev_init(struct v4l2_subdev *sd,
                          struct i2c_client *client,
                          const struct v4l2_subdev_ops *ops)
{
   v4l2_subdev_init(sd, ops);
   sd->flags |= V4L2_SUBDEV_FL_IS_I2C;
   /* the owner is the same as the i2c_client's driver owner */
   sd->owner = client->dev.driver->owner;
   sd->dev = &client->dev;
   /* i2c_client and v4l2_subdev point to one another */     
   v4l2_set_subdevdata(sd, client);
   i2c_set_clientdata(client, sd);
   /* initialize name */
   snprintf(sd->name, sizeof(sd->name),
            "%s %d-%04x", client->dev.driver->name,    
            i2c_adapter_id(client->adapter), client->addr);
}

在前面三个初始化 API 中,ops是最后一个参数,是指向表示子设备公开/支持的操作的struct v4l2_subdev_ops的指针。然而,让我们在下一节中讨论这个问题。

子设备操作

子设备是以某种方式连接到主桥设备的设备。在整个媒体设备中,每个 IP(子设备)都有其自己的功能集。这些功能必须通过内核开发人员为常用功能定义的回调来向核心公开。这就是struct v4l2_subdev_ops的目的。

然而,一些子设备可以执行如此多不同和不相关的事情,以至于甚至 struct v4l2_subdev_ops 已经被分成小的和分类的一致的子结构操作,每个子结构操作都收集相关的功能,以便 struct v4l2_subdev_ops 成为顶级操作结构,描述如下:

struct v4l2_subdev_ops {
    const struct v4l2_subdev_core_ops          *core;
    const struct v4l2_subdev_tuner_ops         *tuner;
    const struct v4l2_subdev_audio_ops         *audio;
    const struct v4l2_subdev_video_ops         *video;
    const struct v4l2_subdev_vbi_ops           *vbi;
    const struct v4l2_subdev_ir_ops            *ir;
    const struct v4l2_subdev_sensor_ops        *sensor;
    const struct v4l2_subdev_pad_ops           *pad;
};

重要提示

操作应该只为用户空间公开的子设备提供,通过底层字符设备文件节点。注册时,该设备文件节点将具有与前面讨论的相同的文件操作,即 v4l2_fops。然而,正如我们之前所看到的,这些低级操作只是包装(处理)video_device->fops。因此,为了达到 v4l2_subdev_ops,核心使用 subdev->video_device->fops 作为中间,并在初始化时分配另一个文件操作(subdev->vdev->fops = &v4l2_subdev_fops;),它将包装并调用真正的子设备操作。这里的调用链是 v4l2_fops ==> v4l2_subdev_fops ==> our_custom_subdev_ops

您可以看到前面的顶级操作结构由指向类别操作结构的指针组成,如下所示:

  • v4l2_subdev_core_ops 类型的 core:这是核心操作类别,提供通用的回调,比如日志记录和调试。它还允许提供额外和自定义的 ioctls(特别是当 ioctl 不适用于任何类别时非常有用)。

  • v4l2_subdev_video_ops 类型的 video.s_stream 在流媒体开始时被调用。它根据所选择的帧大小和格式向摄像头的寄存器写入不同的配置值。

  • v4l2_subdev_pad_ops 类型的 pad:对于支持多个帧大小和图像采样格式的摄像头,这些操作允许用户从可用选项中进行选择。

  • tuneraudiovbiir 超出了本书的范围。

  • v4l2_subdev_sensor_ops 类型的 sensor:这涵盖了摄像头传感器操作,通常用于已知有错误的传感器,需要跳过一些帧或行,因为它们已损坏。

每个类别结构中的每个回调对应一个 ioctl。路由实际上是由 subdev_do_ioctl() 在低级别执行的,该函数在 drivers/media/v4l2-core/v4l2-subdev.c 中定义,并间接地由 subdev_ioctl() 调用,对应于 v4l2_subdev_fops.unlocked_ioctl。真正的调用链应该是 v4l2_fops ==> v4l2_subdev_fops.unlocked_ioctl ==> our_custom_subdev_ops

这个顶级 struct v4l2_subdev_ops 结构的性质只是确认了 V4L2 可能支持的设备范围有多广。对于子设备驱动程序不感兴趣的操作类别可以保持 NULL。还要注意,.core 操作对所有子设备都是通用的。这并不意味着它是强制性的;它只是意味着任何类别的子设备驱动程序都可以实现 .core 操作,因为它的回调是与类别无关的。

struct v4l2_subdev_core_ops

这个结构实现了通用的回调,并具有以下定义:

struct v4l2_subdev_core_ops {
    int (*log_status)(struct v4l2_subdev *sd);
    int (*load_fw)(struct v4l2_subdev *sd);
    long (*ioctl)(struct v4l2_subdev *sd, unsigned int cmd,
                   void *arg);
[...]
#ifdef CONFIG_COMPAT
    long (*compat_ioctl32)(struct v4l2_subdev *sd,                            unsigned int cmd, 
                           unsigned long arg);
#endif
#ifdef CONFIG_VIDEO_ADV_DEBUG
   int (*g_register)(struct v4l2_subdev *sd,
                     struct v4l2_dbg_register *reg);
   int (*s_register)(struct v4l2_subdev *sd,
                     const struct v4l2_dbg_register *reg);
#endif
   int (*s_power)(struct v4l2_subdev *sd, int on);
   int (*interrupt_service_routine)(struct v4l2_subdev *sd,
                                    u32 status,                                     bool *handled);
   int (*subscribe_event)(struct v4l2_subdev *sd,                           struct v4l2_fh *fh,
                          struct v4l2_event_subscription *sub);
   int (*unsubscribe_event)(struct v4l2_subdev *sd,
                          struct v4l2_fh *fh,                           struct v4l2_event_subscription *sub);
};

在前面的结构中,我们已经删除了对我们不感兴趣的字段。剩下的字段定义如下:

  • .log_status 用于记录目的。您应该使用 v4l2_info() 宏来实现这一点。

  • .s_power 将子设备(例如摄像头)置于省电模式(on==0)或正常操作模式(on==1)。

  • .load_fw 操作必须被调用以加载子设备的固件。

  • 如果子设备提供额外的 ioctl 命令,应该定义 .ioctl

  • .g_register.s_register 仅用于高级调试,需要设置内核配置选项 CONFIG_VIDEO_ADV_DEBUG。这些操作允许读取和写入硬件寄存器,以响应 VIDIOC_DBG_G_REGISTERVIDIOC_DBG_S_REGISTER ioctls。reg 参数(类型为 v4l2_dbg_register,在 include/uapi/linux/videodev2.h 中定义)由应用程序填充和提供。

  • .interrupt_service_routine由桥接器在其 IRQ 处理程序中调用(应使用v4l2_subdev_call),当由于此子设备而引发中断状态时,以便子设备处理详细信息。handled是桥接驱动程序提供的输出参数,但必须由子设备驱动程序填充,以便通知(作为true 或 false)其处理结果。我们处于 IRQ 上下文中,因此不能休眠。位于 I2C/SPI 总线后面的子设备可能应该在线程化的上下文中安排其工作。

  • .subscribe_event.unsubscribe_event用于订阅或取消订阅控制更改事件。请查看其他实现此功能的 V4L2 驱动程序,以了解如何实现您的驱动程序。

struct v4l2_subdev_video_ops 或 struct v4l2_subdev_pad_ops

人们经常需要决定是否实现struct v4l2_subdev_video_opsstruct v4l2_subdev_pad_ops,因为这两个结构中的一些回调是多余的。问题是,当 V4L2 设备以视频模式打开时,struct v4l2_subdev_video_ops结构的回调被使用,其中包括电视、摄像头传感器和帧缓冲区。到目前为止,一切顺利。struct v4l2_subdev_pad_ops的概念也不需要。然而,媒体控制器框架通过实体对象(稍后我们将看到)抽象了子设备,通过 PAD 连接到其他元素。在这种情况下,使用与 PAD 相关的功能而不是与子设备相关的功能是有意义的,因此,使用struct v4l2_subdev_pad_ops而不是struct v4l2_subdev_video_ops

由于我们还没有介绍媒体框架,所以我们只对struct v4l2_subdev_video_ops结构感兴趣,其定义如下:

struct v4l2_subdev_video_ops {
    int (*querystd)(struct v4l2_subdev *sd, v4l2_std_id *std);
[...]
    int (*s_stream)(struct v4l2_subdev *sd, int enable);
    int (*g_frame_interval)(struct v4l2_subdev *sd,
                  struct v4l2_subdev_frame_interval *interval);
    int (*s_frame_interval)(struct v4l2_subdev *sd,
                  struct v4l2_subdev_frame_interval *interval);
[...]
};

在上述摘录中,为了便于阅读,我删除了与电视和视频输出相关的回调,以及与摄像头设备无关的回调,这对我们也没有什么用。对于常用的回调,它们的定义如下:

  • querystd:这是VIDIOC_QUERYSTD()ioctl 处理程序代码的回调。

  • s_stream:用于通知驱动程序视频流将开始或已停止,取决于enable参数的值。

  • g_frame_interval:这是VIDIOC_SUBDEV_G_FRAME_INTERVAL()ioctl 处理程序代码的回调。

  • s_frame_interval:这是VIDIOC_SUBDEV_S_FRAME_INTERVAL()ioctl 处理程序代码的回调。

struct v4l2_subdev_sensor_ops

当传感器开始流式传输时,有些传感器会产生初始垃圾帧。这样的传感器可能需要一些时间来确保其某些属性的稳定性。该结构使得可以通知核心跳过多少帧以避免垃圾。此外,一些传感器可能始终在顶部产生一定数量的损坏行的图像,或者在这些行中嵌入它们的元数据。在这两种情况下,它们产生的帧始终是损坏的。该结构还允许我们指定在抓取每帧之前要跳过的行数。

以下是v4l2_subdev_sensor_ops结构的定义:

struct v4l2_subdev_sensor_ops {
    int (*g_skip_top_lines)(struct v4l2_subdev *sd,                             u32 *lines);
    int (*g_skip_frames)(struct v4l2_subdev *sd, u32 *frames);
};

g_skip_top_lines用于指定传感器每幅图像中要跳过的行数,而g_skip_frames允许我们指定要跳过的初始帧数,以避免垃圾,如以下示例所示:

#define OV5670_NUM_OF_SKIP_FRAMES	2
static int ov5670_get_skip_frames(struct v4l2_subdev *sd,                                   u32 *frames)
{
    *frames = OV5670_NUM_OF_SKIP_FRAMES;
    return 0;
}

linesframes参数是输出参数。每个回调应返回0

调用子设备操作

最后,如果提供了subdev回调,则打算调用它们。也就是说,调用 ops 回调就像直接调用它一样简单,如下所示:

err = subdev->ops->video->s_stream(subdev, 1);

然而,有一种更方便和更安全的方法可以实现这一点,即使用v4l2_subdev_call()宏:

err = v4l2_subdev_call(subdev, video, s_stream, 1);

include/media/v4l2-subdev.h中定义的宏将执行以下操作:

  • 它将首先检查子设备是否为NULL,否则返回-ENODEV

  • 如果类别(subdev->video在我们的示例中)或回调本身(subdev->video->s_stream在我们的示例中)为NULL,则它将返回-ENOIOCTLCMD,或者它将返回subdev->ops->video->s_stream操作的实际结果。

还可以调用所有或部分子设备:

v4l2_device_call_all(dev, 0, core, g_chip_ident, &chip);

不支持此回调的任何子设备都将被跳过,错误结果将被忽略。如果要检查错误,请使用以下命令:

err = v4l2_device_call_until_err(dev, 0, core,                                  g_chip_ident, &chip);

除了-ENOIOCTLCMD之外的任何错误都将以该错误退出循环。如果没有错误(除了- ENOIOCTLCMD)发生,则返回0

传统子设备(取消)注册

有两种方式可以将子设备注册到桥接设备,取决于媒体设备的性质:

  1. 同步模式:这是传统的方法。在这种模式下,桥接驱动程序负责注册子设备。子设备驱动程序要么是从桥接驱动程序中实现的,要么您必须找到一种方法让桥接驱动程序获取其负责的子设备的句柄。这通常是通过平台数据实现的,或者通过桥接驱动程序公开一组 API,这些 API 将被子设备驱动程序使用,从而允许桥接驱动程序了解这些子设备(例如通过在私有内部列表中跟踪它们)。使用这种方法,桥接驱动程序必须了解连接到它的子设备,并确切地知道何时注册它们。这通常适用于内部子设备,例如 SoC 内的视频数据处理单元或复杂的 PCI(e)板,或者 USB 摄像头中的摄像头传感器或连接到 SoC。

  2. 异步模式:这是关于子设备信息独立于桥接设备向系统提供的情况,这通常是基于设备树的系统的情况。这将在下一章中讨论,第八章与 V4L2 异步和媒体控制器框架集成

但是,为了桥接驱动程序注册子设备,必须调用v4l2_device_register_subdev(),而必须调用v4l2_device_unregister_subdev()来注销此子设备。同时,在将子设备注册到核心后,可能需要为具有设置V4L2_SUBDEV_FL_HAS_DEVNODE标志的子设备创建它们各自的字符文件节点/dev/v4l-subdevX。您可以使用v4l2_device_register_subdev_nodes()来实现这一点:

int v4l2_device_register_subdev(struct v4l2_device *v4l2_dev,
                                struct v4l2_subdev *sd)
void v4l2_device_unregister_subdev(struct v4l2_subdev *sd)
int v4l2_device_register_subdev_nodes(struct                                       v4l2_device *v4l2_dev)

v4l2_device_register_subdev()sd插入v4l2_dev->subdevs,这是由 V4L2 设备维护的子设备列表。如果subdev模块在注册之前消失,这可能会失败。成功调用此函数后,subdev->v4l2_dev字段指向v4l2_device。此函数在成功时返回0,或者v4l2_device_unregister_subdev()将从列表中取出sd。然后,v4l2_device_register_subdev_nodes()遍历v4l2_dev->subdevs,为每个具有设置V4L2_SUBDEV_FL_HAS_DEVNODE标志的子设备创建一个特殊的字符文件节点(/dev/v4l-subdevX)。

重要提示

/dev/v4l-subdevX设备节点允许直接控制子设备的高级和硬件特定功能。

现在我们已经了解了子设备的初始化、操作和注册,让我们在下一节中看看 V4L2 控件。

V4L2 控件基础设施

一些设备具有可由用户设置的控件,以修改一些定义的属性。其中一些控件可能支持预定义值列表、默认值、调整等。问题是,不同的设备可能提供具有不同值的不同控件。此外,虽然其中一些控件是标准的,但其他可能是特定于供应商的。控件框架的主要目的是向用户呈现控件,而不假设其目的。在本节中,我们只讨论标准控件。

控件框架依赖于两个主要对象,都在include/media/v4l2- ctrls.h中定义,就像该框架提供的其他数据结构和 API 一样。第一个是struct v4l2_ctrl。这个结构描述了控件的属性,并跟踪控件的值。第二个和最后一个是struct v4l2_ctrl_handler,它跟踪所有的控件。它们的详细定义在这里呈现:

struct v4l2_ctrl_handler {
    [...]
    struct mutex *lock;
    struct list_head ctrls;
    v4l2_ctrl_notify_fnc notify;
    void *notify_priv;
    [...]
};

struct v4l2_ctrl_handler的前述定义摘录中,ctrls表示此处理程序拥有的控件列表。notify是一个通知回调,每当控件更改值时都会被调用。这个回调在持有处理程序的lock时被调用。最后,notify_priv是作为参数给出的上下文数据。接下来是struct v4l2_ctrl,定义如下:

struct v4l2_ctrl {
    struct list_head node;
    struct v4l2_ctrl_handler *handler;
    unsigned int is_private:1;
    [...]
    const struct v4l2_ctrl_ops *ops;
    u32 id;
    const char *name;
    enum v4l2_ctrl_type type;
    s64 minimum, maximum, default_value;
    u64 step;
    unsigned long flags; [...]
}

这个结构代表了控件本身,具有重要的成员。这些定义如下:

  • node用于将控件插入处理程序的控件列表中。

  • handler是此控件所属的处理程序。

  • opsstruct v4l2_ctrl_ops类型,并表示此控件的获取/设置操作。

  • id 是此控件的 ID。

  • name 是控件的名称。

  • minimummaximum分别是控件接受的最小值和最大值。

  • default_value是控件的默认值。

  • step是非菜单控件的递增/递减步长。

  • flags涵盖了控件的标志。虽然整个标志列表在include/uapi/linux/videodev2.h中定义,但一些常用的标志如下:

V4L2_CTRL_FLAG_DISABLED,表示控件被禁用

V4L2_CTRL_FLAG_READ_ONLY,用于只读控件

V4L2_CTRL_FLAG_WRITE_ONLY,用于只写控件

V4L2_CTRL_FLAG_VOLATILE,用于易失性控件

  • is_private,如果设置,将阻止此控件被添加到任何其他处理程序中。它使得此控件对最初添加它的处理程序私有。这可以用来防止将subdev控件可用于 V4L2 驱动程序控件。

重要提示

enum通常像一种菜单,因此称为菜单控件

V4L2 控件由唯一的 ID 标识。它们以V4L2_CID_为前缀,并且都在include/uapi/linux/v4l2-controls.h中可用。视频捕获设备支持的常见标准控件如下(以下列表不是详尽无遗的):

#define V4L2_CID_BRIGHTNESS        (V4L2_CID_BASE+0)
#define V4L2_CID_CONTRAST          (V4L2_CID_BASE+1)
#define V4L2_CID_SATURATION        (V4L2_CID_BASE+2)
#define V4L2_CID_HUE	(V4L2_CID_BASE+3)
#define V4L2_CID_AUTO_WHITE_BALANCE      (V4L2_CID_BASE+12)
#define V4L2_CID_DO_WHITE_BALANCE  (V4L2_CID_BASE+13)
#define V4L2_CID_RED_BALANCE (V4L2_CID_BASE+14)
#define V4L2_CID_BLUE_BALANCE      (V4L2_CID_BASE+15)
#define V4L2_CID_GAMMA       (V4L2_CID_BASE+16)
#define V4L2_CID_EXPOSURE    (V4L2_CID_BASE+17)
#define V4L2_CID_AUTOGAIN    (V4L2_CID_BASE+18)
#define V4L2_CID_GAIN  (V4L2_CID_BASE+19)
#define V4L2_CID_HFLIP (V4L2_CID_BASE+20)
#define V4L2_CID_VFLIP (V4L2_CID_BASE+21)
[...]
#define V4L2_CID_VBLANK  (V4L2_CID_IMAGE_SOURCE_CLASS_BASE + 1) #define V4L2_CID_HBLANK  (V4L2_CID_IMAGE_SOURCE_CLASS_BASE + 2) #define V4L2_CID_LINK_FREQ (V4L2_CID_IMAGE_PROC_CLASS_BASE + 1)

前面的列表只包括标准控件。要支持自定义控件,你应该根据控件的基类描述符添加其 ID,并确保 ID 不重复。要向驱动程序添加控件支持,控件处理程序应首先使用v4l2_ctrl_handler_init()宏进行初始化。这个宏接受要初始化的处理程序以及此处理程序可以引用的控件数量,如下原型所示:

v4l2_ctrl_handler_init(hdl, nr_of_controls_hint)

完成控件处理程序后,你可以调用v4l2_ctrl_handler_free()释放此控件处理程序的资源。一旦控件处理程序被初始化,就可以创建控件并将其添加到其中。对于标准的 V4L2 控件,你可以使用v4l2_ctrl_new_std()来分配和初始化新的控件:

struct v4l2_ctrl *v4l2_ctrl_new_std(                               struct v4l2_ctrl_handler *hdl,
                               const struct v4l2_ctrl_ops *ops,                               u32 id, s64 min, s64 max,                                u64 step, s64 def);

这个函数在大多数字段上都是基于控件 ID 的。然而对于自定义控件(这里不讨论),你应该使用v4l2_ctrl_new_custom()辅助函数。在前面的原型中,以下元素被定义如下:

  • hdl表示先前初始化的控件处理程序。

  • opsstruct v4l2_ctrl_ops类型,并表示控件操作。

  • id是控件 ID,定义为V4L2_CID_*

  • min是此控件可以接受的最小值。根据控件 ID,这个值可能会被核心修改。

  • max是此控件可以接受的最大值。根据控件 ID,这个值可能会被核心修改。

  • step 是控件的步进值。

  • def 是控件的默认值。

控件的目的是设置/获取。这是前面的 ops 参数的目的。这意味着在初始化控件之前,您应该首先定义将在设置/获取此控件的值时调用的操作。也就是说,整个控件列表可以由相同的操作处理。在这种情况下,操作回调将必须使用 switch ... case 来处理不同的控件。

正如我们之前所看到的,控件操作是 struct v4l2_ctrl_ops 类型,并被定义如下:

struct v4l2_ctrl_ops {
    int (*g_volatile_ctrl)(struct v4l2_ctrl *ctrl);
    int (*try_ctrl)(struct v4l2_ctrl *ctrl);
    int (*s_ctrl)(struct v4l2_ctrl *ctrl);
};

前面的结构由三个回调组成,每个都有特定的目的:

  • g_volatile_ctrl 获取给定控件的新值。只有在对易失性控件(由硬件自身更改,并且大部分时间是只读的,例如信号强度或自动增益)提供此回调才有意义。

  • try_ctrl,如果设置,将被调用来测试要应用的控件值是否有效。只有在通常的最小/最大/步长检查不足以时,提供此回调才有意义。

  • s_ctrl 被调用来设置控件的值。

可选地,您可以在控件处理程序上调用 v4l2_ctrl_handler_setup() 来设置此处理程序的控件为它们的默认值。这有助于确保硬件和驱动程序的内部数据结构保持同步:

int v4l2_ctrl_handler_setup(struct v4l2_ctrl_handler *hdl);

此函数遍历给定处理程序中的所有控件,并使用每个控件的默认值调用 s_ctrl 回调。

总结一下我们在整个 V4L2 控件接口部分所看到的内容,现在让我们更详细地研究一下 OV7740 摄像头传感器的驱动程序(位于 drivers/media/i2c/ov7740.c 中),特别是处理 V4L2 控件的部分。

首先,我们有控件 ops->sg_ctrl 回调的实现:

static int ov7740_get_volatile_ctrl(struct v4l2_ctrl *ctrl)
{
    struct ov7740 *ov7740 = container_of(ctrl->handler,
    struct ov7740, ctrl_handler);
    int ret;
    switch (ctrl->id) {
    case V4L2_CID_AUTOGAIN:
        ret = ov7740_get_gain(ov7740, ctrl);
        break;
    default:
        ret = -EINVAL;
        break;
    }
    return ret;
}

前面的回调只涉及 V4L2_CID_AUTOGAIN 的控件 ID。这是有意义的,因为增益值可能在 自动 模式下由硬件更改。此驱动程序实现了 ops->s_ctrl 控件如下:

static int ov7740_set_ctrl(struct v4l2_ctrl *ctrl)
{
    struct ov7740 *ov7740 =
             container_of(ctrl->handler, struct ov7740,                           ctrl_handler);
    struct i2c_client *client =     v4l2_get_subdevdata(&ov7740->subdev); 
    struct regmap *regmap = ov7740->regmap;
    int ret;
    u8 val = 0;
[...]
    switch (ctrl->id) {
    case V4L2_CID_AUTO_WHITE_BALANCE:
        ret = ov7740_set_white_balance(ov7740, ctrl->val); break;
    case V4L2_CID_SATURATION:
        ret = ov7740_set_saturation(regmap, ctrl->val); break;
    case V4L2_CID_BRIGHTNESS:
        ret = ov7740_set_brightness(regmap, ctrl->val); break;
    case V4L2_CID_CONTRAST:
        ret = ov7740_set_contrast(regmap, ctrl->val); break;
    case V4L2_CID_VFLIP:
        ret = regmap_update_bits(regmap, REG_REG0C,
                                 REG0C_IMG_FLIP, val); break;
    case V4L2_CID_HFLIP:
        val = ctrl->val ? REG0C_IMG_MIRROR : 0x00;
        ret = regmap_update_bits(regmap, REG_REG0C,
                                 REG0C_IMG_MIRROR, val);
        break;
    case V4L2_CID_AUTOGAIN:
        if (!ctrl->val)
            return ov7740_set_gain(regmap, ov7740->gain->val);
        ret = ov7740_set_autogain(regmap, ctrl->val); break;
    case V4L2_CID_EXPOSURE_AUTO:
        if (ctrl->val == V4L2_EXPOSURE_MANUAL)
        return ov7740_set_exp(regmap, ov7740->exposure->val);
        ret = ov7740_set_autoexp(regmap, ctrl->val); break;
    default:
        ret = -EINVAL; break;
    }
[...]
    return ret;
}

前面的代码块还展示了使用 V4L2_CID_EXPOSURE_AUTO 控件作为示例来实现菜单控件有多么容易,其可能的值在 enum v4l2_exposure_auto_type 中被枚举。最后,将用于控件创建的控件操作结构被定义如下:

static const struct v4l2_ctrl_ops ov7740_ctrl_ops = {
    .g_volatile_ctrl = ov7740_get_volatile_ctrl,
    .s_ctrl = ov7740_set_ctrl,
};

一旦定义,这个控件操作可以用来初始化控件。以下是 ov7740_init_controls() 方法(在 probe() 函数中调用)的摘录,为了可读性的目的而被修改和缩小:

static int ov7740_init_controls(struct ov7740 *ov7740)
{
[...]
    struct v4l2_ctrl *auto_wb;
    struct v4l2_ctrl *gain;
    struct v4l2_ctrl *vflip;
    struct v4l2_ctrl *auto_exposure;
    struct v4l2_ctrl_handler *ctrl_hdlr
    v4l2_ctrl_handler_init(ctrl_hdlr, 12);
    auto_wb = v4l2_ctrl_new_std(ctrl_hdlr, &ov7740_ctrl_ops,
                                V4L2_CID_AUTO_WHITE_BALANCE,                                 0, 1, 1, 1);
    vflip = v4l2_ctrl_new_std(ctrl_hdlr, &ov7740_ctrl_ops,
                              V4L2_CID_VFLIP, 0, 1, 1, 0);
    gain = v4l2_ctrl_new_std(ctrl_hdlr, &ov7740_ctrl_ops,
                             V4L2_CID_GAIN, 0, 1023, 1, 500);
    /* let's mark this control as volatile*/
    gain->flags |= V4L2_CTRL_FLAG_VOLATILE;
    contrast = v4l2_ctrl_new_std(ctrl_hdlr, &ov7740_ctrl_ops,
                                 V4L2_CID_CONTRAST, 0, 127,                                  1, 0x20);
    ov7740->auto_exposure =
                   v4l2_ctrl_new_std_menu(ctrl_hdlr,                                        &ov7740_ctrl_ops,
                                       V4L2_CID_EXPOSURE_AUTO,                                        V4L2_EXPOSURE_MANUAL,
                                       0, V4L2_EXPOSURE_AUTO);
[...]
    ov7740->subdev.ctrl_handler = ctrl_hdlr;
    return 0;
}

您可以看到控件处理程序被分配给子设备在前面函数的返回路径上。最后,在代码的某个地方(ov7740 的驱动程序在子设备的 v4l2_subdev_video_ops.s_stream 回调中执行此操作),您应该将所有控件设置为它们的默认值:

ret = v4l2_ctrl_handler_setup(ctrl_hdlr);
if (ret) {
    dev_err(&client->dev, "%s control init failed (%d)\n",
             __func__, ret);
   goto error;
}

有关 V4L2 控件的更多信息,请访问www.kernel.org/doc/html/v4.19/media/kapi/v4l2-controls.html

关于控件继承的说明

子设备驱动程序通常实现了桥接的 V4L2 驱动程序已经实现的控件。

当在 v4l2_subdevv4l2_device 上调用 v4l2_device_register_subdev() 并设置两者的 ctrl_handler 字段时,那么子设备的控件将被添加(通过 v4l2_ctrl_add_handler() 辅助函数,该函数将给定处理程序的控件添加到另一个处理程序中)到 v4l2_device 的控件中。已经由 v4l2_device 实现的子设备控件将被跳过。这意味着 V4L2 驱动程序可以始终覆盖 subdev 控件。

也就是说,控制可能对给定的子设备执行低级别的硬件特定操作,而子设备驱动程序可能不希望此控制对 V4L2 驱动程序可用(因此不会添加到其控制处理程序)。在这种情况下,子设备驱动程序必须将控件的is_private成员设置为1(或true)。这将使控制对子设备私有。

重要说明

即使子设备控件被添加到 V4L2 设备,它们仍然可以通过控制设备节点访问。

总结

在本章中,我们讨论了 V4L2 桥接设备驱动程序的开发,以及子设备的概念。我们了解了 V4L2 的架构,并且现在熟悉了它的数据结构。我们学习了 videobuf2 API,并且现在能够编写平台桥接设备驱动程序。此外,我们应该能够实现子设备操作,并利用 videobuf2 核心。

本章可以被视为一个大局的第一部分,因为下一章仍然涉及 V4L2,但我们将处理异步核心和与媒体控制器框架的集成。

第八章:与 V4L2 异步和媒体控制器框架集成

随着时间的推移,媒体支持已成为片上系统SoCs)的必备和销售论点,这些系统变得越来越复杂。这些媒体 IP 核的复杂性使得获取传感器数据需要软件建立整个管道(由多个子设备组成)。基于设备树的系统的异步性质意味着这些子设备的设置和探测并不是直接的。因此,异步框架应运而生,它解决了无序探测子设备的问题,以便在所有媒体子设备准备就绪时及时弹出媒体设备。最后但并非最不重要的是,由于媒体管道的复杂性,有必要找到一种简化其构成子设备配置的方法。因此,媒体控制器框架应运而生,它将整个媒体管道包装在一个单一元素中,即媒体设备。它带有一些抽象,其中之一是将每个子设备视为一个实体,具有接收端口、发送端口或两者兼有。

本章将重点介绍异步和媒体控制器框架的工作原理和设计,并且我们将通过它们的 API 来学习如何在Video4Linux2V4L2)设备驱动程序开发中利用它们。

换句话说,在本章中,我们将涵盖以下主题:

  • V4L2 异步接口和图形绑定的概念

  • V4L2 异步和图形导向的 API

  • V4L2 异步框架和 API

  • Linux 媒体控制器框架

技术要求

在本章中,您将需要以下元素:

V4L2 异步接口和图形绑定的概念

到目前为止,对于 V4L2 驱动程序开发,我们实际上并没有处理探测顺序。也就是说,我们考虑了同步方法,其中桥接设备驱动程序在它们的探测期间同步为所有子设备注册设备。然而,这种方法不能用于固有的异步和无序设备注册系统,例如扁平设备树。为了解决这个问题,我们目前所说的异步接口被引入。

采用这种新方法,桥接驱动程序注册子设备描述符和通知回调列表,子设备驱动程序注册它们即将探测或已成功探测的子设备。异步核心将负责匹配子设备与硬件描述符,并在找到匹配项时调用桥接驱动程序的回调。当子设备注销时,将调用另一个回调。异步子系统依赖于以一种特殊方式声明的设备,称为图形绑定,我们将在下一节中处理。

图形绑定

嵌入式系统具有一组减少的设备,其中一些是不可发现的。然而,设备树出现在画面中,允许从内核描述实际系统(从硬件角度)的描述。有时(如果不是总是),这些设备在某种程度上是相互连接的。

虽然设备树中指向其他节点的phandle属性可以用来描述简单和直接的连接,例如父/子关系,但无法对由多个互连组成的复合设备进行建模。有时,关系建模会导致相当完整的图形,例如 i.MX6 图像处理单元IPU),它本身是一个逻辑设备,但由多个物理 IP 块组成,它们的互连可能导致一个相当复杂的管道。

这就是所谓的开放固件OF图形介入的地方,以及它的 API 和一些新概念,即端口端点的概念:

  • 端口可以被视为设备中的接口(如 IP 块)。

  • 端点可以被视为一个垫,因为它描述了与远程端口的连接的一端。

然而,phandle属性仍然用于引用树中的其他节点。关于这方面的更多文档可以在Documentation/devicetree/bindings/graph.txt中找到。

端口和端点表示

端口是设备的接口。一个设备可以有一个或多个端口。端口由包含在其所属设备的节点中的端口节点表示。每个端口节点包含一个端点子节点,用于连接到该端口的一个或多个远程设备端口。这意味着单个端口可以连接到一个或多个远程设备的端口,并且每个链接必须由一个端点子节点表示。现在,如果一个设备节点包含多个端口,如果一个端口有多个端点,或者一个端口节点需要连接到选定的硬件接口,那么使用#address-cells#size-cellsreg属性的流行方案用于对节点进行编号。

以下摘录显示了如何使用#address-cells#size-cellsreg属性来处理这些情况:

device {
    ...
    #address-cells = <1>;
    #size-cells = <0>;
    port@0 {
        #address-cells = <1>;
        #size-cells = <0>;
        reg = <0>;
        endpoint@0 {
            reg = <0>;
            ...
        };
        endpoint@1 {
            reg = <1>;
            ...
        };
    };
    port@1 {
        reg = <1>;
        endpoint { ... };
    };
};

完整的文档可以在Documentation/devicetree/bindings/graph.txt中找到。现在我们已经完成了端口和端点的表示,我们需要学习如何将它们彼此连接,如下一节所述。

端点链接

为了将两个端点连接在一起,每个端点都应包含一个指向远程设备端口对应端点的remote-endpoint``phandle属性。反过来,远程端点应包含一个remote-endpoint属性。两个端点的remote-endpoint phandles 相互指向形成包含端口之间的链接,如下例所示:

device-1 {
    port {
        device_1_output: endpoint {
            remote-endpoint = <&device_2_input>;
        };
    };
};
device-2 {
    port {
        device_2_input: endpoint {
            remote-endpoint = <&device_1_output>;
        };
    };
}

在完全不谈论其 API 的情况下介绍图绑定概念将是浪费时间。让我们跳到与这种新绑定方法一起使用的 API。

V4L2 异步和面向图形的 API

这个部分的标题不应误导你,因为图绑定不仅仅是为了 V4L2 子系统。Linux DRM子系统也利用了它。也就是说,异步框架严重依赖设备树来描述媒体设备及其端点和连接,或者端点之间的链接以及它们的总线配置属性。

从 DT(of_graph_)API 到通用 fwnode 图 API(fwnode_graph_

fwnode图 API 是将仅基于设备树的 OF 图 API 更改为通用 API 的成功尝试,将 ACPI 和设备树 OF API 合并在一起,以获得统一和通用的 API。这通过使用相同的 API 扩展了 ACPI 的图概念。通过查看struct device_nodestruct acpi_device结构,您可以看到它们共同具有的成员:struct fwnode_handle fwnode

struct device_node {
[...]
    struct fwnode_handle fwnode;
[...]
};

前面的摘录代表了从设备树角度来看的设备节点,而以下内容与 ACPI 相关:

struct acpi_device	{
[...]
    struct fwnode_handle fwnode;
[...]
};

fwnode成员是struct fwnode_handle类型的,它是一个较低级别和通用的数据结构,抽象出device_nodeacpi_device,因为它们都继承自这个数据结构。这使得struct fwnode_handle成为图 API 同质化的良好客户端,以便端点(通过其fwnode_handle类型的字段)可以引用 ACPI 设备或基于 OF 的设备。这种抽象模型现在用于图 API 中,允许我们通过一个通用数据结构(如下所述的struct fwnode_endpoint)来抽象一个端点,该数据结构嵌入了指向struct fwnode_handle的指针,该指针可以引用 ACPI 或 OF 节点。除了通用性,这还允许与此端点相关的子设备可以是基于 ACPI 或 OF 的:

struct fwnode_endpoint {
    unsigned int port;
    unsigned int id;
    const struct fwnode_handle *local_fwnode;
};

这个结构使旧的struct of_endpoint结构过时,并且类型为device_node*的成员留出了fwnode_handle*类型的成员。在前面的结构中,local_fwnode指向相关的固件节点,port是端口号(即对应于port@0中的0port@1中的1),id是端点在端口内的索引(即对应于endpoint@0中的0endpoint@1中的1)。

V4L2 框架使用这个模型来通过struct v4l2_fwnode_endpoint对 V4L2 相关的端点进行抽象,该结构是建立在fwnode_endpoint之上的。

struct v4l2_fwnode_endpoint {
    struct fwnode_endpoint base;
    /*
     * Fields below this line will be zeroed by
     * v4l2_fwnode_endpoint_parse()
     */
    enum v4l2_mbus_type bus_type;
    union {
        struct v4l2_fwnode_bus_parallel parallel;
        struct v4l2_fwnode_bus_mipi_csi1 mipi_csi1;
        struct v4l2_fwnode_bus_mipi_csi2 mipi_csi2;
    } bus;
    u64 *link_frequencies;
    unsigned int nr_of_link_frequencies;
};

这个结构自内核 v4.13 以来就已经过时并取代了struct v4l2_of_endpoint,以前被 V4L2 用来表示base代表底层 ACPI 或设备节点的struct fwnode_endpoint结构。其他字段都是与 V4L2 相关的,如下:

  • bus_type是此子设备流数据的媒体总线类型。此成员的值确定应该用fwnode端点(设备树或 ACPI)中解析的总线属性填充哪个底层总线结构。可能的值在enum v4l2_mbus_type中列出,如下:
enum v4l2_mbus_type {
    V4L2_MBUS_PARALLEL,
    V4L2_MBUS_BT656,
    V4L2_MBUS_CSI1,
    V4L2_MBUS_CCP2,
    V4L2_MBUS_CSI2,
};
  • bus是表示媒体总线本身的结构。可能的值已经存在于联合体中,bus_type确定要考虑哪一个。这些总线结构都在include/media/v4l2-fwnode.h中定义。

  • link_frequencies是支持此链接的频率列表。

  • nr_of_link_frequencieslink_frequencies中元素的数量。

重要提示

在内核 v4.19 中,bus_type成员是根据fwnode中的bus-type属性来设置的。驱动程序可以检查读取的值并调整其行为。这意味着 V4L2 fwnode API 将始终基于此fwnode属性来解析策略。然而,从内核 v5.0 开始,驱动程序必须将此成员设置为预期的总线类型(在调用解析函数之前),然后将其与在fwnode中读取的bus-type属性的值进行比较,如果它们不匹配,则会引发错误。如果总线类型未知,或者驱动程序可以处理多种总线类型,则必须使用V4L2_MBUS_UNKNOWN值。从内核 v5.0 开始,此值也是enum v4l2_mbus_type的一部分。

在内核代码中,您可能会找到enum v4l2_fwnode_bus_type枚举类型。这是 V4L2 fwnode本地枚举类型,是全局enum v4l2_mbus_type枚举类型的对应物,它们的值相互映射。它们各自的值会随着代码的演变而保持同步。

然后,V4L2 相关的绑定需要额外的属性。这些属性的一部分用于构建v4l2_fwnode_endpoint,而另一部分用于构建底层的bus(实际上是媒体总线)结构。所有这些都在专门的与视频相关的绑定文档Documentation/devicetree/bindings/media/video-interfaces.txt中描述,我强烈建议查看。

以下是桥接(isc)和传感器子设备(mt9v032)之间的典型绑定:

&i2c1 {
    #address-cells = <1>;
    #size-cells = <0>;
    mt9v032@5c {
        compatible = "aptina,mt9v032";
        reg = <0x5c>;
        port {
            mt9v032_out: endpoint {
                remote-endpoint = <&isc_0>;
                link-frequencies =
                       /bits/ 64 <13000000 26600000 27000000>;
                hsync-active = <1>;
                vsync-active = <0>;
                pclk-sample = <1>;
            };
        };
    };
};
&isc {
    port {
        isc_0: endpoint@0 {
            remote-endpoint = <&mt9v032_out>;
            hsync-active = <1>;
            vsync-active = <0>;
            pclk-sample = <1>;
        };
    };
};

在前面的绑定中,hsync-activevsync-activelink-frequenciespclk-sample都是 V4L2 特定的属性,描述了媒体总线。它们的值在这里并不一致,并且实际上没有意义,但非常适合我们的学习目的。这段摘录很好地展示了端点和远程端点的概念;struct v4l2_fwnode_endpoint的使用在Linux 媒体控制器框架部分中有详细讨论。

重要提示

处理fwnode API 的 V4L2 部分称为v4l2_fwnode_,而第二个 API 集以v4l2_of_为前缀。请注意,在仅基于 OF 的 API 中,端点由struct of_endpoint表示,而与 V4L2 相关的端点由struct v4l2_of_endpoint表示。有一些 API 允许从基于 OF 的模型切换到基于fwnode的模型,反之亦然。

V4L2 fwnode和 V4L2 OF 完全可互操作。例如,使用 V4L2 fwnode的子设备驱动程序将与使用 V4L2 OF 的媒体设备驱动程序无需任何努力即可工作,反之亦然!但是,新驱动程序必须使用fwnode API,包括#include <media/v4l2- fwnode.h>,在切换到fwnode API 时应替换旧驱动程序中的#include <media/v4l2-of.h>

话虽如此,前面讨论过的struct fwnode_endpoint仅用于显示底层机制。我们完全可以跳过它,因为只有核心处理此数据结构。为了更通用的方法,您最好使用新的struct fwnode_handle,而不是使用struct device_node来引用设备的固件节点。这绝对确保了 DT 和 ACPI 绑定在驱动程序中使用相同的代码时是兼容/可互操作的。以下是新驱动程序中更改应该如何看起来的简短摘录:

-    struct device_node *of_node;
+    struct fwnode_handle *fwnode;
-    of_node = ddev->of_node;
+	fwnode = dev_fwnode(dev);

一些常见的fwnode节点相关的 API 如下:

[...]
struct fwnode_handle *fwnode_get_parent(
                           const struct fwnode_handle *fwnode);
struct fwnode_handle *fwnode_get_next_child_node(
                           const struct fwnode_handle *fwnode,
                           struct fwnode_handle *child);
struct fwnode_handle *fwnode_get_next_available_child_node(
                            const struct fwnode_handle *fwnode,
                            struct fwnode_handle *child);
#define fwnode_for_each_child_node(fwnode, child) \
    for (child = fwnode_get_next_child_node(fwnode, NULL); child; \
           child = fwnode_get_next_child_node(fwnode, child))
#define fwnode_for_each_available_child_node(fwnode, child) \
    for (child = fwnode_get_next_available_child_node(fwnode,                                                      NULL);          child; \
   child = fwnode_get_next_available_child_node(fwnode, child))
struct fwnode_handle *fwnode_get_named_child_node(
                            const struct fwnode_handle *fwnode,
                            const char *childname);
struct fwnode_handle *fwnode_handle_get(struct                                         fwnode_handle *fwnode);
void fwnode_handle_put(struct fwnode_handle *fwnode);

上述 API 具有以下描述:

  • fwnode_get_parent() 返回给定参数中fwnode值的节点的父句柄,否则返回NULL

  • fwnode_get_next_child_node() 以其父节点作为第一个参数,并在此父节点中给定子节点(作为第二个参数)之后返回下一个子节点(或NULL)。如果child(第二个参数)为NULL,则将返回此父节点的第一个子节点。

  • fwnode_get_next_available_child_node()fwnode_get_next_child_node()相同,但确保设备在返回fwnode句柄之前实际存在(已成功探测)。

  • fwnode_for_each_child_node() 遍历给定节点(第一个参数)中的子节点,并使用第二个参数作为迭代器。

  • fwnode_for_each_available_child_nodefwnode_for_each_child_node()相同,但只遍历实际存在于系统上的设备的节点。

  • fwnode_get_named_child_node() 通过名称获取给定节点中的子节点。

  • fwnode_handle_get() 获取对设备节点的引用,fwnode_handle_put() 释放此引用。

一些fwnode相关的属性如下:

[...]
bool fwnode_device_is_available(const                                 struct fwnode_handle *fwnode); 
bool fwnode_property_present(const                              struct fwnode_handle *fwnode, 
                             const char *propname);
int fwnode_property_read_string(const                              struct fwnode_handle *fwnode, 
                             const char *propname,                              const char **val);
int fwnode_property_match_string(const                                  struct fwnode_handle *fwnode,
                                 const char *propname,                                  const char *string);

fwnode属性和节点相关的 API 都在include/linux/property.h中可用。但是,有一些辅助程序允许在 OF、ACPI 和fwnode之间来回切换。以下是一个简短的示例:

/* to switch from fwnode to of */
struct device_node *of_node = to_of_node(fwnode);
/* to switch from of to fw */
struct fwnode_handle *fwnode = of_fwnode_handle(node)
/* to switch from fwnode to acpi handle, the below macro has
 * been introduced
 *
 * #define ACPI_HANDLE_FWNODE(fwnode)	\
 *        acpi_device_handle(to_acpi_device_node(fwnode))
 *
 * and to switch from acpi device to fwnode:
 *
 *   struct fwnode_handle *
 *          acpi_fwnode_handle(struct acpi_device *adev)
 *
 */

最后,对我们来说最重要的是fwnode图形 API。在以下代码片段中,我们列举了此 API 的最重要功能:

struct fwnode_handle
   *fwnode_graph_get_next_endpoint(const                                   struct fwnode_handle *fwnode,
                                  struct fwnode_handle *prev);
struct fwnode_handle
   *fwnode_graph_get_port_parent(const                                  struct fwnode_handle *fwnode);
struct fwnode_handle
   *fwnode_graph_get_remote_port_parent(
                           const struct fwnode_handle *fwnode);
struct fwnode_handle
   *fwnode_graph_get_remote_port(const                                  struct fwnode_handle *fwnode);
struct fwnode_handle 
   *fwnode_graph_get_remote_endpoint(
                           const struct fwnode_handle *fwnode);
#define fwnode_graph_for_each_endpoint(fwnode, child) \
    for (child = NULL;	\
    (child = fwnode_graph_get_next_endpoint(fwnode, child)); )
int fwnode_graph_parse_endpoint(const                                 struct fwnode_handle *fwnode,
                             struct fwnode_endpoint *endpoint);
[...]

尽管前面的函数名称已经说明了它们的作用,但以下是更好的描述:

  • fwnode_graph_get_next_endpoint() 返回给定节点(第一个参数)中的下一个端点(或NULL),在先前的端点(第二个参数prev)之后。如果prevNULL,则返回第一个端点。此函数获取对返回的端点的引用,必须在使用后放弃。参见fwnode_handle_put()

  • fwnode_graph_get_port_parent() 返回给定参数中端口节点的父节点。

  • fwnode_graph_get_remote_port_parent() 返回包含通过fwnode参数给定的固件节点的端点的远程设备的固件节点。

  • fwnode_graph_get_remote_endpoint() 返回与通过fwnode参数给定的本地端点对应的远程端点的固件节点。

  • fwnode_graph_parse_endpoint() 解析fwnode中表示图端点节点的常见端点节点属性(第一个参数),并将信息存储在endpoint中(第二个和输出参数)。V4L2 固件节点 API 大量使用这个功能。

V4L2 固件节点(V4L2 fwnode)API

V4L2 fwnode API 中的主要数据结构是struct v4l2_fwnode_endpoint。这个结构实际上就是struct fwnode_handle,增加了一些与 V4L2 相关的属性。然而,有一个与 V4L2 相关的 fwnode 图函数值得在这里谈论:v4l2_fwnode_endpoint_parse()。该函数的原型在include/media/v4l2-fwnode.h中声明如下:

int v4l2_fwnode_endpoint_parse(struct fwnode_handle *fwnode,
                             struct v4l2_fwnode_endpoint *vep);

给定端点的fwnode_handle(在前面的函数中的第一个参数),您可以使用v4l2_fwnode_endpoint_parse()来解析所有 fwnode 节点属性。该函数还识别并处理 V4L2 特定的属性,这些属性是在Documentation/devicetree/bindings/media/video-interfaces.txt中记录的。v4l2_fwnode_endpoint_parse()使用fwnode_graph_parse_endpoint()来解析常见的 fwnode 属性,并使用 V4L2 特定的解析器助手来解析与 V4L2 相关的属性。它在成功时返回0,在失败时返回负错误代码。

如果我们考虑dts中的mt9v032 CMOS 图像传感器节点,我们可以在probe方法中有以下代码:

int err;
struct fwnode_handle *ep;
struct v4l2_fwnode_endpoint bus_cfg;
/* We grab the fwnode corresponding to the device */
struct fwnode_handle *fwnode = dev_fwnode(dev);
/* We grab its endpoint(s) node */
ep = fwnode_graph_get_next_endpoint(fwnode, NULL);
/* We parse the endpoint common properties as well as
 * v4l2 related properties  */
err = v4l2_fwnode_endpoint_parse(ep, &bus_cfg);
if (err) {   /* handle error */ }
/* At this point we can access parameters such as bus_type,  * bus.flags  
 * (in case of mipi csi2 or parallel buses), V4L2_MBUS_*  * which are the 
 * media bus flags
 */
/* we drop the reference on the enpoint */
fwnode_handle_put(ep);

上述代码展示了如何使用 fwnode API 及其 V4L2 版本来访问节点和端点属性。然而,在v4l2_fwnode_endpoint_parse()调用时,也会解析特定于 V4L2 的属性。这些属性描述了所谓的媒体总线,通过这个总线,数据从一个接口传输到另一个接口。我们将在下一节讨论这个问题。

V4L2 fwnode 或媒体总线类型

大多数媒体设备支持特定的媒体总线类型。当端点链接在一起时,它们实际上是通过总线连接的,其属性需要在 V4L2 框架中描述。为了使 V4L2 能够找到这些信息,它作为设备的 fwnode(DT 或 ACPI)中的属性提供。由于这些是特定的属性,V4L2 fwnode API 能够识别和解析它们。每个总线都有其特定性和属性。

首先,让我们看一下当前支持的总线,以及它们的数据结构:

  • struct v4l2_fwnode_bus_mipi_csi1

  • struct v4l2_fwnode_bus_mipi_csi1也是如此。

  • HSYNCVSYNC信号。用于表示这个总线的结构是struct v4l2_fwnode_bus_parallel

  • HSYNCVSYNCBLANK)的数据。与标准并行总线相比,这些总线的引脚数量减少。该框架使用struct v4l2_fwnode_bus_parallel来表示这个总线。

  • struct v4l2_fwnode_bus_mipi_csi2结构。但是,这个数据结构没有区分 D-PHY 和 C-PHY。这种缺乏区分在内核 v5.0 中得到了解决。

正如我们将在本章后面的媒体总线的概念部分中看到的,总线的这个概念可以用来检测本地端点与其远程对应端点之间的兼容性,以便如果它们没有相同的总线属性,两个子设备就不能链接在一起,这是完全合理的。

V4L2 fwnode API部分中,我们看到v4l2_fwnode_endpoint_parse()负责解析端点的 fwnode 并填充适当的总线结构。该函数首先调用fwnode_graph_parse_endpoint()来解析常见的 fwnode 图相关属性,然后检查bus-type属性的值,如下所示,以确定适当的v4l2_fwnode_endpoint.bus数据类型:

u32 bus_type = 0;
fwnode_property_read_u32(fwnode, "bus-type", &bus_type);

根据这个值,将选择一个总线数据结构。以下是来自fwnode设备的预期可能值:

  • 0:这意味着自动检测。核心将尝试根据 fwnode 中存在的属性来猜测总线类型(MIPI CSI-2 D-PHY、并行或 BT656)。

  • 1:这意味着 MIPI CSI-2 C-PHY。

  • 2:这意味着 MIPI CSI-1。

  • 3:这意味着 CCP2。

例如,对于 CPP2 总线,设备的 fwnode 将包含以下行:

bus-type = <3>;

重要提示

从内核 v5.0 开始,驱动程序可以在v4l2_fwnode_endpointbus_type成员中指定预期的总线类型,然后将其作为第二个参数提供给v4l2_fwnode_endpoint_parse()。这样,如果前面的fwnode_property_read_u32返回的值与预期值不匹配,解析将失败,除非将预期的总线类型设置为V4L2_MBUS_UNKNOWN

BT656 和并行总线

这些总线类型都由struct v4l2_fwnode_bus_parallel表示,如下所示:

struct v4l2_fwnode_bus_parallel {
    unsigned int flags;
    unsigned char bus_width;
    unsigned char data_shift;
};

在前述数据结构中,flags表示总线的标志。这些标志将根据设备固件节点中存在的属性设置。bus_width表示实际使用的数据线数量,不一定是总线的总线数量。data_shift用于指定实际使用的数据线,通过指定要跳过的线路数量来实现。以下是这些媒体总线的绑定属性,用于设置struct v4l2_fwnode_bus_parallel

  • hsync-active:HSYNC 信号的活动状态;分别为LOW/HIGH0/1。如果此属性的值为0,则在flags成员中设置V4L2_MBUS_HSYNC_ACTIVE_LOW标志。任何其他值将设置V4L2_MBUS_HSYNC_ACTIVE_HIGH标志。

  • vsync-active:VSYNC 信号的活动状态;分别为LOW/HIGH0/1。如果此属性的值为0,则在flags成员中设置V4L2_MBUS_VSYNC_ACTIVE_LOW标志。任何其他值将设置V4L2_MBUS_VSYNC_ACTIVE_HIGH标志。

  • field-even-active:在偶场数据传输期间的场信号电平。这与前面的情况相同,但相关标志为V4L2_MBUS_FIELD_EVEN_HIGHV4L2_MBUS_FIELD_EVEN_LOW

  • pclk-sample:在像素时钟信号的上升(1)或下降(0)沿上采样数据,V4L2_MBUS_PCLK_SAMPLE_RISINGV4L2_MBUS_PCLK_SAMPLE_FALLING

  • data-active:类似于HSYNCVSYNC,指定数据线极性,V4L2_MBUS_DATA_ACTIVE_HIGHV4L2_MBUS_DATA_ACTIVE_LOW

  • slave-mode:这是一个布尔属性,其存在表示链接以从模式运行,并设置了V4L2_MBUS_SLAVE标志。否则,将设置V4L2_MBUS_MASTER标志。

  • data-enable-active:类似于HSYNCVSYNC,指定数据使能信号的极性。

  • bus-width:此属性仅涉及并行总线,并表示实际使用的数据线数量。相应地设置V4L2_MBUS_DATA_ENABLE_HIGHV4L2_MBUS_DATA_ENABLE_LOW标志。

  • data-shift:在并行数据总线上,bus-width用于指定实际使用的数据线数量,此属性可用于指定实际使用的数据线;例如,bus-width=<8>; data-shift=<2>;表示使用线路 9:2。

  • sync-on-green-active0/1的活动状态分别为LOW/HIGH。相应地设置V4L2_MBUS_VIDEO_SOG_ACTIVE_HIGHV4L2_MBUS_VIDEO_SOG_ACTIVE_LOW标志。

这些总线的类型可以是V4L2_MBUS_PARALLELV4L2_MBUS_BT656。负责解析这些总线的底层函数是v4l2_fwnode_endpoint_parse_parallel_bus()

MIPI CSI-2 总线

这是 MIPI 联盟的 CSI 总线的第 2 版。该总线涉及两个 PHY:D-PHY 或 C-PHY。 D-PHY 已经存在一段时间,针对相机、显示器和低速应用。 C-PHY 是一种更新更复杂的 PHY,其中时钟嵌入到数据中,使得不需要单独的时钟通道。它的线路更少,通道数更少,功耗更低,并且与 D-PHY 相比可以实现更高的数据速率。 C-PHY 在带宽受限的通道上提供高吞吐性能。

C-PHY 和 D-PHY 启用的总线都使用一个数据结构struct v4l2_fwnode_bus_mipi_csi2表示,如下所示:

struct v4l2_fwnode_bus_mipi_csi2 {
    unsigned int flags;
    unsigned char data_lanes[V4L2_FWNODE_CSI2_MAX_DATA_LANES]; 
    unsigned char clock_lane;
    unsigned short num_data_lanes;
    bool lane_polarities[1 + V4L2_FWNODE_CSI2_MAX_DATA_LANES];
};

在前面的块中,flags表示总线的标志,并将根据固件节点中存在的属性进行设置:

  • data-lanes是物理数据线索引的数组。

  • lane-polarities:此属性仅适用于串行总线。这是一个从时钟线开始,然后是数据线的极性数组,顺序与data-lanes属性相同。有效值为0(正常)和1(反转)。此数组的长度应为data-lanesclock-lanes属性的组合长度。有效值为0(正常)和1(反转)。如果省略了lane-polarities属性,则必须将值解释为0(正常)。

  • clock-lanes是时钟线的物理线索引。这是时钟线的位置。

  • clock-noncontinuous:如果存在,则设置V4L2_MBUS_CSI2_NONCONTINUOUS_CLOCK标志。否则,设置V4L2_MBUS_CSI2_CONTINUOUS_CLOCK

这些总线具有V4L2_MBUS_CSI2类型。直到 Linux 内核 v4.20,C-PHY 和 D-PHY 启用的 CSI 总线之间没有区别。但是,从 Linux 内核 v5.0 开始,引入了这种差异,并且V4L2_MBUS_CSI2已被分别替换为V4L2_MBUS_CSI2_DPHYV4L2_MBUS_CSI2_CPHY,用于 D-PHY 或 C-PHY 启用的总线。

负责解析这些总线的基础功能是v4l2_fwnode_endpoint_parse_csi2_bus()。一个示例如下:

[...]
    port {
        tc358743_out: endpoint {
          remote-endpoint = <&mipi_csi2_in>;           clock-lanes = <0>;
          data-lanes = <1 2 3 4>;
          lane-polarities = <1 1 1 1 1>;
          clock-noncontinuous;
        };
    };

CPP2 和 MIPI CSI-1 总线

这些是较旧的单数据线串行总线。它们的类型对应于V4L2_FWNODE_BUS_TYPE_CCP2V4L2_FWNODE_BUS_TYPE_CSI1。内核使用struct v4l2_fwnode_bus_mipi_csi1来表示这些总线:

struct v4l2_fwnode_bus_mipi_csi1 {
    bool clock_inv;
    bool strobe;
    bool lane_polarity[2];
    unsigned char data_lane;
    unsigned char clock_lane;
};

以下是此结构中元素的含义:

  • clock-inv:时钟/闪光信号的极性(false 表示未反转,true 表示反转)。0表示 false,其他值表示 true。

  • strobe:False - 数据/时钟,true - 数据/闪光灯。

  • data-lanes:数据线的数量。

  • clock-lanes:时钟线的数量。

  • lane-polarities:这与前面相同,但由于 CPP2 和 MIPI CSI-1 是单数据串行总线,因此数组只能有两个条目:时钟(索引0)和数据线(索引1)的极性。

在解析给定节点后,前面的数据结构由v4l2_fwnode_endpoint_parse_csi1_bus()填充。

总线猜测

将总线类型指定为0(或V4L2_MBUS_UNKNOWN)将指示 V4L2 核心尝试根据在固件节点中找到的属性来猜测实际的媒体总线。它首先会考虑设备是否在 CSI-2 总线上,并尝试相应地解析端点节点,寻找与 CSI-2 相关的属性。幸运的是,CSI-2 和并行总线没有共同的属性。因此,只有在没有找到 MIPI CSI-2 特定属性时,核心才会解析并行视频总线属性。核心不会猜测V4L2_MBUS_CCP2V4L2_MBUS_CSI1。对于这些总线,必须指定bus-type属性。

V4L2 异步

由于基于视频的硬件的复杂性,有时会集成非 V4L2 设备(实际上是子设备)位于不同的总线上,因此需要子设备推迟初始化,直到桥接驱动程序已加载,另一方面,桥接驱动程序需要推迟初始化子设备,直到所有必需的子设备已加载;也就是说,V4L2 异步。

在异步模式下,子设备探测可以独立于桥接驱动程序的可用性进行。然后,子设备驱动程序必须验证是否满足了成功探测的所有要求。这可能包括检查主时钟的可用性、GPIO 或其他任何内容。如果任何条件不满足,子设备驱动程序可能决定返回-EPROBE_DEFER以请求进一步的重新探测尝试。一旦满足所有条件,子设备将使用v4l2_async_register_subdev()函数在 V4L2 异步核心中注册。取消注册使用v4l2_async_unregister_subdev()调用执行。

我们之前看到同步注册适用的情况。这是一种模式,桥接驱动程序了解其负责的所有子设备的上下文。它有责任在其探测期间使用v4l2_device_register_subdev()在每个子设备上注册所有子设备,就像drivers/media/platform/exynos4-is/media-dev.c驱动程序一样。

在 V4L2 异步框架中,子设备的概念被抽象化。在异步框架中,子设备被称为struct v4l2_async_subdev结构的一个实例。除了这个结构,还有另一个struct v4l2_async_notifier结构。两者都在include/media/v4l2-async.h中定义,并且在 V4L2 异步核心的中心部分。在进一步进行之前,我们必须介绍 V4L2 异步框架的中心部分struct v4l2_async_notifier,如下所示:

struct v4l2_async_notifier {
    const struct v4l2_async_notifier_operations *ops;
    unsigned int num_subdevs;
    unsigned int max_subdevs;
    struct v4l2_async_subdev **subdevs;
    struct v4l2_device *v4l2_dev;
    struct v4l2_subdev *sd;
    struct v4l2_async_notifier *parent;
    struct list_head waiting;
    struct list_head done;
    struct list_head list;
};

前面的结构主要由桥接驱动程序和异步核心使用。然而,在某些情况下,子设备驱动程序可能需要被其他子设备通知。在任何情况下,成员的用途和含义都是相同的:

  • ops是由此通知器的所有者提供的一组回调,由异步核心在等待在此通知器中的子设备被探测时调用。

  • v4l2_dev是注册此通知器的桥接驱动程序的 V4L2 父级。

  • sd,如果此通知器是由子设备注册的,将指向此子设备。我们在这里不讨论这种情况。

  • subdevs是应该通知此通知器的注册者(桥接驱动程序或另一个子设备驱动程序)的子设备数组。

  • waiting是此通知器中等待被探测的子设备的列表。

  • done是实际绑定到此通知器的子设备的列表。

  • num_subdevs**subdevs中子设备的数量。

  • list在注册此通知器时由异步核心使用,以将此通知器链接到通知器的全局列表notifier_list

回到我们的struct v4l2_async_subdev结构,定义如下:

struct v4l2_async_subdev {
    enum v4l2_async_match_type match_type;
    union {
        struct fwnode_handle *fwnode;
        const char *device_name;
        struct {
            int adapter_id;
            unsigned short address;
        } i2c;
        struct {
        bool (*match)(struct device *,                      struct v4l2_async_subdev *);
            void *priv;
        } custom;
    } match;
    /* v4l2-async core private: not to be used by drivers */
    struct list_head list;
};

前面的数据结构在 V4L2 异步框架中是一个子设备。只有桥接驱动程序(分配异步子设备)和异步核心可以使用这个结构。子设备驱动程序完全不知道这一点。其成员的含义如下:

  • match_typeenum v4l2_async_match_type类型。匹配是对某些标准进行比较(发生struct v4l2_subdev类型和struct v4l2_async_subdev类型的异步子设备)。由于每个struct v4l2_async_subdev结构必须与其struct v4l2_subdev结构相关联,因此该字段指定了异步核心用于匹配两者的算法。该字段由驱动程序设置(也负责分配异步子设备)。可能的值如下:

--V4L2_ASYNC_MATCH_DEVNAME,指示异步核心使用设备名称进行匹配。在这种情况下,桥接驱动程序必须设置v4l2_async_subdev.match.device_name字段,以便在探测到子设备时可以匹配子设备的设备名称(即dev_name(v4l2_subdev->dev))。

-V4L2_ASYNC_MATCH_FWNODE,这意味着异步核心应使用固件节点进行匹配。在这种情况下,桥接驱动程序必须使用与子设备的设备节点对应的固件节点句柄v4l2_async_subdev.match.fwnode进行匹配。

-应使用V4L2_ASYNC_MATCH_I2C通过检查 I2C 适配器 ID 和地址来执行匹配。使用此功能,桥接驱动程序必须同时设置v4l2_async_subdev.match.i2c.adapter_idv4l2_async_subdev.match.i2c.address。这些值将与与v4l2_subdev.dev关联的i2c_client对象的地址和适配器编号进行比较。

-V4L2_ASYNC_MATCH_CUSTOM是最后一种可能性,意味着异步核心应使用桥接驱动程序中设置的匹配回调v4l2_async_subdev.match.custom.match。如果设置了此标志并且未提供自定义匹配回调,则任何匹配尝试将立即返回 true。

  • list用于将此异步子设备添加到通知程序的等待列表中等待探测。

子设备注册不再依赖于桥接可用性,只需调用v4l2_async_unregister_subdev()方法即可。但是,在注册自身之前,桥接驱动程序将不得不执行以下操作:

  1. 为以后使用分配一个通知程序。最好将此通知程序嵌入较大的设备状态数据结构中。此通知程序对象是struct v4l2_async_notifier类型。

  2. 解析其端口节点并为其中指定的每个传感器(或 IP 块)创建一个异步子设备(struct v4l2_async_subdev),并且它需要进行操作:

a)使用fwnode图形 API(旧驱动程序仍使用of_graph API)进行此解析,例如以下内容:

-fwnode_graph_get_next_endpoint()(或旧驱动程序中的of_graph_get_next_endpoint())来抓取桥接的端口子节点中的端点的fw_handle(或旧驱动程序中的of_node)。

-fwnode_graph_get_remote_port_parent()(或旧驱动程序中的of_graph_get_remote_port_parent())来抓取当前端点的远程端口的fw_handle(或设备的of_node)对应的父级。

可选地(在使用 OF API 的旧驱动程序中),使用of_fwnode_handle()将先前状态中抓取的of_node转换为fw_handle

b)根据应使用的匹配逻辑设置当前异步子设备。它应设置v4l2_async_subdev.match_typev4l2_async_subdev.match成员。

c)将此异步子设备添加到通知程序的异步子设备列表中。在内核的 4.20 版本中,有一个辅助程序v4l2_async_notifier_add_subdev(),允许您执行此操作。

  1. 使用v4l2_async_notifier_register(&big_struct->v4l2_dev,&big_struct->notifier)调用注册通知对象(此通知对象将存储在drivers/media/v4l2-core/v4l2-async.c中定义的全局notifier_list列表中)。要取消注册通知程序,驱动程序必须调用v4l2_async_notifier_unregister(&big_struct->notifier)

当桥接驱动程序调用v4l2_async_notifier_register()时,异步核心会迭代notifier->subdevs数组中的异步子设备。对于每个异步子设备,核心会检查asd->match_type值是否为V4L2_ASYNC_MATCH_FWNODE。如果适用,异步核心会通过比较 fwnodes 来确保asd不在notifier->waiting列表或notifier->done列表中。这可以确保asd尚未为fwnode设置,并且它尚不存在于给定的通知器中。如果asd尚未知晓,则将其添加到notifier->waiting中。之后,异步核心将测试notifier->waiting列表中的所有异步子设备,以与subdev_list中存在的所有子设备进行匹配。subdev_list是“类似”孤立子设备的列表,这些子设备是在其桥接驱动程序(因此在其通知器)之前注册的。异步核心使用每个当前asdasd->match值进行匹配。如果匹配发生(asd->match回调返回 true),则当前异步子设备(来自notifier->waiting)和当前子设备(来自subdev_list)将被绑定,异步子设备将从notifier->waiting列表中移除,子设备将使用v4l2_device_register_subdev()注册到 V4L2 核心,并且子设备将从全局subdev_list列表移动到notifier->done列表中。

最后,被注册的实际通知器将被添加到全局通知器列表notifier_list中,以便在以后使用时,可以在异步核心中注册新的子设备时进行匹配尝试。

重要提示

当子设备驱动程序调用v4l2_async_register_subdev()时,异步核心会尝试将当前子设备与notifier_list全局列表中存在的每个通知器中等待的所有异步子设备进行匹配。如果没有匹配发生,这意味着尚未探测到此子设备的桥接,子设备将被添加到全局子设备列表subdev_list中。如果发生匹配,子设备将根本不会添加到此列表中。

还要记住,匹配测试是在struct v4l2_subdev类型的子设备和struct v4l2_async_subdev类型的异步子设备之间严格发生的一些标准的比较。

在前面的段落中,我们说异步子设备和子设备是绑定的。但这是什么意思呢?这就是notifier->ops成员发挥作用的地方。它是struct v4l2_async_notifier_operations类型,并定义如下:

struct v4l2_async_notifier_operations {
    int (*bound)(struct v4l2_async_notifier *notifier,
                  struct v4l2_subdev *subdev,
                  struct v4l2_async_subdev *asd);
    int (*complete)(struct v4l2_async_notifier *notifier);
    void (*unbind)(struct v4l2_async_notifier *notifier,
                    struct v4l2_subdev *subdev,
                    struct v4l2_async_subdev *asd);
};

在这个结构中,以下是每个回调的含义,尽管所有三个回调都是可选的:

  • bound:如果设置,异步核心将在成功的子设备探测后由其(子设备)驱动程序调用此回调。这也意味着异步子设备已成功匹配此子设备。此回调将以发起匹配的通知器以及匹配的子设备(subdev)和异步子设备(asd)作为参数。大多数驱动程序在这里只是打印调试消息。但是,您可以在这里对子设备进行额外的设置-即v4l2_subdev_call()。如果一切正常,它应该返回一个正值;否则,子设备将被注销。

  • unbind在从系统中移除子设备时被调用。除了在这里打印调试消息外,桥接驱动程序还必须取消注册视频设备,如果未绑定的子设备对其正常工作是必需的-即video_unregister_device()

  • complete在通知器中没有更多的异步子设备等待时被调用。异步核心可以检测到notifier->waiting列表为空(这意味着子设备已经成功探测并全部移动到notifier->done列表中)。完成回调仅对根通知器执行。注册了通知器的子设备不会调用其.complete回调。根通知器通常是由桥接设备注册的。

毫无疑问,在注册通知器对象之前,桥接驱动程序必须设置通知器的ops成员。对我们来说最重要的回调是.complete

在桥接驱动程序的probe函数中调用v4l2_device_register()是一种常见做法,但通常在notifier.complete回调中注册实际的视频设备,因为所有子设备都将被注册,并且/dev/videoX的存在意味着它确实可用。.complete回调也适用于注册实际视频设备的子节点,并通过v4l2_device_register_subdev_nodes()media_device_register()注册媒体设备。

请注意,v4l2_device_register_subdev_nodes()将为每个标有V4L2_SUBDEV_FL_HAS_DEVNODE标志的subdev对象创建一个设备节点(实际上是/dev/v4l2-subdevX)。

异步桥接和子设备探测示例

我们将通过一个简单的用例来介绍这一部分。考虑以下配置:

  • 一个桥接设备(我们的 CSI 控制器) - 让我们说omap ISP,以foo作为其名称。

  • 一个片外子设备,摄像头传感器,以bar作为其名称。

两者是这样连接的:CSI <-- 摄像头传感器

bar驱动程序中,我们可以注册一个异步子设备,如下所示:

static int bar_probe(struct device *dev)
{
    int ret;
    ret = v4l2_async_register_subdev(subdev);
    if (ret) {
        dev_err(dev, "ouch\n");
        return -ENODEV;
    }
    return 0;
}

foo驱动程序的probe函数可能如下所示:

/* struct foo_device */
struct foo_device {
    struct media_device mdev;
    struct v4l2_device v4l2_dev;
    struct video_device *vdev;
    struct v4l2_async_notifier notifier;
    struct *subdevs[FOO_MAX_SUBDEVS];
};
/* foo_probe() */
static int foo_probe(struct device *dev)
{
    struct foo_device *foo = kmalloc(sizeof(*foo)); 
    media_device_init(&bar->mdev);
    foo->dev = dev;
    foo->notifier.subdevs = kcalloc(FOO_MAX_SUBDEVS,
                             sizeof(struct v4l2_async_subdev)); 
    foo_parse_nodes(foo);
    foo->notifier.bound = foo_bound;
    foo->notifier.complete = foo_complete; 
    return 
        v4l2_async_notifier_register(&foo->v4l2_dev,                                      &foo->notifier);
}

在下面的代码中,我们实现了foo fwnode(或of_node)解析器助手foo_parse_nodes()

struct foo_async {
    struct v4l2_async_subdev asd;
    struct v4l2_subdev *sd;
};
/* Either */
static void foo_parse_nodes(struct device *dev,
                            struct v4l2_async_notifier *n)
{
    struct device_node *node = NULL;
    while ((node = of_graph_get_next_endpoint(dev->of_node,                                               node))) { 
        struct foo_async *fa = kmalloc(sizeof(*fa));
        n->subdevs[n->num_subdevs++] = &fa->asd;
        fa->asd.match.of.node =         of_graph_get_remote_port_parent(node); 
        fa->asd.match_type = V4L2_ASYNC_MATCH_OF;
    }
}
/* Or */
static void foo_parse_nodes(struct device *dev,
                            struct v4l2_async_notifier *n)
{
    struct fwnode_handle *fwnode = dev_fwnode(dev);
    struct fwnode_handle *ep = NULL;
    while ((ep = fwnode_graph_get_next_endpoint(ep, fwnode))) {
        struct foo_async *fa = kmalloc(sizeof(*fa));
        n->subdevs[n->num_subdevs++] = &fa->asd;
        fa->asd.match.fwnode =
                fwnode_graph_get_remote_port_parent(ep);
        fa->asd.match_type = V4L2_ASYNC_MATCH_FWNODE;
    }
}

在前面的代码中,of_graph_get_next_endpoint()fwnode_graph_get_next_endpoint()都已经被用来展示如何使用这两者。也就是说,最好使用 fwnode 版本,因为它更通用。

与此同时,我们需要编写foo的通知器操作,可能如下所示:

/* foo_bound() and foo_complete() */
static int foo_bound(struct v4l2_async_notifier *n,
                struct v4l2_subdev *sd,                 struct v4l2_async_subdev *asd)
{
    struct foo_async *fa = container_of(asd, struct bar_async,                                         asd);
    /* One can use subdev_call here */
    [...]
    fa->sd = sd;
}
static int foo_complete(struct v4l2_async_notifier *n)
{
    struct foo_device *foo =
             container_of(n, struct foo_async, notifier);
    struct v4l2_device *v4l2_dev = &isp->v4l2_dev;
    /* Create /dev/sub-devX if applicable */ 
    v4l2_device_register_subdev_nodes(&foo->v4l2_dev);
    /* setup the video device: fops, queue, ioctls ... */
[...]
    /* Register the video device */
       ret = video_register_device(foo->vdev,                                    VFL_TYPE_GRABBER, -1);
    /* Register with the media controller framework */ 
    return media_device_register(&bar->mdev);
}

在设备树中,V4L2 桥接设备可以声明如下:

csi1: csi@1cb4000 {
    compatible = "allwinner,sun8i-v3s-csi";
    reg = <0x01cb4000 0x1000>;
    interrupts = <GIC_SPI 84 IRQ_TYPE_LEVEL_HIGH>;
    /* we omit clock and others */
[...]
    port {
        csi1_ep: endpoint {
            remote-endpoint = <&ov7740_ep>;
           /* We omit v4l2 related properties */
[...]
        };
    };
};

在 I2C 控制器节点内部的摄像头节点可以声明如下:

&i2c1 {
    #address-cells = <1>;
    #size-cells = <0>;
    ov7740: camera@21 {
        compatible = "ovti,ov7740";
        reg = <0x21>;
        /* We omit clock or pincontrol or everything else */

       [...]
       port {
           ov7740_ep: endpoint {
               remote-endpoint = <&csi1_ep>;
               /* We omit v4l2 related properties */
               [...]
           };
       };
   };
};

现在我们熟悉了 V4L2 异步框架,看到了异步子设备注册如何简化探测和代码。我们以一个具体的例子结束了,突出了我们讨论的每个方面。现在我们可以继续并集成媒体控制器框架,这是我们可以为 V4L2 驱动程序添加的最后一个改进。

Linux 媒体控制器框架

媒体设备非常复杂,涉及 SoC 的多个 IP 块,因此需要视频流(重新)路由。

现在,让我们考虑一个更复杂的 SoC 情况,由两个更多的片上子设备组成 - 比如一个重塑器和一个图像转换器,称为bazbiz

V4L2 异步部分的前面的示例中,设置由一个桥接设备和一个子设备(它是片外的事实并不重要),摄像头传感器组成。这相当简单。幸运的是,事情进展顺利。但是,如果现在我们必须通过图像转换器或图像重塑器路由流,甚至通过这两个 IP 呢?或者说我们必须动态地从一个切换到另一个?

我们可以通过sysfsioctls来实现这一点,但这将会有以下问题:

  • 这将会非常丑陋(毫无疑问),而且可能会有 bug。

  • 这将会非常困难(需要大量工作)。

  • 这将深深地依赖于 SoC 供应商,可能会有大量的代码重复,没有统一的用户空间 API 和 ABI,驱动程序之间没有一致性。

  • 这将不是一个非常可信的解决方案。

许多 SoC 可以重新路由内部视频流 - 例如,从传感器捕获它们并进行内存到内存的调整,或直接将传感器输出发送到调整器。由于 V4L2 API 不支持这些高级设备,SoC 制造商制作了自己的定制驱动程序。但是,V4L2 无疑是用于捕获图像的 Linux API,并且有时用于特定的显示设备(这些是 mem2mem 设备)。

很明显,我们需要另一个子系统和框架来涵盖 V4L2 的限制。这就是 Linux 媒体控制器框架诞生的原因。

媒体控制器抽象模型

发现设备的内部拓扑并在运行时对其进行配置是媒体框架的目标之一。为了实现这一点,它带有一层抽象。通过媒体控制器框架,硬件设备通过由实体组成的有向图来表示,这些实体pad通过链接连接。这些元素的集合组成了所谓的媒体设备。源 pad 只能生成数据。

前面的简短描述值得关注。有三个高度关注的突出词:entity、pad 和 link:

  • struct media_entity实例,定义在include/media/media-entity.h中。该结构通常嵌入到更高级的结构中,例如v4l2_subdevvideo_device实例,尽管驱动程序可以直接分配实体。

  • /dev/videoX pad 将被建模为输入 pad,因为它是流的结束。

  • 链接:这些链接可以通过媒体设备进行设置、获取和枚举。为了使驱动程序正常工作,应用程序负责正确设置这些链接,以便驱动程序了解视频数据的源和目的地。

系统上的所有实体以及它们的 pad 和它们之间的连接链接,构成了下图所示的媒体设备

图 8.1 - 媒体控制器抽象模型

图 8.1 - 媒体控制器抽象模型

在前面的图表中,/dev/videoX char 设备因为它是流的结束。

V4L2 设备抽象

在更高级别上,媒体控制器使用struct media_device来抽象 V4L2 框架中的struct v4l2_device。也就是说,struct media_device对于媒体控制器来说就像struct v4l2_device对于 V4L2 一样,包含其他更低级别的结构。回到struct v4l2_devicemdev成员被媒体控制器框架用来抽象此结构。以下是摘录:

struct v4l2_device {
[...]
    struct media_device *mdev;
[...]
};

然而,从媒体控制器的角度来看,V4L2 视频设备和子设备都被视为媒体实体,在该框架中表示为struct media_entity的实例。因此,视频设备和子设备数据结构明显需要嵌入此类型的成员,如下摘录所示:

struct video_device
{
#if defined(CONFIG_MEDIA_CONTROLLER)
    struct media_entity entity;
    struct media_intf_devnode *intf_devnode;
    struct media_pipeline pipe;
#endif
[...]
};
struct v4l2_subdev {
#if defined(CONFIG_MEDIA_CONTROLLER)
    struct media_entity entity;
#endif
[...]
};

视频设备具有额外的成员,intf_devnodepipe。前者是struct media_intf_devnode类型,表示媒体控制器接口到视频设备节点的接口。该结构使媒体控制器能够访问底层视频设备节点的信息,如其主次编号。另一个额外的成员pipestruct media_pipeline类型,存储与该视频设备的流水线相关的信息。

媒体控制器数据结构

媒体控制器框架基于一些数据结构,其中包括struct media_device结构,它位于层次结构的顶部,并定义如下:

struct media_device {
    /* dev->driver_data points to this struct. */
    struct device *dev;
    struct media_devnode *devnode;
    char model[32];
    char driver_name[32];
[...]
    char serial[40];
    u32 hw_revision;
    u64 topology_version;
    struct list_head entities;
    struct list_head pads;
    struct list_head links;
    struct list_head entity_notify;
    struct mutex graph_mutex;
[...]
    const struct media_device_ops *ops;
};

该结构表示高级媒体设备。它允许轻松访问实体并提供基本的媒体设备级别支持:

  • dev是此媒体设备的父设备(通常是&pci_dev&usb_interface&platform_device实例)。

  • devnode是媒体设备节点,抽象底层的/dev/mediaX

  • driver_name是一个可选但建议的字段,表示媒体设备驱动程序的名称。如果未设置,默认为dev->driver->name

  • model是该媒体设备的型号名称。它不必是唯一的。

  • serial是一个可选成员,应该设置为设备序列号。hw_revision是该媒体设备的硬件设备版本。

  • topology_version:用于存储图拓扑的版本的单调计数器。每次拓扑发生变化时应该递增。

  • entities是注册实体的列表。

  • pads是注册到该媒体设备的 pad 列表。

  • links是注册到该媒体设备的链接列表。

  • entity_notify是在新实体注册到该媒体设备时调用的通知回调列表。驱动程序可以通过media_device_unregister_entity_notify()注册此回调,并使用media_device_register_entity_notify()取消注册。当新实体注册时,所有注册的media_entity_notify回调都会被调用。

  • graph_mutex:保护对struct media_device数据的访问。例如,在使用media_graph_*系列函数时应该持有该锁。

  • opsstruct media_device_ops类型,表示该媒体设备的操作处理程序回调。

除了被媒体控制器框架操作外,struct media_device基本上是在桥接驱动程序中使用的,在那里进行初始化和注册。也就是说,媒体设备本身由多个实体组成。这种实体的概念允许媒体控制器成为现代和复杂的 V4L2 驱动程序的中央管理机构,这些驱动程序可能同时支持帧缓冲区、ALSA、I2C、LIRC 和/或 DVB 设备,并用于通知用户空间的各种信息。

媒体实体表示为struct media_entity的实例,如下所示:

struct media_entity {
    struct media_gobj graph_obj;
    const char *name;
    enum media_entity_type obj_type;
    u32 function;
    unsigned long flags;
    u16 num_pads;
    u16 num_links;
    u16 num_backlinks;
    int internal_idx;
    struct media_pad *pads;
    struct list_head links;
    const struct media_entity_operations *ops;
    int stream_count;
    int use_count;
    struct media_pipeline *pipe;
[...]
};

这是媒体框架中按层次结构排列的第二个数据结构。前面的定义已经被缩减到我们感兴趣的最小值。以下是该结构中成员的含义:

  • name是该实体的名称。它应该足够有意义,因为它在用户空间中与media-ctl工具一起使用。

  • type大多数情况下由核心根据该结构嵌入的 V4L2 视频数据结构的类型设置。它是实现media_entity的对象类型,例如,由核心在子设备初始化时设置为MEDIA_ENTITY_TYPE_V4L2_SUBDEV。这允许在运行时对媒体实体进行类型识别,并使用container_of宏安全地转换为正确的对象类型。可能的值如下:

--MEDIA_ENTITY_TYPE_BASE:这意味着该实体未嵌入在其他实体中。

--MEDIA_ENTITY_TYPE_VIDEO_DEVICE:表示该实体嵌入在struct video_device实例中。

--MEDIA_ENTITY_TYPE_V4L2_SUBDEV:这意味着该实体嵌入在struct v4l2_subdev实例中。

  • function表示实体的主要功能。这必须由驱动程序根据include/uapi/linux/media.h中定义的值进行设置。在处理视频设备时,以下是常用的值:

--MEDIA_ENT_F_IO_V4L:此标志表示该实体是数据流输入和/或输出实体。

--MEDIA_ENT_F_CAM_SENSOR:此标志表示该实体是摄像头视频传感器实体。

--MEDIA_ENT_F_PROC_VIDEO_SCALER:表示该实体可以执行视频缩放。这些实体至少有一个接收端口,从中接收帧(在活动端口上),以及一个源端口,用于输出缩放后的帧。

--MEDIA_ENT_F_PROC_VIDEO_ENCODER:表示该实体能够压缩视频。这些实体必须有一个接收端口和至少一个源端口。

--MEDIA_ENT_F_VID_MUX:这是用于视频复用器。这个实体至少有两个接收端口和一个发送端口,并且必须将从活动接收端口接收到的视频帧传递到发送端口。

--MEDIA_ENT_F_VID_IF_BRIDGE:视频接口桥。视频接口桥实体应该至少有一个接收端口和一个发送端口。它从一个类型的输入视频总线(HDMI、eDP、MIPI CSI-2 等)的接收端口接收视频帧,并将其从发送端口输出到另一种类型的输出视频总线(eDP、MIPI CSI-2、并行等)。

  • flags由驱动程序设置。它表示这个实体的标志。可能的值是include/uapi/linux/media.h中定义的MEDIA_ENT_FL_*标志系列。以下链接可能对您理解可能的值有所帮助:linuxtv.org/downloads/v4l-dvb-apis/userspace-api/mediactl/media-types.html

  • function代表这个实体的功能,默认为MEDIA_ENT_F_V4L2_SUBDEV_UNKNOWN。可能的值是include/uapi/linux/media.h中定义的MEDIA_ENT_F_*功能系列。例如,相机传感器子设备驱动程序必须包含sd->entity.function = MEDIA_ENT_F_CAM_SENSOR;。您可以通过此链接找到关于适合您的媒体实体的详细信息:https://linuxtv.org/downloads/v4l-dvb-apis/uapi/mediactl/media-types.html。

  • num_pads是这个实体的 pad 总数(接收端口和发送端口)。

  • num_links是这个实体的链接总数(前向、后向、启用和禁用)。

  • num_backlinks是这个实体的反向链接数。反向链接用于帮助图遍历,并不报告给用户空间。

  • internal_idx:当实体注册时,媒体控制器核心分配的唯一实体编号。

  • pads是这个实体的 pad 数组。其大小由num_pads定义。

  • links是这个实体的数据链接列表。参见media_add_link()

  • opsmedia_entity_operations类型,代表了这个实体的操作。这个结构将在后面讨论。

  • stream_count:实体的流计数。

  • use_count:实体的使用计数。用于电源管理目的。

  • pipe是这个实体所属的媒体管道。

自然而然,我们要介绍的下一个数据结构是struct media_pad结构,它代表了这个框架中的一个 pad。Pad 是一个连接端点,通过它实体可以与其他实体进行交互。实体产生的数据(不限于视频)从实体的输出流向一个或多个实体的输入。Pad 不应与芯片边界上的物理引脚混淆。struct media_pad定义如下:

struct media_pad {
[...]
    struct media_entity *entity;
    u16 index;
    unsigned long flags;
};

Pad 由它们的实体和它们在实体的 pad 数组中的基于 0 的index标识。在flags字段中,可以设置MEDIA_PAD_FL_SINK(表示 pad 支持接收数据)或MEDIA_PAD_FL_SOURCE(表示 pad 支持发送数据),但不能同时设置两者,因为一个 pad 不能同时接收和发送。

Pad 旨在绑定在一起以允许数据流路径。两个 pad,无论是来自同一实体还是来自不同实体,都可以通过点对点的连接方式绑定在一起,称为链接。链接在媒体框架中表示为struct media_link的实例,定义如下:

struct media_link {
    struct media_gobj graph_obj;
    struct list_head list;
[...]
    struct media_pad *source;
    struct media_pad *sink;
[...]
    struct media_link *reverse;
    unsigned long flags;
    bool is_backlink;
};

在上述代码块中,为了可读性,只列出了一些字段。以下是这些字段的含义:

  • list:用于将这个链接与拥有链接的实体或接口关联起来。

  • source:链接的起始位置。

  • sink:链接的目标。

  • flags:表示链接标志,如uapi/media.h中定义的(使用MEDIA_LNK_FL_*模式)。以下是可能的值:

--MEDIA_LNK_FL_ENABLED:此标志表示链接已启用并准备好进行数据传输。

--MEDIA_LNK_FL_IMMUTABLE:此标志表示链接启用状态无法在运行时修改。

--MEDIA_LNK_FL_DYNAMIC:此标志表示链接的状态可以在流媒体期间修改。但是,此标志由驱动程序设置,但对应用程序是只读的。

  • reverse:指向链接(实际上是反向链接)的指针,用于垫到垫链接的反向方向。

  • is_backlink:告诉此链接是否为反向链接。

每个实体都有一个指向其任何垫发出或针对其任何垫的所有链接的列表。因此,给定链接存储两次,一次在源实体中,一次在目标实体中。当您想要将A链接到B时,实际上创建了两个链接:

  • 一个对应于预期的;链接存储在源实体中,并且源实体的num_links字段递增。

  • 另一个存储在接收实体中。接收和源保持不变,不同之处在于is_backlink成员设置为true。这对应于您创建的链接的反向。接收实体的num_backlinksnum_links字段将被递增。然后将此反向链接分配给原始链接的reverse成员。

最后,mdev->topology_version成员递增两次。链接和反向链接的原则允许媒体控制器对实体进行编号,以及实体之间可能的当前链接,如下图所示:

图 8.2 - 媒体控制器实体描述

图 8.2 - 媒体控制器实体描述

在前面的图中,如果我们考虑实体-1实体-2,那么链接反向链接本质上是相同的,只是链接属于实体-1反向链接属于实体-2。然后,应将反向链接视为备用链接。我们可以看到实体可以是接收器、源或两者兼而有之。

到目前为止,我们介绍的数据结构可能会让媒体控制器框架听起来有点可怕。但是,大多数这些数据结构将由框架通过其提供的 API 在幕后进行管理。也就是说,完整的框架文档可以在内核源代码中的Documentation/media-framework.txt中找到。

在驱动程序中集成媒体控制器支持

当需要媒体控制器的支持时,V4L2 驱动程序必须首先使用media_device_init()函数在struct v4l2_device中初始化struct media_device。每个实体驱动程序必须使用media_entity_pads_init()函数初始化其实体(实际上是video_device->entityv4l2_subdev->entity)和其垫数组,并且如果需要,使用media_create_pad_link()创建垫到垫的链接。之后,实体可以注册。但是,V4L2 框架将通过v4l2_device_register_subdev()video_register_device()方法为您处理此注册。在这两种情况下,调用的底层注册函数是media_device_register_entity()

最后一步是使用media_device_register()注册媒体设备。值得一提的是,媒体设备的注册应该延迟到将来的某个时间点,当我们确定每个子设备(或者我应该说实体)都已注册并准备好使用时。在根通知器的.complete回调中注册媒体设备绝对是有意义的。

初始化和注册垫和实体

相同的函数用于初始化实体及其垫数组:

int media_entity_pads_init(struct media_entity *entity,
                         u16 num_pads, struct media_pad *pads);

在前面的原型中,*entity是要注册的垫所属的实体,*pads是要注册的垫数组,num_pads是应该注册的数组中的实体数。在调用之前,驱动程序必须设置垫数组中每个垫的类型:

struct mydrv_state_struct {
    struct v4l2_subdev sd;
    struct media_pad pad;
[...]
};
static int my_probe(struct i2c_client *client,
                     const struct i2c_device_id *id)
{
    struct v4l2_subdev *sd;
    struct mydrv_state_struct *my_struct;
[...]
    sd = &my_struct->sd;
    my_struct->pad.flags = MEDIA_PAD_FL_SINK | 
                            MEDIA_PAD_FL_MUST_CONNECT;
    ret = media_entity_pads_init(&sd->entity, 1,                                  &my_struct->pad);
[...]
    return 0;
}

需要注销实体的驱动程序必须在要注销的实体上调用以下函数:

media_device_unregister_entity(struct media_entity *entity);

因此,为了使驱动程序释放与实体关联的资源,应调用以下函数:

media_entity_cleanup(struct media_entity *entity);

当媒体设备注销时,所有实体将自动注销。然后不需要注销手动实体。

媒体实体操作

实体可以提供链接相关的回调,以便媒体框架在链接创建和验证时调用这些回调:

struct media_entity_operations {
    int (*get_fwnode_pad)(struct fwnode_endpoint *endpoint);
    int (*link_setup)(struct media_entity *entity,
                      const struct media_pad *local,
                      const struct media_pad *remote,                       u32 flags);
    int (*link_validate)(struct media_link *link);
};

提供上述结构是可选的。但是,可能存在需要在链接设置或链接验证时执行或检查其他内容的情况。在这种情况下,请注意以下描述:

  • get_fwnode_pad:根据 fwnode 端点返回垫号,或在错误时返回负值。此操作可用于将 fwnode 映射到媒体垫号(可选)。

  • link_setup:通知实体链接更改。此操作可能返回错误,在这种情况下,链接设置将被取消(可选)。

  • link_validate:返回链接是否从实体角度有效。media_pipeline_start()函数通过调用此操作验证此实体涉及的所有链接。此成员是可选的。但是,如果未设置,则将使用v4l2_subdev_link_validate_default作为默认回调函数,以确保源垫和接收垫的宽度、高度和媒体总线像素代码一致;否则,将返回错误。

媒体总线的概念

媒体框架的主要目的是配置和控制管道及其实体。视频子设备(如摄像头和解码器)通过专用总线连接到视频桥或其他子设备。数据以各种格式通过这些总线传输。也就是说,为了使两个实体实际交换数据,它们的垫配置需要相同。

应用程序负责在整个管道上配置一致的参数,并确保连接的垫有兼容的格式。在VIDIOC_STREAMON时间,管道将检查格式是否不匹配。

驱动程序负责根据用户请求的(从用户)格式在管道输入和/或输出处应用每个块的配置。

采用以下简单的数据流,sensor ---> CPHY ---> csi ---> isp ---> stream

为了使媒体框架能够在流数据之前配置总线,驱动程序需要为媒体总线属性提供一些垫级别的设置器和获取器,这些属性包含在struct v4l2_subdev_pad_ops结构中。此结构实现了必须定义的垫级别操作,如果子设备驱动程序打算处理视频并与媒体框架集成,则必须定义这些操作。以下是其定义:

struct v4l2_subdev_pad_ops {
[...]
    int (*enum_mbus_code)(struct v4l2_subdev *sd,
                      struct v4l2_subdev_pad_config *cfg,
                      struct v4l2_subdev_mbus_code_enum *code);
    int (*enum_frame_size)(struct v4l2_subdev *sd,
                      struct v4l2_subdev_pad_config *cfg,
                      struct v4l2_subdev_frame_size_enum *fse);
    int (*enum_frame_interval)(struct v4l2_subdev *sd,
                  struct v4l2_subdev_pad_config *cfg,
                  struct v4l2_subdev_frame_interval_enum *fie); 
    int (*get_fmt)(struct v4l2_subdev *sd,
                   struct v4l2_subdev_pad_config *cfg,
                   struct v4l2_subdev_format *format);
    int (*set_fmt)(struct v4l2_subdev *sd,
                   struct v4l2_subdev_pad_config *cfg,
                   struct v4l2_subdev_format *format);
#ifdef CONFIG_MEDIA_CONTROLLER
    int (*link_validate)(struct v4l2_subdev *sd,
                         struct media_link *link,
                         struct v4l2_subdev_format *source_fmt,
                         struct v4l2_subdev_format *sink_fmt);
#endif /* CONFIG_MEDIA_CONTROLLER */
[...]
};

以下是此结构中成员的含义:

  • init_cfg:将垫配置初始化为默认值。这是初始化cfg->try_fmt的正确位置,可以通过v4l2_subdev_get_try_format()获取。

  • enum_mbus_codeVIDIOC_SUBDEV_ENUM_MBUS_CODEioctl 处理程序代码的回调。枚举当前支持的数据格式。此回调处理像素格式枚举。

  • enum_frame_sizeVIDIOC_SUBDEV_ENUM_FRAME_SIZEioctl 处理程序代码的回调。枚举子设备支持的帧(图像)大小。列举当前支持的分辨率。

  • enum_frame_intervalVIDIOC_SUBDEV_ENUM_FRAME_INTERVALioctl 处理程序代码的回调。

  • get_fmtVIDIOC_SUBDEV_G_FMTioctl 处理程序代码的回调。

  • set_fmtVIDIOC_SUBDEV_S_FMTioctl 处理程序代码的回调。设置输出数据格式和分辨率。

  • get_selectionVIDIOC_SUBDEV_G_SELECTIONioctl 处理程序代码的回调。

  • set_selectionVIDIOC_SUBDEV_S_SELECTIONioctl 处理程序代码的回调。

  • link_validate:媒体控制器代码用于检查属于管道的链接是否可以用于流的函数。

所有这些回调共同具有的参数是cfg,它是struct v4l2_subdev_pad_config类型,用于存储子设备垫信息。该结构在include/uapi/linux/v4l2-mediabus.h中定义如下:

struct v4l2_subdev_pad_config {
    struct v4l2_mbus_framefmt try_fmt;
    struct v4l2_rect try_crop;
[...]
};

在前面的代码块中,我们感兴趣的主要字段是try_fmt,它是struct v4l2_mbus_framefmt类型。这个数据结构用于描述媒体总线格式的垫级别,并定义如下:

struct v4l2_subdev_format {
    __u32 which;
    __u32 pad;
    struct v4l2_mbus_framefmt format;
[...]
};

在前面的结构中,which是格式类型(尝试或活动),pad是媒体 API 报告的垫编号。这个字段由用户空间设置。format表示总线上的帧格式。这里的format术语表示媒体总线数据格式、帧宽度和帧高度的组合。它是struct v4l2_mbus_framefmt类型,其定义如下:

struct v4l2_mbus_framefmt {
    __u32	width;
    __u32	height;
    __u32	code;
    __u32	field;
    __u32	colorspace;
[...]
};

在前面的总线帧格式数据结构中,只列出了对我们相关的字段。widthheight分别表示图像宽度和高度。code来自enum v4l2_mbus_pixelcode,表示数据格式代码。field表示使用的隔行类型,应该来自enum v4l2_fieldcolorspace表示来自enum v4l2_colorspace的数据颜色空间。

现在,让我们更加关注get_fmtset_fmt回调。它们分别获取和设置图像管道中子设备垫上的数据格式。这些 ioctl 处理程序用于协商图像管道中特定子设备垫的帧格式。要设置当前格式的应用程序,将struct v4l2_subdev_format.pad字段设置为媒体 API 报告的所需垫编号,并将which字段(来自enum v4l2_subdev_format_whence)设置为V4L2_SUBDEV_FORMAT_TRYV4L2_SUBDEV_FORMAT_ACTIVE,然后发出带有指向此结构的指针的VIDIOC_SUBDEV_S_FMTioctl。这个 ioctl 最终会调用v4l2_subdev_pad_ops->set_fmt回调。如果which设置为V4L2_SUBDEV_FORMAT_TRY,那么驱动程序应该使用参数中给定的try格式的值设置请求的垫配置的.try_fmt字段。然而,如果which设置为V4L2_SUBDEV_FORMAT_ACTIVE,那么驱动程序必须将配置应用到设备上。在这种情况下,通常是在流开始时从回调中存储请求的“活动”格式,并将其应用到底层设备。因此,实际应用格式配置到设备的正确位置是在流开始时从回调中调用,例如v4l2_subdev_video_ops.s_stream。以下是 RCAR CSI 驱动程序的示例:

static int rcsi2_set_pad_format(struct v4l2_subdev *sd,
                            struct v4l2_subdev_pad_config *cfg,
                            struct v4l2_subdev_format *format)
{
    struct v4l2_mbus_framefmt *framefmt;
    /* retrieve the private data structure */
    struct rcar_csi2 *priv = sd_to_csi2(sd);
    [...]
    /* Store the requested format so that it can be applied to
     * the device when the pipeline starts
     */
    if (format->which == V4L2_SUBDEV_FORMAT_ACTIVE) {
        priv->mf = format->format;
    } else { /* V4L2_SUBDEV_FORMAT_TRY */ 
        /* set the .try_fmt of this pad config with the
         * value of the requested "try" format
         */
        framefmt = v4l2_subdev_get_try_format(sd, cfg, 0);
        *framefmt = format->format;
        /* driver is free to update any format->* field */
        [...]
    }
    return 0;
}

还要注意,驱动程序可以自由更改请求格式中的值为其实际支持的值。然后由应用程序来检查并根据驱动程序授予的格式调整其逻辑。修改这些try格式不会改变设备状态。

另一方面,当涉及检索当前格式时,应用程序应该像前面一样发出VIDIOC_SUBDEV_G_FMTioctl。这个 ioctl 最终会调用v4l2_subdev_pad_ops->get_fmt回调。驱动程序将使用当前活动格式值或上次存储的try格式填充format字段的成员(大多数情况下在驱动程序状态结构中):

static int rcsi2_get_pad_format(struct v4l2_subdev *sd,
                            struct v4l2_subdev_pad_config *cfg, 
                            struct v4l2_subdev_format *format)
{
    struct rcar_csi2 *priv = sd_to_csi2(sd);
    if (format->which == V4L2_SUBDEV_FORMAT_ACTIVE)
        format->format = priv->mf;
    else
      format->format = *v4l2_subdev_get_try_format(sd, cfg, 0);
    return 0;
}

很明显,在第一次传递给get回调之前,垫配置的.try_fmt字段应该已经初始化,v4l2_subdev_pad_ops.init_cfg回调是进行此初始化的正确位置,如下例所示:

/*
 * Initializes the TRY format to the ACTIVE format on all pads
 * of a subdev. Can be used as the .init_cfg pad operation.
 */
int imx_media_init_cfg(struct v4l2_subdev *sd,
                        struct v4l2_subdev_pad_config *cfg)
{
    struct v4l2_mbus_framefmt *mf_try;
    struct v4l2_subdev_format format;
    unsigned int pad;
    int ret;
    for (pad = 0; pad < sd->entity.num_pads; pad++) {
        memset(&format, 0, sizeof(format));
       format.pad = pad;
       format.which = V4L2_SUBDEV_FORMAT_ACTIVE;
       ret = v4l2_subdev_call(sd, pad, get_fmt, NULL, &format);
       if (ret)
            continue;
        mf_try = v4l2_subdev_get_try_format(sd, cfg, pad);
        *mf_try = format.format;
    }
    return 0;
}

重要提示

支持的格式列表可以在内核源码的include/uapi/linux/videodev2.h中找到,它们的部分文档可以在此链接找到:linuxtv.org/downloads/v4l-dvb-apis/userspace-api/v4l/subdev-formats.html

既然我们已经熟悉了媒体的概念,我们可以学习如何最终通过适当的 API 将媒体设备纳入系统。

注册媒体设备

驱动程序通过调用media_device_register()宏中的__media_device_register()来注册媒体设备实例,并通过调用media_device_unregister()来注销它们。成功注册后,将创建一个名为media[0-9] +的字符设备。设备的主要和次要编号是动态的。media_device_register()接受要注册的媒体设备的指针,并在成功时返回0,在错误时返回负错误代码。

正如我们之前所说,最好在根 notifier 的.complete回调中注册媒体设备,以确保实际的媒体设备只有在所有实体被探测后才注册。以下是 TI OMAP3 ISP 媒体驱动程序的摘录(整个代码可以在内核源码的drivers/media/platform/omap3isp/isp.c中找到):

static int isp_subdev_notifier_complete(
                             struct v4l2_async_notifier *async)
{
    struct isp_device *isp =
              container_of(async, struct isp_device, notifier);
[...]
    return media_device_register(&isp->media_dev);
}
static const
struct v4l2_async_notifier_operations isp_subdev_notifier_ops = {
    .complete = isp_subdev_notifier_complete,
};

前面的代码展示了如何利用根 notifier 的.complete回调来注册最终的媒体设备,通过media_device_register()方法。

既然媒体设备已经成为系统的一部分,现在是时候利用它了,特别是从用户空间。现在让我们看看如何从命令行控制和与媒体设备交互。

来自用户空间的媒体控制器

尽管它仍然是流接口,但/dev/video0不再是默认的管道中心,因为它被/dev/mediaX所包裹。管道可以通过媒体节点(/dev/media*)进行配置,并且控制操作,如流开/关,可以通过视频节点(/dev/video*)执行。

使用 media-ctl(v4l-utils 软件包)

v4l-utils软件包中的media-ctl应用程序是一个用户空间应用程序,它使用 Linux 媒体控制器 API 来配置管道。以下是与其一起使用的标志:

  • --device <dev>指定媒体设备(默认为/dev/media0)。

  • --entity <name>打印与给定实体相关联的设备名称。

  • --set-v4l2 <v4l2>提供一个逗号分隔的格式列表进行设置。

  • --get-v4l2 <pad>打印给定 pad 上的活动格式。

  • --set-dv <pad>在给定的 pad 上配置 DV 定时。

  • --interactive交互修改链接。

  • --links <linux>提供一个逗号分隔的链接描述符列表进行设置。

  • --known-mbus-fmts列出已知格式及其数值。

  • --print-topology打印设备拓扑,或者使用简短版本-p

  • --reset重置所有链接为非活动状态。

也就是说,硬件媒体管道的基本配置步骤如下:

  1. 使用media-ctl --reset重置所有链接。

  2. 使用media-ctl --links配置链接。

  3. 使用media-ctl --set-v4l2配置 pad 格式。

  4. 使用v4l2-ctl配置子设备属性捕获/dev/video*设备上的帧。

使用media-ctl --links将实体源 pad 链接到实体接收 pad 应该遵循以下模式:

media-ctl --links\
"<entitya>:<srcpadn> -> <entityb>:<sinkpadn>[<flags>]

在前一行中,flags可以是0(非活动)或1(活动)。此外,要查看媒体总线的当前设置,请使用以下命令:

$ media-ctl --print-topology

在某些系统上,媒体设备0可能不是默认设备,这种情况下,您应该使用以下方法:

$ media-ctl --device /dev/mediaN --print-topology

前面的命令将打印与指定媒体设备相关联的媒体拓扑。

请注意,--print-topology只是以 ASCII 格式在控制台上转储媒体拓扑。但是,通过生成其dot表示形式,将此拓扑转换为更加人性化的图像更好地表示。以下是使用的命令:

$ media-ctl --print-dot > graph.dot
$ dot -Tpng graph.dot > graph.png

例如,为了设置媒体管道,在 UDOO QUAD 开发板上运行了以下命令。该板已配备 i.MX6 四核和插入 MIPI CSI-2 连接器的 OV5640 摄像头:

# media-ctl -l "'ov5640 2-003c':0 -> 'imx6-mipi-csi2':0[1]"
# media-ctl -l "'imx6-mipi-csi2':2 -> 'ipu1_csi1':0[1]"
# media-ctl -l "'ipu1_csi1':1 -> 'ipu1_ic_prp':0[1]"
# media-ctl -l "'ipu1_ic_prp':1 -> 'ipu1_ic_prpenc':0[1]"
# media-ctl -l "'ipu1_ic_prpenc':1 -> 'ipu1_ic_prpenc capture':0[1]" 

以下是表示前述设置的图表:

图 8.3 - 媒体设备的图形表示

图 8.3 - 媒体设备的图形表示

正如您所看到的,它有助于可视化硬件组件是什么。以下是这些生成图像的描述:

  • 虚线表示可能的连接。您可以使用这些来确定可能性。

  • 实线表示活动连接。

  • 绿色框表示媒体实体。

  • 黄色框表示Video4Linux (V4L)端点。

之后,您可以看到实线与之前进行的设置完全对应。我们有五条实线,对应于用于配置媒体设备的命令数量。以下是这些命令的含义:

  • media-ctl -l "'ov5640 2-003c':0 -> 'imx6-mipi-csi2':0[1]"表示将摄像头传感器('ov5640 2-003c':0)的输出端口号0连接到 MIPI CSI-2 的输入端口号0('imx6-mipi-csi2':0),并设置此链接为活动([1])。

  • media-ctl -l "'imx6-mipi-csi2':2 -> 'ipu1_csi1':0[1]"表示将 MIPI CSI-2 实体('imx6-mipi-csi2':2)的输出接口号2连接到 IPU 捕获传感器接口#1('ipu1_csi1':0)的输入接口号0,并设置此链接为活动([1])。

  • 相同的解码规则适用于其他命令行,直到最后一个命令media-ctl -l "'ipu1_ic_prpenc':1 -> 'ipu1_ic_prpenc capture':0[1]",表示将ipu1的图像转换器预处理编码实体('ipu1_ic_prpenc':1)的输出端口号1连接到捕获接口的输入端口号0,并将此链接设置为活动状态。

请随时返回图像并多次阅读这些描述,以便理解实体、链接和端口的概念。

重要提示

如果您的目标设备上未安装dot软件包,您可以在主机上下载.dot文件(假设主机已安装该软件包)并将其转换为图像。

带有 OV2680 的 WaRP7 示例

WaRP7 是一款基于 i.MX7 的开发板,与 i.MX5/6 系列不同,它不包含 IPU。因此,执行操作或处理捕获帧的能力较少。i.MX7 图像捕获链由三个单元组成:摄像头传感器接口、视频多路复用器和 MIPI CSI-2 接收器,它们表示为以下媒体实体:

  • imx7-mipi-csi2:这是 MIPI CSI-2 接收器实体。它有一个接收来自 MIPI CSI-2 摄像头传感器的像素数据的接收端口。它有一个源端口,对应虚拟通道0

  • csi_mux:这是视频多路复用器。它有两个接收端口,可以从具有并行接口或 MIPI CSI-2 虚拟通道0的摄像头传感器中选择。它有一个源端口,路由到 CSI。

  • csi:CSI 允许芯片直接连接到外部 CMOS 图像传感器。CSI 可以直接与并行和 MIPI CSI-2 总线进行接口。它有 256 x 64 FIFO 来存储接收到的图像像素数据,并嵌入 DMA 控制器通过 AHB 总线从 FIFO 传输数据。此实体有一个接收端口,从csi_mux实体接收,一个源端口,直接路由视频帧到内存缓冲区。此端口路由到捕获设备节点:

                                      |\
MIPI Camera Input --> MIPI CSI-2 -- > | \
                                      |  \
                                      | M |
                                      | U | --> CSI --> Capture
                                      | X |
                                      |  /
Parallel Camera Input --------------> | /
                                      |/

在此平台上,OV2680 MIPI CSI-2 模块连接到内部 MIPI CSI-2 接收器。以下示例配置了一个输出为 800 x 600 的 BGGR 10 位 Bayer 格式的视频捕获管道:

# Setup links
media-ctl --reset
media-ctl -l "'ov2680 1-0036':0 -> 'imx7-mipi-csis.0':0[1]"
media-ctl -l "'imx7-mipi-csis.0':1 -> 'csi_mux':1[1]"
media-ctl -l "'csi_mux':2 -> 'csi':0[1]"
media-ctl -l "'csi':1 -> 'csi capture':0[1]"

前面的行可以合并为一个单一的命令,如下所示:

media-ctl -r -l '"ov2680 1-0036":0->"imx7-mipi-csis.0":0[1], \
                 "imx7-mipi-csis.0":1 ->"csi_mux":1[1], \
                 "csi_mux":2->"csi":0[1], \
                 "csi":1->"csi capture":0[1]'

在前面的命令中,请注意以下内容:

  • -r表示重置所有链接为非活动状态。

  • -l:在逗号分隔的链接描述符列表中设置链接。

  • "ov2680 1-0036":0->"imx7-mipi-csis.0":0[1] 将摄像头传感器的输出端口号0链接到 MIPI CSI-2 输入端口号0,并将此链接设置为活动状态。

  • "csi_mux":2->"csi":0[1]csi_mux的输出端口号2链接到csi的输入端口号0,并将此链接设置为活动状态。

  • "csi":1->"csi capture":0[1]csi的输出端口号1链接到捕获接口的输入端口号0,并将此链接设置为活动状态。

为了在每个端口上配置格式,我们可以使用以下命令:

# Configure pads for pipeline
media-ctl -V "'ov2680 1-0036':0 [fmt:SBGGR10_1X10/800x600 field:none]" 
media-ctl -V "'csi_mux':1 [fmt:SBGGR10_1X10/800x600 field:none]"
media-ctl -V "'csi_mux':2 [fmt:SBGGR10_1X10/800x600 field:none]"
media-ctl \
      -V "'imx7-mipi-csis.0':0 [fmt:SBGGR10_1X10/800x600 field:none]"
media-ctl -V "'csi':0 [fmt:SBGGR10_1X10/800x600 field:none]"

再次,前面的命令行可以合并为一个单一的命令,如下所示:

media-ctl \
    -f '"ov2680 1-0036":0 [SGRBG10 800x600 (32,20)/800x600], \
        "csi_mux":1 [SGRBG10 800x600], \
        "csi_mux":2 [SGRBG10 800x600], \
        "mx7-mipi-csis.0":2 [SGRBG10 800x600], \
        "imx7-mipi-csi.0":0 [SGRBG10 800x600], \
        "csi":0 [UYVY 800x600]'

前面的命令行可以翻译如下:

  • -f:将端口格式设置为逗号分隔的格式描述符列表。

  • "ov2680 1-0036":0 [SGRBG10 800x600 (32,20)/800x600]: 将摄像头传感器端口号0的格式设置为 800 x 600 的 RAW Bayer 10 位图像。设置最大允许的传感器窗口宽度,指定裁剪矩形。

  • "csi_mux":1 [SGRBG10 800x600]:将csi_mux端口号1的格式设置为 800 x 600 的 RAW Bayer 10 位图像。

  • "csi_mux":2 [SGRBG10 800x600]: 将csi_mux端口号2的格式设置为 800 x 600 的 RAW Bayer 10 位图像。

  • "csi":0 [UYVY 800x600]: 将csi端口号0的格式设置为分辨率为 800 x 600 的YUV4:2:2图像。

video_muxcsimipi-csi-2都是 SoC 的一部分,因此它们在供应商dtsi文件中声明(即内核源代码中的arch/arm/boot/dts/imx7s.dtsi)。video_mux声明如下:

gpr: iomuxc-gpr@30340000 {
[...]
    video_mux: csi-mux {
        compatible = "video-mux";
        mux-controls = <&mux 0>;
        #address-cells = <1>;
        #size-cells = <0>;
        status = "disabled";
        port@0 {
            reg = <0>;
        };
        port@1 {
            reg = <1>;
            csi_mux_from_mipi_vc0: endpoint {
                remote-endpoint = <&mipi_vc0_to_csi_mux>;
            };
        };
        port@2 {
            reg = <2>;
           csi_mux_to_csi: endpoint {
               remote-endpoint = <&csi_from_csi_mux>;
           };
        };
    }; 
};

在前面的代码块中,我们有三个端口,其中端口12连接到远程端点。csimipi-csi-2声明如下:

mipi_csi: mipi-csi@30750000 {
    compatible = "fsl,imx7-mipi-csi2";
[...]
    status = "disabled";
    port@0 {
        reg = <0>;
    };
    port@1 {
        reg = <1>;
        mipi_vc0_to_csi_mux: endpoint {
            remote-endpoint = <&csi_mux_from_mipi_vc0>;
        };
    };
};
[...]
csi: csi@30710000 {
    compatible = "fsl,imx7-csi"; [...]
    status = "disabled";
    port {
        csi_from_csi_mux: endpoint {
            remote-endpoint = <&csi_mux_to_csi>;
        };
    };
};

csimipi-csi-2节点,我们可以看到它们如何链接到video_mux节点中的远程端口。

重要说明

有关video_mux绑定的更多信息可以在内核源代码中的Documentation/devicetree/bindings/media/video-mux.txt中找到。

然而,大多数供应商声明的节点默认情况下是禁用的,需要在板文件(实际上是dts文件)中启用。这就是下面的代码块所做的。此外,摄像头传感器是板的一部分,而不是 SoC 的一部分。因此,需要在板dts文件中声明它,即内核源代码中的arch/arm/boot/dts/imx7s-warp.dts。以下是摘录:

&video_mux {
    status = "okay";
};
&mipi_csi {
    clock-frequency = <166000000>;
    fsl,csis-hs-settle = <3>;
    status = "okay";
    port@0 {
        reg = <0>;
        mipi_from_sensor: endpoint {
            remote-endpoint = <&ov2680_to_mipi>;
            data-lanes = <1>;
        };
    };
};
&i2c2 {
    [...]
    status = "okay";
    ov2680: camera@36 {
        compatible = "ovti,ov2680";
        [...]
    port {
        ov2680_to_mipi: endpoint {
            remote-endpoint = <&mipi_from_sensor>;
            clock-lanes = <0>;
            data-lanes = <1>;
        };
    };
};

重要说明

有关 i.MX7 实体绑定的更多信息可以在内核源代码中的Documentation/devicetree/bindings/media/imx7-csi.txtDocumentation/devicetree/bindings/media/imx7-mipi-csi2.txt中找到。

之后,流媒体可以开始。v4l2-ctl工具可用于选择传感器支持的任何分辨率:

root@imx7s-warp:~# media-ctl -p
Media controller API version 4.17.0
Media device information
------------------------
driver          imx7-csi
model           imx-media
serial
bus info
hw revision     0x0
driver version  4.17.0
Device topology
- entity 1: csi (2 pads, 2 links)
            type V4L2 subdev subtype Unknown flags 0
            device node name /dev/v4l-subdev0
        pad0: Sink
                [fmt:SBGGR10_1X10/800x600 field:none]
                <- "csi-mux":2 [ENABLED]
        pad1: Source
                [fmt:SBGGR10_1X10/800x600 field:none]
                -> "csi capture":0 [ENABLED]
- entity 4: csi capture (1 pad, 1 link)
            type Node subtype V4L flags 0
            device node name /dev/video0
        pad0: Sink
                <- "csi":1 [ENABLED]
- entity 10: csi-mux (3 pads, 2 links)
             type V4L2 subdev subtype Unknown flags 0
             device node name /dev/v4l-subdev1
        pad0: Sink
                [fmt:unknown/0x0]
        pad1: Sink
               [fmt:unknown/800x600 field:none]
                <- "imx7-mipi-csis.0":1 [ENABLED]
        pad2: Source
                [fmt:unknown/800x600 field:none]
                -> "csi":0 [ENABLED]
- entity 14: imx7-mipi-csis.0 (2 pads, 2 links)
             type V4L2 subdev subtype Unknown flags 0
             device node name /dev/v4l-subdev2
        pad0: Sink
                [fmt:SBGGR10_1X10/800x600 field:none]
                <- "ov2680 1-0036":0 [ENABLED]
        pad1: Source
                [fmt:SBGGR10_1X10/800x600 field:none]
                -> "csi-mux":1 [ENABLED]
- entity 17: ov2680 1-0036 (1 pad, 1 link)
             type V4L2 subdev subtype Sensor flags 0
             device node name /dev/v4l-subdev3
        pad0: Source
                [fmt:SBGGR10_1X10/800x600 field:none]
                -> "imx7-mipi-csis.0":0 [ENABLED]

随着数据从左到右流动,我们可以将前面的控制台日志解释如下:

  • -> "imx7-mipi-csis.0":0 [ENABLED]: 此源端口向其右侧的实体提供数据,该实体是"imx7-mipi-csis.0":0

  • <- "ov2680 1-0036":0 [ENABLED]: 此接收端口由其左侧的实体提供数据(即,它从左侧查询数据),该实体是"ov2680 1-0036":0

我们现在已经完成了媒体控制器框架的所有方面。我们从其架构开始,然后详细描述了它的数据结构,然后详细了解了其 API。最后,我们以用户空间中的使用方式结束,以利用模式媒体管道。

摘要

在本章中,我们通过了 V4L2 异步接口,这简化了视频桥和子设备驱动程序的探测。这对于固有异步和无序设备注册系统非常有用,比如扁平设备树驱动程序的探测。此外,我们处理了媒体控制器框架,它允许利用 V4L2 视频管道。到目前为止,我们所看到的都是在内核空间中。

在下一章中,我们将看到如何从用户空间处理 V4L2 设备,从而利用其设备驱动程序提供的功能。

第九章:从用户空间利用 V4L2 API

设备驱动程序的主要目的是控制和利用底层硬件,同时向用户公开功能。这些用户可能是在用户空间运行的应用程序或其他内核驱动程序。前两章涉及 V4L2 设备驱动程序,而在本章中,我们将学习如何利用内核公开的 V4L2 设备功能。我们将首先描述和枚举用户空间 V4L2 API,然后学习如何利用这些 API 从传感器中获取视频数据,包括篡改传感器属性。

本章将涵盖以下主题:

  • V4L2 用户空间 API

  • 视频设备属性管理从用户空间

  • 用户空间的缓冲区管理

  • V4L2 用户空间工具

技术要求

为了充分利用本章,您将需要以下内容:

从用户空间介绍 V4L2

编写设备驱动程序的主要目的是简化应用程序对底层设备的控制和使用。用户空间处理 V4L2 设备有两种方式:一种是使用诸如GStreamer及其gst-*工具之类的一体化工具,另一种是使用用户空间 V4L2 API 编写专用应用程序。在本章中,我们只涉及代码,因此我们将介绍如何编写使用 V4L2 API 的应用程序。

V4L2 用户空间 API

V4L2 用户空间 API 具有较少的功能和大量的数据结构,所有这些都在include/uapi/linux/videodev2.h中定义。在本节中,我们将尝试描述其中最重要的,或者更确切地说,最常用的。您的代码应包括以下标头:

#include <linux/videodev2.h>

此 API 依赖以下功能:

  • open(): 打开视频设备

  • close(): 关闭视频设备

  • ioctl(): 向显示驱动程序发送 ioctl 命令

  • mmap(): 将驱动程序分配的缓冲区内存映射到用户空间

  • read()write(),取决于流方法

这个减少的 API 集合由大量的 ioctl 命令扩展,其中最重要的是:

  • VIDIOC_QUERYCAP: 用于查询驱动程序的功能。人们过去常说它用于查询设备的功能,但这并不正确,因为设备可能具有驱动程序中未实现的功能。用户空间传递一个struct v4l2_capability结构,该结构将由视频驱动程序填充相关信息。

  • VIDIOC_ENUM_FMT: 用于枚举驱动程序支持的图像格式。驱动程序用户空间传递一个struct v4l2_fmtdesc结构,该结构将由驱动程序填充相关信息。

  • VIDIOC_G_FMT: 对于捕获设备,用于获取当前图像格式。但是,对于显示设备,您可以使用此功能获取当前显示窗口。在任何情况下,用户空间传递一个struct v4l2_format结构,该结构将由驱动程序填充相关信息。

  • VIDIOC_TRY_FMT应在不确定要提交给设备的格式时使用。这用于验证捕获设备的新图像格式或根据输出(显示)设备使用新的显示窗口。用户空间传递一个带有它想要应用的属性的struct v4l2_format结构,如果它们不受支持,驱动程序可能会更改给定的值。然后应用程序应检查授予了什么。

  • VIDIOC_S_FMT用于为捕获设备设置新的图像格式或为显示(输出设备)设置新的显示窗口。如果不首先使用VIDIOC_TRY_FMT,驱动程序可能会更改用户空间传递的值,如果它们不受支持。应用程序应检查是否授予了什么。

  • VIDIOC_CROPCAP 用于根据当前图像大小和当前显示面板大小获取默认裁剪矩形。驱动程序填充一个 struct v4l2_cropcap 结构。

  • VIDIOC_G_CROP 用于获取当前裁剪矩形。驱动程序填充一个 struct v4l2_crop 结构。

  • VIDIOC_S_CROP 用于设置新的裁剪矩形。驱动程序填充一个 struct v4l2_crop 结构。应用程序应该检查授予了什么。

  • VIDIOC_REQBUFS:这个 ioctl 用于请求一定数量的缓冲区,以便稍后进行内存映射。驱动程序填充一个 struct v4l2_requestbuffers 结构。由于驱动程序可能分配的缓冲区数量多于或少于实际请求的数量,应用程序应该检查实际授予了多少个缓冲区。在此之后还没有排队任何缓冲区。

  • VIDIOC_QUERYBUF ioctl 用于获取缓冲区的信息,这些信息可以被 mmap() 系统调用用来将缓冲区映射到用户空间。驱动程序填充一个 struct v4l2_buffer 结构。

  • VIDIOC_QBUF 用于通过传递与该缓冲区相关联的 struct v4l2_buffer 结构来排队一个缓冲区。在这个 ioctl 的执行路径上,驱动程序将把这个缓冲区添加到其缓冲区列表中,以便在没有更多待处理的排队缓冲区之前填充它。一旦缓冲区被填充,它就会传递给 V4L2 核心,它维护自己的列表(即准备好的缓冲区列表),并且它会从驱动程序的 DMA 缓冲区列表中移除。

  • VIDIOC_DQBUF 用于从 V4L2 的准备好的缓冲区列表(对于输入设备)或显示的(输出设备)缓冲区中出列一个已填充的缓冲区,通过传递与该缓冲区相关联的 struct v4l2_buffer 结构。如果没有准备好的缓冲区,它会阻塞,除非在 open() 中使用了 O_NONBLOCK,在这种情况下,VIDIOC_DQBUF 会立即返回一个 EAGAIN 错误代码。只有在调用了 STREAMON 之后才应该调用 VIDIOC_DQBUF。与此同时,在 STREAMOFF 之后调用这个 ioctl 会返回 -EINVAL

  • VIDIOC_STREAMON 用于开启流。之后,任何 VIDIOC_QBUF 的结果都会呈现图像。

  • VIDIOC_STREAMOFF 用于关闭流。这个 ioctl 移除所有缓冲区。它实际上刷新了缓冲队列。

有很多 ioctl 命令,不仅仅是我们刚刚列举的那些。实际上,内核的 v4l2_ioctl_ops 数据结构中至少有和操作一样多的 ioctl。然而,上述的 ioctl 已经足够深入了解 V4L2 用户空间 API。在本节中,我们不会详细介绍每个数据结构。因此,你应该保持 include/uapi/linux/videodev2.h 文件的打开状态,也可以在 elixir.bootlin.com/linux/v4.19/source/include/uapi/linux/videodev2.h 找到,因为它包含了所有的 V4L2 API 和数据结构。话虽如此,以下伪代码展示了使用 V4L2 API 从用户空间抓取视频的典型 ioctl 序列:

open()
int ioctl(int fd, VIDIOC_QUERYCAP,           struct v4l2_capability *argp)
int ioctl(int fd, VIDIOC_S_FMT, struct v4l2_format *argp)
int ioctl(int fd, VIDIOC_S_FMT, struct v4l2_format *argp)
/* requesting N buffers */
int ioctl(int fd, VIDIOC_REQBUFS,           struct v4l2_requestbuffers *argp)
/* queueing N buffers */
int ioctl(int fd, VIDIOC_QBUF, struct v4l2_buffer *argp)
/* start streaming */
int ioctl(int fd, VIDIOC_STREAMON, const int *argp) 
read_loop: (for i=0; I < N; i++)
    /* Dequeue buffer i */
    int ioctl(int fd, VIDIOC_DQBUF, struct v4l2_buffer *argp)
    process_buffer(i)
    /* Requeue buffer i */
    int ioctl(int fd, VIDIOC_QBUF, struct v4l2_buffer *argp)
end_loop
    releases_memories()
    close()

上述序列将作为指南来处理用户空间中的 V4L2 API。

请注意,ioctl 系统调用可能返回 -1 值,而 errno = EINTR。在这种情况下,这并不意味着错误,而只是系统调用被中断,此时应该再次尝试。为了解决这个(虽然可能性很小但是可能发生的)问题,我们可以考虑编写自己的 ioctl 包装器,例如以下内容:

static int xioctl(int fh, int request, void *arg)
{
        int r;
        do {
                r = ioctl(fh, request, arg);
        } while (-1 == r && EINTR == errno);
        return r;
}

现在我们已经完成了视频抓取序列的概述,我们可以弄清楚从设备打开到关闭的视频流程需要哪些步骤,包括格式协商。现在我们可以跳转到代码,从设备打开开始,一切都从这里开始。

视频设备的打开和属性管理

驱动程序在/dev/目录中公开节点条目,对应于它们负责的视频接口。这些文件节点对应于捕获设备的/dev/videoX特殊文件(在我们的情况下)。应用程序必须在与视频设备的任何交互之前打开适当的文件节点。它使用open()系统调用来打开,这将返回一个文件描述符,将成为发送到设备的任何命令的入口点,如下例所示:

static const char *dev_name = "/dev/video0";
fd = open (dev_name, O_RDWR);
if (fd == -1) {
    perror("Failed to open capture device\n");
    return -1;
}

前面的片段是以阻塞模式打开的。将O_NONBLOCK传递给open()将防止应用程序在尝试出队时没有准备好的缓冲区时被阻塞。完成视频设备的使用后,应使用close()系统调用关闭它:

close (fd);

在我们能够打开视频设备之后,我们可以开始与其进行交互。通常,视频设备打开后发生的第一个动作是查询其功能,通过这个功能,我们可以使其以最佳方式运行。

查询设备功能

通常查询设备的功能以确保它支持我们需要处理的模式是很常见的。您可以使用VIDIOC_QUERYCAP ioctl 命令来执行此操作。为此,应用程序传递一个struct v4l2_capability结构(在include/uapi/linux/videodev2.h中定义),该结构将由驱动程序填充。该结构具有一个.capabilities字段需要进行检查。该字段包含整个设备的功能。内核源代码的以下摘录显示了可能的值:

/* Values for 'capabilities' field */
#define V4L2_CAP_VIDEO_CAPTURE 0x00000001 /*video capture device*/ #define V4L2_CAP_VIDEO_OUTPUT 0x00000002  /*video output device*/ #define V4L2_CAP_VIDEO_OVERLAY 0x00000004 /*Can do video overlay*/ [...] /* VBI device skipped */
/* video capture device that supports multiplanar formats */#define V4L2_CAP_VIDEO_CAPTURE_MPLANE	0x00001000
/* video output device that supports multiplanar formats */ #define V4L2_CAP_VIDEO_OUTPUT_MPLANE	0x00002000
/* mem-to-mem device that supports multiplanar formats */#define V4L2_CAP_VIDEO_M2M_MPLANE	0x00004000
/* Is a video mem-to-mem device */#define V4L2_CAP_VIDEO_M2M	0x00008000
[...] /* radio, tunner and sdr devices skipped */
#define V4L2_CAP_READWRITE	0x01000000 /*read/write systemcalls */ #define V4L2_CAP_ASYNCIO	0x02000000	/* async I/O */
#define V4L2_CAP_STREAMING	0x04000000	/* streaming I/O ioctls */ #define V4L2_CAP_TOUCH	0x10000000	/* Is a touch device */

以下代码块显示了一个常见用例,展示了如何使用VIDIOC_QUERYCAP ioctl 从代码中查询设备功能:

#include <linux/videodev2.h>
[...]
struct v4l2_capability cap;
memset(&cap, 0, sizeof(cap));
if (-1 == xioctl(fd, VIDIOC_QUERYCAP, &cap)) {
    if (EINVAL == errno) {
        fprintf(stderr, "%s is no V4L2 device\n", dev_name);
        exit(EXIT_FAILURE);
    } else {
        errno_exit("VIDIOC_QUERYCAP" 
    }
}

在前面的代码中,struct v4l2_capability在传递给ioctl命令之前首先通过memset()清零。在这一步,如果没有错误发生,那么我们的cap变量现在包含了设备的功能。您可以使用以下内容来检查设备类型和 I/O 方法:

if (!(cap.capabilities & V4L2_CAP_VIDEO_CAPTURE)) {
    fprintf(stderr, "%s is not a video capture device\n",             dev_name);
    exit(EXIT_FAILURE);
}
if (!(cap.capabilities & V4L2_CAP_READWRITE))
    fprintf(stderr, "%s does not support read i/o\n",             dev_name);
/* Check whether USERPTR and/or MMAP method are supported */
if (!(cap.capabilities & V4L2_CAP_STREAMING))
    fprintf(stderr, "%s does not support streaming i/o\n",             dev_name);
/* Check whether driver support read/write i/o */
if (!(cap.capabilities & V4L2_CAP_READWRITE))
    fprintf (stderr, "%s does not support read i/o\n",              dev_name);

您可能已经注意到,在使用之前,我们首先将cap变量清零。在给 V4L2 API 提供参数时,清除参数是一个好的做法,以避免陈旧的内容。然后,让我们定义一个宏——比如CLEAR——它将清零作为参数给定的任何变量,并在本章的其余部分中使用它:

#define CLEAR(x) memset(&(x), 0, sizeof(x))

现在,我们已经完成了查询视频设备功能。这使我们能够配置设备并根据我们需要实现的内容调整图像格式。通过协商适当的图像格式,我们可以利用视频设备,正如我们将在下一节中看到的那样。

缓冲区管理

在 V4L2 中,维护两个缓冲队列:一个用于驱动程序(称为VIDIOC_QBUF ioctl)。缓冲区按照它们被入队的顺序由驱动程序填充。一旦填充,每个缓冲区就会从输入队列移出,并放入输出队列,即用户队列。

每当用户应用程序调用VIDIOC_DQBUF以出队一个缓冲区时,该缓冲区将在输出队列中查找。如果在那里,缓冲区将被出队并推送到用户应用程序;否则,应用程序将等待直到有填充的缓冲区。用户完成使用缓冲区后,必须调用VIDIOC_QBUF将该缓冲区重新入队到输入队列中,以便可以再次填充。

驱动程序初始化后,应用程序调用VIDIOC_REQBUFS ioctl 来设置它需要处理的缓冲区数量。一旦获准,应用程序使用VIDIOC_QBUF队列中的所有缓冲区,然后调用VIDIOC_STREAMON ioctl。然后,驱动程序自行填充所有排队的缓冲区。如果没有更多排队的缓冲区,那么驱动程序将等待应用程序入队缓冲区。如果出现这种情况,那么这意味着在捕获本身中丢失了一些帧。

图像(缓冲区)格式

在确保设备是正确类型并支持其可以使用的模式之后,应用程序必须协商其需要的视频格式。应用程序必须确保视频设备配置为以应用程序可以处理的格式发送视频帧。在开始抓取和收集数据(或视频帧)之前,必须这样做。V4L2 API 使用struct v4l2_format来表示缓冲区格式,无论设备类型是什么。该结构定义如下:

struct v4l2_format {
 u32 type;
 union {
  struct v4l2_pix_format pix; /* V4L2_BUF_TYPE_VIDEO_CAPTURE */    
  struct v4l2_pix_format_mplane pix_mp; /* _CAPTURE_MPLANE */
  struct v4l2_window win;	 /* V4L2_BUF_TYPE_VIDEO_OVERLAY */
  struct v4l2_vbi_format vbi; /* V4L2_BUF_TYPE_VBI_CAPTURE */
  struct v4l2_sliced_vbi_format sliced;/*_SLICED_VBI_CAPTURE */ 
  struct v4l2_sdr_format sdr;   /* V4L2_BUF_TYPE_SDR_CAPTURE */
  struct v4l2_meta_format meta;/* V4L2_BUF_TYPE_META_CAPTURE */
        [...]
    } fmt;
};

在前面的结构中,type字段表示数据流的类型,并应由应用程序设置。根据其值,fmt字段将是适当的类型。在我们的情况下,type必须是V4L2_BUF_TYPE_VIDEO_CAPTURE,因为我们正在处理视频捕获设备。然后,fmt将是struct v4l2_pix_format类型。

重要说明

几乎所有(如果不是全部)直接或间接与缓冲区播放的 ioctl(如裁剪、缓冲区请求/排队/出队/查询)都需要指定缓冲区类型,这是有道理的。我们将使用V4L2_BUF_TYPE_VIDEO_CAPTURE,因为这是我们设备类型的唯一选择。缓冲区类型的整个列表是在include/uapi/linux/videodev2.h中定义的enum v4l2_buf_type类型。你应该看一看。

应用程序通常会查询视频设备的当前格式,然后仅更改其中感兴趣的属性,并将新的混合缓冲区格式发送回视频设备。但这并不是强制性的。我们只是在这里做了这个演示,以演示您如何获取或设置当前格式。应用程序使用VIDIOC_G_FMT ioctl 命令查询当前缓冲区格式。它必须传递一个新的(我指的是清零的)struct v4l2_format结构,并设置type字段。驱动程序将在 ioctl 的返回路径中填充其余部分。以下是一个例子:

struct v4l2_format fmt;
CLEAR(fmt);
/* Get the current format */
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (ioctl(fd, VIDIOC_G_FMT, &fmt)) {
    printf("Getting format failed\n");
    exit(2);
}

一旦我们有了当前的格式,我们就可以更改相关属性,并将新格式发送回设备。这些属性可能是像素格式,每个颜色分量的内存组织,以及每个字段的交错捕获内存组织。我们还可以描述缓冲区的大小和间距。设备支持的常见(但不是唯一的)像素格式如下:

  • V4L2_PIX_FMT_YUYV:YUV422(交错)

  • V4L2_PIX_FMT_NV12:YUV420(半平面)

  • V4L2_PIX_FMT_NV16:YUV422(半平面)

  • V4L2_PIX_FMT_RGB24:RGB888(打包)

现在,让我们编写改变我们需要的属性的代码片段。但是,将新格式发送到视频设备需要使用新的 ioctl 命令,即VIDIOC_S_FMT

#define WIDTH	1920
#define HEIGHT	1080
#define PIXFMT	V4L2_PIX_FMT_YUV420
/* Changing required properties and set the format */ fmt.fmt.pix.width = WIDTH;
fmt.fmt.pix.height = HEIGHT;
fmt.fmt.pix.bytesperline = fmt.fmt.pix.width * 2u;
fmt.fmt.pix.sizeimage = fmt.fmt.pix.bytesperline * fmt.fmt.pix.height; 
fmt.fmt.pix.colorspace = V4L2_COLORSPACE_REC709;
fmt.fmt.pix.field = V4L2_FIELD_ANY;
fmt.fmt.pix.pixelformat = PIXFMT;
fmt.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
if (xioctl(fd, VIDIOC_S_FMT, &fmt)) {
    printf("Setting format failed\n");
    exit(2);
}

重要说明

我们可以使用前面的代码,而不需要当前格式。

ioctl 可能会成功。但是,这并不意味着您的参数已经被应用。默认情况下,设备可能不支持每种图像宽度和高度的组合,甚至不支持所需的像素格式。在这种情况下,驱动程序将应用其支持的最接近的值,以符合您请求的值。然后,您需要检查您的参数是否被接受,或者被授予的参数是否足够好,以便您继续进行:

if (fmt.fmt.pix.pixelformat != PIXFMT)
   printf("Driver didn't accept our format. Can't proceed.\n");
/* because VIDIOC_S_FMT may change width and height */
if ((fmt.fmt.pix.width != WIDTH) ||     (fmt.fmt.pix.height != HEIGHT))     
 fprintf(stderr, "Warning: driver is sending image at %dx%d\n",
            fmt.fmt.pix.width, fmt.fmt.pix.height);

我们甚至可以进一步改变流参数,例如每秒帧数。我们可以通过以下方式实现这一点:

  • 使用VIDIOC_G_PARM ioctl 查询视频设备的流参数。此 ioctl 接受一个新的struct v4l2_streamparm结构作为参数,并设置其type成员。此类型应该是enum v4l2_buf_type值之一。

  • 检查v4l2_streamparm.parm.capture.capability,并确保设置了V4L2_CAP_TIMEPERFRAME标志。这意味着驱动程序允许更改捕获帧速率。

如果是这样,我们可以(可选地)使用VIDIOC_ENUM_FRAMEINTERVALS ioctl 来获取可能的帧间隔列表(API 使用帧间隔,这是帧速率的倒数)。

  • 使用VIDIOC_S_PARM ioctl 并填写v4l2_streamparm.parm.capture.timeperframe成员的适当值。这应该允许设置捕获端的帧速率。您的任务是确保您读取得足够快,以免出现帧丢失。

以下是一个例子:

#define FRAMERATE 30
struct v4l2_streamparm parm;
int error;
CLEAR(parm);
parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
/* first query streaming parameters */
error = xioctl(fd, VIDIOC_G_PARM, &parm);
if (!error) {
    /* Now determine if the FPS selection is supported */
    if (parm.parm.capture.capability & V4L2_CAP_TIMEPERFRAME) {
        /* yes we can */
        CLEAR(parm);
        parm.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
        parm.parm.capture.capturemode = 0;
        parm.parm.capture.timeperframe.numerator = 1;
        parm.parm.capture.timeperframe.denominator = FRAMERATE;
        error = xioctl(fd, VIDIOC_S_PARM, &parm);
        if (error)
            printf("Unable to set the FPS\n");
        else
           /* once again, driver may have changed our requested 
            * framerate */
            if (FRAMERATE != 
                  parm.parm.capture.timeperframe.denominator)
                printf ("fps coerced ......: from %d to %d\n",
                        FRAMERATE,
                   parm.parm.capture.timeperframe.denominator);

现在,我们可以协商图像格式并设置流参数。下一个逻辑延续将是请求缓冲区并继续进行进一步处理。

请求缓冲区

完成格式准备后,现在是指示驱动程序分配用于存储视频帧的内存的时候了。VIDIOC_REQBUFS ioctl 就是为了实现这一点。此 ioctl 将新的struct v4l2_requestbuffers结构作为参数。在传递给 ioctl 之前,v4l2_requestbuffers必须设置其一些字段:

  • v4l2_requestbuffers.count:此成员应设置为要分配的内存缓冲区的数量。此成员应设置为确保帧不会因输入队列中排队的缓冲区不足而丢失的值。大多数情况下,34是正确的值。因此,驱动程序可能不满意请求的缓冲区数量。在这种情况下,驱动程序将在 ioctl 的返回路径上使用授予的缓冲区数量设置v4l2_requestbuffers.count。然后,应用程序应检查此值,以确保此授予的值符合其需求。

  • v4l2_requestbuffers.type:这必须使用enum 4l2_buf_type类型的视频缓冲区类型进行设置。在这里,我们再次使用V4L2_BUF_TYPE_VIDEO_CAPTURE。例如,对于输出设备,这将是V4L2_BUF_TYPE_VIDEO_OUTPUT

  • v4l2_requestbuffers.memory:这必须是可能的enum v4l2_memory值之一。感兴趣的可能值是V4L2_MEMORY_MMAPV4L2_MEMORY_USERPTRV4L2_MEMORY_DMABUF。这些都是流式传输方法。但是,根据此成员的值,应用程序可能需要执行其他任务。不幸的是,VIDIOC_REQBUFS命令是应用程序发现给定驱动程序支持哪些类型的流式 I/O 缓冲区的唯一方法。然后,应用程序可以尝试使用这些值中的每一个VIDIOC_REQBUFS,并根据失败或成功的情况调整其逻辑。

请求用户指针缓冲区 - VIDIOC_REQBUFS 和 malloc

这一步涉及驱动程序支持流式传输模式,特别是用户指针 I/O 模式。在这里,应用程序通知驱动程序即将分配一定数量的缓冲区:

#define BUF_COUNT 4
struct v4l2_requestbuffers req; CLEAR (req);
req.count	= BUF_COUNT;
req.type	= V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory	= V4L2_MEMORY_USERPTR;
if (-1 == xioctl (fd, VIDIOC_REQBUFS, &req)) {
    if (EINVAL == errno)
        fprintf(stderr,                 "%s does not support user pointer i/o\n", 
                dev_name);
    else
        fprintf("VIDIOC_REQBUFS failed \n");
}

然后,应用程序从用户空间分配缓冲区内存:

struct buffer_addr {
    void  *start;
    size_t length;
};
struct buffer_addr *buf_addr;
int i;
buf_addr = calloc(BUF_COUNT, sizeof (*buffer_addr));
if (!buf_addr) {
    fprintf(stderr, "Out of memory\n");
    exit (EXIT_FAILURE);
}
for (i = 0; i < BUF_COUNT; ++i) {
    buf_addr[i].length = buffer_size;
    buf_addr[i].start = malloc(buffer_size);
    if (!buf_addr[i].start) {
        fprintf(stderr, "Out of memory\n");
        exit(EXIT_FAILURE);
    }
}

这是第一种流式传输,其中缓冲区在用户空间中分配并交给内核以便填充视频数据:所谓的用户指针 I/O 模式。还有另一种花哨的流式传输模式,几乎所有操作都是从内核完成的。让我们立刻介绍它。

请求内存可映射缓冲区 - VIDIOC_REQBUFS,VIDIOC_QUERYBUF 和 mmap

在驱动程序缓冲区模式中,此 ioctl 还返回v4l2_requestbuffer结构的count成员中分配的实际缓冲区数量。此流式传输方法还需要一个新的数据结构struct v4l2_buffer。在内核中由驱动程序分配缓冲区后,此结构与VIDIOC_QUERYBUFS ioctl 一起使用,以查询每个分配的缓冲区的物理地址,该地址可与mmap()系统调用一起使用。驱动程序返回的物理地址将存储在buffer.m.offset中。

以下代码摘录指示驱动程序分配内存缓冲区并检查授予的缓冲区数量:

#define BUF_COUNT_MIN 3
struct v4l2_requestbuffers req; CLEAR (req);
req.count	= BUF_COUNT;
req.type	= V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory	= V4L2_MEMORY_MMAP;
if (-1 == xioctl (fd, VIDIOC_REQBUFS, &req)) {
    if (EINVAL == errno)
        fprintf(stderr, "%s does not support memory mapping\n", 
                dev_name);
    else
        fprintf("VIDIOC_REQBUFS failed \n");
}
/* driver may have granted less than the number of buffers we
 * requested let's then make sure it is not less than the
 * minimum we can deal with
 */
if (req.count < BUF_COUNT_MIN) {
    fprintf(stderr, "Insufficient buffer memory on %s\n",             dev_name);
    exit (EXIT_FAILURE);
}

之后,应用程序应该对每个分配的缓冲区调用VIDIOC_QUERYBUF ioctl,以获取它们对应的物理地址,如下例所示:

struct buffer_addr {
    void *start;
    size_t length;
};
struct buffer_addr *buf_addr;
buf_addr = calloc(BUF_COUNT, sizeof (*buffer_addr));
if (!buf_addr) {
    fprintf (stderr, "Out of memory\n");
    exit (EXIT_FAILURE);
}
for (i = 0; i < req.count; ++i) {
    struct v4l2_buffer buf;
    CLEAR (buf);
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP; buf.index	= i;
    if (-1 == xioctl (fd, VIDIOC_QUERYBUF, &buf))
        errno_exit("VIDIOC_QUERYBUF");
    buf_addr[i].length = buf.length;
    buf_addr[i].start =
        mmap (NULL /* start anywhere */, buf.length,
              PROT_READ | PROT_WRITE /* required */,
              MAP_SHARED /* recommended */, fd, buf.m.offset);
    if (MAP_FAILED == buf_addr[i].start)
        errno_exit("mmap");
}

为了使应用程序能够内部跟踪每个缓冲区的内存映射(使用mmap()获得),我们定义了一个自定义数据结构struct buffer_addr,为每个授予的缓冲区分配,该结构将保存与该缓冲区对应的映射。

请求 DMABUF 缓冲区 - VIDIOC_REQBUFS、VIDIOC_EXPBUF 和 mmap

DMABUF 主要用于mem2mem设备,并引入了导出者导入者的概念。假设驱动程序A想要使用由驱动程序B创建的缓冲区;那么我们称B为导出者,A为缓冲区用户/导入者。

export方法指示驱动程序通过文件描述符将其 DMA 缓冲区导出到用户空间。应用程序使用VIDIOC_EXPBUF ioctl 来实现这一点,并需要一个新的数据结构struct v4l2_exportbuffer。在此 ioctl 的返回路径上,驱动程序将使用文件描述符设置v4l2_requestbuffers.md成员,该文件描述符对应于给定缓冲区。这是一个 DMABUF 文件描述符:

/* V4L2 DMABuf export */
struct v4l2_requestbuffers req;
CLEAR (req);
req.count = BUF_COUNT;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_DMABUF;
if (-1 == xioctl(fd, VIDIOC_REQBUFS, &req))
    errno_exit ("VIDIOC_QUERYBUFS");

应用程序可以将这些缓冲区导出为 DMABUF 文件描述符,以便可以将其内存映射到访问捕获的视频内容。应用程序应该使用VIDIOC_EXPBUFioctl 来实现这一点。此 ioctl 扩展了内存映射 I/O 方法,因此仅适用于V4L2_MEMORY_MMAP缓冲区。但是,使用VIDIOC_EXPBUF导出捕获缓冲区然后映射它们实际上是没有意义的。应该使用V4L2_MEMORY_MMAP

VIDIOC_EXPBUF在涉及 V4L2 输出设备时变得非常有趣。这样,应用程序可以使用VIDIOC_REQBUFSioctl 在捕获和输出设备上分配缓冲区,然后应用程序将输出设备的缓冲区导出为 DMABUF 文件描述符,并在捕获设备上的入队 ioctl 之前使用这些文件描述符来设置v4l2_buffer.m.fd字段。然后,排队的缓冲区将填充其对应的缓冲区(与v4l2_buffer.m.fd对应的输出设备缓冲区)。

在下面的示例中,我们将输出设备缓冲区导出为 DMABUF 文件描述符。这假设已经使用VIDIOC_REQBUFSioctl 分配了此输出设备的缓冲区,其中req.type设置为V4L2_BUF_TYPE_VIDEO_OUTPUTreq.memory设置为V4L2_MEMORY_DMABUF

int outdev_dmabuf_fd[BUF_COUNT] = {-1};
int i;
for (i = 0; i < req.count; i++) {
    struct v4l2_exportbuffer expbuf;
    CLEAR (expbuf);
    expbuf.type = V4L2_BUF_TYPE_VIDEO_OUTPUT;
    expbuf.index = i;
    if (-1 == xioctl(fd, VIDIOC_EXPBUF, &expbuf)
        errno_exit ("VIDIOC_EXPBUF");
    outdev_dmabuf_fd[i] = expbuf.fd;
}

现在,我们已经了解了基于 DMABUF 的流式传输,并介绍了它所带来的概念。接下来和最后的流式传输方法要简单得多,需要的代码也更少。让我们来看看。

请求读/写 I/O 内存

从编码的角度来看,这是更简单的流式传输模式。在读/写 I/O的情况下,除了分配应用程序将存储读取数据的内存位置之外,没有其他事情要做,就像下面的示例中一样:

struct buffer_addr {
    void *start;
    size_t length;
};
struct buffer_addr *buf_addr;
buf_addr = calloc(1, sizeof(*buf_addr));
if (!buf_addr) {
    fprintf(stderr, "Out of memory\n");
    exit(EXIT_FAILURE);
}
buf_addr[0].length = buffer_size;
buf_addr[0].start = malloc(buffer_size);
if (!buf_addr[0].start) {
    fprintf(stderr, "Out of memory\n");
    exit(EXIT_FAILURE);
}

在前面的代码片段中,我们定义了相同的自定义数据结构struct buffer_addr。但是,这里没有真正的缓冲区请求(没有使用VIDIOC_REQBUFS),因为还没有任何东西传递给内核。缓冲区内存只是被分配了,就是这样。

现在,我们已经完成了缓冲区请求。下一步是将请求的缓冲区加入队列,以便内核可以用视频数据填充它们。现在让我们看看如何做到这一点。

将缓冲区加入队列并启用流式传输

在访问缓冲区并读取其数据之前,必须将该缓冲区加入队列。这包括在使用流式 I/O 方法(除了读/写 I/O 之外的所有方法)时,在缓冲区上使用VIDIOC_QBUF ioctl。将缓冲区加入队列将锁定该缓冲区在物理内存中的内存页面。这样,这些页面就无法被交换到磁盘上。请注意,这些缓冲区保持锁定状态,直到它们被出队列,直到调用VIDIOC_STREAMOFFVIDIOC_REQBUFS ioctls,或者直到设备被关闭。

在 V4L2 上下文中,锁定缓冲区意味着将该缓冲区传递给驱动程序进行硬件访问(通常是 DMA)。如果应用程序访问(读/写)已锁定的缓冲区,则结果是未定义的。

要将缓冲区入队,应用程序必须准备struct v4l2_buffer,并根据缓冲区类型、流模式和分配缓冲区时的索引设置v4l2_buffer.typev4l2_buffer.memoryv4l2_buffer.index。其他字段取决于流模式。

重要提示

读/写 I/O方法不需要入队。

主缓冲区的概念

对于捕获应用程序,通常在开始捕获并进入读取循环之前,入队一定数量(大多数情况下是分配的缓冲区数量)的空缓冲区是惯例。这有助于提高应用程序的流畅性,并防止因为缺少填充的缓冲区而被阻塞。这应该在分配缓冲区后立即完成。

入队用户指针缓冲区

要将用户指针缓冲区入队,应用程序必须将v4l2_buffer.memory成员设置为V4L2_MEMORY_USERPTR。这里的特殊之处在于v4l2_buffer.m.userptr字段,必须设置为先前分配的缓冲区的地址,并且v4l2_buffer.length设置为其大小。当使用多平面 API 时,必须使用传递的struct v4l2_plane数组的m.userptrlength成员:

/* Prime buffers */
for (i = 0; i < BUF_COUNT; ++i) {
    struct v4l2_buffer buf;
    CLEAR(buf);
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_USERPTR; buf.index = i;
    buf.m.userptr = (unsigned long)buf_addr[i].start;
    buf.length = buf_addr[i].length;
    if (-1 == xioctl(fd, VIDIOC_QBUF, &buf))
        errno_exit("VIDIOC_QBUF");
}

入队内存映射缓冲区

要将内存映射缓冲区入队,应用程序必须通过设置typememory(必须为V4L2_MEMORY_MMAP)和index成员来填充struct v4l2_buffer,就像以下摘录中所示:

/* Prime buffers */
for (i = 0; i < BUF_COUNT; ++i) {
    struct v4l2_buffer buf; CLEAR (buf);
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    buf.index = i;
    if (-1 == xioctl (fd, VIDIOC_QBUF, &buf))
        errno_exit ("VIDIOC_QBUF");
}

入队 DMABUF 缓冲区

要将输出设备的 DMABUF 缓冲区填充到捕获设备的缓冲区中,应用程序应填充struct v4l2_buffer,将memory字段设置为V4L2_MEMORY_DMABUF,将type字段设置为V4L2_BUF_TYPE_VIDEO_CAPTURE,将m.fd字段设置为与输出设备的 DMABUF 缓冲区关联的文件描述符,如下所示:

/* Prime buffers */
for (i = 0; i < BUF_COUNT; ++i) {
    struct v4l2_buffer buf; CLEAR (buf);
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_DMABUF; buf.index	= i;
    buf.m.fd = outdev_dmabuf_fd[i];
    /* enqueue the dmabuf to capture device */
    if (-1 == xioctl (fd, VIDIOC_QBUF, &buf))
        errno_exit ("VIDIOC_QBUF");
}

上述代码摘录显示了 V4L2 DMABUF 导入的工作原理。ioctl 中的fd参数是与捕获设备关联的文件描述符,在open()系统调用中获得。outdev_dmabuf_fd是包含输出设备的 DMABUF 文件描述符的数组。您可能会想知道,这如何能在不是 V4L2 但是兼容 DRM 的输出设备上工作,例如。以下是一个简要的解释。

首先,DRM 子系统以驱动程序相关的方式提供 API,您可以使用这些 API 在 GPU 上分配(愚笨的)缓冲区,它将返回一个 GEM 句柄。DRM 还提供了DRM_IOCTL_PRIME_HANDLE_TO_FD ioctl,允许通过PRIME将缓冲区导出到 DMABUF 文件描述符,然后使用drmModeAddFB2() API 创建一个framebuffer对象(这是将要读取和显示在屏幕上的东西,或者我应该说,确切地说是 CRT 控制器),对应于这个缓冲区,最终可以使用drmModeSetPlane()drmModeSetPlane()API 进行渲染。然后,应用程序可以使用DRM_IOCTL_PRIME_HANDLE_TO_FD ioctl 返回的文件描述符设置v4l2_requestbuffers.m.fd字段。然后,在读取循环中,在每个VIDIOC_DQBUF ioctl 之后,应用程序可以使用drmModeSetPlane()API 更改平面的帧缓冲区和位置。

重要提示

drm dma-buf接口层集成了GEM,这是 DRM 子系统支持的内存管理器之一

启用流式传输

启用流式传输有点像通知 V4L2 从现在开始将输出队列作为访问对象。应用程序应使用VIDIOC_STREAMON来实现这一点。以下是一个示例:

/* Start streaming */
int ret;
int a = V4L2_BUF_TYPE_VIDEO_CAPTURE;
ret = xioctl(capt.fd, VIDIOC_STREAMON, &a);
if (ret < 0) {
    perror("VIDIOC_STREAMON\n");
    return -1;
}

上述摘录很短,但是必须启用流式传输,否则稍后无法出队缓冲区。

出队缓冲区

这实际上是应用程序的读取循环的一部分。应用程序使用VIDIOC_DQBUF ioctl 出队缓冲区。只有在流启用之后才可能。当应用程序调用VIDIOC_DQBUF ioctl 时,它指示驱动程序检查是否有任何已填充的缓冲区(在open()系统调用期间设置了O_NONBLOCK标志),直到缓冲区排队并填充。

重要提示

尝试在排队之前出队缓冲区是一个错误,VIDIOC_DQBUF ioctl 应该返回-EINVAL。当O_NONBLOCK标志给定给open()函数时,当没有可用的缓冲区时,VIDIOC_DQBUF立即返回EAGAIN错误代码。

出队缓冲区并处理其数据后,应用程序必须立即将此缓冲区重新排队,以便为下一次读取重新填充,依此类推。

出队内存映射缓冲区

以下是一个出队已经内存映射的缓冲区的示例:

struct v4l2_buffer buf;
CLEAR (buf);
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (-1 == xioctl (fd, VIDIOC_DQBUF, &buf)) {
    switch (errno) {
    case EAGAIN:
        return 0;
    case EIO:
    default:
        errno_exit ("VIDIOC_DQBUF");
    }
}
/* make sure the returned index is coherent with the number
 * of buffers allocated  */
assert (buf.index < BUF_COUNT);
/* We use buf.index to point to the correct entry in our  * buf_addr  */ 
process_image(buf_addr[buf.index].start);
/* Queue back this buffer again, after processing is done */
if (-1 == xioctl (fd, VIDIOC_QBUF, &buf))
    errno_exit ("VIDIOC_QBUF");

这可以在循环中完成。例如,假设您需要 200 张图像。读取循环可能如下所示:

#define MAXLOOPCOUNT 200
/* Start the loop of capture */
for (i = 0; i < MAXLOOPCOUNT; i++) {
    struct v4l2_buffer buf;
    CLEAR (buf);
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    if (-1 == xioctl (fd, VIDIOC_DQBUF, &buf)) {
        [...]
    }
   /* Queue back this buffer again, after processing is done */
    [...]
}

上面的片段只是使用循环重新实现了缓冲区出队,其中计数器表示需要抓取的图像数量。

出队用户指针缓冲区

以下是使用用户指针出队缓冲区的示例:

struct v4l2_buffer buf; int i;
CLEAR (buf);
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_USERPTR;
/* Dequeue a captured buffer */
if (-1 == xioctl (fd, VIDIOC_DQBUF, &buf)) {
    switch (errno) {
    case EAGAIN:
        return 0;
    case EIO:
        [...]
    default:
        errno_exit ("VIDIOC_DQBUF");
    }
}
/*
 * We may need the index to which corresponds this buffer
 * in our buf_addr array. This is done by matching address
 * returned by the dequeue ioctl with the one stored in our
 * array  */
for (i = 0; i < BUF_COUNT; ++i)
    if (buf.m.userptr == (unsigned long)buf_addr[i].start &&
                        buf.length == buf_addr[i].length)
        break;
/* the corresponding index is used for sanity checks only */ 
assert (i < BUF_COUNT);
process_image ((void *)buf.m.userptr);
/* requeue the buffer */
if (-1 == xioctl (fd, VIDIOC_QBUF, &buf))
    errno_exit ("VIDIOC_QBUF");

上面的代码展示了如何出队用户指针缓冲区,并且有足够的注释,不需要进一步解释。然而,如果需要许多缓冲区,这可以在循环中实现。

读/写 I/O

这是最后一个示例,展示了如何使用read()系统调用出队缓冲区:

if (-1 == read (fd, buffers[0].start, buffers[0].length)) {
    switch (errno) {
    case EAGAIN:
        return 0;
    case EIO:
        [...]
    default:
        errno_exit ("read");
    }
}
process_image (buffers[0].start);

之前的示例没有详细讨论,因为它们每个都使用了在V4L2 用户空间 API部分已经介绍的概念。现在我们已经熟悉了编写 V4L2 用户空间代码,让我们看看如何通过使用专用工具来快速原型设计摄像头系统而不编写任何代码。

V4L2 用户空间工具

到目前为止,我们已经学会了如何编写用户空间代码与内核中的驱动程序进行交互。对于快速原型设计和测试,我们可以利用一些社区提供的 V4L2 用户空间工具。通过使用这些工具,我们可以专注于系统设计并验证摄像头系统。最知名的工具是v4l2-ctl,我们将重点关注它;它随v4l-utils软件包一起提供。

尽管本章没有讨论,但还有yavta工具(代表Yet Another V4L2 Test Application),它可以用于测试、调试和控制摄像头子系统。

使用 v4l2-ctl

v4l2-utils是一个用户空间应用程序,可用于查询或配置 V4L2 设备(包括子设备)。该工具可以帮助设置和设计精细的基于 V4L2 的系统,因为它有助于调整和利用设备的功能。

重要提示

qv4l2v4l2-ctl的 Qt GUI 等效物。v4l2-ctl非常适合嵌入式系统,而qv4l2非常适合交互式测试。

列出视频设备及其功能

首先,我们需要使用--list-devices选项列出所有可用的视频设备:

# v4l2-ctl --list-devices
Integrated Camera: Integrated C (usb-0000:00:14.0-8):
	/dev/video0
	/dev/video1

如果有多个设备可用,我们可以在任何v4l2-ctl命令之后使用-d选项来针对特定设备。请注意,如果未指定-d选项,默认情况下会针对/dev/video0

要获取有关特定设备的信息,必须使用-D选项,如下所示:

# v4l2-ctl -d /dev/video0 -D
Driver Info (not using libv4l2):
	Driver name   : uvcvideo
	Card type     : Integrated Camera: Integrated C
	Bus info      : usb-0000:00:14.0-8
	Driver version: 5.4.60
	Capabilities  : 0x84A00001
		Video Capture
		Metadata Capture
		Streaming
		Extended Pix Format
		Device Capabilities
	Device Caps   : 0x04200001
		Video Capture
		Streaming
		Extended Pix Format

上面的命令显示了设备信息(如驱动程序及其版本)以及其功能。也就是说,--all命令提供更好的详细信息。你应该试一试。

更改设备属性(控制设备)

在查看更改设备属性之前,我们首先需要知道设备支持的控制、它们的值类型(整数、布尔、字符串等)、它们的默认值以及接受的值是什么。

为了获取设备支持的控制列表,我们可以使用v4l2-ctl-L选项,如下所示:

# v4l2-ctl -L
                brightness 0x00980900 (int)  : min=0 max=255 step=1 default=128 value=128
                contrast 0x00980901 (int)    : min=0 max=255 step=1 default=32 value=32
                saturation 0x00980902 (int)  : min=0 max=100 step=1 default=64 value=64
                     hue 0x00980903 (int)    : min=-180 max=180 step=1 default=0 value=0
 white_balance_temperature_auto 0x0098090c (bool)   : default=1 value=1
                     gamma 0x00980910 (int)  : min=90 max=150 step=1 default=120 value=120
         power_line_frequency 0x00980918 (menu)   : min=0 max=2 default=1 value=1
				0: Disabled
				1: 50 Hz
				2: 60 Hz
      white_balance_temperature 0x0098091a (int)  : min=2800 max=6500 step=1 default=4600 value=4600 flags=inactive
                    sharpness 0x0098091b (int)    : min=0 max=7 step=1 default=3 value=3
       backlight_compensation 0x0098091c (int)    : min=0 max=2 step=1 default=1 value=1
                exposure_auto 0x009a0901 (menu)   : min=0 max=3 default=3 value=3
				1: Manual Mode
				3: Aperture Priority Mode
         exposure_absolute 0x009a0902 (int)    : min=5 max=1250 step=1 default=157 value=157 flags=inactive
         exposure_auto_priority 0x009a0903 (bool)   : default=0 value=1
jma@labcsmart:~$

在上述输出中,"value="字段返回控制的当前值,其他字段都是不言自明的。

既然我们已经知道设备支持的控制列表,控制值可以通过--set-ctrl选项进行更改,如下例所示:

# v4l2-ctl --set-ctrl brightness=192

之后,我们可以使用以下命令检查当前值:

# v4l2-ctl -L
                 brightness 0x00980900 (int)    : min=0 max=255 step=1 default=128 value=192
                     [...]

或者,我们可以使用--get-ctrl命令,如下所示:

# v4l2-ctl --get-ctrl brightness 
brightness: 192

现在可能是时候调整设备了。在此之前,让我们先检查一下设备的视频特性。

设置像素格式、分辨率和帧率

在选择特定格式或分辨率之前,我们需要列举设备可用的内容。为了获取支持的像素格式、分辨率和帧率,需要向v4l2-ctl提供--list-formats-ext选项,如下所示:

# v4l2-ctl --list-formats-ext
ioctl: VIDIOC_ENUM_FMT
	Index       : 0
	Type        : Video Capture
	Pixel Format: 'MJPG' (compressed)
	Name        : Motion-JPEG
		Size: Discrete 1280x720
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 960x540
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 848x480
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 640x480
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 640x360
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 424x240
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 352x288
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 320x240
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 320x180
			Interval: Discrete 0.033s (30.000 fps)
	Index       : 1
	Type        : Video Capture
	Pixel Format: 'YUYV'
	Name        : YUYV 4:2:2
		Size: Discrete 1280x720
			Interval: Discrete 0.100s (10.000 fps)
		Size: Discrete 960x540
			Interval: Discrete 0.067s (15.000 fps)
		Size: Discrete 848x480
			Interval: Discrete 0.050s (20.000 fps)
		Size: Discrete 640x480
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 640x360
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 424x240
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 352x288
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 320x240
			Interval: Discrete 0.033s (30.000 fps)
		Size: Discrete 320x180
			Interval: Discrete 0.033s (30.000 fps)

从上述输出中,我们可以看到目标设备支持的内容,即mjpeg压缩格式和 YUYV 原始格式。

现在,为了更改摄像头配置,首先使用--set-parm选项选择帧率,如下所示:

# v4l2-ctl --set-parm=30
Frame rate set to 30.000 fps
#

然后,可以使用--set-fmt-video选项选择所需的分辨率和/或像素格式,如下所示:

# v4l2-ctl --set-fmt-video=width=640,height=480,  pixelformat=MJPG

在帧率方面,您可能希望使用v4l2-ctl--set-parm选项,只提供帧率的分子—分母固定为1(只允许整数帧率值)—如下所示:

# v4l2-ctl --set-parm=<framerate numerator>

捕获帧和流处理

v4l2-ctl支持的选项比您想象的要多得多。为了查看可能的选项,可以打印适当部分的帮助消息。与流处理和视频捕获相关的常见帮助命令如下:

  • --help-streaming:打印所有与流处理相关的选项的帮助消息

  • --help-subdev:打印所有与v4l-subdevX设备相关的选项的帮助消息

  • --help-vidcap:打印所有获取/设置/列出视频捕获格式的选项的帮助消息

从这些帮助命令中,我已经构建了以下命令,以便在磁盘上捕获 QVGA MJPG 压缩帧:

# v4l2-ctl --set-fmt-video=width=320,height=240,  pixelformat=MJPG \
   --stream-mmap --stream-count=1 --stream-to=grab-320x240.mjpg

我还使用以下命令捕获了一个具有相同分辨率的原始 YUV 图像:

# v4l2-ctl --set-fmt-video=width=320,height=240,  pixelformat=YUYV \
  --stream-mmap --stream-count=1 --stream-to=grab-320x240-yuyv.raw

除非使用一个体面的原始图像查看器,否则无法显示原始 YUV 图像。为了做到这一点,必须使用ffmpeg工具转换原始图像,例如如下所示:

# ffmpeg -f rawvideo -s 320x240 -pix_fmt yuyv422 \
         -i grab-320x240-yuyv.raw grab-320x240.png

您可以注意到原始图像和压缩图像之间的大小差异很大,如下摘录所示:

# ls -hl grab-320x240.mjpg
-rw-r--r-- 1 root root 8,0K oct.  21 20:26 grab-320x240.mjpg
# ls -hl grab-320x240-yuyv.raw 
-rw-r--r-- 1 root root 150K oct.  21 20:26 grab-320x240-yuyv.raw

请注意,在原始捕获的文件名中包含图像格式是一个好习惯(例如在grab-320x240-yuyv.raw中包含yuyv),这样您就可以轻松地从正确的格式进行转换。对于压缩图像格式,这条规则是不必要的,因为这些格式是带有描述其后的像素数据的标头的图像容器格式,并且可以很容易地使用gst-typefind-1.0工具进行读取。JPEG 就是这样一种格式,以下是如何读取其标头的方法:

# gst-typefind-1.0 grab-320x240.mjpg 
grab-320x240.mjpg - image/jpeg, width=(int)320, height=(int)240, sof-marker=(int)0
# gst-typefind-1.0 grab-320x240-yuyv.raw 
grab-320x240-yuyv.raw - FAILED: Could not determine type of stream.

现在我们已经完成了工具的使用,让我们看看如何深入了解 V4L2 调试以及从用户空间开始学习。

在用户空间调试 V4L2

由于我们的视频系统设置可能不是没有错误的,V4L2 为了从用户空间进行跟踪和排除来自 VL4L2 框架核心或用户空间 API 的故障,提供了一个简单但大的后门调试。

可以按照以下步骤启用框架调试:

# echo 0x3 > /sys/module/videobuf2_v4l2/parameters/debug
# echo 0x3 > /sys/module/videobuf2_common/parameters/debug

上述命令将指示 V4L2 向内核日志消息添加核心跟踪。这样,它将很容易地跟踪故障的来源,假设故障来自核心。运行以下命令:

# dmesg
[831707.512821] videobuf2_common: __setup_offsets: buffer 0, plane 0 offset 0x00000000
[831707.512915] videobuf2_common: __setup_offsets: buffer 1, plane 0 offset 0x00097000
[831707.513003] videobuf2_common: __setup_offsets: buffer 2, plane 0 offset 0x0012e000
[831707.513118] videobuf2_common: __setup_offsets: buffer 3, plane 0 offset 0x001c5000
[831707.513119] videobuf2_common: __vb2_queue_alloc: allocated 4 buffers, 1 plane(s) each
[831707.513169] videobuf2_common: vb2_mmap: buffer 0, plane 0 successfully mapped
[831707.513176] videobuf2_common: vb2_core_qbuf: qbuf of buffer 0 succeeded
[831707.513205] videobuf2_common: vb2_mmap: buffer 1, plane 0 successfully mapped
[831707.513208] videobuf2_common: vb2_core_qbuf: qbuf of buffer 1 succeeded
[...]

在先前的内核日志消息中,我们可以看到与内核相关的 V4L2 核心函数调用,以及一些其他细节。如果由于任何原因 V4L2 核心跟踪不是必要的或者对您来说不够,您还可以使用以下命令启用 V4L2 用户空间 API 跟踪:

$ echo 0x3 > /sys/class/video4linux/video0/dev_debug

运行命令后,允许您捕获原始图像,我们可以在内核日志消息中看到以下内容:

$ dmesg
[833211.742260] video0: VIDIOC_QUERYCAP: driver=uvcvideo, card=Integrated Camera: Integrated C, bus=usb-0000:00:14.0-8, version=0x0005043c, capabilities=0x84a00001, device_caps=0x04200001
[833211.742275] video0: VIDIOC_QUERY_EXT_CTRL: id=0x980900, type=1, name=Brightness, min/max=0/255, step=1, default=128, flags=0x00000000, elem_size=4, elems=1, nr_of_dims=0, dims=0,0,0,0
[...]
[833211.742318] video0: VIDIOC_QUERY_EXT_CTRL: id=0x98090c, type=2, name=White Balance Temperature, Auto, min/max=0/1, step=1, default=1, flags=0x00000000, elem_size=4, elems=1, nr_of_dims=0, dims=0,0,0,0
[833211.742365] video0: VIDIOC_QUERY_EXT_CTRL: id=0x98091c, type=1, name=Backlight Compensation, min/max=0/2, step=1, default=1, flags=0x00000000, elem_size=4, elems=1, nr_of_dims=0, dims=0,0,0,0
[833211.742376] video0: VIDIOC_QUERY_EXT_CTRL: id=0x9a0901, type=3, name=Exposure, Auto, min/max=0/3, step=1, default=3, flags=0x00000000, elem_size=4, elems=1, nr_of_dims=0, dims=0,0,0,0
[...]
[833211.756641] videobuf2_common: vb2_mmap: buffer 1, plane 0 successfully mapped
[833211.756646] videobuf2_common: vb2_core_qbuf: qbuf of buffer 1 succeeded
[833211.756649] video0: VIDIOC_QUERYBUF: 00:00:00.00000000 index=2, type=vid-cap, request_fd=0, flags=0x00012000, field=any, sequence=0, memory=mmap, bytesused=0, offset/userptr=0x12e000, length=614989
[833211.756657] timecode=00:00:00 type=0, flags=0x00000000, frames=0, userbits=0x00000000
[833211.756698] videobuf2_common: vb2_mmap: buffer 2, plane 0 successfully mapped
[833211.756704] videobuf2_common: vb2_core_qbuf: qbuf of buffer 2 succeeded
[833211.756706] video0: VIDIOC_QUERYBUF: 00:00:00.00000000 index=3, type=vid-cap, request_fd=0, flags=0x00012000, field=any, sequence=0, memory=mmap, bytesused=0, offset/userptr=0x1c5000, length=614989
[833211.756714] timecode=00:00:00 type=0, flags=0x00000000, frames=0, userbits=0x00000000
[833211.756751] videobuf2_common: vb2_mmap: buffer 3, plane 0 successfully mapped
[833211.756755] videobuf2_common: vb2_core_qbuf: qbuf of buffer 3 succeeded
[833212.967229] videobuf2_common: vb2_core_streamon: successful
[833212.967234] video0: VIDIOC_STREAMON: type=vid-cap

在先前的输出中,我们可以跟踪不同的 V4L2 用户空间 API 调用,这些调用对应于不同的ioctl命令及其参数。

V4L2 合规性驱动程序测试

为了使驱动程序符合 V4L2 标准,它必须满足一些标准,其中包括通过v4l2-compliance工具测试,该工具用于测试各种类型的 V4L 设备。v4l2-compliance试图测试 V4L2 设备的几乎所有方面,并涵盖几乎所有 V4L2 ioctls。

与其他 V4L2 工具一样,可以使用-d--device=命令来定位视频设备。如果未指定设备,则将定位到/dev/video0。以下是一个输出摘录:

# v4l2-compliance
v4l2-compliance SHA   : not available
Driver Info:
	Driver name   : uvcvideo
	Card type     : Integrated Camera: Integrated C
	Bus info      : usb-0000:00:14.0-8
	Driver version: 5.4.60
	Capabilities  : 0x84A00001
		Video Capture
		Metadata Capture
		Streaming
		Extended Pix Format
		Device Capabilities
	Device Caps   : 0x04200001
		Video Capture
		Streaming
		Extended Pix Format
Compliance test for device /dev/video0 (not using libv4l2):
Required ioctls:
	test VIDIOC_QUERYCAP: OK
Allow for multiple opens:
	test second video open: OK
	test VIDIOC_QUERYCAP: OK
	test VIDIOC_G/S_PRIORITY: OK
	test for unlimited opens: OK
Debug ioctls:
	test VIDIOC_DBG_G/S_REGISTER: OK (Not Supported)
	test VIDIOC_LOG_STATUS: OK (Not Supported)
[]
Output ioctls:
	test VIDIOC_G/S_MODULATOR: OK (Not Supported)
	test VIDIOC_G/S_FREQUENCY: OK (Not Supported)
[...]
Test input 0:
	Control ioctls:
		fail: v4l2-test-controls.cpp(214): missing control class for class 00980000
		fail: v4l2-test-controls.cpp(251): missing control class for class 009a0000
		test VIDIOC_QUERY_EXT_CTRL/QUERYMENU: FAIL
		test VIDIOC_QUERYCTRL: OK
		fail: v4l2-test-controls.cpp(437): s_ctrl returned an error (84)
		test VIDIOC_G/S_CTRL: FAIL
		fail: v4l2-test-controls.cpp(675): s_ext_ctrls returned an error (

在先前的日志中,我们可以看到已定位到/dev/video0。此外,我们注意到我们的驱动程序不支持Debug ioctlsOutput ioctls(这些不是失败)。尽管输出已经足够详细,但最好也使用--verbose命令,这样输出会更加用户友好和更加详细。因此,毫无疑问,如果要提交新的 V4L2 驱动程序,该驱动程序必须通过 V4L2 合规性测试。

摘要

在本章中,我们介绍了 V4L2 的用户空间实现。我们从视频流的 V4L2 缓冲区管理开始。我们还学习了如何处理视频设备属性管理,都是从用户空间进行的。然而,V4L2 是一个庞大的框架,不仅在代码方面,而且在功耗方面也是如此。因此,在下一章中,我们将讨论 Linux 内核的电源管理,以使系统在不降低系统性能的情况下保持尽可能低的功耗水平。

第十章:Linux 内核功耗管理

移动设备变得越来越复杂,具有越来越多的功能,以追随商业趋势并满足消费者的需求。虽然这些设备的一些部分运行专有或裸机软件,但它们大多数运行基于 Linux 的操作系统(嵌入式 Linux 发行版,Android 等),并且全部都是由电池供电。除了完整的功能和性能外,消费者需要尽可能长的自主时间和持久的电池。毫无疑问,完整的性能和自主时间(节能)是两个完全不兼容的概念,必须在使用设备时始终找到一个折衷方案。这种折衷方案就是功耗管理,它允许我们在不忽视设备进入低功耗状态后唤醒(或完全运行)所需的时间的情况下处理尽可能低的功耗和设备性能。

Linux 内核配备了几种功耗管理功能,从允许您在短暂的空闲期间节省电力(或执行功耗较低的任务)到在系统不活跃使用时将整个系统置于睡眠状态。

此外,随着设备被添加到系统中,它们可以通过 Linux 内核提供的通用功耗管理 API 参与功耗管理工作,以便允许设备驱动程序开发人员从设备中实现的功耗管理机制中受益。这允许调整每个设备或整个系统的功耗参数,以延长设备的自主时间和电池的寿命。

在本章中,我们将深入了解 Linux 内核功耗管理子系统,利用其 API 并从用户空间管理其选项。因此,将涵盖以下主题:

  • 基于 Linux 的系统上的功耗管理概念

  • 向设备驱动程序添加功耗管理功能

  • 作为系统唤醒的源头

技术要求

为了更好地理解本章,您需要以下内容:

  • 基本的电气知识

  • 基本的 C 编程技能

  • 良好的计算机架构知识

  • Linux 内核 4.19 源代码可在github.com/torvalds/linux上找到

基于 Linux 的系统上的功耗管理概念

功耗管理PM)意味着在任何时候尽可能消耗尽可能少的电力。操作系统必须处理两种类型的功耗管理:设备功耗管理系统功耗管理

  • 设备功耗管理:这是特定于设备的。它允许在系统运行时将设备置于低功耗状态。这可能允许,除其他事项外,当前未使用的设备部分关闭以节省电力,例如在不键入时关闭键盘背光。无论功耗管理活动如何,都可以显式地在设备上调用单个设备功耗管理,或者在设备空闲一定时间后自动发生。设备功耗管理是所谓的运行时功耗管理的别名。

  • 系统电源管理,也称为睡眠状态:这使平台可以进入系统范围的低功耗状态。换句话说,进入睡眠状态是将整个系统置于低功耗状态的过程。根据平台、其功能和目标唤醒延迟,系统可能进入几种低功耗状态(或睡眠状态)。例如,当笔记本电脑的盖子关闭时,当手机屏幕关闭时,或者达到某些关键状态(例如电池电量)时,就会发生这种情况。许多这些状态在各个平台上都是相似的(例如冻结,这纯粹是软件,因此不依赖于设备或系统),并且将在以后详细讨论。总体概念是在系统关闭电源之前保存运行系统的状态(或将其置于睡眠状态,这与关闭不同),并在系统重新获得电源后恢复。这可以防止系统执行整个关闭和启动序列。

尽管系统 PM 和运行时 PM 处理空闲管理的不同情景,但部署两者都很重要,以防止平台浪费电力。正如我们将在接下来的章节中看到的那样,您应该将它们视为互补的。

运行时功耗管理

这是 Linux PM 的一部分,它在不将整个系统置于低功耗状态的情况下管理单个设备的电源。在这种模式下,操作在系统运行时生效,因此被称为运行时电源管理。为了适应设备的功耗,其属性在系统仍在运行时进行了更改,因此也被称为动态功耗管理

一些动态功耗管理接口的介绍

除了驱动程序开发人员可以在设备驱动程序中实现的每个设备的功耗管理能力之外,Linux 内核还提供了用户空间接口来添加/删除/修改电源策略。其中最著名的列在这里:

  • CPU 空闲:这有助于在 CPU 没有任务可执行时管理 CPU 功耗。

  • CPUFreq:这允许根据系统负载更改 CPU 功率属性(即电压和频率)。

  • 热量:这允许根据系统预定义区域中感测到的温度调整功率属性,大多数情况下是靠近 CPU 的区域。

您可能已经注意到前面的策略涉及 CPU。这是因为 CPU 是移动设备(或嵌入式系统)上功耗的主要来源之一。虽然下一节只介绍了三个接口,但也存在其他接口,例如 QoS 和 DevFreq。读者可以自由探索这些接口以满足他们的好奇心。

CPU 空闲

每当系统中的逻辑 CPU 没有任务可执行时,可能需要将其置于特定状态以节省电力。在这种情况下,大多数操作系统简单地安排所谓的空闲线程。在执行此线程时,CPU 被称为空闲状态。C0是正常的 CPU 工作模式;换句话说,CPU 处于 100%开启状态。随着 C 编号的增加,CPU 睡眠模式变得更深;换句话说,更多的电路和信号被关闭,CPU 需要返回C0模式的时间也更长,也就是唤醒的时间。C1是第一个 C 状态,C2是第二个状态,依此类推。当逻辑处理器处于空闲状态(任何 C 状态除C0之外),其频率通常为0

下一个事件(按时间顺序)决定 CPU 可以休眠多长时间。每个空闲状态由三个特征描述:

  • 退出延迟,单位为µS:这是退出此状态所需的延迟时间。

  • 功耗,单位为 mW:这并不总是可靠的。

  • 目标驻留时间,单位为µS:这是使此状态变得有趣的空闲持续时间。

CPU 空闲驱动程序是特定于平台的,Linux 内核期望 CPU 驱动程序支持最多 10 个状态(请参阅内核源代码中的CPUIDLE_STATE_MAX)。但是,实际状态的数量取决于底层 CPU 硬件(其中嵌入了内置的节能逻辑),大多数 ARM 平台只提供一个或两个空闲状态。进入的状态选择基于由州长管理的策略。

在这种情况下,州长是实现算法的简单模块,使得可以根据某些属性做出最佳的 C 状态选择。换句话说,州长决定系统的目标 C 状态。虽然系统上可能存在多个州长,但在任何时候只有一个州长控制给定的 CPU。它设计成这样,如果调度程序运行队列为空(这意味着 CPU 没有其他事情要做)并且需要使 CPU 空闲,它将请求 CPU 空闲到 CPU 空闲框架。然后,框架将依赖于当前选择的州长来选择适当的C 状态。有两个 CPU 空闲州长:ladder(用于周期性定时器基础系统)和menu(用于无滴答系统)。虽然ladder州长始终可用,但如果选择了CONFIG_CPU_IDLE,则menu州长另外需要设置CONFIG_NO_HZ_IDLE(或在较旧的内核上设置CONFIG_NO_HZ)。在配置内核时选择州长。粗略地说,使用哪个取决于内核的配置,特别是取决于调度程序滴答是否可以被空闲循环停止,因此取决于CONFIG_NO_HZ_IDLE。您可以参考Documentation/timers/NO_HZ.txt以获取更多关于此的信息。

州长可以决定是继续保持当前状态还是转换到另一个状态,如果是后者,它将指示当前驱动程序转换到所选状态。可以通过读取/sys/devices/system/cpu/cpuidle/current_driver文件来识别当前空闲驱动程序,通过/sys/devices/system/cpu/cpuidle/current_governor_ro来获取当前的州长:

$ cat /sys/devices/system/cpu/cpuidle/current_governor_ro menu

在给定系统上,/sys/devices/system/cpu/cpuX/cpuidle/中的每个目录对应一个 C 状态,并且每个 C 状态目录属性文件的内容描述了这个 C 状态:

$ ls /sys/devices/system/cpu/cpu0/cpuidle/
state0 state1 state2 state3 state4 state5 state6 state7 state8
$ ls /sys/devices/system/cpu/cpu0/cpuidle/state0/
above below desc disable latency name power residency time usage

在 ARM 平台上,空闲状态可以在设备树中描述。您可以查阅内核源中的Documentation/devicetree/bindings/arm/idle-states.txt文件以获取更多关于此的信息。

重要提示

与其他电源管理框架不同,CPU 空闲不需要用户干预即可工作。

有一个与此略有相似的框架,即CPU 热插拔,它允许在运行时动态启用和禁用 CPU,而无需重新启动系统。例如,要从系统中热插拔 CPU#2,可以使用以下命令:

# echo 0 > /sys/devices/system/cpu/cpu2/online

我们可以通过读取/proc/cpuinfo来确保 CPU#2 实际上已禁用:

# grep processor /proc/cpuinfo
processor	: 0
processor	: 1
processor	: 3
processor	: 4
processor	: 5
processor	: 6
processor	: 7

前面的内容证实了 CPU2 现在已经离线。为了将该 CPU 重新插入系统,我们可以执行以下命令:

# echo 1 > /sys/devices/system/cpu/cpu2/online

CPU 热插拔在底层会根据特定的硬件和驱动程序而有所不同。在某些系统上,它可能只是将 CPU 置于空闲状态,而在其他系统上,可能会从指定的核心中断电。

CPU 频率或动态电压和频率缩放(DVFS)

该框架允许根据约束和要求、用户偏好或其他因素对 CPU 进行动态电压选择和频率缩放。因为该框架涉及频率,所以它无条件地涉及时钟框架。该框架使用{频率,电压}元组的概念。

OPP 可以在设备树中描述,并且内核源中的绑定文档可以作为更多信息的良好起点:Documentation/devicetree/bindings/opp/opp.txt

重要提示

您偶尔会在基于英特尔的机器上遇到术语ls /sys/devices/system/cpu/cpufreq/。因此,C 状态是空闲节能状态,与 P 状态相反,后者是执行节能状态。

CPUfreq 还使用了州长的概念(实现了缩放算法),该框架中的州长如下:

  • ondemand:这个州长对 CPU 的负载进行采样,并积极地将其扩展,以提供适当的处理能力,但在必要时将频率重置为最大值。

  • conservative:这类似于ondemand,但使用了一种不那么激进的增加 OPP 的方法。例如,即使系统突然需要高性能,它也不会从最低的 OPP 跳到最高的 OPP。它会逐渐增加。

  • performance:这个州长总是选择具有最高频率的 OPP。这个州长优先考虑性能。

  • powersave:与性能相反,这个州长总是选择具有最低频率的 OPP。这个州长优先考虑节能。

  • userspace:这个州长允许用户使用在/sys/devices/system/cpu/cpuX/cpufreq/scaling_available_frequencies中找到的任何值来设置所需的 OPP,通过将其回显到/sys/devices/system/cpu/cpuX/cpufreq/scaling_setspeed

  • schedutil:这个州长是调度程序的一部分,因此它可以在内部访问调度程序数据结构,从而能够更可靠和准确地获取有关系统负载的统计信息,以更好地选择适当的 OPP。

userspace州长是唯一允许用户选择 OPP 的州长。对于其他州长,根据其算法的系统负载,OPP 的更改会自动发生。也就是说,从userspace开始,可用的州长如下所示:

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_governors
performance powersave

要查看当前的州长,执行以下命令:

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor
powersave

要设置州长,可以使用以下命令:

$ echo userspace > /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor

要查看当前的 OPP(频率以 kHz 为单位),执行以下命令:

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_cur_freq 800031

要查看支持的 OPP(以 kHz 为单位的频率),执行以下命令:

$ cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_available_frequencies
275000 500000 600000 800031

要更改 OPP,可以使用以下命令:

$ echo 275000 > /sys/devices/system/cpu/cpu0/cpufreq/scaling_setspeed

重要提示

还有devfreq框架,它是一个通用的Ondemandperformancepowersavepassive

请注意,前面的命令仅在选择了ondemand州长时才有效,因为它是唯一允许更改 OPP 的州长。但是,在所有前面的命令中,cpu0仅用于教学目的。将其视为cpuX,其中X是系统看到的 CPU 的索引。

该框架致力于监控系统温度。它根据温度阈值具有专门的配置文件。热传感器感知热点并报告。该框架与冷却设备一起工作,后者有助于控制/限制过热的功耗散失。

热框架使用以下概念:

  • 热区:您可以将热区视为需要监视温度的硬件。

  • 热传感器:这些是用于进行温度测量的组件。热传感器在热区提供温度感应能力。

  • 冷却设备:这些设备在功耗控制方面提供控制。通常有两种冷却方法:被动冷却,包括调节设备性能,此时使用 DVFS;和主动冷却,包括激活特殊的冷却设备,如风扇(GPIO 风扇,PWM 风扇)。

  • 触发点:这些描述了建议进行冷却操作的关键温度(实际上是阈值)。这些点集是基于硬件限制选择的。

  • 州长们:这些包括根据某些标准选择最佳冷却的算法。

  • 冷却映射:这些用于描述触发点和冷却设备之间的链接。

热框架可以分为四个部分,分别是热区热管理器热冷却热核,它是前三个部分之间的粘合剂。它可以在用户空间中从/sys/class/thermal/目录中进行管理:

$ ls /sys/class/thermal/
cooling_device0  cooling_device4 cooling_device8  thermal_zone3  thermal_zone7
cooling_device1  cooling_device5 thermal_zone0    thermal_zone4
cooling_device2  cooling_device6 thermal_zone1    thermal_zone5
cooling_device3  cooling_device7 thermal_zone2    thermal_zone6

在前面的内容中,每个thermal_zoneX文件代表一个热区驱动程序,或者热驱动程序。热区驱动程序是与热区相关的热传感器的驱动程序。该驱动程序公开了需要冷却的触发点,但也提供了与传感器相关的冷却设备列表。热工作流程旨在通过热区驱动程序获取温度,然后通过热管理器做出决策,最后通过热冷却进行温度控制。有关此内容的更多信息,请参阅内核源中的热 sysfs 文档,Documentation/thermal/sysfs- api.txt。此外,热区描述,触发点定义和冷却设备绑定可以在设备树中执行,其相关文档在源中的Documentation/devicetree/bindings/thermal/thermal.txt中。

系统电源管理睡眠状态

系统电源管理针对整个系统。其目的是将其置于低功耗状态。在这种低功耗状态下,系统消耗的功率很小,但维持相对较低的响应延迟。功率和响应延迟的确切数量取决于系统所处的睡眠状态有多深。这也被称为静态电源管理,因为当系统长时间处于非活动状态时会激活它。

系统可以进入的状态取决于底层平台,并且在不同的架构甚至同一架构的不同世代或系列之间也有所不同。然而,在大多数平台上通常可以找到四种睡眠状态。这些是挂起到空闲(也称为冻结),开机待机(待机),挂起到 RAM(内存)和挂起到磁盘(休眠)。它们有时也按照它们的 ACPI 状态来称呼:S0S1S3S4,分别是:

# cat /sys/power/state
freeze mem disk standby

CONFIG_SUSPEND是必须设置的内核配置选项,以便系统支持系统的电源管理睡眠状态。也就是说,除了冻结之外,每个睡眠状态都是特定于平台的。因此,要支持剩下的三种状态中的任何一种,必须显式地向核心系统挂起子系统注册每种状态。然而,休眠的支持取决于其他内核配置选项,我们稍后会看到。

重要提示

因为只有用户知道系统何时不会被使用(甚至用户代码,如 GUI),系统电源管理操作总是从用户空间发起的。内核对此一无所知。这就是为什么本节的大部分内容都涉及sysfs和命令行的原因。

挂起到空闲(冻结)

这是最基本和轻量级的。这种状态纯粹由软件驱动,并涉及尽可能使 CPU 保持在最深的空闲状态。为了实现这一点,用户空间被冻结(所有用户空间任务都被冻结),并且所有 I/O 设备都被置于低功耗状态(可能低于运行时可用的功耗),以便处理器可以在其空闲状态下花费更多时间。以下是使系统处于空闲状态的命令:

$ echo freeze > /sys/power/state

前面的命令使系统处于空闲状态。因为它纯粹是软件,所以这种状态始终受支持(假设设置了CONFIG_SUSPEND内核配置选项)。这种状态可以用于不支持开机挂起或挂起到 RAM 的平台。然而,我们稍后会看到,它可以与挂起到 RAM 一起使用,以提供较低的恢复延迟。

重要提示

挂起到空闲等于冻结进程+挂起设备+空闲处理器

开机待机(待机或开机挂起)

除了冻结用户空间并将所有 I/O 设备置于低功耗状态之外,此状态执行的另一个操作是关闭所有非引导 CPU。以下是将系统置于待机状态的命令,假设平台支持:

$ echo standby > /sys/power/state

由于这种状态比冻结状态更进一步,因此相对于挂起到空闲状态,它也可以节省更多的能量,但是恢复延迟通常会大于冻结状态,尽管它相当低。

挂起到 RAM(挂起或 mem)

除了将系统中的所有内容置于低功耗状态之外,此状态通过关闭所有 CPU 并将内存置于自刷新状态进一步进行,以便其内容不会丢失,尽管根据平台的能力可能会执行其他操作。响应延迟高于待机,但仍然相当低。在此状态下,系统和设备状态被保存并保留在内存中。这就是为什么只有 RAM 是完全可操作的原因,因此状态名称为:

# echo mem > /sys/power/state

上述命令应该将系统置于挂起到 RAM 状态。然而,写入mem字符串时执行的真正操作由/sys/power/mem_sleep文件控制。该文件包含一个字符串列表,其中每个字符串代表系统在将mem写入/sys/power/state后可以进入的模式。虽然并非所有模式始终可用(这取决于平台),但可能的模式包括以下内容:

  • s2idle:这相当于挂起到空闲。因此,它始终可用。

  • shallow:这相当于待机挂起。其可用性取决于平台对待机模式的支持。

  • deep:这是真正的挂起到 RAM 状态,其可用性取决于平台。

查询内容的示例可以在此处看到:

$ cat /sys/power/mem_sleep
[s2idle] deep

所选模式用方括号[ ]括起来。如果某个模式不受平台支持,与之对应的字符串仍不会出现在/sys/power/mem_sleep中。将/sys/power/mem_sleep中的其他字符串之一写入其中会导致随后使用挂起模式更改为该字符串所代表的模式。

当系统启动时,默认的挂起模式(换句话说,不写入任何内容到/sys/power/mem_sleep)要么是deep(如果支持挂起到 RAM),要么是s2idle,但可以通过内核命令行中的mem_sleep_default参数的值来覆盖。

测试的一种方法是使用系统上可用的 RTC,假设它支持唤醒闹钟功能。您可以使用ls /sys/class/rtc/来识别系统上可用的 RTC。每个 RTC(换句话说,rtc0rtc1)都会有一个目录。对于支持alarm功能的rtc,该rtc目录中将有一个wakealarm文件,可以按照以下方式使用它来配置闹钟,然后将系统挂起到 RAM:

/* No value returned means no alarms are set */
$ cat /sys/class/rtc/rtc0/wakealarm
/* Set the wakeup alarm for 20s */
# echo +20 > /sys/class/rtc/rtc0/wakealarm
/* Now Suspend system to RAM */ # echo mem > /sys/power/state

您应该在唤醒之前不会在控制台上看到进一步的活动。

挂起到磁盘(休眠)

这种状态由于尽可能关闭系统的大部分部分(包括内存)而实现了最大的节能。内存内容(快照)通常写入持久介质,通常是磁盘。之后,内存和整个系统都被关闭。在恢复时,快照被读回内存,并且系统从此休眠镜像引导。然而,这种状态也是最长的恢复时间,但仍然比执行完整的(重新)引导序列要快:

$ echo disk > /sys/power/state

一旦将内存状态写入磁盘,就可以执行多个操作。要执行的操作由/sys/power/disk文件及其内容控制。该文件包含一个字符串列表,其中每个字符串代表系统状态保存在持久存储介质上后可以执行的操作。可能的操作包括以下内容:

  • platform:自定义和特定于平台的,可能需要固件(BIOS)干预。

  • shutdown:关闭系统电源。

  • reboot:重新启动系统(主要用于诊断)。

  • suspend:将系统置于通过先前描述的mem_sleep文件选择的挂起睡眠状态。如果系统成功从该状态唤醒,那么休眠映像将被简单丢弃,一切将继续。否则,映像将用于恢复系统的先前状态。

  • test_resume:用于系统恢复诊断目的。加载镜像,就好像系统刚从休眠中醒来,当前运行的内核实例是一个恢复内核,并随后进行完整的系统恢复。

然而,给定平台上支持的操作取决于/sys/power/disk文件的内容:

$ cat /sys/power/disk
[platform] shutdown reboot suspend test_resume

所选操作用方括号[ ]括起来。将列出的字符串之一写入此文件会导致选择所代表的选项。休眠是如此复杂的操作,以至于它有自己的配置选项CONFIG_HIBERNATION。必须设置此选项才能启用休眠功能。也就是说,只有在给定 CPU 架构的支持包括系统恢复的低级代码时,才能设置此选项(参考ARCH_HIBERNATION_POSSIBLE内核配置选项)。

为了使挂起到磁盘工作,并且取决于休眠映像应存储在何处,磁盘上可能需要一个专用分区。这个分区也被称为交换分区。该分区用于将内存内容写入空闲交换空间。为了检查休眠是否按预期工作,通常尝试在reboot模式下进行休眠,如下所示:

$ echo reboot > /sys/power/disk 
# echo disk > /sys/power/state

第一个命令通知电源管理核心在创建休眠映像后应执行什么操作。在这种情况下,是重启。重启后,系统将从休眠映像中恢复,并且您应该回到您开始转换的命令提示符。这个测试的成功可能表明休眠很可能能够正确工作。也就是说,应该多次进行以加强测试。

现在我们已经完成了从运行系统管理睡眠状态,我们可以看看如何在驱动程序代码中实现其支持。

向设备驱动程序添加电源管理功能

设备驱动程序本身可以实现一个称为运行时电源管理的独特电源管理功能。并非所有设备都支持运行时电源管理。但是,那些支持的设备必须根据用户或系统的策略决定导出一些回调来控制它们的电源状态。正如我们之前所见,这是特定于设备的。在本节中,我们将学习如何通过电源管理支持扩展设备驱动程序功能。

尽管设备驱动程序提供运行时电源管理回调,但它们也通过提供另一组回调来便利和参与系统睡眠状态。每个集合都参与特定的系统睡眠状态。每当系统需要进入或从给定集合恢复时,内核将遍历为该状态提供回调的每个驱动程序,然后按照精确的顺序调用它们。简而言之,设备电源管理包括设备所处状态的描述,以及控制这些状态的机制。这是由内核提供的struct dev_pm_ops来实现的,每个对电源管理感兴趣的设备驱动程序/类/总线都必须填充。这允许内核与系统中的每个设备通信,而不管设备所在的总线或所属的类是什么。让我们退一步,记住struct device是什么样子的:

struct device {
    [...]
    struct device *parent;
    struct bus_type *bus;
    struct device_driver *driver;
    struct dev_pm_info power;
    struct dev_pm_domain *pm_domain;
}

在前面的struct device数据结构中,我们可以看到设备可以是子设备(其.parent字段指向另一个设备)或设备父级(当另一个设备的.parent字段指向它时),可以位于给定总线后面,或者可以属于给定类,或者可以间接属于给定子系统。此外,我们可以看到设备可以是给定电源域的一部分。.power字段是struct dev_pm_info类型。它主要保存与电源管理相关的状态,例如当前电源状态,是否可以唤醒,是否已准备好,是否已挂起。由于涉及的内容如此之多,我们将在使用它们时详细解释这些内容。

为了使设备能够参与电源管理,无论是在子系统级别还是在设备驱动程序级别,它们的驱动程序都需要通过定义和填充include/linux/pm.h中定义的struct dev_pm_ops类型的对象来实现一组设备电源管理操作,如下所示:

struct dev_pm_ops {
    int (*prepare)(struct device *dev);
    void (*complete)(struct device *dev);
    int (*suspend)(struct device *dev);
    int (*resume)(struct device *dev);
    int (*freeze)(struct device *dev);
    int (*thaw)(struct device *dev);
    int (*poweroff)(struct device *dev);
    int (*restore)(struct device *dev);
    [...]
    int (*suspend_noirq)(struct device *dev);
    int (*resume_noirq)(struct device *dev);
    int (*freeze_noirq)(struct device *dev);
    int (*thaw_noirq)(struct device *dev);
    int (*poweroff_noirq)(struct device *dev);
    int (*restore_noirq)(struct device *dev);
    int (*runtime_suspend)(struct device *dev);
    int (*runtime_resume)(struct device *dev);
    int (*runtime_idle)(struct device *dev);
};

在前面的数据结构中,*_early()*_late()回调已被删除以提高可读性。我建议您查看完整的定义。也就是说,鉴于其中的大量回调,我们将在本章的各个部分中逐步描述它们的使用。

重要提示

设备电源状态有时被称为D状态,受 PCI 设备和 ACPI 规范的启发。这些状态从D0D3,包括。尽管并非所有设备类型都以这种方式定义电源状态,但这种表示可以映射到所有已知的设备类型。

实现运行时电源管理能力

运行时电源管理是一种每设备的电源管理功能,允许特定设备在系统运行时控制其状态,而不受全局系统的影响。为了实现运行时电源管理,驱动程序应该只提供struct dev_pm_ops中的部分回调函数,如下所示:

struct dev_pm_ops {
    [...]
    int (*runtime_suspend)(struct device *dev);
    int (*runtime_resume)(struct device *dev);
    int (*runtime_idle)(struct device *dev);
};

内核还提供了SET_RUNTIME_PM_OPS(),它接受要填充到结构中的三个回调。此宏定义如下:

#define SET_RUNTIME_PM_OPS(suspend_fn, resume_fn, idle_fn) \
        .runtime_suspend = suspend_fn, \
        .runtime_resume = resume_fn, \
        .runtime_idle = idle_fn,

前面的回调是运行时电源管理中涉及的唯一回调,以下是它们必须执行的描述:

  • 如果需要,.runtime_suspend()必须记录设备的当前状态并将设备置于静止状态。当设备未被使用时,PM 将调用此方法。在其简单形式中,此方法必须将设备置于一种状态,使其无法与 CPU 和 RAM 通信。

  • 当设备必须处于完全功能状态时,将调用.runtime_resume()。如果系统需要访问此设备,可能会出现这种情况。此方法必须恢复电源并重新加载任何所需的设备状态。

  • 当设备不再使用时(实际上是当其达到0时),将调用.runtime_idle(),以及活动子设备的数量。但是,此回调执行的操作是特定于驱动程序的。在大多数情况下,如果满足某些条件,驱动程序会在设备上调用runtime_suspend(),或者调用pm_schedule_suspend()(给定延迟以设置定时器以在将来提交挂起请求),或者调用pm_runtime_autosuspend()(根据已使用pm_runtime_set_autosuspend_delay()设置的延迟来安排将来的挂起请求)。如果.runtime_idle回调不存在,或者返回0,PM 核心将立即调用.runtime_suspend()回调。对于 PM 核心不执行任何操作,.runtime_idle()必须返回非零值。驱动程序通常会在这种情况下返回-EBUSY1

在实现了回调之后,它们可以被填充到struct dev_pm_ops中,如下例所示:

static const struct dev_pm_ops bh1780_dev_pm_ops = {
    SET_SYSTEM_SLEEP_PM_OPS(pm_runtime_force_suspend,
                            pm_runtime_force_resume)
    SET_RUNTIME_PM_OPS(bh1780_runtime_suspend,
                           bh1780_runtime_resume, NULL)
};
[...]
static struct i2c_driver bh1780_driver = {
    .probe = bh1780_probe,
    .remove = bh1780_remove,
    .id_table = bh1780_id,
    .driver = {
        .name = “bh1780”,
        .pm = &bh1780_dev_pm_ops,
        .of_match_table = of_match_ptr(of_bh1780_match),
    },
};
module_i2c_driver(bh1780_driver);

上述是来自drivers/iio/light/bh1780.c的摘录,这是一个 IIO 环境光传感器驱动程序。在这段摘录中,我们可以看到如何使用方便的宏来填充struct dev_pm_ops。在这里使用SET_SYSTEM_SLEEP_PM_OPS来填充与系统睡眠相关的宏,我们将在接下来的部分中看到。pm_runtime_force_suspendpm_runtime_force_resume是 PM 核心公开的特殊辅助函数,用于强制设备挂起和恢复。

驱动程序中的运行时 PM

事实上,PM 核心使用两个计数器跟踪每个设备的活动情况。第一个计数器是power.usage_count,它计算对设备的活动引用。这些可能是外部引用,例如打开的文件句柄,或者正在使用此设备的其他设备,或者它们可能是用于在操作期间保持设备活动的内部引用。另一个计数器是power.child_count,它计算活动的子设备数量。

这些计数器定义了从 PM 角度看给定设备的活动/空闲状态。设备的活动/空闲状态是 PM 核心确定设备是否可访问的唯一可靠手段。空闲状态是指设备使用计数递减至0,而活动状态(也称为恢复状态)发生在设备使用计数递增时。

在空闲状态下,PM 核心发送/执行空闲通知(即将设备的power.idle_notification字段设置为true,调用总线类型/类/设备->runtime_idle()回调,并将.idle_notification字段再次设置为false)以检查设备是否可以挂起。如果->runtime_idle()回调不存在或者返回0,PM 核心将立即调用->runtime_suspend()回调来挂起设备,之后设备的power.runtime_status字段将设置为RPM_SUSPENDED,这意味着设备已挂起。在恢复条件下(设备使用计数增加),PM 核心将在特定条件下同步或异步地恢复此设备。请查看drivers/base/power/runtime.c中的rpm_resume()函数及其描述。

最初,所有设备的运行时 PM 都被禁用。这意味着在为设备调用pm_runtime_enable()之前,对设备调用大多数与 PM 相关的辅助函数将失败,这将启用此设备的运行时 PM。尽管所有设备的初始运行时 PM 状态都是挂起的,但它不需要反映设备的实际物理状态。因此,如果设备最初是活动的(换句话说,它能够处理 I/O),则必须使用pm_runtime_set_active()(它将设置power.runtime_statusRPM_ACTIVE)来将其运行时 PM 状态更改为活动状态,并且如果可能的话,必须在为设备调用pm_runtime_enable()之前使用pm_runtime_get_noresume()增加其使用计数。一旦设备完全初始化,就可以对其调用pm_runtime_put()

在这里调用pm_runtime_get_noresume()的原因是,如果调用了pm_runtime_put(),设备的使用计数将回到零,这对应于空闲状态,然后将进行空闲通知。此时,您可以检查是否满足必要条件并挂起设备。但是,如果初始设备状态为禁用,则无需这样做。

还有pm_runtime_get()pm_runtime_get_sync()pm_runtime_put_noidle()pm_runtime_put_sync()辅助程序。pm_runtime_get_sync()pm_runtime_get()pm_runtime_get_noresume()之间的区别在于,前者在增加设备使用计数后,如果匹配了活动/恢复条件,将同步(立即)执行设备的恢复,而第二个辅助程序将异步执行(提交请求)。第三个和最后一个在减少设备使用计数后立即返回(甚至不检查恢复条件)。相同的机制适用于pm_runtime_put_sync()pm_runtime_put()pm_runtime_put_noidle()

给定设备的活动子级数量会影响该设备的使用计数。通常,需要父级才能访问子级,因此在子级活动时关闭父级将是适得其反的。然而,有时可能需要忽略设备的活动子级,以确定该设备是否处于空闲状态。一个很好的例子是 I2C 总线,在这种情况下,总线可以在总线上的设备(子级)活动时报告为空闲。对于这种情况,可以调用pm_suspend_ignore_children()来允许设备在具有活动子级时报告为空闲。

运行时 PM 同步和异步操作

在前面的部分中,我们介绍了 PM 核心可以执行同步或异步 PM 操作的事实。对于同步操作,事情很简单(方法调用是串行的),但是在 PM 上下文中异步调用时,我们需要注意执行哪些步骤。

你应该记住,在异步模式下,提交动作的请求而不是立即调用此动作的处理程序。它的工作方式如下:

  1. PM 核心将设备的power.request字段(类型为enum rpm_request)设置为要提交的请求类型(换句话说,对于空闲通知请求为RPM_REQ_IDLE,对于挂起请求为RPM_REQ_SUSPEND,对于自动挂起请求为RPM_REQ_AUTOSUSPEND),这对应于要执行的动作。

  2. PM 核心将设备的power.request_pending字段设置为true

  3. PM 核心队列(计划稍后执行)设备的 RPM 相关工作(power.work,其工作函数为pm_runtime_work();请参阅pm_runtime_init(),其中初始化了该工作)在全局 PM 相关工作队列中。

  4. 当这项工作有机会运行时,工作函数(即pm_runtime_work())将首先检查设备上是否仍有待处理的请求(if (dev->power.request_pending)),并根据设备的power.request_pending字段执行switch ... case以调用底层请求处理程序。

请注意,工作队列管理自己的线程,可以运行计划的工作。因为在异步模式下,处理程序被安排在工作队列中,异步 PM 相关的辅助程序完全可以在原子上下文中调用。例如,在 IRQ 处理程序中调用,相当于推迟 PM 请求处理。

自动挂起

自动挂起是由不希望设备在运行时一旦空闲就挂起的驱动程序使用的机制,而是希望设备在一定的最短时间内保持不活动。

在 RPM 的背景下,术语autosuspend并不意味着设备会自动挂起自己。相反,它是基于一个定时器,当定时器到期时,将排队一个挂起请求。这个定时器实际上是设备的power.suspend_timer字段(请参阅pm_runtime_init(),在那里它被设置)。调用pm_runtime_put_autosuspend()将启动定时器,而pm_runtime_set_autosuspend_delay()将设置超时(尽管可以通过sysfs中的/sys/devices/.../power/autosuspend_delay_ms属性设置)。

pm_schedule_suspend()辅助程序也可以使用这个定时器,带有延迟参数(在这种情况下,它将优先于power.autosuspend_delay字段中设置的延迟),之后将提交一个挂起请求。您可以将这个定时器视为可以用来在计数器达到零和设备被视为空闲之间添加延迟的东西。这对于开关成本很高的设备非常有用。

为了使用autosuspend,子系统或驱动程序必须调用pm_runtime_use_autosuspend()(最好在注册设备之前)。这个辅助程序将把设备的power.use_autosuspend字段设置为true。在启用了 autosuspend 的设备上调用pm_runtime_mark_last_busy(),这样它就可以将power.last_busy字段设置为当前时间(以jiffies为单位),因为这个字段用于计算 autosuspend 的空闲期(例如,new_expire_time = last_busy + msecs_to_jiffies(autosuspend_delay))。

考虑到引入的所有运行时 PM 概念,现在让我们把所有东西放在一起,看看在一个真实的驱动程序中是如何完成的。

把所有东西放在一起

在没有真实案例研究的情况下,对运行时 PM 核心的理论研究将变得不那么重要。现在是时候看看之前的概念是如何应用的。对于这个案例研究,我们将选择 Linux 驱动程序bh1780,它是 Linux 内核源代码中的drivers/iio/light/bh1780.c

首先,让我们看一下probe方法的摘录:

static int bh1780_probe(struct i2c_client *client,
                        const struct i2c_device_id *id)
{
    [...]
    /* Power up the device */ [...]
    pm_runtime_get_noresume(&client->dev);
    pm_runtime_set_active(&client->dev);
    pm_runtime_enable(&client->dev);
    ret = bh1780_read(bh1780, BH1780_REG_PARTID);
    dev_info(&client->dev, “Ambient Light Sensor, Rev : %lu\n”,
                 (ret & BH1780_REVMASK));
    /*
     * As the device takes 250 ms to even come up with a fresh
     * measurement after power-on, do not shut it down      * unnecessarily.
     * Set autosuspend to five seconds.
     */
    pm_runtime_set_autosuspend_delay(&client->dev, 5000);
    pm_runtime_use_autosuspend(&client->dev);
    pm_runtime_put(&client->dev);
    [...]
    ret = iio_device_register(indio_dev);
    if (ret)
        goto out_disable_pm; return 0;
out_disable_pm:
    pm_runtime_put_noidle(&client->dev);
    pm_runtime_disable(&client->dev); return ret;
}

在前面的片段中,为了便于阅读,只留下了与电源管理相关的调用。首先,pm_runtime_get_noresume()将增加设备的使用计数,而不携带设备的空闲通知(_noidle后缀)。您可以使用pm_runtime_get_noresume()接口关闭运行时挂起功能,或者在设备挂起时使使用计数为正,以避免由于运行时挂起而无法正常唤醒的问题。然后,在驱动程序中的下一行是pm_runtime_set_active()。这个辅助程序将设备标记为活动的(power.runtime_status = RPM_ACTIVE),并清除设备的power.runtime_error字段。此外,设备父级的未挂起(活动)子级计数将被修改以反映新的状态(实际上是增加)。在设备上调用pm_runtime_set_active()将阻止该设备的父级在运行时挂起(假设父级的运行时 PM 已启用),除非父级的power.ignore_children标志已设置。因此,一旦为设备调用了pm_runtime_set_active(),就应该尽快为其调用pm_runtime_enable()。调用这个函数并不是强制性的;它必须与 PM 核心和设备的状态保持一致,假设初始状态是RPM_SUSPENDED

重要说明

pm_runtime_set_active()的相反操作是pm_runtime_set_suspended(),它将设备状态更改为RPM_SUSPENDED,并减少父级的活动子级计数。提交父级的空闲通知请求。

pm_runtime_enable()是强制的运行时 PM 助手,它启用设备的运行时 PM,即在设备的power.disable_depth值大于0时递减该值。需要注意的是,每次运行时 PM 助手调用时都会检查设备的power.disable_depth值,该值必须为0才能继续执行。其初始值为1,并且在调用pm_runtime_enable()时递减该值。在错误路径上,会调用pm_runtime_put_noidle()以使 PM 运行时计数平衡,并且pm_runtime_disable()会完全禁用设备的运行时 PM。

正如你可能已经猜到的,这个驱动程序也处理 IIO 框架,这意味着它在 sysfs 中公开了与其物理转换通道对应的条目。读取与通道对应的 sysfs 文件将报告该通道产生的转换的数字值。然而,对于bh1780,其驱动程序中的通道读取入口点是bh1780_read_raw()。该方法的摘录如下:

static int bh1780_read_raw(struct iio_dev *indio_dev,
                           struct iio_chan_spec const *chan,
                           int *val, int *val2, long mask)
{
    struct bh1780_data *bh1780 = iio_priv(indio_dev);
    int value;
    switch (mask) {
    case IIO_CHAN_INFO_RAW:
        switch (chan->type) {
        case IIO_LIGHT:
            pm_runtime_get_sync(&bh1780->client->dev);
            value = bh1780_read_word(bh1780, BH1780_REG_DLOW);
            if (value < 0)
                return value;
            pm_runtime_mark_last_busy(&bh1780->client->dev); 
            pm_runtime_put_autosuspend(&bh1780->client->dev);
            *val = value;
            return IIO_VAL_INT;
        default:
            return -EINVAL;
    case IIO_CHAN_INFO_INT_TIME:
        *val = 0;
        *val2 = BH1780_INTERVAL * 1000;
        return IIO_VAL_INT_PLUS_MICRO;
    default:
        return -EINVAL;
    }
}

同样,只有与运行时 PM 相关的函数调用值得我们关注。在通道读取时,会调用前面的函数。设备驱动程序必须指示设备对通道进行采样,执行转换,其结果将由设备驱动程序读取并报告给读取者。问题在于,设备可能处于挂起状态。因此,因为驱动程序需要立即访问设备,驱动程序在设备上调用pm_runtime_get_sync()。如果你还记得的话,这个方法会增加设备的使用计数,并进行同步(_sync后缀)恢复设备。设备恢复后,驱动程序可以与设备通信并读取转换值。因为驱动程序支持自动挂起,所以会调用pm_runtime_mark_last_busy()以标记设备最后活动的时间。这将更新用于自动挂起的定时器的超时值。最后,驱动程序调用pm_runtime_put_autosuspend(),这将在自动挂起定时器到期后执行设备的运行时挂起,除非该定时器在到期前由pm_runtime_mark_last_busy()在某处被调用重新启动,或者在再次进入读取函数(例如在 sysfs 中读取通道)之前到期。

总之,在访问硬件之前,驱动程序可以使用pm_runtime_get_sync()恢复设备,当完成硬件操作后,驱动程序可以使用pm_runtime_put_sync()pm_runtime_put()pm_runtime_put_autosuspend()通知设备处于空闲状态(假设启用了自动挂起,在这种情况下,必须先调用pm_runtime_mark_last_busy()以更新自动挂起定时器的超时)。

最后,让我们专注于模块被卸载时调用的方法。以下是一个摘录,其中只有与电源管理相关的调用是感兴趣的:

static int bh1780_remove(struct i2c_client *client)
{
    int ret;
    struct iio_dev *indio_dev = i2c_get_clientdata(client);
    struct bh1780_data *bh1780 = iio_priv(indio_dev);
    iio_device_unregister(indio_dev);
    pm_runtime_get_sync(&client->dev);
    pm_runtime_put_noidle(&client->dev);
    pm_runtime_disable(&client->dev);
    ret = bh1780_write(bh1780, BH1780_REG_CONTROL,                        BH1780_POFF);
    if (ret < 0) {
        dev_err(&client->dev, “failed to power off\n”);
        return ret;
    }
    return 0;
}

这里调用的第一个运行时 PM 方法是pm_runtime_get_sync()。这个调用让我们猜测设备将要被使用,也就是说,驱动程序需要访问硬件。因此,这个辅助函数立即恢复设备(实际上增加了设备的使用计数并进行了同步恢复设备)。之后,调用pm_runtime_put_noidle()以减少设备使用计数而不进行空闲通知。接下来,调用pm_runtime_disable()以在设备上禁用运行时 PM。这将增加设备的power.disable_depth,如果之前为零,则取消设备的所有挂起运行时 PM 请求,并等待所有正在进行的操作完成,因此从 PM 核心的角度来看,设备不再存在(请记住,power.disable_depth将不匹配 PM 核心的期望,这意味着在此设备上调用的任何进一步的运行时 PM 辅助函数将失败)。最后,通过 i2c 命令关闭设备,之后其硬件状态将反映其运行时 PM 状态。

以下是适用于运行时 PM 回调和执行的一般规则:

  • ->runtime_idle()->runtime_suspend()只能为活动设备(状态为活动)执行。

  • ->runtime_idle()->runtime_suspend()只能为使用计数为零的设备执行,并且子设备的活动计数为零,或者设置了power.ignore_children标志。

  • ->runtime_resume()只能为挂起的设备(状态为挂起)执行。

此外,PM 核心提供的辅助函数遵守以下规则:

  • 如果->runtime_suspend()即将被执行,或者有一个挂起的请求要执行它,->runtime_idle()将不会为同一设备执行。

  • 执行或计划执行->runtime_suspend()的请求将取消执行同一设备的->runtime_idle()的任何挂起请求。

  • 如果->runtime_resume()即将被执行,或者有一个挂起的请求要执行它,其他回调将不会为同一设备执行。

  • 执行->runtime_resume()的请求将取消执行同一设备的其他回调的任何挂起或计划请求,除了计划的自动挂起。

上述规则是这些回调的任何调用可能失败的很好的指标。从中我们还可以观察到,恢复或请求恢复优于任何其他回调或请求。

电源域的概念

从技术上讲,电源域是一组共享电源资源(例如时钟或电源平面)的设备。从内核的角度来看,电源域是一组使用相同的回调和子系统级别的公共 PM 数据的设备集合。从硬件的角度来看,电源域是一个用于管理电压相关的设备的硬件概念;例如,视频核心 IP 与显示 IP 共享一个电源轨。

由于 SoC 设计变得更加复杂,需要找到一种抽象方法,使驱动程序尽可能通用;然后,genpd出现了。这代表通用电源域。它是 Linux 内核的一个抽象,将每个设备的运行时电源管理扩展到共享电源轨的设备组。此外,电源域被定义为设备树的一部分,其中描述了设备和电源控制器之间的关系。这允许电源域在运行时重新设计,并且驱动程序可以适应而无需重新启动整个系统或重新构建新的内核。

它被设计成如果设备存在电源域对象,则其 PM 回调优先于总线类型(或设备类或类型)回调。有关此的通用文档可在内核源代码的Documentation/devicetree/bindings/power/power_domain.txt中找到,与您的 SoC 相关的文档可以在同一目录中找到。

系统挂起和恢复序列

struct dev_pm_ops数据结构的引入在某种程度上促进了对 PM 核心在挂起或恢复阶段执行的步骤和操作的理解,可以总结如下:

“prepare —> Suspend —> suspend_late —> suspend_noirq”
          |---------- Wakeup ----------|
“resume_noirq —> resume_early —> resume -> complete”

上述是完整的系统 PM 链,列在include/linux/suspend.h中定义的enum suspend_stat_step中。这个流程应该让你想起struct dev_pm_ops数据结构。

在 Linux 内核代码中,enter_state()是由系统电源管理核心调用的函数,用于进入系统睡眠状态。现在让我们花一些时间了解系统挂起和恢复期间真正发生了什么。

挂起阶段

在挂起时,enter_state()经历的步骤如下:

  1. 如果内核配置选项CONFIG_SUSPEND_SKIP_SYNC未设置,则首先在文件系统上调用sync()(参见ksys_sync())。

  2. 调用挂起通知器(当用户空间仍然存在时)。参考register_pm_notifier(),这是用于注册它们的辅助程序。

  3. 它冻结任务(参见suspend_freeze_processes()),这会冻结用户空间以及内核线程。如果内核配置中未设置CONFIG_SUSPEND_FREEZER,则会跳过此步骤。

  4. 通过调用驱动程序注册的每个.suspend()回调来挂起设备。这是挂起的第一阶段(参见suspend_devices_and_enter())。

  5. 它禁用设备中断(参见suspend_device_irqs())。这可以防止设备驱动程序接收中断。

  6. 然后,发生设备挂起的第二阶段(调用.suspend_noirq回调)。这一步被称为noirq阶段。

  7. 它禁用非引导 CPU(使用 CPU 热插拔)。在它们下线之前,CPU 调度程序被告知不要在这些 CPU 上安排任何任务(参见disable_nonboot_cpus())。

  8. 关闭中断。

  9. 执行系统核心回调(参见syscore_suspend())。

  10. 它让系统进入睡眠状态。

这是系统进入睡眠状态之前执行的操作的粗略描述。某些操作的行为可能会根据系统即将进入的睡眠状态略有不同。

恢复阶段

一旦系统被挂起(无论有多深),一旦发生唤醒事件,系统就需要恢复。以下是 PM 核心执行的唤醒系统的步骤和操作:

  1. (唤醒信号。)

  2. 运行 CPU 的唤醒代码。

  3. 执行系统核心回调。

  4. 打开中断。

  5. 启用非引导 CPU(使用 CPU 热插拔)。

  6. 恢复设备的第一阶段(.resume_noirq()回调)。

  7. 启用设备中断。

  8. 挂起设备的第二阶段(.resume()回调)。

  9. 解冻任务。

  10. 调用通知器(当用户空间恢复时)。

我会让你在 PM 代码中发现在恢复过程的每个步骤中调用了哪些函数。然而,从驱动程序内部来看,这些步骤都是透明的。驱动程序唯一需要做的就是根据希望参与的步骤填充struct dev_pm_ops中的适当回调,我们将在下一节中看到。

实现系统睡眠功能

系统睡眠和运行时 PM 是不同的东西,尽管它们彼此相关。有些情况下,通过不同的方式进行操作,它们会将系统带到相同的物理状态。因此,通常不建议用一个替换另一个。

我们已经看到设备驱动程序如何通过根据它们需要参与的睡眠状态在struct dev_pm_ops数据结构中填充一些回调来参与系统休眠。无论睡眠状态如何,通常提供的回调都是.suspend.resume.freeze.thaw.poweroff.restore。它们是相当通用的回调,定义如下:

  • .suspend:在将系统置于保留主存储器内容的睡眠状态之前执行此操作。

  • .resume:在从保留主存储器内容的睡眠状态唤醒系统后调用此回调,此时设备的状态取决于设备所属的平台和子系统。

  • .freeze:特定于休眠,此回调在创建休眠镜像之前执行。它类似于.suspend,但不应该使设备发出唤醒事件或更改其电源状态。大多数实现此回调的设备驱动程序只需将设备设置保存在内存中,以便在随后的休眠恢复中可以重新使用。

  • .thaw:这是特定于休眠的回调,在创建休眠镜像后执行,或者如果创建镜像失败,则执行。在尝试从这样的镜像中恢复主存储器的内容失败后,也会执行。它必须撤消前面.freeze所做的更改,以使设备以与调用.freeze之前相同的方式运行。

  • .poweroff:也是特定于休眠,此回调在保存休眠镜像后执行。它类似于.suspend,但不需要在内存中保存设备的设置。

  • .restore:这是最后一个特定于休眠的回调,在从休眠镜像中恢复主存储器的内容后执行。它类似于.resume

大多数前面的回调都是相似的,或者执行大致相似的操作。虽然.resume.thaw.restore三者可能执行类似的任务,但对于另一个三者——->suspend->freeze->poweroff也是如此。因此,为了提高代码可读性或简化回调填充,PM 核心提供了SET_SYSTEM_SLEEP_PM_OPS宏,它接受suspendresume函数,并填充系统相关的 PM 回调如下:

#define SET_SYSTEM_SLEEP_PM_OPS(suspend_fn, resume_fn) \
        .suspend = suspend_fn, \
        .resume = resume_fn, \
        .freeze = suspend_fn, \
        .thaw = resume_fn, \
        .poweroff = suspend_fn, \
        .restore = resume_fn,

_noirq()相关的回调也是如此。如果驱动程序只需要参与系统挂起的noirq阶段,则可以使用SET_NOIRQ_SYSTEM_SLEEP_PM_OPS宏,以便自动填充struct dev_pm_ops数据结构中的_noirq()相关回调。以下是该宏的定义:

#define SET_NOIRQ_SYSTEM_SLEEP_PM_OPS(suspend_fn, resume_fn) \
        .suspend_noirq = suspend_fn, \
        .resume_noirq = resume_fn, \
        .freeze_noirq = suspend_fn, \
        .thaw_noirq = resume_fn, \
        .poweroff_noirq = suspend_fn, \
        .restore_noirq = resume_fn,

前面的宏只有两个参数,就像前面的宏一样,表示这次是noirq阶段的suspendresume回调。您应该记住,这些回调在系统上禁用 IRQ 时被调用。

最后,还有SET_LATE_SYSTEM_SLEEP_PM_OPS宏,它将->suspend_late->freeze_late->poweroff_late指向相同的函数,反之亦然,将->resume_early->thaw_early->restore_early指向相同的函数:

#define SET_LATE_SYSTEM_SLEEP_PM_OPS(suspend_fn, resume_fn) \
        .suspend_late = suspend_fn, \
        .resume_early = resume_fn, \
        .freeze_late = suspend_fn, \
        .thaw_early = resume_fn, \
        .poweroff_late = suspend_fn, \
        .restore_early = resume_fn,

除了减少编码工作外,所有前面的宏都受到#ifdef CONFIG_PM_SLEEP内核配置选项的限制,以便在不需要 PM 的情况下不构建它们。最后,如果要将相同的挂起和恢复回调用于挂起到 RAM 和休眠,可以使用以下命令:

#define SIMPLE_DEV_PM_OPS(name, suspend_fn, resume_fn) \
const struct dev_pm_ops name = { \
    SET_SYSTEM_SLEEP_PM_OPS(suspend_fn, resume_fn) \
}

在上面的片段中,name表示设备 PM 操作结构的实例化名称。suspend_fnresume_fn是在系统进入挂起状态或从睡眠状态恢复时要调用的回调。

现在我们能够在驱动程序代码中实现系统休眠功能,让我们看看如何行为系统唤醒源,允许退出睡眠状态。

成为系统唤醒源

PM 核心允许系统在系统暂停后被唤醒。能够唤醒系统的设备在 PM 语言中被称为唤醒源。为了使唤醒源正常工作,它需要一个所谓的唤醒事件,大多数情况下,这被等同于 IRQ 线。换句话说,唤醒源生成唤醒事件。当唤醒源生成唤醒事件时,通过唤醒事件框架提供的接口将唤醒源设置为激活状态。当事件处理结束时,它被设置为非激活状态。激活和停用之间的间隔表示事件正在被处理。在本节中,我们将看到如何在驱动程序代码中使您的设备成为系统唤醒源。

唤醒源的工作方式是,当系统中有任何唤醒事件正在被处理时,不允许暂停。如果暂停正在进行,它将被终止。内核通过struct wakeup_source来抽象唤醒源,该结构也用于收集与它们相关的统计信息。以下是include/linux/pm_wakeup.h中此数据结构的定义:

struct wakeup_source {
    const char *name;
    struct list_head entry;
    spinlock_t lock;
    struct wake_irq *wakeirq;
    struct timer_list timer;
    unsigned long timer_expires;
    ktime_t total_time;
    ktime_t max_time;
    ktime_t last_time;
    ktime_t start_prevent_time;
    ktime_t prevent_sleep_time;
    unsigned long event_count;
    unsigned long active_count;
    unsigned long relax_count;
    unsigned long expire_count;
   unsigned long wakeup_count;
    bool active:1;
    bool autosleep_enabled:1;
};

这个结构对于您的代码来说是完全无用的,但是研究它将有助于您理解sysfs属性的唤醒源的含义:

  • entry用于在链表中跟踪所有唤醒源。

  • 计时器timer_expires密切相关。当唤醒源生成唤醒事件并且该事件正在被处理时,唤醒源被称为活动的,这会阻止系统暂停。处理完唤醒事件后(系统不再需要为此目的处于活动状态),它将恢复为非活动状态。驱动程序可以执行激活和停用操作,也可以通过在激活期间指定超时来决定其他操作。这个超时将被 PM 唤醒核心用来配置一个定时器,在超时后自动将事件设置为非活动状态。timertimer_expires就是用于这个目的。

  • total_time是这个唤醒源处于活动状态的总时间。它总结了唤醒源在活动状态下花费的总时间。这是设备繁忙水平和功耗水平的良好指标。

  • max_time是唤醒源保持(或连续)处于活动状态的最长时间。时间越长,异常性就越大。

  • last_time表示此唤醒源上次活动的开始时间。

  • start_prevent_time是唤醒源开始阻止系统自动休眠的时间点。

  • prevent_sleep_time是这个唤醒源阻止系统自动休眠的总时间。

  • event_count表示唤醒源报告的事件数量。换句话说,它表示被触发的唤醒事件的数量。

  • active_count表示唤醒源被激活的次数。在某些情况下,这个值可能不相关或不一致。例如,当唤醒事件发生时,唤醒源需要切换到激活状态。但这并不总是这样,因为事件可能发生在唤醒源已经被激活的情况下。因此active_count可能小于event_count,在这种情况下,这可能意味着在上一个唤醒事件被处理完之前,很可能另一个唤醒事件被生成。这在一定程度上反映了唤醒源所代表的设备的繁忙程度。

  • relax_count表示唤醒源被停用的次数。

  • expire_count表示唤醒源超时的次数。

  • wakeup_count是唤醒源终止挂起过程的次数。如果唤醒源在挂起过程中生成唤醒事件,挂起过程将被中止。此变量记录了唤醒源终止挂起过程的次数。这可能是检查系统是否总是无法挂起的良好指标。

  • active表示唤醒源的激活状态。

  • 对于我来说,autosleep_enabled记录了系统的自动睡眠状态,无论它是否启用。

为了使设备成为唤醒源,其驱动程序必须调用device_init_wakeup()。此函数设置设备的power.can_wakeup标志(以便device_can_wakeup()助手返回当前设备作为唤醒源的能力),并将其唤醒相关属性添加到 sysfs。此外,它创建一个唤醒源对象,注册它,并将其附加到设备(dev->power.wakeup)。但是,device_init_wakeup()只会将设备变成一个具有唤醒功能的设备,而不会为其分配唤醒事件。

重要提示

请注意,只有具有唤醒功能的设备才会在 sysfs 中有一个 power 目录,提供所有唤醒信息。

为了分配唤醒事件,驱动程序必须调用enable_irq_wake(),并将用作唤醒事件的 IRQ 线作为参数。enable_irq_wake()的功能可能是特定于平台的(除其他功能外,它还调用底层 irqchip 驱动程序公开的irq_chip.irq_set_wake回调)。除了打开处理给定 IRQ 作为系统唤醒中断线的平台逻辑外,它还指示suspend_device_irqs()(在系统挂起路径上调用:参考Suspend stages部分,step 5)以不同方式处理给定的 IRQ。因此,IRQ 将保持启用状态,直到下一个中断,然后将被禁用,标记为挂起,并暂停,以便在随后的系统恢复期间由resume_device_irqs()重新启用。这使得驱动程序的->suspend方法成为调用enable_irq_wake()的正确位置,以便在正确时刻始终重新激活唤醒事件。另一方面,驱动程序的->resume回调是调用disable_irq_wake()的正确位置,该回调将关闭 IRQ 的系统唤醒功能的平台配置。

设备作为唤醒源的能力是硬件问题,唤醒能力设备是否应发出唤醒事件是一项政策决定,并由用户空间通过sysfs属性/sys/devices/.../power/wakeup进行管理。此文件允许用户空间检查或决定设备(通过其唤醒事件)是否能够唤醒系统从睡眠状态中唤醒。此文件可以读取和写入。读取时,可以返回enableddisabled。如果返回enabled,这意味着设备能够发出事件;如果返回disabled,这意味着设备无法这样做。向其写入enableddisabled字符串将指示设备是否应该信号系统唤醒(内核device_may_wakeup()助手将分别返回truefalse)。请注意,对于无法生成系统唤醒事件的设备,此文件不存在。

让我们看一个例子,看看驱动程序如何利用设备的唤醒功能。以下是i.MX6 SNVS powerkey 驱动程序的摘录,位于drivers/input/keyboard/snvs_pwrkey.c中:

static int imx_snvs_pwrkey_probe(struct platform_device *pdev)
{
    [...]
    error = devm_request_irq(&pdev->dev, pdata->irq,
    imx_snvs_pwrkey_interrupt, 0, pdev->name, pdev);
    pdata->wakeup = of_property_read_bool(np, “wakeup-source”); 
    [...]
    device_init_wakeup(&pdev->dev, pdata->wakeup);
    return 0;
}
static int
    maybe_unused imx_snvs_pwrkey_suspend(struct device *dev)
{
    [...]
    if (device_may_wakeup(&pdev->dev))
        enable_irq_wake(pdata->irq);
    return 0;
}
static int maybe_unused imx_snvs_pwrkey_resume(struct                                                device *dev)
{
    [...]
    if (device_may_wakeup(&pdev->dev))
        disable_irq_wake(pdata->irq);
    return 0;
}

在上面的代码摘录中,从上到下,我们有驱动程序探测方法,首先使用device_init_wakeup()函数启用设备唤醒功能。然后,在 PM 恢复回调中,它通过调用enable_irq_wake()来检查设备是否允许发出唤醒信号,然后通过device_may_wakeup()助手来启用唤醒事件,参数是相关的 IRQ 号。使用device_may_wakeup()来进行唤醒事件的启用/禁用的原因是因为用户空间可能已经更改了该设备的唤醒策略(通过/sys/devices/.../power/wakeup sysfs文件),在这种情况下,此助手将返回当前启用/禁用状态。此助手使用户空间决策与启用一致。在禁用唤醒事件的 IRQ 线之前,恢复方法也会进行相同的检查。

接下来,在驱动程序代码的底部,我们可以看到以下内容:

static SIMPLE_DEV_PM_OPS(imx_snvs_pwrkey_pm_ops,
                         imx_snvs_pwrkey_suspend,
                         imx_snvs_pwrkey_resume);
static struct platform_driver imx_snvs_pwrkey_driver = {
    .driver = {
        .name = “snvs_pwrkey”,
        .pm   = &imx_snvs_pwrkey_pm_ops,
        .of_match_table = imx_snvs_pwrkey_ids,
    },
    .probe = imx_snvs_pwrkey_probe,
};

前面显示了著名的SIMPLE_DEV_PM_OPS宏的用法,这意味着相同的挂起回调(即imx_snvs_pwrkey_suspend)将用于挂起到 RAM 或休眠睡眠状态,并且相同的恢复回调(实际上是imx_snvs_pwrkey_resume)将用于从这些状态恢复。设备 PM 结构被命名为imx_snvs_pwrkey_pm_ops,正如我们在宏中看到的那样,并且稍后提供给驱动程序。填充 PM 操作就是这么简单。

在结束本节之前,让我们注意一下此设备驱动程序中的 IRQ 处理程序:

static irqreturn_t imx_snvs_pwrkey_interrupt(int irq,
                                             void *dev_id)
{
    struct platform_device *pdev = dev_id;
    struct pwrkey_drv_data *pdata = platform_get_drvdata(pdev);
    pm_wakeup_event(pdata->input->dev.parent, 0);
    [...]
    return IRQ_HANDLED;
}

这里的关键函数是pm_wakeup_event()。粗略地说,它报告了一个唤醒事件。此外,这将停止当前系统状态转换。例如,在挂起路径上,它将中止挂起操作并阻止系统进入睡眠状态。以下是此函数的原型:

void pm_wakeup_event(struct device *dev, unsigned int msec)

第一个参数是唤醒源所属的设备,第二个参数msec是在 PM 唤醒核心自动将唤醒源切换到非活动状态之前等待的毫秒数。如果msec等于 0,则在报告事件后立即禁用唤醒源。如果msec不等于 0,则唤醒源的停用将在未来的msec毫秒后计划进行。

这是唤醒源的timertimer_expires字段被使用的地方。粗略地说,唤醒事件报告包括以下步骤:

  • 它增加了唤醒源的event_count计数器,并增加了唤醒源的wakeup_count,这是唤醒源可能中止挂起操作的次数。

  • 如果唤醒源尚未激活(以下是激活路径上执行的步骤):

  • 它标记唤醒源为活动状态,并增加唤醒源的active_count元素。

  • 它将唤醒源的last_time字段更新为当前时间。

  • 如果其他字段autosleep_enabledtrue,则更新唤醒源的start_prevent_time字段。

然后,唤醒源的停用包括以下步骤:

    • 它将唤醒源的active字段设置为false
  • 它通过将处于活动状态的时间添加到旧值中来更新唤醒源的total_time字段。

    • 如果活动状态的持续时间大于旧的max_time字段的值,则使用活动状态的持续时间更新唤醒源的max_time字段。
  • 它使用当前时间更新唤醒源的last_time字段,删除唤醒源的计时器,并清除timer_expires

    • 如果其他字段prevent_sleep_timetrue,则更新唤醒源的prevent_sleep_time字段。

停用可能会立即发生,如果msec == 0,或者如果不为零,则在将来的msec毫秒后进行计划。所有这些都应该提醒您struct wakeup_source,我们之前介绍过,其中大多数元素都是通过此函数调用更新的。 IRQ 处理程序是调用它的好地方,因为中断触发也标记了唤醒事件。您还应该注意,可以从 sysfs 接口检查任何唤醒源的每个属性,我们将在下一节中看到。

唤醒源和 sysfs(或 debugfs)

这里还有一些需要提及的东西,至少是为了调试目的。可以通过打印/sys/kernel/debug/wakeup_sources的内容列出系统中所有唤醒源的完整列表(假设debugfs已挂载在系统上):

# cat /sys/kernel/debug/wakeup_sources

该文件还报告了每个唤醒源的统计信息,这些统计信息可以通过设备的与电源相关的 sysfs 属性单独收集。其中一些 sysfs 文件属性如下:

#ls /sys/devices/.../power/wake*
wakeup wakeup_active_count  wakeup_last_time_ms autosuspend_delay_ms wakeup_abort_count  wakeup_count	wakeup_max_time_ms wakeup_active wakeup_expire_count	wakeup_total_time_ms

我使用wake*模式来过滤与运行时 PM 相关的属性,这些属性也在同一个目录中。而不是描述每个属性是什么,更有价值的是指出在struct wakeup_source结构中的哪些字段中映射了前面的属性:

  • wakeup是一个 RW 属性,之前已经描述过。它的内容决定了device_may_wakeup()助手的返回值。只有这个属性是可读和可写的。这里的其他属性都是只读的。

  • wakeup_abort_countwakeup_count是只读属性,指向相同的字段,即wakeup->wakeup_count

  • wakeup_expire_count属性映射到wakeup->expire_count字段。

  • wakeup_active是只读的,并映射到wakeup->active元素。

  • wakeup_total_time_ms是一个只读属性,返回wakeup->total_time值,单位是ms

  • wakeup_max_time_msms返回power.wakeup->max_time值。

  • wakeup_last_time_ms是一个只读属性,对应于wakeup->last_time值;单位是ms

  • wakeup_prevent_sleep_time_ms也是只读的,并映射到 wakeup ->prevent_sleep_time值,其单位是ms

并非所有设备都具有唤醒功能,但是那些具有唤醒功能的设备可以大致遵循这个指南。

现在我们已经完成并熟悉了来自 sysfs 的唤醒源管理,我们可以介绍特殊的IRQF_NO_SUSPEND标志,它有助于防止在系统挂起路径中禁用 IRQ。

IRQF_NO_SUSPEND 标志

有一些中断需要能够在整个系统挂起-恢复周期中触发,包括挂起和恢复设备的noirq阶段,以及在非引导 CPU 被下线和重新上线时。例如,定时器中断就是这种情况。必须在这些中断上设置此标志。尽管此标志有助于在挂起阶段保持中断启用,但并不保证 IRQ 将唤醒系统从挂起状态唤醒-对于这种情况,有必要使用enable_irq_wake(),再次强调,这是特定于平台的。因此,您不应混淆或混合使用IRQF_NO_SUSPEND标志和enable_irq_wake()

如果带有此标志的 IRQ 被多个用户共享,那么每个用户都会受到影响,而不仅仅是设置了该标志的用户。换句话说,即使在suspend_device_irqs()之后,也会像往常一样调用注册到中断的每个处理程序。这可能不是您所需要的。因此,您应该避免混合使用IRQF_NO_SUSPENDIRQF_SHARED标志。

总结

在本章中,我们已经学会了如何管理系统的功耗,无论是从驱动程序中的代码内部还是从用户空间的命令行中进行操作,可以在运行时通过对单个设备进行操作,或者通过调整睡眠状态来对整个系统进行操作。我们还学会了其他框架如何帮助减少系统的功耗(如 CPUFreq、Thermal 和 CPUIdle)。

在下一章中,我们将转向处理 PCI 设备驱动程序,这些驱动程序处理着这个无需介绍的著名总线上的设备。

第三部分:与其他 Linux 内核子系统保持最新

本节深入探讨了一些有用的 Linux 内核子系统,这些子系统并未被充分讨论,或者可用文档已经过时。本节采用了逐步的方法来开发 PCI 设备驱动程序,利用 NVMEM 和看门狗框架,并通过一些技巧和最佳实践来提高效率。

本节包括以下章节:

  • 第十一章,编写 PCI 设备驱动程序

  • 第十二章,利用 NVMEM 框架

  • 第十三章,看门狗设备驱动程序

  • 第十四章,Linux 内核调试技巧和最佳实践

第十一章:编写 PCI 设备驱动程序

PCI 不仅仅是一个总线,它是一个具有完整规范集的标准,定义了计算机的不同部分应该如何交互。多年来,PCI 总线已成为设备互连的事实标准,以至于几乎每个 SoC 都原生支持这样的总线。对速度的需求导致了不同版本和世代的总线。

在标准的早期阶段,实现 PCI 标准的第一个总线是 PCI 总线(总线名称与标准相同),作为 ISA 总线的替代。这改进了 ISA 的地址限制(限制为 24 位,并且有时需要通过跳线来路由 IRQ 等)。与 PCI 标准的先前总线实现相比,主要改进的因素是速度。

PCI Express 是当前的 PCI 总线系列。它是串行总线,而其祖先是并行总线。除了速度,PCIe 将其前身的 32 位寻址扩展到 64 位,并在中断管理系统中进行了多项改进。这个系列被分为世代,GenX,我们将在本章的以下部分中看到。我们将从 PCI 总线和接口的介绍开始,了解总线枚举,然后我们将看看 Linux 内核 PCI API 和核心功能。

所有这些的好消息是,无论是哪个系列,几乎所有内容对驱动程序开发人员来说都是透明的。Linux 内核将通过一组简化的 API 来抽象和隐藏大部分机制,这些 API 可用于编写可靠的 PCI 设备驱动程序。

本章将涵盖以下主题:

  • PCI 总线和接口简介

  • Linux 内核 PCI 子系统和数据结构

  • PCI 和直接内存访问(DMA)

技术要求

需要对 Linux 内存管理和内存映射有良好的概述,以及对中断和锁定的概念有熟悉,特别是在 Linux 内核中。

Linux 内核 v4.19.X 源代码可在git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/refs/tags上找到。

PCI 总线和接口简介

外围组件互连(PCI)是一种本地总线标准,用于连接计算机系统的外围硬件设备。作为总线标准,它定义了计算机的不同外围设备应该如何交互。然而,多年来,PCI 标准在功能或速度方面都有所发展。从其创建至今,我们已经有几个实现 PCI 标准的总线系列,如 PCI(是的,与标准同名的总线),PCI Extended(PCI-X),PCI Express(PCIe 或 PCI-E),这是 PCI 的当前世代。遵循 PCI 标准的总线称为 PCI 总线。

从软件角度来看,所有这些技术都是兼容的,并且可以由同一内核驱动程序处理。这意味着内核不需要知道使用的确切总线变体。从软件角度来看,PCIe 在很大程度上扩展了 PCI,具有许多软件方面的相似之处(特别是读/写 I/O 或内存事务)。虽然两者在软件上兼容,但 PCIe 是串行总线,而不是并行总线(在 PCIe 之前,每个 PCI 总线系列都是并行的),这也意味着你不能在 PCIe 插槽中安装 PCI 卡,或者在 PCI 插槽中安装 PCIe 卡。

PCI Express 是当今计算机上最流行的总线标准,因此在本章中,我们将以 PCIe 为目标,同时在必要时提到与 PCI 的相似之处或不同之处。除了前述内容,以下是 PCIe 的一些改进:

  • PCIe 是串行总线技术,而 PCI(或其他实现)是并行的,从而减少了连接设备所需的 I/O 通道数量,从而减少了设计复杂性。

  • PCIe 实现了增强的中断管理功能(提供基于消息的中断,也称为 MSI,或其扩展版本 MSI-X),扩展了 PCI 设备可以处理的中断数量,而不增加其延迟。

  • PCIe 增加了传输频率和吞吐量:Gen1,Gen2,Gen3...

PCI 设备是一种内存映射设备。连接到任何 PCI 总线的设备在处理器的地址空间中被分配地址范围。这些地址范围在 PCI 地址域中有不同的含义,根据它们包含的内容(PCI 设备的控制、数据和状态寄存器)或它们被访问的方式(I/O 端口或内存映射)。设备驱动程序/内核将访问这些内存区域,以控制连接到 PCI 总线上的特定设备并与其共享信息。

PCI 地址域包含三种不同的内存类型,必须在处理器的地址空间中进行映射。

术语

由于 PCIe 生态系统非常庞大,我们可能需要在继续之前熟悉一些术语。这些术语如下:

  • 根复杂RC):这指的是 SoC 中的 PCIe 主机控制器。它可以在 CPU 不介入的情况下访问主存储器,这是其他设备用来访问主存储器的特性。它们也被称为主机到 PCI 桥接器。

  • 00h配置空间头。它们永远不会出现在交换机的内部总线上,并且没有下游端口。

  • 通道:这代表一组差分信号对(一个对用于 Tx,一个对用于 Rx)。

  • xNx1x2x4x8x12x16x32),其中N是对的数量。

并非所有的 PCIe 设备都是端点。它们也可能是交换机或桥接器。

  • 桥接器:这些提供了与其他总线的接口,如 PCI 或 PCI X,甚至另一个 PCIe 总线。桥接器也可以提供与同一总线的接口。例如,PCI 到 PCI 桥通过创建一个完全独立的次级总线来方便在总线上添加更多负载(我们将在接下来的部分中看到次级总线是什么)。桥接器的概念有助于理解和实现交换机的概念。

  • 交换机:这些提供了聚合功能,并允许更多的设备连接到单个根端口。不用说,交换机有一个上游端口,但可能有多个下游端口。它们足够智能,可以作为数据包路由器,并根据其地址或其他路由信息(如 ID)识别给定数据包需要采取的路径。也就是说,还有隐式路由,仅用于某些消息事务,例如来自根复杂的广播和始终发送到根复杂的消息。

交换机下游端口是(虚拟的)PCI-PCI 桥,从内部总线桥接到代表此 PCI Express 交换机的下游 PCI Express 链路的总线。应该记住,只有代表交换机下游端口的 PCI-PCI 桥可能出现在内部总线上。

重要提示

PCI 到 PCI 桥提供了两个外围组件互连(PCI)总线之间的连接路径。在总线枚举过程中,应该记住只有 PCI-PCI 桥的下游端口会被考虑。这对于理解枚举过程非常重要。

PCI 总线枚举,设备配置和寻址

PCIe 相对于 PCI 最明显的改进是其点对点总线拓扑结构。每个设备都位于自己专用的总线上,在 PCIe 术语中被称为链路。理解 PCIe 设备的枚举过程需要一些知识。

当您查看设备的寄存器空间(在头部类型寄存器中),它们会说明它们是类型0还是类型1的寄存器空间。通常,类型0表示端点设备,类型1表示桥接设备。软件必须确定它是在与端点设备还是桥接设备通信。桥接设备配置与端点设备配置不同。在桥接设备(类型 1)枚举期间,软件必须为其分配以下元素:

  • 主总线号:这是上游总线号。

  • 0xFF,因为255是最高的总线号。随着枚举的继续,这个字段将获得这座桥可以走多远的真实值。

设备识别

设备识别由一些属性或参数组成,使设备成为唯一或可寻址的。在 PCI 子系统中,这些参数如下:

  • 供应商 ID:这标识设备的制造商。

  • 设备 ID:这标识特定的供应商设备。

前面的两个元素可能足够了,但您也可以依赖以下元素:

  • 修订 ID:这指定了设备特定的修订标识符。

  • 类别码:这标识了设备实现的通用功能。

  • 头部类型:这定义了头部的布局。

所有这些参数都可以从设备配置寄存器中读取。这就是内核在枚举总线时用来识别设备的方法。

总线枚举

在深入研究 PCIe 总线枚举功能之前,我们需要处理一些基本限制:

  • 系统上可以有 256 个总线(0-255),因为有 8 位来识别它们。

  • 每个总线可以有 32 个设备(0-31),因为每个总线上有 5 位来识别它们。

  • 一个设备最多可以有 8 个功能(0-7),因此有 3 位来识别它们。

无论外部 PCIe 通道是否来自 CPU,都位于 PCIe 桥后面(因此获得新的 PCIe 总线号)。配置软件能够在给定系统上枚举高达 256 个 PCI 总线。编号 0 总是分配给根复杂。请记住,在总线枚举过程中,只有 PCI-PCI 桥的下游端口(次级端口)会被考虑。

PCI 枚举过程基于深度优先搜索DFS)算法,通常从一个随机节点开始(但在 PCI 枚举的情况下,这个节点是预先知道的,在我们的情况下是 RC),并且在回溯之前尽可能地探索(实际上是寻找桥)每个分支。

这样说,当找到一个桥时,配置软件会为其分配一个号码,至少比这座桥所在的总线号大 1。之后,配置软件开始在这个新总线上寻找新的桥,依此类推,然后回溯到这座桥的兄弟(如果该桥是多端口交换机的一部分)或邻近桥(就拓扑而言)。

枚举的设备使用 BDF 格式进行标识,即总线-设备-功能,使用三个字节 - 也就是XX:YY:ZZ - 用十六进制(不带0x)表示。例如,00:01:03实际上意味着总线0x00:设备0x01:功能0x03。我们可以解释为在总线0上的设备1的功能3。这种表示法有助于快速定位给定拓扑中的设备。如果使用双字节表示法,这意味着功能已被省略或不重要,换句话说,XX:YY

以下图显示了 PCIe 结构的拓扑:

图 11.1 - PCI 总线枚举

图 11.1 - PCI 总线枚举

在我们描述前面的拓扑图之前,请重复以下四个陈述,直到您熟悉它们:

  1. PCI 到 PCI 桥通过创建一个完全独立的次级总线,便于向总线添加更多负载。因此,每个桥下游端口都是一个新的总线,并且必须给予一个总线号,至少比它所在的总线号大 1。

  2. 交换机下游端口是(虚拟的)PCI-PCI(P2P)桥,从内部总线桥接到代表此 PCI Express 交换机的下游 PCI Express 链路的总线。

  3. CPU 通过主机到 PCI 桥与根复杂结构连接,这代表了根复杂结构中的上游桥。

  4. 在总线枚举过程中,只考虑 PCI-PCI 桥的下游端口。

在将枚举算法应用于图表中的拓扑之后,我们可以列出 10 个步骤,从0开始,以及两个桥(因此提供了两个总线),00:00:0000:01:00。以下是在前述拓扑图中枚举过程中步骤的描述,尽管步骤C是标准化枚举逻辑开始的地方:

  • 00:00作为(虚拟)桥,毫无疑问,它的下游端口是一个总线。然后它被分配编号1(记住,它总是大于桥所在的总线号,这种情况下是0)。总线1然后被枚举。

  • 步骤1(一个提供其内部总线的上游虚拟桥和两个暴露其输出总线的下游虚拟桥)。这个交换机的内部总线被赋予编号2

  • 我们立即进入步骤3,在它后面有一个端点(没有下游端口)。根据 DFS 算法的原则,我们到达了这个分支的叶节点,所以我们可以开始回溯。

  • 因此到达步骤4,在它后面有一个设备。回溯可以再次发生。

  • 然后我们到达步骤5。在这个总线后面有一个交换机(一个实现内部总线的上游虚拟桥,其总线号为6,以及3个代表其外部总线的下游虚拟桥,因此这是一个 3 端口交换机)。

  • 步骤7,在这个总线后面有一个端点。如果我们要使用 BDF 格式标识这个端点的功能0,它将是07:00:00(设备0的功能0在总线7上)。回到 DFS 算法,我们已经到达了分支的底部。然后我们可以开始回溯,这将引导我们到步骤H

  • 在步骤8。在这个总线后面有一个 PCIe 到 PCI 桥。

  • 在步骤9下游,并且在这个总线后面有一个 3 功能端点。在 BDF 表示法中,这些将被标识为09:00:0009:00:0109:00:02。由于端点标记了分支的深度,它允许我们执行另一个回溯,这将引导我们到步骤J

  • 在回溯阶段,我们进入步骤10。在这个总线后面有一个端点,它将以 BDF 格式被标识为0a:00:00。这标志着枚举过程的结束。

PCI(e)总线枚举乍看起来可能很复杂,但实际上很简单。阅读前面的材料两次就足以理解整个过程。

PCI 地址空间

PCI 目标可以根据其内容或访问方法实现最多三种不同类型的地址空间。这些是配置地址空间内存地址空间I/O 地址空间。配置和内存地址空间是内存映射的 - 它们被分配了系统地址空间的地址范围,因此对该地址范围的读写不会进入 RAM,而是直接从 CPU 路由到设备,而 I/O 地址空间则不是。不多说了,让我们分析它们之间的区别以及它们不同的用例。

PCI 配置空间

这是设备配置可以访问的地址空间,存储有关设备的基本信息,也被操作系统用于编程设备的操作设置。PCI 上有 256 字节的配置空间。PCIe 将其扩展到 4KB 的寄存器空间。由于配置地址空间是内存映射的,指向配置空间的任何地址都是从系统内存映射中分配的。因此,这 4KB 的空间从系统内存映射中分配内存地址,但实际的值/位/内容通常在外围设备的寄存器中实现。例如,当读取供应商 ID 或设备 ID 时,目标外围设备将返回数据,即使使用的内存地址来自系统内存映射。

该地址空间的一部分是标准化的。配置地址空间分为以下部分:

  • 前 64 字节(00h-3Fh)代表标准配置头,包括 PCI 总线 ID、供应商 ID 和设备 ID 寄存器,用于识别设备。

  • 剩下的 192 字节(40h-FFh)组成用户定义的配置空间,例如特定于 PC 卡的信息,将由其附带的软件驱动程序使用。

一般来说,配置空间存储有关设备的基本信息。它允许中央资源或操作系统使用操作设置对设备进行编程。配置地址空间没有与之关联的物理内存。它是 TLP(事务层数据包)中使用的地址列表,以便识别事务的目标。

用于在每个 PCI 设备的配置地址空间之间传输数据的命令称为配置读命令或配置写命令。

PCI I/O 地址空间

如今,I/O 地址空间用于与 x86 架构的 I/O 端口地址空间兼容。PCIe 规范不鼓励使用此地址空间。如果 PCI Express 规范的未来修订废弃了 I/O 地址空间的使用,这将不足为奇。I/O 映射 I/O 的唯一优势是,由于其单独的地址空间,它不会从系统内存空间中窃取地址范围。因此,计算机可以在 32 位系统上访问整个 4GB 的 RAM。

I/O 读和 I/O 写命令用于在 I/O 地址空间中传输数据。

PCI 内存地址空间

在计算机早期,英特尔定义了通过所谓的 I/O 地址空间访问 I/O 设备寄存器的方法。在那些日子里是有意义的,因为处理器的内存地址空间相当有限(例如,考虑 16 位系统),使用一些范围来访问设备几乎没有意义,甚至没有意义。当系统内存空间不再是约束时(例如,考虑 32 位系统,其中 CPU 可以寻址高达 4GB),I/O 地址空间和内存地址空间之间的分离变得不那么重要,甚至是累赘。

该地址空间上有许多限制和约束,导致 I/O 设备中的寄存器直接映射到系统的内存地址空间,因此称为内存映射 I/O 或 MMIO。这些限制和约束包括以下内容:

  • 专用总线的需求

  • 单独的指令集

  • 由于它是在 16 位系统时代实施的,端口地址空间被限制为 65536 个端口(对应于 216),尽管非常旧的机器使用 10 位进行 I/O 地址空间,并且只有 1024 个唯一的端口地址。

因此,利用内存映射 I/O 的好处变得更加实际。

内存映射 I/O 允许硬件设备通过简单读取或写入这些“特殊”地址来访问,使用正常的内存访问指令,尽管与 65536 相比,解码多达 4GB 的地址(或更多)更昂贵。话虽如此,PCI 设备通过称为 BAR 的窗口公开其内存区域。PCI 设备最多可以有六个 BAR。

BAR 的概念

BAR 代表基址寄存器,是 PCI 的一个概念,设备通过它告诉主机需要多少内存以及其类型。这是内存空间(从系统内存映射中获取),而不是实际的物理 RAM(实际上可以将 RAM 本身看作是一个“专门的内存映射 I/O 设备”,其工作只是保存和返回数据,尽管在今天的现代 CPU 中,具有缓存等功能,这并不是物理上直接的)。将请求的内存空间分配给目标设备是 BIOS 或操作系统的责任。

一旦分配,BAR 被主机系统(CPU)视为内存窗口,用于与设备通信。设备本身不会写入该窗口。这个概念可以看作是一种间接机制,用于访问真正的物理内存,这是 PCI 设备内部和本地的。

实际上,内存的真实物理地址和输入/输出寄存器的地址是内部的,属于 PCI 设备。以下是主机处理外围设备存储空间的方式:

  1. 外围设备通过某种方式告诉系统它有几个存储间隔和 I/O 地址空间,每个间隔有多大,以及它们各自的本地地址。显然,这些地址是本地和内部的,全部从0开始。

  2. 系统软件知道有多少外围设备以及它们具有什么样的存储间隔后,可以为这些间隔分配“物理地址”,并建立这些间隔与总线之间的连接。这些地址是可访问的。显然,这里所说的“物理地址”与真实的物理地址有些不同。实际上,这是一个逻辑地址,因此它经常变成“总线地址”,因为这是 CPU 在总线上看到的地址。可以想象,外围设备必须有某种地址映射机制。所谓的“为外围设备分配地址”是为它们分配总线地址并建立映射。

中断分发

在这里,我们将讨论 PCI 设备处理中断的方式。PCI Express 中有三种中断类型。它们如下:

  • 传统中断,也称为 INTx 中断,在旧 PCI 实现中是唯一可用的机制。

  • MSI(基于消息的中断)扩展了传统机制,例如,通过增加可能的中断数量。

  • MSI-X(扩展的 MSI)扩展和增强了 MSI,例如,通过允许将单个中断定位到不同的处理器(在某些高速网络应用中很有帮助)。

PCI Express 端点中的应用逻辑可以实现上述三种方法中的一种或多种来发出中断信号。让我们详细看看这些。

PCI 传统 INT-X 中断

传统中断管理基于 PCI INT-X 中断线,由最多四根虚拟中断线组成,称为 INTA、INTB、INTC 和 INTD。这些中断线由系统中所有 PCI 设备共享。以下是传统实现必须经历的步骤,以识别和处理中断的概念:

  1. 设备通过其 INT#引脚之一来生成中断。

  2. CPU 确认中断并轮询连接到此 INT#线(共享)的每个设备(实际上是其驱动程序)来调用它们的中断处理程序。服务中断所需的时间取决于共享该线路的设备数量。设备的中断服务例程(ISR)可以通过读取设备的内部寄存器来检查中断是否来自该设备,以识别中断的原因。

  3. 中断服务程序(ISR)采取行动来处理中断。

在前述方法以及传统方法中,中断线是共享的:每个人都会接电话。此外,物理中断线是有限的。在下一节中,我们将看到 MSI 如何解决这些问题并促进中断管理。

重要说明

i.MX6 将 INTA/B/C/D 映射到 ARM GIC IRQ 155/154/153/152,这允许 PCIe 到 PCI 桥正常运行。请参阅 IMX6DQRM.pdf,第 225 页。

基于消息的中断类型 - MSI 和 MSI-X

有两种基于消息的中断机制:MSI 和 MSI-X,增强和扩展版本。MSI(或 MSI-X)只是使用 PCI Express 协议层来发出中断的一种方式,PCIe 根复杂(主机)负责中断 CPU。

传统上,设备被分配为中断线,当它想要向 CPU 发出中断信号时,必须断言这些线。这种信号方法是带外的,因为它使用了另一种方式(与主数据路径不同)来发送这样的控制信息。

然而,MSI 允许设备向特殊的内存映射 I/O 地址写入少量描述中断的数据,然后根复杂负责将相应的中断传递给 CPU。一旦端点设备想要生成 MSI 中断,它就会向消息地址寄存器中指定的地址发出写请求,数据内容指定在消息数据寄存器中。由于数据路径用于此,这是一种带内机制。此外,MSI 增加了可能的中断数量。这将在下一节中描述。

重要说明

PCI Express 根本没有单独的中断引脚。但是,它在软件级别上与传统中断兼容。为此,它需要 MSI 或 MSI-X,因为它使用特殊的带内消息来允许引脚断言或取消断言的模拟。换句话说,PCI Express 通过提供assert_INTxdeassert_INTx来模拟这种能力。消息包通过 PCI Express 串行链路发送。

在使用 MSI 的实现中,通常的步骤如下:

  1. 设备通过发送 MSI 内存写来生成中断。

  2. CPU 确认中断并调用适当的设备 ISR,因为这是根据 MSI 向量事先知道的。

  3. 中断服务程序(ISR)采取行动来处理中断。

MSI 不是共享的,因此分配给设备的 MSI 在系统内是唯一的。不言而喻,MSI 实现显著减少了中断所需的总服务时间。

重要说明

大多数人认为 MSI 允许设备作为中断的一部分向处理器发送数据。这是一个误解。事实是,作为内存写事务的一部分发送的数据是由芯片组(实际上是根复杂)专门用于确定在哪个处理器上触发哪个中断;这些数据不可用于设备向中断处理程序传递附加信息。

MSI 机制

MSI 最初是作为 PCI 2.2 标准的一部分定义的,允许设备分配 1、2、4、8、16 或多达 32 个中断。设备被编程为写入地址以发出中断的信号(通常是中断控制器中的控制寄存器),并且一个 16 位数据字用于识别设备。中断号被添加到数据字中以识别中断。

PCI Express 端点可以通过向根端口发送标准的 PCI Express 发布写数据包来发出 MSI 信号。数据包由特定地址(由主机分配)和主机提供给端点的最多 32 个数据值(因此,32 个中断)组成。不同的数据值和地址值提供了比传统中断更详细的中断事件识别。中断屏蔽功能在 MSI 规范中是可选的。

这种方法确实有一些限制。32 个数据值只使用一个地址,这使得将单个中断定位到不同处理器变得困难。这种限制是因为与 MSI 相关联的内存写操作只能通过它们所针对的地址位置(而不是数据)来区分,这些地址位置由系统保留用于中断传递。

以下是由 PCI 控制器驱动程序执行的 PCI Express 设备的 MSI 配置步骤:

  1. 总线枚举过程发生在启动期间。它包括内核 PCI 核心代码扫描 PCI 总线,以发现设备(换句话说,它为有效的供应商 ID 执行配置读取)。在发现 PCI Express 功能时,PCI 核心代码读取能力列表指针,以获取链式寄存器中第一个能力寄存器的位置。

  2. 然后,PCI 核心代码搜索能力寄存器集。它会一直这样做,直到发现 MSI 能力寄存器集(能力 ID 为05h)。

  3. 之后,PCI 核心代码配置设备,将内存地址分配给设备的消息地址寄存器。这是在传递中断请求时使用的内存写的目的地址。

  4. PCI 核心代码检查设备的消息控制寄存器中的多消息能力字段,以确定设备希望分配给它多少个特定事件的消息。

  5. 然后,核心代码分配与设备请求的数量相等或少于该数量的消息。至少会分配一个消息给设备。

  6. 核心代码将基本消息数据模式写入设备的消息数据寄存器。

  7. 最后,PCI 核心代码在设备的消息控制寄存器中设置 MSI 使能位,从而使其能够使用 MSI 内存写生成中断。

MSI-X 机制

2048地址和数据对。由于每个端点可用的地址值数量很大,因此可以将 MSI-X 消息路由到系统中的不同中断消费者,而不像 MSI 数据包只有单个地址可用。此外,具有 MSI-X 功能的端点还包括应用逻辑来屏蔽和保持未决中断,以及用于地址和数据对的内存表。

除上述之外,MSI-X 中断与 MSI 相同。但是,MSI 中的可选功能(如 64 位寻址和中断屏蔽)在 MSI-X 中变为强制性。

传统 INTx 模拟

因为 PCIe 声称与传统的并行 PCI 向后兼容,所以它还需要支持基于 INTx 的中断机制。但是这该如何实现呢?实际上,在经典 PCI 系统中有四条 INTx(INTA、INTB、INTC 和 INTD)物理 IRQ 线,它们都是电平触发的,实际上是低电平(换句话说,只要物理 INTx 线处于低电压,中断请求就是活动的)。那么在模拟版本中如何传输每个 IRQ 呢?

答案是 PCIe 通过使用一种称为 MSI 的带内信号机制来虚拟化 PCI 物理中断信号。由于每个物理线有两个级别(断开和断开),PCIe 为每条线提供两个消息,称为assert_INTxdeassert_INTx消息。总共有八种消息类型:assert_INTAdeassert_INTA,... assert_INTDdeassert_INTD。实际上,它们简单地被称为 INTx 消息。这样,INTx 中断就像 MSI 和 MSI-X 一样在 PCIe 链路上传播。

这种向后兼容性主要存在于 PCI 到 PCIe 桥接芯片,以便 PCI 设备可以在 PCIe 系统中正常工作,而无需修改驱动程序。

现在我们熟悉了 PCI 子系统中的中断分发。我们已经涵盖了传统的基于 INT-X 的机制和基于消息的机制。现在是时候深入代码,从数据结构到 API。

Linux 内核 PCI 子系统和数据结构

Linux 内核支持 PCI 标准,并提供处理此类设备的 API。在 Linux 中,PCI 实现可以大致分为以下主要组件:

  • arch/arm/kernel/bios32.c。PCI BIOS 代码与 PCI 主机控制器代码以及 PCI 核心接口,以执行总线枚举和资源分配,如内存和中断。

BIOS 执行成功保证了系统中所有 PCI 设备被分配了可用 PCI 资源的部分,并且它们各自的驱动程序(称为从属或终端点驱动程序)可以利用 PCI 核心提供的设施来控制它们。

在这里,内核调用架构和特定于板的 PCI 功能的服务。PCI 配置的两个重要任务在这里完成。第一个任务是扫描总线上的所有 PCI 设备,对它们进行配置,并分配内存资源。第二个任务是配置设备。这里的配置意味着已经保留了资源(内存)并分配了 IRQ。这并不意味着初始化。初始化是特定于设备的,应该由设备驱动程序完成。PCI BIOS 可以选择跳过资源分配(例如,在 Linux 引导之前已经分配了资源,例如在 PC 场景中)。

  • drivers/pci/host/,换句话说,drivers/pci/controller/pcie-rcar.c适用于 r-car SoCs)。然而,一些 SoCs 可能会实现来自特定供应商的相同 PCIe IP 块,比如 Synopsys DesignWare。这样的控制器可以在同一目录中找到,比如内核源代码中的drivers/pci/controller/dwc/。例如,i.MX6 的 PCIe IP 块来自这个供应商,其驱动程序实现在drivers/pci/controller/dwc/pci-imx6.c中。这部分处理 SoC(有时也是板)特定的初始化和配置,并可能调用 PCI BIOS。然而,它应该提供 PCI 总线访问和为 BIOS 以及 PCI 核心提供回调函数,这些函数将在 PCI 系统初始化期间和访问 PCI 总线进行配置周期时被调用。此外,它提供可用内存/IO 空间、INTx 中断线和 MSI 的资源信息。它应该便于 IO 空间访问(如果支持),并且可能还需要提供间接内存访问(如果硬件支持)。

  • drivers/pci/probe.c:这个文件负责在系统中创建和初始化总线、设备以及桥接器的数据结构树。它处理总线/设备编号。它创建设备条目并提供proc/sysfs信息。它还为 PCI BIOS 和从属(终端点)驱动程序提供服务,并可选地支持热插拔(如果硬件支持)。它针对(EP)驱动程序接口查询并初始化枚举期间发现的相应设备。它还提供了 MSI 中断处理框架和 PCI Express 端口总线支持。以上所有内容足以促进 Linux 内核中设备驱动程序的开发。

PCI 数据结构

Linux 内核 PCI 框架有助于 PCI 设备驱动程序的开发,这些驱动程序建立在两个主要数据结构的基础上:struct pci_dev代表内核中的 PCI 设备,struct pci_driver代表 PCI 驱动程序。

结构体 pci_dev

这是内核在系统上实例化每个 PCI 设备的结构。它描述了设备并存储了一些状态参数。该结构在include/linux/pci.h中定义如下:

struct pci_dev {
  struct pci_bus    *bus; /* Bus this device is on */
  struct pci_bus *subordinate; /* Bus this device bridges to */
    struct proc_dir_entry *procent;
    struct pci_slot		*slot;
    unsigned short	vendor;
    unsigned short	device;
    unsigned short	subsystem_vendor;
    unsigned short	subsystem_device;
    unsigned int		class;
   /* 3 bytes: (base,sub,prog-if) */
   u8 revision;     /* PCI revision, low byte of class word */
   u8 hdr_type; /* PCI header type (multi' flag masked out) */
   u8 pin;                /* Interrupt pin this device uses */
   struct pci_driver *driver; /* Driver bound to this device */
   u64	dma_mask;
   struct device_dma_parameters dma_parms;
    struct device		dev;
    int	cfg_size;
    unsigned int	irq;
[...]
    unsigned int		no_msi:1;	/* May not use MSI */
    unsigned int no_64bit_msi:1; /* May only use 32-bit MSIs */
    unsigned int msi_enabled:1;
    unsigned int msix_enabled:1;     atomic_t enable_cnt;
[...]
};

在前面的块中,为了可读性,已删除了一些元素。对于剩下的元素,以下元素具有以下含义:

  • procent/proc/bus/pci/中的设备条目。

  • slot是设备所在的物理插槽。

  • vendor是设备制造商的供应商 ID。PCI 特别兴趣组织维护了这些数字的全球注册表,制造商必须申请分配给他们一个唯一的编号。此 ID 存储在设备配置空间中的一个 16 位寄存器中。

  • device是一旦被探测出来就能识别此特定设备的 ID。这是与供应商相关的,没有官方注册表。这也存储在一个 16 位寄存器中。

  • subsystem_vendorsubsystem_device指定了 PCI 子系统供应商和子系统设备 ID。它们可以用于进一步识别设备,就像我们之前看到的那样。

  • class标识了设备所属的类。它存储在一个 16 位寄存器中(在设备配置空间中),其高 8 位标识基类或组。

  • pin是设备使用的中断引脚,在传统的基于 INTx 的中断情况下。

  • driver是与此设备关联的驱动程序。

  • dev是此 PCI 设备的基础设备结构。

  • cfg_size是配置空间的大小。

  • irq是值得花时间研究的字段。当设备启动时,MSI(-X)模式未启用,并且直到通过pci_alloc_irq_vectors()API(旧驱动程序使用pci_enable_msi())显式启用为止,它保持不变。

因此,irq首先对应于默认预分配的非 MSI IRQ。但是,根据以下情况之一,它的值或使用可能会发生变化:

a) 在 MSI 中断模式下(成功调用pci_alloc_irq_vectors()并设置了PCI_IRQ_MSI标志),此字段的(预分配)值将被新的 MSI 向量替换。该向量对应于分配向量的基本中断号,因此与向量 X(从 0 开始的索引)对应的 IRQ 号等同于(与)pci_dev->irq + X(参见pci_irq_vector()函数,旨在返回设备向量的 Linux IRQ 号)。

b) 在 MSI-X 中断模式下(成功调用pci_alloc_irq_vectors()并设置了PCI_IRQ_MSIX标志),此字段的(预分配)值不变(因为每个 MSI-X 向量都有其专用的消息地址和消息数据对,这不需要 1:1 的向量到条目映射)。但是,在此模式下,irq是无效的。在驱动程序中使用它来请求服务中断可能导致不可预测的行为。因此,如果需要 MSI(-X),则应在驱动程序调用devm_equest_irq()之前调用pci_alloc_irq_vectors()函数(该函数在分配向量之前启用 MXI(-X)),因为 MSI(-X)通过与基于引脚的中断向量不同的向量传递。

  • msi_enabled保存了 MSI IRQ 模式的启用状态。

  • msix_enabled保存了 MSI-X IRQ 模式的启用状态。

  • enable_cnt保存了pci_enable_device()被调用的次数。这有助于在所有pci_enable_device()的调用者都调用了pci_disable_device()之后才真正禁用设备。

结构体 pci_device_id

虽然struct pci_dev描述了设备,struct pci_device_id旨在标识设备。该结构定义如下:

struct pci_device_id {
    u32 vendor, device;
    u32 subvendor, subdevice;
    u32 class, class_mask;
    kernel_ulong_t driver_data;
};

要了解此结构对于 PCI 驱动程序的重要性,让我们描述其每个元素:

  • vendordevice分别表示设备的供应商 ID 和设备 ID。两者配对以形成设备的唯一 32 位标识符。驱动程序依赖于这个 32 位标识符来识别其设备。

  • subvendorsubdevice表示子系统 ID。

  • classclass_mask是与类相关的 PCI 驱动程序,旨在处理给定类的每个设备。对于这样的驱动程序,应将vendordevice设置为PCI_ANY_ID。PCI 设备的不同类别在 PCI 规范中有描述。这两个值允许驱动程序指定它支持的 PCI 类设备的类型。

  • driver_data是驱动程序私有的数据。此字段不用于标识设备,而是用于传递不同的数据以区分设备。

有三个宏允许您创建struct pci_device_id的特定实例。

  • PCI_DEVICE:此宏用于通过创建一个struct pci_device_id来描述具有供应商和设备 ID 的特定 PCI 设备(PCI_DEVICE(vend,dev)),并将子供应商、子设备和与类相关的字段设置为PCI_ANY_ID

  • PCI_DEVICE_CLASS:此宏用于通过创建一个struct pci_device_id来描述特定的 PCI 设备类,该类与给定的classclass_mask参数匹配(PCI_DEVICE_CLASS(dev_class,dev_class_mask))。供应商、设备、子供应商和子设备字段将设置为PCI_ANY_ID。典型示例是PCI_DEVICE_CLASS(PCI_CLASS_STORAGE_EXPRESS, 0xffffff),它对应于 NVMe 设备的 PCI 类,并且无论供应商和设备 ID 是什么,都将匹配任何这些设备。

  • PCI_DEVICE_SUB:此宏用于通过创建一个struct pci_device_id来描述具有子系统的特定 PCI 设备,其中子系统信息作为参数给出(PCI_DEVICE_SUB(vend, dev, subvend, subdev))。

驱动程序支持的每个设备/类别都应该被放入同一个数组以供以后使用(我们将在两个地方使用它),如以下示例所示:

static const struct pci_device_id bt8xxgpio_pci_tbl[] = {
  { PCI_DEVICE(PCI_VENDOR_ID_BROOKTREE, PCI_DEVICE_ID_BT848) },
  { PCI_DEVICE(PCI_VENDOR_ID_BROOKTREE, PCI_DEVICE_ID_BT849) },
  { PCI_DEVICE(PCI_VENDOR_ID_BROOKTREE, PCI_DEVICE_ID_BT878) },
  { PCI_DEVICE(PCI_VENDOR_ID_BROOKTREE, PCI_DEVICE_ID_BT879) },
  { 0, },
};

每个pci_device_id结构都需要导出到用户空间,以便让热插拔和设备管理器(udevmdev等)知道哪个驱动程序适用于哪个设备。将它们全部放入同一个数组的第一个原因是它们可以一次性导出。为了实现这一点,您应该使用MODULE_DEVICE_TABLE宏,如以下示例所示:

MODULE_DEVICE_TABLE(pci, bt8xxgpio_pci_tbl);

此宏创建一个具有给定信息的自定义部分。在编译时,构建过程(更精确地说是depmod)从驱动程序中提取这些信息,并构建一个名为modules.alias的人类可读表,位于/lib/modules/<kernel_version>/目录中。当内核告诉热插拔系统有新设备可用时,热插拔系统将参考modules.alias文件找到适当的驱动程序进行加载。

struct pci_driver

此结构表示 PCI 设备驱动程序的一个实例,无论它是什么,属于什么子系统。这是每个 PCI 驱动程序必须创建和填充的主要结构,以便能够将它们注册到内核中。struct pci_driver定义如下:

struct pci_driver {
   const char *name;
   const struct pci_device_id *id_table; int (*probe)(struct                                                   pci_dev *dev,
   const struct pci_device_id *id); void (*remove)(struct                                                 pci_dev *dev);
   int (*suspend)(struct pci_dev *dev, pm_message_t state);   int (*resume) (struct pci_dev *dev);	/* Device woken up */
   void (*shutdown) (struct pci_dev *dev); [...]
};

此结构中的元素部分已被删除,因为它们对我们没有兴趣。以下是结构中剩余字段的含义:

  • name: 这是驱动程序的名称。由于驱动程序是通过其名称标识的,它必须在内核中所有 PCI 驱动程序中是唯一的。通常将此字段设置为与驱动程序的模块名称相同的名称。如果在相同的子系统总线中已经注册了具有相同名称的驱动程序,则您的驱动程序的注册将失败。要了解其内部工作原理,请查看elixir.bootlin.com/linux/v4.19/source/drivers/base/driver.c#L146中的driver_register()

  • id_table: 这应该指向先前描述的struct pci_device_id表。这是驱动程序中使用此结构的第二个也是最后一个地方。对于调用探测函数,它必须是非 NULL 的。

  • 探测: 这是驱动程序的probe函数的指针。当 PCI 设备与驱动程序中的id_table中的条目匹配(通过供应商/产品 ID 或类 ID),PCI 核心会调用它。如果该方法成功初始化设备,则应返回0,否则返回负错误值。

  • remove: 当此驱动程序处理的设备从系统中移除(从总线上消失)或驱动程序从内核中卸载时,PCI 核心会调用此函数。

  • suspendresumeshutdown: 这些是可选但建议的电源管理函数。在这些回调中,您可以使用与 PCI 相关的电源管理助手,如pci_save_state()pci_restore_state()pci_disable_device()pci_enable_device()pci_set_power_state()pci_choose_state()。这些回调分别由 PCI 核心调用:

  • 当设备被挂起时,此时状态作为回调的参数给出。

  • 当设备被恢复时。这可能仅在调用suspend后发生。

  • 为了设备的正确关闭。

以下是初始化 PCI 驱动程序结构的示例:

static struct pci_driver bt8xxgpio_pci_driver = {
    .name		= "bt8xxgpio",
    .id_table	= bt8xxgpio_pci_tbl,
    .probe		= bt8xxgpio_probe,
    .remove	= bt8xxgpio_remove,
    .suspend	= bt8xxgpio_suspend,
    .resume	= bt8xxgpio_resume,
};

注册 PCI 驱动程序

通过调用pci_register_driver()注册 PCI 核心的 PCI 驱动程序,给定一个指向先前设置的struct pci_driver结构的参数。这应该在模块的init方法中完成,如下所示:

static int init pci_foo_init(void)
{
    return pci_register_driver(&bt8xxgpio_pci_driver);
}

pci_register_driver()在注册成功时返回0,否则返回负错误值。这个返回值由内核处理。

然而,在模块卸载路径上,需要注销struct pci_driver,以防系统尝试使用对应模块已不存在的驱动程序。因此,卸载 PCI 驱动程序需要调用pci_unregister_driver(),并且指向与注册相同的结构体指针,如下所示。这应该在模块的exit函数中完成:

static void exit pci_foo_exit(void)
{
    pci_unregister_driver(&bt8xxgpio_pci_driver);
}

也就是说,由于这些操作在 PCI 驱动程序中经常重复,PCI 核心暴露了module_pci_macro()宏,以便自动处理注册/注销,如下所示:

module_pci_driver(bt8xxgpio_pci_driver);

这个宏更安全,因为它负责注册和注销,防止一些开发人员提供一个并忘记另一个。

现在我们熟悉了最重要的 PCI 数据结构 - struct pci_devpci_device_idpci_driver,以及处理这些数据结构的连字符助手。逻辑的延续是驱动程序结构,在其中我们学习如何以及在哪里使用先前列举的数据结构。

PCI 驱动程序结构概述

在编写 PCI 设备驱动程序时,需要遵循一些步骤,其中一些需要按预定义的顺序执行。在这里,我们试图详细讨论每个步骤,解释适用的细节。

启用设备

在对 PCI 设备执行任何操作之前(即使只是读取其配置寄存器),必须启用此 PCI 设备,而且必须由代码显式地执行此操作。内核提供了pci_enable_device()来实现这一目的。此函数初始化设备,以便驱动程序可以使用它,并要求低级代码启用 I/O 和内存。它还处理 PCI 电源管理唤醒,以便如果设备被挂起,它也将被唤醒。以下是pci_enable_device()的样子:

int pci_enable_device(struct pci_dev *dev)

由于pci_enable_device()可能失败,因此必须检查其返回值,如以下示例所示:

int err;
    err = pci_enable_device(pci_dev);     if (err) {
    printk(KERN_ERR "foo_dev: Can't enable device.\n");
    return err;
}

请记住,pci_enable_device()将初始化内存映射和 I/O BARs。但是,您可能只想初始化其中一个,而不是另一个,要么是因为您的设备不支持两者,要么是因为您在驱动程序中不会同时使用两者。

为了不初始化 I/O 空间,可以使用启用方法的另一个变体pci_enable_device_mem()。另一方面,如果只需要处理 I/O 空间,可以使用pci_enable_device_io()变体。两种变体之间的区别在于,pci_enable_device_mem()将仅初始化内存映射 BARs,而pci_enable_device_io()将初始化 I/O BARs。请注意,如果设备启用了多次,每次操作都将增加struct pci_dev结构中的.enable_cnt字段,但只有第一次操作才会真正影响设备。

当要禁用 PCI 设备时,应该采用pci_disable_device()方法,无论您使用的是哪种启用变体。此方法向系统发出信号,表明 PCI 设备不再被系统使用。以下是其原型:

void pci_disable_device(struct pci_dev *dev)

pci_disable_device()还会在设备上禁用总线主控(如果激活)。但是,只有在pci_enable_device()(或其变体之一)的所有调用者都调用了pci_disable_device()之后,设备才会被禁用。

总线主控能力

PCI 设备可以根据定义在总线上启动事务,即在成为总线主控的那一刻。在启用设备之后,您可能希望启用总线主控。

实际上是通过在适当的配置寄存器中设置总线主控位来启用设备中的 DMA。PCI 核心提供了pci_set_master()来实现这一目的。此方法还调用pci_bios(实际上是 pcibios_set_master())以执行必要的特定于体系结构的设置。pci_clear_master()将通过清除总线主控位来禁用 DMA。这是相反的操作:

void pci_set_master(struct pci_dev *dev)
void pci_clear_master(struct pci_dev *dev)

请注意,如果设备打算执行 DMA 操作,则必须调用pci_set_master()

访问配置寄存器

一旦设备绑定到驱动程序并由驱动程序启用后,通常会访问设备内存空间。通常首先访问的是配置空间。传统 PCI 和 PCI-X 模式 1 设备有 256 字节的配置空间。PCI-X 模式 2 和 PCIe 设备有 4096 字节的配置空间。对于驱动程序能够访问设备配置空间至关重要,无论是为了读取对驱动程序的正常操作必不可少的信息,还是为了设置一些重要的参数。内核为不同大小的数据配置空间提供了标准和专用的 API(读取和写入)。

为了从设备配置空间中读取数据,可以使用以下原语:

int pci_read_config_byte(struct pci_dev *dev, int where,                          u8 *val);
int pci_read_config_word(struct pci_dev *dev, int where,                          u16 *val);
int pci_read_config_dword(struct pci_dev *dev, int where,                           u32 *val);

上述原语分别作为一个、两个或四个字节在此处由dev参数表示的 PCI 设备的配置空间中读取。read值返回到val参数。在写入数据到设备配置空间时,可以使用以下原语:

int pci_write_config_byte(struct pci_dev *dev, int where,                           u8 val);
int pci_write_config_word(struct pci_dev *dev, int where,                           u16 val);
int pci_write_config_dword(struct pci_dev *dev, int where,                            u32 val);

上述原语分别将一个、两个或四个字节写入设备配置空间。val参数表示要写入的值。

在读取或写入情况下,where参数是从配置空间开头的字节偏移量。但是,在内核中存在一些常用的配置偏移量,由include/uapi/linux/pci_regs.h中定义的符号命名的宏标识。以下是一个简短的摘录:

#define	PCI_VENDOR_ID	0x00	/*	16	bits	*/
#define	PCI_DEVICE_ID	0x02	/*	16	bits	*/
#define	PCI_STATUS		0x06	/*	16	bits	*/
#define PCI_CLASS_REVISION  0x08  /* High 24 bits are class,                                      low 8 revision */
#define    PCI_REVISION_ID   0x08  /* Revision ID */
#define    PCI_CLASS_PROG    0x09  /* Reg. Level Programming                                       Interface */
#define    PCI_CLASS_DEVICE  0x0a  /* Device class */
[...]	

因此,要获取给定 PCI 设备的修订 ID,可以使用以下示例:

static unsigned char foo_get_revision(struct pci_dev *dev)
{
    u8 revision;
    pci_read_config_byte(dev, PCI_REVISION_ID, &revision);
    return revision;
}

在上面的例子中,我们使用了pci_read_config_byte(),因为修订仅由一个字节表示。

重要提示

由于数据以小端格式存储在(和从)PCI 设备中,读取原语(实际上是worddword变体)负责将读取的数据转换为 CPU 的本机字节顺序,并且写入原语(worddword变体)负责在将数据写入设备之前将数据从本机 CPU 字节顺序转换为小端格式。

访问内存映射 I/O 资源

内存寄存器用于几乎所有其他事情,例如用于突发事务。这些寄存器实际上对应于设备内存 BAR。然后,为系统地址空间中的每个寄存器分配一个内存区域,以便将对这些区域的任何访问重定向到相应的设备,从而针对与 BAR 对应的正确本地(在设备中)内存。这就是内存映射 I/O。

在 Linux 内核内存映射 I/O 世界中,通常在创建映射之前请求(实际上是声明)内存区域是很常见的。您可以使用request_mem_region()ioremap()原语来实现这两个目的。以下是它们的原型:

struct resource *request_mem_region (unsigned long start,
                                     unsigned long n,                                      const char *name)
void iomem *ioremap(unsigned long phys_addr,                     unsigned long size);

request_mem_region()是一个纯预留机制,不执行任何映射。它依赖于其他驱动程序应该礼貌并应该在他们的轮次上调用request_mem_region(),这将防止另一个驱动程序重叠已经被声明的内存区域。除非此调用成功返回,否则不应映射或访问声明的区域。在其参数中,name表示要赋予资源的名称,start表示应为其创建映射的地址,n表示映射应有多大。要获取给定 BAR 的此信息,可以使用pci_resource_start()pci_resource_len(),甚至pci_resource_end(),其原型如下:

  • unsigned long pci_resource_start (struct pci_dev *dev, int bar): 此函数返回与索引为 bar 的 BAR 关联的第一个地址(内存地址或 I/O 端口号)。

  • unsigned long pci_resource_len (struct pci_dev *dev, int bar): 此函数返回 BAR bar的大小。

  • unsigned long pci_resource_end (struct pci_dev *dev, int bar): 此函数返回作为 I/O 区域编号bar的一部分的最后地址。

  • unsigned long pci_resource_flags (struct pci_dev *dev, int bar): 此函数不仅与内存资源 BAR 相关。它实际上返回与此资源关联的标志。IORESOURCE_IO表示 BAR bar是 I/O 资源(因此适用于 I/O 映射 I/O),而IORESOURCE_MEM表示它是内存资源(用于内存映射 I/O)。

另一方面,ioremap()确实创建了实际映射,并返回映射区域上的内存映射 I/O cookie。例如,以下代码显示了如何映射给定设备的bar0

unsigned long bar0_base; unsigned long bar0_size;
void iomem *bar0_map_membase;
/* Get the PCI Base Address Registers */
bar0_base = pci_resource_start(pdev, 0);
bar0_size = pci_resource_len(pdev, 0);
/*  * think about managed version and use  * devm_request_mem_regions()	 */
if (request_mem_region(bar0_base, bar0_size, "bar0-mapping")) {
    /* there is an error */
    goto err_disable;
}
/* Think about managed version and use devm_ioremap instead */ bar0_map_membase = ioremap(bar0_base, bar0_size);
if (!bar0_map_membase) {
    /* error */
    goto err_iomap;
}
/* Now we can use ioread32()/iowrite32() on bar0_map_membase*/

前面的代码运行良好,但很繁琐,因为我们需要为每个 BAR 执行此操作。实际上,request_mem_region()ioremap()是非常基本的原语。PCI 框架提供了许多与 PCI 相关的函数,以便简化这些常见任务:

int pci_request_region(struct pci_dev *pdev, int bar,
                       const char *res_name)
int pci_request_regions(struct pci_dev *pdev,                         const char *res_name)
void iomem *pci_iomap(struct pci_dev *dev, int bar,
                      unsigned long maxlen)
void iomem *pci_iomap_range(struct pci_dev *dev, int bar,
                           unsigned long offset,                            unsigned long maxlen)
void iomem *pci_ioremap_bar(struct pci_dev *pdev, int bar)
void pci_iounmap(struct pci_dev *dev, void iomem *addr)
void pci_release_regions(struct pci_dev *pdev)

前面的辅助程序可以描述如下:

  • pci_request_regions()标记与pdev PCI 设备关联的所有 PCI 区域为所有者res_name所保留。在其参数中,pdev是要保留其资源的 PCI 设备,res_name是要与资源关联的名称。另一方面,pci_request_region()针对由bar参数标识的单个 BAR。

  • pci_iomap()为 BAR 创建映射。您可以使用ioread*()iowrite*()来访问它。maxlen指定要映射的最大长度。如果要在不先检查其长度的情况下访问完整的 BAR,请在此处传递0

  • pci_iomap_range()从 BAR 的偏移开始创建映射。生成的映射从offset开始,宽度为maxlenmaxlen指定要映射的最大长度。如果要从offset到结尾访问完整的 BAR,请在此处传递0

  • pci_ioremap_bar()提供了一种无误巧的方式(相对于pci_ioremap())来执行 PCI 内存重映射。它确保 BAR 实际上是一个内存资源,而不是一个 I/O 资源。然而,它映射整个 BAR 大小。

  • pci_iounmap()pci_iomap()的相反操作,用于取消映射。它的addr参数对应于先前由pci_iomap()返回的 cookie。

  • pci_release_regions()pci_request_regions()的相反操作。它释放先前声明(保留)的 PCI I/O 和内存资源。pci_release_region()针对单个 BAR 变体。

使用这些辅助程序,我们可以重新编写与 BAR1 相同的代码。这将如下所示:

#define DRV_NAME "foo-drv"
void iomem *bar1_map_membase;
int err;
err = pci_request_regions(pci_dev, DRV_NAME);
if (err) {
    /* an error occured */ goto error;
}
bar1_map_membase = pci_iomap(pdev, 1, 0);
if (!bar1_map_membase) {
    /* an error occured */
    goto err_iomap;
}

在内存区域被声明和映射之后,提供平台抽象的ioread*()iowrite*()API 访问映射的寄存器。

访问 I/O 端口资源

I/O 端口访问需要经过与 I/O 内存相同的步骤,尽管底层机制不同:请求 I/O 区域,映射 I/O 区域(这不是强制性的,这只是一种礼貌),并访问 I/O 区域。

前两个步骤已经在您不知不觉中得到了解决。实际上,pci_requestregion*()原语处理 I/O 端口和 I/O 内存。它依赖于资源标志(pci_resource_flags())以便调用适当的低级辅助程序((request_region())用于 I/O 端口或request_mem_region()用于 I/O 内存:

unsigned long flags = pci_resource_flags(pci_dev, bar);
if (flags & IORESOURCE_IO)
    /* using request_region() */
else if (flag & IORESOURCE_MEM)
    /* using request_mem_region() */

因此,无论资源是 I/O 内存还是 I/O 端口,您都可以安全地使用pci_request_regions()或其单个 BAR 变体pci_request_region()

同样适用于 I/O 端口映射。pci_iomap*()原语能够处理 I/O 端口或 I/O 内存。它们也依赖于资源标志,并调用适当的辅助程序来创建映射。根据资源类型,底层映射函数是ioremap()用于 I/O 内存,这是IORESOURCE_MEM类型的资源,以及__pci_ioport_map()用于 I/O 端口,对应于IORESOURCE_IO类型的资源。__pci_ioport_map()是一个与体系结构相关的函数(实际上被 MIPS 和 SH 体系结构覆盖),大多数情况下对应于ioport_map()

要确认我们刚才说的话,我们可以看一下pci_iomap_range()函数的主体,pci_iomap()依赖于它:

void iomem *pci_iomap_range(struct pci_dev *dev, int bar,
                            unsigned long offset,                             unsigned long maxlen)
{
    resource_size_t start = pci_resource_start(dev, bar);
    resource_size_t len = pci_resource_len(dev, bar);
    unsigned long flags = pci_resource_flags(dev, bar);
    if (len <= offset || !start)
        return NULL;
    len -= offset; start += offset;
    if (maxlen && len > maxlen)
        len = maxlen;
    if (flags & IORESOURCE_IO)
        return pci_ioport_map(dev, start, len);
    if (flags & IORESOURCE_MEM)
        return ioremap(start, len);
    /* What? */
    return NULL;
}

然而,当涉及访问 I/O 端口时,API 完全改变。以下是用于访问 I/O 端口的辅助程序。这些函数隐藏了底层映射的细节和它们的类型。以下列出了内核提供的用于访问 I/O 端口的函数:

u8 inb(unsigned long port);
u16 inw(unsigned long port);
u32 inl(unsigned long port);
void outb(u8 value, unsigned long port);
void outw(u16 value, unsigned long port);
void outl(u32 value, unsigned long port);

在前面的摘录中,in*()系列从port位置分别读取一个、两个或四个字节。获取的数据由一个值返回。另一方面,out*()系列将一个、两个或四个字节写入到port位置中的value参数中。

处理中断

需要为设备服务中断的驱动程序首先需要请求这些中断。通常在probe()方法中请求中断是很常见的。也就是说,为了处理传统和非 MSI IRQ,驱动程序可以直接使用pci_dev->irq字段,这在设备被探测时就预先分配好了。

然而,对于更通用的方法,建议使用pci_alloc_irq_vectors() API。此函数定义如下:

int pci_alloc_irq_vectors(struct pci_dev *dev,                           unsigned int min_vecs,
                          unsigned int max_vecs,                           unsigned int flags);

如果成功,上述函数将返回分配的向量数(如果成功可能小于max_vecs),或者在出现错误时返回负错误代码。分配的向量数始终至少达到min_vecs。如果对于dev来说少于min_vecs个中断向量是可用的,函数将以-ENOSPC失败。

这个函数的优点是它可以处理传统中断和 MSI 或 MSI-X 中断。根据flags参数,驱动程序可以指示 PCI 层为此设备设置 MSI 或 MSI-X 功能。此参数用于指定设备和驱动程序使用的中断类型。可能的标志在include/linux/pci.h中定义:

  • PCI_IRQ_LEGACY:一个传统的 IRQ 向量。

  • PCI_IRQ_MSI:在成功路径上,pci_dev->msi_enabled设置为1

  • PCI_IRQ_MSIX:在成功路径上,pci_dev->msix_enabled设置为1

  • PCI_IRQ_ALL_TYPES:这允许尝试分配上述任何一种中断,但按固定顺序。总是首先尝试 MSI-X 模式,并在成功时立即返回。如果 MSI-X 失败,则尝试 MSI。如果 MSI-X 和 MSI 都失败,则使用传统模式作为后备。驱动程序可以依赖于pci_dev->msi_enabledpci_dev->msix_enabled来确定哪种模式成功。

  • PCI_IRQ_AFFINITY:这允许关联自动分配。如果设置,pci_alloc_irq_vectors()将在可用的 CPU 周围分配中断。

要获取要传递给request_irq()free_irq()的 Linux IRQ 编号,对应于一个向量,请使用以下函数:

int pci_irq_vector(struct pci_dev *dev, unsigned int nr);

在上述中,dev是要操作的 PCI 设备,nr是设备相关的中断向量索引(从 0 开始)。现在让我们更仔细地看看这个函数是如何工作的:

int pci_irq_vector(struct pci_dev *dev, unsigned int nr)
{
    if (dev->msix_enabled) {
        struct msi_desc *entry;
        int i = 0;
        for_each_pci_msi_entry(entry, dev) {
            if (i == nr)
                return entry->irq;
            i++;
        }
        WARN_ON_ONCE(1);
        return -EINVAL;
    }
    if (dev->msi_enabled) {
        struct msi_desc *entry = first_pci_msi_entry(dev);
        if (WARN_ON_ONCE(nr >= entry->nvec_used))
            return -EINVAL;
    } else {
        if (WARN_ON_ONCE(nr > 0))
            return -EINVAL;
    }
    return dev->irq + nr;
}

在上述摘录中,我们可以看到 MSI-X 是第一次尝试(if (dev->msix_enabled))。此外,返回的 IRQ 与设备探测时预分配的pci_dev->irq没有任何关系。但是如果启用了 MSI(dev->msi_enabled为真),那么这个函数将执行一些合理性检查,并返回dev->irq + nr。这证实了在 MSI 模式下操作时,pci_dev->irq被替换为一个新值,这个新值对应于分配的 MSI 向量的基本中断编号。最后,您会注意到在传统模式下没有特殊检查。

实际上,在传统模式下,预分配的pci_dev->irq保持不变,只有一个分配的向量。因此,在传统模式下操作时,nr应该是0。在这种情况下,返回的向量什么都不是,只是dev->irq

一些设备可能不支持使用传统线中断,这种情况下,驱动程序可以指定只接受 MSI 或 MSI-X:

nvec =
    pci_alloc_irq_vectors(pdev, 1, nvec,                           PCI_IRQ_MSI | PCI_IRQ_MSIX);
if (nvec < 0)
    goto out_err;

重要提示

请注意,MSI/MSI-X 和传统中断是互斥的,参考设计默认支持传统中断。一旦在设备上启用了 MSI 或 MSI-X 中断,它将一直保持在这种模式,直到再次被禁用。

传统 INTx IRQ 分配

PCI 总线类型(struct bus_type pci_bus_type)的探测方法是pci_device_probe(),实现在drivers/pci/pci-driver.c中。每当新的 PCI 设备添加到总线上或者新的 PCI 驱动程序注册到系统时,都会调用这个方法。这个函数调用pci_assign_irq(pci_dev),然后调用pcibios_alloc_irq(pci_dev)来为 PCI 设备分配一个 IRQ,即著名的pci_dev->irq。技巧开始在pci_assign_irq()中发生。pci_assign_irq()读取 PCI 设备连接的引脚,如下:

u8 pin;
pci_read_config_byte(dev, PCI_INTERRUPT_PIN, &pin);
/* (1=INTA, 2=INTB, 3=INTD, 4=INTD) */

接下来的步骤依赖于 PCI 主机桥,其驱动程序应该公开一些回调,包括一个特殊的回调.map_irq,其目的是根据设备的插槽和先前读取的引脚为设备创建 IRQ 映射:

void pci_assign_irq(struct pci_dev *dev)
{
    int irq = 0; u8 pin;
    struct pci_host_bridge *hbrg =                pci_find_host_bridge(dev->bus);
    if (!(hbrg->map_irq)) {
    pci_dbg(dev, "runtime IRQ mapping not provided by arch\n");
         return;
    }
    pci_read_config_byte(dev, PCI_INTERRUPT_PIN, &pin);
    if (pin) {
        [...]
        irq = (*(hbrg->map_irq))(dev, slot, pin);
        if (irq == -1)
            irq = 0;
    }
    dev->irq = irq;
    pci_dbg(dev, "assign IRQ: got %d\n", dev->irq);
    /* Always tell the device, so the driver knows what is the
     * real IRQ to use; the device does not use it.      */
    pci_write_config_byte(dev, PCI_INTERRUPT_LINE, irq);
}

这是设备探测期间 IRQ 的第一个分配。回到pci_device_probe()函数,pci_assign_irq()之后调用的下一个方法是pcibios_alloc_irq()。然而,pcibios_alloc_irq()被定义为一个弱函数,只有 AArch64 架构才覆盖,位于arch/arm64/kernel/pci.c中,并且依赖于 ACPI(如果启用)来修改分配的 IRQ。也许在未来其他架构也会想要覆盖这个函数。

pci_device_probe()的最终代码如下:

static int pci_device_probe(struct device *dev)
{
    int error;
    struct pci_dev *pci_dev = to_pci_dev(dev);
    struct pci_driver *drv = to_pci_driver(dev->driver); 
    pci_assign_irq(pci_dev);
    error = pcibios_alloc_irq(pci_dev);
    if (error < 0)
        return error;
    pci_dev_get(pci_dev);
    if (pci_device_can_probe(pci_dev)) {
        error = pci_device_probe(drv, pci_dev);
        if (error) {
            pcibios_free_irq(pci_dev);
            pci_dev_put(pci_dev);
        }
    }
    return error;
}

重要提示

PCI_INTERRUPT_LINE中包含的 IRQ 值在调用pci_enable_device()之后才是有效的。但是,外围设备驱动程序不应该改变PCI_INTERRUPT_LINE,因为它反映了 PCI 中断如何连接到中断控制器,这是不可更改的。

模拟 INTx IRQ 交换

请注意,大多数处于传统 INTx 模式的 PCIe 设备将默认为本地 INTA“虚拟线输出”,对于许多通过 PCIe/PCI 桥连接的物理 PCI 设备也是如此。操作系统最终会在系统中的所有外围设备之间共享 INTA 输入;所有共享相同 IRQ 线的设备 - 我会让你想象一下灾难。

解决这个问题的方法是“虚拟线 INTx IRQ 交换”。回到pci_device_probe()函数的代码,它调用了pci_assign_irq()。如果你看一下这个函数的主体部分(在drivers/pci/setup-irq.c中),你会注意到一些交换操作,这些操作旨在解决这个问题。

锁定注意事项

许多设备驱动程序通常有一个每设备自旋锁,在中断处理程序中会使用。由于在基于 Linux 的系统上中断是非可重入的,因此在使用基于引脚的中断或单个 MSI 时,不需要禁用中断。但是,如果设备使用多个中断,驱动程序必须在持有锁时禁用中断。这将防止死锁,如果设备发送不同的中断,其处理程序将尝试获取已被正在服务的中断锁定的自旋锁。因此,在这种情况下要使用的锁原语是spin_lock_irqsave()spin_lock_irq(),它们会禁用本地中断并获取锁。您可以参考第一章**,嵌入式开发人员的 Linux 内核概念,了解有关锁定原语和中断管理的更多详细信息。

关于传统 API 的说明

仍然有许多驱动程序使用旧的现在已弃用的 MSI 或 MSI-X API,包括pci_enable_msi()pci_disable_msi()pci_enable_msix_range()pci_enable_msix_exact()pci_disable_msix()

前面列出的 API 在新代码中都不应该使用。然而,以下是一个尝试使用 MSI 并在 MSI 不可用时回退到传统中断模式的代码摘录的例子:

    int err;
    /* Try using MSI interrupts */
    err = pci_enable_msi(pci_dev);
    if (err)
        goto intx;
    err = devm_request_irq(&pci_dev->dev, pci_dev->irq,
                        my_msi_handler, 0, "foo-msi", priv);
    if (err) {
        pci_disable_msi(pci_dev);
        goto intx;
    }
    return 0;
    /* Try using legacy interrupts */
intx:
    dev_warn(&pci_dev->dev,
    "Unable to use MSI interrupts, falling back to legacy\n");
    err = devm_request_irq(&pci_dev->dev, pci_dev->irq, 
             my_shared_handler, IRQF_SHARED, "foo-intx", priv);
    if (err) {
        dev_err(pci_dev->dev, "no usable interrupts\n");
        return err;
    }
    return 0;

由于前面的代码包含了已弃用的 API,将其转换为新的 API 可能是一个很好的练习。

现在我们已经完成了通用 PCI 设备驱动程序结构,并解决了这些驱动程序中的中断管理问题,我们可以向前迈进一步,利用设备的直接内存访问能力。

PCI 和直接内存访问(DMA)

为了加快数据传输速度并通过允许 CPU 不执行繁重的内存复制操作来卸载 CPU 负担,控制器和设备都可以配置为执行直接内存访问(DMA),这是一种在设备和主机之间交换数据而不涉及 CPU 的方式。根据根复杂性,PCI 地址空间可以是 32 位或 64 位。

作为 DMA 传输的源或目的地的系统内存区域称为 DMA 缓冲区。但是,DMA 缓冲区内存范围取决于总线地址的大小。这源自 ISA 总线,其宽度为 24 位。在这样的总线上,DMA 缓冲区只能存在于系统内存的底部 16MB。这个底部内存也被称为ZONE_DMA。但是,PCI 总线没有这样的限制。经典 PCI 总线支持 32 位寻址,PCIe 将其扩展到 64 位。因此,可以使用两种不同的地址格式:32 位地址格式和 64 位地址格式。为了调用 DMA API,驱动程序应包含#include <linux/dma-mapping.h>

为了告知内核 DMA 可用缓冲区的任何特殊需求(包括指定总线宽度),可以使用dma_set_mask(),其定义如下:

dma_set_mask(struct device *dev, u64 mask);

这将有助于系统在有效的内存分配方面,特别是如果设备可以直接寻址系统 RAM 中 4GB 以上的物理 RAM 中的“一致内存”。在上面的帮助程序中,dev是 PCI 设备的基础设备,mask是要使用的实际掩码,您可以使用DMA_BIT_MASK宏以及实际总线宽度来指定。dma_set_mask()在成功时返回0。任何其他值都表示发生了错误。

以下是 32 位(或 64 位)系统的示例:

int err = 0;
err = pci_set_dma_mask(pci_dev, DMA_BIT_MASK(32));
/* 
 * OR the below on a 64 bits system:
 * err = pci_set_dma_mask(dev, DMA_BIT_MASK(64));
 */
if (err) {
    dev_err(&pci_dev->dev,
            "Required dma mask not supported, \
              failed to initialize device\n");
    goto err_disable_pci_dev;
}

也就是说,DMA 传输需要合适的内存映射。这种映射包括分配 DMA 缓冲区并为每个生成总线地址,这些地址的类型是dma_addr_t。由于 I/O 设备通过总线控制器和任何中间 I/O 内存管理单元(IOMMU)查看 DMA 缓冲区,因此生成的总线地址将被提供给设备,以便通知其 DMA 缓冲区的位置。由于每个内存映射还会产生一个虚拟地址,因此不仅会生成总线地址,还会为映射生成虚拟地址。为了使 CPU 能够访问缓冲区,DMA 服务例程还将 DMA 缓冲区的内核虚拟地址映射到总线地址。

有两种(PCI)DMA 映射类型:连贯映射和流映射。对于任何一种,内核都提供了一个健壮的 API,可以屏蔽许多处理 DMA 控制器的内部细节。

PCI 连贯(又称一致)映射

这种映射被称为连贯,因为它为设备执行 DMA 操作分配了非缓存(连贯)和非缓冲内存。由于设备或 CPU 的写入可以立即被任一方读取,而不必担心缓存一致性,因此这种映射也是同步的。所有这些使得连贯映射对系统来说太昂贵,尽管大多数设备都需要它。但是,从代码的角度来看,它更容易实现。

以下函数设置了一个连贯的映射:

void * pci_alloc_consistent(struct pci_dev *hwdev, size_t size,
                            dma_addr_t *dma_handle)

通过上述方法,为映射分配的内存保证是物理上连续的。size是您需要分配的区域的长度。此函数返回两个值:您可以用来从 CPU 访问它的虚拟地址和dma_handle,第三个参数,它是一个输出参数,对应于函数调用为分配的区域生成的总线地址。总线地址实际上是您传递给 PCI 设备的地址。

请注意,pci_alloc_consistent()实际上是dma_alloc_coherent()的一个简单包装,设置了GFP_ATOMIC标志,这意味着分配不会休眠,并且可以安全地在原子上下文中调用它。如果您希望更改分配标志,例如使用GFP_KERNEL而不是GFP_ATOMIC,则可以使用dma_alloc_coherent()(强烈建议)。

请记住,映射是昂贵的,它最少可以分配一页。在底层,它只分配 2 的幂次方的页数。页面的顺序是通过int order = get_order(size)获得的。这样的映射应该用于设备寿命的缓冲区。

要取消映射并释放这样的 DMA 区域,可以调用pci_free_consistent()

pci_free_consistent(dev, size, cpu_addr, dma_handle);

在这里,cpu_addrdma_handle对应于pci_alloc_consistent()返回的内核虚拟地址和总线地址。虽然映射函数可以从原子上下文中调用,但这个函数可能不能在这样的上下文中调用。

还要注意,pci_free_consistent()dma_free_coherent()的一个简单包装,如果映射是使用dma_alloc_coherent()完成的,可以使用它:

#define DMA_ADDR_OFFSET	0x14
#define DMA_REG_SIZE_OFFSET		0x32
[...]
int do_pci_dma (struct pci_dev *pci_dev, int direction,                 size_t count)
{
    dma_addr_t dma_pa;
    char *dma_va;
    void iomem *dma_io;
    /* should check errors */
    dma_io = pci_iomap(dev, 2, 0);
    dma_va = pci_alloc_consistent(&pci_dev->dev, count,                                   &dma_pa);
    if (!dma_va)
        return -ENOMEM;
    /* may need to clear allocated region */
    memset(dma_va, 0, count);
    /* set up the device */
    iowrite8(CMD_DISABLE_DMA, dma_io + REG_CMD_OFFSET); 
    iowrite8(direction ? CMD_WR : CMD_RD);
    /* Send bus address to the device */
    iowrite32(dma_pa, dma_io + DMA_ADDR_OFFSET);
    /* Send size to the device */
    iowrite32(count, dma_io + DMA_REG_SIZE_OFFSET);
    /* Start the operation */
    iowrite8(CMD_ENABLE_DMA, dma_io + REG_CMD_OFFSET);
    return 0;
}

前面的代码展示了如何执行 DMA 映射并将结果总线地址发送到设备。在现实世界中,可能会引发中断。然后您应该在驱动程序中处理它。

流式 DMA 映射

另一方面,流式映射在代码方面有更多的约束。首先,这样的映射需要使用已经分配的缓冲区。此外,已经映射的缓冲区属于设备而不再属于 CPU。因此,在 CPU 可以使用缓冲区之前,应该首先取消映射,以解决可能的缓存问题。

如果需要启动写事务(CPU 到设备),驱动程序应该在映射之前将数据放入缓冲区。此外,必须指定数据应该移动的方向,并且只能基于这个方向使用数据。

缓冲区在可以被 CPU 访问之前必须取消映射的原因是因为缓存。不用说 CPU 映射是可缓存的。dma_map_*()系列函数(实际上是由pci_map_*()函数包装)用于流式映射,将首先清除/使无效与缓冲区相关的缓存,并且依赖于 CPU 在相应的dma_unmap_*()(由pci_unmap_*()函数包装)之前不访问这些缓冲区。在此期间,这些取消映射将再次使缓存无效(如果有必要),以便 CPU 可以读取设备写入内存的任何数据之前的任何推测获取。只有在这个时候 CPU 才能访问这些缓冲区。

有流式映射可以接受多个非连续和分散的缓冲区。然后我们可以列举两种形式的流式映射:

  • 单缓冲区映射,只允许单页映射

  • 分散/聚集映射,允许传递多个分散在内存中的缓冲区

它们中的每一个在以下各节中介绍。

单缓冲区映射

这包括映射单个缓冲区。它是用于偶尔映射的。也就是说,您可以使用以下方法设置单个缓冲区:

dma_addr_t pci_map_single(struct pci_dev *hwdev, void *ptr,
                          size_t size, int direction)

direction应该是PCI_DMA_BIDIRECTIONPCI_DMA_TODEVICEPCI_DMA_FROMDEVICEPCI_DMA_NONEptr是缓冲区的内核虚拟地址,dma_addr_t是返回的总线地址,可以发送到设备。您应该确保使用真正匹配数据移动方式的方向,而不仅仅是DMA_BIDIRECTIONALpci_map_single()dma_map_single()的一个简单包装,方向映射到DMA_TO_DEVICEDMA_FROM_DEVICEDMA_BIDIRECTIONAL

您应该使用以下方法释放映射:

Void pci_unmap_single(struct pci_dev *hwdev,                       dma_addr_t dma_addr,
                      size_t size, int direction)

这是dma_unmap_single()的一个包装。dma_addr应该与pci_map_single()返回的地址相同(或者如果您使用了dma_map_single(),则应该与其返回的地址相同)。directionsize应该与您在映射中指定的相匹配。

以下是流式映射的简化示例(实际上是单个缓冲区):

int do_pci_dma (struct pci_dev *pci_dev, int direction,
                void *buffer, size_t count)
{
    dma_addr_t dma_pa;
    /* bus address */
    void iomem *dma_io;
    /* should check errors */
    dma_io = pci_iomap(dev, 2, 0);
    dma_dir = (write ? DMA_TO_DEVICE : DMA_FROM_DEVICE);
    dma_pa = pci_map_single(pci_dev, buffer, count, dma_dir);
    if (!dma_va)
        return -ENOMEM;
    /* may need to clear allocated region */
    memset(dma_va, 0, count);
    /* set up the device */
    iowrite8(CMD_DISABLE_DMA, dma_io + REG_CMD_OFFSET);
    iowrite8(direction ? CMD_WR : CMD_RD);
    /* Send bus address to the device */
    iowrite32(dma_pa, dma_io + DMA_ADDR_OFFSET);
    /* Send size to the device */
    iowrite32(count, dma_io + DMA_REG_SIZE_OFFSET);
    /* Start the operation */
    iowrite8(CMD_ENABLE_DMA, dma_io + REG_CMD_OFFSET);
    return 0;
}

在前面的示例中,buffer应该已经被分配并包含数据。然后它被映射,其总线地址被发送到设备,并且 DMA 操作被启动。下一个代码示例(作为 DMA 事务的中断处理程序实现)演示了如何从 CPU 端处理缓冲区:

void pci_dma_interrupt(int irq, void *dev_id)
{
    struct private_struct *priv =     (struct private_struct *) dev_id;
    /* Unmap the DMA buffer */
    pci_unmap_single(priv->pci_dev, priv->dma_addr,
                     priv->dma_size, priv->dma_dir);
    /* now it is safe to access the buffer */
    [...]
}

在前面的示例中,映射在 CPU 可以处理缓冲区之前被释放。

散射/聚集映射

散射/聚集映射是流式 DMA 映射的第二类,您可以在一次传输中传输多个(不一定是物理上连续的)缓冲区域,而不是分别映射每个缓冲区并逐个传输它们。为了设置scatterlist映射,您应该首先分配您的散射缓冲区,这些缓冲区必须是页面大小,除了最后一个可能有不同的大小。之后,您应该分配一个scatterlist数组,并使用sg_set_buf()将先前分配的缓冲区填充进去。最后,您必须在scatterlist数组上调用dma_map_sg()。完成 DMA 后,调用dma_unmap_sg()来取消映射scatterlist条目。

虽然您可以通过映射每个缓冲区逐个发送多个缓冲区的内容,但是散射/聚集可以通过一次发送scatterlist指针以及一个长度(列表中的条目数)来一次发送它们。

u32 *wbuf1, *wbuf2, *wbuf3;
struct scatterlist sgl[3];
int num_mapped;
wbuf1 = kzalloc(PAGE_SIZE, GFP_DMA);
wbuf2 = kzalloc(PAGE_SIZE, GFP_DMA);
/* size may be different for the last entry */
wbuf3 = kzalloc(CUSTOM_SIZE, GFP_DMA); 
sg_init_table(sg, 3);
sg_set_buf(&sgl[0], wbuf1, PAGE_SIZE);
sg_set_buf(&sgl[1], wbuf2, PAGE_SIZE);
sg_set_buf(&sgl[2], wbuf3, CUSTOM_SIZE);
num_mapped = pci_map_sg(NULL, sgl, 3, PCI_DMA_BIDIRECTIONAL);

首先要注意的是,pci_map_sg()dma_map_sg()的一个简单包装。在前面的代码中,我们使用了sg_init_table(),这导致了一个静态分配的表。我们可以使用sg_alloc_table()进行动态分配。此外,我们可以使用for_each_sg()宏,以便循环遍历每个sg(使用sg_set_page()助手来设置此 scatterlist 绑定的页面(您不应该直接分配页面)。以下是涉及此类助手的示例:

static int pci_map_memory(struct page **pages,
                          unsigned int num_entries,
                          struct sg_table *st)
{
    struct scatterlist *sg;
    int i;
    if (sg_alloc_table(st, num_entries, GFP_KERNEL))
        goto err;
    for_each_sg(st->sgl, sg, num_entries, i)
        sg_set_page(sg, pages[i], PAGE_SIZE, 0);
    if (!pci_map_sg(priv.pcidev, st->sgl, st->nents, 
                    PCI_DMA_BIDIRECTIONAL))
        goto err;
    return 0;
err:
    sg_free_table(st);
    return -ENOMEM;
}

在前面的代码块中,页面应该已经被分配,并且显然应该是PAGE_SIZE大小。st是一个输出参数,在此函数的成功路径上将被适当设置。

再次注意,scatterlist 条目必须是页面大小(除了最后一个条目可能有不同的大小)。对于输入 scatterlist 中的每个缓冲区,dma_map_sg()确定要给设备的适当总线地址。每个缓冲区的总线地址和长度存储在 struct scatterlist 条目中,但它们在结构中的位置因架构而异。因此,有两个宏可以用于使您的代码可移植:

  • dma_addr_t sg_dma_address(struct scatterlist *sg): 这返回此 scatterlist 条目的总线(DMA)地址。

  • unsigned int sg_dma_len(struct scatterlist *sg): 这返回此缓冲区的长度。

dma_map_sg()dma_unmap_sg()负责缓存一致性。但是,如果您必须在 DMA 传输之间访问(读/写)数据,则必须适当地在每次传输之间同步缓冲区,通过dma_sync_sg_for_cpu()(如果 CPU 需要访问缓冲区)或dma_sync_sg_for_device()(如果设备需要访问)来实现。单个区域映射的类似函数是dma_sync_single_for_cpu()dma_sync_single_for_device()

鉴于上述所有内容,我们可以得出结论,一致映射编码简单但使用昂贵,而流式映射具有相反的特征。当 I/O 设备长时间拥有缓冲区时,应使用流式映射。流式 DMA 在异步操作中很常见,每个 DMA 在不同的缓冲区上操作,例如网络驱动程序,其中每个skbuf数据都是动态映射和取消映射的。然而,设备可能对你应该使用的方法有最后的决定权。也就是说,如果你有选择的话,应该在可以的时候使用流式映射,在必须的时候使用一致映射。

总结

在本章中,我们处理了 PCI 规范总线和实现,以及它在 Linux 内核中的支持。我们经历了枚举过程以及 Linux 内核如何允许访问不同的地址空间。然后,我们详细介绍了如何编写 PCI 设备驱动程序的逐步指南,从设备表的填充到模块的“退出”方法。我们深入研究了中断机制及其基本行为以及它们之间的区别。现在你能够自己编写 PCI 设备驱动程序,并且熟悉它们的枚举过程。此外,你了解它们的中断机制,并且知道它们之间的区别(MSI 或非 MSI)。最后,你学会了如何访问它们各自的内存区域。

在下一章中,我们将处理 NVMEM 框架,该框架有助于开发用于 EEPROM 等非易失性存储设备的驱动程序。这将有助于结束我们在学习 PCI 设备驱动程序时迄今所经历的复杂性。

第十二章:利用 NVMEM 框架

drivers/misc/,在大多数情况下,每个驱动程序都必须实现自己的 API 来处理相同的功能,无论是为内核用户还是向用户空间公开其内容。结果表明,这些驱动程序严重缺乏抽象代码。此外,内核对这些设备的支持不断增加,导致了大量的代码重复。

在内核中引入此框架的目的是解决先前提到的这些问题。它还为消费者设备引入了 DT 表示,以从 NVMEM 获取它们需要的数据(MAC 地址、SoC/修订 ID、零件号等)。我们将从介绍 NVMEM 数据结构开始本章,这是必须了解该框架的内容,然后我们将看看 NVMEM 提供者驱动程序,学习如何将 NVMEM 内存区域暴露给消费者。最后,我们将学习 NVMEM 消费者驱动程序,以利用提供者公开的内容。

在本章中,我们将涵盖以下主题:

  • 介绍 NVMEM 数据结构和 API

  • 编写 NVMEM 提供者驱动程序

  • NVMEM 消费者驱动 API

技术要求

以下是本章的先决条件:

介绍 NVMEM 数据结构和 API

NVMEM 是一个具有减少 API 和数据结构集的小型框架。在本节中,我们将介绍这些 API 和数据结构,以及cell的概念,这是该框架的基础。

NVMEM 基于生产者/消费者模式,就像第四章中描述的时钟框架一样,Storming the Common Clock Framework。NVMEM 设备只有一个驱动程序,公开设备单元格,以便消费者驱动程序可以访问和操作它们。虽然 NVMEM 设备驱动程序必须包括<linux/nvmem-provider.h>,但消费者必须包括<linux/nvmem-consumer.h>。该框架只有少量数据结构,其中包括struct nvmem_device,其外观如下:

struct nvmem_device {
    const char  *name;
    struct module *owner;
    struct device dev;
    int stride;
    int word_size;
    int id;
    int users;
    size_t size;
    bool read_only;
    int flags;
    nvmem_reg_read_t reg_read;
    nvmem_reg_write_t reg_write; void *priv;
    [...]
};

这个结构实际上抽象了真实的 NVMEM 硬件。它是由框架在设备注册时创建和填充的。也就是说,它的字段实际上是使用struct nvmem_config中字段的完整副本设置的,该结构描述如下:

struct nvmem_config {
    struct device *dev;
    const char *name;
    int id;
    struct module *owner;
    const struct nvmem_cell_info *cells;
    int ncells;
    bool read_only;
    bool root_only;
    nvmem_reg_read_t reg_read;     nvmem_reg_write_t reg_write;
    int size;
    int word_size;
    int stride;
    void *priv;
    [...]
};

这个结构是 NVMEM 设备的运行时配置,提供有关它的信息或访问其数据单元的辅助函数。在设备注册时,大多数字段都用于填充新创建的nvmem_device结构。

结构中字段的含义如下(了解这些用于构建底层struct nvmem_device):

  • dev是父设备。

  • name是此 NVMEM 设备的可选名称。它与填充的id一起用于构建完整的设备名称。最终的 NVMEM 设备名称将是<name><id>。最好在名称中添加-,以便完整名称可以具有此模式:<name>-<id>。这是 PCF85363 驱动程序中使用的方法。如果省略,将使用nvmem<id>作为默认名称。

  • id是此 NVMEM 设备的可选 ID。如果nameNULL,则会被忽略。如果设置为-1,内核将负责为设备提供唯一的 ID。

  • owner是拥有此 NVMEM 设备的模块。

  • cells是预定义的 NVMEM 单元格数组。这是可选的。

  • ncells是单元格中元素的数量。

  • read_only将此设备标记为只读。

  • root_only指示此设备是否仅对 root 可访问。

  • reg_readreg_write是框架用于分别读取和写入数据的基础回调。它们的定义如下:

typedef int (*nvmem_reg_read_t)(void *priv,                                 unsigned int offset,
                                void *val, size_t bytes);
typedef int (*nvmem_reg_write_t)(void *priv,                                  unsigned int offset,
                                 void *val,                                  size_t bytes);
  • size表示设备的大小。

  • word_size是此设备的最小读/写访问粒度。stride是最小读/写访问跨距。其原理已在前几章中解释过。

  • priv是传递给读/写回调的上下文数据。例如,它可以是包装此 NVMEM 设备的更大结构。

以前,我们在提供者方面使用了struct nvmem_cell_info结构,而在消费者方面使用了struct nvmem_cell。在 NVMEM 核心代码中,内核使用nvmem_cell_info_to_nvmem_cell()从前一个结构切换到第二个结构。

这些结构如下引入:

struct nvmem_cell {
    const char *name;
    int offset;
    int bytes;
    int bit_offset;
    int nbits;
    struct nvmem_device *nvmem;
    struct list_head node;
};

另一个数据结构struct nvmem_cell如下所示:

struct nvmem_cell_info {
    const char *name;
    unsigned int offset;
    unsigned int bytes;
    unsigned int bit_offset;
    unsigned int nbits;
};

如您所见,前两个数据结构几乎具有相同的属性。让我们看看它们的含义,如下所示:

  • name是单元的名称。

  • 偏移量是单元从整个硬件数据寄存器中的偏移量(开始位置)。

  • bytes是从offset开始的数据单元的大小(以字节为单位)。

  • 单元可以具有位级粒度。对于这些单元,应设置bit_offset以指定单元内的位偏移,并且应根据感兴趣区域的大小(以位为单位)定义nbits

  • nvmem是此单元所属的 NVMEM 设备。

  • node用于跟踪整个单元系统。此字段最终出现在nvmem_cells列表中,该列表保存系统上所有可用的单元,而不管它们属于哪个 NVMEM 设备。这个全局列表实际上由drivers/nvmem/core.c中静态定义的互斥体nvmem_cells_mutex保护。

为了澄清前面的解释,让我们以以下配置为例的单元:

static struct nvmem_cellinfo mycell = {
    .offset = 0xc,
    .bytes = 0x1,
    [...],
}

在前面的例子中,如果我们将.nbits.bit_offset都等于0,这意味着我们对单元的整个数据区域感兴趣,在我们的情况下是 1 字节大小。但是如果我们只对位 2 到 4(实际上是 3 位)感兴趣呢?结构将如下所示:

staic struct nvmem_cellinfo mycell = {
    .offset = 0xc,
    .bytes = 0x1,
    .bit_offset = 2,
    .nbits = 2 [...]
}

重要说明

前面的例子仅用于教学目的。即使您可以在驱动程序代码中预定义单元,也建议您依赖设备树声明单元,正如我们稍后在章节中将看到的那样,NVMEM 提供程序的设备树绑定部分。

消费者驱动程序和提供者驱动程序都不应创建struct nvmem_cell的实例。NVMEM 核心在生产者提供单元信息数组时,或者在消费者请求单元时,内部处理这一点。

到目前为止,我们已经介绍了该框架提供的数据结构和 API。但是,NVMEM 设备可以从内核或用户空间访问。此外,在内核中,必须有一个暴露设备存储的驱动程序,以便其他驱动程序访问它。这是生产者/消费者设计,其中提供者驱动程序是生产者,而其他驱动程序是消费者。现在,让我们从该框架的提供者(又名生产者)部分开始。

编写 NVMEM 提供程序驱动程序

提供者是暴露设备内存以便其他驱动程序(消费者)可以访问的人。这些驱动程序的主要任务如下:

  • 提供与设备数据表相关的适当的 NVMEM 配置,以及允许您访问内存的例程

  • 向系统注册设备

  • 提供设备树绑定文档

这就是提供者必须做的全部。大多数(其余)机制/逻辑由 NVMEM 框架的代码处理。

NVMEM 设备(取消)注册

注册/注销 NVMEM 设备实际上是提供方驱动程序的一部分,它可以使用nvmem_register()/nvmem_unregister()函数或其托管版本devm_nvmem_register()/devm_nvmem_unregister()

struct nvmem_device *nvmem_register(const                                    struct nvmem_config *config)
struct nvmem_device *devm_nvmem_register(struct device *dev,
                             const struct nvmem_config *config)
int nvmem_unregister(struct nvmem_device *nvmem)
int devm_nvmem_unregister(struct device *dev,
                          struct nvmem_device *nvmem)

注册后,将创建/sys/bus/nvmem/devices/dev-name/nvmem二进制条目。在这些接口中,*config参数是描述要创建的 NVMEM 设备的 NVMEM 配置。*dev参数仅适用于托管版本,并表示使用 NVMEM 设备的设备。在成功路径上,这些函数返回一个指向nvmem_device的指针,否则在出错时返回ERR_PTR()

另一方面,注销函数接受在注册函数成功路径上创建的 NVMEM 设备的指针。在成功注销时返回0,否则返回负错误。

RTC 设备中的 NVMEM 存储

在许多include/linux/rtc.h中,您会注意到以下与 NVMEM 相关的字段:

struct rtc_device {
    [...]
    struct nvmem_device *nvmem;
    /* Old ABI support */
    bool nvram_old_abi;
    struct bin_attribute *nvram;
    [...]
}

请注意前面结构摘录中的以下内容:

  • nvmem 抽象了底层硬件内存。

  • nvram_old_abi 是一个布尔值,告诉我们是否要使用旧的(现在已弃用)NVRAM ABI 来注册此 RTC 的 NVMEM,该 ABI 使用/sys/class/rtc/rtcx/device/nvram来公开内存。只有在您有现有应用程序(您不想破坏)使用这个旧的 ABI 接口时,才应将此字段设置为true。新驱动程序不应设置此字段。

  • nvram 实际上是底层内存的二进制属性,仅由 RTC 框架用于旧 ABI 支持;也就是说,如果nvram_old_abitrue

RTC 相关的 NVMEM 框架 API 可以通过RTC_NVMEM内核配置选项启用。此 API 在drivers/rtc/nvmem.c中定义,并分别公开了rtc_nvmem_register()rtc_nvmem_unregister(),用于 RTC-NVMEM 注册和注销。它们的描述如下:

int rtc_nvmem_register(struct rtc_device *rtc,
                        struct nvmem_config *nvmem_config)
void rtc_nvmem_unregister(struct rtc_device *rtc)

rtc_nvmem_register() 在成功时返回0。它接受一个有效的 RTC 设备作为其第一个参数。这对代码有影响。这意味着只有在实际的 RTC 设备成功注册后,RTC 的 NVMEM 才应该被注册。换句话说,只有在rtc_register_device()成功后才应该调用rtc_nvmem_register()。第二个参数应该是一个指向有效的nvmem_config对象的指针。此外,正如我们已经看到的,这个配置可以在堆栈中声明,因为它的所有字段都被完全复制以构建nvmem_device结构。相反的是rtc_nvmem_unregister(),它取消注册 NVMEM。

让我们通过 DS1307 RTC 驱动程序的probe函数的摘录来总结一下,drivers/rtc/rtc-ds1307.c

static int ds1307_probe(struct i2c_client *client,
                        const struct i2c_device_id *id)
{
    struct ds1307 *ds1307;
    int err = -ENODEV;
    int tmp;
    const struct chip_desc *chip;
    [...]
    ds1307->rtc->ops = chip->rtc_ops ?: &ds13xx_rtc_ops;
    err = rtc_register_device(ds1307->rtc);
    if (err)
        return err;
    if (chip->nvram_size) {
        struct nvmem_config nvmem_cfg = {
            .name = "ds1307_nvram",
            .word_size = 1,
            .stride = 1,
            .size = chip->nvram_size,
            .reg_read = ds1307_nvram_read,
            .reg_write = ds1307_nvram_write,
            .priv = ds1307,
        };
        ds1307->rtc->nvram_old_abi = true;
        rtc_nvmem_register(ds1307->rtc, &nvmem_cfg);
    }
    [...]
}

前面的代码首先在注册 NVMEM 设备之前将 RTC 注册到内核,提供与 RTC 存储空间相对应的 NVMEM 配置。前面是与 RTC 相关的,而不是通用的。其他 NVMEM 设备必须让其驱动程序公开回调,NVMEM 框架将把任何来自用户空间或内核内部的读/写请求转发给这些回调。下一节将解释如何实现这一点。

实现 NVMEM 读/写回调

为了使内核和其他框架能够从 NVMEM 设备和其单元中读取/写入数据,每个 NVMEM 提供程序必须公开一对回调,允许进行这些读/写操作。这种机制允许硬件无关的消费者代码,因此来自消费者端的任何读/写请求都会被重定向到底层提供程序的读/写回调。以下是每个提供程序必须符合的读/写原型:

typedef int (*nvmem_reg_read_t)(void *priv,                                 unsigned int offset,
                                void *val, size_t bytes);
typedef int (*nvmem_reg_write_t)(void *priv,                                  unsigned int offset,
                                 void *val, size_t bytes);

这些与 NVMEM 设备所在的底层总线无关。nvmem_reg_read_t 用于从 NVMEM 设备读取数据。priv 是 NVMEM 配置中提供的用户上下文,offset 是读取应该开始的位置,val 是读取数据必须存储的输出缓冲区,bytes 是要读取的数据的大小(实际上是字节数)。该函数应在成功时返回成功读取的字节数,并在出错时返回负错误代码。

另一方面,nvmem_reg_write_t 用于写入目的。priv 的含义与读取相同,offset 是写入应该从哪里开始的地方,val 是包含要写入的数据的缓冲区,bytesval 中数据的字节数,应该被写入。bytes 不一定是 val 的大小。此函数应在成功时返回成功写入的字节数,并在出错时返回负错误代码。

现在我们已经看到了如何实现提供者读/写回调,让我们看看如何通过设备树扩展提供者的功能。

NVMEM 提供者的设备树绑定

NVMEM 数据提供者没有特定的绑定。它应该根据其父总线 DT 绑定进行描述。这意味着,例如,如果它是一个 I2C 设备,它应该(相对于 I2C 绑定)被描述为坐在代表其后面的 I2C 总线节点的子节点。但是,还有一个可选的 read-only 属性,使设备成为只读。此外,每个子节点都将被视为数据单元(NVMEM 设备中的内存区域)。

让我们考虑以下 MMIO NVMEM 设备以及其子节点以进行解释:

ocotp: ocotp@21bc000 {
    #address-cells = <1>;
    #size-cells = <1>;
    compatible = "fsl,imx6sx-ocotp", "syscon";
    reg = <0x021bc000 0x4000>;
    [...]
    tempmon_calib: calib@38 {
        reg = <0x38 4>;
    };
    tempmon_temp_grade: temp-grade@20 {
        reg = <0x20 4>;
    };
    foo: foo@6 {
        reg = <0x6 0x2> bits = <7 2>
    };
    [...]
};

根据子节点中定义的属性,NVMEM 框架构建适当的 nvmem_cell 结构,并将它们插入到系统范围的 nvmem_cells 列表中。以下是数据单元绑定的可能属性:

  • reg:此属性是强制性的。它是一个双单元属性,描述了 NVMEM 设备中数据区域的字节偏移量(属性的第一个单元)和字节大小(属性的第二个单元)。

  • bits:这是一个可选的双单元属性,指定位偏移量(可能的值为 0-7)和由 reg 属性指定的地址范围内的位数。

在提供者节点内定义数据单元后,可以使用 nvmem-cells 属性将其分配给消费者,该属性是指向 NVMEM 提供者的句柄列表。此外,还应该有一个 nvmem-cell-names 属性,其主要目的是为每个数据单元命名。因此,分配的名称可以用于使用消费者 API 查找适当的数据单元。以下是一个示例分配:

tempmon: tempmon {
    compatible = "fsl,imx6sx-tempmon", "fsl,imx6q-tempmon";
    interrupt-parent = <&gpc>;
    interrupts = <GIC_SPI 49 IRQ_TYPE_LEVEL_HIGH>;
    fsl,tempmon = <&anatop>;
    clocks = <&clks IMX6SX_CLK_PLL3_USB_OTG>;
    nvmem-cells = <&tempmon_calib>, <&tempmon_temp_grade>;
    nvmem-cell-names = "calib", "temp_grade";
};

完整的 NVMEM 设备树绑定可在 Documentation/devicetree/bindings/nvmem/nvmem.txt 中找到。

我们刚刚了解了实现驱动程序(所谓的生产者)的存储的情况。虽然这并不总是这样,但内核中可能有其他驱动程序需要访问生产者(也称为提供者)提供的存储。下一节将详细描述这些驱动程序。

NVMEM 消费者驱动程序 API

NVMEM 消费者是访问生产者提供的存储的驱动程序。这些驱动程序可以通过包含 <linux/nvmem-consumer.h> 来调用 NVMEM 消费者 API,这将带来以下基于单元的 API:

struct nvmem_cell *nvmem_cell_get(struct device *dev,
                                  const char *name);
struct nvmem_cell *devm_nvmem_cell_get(struct device *dev,
                                       const char *name);
void nvmem_cell_put(struct nvmem_cell *cell);
void devm_nvmem_cell_put(struct device *dev,
                         struct nvmem_cell *cell);
void *nvmem_cell_read(struct nvmem_cell *cell, size_t *len);
int nvmem_cell_write(struct nvmem_cell *cell,                      void *buf, size_t len); 
int nvmem_cell_read_u32(struct device *dev,                         const char *cell_id,
                        u32 *val);

devm_ 前缀的 API 是受资源管理的版本,应尽可能使用。

话虽如此,消费者接口完全取决于生产者公开(部分)单元的能力,以便其他人可以访问它们。如前所述,提供/公开单元的能力应通过设备树完成。devm_nvmem_cell_get() 用于根据通过 nvmem-cell-names 属性分配的名称获取给定单元。nvmem_cell_read API 总是读取整个单元大小(即 nvmem_cell->bytes),如果可能的话。它的第三个参数 len 是一个输出参数,保存实际读取的 nvmem_config.word_size 的数量(实际上,大部分时间它保存 1,这意味着一个字节)。

成功读取后,len 指向的内容将等于单元格中的字节数:*len = nvmem_cell->bytes。另一方面,nvmem_cell_read_u32()u32 的形式读取单元格的值。

以下是在前一节中描述的tempmon节点分配的单元,并读取它们的内容的代码:

static int imx_init_from_nvmem_cells(struct                                      platform_device *pdev)
{
    int ret; u32 val;
    ret = nvmem_cell_read_u32(&pdev->dev, "calib", &val);
    if (ret)
        return ret;
    ret = imx_init_calib(pdev, val);
    if (ret)
        return ret;
    ret = nvmem_cell_read_u32(&pdev->dev, "temp_grade", &val);
    if (ret)
        return ret;
    imx_init_temp_grade(pdev, val);
    return 0;
}

在这里,我们已经介绍了这个框架的消费者和生产者方面。通常,驱动程序需要将它们的服务暴露给用户空间。NVMEM 框架(就像其他 Linux 内核框架一样)可以透明地处理将 NVMEM 服务暴露给用户空间。下一节将详细解释这一点。

用户空间中的 NVMEM

NVMEM 用户空间接口依赖于sysfs,就像大多数内核框架一样。系统中注册的每个 NVMEM 设备在/sys/bus/nvmem/devices中创建一个目录条目,以及在该目录中创建一个nvmem二进制文件(您可以使用hexdumpecho),代表设备的内存。完整路径遵循以下模式:/sys/bus/nvmem/devices/<dev-name>X/nvmem。在这个路径模式中,<dev-name>是生产驱动程序提供的nvmem_config.name名称。以下代码摘录显示了 NVMEM 核心如何构造<dev-name>X模式:

int rval;
rval	= ida_simple_get(&nvmem_ida, 0, 0, GFP_KERNEL);
nvmem->id = rval;
if (config->id == -1 && config->name) {
    dev_set_name(&nvmem->dev, "%s", config->name);
} else {
    dev_set_name(&nvmem->dev, "%s%d", config->name ? : "nvmem",
    config->name ? config->id : nvmem->id);
}

前面的代码表示,如果nvmem_config->id == -1,那么模式中的X将被省略,只使用nvmem_config->name来命名sysfs目录条目。如果nvmem_config->id != -1并且设置了nvmem_config->name,它将与驱动程序设置的nvmem_config->id字段一起使用(这是模式中的X)。但是,如果驱动程序没有设置nvmem_config->name,核心将使用nvmem字符串以及已生成的 ID(这是模式中的X)。

重要说明

无论定义了什么单元,NVMEM 框架都通过 NVMEM 二进制文件而不是单元来暴露完整的寄存器空间。从用户空间访问单元需要预先知道它们的偏移量和大小。

然后,NVMEM 内容可以在用户空间中使用sysfs接口进行读取,可以使用hexdump或简单的cat命令。例如,假设我们在系统上注册了一个 I2C EEPROM,它位于 I2C 编号 2 的地址 0x55 处,作为 NVMEM 设备注册,其sysfs路径将是/sys/bus/nvmem/devices/2-00550/nvmem。以下是如何写入/读取一些内容:

cat /sys/bus/nvmem/devices/2-00550/nvmem
echo "foo" > /sys/bus/nvmem/devices/2-00550/nvmem
cat /sys/bus/nvmem/devices/2-00550/nvmem

现在我们已经看到了 NVMEM 寄存器是如何暴露给用户空间的。虽然本节内容很短,但我们已经涵盖了足够的内容来从用户空间利用这个框架。

总结

在本章中,我们介绍了 Linux 内核中 NVMEM 框架的实现。我们从生产者和消费者方面介绍了其 API,并讨论了如何从用户空间使用它。我毫不怀疑这些设备在嵌入式世界中有它们的位置。

在下一章中,我们将通过看门狗设备来解决可靠性问题,讨论如何设置这些设备并编写它们的 Linux 内核驱动程序。

第十三章:看门狗设备驱动程序

看门狗是一种旨在确保给定系统可用性的硬件(有时由软件模拟)设备。它有助于确保系统在关键挂起时始终重新启动,从而允许监视系统的“正常”行为。

无论是基于硬件还是由软件模拟,看门狗大多数情况下只是一个使用合理超时初始化的定时器,应该由受监视系统上运行的软件定期刷新。如果由于任何原因软件在超时之前停止/失败刷新定时器(并且没有明确关闭它),这将触发整个系统(在我们的情况下是计算机)的(硬件)复位。这种机制甚至可以帮助从内核恐慌中恢复。在本章结束时,您将能够做到以下事情:

  • 阅读/理解现有的看门狗内核驱动程序,并在用户空间使用其提供的功能。

  • 编写新的看门狗设备驱动程序。

  • 掌握一些不太为人知的概念,如看门狗管理器预超时

在本章中,我们还将讨论 Linux 内核看门狗子系统背后的概念,包括以下主题:

  • 看门狗数据结构和 API

  • 看门狗用户空间接口

技术要求

在我们开始阅读本章之前,需要以下元素:

看门狗数据结构和 API

在本节中,我们将深入研究看门狗框架,并了解其在底层的工作原理。看门狗子系统有一些数据结构。主要的是struct watchdog_device,它是 Linux 内核对看门狗设备的表示,包含有关它的所有信息。它在include/linux/watchdog.h中定义如下:

struct watchdog_device {
    int id;
    struct device *parent;
    const struct watchdog_info *info;
    const struct watchdog_ops *ops;
    const struct watchdog_governor *gov;
    unsigned int bootstatus;
    unsigned int timeout;
    unsigned int pretimeout;
    unsigned int min_timeout;
    struct watchdog_core_data *wd_data;
    unsigned long status;
    [...]
};

以下是此数据结构中字段的描述:

  • id:内核在设备注册期间分配的看门狗 ID。

  • parent:表示此设备的父级。

  • info:此struct watchdog_info结构指针提供有关看门狗定时器本身的一些附加信息。这是在看门狗字符设备上调用WDIOC_GETSUPPORT ioctl 以检索其功能时返回给用户的结构。我们稍后将详细介绍此结构。

  • ops:指向看门狗操作列表的指针。我们稍后将介绍这个数据结构。

  • gov:指向看门狗预超时管理器的指针。管理器只是根据某些事件或系统参数做出反应的策略管理器。

  • bootstatus:引导时看门狗设备的状态。这是触发系统复位的原因的位掩码。在描述struct watchdog_info结构时将枚举可能的值。

  • timeout:这是看门狗设备的超时值(以秒为单位)。

  • pretimeout预超时的概念可以解释为在真正的超时发生之前的某个时间发生的事件,因此,如果系统处于不健康状态,它会在真正的超时复位之前触发中断。这些中断通常是不可屏蔽的(pretimeout字段实际上是触发真正超时中断之前的时间间隔(以秒为单位)。这不是直到预超时的秒数。例如,如果将超时设置为60秒,预超时设置为10,则预超时事件将在50秒触发。将预超时设置为0会禁用它。

  • min_timeoutmax_timeout分别是看门狗设备的最小和最大超时值(以秒为单位)。这实际上是一个有效超时范围的下限和上限。如果值为 0,则框架将留下一个检查看门狗驱动程序本身。

  • wd_data:指向看门狗核心内部数据的指针。这个字段必须通过watchdog_set_drvdata()watchdog_get_drvdata()助手来访问。

  • status 是一个包含设备内部状态位的字段。可能的值在这里列出:

--WDOG_ACTIVE:告诉看门狗是否正在运行/活动。

--WDOG_NO_WAY_OUT:通知是否设置了nowayout特性。您可以使用watchdog_set_nowayout()来设置nowayout特性;它的签名是void watchdog_set_nowayout(struct watchdog_device *wdd, bool nowayout)

--WDOG_STOP_ON_REBOOT:应该在重启时停止。

--WDOG_HW_RUNNING:通知硬件看门狗正在运行。您可以使用watchdog_hw_running()助手来检查这个标志是否设置。但是,您应该在看门狗启动函数的成功路径上设置这个标志(或者在探测函数中,如果由于任何原因您在那里启动它或者发现看门狗已经启动)。您可以使用set_bit()助手来实现这一点。

--WDOG_STOP_ON_UNREGISTER:指定看门狗在注销时应该停止。您可以使用watchdog_stop_on_unregister()助手来设置这个标志。

正如我们之前介绍的,让我们详细了解struct watchdog_info结构,在include/uapi/linux/watchdog.h中定义,实际上,因为它是用户空间 API 的一部分:

struct watchdog_info {
    u32 options;
    u32 firmware_version;
    u8 identity[32];
};

这个结构也是在WDIOC_GETSUPPORTioctl 的成功路径上返回给用户空间的。在这个结构中,字段的含义如下:

  • options 代表了卡片/驱动程序支持的能力。它是由看门狗设备/驱动程序支持的能力的位掩码,因为一些看门狗卡片提供的不仅仅是一个倒计时。这些标志中的一些也可以在watchdog_device.bootstatus字段中设置,以响应GET_BOOT_STATUSioctl。这些标志如下列出,必要时给出双重解释:

--WDIOF_SETTIMEOUT表示看门狗设备可以设置超时。如果设置了这个标志,那么必须定义一个set_timeout回调。

--WDIOF_MAGICCLOSE表示驱动程序支持魔术关闭字符功能。由于关闭看门狗字符设备文件不会停止看门狗,这个功能意味着在这个看门狗文件中写入一个V字符(也称为魔术字符或魔术V)序列将允许下一个关闭关闭看门狗(如果没有设置nowayout)。

--WDIOF_POWERUNDER表示设备可以监视/检测不良电源或电源故障。当在watchdog_device.bootstatus中设置时,这个标志意味着机器显示了欠压触发了重置。

--另一方面,WDIOF_POWEROVER表示设备可以监视操作电压。当在watchdog_device.bootstatus中设置时,这意味着系统重置可能是由于过压状态。请注意,如果一个级别低于另一个级别,两个位都将被设置。

--WDIOF_OVERHEAT表示看门狗设备可以监视芯片/SoC 温度。当在watchdog_device.bootstatus中设置时,这意味着通过看门狗导致的上次机器重启的原因是超过了热限制。

--WDIOF_FANFAULT告诉我们这个看门狗设备可以监视风扇。当设置时,意味着看门狗卡监视的系统风扇已经失败。

一些设备甚至有单独的事件输入。如果定义了,这些输入上存在电信号,这也会导致重置。这就是WDIOF_EXTERN1WDIOF_EXTERN2的目的。当在watchdog_device.bootstatus中设置时,这意味着机器上次重启是因为外部继电器/源 1 或 2。

--WDIOF_PRETIMEOUT表示这个看门狗设备支持预超时功能。

--WDIOF_KEEPALIVEPING表示此驱动程序支持WDIOC_KEEPALIVE ioctl(可以通过 ioctl 进行 ping);否则,ioctl 将返回-EOPNOTSUPP。当在watchdog_device.bootstatus中设置时,此标志表示自上次查询以来看门狗看到了一个保持活动的 ping。

--WDIOF_CARDRESET:这是一个特殊标志,只能出现在watchdog_device.bootstatus中。它表示最后一次重启是由看门狗本身引起的(实际上是由它的超时引起的)。

  • firmware_version是卡的固件版本。

  • identity应该是描述设备的字符串。

另一个没有这个结构就无法实现任何操作的是struct watchdog_ops,定义如下:

struct watchdog_ops { struct module *owner;
    /* mandatory operations */
    int (*start)(struct watchdog_device *);
    int (*stop)(struct watchdog_device *);
    /* optional operations */
    int (*ping)(struct watchdog_device *);
    unsigned int (*status)(struct watchdog_device *);
    int (*set_timeout)(struct watchdog_device *, unsigned int);
    int (*set_pretimeout)(struct watchdog_device *,                           unsigned int);
    unsigned int (*get_timeleft)(struct watchdog_device *);
    int (*restart)(struct watchdog_device *,                    unsigned long, void *);
    long (*ioctl)(struct watchdog_device *, unsigned int,
                  unsigned long);
};

前面的结构包含了在看门狗设备上允许的操作列表。每个操作的含义在以下描述中呈现:

  • startstop:这些是强制操作,分别启动和停止看门狗。

  • ping回调用于向看门狗发送保持活动的 ping。这个方法是可选的。如果没有定义,那么看门狗将通过.start操作重新启动,因为这意味着看门狗没有自己的 ping 方法。

  • status是一个可选的例程,返回看门狗设备的状态。如果定义了,其返回值将作为响应WDIOC_GETBOOTSTATUS ioctl 发送。

  • set_timeout是设置看门狗超时值(以秒为单位)的回调。如果定义了,还应该设置X选项标志;否则,任何尝试设置超时都将导致-EOPNOTSUPP错误。

  • set_pretimeout是设置预超时的回调。如果定义了,还应该设置WDIOF_PRETIMEOUT选项标志;否则,任何尝试设置预超时都将导致-EOPNOTSUPP错误。

  • get_timeleft是一个可选操作,返回重置前剩余的秒数。

  • restart:实际上是重新启动机器的例程(而不是看门狗设备)。如果设置了,您可能希望在注册看门狗设备之前调用watchdog_set_restart_priority()来设置此重启处理程序的优先级。

  • ioctl:除非必须,否则不应该实现此回调函数,例如,如果您需要处理额外/非标准的 ioctl 命令。如果定义了此方法,它将覆盖看门狗核心默认的 ioctl,除非它返回-ENOIOCTLCMD

这个结构包含了设备支持的回调函数,根据其能力。

现在我们熟悉了数据结构,我们可以转向看门狗 API,特别是看如何在系统中注册和注销这样一个设备。

注册/注销看门狗设备

看门狗框架提供了两个基本函数来在系统中注册/注销看门狗设备。这些函数分别是watchdog_register_device()watchdog_unregister_device(),它们的原型如下:

int watchdog_register_device(struct watchdog_device *wdd)
void watchdog_unregister_device(struct watchdog_device *wdd)

前面的注册方法在成功路径上返回零,或者在失败时返回一个负的errno代码。另一方面,watchdog_unregister_device()执行相反的操作。为了不再烦恼注销,您可以使用此函数的托管版本devm_watchdog_register_device,其原型如下:

int devm_watchdog_register_device(struct device *dev,                                   struct watchdog_device *wdd)

前面的托管版本将在驱动程序分离时自动处理注销。

注册方法(无论是托管还是非托管)都将检查是否提供了wdd->ops->restart函数,并将此方法注册为重启处理程序。因此,在向系统注册看门狗设备之前,驱动程序应该使用watchdog_set_restart_priority()助手来设置重启优先级,知道重启处理程序的优先级值应遵循以下准则:

  • 0:这是最低优先级,意味着作为最后手段使用看门狗的重启功能;也就是说,在系统中没有提供其他重启处理程序时。

  • 128:这是默认优先级,意味着如果没有其他处理程序可用,或者如果重新启动足以重新启动整个系统,则默认使用此重新启动处理程序。

  • 255:这是最高优先级,可以抢占所有其他处理程序。

设备注册应该在您处理了我们讨论的所有元素之后才能完成;也就是说,在为看门狗设备提供有效的.info.ops和与超时相关的字段之后。在所有这些之前,应为watchdog_device结构分配内存空间。将此结构包装在更大的、每个驱动程序数据结构中是一个好的做法,如下面的示例所示,这是从drivers/watchdog/imx2_wdt.c中摘录的:

[...]
struct imx2_wdt_device {
    struct clk *clk;
    struct regmap *regmap;
    struct watchdog_device wdog;
    bool ext_reset;
};

您可以看到看门狗设备数据结构嵌入在一个更大的结构struct imx2_wdt_device中。现在是probe方法,它初始化所有内容并在更大的结构中设置看门狗设备:

static int init imx2_wdt_probe(struct platform_device *pdev)
{
    struct imx2_wdt_device *wdev;
    struct watchdog_device *wdog; int ret;
    [...]
    wdev = devm_kzalloc(&pdev->dev, sizeof(*wdev), GFP_KERNEL);
    if (!wdev)
        return -ENOMEM;
    [...]
    Wdog = &wdev->wdog;
    if (imx2_wdt_is_running(wdev)) {
        imx2_wdt_set_timeout(wdog, wdog->timeout); 
        set_bit(WDOG_HW_RUNNING, &wdog->status);
    }
    ret = watchdog_register_device(wdog);
    if (ret) {
        dev_err(&pdev->dev, "cannot register watchdog device\n");
        [...]
    }
    return 0;
}
static int exit imx2_wdt_remove(struct platform_device *pdev)
{
    struct watchdog_device *wdog = platform_get_drvdata(pdev);
    struct imx2_wdt_device *wdev = watchdog_get_drvdata(wdog);
    watchdog_unregister_device(wdog);
    if (imx2_wdt_is_running(wdev)) {
      imx2_wdt_ping(wdog);
      dev_crit(&pdev->dev, "Device removed: Expect reboot!\n");
    }
    return 0;
}
[...]

此外,更大的结构可以在move方法中用于跟踪设备状态,特别是内嵌在其中的看门狗数据结构。这就是前面的代码摘录所突出的内容。

到目前为止,我们已经处理了看门狗的基础知识,走过了基本数据结构,并描述了主要的 API。现在,我们可以了解一些高级功能,比如预超时和管理者,以定义系统在看门狗事件发生时的行为。

处理预超时和管理者

在 Linux 内核中,管理者的概念出现在几个子系统中(热管理管理者、CPU 频率管理者,现在是看门狗管理者)。它只是一个实现策略管理(有时以算法的形式)的驱动程序,对系统的某些状态/事件做出反应。

每个子系统实现其管理者驱动程序的方式可能与其他子系统不同,但主要思想仍然是相同的。此外,管理者由唯一名称和正在使用的管理者(策略管理器)标识。它们通常可以在 sysfs 接口内部进行动态更改。

现在,回到看门狗预超时和管理者。可以通过启用CONFIG_WATCHDOG_PRETIMEOUT_GOV内核配置选项向 Linux 内核添加对它们的支持。实际上,内核中有两个看门狗管理者驱动程序:drivers/watchdog/pretimeout_noop.cdrivers/watchdog/pretimeout_panic.c。它们的唯一名称分别是nooppanic。可以通过启用CONFIG_WATCHDOG_PRETIMEOUT_DEFAULT_GOV_NOOPCONFIG_WATCHDOG_PRETIMEOUT_DEFAULT_GOV_PANIC来默认使用其中任何一个。

本节的主要目标是将预超时事件传递给当前活动的看门狗管理者。这可以通过watchdog_notify_pretimeout()接口来实现,其原型如下:

void watchdog_notify_pretimeout(struct watchdog_device *wdd)

正如我们所讨论的,一些看门狗设备在预超时事件发生时会生成一个中断请求。主要思想是在此中断处理程序中调用watchdog_notify_pretimeout()。在底层,此接口将在全局注册的看门狗管理者列表中查找其名称,并调用其.pretimeout回调。

只是为了您的信息,以下是看门狗管理者结构的样子(您可以通过查看drivers/watchdog/pretimeout_noop.cdrivers/watchdog/pretimeout_panic.c中的源代码来了解更多关于看门狗管理者驱动程序的信息):

struct watchdog_governor {
    const char name[WATCHDOG_GOV_NAME_MAXLEN];
    void (*pretimeout)(struct watchdog_device *wdd);
};

显然,其字段必须由底层看门狗管理器驱动程序填充。对于预超时通知的实际使用,您可以参考drivers/watchdog/imx2_wdt.c中定义的 i.MX6 看门狗驱动程序的 IRQ 处理程序。在前一节中已经显示了部分内容。在那里,您会注意到watchdog_notify_pretimeout()是从看门狗(实际上是预超时)IRQ 处理程序中调用的。此外,您会注意到,驱动程序根据看门狗是否有有效的 IRQ 使用不同的watchdog_info结构。如果有有效的 IRQ,将使用.options中设置了WDIOF_PRETIMEOUT标志的结构,这意味着设备具有预超时功能。否则,它将使用未设置WDIOF_PRETIMEOUT标志的结构。

现在我们已经熟悉了管理器和预超时的概念,我们可以考虑学习实现看门狗的另一种方法,例如基于 GPIO 的方法。

基于 GPIO 的看门狗

有时,使用外部看门狗设备可能比使用 SoC 本身提供的看门狗更好,例如出于功耗效率的原因,因为有些 SoC 的内部看门狗需要比外部看门狗更多的功率。大多数情况下,这种外部看门狗设备是通过 GPIO 线控制的,并且具有重置系统的可能性。它通过切换连接的 GPIO 线来进行 ping 操作。这种配置在 UDOO QUAD 中使用(未在其他 UDOO 变体上进行检查)。

Linux 内核能够通过启用CONFIG_GPIO_WATCHDOG config选项来处理此设备,这将拉取底层驱动程序drivers/watchdog/gpio_wdt.c。如果启用,它将定期通过切换连接到 GPIO 线的硬件来ping。如果该硬件未定期接收到 ping,它将重置系统。您应该使用这个而不是直接使用 sysfs 与 GPIO 进行通信;它提供了比 GPIO 更好的 sysfs 用户空间接口,并且与内核框架集成得比您的用户空间代码更好。

这种支持仅来自设备树,有关其绑定的更好文档可以在Documentation/devicetree/bindings/watchdog/gpio-wdt.txt中找到,显然是在内核源代码中。

以下是一个绑定示例:

watchdog: watchdog {
    compatible = "linux,wdt-gpio";
    gpios = <&gpio3 9 GPIO_ACTIVE_LOW>;
    hw_algo = "toggle";
    hw_margin_ms = <1600>;
};

compatible属性必须始终为linux,wdt-gpiogpios是控制看门狗设备的 GPIO 指定器。hw_algo应为togglelevel。前者意味着可以使用低至高或高至低的转换来 ping 外部看门狗设备,并且当 GPIO 线浮空或连接到三态缓冲器时,看门狗被禁用。为了实现这一点,将 GPIO 配置为输入就足够了。第二个algo意味着应用信号电平(高或低)就足以 ping 看门狗。

其工作方式如下:当用户空间代码通过/dev/watchdog设备文件 ping 看门狗时,底层驱动程序(实际上是gpio_wdt.c)将切换 GPIO 线(如果hw_algotoggle,则为1-0-1)或在该 GPIO 线上分配特定电平(如果hw_algolevel,则为高或低)。例如,UDOO QUAD 使用APX823-31W5,一个由 GPIO 控制的看门狗,其事件输出连接到 i.MX6 PORB 线(实际上是复位线)。其原理图在这里可用:udoo.org/download/files/schematics/UDOO_REV_D_schematics.pdf

现在,我们在内核端完成了看门狗。我们已经了解了底层数据结构,处理了其 API,介绍了预超时的概念,甚至处理了基于 GPIO 的看门狗替代方案。在接下来的部分中,我们将研究用户空间实现,这是看门狗服务的一种消费者。

看门狗用户空间接口

在基于 Linux 的系统上,看门狗的标准用户空间接口是/dev/watchdog文件,通过它,守护进程将通知内核看门狗驱动程序用户空间仍然活动。文件打开后,看门狗立即启动,并通过定期写入此文件进行 ping。

当通知发生时,底层驱动程序将通知看门狗设备,这将导致重置其超时;然后看门狗将等待另一个timeout持续时间之后才重置系统。但是,如果由于任何原因用户空间在超时之前未执行通知,看门狗将重置系统(导致重新启动)。这种机制提供了一种强制系统可用性的方法。让我们从基础知识开始,学习如何启动和停止看门狗。

启动和停止看门狗

一旦您打开/dev/watchdog设备文件,看门狗就会自动启动,如下例所示:

int fd;
fd = open("/dev/watchdog", O_WRONLY);
if (fd == -1) {
    if (errno == ENOENT)
        printf("Watchdog device not enabled.\n");
    else if (errno == EACCES)
        printf("Run watchdog as root.\n");
    else
        printf("Watchdog device open failed %s\n", strerror(errno));
    exit(-1);
}

只是关闭看门狗设备文件并不能停止它。关闭文件后,您将惊讶地发现系统重置。要正确停止看门狗,您首先需要向看门狗设备文件写入魔术字符V。这会指示内核在下次关闭设备文件时关闭看门狗,如下所示:

const char v = 'V';
printf("Send magic character: V\n"); ret = write(fd, &v, 1);
if (ret < 0)
    printf("Stopping watchdog ticks failed (%d)...\n", errno);

然后,您需要关闭看门狗设备文件以停止它:

printf("Close for stopping..\n");
close(fd);

重要说明

关闭文件设备以停止看门狗时有一个例外:即内核的CONFIG_WATCHDOG_NOWAYOUT配置选项启用时。启用此选项后,看门狗将无法停止。因此,您需要一直对其进行服务,否则它将重置系统。此外,看门狗驱动程序应该在其选项中设置WDIOF_MAGICCLOSE标志;否则,魔术关闭功能将无法工作。

现在我们已经了解了如何启动和停止看门狗,现在是时候学习如何刷新设备以防止系统突然重新启动了。

ping/kick 看门狗-发送保持活动的 ping

有两种方法可以踢或喂狗:

  1. /dev/watchdog写入任何字符:向看门狗设备文件写入被定义为保持活动的 ping。建议根本不要写入V字符(因为它具有特定含义),即使它在字符串中也是如此。

  2. 使用WDIOC_KEEPALIVE ioctl,ioctl(fd, WDIOC_KEEPALIVE, 0);:忽略 ioctl 的参数。看门狗驱动程序应该在此 ioctl 之前在其选项中设置WDIOF_KEEPALIVEPING标志,以便其工作。

喂狗是一个好的做法,每半个超时值喂一次狗。这意味着如果超时是30s,您应该每15s喂一次。现在,让我们了解一些关于看门狗如何管理我们的系统的信息。

获取看门狗能力和身份

获取看门狗能力和/或身份包括抓取与看门狗关联的底层struct watchdog_info结构。如果您记得的话,这个信息结构是强制性的,并由看门狗驱动程序提供。

为了实现这一点,您需要使用WDIOC_GETSUPPORT ioctl。以下是一个例子:

struct watchdog_info ident;
ioctl(fd, WDIOC_GETSUPPORT, &ident);
printf("WDIOC_GETSUPPORT:\n");
/* Printing the watchdog's identity, its unique name actually */
printf("\tident.identity = %s\n",ident.identity);
/* Printing the firmware version */
printf("\tident.firmware_version = %d\n",        ident.firmware_version);
/* Printing supported options (capabilities) in hex format */
printf("WDIOC_GETSUPPORT: ident.options = 0x%x\n",       ident.options);

我们可以通过测试一些功能来进一步了解其能力,如下所示:

if (ident.options & WDIOF_KEEPALIVEPING)
    printf("\tKeep alive ping reply.\n");
if (ident.options & WDIOF_SETTIMEOUT)
    printf("\tCan set/get the timeout.\n");

您可以(或者我应该说"必须")使用这个来在执行某些操作之前检查看门狗的功能。现在,我们可以进一步学习如何获取和设置更多花哨的看门狗属性。

设置和获取超时和预超时

在设置/获取超时之前,看门狗信息应该设置WDIOF_SETTIMEOUT标志。有一些驱动程序可以使用WDIOC_SETTIMEOUT ioctl 动态修改看门狗超时时间。这些驱动程序必须在其看门狗信息结构中设置WDIOF_SETTIMEOUT标志,并提供.set_timeout回调。

虽然这里的参数是以秒为单位表示的超时值的整数,但返回值是应用于硬件设备的实际超时,因为由于硬件限制,它可能与 ioctl 中请求的超时不同:

int timeout = 45;
ioctl(fd, WDIOC_SETTIMEOUT, &timeout);
printf("The timeout was set to %d seconds\n", timeout);

当涉及到查询当前超时时,您应该使用WDIOC_GETTIMEOUTioctl,如下例所示:

int timeout;
ioctl(fd, WDIOC_GETTIMEOUT, &timeout);
printf("The timeout is %d seconds\n", timeout);

最后,当涉及到预超时时,看门狗驱动程序应该在选项中设置WDIOF_PRETIMEOUT,并在其操作中提供.set_pretimeout回调。然后,您应该使用WDIOC_SETPRETIMEOUT,并将预超时值作为参数:

pretimeout = 10;
ioctl(fd, WDIOC_SETPRETIMEOUT, &pretimeout);

如果所需的预超时值为0或大于当前超时,则会收到-EINVAL错误。

现在我们已经看到了如何获取和设置看门狗设备的超时/预超时,我们可以学习如何获取看门狗触发之前剩余的时间。

获取剩余时间

WDIOC_GETTIMELEFTioctl 允许检查看门狗计数器在发生重置之前剩余多少时间。此外,看门狗驱动程序应通过提供.get_timeleft()回调来支持此功能;否则,您将收到EOPNOTSUPP错误。以下是一个示例,显示如何使用此 ioctl:

int timeleft;
ioctl(fd, WDIOC_GETTIMELEFT, &timeleft);
printf("The remaining timeout is %d seconds\n", timeleft);

timeleft变量在 ioctl 的返回路径上填充。

一旦看门狗触发,它会在配置为这样做时触发重启。在下一节中,我们将学习如何获取上次重启的原因,以查看重启是否是由看门狗引起的。

获取(引导/重新引导)状态

在本节中有两个 ioctl 命令可供使用。这些是WDIOC_GETSTATUSWDIOC_GETBOOTSTATUS。这些的处理方式取决于驱动程序的实现,并且有两种类型的驱动程序实现:

  • 通过杂项设备提供看门狗功能的旧驱动程序。这些驱动程序不使用通用看门狗框架接口,并提供自己的file_ops以及自己的.ioctl操作。此外,这些驱动程序仅支持WDIOC_GETSTATUS,而其他驱动程序可能同时支持WDIOC_GETSTATUSWDIOC_GETBOOTSTATUS。两者之间的区别在于前者将返回设备状态寄存器的原始内容,而后者应该更智能,因为它解析原始内容并仅返回引导状态标志。这些驱动程序需要迁移到新的通用看门狗框架。请注意,一些支持两个命令的驱动程序可能会为两个 ioctl 返回相同的值(相同的case语句),而其他驱动程序可能会返回不同的值(每个命令都有自己的case语句)。

  • 新驱动程序使用通用看门狗框架。这些驱动程序依赖于框架,不再关心file_ops。一切都是从drivers/watchdog/watchdog_dev.c文件中完成的(您可以查看,特别是如何实现 ioctl 命令)。对于这类驱动程序,WDIOC_GETSTATUSWDIOC_GETBOOTSTATUS由看门狗核心分别处理。本节将处理这些驱动程序。

现在,让我们专注于通用实现。对于这些驱动程序,WDIOC_GETBOOTSTATUS将返回底层watchdog_device.bootstatus字段的值。对于WDIOC_GETSTATUS,如果提供了看门狗.status操作,将调用它,并将其返回值复制到用户;否则,将使用AND操作调整watchdog_device.bootstatus的内容,以清除(或标记)无意义的位。以下代码片段显示了在内核空间中如何完成这项工作:

static unsigned int watchdog_get_status(struct                                         watchdog_device *wdd)
{
    struct watchdog_core_data *wd_data = wdd->wd_data;
    unsigned int status;
    if (wdd->ops->status)
        status = wdd->ops->status(wdd);
    else
        status = wdd->bootstatus &
                      (WDIOF_CARDRESET | WDIOF_OVERHEAT |
                       WDIOF_FANFAULT | WDIOF_EXTERN1 |
                       WDIOF_EXTERN2 | WDIOF_POWERUNDER |
                       WDIOF_POWEROVER);
    if (test_bit(_WDOG_ALLOW_RELEASE, &wd_data->status))
        status |= WDIOF_MAGICCLOSE;
    if (test_and_clear_bit(_WDOG_KEEPALIVE, &wd_data->status))
        status |= WDIOF_KEEPALIVEPING;
    return status;
}

上述代码是一个用于获取看门狗状态的通用看门狗核心函数。实际上,它是一个负责调用底层ops.status回调的包装器。现在,回到我们的用户空间使用。我们可以这样做:

int flags = 0;
int flags;
ioctl(fd, WDIOC_GETSTATUS, &flags);
/* or ioctl(fd, WDIOC_GETBOOTSTATUS, &flags); */

显然,我们可以继续像在获取看门狗功能和身份部分中那样进行单独的标志检查。

到目前为止,我们已经编写了与看门狗设备交互的代码。下一节将向我们展示如何在用户空间处理看门狗而无需编写代码,基本上使用 sysfs 接口。

看门狗 sysfs 接口

看门狗框架提供了通过 sysfs 接口从用户空间管理看门狗设备的可能性。如果内核中启用了CONFIG_WATCHDOG_SYSFS配置选项,并且根目录是/sys/class/watchdogX/,则这是可能的。X是系统中看门狗设备的索引。sysfs 中的每个看门狗目录都具有以下内容:

  • nowayout:如果设备支持nowayout功能,则返回1,否则返回0

  • status:这是WDIOC_GETSTATUSioctl 的 sysfs 等效项。此 sysfs 文件报告了看门狗的内部状态位。

  • timeleft:这是WDIOC_GETTIMELEFTioctl 的 sysfs 等效项。此 sysfs 条目返回在看门狗重置系统之前剩余的时间(实际上是秒数)。

  • timeout:给出已编程超时的当前值。

  • identity:包含看门狗设备的标识字符串。

  • bootstatus:这是WDIOC_GETBOOTSTATUSioctl 的 sysfs 等效项。此条目通知系统重置是否是由看门狗设备引起的。

  • state:给出看门狗设备的活动/非活动状态。

现在,前面的看门狗属性已经描述了,我们可以专注于来自用户空间的预超时管理。

处理预超时事件

通过 sysfs 设置管理器。管理器只是根据一些外部(但输入)参数采取某些操作的策略管理器。有热管理器、CPUFreq 管理器,现在还有看门狗管理器。每个管理器都在自己的驱动程序中实现。

您可以使用以下命令检查看门狗(比如watchdog0)的可用管理器:

# cat /sys/class/watchdog/watchdog0/pretimeout_available_governors
noop panic

现在,我们可以检查是否可以选择预超时管理器:

# cat /sys/class/watchdog/watchdog0/pretimeout_governor
panic
# echo -n noop > /sys/class/watchdog/watchdog0/pretimeout_governor
# cat /sys/class/watchdog/watchdog0/pretimeout_governor
noop

要检查预超时值,您可以简单地执行以下操作:

# cat /sys/class/watchdog/watchdog0/pretimeout
10

现在我们熟悉了如何从用户空间使用看门狗 sysfs 接口。虽然我们不在内核中,但我们可以利用整个框架,特别是与看门狗参数交互。

总结

在本章中,我们讨论了看门狗设备的所有方面:它们的 API、GPIO 替代方案以及它们如何帮助保持系统可靠。我们看到了如何启动,如何(在可能的情况下)停止,以及如何维护看门狗设备。此外,我们介绍了预超时和专用看门狗管理器的概念。

在下一章中,我们将讨论一些 Linux 内核开发和调试技巧,比如分析内核恐慌消息和内核跟踪。

第十四章:Linux 内核调试技巧和最佳实践

大多数情况下,在开发过程中,编写代码并不是最困难的部分。困难之处在于 Linux 内核是操作系统最底层的独立软件。这使得调试 Linux 内核变得具有挑战性。然而,这得到了补偿,因为大多数情况下,我们不需要额外的工具来调试内核代码,因为大多数内核调试工具都是内核本身的一部分。我们将首先熟悉 Linux 内核发布模型,然后学习 Linux 内核发布流程和步骤。接下来,我们将关注与 Linux 内核调试相关的开发技巧(特别是通过打印进行调试),最后,我们将专注于跟踪 Linux 内核,以离线调试和学习如何利用内核 oops 结束。

本章将涵盖以下主题:

  • 了解 Linux 内核发布流程

  • Linux 内核开发技巧

  • Linux 内核跟踪和性能分析

  • Linux 内核调试技巧

技术要求

本章的先决条件如下:

了解 Linux 内核发布流程

根据 Linux 内核发布模型,始终存在三种类型的活动内核发布:主线、稳定发布和长期支持(LTS)发布。首先,子系统维护者收集和准备 bug 修复和新功能,然后提交给 Linus Torvalds,以便他将它们包含在自己的 Linux 树中,这被称为主线 Linux 树,也被称为主 Git 存储库。这是每个稳定发布的起源。

在每个新内核版本发布之前,它都会通过发布候选标签提交给社区,以便开发人员可以测试和完善所有新功能,最重要的是分享反馈。在这个周期内,Linus 将依赖反馈来决定最终版本是否准备好发布。当他确信新内核准备就绪时,他会进行(实际上是标记)最终发布,我们称这个发布为稳定,以表示它不再是发布候选:这些发布是vX.Y版本。

没有制定发布的严格时间表。然而,新的主线内核通常每 2-3 个月发布一次。稳定内核发布基于 Linus 的发布,即主线树发布。

一旦 Linus 发布了主线内核,它也会出现在 linux-stable 树中(位于git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/),在那里它成为一个分支,并且可以接收稳定发布的 bug 修复。Greg Kroah-Hartman 负责维护这个树,它也被称为稳定树,因为它用于跟踪先前发布的稳定内核。也就是说,为了将修复应用到这个树中,这个修复必须首先被合并到 Linus 的树中。因为修复必须先前进再返回,所以说这个修复是被反向移植的。一旦在主线存储库中修复了 bug,它就可以应用到仍由内核开发社区维护的先前发布的内核中。所有反向移植到稳定发布的修复必须符合一组强制接受标准,其中之一是它们必须已经存在于 Linus 的树中。

重要提示

修复 bug 的内核发布被认为是稳定的。

例如,4.9内核由 Linus 发布,然后基于此内核的稳定内核发布被编号为4.9.14.9.24.9.3等。这些发布被称为错误修复内核发布,通常在稳定内核发布树中引用它们的分支时,序列通常缩短为数字4.9.y。每个稳定内核发布树由单个内核开发人员维护,负责挑选发布所需的补丁,并执行审查/发布流程。通常只有几个错误修复内核发布,直到下一个主线内核可用,除非它被指定为长期维护内核

每个子系统和内核维护者存储库都托管在这里:git.kernel.org/pub/scm/linux/kernel/git/。在那里,我们也可以找到 Linus 或稳定树。在 Linus 树中(git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/),Linus 的树中只有一个分支,即主分支。其中的标签要么是稳定发布,要么是发布候选版本。在稳定树中(git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/),每个稳定内核发布都有一个分支(命名为<A.B>.y,其中<A.B>是 Linus 树中的发布版本),每个分支都包含其错误修复内核发布。

重要提示

有一些链接可以随时保留,以便跟踪 Linux 内核发布。第一个是www.kernel.org/,您可以从这里下载内核存档,然后是www.kernel.org/category/releases.html,您可以访问最新的 LTS 内核发布及其支持时间表。您还可以参考此链接patchwork.kernel.org/,从这里您可以按子系统基础跟踪内核补丁提交。

现在我们熟悉了 Linux 内核发布模型,我们可以深入一些开发提示和最佳实践,这有助于巩固和利用其他内核开发人员的经验。

Linux 内核开发提示

最佳的 Linux 内核开发实践是受现有内核代码的启发。这样,您肯定可以学到良好的实践。也就是说,我们不会重复造轮子。我们将专注于本章所需的内容,即调试。最常用的调试方法涉及日志记录和打印。为了利用这种经过时间考验的调试技术,Linux 内核提供了适当的日志记录 API,并公开了内核消息缓冲区以存储日志。虽然这似乎很明显,我们将专注于内核日志记录 API,并学习如何管理消息缓冲区,无论是从内核代码内部还是从用户空间。

消息打印

消息打印和记录是开发的固有部分,无论我们是在内核空间还是用户空间。在内核中,printk()函数长期以来一直是事实上的内核消息打印函数。它类似于 C 库中的printf(),但具有日志级别的概念。

如果您查看实际驱动程序代码的示例,您会注意到它的用法如下:

printk(<LOG_LEVEL> "printf like formatted message\n");

在这里,<LOG_LEVEL>include/linux/kern_levels.h中定义的八个不同日志级别之一,并指定错误消息的严重程度。您还应该注意,日志级别和格式字符串之间没有逗号(因为预处理器连接了这两个字符串)。

内核日志级别

Linux 内核使用级别的概念来确定消息的严重程度。共有八个级别,每个级别都定义为一个字符串,它们的描述如下:

  • KERN_EMERG,定义为"0"。它用于紧急消息,意味着系统即将崩溃或不稳定(无法使用)。

  • KERN_ALERT,定义为"1",意味着发生了严重的事情,必须立即采取行动。

  • KERN_CRIT,定义为"2",意味着发生了严重的条件,例如严重的硬件/软件故障。

  • KERN_ERR,定义为"3",在错误情况下使用,通常由驱动程序用于指示与硬件的困难或与子系统的交互失败。

  • KERN_WARNING,定义为"4",用作警告,本身并不严重,但可能表示问题。

  • KERN_NOTICE,定义为"5",意味着没有严重问题,但仍然值得注意。这经常用于报告安全事件。

  • KERN_INFO,定义为"6",用于信息消息,例如驱动程序初始化时的启动信息。

  • KERN_DEBUG,定义为"7",用于调试目的,仅在启用DEBUG内核选项时才有效。否则,它的内容将被简单地忽略。

如果您在消息中未指定日志级别,则默认为DEFAULT_MESSAGE_LOGLEVEL(通常为"4" = KERN_WARNING),可以通过CONFIG_DEFAULT_MESSAGE_LOGLEVEL内核配置选项进行设置。

因此,对于新的驱动程序,建议您使用更方便的打印 API,这些 API 在其名称中嵌入了日志级别。这些打印助手是pr_emergpr_alertpr_critpr_errpr_warningpr_warnpr_noticepr_infopr_debugpr_dbg。除了比等效的printk()调用更简洁之外,它们还可以通过pr_fmt()宏使用格式字符串的通用定义;例如,在源文件的顶部(在任何#include指令之前)定义这个:

#define pr_fmt(fmt) "%s:%s: " fmt, KBUILD_MODNAME, __func__

这将在该文件中的每个pr_*()消息前缀模块和生成消息的函数名称。如果内核是使用DEBUG编译的,则pr_develpr_debug将被替换为printk(KERN_DEBUG …),否则它们将被替换为空语句。

pr_*()系列宏应该在核心代码中使用。对于设备驱动程序,您应该使用与设备相关的辅助程序,这些辅助程序还接受相关设备结构作为参数。它们还以标准形式打印相关设备的名称,确保始终可以将消息与生成它的设备关联起来:

dev_emerg(const struct device *dev, const char *fmt, ...);
dev_alert(const struct device *dev, const char *fmt, ...);
dev_crit(const struct device *dev, const char *fmt, ...);
dev_err(const struct device *dev, const char *fmt, ...);
dev_warn(const struct device *dev, const char *fmt, ...);
dev_notice(const struct device *dev, const char *fmt, ...);
dev_info(const struct device *dev, const char *fmt, ...);
dev_dbg(const struct device *dev, const char *fmt, ...);

虽然日志级别的概念被内核用来确定消息的重要性,但它也用于决定是否应立即将此消息呈现给用户,通过将其打印到当前控制台(其中控制台也可以是串行线或甚至打印机,而不是xterm)。

为了决定,内核将消息的日志级别与console_loglevel内核变量进行比较,如果消息的日志级别重要性更高(即较低的值)比console_loglevel,则消息将被打印到当前控制台。由于默认内核日志级别通常为"4",这就是为什么您在控制台上看不到pr_info()pr_notice()甚至pr_warn()消息的原因,因为它们具有更高或相等的值(这意味着优先级更低)比默认值。

要确定系统上的当前console_loglevel,您只需输入以下内容:

$ cat /proc/sys/kernel/printk
4    4    1    7

第一个整数(4)是当前控制台日志级别,第二个数字(4)是默认值,第三个数字(1)是可以设置的最小控制台日志级别,第四个数字(7)是启动时的默认控制台日志级别。

要更改当前的console_loglevel,只需写入同一文件,即/proc/sys/kernel/printk。因此,为了将所有消息打印到控制台,执行以下简单命令:

# echo 8 > /proc/sys/kernel/printk

每条内核消息都会出现在您的控制台上。然后您将看到以下内容:

# cat /proc/sys/kernel/printk
8    4    1    7

改变控制台日志级别的另一种方法是使用带有-n参数的dmesg

# dmesg -n 5

通过上述命令,console_loglevel被设置为打印KERN_WARNING4)或更严重的消息。您还可以在引导时使用loglevel引导参数指定console_loglevel(有关更多详细信息,请参阅Documentation/kernel-parameters.txt)。

重要说明

还有KERN_CONTpr_cont,它们有点特殊,因为它们不指定紧急级别,而是指示继续的消息。它们只应该在早期引导期间由核心/架构代码使用(否则,继续的行不是 SMP 安全的)。当要打印的消息行的一部分取决于计算结果时,这可能是有用的,如下面的示例所示:

[…]
pr_warn("your last operation was ");
if (success)
   pr_cont("successful\n");
else
   pr_cont("NOT successful\n");

您应该记住,只有最终的打印语句才有尾随的\n字符。

内核日志缓冲区

无论它们是否立即在控制台上打印,每个内核消息都会被记录在一个缓冲区中。这个内核消息缓冲区是一个固定大小的循环缓冲区,这意味着如果缓冲区填满,它会环绕并且您可能会丢失一条消息。因此,增加缓冲区大小可能会有所帮助。为了改变内核消息缓冲区大小,您可以调整LOG_BUF_SHIFT选项,其值用于左移 1 以获得最终大小,即内核日志缓冲区大小(例如,16 => 1<<16 => 64KB17 => 1 << 17 => 128KB)。也就是说,这是一个在编译时定义的静态大小。这个大小也可以通过内核引导参数定义,通过使用log_buf_len参数,换句话说,log_buf_len=1M(只接受 2 的幂值)。

添加时间信息

有时,向打印的消息添加时间信息是有用的,这样您就可以看到特定事件发生的时间。内核包括一个用于执行此操作的功能,称为printk times,通过CONFIG_PRINTK_TIME选项启用。在配置内核时,此选项可以在内核调试菜单中找到。一旦启用,此时间信息将作为每条日志消息的前缀。

$ dmesg
[]
[    1.260037] loop: module loaded
[    1.260194] libphy: Fixed MDIO Bus: probed
[    1.260195] tun: Universal TUN/TAP device driver, 1.6
[    1.260224] PPP generic driver version 2.4.2
[    1.260260] ehci_hcd: USB 2.0 'Enhanced' Host Controller (EHCI) Driver
[    1.260262] ehci-pci: EHCI PCI platform driver
[    1.260775] ehci-pci 0000:00:1a.7: EHCI Host Controller
[    1.260780] ehci-pci 0000:00:1a.7: new USB bus registered, assigned bus number 1
[    1.260790] ehci-pci 0000:00:1a.7: debug port 1
[    1.264680] ehci-pci 0000:00:1a.7: cache line size of 64 is not supported
[    1.264695] ehci-pci 0000:00:1a.7: irq 22, io mem 0xf7ffa000
[    1.280103] ehci-pci 0000:00:1a.7: USB 2.0 started, EHCI 1.00
[    1.280146] usb usb1: New USB device found, idVendor=1d6b, idProduct=0002
[    1.280147] usb usb1: New USB device strings: Mfr=3, Product=2, SerialNumber=1
[]

内核消息输出中插入的时间戳由秒和微秒(实际上是秒.微秒)组成,作为从机器操作开始(或从内核时间跟踪开始)的绝对值,对应于引导加载程序将控制权传递给内核时的时间(当您在控制台上看到类似[ 0.000000] Booting Linux on physical CPU 0x0的内容)。

可以通过写入/sys/module/printk/parameters/time来在运行时控制printk时间戳,以启用和禁用printk时间戳。以下是示例:

# echo 1 >/sys/module/printk/parameters/time
# cat /sys/module/printk/parameters/time
N
# echo 1 >/sys/module/printk/parameters/time
# cat /sys/module/printk/parameters/time
Y

它不控制时间戳是否被记录。它只控制在内核消息缓冲区被转储时、在启动时或在使用dmesg时是否打印。这可能是启动时优化的一个领域。如果禁用,日志打印所需的时间将更少。

我们现在熟悉了内核打印 API 及其日志缓冲区。我们已经看到了如何调整消息缓冲区,并根据需求添加或删除信息。这些技能可以用于通过打印进行调试。然而,Linux 内核中还提供了其他调试和跟踪工具,下一节将介绍其中一些。

Linux 内核跟踪和性能分析

尽管通过打印进行调试可以满足大部分调试需求,但有时我们需要在运行时监视 Linux 内核以跟踪奇怪的行为,包括延迟、CPU 占用、调度问题等。在 Linux 世界中,实现这一目标最有用的工具是内核本身的一部分。最重要的是ftrace,它是 Linux 内核内部跟踪工具,也是本节的主要内容。

使用 Ftrace 来对代码进行仪器化

功能跟踪,简称 Ftrace,不仅仅是其名称所说的那样。例如,它可以用来测量处理中断所需的时间,跟踪耗时的函数,计算激活高优先级任务所需的时间,跟踪上下文切换等等。

Steven Rostedt开发,Ftrace 自 2008 年 2.6.27 版内核开始已经包含在内核中。这是一个提供用于记录数据的调试环形缓冲区的框架。这些数据是由内核集成的跟踪程序收集的。Ftrace 在debugfs文件系统之上工作,并且在大多数情况下,在启用时会被挂载在自己的名为tracing的目录中。在大多数现代 Linux 发行版中,默认情况下会在/sys/kernel/debug/目录中挂载(这仅对 root 用户可用),这意味着您可以在/sys/kernel/debug/tracing/中利用 Ftrace。

以下是要在系统上启用以支持 Ftrace 的内核选项:

CONFIG_FUNCTION_TRACER
CONFIG_FUNCTION_GRAPH_TRACER
CONFIG_STACK_TRACER
CONFIG_DYNAMIC_FTRACE

前面的选项取决于支持跟踪功能的架构,需要启用CONFIG_HAVE_FUNCTION_TRACERCONFIG_HAVE_DYNAMIC_FTRACECONFIG_HAVE_FUNCTION_GRAPH_TRACER选项。

要挂载tracefs目录,您可以将以下行添加到您的/etc/fstab文件中:

tracefs   /sys/kernel/debug/tracing   tracefs defaults   0   0

或者您可以使用以下命令在运行时挂载它:

mount -t tracefs nodev /sys/kernel/debug/tracing

目录的内容应该如下所示:

# ls /sys/kernel/debug/tracing/
README                      set_event_pid
available_events            set_ftrace_filter
available_filter_functions  set_ftrace_notrace
available_tracers           set_ftrace_pid
buffer_size_kb              set_graph_function
buffer_total_size_kb        set_graph_notrace
current_tracer              snapshot
dyn_ftrace_total_info       stack_max_size
enabled_functions           stack_trace
events                      stack_trace_filter
free_buffer                 trace
function_profile_enabled    trace_clock
instances                   trace_marker
max_graph_depth             trace_options
options                     trace_pipe
per_cpu                     trace_stat
printk_formats              tracing_cpumask
saved_cmdlines              tracing_max_latency
saved_cmdlines_size         tracing_on
set_event                   tracing_thresh

我们不会描述所有这些文件和子目录,因为官方文档已经涵盖了这些内容。相反,我们只会简要描述与我们上下文相关的文件:

  • available_tracers:可用的跟踪程序。

  • tracing_cpumask:允许对选定的 CPU 进行跟踪。掩码应该以十六进制字符串格式指定。例如,要跟踪只有核心0,你应该在这个文件中包含1。要跟踪核心1,你应该在其中包含2。对于核心3,应该包含数字8

  • current_tracer:当前正在运行的跟踪程序。

  • tracing_on:负责启用或禁用数据写入环形缓冲区的系统文件(要启用此功能,必须在文件中添加数字1;要禁用它,添加数字0)。

  • trace:保存跟踪数据的文件,以人类可读的格式。

现在我们已经介绍了 Ftrace 并描述了它的功能,我们可以深入了解其用法,并了解它在跟踪和调试目的中有多么有用。

可用的跟踪器

我们可以使用以下命令查看可用跟踪器的列表:

# cat /sys/kernel/debug/tracing/available_tracers 
blk function_graph wakeup_dl wakeup_rt wakeup irqsoff function nop

让我们快速看一下每个跟踪器的特点:

  • function:无参数的函数调用跟踪器。

  • function_graph:带有子调用的函数调用跟踪器。

  • blk:与块设备 I/O 操作相关的调用和事件跟踪器(这是blktrace使用的)。

  • mmiotrace:内存映射 I/O 操作跟踪器。它跟踪模块对硬件的所有调用。它通过CONFIG_MMIOTRACE启用,这取决于CONFIG_HAVE_MMIOTRACE_SUPPORT

  • irqsoff:跟踪禁用中断的区域,并保存具有最长最大延迟的跟踪。此跟踪器取决于CONFIG_IRQSOFF_TRACER

  • preemptoff:取决于CONFIG_PREEMPT_TRACER。类似于irqsoff,但跟踪和记录禁用抢占的时间。

  • preemtirqsoff:类似于irqsoffpreemptoff,但跟踪和记录中断和/或抢占被禁用的最长时间。

  • wakeupwakeup_rt,由CONFIG_SCHED_TRACER启用:前者跟踪和记录唤醒后最高优先级任务被调度所需的最大延迟,而后者跟踪和记录唤醒后仅wakeup跟踪器所需的最大延迟。

  • nop:最简单的跟踪器,顾名思义,什么也不做。nop跟踪器只是显示trace_printk()调用的输出。

irqsoffpreemptoffpreemtirqsoff是所谓的延迟跟踪器。它们测量中断被禁用的时间,抢占被禁用的时间,以及中断和/或抢占被禁用的时间。唤醒延迟跟踪器测量进程在被唤醒后运行所需的时间,可以是所有任务或仅实时任务。

函数跟踪器

我们将从函数跟踪器开始介绍 Ftrace。让我们看一个测试脚本:

# cd /sys/kernel/debug/tracing
# echo function > current_tracer
# echo 1 > tracing_on
# sleep 1
# echo 0 > tracing_on
# less trace

这个脚本非常简单,但有一些值得注意的事情。我们通过将其名称写入current_tracer文件来启用当前跟踪器。接下来,我们将1写入tracing_on,这将启用环形缓冲区。语法要求1>符号之间有一个空格;echo1> tracing_on将无法工作。一行之后,我们将其禁用(如果将0写入tracing_on,缓冲区将不会清除,Ftrace 也不会被禁用)。

为什么要这样做?在两个echo命令之间,我们看到sleep 1命令。我们启用缓冲区,运行此命令,然后禁用它。这样可以让跟踪器包含与命令运行时发生的所有系统调用相关的信息。在脚本的最后一行,我们给出了在控制台中显示跟踪数据的命令。脚本运行后,我们将看到以下打印输出(这只是一个小片段):

图 14.1 - Ftrace 函数跟踪器快照

图 14.1 - Ftrace 函数跟踪器快照

打印开始时包含有关缓冲区中条目数量和已写入的总条目数量的信息。这两个数字之间的差异是填充缓冲区时丢失的事件数量。然后,列出了包括以下信息的函数列表:

  • 进程名称(TASK)。

  • 进程标识符(PID)。

  • 进程运行的 CPU(CPU#)。

  • 函数开始时间(TIMESTAMP)。此时间戳是自启动以来的时间。

  • 正在跟踪的函数的名称(FUNCTION)和在<-符号后调用的父函数。例如,在我们输出的第一行中,irq_may_run函数是由handle_fasteoi_irq调用的。

现在我们熟悉了函数跟踪器及其特点,我们可以了解下一个跟踪器,它更加功能丰富,提供了更多的跟踪信息,例如调用图。

function_graph跟踪器

function_graph跟踪器的工作方式类似于函数,但更详细:显示每个函数的入口和出口点。使用此跟踪器,我们可以跟踪具有子调用的函数,并测量每个函数的执行时间。

让我们编辑我们先前示例的脚本:

# cd /sys/kernel/debug/tracing
# echo function_graph > current_tracer
# echo 1 > tracing_on
# sleep 1
# echo 0 > tracing_on
# less trace

运行此脚本后,我们得到以下打印输出:

# tracer: function_graph
#
# CPU  DURATION                  FUNCTION CALLS
# |     |   |                     |   |   |   |
 5)   0.400 us    |                } /* set_next_buddy */
 5)   0.305 us    |                __update_load_avg_se();
 5)   0.340 us    |                __update_load_avg_cfs_rq();
 5)               |                update_cfs_group() {
 5)               |                  reweight_entity() {
 5)               |                    update_curr() {
 5)   0.376 us    |                      __calc_delta();
 5)   0.308 us    |                      update_min_vruntime();
 5)   1.754 us    |                    }
 5)   0.317 us    |                   account_entity_dequeue();
 5)   0.260 us    |                   account_entity_enqueue();
 5)   3.537 us    |                  }
 5)   4.221 us    |                }
 5)   0.261 us    |                hrtick_update();
 5) + 16.852 us   |              } /* dequeue_task_fair */
 5) + 23.353 us   |            } /* deactivate_task */
 5)               |            pick_next_task_fair() {
 5)   0.286 us    |              update_curr();
 5)   0.271 us    |              check_cfs_rq_runtime();
 5)               |              pick_next_entity() {
 5)   0.441 us    |            wakeup_preempt_entity.isra.77();
 5)   0.306 us    |                clear_buddies();
 5)   1.645 us    |              }
 ------------------------------------------
 5) SCTP ti-27174  =>  Composi-2089 
 ------------------------------------------
 5)   0.632 us    |              __switch_to_xtra();
 5)   0.350 us    |              finish_task_switch();
 5) ! 271.440 us  |            } /* schedule */
 5)               |            _cond_resched() {
 5)   0.267 us    |              rcu_all_qs();
 5)   0.834 us    |            }
 5) ! 273.311 us  |          } /* futex_wait_queue_me */

在此图中,DURATION显示运行函数所花费的时间。请特别注意标有+!符号的点。加号(+)表示函数花费的时间超过 10 微秒,而感叹号(!)表示函数花费的时间超过 100 微秒。在FUNCTION_CALLS下,我们找到与每个函数调用相关的信息。用于显示每个函数启动和完成的符号与 C 编程语言中相同:大括号({})标记函数,一个在开始,一个在结束;不调用任何其他函数的叶函数用分号(;)标记。

Ftrace 还允许仅对超过一定时间的函数进行跟踪,使用tracing_thresh选项。应在微秒单位中将应记录函数的时间阈值写入该文件。这可用于查找在内核中花费很长时间的例程。可能有趣的是在内核启动时使用此功能,以帮助优化启动时间。要在启动时设置阈值,可以在内核命令行中设置如下:

tracing_thresh=200 ftrace=function_graph

这将跟踪所有执行时间超过 200 微秒(0.2 毫秒)的函数。您可以使用任何持续时间阈值。

在运行时,您可以简单地执行echo 200 > tracing_thresh

函数过滤器

挑选要跟踪的函数。不言而喻,要跟踪的函数越少,开销就越小。Ftrace 的输出可能很大,要找到自己想要的内容可能会非常困难。然而,我们可以使用过滤器来简化搜索:输出将只显示我们感兴趣的函数的信息。为此,我们只需在set_ftrace_filter文件中写入我们函数的名称,如下所示:

# echo kfree > set_ftrace_filter

要禁用过滤器,我们在这个文件中添加一个空行:

# echo  > set_ftrace_filter

然后运行以下命令:

# echo kfree > set_ftrace_notrace

结果正好相反:输出将给出有关除kfree()之外的每个函数的信息。另一个有用的选项是set_ftrace_pid。这个工具用于跟踪可以代表特定进程调用的函数。

Ftrace 还有许多其他过滤选项。要更详细地了解这些选项,您可以阅读www.kernel.org/doc/Documentation/trace/ftrace.txt上提供的官方文档。

跟踪事件

介绍跟踪事件之前,让我们先谈谈tracepoints。Tracepoints 是触发系统事件的特殊代码插入。Tracepoints 可以是动态的(意味着它们有几个附加的检查),也可以是静态的(没有附加检查)。

静态 tracepoints 不会以任何方式影响系统;它们只会在被检测的函数末尾添加几个字节的函数调用,并在一个单独的部分中添加一个数据结构。动态 tracepoints 在相关代码片段执行时调用跟踪函数。跟踪数据被写入环形缓冲区。Tracepoints 可以包含在代码的任何位置。实际上,它们已经可以在许多内核函数中找到。让我们看一下mm/slab.ckmem_cache_free函数的摘录:

void kmem_cache_free(struct kmem_cache *cachep, void *objp)
{
	[...]
	trace_kmem_cache_free(_RET_IP_, objp);
}

kmem_cache_free本身就是一个 tracepoint。我们可以通过查看其他内核函数的源代码找到无数更多的例子。

Linux 内核有一个专门的 API 用于从用户空间处理 tracepoints。在/sys/kernel/debug/tracing目录中,有一个events目录,其中保存了系统事件。这些事件可供跟踪。在这个上下文中,系统事件可以理解为内核中包含的 tracepoints。

通过运行以下命令,可以查看这些列表:

# cat /sys/kernel/debug/tracing/available_events
mac80211:drv_return_void
mac80211:drv_return_int
mac80211:drv_return_bool
mac80211:drv_return_u32
mac80211:drv_return_u64
mac80211:drv_start
mac80211:drv_get_et_strings
mac80211:drv_get_et_sset_count
mac80211:drv_get_et_stats
mac80211:drv_suspend
[...]

控制台将打印出一个长列表,格式为<subsystem>:<tracepoint>。这有点不方便。我们可以通过使用以下命令打印出更结构化的列表:

# ls /sys/kernel/debug/tracing/events
block         gpio          napi          regmap      syscalls
cfg80211      header_event  net           regulator   task
clk           header_page   oom           rpm         timer
compaction    i2c           pagemap       sched       udp
enable        irq           power         signal      vmscan
fib           kmem          printk        skb         workqueue
filelock      mac80211      random        sock        writeback
filemap       migrate       raw_syscalls  spi
ftrace        module        rcu           swiotlb

所有可能的事件都组合在子目录中。在我们开始跟踪事件之前,我们将确保已经启用了对环形缓冲区的写入。

第一章中,嵌入式开发人员的 Linux 内核概念,我们介绍了hrtimers。通过列出/sys/kernel/debug/tracing/events/timer的内容,我们将得到与定时器相关的 tracepoints,包括与hrtimer相关的内容,如下所示:

# ls /sys/kernel/debug/tracing/events/timer
enable                hrtimer_init          timer_cancel
filter                hrtimer_start         timer_expire_entry
hrtimer_cancel        itimer_expire         timer_expire_exit
hrtimer_expire_entry  itimer_state          timer_init
hrtimer_expire_exit   tick_stop             timer_start
#

现在让我们跟踪对hrtimer相关内核函数的访问。对于我们的跟踪器,我们将使用nop,因为functionfunction_graph记录了太多信息,包括我们不感兴趣的事件信息。以下是我们将使用的脚本:

# cd /sys/kernel/debug/tracing/
# echo 0 > tracing_on
# echo > trace
# echo nop > current_tracer 
# echo 1 > events/timer/enable 
# echo 1 > tracing_on;
# sleep 1;
# echo 0 > tracing_on; 
# echo 0 > events/timer/enable 
# less trace

首先,我们禁用跟踪(以防已经在运行)。然后清除环形缓冲区数据,然后将当前 tracer 设置为nop。接下来,我们启用与定时器相关的 tracepoints,或者说,我们启用定时器事件跟踪。最后,我们启用跟踪并转储环形缓冲区内容,内容如下:

图 14.2 - 使用 nop tracer 快照进行 Ftrace 事件跟踪

图 14.2 - 使用 nop tracer 快照进行 Ftrace 事件跟踪

在打印输出的末尾,我们将找到有关hrtimer函数调用的信息(这里是一个小节)。有关配置事件跟踪的更详细信息可以在这里找到:www.kernel.org/doc/Documentation/trace/events.txt

使用 Ftrace 接口跟踪特定进程

使用 Ftrace 可以让您拥有启用跟踪的内核跟踪点/函数,而不管这些函数代表哪个进程运行。要跟踪代表特定函数执行的内核函数,您应该将伪set_ftrace_pid变量设置为pgrep,例如。如果进程尚未运行,可以使用包装器 shell 脚本和exec命令以已知 PID 执行命令,如下所示:

#!/bin/sh
echo $$ > /debug/tracing/set_ftrace_pid
# [can set other filtering here]
echo function_graph > /debug/tracing/current_tracer
exec $*

在上面的例子中,$$是当前执行进程的 PID(即 shell 脚本本身)。这是在set_ftrace_pid变量中设置的,然后启用function_graph跟踪器,之后这个脚本执行命令(由脚本的第一个参数指定)。

假设脚本名称为trace_process.sh,使用示例可能如下:

sudo ./trace_command ls

现在我们熟悉了跟踪事件和跟踪点。我们能够跟踪特定的内核事件或子系统。虽然在内核开发中跟踪是必须的,但有时会影响内核的稳定性。这种情况可能需要离线分析,这在调试中得到了解决,并在下一节中进行了讨论。

Linux 内核调试技巧

编写代码并不总是内核开发中最困难的方面。调试才是真正的瓶颈,即使对经验丰富的内核开发人员也是如此。也就是说,大多数内核调试工具都是内核本身的一部分。有时,通过内核消息称为Oops来辅助找到故障发生的位置。然后调试就变成了分析消息。

Oops 和紧急情况分析

Oops 是 Linux 内核在发生错误或未处理的异常时打印的消息。它尽力描述异常并在错误或异常发生之前转储调用堆栈。

例如,考虑以下内核模块:

#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/init.h>

static void __attribute__ ((__noinline__)) create_oops(void) {
        *(int *)0 = 0;
}

static int __init my_oops_init(void) {
       printk("oops from the module\n");
       create_oops();
       return 0;
}
static void __exit my_oops_exit(void) {
       printk("Goodbye world\n");
}
module_init(my_oops_init);
module_exit(my_oops_exit);
MODULE_LICENSE("GPL");

在上述模块代码中,我们尝试取消引用空指针以使内核发生紧急情况。此外,我们使用__noinline__属性,以便create_oops()不会被内联,允许它在反汇编和调用堆栈中显示为单独的函数。此模块已在 ARM 和 x86 平台上构建和测试。Oops 消息和内容将因机器而异:

# insmod /oops.ko 
[29934.977983] Unable to handle kernel NULL pointer dereference at virtual address 00000000
[29935.010853] pgd = cc59c000
[29935.013809] [00000000] *pgd=00000000
[29935.017425] Internal error: Oops - BUG: 805 [#1] PREEMPT ARM
[...]
[29935.193185] systime: 1602070584s
[29935.196435] CPU: 0 PID: 20021 Comm: insmod Tainted: P           O    4.4.106-ts-armv7l #1
[29935.204629] Hardware name: Columbus Platform
[29935.208916] task: cc731a40 ti: cc66c000 task.ti: cc66c000
[29935.214354] PC is at create_oops+0x18/0x20 [oops]
[29935.219082] LR is at my_oops_init+0x18/0x1000 [oops]
[29935.224068] pc : [<bf2a8018>]    lr : [<bf045018>]    psr: 60000013
[29935.224068] sp : cc66dda8  ip : cc66ddb8  fp : cc66ddb4
[29935.235572] r10: cc68c9a4  r9 : c08058d0  r8 : c08058d0
[29935.240813] r7 : 00000000  r6 : c0802048  r5 : bf045000  r4 : cd4eca40
[29935.247359] r3 : 00000000  r2 : a6af642b  r1 : c05f3a6a  r0 : 00000014
[29935.253906] Flags: nZCv  IRQs on  FIQs on  Mode SVC_32  ISA ARM  Segment none
[29935.261059] Control: 10c5387d  Table: 4c59c059  DAC: 00000051
[29935.266822] Process insmod (pid: 20021, stack limit = 0xcc66c208)
[29935.272932] Stack: (0xcc66dda8 to 0xcc66e000)
[29935.277311] dda0:                   cc66ddc4 cc66ddb8 bf045018 bf2a800c cc66de44 cc66ddc8
[29935.285518] ddc0: c01018b4 bf04500c cc66de0c cc66ddd8 c01efdbc a6af642b cff76eec cff6d28c
[29935.293725] dde0: cf001e40 cc24b600 c01e80b8 c01ee628 cf001e40 c01ee638 cc66de44 cc66de08
[...]
[29935.425018] dfe0: befdcc10 befdcc00 004fda50 b6eda3e0 a0000010 00000003 00000000 00000000
[29935.433257] Code: e24cb004 e52de004 e8bd4000 e3a03000 (e5833000) 
[29935.462814] ---[ end trace ebc2c98aeef9342e ]---
[29935.552962] Kernel panic - not syncing: Fatal exception

让我们仔细看一下上面的转储,以了解一些重要信息:

[29934.977983] Unable to handle kernel NULL pointer dereference at virtual address 00000000

第一行描述了错误及其性质,本例中指出代码尝试取消引用NULL指针:

[29935.214354] PC is at create_oops+0x18/0x20 [oops]

create_oops函数位于oops模块中(列在方括号中)。十六进制数字表示指令指针位于函数内的24(十六进制为0x18)字节处,该函数似乎为32(十六进制为0x20)字节长:

[29935.219082] LR is at my_oops_init+0x18/0x1000 [oops]

LR是链接寄存器,它包含程序计数器在达到“从子例程返回”指令时应设置的地址。换句话说,LR保存调用当前执行函数的函数的地址(PC所在的函数)。首先,这意味着my_oops_init是调用执行代码的函数。这也意味着如果PC中的函数返回,将执行的下一行将是my_oops_init+0x18,这意味着 CPU 将在my_oops_init的起始地址偏移0x18处分支:

[29935.224068] pc : [<bf2a8018>]    lr : [<bf045018>]    psr: 60000013

在上一行代码中,pclrPCLR的真实十六进制内容,没有显示符号名称。这些地址可以与addr2line程序一起使用,这是另一个我们可以用来找到错误行的工具。这是如果内核是使用CONFIG_KALLSYMS选项禁用构建的话,我们在打印输出中会看到的内容。然后我们可以推断create_oopsmy_oops_init的地址分别是0xbf2a80000xbf045000

[29935.224068] sp : cc66dda8  ip : cc66ddb8  fp : cc66ddb4 

sp代表堆栈指针,保存堆栈中的当前位置,而fp代表帧指针,指向堆栈中当前活动的帧。当函数返回时,堆栈指针被恢复到帧指针,即在调用函数之前的堆栈指针的值。维基百科的以下示例解释得很好:

例如,DrawLine的堆栈帧将具有一个内存位置,该位置保存DrawSquare使用的帧指针值。该值在进入子例程时保存,并在返回时恢复:

[29935.235572] r10: cc68c9a4  r9 : c08058d0  r8 : c08058d0
[29935.240813] r7 : 00000000  r6 : c0802048  r5 : bf045000  r4 : cd4eca40
[29935.247359] r3 : 00000000  r2 : a6af642b  r1 : c05f3a6a  r0 : 00000014

上述是一些 CPU 寄存器的转储:

[29935.266822] Process insmod (pid: 20021, stack limit = 0xcc66c208)

前一行显示了发生 panic 的进程,这里是insmod,其 PID 为20021

也有 oops 中存在回溯的情况,就像以下这样,这是通过键入echo c > /proc/sysrq-trigger生成的 oops 的摘录:

图 14.3 - 内核 oops 中的回溯摘录

图 14.3 - 内核 oops 中的回溯摘录

回溯跟踪了生成 oops 之前的函数调用历史:

[29935.433257] Code: e24cb004 e52de004 e8bd4000 e3a03000 (e5833000) 

Code是在 oops 发生时正在运行的机器代码部分的十六进制转储。

oops 上的跟踪转储

当内核崩溃时,可以使用kdump/kexeccrash实用程序来检查崩溃点的系统状态。但是,这种技术无法让您看到导致崩溃的事件之前发生了什么,这可能是理解或修复错误的良好输入。

Ftrace 附带了一个试图解决此问题的功能。为了启用它,您可以将1回显到/proc/sys/kernel/ftrace_dump_on_oops,或者在内核引导参数中启用ftrace_dump_on_oops。配置了 Ftrace 并启用了此功能将指示 Ftrace 在 oops 或 panic 时以 ASCII 格式将整个跟踪缓冲区转储到控制台。将控制台输出到串行线使得调试崩溃变得更加容易。这样,您可以设置好一切,然后等待崩溃。一旦发生崩溃,您将在控制台上看到跟踪缓冲区。然后,您将能够追溯导致崩溃的事件。您可以追溯多远取决于跟踪缓冲区的大小,因为这是存储事件历史数据的地方。

也就是说,将转储到控制台可能需要很长时间,通常在放置所有内容之前缩小跟踪缓冲区,因为默认的 Ftrace 环形缓冲区每个 CPU 超过 1 兆字节。您可以使用/sys/kernel/debug/tracing/buffer_size_kb来通过在该文件中写入所需的环形缓冲区大小(以千字节为单位)来减小跟踪缓冲区的大小。请注意,该值是每个 CPU 的值,而不是环形缓冲区的总大小。

以下是修改跟踪缓冲区大小的示例:

# echo 3 > /sys/kernel/debug/tracing/buffer_size_kb

上述命令将将 Ftrace 环形缓冲区缩小到每个 CPU 的 3 千字节(1 kb 可能足够;这取决于在崩溃之前需要回溯多远)。

使用 objdump 来识别内核模块中的错误代码行的示例

我们可以使用objdump来反汇编对象文件并识别生成 oops 的行。我们使用反汇编代码来调整符号名称和偏移量,以指向确切的错误行。

以下一行将在oops.as文件中反汇编内核模块:

arm-XXXX-objdump -fS  oops.ko > oops.as

生成的输出文件将具有类似以下内容:

[...]
architecture: arm, flags 0x00000011:
HAS_RELOC, HAS_SYMS
start address 0x00000000
Disassembly of section .text.unlikely:
00000000 <create_oops>:
   0:	e1a0c00d 	mov	ip, sp
   4:	e92dd800 	push	{fp, ip, lr, pc}
   8:	e24cb004 	sub	fp, ip, #4
   c:	e52de004 	push	{lr}		; (str lr, [sp, #-4]!)
  10:	ebfffffe 	bl	0 <__gnu_mcount_nc>
  14:	e3a03000 	mov	r3, #0
  18:	e5833000 	str	r3, [r3]
  1c:	e89da800 	ldm	sp, {fp, sp, pc}
Disassembly of section .init.text:
00000000 <init_module>:
   0:	e1a0c00d 	mov	ip, sp
   4:	e92dd800 	push	{fp, ip, lr, pc}
   8:	e24cb004 	sub	fp, ip, #4
   c:	e59f000c    ldr   r0, [pc, #12]    ; 20    <init_module+0x20>
  10:	ebfffffe 	bl	0 <printk>
  14:	ebfffffe 	bl	0 <init_module>
  18:	e3a00000 	mov	r0, #0
  1c:	e89da800 	ldm	sp, {fp, sp, pc}
  20:	00000000 	.word	0x00000000
Disassembly of section .exit.text:
00000000 <cleanup_module>:
   0:	e1a0c00d 	mov	ip, sp
   4:	e92dd800 	push	{fp, ip, lr, pc}
   8:	e24cb004 	sub	fp, ip, #4
   c:	e59f0004 	ldr	r0, [pc, #4]	; 18    <cleanup_module+0x18>
  10:	ebfffffe 	bl	0 <printk>
  14:	e89da800 	ldm	sp, {fp, sp, pc}
  18:	00000016 	.word	0x00000016

重要提示

在编译模块时启用调试选项会使调试信息在.ko对象中可用。在这种情况下,objdump -S会插入源代码和汇编,以便更好地查看。

从 oops 中,我们看到 PC 位于create_oops+0x18,这是在create_oops地址的0x18偏移处。这将我们带到18: e5833000 str r3, [r3]行。为了理解我们感兴趣的行,让我们描述它之前的行,mov r3, #0。在这行之后,我们有r3 = 0。回到我们感兴趣的行,对于熟悉 ARM 汇编语言的人来说,它意味着将r3写入到r3指向的原始地址(C 语言中[r3]的等价物是*r3)。请记住,这对应于我们代码中的*(int *)0 = 0

总结

本章介绍了一些内核调试技巧,并解释了如何使用 Ftrace 来跟踪代码,以识别奇怪的行为,比如耗时的函数和中断延迟。我们讨论了 API 的打印,无论是针对核心代码还是设备驱动程序相关的代码。最后,我们学习了如何分析和调试内核 oops。

本章标志着本书的结束,我希望您在阅读本书时和我在写作时一样享受这段旅程。我也希望我在整本书中传授知识的最大努力对您有所帮助。

posted @   绝不原创的飞龙  阅读(201)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 记一次.NET内存居高不下排查解决与启示
· DeepSeek 开源周回顾「GitHub 热点速览」
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
点击右上角即可分享
微信分享提示