![ab1bba69a7c76310f927e45d7d3a1f4d.png](https://i-blog.csdnimg.cn/blog_migrate/5ee3afffd0258866d952a0a3466fc416.jpeg)
作者简介:陈华才(龙芯3号Maintainer),博士,高级工程师,负责龙芯3号处理器的内核开发。作为Linux内核中MIPS/ 龙芯3 号的Maintainer,向Linux内核官方提交了大量的代码。
阅读软件源代码是每个开发者的必由之路,尤其是内核开发者。因为内核开发在很大程度上并不是重新发明轮子,而是深入理解并尽量复用现有的内核设计框架,然后参照相似的功能模块去添加或改写某项需要的功能。在对内核整体框架以及某些子系统融会贯通以后,才有可能站在巨人的肩膀上去改进框架本身,实现自主创新。就我的个人经验来说,阅读代码与编写代码的时间大概是6 : 4。
自由软件的开发与商业软件相比,有一个很大的不同就是文档相对比较缺乏。但同时有一种说法叫做“代码就是最好的文档”——只要你愿意,没什么学不会的。那么,像Linux内核这样动辄上千万行代码的浩大工程,该如何读懂,又从哪里读起?
为此,我梳理了自己在十余年Linux内核相关的工作经验,总结了一套可行的代码阅读方法:基于“广度优先”的大原则,“三部曲”具体实现,理解补丁文件。
➤广度优先原则
建议用“广度优先”的方式阅读代码。
“广度优先”指的是当我们在阅读一个函数的时候,先看看这个函数整体上在一步一步完成什么工作,等基本掌握要领以后,再逐个去了解其所调用的子函数。与之对应的“深度优先”方法,一上来就把第一个子函数以及子函数的子函数弄清楚,这里并不推荐采用。
代码好比一棵树,“广度优先”就是说我们要先找到主干,然后搞清楚主干上有几根树枝,再去某条感兴趣的树枝上寻找有意义的叶子;而“深度优先”则是随便碰到一根树枝,就赶紧深入进去把所有的叶子给找出来。广度优先会让你有一种“会当临绝顶,一览众山小”的自信;而深度优先会让你有一种“不识庐山真面目,只缘身在此山中”的迷茫。
基于“广度优先”的大原则,我们更进一步来详细解析“三部曲”的具体实现方法。“三部曲”依次是:找准入口点,理清主脉络,顾名思义看功能。
➤找准入口点
“三部曲”的第一步是找准入口点。
Linux内核基本上由C语言和汇编语言组成,在C语言里面,大家都知道应用程序的入口点是main()函数,其实内核也是比较类似的。以相对独立的内核模块为例,在绝大多数情况下,如果模块的名字是modname,那么模块的入口函数就是modname_init()。万一不是,可以在相关文件目录里面用module_init进行搜索,因为模块入口一般会用module_init(mod_entry)或者类似的方式进行声明(这里mod_entry指的是模块的入口函数名字)。
那么内核本身的入口又在哪里呢?凭直觉,凭经验,总会跟start、init、entry之类的词汇有些关联。初始入口(第一入口)是体系结构相关的,在汇编语言代码里面;而通用入口(第二入口)是体系结构无关的,在C语言里面——实际上就是init/main.c里面的start_kernel()。顺便说一下,包括龙芯在内的所有MIPS处理器,第一入口都是arch/mips/ kernel/head.S里面的kernel_entry。CPU执行的第一条内核指令就在初始入口kernel_entry,而kernel_entry之后的代码在完成与体系结构高度相关的初始化以后,会跳转到通用入口start_kernel()执行。
➤理清主脉络
“三部曲”的第二步是理清主脉络。这一步的原则是“去粗取精”:去掉没用的,留下有用的。
眼不见为净,影响阅读的直接统统删除。当然了,我们并不是建议在原始代码库中直接删除,而是建议一边看代码一边做笔记,先把函数复制到笔记里面,然后再根据需要逐步删除,直到留下主脉络。
那么什么叫“有用”的,什么叫“没用”的?既然已经进入了内核源代码库,当然每一句都是有用的,这里所说的有用或者没用,仅仅指的是对理解主脉络有利还是不利。而“有利”或者“不利”又是分多个层次的。
- 1.代码 vs. 注释
- 注释是非常有用的,可以帮助我们理解代码,但是注释在很大程度上是用于了解细节的,而在对主脉络的了解上面,用处不大。所以首先考虑的就是去掉注释,包括//…和/*…*/格式的直接注释,以及#if 0 … #endif格式的条件汇编。
- 2.程序流程 vs. 变量声明
- 去掉注释以后,纯粹的代码会变得清爽很多。但是如果依旧十分复杂,影响阅读,那么就可以删除一些变量声明和简单的初始化赋值语句,留下重要的程序流程。
- 3.功能语句 vs. 调试语句
- 如果到了这里主脉络依旧不是十分清晰,那么可以开始考虑去掉各种调试和打印语句,比如printf()、printk()、debug()等。
- 4.正常流程 vs. 异常流程
- 异常处理是程序健壮性必不可少的部分,但是如果异常处理本身代码过于复杂,那它势必会影响可读性。因此在必要的时候,为了方便阅读,可以去掉返回值检查、try-catch中的catch子句等。
- 5.常见路径 vs. 罕见路径
- 通常情况下,代码精简到这一步就已经比较容易理解了,但如果有必要,可以对switch-case、if-else等结构进行处理,只保留最常见的一种情况。
下面举一个实际的例子,代码来源是GXemul。这是一个模拟MIPS(包括龙芯)机器的模拟器软件,代码入口为main()。相比于Linux内核的庞大复杂,GXemul是一个设计精巧的应用程序,非常适合用来举例。(PS:这是一个长达106行的函数~)
int
这是一个长达106行的函数,下面我们开始精简!
1、根据“三部曲”精简原则,我们首先去掉其中的注释。还剩下92行:
int
2、删除注释以后仍然很长,所以我们开始第二次精简,去掉变量声明和简单的赋值语句。还剩下78行:
int
3、现在看起来比较清爽了,但是仍然不够,因此我们进行第三轮精简,去掉各种调试和打印语句,还剩下52行:
int
4、一般来说,超过一屏的函数或多或少都会影响可读性,因此我们需要进行第四轮精简,去掉各种异常处理语句,还剩下43行:
int
对于一个熟练的开发者来说,该函数的逻辑精简到了这个状态以后已经比较清晰了(可以到此为止)。但如果是初次接触的话,还是相对显得有点复杂。
5、让我们来进行第五轮精简,去掉那些不常用的、罕见的代码路径,剩下18行:
int
这就是最终剩下的主脉络!非常清晰明了,那么,这个函数到底在干什么呢?让我们开始“三部曲”的第三步。
➤顾名思义看功能
前面我们提到,代码就像一棵树,因此本文选用一种树形视图来表示函数。依旧以GXemul为例,前面经过五轮精简的main()函数,稍作处理后可以按下面的方法表示:
main()
|-- console_init();
|-- cpu_init();
|-- device_init();
|-- machine_init();
|-- timer_init();
|-- useremul_init();
|-- emuls[0] = emul_new(NULL);
|-- get_cmd_args(argc, argv, emuls[0], &diskimages, &n_diskimages);
|-- for (i=0; i<n_diskimages; i++) diskimage_add(emuls[0]->machines[0], diskimages[i]);
|-- emul_simple_init(emuls[0]);
|-- emul_run(emuls, n_emuls);
| |-- console_init_main(emuls[0]);
| |-- for (j=0; j<e->n_machines; j++) cpu_run_init(e->machines[j]);
| |-- timer_start();
| |-- for (j=0; j<e->n_machines; j++) machine_run(e->machines[j]);
| |-- timer_stop();
| |-- for (j=0; j<e->n_machines; j++) cpu_run_deinit(e->machines[j]);
| -- console_deinit_main();
|-- console_deinit();
-- emul_destroy(emuls[i]);
其中main()函数为根节点,五轮精简后的每一行为根节点的下级节点,进一步展开感兴趣的下级节点(如树形视图中的emul_run()函数),可以得到更下一级的叶子节点。树形视图中的每行代码,甚至不需要看其实现细节,光靠“顾名思义”就能大概知道其功能了。比如console_init()是控制台初始化,cpu_init()是处理器初始化,device_init()是设备初始化,machine_init()是机器架构初始化,timer_init()是时钟初始化,useremul_init()是用户模拟器初始化,diskimage_add()是添加磁盘设备,emul_simple_init()是模拟机器的初始化,而emul_run()显然是核心中的核心,即模拟器运行的主事件循环(通过进一步展开的下级节点也证实了这一点)。
树形视图是一种自顶向下鸟瞰全局的宏观视图,但有时候我们特别希望能有一种方法能够清晰地表述某个很深的函数调用,因此作为补充,本文推荐一种叫链式视图的表示法。比如Radeon显卡驱动中的模式设置函数drm_crtc_helper_set_mode(),在首次模式设置中从驱动入口函数开始一路向下的调用链展示如下:
![773eea12ccc72cf9ae31553713692b13.png](https://i-blog.csdnimg.cn/blog_migrate/ac5668074bb9daa617a7487dac80f55c.png)
这是一条很长的调用链,非常具有典型意义。
在很多解析源代码的书籍中,都会使用流程图来描述代码逻辑。然而,流程图虽然直观,但是其描述能力有限(尤其是缺乏树形源代码视图的层次化表达能力),往往很难精确描述一个函数的执行过程。而一个费尽心机画出来的精确的流程图,往往又会因为其复杂性而失去了直观的功能。并且,单靠流程图并不能完全理解源代码,而是需要将源代码与流程图两相对照。
因此用精简版的源代码(即树形视图和链式视图)来代替流程图,一方面可以快速理解多级函数的复杂调用关系,另一方面可以不需要在源代码和流程图之间反复切换。
➤理解补丁文件
阅读软件源代码,尤其是Linux内核源代码时,不可避免地要接触补丁文件,那么什么是补丁文件呢?其实,补丁文件就是一个变更集,它描述了源代码从旧版本到新版本之间的差异变化,或者更一般地说,描述了源代码从一个状态变到另一个状态的差异(不一定是从旧版本到新版本)。
如果用数学方法来表达,就是:
公式1:源代码差异 = 源代码状态B – 源代码状态A
也可以反过来表达:
公式2:源代码状态A + 源代码差异 = 源代码状态B
举个具体的例子,假设当前目录下有两个子目录linux-4.4.1和linux-4.4.2,分别是Linux-4.4.1版本和Linux-4.4.2版本的源代码顶级目录。那么可以用diff命令来执行公式1的过程,导出一个变更集(即源代码差异)到补丁文件kernel.patch:
diff -Naurp linux-4.4.1 linux-4.4.2 > kernel.patch
接下来可以先进入linux-4.4.1目录,用patch命令来执行公式2的过程,通过应用补丁文件kernel.patch将Linux-4.4.1的源代码状态变成跟Linux-4.4.2一致:
patch -p1 < kernel.patch
上面是对补丁文件的正向应用,使源代码状态A变成源代码状态B。实际上补丁文件还可以反向应用,使源代码状态从B变成源代码状态A。比如先进入linux-4.4.2目录,然后通过反向应用补丁文件kernel.patch将Linux-4.4.2的源代码状态变成跟Linux-4.4.1一致:
patch -Rp1 < kernel.patch
利用两个目录来保存两个版本的内核源代码,使用diff和patch命令来操作补丁文件的做法是一种非常原始的方法。通常在内核开发中我们推荐使用Git做版本管理工具。Git可以记录源代码变化的版本历史,可以回滚到任意一个历史状态,也可以导出两个版本之间的变更集(即源代码差异)。
图1是一个Git历史记录的示例(用git log命令查看)。
![9b4177e248d5c237150d6d027560eacd.png](https://i-blog.csdnimg.cn/blog_migrate/6adae38221bbd4cdae61de8bb8c45f43.jpeg)
图中用节点和线条来描述历史演进关系,每个节点是代表一个完整的源代码状态(即某一个版本的完整源代码)。在Git的术语里面一个版本节点称之为一个commit,用一个40位十六进制数的哈希值来表达。Git里面有分支的概念,历史记录是允许分叉合并的,也就是说可以有多条历史线同时演进。
在Git里面我们有更先进的方法导出和应用补丁文件(commit1和commit2代表两次commit的散列值,散列值也叫哈希值,即Hash的音译):
导出补丁(公式1):git diff commit1 commit2 > kernel.patch
应用补丁(公式2):git apply kernel.patch
这两个git命令导出和应用的补丁称之为简单格式补丁,它与diff/patch命令所操作的补丁具有相同的格式。但git还可以操作更加强大的正规格式补丁(commit1和commit2代表两次commit的散列值):
导出补丁(公式1):git format-patch commit1..commit2 -o kernel_patch_dir
应用补丁(公式2):git am kernel_patch_dir/*.patch
这两个命令中,如果commit1和commit2相邻,就会导出一个补丁;如果不相邻,就会导出一系列补丁。这些补丁保存在目录kernel_patch_dir中,按版本从早到晚(从旧到新)的顺序,以0001-xxx-yyy.patch,0002-xxx-yyy.patch的格式逐个命名。正规格式补丁导出以后可以直接以电子邮件的形式发送出去,而应用正规格式补丁的同时会自动提交到代码库。图2是一个正规格式补丁的具体示例。
![521b95d2591c2d1b04b93749adb728f5.png](https://i-blog.csdnimg.cn/blog_migrate/1ba543dfe6535a54b771329746bbf169.jpeg)
一个正规格式的补丁内容包括4大部分:头部信息、描述信息、正文区和脚注区。图2中补丁的头部信息指的是前4行,包含了作为电子邮件的Commit编号、发送人、补丁日期和邮件标题(邮件标题同时也是Commit标题)。描述信息指的是图中第一个空白行以后,第一个分割线之前的部分,包括补丁内容描述(补丁内容描述同时也是Commit描述)和作者签名(Signed-of-by开头的两行,如有必要还可以加上审查者签名Reviewed-by、确认者签名Acked-by、报告者签名Reported-by、测试者签名Tested-by等)。接下来从第一个分割线开始到最后一个分割线之前的部分都是正文区,这是最重要的一部分,即补丁的主体部分(简单格式补丁只有正文部分)。最后的脚注区就是git的版本号标识。
接下来重点关注补丁的正文区。一个补丁文件可以涉及多个源代码文件,每个涉及的源代码文件可以包含多处变更。因此补丁正文区的内容包括三大部分:总体概述(修改了哪些源文件,增加了多少行,删除了多少行),文件路径描述(以diff开头的连续四行,其中以---开头的表示旧版本中的源文件路径,以+++开头的表示新版本中的源文件路径),若干个变更区段(以@@开始的若干行)。变更区段是补丁内容的最小单位,图2所示的补丁仅涉及一个源代码文件的一处变更,也就只有一个变更区段。
变更区段的内容有四种行:定位行,上下文行,删除行,增加行。定位行就是以@@开头的行,其中的4个数字分别是变更区段在旧版本源文件中的起始行号/总行数以及在新版本源文件中的起始行号/总行数。起始行号允许一定的误差,因此需要配合上下文(区段的前三行与后三行,以及区段中其他以空格开头的行)进一步确定区段的位置;总行数则不允许有任何误差,否则会被认为是一个非法补丁。区段中以-开头的行是删除行,代表旧版本源文件里面有而新版本源文件里面没有的行;以+开头的行是增加行,代表旧版本源文件里面没有而新版本源文件里面有的行。
那么图2中这个补丁究竟包含了什么信息呢?现在我们可以知道了:
它来自Git代码库中一次哈希值为dee809bfaa4caedb56cbc4d842ecf85acbbdb3e1的Commit,该Commit标题是“drm/amdgpu: Set a suitable dev_info.gart_page_size”,源代码补丁的作者是Rui Wang <wangr@lemote.com>和Huacai Chen <chenhc@lemote.com>,其中后者同时也是邮件的发送人。这个补丁修改了源代码文件drivers/gpu/drm/amd/amdgpu/ amdgpu_kms.c,修改的位置在旧版源文件和新版源文件的第717行左右,该区段在变更前和变更后的代码行数均为7行。这次变更删除了一行代码:
dev_info.gart_page_size = AMDGPU_GPU_PAGE_SIZE;
同时又在原来的位置增加了一行代码(实质上就是修改了一行代码):
dev_info.gart_page_size = max((int)PAGE_SIZE, AMDGPU_GPU_PAGE_SIZE);
实际的内核开发过程中,代码补丁往往比这个例子要复杂很多,但是其原理是相同的。理解了补丁文件的原理,在阅读源代码及其变更历史的时候就会如虎添翼。例如,我们已经知道Linux-3.15版本的内核加入了龙芯3A的支持,但是如果直接查看Linux-3.15的完整源代码,你会发现相关的代码延伸非常广泛,遍及到多个子系统,几十个目录,上百个文件。面对这种情况,想要在一个早期的内核版本上移植相同的功能,简直无从下手。那么,如何才能“干净利落”又“完整无缺”地分离出那些跟龙芯3号有关的一项项功能呢?答案就是:查看git记录,导出系列补丁,然后按顺序逐个分析解读。在理解这一系列补丁的基础上,如果你需要在一个早期的内核版本(如Linux-3.12)上添加龙芯3号的支持,并不会是一件非常困难的事情。
结语
基于“广度优先”的大原则,首先找准代码的入口点,从源头开始梳理代码框架;其次理清主脉络,通过精简代码,只保留源码最核心的内容;最终可根据函数名了解各个模块的功能,厘清代码的实现逻辑,同时为了使得阅读更加方便,还介绍了树形视图和链式视图。通过结合具体的案例,本文详细解析了“三部曲”的具体实现方法,并且介绍了补丁文件的阅读方式。
![b55d14fc8cc0827fa3efe589396694c9.png](https://i-blog.csdnimg.cn/blog_migrate/77c21aa29344da4b5cc7d73f13060e54.jpeg)
这一套方法不仅贯穿在我的实际工作中,也是《用“芯”探核:基于龙芯的linux内核探索解析》一书中绝大部分代码解析的方法,使得本书更方便读者阅读和理解。