高級字符設備進階
1.一個完整的IO過程包含以下幾個步驟:1應用程序向操作系統發起IO調用請求(系統調用);2操作系統準備數據,把IO設備的數據加載到內核緩沖區;3操作系統拷貝數據,把內核緩沖區的數據從內核空間拷貝到應用空間。IO模型有阻塞IO、非阻塞IO、IO多路復用、信號驅動IO、異步IO,其中前四個被稱之為同步IO,只有最后一種是真正的異步IO,因為無論是多路復用IO還是信號驅動模型,IO操作的第2個階段都會引起用戶線程阻塞,也就是內核進行數據拷貝的過程都會讓用戶線程阻塞。示意圖如下:
- 阻塞IO:
- 非阻塞IO:
- IO多路復用:IO多路復用可以實現一個進程監視多個文件描述符,一旦其中一個文件描述符準備就緒,就通知應用程序進行相應的操作
- 信號驅動IO:信號驅動IO不需要應用程序去查詢設備的狀態。一旦設備準備就緒,就觸發SIGIO信號,該信號會通知應用程序數據已經到來
- 異步IO:
2.等待隊列:等待隊列是內核實現阻塞和喚醒的內核機制,等待隊列以循環鏈表為基礎結構,鏈表頭和鏈表項分別為等待隊列頭和等待隊列元素,整個等待隊列由等待隊列頭進行管理。等待隊列頭使用結構體 wait_queue_head_t來表示, 等待隊列頭就是一個等待隊列的頭部,Linux中與等待隊列定義在文件include/linux/wait.h里面,如下圖:
結構體wait_queue_entry_t表示等待隊列項,結構體內容如下:
list_head的結構為:
所以整個等待隊列的結構如下圖所示:
初始化一個等待隊列頭的方法有兩種:1先定義一個等待隊列頭wait_queue_head_t head;,然后用void init_waitqueue_head(wait_queue_head_t *q)函數將其初始化;2使用宏定義DECLARE_WAIT_QUEUE_HEAD(name)一次性定義并初始化一個等待隊列頭,這里的name的類型是結構體 wait_queue_head_t。等待隊列項一般使用宏DECLARE_WAITQUEUE(name, task)來創建,name就是等待隊列項的名字,其類型是結構體wait_queue_entry_t。task表示這個等待隊列項屬于哪個任務(進程),一般設置為current,在Linux內核中current相當于一個全局變量,表示當前進程。因此DECLARE_WAITQUEUE就是給當前正在運行的進程創建并初始化了一個等待隊列項。例如:DECLARE_WAITQUEUE(wait,current);表示給當前正在運行的進行創建一個名為wait的等待隊列項,再使用add_wait_queue(&wq,&wait);表示將wait這個等待隊列項加到wq這個等待隊列當中。void add_wait_queue(wait_queue_head_t *q, wait_queue_entry_t *wait)函數可將隊列項加入某個隊列中,q是待加入隊列的等待隊列(頭),wait是要加入的等待隊列項。void remove_wait_queue(wait_queue_head_t *q, wait_queue_entry_t *wait)函數用于從等待隊列中刪除等待隊列項,其中q是目標隊列的等待隊列頭,wait是要刪除的等待隊列項。當設備可以使用的時候就要喚醒進入休眠態的進程,喚醒可以使用如下兩個函數:wak_up(wait_queue_head_t *q)函數用于喚醒所有休眠進程;wake_up_interruptible(wait_queue_head_t *q)用于喚醒可中斷的休眠進程。等待事件:1 wait_event (wq,condition)宏,不可中斷的阻塞等待,讓調用進程進入不可中斷的睡眠狀態, 在等待隊列里面睡眠直到condition變成真,被內核喚醒,這里的wq的類型是結構體wait_queue_head_t,在condition為1時該函數不會阻塞而是會立即返回。2 wait_event_interruptible(wq,condition)宏,可中斷的阻塞等待,讓調用進程(當前進程)進入可中斷的睡眠狀態,直到condition 變成真被內核喚醒或信號打斷喚醒,這里的wq的類型是結構體wait_queue_head_t,在condition為1時該函數不會阻塞而是會立即返回。等待隊列的使用方法為:1.初始化等待隊列頭,并將條件置成假(condition=0);2.在需要阻塞的地方調用wait_event(),使進程進入休眠;3.當條件滿足時,需要解除休眠,先將條件置成真(condition=1),然后調用wake_up函數喚醒等待隊列中的休眠進程(可參考訊為Linux驅動視頻第四期P2)。
3.應用程序可以使用如下所示示例代碼來實現阻塞訪問:fd=open("/dev/xxx_dev”,0RDWR);,ret =read(fd,&data,sizeof(data));,可以看出對于設備驅動文件的默認讀取方式就是阻塞式的。如果應用程序要采用非阻塞的方式來訪問驅動設備文件,可以使用如下所示代碼:fd =open("/dev/xxx_dev",O_RDWR|O_NONBLOCK);、ret = read(fd, &data, sizeof(data)),上述代碼在使用open數打開“/dev/xxx_dev”設備文件的時候添加了參數“O_NONBLOCK”表示以非阻塞方式打開設備,這樣從設備中讀取數據的時候就是非阻塞方式的了。在驅動程序中的讀寫函數里面,他們的參數中有一個struct file *file,這個結構體有一個成員file->flags用來記錄打開文件時傳入的O_RDWR|O_NONBLOCK,可以在驅動程序中根據這個來判斷是否是以非阻塞方式實現IO(可參考訊為Linux驅動視頻第四期P3)。
4.在應用層使用select(int select(int nfds, fd_set *readfds, fd_set *writefds,fd_set *exceptfds, struct timeval *timeout);)和poll(int poll(struct pollfd *fds, nfds_t nfds, int timeout);)等系統調用會觸發設備驅動中的poll()函數被執行,file_operations結構體中有一個函數指針成員poll:__poll_t (*poll) (struct file *, struct poll_table_struct *);,一般在驅動程序中將其綁定到驅動程序中自定義的poll函數以實現應用層和驅動層的互通。驅動中poll函數要進行兩項工作,第一項工作:對可能引起設備文件狀態變化的等待隊列調用poll_wait(void poll_wait(struct file *filp, wait_queue_head_t *queue, poll_table *wait);),該函數將對應的等待隊列頭添加到poll_table,等待特定事件的發生(如設備準備好讀取或寫入數據),poll_table 結構用于跟蹤該進程的等待狀態。poll_wait 函數本身是非阻塞的,它只是將當前進程加入到等待隊列中,實際上阻塞當前進程的工作是在內核中的內核中的其他函數(do_poll)中完成的,事件發生時需要調用wake_up_interruptible等函數來喚醒被阻塞的進程。當應用層調用 poll 時,內核會依次檢查文件描述符對應的設備是否已經準備好(例如是否可以讀取數據,當應用層調用poll()函數時,它會遍歷傳入的文件描述符集合,并將每個文件描述符對應的設備驅動的poll()函數調用一遍)。如果設備尚未準備好(例如沒有數據可讀),進程會被加入到等待隊列中,并且會進入阻塞狀態。第二項工作:返回表示是否能對設備進行無阻塞讀寫訪問的掩碼。Linux中與poll有關的定義在include/linux/poll.h中。如下圖所示(可參考訊為Linux驅動視頻第四期P4,及實驗18_poll):
5.應用程序使用信號驅動IO的步驟:1注冊信號處理函數,應用程序使用signal函數(sighandler_t signal(int signum, sighandler_t handler);)來注冊SIGIO信號的信號處理函數,SIGIO信號用于通知進程與IO相關的事件。這是一個事件驅動信號,通常用于告知進程,某個文件描述符已經準備好進行IO操作(例如,數據可以讀取或可以寫入),使得進程可以在不阻塞的情況下處理IO操作;2設置能夠接收這個信號的進程,通過fcntl(fd,F_SETOWN,getpid());實現,這句的作用是將指定的文件描述符fd的IO操作的信號通知目標設置為當前進程。這意味著,當該文件描述符上的IO事件(如可讀、可寫等)發生時,操作系統會發送一個信號(通常是SIGIO)給指定的進程;3開啟信號驅動IO,通常使用fcnt1的F_SETFL命令打開FASYNC標志,如:flags=fcntl(fd,F_GETFD);,fcntl(fd,F_SETFL,flags|FASYNC);。驅動程序中要執行以下步驟:1當應用程序開啟信號驅動IO時,會觸發驅動中的fasync函數,所以首先將file_operations結構體中的成員fasync(int (*fasync) (int, struct file *, int);)綁定到在驅動程序中自定義的fasync函數。2在自定義的fasync函數中調用fasync_helper函數來操作fasync_struct結構體,fasync_helper函數原型為int fasync_helper(int fd, struct file * filp, int on, struct fasync_struct **fapp),該函數用于管理與文件描述符相關的IO事件,它根據文件描述符的標志位,控制是否將IO事件的信號(如SIGIO)發送給進程,fasync_helper會在文件描述符注冊時,通過fasync_struct將文件描述符與進程進行綁定,確保在文件描述符準備好時,能夠通知進程(通過發送SIGIO信號)。其中fd是這是需要設置IO的文件描述符,filp是指向struct file結構體的指針,該結構體代表一個已經打開的文件,filp是內核中的文件句柄,用于表示內核中的文件對象,on參數指定是否啟用IO,fapp是一個指向fasync_struct指針的指針,fasync_struct用于存儲與IO相關的數據,如進程的異步通知隊列。3當設備準備好的時候,驅動程序需要調用kill_fasync函數通知應用程序,此時應用程序的SIGIO信號處理函數就會被執行,kill_fasync負責發送指定的信號。函數原型為void kill_fasync(struct fasync_struct **fp, int sig, int band);,其中fp是要操作的fasync_struct,sig是要發送的信號,band在可讀的時候設置成POLLIN,可寫的時候設置成POLLOUT(可參考訊為Linux驅動視頻第四期P5)。
6.Linux內核定時器是一種基于未來時間點的計時方式,基于未來時間點的計時是以當前時刻為計時開始的時間點,以未來的某一時刻為計時的終點。比如,現在是早上7點,我用手機定時五分鐘,定時時間就是7點+5分鐘=7點5分。內核定時器的精度不高,所以不能作為高精度定時器使用,并且內核定時器不是周期性運行的,到計時終點后會自動關閉。如果想要實現周期性定時,就需要定時處理函數中重新開啟定時器。Linux內核使用timer_list結構體表示內核定時器(這個定時器只能用在內核,用戶空間的定時器相關函數是timer_create()、timer_settime()等),timer_list定義在include/linux/timer.h頭文件當中,定義如下:
在timer_list結構體中,expires為計時終點的時間,單位是節拍數。Linux內核中有一個宏HZ,這個宏用來表示一秒鐘對應的節拍的數量,利用這個宏就可以把時間轉換成節拍數。比如定時一秒鐘換成節拍數就是expires=jiffies+5*HZ,其中jiffies為系統當前時間對應的節拍數。宏HZ的值是可以設置的,也就是說一秒鐘對應多少個節拍數是可以設置的。進入內核源碼目錄,打開menuconfig圖形化配置界面,按照->Kernel Features->Timer frequency(<choice>[=y])就可以設置。全局變量jiffies用來記錄自系統啟動以來產生的節拍的總數。系統啟動時,內核將該變量初始化為0,此后每次時鐘中斷處理程序都會增加該變量的值。 因為一秒內時鐘中斷的次數為HZ(節拍數),所以jiffies一秒內增加的值也就為HZ(節拍數),系統運行時間以秒為單位計算, 就等于 jiffies/HZ。jiffies=seconds*HZ。jiffies定義在文件include/linux/jiffies.h 中,定義如下(jiffies_64和jiffies分別對應64和32位系統):
與全局變量jiffies相關的轉換函數如下圖:
內核定時器的使用步驟為:1初始化內核定時器(struct timer_list),可以直接使用宏DEFINE_TIMER(_name, _function)來初始化內核定時器(對應于5以上的內核版本),其中_name為定時器名字,_function為回調函數,然后用_name.expires=jiffies_64 +msecs_to_jiffies(ms);來設置定時時間。2調用void add_timer(struct timer_list *timer)函數向Linux內核注冊定時器。3在驅動出口函數中調用int del_timer(struct timer_list * timer)刪除定時器。4如果想修改定時時間,可以調用int mod_timer(struct timer_list *timer, unsigned long expires)進行修改(可參考訊為Linux驅動視頻第四期P7、P8)。
7.Linux中的dmesg命令用于顯示內核的環形緩沖區(ring buffer)中的消息,這些消息通常是內核在系統啟動時、驅動加載時、硬件設備初始化時等階段產生的日志信息。通過dmesg命令,可以查看到系統啟動過程中的各種硬件設備識別、驅動加載以及其他內核層面的信息。dmesg命令的參數如下圖所示:
在ubuntu中直接執行insmod命令安裝內核模塊是看不到打印信息的,而cat /proc/kmsg 命令用于查看 Linux 系統中的內核日志,它直接從 /proc/kmsg 文件讀取內核緩沖區中的日志消息,實時顯示系統運行時內核產生的實時日志輸出在終端。所以可以開啟兩個終端在其中一個中執行cat /proc/kmsg 命令,在另一個終端進行內核模塊的安裝就能看到打印信息了。
8.Linux內核日志的打印是有打印等級的,可以通過調整內核的打印等級來控制打印日志的輸出,使用命令cat /proc/sys/kernel/printk可以查看默認的打印等級。如下圖所示:
打印等級有四個數字,這四個數字分別代表console_loglevel(當前控制臺日志等級,它控制了內核消息被輸出到控制臺的最低日志等級)、default_message_loglevel(默認消息等級,若沒指定輸出日志的等級,內核將使用這個默認值)、minimum_console_loglevel(控制臺日志等級可被設置的最小值(最高優先級))、default_console_loglevel(默認控制臺日志的等級)。這四個等級定義在kernel/printk/printk.c文件當中,如下圖所示:
Linux內核提供了8中不同的日志級別,分別對應0到7,數字越小級別就越高,定義在include/linux/kern_levels.h文件當中。如下圖:
在內核打印的時候,只有數值小于(級別高)當前系統的設置的打印等級,打印信息才可以被顯示到控制臺上,大于或者等于(級別低)的打印信息不會被顯示到終端上。可以用以下三種方法來修改Linux內核打印等級:1通過make menuconfig圖形化配置界面修改默認的日志級別default_console_loglevel,menuconfig圖形化配置界面路徑:Kernel hacking ->printk and dmesg options ->Default message log level();2在調用printk的時候設置打印等級,例如printk(KERN_EMERG "hello!\n”);;3使用echo直接修改打印等級,具體步驟為:首先可以用命令cat /proc/sys/kernel/printk查看內核打印等級,然后按需求修改控制臺打印等級。例如要屏蔽所有打印,只需要將第一個數值調整到0即可,使用命令為echo 0 4 1 7 >/proc/sys/kernel/printk。再如要打開控制臺的所有打印,使用命令為echo 7 4 1 7 >/proc/sys/kernel/printk。
9.用戶空間的lseek函數:off_t lseek(int fd,off_t offset,int whence)其中,fd為文件描述符,offset為偏移量(單位為字節,可正可負),whence的值可為SEEK_SET、SEEK_CUR、SEEK_END分別表示文件開頭、文件當前偏移位置、文件結尾,這個函數可以用來改變文件的當前偏移位置,若成功返回文件當前相對于文件開頭的偏移量失敗則返回-1,同一個文件“讀”和“寫”使用的是同一偏移位置(lseek函數還可以用來查詢當前文件的大小,如lseek(fd,0,SEEK_END)返回的就是當前文件大小,還可以用此函數拓展文件大小,但是要想真正拓展文件大小,必須引起IO操作)。在用戶空間中調用lseek函數會調用驅動中的file_operations結構體中的成員loff_t (*llseek) (struct file *, loff_t, int);,可以在驅動程序中自定義llseek函數并將其綁定到file_operations的成員llseek,如下圖:
同時需要對驅動中的read和write進行修改,驅動中的read函數原型為ssize_t (*read)(struct file *filp,char __user *buffer,size_t size,loff_t *p);,其中file指向打開的文件,buffer為存放數據的緩沖區,size為要讀取的數據長度,p為讀的位置,也就是相對于文件的開頭的偏移,在讀完數據以后,這個指針要進行移動,動的值為讀取信息的長度。file結構體中有一個成員file-> f_pos指向文件當前偏移量。一個read函數示例如下圖:
驅動中的write函數原型為ssize_t (*write)(struct file *filp, const char __user *buffer, size_t count, loff_t *ppos);,其中file指向打開的文件,buffer為寫入數據的緩沖區,size為要寫入的數據長度,p為寫的位置,也就是相對于文件的開頭的偏移。下圖是一個例子(可參考訊為Linux驅動視頻第四期P11):
10.Linux中對非數據的操作通常通過ioctl操作來實現。應用層的ioctl函數在頭文件sys/ioctl.h中定義,函數原型為int ioctl(int fd,unsigned int cmd, unsigned long request, ...);,其中fd為打開設備節點獲得的文件描述符,cmd為給驅動傳遞的命令,后面為可變參數,該函數調用成功返回0失敗返回-1。調用用戶空間的ioctl最終會調用驅動程序中file_operations結構體中的成員long (*unlocked_ioctl) (struct file *file, unsigned int cmd, unsigned long arg);,unlocked_ioctl的三個參數和ioctl的參數是對應的。ioctl或unlocked_ioctl函數中參數cmd命令的格式如下圖:
上圖中,設備類型代表一類設備,一般用一個字母或者一個8bit的數字來表示;序列號代表的是這類設備的第幾個命令;方向表示命令的方向,如:只讀(10)、只寫(01)、寫讀(11)、無數據(00);數據大小表示用戶數據的大小,注意這里傳遞的不是數字,而是數據類型,比如要傳遞四個字節,就可以寫入int。Linux定義了幾個宏來構建和分解上面的cmd,ioctl的參數cmd的合成宏包括:合成沒有數據傳遞的命令的宏_IO(type,nr)、合成從驅動中讀取數據的命令的宏_IOR(type,nr,size)、合成向驅動中寫數據的命令的宏_IOW(type,nr,size)、合成先寫入數據再讀取數據的命令的宏_IOWR(type,nr,size),其中type為設備類型,nr為序列號,size為數據尺寸。ioctl的參數cmd的分解宏包括:獲取方向的宏_IOC_DIR(nr)、獲取設備類型的宏_IOC_TYPE(nr)、獲取序列號的宏_IOC_NR(nr)、獲取數據尺寸的宏_IOC_SIZE(nr),其中nr為前面合成的cmd命令。當想要通過ioctl函數傳遞多個參數時,可以將這些參數封裝成一個結構體,然后傳入結構體的地址即可(可參考訊為Linux驅動視頻第四期P15)。
11.可以通過靜態庫將驅動程序進行封裝,提供API函數供應用層使用(可參考訊為Linux驅動視頻第四期P17)。靜態庫的制作及使用:首先把將要制作成靜態庫的.c文件轉換成.o文件:如gcc -c add.c -o add.o,然后使用ar工具制作靜態庫:如ar -rcs libname.a add.o sub.o div.o(-r選項會將指定的目標文件add.o、sub.o、div.o插入到 libname.a文件中。如果這些目標文件已經存在于該文件中,則會被更新,如果libname.a文件不存在,則使用-c將創建一個新的文件,-s表示創建文件時加入符號表,該符號表用于鏈接時解析符號引用),最后編譯靜態庫到可執行文件中:如gcc test.c libname.a -o a.out(注意在命令中靜態鏈接庫位于源程序后面,否則編譯不成功,這與鏈接器工作時的算法有關,源程序用到的靜態庫函數需要聲明,否則會報警告,一般可以寫入頭文件來聲明)(靜態庫都以.a結尾,默認庫名以lib開頭,如果在生成可執行文件中沒有加入靜態庫,而源程序需要用到靜態庫的某些函數,則編譯時會在鏈接階段報錯,一般程序編譯過程中只會在4個步驟中的編譯階段或鏈接階段報錯,前者有報錯的行號后者沒有)。
12.優化驅動的穩定性和效率:通過在一些函數調用后面添加錯誤處理可以增加程序的穩定性,但這樣會導致增加許多if條件分支。現在的CPU都有I-Cache和流水線機制,運行當前的指令時,I-Cache會預讀取后面的指令,從而提升效率。但是如果條件分支的結果是跳轉到了其他指令,那預取下一條指令就浪費時間了。如果使用likely和unlikely來讓編譯器總是將大概率執行的代碼放在靠前的位置,就可以提高效率。likely(condition)表示 condition 這個條件是大概率為真的,即程序流大概率會走到這個分支。unlikely(condition)表示 condition 這個條件是大概率為假的,即程序流大概率不會走到這個分支。如下圖是一個例子:
access_ok(addr, size)函數可用于檢查用戶空間指針是否可用,其中addr為用戶空間的指針變量,指向一個要檢查的內存塊的開始,size是要檢查的內存塊的大小,如果待檢查用戶空間的內存塊可用該函數返回真,否則返回假。
13.內核驅動程序中添加調試信息的幾種方法:
- 使用printk函數進行打印
- 使用dump_stack()函數:dump_stack() 會打印出當前執行棧的調用信息,顯示從當前函數到棧頂的所有函數調用路徑
- 使用WARN(condition, fmt...) 和 WARN_ON(condition):這是Linux內核中的調試宏,主要用于在代碼中進行條件檢查,當某個條件不滿足時,向內核日志輸出警告信息,并可以觸發調試處理。其中condition是要檢查的條件,如果條件為真,宏會打印警告信息。fmt...為可變參數,用于格式化打印的消息,類似 printf 的格式。這兩個宏的打印信息內容與dump_stack()函數類似。WARN_ON(condition)的返回值是true或false,具體取決于條件condition是否為真
- 使用BUG()和BUG_ON(condition)函數:它們都是 Linux 內核中的調試宏,用于在遇到嚴重錯誤時觸發內核的調試功能,主要目的是標識和報告致命的錯誤,并在發生錯誤時終止內核的執行。BUG() 宏用于在發生嚴重錯誤時立即觸發內核崩潰,并生成一個內核 panic,它沒有條件檢查,因此它總是會執行,并且不會返回。BUG_ON(condition) 是一個帶有條件檢查的宏,用于在給定條件condition為真時觸發內核崩潰。這兩個宏會將寄存器、堆棧等內容都打印出來
- 使用panic(fmt...)函數:用于在發生嚴重錯誤時觸發內核崩潰,它輸出一條格式化的錯誤信息,并終止內核的執行。panic() 的調用通常標志著程序進入了一個不可恢復的錯誤狀態,系統會停止執行,并且可能會生成一個內核轉儲供后續分析