|
linux内核为用户态进程提供了一组IO相关的系统调用: select/poll/epoll, 这三个系统调用功能类似, 在使用方法和性能等方面存在一些差异. 使用它们, 用户态的进程可以"监控"自己感兴趣的文件描述符, 当这些文件描述符的状态发生改变时, 比如可读或者可写了, 内核会通知进程去处理, 这里的文件描述符可以是socket, 设备文件, 管道等. 使用这组系统调用, 用户态可以实现事件循环机制, 比如redis源码中就基于此实现了自己内部使用的事件循环, 同样还有很多其他专门提供事件循环机制的开源库. 这里通过一个驱动模块实现的poll接口, 去分析内核中poll系统调用的实现原理. 主要讨论了以下3个问题:
- 用户态进程如何使用poll系统调用?
- 内核如何处理poll系统调用?
- 怎样调试从进程发起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调用到返回的过程?
这里构造的场景如下:
- 实现一个字符设备驱动, 驱动中实现了poll, write, read接口;
- 在内核中插入该模块, 在/dev下生成设备文件节点;
- 启动一个用户态进程, 并让它后台运行, 在进程中打开设备文件, 对该文件进行poll操作, 开始时设备数据为空, 进程将睡眠
- 使用echo命令向设备文件写入数据, 驱动的write接口被调用, 睡眠的进程被唤醒, 并读取设备数据;
在对poll的实现有了一个基本了解之后, 调试面临的第一个问题就是找到这个系统调用的入口, 这里提供两个调试技巧:
- 你知道在linux内核中系统调用使用SYSCALL_DEFINEx宏定义, 可以直接在代码中用正则表达式SYSCALL_DEFINE.*poll去搜索poll系统调用的位置, 然后在入口打断点, 开始调试即可.
- 你不知道poll的入口在那, 但是在你的字符设备驱动中实现了poll接口, 这个接口一定会出现在poll系统调用的调用链上, 可以在你的驱动模块上打断点, 断点命中之后, 看调用栈找到syscall的入口, 再进一步调试. 这种方法需要借助内核提供的gdb脚本加载驱动模块的调试信息, 否则gdb无法获得指令和源文件中各行的对应信息以及其他的符号信息.
关于调试的环境问题, 可以参考之前的文章, 以下是调试过程的视频记录:
poll系统调用涉及到的重要数据结构, 以及它们之间的关系总结如下:
在逻辑上可以分成如图所示的两个部分, 分别和poll系统调用的上层实现以及驱动模块的poll接口实现相关. 各数据结构的作用如下:
- 进程进入poll系统调用时, 内核对poll_wqueues的各个成员进行初始化, 包括:
- 用一个默认的函数初始化pt的_qproc函数指针;
- 用current初始化polling_task, 记录发起poll系统调用的进程;
- 虚线框中的成员嵌套着wait_queue_entry, 这个被嵌套的数据类型, 是将来真正插入到驱动模块提供的等待队列wait_queue_head的节点;
- 当驱动模块的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指针找到要唤醒的进程;
- 通过链表操作, 将节点插入到等待队列中;
- 当有数据写入设备时:
驱动模块检测到设备有数据可读了, 需要唤醒传递给poll_wait的等待队列, 这时队列上每个节点的func都会被调用, 最终之前睡眠的进程被唤醒;
- 当设备可写时, 唤醒过程类似, 只是使用的队列不同.
概括下来:
- 驱动模块只要维护自己的等待队列, 在poll接口的实现中, 调用上层提供的poll_wait向队列中插入元素, 并返回当前的设备状态;
- 驱动的其他部分在合适的时机对等待队列执行唤醒操作;
- poll系统调用的上层实现代码, 负责维护一套数据结构, 记录插入到等待队列中的节点, 给节点进行必要的设置, 使得通过节点能够唤醒正确的进程;
总结
设备驱动的开发是在内核提供的框架下进行的, 为了降低驱动的开发难度, 快速支持各种新设备, 这套框架的设计必然要经得住考验, 这也导致驱动的开发存在很多模板一样的套路, 有人戏称为"完形填空". 但是以驱动开发为出发点, 深入了解内核的各个模块, 个人感觉是学习linux的一个很好的方式. 欢迎加入技术讨论qq群: 838923389 一起研究linux相关的底层技术.
来源:https://www.cnblogs.com/kfggww/p/17653270.html
免责声明:由于采集信息均来自互联网,如果侵犯了您的权益,请联系我们【E-Mail:cb@itdo.tech】 我们会及时删除侵权内容,谢谢合作! |
本帖子中包含更多资源
您需要 登录 才可以下载或查看,没有账号?立即注册
x
|