code-debug

Proj158-支持Rust语言的源代码级内核调试工具

项目仓库

仓库名 仓库描述 Github 地址 commit数量 (去年九月至今)
code-debug 代码仓库 https://github.com/chenzhiy2001/code-debug 81(已去除零碎修改)

本仓库结构

.
├── .github/                    # 存放 GitHub 相关的配置并自动化工作流
├── .vscode/                    # 存放 VS Code 编辑器的项目特定配置文件
├── code-debug/                 # 存放项目代码
├── docker/                     # 存放与 Docker 相关的文件、配置和脚本
├── docs/                       # 存放相关文档,包括会议纪要、工作记录等
├── installation and usage      # 存放安装和使用相关说明文档
├── .gitigonre                  # 指定 Git 版本控制系统忽略的文件和目录
├── LICENSE                     
└── README.md                   # 项目说明文档

2023年9月-2024年8月

目录:

第一部分 项目概要

  1. 项目背景
  2. 调试工具框架结构
  3. 调试工具功能说明与使用方式介绍
    3.1 功能说明
    3.2 安装与使用
    3.2.1 安装
    3.2.2 使用
  4. 工作概要
    4.1 对调试器进行完善
    4.2 对可支持调试的操作系统进行语言及运行环境扩充

第二部分 完善调试器

  1. 功能完善
    1.1 调试器自动设置的断点不会在 VSCode 里面显示出来
    1.2 边界断点逻辑不具普适性
    1.3 代码实现复杂不易维护和扩展
    1.4 没有实现单步步进
    1.5 调试器 continue 按钮不能适用于所有情况
    1.6 控制台输出杂乱且不完整
    1.7 编译后插件太大
    1.8 配置文件硬编码
  2. 新增功能
    2.1 通过SSH调试
    2.2 通过右键菜单添加/取消边界断点

第三部分 对可支持调试的操作系统进行语言及运行环境扩充

  1. 支持C语言(xv6)的调试
    1.1 适配所有语言
    1.2 配置调试会话
    1.2.1 xv6的qemu启动参数
    1.2.2 获取断点组名称及路径
    1.2.3 xv6内核态和用户态转换的边界
    1.2.4 修改钩子断点 1.2.5 适配性提升
  2. 支持硬件上的调试的调试
    2.1 框架搭建
    2.2 硬件部署及调试 2.3 硬件适配

第一部分 项目概要

code-debug是一款适用于操作系统开发的源代码级调试工具,实现了基于Qemu与实际硬件环境的操作系统的开发与调试。此工具支持跨内核态和用户态的源代码静态跟踪调试与跨内核态和用户态的性能分析检测,基于VScode构建了远程开发环境,实现了断点静态断点调试与动态性能检测的功能结合。

项目背景

操作系统的调试难度大通常是阻碍开发人员进行操作系统功能开发的重要因素。 由于操作系统内核代码复杂,静态分析、动态分析都具有相当的难度,包括特权级切换,进程调度等。 已有的集成开发环境通常面向应用程序的开发,对操作系统代码特别是新兴的Rust操作系统代码开发调试暂未提供良好的支持。 如何提供方便、高效且可跨特权级跟踪的操作系统调试工具是待解决的关键问题。

基于以上背景,本工作将GDB与eBPF结合,实现用户态、内核态代码的静态断点调试与动态分析结合, 提供了基于VSCode插件的调试器code-debug。为用户提供支持多种语言、方便QEMU与实际硬件上运行的操作系统的开发与调试。

2022-2023年完成的工作:

(1) 支持基于GDB的内核态,用户态代码联合断点调试;

(2) 基于eBPF的内核态,用户态代码的动态跟踪调试;

(3) 远程开发环境下的用户界面(集成开发环境)支持与qemu的支持。

并且在去年已经开始尝试完成硬件支持的相关工作,但由于时间问题并未完成,调试器仍有很多地方需要改善:

· 调试器自动设置的断点不会在 VScode 显示出来 · 边界断点逻辑不具普适性 · 代码实现复杂不易维护和扩展 · 没有实现单步步进 · 调试器 continue 按钮不能适用所有情况 · 控制台输出杂乱且不完整 · 编译后的插件太大 · 配置文件硬编码

今年的主要工作:

我们在之前调试器的基础上进一步完善,修复了去年调试器存在的以上问题, 进行代码重构并新增了使用SSH进行调试、通过右键菜单添加/取消边界等功能, 具体请见第二部分内容。除此之外,我们进行了被操作系统语言(C语言)扩充以及运行环境的扩充 (对实际硬件进行支持),具体请见第三部分内容。

调试工具框架结构

code-dcbug 对上通过 DAP 协议和 VSCode 调试界面进行交互在 Dcbug Adaptcr中实现“断点组切换”;对下通过 GDB/MI接口控制 GDB,GDB再与OpcnOCD/GDBscrvcr 通信以调试日标操作系统。其核心的原理是,将用户设置的 GDB 断点按断点所在的地址空间分为若干组,在任意时刻下,只有当前地址空间对应的断点组是生效的。如果调试器检测到地址空间发生了切换,就会令GDB 立即切换到切换后的地址空间对应的断点组和符号表,从而使得在任意时刻下,GDB都只设置当前运行的指令所在的地址空间的断点,这样就避免了断点失效的问题,保证了用户设置的用户态内核态的断点均可生效。

微信图片_20240815033802

调试工具功能说明与使用方式介绍

功能说明

code-debug的功能包括断点设置、单步执行、监视窗口、日志记录、内存检查、条件断点、异常捕获和图形化调试界面。 断点设置允许开发者在代码的特定行暂停执行,以检查程序状态;单步执行则可以逐行执行代码,详细观察每一步的执行过程; 监视窗口实时查看特定变量的值;日志记录保存程序执行过程中的关键信息;条件断点仅在满足特定条件时触发,避免不必要的暂停; 异常捕获识别运行中的异常;图形化调试界面提供用户友好的操作界面,提高调试效率。通过这些功能的综合使用, 帮助开发者更快速准确地定位和解决代码中的问题,显著提高开发效率。

安装与使用

安装

由于调试器及ebpf所需工具和库非常多,而且依赖关系非常复杂,为了减少人为错误、提高可移植性、简化复杂构建过程,我们决定编写一个自动化安装脚本来提高效率。 考虑到跨平台兼容性和系统资源的利用,最后选择用shell语言来进行编写,并进行输出提示。 以安装 QEMU为例,检测是否安装 QEMU,如果没有则下载最新版本,配置并编译 QEMU,编译完成后返回上一级目录。提示用户每个步骤的状态,并将这些信息记录到 output1.txt 文件中,用以提示用户。

 # 如果未安装 QEMU,则下载最新版本
    echo -e "${YELLOW}QEMU is not installed. Downloading the latest version...
    ${RESET}" | tee -a output1.txt
    if git clone https://github.com/chenzhiy2001/qemu-system-riscv64; then
        echo -e "${YELLOW}下载完成${RESET}"| tee -a output1.txt
        # 编译安装并配置 RISC-V 支持
        cd qemu-system-riscv64
        echo -e "${YELLOW}编译qemu.....${RESET}"| tee -a output1.txt
        ./configure --target-list=riscv64-softmmu,riscv64-linux-user
        # 如果要支持图形界面,可添加 " --enable-sdl" 参数
        make -j$(nproc)    
        cd ..
        echo -e "${YELLOW}编译完成.${RESET}" | tee -a output1.txt
    else
        echo -e "${YELLOW}Error: Failed to clone qemu-system-riscv64.${RESET}"| 
        tee -a output1.txt
        exit 1
    fi
fi

自动安装脚本经过测试和完善,可以正确安装调试所需要的所有工具和库(在网络良好的情况下)。 详细安装步骤见安装说明文档

使用

image

image

调试调试器

由于我们获得的调试信息不够具体详细,我们查阅资料后实现了调试调试器的方法,可以获取更多信息来进一步排查原因。

工作概要

我们的工作主要分为两大部分:

对去年的调试器进行完善

现在,VSCode在某个更新中增加了“在VSCode中设置断点”的API,我们的插件可以利用这个API来模拟用户设置断点的操作,这样VSCode知道了断点的存在,断点就可以了显示出来了。

2. 边界断点逻辑不具有普适性

我们的解决方法是直接设置边界断点,所有地方都用完整的文件路径来解决断点组名字和断点文件名的对应的问题。在实际实现时将边界断点包含在断点组属性中,支持动态设置和取消(但是逻辑上仍然独立于断点组)

我们之前把 isBorder 这个属性交给断点组管理模块去管理。因此,在 Debug Adapter 层面是先设置断点,再给断点添加“边界”属性的。(在 VSCode 的层面则不是)

//this will go through setBreakPointsRequest in mibase.ts
vscode.debug.addBreakpoints([breakpoint]);
vscode.debug.activeDebugSession?.customRequest('setBreakpointAsBorder',args[0]);

由于Debug Adapter 会对这些断点做很多的操作,把断点组和 Debug Adapter 本身的断点管理功能合为一体不是好事,怕会造成更多麻烦。 之前在断点组里面直接存SetBreakpointArguments的策略没有问题,因为断点组管理模块的作用就是在合适的时机进行断点设置,而非存储某个断点。而且 SetBreakpointArguments 里面已经包含了断点所需要的所有信息。只不过我们要继承 SetBreakpointArguments,添加一个 isBorder 属性。然后通过 customRequest 对这个 isBorder 属性做更改,非常麻烦。

我们现在不把边界的信息并存储在断点的数据结构里,而是存在断点组的数据结构里,这样就不用去查找到某个断点的数据结构,再将它改为“边界”。 这样做还有一个好处,无需改动原有的断点数据结构(因为边界的信息不再存储在断点的数据结构中,而是存在断点组的属性中)。再增加一个“去除本地址空间的边界断点”功能,就同时实现了边界断点的更改。 断点组切换的代码除了完全清空所有断点组信息(removeallclibreakpoint)的情况外,断点组本身是不会被删除的(断点组里面的断点可能会被删除)。因此我们把边界的信息附加在断点组上,做到了和之前代码的兼容,因此代码量小,现有的断点组切换的代码完全不需要更改。

    // 每个断点组只能有一个边界断点。因此,如果在同一个断点组中设置两次边界断点,
    //新的边界断点会替换旧的
    const setBreakpointAsBorderCmd = vscode.commands.registerCommand
    ('code-debug.setBreakpointAsBorder', (...args) => {
    const uri = args[0].uri;
    const fullpath = args[0].uri.fsPath; // fsPath 提供了适用于操作系统的路径格式
    const lineNumber = args[0].lineNumber;
    // 我们将行索引设置为0,因为目前不需要处理行内的位置
    let breakpoint = new vscode.SourceBreakpoint(new vscode.Location
                        (uri,new vscode.Position(lineNumber,0)),true);
    //这将会通过mibase.ts中的setBreakPointsRequest
    vscode.debug.addBreakpoints([breakpoint]);
    vscode.debug.activeDebugSession?.customRequest('setBorder',new 
                                                Border(fullpath,lineNumber));
});

此时设置边界的逻辑变得更加简单——接收一个包含边界信息的参数,并调用 updateBorder 方法更新断点组的边界。

   //customRequest=======
                case 'setBorder':
                // args have border type
                this.breakpointGroups.updateBorder(args as Border);
                break;

根据给定文件路径的边界断点,更新/创建对应的断点组。首先通过 eval 执行函数 filePathToBreakpointGroupNames 获取与文件路径关联的断点组名称列表,然后遍历这些组名,检查每个组名是否存在于当前的断点组列表中。如果找到匹配的断点组,则更新其边界属性;如果未找到,则创建一个新的断点组并将其添加到列表中。确保每个文件路径的边界断点都能正确地归属到相应的断点组中。

  public updateBorder(border: Border) {
        const result =
        eval(this.debugSession.filePathToBreakpointGroupNames)(border.filepath);
        const groupNamesOfBorder: string[] = result;
        for (const groupNameOfBorder of groupNamesOfBorder) {
            let groupExists = false;
            for (const group of this.groups) {
                if (group.name === groupNameOfBorder) {
                    groupExists = true;
                    // 注意:这里假设每个组只有一个 border,如果需要支持多个边界断点,
                    应更改为 group.borders.push(border);
                    group.border = border;
                }
            }

如果没有找到匹配的断点组,则创建一个新的断点组并添加到列表中

            if (groupExists === false) {
                this.groups.push(new BreakpointGroup(
                    groupNameOfBorder, 
                    [], 
                    new HookBreakpoints([]), 
                    border // 注意:这里传入单个边界断点,如果需要支持多个边界断点,
                    应更改为 [border]
                ));
            }
        }
    }

这样做可以确保每个文件路径的边界断点都能正确地归属到相应的断点组中。如 果没给边界的话就不会切换断点组,就在当前断点组一直运行下去。 除此之外我们加了一个把边界断点改回普通断点的功能。

3. 代码实现复杂不易维护和扩展

由于之前代码之前散落在各处,没有可读性,而且许多代码实现起来很是复杂,我们决定将之前的代码重构,用状态机的形式来更清晰的描述行为和状态变化,以简化复杂流程的管理,增强代码的灵活性、逻辑性和可读性。 我们构造的状态机只管理 extension.ts,任何 mibase.ts 的操作都要提到 extension.ts 来完成。因为我们的调试器本质上是模拟用户操作,而用户操作的相关逻辑就是在 extension.ts 里实现的。

我们的做法是: 维持原有的断点组,将状态机作为断点组上层的东西。首先,调试器启动并初始化状态机,监听并处理各种事件,如程序停止、断点触发等。当事件发生时,stopEvent 方法被调用,并触发状态机中定义的动作。接着,OSStateTransition 方法根据当前状态和事件确定新状态和应执行的动作。doAction 方法执行这些动作,可能导致状态机状态的改变或执行其他调试器操作。如果到达特定的边界或条件,状态机将更新其状态,并可能触发新的动作。完成后,状态机继续监听和响应事件,直到调试会话结束。

我们要实现的功能:

状态机通过定义不同的状态和事件来管理调试器的行为。OSStates 枚举列出了所有可能的状态,如内核态和用户态,以及它们之间的单步执行状态。OSEvents 枚举定义了触发状态转换的事件,例如程序停止、到达内核或用户态边界等。DebuggerActions 枚举定义了状态机可以执行的各种动作,如检查是否到达特定的内存地址区间、开始连续单步执行、获取下一个断点组名称等。

enum OSStates { //定义了内核、用户态以及单步执行状态
        kernel,
        kernel_lines_step_to_user,
        user,
        user_lines_step_to_kernel,
}
enum OSEvents { //定义了调试过程中可能遇到的事件,如停止、到达内核/用户边界等
        STOPPED,
        AT_KERNEL,
        AT_KERNEL_TO_USER_BORDER,
        AT_USER,
        AT_USER_TO_KERNEL_BORDER,
}
enum DebuggerActions { //定义了状态机响应事件时可能执行的动作
        check_if_kernel_yet,
        check_if_user_yet,
        check_if_kernel_to_user_border_yet,
        check_if_user_to_kernel_border_yet,
        get_next_breakpoint_group_name,
        start_consecutive_lines_steps,
        switch_breakpoint_group,
}switch_breakpoint_group,

状态机里面将“自动单步”这些自动化操作表示为”actions”(类型为Actions[],类似于iOS 的快捷指令)。

改变状态触发事件

状态机通过监听调试器事件(如停止事件)来触发状态改变。stopEvent 方法处理停止事件,并根据当前状态和事件触发状态机中定义的动作。之前为了提供“停下”的信号,在四五个地方(断点触发,单步结束……)执行后发送“停下”的事件。但是我们发现,stopEvent 确实会在每次 OS 停下来时被创建,只处理一种停下来的情况,不包括因为断点而停下来的情况。所以只要在 stopevent 一个地方设 stop 的“钩子断点”即可。

4. 没有实现单步步进

之前自动单步不能运转,是因为直接调 gdb 的 si 命令(会导致终端里面重新自动输入 qemu 启动命令),这回改用 midebugger 里面提供的单步函数。实现了之前没有的单步步进的功能:包括逐步执行、单条指令级别的调试以及跳出当前函数的调试操作。可以逐步分析和理解代码的执行过程,从而更快地定位和解决问题。

以 step 为例,方法接收一个可选的布尔参数 reverse ,默认值为 false 。如果 reverse 为 true,命令中会添加 –reverse 选项。方法首先检查 trace 的值,如果为真,就记录 step 信息到 stderr。接着,返回一个 Promise,该 Promise 发送 exec-step 命令,并根据 reverse 的值决定是否添加 –reverse 。命令执行后,Promise 会根据 info.resultRecords.resultClass 是否等于 running 来决定解析为 true 或 false,否则,Promise 会被拒绝。这个方法通常在调试环境中逐步执行代码,并根据结果决定接下来的操作。 执行程序的单步操作,可选择正向或反向:

    step(reverse: boolean = false): Thenable<boolean> {
        if (trace) this.log("stderr", "step");
        return new Promise((resolve, reject) => {
            this.sendCommand("exec-step" + (reverse ? " --reverse" : "")).then((info)
            => { resolve(info.resultRecords.resultClass == "running");
            }, reject);
        });
    }
    stepInstruction(reverse: boolean = false): Thenable<boolean> {
        // ```
    }
    stepOut(reverse: boolean = false): Thenable<boolean> {
       // ```
    }

5. 调试器continue按钮不能适用于所有情况

由于今年新实现了单步步进功能,我们可以通过不断的自动的单步(step instruction)每单步一次就查看内存地址来确定是否到达新的特权级。

6. 控制台输出杂乱且不完整

我们在mi2.ts中定义了多个处理函数,用于接收和处理来自调试器(如 GDB)的标准输出(stdout)和标准错误(stderr)。

相关代码实现如下:

判断是否为调试器输出并解析:

   onOutput(str: string) {
    const lines = str.split('\n');
    lines.forEach(line => {
        // 判断当前行是否可能是调试器的输出
        if (couldBeOutput(line)) {
            // 如果当前行不是通过 gdbMatch 正则表达式匹配的特定格式,则记录为标准输出
            if (!gdbMatch.exec(line)) this.log("stdout", line);
        } else {
            // 解析当前行为 GDB 机器接口(MI)格式
            const parsed = parseMI(line);
            console.log("parsed:" + JSON.stringify(parsed));
            let handled = false; // 标记当前行是否已经被处理

根据解析结果中的token调用处理函数,若没有则分配一个:

            // 如果解析结果包含 token
            if(parsed.token !== undefined){
                // 如果存在对应的处理函数,则调用该函数并删除该 token 的记录
                if (this.handlers[parsed.token]) {
                    this.handlers[parsed.token](parsed);
                    delete this.handlers[parsed.token];
                    handled = true;
                }
                this.tokenCount = this.tokenCount + 1;
                parsed.token = this.tokenCount;
            }
            else{
                // 如果解析结果不包含 token,则分配一个 token
                parsed.token = this.tokenCount + 1;
                // 存储原始没有 token 的 MI 节点
                this.originallyNoTokenMINodes.push(parsed);
                // 如果存储的节点超过 100 个,则移除前面的 90 个
                if (this.originallyNoTokenMINodes.length >= 100) {
                    this.originallyNoTokenMINodes.splice(0, 90);
                    const rest = this.originallyNoTokenMINodes.splice(89);
                    this.originallyNoTokenMINodes = rest;
                }
            }

记录错误输出:

            // 如果启用了调试输出,则记录美化后的 JSON 输出和原始解析结果
            if (this.debugOutput) {
                this.log("stdout", "GDB -> App: " + prettyPrintJSON(parsed));
                console.log("onoutput:" + JSON.stringify(parsed));
            }
            // 如果解析结果包含错误记录,则记录为标准错误输出
            if (!handled && parsed.resultRecords && parsed.resultRecords.
            resultClass == "error") {
                this.log("stderr", parsed.result("msg") || line);
            }

处理异步事件记录:

    if (parsed.outOfBandRecord) {
        parsed.outOfBandRecord.forEach((record) => {
            // 根据记录类型进行相应处理
            if (record.isStream) {
                this.log(record.type, record.content);
            } else {
                // 处理不同类型的异步事件
                if (record.type == "exec") {
                    this.emit("exec-async-output", parsed);
                    // 发出不同的事件,表示程序正在运行或已停止
                    if (record.asyncClass == "running") 
                        this.emit("running", parsed);
                    else if (record.asyncClass == "stopped") {
                        // 根据停止原因发出不同的事件
                        const reason = parsed.record("reason");
                        // ...(处理不同的停止原因)
                    }
                } else if (record.type == "notify") {
                    // 处理通知类型的异步事件
                    if (record.asyncClass == "thread-created") {
                        this.emit("thread-created", parsed);
                    } else if (record.asyncClass == "thread-exited") {
                        this.emit("thread-exited", parsed);
                    }
                }
            }
        });
        handled = true;
    }
    // 如果当前行没有被任何上述条件处理,则记录为未处理的输出
    if (!handled) this.log("log", "Unhandled: " + JSON.stringify(parsed));

通过这些方法,代码确保了调试控制台输出的可读性和有用性,使得开发者能够更容易地理解调试过程中发生的事情,这对于调试复杂的应用程序或在开发过程中解决问题至关重要。

7. 编译后插件太大

使得编译本插件的时候忽略文档文件夹和根文件夹下 60m 的“演示视频.mp4”,从而极大减小编译出的插件二进制包的大小

8. 配置文件硬编码

由于之前的文件是硬编码的,十分不灵活。我们重新改写为了用户可以提交自定义代码。 launch.json ,支持${workspacefolder}插值(之前有一些参数是不能用这个插值的),大大提升了配置文件的便携性。

新增功能:

1. 通过SSH进行调试

为了让开发者可以在本地机器上使用VSCode进行远程调试,我们决定增加通过SSH进行远程操作系统调试的功能。通过以下步骤来集成这个功能:

首先,初始化调试会话,initializeRequest 方法设置了调试会话支持的各种功能,例如支持条件断点、函数断点、内存读写等。这些功能通过修改 response.body 的不同属性来指定。接着,用launchRequest 方法启动调试。然后,使用提供的GDB路径和其他参数(如调试器参数和环境变量)创建 MI2 类的实例。根据 args.pathSubstitutions 设置源文件的路径替换规则以便调试器可以正确地定位到原始源文件。配置好调试会话后,就开始处理ssh配置。如果提供了SSH参数 (args.ssh),则进入SSH配置分支: 相关代码实现如下:

定义一个 ssh 方法,用于通过 SSH 连接到远程主机并执行命令:

ssh(args: SSHArguments, cwd: string, target: string, procArgs: string, 
separateConsole: string, attach: boolean, autorun: string[]): Thenable<any> {
    return new Promise((resolve, reject) => {
        this.isSSH = true;// 标记已通过 SSH 连接。
        this.sshReady = false;// 初始 SSH 连接状态为未就绪。
        this.sshConn = new Client();// 创建一个新的 SSH 客户端实例。

        // 如果提供了单独的控制台参数,发出警告,因为 SSH 不支持终端模拟器的输出。
        if (separateConsole !== undefined)
            this.log("stderr", "WARNING: Output to terminal emulators are not supported over SSH");
    // ```
}

如果需要 X11 转发,设置相关事件处理:

\begin{minted}[frame=lines, breaklines=true,breakanywhere=true,breaksymbol=]{typescript} if (args.forwardX11) { this.sshConn.on(“x11”, (info, accept, reject) => { const xserversock = new net.Socket(); // 处理本地 X11 服务器连接错误。 xserversock.on(“error”, (err) => { this.log(“stderr”, “Could not connect to local X11 server! Did you enable it in your display manager?\n” + err); }); // 处理成功连接到本地 X11 服务器的事件。 xserversock.on(“connect”, () => { const xclientsock = accept(); // 创建 X11 转发数据的管道。 xclientsock.pipe(xserversock).pipe(xclientsock); }); // 连接到指定的本地 X11 端口和主机。 xserversock.connect(args.x11port, args.x11host); }); }


 设置 SSH 连接参数,包括主机、端口和用户名:
 
```ts
        const connectionArgs: any = {
            host: args.host,
            port: args.port,
            username: args.user
        };

根据认证方式设置连接参数,使用密钥或密码:

       
        if (args.useAgent) {
            // 如果使用 SSH 代理,设置环境变量中的 SSH 认证套接字。
            connectionArgs.agent = process.env.SSH_AUTH_SOCK;
        } else if (args.keyfile) {
            // 如果使用私钥文件,检查文件是否存在并读取内容。
            if (fs.existsSync(args.keyfile))
                connectionArgs.privateKey = fs.readFileSync(args.keyfile);
            else {
                // 如果私钥文件不存在,记录错误并拒绝 Promise。
                this.log("stderr", "SSH key file does not exist!");
                this.emit("quit");
                reject();
                return;
            }
        } else {
            // 如果不使用密钥文件,则使用密码认证。
            connectionArgs.password = args.password;
        }

当 SSH 客户端准备就绪时,执行回调函数:

        this.sshConn.on("ready", () => {
            // 记录正在通过 SSH 运行的应用程序。
            this.log("stdout", "Running " + this.application + " over ssh...");
            // 设置执行命令时的参数,如 X11 转发参数。
            const execArgs: ExecOptions = {};
            if (args.forwardX11) {
                execArgs.x11 = {
                    lines: false,
                    screen: args.remotex11screen
                };
            }
            // 构造要通过 SSH 执行的命令。
            let sshCMD = this.application + " " + this.preargs.concat(this.extraargs || []).join(" ");
            if (args.bootstrap) sshCMD = args.bootstrap + " && " + sshCMD;
            // 执行命令,并处理结果。
            this.sshConn.exec(sshCMD, execArgs, (err, stream) => {
                if (err) {
                    // 如果执行命令时出错,记录错误并拒绝 Promise。
                    // ```
                    reject();
                    return;
                }
                this.sshReady = true; // 如果命令执行成功,设置 SSH 连接为就绪状态
                this.stream = stream; // 保存执行命令的流,以便后续操作
                // 设置数据和错误输出的处理函数。
                stream.on("data", this.stdout.bind(this));
                stream.stderr.on("data", this.stderr.bind(this));
                // 设置命令执行退出的处理函数。
                stream.on("exit", () => {
                    this.emit("quit");
                    this.sshConn.end();
                });
                // 发送初始化命令,如更改工作目录。
                const promises = this.initCommands(target, cwd, attach);
                promises.push(this.sendCommand("environment-cd \"" + escape(cwd) + "\""));
                // 如果需要附加到进程,发送相应的命令。
                if (attach) {
                    promises.push(this.sendCommand("target-attach " + target));
                } else if (procArgs && procArgs.length)
                    promises.push(this.sendCommand("exec-arguments " + procArgs));
                // 发送自动运行命令。
                promises.push(...autorun.map(value => { return 
                this.sendUserInput(value); }));
                // 等待所有初始化命令完成。
                Promise.all(promises).then(() => {
                    this.emit("debug-ready");
                    resolve(undefined);
                }, reject);
            });
        }).on("error", (err) => {
            // 如果 SSH 连接出错,记录错误并拒绝 Promise。
            // ```
        }).connect(connectionArgs);
    });
}

以上所用到的SSH 配置是通过 args 参数传递给 ssh 方法的,args 是一个 SSHArguments 类型的对象,包含了建立 SSH 连接所需的所有参数和配置(在backend.ts文件中)。

2. 通过右键菜单添加/取消边界断点

根据我们现在的状态机,边界断点应该包含在断点组里面,所以像之前那样发一个 customRequest,让 GDB 直接设断点(不经过 mibase.ts 的 setBreakPointsRequest,因此不会保存到断点组里面)就不合适了。我们移除了GoToKernel 等几个按钮,添加了一个右键菜单,用户在某个断点上面右键单击即可将这个断点变成边界断点。原因如下:

这样做之后边界断点除了通过配置文件添加,也可以通过右键菜单添加或者取消。

第三部分 对可支持调试的操作系统进行语言及运行环境扩充

支持C语言(xv6)的调试

xv6-riscv 是一个小型的 Unix 第六版操作系统实现,包含了基本的操作系统功能,如进程管理、内存管理、文件系统、设备驱动和系统调用。 xv6-riscv 采用单内核结构,所有的操作系统服务都在内核模式下运行。内核代码包括内存管理、进程管理、文件系统、驱动程序和系统调用接口等部分。

1. 界面适配所有语言

由于之前的调试器是仅rust语言可见的,我们修改了调试器的 package.json 文件,让它能够适配所有语言。

"menus": {
			"editor/title": [
				{
					"when": "resourceLangId == true",
					"command": "code-debug.removeAllCliBreakpoints",
					"group": "navigation"
				},
			],
        }

2. 配置调试会话

xv6 的qemu启动参数

一开始我们沿用了了ebpf的部分参数,发现会导致启动不了,最后阅读了官方文档,找到了推荐的启动参数。

    "qemuPath": "qemu-system-riscv64",
                "qemuArgs": [
                    "-machine", "virt", "-bios", "none",
                    "-kernel", "${workspaceFolder}/kernel/kernel",
                    "-m", "128M", "-smp", "2", "-nographic",
                    "-global", "virtio-mmio.force-legacy=false",
                    "-drive", "file=${workspaceFolder}/fs.img,if=none,format=raw,id=x0",
                    "-device", "virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0",
                    
                    "-s", "-S"
                ],

获取断点组名称及路径

初步编写配置文件后发现只能从内核态转换到用户态,不能从用户态回到内核态,经过调试排查,我们发现不能从用户态回到内核态的原因之一是断点组名映射到调试文件函数的返回值不对,导致调试器没有成功根据断点组寻找到正确的符号表。xv6的用户文件经过编译后应为 _ + 文件名,故做出如下修改:

    "breakpointGroupNameToDebugFilePath":{
                    "isAsync": false,
                    "functionArguments": "groupName",
                    "functionBody": "if (groupName === 'kernel') {        return '${workspaceFolder}/kernel/kernel';    }    else {        let pathSplited = groupName.split('/');            let filename = pathSplited[pathSplited.length - 1].split('.');         let filenameWithoutExtension = filename[filename.length - 2];        return '${workspaceFolder}/user/' + '_' + filenameWithoutExtension;    }"
                }
        

xv6 内核态和用户态转换的边界

不能从用户态回到内核态还有一个原因是用户态的边界未被正确设置。 + kernel/syscall.c是负责处理已经进到内核之后的syscall处理流程。我们需要的是用户态的syscall接口,在usys.S中。 设置正确的边界断点后可以得到正确的边界文件路径到断点组名的映射。当断点在kernel文件夹下,说明是内核出口,返回内核断点组名;当断点在usuy.S中时,说明是所有文件的用户态出口,返回所有用户断点组名。

```json
"filePathToBreakpointGroupNames": {
            "isAsync": false,
            "functionArguments": "filePathStr",
            "functionBody": "if (filePathStr.includes('kernel')) { return 
            ['kernel']; } else if (filePathStr === '${workspaceFolder}/user/usys.S')
            { return ['${workspaceFolder}/user/ln.c', '${workspaceFolder}/user/ls.c',
            ```
            '${workspaceFolder}/user/usertests.c']; } else if (filePathStr.includes('user') 
            && filePathStr !== '${workspaceFolder}/user/usys.S') { return [filePathStr]; } 
            else { return ['kernel']; }"
        },
```
+ 因为usys.S文件中有多个ecall,也就是说**用户态有多个边界断点**(因为xv6在用户态没有一个专门的syscall()处理函数,而是每个syscall的调用单独处理)。我们的调试器一开始是基于ebpf写的,所以用户和内核的边界都只有一个,添加新的边界断点时旧的会被替换掉。所以需要将边界改成数组,并**修改调试器的边界代码及相关处理函数**。   

修改边界定义:

    export class Border  {
            filepath:string;
            line:number;
            constructor(filepath:string, line:number){
                this.filepath = filepath;
                this.line = line;
        }
    }

边界断点组:

    class BreakpointGroup {
            name: string;
            setBreakpointsArguments: DebugProtocol.SetBreakpointsArguments[];
            borders?:Border[]; // can be a border or undefined
            hooks:HookBreakpoints; //cannot be `undefined`. It should at least
            an empty array `[]`.
            constructor(name: string, setBreakpointsArguments: 
            DebugProtocol.SetBreakpointsArguments[], hooks:HookBreakpoints, 
            borders?:Border[] ) {
                console.log(name);
                this.name = name;
                this.setBreakpointsArguments = setBreakpointsArguments;
                this.hooks = hooks;
                this.borders = borders;
            }
}

修改更新边界的逻辑:
通过 eval 执行 filePathToBreakpointGroupNames 函数来获取与边界断点文件路径对应的断点组名称列表,然后遍历这些组名,检查每个组名是否存在于当前断点组列表中。如果找到匹配的断点组,则将边界断点添加到该组的 borders 属性中;如果未找到,则创建一个新的断点组,并将边界断点添加到新的断点组中。

public updateBorder(border: Border) {
            const result = eval(this.debugSession.filePathToBreakpointGroupNames)
            (border.filepath);
            const groupNamesOfBorder:string[] = result;
            for(const groupNameOfBorder of groupNamesOfBorder){
                let groupExists = false;
                for(const group of this.groups){
                if(group.name === groupNameOfBorder){
                        groupExists = true;
                        group.borders.push(border);
                }
            }
            if(groupExists === false){
                this.groups.push(new BreakpointGroup(groupNameOfBorder, [], 
                new HookBreakpoints([]), [border]));
            }
        }
    }

修改调用边界信息的相关函数:

通过调用 miDebugger.getStack 方法获取当前执行堆栈的文件路径和行号,接着遍历边界断点列表,比较文件路径和行号是否匹配。如果匹配,则触发操作系统状态转换事件。

else if(action.type === DebuggerActions.check_if_user_to_kernel_border_yet){
        this.showInformationMessage('doing action: check_if_user_to_kernel_border_yet');
        //初始化```
        const userToKernelBorders = this.breakpointGroups.getCurrentBreakpointGroup().borders;
        const userToKernelBorderFile = this.breakpointGroups.getCurrentBreakpointGroup().border?.filepath;
        const userToKernelBorderLine = this.breakpointGroups.getCurrentBreakpointGroup().border?.line;
        this.miDebugger.getStack(0, 1, this.recentStopThreadID).then(v=>{
            filepath = v[0].file;
            lineNumber = v[0].line;
            if (userToKernelBorders) {
                for (const border of userToKernelBorders) {
                    if (filepath === border.filepath && lineNumber ===  border.line) {
                        this.OSStateTransition(new 
                        OSEvent(OSEvents.AT_USER_TO_KERNEL_BORDER));
                    break;
               //```
    }
else if(action.type === DebuggerActions.check_if_kernel_to_user_border_yet){
    //```
}

在launch.json里面只指定边界断点,没有指定边界断点所属的断点组。边界断点所属的断点组是由调试器自己去判定的。所以当触发了多个断点组中的一个, 调试器就会判定这个边界断点属于某某断点组,然后进行断点组切换的流程。

修改钩子断点

完成以上修改之后,用户态和内核态已经可以正常切换了。但是经过几次切换后,调试器会自己中断。是因为我们之前将钩子断点设在了第6行,此时获取下一进程名时返回为空。

    // sysfile.c
    1 sys_exec(void)
    2 {
    3   char path[MAXPATH], *argv[MAXARG];
    4   int i;
    5   uint64 uargv, uarg;
    6   argaddr(1, &uargv);
    7   if(argstr(0, path, MAXPATH) < 0) {
    8     return -1;
    9   }
    10 }

修改后将钩子断点设置在了 int ret = exec(path, argv),可以正常返回下一进程名,至此,调试器可以正确调试xv6和ebpf。 经验证后的 配置文件

适配性提升

\textbf{通过对xv6操作系统的适配,我们将调试器进一步完善,使之可兼容一个或多个边界的情况,完成了对c语言操作系统调试的扩充。今后,只需修改配置文件中用户自定义的部分,找到正确的边界,修改映射函数即可扩展可支持的语言,大大提高了灵活性和便捷性。}

支持在硬件上的调试

昉·星光2 搭载四核64位RV64GC ISA的芯片平台(SoC),开源的昉·星光 2具有强大的软件适配性,官方适配Debian操作系统,同时通过社区合作适配各种Linux发行版,包括Ubuntu、OpenSUSE、OpenKylin、OpenEuler、Deepin等,及在这些操作系统上运行的各类软件。

框架搭建

与硬件的连接主要借助jtag接口和openocd:前者一种硬件接口标准,用于芯片内部测试、系统仿真和调试,它允许开发者通过一组标准引脚(TCK, TMS, TDI, TDO, 以及可选的TRST)与芯片进行通信;后者是一个开源的调试器,它提供了对多种处理器和调试硬件的接口,包括JTAG,主要作用是作为一个中间件,它接收来自开发环境(如Eclipse, Visual Studio等)的调试命令,并通过JTAG等接口发送到目标硬件上执行。

实际调试过程中,pc端的openocd向硬件上的u-boot发送指令,二者通过jtag通信。u-boot收到启动指令后,加载运行硬件上的os,完成后,u-boot停止。此后gdb的指令直接通过openocd沿着jtag发送给os,实现控制和访问,得到的数据也精油同样的过程返回gdb,以得到调试信息。

image

简而言之,JTAG提供了硬件层面的连接和通信能力,而OpenOCD则提供了软件层面的控制和调试功能。两者结合使用,为嵌入式系统的开发和调试提供了强大的支持。

硬件部署及调试

硬件连接及相关软件驱动详见:硬件配置及使用

调试方法

  1. 连接 JLink 和串口
  2. 打开调试器
  3. 按重置按钮三秒后松开
  4. 在任务管理器里关掉 tmux(如果有的话)
  5. 在 vscode 里开始调试(按 f5)

编译

make clean
make hardcoded_ramdisk
make

硬件适配

目前实现:

  1. 内核态的单步调试
  2. 内核态到用户态的转换

开发板上有一个 qemu 虚拟机环境没用到的 dcsr 寄存器,里面 stepie 字段可以 控制单步的时候是否打开中断。我们查看后发现 stepie 的值是 0,那么单步的时候中 断是关闭的,这个时候去单步一个会导致中断的指令无效。但是这个开发板不支持调 试器修改 csr,如果调用 openocd 的 set_reg 命令修改 dcsr 寄存器值,不仅寄存器的 值不会改变,而且还会出现一个提示,说正在关闭 csr 寄存器的写入功能。

我们又想到 ecall 之后不会立马切换页表,可以尝试把边界断点设置到 ecall 中断 的中断处理函数上面。用户态断点保存在我们设定的断点组里面也不会丢,操作系统 页表刷新以后再把断点加回来。在我们这个 os 中,ecall 的中断处理函数刚好在跳板 页里,跳板页是不论内核态还是用户态都可以访问的,且不论内核态还是用户态,跳 板页的虚拟地址都是一样的,所以这个思路是可行的。

但是中断处理函数是写在 trampoline.S 这个汇编语言文件里的,GDB 无法根据 内核符号表定位到这个汇编文件,GDB 根据内核符号表只能定位到 c 语言的源代码 文件。奇怪的是,有一个用户态程序是直接由一个汇编代码文件编译生成,GDB 却 可以定位到。

经过尝试,我们发现把断点设置在把边界断点设置为 ecall 指令的前一个指令, 就可以成功实现用户态到内核态的切换。至此,完成了调试器对硬件的适配。