Trace体系
🗓️ 2.7~2.10:阅读与提升(基础设施们)¶
undefined behavior的一点了解(未定义行为)(1-2h)⚡¶
核心:C 的“契约陷阱” 探讨编译器优化-性能与程序员直觉-正确性之间的鸿沟。 C 标准中的“未定义行为”(Undefined Behavior, UB)本意是给编译器自由以生成高效代码,但现代编译器滥用了这种自由,将“未定义行为”视为“绝对不会发生”,导致防御性代码被编译器直接删除。
C 不是“可移植的汇编” 误区:常认为 C 代码直接对应机器指令,认为 x + 1 就是寄存器加法,溢出就是回绕。 真相:对编译器,C 是一个抽象的逻辑模型。如果标准规定“有符号溢出是未定义的”,编译器就会在数学上假设溢出永远不可能发生,进而推导出所有依赖溢出判断的代码都是多余的。
系统级编程的脆弱性 即使是 Linux 内核、PostgreSQL 这样成熟的系统,也充满了因为 UB 导致的严重漏洞。说明依靠代码来避免 UB 是不可靠的,需要更好的工具或语言规范来弥合这一分歧。
实现了iringbuf与mtrace与ftrace(13h)🕊️¶
ftrace的逻辑¶
key:jal jalr elf 函数构建
* 对于jal jalr,根据指令本身特点,可以得知其与函数调用、返回有关,且直接操作相关地址,于是通过汇编指令抓到函数的一个线索————调用与返回时的地址(并通过在指令集中直接嵌入ftrace相关函数以取得地址)
但问题是要将地址和函数具体信息作联系————因为我们需要程序语义层面的信息,需要知晓函数调用与返回的行为————于是想到elf
* 对于elf,需要记录调用返回地址与函数大小与函数名,因为我们在汇编指令中仅仅获得地址,将地址范围作为索引,搜索得到函数名
* 对于函数构建,根据上述逻辑以及ftrace本身功能————需要以下实现:1.判断jal jalr指令行为并根据指令行为译出调用与返回行为 2.函数调用返回信息的输出(elf中地址范围做索引) 3.读入elf信息并对相关信息分类存储
对elf的深入解读🐦🔥¶
针对ftrace的elf解析逻辑:
ehdr中储存着信息:shstrtab在shdr数组中的索引,shdr数组的元素是节们(节头),strtab,symtab,shstrtab是其中的几个(除此之外还有text等),
ftrace(myftrace)的处理顺序:
* 找到三个大表(具体方法:shstrtab依据ehdr的信息直接拿到其在节头表中的下标,symtab依据遍历查找(宏定义携带信息匹配),strtab则依据已找到的symtab节的元数据(此时还没进入symtab内部,还是shdr元素层面)中的sh_link找到strtab位置)
* 用固定的数据结构来取得节中的具体数据(这里真是十分严密且融洽)
* 通过单个符号元素中的偏移量信息去strtab中找自己对应的函数名
确证的猜想:st_name的值并不连续分布,并且杂乱无章😎
对于shstrtab的问题
myftrace并未利用shstrtab实现节名的直接查找(shuimushi用的节类型匹配,是降级策略),而在观察标准elf库时发现(本身便有节类型的宏定义)于是有了:
确证的猜想:elf本身就提供了两种标准的查找符号表的方法,其一是利用shstrtab解析名字直接查找,其二是利用宏匹配😎
对于shstrtab的问题——针对原有单一类型匹配不健壮的修改:
遍历 shdr[i].sh_type 优先匹配 SHT_SYMTAB,找不到则 fallback 到 SHT_DYNSYM。
通过外键关联字符串表:找到符号表节后,用 symtab_shdr->sh_link 取得对应字符串表节(.strtab 或 .dynstr),并增加校验:sh_link < e_shnum 且 shdr[sh_link].sh_type == SHT_STRTAB。
读取并解析:按 symtab_shdr->sh_offset/sh_size/sh_entsize 读入 Elf32_Sym 数组;按 strtab_shdr->sh_offset/sh_size 读入字符串池。对每个符号取 name = strtab + st_name,并用 STT_FUNC过滤函数符号,建立地址→函数名/范围的查询结构。
符号表——符号究竟是什么? * 符号的本质 符号(symbol)本质是“命名锚点”:工具链用一个名字来指代某个代码/数据实体(函数、全局/静态全局变量、段/位置等),并能把它映射到位置/地址/范围/属性。 符号表就是这种“名字 → 可定位信息”的目录(包含绑定属性如 LOCAL/GLOBAL、所在段、值/大小等)。 * 为什么需要 因为在生成 .o 时,很多引用的最终地址还不能确定(最终布局要等链接把多个文件/库排完)。 所以编译器/汇编器会: 在代码/数据里先留“待定位置”(坑位) 同时生成重定位信息(哪里要修、修成谁) 链接器根据符号表提供的锚点,在最终布局确定后完成修补(重定位)。 * 编译器—ELF—工具链的工作关系(逻辑关系) 编译器/汇编器按 ELF/ABI 规范把“待定引用(坑位)+ 重定位条目(怎么补)+ 符号(补到谁)”写入 .o;链接器按这些信息在最终布局确定后完成符号解析与重定位。
readelf命令
riscv64-linux-gnu-readelf -a add-riscv32-nemu.elf(根据自己要求修改文件名即可,使用riscv64交叉编译,可靠稳定且满足riscv32要求)
三个工具的意义:(0.5h)👨💻¶
ftrace:宏观层面的“热点侦察” (Profiling) 价值:Amdahl 定律的具象化工具。 逻辑:程序 90% 的时间通常消耗在 10% 的代码上(热点函数)。 指导: 频次统计:发现 memcpy 被调用了 100 万次,且每次都只拷贝 4 个字节,那么通用的 memcpy 算法效率极低。 决策依据:有了 ftrace,才能知道是该优化 malloc 还是该优化 printf。
mtrace:瓶颈层面的“攻破内存墙” (Locality Analysis) 价值:Cache 设计的绝对依据(为 Lab4 铺路)。 逻辑:CPU 很快,内存很慢。Cache 依靠“局部性原理”(时间局部性、空间局部性)活着。 指导: 可以基于mtrace得到程序的访存序列, 作为缓存模型的输入, 对预取算法和替换算法的优化进行指导(会在Lab4中体会这一点)
itrace (iringbuf):微观层面的“流水线润滑” (Branch Prediction) 价值:分支预测器的训练数据。 逻辑:现代 CPU 是流水线工作的。遇到 if/else(跳转指令)时,CPU 必须猜往哪边走。猜错了,流水线就要清空,性能损失巨大。 指导: 模式识别:分析 itrace 中的跳转记录,发现某些分支是“总是跳转”的,某些是“交替跳转”的。 硬件调优:这些数据是设计分支预测器(Branch Predictor)的输入。如,发现某个循环的跳转规律是循环3次退出,普通的预测器可能会错两次,而基于历史的预测器可以全对。
核心: 数据驱动:Trace 提供了程序运行的真实统计分布。 实际上是在构建一个性能分析器(Profiler)的雏形
尾调用优化(ftrace不严谨结果的诱因)(1h)🕵️¶
当函数 A 的最后一个动作是调用函数 B 时,编译器认为 A 的任务已结束,无需保留 A 的栈帧或返回现场。因此,编译器将“调用 B”优化为“跳转到 B”。 指令行为对照 行为类型 汇编指令 机器码逻辑 (jalr rd, offset(rs1)) 对 ra 的影响 普通调用 jal / jalr rd = ra 更新。将当前 PC 下一条地址存入 ra,建立“回 A 的路”。 尾调用优化 jr rd = x0 (零寄存器) 不更新。ra 保持原值,丢弃“回 A 的路”。
AM作为基础设施报错时“库函数 vs 硬件实现”的责任拆分🧠¶
三级链(责任拆分) * native + glibc(基准) 过:测试/程序在该覆盖下大概率合理 不过:优先怀疑测试/程序本身或 UB(未定义行为)
-
native + klib(库验证) 在同一硬件环境下用 klib 替换 glibc。 glibc 过、klib 挂:高概率是 klib 语义/实现问题
-
NEMU + klib(环境验证) 软件栈保持不变(AM+klib+程序),只换执行环境到 NEMU。 native(klib) 过、NEMU(klib) 挂:高概率是 NEMU/ISA/设备/内存模型问题
链逻辑“稳定性”的前提 测试覆盖率足够:测不到的分支不能下结论 尽量排除 UB:UB 会导致“在 glibc 上看似正常、换环境就炸” 三次对照使用同一组输入与期望:避免对照不一致
类型提升问题🔬¶
C语言在进行比较(==)时,会将低精度整数(char, short)提升为 int。 signed char 的 0xff (11111111) -> 提升为 int 的 -1 (1111...1111)。 而字面量 0xff -> 默认为 int 的 255 (0000...1111)。 因此处理原始内存(byte manipulation)时,永远强制使用 unsigned char,或者在比较前显式强转。
差分测试精髓以及被激发的灵感(最后的慰藉)😎¶
DiffTest 的“真相”是:让两套系统(DUT 与 REF)运行同一个客体程序,它们各自用自己的取指/译码/执行路径去解释并执行这些指令;
一旦两边的运行结果出现分歧,表明大概率nemu存在错误(但还有余地,见下述)
从抽象层上看,责任只能被定位到“软件/硬件实现某处不一致”这么抽象的层级;
但切换到状态机视角,当我们进一步观测指令位置(PC)与寄存器状态时,DiffTest 能把“第一次产生分歧的那一步”精确抓出来,从而把嫌疑范围从“整个系统”压缩到“某条指令的某次状态转移”。
灵光😎:调试/理解计算机要学会在两种视角之间切换——抽象层(契约/语义一致性)和状态机层(具体状态转移)。 严格来讲: 发现不一致 ⇒
DUT与REF至少有一方违反了该ISA的期望语义,或两边的外部条件不一致。 要进一步推出“DUT(如 NEMU)有 bug”,需要额外假设: REF 作为参考实现在当前指令/特性上更可信 双方对“不可比/未建模行为”做了处理 必须排除或处理“不可比/未定义/环境差异” 否则 DiffTest 会报假错 因此有difftest_skip_ref() / difftest_skip_dut()等机制在特定点“校准”或“跳过对比”