中继接口 (原 relayfs)

中继接口提供了一种机制,使内核应用程序可以通过用户定义的“中继通道”有效地将大量数据从内核记录和传输到用户空间。

“中继通道”是一种内核 -> 用户数据中继机制,实现为一组每个 CPU 的内核缓冲区(“通道缓冲区”),每个缓冲区在用户空间中表示为一个常规文件(“中继文件”)。内核客户端使用高效的写入函数写入通道缓冲区;这些函数会自动记录到当前 CPU 的通道缓冲区中。用户空间应用程序使用 mmap() 或 read() 从中继文件中检索数据,一旦数据可用即可。中继文件本身是在主机文件系统中创建的文件,例如 debugfs,并且使用下面描述的 API 与通道缓冲区相关联。

记录到通道缓冲区中的数据格式完全由内核客户端决定;中继接口确实提供了钩子,允许内核客户端对缓冲区数据施加一些结构。中继接口不实现任何形式的数据过滤 - 这也留给内核客户端处理。目的是使事情尽可能简单。

本文档概述了中继接口 API。函数参数的详细信息与中继接口代码中的函数一起记录 - 请参阅该代码以了解详细信息。

语义

每个中继通道每个 CPU 都有一个缓冲区,每个缓冲区有一个或多个子缓冲区。消息写入第一个子缓冲区,直到它太满而无法包含新消息,在这种情况下,它会写入下一个(如果可用)。消息永远不会跨子缓冲区分割。此时,可以通知用户空间,以便它清空第一个子缓冲区,而内核继续写入下一个子缓冲区。

当通知子缓冲区已满时,内核知道其中有多少字节是填充的,即由于完整消息无法放入子缓冲区而出现的未使用空间。用户空间可以使用此知识仅复制有效数据。

复制后,用户空间可以通知内核子缓冲区已被消耗。

中继通道可以在一种模式下运行,在这种模式下,它将覆盖用户空间尚未收集的数据,而不是等待它被消耗。

中继通道本身不提供用户空间和内核之间此类数据的通信,这使得内核端保持简单,并且不会在用户空间上强加单一接口。它确实提供了一组示例和一个单独的帮助程序,如下所述。

read() 接口既删除填充,又在内部消耗读取的子缓冲区;因此,在使用 read(2) 来清空通道缓冲区的情况下,内核和用户之间不需要特殊的通信来进行基本操作。

中继接口的主要目标之一是提供一种低开销的机制,用于将内核数据传递到用户空间。虽然 read() 接口易于使用,但它不如 mmap() 方法高效;示例代码试图使这两种方法之间的权衡尽可能小。

klog 和 relay-apps 示例代码

中继接口本身已准备好使用,但为了简化操作,提供了一些简单的实用函数和一组示例。

relay-apps 示例 tarball(可在 relay sourceforge 站点上获得)包含一组独立的示例,每个示例都由一对 .c 文件组成,其中包含中继应用程序用户端和内核端的样板代码。当组合在一起时,这两组样板代码提供了胶水,可以轻松地将数据流式传输到磁盘,而无需担心琐碎的内务处理。

“klog 调试函数”补丁(relay-apps tarball 中的 klog.patch)为内核提供了一些高级日志记录函数,允许将格式化的文本或原始数据写入通道,而不管是否存在要写入的通道,甚至不管中继接口是否已编译到内核中。这些函数允许您在内核或内核模块的任何位置放置无条件的“跟踪”语句;只有在注册了“klog 处理程序”时,才会实际记录数据(有关详细信息,请参阅 klog 和 kleak 示例)。

当然,可以从头开始使用中继接口,即不使用任何 relay-apps 示例代码或 klog,但是您必须实现用户空间和内核之间的通信,允许两者传达缓冲区的状态(满、空、填充量)。read() 接口既删除填充,又在内部消耗读取的子缓冲区;因此,在使用 read(2) 来清空通道缓冲区的情况下,内核和用户之间不需要特殊的通信来进行基本操作。但是,仍然需要通过某些通道来传达诸如缓冲区已满之类的情况。

klog 和 relay-apps 示例可以在 http://relayfs.sourceforge.net 上的 relay-apps tarball 中找到

中继接口用户空间 API

中继接口实现了基本的文件操作,供用户空间访问中继通道缓冲区数据。以下是可用的文件操作以及有关其行为的一些注释

open()

使用户能够打开_现有_通道缓冲区。

mmap()

导致通道缓冲区映射到调用者的内存空间。请注意,您不能执行部分 mmap - 您必须映射整个文件,即 NRBUF * SUBBUFSIZE。

read()

读取通道缓冲区的内容。读取的字节由读取器“消耗”,即它们不会再次提供给后续读取。如果通道在非覆盖模式(默认)下使用,则即使存在活动的内核写入器,也可以随时读取它。如果通道在覆盖模式下使用,并且存在活动的通道写入器,则结果可能是不可预测的 - 用户应确保在覆盖模式下使用 read() 之前,已结束对通道的所有日志记录。子缓冲区填充会被自动删除,读取器将看不到它。

sendfile()

将数据从通道缓冲区传输到输出文件描述符。子缓冲区填充会被自动删除,读取器将看不到它。

poll()

支持 POLLIN/POLLRDNORM/POLLERR。当跨越子缓冲区边界时,会通知用户应用程序。

close()

递减通道缓冲区的 refcount。当 refcount 达到 0 时,即当没有进程或内核客户端打开缓冲区时,通道缓冲区将被释放。

为了使用户应用程序能够使用中继文件,必须挂载主机文件系统。例如

mount -t debugfs debugfs /sys/kernel/debug

注意

主机文件系统不需要挂载才能让内核客户端创建或使用通道 - 只有当用户空间应用程序需要访问缓冲区数据时才需要挂载。

中继接口内核 API

以下是中继接口向内核客户端提供的 API 的摘要

待办事项 (当前行 MT:/API/)

通道管理函数

relay_open(base_filename, parent, subbuf_size, n_subbufs,
           callbacks, private_data)
relay_close(chan)
relay_flush(chan)
relay_reset(chan)

通道管理通常在用户空间启动时调用

relay_subbufs_consumed(chan, cpu, subbufs_consumed)

写入函数

relay_write(chan, data, length)
__relay_write(chan, data, length)
relay_reserve(chan, length)

回调

subbuf_start(buf, subbuf, prev_subbuf, prev_padding)
buf_mapped(buf, filp)
buf_unmapped(buf, filp)
create_buf_file(filename, parent, mode, buf, is_global)
remove_buf_file(dentry)

帮助程序函数

relay_buf_full(buf)
subbuf_start_reserve(buf, length)

创建通道

relay_open() 用于创建通道,以及其每个 CPU 的通道缓冲区。每个通道缓冲区都会在主机文件系统中为其创建一个关联的文件,该文件可以在用户空间中进行 mmap 或读取。这些文件被命名为 basename0...basenameN-1,其中 N 是在线 CPU 的数量,默认情况下将在文件系统的根目录中创建(如果父参数为 NULL)。如果您希望目录结构包含您的中继文件,则应使用主机文件系统的目录创建函数(例如 debugfs_create_dir())创建它,并将父目录传递给 relay_open()。用户负责在通道关闭时清理他们创建的任何目录结构 - 同样,主机文件系统的目录删除函数应为此使用,例如 debugfs_remove()

为了创建通道并使主机文件系统的文件与其通道缓冲区相关联,用户必须为两个回调函数提供定义:create_buf_file() 和 remove_buf_file()。create_buf_file() 从 relay_open() 为每个 CPU 缓冲区调用一次,并允许用户创建将用于表示相应通道缓冲区的文件。回调应返回为表示通道缓冲区而创建的文件的 dentry。还必须定义 remove_buf_file();它负责删除在 create_buf_file() 中创建的文件,并在 relay_close() 期间调用。

以下是这些回调的一些典型定义,在本例中使用 debugfs

/*
* create_buf_file() callback.  Creates relay file in debugfs.
*/
static struct dentry *create_buf_file_handler(const char *filename,
                                            struct dentry *parent,
                                            umode_t mode,
                                            struct rchan_buf *buf,
                                            int *is_global)
{
        return debugfs_create_file(filename, mode, parent, buf,
                                &relay_file_operations);
}

/*
* remove_buf_file() callback.  Removes relay file from debugfs.
*/
static int remove_buf_file_handler(struct dentry *dentry)
{
        debugfs_remove(dentry);

        return 0;
}

/*
* relay interface callbacks
*/
static struct rchan_callbacks relay_callbacks =
{
        .create_buf_file = create_buf_file_handler,
        .remove_buf_file = remove_buf_file_handler,
};

以及使用它们的一个 relay_open() 调用示例

chan = relay_open("cpu", NULL, SUBBUF_SIZE, N_SUBBUFS, &relay_callbacks, NULL);

如果 create_buf_file() 回调失败或未定义,则通道创建以及 relay_open() 将失败。

每个 CPU 缓冲区的总大小通过将子缓冲区数量乘以传递给 relay_open() 的子缓冲区大小来计算。子缓冲区的想法是,它们基本上是将双缓冲扩展到 N 个缓冲区,并且它们还允许应用程序轻松实现缓冲区边界上的随机访问方案,这对于某些高容量应用程序非常重要。子缓冲区的数量和大小完全取决于应用程序,即使对于同一应用程序,不同的条件也会在不同的时间需要不同的参数值。通常,要使用的正确值最好在经过一些实验后决定;不过,一般来说,假设只有 1 个子缓冲区是一个坏主意是安全的 - 您肯定会根据所使用的通道模式覆盖数据或丢失事件。

create_buf_file() 的实现也可以定义为允许创建单个“全局”缓冲区,而不是默认的每个 CPU 集。这对于主要关注查看系统范围事件的相对顺序,而不需要为了在后处理步骤中合并/排序每个 CPU 文件而保存显式时间戳的应用程序非常有用。

为了让 relay_open() 创建一个全局缓冲区,create_buf_file() 的实现应将 is_global outparam 的值设置为非零值,此外还要创建将用于表示单个缓冲区的文件。在全局缓冲区的情况下,create_buf_file() 和 remove_buf_file() 将仅被调用一次。正常的通道写入函数,例如 relay_write(),仍然可以使用 - 来自任何 CPU 的写入将透明地最终进入全局缓冲区 - 但由于它是一个全局缓冲区,调用者应确保为此类缓冲区使用适当的锁定,可以通过将写入包装在自旋锁中,或者通过从 relay.h 复制写入函数并创建一个在内部进行适当锁定的本地版本来实现。

传递给 relay_open() 的 private_data 允许客户端将用户定义的数据与通道关联,并且可以通过 chan->private_data 或 buf->chan->private_data 立即使用(包括在 create_buf_file() 中)。

仅缓冲通道

这些通道没有关联的文件,可以使用 relay_open(NULL, NULL, ...) 创建。此类通道在内核中进行早期跟踪时非常有用,在 VFS 启动之前。在这些情况下,可以打开一个仅缓冲通道,然后在内核准备好处理文件时调用 relay_late_setup_files(),以将缓冲的数据暴露给用户空间。

通道“模式”

relay 通道可以在两种模式下使用 - “覆盖”或“不覆盖”。该模式完全由 subbuf_start() 回调的实现决定,如下所述。如果没有定义 subbuf_start() 回调,则默认模式为“不覆盖”模式。如果默认模式适合您的需求,并且您计划使用 read() 接口来检索通道数据,则可以忽略本节的详细信息,因为它主要与 mmap() 实现有关。

在“覆盖”模式(也称为“飞行记录仪”模式)中,写入会不断循环绕着缓冲区,并且永远不会失败,但会无条件地覆盖旧数据,而不管它是否已被实际消耗。在不覆盖模式下,如果未消耗的子缓冲区数量等于通道中子缓冲区的总数,则写入将失败,即数据将丢失。应该清楚的是,如果没有消费者或者消费者无法足够快地消耗子缓冲区,数据在任何情况下都会丢失;唯一的区别是数据是从缓冲区的开头还是结尾丢失。

如上所述,relay 通道由一个或多个每个 CPU 的通道缓冲区组成,每个缓冲区都实现为循环缓冲区,细分为一个或多个子缓冲区。消息通过下面描述的写入函数写入通道当前每个 CPU 缓冲区的当前子缓冲区中。每当消息无法放入当前子缓冲区时,因为没有剩余空间,客户端会通过 subbuf_start() 回调收到通知,即将发生切换到新子缓冲区。客户端使用此回调来 1) 在适当的情况下初始化下一个子缓冲区 2) 在适当的情况下完成上一个子缓冲区,以及 3) 返回一个布尔值,指示是否实际移动到下一个子缓冲区。

为了实现“不覆盖”模式,用户空间客户端将提供类似以下 subbuf_start() 回调的实现

static int subbuf_start(struct rchan_buf *buf,
                        void *subbuf,
                        void *prev_subbuf,
                        unsigned int prev_padding)
{
        if (prev_subbuf)
                *((unsigned *)prev_subbuf) = prev_padding;

        if (relay_buf_full(buf))
                return 0;

        subbuf_start_reserve(buf, sizeof(unsigned int));

        return 1;
}

如果当前缓冲区已满,即所有子缓冲区都保持未消耗状态,则回调返回 0,表示缓冲区切换不应立即发生,即直到消费者有机会读取当前就绪的子缓冲区集。为了使 relay_buf_full() 函数有意义,消费者有责任在子缓冲区被消耗后通过 relay_subbufs_consumed() 通知 relay 接口。任何后续写入缓冲区的尝试将再次使用相同的参数调用 subbuf_start() 回调;仅当消费者消耗了一个或多个就绪的子缓冲区时,relay_buf_full() 才会返回 0,在这种情况下,缓冲区切换可以继续。

“覆盖”模式的 subbuf_start() 回调的实现将非常相似

static int subbuf_start(struct rchan_buf *buf,
                        void *subbuf,
                        void *prev_subbuf,
                        size_t prev_padding)
{
        if (prev_subbuf)
                *((unsigned *)prev_subbuf) = prev_padding;

        subbuf_start_reserve(buf, sizeof(unsigned int));

        return 1;
}

在这种情况下,relay_buf_full() 检查毫无意义,回调始终返回 1,导致无条件地发生缓冲区切换。在这种模式下,客户端使用 relay_subbufs_consumed() 函数也是毫无意义的,因为它永远不会被咨询。

如果客户端未定义任何回调,或者未定义 subbuf_start() 回调,则使用的默认 subbuf_start() 实现实现了最简单的“不覆盖”模式,即它除了返回 0 之外什么都不做。

可以通过从 subbuf_start() 回调中调用 subbuf_start_reserve() 辅助函数在每个子缓冲区的开头保留头部信息。此保留区域可用于存储客户端想要的任何信息。在上面的示例中,在每个子缓冲区中保留空间以存储该子缓冲区的填充计数。这是在 subbuf_start() 实现中为上一个子缓冲区填充的;上一个子缓冲区的填充值与上一个子缓冲区的指针一起传递到 subbuf_start() 回调中,因为填充值直到子缓冲区被填充才知道。当通道打开时,也会调用 subbuf_start() 回调以使客户端有机会在其保留空间。在这种情况下,传递给回调的先前子缓冲区指针将为 NULL,因此客户端应在写入先前子缓冲区之前检查 prev_subbuf 指针的值。

写入通道

内核客户端使用 relay_write() 或 __relay_write() 将数据写入当前 CPU 的通道缓冲区。relay_write() 是主要的日志记录函数 - 它使用 local_irqsave() 来保护缓冲区,如果可能从中断上下文中记录,则应使用该函数。如果您知道永远不会从中断上下文中记录,则可以使用 __relay_write(),它只会禁用抢占。这些函数不返回值,因此您无法确定它们是否失败 - 假设您无论如何都不想在快速日志记录路径中检查返回值,并且它们总是会成功,除非缓冲区已满且正在使用不覆盖模式,在这种情况下,您可以通过调用 relay_buf_full() 辅助函数在 subbuf_start() 回调中检测到失败的写入。

relay_reserve() 用于在通道缓冲区中保留一个槽,该槽可以在以后写入。这通常用于需要在没有事先在临时缓冲区中暂存数据的情况下直接写入通道缓冲区的应用程序中。由于实际写入可能不会在保留槽后立即发生,因此使用 relay_reserve() 的应用程序可以保留实际写入的字节数的计数,可以在子缓冲区本身中保留的空间中,也可以作为单独的数组。有关如何执行此操作的示例,请参阅 http://relayfs.sourceforge.net 上的 relay-apps tarball 中的“reserve”示例。由于写入由客户端控制并且与保留分离,因此 relay_reserve() 不会保护缓冲区 - 当使用 relay_reserve() 时,客户端负责提供适当的同步。

关闭通道

当客户端完成使用通道时,它会调用 relay_close()。当不再有对任何通道缓冲区的引用时,通道及其关联的缓冲区将被销毁。 relay_flush() 强制在所有通道缓冲区上进行子缓冲区切换,并且可用于在通道关闭之前完成和处理最后一个子缓冲区。

其他

某些应用程序可能希望保留一个通道并重新使用它,而不是每次使用都打开和关闭一个新通道。relay_reset() 可以用于此目的 - 它将通道重置为其初始状态,而无需重新分配通道缓冲区内存或销毁现有映射。但是,它应该只在安全的情况下调用,即当通道当前未被写入时。

最后,有一些实用程序回调可用于不同的目的。每当从用户空间 mmap 通道缓冲区时,都会调用 buf_mapped(),而当它未映射时,则调用 buf_unmapped()。客户端可以使用此通知来触发内核应用程序中的操作,例如启用/禁用通道的日志记录。

资源

有关新闻、示例代码、邮件列表等,请参阅 relay 接口主页

鸣谢

中继接口的想法和规范来源于以下人员参与的跟踪讨论:

Michel Dagenais <michel.dagenais@polymtl.ca> Richard Moore <richardj_moore@uk.ibm.com> Bob Wisniewski <bob@watson.ibm.com> Karim Yaghmour <karim@opersys.com> Tom Zanussi <zanussi@us.ibm.com>

同时感谢 Hubertus Franke 提出的许多有用的建议和错误报告。