1. 程式人生 > 其它 >《Linux裝置驅動程式1-3章》

《Linux裝置驅動程式1-3章》

裝置驅動程式簡介

以Linux為代表的的開源作業系統有許多優點,其中之一就是讓更多的人瞭解了作業系統的細節,方便地進行驗證、理解和修改作業系統,讓作業系統更民主化。學習開發裝置驅動程式是切入瞭解作業系統的最有效方式。

人們對Linux驅動程式開發的感興趣的原因有很多,首先是新硬體不斷面世,其次是人們需要了解驅動程式才能方便訪問裝置,另外硬體廠商需要為自己的裝置開發驅動。

裝置驅動程式的作用

裝置驅動程式的終極目標是提供機制,而不是提供策略。區分機制和策略是Unix設計背後隱含的最好思想之一。大多數程式設計問題實際上都可以分成兩部分,需要提供什麼功能(機制)和如何使用這些功能(策略)。這兩個問題由不同模組來實現和處理會更容易開發和維護。

在實際程式設計中經常遇到機制和策略的分離問題。例如LED驅動的基本功能是亮和滅,上層應用來決定什麼時候亮什麼時候滅,以及要亮多久。驅動程式儘可能做到不帶策略。編寫訪問硬體的核心程式碼時不要給使用者強加任何特定策略,因為不同使用者有不同的需求,驅動程式應該處理如何使硬體可用的問題,而怎樣使用硬體的問題留給上層應用程式

從軟體分層角度來看,驅動程式是應用程式和實際硬體之間的一個軟體層。驅動程式的設計主要考慮以下三個方面:1)提供給使用者儘量多的選項。2)編寫驅動程式要佔用的時間。3)儘量保持程式簡單不至於錯誤叢生。不帶策略的驅動程式有一些典型特徵:同時支援同步和非同步操作、被多次開啟、充分利用硬體特性、不提供“簡化任務”目的與策略相關的軟體層。

可裝載模組

Linux有一個很好的特性,核心提供的特性可在執行時進行擴充套件,這意味著當系統啟動並執行時可在核心新增功能。使用insmod程式將模組連線到核心,也可以使用rmmod程式來移除連線。

裝置和模組的分類

Linux系統將裝置分成三種基本型別:字元模組、塊模組、網路模組

字元裝置:位元組流裝置如串列埠裝置,特點是隻能順序訪問,通常有open、close、read、write等介面。

塊裝置:允許一次傳遞任意多位元組資料。塊裝置能夠容納檔案系統。

網路介面:網路裝置圍繞資料包的傳輸和接收而設計。

安全問題

弄清楚安全問題的原則性概念。

  • 系統中的所有安全檢查都由核心程式碼進行,如果核心有安全漏洞,則整個系統就會有安全漏洞。只允許超級使用者裝載模組,防止核心入侵風險。
  • 驅動程式編寫者應該避免實現安全策略。安全策略問題最好在系統管理員的控制之下,由核心的高層來實現。安全檢查必須由驅動程式本身完成。
  • 驅動程式編寫者應避免自身原因引入安全方面的缺陷。防止出現C語言程式設計典型的錯誤,如緩衝區溢位導致的記憶體被踩影響其他程式執行。
  • 任何從使用者得到的輸入都要經過核心嚴格驗證後才能使用
  • 小心對待未初始化的記憶體,核心申請的記憶體提供給使用者之前都要處理,防止資訊洩露(資料和密碼)。
  • 小心使用第三方獲得的軟體,特別是與核心相關的。因為原始碼是開放的,每個人都可以修改和重新編譯它,如果對原始碼不熟悉,可能存在某些安全漏洞。

版本編號

對核心來說,偶數編號的核心是用於正式發行的穩定版本,而奇數編號則是開發過程中的一個快照,它很快就會被下一個開發版本更新。

每個軟體包都有發行編號,而軟體包之間經常存在相互的依賴關係,也就是說某個軟體包依賴某個軟體包的特定版本,Linux發行版一般會解決了複雜的包匹配問題,但是如果替換或者更新系統中的某個軟體包,則另當別論。

電子書籍:https://lwn.net/Kernel/LDD3

構造和執行模組

設定測試系統

發行商提供的核心通常打了許多的補丁,從而和主線核心有很大差異,甚至會修改核心的API,因此學習驅動程式的編寫,讀者應該使用標準核心。
開發的核心驅動程式可能會有很多bug,有可能導致嚴重系統異常,所以應該尋找一個試驗、開發和測試的環境,典型的是使用QEMU環境。

hello world模組

#include <linux/init.h>
#include <linux/module.h>

MODULE_LICENSE("Dual BSD/GPL"); //開源許可宣告
static int hello_init(void)
{
    printk(KERN_ALERT"Hello world \n");
    return 0;
}

static void hello_exit(void)
{
    printk(KERN_ALERT"Goodbye,cruel world\n");
}

module_init(hello_init); //模組載入入口宣告
module_exit(hello_exit); //模組解除安裝入口宣告

函式printk在Linux核心中定義,功能和標準C庫中的函式printf類似,並且提供了列印級別控制等功能。核心需要自己單獨的列印輸出函式,這是因為它在執行時不能依賴C庫?。模組能夠呼叫printk是因為insmod函式裝入模組後,模組就連線到了核心,因而能夠訪問核心的公用符號(函式、變數)。優先順序只是個字串,例如KERN_ALERT是<1>,該字串位於printk格式字串的前面。請注意KERN_ALERT之後並不適用逗號。

核心模組和應用程式的對比

核心模組和應用程式之間存在種種不同之處

  • 大部分應用程式從頭到尾執行單個任務;而模組只是預先註冊自己以便服務將來的某個請求(介面呼叫),換句話就是模組初始化的任務就是為以後呼叫模組功能做準備。核心模組是類似事件驅動的程式設計方式。
  • 事件驅動的應用程式不需要管理資源的申請和釋放;核心模組要在申請資源時進行驗證,退出時仔細撤銷初始化函式所做的一切,否則會有殘留資源再也得不到排程。
  • 應用程式通過連結第三方函式庫,可以使用未定義的函式;模組僅僅連結到核心,因此只能使用核心匯出的那些函式,而不存在任何可連結的函式庫。
  • 異常處理方式不同,應用程式開發過程的段錯誤是無害的;而核心模組錯誤有可能會導致整個系統異常。

模組化有利於快速的測試驅動程式,不需要每次都經過冗長的關機/重啟過程。核心標頭檔案大部分儲存在include/linux和include/asm目錄中。

使用者空間和核心空間

模組執行在核心空間,而應用程式執行在所謂的使用者空間。這個概念是作業系統理論的基礎之一。作業系統作為應用程式和硬體之間的軟體層,為應用程式提供統一的介面,除此之外,還保護資源不受非法訪問。目前所有的作業系統都具備這個功能,人們選擇的方法是實現不同的操作模式。不同的操作級別具有不同的訪問許可權。例如最新的ARM v8系列CPU共有4個級別分為為EL0~EL3。EL0是安全世界且許可權最大,EL3是應用程式的級別許可權最小。

核心中的併發

核心程式設計為什麼需要考慮併發問題

  • 首選Linux系統通常執行多個併發程序,並且可能多個程序同時使用同一個驅動程式。
  • 其次中斷處理程式也會打斷CPU,而且還存在核心定時器
  • 如果是SMP系統,通常不止一個CPU執行著同樣的驅動程式。

其他一些細節

核心程式碼可通過訪問全域性項current來獲得當前程序。current在<asm.current.h>中定義。是一個指向struct task_struct的指標,這個結構體定在<linux/sched.h>中。核心開發者設計了一種能夠找到執行在相關CPU上的當前程序的機制,將task_struct結構的指標隱藏在核心棧中。
應用程式在虛擬記憶體中佈局,並且具有一塊很大的棧空間。然而,核心具有非常小的棧,它可能只有一個4k的頁那麼小。
通常具有兩個下劃線字首(__)的函式名稱,應該謹慎使用,這通常是介面的底層元件。

編譯和裝載

裝載模組的命令是insmod,它和ld有些類似,將模組的程式碼和資料裝入核心,然後使用核心的符號表解析模組中任何未解析的符號。insmod依賴於定義在kernel/module.c中的一個系統呼叫。函式sys_init_module給模組分配核心記憶體以便裝載模組,然後該系統呼叫將模組正文複製到記憶體區域,並通過核心符號表解析模組中的核心引用,最後呼叫模組的初始化函式。通常系統呼叫的函式名字帶有字首sys_,而其他函式都沒有這個字首。

modprobe工具也用來裝載模組到核心中,但與insmod的區別是,它會考慮裝載的模組是否引用了當前核心不存在的符號,如果有這類引用,modprobe會試圖找到這些引用所在的模組並一起裝載到核心中。如果在這種情況下使用insmod,則該命令會失敗,並在系統日誌中記錄“unresolved symbols”訊息。

rmmod工具用來移除模組。注意,如果核心認為模組仍然在使用狀態或者內配置為禁止移除模組,那麼無法移除該模組。

lsmod工具用來列出當前裝載到核心中的所有模組。

版本依賴

在構造模組時可將模組和當前核心樹中的一個檔案vermagic.o連結;該目標檔案包含了大量有關核心的資訊,包括目標核心版本、編譯器版本、以及一些其他重要配置變數的設定。在試圖裝載模組時會檢查模組與當前核心的相容性,如果有任何不匹配,就不會裝載模組,同時有“invalid module format”資訊。在linux/version.h中會有版本號相關的巨集定義,例如UTS_RELEASE被擴充套件為核心版本的字串“2.6.10”。

核心符號表

前面提到,modprobe工具會解決模組間依賴,並把相關模組一同裝載到核心中,這其實是模組層疊技術的體現。通過將模組分為多個層,能夠縮短開發時間。Linux核心標頭檔案提供了一個方便的方法來管理符號對模組外部的可見性,從而減少了可能造成的名稱空間汙染,並且適當隱藏資訊。如果一個模組需要向其他模組匯出符號,則應該使用下面的巨集。_GPL版本使得要匯出的模組只能被GPL許可證下的模組使用。符號必須是全域性的變數。(更多資訊檢視linux/module.h檔案)

EXPORT_SYSMBOL(name)

EXPORT_SYSMBOL_GPL(name)

其他模組宣告:

MODULE_LICENSE("GPL") 指定程式碼使用的許可證,核心能夠識別的許可證還有“GPL”(任一版本的GNU通用公共許可證)、“GPL v2”、“Dual BSD/GPL”以及“Proprietary”(專有)。如果沒有顯示宣告的話,則假定為專有的。

MODULE_AUTHOR("name") 描述作者姓名

MODULE_DESCRIPTION("function") 描述模組簡短作用

MODULE_VERSION("ver") 描述程式碼修訂號

模組初始化和關閉的注意事項

初始化

模組初始化函式負責註冊模組所提供的任何設施,這裡的設施指的的一個新功能。初始化函式應該是static的,意味著不應該對其他檔案可見。__init標記暗示核心該函式僅在初始化期間使用,在模組裝載之後這部分記憶體可釋放發來。module_init宣告是強制的。

清除函式

清楚函式沒有返回值。__exit標記該段程式碼僅用於模組解除安裝。module_exit宣告是強制的。

初始化過程中的錯誤處理

  • 當我們在核心中註冊設施時,要時刻銘記註冊可能會失敗。即便最簡單的動作需要判斷是否成功,因此模組程式碼必須始終檢查返回值。
  • 如果註冊設施時遇到任何錯誤,首先判斷模組是否可以繼續初始化,是否可以降低功能來繼續運轉。
  • 如果發生特定錯誤無法繼續提供服務,那麼要仔細核對撤銷出錯之前已註冊的工作,因為沒有撤銷已註冊的設施,那麼核心會處於一種不穩定狀態。
  • 錯誤恢復的處理通常使用goto語句,可避免大量複雜的、高度縮排的結構化邏輯。
  • 每次返回合適的錯誤編碼是一個好習慣,可幫助迅速找到問題原因。
  • 如果已註冊設施過多,則goto方法可能變得難以管理,那麼每次失敗呼叫同一個清楚函式會更清晰(清除函式要檢查註冊設施的狀態)。

模組裝載競態

首先要銘記的是,在註冊完成之後,核心的某些部分可能會立即使用我們剛剛註冊的任何設施。因此在註冊設施之前務必要做完該設施的初始化。

模組引數

在insmod裝載模組時可向模組傳入引數。引數必須使用module_param巨集來宣告,才能對外部可見。module_param需要三個引數,變數名字、型別、以及用於sysfs入口項的訪問許可掩碼。這些巨集定義在<moduleparam.h>檔案。perm訪問許可值,決定了模組引數在sys/module路徑下的讀寫屬性。如果引數通過sysfs修改,則如同核心修改了這個引數的值一樣,但是核心不會以任何方式通知模組。

static char *who = "world";

static int howmany = 1;

module_param(who, charp, S_IRUGO);

module_param(howmany, int, S_IRUGO);

module_param_array(name, type, num, perm); //還可定義陣列

字元裝置驅動程式

開發字元裝置驅動程式的原因是因為此類驅動程式適合大多數簡單地硬體裝置,scull:simple character utility for loading localities。scull的優點在於它不依賴於硬體,而只是操作從核心分配的一些記憶體。

主裝置號和次裝置號

如果在執行命令 ls -l /dev,則在裝置檔案項的最後修改日期前看到兩個數(用逗號分隔),分別對應主裝置號和次裝置號。

主裝置號標識裝置對應的驅動程式;次裝置號由核心程式使用,標識同類型裝置的不同裝置。

裝置編號的內部表示

Linux核心中,裝置號用dev_t來描述,在<linux/types.h>中定義。dev_t是一個32位無符號整數,其中高12位用來表示主裝置號,低20位表示次裝置號。這些都是通過巨集定義的,我們軟體不能做任何假定。獲取主次裝置號要通過巨集的方式<linux/kdev_t.h>

#define MINORBITS    20
#define MINORMASK    ((1U << MINORBITS) - 1)
#define MAJOR(dev)    ((unsigned int) ((dev) >> MINORBITS))
#define MINOR(dev)    ((unsigned int) ((dev) & MINORMASK))
#define MKDEV(ma,mi)    (((ma) << MINORBITS) | (mi))

分配和釋放裝置編號

在建立一個字元裝置之前,我們的驅動程式首先要做的事情就是獲得一個或者多個裝置編號。<linux/fs.h>中聲明瞭有關介面,register_chrdev_region用於明確知道裝置編號的情況;alloc_chrdev_region則用於動態申請裝置編號,不論使用哪種方法分配裝置編號,都應該在不再使用時釋放這些編號。強烈建議新驅動程式使用動態分配機制獲取主裝置號,避免軟體開源後與其他程式產生衝突。

int register_chrdev_region(dev_t from, unsigned count, const char *name)

int alloc_chrdev_region(dev_t *dev, unsigned baseminor, unsigned count, const char *name)

void unregister_chrdev_region(dev_t from, unsigned count)

讀取cat /proc/devices檔案可知道系統下面所有裝置編號對應的驅動程式。

Character devices:
1 mem
4 /dev/vc/0
4 tty
5 /dev/tty
5 /dev/console

Block devices:
1 ramdisk
7 loop
8 sd

重要的資料結構

檔案操作 file_operations

迄今為止,我們申請了裝置編號,但尚未將任何驅動程式操作連線到這些編號。file_operations結構就是用來建立這種連線的,這個結構體定義在<linux/fs.h>中。每個開啟的檔案在核心中用file結構體表示,file結構體包含一個file_operations結構的指標。我們可以認為檔案是一個物件,而操作它的函式是方法,這是核心應用面向物件程式設計的一個例證。file_operations結構或者指向它的指標稱為fops,這個結構中的每個欄位都必須指向驅動程式中實現特定操作的函式,對於不支援的操作對應欄位可設定為NULL值。從file_operations結構的成員函式來看,其入參大多為file結構,也驗證了其操作物件主要為file。

struct file_operations {
  struct module *owner;
  loff_t (*llseek) (struct file *, loff_t, int);
  ssize_t (*read) (struct file *, char __user *, size_t, loff_t *);
  ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *);
  ssize_t (*read_iter) (struct kiocb *, struct iov_iter *);
  ssize_t (*write_iter) (struct kiocb *, struct iov_iter *);
  __poll_t (*poll) (struct file *, struct poll_table_struct *);
  int (*mmap) (struct file *, struct vm_area_struct *);
  void (*show_fdinfo)(struct seq_file *m, struct file *f);
}

檔案結構 file

在<linux/fs.h>定義的struct file是裝置驅動程式所使用的重要資料結構,注意與使用者空間程式中的FILE沒有任何聯絡。FILE在C庫中定義且不會出現在核心程式碼中。而struct file是一個核心結構,它不會出現在使用者程式中。file結構代表一個開啟的檔案。它由核心開啟並傳遞給在該檔案上操作的所有函式。指向struct file的指標通常稱為file或者filp檔案指標。

const struct file_operations*f_op; /* 與檔案相關的操作 */

unsigned int f_flags;  /* 檔案標誌 */

fmode_tf_mode;  /* 檔案模式 */

loff_tf_pos; /* 當前檔案位置 */

inode結構

核心用inode結構表示檔案。它和file結構不同,後者表示開啟的檔案描述符,前者是檔案在核心中的組織結構。對單個檔案,可能存在許多個表示開啟的檔案描述符file結構,但它們都指向單個inode結構。

dev_t i_rdev 表示裝置檔案的inode結構,該欄位包含真正的裝置編號

struct cdev *i_cdev 表示字元裝置的核心的核心結構。

file_operations重要的函式操作

open方法

open方法提供給驅動程式以初始化的能力,從而為以後的操作完成初始化做準備。在大部分驅動程式中,open應完成如下工作:

  • 檢查裝置特定的錯誤,諸如裝置未就緒或類似的硬體問題
  • 如果裝置是首次開啟,則對其進行初始化
  • 如有必要,更新f_op指標
  • 分配並填寫置於filp->private_data裡的資料結構

int (*open) (struct inode *, struct file *);

release/close方法

release方法的作用於open相反,有時這個方法被稱為device_close而不是device_release。釋放由open分配、儲存在filp->private_data中的所有內容;在最後一次關閉操作時關閉裝置。並不是每次close系統呼叫時都會呼叫release方法。核心維持一個檔案被使用的次數(fork/dup)都不建立新檔案,而只是新增結構中的計數。當呼叫close遞減為0時才執行release。

read/write方法

對於這兩個方法,filp是檔案指標,count是請求傳輸資料的大小。buff是指向使用者空間的緩衝區(這個緩衝區儲存要寫入的資料),這個offp是使用者正在讀取檔案filp的位置。__user標識buff為使用者空間指標,buff不能被核心直接引用,在程式碼中沒有其他實際作用,可用於靜態檢查。

read和write工作否核心是在使用者空間和記憶體地址之間進行整段資料的拷貝,這種能力是通過copy_from_user / copy_to_user 核心函式提供的。copy*函式用於使用者空間和核心空間傳輸,它們的作用不僅限於memcpy,還會檢查使用者空間指標的有效性。

ssize_t read(struct file *filp, char __user *buff, size_t count, loff_t *offp);
ssize_t write(struct file *filp, const char __user *buff, size_t count, loff_t *offp);

在使用者空間和核心空間拷貝資料
unsigned long copy_from_user (void *to, const void *from, unsigned long count);
unsigned long copy_to_user (void *to, const void *from, unsigned long count);