Linux内核模块符号CRC检查机制

Linux内核不承诺模块编程接口兼容性,事实上这类编程接口在内核主线的演进过程中,不停地发生变化。那就引出一个问题:插入模块时,内核怎么判断该模块引用的内核接口已发生变化(二进制不兼容),防止模块不经重新编译就插入内核,造成系统Oops……。

由于内核只需要检查模块调用的接口与当前内核提供的接口,在语法和语义是否完全一致(即二进制兼容);而不需要做接口的兼容,甚至保持ABI接口不变。因此不需要使用类似glibc的版本机制,或者Window下的COM方案。

内核做法相对简单,只做两件事情:

1. 判断内核版本是否一致,以及几个重要的配置选项情况是否相同(CONFIG_PREEMPT, CONFIG_SMP)

2. 判断模块引用的导出模符号的CRC值,与当前内核该符号的CRC值是否相同

只有上述两个条件满足,才能说明模块不需要重新编译,本文重点分析CRC机制。

浅谈内核模块符号CRC机制

CRC是什么,很直观的理解就是签名或者哈希,只当数据保持不变时,CRC结果才保持不变;哪怕有一丁点的变化,它都会跳出来告诉你,有变化了,接口不匹配,请重新呼叫编译器进行工作。

那么问题来了,什么情况下导出符号(EXPORT_SYMBOL)不兼容,即二进制不兼容。

其实二进制接口兼容要求保持两个不变:

1. 语法保持不变

遵守这个条件,说明如果模块在新内核下重新编译,那应该没有任何语法问题。

即导出符号的类型名没有变化,如果是函数,则要求参数和返回值类型没有任何变化;如果这些类型是结构体的话,结构体的成员名也没有有任何变化。

2. 语义保持不变

这要求符号的类型不能有变化,如果类型本身是结构体(struct),则它成员的类型不能有变化,成员在结构体内的位置不能有变化,以及成员本身不能增删。

上述两点,背后朴素的道理就是:导出符号的签名不能有变化。

下面先讲述符号的CRC计算过程,然后再说明该CRC结果如何识别上述任何一个变化。

内核导出符号的CRC生成规则

如果你像我一样,对这个CRC生成规则感兴趣,一定要刨根问底了解它的规则才能睡着觉的话,那恭喜你,一定度过不眠之夜。因为生成这个CRC需要解释C源代码,像编译器一样,小心翼翼地根据C的语法识别各种类型定义,你得把lex和yacc的定义翻个朝天还是搞不懂。

好吧,我就以退为进吧,相信你也会赞成这个方法:增加调试输出,对感兴趣的内核符号输出CRC的整个计算过程,从而提取它的规则,然后在这里卖关子,哈哈:)

好!不扯了,下面谈一下具体的规则。

1.  CRC基本函数

Linux内核使用CRC32来做基本哈希运算, 它的定义如下:

static unsigned long partial_crc32_one(unsigned char c, unsigned long crc)

{

return crctab32[(crc ^ c) & 0xff] ^ (crc >> 8);

}

具体的实现细节,可以参考内核源码。相信大家不会对这个函数感兴趣,更多是关心最终符号的CRC结果与什么正相关。

ok,为了将关注的重点转移到CRC的计算结构,我们做下面的简单定义字符串的CRC计算结果:

H(<字符串>, crc0)  := H(<子串1:未字符>,
crc0) = partial_crc32_one(未字符,
H(<子串1>, crc0))

这个递归定义太复杂了吧,直白地说,就是以crc0作为初值,对每个字符,都调用上述的partial_crc32_one,得到一个crc值,再将下个字符和该crc结果,调用partial_crc32_one,依次下去,直到字符串结束,得到的值就字符串的CRC值。

好了,有上述的约定,就可以计算每个符号的CRC值了。

2. 基础类型的crc规则

类型             CRC值

----------------------------------------------------------------

int                H("int", 0xffffffff) ^ 0xffffffff

char             H("char", 0xffffffff) ^ 0xffffffff

long             H("long", 0xffffffff) ^ 0xfffffff

....

那么再复杂一点的unsigned int, unsigned long该如何计算,很简单,使用复合+偏序的计算结构:

unsinged int的计算方法:

1) H("unsigned", 0xffffffff ) -> crc1

2) H(空白, crc1) -> crc2

3) H("int", crc2) - >crc3

4) crc3 ^ 0xfffffff -> 结果

为什么说是偏序呢?因此保持从左右到的计算结构,它的计算结构可以表示成:

0xffffffff -> "unsigned" -> 空白 -> "int" -> 0xffffffff

为了减少阅读的噪音,去掉空白、引号和0xffffffff,将这个表达结果简成下面这样:

unsigned -> int

简化之后规则,只使用计算结构进行表达,方便大家阅读。

3. 复合类型CRC规则

1. 结构体

如 struct foo {

int a;

int b;

};

它的crc计算方式很简单,它的计算结构为:

struct -> foo -> {  -> int -> a -> ; -> int -> b -> ; -> }

2. 数组 type arr[N];

计算结构为:

type -> arr -> [ -> N -> ]

注:这里的type本身可能是个复合类型,它的它的crc计算方法或者结构遵守上述的规则, 比如

type 为unsigned int,即:

unsigned int arr[N]

type -> arr -> [ -> N ->]  ==>unsigned int -> arr -> [ -> N -> ] ==>unsigned -> int -> arr
-> [ -> N -> ]

3. 指针 type *p;

计算结构为: type -> * p

简单的有如:int * p

它的计算结构:int -> * ->p

复杂的有如:

struct foo {

int a;

int b;

}

struct foo * p

那么p的计算结构:

struct -> foo -> { -> int -> a -> ; -> int -> b -> ; ->} -> * -> p

其它构造类型,在这里不一一枚举,有兴趣可以查阅相关代码;或者使有我后面提供的patch,对你感兴趣的类型做测试。

4. 导出变量的CRC计算方法

上在谈的一直是类型,那变量呢,因为内核导出的符号最终是变量或者函数。

假设内核有下面的导出变量

type var;

EXPORT_SYMBOL(var);

计算结构为:type -> var

5. 函数的CRC计算方法

假设内核有下面的导出函数

type1 func(type2 a, type3 b)

计算结构为:type1 -> func -> ( -> type2 -> a -> , type3 -> b -> )

CRC计算过程如何保持符号的语法和语义

前面提到,符号CRC值不变,意味着符号的语法和语义保持不变,下面用例子说明:

struct foo1 {

int a;

int b;

};

struct foo {

int c;

struct foo1 b;

};

int func(int u, struct foo *foo);

EXPORT_SYMBOL(func);

那整个CRC计算结构如下:

int -> func -> (  ->

int -> u -> , ->

struct -> foo ->

{ ->

int -> c -> ; ->

struct -> foo1 ->

{ ->

int -> a -> ; ->

int -> b -> ; ->

} ->

b  ->

} ->

* -> foo ->

)

看到了吧:

1)语法属性:任保一个类型名,或者变量名发生变化,都会造成最终的CRC发生变化

2)语议属性:任何一个类型变化,或者结构成员出现位置调整,都会造成最终的CRC发生变化

不要恐慌

看了导出符号CRC的定义,对于通用的导出函数,只有它的类型树结构稍有点风吹草动,它的CRC就会发生变化,依赖该函数的内核模块就得重编了。

事实上没有这么大的恐慌,一般内核bugfix是不会造成核心数据结构的变化(当前是一般情况,但无绝对),因此无须太担心。 一般的bugfix只是增减代码,不会修改数据结构和函数签名。

一个具体的计算例子

上面一直使用计算结构来表过每个符号的计算过程,目的是使大家重点关注CRC值与哪些因素相关,而不是陷入万劫不复的计算细节中。 这里举个具体的例子来说明上述的计算结构是如何工作的。

数据结构的定义:

struct pair {

int a1;

int b1;

};

struct comp {

struct pair p;

long l;

};

函数定义:

void my_func_comp_p(struct comp *p) {}

EXPORT_SYMBOL(my_func_comp_p);

相信大家根据上面的规则很容易推算my_func_comp_p的计算结构。下面调试日志记录的计算过程,crc可以理解成上面的H定义。

上面描述计算结构时,我们一直将空格(空白字符)没有写出来,实际在计算过程,是需要使用空白字符来计算的。否则CRC就无法区分"unsigned int"类型和"unsignedint"变量了。

crc(void, 0xffffffff) = 0x2d842611

crc( , 0x2d842611) = 0x51f3841c

crc(my_func_comp_p, 0x51f3841c) = 0x3cde0c75

crc( , 0x3cde0c75) = 0x1b3d7b77

crc((, 0x1b3d7b77) = 0xfbcf711e

crc( , 0xfbcf711e) = 0xc19ad2da

crc(struct, 0xc19ad2da) = 0x0950984a

crc( , 0x0950984a) = 0xad6ed8de

crc(comp, 0xad6ed8de) = 0xfc468378

crc( , 0xfc468378) = 0x654c9f45

crc({, 0x654c9f45) = 0xc1045134

crc( , 0xc1045134) = 0x1a1bd02c

crc(pair, 0xffffffff) = 0xf6a5e196

crc(struct, 0x1a1bd02c) = 0x64623aa1

crc( , 0x64623aa1) = 0x9adbd18c

crc(pair, 0x9adbd18c) = 0x71ccf17f

crc( , 0x71ccf17f) = 0xfba58094

crc({, 0xfba58094) = 0x304e5a69

crc( , 0x304e5a69) = 0x0f30b76e

crc(int, 0x0f30b76e) = 0x2404ed55

crc( , 0x2404ed55) = 0x204b815e

crc(a1, 0x204b815e) = 0x93bfb9fb

crc( , 0x93bfb9fb) = 0x1192b4e5

crc(;, 0x1192b4e5) = 0x617a6d67

crc( , 0x617a6d67) = 0xe8d9ae5e

crc(int, 0xe8d9ae5e) = 0x42b9fbe6

crc( , 0x42b9fbe6) = 0x7245de7e

crc(b1, 0x7245de7e) = 0xd6c2d0f1

crc( , 0xd6c2d0f1) = 0xf1022092

crc(;, 0xf1022092) = 0xaffb196c

crc( , 0xaffb196c) = 0x7fc5f6a2

crc(}, 0x7fc5f6a2) = 0x16130ab3

crc( , 0x16130ab3) = 0x6910d1f4

crc(p, 0x6910d1f4) = 0xeabc57e8

crc( , 0xeabc57e8) = 0x9555f6d5

crc(;, 0x9555f6d5) = 0x47279a89

crc( , 0x47279a89) = 0xaf4d3cd6

crc(long, 0xaf4d3cd6) = 0x372303f4

crc( , 0x372303f4) = 0x818935ce

crc(l, 0x818935ce) = 0x38594bf1

crc( , 0x38594bf1) = 0xf1ecbb09

crc(;, 0xf1ecbb09) = 0xc826bd3b

crc( , 0xc826bd3b) = 0x8aadef51

crc(}, 0x8aadef51) = 0x3252c10c

crc( , 0x3252c10c) = 0x32ea3e22

crc(*, 0x32ea3e22) = 0x0ee9620c

crc( , 0x0ee9620c) = 0x32d68581

crc(), 0x32d68581) = 0xd83ffd5f

crc( , 0xd83ffd5f) = 0xc0625350

0xc0625350 ^ 0xffffffff = 0x3f9dacaf

Result:  my_func_comp_p = 0x3f9dacaf

为什么会有这篇文章

相信大家都带着一个很大的疑问看到这里,为什么需要知道符号CRC的计算规则;将模块插入到内核时,只有符号CRC值有变化,内核会报告模块插入失败,重编模块就是了,对系统造成影响。

事实上,我是个苦逼的程序员,每次在内核合入小的修改,都尽可能地减少CRC的变化;如果有变化,还需要告诉为什么会有变化。这需求很贴心吧。

另外一点,这个CRC机制是否可以应用于 程序跟动态库的接口兼容 检查,开发一个新工具来检测修改前和修改后编译出来的动态库,对外接口上是否完全二进制兼容,这也是我们所期望的。

有需要请联系

csdn博客不能上传代码,可谓一大遗憾。我通过修改生成crc模块的代码,增加调试信息,可以在编译过程中,通过命令行参数来控制,生成哪些导出符号CRC 的详细计算过程,有兴趣的小伴伙请联系我。

时间: 2024-10-03 23:16:40

Linux内核模块符号CRC检查机制的相关文章

解析 Linux 内核可装载模块的版本检查机制

转自:http://www.ibm.com/developerworks/cn/linux/l-cn-kernelmodules/ 为保持 Linux 内核的稳定与可持续发展,内核在发展过程中引进了可装载模块这一特性.内核可装载模块就是可在内核运行时加载到内核的一组代码.通常 , 我们会在两个版本不同的内核上装载同一模块失败,即使是在两个相邻的补丁级(Patch Level)版本上.这是因为内核在引入可装载模块的同时,对模块采取了版本信息校验.这是一个与模块代码无关,却与内核相连的机制.该校验机

Linux内核模块简介

1. 宏内核与微内核 内核(Kernel)在计算机科学中是操作系统最基本的部分,主要负责管理系统资源.中文版维基百科上将内核分为四大类:单内核(宏内核):微内核:混合内核:外内核. 混合内核实质上也是微内核,而外内核是一种比较极端的设计方法,目前还处于研究阶段,所以我们就着重讨论宏内核与微内核两种内核. 简单的介绍,宏内核(Monolithickernel)是将内核从整体上作为一个大过程来实现,所有的内核服务都在一个地址空间运行,相互之间直接调用函数,简单高效.微内核(Microkernel)功

Linux内核模块编写详解

内核编程常常看起来像是黑魔法,而在亚瑟 C 克拉克的眼中,它八成就是了.Linux内核和它的用户空间是大不相同的:抛开漫不经心,你必须小心翼翼,因为你编程中的一个bug就会影响到整个系统,本文给大家介绍linux内核模块编写,需要的朋友可以参考下 内核编程常常看起来像是黑魔法,而在亚瑟 C 克拉克的眼中,它八成就是了.Linux内核和它的用户空间是大不相同的:抛开漫不经心,你必须小心翼翼,因为你编程中的一个bug就会影响到整个系统.浮点运算做起来可不容易,堆栈固定而狭小,而你写的代码总是异步的,

Linux内核模块编程与内核模块LICENSE -《详解(第3版)》预读

Linux内核模块简介 Linux内核的整体结构已经非常庞大,而其包含的组件也非常多.我们怎样把需要的部分都包含在内核中呢?一种方法是把所有需要的功能都编译到Linux内核.这会导致两个问题,一是生成的内核会很大,二是如果我们要在现有的内核中新增或删除功能,将不得不重新编译内核. 有没有一种机制使得编译出的内核本身并不需要包含所有功能,而在这些功能需要被使用的时候,其对应的代码被动态地加载到内核中呢?Linux提供了这样的一种机制,这种机制被称为模块(Module).模块具有这样的特点. 模块本

Linux内核模块编程与内核模块LICENSE -《具体解释(第3版)》预读

Linux内核模块简单介绍 Linux内核的总体结构已经很庞大,而其包括的组件或许多.我们如何把须要的部分都包括在内核中呢?一种方法是把全部须要的功能都编译到Linux内核.这会导致两个问题.一是生成的内核会很大,二是假设我们要在现有的内核中新增或删除功能,将不得不又一次编译内核. 有没有一种机制使得编译出的内核本身并不须要包括全部功能,而在这些功能须要被使用的时候,其相应的代码被动态地载入到内核中呢?Linux提供了这样的一种机制,这样的机制被称为模块(Module).模块具有这样的特点. 模

编写你的第一个Linux内核模块(目前校对到杂项设备)

想要开始黑掉核?没有线索不知道如何开始?让我们向你展示如何做- 内核编程通常被视为黑魔法.在Arthur C Clarke的意义上说,它可能是.Linux内核与用户空间有很大的不同:抛开漫不经心的态度,你要格外小心,因为在你代码中的一个小小的bug都会影响整个系统.这里没有简单的方法来做浮点运算.堆栈既固定又小,你写的代码总是异步所以你需要考虑并发性.尽管如此,Linux内核是一个非常大而复杂的C程序,对每个人都是开放的(阅读.学习.提高),而你也可以成为其中的一部分. "最简单的方法开始内核编

linux程序的常用保护机制

linux程序的常用保护机制 来源 https://www.cnblogs.com/Spider-spiders/p/8798628.html 操作系统提供了许多安全机制来尝试降低或阻止缓冲区溢出攻击带来的安全风险,包括DEP.ASLR等.在编写漏洞利用代码的时候,需要特别注意目标进程是否开启了DEP(Linux下对应NX).ASLR(Linux下对应PIE)等机制,例如存在DEP(NX)的话就不能直接执行栈上的数据,存在ASLR的话各个系统调用的地址就是随机化的. 一.checksec che

3、Linux内核模块学习

一.内核模块的学习   内核的整体框架是非常的大,包含的组件也是非常多,如何将需要的组件包含在内核中呢?选择一,就是将所有的组件全部编译进内核,虽然需要的组件都可以使用,但是内核过分庞大,势必带来效率影响:选择二是,将组件编译为模块,需要的时候,就自行加载进内核,这种就是我们称之为的模块,当模块被加载到内核的机制,不仅控制了内核大小,同时被加载的内核与被编译进内核的部分,功能意义.    3.1.内核的加载与卸载     将 hello.c 编译为模块,hello.ko, insmod hell

Linux的分段和分页机制

1 基于80x86的Linux分段机制 80386的两种工作模式:80386的工作模式包括实地址模式和虚地址模式(保护模式).Linux主要工作在保护模式下. 在保护模式下,80386虚地址空间可达16K个段,每段大小可变,最大达4GB.逻辑地址到线性地址的转换由80386分段机制管理.段寄存器CS.DS.ES.SS.FS或GS各标识一个段.这些段寄存器作为段选择器,用来选择该段的描述符. 分段逻辑地址到线性地址转换图: Linux对80386的分段机制使用得很有限,因为Linux的设计目标是支