阅读大规模代码:挑战与实践(3)宏观理解代码结构

目录

  1. 引言 链接

    • 1.1 阅读大型、复杂项目代码的挑战

    • 1.2 阅读代码的机会成本

    • 1.3 本系列文章的目的

  2. 准备阅读代码 链接

    • 2.1 阅读需求文档和设计文档

    • 2.2 以用户角度深度体验程序

  3. 宏观理解代码结构 链接

    • 3.1 抓大放小:宏观视角的重要性

    • 3.2 建立概念手册:记录关键抽象和接口

    • 3.3 分析目录树

      • 3.3.1 命名推测用途

      • 3.3.2 文件结构猜测功能

      • 3.3.3 代码层面的功能推断

  4. 深入代码细节

    • 4.1 阅读测试代码:单元测试的洞察

    • 4.2 函数分析:追踪输入变量的来源

    • 4.3 过程块理解

      • 4.3.1 排除Guard语句,找到核心逻辑

      • 4.3.2 利用命名猜测功能

      • 4.3.3 倒序阅读:追溯参数构造

  5. 分析交互流程

    • 5.1 交互流程法:用户交互到输出的全流程
  6. 调用关系和深层次逻辑

    • 6.1 可视化调用关系
      • 6.1.1 画布法

      • 6.1.2 树形结构法

  7. 辅助工具的使用

    • 7.1 利用AI理解代码
  8. 专项深入

    • 8.1 阅读算法:理解概念和算法背景

    • 8.2 心态调节:将代码视为己出

  9. 结语

    • 阅读大型代码的心得与建议

抓大放小:宏观视角的重要性

在阅读代码时,往往面临着海量的代码文件和复杂的代码逻辑。如果我们只关注细节和具体实现,很容易陷入细枝末节的琐碎细节中,导致挫败感和低效率。

宏观视角意味着我们要从整体上抓住代码的结构和组织方式,将代码划分为不同的模块、组件或功能单元。通过宏观视角,我们可以快速了解代码的大致框架,理解各个模块之间的关系和交互方式。这种全局的认知有助于我们更好地理解代码的功能和设计,从而更高效地进行后续的代码阅读和分析工作。

建立概念手册:记录关键抽象和接口

这里我们提出了第一个实用的代码阅读方法:建立概念手册(Concept Manual)。

概念手册是一个记录关键抽象和接口的文档,用于帮助我们理清代码中的重要概念和它们之间的关系。

尽管许多系统表面上提供简单的接口,但是在实现细节中,它们必然会涉及到一些开发者自己发明的或是领域内通行的抽象。例如 jemalloc 内存分配器涉及到了 size_classbinarena 等抽象,并且反复在代码中出现。我们可以首先简单记录下来这些名字,然后在阅读代码时,逐渐理解并完善概念手册。

这里给出一篇写得非常好的文章,作者首先介绍了基础概念和结构,以及主要字段,然后分析了相关的算法实现,文章篇幅不长但是已经让人大致知道了整个系统的工作原理: JeMalloc(作者 UncP)

建立概念手册应该力求事半功倍,一种最常见的误区是逐个研究各个字段的含义,这样立刻你会陷入细节,递归式翻遍全网,然后因为一些小问题卡一整天。举个例子,mm_struct 是 Linux 内核中最重要的结构之一,它的定义如下:

  1struct mm_struct {
  2	struct {
  3		struct {
  4			atomic_t mm_count;
  5		} ____cacheline_aligned_in_smp;
  6
  7		struct maple_tree mm_mt;
  8#ifdef CONFIG_MMU
  9		unsigned long (*get_unmapped_area) (struct file *filp,
 10				unsigned long addr, unsigned long len,
 11				unsigned long pgoff, unsigned long flags);
 12#endif
 13		unsigned long mmap_base;	
 14		unsigned long mmap_legacy_base;	
 15#ifdef CONFIG_HAVE_ARCH_COMPAT_MMAP_BASES
 16
 17		unsigned long mmap_compat_base;
 18		unsigned long mmap_compat_legacy_base;
 19#endif
 20		unsigned long task_size;	
 21		pgd_t * pgd;
 22
 23#ifdef CONFIG_MEMBARRIER
 24		atomic_t membarrier_state;
 25#endif
 26
 27		atomic_t mm_users;
 28
 29#ifdef CONFIG_SCHED_MM_CID
 30		struct mm_cid __percpu *pcpu_cid;
 31		unsigned long mm_cid_next_scan;
 32#endif
 33#ifdef CONFIG_MMU
 34		atomic_long_t pgtables_bytes;	
 35#endif
 36		int map_count;			
 37
 38		spinlock_t page_table_lock;
 39		struct rw_semaphore mmap_lock;
 40
 41		struct list_head mmlist;
 42#ifdef CONFIG_PER_VMA_LOCK
 43		int mm_lock_seq;
 44#endif
 45
 46		unsigned long hiwater_rss; 
 47		unsigned long hiwater_vm;  
 48
 49		unsigned long total_vm;	   
 50		unsigned long locked_vm;   
 51		atomic64_t    pinned_vm;   
 52		unsigned long data_vm;	   
 53		unsigned long exec_vm;	   
 54		unsigned long stack_vm;	   
 55		unsigned long def_flags;
 56
 57		seqcount_t write_protect_seq;
 58
 59		spinlock_t arg_lock; 
 60
 61		unsigned long start_code, end_code, start_data, end_data;
 62		unsigned long start_brk, brk, start_stack;
 63		unsigned long arg_start, arg_end, env_start, env_end;
 64
 65		unsigned long saved_auxv[AT_VECTOR_SIZE]; 
 66
 67		struct percpu_counter rss_stat[NR_MM_COUNTERS];
 68
 69		struct linux_binfmt *binfmt;
 70
 71		mm_context_t context;
 72
 73		unsigned long flags; 
 74
 75#ifdef CONFIG_AIO
 76		spinlock_t			ioctx_lock;
 77		struct kioctx_table __rcu	*ioctx_table;
 78#endif
 79#ifdef CONFIG_MEMCG
 80		struct task_struct __rcu *owner;
 81#endif
 82		struct user_namespace *user_ns;
 83
 84		struct file __rcu *exe_file;
 85#ifdef CONFIG_MMU_NOTIFIER
 86		struct mmu_notifier_subscriptions *notifier_subscriptions;
 87#endif
 88#if defined(CONFIG_TRANSPARENT_HUGEPAGE) && !USE_SPLIT_PMD_PTLOCKS
 89		pgtable_t pmd_huge_pte; 
 90#endif
 91#ifdef CONFIG_NUMA_BALANCING
 92		unsigned long numa_next_scan;
 93
 94		unsigned long numa_scan_offset;
 95
 96		int numa_scan_seq;
 97#endif
 98		atomic_t tlb_flush_pending;
 99#ifdef CONFIG_ARCH_WANT_BATCHED_UNMAP_TLB_FLUSH
100
101		atomic_t tlb_flush_batched;
102#endif
103		struct uprobes_state uprobes_state;
104#ifdef CONFIG_PREEMPT_RT
105		struct rcu_head delayed_drop;
106#endif
107#ifdef CONFIG_HUGETLB_PAGE
108		atomic_long_t hugetlb_usage;
109#endif
110		struct work_struct async_put_work;
111
112#ifdef CONFIG_IOMMU_SVA
113		u32 pasid;
114#endif
115#ifdef CONFIG_KSM
116		unsigned long ksm_merging_pages;
117		unsigned long ksm_rmap_items;
118		unsigned long ksm_zero_pages;
119#endif 
120#ifdef CONFIG_LRU_GEN
121		struct {
122
123			struct list_head list;
124
125			unsigned long bitmap;
126#ifdef CONFIG_MEMCG
127
128			struct mem_cgroup *memcg;
129#endif
130		} lru_gen;
131#endif 
132	} __randomize_layout;
133
134	unsigned long cpu_bitmap[];
135};

这里有很多字段,虽然说它的注释非常丰富(这里篇幅所限有删减),但它涉及到很多概念、机制,妄图努努力花个一两天就能理解它的含义是不现实的,也是不理智和不值得的。没有人学习 Linux 的目的是搞懂所有内核源码,即便是 Linus 本人也不敢保证内核中每行代码每个字段他都了然于心。所以如果如果你的目的是研究某个特定的功能,那么你应该只关注于你需要的部分,以及无论如何都绕不过的部分,而不是试图把所有的细节都搞懂。

下面详细介绍如何建立概念手册。

总体而言,在建立概念手册时,我们可以首先识别代码中的关键抽象,例如类、接口、数据结构等。对于每个关键抽象,可以记录其名称、核心功能、核心字段以及与其他抽象的关联关系。这个过程不是直接在阅读代码之前完成,而是交替进行。

我们以 spaskalev/buddy_alloc 这个项目为例,它是一个内存分配器,不要小看它只有两千行,这里两千行不是那种简单的业务代码,而是涉及到了很多算法和数据结构。而且一些比较大的项目,但凡设计比较合理的,拆解之后一个原子模块往往也不会超过一万行,大多也就是几千行。

阅读源码前你应该已经知道了什么是内存分配器,什么是 buddy 分配。

源码的前五百行内基本上是一些接口定义,你可以粗略地浏览一遍,不过应该还是一头雾水。现在看代码还太早了!

现在请你阅读下面的“分析目录树章节”。

回到 buddy_alloc 项目,我们现在知道,应该先阅读 README 可以了解到用法(Usage)。

如果 README 里没有,那么你可以去看看测试代码,测试代码可能有更丰富的用法。关注 tests 和 examples 目录!

 1size_t arena_size = 65536;
 2/* You need space for the metadata and for the arena */
 3void *buddy_metadata = malloc(buddy_sizeof(arena_size));
 4void *buddy_arena = malloc(arena_size);
 5struct buddy *buddy = buddy_init(buddy_metadata, buddy_arena, arena_size);
 6
 7/* Allocate using the buddy allocator */
 8void *data = buddy_malloc(buddy, 2048);
 9/* Free using the buddy allocator */
10buddy_free(buddy, data);
11
12free(buddy_metadata);
13free(buddy_arena);

这里非常好地展现了如何使用这个库,以及库中最关键的用户接口、结构等。你应该已经能推测他们的大致用途,请跳转到“代码层面的功能推断”章节继续。

当一个库提供非常多的 API 时,你应该先找到最关键的接口,然后从这些接口开始阅读代码。

分析目录树

分析代码的目录结构也是宏观理解代码结构的重要步骤之一。代码的目录结构通常反映了代码的模块划分和组织方式,通过分析目录树,我们可以获取关于代码结构的一些线索和信息。

先看一个比较简单的,spaskalev/buddy_alloc 项目的目录树:

 1.gitignore
 2CMakeLists.txt
 3CONTRIBUTING.md
 4LICENSE.md
 5Makefile
 6README.md
 7SECURITY.md
 8_config.yml
 9bench.c
10buddy_alloc.h
11test-fuzz.c
12testcxx.cpp
13tests.c

你心里应该对它进行归类:

 1# 文档
 2README.md
 3CONTRIBUTING.md
 4LICENSE.md
 5
 6# 测试代码
 7bench.c
 8test-fuzz.c
 9testcxx.cpp
10tests.c
11
12# 核心代码
13buddy_alloc.h
14
15# 构建相关
16CMakeLists.txt
17Makefile
18
19# 其它
20.gitignore
21SECURITY.md
22_config.yml

当然,不是所有项目都这么简单,但分类之后大致也是这些类别,例如 LLVM,一种分类如下:

 1# 项目配置文件
 2.arcconfig
 3.arclint
 4.clang-format
 5.clang-tidy
 6.git-blame-ignore-revs
 7.gitignore
 8.mailmap
 9
10# 项目文档
11CODE_OF_CONDUCT.md
12CONTRIBUTING.md
13LICENSE.TXT
14README.md
15SECURITY.md
16
17# 持续集成/持续部署配置
18.ci/
19.github/
20
21# LLVM项目主要组件
22llvm/
23clang/
24clang-tools-extra/
25lld/
26lldb/
27mlir/
28polly/
29
30# 运行时库
31compiler-rt/
32libc/
33libclc/
34libcxx/
35libcxxabi/
36libunwind/
37openmp/
38
39# 其他语言和工具支持
40flang/
41bolt/
42
43# 跨项目测试
44cross-project-tests/
45
46# 第三方库
47third-party/
48
49# 通用工具
50utils/
51
52# 并行STL实现
53pstl/
54
55# LLVM运行时环境
56runtimes/
57
58# CMake支持
59cmake/

对于第一次阅读这个项目的人,你可能感到无法分类,因为看不出来。这种情况下,我会立刻去看 CONTRIBUTING.md、README.md 文件。根据里面的指引,你可能会得到官方提供的目录树介绍,也可能直接重定向到一个专门的文档网站,例如 LLVM。一般比较大的项目都会有这样的文档,并且也会有比较丰富的非官方文档、教程和博客。

命名推测用途

首先,我们可以通过目录和文件的命名来推测其用途和功能。通常,良好的命名规范能够提供一些关于代码功能和模块划分的线索。

例如,如果一个目录名为 “utils”,那么很可能它包含了一些通用的工具函数;如果一个文件名以 “controller” 结尾,那么它可能是一个控制器模块的实现。

下面我整理了一些比较通用的常见的文件/目录命名惯例。

  • src:常用于存放源代码。

  • utils:通常用于存放通用的工具函数或类。

  • config:常用于存放配置文件,例如应用程序的配置项、数据库连接配置等。

  • tests:通常用于存放单元测试或集成测试的代码。

  • examples:常用于存放示例代码,例如如何使用某个库或框架的示例。

  • docs:通常用于存放文档,例如项目的说明文档、API 文档等。

  • scripts:常用于存放脚本文件,例如构建脚本、部署脚本等。

  • dist / build:通常用于存放构建后的发布版本,例如编译后的可执行文件或打包后的压缩包。通常会被添加到 .gitignore 中,因为它们可以通过源代码构建而来。

  • lib:通常用于存放库文件。

  • include:常用于存放头文件。

  • bin:通常用于存放可执行文件,一般也会被添加到 .gitignore 中。

我们自己写代码时也最好遵循惯例,保持一致性和可读性,以便别人能够轻松理解代码结构和功能。

对于不同类型的项目,目录结构会有所变化。下面是一些特定类型的项目以及它们可能包含的目录:

Web 类项目:

  • controllers:常用于存放控制器模块的实现,负责处理请求和控制应用逻辑。

  • models:通常用于存放数据模型的定义和操作,例如数据库表的映射类或数据结构的定义。

  • views:常用于存放视图文件,即用户界面的展示层。

  • services:通常用于存放服务层的实现,负责处理业务逻辑和与数据访问层的交互。

  • public:常用于存放公共资源文件,例如静态文件(CSS、JavaScript)或上传的文件。

  • routes:通常用于存放路由配置文件或路由处理函数,负责处理不同 URL 路径的请求分发。

  • middlewares:常用于存放中间件的实现,用于在请求和响应之间进行处理或拦截。

数据科学/机器学习项目:

  • data:用于存放数据文件,如数据集、预处理后的数据等。

  • notebooks:用于存放Jupyter笔记本,常用于数据分析和探索性数据分析(EDA)。

  • models:用于存放训练好的模型文件,如.h5.pkl等。

  • reports:用于存放生成的分析报告,可以包括图表、表格等。

  • features:用于存放特征工程相关的代码。

  • scripts:用于存放数据处理或分析的独立脚本。

移动应用项目:

  • assets:用于存放图像、字体和其他静态资源文件。

  • lib:在像Flutter这样的框架中,用于存放Dart源文件。

  • res:在Android开发中,用于存放资源文件,如布局XML、字符串定义等。

  • ios/android:用于存放特定平台的原生代码和配置文件。

游戏开发项目:

  • assets:用于存放游戏资源,如纹理、模型、音效、音乐等。

  • scripts:用于存放游戏逻辑脚本,如Unity中的C#脚本。

  • scenes:用于存放游戏场景文件。

  • prefabs:在Unity中用于存放预设(可重用游戏对象模板)。

嵌入式系统/ IoT项目:

  • src:用于存放源代码,可能会进一步细分为不同的功能模块。

  • include:用于存放头文件,特别是在C/C++项目中。

  • drivers:用于存放与硬件通信的驱动程序代码。

  • firmware:用于存放固件代码。

  • tools:用于存放与硬件通信或调试的工具。

  • sdk:用于存放软件开发工具包。

文件结构猜测功能

如果一个目录的名字不足以让你推测出它的用途,那么你可以进一步分析目录中的文件结构。例如 LLVM 有一个目录叫做 BinaryFormat,光看名字会有点迷惑,但是如果看下面的文件:

BinaryFormat/
    ELFRelocs/
    AMDGPUMetadataVerifier.h
    COFF.h
    DXContainer.h
    DXContainerConstants.def
    Dwarf.def
    Dwarf.h
    DynamicTags.def
    ELF.h
    GOFF.h
    MachO.def
    MachO.h
    Magic.h
    Minidump.h
    MinidumpConstants.def
    MsgPack.def
    MsgPack.h
    MsgPackDocument.h
    MsgPackReader.h
    MsgPackWriter.h
    Swift.def
    Swift.h
    Wasm.h
    WasmRelocs.def
    WasmTraits.h
    XCOFF.h

就知道这个 Binary 原来是指的是 ELF 之类的二进制文件格式。再看到 WASM(一种浏览器里运行的二进制)COFF(Windows 系统的二进制)啥的,可以推测这个目录是用于存放二进制文件格式的定义和解析的,并且支持了很多种格式包括 ELF、MachO、WASM 等。你看,光从一个目录结构就能推测出这么多信息,某种意义上比你钻进去读代码更快。

有时候我们会猜错,但是没关系,我们可以在阅读代码的过程中不断完善概念手册。

代码层面的功能推断

除了目录和文件的分析,我们还可以通过观察代码的功能和调用关系来推断代码的整体结构。通过仔细观察代码中的函数、方法、类之间的调用关系,我们可以推断出它们之间的依赖关系和组织方式。

  • 核心公开接口的特征:在示例代码中被频繁使用

  • 核心、底层功能的特征:一个函数/方法/类被多个其他方法调用。

  • 包装器的特征:短小的函数,并且基本上是对另一个/类模块的调用。

  • 上层代码的特征:调用了多个其他模块的函数/方法/类,很长的 import 列表。

回到 buddy_alloc 项目,我们阅读示例代码,可以推测出一些比较重要的概念和函数:

  • buddy_metadata - 用于存储 buddy 分配器的元数据

  • buddy_arena - 一块一次性分配好的内存,后面的 buddy 分配器会从这里细分分配内存

  • buddy - buddy 分配器的实例

  • buddy_init - 初始化 buddy 分配器

  • buddy_malloc - 分配内存

  • buddy_free - 释放内存

我们还能推测出 buddy_metadata、buddy 应该是两个需要重点了解的核心结构,buddy_init、buddy_malloc、buddy_free 是三个重要的公开函数。

你可以把这些先记到概念手册里。然后进一步阅读代码的过程中,会涉及到他们的具体实现,以及更多隐含的概念。比如二叉树、tree_order、depth、index、目标深度、size_for_order、internal_position、local_offset、bitset 等。这是一个不断探索的过程。


本章介绍了宏观理解代码结构的重要性,并提供了一些实用的方法和技巧。通过采用宏观视角,建立概念手册,分析目录树以及观察代码的功能和调用关系,我们可以更好地理解代码的整体结构,为后续的代码阅读和分析工作打下坚实的基础。在下一章中,我们将介绍如何进行代码的细节分析,以更深入地理解代码的实现细节。