暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

Linux Scsi子系统框架介绍

内核工匠 2021-07-27
1952
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


  • 关于channelid,我目前没有在scsi协议里面找到任何关于它们的描述。

  • 这里个人理解的channelid更多的是要给底层各种驱动程序一个灵活性。软件上channeliddevice内部构造了一个树形图,而众多的lun是这个树上的叶子节点。关于channelid的处理,scsi是交给底层驱动去处理的,scsi仅仅只是用这些来给lunstruce device做命名,并在发送scsi命令时把lun所属的channelid信息交给了驱动。更详细的信息在后面的数据结构图里面会描述。

  • channelidscsi而言没有实质意义,因此linuxscsi子系统创造了一个target的概念,如下图:



  • 上面的图用不同的颜色画出了三个targettarget的名字是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固件虚拟出来的rpmbboot0boot1;也有一些lun不是物理实体但是能接收scsi命令,也被看作为lun,例如report luns可以响应scsi report luns command返回设备lun总数等信息。因此软件上每个lunlinux的通用块设备层都有独享的一个request queue。注意下,有时候一个硬盘通过GPT或者MBR分为多个逻辑分区;它们是一个lun里面,共用一个request queue。关于逻辑分区,不在linux scsi子系统中处理,这里不深究。

  • 注意每个lun都从host对应的物理总线通路发送和接收数据。这个也涉及到通用块设备层的配置,后面会讲。



二、Linux scsi子系统软件模型



接下来会基于linux设备驱动模型描述scsi子系统的框图。看到这里的小伙伴接下来需要有linux设备驱动模型的基础知识背景了。

由于linux scsi子系统代码庞大,直接说代码,会把人绕进去,这里会通过linux设备驱动模型中的busclassdevice框图来描述linux scsi子系统框架,后面会结合框架,指出子系统中的关键代码和位置。



1.图示说明



后面的图中会使用到上面的各种颜色和图形,这里描述了这些信息的含义。这些信息都是linux软件层面的含义


2.主要bus和class


图示中有三个主要的busclass,分别是左边的”scsi”,右边的”scsi_host”,和下边的”scsi_device”
它们三个构成了scsi的主体范围。也就是下面三个很粗的双向箭头包裹的区域。


简单介绍:
”scsi” bus:所有hosttargetlun都有对应的structdevice放在这上面;通用的scsi的磁盘驱动”sd”,光盘驱动”sr”,磁带驱动”osst”等驱动也在这个bus上面,这些驱动通过struct device被激活。
“scsi_host” class: host有对应的device寄存在这上面,通过hoststructdeviceattr(group,type)获取到控制器的属性。例如可以通过这上面的scan触发系统做对整个hostscan动作。
“scsi_device” class:所有lun的对应的structdevice寄存在这上面。操作它们的驱动是sg.c
 
下面详细说明


3. Host,target,lun设备建模


(1) host(0-k)

  • Scsi子系统内部针对每个host控制器在linux子系统内部创建两个structdevice结构体:sdhost_devshost_gendev



  • sdhost_dev寄存”scsi_host” class上面,并通过attr显示一些host相关的属性。



  • shost_gendev则挂在“scsibus上面。“scsi busmatch函数(scsi_bus_match)不允许shost_gendevdriver对应。所以目前只有一些attribute可以在用户空间使用。



  • 实例:





可以看到我本地电脑有
  • 6shost_gendev6shost_dev设备,对应着硬件上的6host控制器。

  • 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个)之后为每个portscsi内部申请对应的hoststructdevice
ahci_init_one-->ahci_host_activate-->ata_host_register-->ata_scsi_add_hosts
-->scsi_host_allocscsi_add_host_with_dma
这里用到了两个scsi子系统重要的对外接入函数。

(2) target

  • scsi内部针对每个target创建了一个名字为“targetk:m:n”device结构体,其中khost编号,mchannel编号,nid编号。




  • 这个targe device也被挂在到”scsi”bus总线上。“scsi busmatch函数(scsi_bus_match)不允许target不有driver,所以目前只有一些attribute可以在用户空间使用



(3) lun

  • Scsi子系统针对每个lun创建了两个device对象,分别是sdev_gendevsdev_dev



  • 它们的名字都是k:m:n:lunN,其中khost编号,mchannel编号,nid编号,lunNlun编号。例如



  • sdev_gendev挂在”scsi” bus上,它会触发bus驱动,驱动会通过sdev_gendev->type字段,来判断该device是否和自己匹配。

    例如ufsssddevice会触发名为“sd”的驱动。sd驱动会给匹配上的lun,在用户空间创建对应的block设备节点,类似于sdasdb这些(sda1,sda2sdaGPT或者MBR搞出来的逻辑分区,不属于scsi内容)。例如




  • sdev_dev是挂在名为”scsi_device”class上,用作它用。其中比较重要的sg.c驱动,它在这个class上注册了interface(callback),当有device挂在这个class上时,interface会被调用,从而间接的创建对应的char设备。sg比较特殊,它会不加区分的给所有进来的lun创建一个对应的字符设备到用户空间,类似于sg0sg1



(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 cmdsense cache用到的slab内存,这样后续scsi cmdsense都可以在slab中申请内存,加快内存申请速度。

(2)Scsi_init_procfs:

这个是创建一个/proc/scsi/scsi的文件节点
这个节点会显示当前系统注册了哪些scsi设备,包括这些设备的channel编号,id编号 lun编号等信息。
这些信息都是实时变化的;如果有写入动作,也会触发子系统的scan动作。


(3) Scsi_init_devinfo

这个函数创建了/proc/scsi/device_info节点。
这个节点有点像kernel 里面常用的quirkfix机制,内容如下



结构体的前面三类分别是vendor,modelrevision,其实就是scsi inquiry命令返回的数据,最后一个是个整形flag值,这个flag值影响着设备的初始话过程和操作过程。例如BLIST_NOLUN会让scsi扫描外设时,只扫描lun0

(4) Scsi_init_sysctl

这个函数创建了一个/proc/sys/dev/scsi/logging_level节点,这个节点控制着scsi子系统debug打印的log等级,值越小,打印越少。

(5) Scsi_init_hostsscsi_sysfs_register

这两个函数创建了scsi子系统最关键的busclass“scsi”, “scsi_host”“scsi_device”):


2.子设备驱动加载


这类驱动加载一般比较简单,而且单独以module形式,耦合性很小。它们一般在module初始化时注册到”scsi” bus总线上,然后一直等待有对应的子设备sdev_devgen挂到”scsi” bus上来。例如:



3.外设扫描


Scsi扫描过程定义: 是识别每个host,每个targe和每个lun,给其创建对应的device结构,并将device挂载到相应的busclass上。
 
设备扫描的方式很多:
  • host为单位进行scan。它会把host对应的device下面所有的targetlun全扫描出来。

  • target为单位触发scsi进行scan。它会把target下面所有的lun全扫出来

  • lun为单位触发scsi进行scan。它会扫描特定lun

  • 通过/proc/scsi/scsi触发特定的targetlunscan

  • 通过host对应user空间设备的属性”scan”节点触发特定的targetlunscan


(1) Host扫描


由于host控制器各个芯片平台不一样,它的扫描过程是host device的父设备所在驱动完成的,它的父设备驱动可以是platform总线,也可以是pcie设备对应的pci_driver,也可以是ufs子系统(ufshcd.c)等等。
例如我电脑上,host设备是名为”ahci”pci_driver扫描创建的控制器,一共有6个控制器,其中只有host4这个控制器上接了一块硬盘。


无论哪种当上一级驱动找到host后,会通过下面的
scsi_host_alloc:创建shost_gendevshost_dev
scsi_add_host: shost_gendevshost_dev挂靠到各自的busclass上。

(2) Targetlun扫描



从前面的硬件建模上来看,它的扫描过程是
  • 先从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,例如通过ioctlsda或者sg设备的命令request都会进入到其对应lunrequest_queue。最终都会走到tag_setqueue_rq钩子函数,也就是走到了scsi_queue_rq->scsi_dispatch_cmd->host->hostt->queuecommand函数,其中queuecommand是底层驱动注册上来的钩子函数,scsi子系统把request请求发送到这一步之后,剩下的工作就交给底层类似于ufssata驱动去处理了。例如,ufs会根据请求的类型把上层传下来的信息封装成upiu发给硬件控制器,从而完成一次传输。


5.休眠唤醒


休眠唤醒是驱动的一部分,包括PM(suspendresume)runtime PM,也有shutdownremove等。以休眠为例:在”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是有channelid虚拟出来的,没有任何休眠唤醒动作。
 
爷爷辈设备host从属于上一级驱动,前面说的sata是其中一种。sata也有更上一级的pcie相关的父设备。在手机里host的上一级也可能是ufs驱动。拿ufs为例,在子设备驱动休眠后,爷爷辈驱动的功耗相关的函数会被linux设备驱动模型触发,也就是ufssuspend函数会让devicehostlink状态进入hibernate8低功耗状态。
 
至于resumeruntime PM各位自己可以去阅读研究。


6.底层驱动注册


前面说了,没有纯粹的scsi控制器,现实的控制器是sataufs这些把scsi封装在自定义的通讯结构中的控制器。因此linux scsi提供一套用于scsi和各种实际控制器驱动交互的钩子函数模板scsi_host_template
例如ufs驱动中注册了这套模板


这些钩子函数由scsi主动调用,scsi并不关注这些钩子的实现,例如ufshcd_queuecommand,用于接收scsi发下来的请求,并把scsi命令封装到upiu中并发送给硬件host控制。scsi不关心ufs驱动如何封装scsi命令,如何触发硬件发送命令。


7.关于channel和id的使用


前面说了channelid没有在scsi协议文档里面找到对应描述,scsi里面也没有对target(channel+id)特别的操作,而是直接给host驱动去处理。这样驱动可以自由定义channelidHost驱动在申请scsi_host时会定义该驱动支持多少个channel和每个channel支持多少个id,例如:

  • ufs驱动支持一1channel1id


因此对ufs驱动而言,ufs只需要关注lun,忽略channelid的存在,通篇ufshcd.c中看不到channelid的处理。

  • sata驱动里面,支持2channel16id,每个id下面就1lun



在这个驱动里面有channelid的相关操作


driver/scsi目录下搜索max_channelchannel,可以看到各种各样的用法,这些在scsi这层没有规定,完全取决于host驱动根据自身的情况来选择合适的用法。


8.标准外设驱动


scsi定义了很多组命令,除了一些commonscsi命令外,也对具体类型的外设定义了一些命令标准。
针对不同的外设,scsi子系统里面也集成了一些公版驱动,如下


(1) Sd.c

由于sd.c比较常用,这里把sd.c单独拿出来描述下,其余的外设驱动都大同小异,不再复述。Sd.c它操作的是硬盘,ssd等以sect为单位进行读取写入的存储设备。

  • 该驱动的名字是“sd”
  • ”sd”内部创建了一个”scsi_disk”class


  • “sd”会针对每个匹配上的sdev_gendev,做alloc_diskdevice_add_disk操作。也就是说在user空间创建对应的块设备节点,例如sdasdb这些节点。
  • “sd”也会在”scsi_disk”class上创建和sdev_gendev同名的device,会有对应group attr和其对应做一些操作。
  • 关于sd设备驱动特别说明
    Sd设备驱动本身是块设备驱动,它需要使用block相关的request_queue来发送块设备相关请求给lun,前面讲到lunhost之间的沟通是通过block层来完成的,每个lun有自己独有的request_queue,因此sd驱动直接把这个request_queue拿来用之,把这个request_queue和本地申请的alloc_disk进行绑定。sdasdb这些块设备就可以直接通过request_queuelun发送请求。


(2) Sg.c

Sg.c比较特殊,不是对某个类型的设备驱动。它不管三七二一,对所有挂到“scsi_deviceclass上的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子系统里面,hosttargetlun对应的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做好自己分内的操作就可以兼容这些设备。

  • 外部硬件的连接方式像搭积木,分级驱动也可以以最小代价像积木一样应对硬件的变化。



长按关注
内核工匠微信

Linux 内核黑科技 | 技术文章 | 精选教程


文章转载自内核工匠,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论