我们的产品包含多个内核驱动模块,随着Linux内核的不断演进,既有的驱动代码可能因为使用了一些被新版本内核所废弃的函数或者数据结构,导致不能编译通过,或者运行时出错。这时,我们就需要修改我们的驱动代码,以便其能在新版本的内核上正常工作,这个过程通常被称为「适配」。
最近就接到了一个客户在CentOS 7上适配5.7.x内核的需求,在此之前,我们适配过的最高内核版本是5.4.x。经过与Makefile的一番较劲,编译总算通过了,可是一运行新编译的驱动,系统立刻卡住,失去响应。
本来想着要是有coredump文件的话,可以好好分析下,后来才意识到,像这种非CentOS标准标本的内核(比如CentOS 7.8对应的标准内核是3.10.0-1127),网上是没有对应的debuginfo可供下载的,就算产生了coredump也是白搭。不过好在还是有一段弥足珍贵的call trace:
从"Comm"后面跟的名字看,确实是我们产品中操作这个驱动的应用层进程的名称。这里显然是在做一个"open"操作,没有debuginfo,不知道具体打开的是哪个文件,不过从"proc_reg_open"来看,应该是在访问"/proc"目录下的某个文件。
我们的驱动会在procfs文件系统中注册一些参数,以便于观察该驱动的状态,以及动态地调整一部分配置。为了确定是由访问这些参数引起的异常,尝试手动加载对应的驱动,然后去访问"/proc"目录下我们驱动生成的文件,果然在"cat"其中一个文件时,复现了这个问题。
call trace给出的错误提示是访问了一个空指针,并且是在调用mutex_lock()的时候。没有5.7.x版本的debuginfo,那就退而求其次,借助一个拥有调试信息、并且版本尽可能接近的内核来辅助分析。在基于4.18内核的CentOS 8上,先来用crash工具看一下"mutex_lock"的反汇编:
结合对应源码可以推断,多半就是因为这里的"lock"被判定为了空指针:
bool __mutex_trylock_fast(struct mutex *lock)
{
if (atomic_long_try_cmpxchg_acquire(&lock->owner, &zero, curr))
return true;
...
"lock"是上一级函数传入的参数,所以得再顺着call trace往前找找:
loff_t seq_lseek(struct file *file, loff_t offset, int whence)
{
struct seq_file *m = file->private_data;
loff_t retval = -EINVAL;
mutex_lock(&m->lock);
...
看来是"file->private_data->lock"为空指针,在crash工具中用"struct"命令查一下:
再结合seg_lseek()的反汇编结果:
"rdi"寄存器的值加上"0xc8"得到一个内存地址,将该地址处的变量的值赋给"rbp"(注意这里是AT&T汇编语法,而不是Intel汇编语法),然后计算出"rbp"加上"0x40"的地址送给"r14",所以这里前一个"rdi"存储的值就是"file",而"rbp"存储的值就是"private_data"(即"seq_file")。
因为我们借用的是4.18的内核来反汇编,对照源码,在5.7内核中,"seq_file"结构体中"lock"的偏移是0x38(4.18内核是0x40),而在call trace里显示的空指针引用就是"0x38",说明作为基址的"file->private_data"的值为0。
那这个"private_data"为什么会为0呢?继续从更上一级caller中寻找线索。依然是结合反汇编和源码,推断是从这里进入下一级函数的:
int proc_reg_open(struct inode *inode, struct file *file)
{
open = pde->proc_ops->proc_open;
if (open)
rv = open(inode, file);
...
"open"实际会调用我们驱动注册的"xxx_open",而"xxx_open"最终又会调到"seq_open"。
struct file_operations xxx_fops = {
.owner = THIS_MODULE,
.open = xxx_open,
.llseek = seq_lseek,
...
int xxx_open(struct inode *inode, struct file *file)
{
int rc = seq_open(file, &yyy_ops);
...
看下seq_open()函数的实现:
int seq_open(struct file *file, const struct seq_operations *op)
{
struct seq_file *p = kmem_cache_zalloc(seq_file_cache, GFP_KERNEL);
if (!p)
return -ENOMEM;
file->private_data = p;
mutex_init(&p->lock);
...
如果调用了"seq_open",那么"p"值就不应该为0,否则就会因为"ENOMEM"返回了,而且往下走的话,mutex_init()完成初始化操作,后面再使用这个mutex lock的时候,也就不应该再出现NULL pointer的问题。
而且,反复查看这一段调用路径,也没发现从哪里可能进入"seq_lseek",从"open"怎么会直接调到"lseek"里去呢?笔者甚至都开始怀疑这段call stack打错了……
分析陷入僵局,只能从其他地方寻找突破口了。既然我们之前支持过5.4.x的内核,那来对比下这两个版本的内核在源码上的差异,看看到底是哪一处变动导致的。根据最新的统计数据,内核平均每1小时就有超过10次commit,所以肯定得结合这个call trace,来缩小差异对比的范围。
在两个版本的源码中,"mutex_lock"的实现没有变化,"seq_lseek"在调用"mutex_lock"前的这部分实现也是完全相同的,到了"proc_reg_open"这里,粗看好像也是没有大的差异,但仔细一瞧,还是察觉出了一丝端倪:
在5.4.x的内核中,"proc_dir_entry"结构体的定义是这样的:
struct proc_dir_entry {
const struct file_operations *proc_fops;
...
而到了5.7.x版本,对应的部分则变成了这样:
union {
const struct proc_ops *proc_ops;
const struct file_operations *proc_dir_ops;
};
这个有趣的"union"啊,当我们的驱动还是用"file_operations"去注册时,编译阶段是不会报错的,但到了运行阶段,根据"pde->proc_ops->proc_open",实际是按照"proc_ops"来调用的:
根据C语言"union"的元素共享内存地址的属性,本来是想调"proc_open",结果地址指向的是"llseek",可不就走到seq_lseek()里去了嘛,没有"seq_open"进行初始化就调用了"seq_lseek",可不就是会出现指针访问的地址异常么。
看似诡异的一段call trace,原来隐藏着这样的玄机。表象上是mutex lock的时候访问到了空指针,root cause却是因为前面2级函数中结构体中元素的变化,以及"union"的特殊性。
那内核开发者为什么要做出这样一个代码的改动呢?进入Linux的github,用"Blame"模式看下这个改动是由哪次commit引入的:
根据这次提交的说明,之前procfs一直通过"file_operations"来让模块挂接自定义的函数,而"file_operations"本身是设计给VFS用的,但进入procfs的调用路径后,其实跟VFS已经没有什么直接的关系了。
借用人家的结构体来用看起来是省事,但每当VFS扩展自己的"file_operations"时,procfs也必须跟着扩展。VFS里大部分的operations对procfs来说都是用不到的,这样白白增加空间占用,实在说不过去。
Currently core /proc code uses "struct file_operations" for custom hooks,
however, VFS doesn't directly call them. Every time VFS expands
file_operations hook set, /proc code bloats for no reason.
Introduce "struct proc_ops" which contains only those hooks which /proc
allows to call into (open, release, read, write, ioctl, mmap, poll). It
doesn't contain module pointer as well.
所以呢,procfs的维护者终于决定设计一套适合自己的operations函数,实际用到哪些,才会包含哪些,也就是这个新的"proc_ops"结构体啦。
参考:
- https://en.wikipedia.org/wiki/X86_assembly_language
- What's the purpose of the LEA instruction
原创文章,转载请注明出处。