调试linux内核(2): poll系统调用的实现

linux内核为用户态进程提供了一组IO相关的系统调用: select/poll/epoll, 这三个系统调用功能类似, 在使用方法和性能等方面存在一些差异. 使用它们, 用户态的进程可以"监控"自己感兴趣的文件描述符, 当这些文件描述符的状态发生改变时, 比如可读或者可写了, 内核会通知进程去处理, 这里的文件描述符可以是socket, 设备文件, 管道等. 使用这组系统调用, 用户态可以实现事件循环机制, 比如redis源码中就基于此实现了自己内部使用的事件循环, 同样还有很多其他专门提供事件循环机制的开源库. 这里通过一个驱动模块实现的poll接口, 去分析内核中poll系统调用的实现原理. 主要讨论了以下3个问题:

  1. 用户态进程如何使用poll系统调用?
  2. 内核如何处理poll系统调用?
  3. 怎样调试从进程发起poll调用到返回的过程?

问题1

用户态进程如何使用poll系统调用?

简单来说, 使用poll的时候, 进程需要告诉内核自己关心哪些文件描述符, 关心它们的什么事件, 这些都是通过参数传递给poll系统调用的. 下面是手册中对poll的详细说明:

POLL(2)                                                                                                                       Linux Programmer's Manual                                                                                                                       POLL(2)

NAME
       poll, ppoll - wait for some event on a file descriptor

SYNOPSIS
       #include <poll.h>

       int poll(struct pollfd *fds, nfds_t nfds, int timeout);

       #define _GNU_SOURCE         /* See feature_test_macros(7) */
       #include <signal.h>
       #include <poll.h>

       int ppoll(struct pollfd *fds, nfds_t nfds,
               const struct timespec *tmo_p, const sigset_t *sigmask);

DESCRIPTION
       poll() performs a similar task to select(2): it waits for one of a set of file descriptors to become ready to perform I/O.  The Linux-specific epoll(7) API performs a similar task, but offers features beyond those found in poll().

       The set of file descriptors to be monitored is specified in the fds argument, which is an array of structures of the following form:

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

       The caller should specify the number of items in the fds array in nfds.

poll接受三个参数, 其中pollfd的数组用来告诉内核, 进程关心哪些文件描述符, 结构体的fd字段是文件描述符的值, events是关心的事件, 比如希望fd可读时收到内核通知, 就可以设为POLL_IN, 这个events字段支持位或, 也就是关心的多种事件可以用或运算一起计算出events的最终数值, revents字段表示poll系统调用返回之后, 在该fd上发生的事件. poll的第二和第三个参数分别表示数组的大小和超时时间, 其中timeout以毫秒为单位, 如果timeout==0, poll会立即返回, 如果timeout < 0, poll会一直等待, 直到fds中期待的事件发生, 或者进程收到信号, 或者其他原因进程退出了. 当fds中的事件没有发生或者超时时间没到时, 进程就会处于睡眠状态. poll的返回值反映了三种可能的结果, 1) 出错, 2) 超时, 3) 发生期待事件的fd的数量. 其他的信息可以自行阅读manual.

以下代码会用来发起poll调用, 然后调试poll的实现:

/*ignore include headers*/
int main(int argc, char *argv[])
{
	int dev_fd = open("/dev/cdev03", O_RDWR);
	if (dev_fd < 0) {
		perror("Can not open device file");
		return -1;
	}

	struct pollfd pollfd = {
		.fd = dev_fd,
		.events = POLL_IN,
		.revents = 0,
	};

	char buf[1024];
	int max_poll_calls = 3;
	while (max_poll_calls) {
		int ret = poll(&pollfd, 1, -1);
		if (ret == 1) {
			memset(buf, 0, 1024);
			read(dev_fd, buf, 1024);
			printf("poll_reader recv data: %s\n", buf);
		}
		max_poll_calls--;
	}

	close(dev_fd);
	return 0;
}

代码中poll设备文件"/dev/cdev03"的状态变化, 在poll三次之后退出.

问题2

内核如何处理poll系统调用?

因为进程传递给内核的可能是多个文件描述符, 所以在poll的实现中也需要遍历这些fd并检查它们的状态, 实际poll的实现涉及到比较多的数据结构, 这里先简单概括一下进入到poll系统调用之后内核的处理逻辑:

# ATTENION: we are in poll syscall now

0) 计算超时状态初始值;

while True:
	for fd in fds:
		1) 获取当前fd的状态;
		2) 记录fd的状态;
		3) 对符合条件的fd计数;

	if 存在符合条件的fd 或者 超时时间到:
		break

	4) 调用schedule相关API, 让出CPU, 当前进程开始带有超时的睡眠;

	5) 更新超时状态;
	# 如果进程被唤醒, schedule调用就会返回, 进程将在内核态, 继续这个循环

以上就是poll实现中的核心逻辑, 当然, 实际情况还是会稍微复杂亿点的, 以上描述中省略了进程被信号唤醒等处理逻辑. 后面会用一个字符设备驱动, 跟踪这个实现过程, 下面是设备驱动和poll系统调用在交互过程中各自的职责划分:

  • 内核怎么获取fd的状态?
    对于字符设备驱动, 它要实现file_operations中的poll接口, 内核在步骤1)会调用, 得到设备的状态
  • 设备怎么通知进程设备的状态发生了变化?
    在设备驱动实现的poll接口被调用时, 会使用poll_wait传递给调用者一个等待队列, 调用驱动接口的上层代码在这个队列中插入元素, 通过这个元素, 可以间接找到睡眠的进程, 当有数据写入设备时, 驱动模块的write接口被调用, 驱动代码可以在write接口中对这个等待队列进行唤醒操作, 从而实现唤醒进程. 具体的数据结构细节在后面的调试过程中展开.

问题3

怎样调试从进程发起poll调用到返回的过程?

这里构造的场景如下:

  1. 实现一个字符设备驱动, 驱动中实现了poll, write, read接口;
  2. 在内核中插入该模块, 在/dev下生成设备文件节点;
  3. 启动一个用户态进程, 并让它后台运行, 在进程中打开设备文件, 对该文件进行poll操作, 开始时设备数据为空, 进程将睡眠
  4. 使用echo命令向设备文件写入数据, 驱动的write接口被调用, 睡眠的进程被唤醒, 并读取设备数据;

在对poll的实现有了一个基本了解之后, 调试面临的第一个问题就是找到这个系统调用的入口, 这里提供两个调试技巧:

  1. 你知道在linux内核中系统调用使用SYSCALL_DEFINEx宏定义, 可以直接在代码中用正则表达式SYSCALL_DEFINE.*poll去搜索poll系统调用的位置, 然后在入口打断点, 开始调试即可.
  2. 你不知道poll的入口在那, 但是在你的字符设备驱动中实现了poll接口, 这个接口一定会出现在poll系统调用的调用链上, 可以在你的驱动模块上打断点, 断点命中之后, 看调用栈找到syscall的入口, 再进一步调试. 这种方法需要借助内核提供的gdb脚本加载驱动模块的调试信息, 否则gdb无法获得指令和源文件中各行的对应信息以及其他的符号信息.

关于调试的环境问题, 可以参考之前的文章, 以下是调试过程的视频记录:

poll系统调用涉及到的重要数据结构, 以及它们之间的关系总结如下:
image

在逻辑上可以分成如图所示的两个部分, 分别和poll系统调用的上层实现以及驱动模块的poll接口实现相关. 各数据结构的作用如下:

  1. 进程进入poll系统调用时, 内核对poll_wqueues的各个成员进行初始化, 包括:

    • 用一个默认的函数初始化pt的_qproc函数指针;
    • 用current初始化polling_task, 记录发起poll系统调用的进程;
    • 虚线框中的成员嵌套着wait_queue_entry, 这个被嵌套的数据类型, 是将来真正插入到驱动模块提供的等待队列wait_queue_head的节点;
  2. 当驱动模块的poll接口被上层调用时:

    • 驱动代码需要调用poll_wait函数, 以自己维护的等待队列wait_queue_head作为参数, 并透传poll_table指针和file指针;
      • 在poll_wait的实现中, 会检查poll_table的_qproc是否为空, 不为空则继续透传参数, 调用_qproc;
        • 在_qproc中, 会从poll_wqueues中获取一个空闲的poll_table_entry, 初始化图中的三个成员, 其中的wait_queue_entry:
          • private指针被设为poll_wqueues的地址, 这样将来被唤醒时就可以找到之前睡眠的进程, 也就是polling_task;
          • func被设为一个默认的函数,将来这个节点所属的等待队列被唤醒时, func被调用, 根据private指针找到要唤醒的进程;
          • 通过链表操作, 将节点插入到等待队列中;
  3. 当有数据写入设备时:
    驱动模块检测到设备有数据可读了, 需要唤醒传递给poll_wait的等待队列, 这时队列上每个节点的func都会被调用, 最终之前睡眠的进程被唤醒;

  4. 当设备可写时, 唤醒过程类似, 只是使用的队列不同.

概括下来:

  • 驱动模块只要维护自己的等待队列, 在poll接口的实现中, 调用上层提供的poll_wait向队列中插入元素, 并返回当前的设备状态;
  • 驱动的其他部分在合适的时机对等待队列执行唤醒操作;
  • poll系统调用的上层实现代码, 负责维护一套数据结构, 记录插入到等待队列中的节点, 给节点进行必要的设置, 使得通过节点能够唤醒正确的进程;

总结

设备驱动的开发是在内核提供的框架下进行的, 为了降低驱动的开发难度, 快速支持各种新设备, 这套框架的设计必然要经得住考验, 这也导致驱动的开发存在很多模板一样的套路, 有人戏称为"完形填空". 但是以驱动开发为出发点, 深入了解内核的各个模块, 个人感觉是学习linux的一个很好的方式. 欢迎加入技术讨论qq群: 838923389 一起研究linux相关的底层技术.

posted @ 2023-08-25 08:47  编程这点事儿  阅读(300)  评论(0编辑  收藏  举报
http://www.vxiaotou.com