飞道的博客

LINUX内核第一霸

300人阅读  评论(0)

近日有同行十万火急找到我,说遇到个极其古怪的问题,请求救援。我说什么问题,他说是一个诡异的编译错误。

我心中暗笑,编译错误也需要找老雷么。在软件世界的诸般错误中,编译错误按说是最简单的啊。

我说,发个截图来看。很快他便发过来了。我把截图(因为同行代码敏感,所有本文中的截图和代码均已经换成可以重现相同问题的示例代码)放大观看,的确是一个编译错误。

为了便于观察,特意摘录错误提示文字如下:

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
查看评论
* 以上用户言论只代表其个人观点,不代表本网站的观点或立场