scsi是一套古老的協議,至今它還在一些硬件中存在和使用,例如基于sata協議的ssd硬盤,ufs器件等。因為scsi命令已經標準化,因此scsi子系統也成為了linux kernel眾多子系統中的一份子。
這篇文章以抽象硬件模型,引申出linux scsi子系統的設計框架。
一、硬件建模
以下描述:
- 硬件層面的總線或者控制器,在文檔里稱之為總線或者控制器;硬件層面的設備在文檔里稱之為設備。
- 軟件層面的總線,在文檔里稱之為bus,對應著structbus_type類型;軟件層面的設備稱之為device,對應著struce device類型。
linux內部的任何大的驅動子系統(例如mmc,scsi,pcie,usb等等)都是以硬件對象為基礎設計的,包括
- 硬件各級設備的睡眠和喚醒順序,決定了軟件上的設備父子關系。例如sd,emmc先sleep,sdio才能sleep。
- 硬件上的連接關系決定了軟件上掃描順序。例如pcie現有rc虛擬bridge,再掃描一級總線上的各種外設,掃到了bridge才能再遞歸掃描下一級總線上的外設。
- 硬件總線上傳輸的信息的封裝方式,決定了多級設備的驅動,各自處理的范圍。例如ufs驅動負責upiu等等處理,scsi子系統負責scsi命令的處理。
因此了解linux scsi子系統前,需要先了解scsi硬件拓撲模型
硬件模型:
上面這張圖是一個抽象的scsi子系統的硬件拓撲圖。圖上:
- soc芯片內部有host(0),host(1)...host(k)這些有scsi功能的控制器。
- 這些host分別連接著片外的scsi設備device(0)...device(k)外設。為了形象點,host(1)沒有接任何scsi外設。
- 每個device內部有若干個channel,每個channel下面有若干個id,每個id下面有若干個lun。
- 這些lun就是可以接受scsi命令的實體,例如可以是硬盤,cdrom,磁帶等等,也可以是一些可以接收特殊scsi命令的wlun。
下面分別詳細介紹:
1. Host(0-k)
- 表示可以發送和接收scsi命令的控制器。圖示中的host(0),host(1)是一個示意圖,框圖以描述host(k)為主
- 一個控制器對應一個外設;也可以不接任何外設。
注意:需要說明的是,現實硬件里看不到任何純scsi控制器;例如ufs的scsi命令是ufs控制器通過upiu傳送和接收的,upiu是在mipi總線上傳送的物理信息,而scsi則是cmd upiu中的字段。再例如usb U盤,也是類似情況。
因此這里的host(0),host(1)...host(k)是一個控制器抽象描述,真實的控制器可以是ufs、usb上接著的硬盤控制器(這個應該畫在soc外面)或者pcie上掛著的硬盤控制器(這個也應該畫在soc外面)。
2. device(0-k)
圖示中,例子device(0)是連接到host(0)控制器上的外設;device(k)是連接到host(k)控制器上的外設。外設可以是硬盤,光驅,ufs等。
注意:
Host和device之間的連接方式用了一個雙箭頭表示,它是一個抽象描述,代表scsi命令通道。
Scsi只是一個協議,因此各種五花八門的控制器都可以使用scsi進行交互。因此這個“通道”是借助各種控制器的驅動來完成的。有點像協議分層,scsi類似于協議層,而物理層,鏈路層則交給了各種控制器去完成。
在軟件上,linux scsi子系統發送和接收的任何scsi命令都是由底層物理設備對應的驅動程序完成的,例如可以通過usb某個子設備驅動或者通過ufs驅動去實現scsi命令傳輸
3. channel+id
- 關于channel和id,我目前沒有在scsi協議里面找到任何關于它們的描述。
- 這里個人理解的channel和id更多的是要給底層各種驅動程序一個靈活性。軟件上channel和id給device內部構造了一個樹形圖,而眾多的lun是這個樹上的葉子節點。關于channel和id的處理,scsi是交給底層驅動去處理的,scsi僅僅只是用這些來給lun的struce device做命名,并在發送scsi命令時把lun所屬的channel和id信息交給了驅動。更詳細的信息在后面的數據結構圖里面會描述。
- channel和id對scsi而言沒有實質意義,因此linuxscsi子系統創造了一個target的概念,如下圖:
- 上面的圖用不同的顏色畫出了三個target,target的名字是host編號,channel編號和id編號組合命名,因此上面三個target的名字分別是(k:0:0)(k:0:1) (k:0:...)。以此類推,這張圖后面還可以框出更多的target。注意channel編號和id編號是底層驅動自行管理的,而host編號(也就是前k)則是linux scsi子系統自行管理(參看static DEFINE_IDA(host_index_ida)(drivers/scsi/host.c)
- 引入target概念后,每個device內部可以看成被分為被多個target,每個target下面接著多個lun。硬件拓撲圖可以畫成下面的樣子:
其中channel(0)_id(0)到channel(m)_id(n)就是target(k:0:0)到target(k:m:n)。
4. lun
- 每個target下面掛接著多個lun。
- lun是能夠接收scsi命令的主體。例如可以是一個物理硬盤,一個光驅;對ufs而言是ufs固件虛擬出來的rpmb,boot0,boot1;也有一些lun不是物理實體但是能接收scsi命令,也被看作為lun,例如report luns可以響應scsi report luns command返回設備lun總數等信息。因此軟件上每個lun在linux的通用塊設備層都有獨享的一個request queue。注意下,有時候一個硬盤通過GPT或者MBR分為多個邏輯分區;它們是一個lun里面,共用一個request queue。關于邏輯分區,不在linux scsi子系統中處理,這里不深究。
- 注意每個lun都從host對應的物理總線通路發送和接收數據。這個也涉及到通用塊設備層的配置,后面會講
二、Linux scsi子系統軟件模型
接下來會基于linux設備驅動模型描述scsi子系統的框圖。看到這里的小伙伴接下來需要有linux設備驅動模型的基礎知識背景了。
由于linux scsi子系統代碼龐大,直接說代碼,會把人繞進去,這里會通過linux設備驅動模型中的bus,class,device框圖來描述linux scsi子系統框架,后面會結合框架,指出子系統中的關鍵代碼和位置。
1.圖示說明
后面的圖中會使用到上面的各種顏色和圖形,這里描述了這些信息的含義。這些信息都是linux軟件層面的含義。
2.主要bus和class
圖示中有三個主要的bus和class,分別是左邊的”scsi”,右邊的”scsi_host”,和下邊的”scsi_device”
它們三個構成了scsi的主體范圍。也就是下面三個很粗的雙向箭頭包裹的區域。
簡單介紹:
”scsi” bus:所有host,target,lun都有對應的structdevice放在這上面;通用的scsi的磁盤驅動”sd”,光盤驅動”sr”,磁帶驅動”osst”等驅動也在這個bus上面,這些驅動通過struct device被激活。
“scsi_host” class: host有對應的device寄存在這上面,通過host的structdevice的attr(group,type)獲取到控制器的屬性。例如可以通過這上面的scan觸發系統做對整個host做scan動作。
“scsi_device” class:所有lun的對應的structdevice寄存在這上面。操作它們的驅動是sg.c。
下面詳細說明
3. Host,target,lun設備建模
(1) host(0-k)
- Scsi子系統內部針對每個host控制器在linux子系統內部創建兩個structdevice結構體:sdhost_dev和shost_gendev。
- sdhost_dev寄存”scsi_host” class上面,并通過attr顯示一些host相關的屬性。
- shost_gendev則掛在“scsi”bus上面。“scsi” bus的match函數(scsi_bus_match)不允許shost_gendev有driver對應。所以目前只有一些attribute可以在用戶空間使用。
- 實例:
可以看到我本地電腦有
- 6個shost_gendev和6個shost_dev設備,對應著硬件上的6個host控制器。
- 這6個控制器是sata控制器,并且都是一個同一個pcie外設擴展出來的,共享一個pcie設備帶寬。
- sata驅動創建了ata1-ata6無總線掛靠的虛擬設備,Sata驅動不是這里討論的內容。但是sata在掃描完port之后,對每個port通過下面的函數創建了硬件host對應的struct device:Pci_dev添加到pci_bus
---->觸發ahci驅動的probe(ahci_init_on)
---->ahci掃描port個數(這里有6個)之后為每個port在scsi內部申請對應的host的structdevice
ahci_init_one-->ahci_host_activate-->ata_host_register-->ata_scsi_add_hosts
-->scsi_host_alloc和scsi_add_host_with_dma
這里用到了兩個scsi子系統重要的對外接入函數。
(2) target
- 在scsi內部針對每個target創建了一個名字為“targetk:m:n”的device結構體,其中k是host編號,m是channel編號,n是id編號。
- 這個targe device也被掛在到”scsi”bus總線上。“scsi” bus的match函數(scsi_bus_match)不允許target不有driver,所以目前只有一些attribute可以在用戶空間使用。
(3) lun
- Scsi子系統針對每個lun創建了兩個device對象,分別是sdev_gendev和sdev_dev。
- 它們的名字都是k:m:n:lunN,其中k是host編號,m是channel編號,n是id編號,lunN是lun編號。例如
- sdev_gendev掛在”scsi” bus上,它會觸發bus驅動,驅動會通過sdev_gendev->type字段,來判斷該device是否和自己匹配。例如ufs,ssd的device會觸發名為“sd”的驅動。sd驅動會給匹配上的lun,在用戶空間創建對應的block設備節點,類似于sda,sdb這些(sda1,sda2是sda上GPT或者MBR搞出來的邏輯分區,不屬于scsi內容)。例如
- sdev_dev是掛在名為”scsi_device”的class上,用作它用。其中比較重要的sg.c驅動,它在這個class上注冊了interface(callback),當有device掛在這個class上時,interface會被調用,從而間接的創建對應的char設備。sg比較特殊,它會不加區分的給所有進來的lun創建一個對應的字符設備到用戶空間,類似于sg0,sg1。
(4) 公版驅動
在”scsi”bus上掛著很多驅動:
- 這些驅動都通過scsi_register_driver注冊到”scsi” bus上,如果我們寫一個自定義設備驅動,也可以這樣放置到scsi子系統里。
- 這些公版驅動有針對硬盤的,磁帶的,光驅的,掃描儀,ROM等等各種設備的驅動。
- 基本上這些驅動都會在自己的probe里面去查看sdev_gendev->type字段,判斷該device是否和自己匹配。例如:
只有符合指定類型的設備,才會觸發對應的驅動程序。
三、主體代碼描述
linux驅動子系統,一般包含下面幾個內容:
- 子系統初始化:驅動bus的建立,子設備驅動的掛載。
- 外設掃描:對于scsi而言就是把device側的所有lun掃描出來。
- 通路建立:建立子設備驅動和device之間的連接,對于scsi而言就是公版外設驅動和lundevice之間的通路。Scsi子系統是借助block通用塊設備層完成這部分工作。
- 休眠喚醒:對于scsi而言,休眠過程是lun->target->host,喚醒過程是反過來。這個決定了host是爺爺輩設備,targe是父設備,lun是子設備,所有的公版驅動都是子設備驅動。
1.子系統初始化
代碼位置:kernel/drivers/scsi/scsi.c
(1) Scsi_init_queue:
這個函數主要是創建scsi cmd和sense cache用到的slab內存,這樣后續scsi cmd和sense都可以在slab中申請內存,加快內存申請速度。
(2)Scsi_init_procfs:
這個是創建一個/proc/scsi/scsi的文件節點
這個節點會顯示當前系統注冊了哪些scsi設備,包括這些設備的channel編號,id編號 lun編號等信息。
這些信息都是實時變化的;如果有寫入動作,也會觸發子系統的scan動作。
(3) Scsi_init_devinfo
這個函數創建了/proc/scsi/device_info節點。
這個節點有點像kernel 里面常用的quirk等fix機制,內容如下
結構體的前面三類分別是vendor,model和revision,其實就是scsi inquiry命令返回的數據,最后一個是個整形flag值,這個flag值影響著設備的初始話過程和操作過程。例如BLIST_NOLUN會讓scsi掃描外設時,只掃描lun0。
(4) Scsi_init_sysctl
這個函數創建了一個/proc/sys/dev/scsi/logging_level節點,這個節點控制著scsi子系統debug打印的log等級,值越小,打印越少。
(5) Scsi_init_hosts和scsi_sysfs_register
這兩個函數創建了scsi子系統最關鍵的bus和class(“scsi”, “scsi_host”和“scsi_device”):
2.子設備驅動加載
這類驅動加載一般比較簡單,而且單獨以module形式,耦合性很小。它們一般在module初始化時注冊到”scsi” bus總線上,然后一直等待有對應的子設備sdev_devgen掛到”scsi” bus上來。例如:
3.外設掃描
Scsi掃描過程定義: 是識別每個host,每個targe和每個lun,給其創建對應的device結構,并將device掛載到相應的bus或class上。
設備掃描的方式很多:
- 以host為單位進行scan。它會把host對應的device下面所有的target和lun全掃描出來。
- 以target為單位觸發scsi進行scan。它會把target下面所有的lun全掃出來
- 以lun為單位觸發scsi進行scan。它會掃描特定lun。
- 通過/proc/scsi/scsi觸發特定的target或lun的scan。
- 通過host對應user空間設備的屬性”scan”節點觸發特定的target或lun的scan。
(1) Host掃描
由于host控制器各個芯片平臺不一樣,它的掃描過程是host device的父設備所在驅動完成的,它的父設備驅動可以是platform總線,也可以是pcie設備對應的pci_driver,也可以是ufs子系統(ufshcd.c)等等。
例如我電腦上,host設備是名為”ahci”的pci_driver掃描創建的控制器,一共有6個控制器,其中只有host4這個控制器上接了一塊硬盤。
無論哪種當上一級驅動找到host后,會通過下面的
scsi_host_alloc:創建shost_gendev和shost_dev。
scsi_add_host: 把shost_gendev和shost_dev掛靠到各自的bus或class上。
(2) Target和lun掃描
從前面的硬件建模上來看,它的掃描過程是
- 先從0開始for循環掃描channel
- 在每個channel循環下面再for循環掃描每個id
- 在每個id下面再for循環掃描所有的lun偽碼如下:
以scsi_scan_host為例,這個函數是以host為單位進行全掃描
(3) 各種scan入口
- 以host為單位進行scan:scsi_scan_host
- 以target為單位觸發scsi進行scan:scsi_scan_target
- 以lun為單位觸發scsi進行scan:scsi_add_device或者__scsi_add_device
- 通過/proc/scsi/scsi:scsi_scan_host_selected
- 通過host對應user空間設備的屬性“scan”節點:scsi_scan_host_selected
4.通路建立:借助block層
Scsi注冊block層有兩個方式,一種是single q,另一種是multi q方式,這里介紹multi q的方式。
注冊multi q,需要做兩件事情
- 通過blk_mq_alloc_tag_set注冊一個blk_mq_tag_set。注冊時我們要提供一堆鉤子函數給通用塊設備層,處理block發下來的request請求。
- 通過blk_mq_init_queue并以blk_mq_tag_set為參數為每個能獨立處理block請求的實體申請一個request_queue。這樣所有的request_queue都和tag_set關聯起來了。
通過上述操作后,所有發送到request_queue中的request都會匯集到tag_set中做處理。
前面講了host(k)和device(k)中間的雙箭頭是scsi命令的傳輸通道,lun是接收和處理scsi命令的實體。因此和block層關聯的
- 第一步是在創建shost_devgen或者shost_dev的地方做的。
- 第二步是在創建sdev_dev或者sdev_devgen的地方做的。
代碼截圖:
第1步
第2步
至此外界任何發送給lun的請求都會進入到lun相關的request_queue,例如通過ioctl對sda或者sg設備的命令request都會進入到其對應lun的request_queue。最終都會走到tag_set的queue_rq鉤子函數,也就是走到了scsi_queue_rq->scsi_dispatch_cmd->host->hostt->queuecommand函數,其中queuecommand是底層驅動注冊上來的鉤子函數,scsi子系統把request請求發送到這一步之后,剩下的工作就交給底層類似于ufs,sata驅動去處理了。例如,ufs會根據請求的類型把上層傳下來的信息封裝成upiu發給硬件控制器,從而完成一次傳輸。
5.休眠喚醒
休眠喚醒是驅動的一部分,包括PM(suspendresume),runtime PM,也有shutdown,remove等。以休眠為例:在”scsi” bus上那些公版driver實現了子設備的休眠喚醒操作。這個級別的驅動操作的都是lun設備,因此這個級別的驅動是基于scsi命令對設備進行操作。那些更底層的操作例如斷開link,給外設斷電等是更底層的父設備們去完成的。例如
- 硬盤驅動sd.c在休眠的時候,給lun發送了scsiSYNCHRONIZE_CACHE命令,要求lun把緩存數據回寫到硬盤防止斷電丟失,并發送了start_stop命令要求lun進入低功耗狀態。
- 光盤驅動sr.c在連休眠喚醒沒有實現,只有一個runtime pm操作啥也沒做,可能它的功耗是由光驅自動控制的,不需要軟件參與。
Linux設備驅動模型會保證子設備suspend之后,才會是父設備的suspend,向底層一級一級父輩驅動的suspend調用。
Scsi里面的父設備target是有channel和id虛擬出來的,沒有任何休眠喚醒動作。
爺爺輩設備host從屬于上一級驅動,前面說的sata是其中一種。sata也有更上一級的pcie相關的父設備。在手機里host的上一級也可能是ufs驅動。拿ufs為例,在子設備驅動休眠后,爺爺輩驅動的功耗相關的函數會被linux設備驅動模型觸發,也就是ufs的suspend函數會讓device和host的link狀態進入hibernate8低功耗狀態。
至于resume,runtime PM各位自己可以去閱讀研究。
6.底層驅動注冊
前面說了,沒有純粹的scsi控制器,現實的控制器是sata,ufs這些把scsi封裝在自定義的通訊結構中的控制器。因此linux scsi提供一套用于scsi和各種實際控制器驅動交互的鉤子函數模板scsi_host_template。
例如ufs驅動中注冊了這套模板
這些鉤子函數由scsi主動調用,scsi并不關注這些鉤子的實現,例如ufshcd_queuecommand,用于接收scsi發下來的請求,并把scsi命令封裝到upiu中并發送給硬件host控制。scsi不關心ufs驅動如何封裝scsi命令,如何觸發硬件發送命令。
7.關于channel和id的使用
前面說了channel和id沒有在scsi協議文檔里面找到對應描述,scsi里面也沒有對target(channel+id)特別的操作,而是直接給host驅動去處理。這樣驅動可以自由定義channel和id。Host驅動在申請scsi_host時會定義該驅動支持多少個channel和每個channel支持多少個id,例如:
- ufs驅動支持一1個channel和1個id
因此對ufs驅動而言,ufs只需要關注lun,忽略channel和id的存在,通篇ufshcd.c中看不到channel和id的處理。
- sata驅動里面,支持2個channel,16個id,每個id下面就1個lun
在這個驅動里面有channel和id的相關操作
在driver/scsi目錄下搜索max_channel和channel,可以看到各種各樣的用法,這些在scsi這層沒有規定,完全取決于host驅動根據自身的情況來選擇合適的用法。
8.標準外設驅動
scsi定義了很多組命令,除了一些common的scsi命令外,也對具體類型的外設定義了一些命令標準。
針對不同的外設,scsi子系統里面也集成了一些公版驅動,如下
(1) Sd.c
由于sd.c比較常用,這里把sd.c單獨拿出來描述下,其余的外設驅動都大同小異,不再復述。Sd.c它操作的是硬盤,ssd等以sect為單位進行讀取寫入的存儲設備。
- 該驅動的名字是“sd”,
- ”sd”內部創建了一個”scsi_disk”的class
- “sd”會針對每個匹配上的sdev_gendev,做alloc_disk和device_add_disk操作。也就是說在user空間創建對應的塊設備節點,例如sda,sdb這些節點。
- “sd”也會在”scsi_disk”class上創建和sdev_gendev同名的device,會有對應group attr和其對應做一些操作。
- 關于sd設備驅動特別說明Sd設備驅動本身是塊設備驅動,它需要使用block相關的request_queue來發送塊設備相關請求給lun,前面講到lun和host之間的溝通是通過block層來完成的,每個lun有自己獨有的request_queue,因此sd驅動直接把這個request_queue拿來用之,把這個request_queue和本地申請的alloc_disk進行綁定。sda,sdb這些塊設備就可以直接通過request_queue給lun發送請求。
(2) Sg.c
Sg.c比較特殊,不是對某個類型的設備驅動。它不管三七二一,對所有掛到“scsi_device”class上的device,都創建一個char類型的設備節點到user空間。由于所有被掃描出來的lun會有一個sdev_dev在”scsi_device”上,因此sg實際上是給每個lun創建了char設備節點。
它也會創建一個同名的sg device掛在自定義的”scsi_generic” class上(沒有什么特別作用)。
sg作用:
- Sg存在的唯一目的,是使用ioctl命令,例如rpmb的操作,FFU固件升級等操作,都是通過ioctl方式完成。
- 由于無論sg還是sd,還是別的什么scsi外設驅動創建出來用戶態設備節點,最終都是通過lun對應的request_queue來完成發送scsi命令,所以sg能做的事情,其它節點也能做,因此有的平臺沒有打開sg編譯開關。
四、數據鏈表結構
硬件拓撲結構是一個樹形。在linuxscsi子系統里面,host,target,lun對應的scsi_host, scsi_target, scsi_device也是一個樹形鏈表結構,如下圖:
Linux scsi子系統中的所有list鏈表操作都是按照這張圖中的數據結構處理的。
scsi.c代碼很多是圍繞這個結構體進行操作的,例如:
shost_for_each_device
starget_for_each_device
scsi_device_lookup_by_target
scsi_device_lookup
__scsi_iterate_devices
具體實現,大家可以自己研究,對照這張圖,代碼看起來不會很難。
五、總結:
- Linux中復雜的驅動子系統的設計基本類似與scsi子系統,先有一個物理設備拓補模型,再設計對應的軟件驅動模型。
- 有時為了軟件操作有條理,或者為了軟件方便擴展,也或者為了代碼解耦,可以把物理設備拓補模型進行虛擬擴展。例如:1上述虛擬出的target設備. 2 pcie也會把抽象的bus信號線虛擬出一個pci_bus class設備等等。目的就是軟件上采用分級驅動形式,把復雜的操作簡化。
- 設備模型建好后,再實現設備掃描和PM相關的功能,各層驅動負責各層操作,盡量不越界操作,減少代碼耦合。例如sd.c不關心device是怎么放到scsi總線上來的(這個device可能是某個pcie插槽接的板卡,板卡再外接的一個外設;也可能是soc ufs控制器上的外設)sd.c驅動只管基于scsi command做好自己分內的操作就可以兼容這些設備。
- 外部硬件的連接方式像搭積木,分級驅動也可以以最小代價像積木一樣應對硬