李逵遇李鬼:实战图形驱动混搭导致的系统黑屏

图形界面的是一个负担较重的工作,谁干这个活是个很重要的问题。一种做法是让CPU做。让CPU处理图形界面的好处是方便简单,坏处呢,是会加重CPU的负担,而且效果可能也不是特别好。因为图形界面需要大量的浮点运算,是CPU所不擅长的。

为了解决这个问题,显卡(GPU)就应运而生。GPU的核数多,天生擅长并行计算。

不过,要让 GPU 工作,也不是那么容易。在Linux下,调用GPU的过程是一条复杂的调用链:用户态应用(App) → Mesa(Panfrost GL/GLES) → libdrm_panfrost → Kernel DRM panfrost驱动

对于使用 RK3588等 ARM 处理器的设备(如幽兰代码本)来讲,从厂商镜像迁移到主线 Debian 13 (Trixie) 时,最棘手的问题不是内核驱动(Kernel Driver)的 Bug,而是用户态共享库的版本不匹配。这种不匹配会导致图形系统在启动瞬间发生静默崩溃(Segmentation Fault),无法进入图形桌面,且报错信息极具误导性。

本文将深入 ELF 动态链接机制,还原一次真实的崩溃现场,并提供基于 GDB 和 GNU Linker 的深度调试方案。

1. 问题背景:为何 Panfrost 如此脆弱?

1.1 闭源与开源的博弈

ARM Mali GPU 的官方驱动(DDK)长期闭源。厂商 BSP 固件通常会在 /usr/lib/ 下强行放置闭源的 libmali.so,并劫持 libEGL.so 和 libGLESv2.so 的软链接。

而开源项目 Panfrost(由 Alyssa Rosenzweig 等人逆向开发)则依赖标准的 Mesa 和 libdrm

当这两套体系在同一个文件系统中混用时,就会出现“李逵遇李鬼”的符号冲突。

1.2 ABI 的隐形杀手

虽然都是纯开源的,但是Mesa 与 libdrm 之间也有严格的 ABI(二进制接口)

  • • Mesa 编译时:基于新版 libdrm 头文件编译,认为 drm_panfrost_submit 结构体大小为 64 字节。

  • • 运行时:系统加载了旧版 libdrm 动态库,该结构体大小仅为 48 字节。

  • • 结果:Mesa 写入数据时发生越界,或内核读取到垃圾数据,导致 IOCTL 失败或进程崩溃。

2. 崩溃现场还原

场景:在 RK3588 上启动 Wayland 合成器 weston,进程立即崩溃。

GDB 调试记录

$ gdb /usr/bin/weston
(gdb) run
Starting program: /usr/bin/weston
...
Program received signal SIGSEGV, Segmentation fault.
0x0000ffffbe42d4c0 in panfrost_drm_submit_job (ctx=0xaaaaaaab0200, access=3, reqs=0xfffff7ffec30) 
    at ../src/gallium/drivers/panfrost/pan_drm.c:256
256     ../src/gallium/drivers/panfrost/pan_drm.c: No such file or directory.

(gdb) bt
#0  0x0000ffffbe42d4c0 in panfrost_drm_submit_job (ctx=0xaaaaaaab0200, ...) 
    at ../src/gallium/drivers/panfrost/pan_drm.c:256
#1  0x0000ffffbe4289a4 in panfrost_flush (...) 
    at ../src/gallium/drivers/panfrost/pan_context.c:1042
#2  0x0000ffffbe1f3b80 in st_flush (...) 
    at ../src/mesa/state_tracker/st_cb_flush.c:64
#3  0x0000ffffbe2d849c in egl_dri2_swap_buffers (...) 
    at ../src/egl/drivers/dri2/platform_wayland.c:1450

初步分析

崩溃发生在 panfrost_drm_submit_job。GDB 显示上下文指针 ctx 是合法的堆地址(0xaaaa...),这排除了简单的空指针引用。反汇编显示崩溃点位于对 libdrm 函数的 PLT 跳转或结构体成员访问处。这强烈暗示了库版本不匹配

3. 深度调试:穿透动态链接器 (ld.so)

单纯看 GDB 堆栈无法解决库依赖问题,而且ldd 命令显示的只是静态依赖,而非运行时真实的加载路径。我们需要更底层的手段。

3.1 静态分析:它到底想要什么版本?

ELF 文件通过 .gnu.version_r 段记录了对依赖库的具体版本需求。使用 readelf 检查 Mesa 驱动 (panfrost_dri.so):

readelf -V /usr/lib/aarch64-linux-gnu/dri/panfrost_dri.so

输出关键信息

Version needs section '.gnu.version_r' contains 4 entries:
 Addr: 0x0000000000123456  Offset: 0x123456  Link: 4 (.dynstr)
  000000: Version: 1  File: libdrm.so.2  Cnt: 2
  0x0010:   Name: LIBDRM_2.4.109  Flags: none  Version: 5
  0x0020:   Name: LIBDRM_2.4.60   Flags: none  Version: 3

需要注意的是,一般来讲,共享库默认是不会在 SO_NAME 中设置版本信息的,所以这里只能通过归纳的符号信息进行确认。

解读:该驱动明确要求 libdrm.so.2 必须支持 LIBDRM_2.4.109 版本引入的符号特性。如果系统中的库版本低于此(例如 2.4.100),即便加载成功,也会在调用新特性时崩溃。

3.2 动态追踪:LD_DEBUG

Linux 动态链接器提供了强大的调试环境变量 LD_DEBUG

查路径 (Files)

export LD_DEBUG=files
weston 2>&1 | grep libdrm
  • • 异常:如果看到加载路径指向 /usr/lib/mali/libmali.so,说明闭源驱动污染了环境。

  • • 正常:如果指向 /usr/lib/libdrm.so.2,需结合 ls -l 确认其实际版本。

查绑定 (Bindings)

export LD_DEBUG=bindings
weston 2>&1 | grep -i error

这能捕捉到 symbol lookup error,即 Mesa 引用了 libdrm 中不存在的符号。

3.3 GDB 进阶:调试 Linker 本身 (do_lookup_x)

如果问题非常隐蔽(例如符号存在但版本不对),我们需要在 GDB 中 Hook 链接器的加载过程。

  1. 1. 启动 GDB 指向应用

gdb /usr/bin/weston
  1. 2. 设置 Linker 断点

ld.so 中的 do_lookup_x 函数负责符号查找。

(gdb) set stop-on-solib-events 1
(gdb) start
(gdb) break do_lookup_x
  1. 3. 分析符号解析

当断点触发时,检查当前的符号名称和版本映射。你会看到 libc 是如何在多个版本的库之间徘徊,以及最终是否“被迫”选择了一个版本不匹配的弱符号。

这个被迫选项不匹配符号的过程实际上是由 check_match决定的。

do_lookup_x -> check_match

这一过程是严重依赖 ELF 文件中的哈希节的。

_dl_lookup_symbol_x
    -> for (size_t start = i; *scope != NULL; start = 0, ++scope)
        -> do_lookup_x -> do { ... } while
            -> map = list[i]->l_real

所谓哈希节,在现代 ELF 文件中一般指的就是 .gnu.hash 。

.gnu.hash 节是根据程序中的导出且可见的函数生成的,其作用有两个,一是快速检索符号是否在库中,二是减小遍历的压力。

.gnu.hash 能做到这两点的原因在于概率。

在 .gnu.hash 的设想中,每个导出符号都会根据符号名使用 DJB 算法生成一段散列数据,do_lookup_x 匹配符号时,也需要使用相同的算法生成散列数据进行匹配。

之所以说是概率,是因为 .gnu.hash 节设计了一种“可以假阳,但不会假阴”的机制。

.gnu.hash 会根据符号的哈希值生成一组掩码,当 _dl_lookup_symbol_x 拿着符号哈希来匹配的时候,do_lookup_x 会从符号哈希中提取两段数据,跟哈希值对应的掩码进行匹配,编译器在生成掩码 bitmask_word 的过程,会保证正确的哈希一定是匹配掩码的。

所以,要是符号正确,那么符号的哈希一定能匹配上掩码,但有没有可能错误的哈希也匹配上呢,也是可能的,不过在哈希的加持下概率是很小的,但是无法匹配上的哈希,那就一定错误的。

如果匹配到了错误的哈希,那么代码就只能接收额外运行带来的性能开销了。

匹配哈希只是第一步,接下来会使用 .gnu.hash 哈希链,哈希链可以匹配从头部开始匹配,它会跟哈希指示代码到达哈希链中的某一部分后,再开始搜索。

哈希链中的哈希是跟 .dynsym 中的符号顺序一致的,当哈希值匹配上后,可以直接根据哈希链基地址计算出符号在 .dynsym 中的偏移值。

do_lookup_x
    -> bitmask = map->l_gnu_bitmask
    -> if (bitmask != NULL)
        -> bitmask_word = bitmask[(new_hash / __ELF_NATIVE_CLASS) & map->l_gnu_bitmask_idxbits]
        -> hashbit1 = new_hash & (__ELF_NATIVE_CLASS - 1)
        -> hashbit2 = ((new_hash >> map->l_gnu_shift) & (__ELF_NATIVE_CLASS - 1))
        -> if ((bitmask_word >> hashbit1) & (bitmask_word >> hashbit2) & 1)
            -> bucket = map->l_gnu_buckets[new_hash % map->l_nbuckets]
            -> if (bucket != 0)
                -> hasharr = &map->l_gnu_chain_zero[bucket]
                -> do { ... } while ((*hasharr++ & 1u) == 0)
                    -> if (((*hasharr ^ new_hash) >> 1) == 0)
                        -> symidx = ELF_MACHINE_HASH_SYMIDX (map, hasharr) -> ((hasharr) - (map)->l_gnu_chain_zero)
                        -> sym = check_match
                    -> if (sym != NULL) -> goto found_it
        -> symidx = SHN_UNDE

找到符号并不代表结束,接下来代码会进入 check_match 中,确认是否使用当前符号。

最直接的情况就是,引用方的符号及版本完全对应定义方的符号和版本(都要求版本,或都不要求版本),那么这个时候就会直接返回符号进行下一步。

如果版本无法匹配,那么ld就会视情况设置备用符号 versioned_sym(版本单向满足,比如引用方需要符号版本,但定义方不需要,或者引用方不需要版本,但定义方提供的符号版本也在要求的范围内),但如果符号版本完全无法匹配,那么就不会设置备用符号,直接返回空指针。

check_match
    -> if (sym != ref && strcmp (strtab + sym->st_name, undef_name))
        -> return NULL
    -> verstab = map->l_versyms
    -> if (version != NULL)
        -> if (verstab != NULL)
            -> ndx = verstab[symidx] & 0x7fff
            -> if (map->l_versions[ndx].hash == version->hash && strcmp (map->l_versions[ndx].name, version->name) == 0)
                -> nothing to do
            -> else
                -> if (!version->hidden && map->l_versions[ndx].name[0] == '\0' && (verstab[symidx] & 0x8000) == 0 && (*num_versions)++ == 0)
                    -> *versioned_sym = sym
                -> return NULL
    -> else
        -> if (verstab != NULL)
            -> if ((verstab[symidx] & 0x7fff) >= ((flags & DL_LOOKUP_RETURN_NEWEST) ? 2 : 3))
                -> if ((verstab[symidx] & 0x8000) == 0 && (*num_versions)++ == 0)
                    -> *versioned_sym = sym
                -> return NULL
    -> return sym

do_lookup_x 可能会收到 check_match 的三种返回结果,不管是哪种结果,也还是需要看符号类型的脸色。

对于符号完全匹配的情况来讲,这时代码会直接开始检测符号类型,如果是有备用符号,那么就需要把当前 link_map 的符号全部找到后才会开始检测符号类型,如果返回就是纯空指针,那么代码就会开始遍历下一个 link_map

当定义方的符号版本是弱符号时,这个符号会被留下来,如果后面没有匹配到强符号时才会时候,如果定义方的符号就是强符号,那么这个符号会立即返回使用。

为了保证库顺序对符号的影响,弱符号一经设置就不会再更改,优先使用最早匹配到的弱符号。

do_lookup_x
    -> sym = num_versions == 1 ? versioned_sym : NULL
    -> if (sym != NULL)
        -> if (dl_symbol_visibility_binds_local_p (sym)) -> goto skip
        -> switch (ELFW(ST_BIND) (sym->st_info))
            -> case STB_WEAK
                -> if (!result->s) -> result->s = sym
            -> case STB_GLOBAL
                -> result->s = sym
                -> return 1
            -> default -> break

所以,总结来讲,当你在 do_lookup_x 中看到 libc 选择使用备用符号 versioned_sym 的时候,就应该知道,当前进程正在使用与默认版本不相匹配的符号。

4. 根因总结

通过上述手段,我们通常能定位到以下两类根因:

  1. 1. 编译环境污染:编译环境中混入了 Vendor 的闭源 SDK,导致编译出的 Mesa 库链接到了错误的 libmali 桩代码。

  2. 2. 运行时环境陈旧:开发板上的 libdrm 或 libc 版本(Target)滞后于编译主机(Host),导致 DT_NEEDED 依赖满足但 ABI 不兼容。

5. 工程化解决方案

在解决崩溃问题时,我们追求的是确定性

5.1 方案 A:RPATH 隔离(推荐)

不要依赖系统默认的 /usr/lib。在编译 Mesa 时,利用 RPATH (Run-time Search Path) 强制指定依赖库加载路径。

Meson 配置示例

# 假设我们将编译好的库安装在 /opt/mesa-custom
meson setup build \
  -Dprefix=/opt/mesa-custom \
  -Dlibdir=lib \
  -Dc_args="-Wl,-rpath=\$ORIGIN/../lib" \
  -Dcpp_args="-Wl,-rpath=\$ORIGIN/../lib" \
  -Dplatforms=wayland

效果

编译出的 panfrost_dri.so 的 ELF Header 中会包含 RUNPATH: $ORIGIN/../lib。无论系统环境如何混乱,驱动都会优先加载 /opt/mesa-custom/lib 下配套编译的 libdrm.so

5.2 方案 B:容器化封装 (Flatpak)

如果你无法控制目标系统的基础库版本,使用 Flatpak 是最佳实践。Flatpak Runtime (如 org.freedesktop.Platform) 提供了一套版本严格匹配的 Mesa、libdrm 和 Wayland 库,完全屏蔽了宿主机(Host)的差异。

(写文章很辛苦,恳请各位读者点击“在看”,也欢迎转发)

*************************************************

正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生

扫描下方二维码或者在微信中搜索“盛格塾”小程序,可以阅读更多文章和有声读物

Image

也欢迎关注格友公众号

Image

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值