在Linux内核中SCSI驱动应该是最为复杂的驱动,没有之一。因为对于整个SCSI系统来说,不只包含一种类型的设备,而是一类设备。前面文章我们简单介绍了Linux操作系统内核中SCSI子系统的整体架构,我们这里简单回忆一下。
在Linux内核中抽象了一个称谓SCSI总线的虚拟总线。而在SCSI总线上又包含SCSI的驱动和设备。
图1 SCSI体系结构
Linux操作系统中的SCSI整个架构分为3层,各层的具体作用如下:
中间层,用于实现SCSI的公共功能,比如错误处理等。
高层或上层,它代表各种scsi设备类型的驱动,如scsi磁盘驱动,scsi磁带驱动,高层驱动认领低层驱动发现的scsi设备,为这些设备分配名称,将对设备的IO转换为scsi命令,交由低层驱动处理。
底层或下层,它代表与SCSI的物理接口的实际驱动器,主要为各个厂商为其特定的主机适配器(Host Bus Adapter, HBA)驱动,例如: FC卡驱动、SAS卡驱动和iSCSI(iSCSI可以使硬件HBA卡或者基于普通网卡的软件实现)等。
SCSI设备通常是支持热插拔(即插即用)的,也就是可以直接连接目标设备,并在Linux操作系统看到并使用。比如我们常见优盘可以插上之后马上使用,而不需要重启电脑。而在企业级服务器中的SAN存储也是通过光纤连接后在Linux服务可以看到磁盘设备(当然需要在存储端有相关的配置)。为了后续内容的理解,本节将介绍在设备物理连接就绪的情况下,在Linux服务器中如何呈现设备(专业术语叫扫描发现)。
我们日常连接存储设备的方式有很多种,除了最常见的优盘、光盘外,还有企业上常用的FC-SAN、IP-SAN和SAS磁盘阵列等。我们今天主要介绍一下FC-SAN和IP-SAN这两种连接存储设备的方式。
对于FC-SAN存储,当建立的Linux服务器与存储设备的物理连接,且存储设备做过必须的配置之后,可以通过如下几种方式在Linux服务程序磁盘设备。
这几种方式的本质是一样的,其实就是对总线上的设备进行重新扫描,发现新加入的设备,并在内核中创建必要的内核数据结构,然后呈现给用户。前者自不必说,我们看一下后2者是如何进行操作的。
以下命令可扫描所有通道、target、LUN和主机。
echo “- - -” > /sys/class/scsi_host/hostH/scan
其中hostH中H是变化的,需要根据具体的适配卡(HBA)灵活调整。
可通过以下命令删除或对SCSI磁盘取消配置:
echo "scsi remove-single-device H B T L" > /proc/scsi/scsi
命令示例中,H, B, T, L代表设备的主机,总线,target,和LUN ID。
对于IP-SAN的操作要简单的多,前面我们介绍过启动器端的配置管理的命令。该命令中有一个登陆操作,在进行登陆的时候其实就相当于进行设备的扫描,此时会建立新的设备,并呈现给用户。关于具体操作本文不再赘述,具体可以参考之前的文章。
上面命令是关于设备扫描的基本操作,这里作为一个简单的入门,后续针对代码做详细的介绍。
前文我们介绍过,SCSI的上层中sd的驱动其实就是一个SCSI磁盘驱动,它呈现给我们的是一个磁盘设备。而底层的FC Driver则是FC-HBA卡的驱动。两者结合就形成了我们在操作系统层面看到的基于FC-SAN的磁盘。因此,Linux操作系统发现设备的过程其实就是上述两个驱动创建实例(数据结构)的过程。
SCSI设备复杂的地方在于FC-HBA卡还可能包含多个通道,而存储设备又可能包含多个目标器,目标器中又可能有多个设备(LUN)。因此,对应关系变得非常复杂。在Linux操作系统中通过一个四元组(host,channel,target,lun)来标识这种关系。这种组织关系在内核中也有对应的数据结构。如图2是SCSI四元组的逻辑关系。
图2 SCSI四元组
上述概念在Linux内核中有3个对应的数据结构,分别是Scsi_Host、scsi_target和scsi_device。
图3 核心数据结构关系
上面3个核心数据结构中,Scsi_Host是适配器(HBA)对应的数据结构,位于SCSI总线下面。scsi_target对应着一个目标器设备,目标器设备未必是物理的,也可能是虚拟的。而scsi_device则为目标器中的设备,可以理解为存储中的LUN在客户端的体现。
观察一下图3,可以看出在Scsi_Host下面通过链表的方式存储着多个scsi_target,而scsi_target下面又通过链表存储着多个scsi_device。这种关系只是我们在图2中描述的物理对应关系。
内核中除了这三个主要的数据结构外,还有一些辅助的数据结构,比如scsi_host_template和scsi_transport_template等数据结构,这些数据结构主要是完成一些公共的功能。比如对于SCSI设备,有磁盘、光驱和磁带等等,但这些设备有一些公共的功能,因此内核中将这些公共的功能提取成模板。这样,在初始化设备的时候可以借助模板减少初始化的复杂度。
本节结合iSCSI分析一下在SCSI设备中的设备发现流程,通过这个流程及关键数据结构的了解,对整个SCSI和iSCSI就可以有一个概括的了解。
在介绍设备发现之前,我们先看一下在Linux操作系统中iSCSI的整体软件架构。如图4所示,整个iSCSI启动器(客户端)的软件架构图包括用户态和内核态两部分的内容。其中用户态主要负责管理,包括iscsiadm命令行工具和iscsid守护进程。而内核态包括如图中绿色的4个内核模块,这些内核模块与SCSI子系统结合起来形成了整个功能。
图4 iSCSI软件架构
对于一个管理命令,通常是iscsiadm命令行通过本地套接字发送给iscsid守护进程,而该守护进程经过处理后通过netlink发送到内核模块进行处理。关于整个流程还是比较复杂的,本文暂时不做介绍,后续文章再进行详细的分析。
本文我们需要知道的是在iSCSI中启动器和目标器之间是通过session维护两者之间的关系,而每个session中可能不止有一条连接。但只要建立连接之后,在Linux操作系统中就可以看到磁盘设备(当然,前提是存储端已经做过相关配置)。对于session中的连接数量,设计为可以在同一session中有多条连接的目的是为了可以实现物理链路的冗余,这样可以保证某条链路故障的情况下可以通过其它链路继续提供服务。
废话说了半天,我们当前只需要知道在启动器和目标器之间建立session之后,也就是目标器登录之后,就可以在启动器端看到磁盘,这个也就是设备发现的过程。因此,这里核心就是分析iSCSI中创建session的过程。
这里所说的创建session的过程其实是个宽泛的概念,其实在Linux最终实现时是分2步进行的,第一个是创建session,此时是创建必要的数据结构和进行处理函数的初始化等工作;而第2步则是创建session中的一个连接,这个连接创建成功后,session才能真正起作用。
1) 创建session
我们这里忽略不必要的细节,从内核中创建session流程讲起。其入口函数是iscsi_if_create_session,该函数在文件scsi_transport_iscsi.c中,在该函数中完成了所有功能。但该函数是调用其它函数实现的,具体如图所示。
图5 内核创建session流程
对于纯软件的iSCSI其实是调用了iscsi_sw_tcp_session_create函数,而该函数则通过iscsi_host_alloc分配一个Scsi_Host实例,并通过iscsi_session_setup完成session的设置。
我们前面提到过host其实对应着硬件的适配卡,适配卡是Linux客户端与存储端(服务端)通信的通道,即使是纯软件的情况下内核也会创建这样一个数据结构,只不过通信的方式是通过TCP协议。而在函数iscsi_host_alloc中正是完成了该结构体的分配和部分初始化工作。
函数iscsi_session_setup则是进行session的创建和初始化,核心是将iscsi_sw_tcp_transport函数集初始化到session当中,这样在后续的操作中就可以使用该函数集中的函数。最终,该函数会将创建的session添加到全局session链表sesslist中。
2) 创建连接
前面创建了一个session,这里就是要建立连接了。建立连接也是由iscsiadm命令行发起的。最终触发到内核态的函数接口。该函数接口为iscsi_conn_start。
图6 启动连接主要流程
通过图6的流程可以看出来,启动连接的本质其实是扫描目标器。扫描目标器也就是看看是否有新的目标器,并且目标器上是否有新的LUN。如果有新的设备加入,则需要创建对应的数据结构。
图7 扫描目标器主要流程
扫描目标器的函数为__scsi_scan_target,该函数一个SCSI子系统的公共函数。不仅仅iSCSI要用到该函数,基于FC和iSCSI HBA卡的驱动也要用到该函数。
继续分析该函数内部的实现,这里面有几个比较核心的函数需要介绍一下。scsi_alloc_target函数用于分配一个目标器结构体,并进行必要的初始化。scsi_alloc_sdev函数是创建scsi_device结构体,并进行必要的初始化,这个设备就是与具体磁盘对应的设备。这里面比较重要的是初始化了请求队列。这个队列用于处理读写数据,我们在后面介绍读写数据的流程的时候会介绍到。
if (shost_use_blk_mq(shost)) sdev->request_queue = scsi_mq_alloc_queue(sdev); else sdev->request_queue = scsi_alloc_queue(sdev);
最后一个比较重要的函数是scsi_add_lun,这个函数是进行设备扫描的重头戏。它最终会掉到sd驱动(也就是SCSI磁盘驱动)的sd_probe函数,该函数完成创建磁盘和与设备关联的相关操作。简单的说,这个函数完成后在用户层面就可以看到磁盘,并且该磁盘与底层的驱动建立了关联。这样,当用户对磁盘进行读写操作的时候,请求就可以经过底层的驱动(以太网卡,iSCSI-HBA或者FC-HBA)发送到存储端。
本文并没有介绍每个函数的细节,只是介绍了大体的流程。主要是考虑到贴代码代码会导致文章太乱,也不利于表达。后面本号在写一篇关于IO的文章,这样就比较清楚的了解了数据是如何从用户的应用到存储设备了。