linux內核為用戶態進程提供了一組IO相關的系統調用: select/poll/epoll, 這三個系統調用功能類似, 在使用方法和性能等方面存在一些差異. 使用它們, 用戶態的進程可以"監控"自己感興趣的文件描述符, 當這些文件描述符的狀態發生改變時, 比如可讀或者可寫了, 內核會通知進程去處理... ...
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指針找到要喚醒的進程;
- 通過鏈表操作, 將節點插入到等待隊列中;
- 在_qproc中, 會從poll_wqueues中獲取一個空閑的poll_table_entry, 初始化圖中的三個成員, 其中的wait_queue_entry:
- 在poll_wait的實現中, 會檢查poll_table的_qproc是否為空, 不為空則繼續透傳參數, 調用_qproc;
- 驅動代碼需要調用poll_wait函數, 以自己維護的等待隊列wait_queue_head作為參數, 並透傳poll_table指針和file指針;
-
當有數據寫入設備時:
驅動模塊檢測到設備有數據可讀了, 需要喚醒傳遞給poll_wait的等待隊列, 這時隊列上每個節點的func都會被調用, 最終之前睡眠的進程被喚醒; -
當設備可寫時, 喚醒過程類似, 只是使用的隊列不同.
概括下來:
- 驅動模塊只要維護自己的等待隊列, 在poll介面的實現中, 調用上層提供的poll_wait向隊列中插入元素, 並返回當前的設備狀態;
- 驅動的其他部分在合適的時機對等待隊列執行喚醒操作;
- poll系統調用的上層實現代碼, 負責維護一套數據結構, 記錄插入到等待隊列中的節點, 給節點進行必要的設置, 使得通過節點能夠喚醒正確的進程;
總結
設備驅動的開發是在內核提供的框架下進行的, 為了降低驅動的開發難度, 快速支持各種新設備, 這套框架的設計必然要經得住考驗, 這也導致驅動的開發存在很多模板一樣的套路, 有人戲稱為"完形填空". 但是以驅動開發為出發點, 深入瞭解內核的各個模塊, 個人感覺是學習linux的一個很好的方式. 歡迎加入技術討論qq群: 838923389 一起研究linux相關的底層技術.
本文來自博客園,作者:kfggww,轉載請註明原文鏈接:https://www.cnblogs.com/kfggww/p/17653270.html