1. 为什么把大模型拉进内核代码审查这件事值得认真做
内核驱动代码审查一直是件苦差事,尤其是drivers/staging这个目录。做过一段时间内核开发的人都知道,staging 树本质上是个"半成品收容所"——里面堆着大量功能可用但代码质量参差不齐的驱动,有的来自小厂商随手甩出来的 BSP 包,有的来自个人开发者练手的实验性模块,还有的是从其他操作系统移植过来、只求能跑通的粗糙实现。这些代码要往drivers/net、drivers/gpu、drivers/media这些正式子系统里挪,中间隔着的就是一轮又一轮的审查和整改。
我前后参与过几个 staging 驱动的清理工作,最直观的感受是:审查的瓶颈不在"看不懂",而在"看得太慢"。一个中等规模的驱动动辄几千行,里面混杂着编码风格问题、资源泄漏隐患、并发保护缺失、错误路径处理不当、API 用法过时等一大堆问题。人工逐行过一遍,眼睛会疲劳,注意力会漂移,很多问题不是发现不了,而是根本没精力看到那里。更麻烦的是,staging 代码的审查往往需要对照内核编码规范、子系统维护者的偏好、以及当前内核版本的 API 现状,这些知识分散在文档、邮件列表历史和个人经验里,新人上手门槛极高。
大语言模型(LLM)在这个场景里的价值,不是替代审查者做最终判断,而是把"机械性、模式化、可枚举"的那部分问题先扫一遍,把人的注意力解放出来,集中到真正需要架构判断和上下文推理的地方。这个定位很重要——我见过不少人一上来就想让模型"审查整个驱动",结果拿到一堆似是而非的建议,反而增加了甄别成本。正确的用法是把它当成一个不知疲倦的初级审查助手,你给它明确的检查清单和上下文,它给你结构化的候选问题列表,你再做二次确认。
这篇内容适合三类人看:一是刚开始接触内核驱动、想搞清楚 staging 代码到底要审什么的新手;二是已经在做驱动维护、想用工具提升审查效率的老手;三是对 LLM 辅助代码分析这个方向感兴趣、想了解实际落地边界的技术人员。我会把整个策略拆成可复现的流程,包括提示词怎么设计、检查项怎么分类、模型输出怎么验证、哪些坑必须提前避开。所有操作都基于公开的内核开发实践和通用的 LLM 使用方式,不涉及任何特定厂商工具。
需要先说明一个前提:LLM 对内核代码的理解是有天花板的。它能识别明显的风格违规、常见的资源管理错误、可疑的锁使用模式,但对涉及硬件时序、内存屏障语义、特定子系统内部约定的深层问题,它的判断经常不可靠。所以整套方法的核心原则是——让模型做"高召回、低精度"的第一遍筛查,人做"高精度"的最终裁决。理解了这个分工,后面的所有设计就都顺了。
2. 整体策略设计:把审查拆成模型擅长和不擅长的两部分
2.1 审查任务的分解逻辑
直接让模型"审查这个驱动文件"是最差的用法。原因很简单:任务太宽泛,模型不知道你关心什么,输出会变成一篇泛泛而谈的代码评论,既没有优先级也没有可操作性。我的做法是把 staging 驱动的审查拆成若干个边界清晰、判定标准明确的子任务,每个子任务单独喂给模型。
拆分的依据是"这个检查项能不能用相对确定的规则描述"。能描述的,交给模型批量扫;不能描述的,留给人。具体来说,我通常拆成这么几类:
- 编码风格类:缩进、行宽、括号位置、空格使用、注释格式、命名规范。这类问题判定标准最明确,模型表现也最稳定。
- API 使用类:是否使用了已废弃的接口、是否该用某个更现代的替代 API、参数顺序和类型是否匹配。这类需要模型对内核 API 演进有一定了解,表现中等。
- 资源管理类:内存分配与释放是否配对、错误路径是否遗漏清理、引用计数是否平衡。这类是模型相对擅长的模式识别任务。
- 并发与同步类:锁的获取释放是否成对、临界区是否合理、是否存在明显的竞态模式。这类模型能抓明显问题,深层语义靠不住。
- 错误处理类:返回值是否检查、错误码是否合理传播、失败路径是否完整。这类表现不错。
- 架构与语义类:这个驱动该不该放在 staging、它的整体设计是否符合子系统约定、硬件操作时序是否正确。这类基本别指望模型,人来做。
这个拆分不是为了好看,而是为了控制每次输入的上下文规模和输出范围。一个文件几千行,全塞进去模型注意力会稀释;拆成子任务后,每次只让它关注一类问题,输出的信噪比明显提升。
2.2 为什么选择"分块+清单"而不是"整文件自由审查"
我试过两种极端方式。第一种是把整个驱动文件丢给模型,让它自由发挥。结果是输出又长又散,前面几条还算靠谱,越往后越像凑数,而且经常把同一个问题换个说法重复好几遍。第二种是只给模型一个函数,让它逐行审查。这样精度上去了,但效率太低,一个文件几十个函数,来回调用成本受不了。
最后稳定下来的方案是按文件分块、按检查清单驱动。具体做法是:先把驱动按逻辑单元切成若干块(通常按函数或按功能模块),每块控制在几百行以内;然后针对每一块,用一份固定的检查清单去提问,清单里的每一项都对应一个明确的判定标准。这样既保证了上下文完整,又让模型的输出有结构、可对比、可汇总。
这里有个细节值得说:分块的边界要顺着代码的自然结构走,不要机械按行数切。比如一个probe函数和它调用的初始化辅助函数应该放在同一块里,因为资源分配和清理的配对关系跨函数存在,切开就看不出问题了。我一般按"一个对外入口函数 + 它直接调用的内部函数"作为一个块,这样资源生命周期基本在一个块内闭合。
2.3 模型输出在流程中的定位
必须反复强调:模型的输出是"候选问题",不是"结论"。我在流程里给模型输出设了一道强制的人工复核关卡,每一条模型提出的问题都要经过"是否真实存在→是否值得修→怎么修"三步确认。这道关卡不是走形式,实测下来模型提出的问题里,真正需要修改的大概占六到七成,剩下的是误报或者过度挑剔。
误报主要来自几个方面:模型对某些内核惯用写法不熟悉,把正常代码当成问题;模型把"可以更好"当成"必须改",提出一些风格偏好级别的建议;模型对上下文理解不全,比如没看到某个资源在别处已经释放了。这些都需要人来过滤。所以整套流程的效率提升,不是来自"模型直接给出正确答案",而是来自"人只需要复核候选列表,不用从零开始找问题"。这个区别很关键,想清楚这一点才不会对工具产生不切实际的期待。
3. 核心检查清单的设计与提示词工程
3.1 检查清单怎么写成模型能用的形式
检查清单是整个方法的核心资产。写得好,模型输出精准;写得含糊,模型就开始自由发挥。我的经验是,每一条检查项都要包含三要素:检查什么、判定标准是什么、输出格式是什么。
举个具体的例子。关于内存分配的检查项,不能只写"检查内存泄漏",而要写成类似这样的结构:
检查本代码块中所有的内存分配调用(如
kmalloc、kzalloc、devm_kzalloc、vmalloc等)。对每一次分配,追踪其对应的释放路径,确认:1)正常执行路径上是否有释放;2)所有错误返回路径上是否有释放;3)是否存在释放后继续使用的情况。如果发现分配与释放不配对,输出:分配所在行号、分配函数名、缺失释放的路径描述。
这样写的好处是,模型有了明确的搜索目标和输出模板,不会跑偏去评论别的东西。我整理了一份常用的检查项模板,覆盖 staging 驱动审查里最高频的问题类型,实际使用时按驱动类型做增删。
| 检查类别 | 典型检查项 | 模型可靠性 | 人工复核重点 |
|---|---|---|---|
| 编码风格 | 行宽超限、缩进用空格、括号换行不规范 | 高 | 确认是否属于工具已覆盖的范围 |
| API 使用 | 调用已废弃接口、参数类型不匹配 | 中 | 核对当前内核版本的 API 现状 |
| 内存管理 | 分配释放不配对、错误路径遗漏释放 | 中高 | 确认跨函数的资源生命周期 |
| 锁与同步 | 加锁解锁不配对、临界区过大 | 中 | 判断锁的语义是否正确 |
| 错误处理 | 返回值未检查、错误码传播不当 | 高 | 确认错误码是否符合子系统约定 |
| 类型与转换 | 隐式类型转换、指针类型混用 | 中高 | 核对硬件寄存器宽度 |
| 日志与调试 | 使用printk而非dev_*系列 | 高 | 确认日志级别是否合理 |
这张表是我自己用的版本,你可以根据自己的驱动类型调整。比如网络驱动要额外关注 skb 的生命周期,字符设备要关注 file_operations 的各个回调是否完整,USB 驱动要关注 urb 的提交与回调配对。检查清单不是一成不变的,它应该随着你踩过的坑不断增补——每次人工复核发现一个模型漏掉的问题类型,就把它补进清单,下次就能自动扫到。
3.2 提示词的结构化写法
提示词不是越长越好,但结构一定要清楚。我用的模板大致分四段:角色设定、上下文说明、检查任务、输出约束。
角色设定部分简短即可,告诉模型它扮演的是一个熟悉内核编码规范的有经验审查者。上下文说明部分要交代清楚:这是什么类型的驱动、目标内核版本大概是什么范围、当前代码块在整个驱动里的位置。检查任务部分就是把上面说的检查清单贴进去。输出约束部分最关键,要明确规定输出格式,比如"每条问题用一行,格式为:行号 | 问题类别 | 问题描述 | 建议修改方向",并且要求"如果没有发现问题,明确输出无问题,不要编造"。
这里有个实操心得:在提示词里明确要求模型标注置信度。我一般让它对每条问题标"高/中/低"三档置信度,人工复核时优先看高置信度的,低置信度的快速扫一眼即可。这个简单的标注能显著降低复核成本,因为大部分误报都集中在低置信度那部分。
还有一个技巧是给模型提供正例和反例。比如在检查锁使用时,贴一小段正确加锁解锁的代码和一小段遗漏解锁的代码,让模型对照。实测下来,带示例的提示词比纯文字描述的提示词,输出准确率能高出一截。示例不用多,每个检查类别给一组就够。
3.3 上下文窗口的管理策略
内核驱动文件动辄几千行,加上检查清单和示例,很容易超出模型的上下文窗口。我的处理原则是:宁可多切几块,也不要硬塞。一块代码控制在 300 到 500 行是比较舒服的范围,既能保证函数间的调用关系可见,又不会让模型注意力涣散。
切块的时候要注意保留必要的上下文。比如一个函数用到了文件顶部定义的结构体,那这个结构体的定义要跟着块一起给模型,否则它看不懂字段含义。我的做法是在每个块的头部附上一段"相关声明摘要",把这块代码依赖的结构体定义、宏定义、全局变量声明摘出来。这段摘要可以手工整理,也可以让模型先做一遍提取——后者更省事,但提取结果要抽查。
另外,跨块的问题需要单独处理。比如一个资源在 A 函数分配、在 B 函数释放,分块后就看不出来了。这类跨函数的资源配对,我会在分块审查完成后,单独做一轮"全局资源追踪",把所有分配点和释放点列出来对照。这一轮可以人工做,也可以让模型基于汇总信息做,但结论必须人工确认。
4. 实操流程:从拿到驱动到产出审查报告
4.1 第一步:摸清驱动的整体结构
拿到一个 staging 驱动,先别急着上模型。花十几分钟把目录结构、主要源文件、Kconfig 和 Makefile 过一遍,搞清楚这个驱动大概做什么、有多少行代码、依赖哪些子系统。这一步的目的是建立全局认知,后面分块和判断问题严重性都靠它。
我通常会用几个命令快速摸底。统计各文件行数,找出最大的几个文件重点关照;看看有没有明显的代码异味,比如单个函数超过几百行、嵌套层级特别深、大量重复代码块。这些"结构性异味"往往预示着审查工作量的大头在哪里。
# 统计各源文件行数,按从大到小排序 find drivers/staging/your_driver -name "*.c" -o -name "*.h" | xargs wc -l | sort -rn | head -20 # 粗略看看函数规模分布,找出超长函数 grep -n "^static\|^int\|^void" drivers/staging/your_driver/*.c | head -50摸底之后,我会画一个简单的模块关系草图,标出哪些文件是核心逻辑、哪些是硬件抽象层、哪些是辅助工具。这个草图决定了后面分块的优先级——核心逻辑文件优先审,辅助文件可以粗审。
4.2 第二步:跑自动化工具做基础清扫
在把代码交给 LLM 之前,先让传统工具把能自动修的问题处理掉。这一步不能省,因为风格类问题占了 staging 代码问题的一大半,而这些恰恰是checkpatch.pl和clang-format最擅长的。让模型去干这些活是浪费,而且模型对风格问题的判定不如工具稳定。
标准动作是跑一遍checkpatch.pl,把输出按严重程度分类。ERROR级别的必须修,WARNING级别的逐条看,CHECK级别的酌情处理。跑完之后,能自动修的用工具修,不能自动修的记下来,作为后续模型审查的输入之一——把 checkpatch 的输出作为上下文喂给模型,让它重点关注那些工具没覆盖到的语义问题,而不是重复报告风格问题。
# 对单个文件跑 checkpatch,输出详细报告 ./scripts/checkpatch.pl --no-tree --strict -f drivers/staging/your_driver/your_file.c # 对整个驱动目录跑,汇总统计 find drivers/staging/your_driver -name "*.c" | xargs ./scripts/checkpatch.pl --no-tree -f 2>&1 | tee checkpatch_report.txt这一步跑完,你手里应该有一份"工具已覆盖的问题清单"和一份"待人工和模型处理的语义问题清单"。两份清单分开管理,避免重复劳动。
4.3 第三步:分块喂给模型做清单式审查
这是整个流程的主体。按前面说的分块策略把代码切好,每块配上对应的检查清单,逐块调用模型。我一般会写一个简单的脚本把这件事批量化,避免手工复制粘贴出错。
脚本的逻辑很朴素:读入代码块文件,拼接提示词模板,调用模型接口,把输出按块保存。这里不展开具体代码,因为不同环境的接口调用方式差异很大,但核心结构就是"模板 + 数据 = 请求"。关键是模板要稳定,每次调用用同一份,这样输出格式一致,方便后续汇总。
调用的时候有几个参数值得注意。温度参数调低,让输出更确定、更少发散;最大输出长度给足,避免报告被截断;如果模型支持系统提示词,把角色设定和输出约束放在系统提示词里,把具体代码块放在用户消息里,效果更稳。
每块审查完成后,我会快速扫一眼输出,如果发现某块的问题数量异常多或者异常少,就检查一下是不是分块切得不好、上下文给得不全。这种异常往往是分块策略需要调整的信号。
4.4 第四步:汇总去重与人工复核
所有块跑完之后,把输出汇总成一张总表。汇总的时候要做两件事:一是去重,同一个问题可能在不同块里被重复报告(比如一个全局函数被多个块引用);二是排序,按问题类别和置信度排,让人工复核有优先级。
人工复核是整个流程里最花时间但也最不能省的环节。我的复核顺序是:先看高置信度的资源管理和错误处理问题,这类问题一旦属实影响最大;再看中置信度的 API 和并发问题;最后快速扫低置信度的风格类建议,大部分直接忽略。
复核时对每条问题做三选一的判定:确认存在且需修、确认存在但可暂缓、误报。判定结果记在表里,形成最终的审查报告。这份报告才是真正交付的东西,模型输出只是它的原材料。
4.5 第五步:形成可提交的整改清单
审查的终点不是一份问题列表,而是一份可执行的整改清单。每条待修问题要写清楚:问题位置、问题性质、修改方案、验证方式。修改方案要具体到代码层面,不能只说"建议修复内存泄漏",而要写"在第 X 行的错误分支中补充kfree(buf)"。
这份清单可以直接作为提交补丁的依据,也可以作为和上游维护者沟通的材料。我习惯把清单按"必须修"和"建议修"分成两部分,前者是阻塞性问题,后者是锦上添花。这样在时间有限的时候,能保证先把硬伤解决掉。
5. 常见问题与排查技巧实录
5.1 模型误报太多怎么办
误报是这套方法最常见的抱怨。我踩过的坑里,误报主要来自三个源头,对应三种解法。
第一种是检查项描述太模糊。比如"检查代码质量"这种检查项,模型只能自由发挥,输出自然发散。解法是把检查项写到可判定的粒度,前面 3.1 节已经说过,这里不重复。
第二种是上下文给得不全。模型看不到某个资源在别处已经释放,就会误报泄漏。解法是完善分块时的"相关声明摘要",把跨块的依赖信息补进去。如果某个误报反复出现,说明这个块的上下文切得有问题,考虑把相关代码合并到同一块。
第三种是模型对内核惯用写法不熟悉。比如某些子系统有自己的一套资源管理宏,模型没见过就当成问题。解法是在提示词里补充这类惯用写法的说明,或者干脆在检查清单里排除这类模式。我一般会维护一个"已知误报模式"列表,每次复核发现新的误报模式就记进去,下次提示词里明确告诉模型"以下写法是正常的,不要报告"。
5.2 模型漏报严重问题怎么补
漏报比误报更危险,因为漏掉的问题不会进入人工复核环节。我遇到过几次模型完全没提、但人工一看就是硬伤的情况,基本都是涉及深层语义的:内存屏障用错、锁的粒度设计不合理、硬件寄存器访问顺序有问题。
对付漏报,单靠调提示词效果有限,因为这类问题本来就超出模型能力边界。我的做法是用人工检查清单兜底。针对每类驱动,整理一份"模型靠不住、必须人工过"的检查项,审查时专门花时间逐项确认。比如网络驱动必查 skb 在各条路径上的释放、字符设备必查 file_operations 每个回调的返回值处理、DMA 相关代码必查映射与解映射的配对。这份人工清单和模型清单是互补关系,不是替代关系。
另外一个补救手段是换角度提问。同一个问题,正面问模型可能漏,换个问法可能就抓到了。比如不问"有没有内存泄漏",而问"列出本块中所有分配点和释放点,并说明每个分配点的释放路径"。这种"先枚举再判断"的问法,能逼模型把相关代码都过一遍,漏报率会低一些。
5.3 不同模型表现差异大,怎么选
我用过几种不同规模的模型做这件事,体感差异很明显。大参数量的模型对内核 API 和编码规范的理解更到位,误报少,但对长上下文的分块更敏感,成本也高;小参数量模型响应快、便宜,适合做初筛,但误报明显多,需要更严格的人工复核。
我的实际搭配是:用能力较强的模型做资源管理和并发这类需要推理的检查,用轻量模型做风格和 API 这类模式匹配的检查。这样在成本和效果之间取了个平衡。如果你的环境只能用一种模型,那就选能力强的那个,然后通过分块和清单设计把它的优势发挥出来。
还有一点值得注意:同一个模型在不同提示词下的表现差异,可能比不同模型之间的差异还大。所以与其纠结选哪个模型,不如先把提示词打磨好。我见过有人抱怨模型不行,结果一看提示词就一句话"帮我审查这段代码",那再强的模型也发挥不出来。
5.4 审查报告怎么组织才有人看
审查报告写成一坨问题列表,维护者是不会看的。我组织报告的原则是按影响面排序,按修复成本分组。
影响面大的放前面:会导致崩溃的、会泄漏资源的、会在特定条件下出错的,这些优先。影响面小的放后面:风格问题、日志不规范、注释缺失,这些可以批量处理。每组内部再按修复成本排,改一行就能解决的放前面,需要重构的放后面。
报告里每条问题都要有明确的定位和可操作的修改建议,最好附上修改前后的代码对比。维护者看报告是为了决定改不改、怎么改,不是来猜谜的。这一点上,模型输出的原始格式往往不够,需要人工整理成最终形态。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查方向 | 处理建议 |
|---|---|---|---|
| 模型输出大量风格类建议 | 检查清单未排除工具已覆盖项 | 检查提示词是否包含风格检查项 | 从清单移除风格项,交给 checkpatch |
| 同一问题被重复报告 | 分块边界导致跨块重复 | 检查分块是否按函数边界切 | 汇总时去重,或调整分块策略 |
| 模型报告"未发现问题"但人工发现硬伤 | 问题超出模型能力边界 | 对照人工检查清单 | 补充人工兜底检查项 |
| 输出格式混乱无法汇总 | 输出约束不明确 | 检查提示词输出格式部分 | 固定输出模板,要求严格遵循 |
| 模型对某 API 判断错误 | 模型知识过时 | 核对当前内核版本 API 现状 | 提示词中补充 API 现状说明 |
| 长文件审查到后面质量下降 | 上下文过长注意力稀释 | 检查单块行数 | 缩小分块粒度,控制在 500 行内 |
6. 边界认知:哪些事该交给模型,哪些必须人来
6.1 模型能稳定做好的事
经过一段时间的实践,我对模型的能力边界有了比较清楚的认识。模式化、可枚举、判定标准明确的任务,模型做得又快又稳。具体来说,编码风格的机械检查、常见 API 的用法核对、资源分配释放的配对追踪、返回值的检查覆盖、日志调用的规范性,这些交给模型基本不会出大错,人工只需要抽查确认。
这类任务的共同特点是:判定不依赖深层语义,只看代码表面就能得出结论。模型在这方面的优势是速度快、不知疲倦、不会因为看了几千行就注意力涣散。一个几千行的驱动,人工过一遍风格问题要大半天,模型几分钟就能给出候选列表,人工复核半小时搞定。这个效率提升是实打实的。
6.2 模型靠不住、必须人工把关的事
反过来,涉及硬件语义、并发正确性、子系统约定的深层问题,模型基本靠不住。内存屏障该不该加、加在哪、用哪种,锁的粒度设计是否合理,临界区里能不能睡眠,硬件寄存器的访问顺序有没有约束,这些问题的判断需要对具体硬件和子系统有深入理解,模型只能给出表面看起来合理的建议,但经常是错的。
我印象很深的一次,模型对一个 DMA 缓冲区的同步操作提出了"优化建议",说某个屏障调用是多余的。这个建议表面上有道理,但完全忽略了硬件对访问顺序的要求,照做就是引入难以复现的偶发故障。这类问题一旦被模型"自信地"报出来,如果审查者经验不足照单全收,后果很严重。所以我在流程里反复强调:模型对语义类问题的输出,置信度一律按最低档处理,必须人工从原理层面确认。
6.3 人机分工的稳定配比
总结下来,我的稳定配比是:模型负责"广度",人负责"深度"。模型把整个驱动扫一遍,保证没有明显的、模式化的问题被漏掉;人把精力集中在模型标记为高影响、以及模型明确表示不确定的地方,做深入判断。这个配比下,审查的覆盖率和准确率都比纯人工或纯模型要高。
具体到时间分配,一个中等规模驱动的审查,模型调用和汇总大概占两三成时间,人工复核和深入判断占七八成。看起来人工还是大头,但和纯人工相比,人的时间从"逐行找问题"变成了"复核候选问题",单位时间的产出高了不少,而且不容易因为疲劳漏掉明显问题。
7. 我个人的几点实操体会
这套方法用下来,最大的感受是工具的价值取决于你怎么用它,而不是它本身有多强。同样一个模型,有人用出来是"人工智障",有人用出来是效率倍增,差别全在任务拆解和提示词设计上。我见过太多人一上来就想让模型干最难的活,结果失望而归,然后得出结论说"模型不行"。其实换个用法,让它干它擅长的活,效果立刻就不一样。
第二个体会是检查清单要持续迭代。我最初的清单只有十几项,现在扩充到几十项,每增加一项背后都是一次人工复核发现的漏报。这份清单是这套方法真正的护城河,模型会换、工具会变,但你对"这类驱动容易出什么问题"的认知是带得走的。建议每个用这套方法的人,都维护一份自己的清单,边用边补。
第三个体会是别追求全自动。我试过把整个流程串成一条流水线,从分块到出报告全自动,结果发现人工复核环节一旦省掉,报告质量断崖式下降。后来老老实实把人工复核加回来,接受它占大头时间的事实。审查这件事,最终的责任在人,工具只是放大器,放大的是人的判断力,不是替代它。
最后一个实用建议:从小处开始。别一上来就挑最大的驱动练手,找个几百行的小模块,把整套流程走一遍,熟悉每个环节的手感,再逐步扩大规模。流程跑顺了,再考虑批量化、脚本化。我当初就是在一个几百行的字符设备驱动上把方法磨出来的,那个驱动的问题类型比较全,练手很合适。等流程稳定了,再上几千行的网络驱动,心里就有底了。