近日有同行十万火急找到我,说遇到个极其古怪的问题,请求救援。我说什么问题,他说是一个诡异的编译错误。
我心中暗笑,编译错误也需要找老雷么。在软件世界的诸般错误中,编译错误按说是最简单的啊。
我说,发个截图来看。很快他便发过来了。我把截图(因为同行代码敏感,所有本文中的截图和代码均已经换成可以重现相同问题的示例代码)放大观看,的确是一个编译错误。
为了便于观察,特意摘录错误提示文字如下:
error: function declaration isn’t a prototype [-Werror=strict-prototypes]
从错误提示信息来看,编译器陈述的错误原因是:“函数声明不是一个原型”。
发生错误的代码,的确是一个函数声明,内容如下:
struct inode * xxx_get_next(struct inode* current);
对于这样的编译错误,一个经典的例子便是:
int get_name();
如果编译这样的代码,会得到很类似的一个编译错误:
对于get_name()这样的问题,修改的方法很简单,那就是在括号里加上void,也就是改为:
int get_name(void);
加了个void就烟消云散了。
讲到这里需要陈述一下,问题的发生环境是在Linux下编译驱动程序(Loadable Kernel Module)时发生的。为什么要加一个void呢?因为Linux驱动中主要使用的是C语言,在C语言的语法里,下面两种写法是有巨大差异的:
int get_name();
int get_name(void);
在C++中,上述两种写法是等价的。但是在C语言中,后一种写法代表0个参数,前一种写法因为历史原因而有古怪的含义。
在老的C语言(C89之前)里,声明函数原型时,就是不用带参数,调用时可以传递任意多个参数,因为在C语言的调用协定里,总是调用者清理参数,直到今天我们传递可变参数时,一般使用的仍是C调用协定,不过为了严谨,要使用...来代表可变参数。
为了防止歧义导致故障,今天的编译器对于get_name()这样的写法会给出警告,在编译驱动程序时,标准更高,指定了-Werror=strict-prototypes,遇到这样的问题便报告编译错误。
但现在的问题是,报告错误的函数是有明确参数的,不是简单替换void可以解决的。
我仔细端详这个编译错误后,首先想到的是函数参数类型是不是有问题,反复确认没有问题后,我意识到了这个问题是有点难度的。
编译器是每个程序员离不开的助手,每天与它交往,彼此已经很了解,但是也还是可能有误会。为了避免误会,今天编译器在报告错误时,常常给出很详细的错误陈述,GCC尤其如此。
在这方面,微软的编译器最近几年还做了个“增强”,就是把本来用英文表达的提示信息汉化为中文。这个所谓的“增强”让我非常反感,原本很好理解的英文信息翻译成中文后,有时竟然不知道是说什么。
空说无凭,调出VS 2019,打开我经常用的NDB项目,重新编译一下,中英文夹杂的提示信息立刻涌出来了。
看其中的提示信息,有些简单的信息,翻译的还可以,但是复杂一点的,就有问题了,比如下面这个C4275:
warning C4275: 非 dll 接口 class“NdObject”用作 dll 接口 class“NdConfig”的基
提取关键要素便是:“非 dll 接口用作 dll 接口的基"。啥叫”基“啊?应该是从英文的base强翻译过来的。英文base对于学过C++的人都比较好理解,但是翻译为“基”之后,就有点搞笑了,翻译为基类或者基础也好一点啊,不过还是不贴切,因为翻译为基类对于基础接口的情况就错了。
闲言打住,回到正题。排除了几种简单原因后,我决定自己试一下,防止是环境的问题。唤起虚拟机,在经常使用的llaolao(是的,是刘姥姥的意思)驱动中加了几行代码,模拟同行的问题。
struct inode * xxx_get_next(struct inode* current);
果然能够重现。这让我有点惊愕,不由得把平时很小的眼睛睁大,细看这一行代码。
或许是睁大眼睛刺激了我的大脑,把沉睡的记忆唤醒过来,或许是我一向喜爱的钟进士通过未知力量相助,一道亮光划过我的眼前,思路来了,而且我坚信这就是答案。
把有问题的语句略作修改,从:
struct inode * xxx_get_next(struct inode* current);
改为:
struct inode * xxx_get_next(struct inode* current_node);
再编译,问题没了!
或者改成这样:
struct inode * xxx_get_next(struct inode* cur);
也是可以的。
改成这样呢?
struct inode * xxx_get_next(struct inode* cur_node);
也是可以的。
[此处省略十万八千字,因为有无穷多种改法] ^-^
前面故意做了点重复,为了活跃气氛。我把修改方法告诉同行,他一改也好了。
用我喜欢的“概而言之”,有很多种改法,只要参数的名字不叫current。看到这里,有看官可能急着要问,难道就是因为名字叫current么?
也有看官可能急着要问,为什么不能叫“current”呢?
简单回答,是的,错误的根本原因就是因为名字叫current。那么为什么不能叫current呢?这个说来话长了。
“避讳”制度在中国有着很长时间的历史。很繁琐,很苛刻,简单说就是有些字不能用,比如皇帝的名字不可以随便用。举例来说,古人本来把嫦娥叫做恒娥,但到了汉文帝时,因为汉文帝叫刘恒(和我的创业伙伴之名同音^-^),于是人们不得不将恒娥改为嫦娥,之后一直流传下来,直到今天。
在软件世界里,名字冲突也是个大问题。
比如本文讨论的问题,就是因为参数名中的current与Linux内核本身使用的current名字冲突了。
说起current在Linux内核里的地位,真可谓既深又广。
从深处说,它牵涉到Linux内核的最核心代码,比如线程调度,信号分发,中断和异常处理等等。比如浏览一下kernel目录中发神经(就是信号的意思^-^)用的signal.c,随处都有current身影,精确匹配也有144处之多。
从广处说,它的影响遍及各种CPU架构、内核的各个执行体、以及驱动程序,在今天的内核代码树中随便搜索一下,其出现次数高达万次。
从Linux内核的历史来看,这个名字也是绝对的元老级别,在1991年发布的0.11版本中,它就堂而皇之的在那里了。
而且出现率非常高,在40个文件里出现了389次。
要知道0.11的内核一共也只有100个文件。
那么current到底是个什么东西呢?
在0.11中,它就是一个全局变量,进一步说,就是个全局指针,即:
struct task_struct *current = &(init_task.task);
指针的类型是大名鼎鼎的task_struct。这个结构体直到今天仍然在,但是大了很多倍,留恋一下它小时候的样子吧:
今天的task_struct有多大呢?一个屏幕根本显示不完,只能显示冰山一角。
对于手头的5.0.7内核代码,这个结构体的行数多达628行,从592行开始,直到1220行。
0:000> ?? 1220-592
int 0n628
特意唤出NDB,计算了一下(^_^)。
除了结构体变大二十多倍之外,current的性质也有了变化,它不再是一个单纯的全局变量,而是演变成了一个宏。
#define current get_current()
这个宏定义在头文件中,每一种CPU架构都有,定义基本都一样。
为什么变成宏了呢?简单说,因为要支持多CPU,每个CPU都要有一个current指针,所以简单的全局变量已经无法支持了。
既然每个CPU有自己的current指针,那么每个CPU到哪里找到它呢?
这也是复杂问题,历史上,曾经用过简单的方法,从栈上来取,有代码为证:
static inline struct task_struct * get_current(void)
{
struct task_struct *current;
__asm__("andl %%esp,%0; ":"=r" (current) : "0" (~8191UL));
return current;
}
上面是2.4内核的X86实现,栈的底部有个thread_info,thread_info的第一个字段便是current指针。
这样直接从栈上获取的安全风险太高了,不得不修改,所以在4.16内核中,使用的就是所谓的per-cpu机制了。
DECLARE_PER_CPU(struct task_struct *, current_task);
static __always_inline struct task_struct *get_current(void)
{
return this_cpu_read_stable(current_task);
}
所谓per-cpu就是每个CPU都有的一个内存区,在x86架构中,使用每个CPU的gs寄存器来定位这个内存区的起点。
这个地方的技术难度比较大,来个演示吧,把GDK7开机,唤出NDB,中断下来,观察栈回溯:
从栈回溯来看,这个CPU正在执行idle线程,说明它闲着没事在这里休息呢。
执行r命令观察CPU的状态:
微观上看,它正在执行intel_idle函数。
再进一步看,它的程序指针指向的这条指令就是很独特的访问per-cpu区的指令:
ffffffff`9229ed37 65488b0425c06b0100 mov rax,qword ptr gs:[16BC0h] gs:00000000`00016bc0
这条指令的机器码有点长,因为它其中还编码了要访问的偏移地址16bc0,也就是per-cpu区的偏移地址。
今天的per-cpu区域一般都有很多个页,也是很大一片地。如何使用这片地呢?简单说,可以通过特殊的方式在上面定义所谓的per-cpu变量,每个变量有个偏移地址。
而上面这个16bc0偏移地址对应的偏移变量就是本文的主角current。
使用.srcpath设置源代码路径:
.srcpath F:\bench\linux-5.0.7
结合源代码再来看一下。
可以看到CPU正在执行的位置正是get_current函数,源代码路径为:
F:\bench\linux-5.0.7\arch\x86\include\asm\current.h
这显然是x86的实现。代码在.h中,先是预声明著名的task_struct结构体,再声明per-cpu变量,然后再定义内联函数,而后再定义“邪恶”的current宏。
per-cpu变量的基本特征就是对于不同的CPU,它的值是不一样的。比如在0号CPU上执行dq gs:00000000`00016bc0观察内存:
可以看到0号CPU的current指针内容为ffffffff`92e13780。
执行~1s切换到1号CPU,执行相同的dq gs:00000000`00016bc0观察内存:
比较上面两个命令的结果,可以看到读出的内存是不一样的。对于CPU1,它的current指针内容为ffff8a60`29950000。
执行k命令观察CPU1的执行经过:
可以看出它也是在执行idle线程,也在休息,不过它执行的是它自己的idle线程。从理论上上讲,每个CPU都有自己的idle线程,在内核初始化时,就给每个CPU创建好了这个线程。
一样东西势力太大,就可能有副作用,比如店大欺客就是一个浅显的例子。^-^
在Linux内核中,current的势力也太大了,历史悠久,根深蒂固,影响错综复杂。其实current所指向的巨大结构体task_struct有很多不合理的地方,最基本的缺陷是这个结构体既用来描述进程信息,又用来描述线程信息,这是Linux内核进程管理器的一大顽疾,是Linux系统中进程、线程概念混乱的根源。或许包括Linus先生本人在内的很多Linux内核高手早都意识到了这个问题,可是要改掉这个问题太难了,牵涉的代码太多了,它已经成为Linux内核的基本基因之一,要改的话,可能伤筋动骨。
真实的世界总是不完美的,在矛盾中发展,在不平衡中寻找平衡。有些问题是短时间内不好解决的,无论是理解软件世界,还是理解现实世界,认识到这一点都很重要。
短文已经不短,就此打住,如果看官对per-cpu和task_struct的讨论没有看通透,那可以暂且放过,只要记住一个简单的结论:以后写Linux内核驱动的代码时,千万不能给参数取名current,因为那是内核老大在用的名字,姑且称它为内核第一霸吧,惹不得。
2020/11/29 于xx东门之xx酒店
【年底很忙,挤时间写短文不易,请看官帮忙点“在看”,也欢迎转发】
***********************************************************
正心诚意,格物致知,以人文情怀审视软件,以软件技术改变人生
欢迎关注格友公众号
转载:https://blog.csdn.net/PCb4jR/article/details/110358466