系列:gdev-master(NVIDIA/nouveau 用户态 GPGPU 运行时)从 C/C++ 到 Rust 的移植工程 ·
第三部换了一个问题:前两部回答「怎么搬」,这一部回答「凭什么说对了」。
| 一句话核心 | 最值钱的一格 |
|---|---|
| 每写一条检查,同时写「它凭什么可信」 | 一条从未失败过的检查,和没有检查在证据强度上完全相同 |
| 把「全绿」这两个字的含义钉死 | 被自己的判据逮住四次,全在证伪测试全绿之后 |
| 比译错更贵的,是从来没进过清单 | 差 208 MiB 的真差异,所有检查全是绿的 |
| 代码漂移会响,文档漂移永远不红 | 最毒的形态:只有一个版本,而它是错的 |
| 查看「跑偏了吗」,回答只能靠查事实 | 复核的时候,不许改要复核的那条命令 |
一、判据(13):先让它失败一次
最直接的现场:两个后端「必须恰好选一个」的编译期断言,写的是——
assert!(DRIVER_NOUVEAU||DRIVER_NVRM||DRIVER_PSCNV);// 而 DRIVER_NOUVEAU 的定义就是「另外两个都没开」(!NVRM && !PSCNV)代入之后这个析取式恒为真:它声称要防「两个后端同时开着」,而那恰恰是它唯一防不住的情况。发现它不靠读代码,靠一条真命令:故意把两个后端都打开去构建——照编不误、退出码 0。改成和式(三个开关计数== 1)后,同一格立刻编译期报错。
由此固定成新判据的三道关:
| 关 | 内容 | 抓过的活例 |
|---|---|---|
| ① 先红 | 新判据先确认它会红,且红在该红的那条 | 一条永远红的检查会盖住别人的绿(14 篇的GLIBC_前缀就是恒红) |
| ② 注入 + 回绿 | 把被测代码改坏一点必须变红,改回去必须变绿 | 只证红不证复原 = 半个自检 |
| ③ 非退化 | 比对的两边必须真是两个东西 | .so.1软链没建好,加载器悄悄回落原版 C 库——那门比的是「C 对 C」,输出当然全绿,对 Rust 侧零信息 |
第三关还带出了「基准的来历」问题:golden 必须证明是当前源码现编出来的(「先传.so、后传清单」这个顺序本身就断言了新鲜度),C 基线的指纹默认判失败、不自动放行;而指纹若只存在本机,「本机产物 ↔ 本机指纹」就是循环论证。同一次把 9 处判据整体审计了一遍:7 处修、2 处登记。
硬件后端那条路还有个结构性问题:开发机没有设备节点,不换掉内核,一千多行函数体一行都走不到。做法是LD_PRELOAD一个假内核,在libc 边界拦open/ioctl/mmap/close,同一份假内核同时服务 C 侧和 Rust 侧(两个 mock 会各自漂移),比四条通道:stdout / stderr / 退出码 / 系统调用轨迹。附带一条硬要求:到内核必须走 libc,不许裸syscall——裸调用绕过拦截后,失效是静默的;轨迹通道还断言「非空」(实测每案 ≥3 行),防它悄悄退化成空比对。这条守卫后来自己改强过一次,强到把真用例也拦了——于是给那个用例换通道,守卫和用例各归各位。
这一篇留下的判据纪律,后来每个任务都在跑:缺产物判失败、不许跳过(跳过和通过在日志里长得一样);红在指定入口(红在别的格不算数);判据与实现不同源(不能拿实现自己的输出当期望值);绿表必须带任务名——不带名的绿等于没判定(那张 48/48 的绿表就是按这条攒出来的)。
二、全绿(14):一个需要被证明有信息量的信号
先写清楚门不能证什么:① 覆盖不到的路径(没进用例的代码,绿与它无关);② 无卡路径上两侧一起失败在同一个错误码——diff 照样全绿;③ 系统调用轨迹这类重通道一旦降级成空输出,「比对通过」什么都没说;④ 抽样(抽中的绿不等于没抽到的绿)。
这道门自己就撞过 ①:上机脚本在开发机上从未真跑过——第一次跑就撞出两个配置错(缺xf86drm.h要-idirafter补路径;drm 要链接宿主的库目录),还发现 libdrm 会引进 glibc 2.17/2.33/2.38 的符号、而目标机是 2.16 ⇒ 产物只能出库、不能建可执行(可执行会被目标机的动态加载器拒载);_init/_fini模板符号是恒红噪声——排除之外还要在绿行里写清「排除了什么」。从此文档里的验证状态一律带标记:【已实测 日期】/【未验】——「备好」和「跑过」必须长得不一样。
「两侧同败也全绿」这个死角,用差分实验堵上:把设备藏起来(LD_PRELOAD垫片让打开/dev/dri/*、/dev/gdev*返回「不存在」——不用 root、不动内核),同一个用例的输出必须变。于是「全绿」有了正式定义:
✅ 全绿 = 逐字节一致,且至少一个用例的行为真的依赖设备。
细节全是要命的:open/open64/openat/openat64四个入口必须全拦(漏一个就藏不住);差分对文案零假设(不许假设错误消息长什么样,只断言「变了」);一个数字不许有两个含义;配正控负控双向证伪。
这一篇最值钱的是四个错全在判据自己身上,而且全发生在它的证伪测试已经全绿之后:
| 错 | 形状 | 为什么危险 |
|---|---|---|
比较版本号一边带GLIBC_前缀 | 恒红 | 恒绿坏在看不见,恒红坏在「红得理直气壮」——它判的是好产物,人照着报错越改越远 |
| 两种失败真因共用一句报错 | 误导 | 处置正好相反的两件事(缺件 vs 没变化)让人走错方向 |
| 未加引号的 heredoc + 裸反引号 | 静默执行 | 正文里的反引号在本机被执行了——同样机制可以悄悄吞内容 |
| 总结横幅打「all 个局面」 | 数没被数过 | 把选择器参数当统计结果打印——而总结横幅是唯一会被抄进记录的那一行 |
最后一条的推广版值得单独抄走:「跑一遍」抓不到的东西里,有一种是「跑完之后打的那句话」。判定对了不等于总结对了,总结和判定必须走同一条计算路径。
出门前的最后准备也按同一标准过了一遍:本机彩排三个局面(正常/缺库/环境错)逐条判输出与退出码;五段剧本每段写清「这段绿凭什么绿」;三条不可协商前提(环境别人配不碰/C 源树一字节不改/判据不许为绿而改);当天不许的四件事(不覆盖 C 基线产物/不碰内核模块/不写设备节点/不用 sudo)。这套剧本后来原样兑现——见 19 上机。
三、缺口(15):门是绿的,差异是真的
三种形状的缺口,都不在清单上、都不产生输出、都不响:
- 藏在编译期开关后面。
driver=这个开关其实同时切三件事:(a) 编进哪些源文件、(b) 生成头里的GDEV_DRIVER_*宏、© 链接哪个库——我们只落了 (a)©、漏了 (b)。后果:共享内存基址smem_base差了 218,103,808 字节(约 208 MiB)(C0x0e000000vs Rust0x01000000)。为什么所有检查全绿?那个值只被写进内核描述结构体,开发机没有 GPU,两侧的启动都必然失败在同一个错误码——四条通道全一致。更狠的是那里有一条单测,期望值钉的正是错误分支:22 条单测全绿在这个问题上不是证据,是同谋。修法三件:开关收成单一事实源(三个后端 21 个同名#[no_mangle]导出在同一.so里必然重名,「共存」不是风格是做不到);单测期望值改成硬字面量(不许从被测代码现算);顺手打掉那条恒真断言。 - 整块没排进计划。判据从「参考移植转过没有」改成「是不是 gdev 用户态、实现在不在本仓」,两块东西自己掉出来:一个169 行的 dump 工具(把库编进自己、静态链 rlib 独立成包——它不是测试,是能单独跑的产物;两个出口方向相反:参数错走 stdout 返 1;加载失败走 stderr返 0——C 自身 bug,忠实复刻不许修;用例里有「截断 ELF 前 200 字节」这种恶意输入),和一个1,487 行
.c+ 1,589 行.h、72 个函数的第三后端(仓顶nvrm/与lib/user/nvrm/是软链、同一批文件——这条后来在 16 篇惹出第七种漂移形态,见下节)。第三后端永远等不到真机验证(目标机不走它),编译面 0 诊断、契约 21/21——所以判据必须先于实现就位(先写完实现再想怎么验,注意力就全花在「怎么让它绿」上;判据先行的三个 commit:97257e8/527d1ba/37c09d5)。 - 符号有了,行为没人验。21 个导出符号 ABI 面扎实(445 行结构体逐字节对拍、集合差为 0),但函数体一行都没有行为覆盖。收口时把「没有判据」白纸黑字登记为缺口——「没有判据」和「验过了」是两件事,前者必须写在纸上,不许默认成后者。补欠账时还推翻了自己登记的根因:台账写着的根因指向一段根本没被编译的预处理分支(三条可复现证据)——缺口登记的根因,本身也是一个待验的命题。
这一批里还有一条故意让它崩的用例:C 的 unmap 不把映射指针置空,第二次映射返回悬垂地址(真实 use-after-unmap)。两侧必须崩在同一个地方(退出码 139),它同时是守卫——谁哪天「顺手修干净」那行,退出码从 139 变 0,形状变化立刻被尺子看见(这条守卫实测过:注入-见红-撤销-回绿)。
四、漂移(16):代码漂移会响,文档漂移永远不红
根子一句话:同一件事写在两个地方,它们就会分叉;不是「可能会」,是只差时间。副本的危险不在会错,在错了也不吭声——上游加了三条规则,副本安静地少三条。文档不会构建失败,所以漂移只能靠翻。
五批漂移,形态一批比一批难发现:
| 批 | 形态 | 例 |
|---|---|---|
| 14 处 | 数没钉到命令 | 「59 个薄转发」实为 60;「约 11.5k 行」复现不出 ⇒ 换成可复现的 17,518;「38 万行」实为 394,440;「258 个 commit」实为 260(这数每次提交都变,引用时现算) |
| 5 类 | 同一文档自己和自己对不上 | 同一份 spec 前半「914 个必须仍全绿」、后半三处「915 passed」、总账早已写明实测 922 |
| 6 条 | 计数口径错 | 三处「26 个 ioctl 包装」实为 23——26 是它的表行数(3 个drm*+ 23 个pscnv_*) |
| 8 类 | 更正写在下游,旧结论留在上游 | 两个版本同时可读,读者拿走哪个取决于先读到哪行 |
| 10 条 | ★ 只有一个版本,而它是错的 | 没有对撞面——唯一能发现的方式是去读被移植的 C 源码本身 |
批五里最重的一条单独讲。被移植的 C++ 里有个状态机守卫:
if(prototypeState==PS_ReturnParams||PS_Params){/* push 参数 */}enum{PS_NoState,PS_ReturnParams,PS_Params,PS_End};第二项是裸枚举常量(值为 2,非零),整式等价于(x == 1) || 2——恒真。而它的 spec 摘要把这行概括成「prototypeState恒等于PS_Params」:恒真的意思是「无论如何都进」,摘要读成了「只有一个状态可达」——把恒真的意思读反了。实现照摘要做,丢掉了PS_NoState这个状态,到验收阶段才捞回来(修正提交ed2d4c0)。由此得到全系列被引最多的一句:
「C 是 oracle」这条铁律,在这里的作用不是防改 C,是防把 C 的摘要当成 C。
摘要是有损压缩,而且损失是无声的——它不会自己标注「我这里丢了一个状态」。前几批至少还能靠「两个数对不上」发现,这一种只能回到源码本身去读那一行。
批四则给出了能照做的规矩:更正必须与被更正的结论相邻——在原结论那一行就地标 ⛔ 或划掉旧值紧挨着写新值,不许只在文末写「更正见 §X」(那等于留下两个版本同时可读)。这条纪律写下的当晚就对 28 处漂移执行完毕:实修 27、撤回 1、改正位置 1,另带 4 条顺带(bb14662)。最值钱的反而是执行中的两次「台账与实况不符」——都是台账自己犯的(一次记了没复现过的位置、一次记错了文件)。所以执行要求是:每条先自己跑一遍复现命令;对不上就停下来报告,不许照台账硬改。台账也是文档,也会漂。
写这篇的过程中又连续逮到两种新形态:第六种——结论站得住,但挂在一句跑不出来的复现命令上:「这个宏族只命中 1 处」,命令原样跑出来 5 处(另外几处是别的后端的同族宏 + 生成宏的脚本自己);一条假的可复现证据比没有证据更伤,人家一验数字不对,连对的那句结论也一起不信了。第七种——后写的文档用一次空的检测,改错了先写的正确记录:一份盘点表把某目录登记成「死副本」,证据是「13 个文件与另一处逐字节相同(已 diff)」;可那 13 个文件本来就是软链,diff 说一样是必然的——早先的登记明明写着「符号链接壳」。判断「是不是两份东西」得看 inode,看内容永远看不出来;「两处数字恰好相等」不是交叉验证——它得先证明两条证据链互不相干。配套一条:复核别人的数时顺手给命令加了个参数、当场判「复现不了」——错的是复核方:复核的时候,不许改你要复核的那条命令,一个开关就是口径的一部分。
诚实边界照例写清:那 28 处「已改」不是「已验」(子代理执行 + 抽验几条复现命令,其余二十来条只核了改动形态);这几批漂移全部在文档面、无一在判据面,所以不阻塞上机——但「登记了就算处理过」是假账,诚实的说法是「已知、已定位、已给出处置方案」。
五、自查(17):回答「跑偏了吗」,只能靠查事实
有人提了三条难听的质疑(结构没讲清/规划乱/记录粗糙)加三个追问(走了参考博客废弃的路?为什么代码量这么大?unsafe 口径有什么影响?)。查法不是辩解,是逐条去量——结果每条都查出了真的问题:
- 「讲不清要翻译什么」——查实:全仓确实没有单一出处。全树量出的总账:要译 98,556 行(63,979
.c/.cpp+ 34,577.h)、不译 616,845 行,合计 715,401(-type f口径 705,830——差值是 58 条软链的 9,571 行,两种口径都对,引用要带口径)——要译的只占 11.8%。这张映射表一行一个目录、可被命令重放。顺手逮到三件漏网:编译器目录下还有第二份 ocelot(15.7 万行,同名不同物)、仓顶nvrm/是软链不是死副本、common/gdev_nvidia_nv50.c584 行是不在编译列表的死码。另有一次自我更正:总账第一版漏算了 10,956 行 flex/bison 生成物。 - 「规划乱」——查实:不是「有好几套顺序」,是顺序文档的标题与它自己两周前的裁决正好相反(写着「硬件最后」,实际 P4 先于 P3 剩余执行),从 9-07 错到 9-18。收成唯一权威的 8 行表,其余文档一律引用。那张表当时唯一没答的是 M4(上机)——这个空格后来由第四部补上了(19 上机)。
- 「记录粗糙」——查实根因是一条机制:工作流模板里标着「由脚本生成」的示范段落,其实是手写散文——因为模板说它是生成的,陈旧了也没人敢改。
三个追问的答案也各有硬货:
- 走了参考博客废弃的路吗?没走。把参考博客 12 篇原文(8,263 行)逐条核对成一张对照表:方法与结构 7 条全 ✅、删除/重设计决定 4 条(1 条 N/A)全 ✅、验证方法 9 条 8✅+1⏳(那条「C 测试不改一行源码」正是 M4 判据,现已兑现)、本仓对参考博客的偏离 5 条逐条给处置、参考博客自己的毛病 4 条明确不照抄。被废弃的是「方案 C:逐文件替换」——把
.o塞回 C 的 CMake 再 bindgen 绑一层,原文自己给的死因:翻 150 行的device.c,单 bindgen 生成的bindings.rs就有 1,200 行(8 倍),「产出仅是『Rust 语法的 C』」。而「前三层直译、第四层重设计」是参考博客明文规定的方法——本项目恰好没走废弃那条(独立 workspace,没有.o回填)。 - 为什么代码量这么大?口径问题。参考博客「22,500 → 3,400」是预估、从未落地(实交约 10,300,且它自己的总数三处打架:9,080 / 10,300 / 11,054)。本仓 P3 层实测:Rust 55,842 行 vs C 纯
.c/.cpp48,762(含头文件 76,093)——同一堆代码三种口径:全量1.15×(反而变大)、扣测试0.85×、再扣注释空行0.41×,三个都「对」。所以对外不许只报一个数:报数必须同时给口径加命令(这条纪律在 20 性能三问 又原样兑现:每个数带 median、min–max、口径三件套)。至于为什么确实缩得不多:参考博客的 ocelot 被裁到 8.2k 行(它的硬件不走 PTX),master 是完整版 48.8k——两者根本不是同一个东西;直译层本来就缩不动;测试内联在行数里。 - unsafe 口径有什么影响?影响的是「Rust 换掉 C 到底换来了什么」这句话有没有证据——散文式的「实质等价」不可机械核对,等于没有证据。于是立门,三条判据:unsafe 只能出现在白名单结构里;
unsafe extern不计量但超基线 WARN;pub unsafe fn必须带# Safety文档节。立门过程自己就栽了两跤,都值得抄走:①原稿写「// SAFETY注释 0 条」是量错的——本仓用的是/// # Safety文档节,实测209处;按「pub unsafe fn有没有# Safety节」量是 104/117 = 89%、缺 13。结论(口径确实缺)不变,理由整段换掉——前一个说法会误导下一个人。②第一版判据只数unsafe {块——而生产代码里还有unsafe impl4 个、unsafe extern579 个(38 个文件)、unsafe fn声明 228 个;unsafe impl不是 FFI,是安全性断言,恰恰最该被人看一眼,可它对旧口径整个不存在。计量单位改成「块 + impl」,并为此多补一条注入(0 块 + 1 impl ⇒ 必须红)。——这两跤是同一个形状:判据的覆盖范围没量全,就宣布了覆盖率;纪律是凡要宣布「X 都在边界内」,先量 X 有几种形态、各多少。
这一篇的一句话:没有显卡的时候,「比对」是唯一的证据来源,于是「比对本身可不可信」就成了工程的主线——判据要会失败、全绿要证明有信息量、缺口要登记、账本要能重放、质疑要拿命令回答。