2024-09-11 多分区U盘多次插拔后设备分区盘符递增

很久没写文档了,分享一个开源组件 udisks 的 bug 修复分析过程。

问题描述:
将一个U盘分成4个分区,分别格式化为 xfs、ntfs、btrfs 和minix 格式。首次插入U盘,显示为 sda1、sda2、sda3和 sda4。
拔掉U盘后再次插入,U盘的盘符从 sda变成 sdb

具体复现步骤:

  1. 根分区创建四个目录 test1 test2 test3 test4
  2. 使用分区编辑器在U盘创建四个分区sda1 sda2 sda3 sda4,配置fstab文件,让四个分区可自动挂载到test对应四个目录,格式分别为 xfs、ntfs、btrfs 和minix
  3. 拔插两次U盘,四个分区盘符不是sda,变成sdb sdc了。

问题定位

一开始看到设备节点发生变化,以为是内核的问题,所以先请内核兄弟分析了。他们分析的结论是:可能与自动挂载服务相关。udisks2.service
1、禁用该服务后,插拔u盘盘符未改变
2、启用该服务后,插拔u盘发生改变
3、推测该服务在插拔u盘未能正确umout导致
4、自动挂载后手动卸载再插拔,盘符未发生改变。

于是我只好开始从系统层分析,首先分析U盘接入后的自动挂载流程,可以简化为以下几步:
1 上层自动挂载服务,假设叫它 automount_daemon。它收到 gvfs daemon 的 volume-added 信号,调用 g_volume_mount 来进行挂载
2 这个接口函数在 gvfs 中实现,也就是 gvfs_udisks2_volume_mount; 【该函数最后执行 do_mount】
3 接着从 gvfs 调用了 udisks2 的接口 udisks_filesystem_call_mount == handle_mount
4 udisks 的这个函数呢,负责使用正确的用户权限来创建挂载路径并挂载 bd_fs_mount
5 这就到了 libblockdev, do_mount 又使用 mnt_context_mount
6 也就是 util-linux
7 最终,执行 mount 系统调用。
这样看下来,涉及的源码包有点多。我们需要看的是为什么自动挂载的时候,设备信息变了?所以从 udisks 的 handle_mount 开始。

问题分析

3.1 体系结构流程图

整体流程简介如下:
1、用户插拔U盘,首先是内核处理并发出 uevent 事件,通过 netlink socket , systemd 的udevd 服务可以跟内核实现通信,解析设备信息并封装 API,提供其他应用程序使用[udev->libgudev]。
2、通过 libgudev库,udisks 服务收到信号,处理 uevent 事件。
以拔掉U盘为例,先unexport 了 dbus 上的 block object 对象。比如我拔掉一个U盘后,DBus 接口关掉了 /org/freedesktop/UDisks2/block_devices/sda3,触发 InterfacesRemove 的信号。
3、UDisks 的客户端UDisksClient有一个机制,将收到的 object remove,interface remove 等信号,都存在 client 队列,在空闲时一起发出 changed 信号。
4、gvfs 的 volume monitor 进程作为 udisks 的客户端,监听并处理 changed 信号。
它会遍历自己维护的新旧链表,通过对比得到添加、删除、更新的设备列表等,发出 volume-added、volume-removed 等信号。
5、上层程序,比如caja,比如 automount_daemon等,收到设备添加、移除的信号后,对应执行 mount 或者 unmount。具体操作在第一节已经介绍过了,最终经过 udisks和libblockdev 执行对应的系统调用。

自动挂载的信号们.png

3.2 从上往下分析

首先,对于“问题定位”中的第三步,从 gvfs 中调用的 udisks_filesystem_call_mount 函数来找到 udisks 中的 handle_mount 接口函数,做个简单的说明。

在分析 handle_mount 之前,需要先了解一下如何通过 udisks_filesystem_call_mount 来找到 handle_mount。

分析 udisks 代码可以发现,接口函数 udisks_filesystem_call_mount 是自动生成的。根据文件 org.freedesktop.UDisks2.xml,通过 gdbus-codegen 自动生成。

$(dbus_built_sources) : Makefile.am $(top_srcdir)/data/org.freedesktop.UDisks2.xml

    gdbus-codegen                                           \
        --interface-prefix org.freedesktop.UDisks2\.                             \
        --c-namespace UDisks                            \
        --c-generate-object-manager                     \
        --c-generate-autocleanup all                        \
        --generate-c-code udisks-generated                                  \
        --generate-docbook udisks-generated-doc                 \
        $(top_srcdir)/data/org.freedesktop.UDisks2.xml                  \
        $(NULL)

生成的 C 文件是 udisks/udisks-generated.c

  g_dbus_proxy_call (G_DBUS_PROXY (proxy),
    "Mount",
    g_variant_new ("(@a{sv})", arg_options),
    G_DBUS_CALL_FLAGS_NONE,
    -1,
    cancellable,
    callback,
    user_data);

可以看出自动生成的这个函数的功能是调用 Mount 接口,我们需要知道,当其他应用程序调用 udisks 对外的dbus 接口,比如 gvfs 调用了这个 Mount,对 dbus 服务程序来说,就会收到 handle-mount 信号。
【handle-xxxx 组合出来的,所有对外提供的 dbus 接口的程序都这样,一旦别人调用自己提供的接口,框架上一般都会自动在接口名前加 handle,生成信号名】

handle-mount 信号在创建的时候,设置了对应的回调函数handle_mount。一般来说找 dbus 接口函数,知道这个 Mount,就知道要去找信号 handle-mount, 就是最终的执行了。

  g_signal_new ("handle-mount",
    G_TYPE_FROM_INTERFACE (iface),
    G_SIGNAL_RUN_LAST,
    G_STRUCT_OFFSET (UDisksFilesystemIface, handle_mount),
    g_signal_accumulator_true_handled,
    NULL,
    g_cclosure_marshal_generic,
    G_TYPE_BOOLEAN,
    2,
    G_TYPE_DBUS_METHOD_INVOCATION, G_TYPE_VARIANT);

也就是说,automount_daemon 自动挂载,通过 gvfs 走了一圈,最终进入了 udisks 中。

3.3 udisks 分析

从信号定义函数找实现,我们需要关注的就是 UDisksFilesystemIface 接口的实现对象 UDisksLinuxFilesystem.
分析挂载函数发现,测试用例中的盘符不一致,是因为 btrfs 分区,在拔掉后再次接入,没能挂载上。测试看到的 sda3 其实是上一次挂载的缓存信息。
首先看一下日志,从日志来看,内核给出来的设备标识符是正确的 sdb3:

Jul 10 09:52:01 xunli-pc kernel: [160500.359182][ 2] [T3511007] sdb: sdb1 sdb2 sdb3 sdb4 
Jul 10 09:52:02 xunli-pc udisksd[980]: udisks_linux_block_update : device file is /dev/sdb3

还有一条错误错误,如下:

Jul 10 09:52:02 xunli-pc udisksd[980]: Error statting /dev/sda3: No such file or directory

为什么 udisks 会去 stat /dev/sda3 呢?这条日志在函数 udisks_mount_monitor_get_mountinfo 中打印的,去读文件 /proc/self/mountinfo
拔掉U盘设备后,mountinfo文件中残留这一行:
433 29 0:62 / /media/xunli/70b1511c-b8fd-43c2-98dd-d1538b2f45d4 rw,nosuid,nodev,relatime shared:233 - btrfs /dev/sda3 rw,space_cache,subvolid=5,subvol=/
其中 0:62 表示 major 为 0,是一个 btrfs 格式的文件系统。
bug 中反馈的mount和 df 命令看到盘符不一致,是因为这两条命令都是通过读取 /proc/self/mountinfo 来显示的,显示出来的 sda3 并不是实际的拔掉后又接入的 U 盘设备,而是上一次拔掉后在 /proc 下面的缓存信息。
此时从内核日志可以看到,它识别到了 btrfs 文件系统重复的 fsid:devid
Jul 10 09:52:02 xunli-pc kernel: [160500.476906][ 6] [T3762137] BTRFS warning (device sda3): duplicate device fsid:devid for 70b1511c-b8fd-43c2-98dd-d1538b2f45d4:1 old:/dev/sda3 new:/dev/sdb3
存在旧的 btrfs sda3,导致新接入设备变成了 sdb,除了 btrfs 格式的分区,其他三个都能正常挂载上,只有sdb3 因为是 btrfs 格式的,存在这个缓存错误,导致 sdb3 的 mount 系统调用没有执行,所以真正的 sdb3 没有执行挂载。
总之,mount命令显示的是错误的缓存,需要手动卸载 btrfs 之后,这个 sdb3 才能被挂载上。

3.4 从下往上分析

U盘插拔事件如何传递?
正常情况下,内核检测到设备插拔会发出 uevent 事件,常见的 uevent 事件类型包括设备添加(add)、移除(remove)、改变(change)等,这些事件携带设备的标识信息,如设备路径、设备类型等,使得用户空间程序能精确识别并处理特定设备的事件。
systemd 中的 udevd 服务会接受并处理 uevent 事件。我一开始以为是内核发给 udev,udev 直接调用 dbus 与 udisks 交互。经过分析发现,在udisks 服务中的 uevent 事件,很可能是 udisks 的 provider 闲时检测线程去处理的。
udisks 中对Linux 下的设备管理,主要是通过 UDisksLinuxProvider 来实现的。它在初始化的时候,监听了 udev 设备的信号【这是通过 libgudev 库来实现的】。在我们分析的场景中,用到了GUdevClient,用它来监听设备的热插拔事件:

 provider->gudev_client = g_udev_client_new (subsystems);
 g_signal_connect (provider->gudev_client,
                     "uevent",
                     G_CALLBACK (on_uevent),  
                         provider);

udisks 服务通过libgudev 库封装对象来监听 uevent 事件, 在该库 gudev_client 对象的 monitor_event 事件处理函数中,通过 systemd 接口函数 udev_monitor_receive_device 来从 socket 中读取设备变化信息,并且将这个设备信息随着 uevent 一起发出来。注意,这里的 socket 不是我们平常的 unix domain socket,而是 netlink socket【systemd 与内核之间通过 netlink socket通信】。

g_signal_emit (client,
signals[UEVENT_SIGNAL], 0,
                 g_udev_device_get_action (device),
                 device);

回到udisks服务,一旦监听到了 uevent 信号,就会将解析信息,获取随着uevent 信号一起发过来的设备,封装为 ProbeRequest,压到请求队列中【防止事件太多,缓解处理压力】,它的闲时任务 on_idle_with_probed_uevent, 挨个处理队列中的请求们。
udisks_linux_provider_handle_uevent (request->provider, g_udev_device_get_action (request->udev_device), request->udisks_device);
也就是说,插拔U盘,内核发信号给 udev,udev 封装操作提供对外 API,udisks 有个闲时任务,一有空就通过 udev 的 API去探测是否有设备插拔事件。一旦判断为 remove 事件,就会关闭 dbus 接口对象,触发其他一系列的清除挂载操作。

3.5 拔U盘的卸载流程分析

在我们这个问题场景中,udisks 处理 remove 类型的 uevent 事件,unexport 了/org/freedesktop/UDisks2/block_devices/sda3,也就是关闭 dbus 接口对象,触发 UDisksClient 的changed 信号。
gvfs 更新维护链表,发出 volume-removed 信号。
上层自动挂载服务 mount_daemon 收到该信号后,更新它前端显示面板。当用户不拔 U盘而是点击卸载按钮时,走的就是正常的 g_mount_unmount 流程了。
但主动拔 U 盘时的卸载动作,是谁下发的呢?

以下是正常拔U盘时的日志:

 kernel:  usb 2-2: USB disconnect, device number 6  内核事件
 udisksd:  udisks_linux_provider_handle_uevent: block  
 udisksd:  handle_block_uevent 1313: remove   
`IO 块设备的 remove 类型 uevent 发送到了 udisks 服务`
 udisksd:  handle_block_uevent_for_block: object_path /org/freedesktop/UDisks2/block_devices/sda1  `关闭 dbus 对象`
udisks_linux_block_object_uevent `开始 update_iface,更新block、filesystem、swap 等等等`
 udisksd: udisks_state_check: 389   `设备插拔的状态改变时,触发 UdisksState 状态机检测系统状态是否一致`
 udisksd: udisks_state_check_in_thread: 502
 udisksd: udisks_state_check_mounted_fs_entry: 663
 udisksd: udisks_mount_monitor_get_mounts_for_dev: 693
 udisksd:  attention: udisks_state_check_mounted_fs_entry: escaped_mount_point '/media/test/KYLIN-DESKT' `获取挂载目录`
 udisksd: Cleaning up mount point /media/test/KYLIN-DESKT (device 8:1 no longer exists)  `设备不存在,准备通过 umount -l清除挂载点`
 udisksd:  udisks_linux_provider_handle_uevent: block
 udisksd:  handle_block_uevent 1313: remove
 udisksd:  handle_block_uevent_for_block: object_path /org/freedesktop/UDisks2/block_devices/sda   `关闭设备 dbus 对象
 触发客户端 changed 信号,客户端 gvfs 开始更新卷信息等`
 gvfs-udisks2-volume-monitor[3344]: update_fstab_volumes: 0 0
 gvfs-udisks2-volume-monitor[3344]: ==== signal when dbus object removed?: on_object_removed   `客户端收到了 dbus 关闭的信号`
 udisksd: mounts_changed_event: 288  `监听到了 /proc/self/mountinfo 的变化事件`
 udisksd: reload_mounts: 244
 udisksd: udisks_mount_monitor_ensure: 647
udisksd: udisks_mount_monitor_get_mountinfo :`解析 mountinfo 文件,获取 mounts列表,对于 btrfs 分区特殊处理。清空旧表mounts,完成新表mounts的收集。`
 udisksd:  udisks_daemon_launch_spawned_job_gstring_sync: umount -l '/media/test/KYLIN-DESKT' `遍历mounts,挨个卸载`
 udisksd:  udisks_state_check_mounted_fs_entry: mount point /media/test/KYLIN-DESKT

以下是带有 btrfs 分区的U盘拔出日志分析:

kernel: [  161.521976][ 0] [  T155] usb 2-2: USB disconnect, device number 2  内核发现设备拔出
udisksd: handle_block_uevent 1313: remove
udisksd: handle_block_uevent_for_block: object_path /org/freedesktop/UDisks2/block_devices/sda4  
`udisksd 收到 uevent,同设备上各个分区的 uevent 一起处理,包括 btrfs分区,都会 unexport DBus 的 object_path。`
udisksd: udisks_state_check: 389 `设备插拔的状态改变时,触发 UdisksState 状态机检测系统状态是否一致`
udisksd: udisks_state_check_in_thread: 502
udisksd: udisks_state_check_mounted_fs_entry: 663
udisksd: udisks_mount_monitor_get_mounts_for_dev: 693
udisksd: udisks_state_check_mounted_fs_entry: mount point
然后,到了 gvfs-udisks2-volume-monitor 的 on_object_removed
udisksd: mounts_changed_event: `监听到 /proc/self/mountinfo 变化,触发 reload`
udisksd:reload_mounts: 244 `对比新旧列表`
udisksd: udisks_mount_monitor_ensure: 646 
udisksd: udisks_mount_monitor_get_mountinfo: `解析 mountinfo 文件,获取 mounts列表,对于 btrfs 分区特殊处理。清空旧表mounts,完成新表mounts的收集。`
udisksd: mount_monitor_on_mount_removed: ` reloads时发现新旧表的挂载状态变更,触发 UDisks 状态检测`
udisksd: udisks_state_check: 389 
udisksd: Error statting /dev/sda3: No such file or directory `走到 btrfs 分支线,出现异常。设备实际已经断开,节点不存在了,无法stat。导致需要被清除的 btrfs 挂载点,没能保存到 mounts `
udisksd: udisks_linux_block_object_uevent
Cleaning up mount point /media/test/30da29d6-1b3c-4011-a2b1-2b2e6d757b7b (device 8:1 no longer exists)  正常的分区清除不再存在的挂载点
udisksd: udisks_daemon_launch_spawned_job_gstring_sync: umount -l '/media/test/30da29d6-1b3c-4011-a2b1-2b2e6d757b7b' `mounts链表中其他正常的分区开始执行卸载了。除了 btrfs`
Error cleaning up mount point /media/test/bb050eae-9bb0-498e-a0fd-f775636f62a8: Error removing directory: Device or resource busy 。` btrfs 异常无法 clean up。`

在udisks 中,需要关注两条线:

第一条线

mountinfo 文件的 IO 状态发生了变化,在reload_mounts函数里触发了重新读取 /proc/self/mountinfo 文件的行为。
对于 major 不为 0 的设备,记录设备节点信息【 major 设备号为 0 的条目时,这表示挂载的是一个不对应于物理硬件的伪文件系统,btrfs 除外】。对该设备再做一次确认,检查看设备是否挂载?如果非挂载状态,需要先保存新建到UDisksMount 对象并保存到 mounts 链表中。
可以查阅udisks_mount_monitor_ensure。

第二条线

设备状态变更,比如拔掉U盘触发状态机的 udisks_stat_check ,开始对各挂载点的检测。
udisks_state_check_mounted_fs 负责从状态机中拿到当前被挂载的那些文件系统们 mounted-fs,遍历所有文件系统,获取对应的 block-device。
对于所有的块设备 block-device,通过 udisks_mount_monitor_get_mounts_for_dev 获取设备对应的挂载对象。遍历上面的 mounts 链表,做设备号的对比,对比上就得到挂载对象UDisksMount。
紧接着判断这个block-device 是否还存在【libgudev库接口】? 如果设备不存在了,就直接执行 umount -l,将挂载对象清理掉。
【udisks_state_check_in_thread -> udisks_state_check_mounted_fs-> udisks_state_check_mounted_fs_entry】
PS, 如果是用户正常点击卸载的话,流程就稍微简单一点,udisks 的 handle_unmount 先执行卸载,不需要后面的 umount -l 来清除挂载点。

四 问题处理

分析到现在,可以看出来,btrfs不在 mounts 链表中,所以当设备节点不存在的时候,并没有匹配到它的挂载对象,导致直接拔掉U盘时,无法对btrfs分区做清理。
但是,如果U盘上只有一个 btrfs 分区时,拔掉 U 盘,状态机更新时 udisks_state_check遍历所有文件系统,得到 btrfs 分区设备的设备id,遍历当前的mounts列表,找到Mount 对象,设备不存在了,直接就 unmount -l,一切正常。
当存在几个分区时,情况就不一样了。因为其他分区在拔掉后,会更新 /proc/self/mountinfo。触发了 mountinfo 的 IO 事件。reload_mounts 清空原先的 mounts 列表,重新读 /proc/self/mountinfo,udisks_mount_monitor_get_mountinfo 解析到 btrfs 之后,发现 stat 出错了,设备节点早就没了,所以没有记录 dev 信息,也就是说 btrfs 对应的 mount 对象丢失了。导致 udisks_state_check_mounted_fs 无法清除挂载点。也就是说,当 stat 不到设备的时候,需要判断是不是 btrfs 文件系统,记录这个 dev 设备,才能保证流程正确。修改 udisks 就可以解决这个问题,代码修改量一行。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,230评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,261评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,089评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,542评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,542评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,544评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,922评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,578评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,816评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,576评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,658评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,359评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,937评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,920评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,156评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,859评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,381评论 2 342

推荐阅读更多精彩内容